Files
rippled/docker/telemetry/workload/tx_submitter.py
Pratik Mankawde d059f21bf3 fix(telemetry): address review findings in the workload validation harness
Fixes the review findings on this PR that belong to files it owns, plus
several defects found while verifying those fixes. Findings in files owned
by upstream branches are routed there and left untouched here.

Correctness:
- tx_submitter: advance the account sequence only on results that actually
  consume one (tes*, tec*, terQUEUED). tem*/tef*/tel* never reach the
  ledger, so advancing left a permanent gap that every later submit from
  that account inherited. Add a re-fetch hatch so a repeated non-consuming
  failure cannot livelock on the same sequence, and gate the account check
  on funded-ness rather than list length.
- validate_telemetry: filter spans by name before collecting attributes, so
  a per-span attribute contract can no longer be satisfied by a sibling
  span; require exact name equality for non-wildcard children and glob
  matching for wildcards; bounds-check every returned series instead of
  only the first.
- collect_system_metrics: select xrpld by argv[0] rather than a substring
  match on the whole command line, which averaged in unrelated processes
  and reported their RSS as xrpld's. Count genuine 0.0 CPU readings, use a
  clamped nearest-rank p99 index, and record RPC latency only on success.
- benchmark: return each verdict through a named variable instead of a
  command substitution, so the pass/fail counters survive and the exit gate
  can fire. Scale before dividing in the percentage math, which truncated a
  1.26% impact to 1.00% and cleared a 1% threshold.
- compare_to_baseline: fall back to the absolute bound when the baseline is
  not positive, so a 0 -> 500 ms jump is no longer "within bounds".
- rpc_load_generator: bound each connection to one in-flight recv(), drain
  in-flight requests before closing, use a nearest-rank percentile, and
  report delivery shortfall so an under-delivered run cannot pass with a 0%
  error rate.

Fail loudly instead of silently:
- run-full-validation: treat a consensus timeout and a missing validated
  ledger as fatal infrastructure errors, and fold the orchestrator and
  benchmark exit codes into the final status. A degraded cluster previously
  ran a full validation pass and reported misleading downstream failures.
- collect_system_metrics: warn per empty measurement source, emit
  metrics_complete, and exit non-zero instead of substituting zeros that
  pass every threshold. Require GNU date with %N rather than falling back
  to a per-sample python3 fork that costs more than the threshold it is
  measured against.
- benchmark: distinguish "could not measure" from "exceeded thresholds",
  install a cleanup trap so a failure cannot leak nodes and ports, and
  report an unusable baseline as inconclusive.
- workload_orchestrator: bound subprocess communicate() and fail the exit
  gate on per-phase errors.

Also pins the workload compose images to the versions the sibling stack
already uses, hash-pins the Python dependencies, restricts the validator
config template to loopback, corrects the dashboard and metric counts in
the reference docs, drops a span from the regression gate that cannot fire
under a WebSocket-only workload, and narrows the teardown pkill pattern so
it no longer matches processes that merely mention the work directory.

Verified with a full harness run against a local five-node cluster:
158 of 158 checks passed with no regressions detected.
2026-08-14 19:59:19 +01:00

1063 lines
35 KiB
Python

