Skip to content

Add streaming-loadtest ledger backend for apply-load replay - #679

Open
aditya1702 wants to merge 11 commits into
main-blendfrom
replay-loadtest-backend-pr6
Open

Add streaming-loadtest ledger backend for apply-load replay#679
aditya1702 wants to merge 11 commits into
main-blendfrom
replay-loadtest-backend-pr6

Conversation

@aditya1702

@aditya1702 aditya1702 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Start with streaming_loadtest_ledger_backend.go (the backend commit), then the TCP-producers commit. Review time: ~40 min.

Dev-only ledger backend that replays stellar-core apply-load meta straight into live ingestion — the real ingest loop at rates no test network can produce. Only the ledger backend is swapped; processors and persistence are untouched, so what it measures predicts production.

Based on blend/pr6-integration-tests (what dev runs today); re-targets to main when pr6 lands.

What's in it

  • New LEDGER_BACKEND_TYPE=streaming-loadtest: merges framed meta from N producer sources into one ledger sequence, renumbers so restarts never need a DB reset, stamps advancing close times, paces via --loadtest-ledger-close-duration. No history archive — starts from ledger 1 on an empty DB.
  • --loadtest-meta-sources takes FIFO paths and/or tcp-listen://HOST:PORT, so producers run in their own pods and dial in. Connection close = epoch boundary (same as FIFO EOF); the listener outlives epochs so producers can restart.
  • Reader throughput: each source splits into a raw drain + a decode goroutine, with 1 decoded frame of lookahead — the old lockstep handoff cost ~0.6 s/ledger. Lookahead is 1, not 2: each frame is tens of MB of pointer-dense XDR the GC re-walks (~600 MB resident across 12 sources per unit); the second frame bought nothing.
  • One non-loadtest fix: SEP-41 metadata treats the DB as the durable fetched-cache. Persisted tokens are no longer re-fetched over RPC after every restart — or retried forever (~850 ms simulate backoff each) against an endpoint that can never resolve them, which is the rig's exact setup.

Review path

  1. streaming_loadtest_ledger_backend.go — merge, renumber, pacing (the bulk)
  2. TCP-producers commit — listener/epoch lifecycle
  3. Two reader-perf commits — drain/decode split, lookahead
  4. sep41 fix — 5 files, +88 lines

Testing

Unit tests: renumbering, multi-source merge, generator restart, truncated frames, pacing, TCP epoch lifetime — -race clean. Opt-in corpus test (STREAMING_LOADTEST_CORPUS) replays real v27 apply-load output: 200 merged ledgers, ~57k txs.

Deploy note

The rig env must rename LOADTEST_META_PIPE_PATHSLOADTEST_META_SOURCES with this image. The producer side (dialer shim, StatefulSets, jitter) lives in the kube repo.


