Skip to content

Rearchitect live ingestion for high-TPL ledgers: pipeline, batched 5-way COPY commits, once-per-op decoding, staggered compression - #682

Open
aditya1702 wants to merge 28 commits into
replay-loadtest-backend-pr6from
replay-loadtest-ingest-pipeline
Open

Rearchitect live ingestion for high-TPL ledgers: pipeline, batched 5-way COPY commits, once-per-op decoding, staggered compression#682
aditya1702 wants to merge 28 commits into
replay-loadtest-backend-pr6from
replay-loadtest-ingest-pipeline

Conversation

@aditya1702

@aditya1702 aditya1702 commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What

Rearchitects live ingestion for sustained high-TPL ledgers: a fetch ‖ process ‖ persist pipeline with bounded batched commits, five parallel COPY streams, once-per-operation change decoding, staggered TimescaleDB compression, and a consumer-justified index diet on state_changes. Also fixes a silent transaction-loss bug in the indexer buffer and hardens the balance upserts against heap bloat.

Why

The network roadmap shrinks block close time 2s → 1s → 600ms while per-ledger transaction volume stays roughly constant, so live ingestion must process one full ledger in under the block time. The engineering contract is p99 of the slowest pipeline stage ≤ block time. On the loadtest rig (Phase-1 shape: 6000 SAC + 1000 classic + 4000 custom-token + 1500 soroswap TPL), serial ingestion took 7.7–8.4 s/ledger. The changes here address, in measured order of impact: serialized insert streams, the hourly compression I/O storm (persist p50 0.68 s → 3.0 s for 18 of every 60 minutes), redundant per-processor change decoding (19% of process CPU in GetChangesFromLedgerEntryChanges, 14% in the SDK's sortChanges), and index maintenance on the highest-volume table.

How

Pipeline (fetch ‖ process ‖ persist)

ingestLiveLedgers runs three stages connected by channels: while ledger N persists, N+1 processes and N+2 is fetched. Ledger time becomes the slowest stage, not the sum. Persist stays strictly sequential in ledger order (guarded cursor and per-protocol CAS chains advance N-1 → N); any stage error cancels the pipeline and the process resumes from the cursor after restart. wallet_ingestion_duration_seconds sums the stage times so panels stay comparable to the old serial values; throughput lives in rate(wallet_ingestion_ledgers_total) and per-stage numbers in wallet_ingestion_phase_duration_seconds{phase}, whose buckets bracket the contract: boundaries sit on 0.6s (the 600ms block target) and 1s (the rig's 1.67×-sized merged ledgers), with 0.5 and 0.75 around them. The previous 0.5 → 1 → 2 steps could not separate a stage at 1.05s from one at 1.95s, so the metric could not grade the bar it exists to measure.

Five sibling COPY streams + coordinated late commits

Each persist opens five sibling transactions — transactions, transactions_accounts, operations, operations_accounts, state_changes, each owning exactly one table — plus a coordinating transaction for everything else (assets, contracts, classification, protocol state, token changes, cursor). All the slow work happens uncommitted; commits fire only after every stream succeeds, siblings first, coordinator strictly last. The cursor is the authority: the only crash state is orphaned bulk rows above the committed cursor, which DeleteRowsAboveLedger removes at startup. Failure before the first commit is retryable; after it, ErrPartialPersist is fatal.

Sibling sessions run SET LOCAL synchronous_commit = off: the coordinator's synchronous, strictly-last commit flushes all earlier WAL including the sibling commit records, so a durable cursor implies durable siblings — up to five fsync waits per persist removed with no durability change.

Bounded batched commits (--live-persist-max-batch-size, default 1)

When process finishes ledgers faster than persist drains them, persist coalesces up to N consecutive ledgers into one commit set — each sibling streams the whole batch, the coordinator stages ledgers in order, the cursor lands on the batch's last ledger — amortizing COPY setup, index churn, and fsyncs across the backlog. While the pipeline keeps pace every batch has size 1 and behavior is exactly the unbatched persist. Batching is opt-in per deployment: the default 1 keeps commit-per-ledger on mainnet/testnet, whose 5s close time comfortably exceeds the persist time; high-TPL/short-block deployments (the loadtest rig, future network phases) raise it. A ledger carrying classification inputs no committed batch has yet classified opens its own batch, preserving the plan's requirement that its pool reads see the previous commit; re-observations of already-classified contracts ride mid-batch. wallet_ingestion_persist_batch_size observes coalescing; per-ledger histograms record amortized shares.

The cap also sizes the process↔persist buffer rotation. A buffer stays checked out from the moment process takes it until the batch carrying it commits, so sustaining a full batch needs 2N+1 buffers: N held by the batch in flight, N-1 queued behind it, one being filled. Sized any tighter, process starves on free buffers before the queue can refill and the batch settles at a fixed point strictly below N — the configured cap becomes unreachable at every load, which the histogram reports as a suspiciously constant value. A test drives the pipeline with the process stage ahead and fails if the mean batch falls short of the cap. Each buffer retains a merged ledger's maps, so raising N raises ingest's memory floor with it.

Process stage: decode once, hash in parallel

Every state-change/effects processor called Transaction.GetOperationChanges independently, and the SDK re-decodes, re-allocates, and re-sorts on each call. All of an operation's processors share one TransactionOperationWrapper, so Changes() now memoizes the extraction; all 14 call sites route through it (audited read-only — the memoized slice is shared). BenchmarkProcessRealLedger on five real pubnet ledgers: 40–50% less wall time, ~50% fewer bytes, ~57% fewer allocations. The ledger-indexer pool also sizes off GOMAXPROCS instead of node cores (a 6-core pod on a 36-core node ran 72 workers over 6 schedulable threads).

Reading a ledger's transactions was what remained of the stage's serial time. Pairing metas with envelopes needs every envelope's hash — the transaction set carries envelopes in agreement order while metas are sorted by hash — and the SDK reader computes all of them on the caller's goroutine before returning the first transaction, so no amount of overlapping with the fan-out that follows can recover it. On the rig that was ~0.37 s single-threaded per ledger, 41% of the process stage. Hashing now runs across the indexer's worker pool, one contiguous chunk per schedulable thread, and three costs went with it: the network id is a hash of the passphrase alone, so it is computed once per ledger instead of once per transaction; each chunk marshals through one reused xdr.EncodingBuffer rather than allocating a buffer per hash — that path alone was 8.5% of everything the ingest process allocated, against a GC taking 25.7% of its CPU; and the envelope's transaction is tagged in place instead of copied by value. On the real pubnet fixtures, where a few hundred transactions per ledger barely engage the fan-out, BenchmarkGetLedgerTransactions is 2.8–3.8× faster with 4.3–8.6× fewer allocations. A differential test against the SDK reader over every committed fixture is the merge gate, and it also restores the independence of the two other reader-based oracles in the package, which had been materializing their transactions through the very function they were the oracle for.

The wrapper memo also feeds ContractData extraction now. The persist stage had been deriving each ledger's ContractData changes through tx.GetChanges(), which rebuilds and ledger-key-sorts every operation's change group from the meta — the same work the wrappers had already done and memoized one stage earlier. Measured on the rig, that duplicate path was 3.6% of ingest CPU plus its allocation halo, all of it on the serial persist goroutine. processTransaction now collects each successful transaction's ContractData changes from the memoized slices, mirroring GetChanges' per-meta-version composition (transaction-level before, operations ascending, transaction-level after), and folds them into the ledger buffer persist already receives — replacing the lazy extraction memo and the transactions plumbing through the process→persist handoff. Transaction-level segments are materialized only when a type-tag scan of the raw group mentions ContractData at all. The reader-based tx.GetChanges() reference stays the merge gate over every committed fixture.

isLiquidityPool and isClaimableBalance stop decoding too. They ran a full strkey decode — base32, CRC16, and a copy of the input — on both endpoints of every transfer event, which are ordinary account and contract addresses almost every time. A strkey version byte is a multiple of 8, so its top five bits are exactly what the first base32 character encodes, and only an L or a B can decode to those two version bytes whatever the payload. Testing that character first skips the decode for everything else while anything that passes it still goes through the full decode, so validation is unchanged. 13.6% of the token-transfer processor on the rig.

Compression staggering

Each hypertable's columnstore policy is auto-created with an identical schedule, so all five fired at the same instant and compressed ~an hour of chunks concurrently — measured 4.5× persist degradation for 18.5 min of every hour on the rig. configureHypertableSettings now converges each policy onto a fixed schedule anchored to the interval grid with a distinct per-table slot (initial_start = date_bin(interval, now(), epoch) + interval + i/5·interval), so at most one policy comes due at a time. Jobs already on their slot are left untouched across restarts.

Index diet on state_changes

TOID encoding derives a state change's parent transaction (to_id = operation_id &^ 0xFFF), so BatchGetByOperationID(s) now bind (ledger_created_at, to_id, operation_id) — a PK-prefix seek, measured 7 vs 307 buffers — and idx_state_changes_operation_id is dropped. idx_state_changes_account_category narrows to idx_state_changes_account_id (account_id + the PK sort key): category/reason filters scan-and-filter the account's rows on the active chunk and prune compressed chunks via the existing bloom sparse indexes. Two fewer btree columns per insert on the highest-volume table. Already-migrated DBs need a one-time manual DDL (drop both old indexes, create the narrowed one; TimescaleDB rejects CREATE INDEX CONCURRENTLY on hypertables).

Correctness fixes

  • Indexer buffer keyed transactions by hash but participants by ToID. The streaming-loadtest backend's merged bootstrap ledgers can carry the same envelope at several tx-set positions (distinct ToIDs, one hash), and the maps then disagreed: duplicate transactions were silently dropped from the COPY while their participant links survived with a zero ledger_created_at (materializing a year-0001 chunk). Both maps now share the ToID key domain, mirroring the operations pair; BatchCopyAccounts errors on a link row with no parent instead of writing a zero timestamp. Real networks cannot repeat a hash, so this was latent there and triggered only on the rig.
  • Oldest-ledger lookup ordered path: the hourly reconcile_oldest_cursor job had been 45% of all loadtest-DB disk reads; the default transactions_ledger_created_at_idx is no longer dropped and the dead to_id tie-break is removed.
  • Balance upserts (native_balances, trustline_balances): rows sort by PK before UNNEST (map-iteration order scattered thousands of random btree/heap probes; measured 60+ heap buffer touches/row vs ~6 healthy) and the DO UPDATE carries an IS DISTINCT FROM guard so identical rows produce no dead tuple, WAL, or index churn.
  • Startup reconciliation bounded by the cursor ledger's close time: DeleteRowsAboveLedger scanned every chunk TOID chunk-skipping stats could not exclude (stats only cover compressed chunks; three of five tables have no TOID-leading index) — over 10 minutes on a DB carrying hours of full ledgers, long enough that unread meta pipes wedged the loadtest generators into liveness-probe kills. Close-time monotonicity makes every orphan's ledger_created_at ≥ the cursor ledger's own close time, so the partition-column bound statically excludes all older chunks and turns state_changes into a PK range seek.
  • SEP-41 metadata treats the database as the durable fetched-cache: a token whose contract_tokens row already carries metadata is never re-fetched over RPC (previously once per process lifetime per token after every restart, and forever on a 5-minute backoff against an RPC that can never resolve it); genuinely missing metadata keeps the existing backoff retry.
  • Fetch metric buckets widened to match real durations; dead fields, an inert flag, and a phantom metric label removed; the live retry ladder folded into utils.RetryWithBackoff.

Loadtest backend

Per-pipe readers split into a raw drain and a parallel decoder: the drain slurps each framed XDR record at pipe-transfer speed (apply-load's meta write is synchronous, so core cannot start generating its next ledger until the frame drains — decoding inline put the XDR decoder inside every producer's ledger cycle), and a per-pipe goroutine decodes from memory, buffering two frames ahead. Frame and error ordering are preserved; producer restarts roll epochs exactly as before.

Measured results (loadtest rig, Phase-1 shape)

Metric Before After
Serial ingest, drifted DB 7.7–8.4 s/ledger
Pipelined work per ledger (fresh DB, pre-batch/memo) ~1.4–1.5 s (process p50 0.79 s, persist p50 0.68 s)
Insert phase (drifted DB) 8.2 s serial sum 3.4 s (bounded by largest single COPY)
Compression window impact persist 4.5× worse, 31% duty cycle staggering targets ≤1 concurrent policy
Process-stage benchmark (real ledgers) −40–50% wall, −57% allocs
Transaction-read benchmark (real ledgers) 2.8–3.8× faster, 4.3–8.6× fewer allocs
Process stage, rig, phase-matched 0.895 s mean 0.594 s mean (p50 0.556 s, p90 0.852 s, p99 1.335 s)
Process stage ≤1 s 67% of ledgers 97%
Persist stage, same windows 0.824 s mean 0.812 s (untouched, as expected)
Ingest CPU per transaction 0.499 ms 0.415 ms
GC share of ingest CPU 25.7% 19.6%
Transaction read: CPU / allocations 3.94% / 10.67% 0.22% / 0.61%

The two rig windows are phase-matched — same 10 minutes past the hour, so the same age of the
one-hour active chunk and the same overlapping compression policy — because that turned out to
matter more than anything measured here. Persist swings 0.789 s to 3.369 s within a single
hour
purely as the active chunk's indexes grow and then reset on the roll, with batching rising
to the cap and the rig flipping from supply-bound to sink-bound at the top of the ramp. Any
measurement on this rig that does not control for chunk phase is dominated by a 4.3× effect. It
is also a fresh argument for a shorter chunk interval: 15-minute chunks would cap how far up that
ramp the rig can climb.

The pod defaults changed: DB pool default rises to 12 connections (persist holds 6 at the commit barrier).

Deploy notes

  • One-time manual DDL on already-migrated DBs for the state_changes index changes (see above; done on loadtest, pending on dev mainnet/testnet/prod together with the earlier transactions_ledger_created_at_idx restore, which prod still needs).
  • New flag --live-persist-max-batch-size (env LIVE_PERSIST_MAX_BATCH_SIZE), default 1 (commit-per-ledger, unchanged behavior). Raising it costs memory: the rotation holds 2N+1 buffers, each retaining a merged ledger's maps. The loadtest rig sets 3, which is what its ingest container fits alongside GOMEMLIMIT.
  • Grafana: the summed-duration panel should gain per-stage companions, e.g. histogram_quantile(0.99, sum by (le, phase) (rate(wallet_ingestion_phase_duration_seconds_bucket[15m]))) and a wallet_ingestion_persist_batch_size p50 panel.

The wallet_db_query_duration_seconds histogram topped out at 0.38s
(ExponentialBuckets(0.0001, 2.5, 10)), so every multi-second bulk COPY
landed in +Inf and histogram_quantile panels clipped at ~0.38s,
under-reporting exactly the queries being optimized. Widen to
ExponentialBuckets(0.0001, 3, 12) — 0.1ms through ~17.7s.
…-ledger lookup

The oldest-ledger lookup (SELECT ledger_number FROM transactions ORDER BY
ledger_created_at ASC LIMIT 1) has two consumers — backfill gap
detection's left bound (GetOldestLedger) and the hourly
reconcile_oldest_cursor TimescaleDB job — and neither had an ordered
path: the transactions migration dropped TimescaleDB's default
partition-column index as consumer-less, an audit that counted
WHERE-clause consumers and missed that this query consumes the index's
ordering. Without it the planner pulls the first row from every chunk
instead of running an ordered ChunkAppend that stops at the oldest
(12-187s on an 87GB DB; the hourly job alone accounted for 45% of all DB
disk-read time and flushed shared_buffers every run).

Keep the default index (fresh databases get it from create_hypertable;
already-migrated environments need a one-time manual
`CREATE INDEX transactions_ledger_created_at_idx ON transactions (ledger_created_at DESC)`)
and drop the to_id tie-break from both query sites: close times increase
strictly, so rows sharing a ledger_created_at carry the same
ledger_number and the tie-break could only force an incremental sort on
top of the index's pathkeys. Covered by a new test that runs the
reconciliation job via run_job against out-of-order chunks.
…flag

ingestService carried four fields (appTracker, getLedgersLimit,
knownContractIDs, contractMetadataService) and Indexer two
(ingestionMetrics, networkPassphrase) that were assigned at construction
and never read — consumers get these through their own constructors or
through internal/ingest.Configs. Deleting them also drops AppTracker,
GetLedgersLimit, and ContractMetadataService from IngestServiceConfig
(the checkpoint service keeps its own metadata-service wiring).

Also removes the --latest-ledger-cursor-name flag, which only fed a
deprecation warning, and the phantom load_current_state phase label from
the ProtocolStateProcessingDuration help text (never emitted).
Every remaining parameter of persistLedgerData had exactly one production
value: cursorName was always the latest-ledger cursor (the blind-Update
branch was reachable only from tests), ledgerMeta was never nil, and the
numTxs/numOps returns only fed two counters the buffer already knows via
GetNumberOfTransactions/GetNumberOfOperations. The signature is now
(ctx, ledgerSeq, ledgerMeta, plan, contractData, buffer) error, the
cursor write is unconditionally guarded, and the contractDataMemo
nil-receiver branch (test-only) is gone.

IndexerBufferInterface shrinks to its real seam — the indexer's fold-in
plus the five getters insertIntoDB reads; everything else is reached on
the concrete *IndexerBuffer. Tests now exercise the guarded cursor path
persistLedgerData actually runs in production.
…Backoff

persistLedgerDataWithRetry (né ingestProcessedDataWithRetry) hand-rolled
the same attempt loop, exponential backoff, cap, and context handling
that utils.RetryWithBackoff already provides — the body is now a single
RetryWithBackoff call with isPermanentPersistError as the classifier.
Metric semantics preserved: transient retries count RetriesTotal,
permanent errors count ErrorsTotal and return immediately, exhaustion
counts both plus RetryExhaustionsTotal, and context cancellation counts
neither.

RetryWithBackoff's exhaustion return now wraps the exported sentinel
utils.ErrRetriesExhausted alongside the final error, which is how the
wrapper tells exhaustion apart from a permanent-error exit.
…ist stages

The live loop ran fetch (~0.3s of waiting), process_ledger (~0.8s of Go
CPU), and the DB persist (~1.1s) strictly in sequence, so the per-ledger
time was their sum — above a 2s ledger cadence at high transaction
volume even though each stage individually fits. The loop is now a
three-stage errgroup pipeline over depth-1 channels: while ledger N
persists, N+1 processes and N+2 is fetched, making the ledger time the
slowest stage instead of the sum.

Persist stays strictly sequential in ledger order (the guarded cursor
and per-protocol CAS chain advance N-1 → N) and is the only writing
stage, so the advisory-lock liveness probe moves there. Classification
planning also stays on the persist stage: its known-hash lookup is a
non-transactional pool read that must observe the previous ledger's
committed protocol_wasms rows. Two IndexerBuffers rotate through a
freeBuffers channel, cleared only at take so a clear can never tear a
persist still reading the maps the getters alias. Any stage error
cancels the pipeline and the process exits, resuming from the cursor on
restart — the Duration metric now sums the ledger's stage times, since
wall time under a pipeline would count queue wait.
…with coordinated late commits

The live persist ran its three COPY families — transactions(+accounts),
operations(+accounts), state_changes — sequentially inside one
transaction on one connection, so the insert phase cost their sum and a
single Postgres backend did all the index maintenance. They now stream
concurrently on three sibling connections, each in its own transaction,
while the coordinating transaction stages everything else (assets,
contracts, classification, protocol state, token changes, cursor). The
table sets are disjoint with no FKs among them, and every goroutine only
reads the quiescent buffer.

Commits are the visibility point and are held until all four
transactions have done their work: siblings commit first (sub-ms each),
the coordinating transaction — whose cursor is the authority on which
ledgers exist — strictly last. A failure before the first commit rolls
everything back and stays retryable exactly as before; a failure after
it wraps the new ErrPartialPersist sentinel, which
isPermanentPersistError classifies as fatal, because COPY has no ON
CONFLICT and re-running the ledger would collide on primary keys.

The only crash state this ordering can produce is orphaned bulk rows for
the single ledger past the committed cursor, so startup runs
IngestStoreModel.DeleteRowsAboveLedger before resuming: one transaction
of TOID-bounded deletes (rows of ledgers > cursor are exactly rows with
TOID >= toid.New(cursor+1,0,0)) across the five bulk tables, kept
chunk-local by each table's chunk skipping. Backfill keeps the
single-transaction insertIntoDB path unchanged.
@aditya1702
aditya1702 force-pushed the replay-loadtest-ingest-pipeline branch from 0d92cb5 to 0a18a10 Compare August 10, 2026 21:23
…the schedule interval

Each hypertable's columnstore policy is auto-created with an identical
schedule, so all five fire at the same instant and compress their
just-closed chunks concurrently — an I/O storm that starves the persist
stage (measured on the loadtest rig: persist p50 0.68s -> 3.0s for 18 of
every 60 minutes). Converge each policy onto a fixed schedule anchored to
the interval grid with a distinct per-table slot offset, so at most one
policy comes due at a time. Jobs already on their slot are left untouched
across restarts, preserving next_start and run history.
…ommits

When the process stage finishes ledgers faster than persist drains them,
the persist stage now folds up to --live-persist-max-batch-size (default
5) consecutive ledgers into one commit set: each sibling COPY streams the
whole batch on its connection, the coordinating transaction stages the
batch's ledgers in order, and the commit barrier fires once — amortizing
COPY setup, index-page churn, and fsyncs across the backlog. While the
pipeline keeps pace every batch has size 1 and behavior is unchanged.

A ledger with classification inputs always opens its own batch: its
plan's pool reads see exactly what the previous batch committed, which
preserves the deployed-contract-sees-prior-wasm invariant. The cursor
stays the authority — one guarded update lands on the batch's last
ledger, and a pre-commit failure rolls back and retries the whole batch.
wallet_ingestion_persist_batch_size observes coalescing; per-ledger
duration histograms record each ledger's amortized share so panels stay
comparable across batch sizes.
…on WAL flush

SET LOCAL synchronous_commit = off on each sibling session removes up to
three serialized fsync waits per persist. Durability is unchanged: the
coordinating transaction commits synchronously and strictly last, and its
flush covers all earlier WAL including the sibling commit records, so a
durable cursor implies durable siblings; rows a crash could lose are
exactly the unacknowledged ones startup reconciliation deletes.
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.
…ary key

TOID encoding makes a state change's parent transaction to_id derivable
from its operation_id (to_id = operation_id &^ 0xFFF), so
BatchGetByOperationID and BatchGetByOperationIDs now bind
(ledger_created_at, to_id, operation_id) — a primary-key prefix seek —
instead of filtering operation_id across every entry sharing the
timestamp (measured 7 vs 307 buffers on a single-chunk fixture). That
leaves idx_state_changes_operation_id with no consumer; it is removed.

idx_state_changes_account_category narrows to
idx_state_changes_account_id (account_id + the PK sort key): the
category and reason columns only ever seeded an ordered seek for the
one filter shape that pins both, while every other shape and
BatchGetAccountStateChangesByToIDs seek on account_id alone. Filtered
variants scan-and-filter the account's rows on the active chunk and
prune compressed chunks via the existing bloom sparse indexes. Two
fewer btree columns per insert on the highest-volume table.

Already-migrated DBs need the one-time manual DDL (drop both old
indexes, create the narrowed one); TimescaleDB rejects CREATE INDEX
CONCURRENTLY on hypertables.
…lings

The live persist path's sibling COPY streams grow from three to five:
transactions_accounts and operations_accounts — the two largest
index-maintenance payloads, each carrying an account_id-leading unique
PK — stream concurrently with their parent tables instead of serially
behind them. TransactionModel and OperationModel each split BatchCopy
into a parent-table COPY and BatchCopyAccounts for the link table, with
the link COPY's duration and batch size recorded under its own metric
labels rather than folded into the parent's. Each sibling now writes
exactly one table, so the disjointness at the commit barrier is
per-table; the backfill path calls the five inserts in the old order
and is behaviorally unchanged.

Persist holds six connections at its commit barrier; the pool default
rises to 12 so the persist path never queues on Acquire behind the
advisory-lock session or pool-side classification reads.
… random order

The native_balances and trustline_balances batch upserts arrive in Go
map-iteration order and unconditionally rewrite every conflicting row.
On the loadtest rig the pattern measured 60+ heap buffer touches per
row (vs ~6 healthy) against a table bloated to 97% free space by the
churn. Two changes: rows sort by the primary-key columns before the
UNNEST arrays are built, so the btree descent and heap touches run in
key order; and the DO UPDATE carries an IS DISTINCT FROM guard, so an
identical row produces no new tuple version, no dead tuple, no WAL,
and no index churn.
…cores

runtime.NumCPU() reports the node's cores in a container with a CPU
limit, so a 6-core pod on a 36-core node ran 72 indexer workers over 6
schedulable threads — pure scheduler churn on CPU-bound work.
GOMAXPROCS honors the limit.
The buffer stored transactions keyed by hash while tracking their
participants keyed by ToID. The streaming-loadtest backend's merged
bootstrap ledgers can carry the same envelope at several tx-set
positions — distinct ToIDs, one hash — and the two maps then disagreed:
duplicate transactions were silently dropped from the transactions COPY
while their participant links survived, whose ledger_created_at lookup
missed and COPYed the zero timestamp into a year-0001 chunk (observed
on the rig: 6 orphaned transactions_accounts rows, 5 missing
transaction rows across bootstrap ledgers). Real networks cannot repeat
a hash within or across ledgers, so both maps now share the ToID key
domain by construction, mirroring the operations pair.

BatchCopyAccounts on both link tables now errors on a ToID/opID with no
parent row instead of silently writing a zero timestamp.
…per processor

Every state-change and effects processor independently called
Transaction.GetOperationChanges for the same operation, and the SDK
accessor re-decodes the op's meta, re-allocates, and re-sorts the
changes on every call — 19% of process-stage CPU in
GetChangesFromLedgerEntryChanges, 14% in sortChanges, and a large share
of GC pressure on a production profile. All of an operation's
processors share one TransactionOperationWrapper, so Changes() now
memoizes the extraction on the wrapper; all 14 call sites route through
it, audited read-only (the memoized slice is shared — callers must not
mutate it or write through Pre/Post). A wrapper copied to describe a
different operation resets its memo so the cache always matches Index.

BenchmarkProcessRealLedger on the five real-ledger fixtures: 40-50%
less wall time, ~50% fewer bytes and ~57% fewer allocations per ledger.
@aditya1702 aditya1702 changed the title Pipeline live ingestion and parallelize the bulk COPYs Rearchitect live ingestion for high-TPL ledgers: pipeline, batched 5-way COPY commits, once-per-op decoding, staggered compression Aug 11, 2026
Batching is opt-in per deployment: at mainnet/testnet cadence the close
time comfortably exceeds the persist time, so every ledger commits on
its own; high-TPL/short-block deployments raise the flag to amortize
backlogs.
…dger's close time

DeleteRowsAboveLedger scanned every chunk TOID chunk-skipping stats
could not exclude — stats only cover compressed chunks, and three of
the five tables have no TOID-leading index — which took over 10 minutes
on a loadtest DB carrying hours of full ledgers, long enough that the
unread meta pipes wedged all three generators into liveness-probe
kills. Close times are monotone, so every orphan above the cursor
carries ledger_created_at at or after the cursor ledger's own close
time; binding that as a partition-column predicate statically excludes
all older chunks regardless of compression, and turns state_changes'
scan into a primary-key range seek. A cursor ledger with no
transactions row leaves the bound unresolvable and the deletes fall
back to the unbounded scan.
…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 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.
The classification-safety cut isolated any ledger carrying protocol
wasm/contract observations, but a plan's pool reads are only unsound for
inputs no committed batch has classified yet — and re-observations of
known contracts are the overwhelmingly common case (synthetic loadtest
traffic re-observes the same token contracts every ledger, which cut
every batch to size one and disabled batching entirely; goroutine dumps
showed the process stage starved of buffers held by the always-cut
pending queue). The persist goroutine now keeps seen-sets of wasm hashes
and contract IDs folded in after each successful commit; only a ledger
introducing unseen inputs opens its own batch. A rolled-back batch marks
nothing, and a restart starts empty — conservative until re-warmed.
@aditya1702 aditya1702 self-assigned this Aug 11, 2026
The seen-set comment block split the struct's alignment group, leaving the
fields below it on the wider alignment. golangci-lint's gofmt check fails on
it; `make check` cannot catch this because its `tidy` step rewrites the file
before the `fmt` gate reads it.
…eachable

A buffer stays checked out from the moment the process stage takes it until
the batch carrying it commits, so the rotation needs 2*cap buffers to sustain
a full batch: cap for the batch in flight, cap-1 queued in processed, one
being filled. It held cap+1, so process starved on freeBuffers after filling
(cap+1)-k and the batch settled at the fixed point k = (cap+1)-k.

The batch size was therefore pinned below the configured cap at every load —
loadtest measured exactly 3.00 against a cap of 5 across 17 sample windows
spanning 3.9k to 9.8k tx/s, and the new test measures 2.04 against a cap of 3
when the pool is sized the old way.
…e bar

The phase histogram stepped 0.5 -> 1 -> 2 seconds, so a stage sitting at
0.9s and a stage sitting at 1.9s both reported the same p99 bucket. The
pipeline's contract is that the slowest stage's p99 stays under the ledger
close time, so the metric could not grade the thing it exists to grade.

Boundaries now land exactly on 0.6s (Phase-3 block rate) and 1s (the
loadtest rig's 1.67x-sized merged ledgers), with 0.5 and 0.75 around them.
Pairing metas with envelopes needs every envelope's hash, and building it was
the pipeline's largest serial stretch: on the loadtest rig it ran ~0.37s
single-threaded ahead of the fan-out, 41% of the process stage, of which 85%
was the hashing itself. Reading transactions out of a LedgerCloseMeta now
hashes envelopes across the indexer's worker pool instead of on the caller's
goroutine.

Three costs went with it. The network id is a hash of the passphrase alone,
so it is computed once per ledger rather than once per transaction. Each
chunk marshals through one xdr.EncodingBuffer, which reuses its scratch space
across transactions instead of allocating a buffer per hash — this path alone
was 8.5% of everything the ingest process allocated. And the envelope's
transaction is tagged in place rather than copied by value.

On the real pubnet fixtures, where the fan-out barely engages at a few hundred
transactions per ledger: 2.8-3.8x faster, 4.3-8.6x fewer allocations.

Also restores the independence of the two reader-based oracles in the test
suite. They materialized their transactions through the same function they
were the oracle for, so they now go through getLedgerTransactionsViaReader,
which drives the SDK reader and shares no code with the fan-out. That helper
is the merge gate for this change too, via
TestGetLedgerTransactions_EquivalenceOnRealLedgers.
isLiquidityPool and isClaimableBalance ran a full strkey decode — base32,
CRC16 validation, and a byte copy of the input — on both endpoints of every
transfer event. Those endpoints are ordinary account and contract addresses
almost every time, so nearly all of that work only ever concluded "no".

A strkey version byte is a multiple of 8, so its top five bits are exactly
what the first base32 character encodes: only an 'L' can decode to a liquidity
pool and only a 'B' to a claimable balance, whatever the payload. Checking
that character first skips the decode for everything else, and a string that
passes it still goes through the decode, so validation is unchanged.

Together this was 13.6% of the token-transfer processor on the loadtest rig.
@aditya1702
aditya1702 force-pushed the replay-loadtest-ingest-pipeline branch from 344e81b to 0c05682 Compare August 11, 2026 22:14
…wrapper memos

The persist stage extracted each ledger's ContractData changes through
tx.GetChanges(), which rebuilds and ledger-key-sorts every operation's change
group — work the process stage had already done and memoized on the shared
TransactionOperationWrapper. On the loadtest rig that duplicate path was 8.7s
of a 239.7s profile (3.6% of ingest CPU, plus its allocation/GC halo), all of
it on the serial persist goroutine.

processTransaction now collects each successful transaction's ContractData
changes from the wrappers' memoized Changes() slices, mirroring GetChanges'
composition per meta version (tx-level before, operations ascending, tx-level
after); transaction-level segments are only materialized when a type-tag scan
of the raw group mentions ContractData at all. Results fold into the ledger
buffer in transaction order and reach the persist stage on the buffer it
already carries, replacing the contractDataMemo and the transactions plumbing
through processedLedger; ProcessLedger no longer returns the materialized
transactions.

Merge gate: TestProcessLedgerTransactions_ContractDataChangesMatchReaderExtraction
compares the buffer's collection against the reader-based tx.GetChanges()
reference over every real-ledger fixture; synthetic tests pin the success
gate, the meta-version guard, wrapper-memo serving, and the no-wrapper
fallback.
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