#!/usr/bin/env python3
"""Transaction Submitter for rippled telemetry validation.
Generates diverse transaction types against a rippled cluster to exercise
the full span and metric surface: tx.process, tx.apply, ledger.build,
consensus.*, and all associated attributes.
Pre-funds test accounts from the genesis account, then submits a
configurable mix of transaction types at a target TPS.
Supported transaction types:
- Payment (XRP and issued currencies)
- OfferCreate / OfferCancel (DEX activity)
- TrustSet (trust line creation)
- NFTokenMint / NFTokenCreateOffer / NFTokenAcceptOffer
- EscrowCreate / EscrowFinish
- AMMCreate / AMMDeposit / AMMWithdraw (if amendment enabled)
Usage:
python3 tx_submitter.py --endpoint ws://localhost:6006 --tps 5 --duration 120
# Custom transaction mix:
python3 tx_submitter.py --endpoint ws://localhost:6006 \\
--weights '{"Payment":50,"OfferCreate":20,"TrustSet":10,"NFTokenMint":10,"EscrowCreate":10}'
"""
import argparse
import asyncio
import json
import logging
import random
import sys
import time
from dataclasses import dataclass, field
from typing import Any
import websockets
logger = logging.getLogger("tx_submitter")
# Failure kinds already reported at WARNING, so a repeated failure is not
# logged again at that level.
_reported_failures: set[str] = set()
def _log_first_failure(key: str, fmt: str, *args: object) -> None:
"""Log a submission failure loudly the first time its kind is seen.
A run in which every transaction fails for one reason -- a refused
connection, an unfunded account -- used to leave no trace, because these
messages were DEBUG and CI does not enable DEBUG. Logging every failure
instead would bury the run in thousands of identical lines, so the first
of each distinct kind is a WARNING and the rest stay at DEBUG.
:param key: Identifies the failure kind, e.g. the engine result code.
:param fmt: Logging format string.
:param args: Format arguments.
"""
if key in _reported_failures:
logger.debug(fmt, *args)
return
_reported_failures.add(key)
logger.warning(fmt, *args)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
GENESIS_ACCOUNT = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"
GENESIS_SEED = "snoPBrXtMeMyMHUVTgbuqAfg1SUTb"
# Amount to fund each test account (100,000 XRP in drops).
FUND_AMOUNT = "100000000000"
# Default transaction mix weights (relative).
DEFAULT_TX_WEIGHTS: dict[str, int] = {
"Payment": 40,
"OfferCreate": 15,
"OfferCancel": 5,
"TrustSet": 10,
"NFTokenMint": 10,
"NFTokenCreateOffer": 5,
"EscrowCreate": 5,
"EscrowFinish": 5,
"AMMCreate": 3,
"AMMDeposit": 2,
}
# Number of test accounts to create.
NUM_TEST_ACCOUNTS = 8
# Minimum number of funded accounts the transaction builders need: TX_BUILDERS
# indexes positions 0..5 of the account list.
MIN_FUNDED_ACCOUNTS = 6
# Engine results that tie up the submitted sequence number, other than the
# tec* family which is matched by prefix. See consumes_sequence().
SEQ_CONSUMING_RESULTS = frozenset({"tesSUCCESS", "terQUEUED"})
# Consecutive non-consuming submit results from one account before its
# sequence is re-read from the ledger. The periodic refresh only ever raises
# the counter, so a counter that leads the ledger -- a queued transaction that
# was later dropped, say -- would otherwise make every further submit from
# that account fail forever. Five is above the two or three rejections a
# single bad transaction type can produce in a row, and at the default 5 TPS
# it triggers within a few seconds, well before the periodic refresh below.
SEQ_REFETCH_AFTER_FAILURES = 5
# How often the submission loop re-reads every account's sequence from the
# ledger, to stay close to sequences other submitters have advanced.
SEQ_REFRESH_INTERVAL_S = 10.0
def consumes_sequence(engine_result: str | None) -> bool:
"""Report whether an engine result tied up the submitted sequence number.
Two outcomes do. ``tesSUCCESS`` is applied (TER.h:243) and every ``tec*``
claims the fee "to use the sequence number" (TER.h:265-266), so both
advance the account root. ``terQUEUED`` is the one ``ter*`` code rippled
forwards (TER.h:205); the transaction waits in the queue still holding
that sequence, so the next submit needs the following one.
Everything else leaves the sequence free. ``tem*``, ``tef*`` and ``tel*``
are neither applied nor forwarded, and neither are the remaining ``ter*``
codes (TER.h:202-206) -- including ``terPRE_SEQ``, which reports that the
submitted sequence is already past the account root (TER.h:217).
Advancing the local counter on those widens the gap TER.h:209 calls a
"hole in sequence which jams transactions", and it is what
SEQ_REFETCH_AFTER_FAILURES exists to recover from.
Args:
engine_result: The ``engine_result`` field of a submit response. A
missing or JSON-null field reads as non-consuming
rather than raising, so the caller records the
transaction exactly once.
Returns:
True if the submitted sequence number is spoken for.
"""
result = str(engine_result or "")
return result in SEQ_CONSUMING_RESULTS or result.startswith("tec")
# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------
@dataclass
class Account:
"""Represents an XRPL test account, funded or not.
Attributes:
name: Human-readable name (e.g., "alice").
account: Classic address (rXXX...).
seed: Secret seed for signing.
sequence: Next available sequence number.
funded: True once the account root exists on the ledger. False
accounts cannot submit, so they are excluded from the
submission loop rather than silently counted.
stalled: Consecutive submit results from this account that consumed
no sequence. Reset by any consuming result; at
SEQ_REFETCH_AFTER_FAILURES the sequence is re-read from
the ledger.
"""
name: str
account: str
seed: str
sequence: int = 0
funded: bool = False
stalled: int = 0
@dataclass
class TxStats:
"""Tracks transaction submission results.
Attributes:
total_submitted: Total transactions sent to the network.
total_success: Transactions that returned tesSUCCESS or terQUEUED.
total_errors: Transactions that returned an error engine_result.
by_type: Per-transaction-type count of submissions.
errors_by_type: Per-transaction-type count of errors.
"""
total_submitted: int = 0
total_success: int = 0
total_errors: int = 0
by_type: dict[str, int] = field(default_factory=dict)
errors_by_type: dict[str, int] = field(default_factory=dict)
def record(self, tx_type: str, success: bool) -> None:
"""Record the result of a transaction submission."""
self.total_submitted += 1
self.by_type[tx_type] = self.by_type.get(tx_type, 0) + 1
if success:
self.total_success += 1
else:
self.total_errors += 1
self.errors_by_type[tx_type] = self.errors_by_type.get(tx_type, 0) + 1
def summary(self) -> dict[str, Any]:
"""Return a summary dict suitable for JSON serialization."""
return {
"total_submitted": self.total_submitted,
"total_success": self.total_success,
"total_errors": self.total_errors,
"success_rate_pct": (
round(self.total_success / self.total_submitted * 100, 2)
if self.total_submitted
else 0
),
"by_type": self.by_type,
"errors_by_type": self.errors_by_type,
}
# ---------------------------------------------------------------------------
# WebSocket RPC helpers
# ---------------------------------------------------------------------------
async def ws_request(
ws: websockets.ClientConnection,
command: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Send a native WebSocket command and return the result payload.
Uses rippled's native WebSocket format (``command`` key with flat
parameters). The response has ``status`` at the top level and the
actual data payload inside ``result``. This helper unwraps the
``result`` dict so callers can read fields directly.
Args:
ws: Open WebSocket connection.
command: RPC command name (e.g., ``account_info``, ``submit``).
params: Optional flat parameter dict merged into the request.
Returns:
The inner ``result`` dict from the response.
Raises:
RuntimeError: If the request fails or times out.
"""
request: dict[str, Any] = {"command": command}
if params:
request.update(params)
await ws.send(json.dumps(request))
raw = await asyncio.wait_for(ws.recv(), timeout=30.0)
resp = json.loads(raw)
# WS command format: {"status": "success", "result": {...}, "type": "response"}
# On error: {"status": "error", "error": "...", "error_message": "..."}
if resp.get("status") == "error":
logger.warning(
"%s error: %s%s",
command,
resp.get("error", "unknown"),
resp.get("error_message", ""),
)
return resp.get("result", resp)
async def create_account(ws: websockets.ClientConnection, name: str) -> Account:
"""Create a new account via wallet_propose RPC.
Args:
ws: Open WebSocket connection.
name: Human-readable name for the account.
Returns:
An Account instance with the generated keypair.
"""
result = await ws_request(ws, "wallet_propose")
if "account_id" not in result:
raise RuntimeError(
f"wallet_propose failed: {json.dumps(result, indent=None)[:300]}"
)
return Account(
name=name,
account=result["account_id"],
seed=result["master_seed"],
)
async def fund_account(
ws: websockets.ClientConnection,
dest: Account,
genesis_seq: int,
) -> tuple[bool, int]:
"""Fund a test account from genesis.
Args:
ws: Open WebSocket connection.
dest: Destination account to fund.
genesis_seq: Current genesis account sequence number.
Returns:
Tuple of (funded: bool, next_genesis_sequence: int). The sequence is
unchanged when the ledger did not consume it.
"""
resp = await ws_request(
ws,
"submit",
{
"secret": GENESIS_SEED,
"tx_json": {
"TransactionType": "Payment",
"Account": GENESIS_ACCOUNT,
"Destination": dest.account,
"Amount": FUND_AMOUNT,
"Sequence": genesis_seq,
},
},
)
engine_result = resp.get("engine_result", "unknown")
success = engine_result in ("tesSUCCESS", "terQUEUED")
if not success:
# Log the full response to help diagnose submit failures in CI.
logger.warning(
"Fund %s failed: engine_result=%s, full response: %s",
dest.name,
engine_result,
json.dumps(resp, indent=None)[:500],
)
# Advance the genesis sequence only when the ledger consumed it. The
# caller reads it once and threads it through every funding submit, so
# advancing past a tem*/tef*/tel* rejection would put every remaining
# submit on a future sequence and fund nothing.
if consumes_sequence(engine_result):
genesis_seq += 1
return success, genesis_seq
async def get_account_sequence(ws: websockets.ClientConnection, account: str) -> int:
"""Get the current sequence number for an account.
Args:
ws: Open WebSocket connection.
account: Classic address.
Returns:
Current sequence number.
"""
resp = await ws_request(ws, "account_info", {"account": account})
if "account_data" not in resp:
# Log full response to diagnose WS API format issues.
logger.warning(
"account_info for %s: no account_data, full response: %s",
account[:12],
json.dumps(resp, indent=None)[:500],
)
return 0
return resp["account_data"].get("Sequence", 0)
# ---------------------------------------------------------------------------
# Transaction builders
# ---------------------------------------------------------------------------
def build_payment(sender: Account, receiver: Account) -> dict[str, Any]:
"""Build an XRP Payment transaction.
Args:
sender: Source account.
receiver: Destination account.
Returns:
Transaction JSON and signing secret.
"""
amount = str(random.randint(1000, 1000000)) # 0.001 - 1 XRP
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "Payment",
"Account": sender.account,
"Destination": receiver.account,
"Amount": amount,
"Sequence": sender.sequence,
},
}
def build_offer_create(sender: Account) -> dict[str, Any]:
"""Build an OfferCreate transaction (XRP/USD pair).
Args:
sender: Account placing the offer.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "OfferCreate",
"Account": sender.account,
"TakerPays": str(random.randint(100000, 10000000)),
"TakerGets": {
"currency": "USD",
"issuer": GENESIS_ACCOUNT,
"value": str(round(random.uniform(0.1, 100.0), 2)),
},
"Sequence": sender.sequence,
},
}
def build_offer_cancel(sender: Account) -> dict[str, Any]:
"""Build an OfferCancel transaction.
Uses a non-existent offer sequence — will fail gracefully but still
exercises the tx.process span pipeline.
Args:
sender: Account cancelling the offer.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "OfferCancel",
"Account": sender.account,
"OfferSequence": max(1, sender.sequence - 1),
"Sequence": sender.sequence,
},
}
def build_trust_set(sender: Account) -> dict[str, Any]:
"""Build a TrustSet transaction for a USD trust line.
Args:
sender: Account setting the trust line.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "TrustSet",
"Account": sender.account,
"LimitAmount": {
"currency": "USD",
"issuer": GENESIS_ACCOUNT,
"value": "1000000",
},
"Sequence": sender.sequence,
},
}
def build_nftoken_mint(sender: Account) -> dict[str, Any]:
"""Build an NFTokenMint transaction.
Args:
sender: Account minting the NFT.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "NFTokenMint",
"Account": sender.account,
"NFTokenTaxon": random.randint(0, 100),
"Flags": 8, # tfTransferable
"Sequence": sender.sequence,
},
}
def build_nftoken_create_offer(sender: Account) -> dict[str, Any]:
"""Build an NFTokenCreateOffer transaction.
Uses a dummy NFTokenID — will fail but exercises the span pipeline.
Args:
sender: Account creating the NFT offer.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "NFTokenCreateOffer",
"Account": sender.account,
"NFTokenID": "0" * 64,
"Amount": str(random.randint(100000, 1000000)),
"Flags": 1, # tfSellNFToken
"Sequence": sender.sequence,
},
}
def build_escrow_create(sender: Account, receiver: Account) -> dict[str, Any]:
"""Build an EscrowCreate transaction.
Creates a time-based escrow that finishes 10 seconds from now.
Args:
sender: Account creating the escrow.
receiver: Destination account for escrow funds.
Returns:
Transaction JSON and signing secret.
"""
# Ripple epoch offset: 946684800 seconds from Unix epoch
ripple_time = int(time.time()) - 946684800
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "EscrowCreate",
"Account": sender.account,
"Destination": receiver.account,
"Amount": str(random.randint(100000, 1000000)),
"FinishAfter": ripple_time + 10,
"Sequence": sender.sequence,
},
}
def build_escrow_finish(sender: Account, owner: Account) -> dict[str, Any]:
"""Build an EscrowFinish transaction.
Uses a dummy offer sequence — will likely fail but exercises spans.
Args:
sender: Account finishing the escrow.
owner: Account that created the escrow.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "EscrowFinish",
"Account": sender.account,
"Owner": owner.account,
"OfferSequence": max(1, owner.sequence - 2),
"Sequence": sender.sequence,
},
}
def build_amm_create(sender: Account) -> dict[str, Any]:
"""Build an AMMCreate transaction (XRP/USD pool).
Requires the AMM amendment to be enabled on the network.
Args:
sender: Account creating the AMM pool.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "AMMCreate",
"Account": sender.account,
"Amount": str(random.randint(10000000, 100000000)),
"Amount2": {
"currency": "USD",
"issuer": GENESIS_ACCOUNT,
"value": str(round(random.uniform(10.0, 1000.0), 2)),
},
"TradingFee": 500, # 0.5%
"Sequence": sender.sequence,
},
}
def build_amm_deposit(sender: Account) -> dict[str, Any]:
"""Build an AMMDeposit transaction.
Args:
sender: Account depositing into the AMM pool.
Returns:
Transaction JSON and signing secret.
"""
return {
"secret": sender.seed,
"tx_json": {
"TransactionType": "AMMDeposit",
"Account": sender.account,
"Asset": {"currency": "XRP"},
"Asset2": {
"currency": "USD",
"issuer": GENESIS_ACCOUNT,
},
"Amount": str(random.randint(1000000, 10000000)),
"Flags": 0x00080000, # tfSingleAsset
"Sequence": sender.sequence,
},
}
# Transaction type -> builder function mapping.
# Each builder takes (accounts: list[Account]) and returns submit params.
TX_BUILDERS: dict[str, Any] = {
"Payment": lambda accts: build_payment(accts[0], accts[1]),
"OfferCreate": lambda accts: build_offer_create(accts[0]),
"OfferCancel": lambda accts: build_offer_cancel(accts[0]),
"TrustSet": lambda accts: build_trust_set(accts[2]),
"NFTokenMint": lambda accts: build_nftoken_mint(accts[3]),
"NFTokenCreateOffer": lambda accts: build_nftoken_create_offer(accts[3]),
"EscrowCreate": lambda accts: build_escrow_create(accts[4], accts[1]),
"EscrowFinish": lambda accts: build_escrow_finish(accts[4], accts[4]),
"AMMCreate": lambda accts: build_amm_create(accts[5]),
"AMMDeposit": lambda accts: build_amm_deposit(accts[5]),
}
# ---------------------------------------------------------------------------
# Main submission loop
# ---------------------------------------------------------------------------
async def setup_accounts(
ws: websockets.ClientConnection,
) -> list[Account]:
"""Create and fund test accounts from genesis.
Generates NUM_TEST_ACCOUNTS accounts via wallet_propose, then funds
each with FUND_AMOUNT XRP from genesis.
Args:
ws: Open WebSocket connection to a rippled node.
Returns:
Every created Account. ``funded`` marks the ones the ledger accepted,
so the caller must filter on it rather than on the list length.
"""
account_names = ["alice", "bob", "carol", "dave", "eve", "frank", "grace", "heidi"]
logger.info("Creating %d test accounts...", NUM_TEST_ACCOUNTS)
accounts: list[Account] = []
for name in account_names[:NUM_TEST_ACCOUNTS]:
acct = await create_account(ws, name)
accounts.append(acct)
logger.info(" Created %s: %s", name, acct.account)
# Get genesis sequence.
genesis_seq = await get_account_sequence(ws, GENESIS_ACCOUNT)
logger.info("Genesis sequence: %d", genesis_seq)
# Fund all accounts.
logger.info("Funding test accounts...")
for acct in accounts:
acct.funded, genesis_seq = await fund_account(ws, acct, genesis_seq)
if acct.funded:
logger.info(" Funded %s", acct.name)
else:
logger.warning(" Failed to fund %s", acct.name)
# Wait for funding transactions to be validated.
logger.info("Waiting 10s for funding transactions to validate...")
await asyncio.sleep(10)
# Refresh sequence numbers, and confirm funding against the ledger rather
# than trusting the submit result. get_account_sequence returns 0 when
# account_info reports no account_data, which means the account root was
# never created; a sequence we cannot read also makes the account
# unusable, so either way it must not be submitted from.
for acct in accounts:
try:
acct.sequence = await get_account_sequence(ws, acct.account)
except Exception as exc:
logger.warning(" Failed to get sequence for %s: %s", acct.name, exc)
if acct.sequence > 0:
logger.info(" %s sequence: %d", acct.name, acct.sequence)
else:
acct.funded = False
logger.warning(
" %s has no ledger sequence — treating as unfunded", acct.name
)
logger.info(
"Funded %d of %d created accounts",
sum(1 for a in accounts if a.funded),
len(accounts),
)
return accounts
async def submit_transaction(
ws: websockets.ClientConnection,
tx_type: str,
accounts: list[Account],
stats: TxStats,
) -> None:
"""Submit a single transaction of the given type.
Selects the appropriate builder, constructs the transaction, submits
it via the submit RPC, and records the result.
Args:
ws: Open WebSocket connection.
tx_type: Transaction type name (e.g., "Payment").
accounts: List of funded test accounts.
stats: TxStats instance to record results.
"""
builder = TX_BUILDERS.get(tx_type)
if not builder:
logger.warning("Unknown transaction type: %s", tx_type)
return
try:
params = builder(accounts)
# Identify which account is the sender to bump its sequence.
sender_addr = params["tx_json"]["Account"]
sender = next((a for a in accounts if a.account == sender_addr), None)
resp = await ws_request(ws, "submit", params)
engine_result = resp.get("engine_result", "unknown")
success = engine_result in (
"tesSUCCESS",
"terQUEUED",
"tecUNFUNDED_OFFER",
"tecNO_DST_INSUF_XRP",
)
stats.record(tx_type, success)
# The sequence gate is deliberately not `success`: that tuple is
# narrower (every tec* consumes a sequence, only two are listed) and
# wider (a tem*/tef*/tel*/ter* rejection consumes none) than the set
# of results that actually consume one. _track_sequence also owns the
# recovery path for a counter that has drifted ahead of the ledger.
if sender:
await _track_sequence(ws, sender, engine_result)
if not success:
# First occurrence of each distinct result at WARNING, the rest at
# DEBUG. A run where every transaction failed previously produced
# no diagnostics at all, because DEBUG is off in CI; logging every
# failure instead would bury the run in thousands of identical
# lines.
_log_first_failure(
"result:%s" % engine_result,
"%s result: %s (%s)",
tx_type,
engine_result,
resp.get("engine_result_message", ""),
)
except Exception as exc:
stats.record(tx_type, False)
# Same reasoning, keyed on the exception type: a connection that is
# refused for the whole run says it once rather than per transaction.
_log_first_failure("exc:%s" % type(exc).__name__, "%s error: %s", tx_type, exc)
async def _track_sequence(
ws: websockets.ClientConnection,
sender: Account,
engine_result: str | None,
) -> None:
"""Advance, or re-read from the ledger, one sender's sequence number.
A consuming result moves the counter on by one and clears the stall
streak. A non-consuming one leaves the counter where it is, so the same
sequence is offered again -- correct when the rejection was about the
transaction, but a livelock when the counter itself is the problem, since
_refresh_sequences never lowers it. After SEQ_REFETCH_AFTER_FAILURES
consecutive non-consuming results the ledger's own value is taken
instead, in either direction.
A zero from get_account_sequence means account_info returned no
account_data, so it is ignored rather than written over a usable counter.
Args:
ws: Open WebSocket connection.
sender: The account the transaction was submitted from.
engine_result: The ``engine_result`` of that submit.
"""
if consumes_sequence(engine_result):
sender.sequence += 1
sender.stalled = 0
return
sender.stalled += 1
if sender.stalled < SEQ_REFETCH_AFTER_FAILURES:
return
sender.stalled = 0
try:
seq = await get_account_sequence(ws, sender.account)
except Exception as exc:
logger.warning("Sequence re-fetch for %s failed: %s", sender.name, exc)
return
if seq > 0 and seq != sender.sequence:
logger.warning(
"Re-syncing %s sequence %d -> %d after %d non-consuming results",
sender.name,
sender.sequence,
seq,
SEQ_REFETCH_AFTER_FAILURES,
)
sender.sequence = seq
async def _refresh_sequences(
ws: websockets.ClientConnection,
accounts: list[Account],
) -> None:
"""Re-sync account sequences from the validated ledger.
In a consensus network, other nodes' transactions advance sequences
beyond the submitter's local tracking. Refreshing every
SEQ_REFRESH_INTERVAL_S keeps the local counter close to the ledger and
prevents tefPAST_SEQ storms.
The counter is only ever raised here, never lowered, because it may
legitimately lead the ledger: a queued transaction holds its sequence
without having applied yet, and lowering the counter would reuse it.
Raising it fixes the opposite case, where the ledger has moved on -- a
submit response that was lost while the transaction applied, or another
submitter using the same account.
That leaves one case this cannot fix: a counter that leads the ledger and
never catches up, because the transaction it was advanced for was dropped
rather than applied. _track_sequence handles that one by re-reading the
ledger value in either direction.
"""
for acct in accounts:
try:
seq = await get_account_sequence(ws, acct.account)
if seq > acct.sequence:
acct.sequence = seq
except Exception:
pass
async def _submission_loop(
ws: websockets.ClientConnection,
accounts: list[Account],
weights: dict[str, int],
duration: float,
interval: float,
stats: TxStats,
) -> float:
"""Submit a weighted transaction mix until ``duration`` elapses.
Args:
ws: Open WebSocket connection.
accounts: Funded accounts to submit from.
weights: Transaction type distribution weights.
duration: Run time in seconds.
interval: Delay between submissions, i.e. 1 / target TPS.
stats: TxStats instance to record results in.
Returns:
Seconds actually spent in the loop.
"""
tx_types = list(weights.keys())
tx_weights = [weights[t] for t in tx_types]
start = time.monotonic()
last_seq_refresh = start
while (time.monotonic() - start) < duration:
# Periodically re-sync account sequences from the ledger so
# locally-tracked sequences don't drift behind consensus.
if (time.monotonic() - last_seq_refresh) >= SEQ_REFRESH_INTERVAL_S:
await _refresh_sequences(ws, accounts)
last_seq_refresh = time.monotonic()
tx_type = random.choices(tx_types, weights=tx_weights, k=1)[0]
await submit_transaction(ws, tx_type, accounts, stats)
await asyncio.sleep(interval)
# Progress logging every 50 transactions.
if stats.total_submitted % 50 == 0 and stats.total_submitted > 0:
elapsed = time.monotonic() - start
logger.info(
"Progress: %d submitted, %d success, %d errors, "
"%.1f TPS (%.0fs elapsed)",
stats.total_submitted,
stats.total_success,
stats.total_errors,
stats.total_submitted / elapsed if elapsed > 0 else 0,
elapsed,
)
return time.monotonic() - start
async def run_submitter(
endpoint: str,
tps: float,
duration: float,
weights: dict[str, int],
) -> TxStats:
"""Run the transaction submitter against a single endpoint.
Args:
endpoint: WebSocket URL (ws://host:port).
tps: Target transactions per second.
duration: Total run time in seconds.
weights: Transaction type distribution weights.
Returns:
TxStats with aggregated results.
"""
stats = TxStats()
interval = 1.0 / tps if tps > 0 else 0.5
elapsed = 0.0
ws = await websockets.connect(endpoint, ping_interval=20, ping_timeout=10)
logger.info("Connected to %s", endpoint)
try:
# Setup test accounts. Every created account is returned whether or
# not funding worked, so submit only from the funded ones — the
# builders address accounts by position and an unfunded account there
# would fail every transaction it is picked for.
created = await setup_accounts(ws)
accounts = [acct for acct in created if acct.funded]
if len(accounts) < MIN_FUNDED_ACCOUNTS:
logger.error(
"Need at least %d funded accounts, only %d of %d created "
"accounts were funded",
MIN_FUNDED_ACCOUNTS,
len(accounts),
len(created),
)
return stats
logger.info(
"Starting TX submission: tps=%s, duration=%ss, types=%d",
tps,
duration,
len(weights),
)
elapsed = await _submission_loop(
ws, accounts, weights, duration, interval, stats
)
finally:
await ws.close()
logger.info(
"Submission complete: %d submitted, %d success, %d errors "
"in %.1fs (%.1f TPS)",
stats.total_submitted,
stats.total_success,
stats.total_errors,
elapsed,
stats.total_submitted / elapsed if elapsed > 0 else 0,
)
return stats
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
"""Parse command-line arguments."""
parser = argparse.ArgumentParser(
description="Transaction Submitter for rippled telemetry validation",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Basic usage (5 TPS for 2 minutes):
python3 tx_submitter.py --endpoint ws://localhost:6006 --tps 5 --duration 120
# Custom transaction mix:
python3 tx_submitter.py --endpoint ws://localhost:6006 \\
--weights '{"Payment": 60, "OfferCreate": 20, "TrustSet": 20}'
""",
)
parser.add_argument(
"--endpoint",
type=str,
default="ws://localhost:6006",
help="WebSocket endpoint (default: ws://localhost:6006)",
)
parser.add_argument(
"--tps",
type=float,
default=5.0,
help="Target transactions per second (default: 5)",
)
parser.add_argument(
"--duration",
type=float,
default=120.0,
help="Run duration in seconds (default: 120)",
)
parser.add_argument(
"--weights",
type=str,
default=None,
help="JSON string of transaction type weights (overrides defaults)",
)
parser.add_argument(
"--output",
type=str,
default=None,
help="Write JSON summary to this file path",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable debug logging",
)
return parser.parse_args()
def main() -> None:
"""Main entry point for the transaction submitter."""
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s %(message)s",
)
# Parse custom weights if provided.
weights = DEFAULT_TX_WEIGHTS.copy()
if args.weights:
try:
custom = json.loads(args.weights)
weights = {k: int(v) for k, v in custom.items()}
logger.info("Using custom weights: %s", weights)
except (json.JSONDecodeError, ValueError) as exc:
logger.error("Invalid --weights JSON: %s", exc)
sys.exit(1)
# Run the submitter.
stats = asyncio.run(
run_submitter(
endpoint=args.endpoint,
tps=args.tps,
duration=args.duration,
weights=weights,
)
)
summary = stats.summary()
print(json.dumps(summary, indent=2))
if args.output:
with open(args.output, "w") as f:
json.dump(summary, f, indent=2)
logger.info("Summary written to %s", args.output)
if __name__ == "__main__":
main()