First of four PRs from the live-ingest loadtest campaign (replaces #682). Review order: this → #684#685; #686 is independent. Combined rig result: 18,458 tx/s, process p99 0.992 s (window p90 0.768 / p99 0.995); PRs not separately re-benchmarked.

aditya1702 and others added 11 commits August 20, 2026 13:30
A dev-only LEDGER_BACKEND_TYPE=streaming-loadtest that reads stream-framed
LedgerCloseMeta from named pipes written by stellar-core apply-load (one
FIFO per transaction profile), renumbers each stream onto the consumer's
requested sequence with per-pipe diffs, merges the per-sequence frames
into one mixed-traffic ledger via the SDK's loadtest.MergeLedgers, and
stamps monotone wall-clock close times (apply-load emits closeTime 0).

Renumbering makes both sides restartable without a database reset: a
restarted apply-load resets to raw sequence 1 and is mapped onto the next
requested ledger; a restarted consumer resumes from its cursor. Pacing via
--loadtest-ledger-close-duration bounds the ledger rate, and FIFO
backpressure throttles the generators to match.

Because apply-load's benchmark mode publishes no history archive, this
backend type skips the archive connection and the cursor-0 checkpoint
bootstrap: ingestion starts from ledger 1 on an empty database and balance
state accumulates from the ledger stream. Everything downstream of the
backend (live ingest loop, processors, persistence) is unchanged, so a
load test exercises the same code path as production ingestion.

Includes an opt-in corpus test (STREAMING_LOADTEST_CORPUS) that replays
real apply-load output through the backend and the production transaction
reader; verified against v27 sac/custom_token/soroswap corpora.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Renumber only the ledger header. The per-entry rewrite of
lastModifiedLedgerSeq/TTL/seqLedger was a full XDR
marshal/parse/walk/re-marshal round trip over every frame, and nothing
in wallet-backend reads those fields — measured at ~5x the entire
ledger-processing cost at full per-ledger volume (the stream's cadence
was bound by it), and a 37x speedup on the corpus replay test once
removed. Merging now appends transaction-set phases and results
directly instead of going through the SDK's renumbering merge, which
also drops the goxdr dependency.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…data

Two fixes for protocol-framework behavior surfaced by the loadtest rig:

Protocol state production (SEP-41 balances/state changes) is gated by
per-protocol cursor rows that only the protocol-migrate CLI creates, and
that CLI needs a replayable ledger source. A streaming-loadtest
deployment has none, so a fresh database silently skipped every
protocol processor. The archive-less bootstrap now seeds each
registered protocol's cursors at startLedger-1 — exactly the value the
first ledger's compare-and-swap expects — and refreshes the cursor
snapshot so production is live from ledger 1.

The SEP-41 metadata fetcher re-fetched failing contracts on every
ledger they were active in: claimed contracts re-enter Prefetch each
classification pass, and a persistently failing name() simulation costs
its full retry-with-backoff (600ms of sleep) every time. A negative
cache now skips recently failed contracts for 5 minutes, keeping
eventual enrichment while removing the per-ledger tax.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Prefetch runs for every claimed contract observed in a ledger's changes
and has no database access by design, so it re-simulated
name/symbol/decimals for tokens whose metadata was already persisted —
on every ledger that touched their instance. An in-process success
cache skips them; a restart refetches each contract once, which doubles
as the refresh path for tokens whose on-chain metadata changed. Persist
retries are unaffected: the fetched values live in the classification
plan, which is reused across retry attempts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each pipe's reader now buffers two decoded frames beyond the one in
flight, so a writer streams its next frames through the FIFO while the
consumer merges and processes earlier ledgers. With lockstep delivery
every GetLedger waited on the slowest writer's in-flight frame — a
constant ~0.6s/ledger at full volume that the one-frame handoff could
not hide. Backpressure still bounds the writers, now with three frames
of slack; an epoch's terminating error stays ordered behind its frames,
so restart handling is unchanged.
…allel

apply-load's meta write is synchronous: core does not start generating
its next ledger until the consumer drains the current frame, and the
reader drained only as fast as it decoded — putting XDR decode inside
every producer's ledger cycle (measured: cycle = generation + decode,
which capped the merged stream well under target cadence). Each pipe's
reader now slurps a record's raw bytes at transfer speed and hands them
to a per-pipe decode goroutine, so the producer starts its next ledger
while the previous frame decodes. Frame and error ordering are
preserved: a drain or decode error is always the last element delivered
and ends the epoch exactly as before.
The streaming-loadtest backend's meta sources generalize beyond FIFOs:
a tcp-listen://HOST:PORT entry binds a listener eagerly at construction
and serves each producer connection as one stream epoch, so apply-load
producers can run in their own pods and dial in. Connection close is the
epoch boundary (as FIFO EOF is), the listener outlives epochs to serve
producer restarts, and keepalive surfaces a vanished peer as a read
error. Frame draining, decoding, renumbering, and merging are shared
between both source kinds; entry order still defines merge order. The
flag is now --loadtest-meta-sources / LOADTEST_META_SOURCES.
Each buffered lookahead frame is a fully decoded LedgerCloseMeta — tens of
MB of pointer-dense XDR per source, ~600MB resident across 12 sources per
lookahead unit — and that mass is scanned by every GC cycle. One frame of
lookahead still overlaps the writer's streaming with the consumer's
processing; the second bought no cadence and cost scan time.
The metadata fetcher's fetched/failure caches are in-memory only, so a
token whose contract_tokens row already carries metadata was still
re-fetched over RPC — once per process lifetime after every restart on
a real network, and forever on a 5-minute backoff against a deployment
whose RPC can never resolve metadata (the loadtest rig's dead endpoint
with externally seeded rows), where each retry burned the ~850ms
simulate backoff ladder inside prepare_classification. After Apply
persists a batch's rows, contracts whose row has metadata are marked
fetched, making the database the durable cache. Tokens whose metadata
is genuinely missing stay unmarked and keep the existing backoff retry.
apply-load pins its ledger protocol to the core binary's current version
(neither LEDGER_PROTOCOL_VERSION nor its testing override is settable from
a config file), and every protocol since 23 emits V2 meta, so the V1 arms
in appendLedger/setLedgerSeq/stampCloseTime were unreachable. Collapse the
header rewrites onto one mutableHeader accessor that owns the V2-only
invariant, rebuild the test fixtures on V2 (the shape production actually
sees), and pin explicit rejection of V0 and V1 streams.
The fetched/failedUntil caches sit on the production ingestion path but
had no coverage: nothing verified that a resolved contract stops hitting
the RPC, that a failed fetch is suppressed for the backoff window, or
that expiry re-fetches and success clears the failure entry. Add those
tests; shortening metadataFailureBackoff here is what makes its
var-for-tests comment true.
@aditya1702
aditya1702 force-pushed the replay-loadtest-backend-pr6 branch from 68d3825 to 745220e Compare August 20, 2026 17:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant