full-history: hot-ingest p99 + memory campaign — packed rows, sorted-run tier, spill-and-merge cold build, zero-decompression freeze - #902
Conversation
b9a226f to
754b486
Compare
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
51caeeb to
d4d291f
Compare
a8eba1c to
df1d98a
Compare
6fa8922 to
24c6e61
Compare
Two diagnostics used to decompose hot-ingest latency: - bench-ingest hot --trace=PATH streams one wall-clock-stamped row per ingested ledger with every phase duration, so individual slow ledgers can be correlated against external timelines (RocksDB LOG flush events, iostat). Partial traces survive failed runs. - STELLAR_RPC_ROCKSDB_STATS=1 enables RocksDB statistics at store open and dumps tickers + histograms to STATISTICS.txt in the DB dir on Close. Env-gated debug knob, deliberately not a Tuning field so production configs cannot turn it on. This is what attributed hot commit time ~92% to memtable insertion (fsync is ~2.4ms of ~60ms). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C3xaaoD9LhzyKPdnZCJa2N
…delta Four cuts at the hot ingest tail, in causal order. The zstd Encode destination is pooled — a fresh ~compressBound dst per ledger was ~43% of hot ingestion's allocations — and the pool is promoted to the canonical internal/rpcv2/zstd.Pool (CGo context + retained dst buffer; the consumer contract is synchronous copy, which BatchWriter.Put honors). The events hot index writes ONE packed row per ledger instead of a row per (term, event) — tens of thousands of memtable keys per ledger gone from the shared commit batch. The ~21ms ledger compression forks off the critical path at IngestLedger entry and joins as the batch's last queue step (AddLedgerToBatch remains as the composition of the pair, one write path). Dense terms in ConcurrentBitmaps go tail-delta: publishes append to an immutable tail and only a threshold-crossing merge touches bitmap state — with the writer mutating and Cloning ONLY its private wbm twin, so published bitmaps are never written again (roaring's Clone writes the receiver's COW bookkeeping that reader-side FastOr reads; the multi-container race test pins this). sac-6000 @600ms, 1k: ingest_total p99 303 -> ~83ms across the four (final figures per constituent benches; GOGC-free). The encode state is store-OWNED, not sync.Pool'd: the write side is single-flight by contract (one compression in flight, hotchunk's single-writer loop; loud CAS latch), reads stay fully concurrent with the writer. A sync.Pool here was measured losing its lone expensive state (~15MB dst + CGo context) about 1-in-5 ledgers to GC pool-emptying and silently re-allocating it; the GC-survival regression test pins the guarantee. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C3xaaoD9LhzyKPdnZCJa2N
Two halves of the events-index memory architecture: the cold build stops holding every term in RAM, and the hot tier stops growing without bound. --- cold memory: spill-and-merge events build, freeze-by-merge The cold events index build drops its in-memory mirror for external memory: the packed-row codec is extracted as the shared run format (AppendPackedRow/AppendTermPostings, stdlib varints, with events.DecodeAscendingIDs as the ONE definition site of the delta-varint ID validation), and (term, id) pairs spill through byte-capped slabs into CRC-framed sorted run files (double-buffered Spiller; k-way merge with cross-run term union). The cold index builds by streaming the merged runs (WriteColdIndexFromRuns, CRC-folding stdlib varint reads) — byte-identical to the in-memory builder, which is retained as the test oracle. Freeze-by-merge builds cold events straight from the hot DB's CFs. Events scratch placement is owned by eventsScratchDir — one deterministic name per chunk, shared by any materializer, since scratch has no catalog key and wipe-on-entry by name is its only cleanup. Full-chunk A/B: peak RSS 24.1GB -> 5.39GB, 28% faster (12m -> 8m36s), artifacts cmp-identical. --- sorted-run tier: bounded hot events index over window + runs + overlay The hot chunk's sparse-term index with a hard-bounded live set: one atomic view publishes a WINDOW of retained per-ledger packed rows (pointerless fp64 accel), immutable CRC-framed RUN files sealed every 256 ledgers on one background goroutine (bloom + fence routing, single-level-merged at 8 live runs — the merge cap is verdict-mandated for multi-term ANDs), and the tail-delta ConcurrentBitmaps as a dense overlay (>=32-events-in-one-ledger promotes, backfilling history so the overlay self-heals after restart). The mirror and its warmup full-scan are gone; warmup is manifest-anchored with bounded tail replay. The engine is born DISARMED: sealing rights follow validation (ArmSealing after verifyChunkConsistency), so an unvalidated open writes no durable index state and a failed open retries against unmoved tripwire inputs — pinned by the poisoned-tail warmup test. Seal output and merges stream through runspill.RunWriter (no whole-payload buffers; framing shared via runspill.HeaderLen; varint reads delegate to the stdlib through the CRC-accounting adapter), and run-file decode delegates to events.DecodeAscendingIDs. Acceptance: 10k full chunk knob-free p50 49.4 / p99 64.1ms, per-decile p99 flat, steady RSS ~1.5GB (old arch: 783ms and falling behind). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
…riteback residue --- zero-decompression freeze: cold artifacts by CF scan, no re-encode FreezeColdChunk materializes a complete chunk's cold artifacts straight from its hot DB with no ledger stream and no decompression: ledgers copy as verbatim zstd frames into a PreCompressed packfile (zstd.FrameHeaderValid gates compatibility; density check pins the positional pack), txhash streams the pre-sorted hash CF into the .bin via coldBinStream (BE CF value re-encoded LE; header patched at finish, fd released on every error path), and events freeze by CF merge. The backfill dispatcher picks the freeze route whenever a ready hot DB covers the chunk (walk plumbing deleted); the freeze bench subcommand puts the route under measurement. The events arm resolves scratch via the shared eventsScratchDir name, so a crashed attempt is wiped by whichever materializer retries. Destination-dir creation is owned by each store's FreezeColdFromStore, mirroring the walk path. Byte-identity gates: every artifact kind compares identical between the walk and freeze paths. Solo refreeze 10m02 -> 7m03 (-30%), cold-protect unchanged; co-located: strictly better than the old freeze like-for-like (141.9 -> ~115-134 window p99 unpaced). --- window scale + separation residue: txindex harness, writeback cadence The txindex window-scale harness drives the production BuildColdIndex over a directory of per-chunk .bin files (names parsed by geometry.ParsePadded, capped at maxChunkID) — the terminal-window build was previously unmeasured past a single bin. Result: RSS flat over 0.6B->7.5B keys, wall linear ~24M keys/s; the co-location cells that sized the separation decision came from here. Separation residue after the two-disk architecture ruling: index.pack writers take BytesPerSync writeback smoothing (one shared default, packfile.DefaultBytesPerSync), and the deployment notes record the topology decision and its rationale in the freeze-arm comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
…s marshal, multithreaded zstd The second latency pass: the commit phase's remaining floors — ~6,000 random txhash memtable keys, ~30k events write-path allocations, and the serial ledger encode — each go. --- txhash: packed-row hot engine — window + sealed runs, point lookups The hot tx-hash tier's storage engine, standalone (nothing wired): one sorted 32B-hash row per ledger in the window, sealed every 256 ledgers into hash-sorted 36B-record run files (TXHRUN01, CRC64, fsync-dir ladder), routed by per-run blooms and a 16B-prefix page ladder — one aligned pread per hit, full-hash verify, boundary-tie two-page rule. Manifest-anchored recovery with drain-verified opens; the engine is born DISARMED (sealing rights follow validation, the events-tier rule) and enforces a dense per-ledger chain. No merge tier, no overlay, no retired handles: a point lookup needs none of them. --- txhash: one packed row per ledger — flip ingest, freeze, and reads The atomic flip onto the packed-row engine. Ingest writes ONE seq-keyed row per ledger (hashes sorted in PhaseExtract, fee-bump outer+inner per #862) instead of ~6,000 hash-keyed Puts — the measured ~10.9ms of per-ledger memtable-insert CPU those random 32B keys cost (commit p50 20.5 -> 7.9ms in the 1k solo cell). Warmup replays the dense row chain, trips loudly on the old format, and arms sealing only after validation; the post-commit hook applies txhash before events (independent, by convention). Read-only opens disable txhash queries structurally — the freeze never queries. The freeze merges the sealed runs with each tail row as its own sorted source (RAM flat at any tail size) and emits duplicates verbatim: cold .bin bytes stay identical to the walk path, gated by the composition test and freeze-at-crash-point identity tests. Freeze txhash arm: 1m25 -> 17.9s at full chunk scale. --- events: flat-pair term accumulation, bucketed sort, derivation arenas The events queue step drops its per-ledger map: the marshal loop appends (term, id) pairs into writer-owned arenas (AppendTerms writes term keys straight into a caller arena; topics walk via Count()+Raw() with All()'s reject-on-truncation semantics preserved and golden-swept; the per-event data key is one hoisted scratch), then a 256-bucket MSD scatter + big-endian word-comparator sort over KEYS ONLY (sorting 20B pairs measured 2-2.5x slower; stability = index tiebreak, property- tested as the exact permutation of a stable bytes.Compare) and one linear pass emits the exact-sized packed row. ApplyLedger takes the sorted term-runs directly; promotion and overlay semantics are pinned equivalent to the retired map path, which survives as test-only reference code for the byte-identity differential gates. Phase p50 8.0 -> 5.9ms and ~30k write-path allocations/ledger -> ~1. --- zstd: multithreaded ledger encode, config-selected (default 2 workers) After the txhash/events/extract cuts, the ~21ms single-threaded zstd encode became the binding floor of the pre-commit section (its join wait absorbed further wins). WithWorkers enables libzstd's internal multithreading — still ONE standard deterministic frame, so FrameHeaderValid and the cold-inherits-hot verbatim-copy contract are untouched, at a measured ~0.1% ratio cost. The workers count is a real configuration field, not an env experiment: measurement settled on default 2 (equal to 3 within noise — total p50 33.4 -> 29.2ms, join 7.35 -> 1.9ms — while claiming one fewer core). It is FORMAT-AFFECTING for the stored ledger frames, so ONE resolved value (storage.zstd_encode_workers in the daemon TOML, --zstd-workers on the bench hot cell; 0 = explicit single-threaded, validated >= 0) feeds BOTH encoders — hotchunk.Tuning.ZstdEncodeWorkers for hot ingest and ingest.Config.ZstdEncodeWorkers for the walk/backfill cold writer — because the freeze copies hot frames into the cold pack verbatim while the walk re-encodes the same ledgers, and a chunk's pack must stay byte-identical whichever materializer built it (the freeze-vs-walk gates arbitrate, now exercising the MT default on both sides). NewCompressor panics loudly on a non-MT libzstd rather than silently degrading the format. Route the two constant-key event terms (event type, topic count) around the pair sort: every event emits the same few keys, so their pairs collapse into one radix bucket and the comparison sort re-derives an order the arena already has. termlanes.go collects their ids in per-key ascending lanes and the run build merges them at their byte-order positions; rows are byte-identical (differential-tested). Measured on sac6000: -1.4ms/ledger in the events phase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
24c6e61 to
5beec6b
Compare
|
Rebased onto Mechanical bulk. The base folded Real reconciliations:
Verification: full 🤖 Generated with Claude Code |
…lized
index.pack stored every term's postings as a roaring bitmap. Measured on a
real pubnet chunk (8.65M events, 204,752 terms), per-term min(roaring,
delta) is 22.23MB against 37.74MB for roaring alone, a 41% saving, and 71%
of it sits below 1024 postings where roaring costs 1.7x to 3.8x a delta
list. Above that, run and bitmap containers pull ahead, reaching 12.5x on
the nine terms holding more than a million postings. So an index.pack item
is now `fingerprint ‖ codec ‖ postings`, delta-varint at or below 1024 and
roaring above.
Storing them that way alone would have made queries slower: decoding a
1024-posting delta term into a bitmap costs 20.6us against 5.7us to
unmarshal an equivalent roaring body. The saving only materializes if the
planner stops materializing, so the second half of this change is
events.Postings, which carries a term in whichever form the store already
had it, and the set operations that work on either.
- Reader.LookupKeys and HotIndex.Get return Postings instead of bitmaps.
ConcurrentBitmaps.Get does too, which retires the memo it kept for its
sparse entries: that state already held an id list, and materializing
it per publish was pure loss.
- events.Intersect drives from the smallest side whatever its form and
probes the rest, since probing beats materialize-then-FastAnd by 4.3x
at cardinality 4, 2.8x at 64 and 1.6x at 1024.
- events.Union merges ascending lists rather than building a bitmap per
input, and keeps the result a list.
- Postings.ClipRange and Postings.SelectIDs replace the range bitmap and
the bitmap drain.
query.go no longer imports roaring. Both builders (in-memory WriteColdIndex
and streaming WriteColdIndexFromRuns) share one encoder, so the
byte-identity gate between them stays meaningful.
One unbounded allocation goes away on the list path: selectEventIDs used to
do make([]uint32, cardinality) whenever MaxEvents was 0, which is 34MB per
query on a real chunk.
Two defects found in review and fixed here rather than shipped: ClipRange
and SelectIDs handed out subslices of the store's live posting list without
clamping capacity, so a downstream append would have overwritten a posting
other readers could still see, invisible to -race because it writes past a
published length; and roaring accepts a run container holding no intervals,
which reads back as a bitmap that reports itself non-empty with zero
postings, so the cold decode now validates and rejects it.
FORMAT: this changes the on-disk index.pack record layout. Split out of the
"events index follow-ons" commit so it can be evaluated, cherry-picked or
reverted independently of the bloom/fence and hardening work it shipped
alongside — cold artifacts are permanent, so this is the piece that must be
right in the first v2 release.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ening pass --- events/SRT: one-pass bloom/fence construction at seal and merge Sealing a window and merging runs used to write the output file and then RE-READ it in full (openSealedRun) to build the bloom + fences, with that re-read also buffering a whole-run fingerprint slice (fps []uint64, ~264MB at terminal merges — the merge window's largest RSS transient). Routing state is now built incrementally in the same pass that writes the file (runRouting): fences take their offsets from RunWriter.Written() so they cannot drift from the bytes on disk, fingerprints stream straight into a bloom sized up front, and the post-write re-read is gone entirely. Boundary kept: openSealedRun's open-time drain-verify of PRE-EXISTING run files during warmup is byte-for-byte unchanged in behavior — it remains the crash-recovery trust anchor. Only freshly WRITTEN output is now trusted without read-back. Measured: unpaced 10k hot run, peak RSS 5.22GB -> 4.55GB; freeze --reuse-hot wall 6m01 -> 5m47.5, events arm 271.8s -> 257.6s. --- events,txhash,runspill: hardening pass — run lifecycle, failure paths, fence caps A post-review hardening batch over the sorted-run machinery: sealed-run fence spans gain a byte cap alongside the record cap (dense terms handed hash-adjacent lookups multi-MB preads); a sealed run is disposed of when the follow-on merge fails and when a failed manifest write cannot list it; the events run format tag moves EVR1 -> EVR2. Note the run-file tag bump is a HOT-tier format change only — sealed runs live in the hot chunk DB and are rebuilt from it, so the upgrade path is to discard the hot DB, unlike the cold index.pack change now split into its own commit beneath this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…der — and a dirent-durability fix
A reviewed consolidation series over the storage layer, squashed here to one commit: every item was designed by an adversarially-verified panel, condition-reviewed at implementation, and the integrated result re-reviewed (zero correctness defects). Leads with a real fix: the events engine could crash with the manifest durably naming a run whose directory entry was never journaled.
--- events,txhash: fsync the run dirent before the manifest names it
The events seal published through runspill's tmp→rename with no
directory fsync anywhere in the tree, while reapSeal's follow-on
PutRuns rides a synced RocksDB write: a crash after the manifest
commit could leave it durably naming a run whose dirent was never
journaled — a loud, unrecoverable warmup failure on a hot chunk.
txhash states the violated invariant verbatim (the manifest may only
ever name a run whose existence survives a crash) and pays syncDir in
writeRun; events now runs the same barrier.
The barrier sits at the tails of sealWindow and mergeSealedRuns —
before the hand-back can reach reapSeal — through an injectable
HotIndex.fsyncDir defaulting to durable.FsyncDir. A barrier failure
disposes the just-opened run like every other error branch there
(sealResult: errors carry no resources). txhash's private syncDir
becomes the same durable.FsyncDir; unlike syncDir it counts a vanished
directory as success, tolerable in the seal path because the follow-on
open fails loudly when the dir — and the run with it — is gone.
The new ordering test records barrier and PutRuns events through the
seam plus a recording manifest and pins barrier-before-publish across
both publish shapes (plain seals and the merge fold); it fails against
the barrier-less engine. Neither engine yet barriers the run
directory's own dirent in its parent — this brings events to txhash
parity, no further.
--- events,txhash: extract the run-routing bloom into one package
Both hot engines carried the same ~35-line bloom filter, byte-identical
apart from one word of doc comment ("term" vs "hash" fingerprints), so
the geometry every sealed-run probe rests on had two homes and no single
place to tune. stores/bloom now holds it: a verbatim move of the type,
the 7 probes, the ~10 bits/key power-of-two sizing and the double-hashed
probe walk. Callers keep their own fp64 — the filter only ever sees a
64-bit fingerprint, so neither engine's key shape leaks into it — and
the stores doc names the RAM-only tier the new package starts.
Filters are never serialized, so bits/key and probe count carry no
format compatibility; they are, however, a live RAM anchor (run blooms
measured 241MB against the 120MB budgeted at stress-density term
cardinality), which the package doc records beside the geometry so the
next tune happens once for both engines.
The package's own tests pin what the move must preserve: no false
negatives, power-of-two sizing at 10 bits/key, and a deliberately loose
false-positive bound that a collapsed probe walk fails (this shape
measures 0.21%, a single probe 7.4%). The events fence test compared the
written and reopened runs' bloom masks; with the field out of package it
now compares the two filters whole, which is strictly stronger.
--- txhash: reduce WriteColdBin to a loop over the .bin stream writer
The .bin format had two serializers — WriteColdBin encoding a caller's
whole slice, coldBinStream encoding the freeze's merge output entry by
entry — whose byte-equality is the freeze's acceptance gate but was a
property only a test kept confirming. WriteColdBin is now a loop over
coldBinStream, so walk-path and freeze-path bytes are identical by
construction and the entry ORDER the merge produces is all
TestFreezeColdFromStore_ByteIdenticalToWalk still has to gate.
The merged path keeps both writers' guarantees. The count is patched
after the entries (no pre-count needed, and one Sync covers header and
body), and finish's Close error is now wrapped like the explicit close
check WriteColdBin documented: ENOSPC/EIO that surface only at fd close
would otherwise let a completion record name a truncated .bin. The
durability ladder and the no-tmp+rename artifact model each live in one
place now, on finish and on the writer type, and the ladder's comment
spells out why each of its four steps is ordered where it is — the
flush-before-patch step in particular, since a chunk small enough to
leave the placeholder header buffered would otherwise flush zeros back
over the count.
The per-append encode scratch moves onto the writer, because bufio can
hand the slice through to the file and that escaped the local array —
otherwise the walk path would have traded its one buffer for an
allocation per entry at ~3M entries a chunk; the freeze sheds that
allocation too. Measured over 100k entries: 7 allocations with the
scratch on the writer, 100007 with it local.
Fixtures written by the old and the merged code compare byte-identical
at 0, 1, 2, 3, 4095, 4096, 4097, 5000 and 300000 entries and for a nil
slice — the last of those past the 1MiB buffer, where the header has
already reached the file before finish patches it.
--- housekeeping: five dead-code deletions, one writer verb pair
Five sites, each verified dead by grep across the tree:
- BuildColdIndex's `opts ...streamhash.BuildOption` and the
format-options-go-last precedence block that existed to constrain
it. Every production caller (backfill/txindex.go, bench/txindex.go)
passes none; the one caller that did was
TestBuildColdIndex_CallerOptsCannotOverrideFormat, which handed in a
bogus WithMetadata solely to pin the precedence property.
Capability, test, and the doc sentence promising that caller opts
override all go together — the builder now sees the same option
sequence the empty-opts case always produced.
- txhashCold.chunkID: written by the constructor, read by nothing. The
constructor parameter goes with it (the bin path is already the
caller's Layout derivation).
- HotStore.overlay(), self-labelled "Test-only write hook", whose one
caller is query_test.go and now reaches hotIdx.overlay directly. Its
warning — a lookup against the overlay alone silently misses every
sparse term — moves onto the field, where the next reader of the
struct meets it instead of a deleted accessor.
- dedupAscendingIDs: a one-line wrapper over slices.Compact with a
single call site, which absorbs both the call and the reasoning.
- ledgersInWindow, in BOTH engines plus termsort_test.go's mirror of
the trigger.
The seal trigger becomes len(rows) >= sealEvery over the slice
ApplyRow/ApplyLedger already holds. The counter equalled len(view.rows)
at every mutation site — NewHotIndex and OpenHotIndex publish a
rows-empty view into a freshly built engine; each Apply paired one
increment with one row append, after the reap that may have trimmed;
each reapSeal paired `-= res.rows` with trimming exactly res.rows, and
skipped both together when the manifest write failed — so a cross-file
invariant maintained by hand in two packages becomes a structural one.
sealResult.rows stays; only the counter dies. No byte-identity gate
covers hot seal-run boundaries (all six guard freeze/cold/build paths),
so that equality is the safety argument of record, with both engines'
hotindex tests and TestApplyLedger_OverlayEquivalentToMapPath as the
behavioural net.
One asymmetry the counter's name hid, now stated per engine: txhash's
row chain is dense (one row per ledger, empty rows included), while an
eventless ledger writes no events index row at all — so the events
cadence has always been in event-bearing ledgers. len(rows) is exactly
what the counter counted in both.
Writer verbs: event.ColdWriter.Finish(offsets) becomes Commit. It wraps
packfile.Finish exactly as ledger.ColdWriter.Commit does, two sibling
stores exposing one operation to the same coldChunk orchestrator under
two names. The rename is compiler-gated; its blast radius is
ingest/events.go, event/cold_freeze.go, cold_writer_test.go,
cold_reader_test.go and query_test.go. txhash's coldBinStream
finish/abort become commit/close for the same reason.
runspill.Spiller.Finish stays Finish: it hands back the run paths for
MergeRuns and publishes nothing. A note on the method says so, so a
later uniformity pass leaves it alone. runspill.RunWriter also keeps
Commit/Discard here — whether Discard becomes Close, which would
override f3509d2's deliberate naming, belongs to the follow-on that
settles it in one place rather than to a deletion pass. The two-phase
pattern doc already lives on RunWriter; nothing is added to it, only
cross-references from ledger.ColdWriter, event.ColdWriter and
coldBinStream.
Adjudicated and recorded rather than implemented: exporting
events.MergeAscending to replace runspill's unionAscending general case
is rejected. It is allocation-neutral (the general case already
allocates fresh), and MergeRuns is not the cold path that would excuse
the coupling in either direction — it sits on the freeze's dominant
events arm and on the merge fold that runs concurrent with live ingest.
What is left is -17 LOC of frozen two-pointer code bought with an
exported cross-package aliasing contract, which is what 83a6ae4 ruled
against. The two stay deliberate duplicates, each carrying one
cross-reference to the other.
The campaign handoff doc gains the pass's negative results — Cursor[K],
a shared fence/ladder router, an events-seal k-way rewrite, a
cross-family run container, and the union above — so they are not
re-litigated; repoints its skip-note's nonexistent "review:
simplify/reuse/efficiency pass" citation at aa599c1; and marks
writer-side bloom/fence collection landed, which 747bcc3's one-pass
runRouting did and 83a6ae4's revert note explicitly kept.
--- events,txhash: extract the seal-publish runset into one package
Both hot engines carried the record-blind half of the seal-publish
protocol as hand-rolled twins that demonstrably version in lockstep:
c0991de fixed the identical dispose-on-failure bug in both engines in
one commit, 7a67983 fixed exit-invariance in events only, and the
manifestStore interfaces, orphan sweeps and Sscanf seq-resume loops
were word-for-word copies. stores/internal/runset now owns exactly
those pieces:
- Manifest, the moved interface (each engine's rocksdbManifest and
key stay put — the codec's "nothing to version in lockstep" ruling
stands; c0991de is evidence about the publish protocol, which
never touched the codec bytes);
- Publish, which builds the name list (live order then fresh,
basenames) and makes the dispose of a run a failed PutRuns could
not list structural — the twin branches both engines' reapSeal
carried are deleted, so that bug class cannot be fixed in one
engine and forgotten in the other again;
- SweepOrphans and NextSealSeq, the warmup pieces that need no
knowledge of what a run contains.
Everything record- or view-aware stays per-engine, per the two-engines
posture (txhash/hotindex.go): the open loops (txhash's dense-chain
check included), the view swaps, reapSeal's trim/retired handling, and
the fsyncDir barrier upstream of Publish. Folding those behind
callbacks would relocate the code, not delete it.
The twin ManifestFailureDisposesRun tests collapse into runset's unit
tests (failure disposes fresh, live runs untouched). Manifest VALUES
were covered by no gate — all six byte-identity gates guard cold
artifacts — so each engine gains a golden-bytes test pinning the
stored value (4B BE lastSealed ‖ csv basenames, live-then-fresh, and
merged-only for events' replaceAll fold) through runset.Publish and
the real rocksdbManifest; txhash's round-trip test and events' full
warmup-through-store tests stay as the read-side pins.
--- runspill: one write path at the final name — Commit publishes, Close abandons
Two deletions leave runspill with a single way to produce a run.
The temp name goes first. NewRunWriter creates the run under the name it
will keep, and Commit is the ladder every other writer in the tree
already runs: flush, patch the header's payload length and record count,
fsync, close with the close error checked (coldBinStream.commit; txhash's
writeRun). The temp name, the rename, and the two states a reader of
this package had to hold — ".tmp is being written, the bare name is a
run" — go with it. That was the tree's only os.Rename; there is now
none. What makes it safe is that nothing anywhere trusts a run by NAME,
the discipline ingest/doc.go already rules for cold artifacts and
txhash's run header states for hot ones: the hot engines trust only the
manifest; the manifest may name a run solely after its Commit and the
dirent barrier that follows; warmup deletes every file the manifest does
not list (runset.SweepOrphans) and resumes the seal sequence past every
listed run (runset.NextSealSeq), so os.Create's truncation can never
land on a live run. The cold build's runs live in a scratch dir wiped
before its first write, a torn payload fails the drain's CRC, and a
half-written file under a final name is garbage nothing reads — exactly
what its .tmp form was. The stage order was load-bearing in one
direction: this lands AFTER the dirent-barrier fix, or deleting the
rename — previously the only thing that made a run's dirent appear at
publish time — would have re-created the bug that commit fixed.
Discard becomes Close, overriding f3509d2, which named it Discard for
exactly these semantics. The census has moved since: the verb and the
abandon semantics — release a writer whose Commit did not succeed, no-op
after one that did, idempotent — are held by packfile.Writer.Close,
ledger.ColdWriter.Close, event.ColdWriter.Close and coldBinStream.close.
Three of those four also unlink the partial file, as RunWriter.Close
does; the fourth leaves it because the artifact model makes it inert
scratch. On the signature the census splits the other way — the three
exported precedents return error, the one void precedent is unexported —
so void RunWriter.Close is a deliberate departure, not parity: its only
failure mode is an unlink a caller that has already failed can do
nothing about, and the void shape lets both engines' `defer rw.Close()`
stand without discarding a value.
The bulk write path goes second (rung 0 of the reviewed deletion ladder
for runspill's two redundancies; the terms.run container stays, gated on
BOTH the freeze-wall events arm and the backfill chunk-build wall).
WriteRun existed for exactly one production caller, Spiller.rotate,
which paid for it twice: SortEncode materialized the whole encoded
payload (~32MB at production slab size) before a single byte hit the
file, and handing that buffer to the spill goroutine and back required a
documented happens-before dance on a Spiller field. Every other run
producer — the seal, the late-chunk merges, the freeze scan — already
streams records through RunWriter.Append. Now the spiller does too:
Slab.SortEncode becomes Slab.EmitSorted — the same in-place sort and
dedup/group walk, each term to a callback — and Spiller.rotate's
goroutine is NewRunWriter → EmitSorted(rw.Append) → Commit. WriteRun,
the dst threading, the encBuf field and its hand-back invariant are
deleted; the slab hand-off is the whole concurrency story. The per-term
ids buffer is slab-owned, so its reuse rides the existing hand-off.
Run bytes are unchanged on both counts: containers come out of the same
writeRaw/Commit ladder, and the gates that consume runs read committed
files by explicit path. The lifecycle tests pin the new exposures:
CloseLeavesNothing (a run IS visible under its final name mid-write —
visibility is not validity; no sidecar; abandonment leaves the dir
empty; Close after Commit is inert) and CommitFailureRemovesTheFile
(the run is written past the bufio capacity so real bytes already sit
on disk under the final name, then the fd is closed underneath the
writer — the error surfaces and nothing is left at that name, flushed
bytes included). The round-trip suite is ported to the streaming API
verbatim via a spillSlab helper; EmitSorted's contract (error aborts
the walk, ids valid only until the next call) gets its own pins.
--- txhash: sealed runs — routing built in the write pass, one streaming reader
Two halves of one change to how TXHRUN01 files are produced and
consumed; the second depends on the first.
Write pass. Sealing a window used to write the run file and then RE-READ
every byte it just produced (openSealedRun: CRC64, order, seq-range)
purely to build the bloom + page ladder — a full second sequential pass
per seal (~55MB at stress density, every 256 ledgers) on the background
goroutine, contending with live ingest. Routing state is now built
incrementally in the same pass that writes the file, through a
runRouting funnel (bloom + ladder + record count, observe per record)
fed by BOTH writeRunPayload's merge loop and drainRun's verify loop —
one construction site, so ladder cadence and bloom geometry cannot drift
between a freshly written run and its reopened form (the same
single-funnel shape as events' fenceBuilder). The merge loop also
cross-checks its emitted record count against the header's promise,
standing in for the stat cross-check the deleted re-read used to run.
This extends the events one-pass trust rule (c5e5fed, owner-accepted;
extension approved with this program) to the engine that is its declared
simplification-clone. Boundary kept: warmup's drain-verify of
PRE-EXISTING files is behaviorally unchanged — the crash-recovery trust
anchor — and freshly written runs are trusted without read-back; if the
post-write open fails, the durable file is left for warmup's orphan
sweep (a failed seal publishes nothing).
Reader. With the seal-time re-drain gone, TXHRUN01 is read in exactly
two places — warmup and freeze — and those were two hand-synced verify
loops running the identical ReadFull / CRC64 / hash-order / count
discipline over the same 36-byte records. Except the freeze had drifted:
it never checked a record's seq against the header's [first,last]
promise, and its coarser chunk-range emit check cannot catch a seq that
stays inside the chunk. TestFreezeAndWarmup_RejectSeqOutsideRunHeaderRange
pins that exact gap (it fails against the pre-fold freeze) by rewriting
one record's seq and re-patching the header CRC64. runSource moves to
run_reader.go as the one streaming reader and gains the seq-range
tripwire — the freeze becomes strictly stricter, and no valid input is
rejected: writeRunPayload sets the header range from the window's first
and last rows, so containment holds by construction. Warmup's
openSealedRun becomes a fold over the same cursor, feeding each verified
record through the write pass's runRouting funnel; the drained-and-
verified fd is handed off (handoff nils the handle, close is nil-guarded,
nothing may fail past the handoff) to serve the lookup preads — the
verified fd IS the fd that serves reads, no double-open. drainRun's
duplicated loop is deleted; readRunHeader and runRecordCount stay the
shared open gates.
Run-file bytes are untouched — the write loop's emission statements did
not move, only observation was added beside the CRC — and routing is
RAM-only derived state, bit-identical by construction; pinned by
TestHotIndex_WritePassRoutingEqualsDrainRebuild on top of the existing
gates (EquivalenceAcrossSeals, LadderBoundaryTie, warmup/corruption,
FreezeColdFromStore byte-identity). Both reads stay off the ingest path;
the freeze adds one two-compare check per record. The error substrings
the gates match ("crc mismatch", "outside", the per-record index) are
preserved.
--- events,txhash: one cached-key merge heap off the hot loop
Three of the tree's five heaps ran three different disciplines for the
same job. txhash's seal (mergeHeap) and freeze (freezeHeap) were both
INDEX heaps: the slice held source indices and every compare chased
back through the source to re-derive its current key —
heap[i]→rowidx→offs[row]→rows[row].bytes for the seal, two interface
hash() calls per compare for the freeze — and both built themselves
with an up()-per-insert the steady-state loop never uses again. events'
slot reorder buffer went the other way entirely, through
container/heap: five interface methods, an `any` box per buffered
record, and two defensive panics for a foreign type that the only
caller cannot produce.
They collapse to one discipline, the cold build's (cold_merge.go):
value entries carrying their own CACHED key, heapify once, replace-root
forever. txhash's two entry types were already structurally identical —
{hash, source index}, bytes.Compare then index — so they become ONE
concrete same-package heap (merge_heap.go, hashEntry/hashHeap). The
seal's per-row cursors move into writeRunPayload, where they are three
lines beside the loop that uses them; mergeHeap and freezeHeap's up/
down/less bodies are deleted. events' slotHeap becomes a typed value
heap (push/popMin/siftUp/siftDown), which deletes the container/heap
import — the tree's only one — the boxing alloc per buffered record on
the freeze-dominating events arm, and both unreachable panics.
Emitted bytes are unchanged everywhere, and not by luck: (hash, source
index) and (slot) are TOTAL orders — indices are unique per live
source, dense MPHF slots are unique — so a replace-root min-heap's pop
sequence is a pure function of its inputs. Heap layout, and therefore
up()-per-insert versus heapify, cannot reach the output.
The seal's duplicate contract was the one link with no direct gate:
TestHotIndex_EquivalenceAcrossSeals asserts only that a duplicate
resolves to SOME containing ledger (require.Contains is order-blind)
and the run reader only checks hashes are non-decreasing, so intra-run
duplicate ORDER was protected transitively, by RNG draws in the freeze
fixture. TestWriteRun_CrossRowDuplicateGoldenBytes now pins every byte
of a run built from one hash in three ledgers plus a tx-less ledger,
against a by-hand emission order — it passes against the pre-change
merge, and inverting the tie-break fails it (and
TestFreezeColdFromStore_ByteIdenticalToWalk with it).
Strictly less work on every touched path. The freeze's compare drops
from two interface calls to zero; the seal's drops two pointer chases
per compare side; neither had a benchmark to regress, and the seal
never blocks live ingest (ApplyRow's reap is non-blocking, the trigger
is skipped while a job is in flight). H1 (cold_merge.go, 30-36M keys/s)
and events/runspill keep their own siftDown: their keys are uint64
pairs and a TermKey, and folding them in is gated on a benchstat of the
bench `txindex` cell — a one-line note at each records that the
two-discipline state is deliberate, so it is not "tidied up" without
the measurement.
--- events: the freeze consumes sealed runs + un-sealed tail, not the whole chunk
The txhash freeze reads its engine's durable state — manifest-listed
sealed runs plus the un-sealed CF tail — while the events freeze
ignored its engine's sealed runs entirely and re-derived the WHOLE
chunk: every IndexCF packed row re-read, re-folded, and re-encoded
into 40-80 fresh scratch runs, ~2x the index bytes written and read
back, on the 272s arm that dominates the ~6min freeze wall. The sealed
prefix already exists on disk as union-merged, CRC-framed EVR2 runs,
and warmup already codifies the trust rule: sealed rows' derived runs
are the CRC-verified authority; packed rows replay from lastSealed+1.
The freeze now anchors on that same rule (freezeIndexInputs): the
manifest-listed runs feed WriteColdIndexFromRuns in place, strictly
read-only, and freezeIndexRuns windows ONLY the tail — IterateRange
from lastSealed+1, warmup's exact seek — into scratch runs. Both
engines' freezes are now the same sentence: merge the engine's durable
runs with its un-sealed tail. Deleted in the normal case: the
whole-chunk IndexCF read, the ~GBs of scratch re-encode/re-read, and
the "40-80 windowed runs per chunk" sizing concern (windows are
tail-only; the 32MB cap now matters only for a crash-inherited
backlog).
Byte identity is structural, not incidental: per-term event IDs are
strictly ascending and globally duplicate-free (an event lives in
exactly one ledger row), so MergeRuns' per-term union is invariant
across ANY partition of the rows into runs, and [sealed runs + tail]
hold exactly the fold of all IndexCF rows. Crash states move the
partition, never the content: an empty manifest degenerates to the
full-tail scan (this same path with zero manifest runs — no fallback
concept), and orphan runs — post-seal-pre-manifest, or a
Close-discarded merge whose inputs the manifest still lists — are
unlisted, ignored, and re-covered by the tail. Only reapSeal writes
the manifest and the production freeze handle composes no events
facade, so no sealer can race the freeze.
No txhash merge_heap instantiation: the events freeze reuses
runspill.MergeRuns through WriteColdIndexFromRuns unchanged. The two
merges want different contracts — txhash emits VERBATIM duplicates in
ledger order (hashHeap's source-index tie-break), events dedups terms
and unions IDs — so feeding tail rows through the cached-key heap
would have meant rebuilding union semantics beside the merge that
already owns them.
New permanent gates, the events mirror of txhash/cold_freeze_test.go:
TestFreezeColdFromStore_ByteIdenticalToWalk (sealEvery=8 over 48
ledgers → 5 seals, a merge fold, and a non-empty tail, all asserted,
against an independent walk oracle: payloads → TermsForBytes → Bitmaps
→ WriteColdIndex); _IdenticalAcrossCrashStates (mid-window/empty
manifest, post-manifest, and a POISONED post-seal-pre-manifest orphan
— union semantics would hide a faithful duplicate, so the orphan
carries a marker term no correct freeze may emit);
_IgnoresUnreapedMerge (post-merge-pre-reap: the manifest still lists
the merge's three inputs, seal and merge orphans both on disk);
_RejectsCorruptRunCRC (flipped CRC trailer → runspill.ErrCorruptRun);
_EmptyChunk; _IdempotentReadOnlyInputs (freeze twice → identical
bytes, hotindex-runs/ untouched name-for-name and byte-for-byte — no
cleanup path can reach manifest-listed files). Gate teeth proven by
perturbation before landing: dropping one manifest run from the inputs
fails ByteIdenticalToWalk and IdenticalAcrossCrashStates; skipping the
first tail row fails ByteIdenticalToWalk.
The composition gate's fixture (ingest TestFreezeColdChunk_
ByteIdenticalToWalk) densifies from eventEvery=100 — 100 window rows,
below the 256-row seal cadence, so it NEVER crossed the sealed
frontier and the manifest-runs input shape had zero permanent gates
(the design sketch misattributed an events
TestFreezeColdFromStore_ByteIdenticalToWalk; until this commit that
name lived only in txhash and ledger) — to eventEvery=10: 1,000 rows,
several production-cadence seals, plus a manifest-non-empty tripwire
(event.ManifestRuns over a bare re-open of the frozen DB) so the gate
cannot silently regress to tail-only coverage.
Freeze-side work strictly drops — one sequential pass over ≤8 manifest
runs plus 1-3 tail runs replaces the full CF iterate, the windowed
re-encode, and the scratch re-read — and the protected paths (hot
ingest, both seal paths, H1/M1) are untouched. The named cells — the
freeze-wall bench (272s events arm) and hot ingest_total p50/p99 —
run at landing.
--- events,txhash: review pass — one seal epilogue, one warmup idiom, a shared barrier-ordering pin
The integrated review of this series (four dimensions, each finding
adversarially verified) confirmed no correctness defect and a batch of
quality items; this commit is that batch. Nothing changes an artifact
byte; three previously uncovered branches gain gates.
Seal epilogue (events). sealWindow and mergeSealedRuns hand-duplicated
the post-Commit epilogue, so the "errors carry no resources" invariant
was maintained at two sites. Both now call runRouting.openDurable, which
puts the fsyncDir dirent barrier BEFORE the run is opened. A new
TestHotIndex_BarrierFailureDisposesRun drives the h.fsyncDir seam for
the plain-seal and merge-fold shapes; neither branch had any coverage
(dropping the unlink from openDurable fails both subtests).
Barrier seam and pin (txhash). runset's package doc lists "run file and
dirent durable BEFORE the manifest names it" as invariant #1 of the
protocol it owns, but the barrier itself is deliberately per-engine, and
only events carried a seam and an ordering gate; txhash's barrier was a
hard-coded durable.FsyncDir inside writeRun with no test pinning it
ahead of PutRuns. txhash now mirrors the events seam: the barrier moves
to the tail of startSeal's goroutine through a new HotIndex.fsyncDir
field defaulting to durable.FsyncDir, with the same dispose-on-failure
branch; writeRun's signature and callers stay untouched. The barrier
stays on the background seal goroutine — deliberately NOT folded into
runset.Publish, which runs on the live-ingest writer goroutine: that
would put a synchronous fsync onto the exact path under the p99
campaign. The ordering gate itself is now shared: a runsettest package
(PublishLog, RecordingManifest, AssertBarrierPrecedesEveryPut) hosts the
scaffolding both engines' pins drive, the events test shrinks onto it
with no assertion lost, and txhash gains the same pin (neutralizing the
barrier fails it with "PutRuns #1 ran without a dirent barrier"). One
deliberate asymmetry, stated where it lives: events barriers before its
open (openDurable owns both steps), txhash after its fused write+open
(sealWindow); the shared pin gates the invariant that matters — barrier
before PutRuns — in both engines, and txhash's vanished-dir tolerance is
argued from chunk teardown plus next-warmup loudness rather than a
follow-on open.
Warmup (txhash). openManifestRuns folds into OpenHotIndex with the
exit-invariant `opened` defer events adopted in 7a67983, appending each
run before the dense-chain check so the defer covers the just-opened
handle. That deletes four hand-placed closeRuns calls, the throwaway
`closeRuns(append(runs, r))` slice, and a read of .last off an
already-closed run. Close collapses to closeRuns(view.runs), so the
engine has one cleanup idiom.
Freeze. events' FreezeColdFromStore drops its `closed` flag for the
unconditional deferred Close — packfile.Writer already owns that state
and Close after Commit is a documented nil no-op. txhash's
collectTailSources polls ctx per row: it was passing len(sources), the
appended-source count, to pollCtx, so the cadence fired on every leading
tx-less ledger and never between multiples.
Test honesty. The TXHRUN01 golden test hardcodes the 40-byte header as
literal bytes (a magic bump or relayout now fails it, and a bad-magic
gate rides along) instead of deriving them from the writer's own
constants. The freeze fixture asserts the merge fold it claims
(manifest of 2 with a "merge-" name) and gains an overlay-promotion
tripwire for the docstring's dense-ledger claim. Over-broad error
substrings tighten to the exact messages ("entry seq ", "record 0 seq ").
The run-name grammar shared by three engine Sprintf sites and runset's
parser gets cross-reference comments and a round-trip pin
(TestNextSealSeq_ParsesTheEnginesOwnNames). The events fakeManifest
keeps putErr with a live setter again (ManifestFailureLeavesObsoleteRuns
— asserting what runset's own tests cannot see: obsolete handles stay
open, files stay on disk, nothing is retired); txhash's copy, which no
test sets, loses the field. A dangling doc reference to the deleted
WriteRun is reworded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
45f9c5b to
bfb1634
Compare
The handoff document for whoever continues this work — state, settled architecture, mechanism findings, next steps, bench quick reference — with its measurement-driven amendments folded (RAM re-anchor, hardware- class ruling) plus the consolidation series' rulings ledger and the design-doc catch-ups (gettransaction hot-tier description, packfile). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
Pins the ingest easy-wins branch (stellar/go-stellar-sdk#5972) and adapts the one call site the branch's API change touches: TxEvents now hands back element views rather than [][]byte, so the stage filter reads tev.Stage() directly instead of re-wrapping each raw, and the per-op payload writes []byte(evView) — a free retype, same backing array. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HotService.Ingest so the caller can also feed the fee windows. The campaign forks the ledger-bytes zstd encode inside IngestLedger and joins it as the batch's LAST queue step, on the premise that it overlaps every other step — including the walk, which is by far the largest thing it can hide behind. Composing the two silently forfeited that overlap: after the hoist the walk completes before IngestLedger is entered, so StartCompress forked only in time to overlap the product reads and the queue steps. Measured on the 1k paced sac-6000 cell, the join went 1.90 -> 4.38ms p50 with p90 blowing out to 11.73ms, costing 3.5ms of ingest_total. DB.StartCompress now exposes the fork so HotService.Ingest starts it before its walk and hands the handle to IngestLedger, which still owns the Discard; a caller that fails before IngestLedger discards it itself. Restores ledgers to 1.08ms p50 / 1.97ms p90 — better than the pre-#917 baseline, since the encode now overlaps the whole walk. Both branches are correct alone; only the composition regressed, with every test passing and byte-identity intact. The symptom was a phase timing nothing asserts on.
…from the artifact Pass B's reorder heap copied every buffered body, which is wasteful in exactly the case that matters: nearly every record strays (~99.7% — within-block slot order is effectively random), and on datasets shaped like soroswap a single block's bodies are multi-MiB roaring giants whose copies balloon the heap. Split the representation by size: bodies at or under inlineBodyMax ride the heap inline (the bytes are already in hand from the sequential scan), larger ones are buffered as (offset, length) references into terms.run and re-read positionally at emit. The threshold derives from the delta codec's ceiling (1 codec byte + a count varint + deltaPostingMaxCardinality posting varints = 5,126) so every delta-coded body inlines by construction and the boundary moves with any codec retune. Measured on sac-6000 (c6id.8xlarge, interleaved adjacent freeze cells): events arm −3.1% vs the copy-everything baseline, lowest peak RSS of the variants tried; on soroswap-1500 the reference path prices at zero (giant bodies dominate either way). An all-reference design was measured first and rejected: one pread per strayed record cost +28.8% on the events arm — the inline copy for the small-body common case is what recovers it. The backstop constant is gone: the bound now derives from the opened index itself via streamhash's new Index.MaxBlockKeys() (stellar/streamhash#13, pinned to its branch until merge). A legal heap never holds more than MaxBlockKeys−1 records — a block's minimum-slot key always takes the fast path — so the guard fires on the first provably-impossible push, and a future format revision cannot silently invalidate a transcribed ceiling. Hardening from two adversarial review passes (zero confirmed wrong-output bugs): ONE fd is shared between the sequential CRC-verified scan and the emit-time positional re-reads (same inode by construction); pw.Finish moves beside packfile.Create in BOTH builders so no emitter can ship an unfinalized pack; crcFoldReader folds through a reusable scratch byte and a hoisted reader (~3M allocations per build removed); the byte-identity gate now straddles inlineBodyMax with premise-checked roaring bodies on both sides; the offsets corpus covers two-byte length varints; and the multi-MiB corpus premise-checks deep reordering. Co-authored-by: Marwen Abid <marwen.abid@stellar.org> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
…doc reattachment The rebase surfaced golangci findings CI scopes to changed lines: gci/gofumpt import ordering in the moved codec files, the DecodeAscendingIDs delegate as a package global, orphaned uvarintLen, an unnecessary conversion, the long-dead eventsFreeze family (unused since the freeze-by-merge rework), a doc block fused across two symbols, and streamUnion one branch over the cyclop limit (batch collection extracted). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou
bfb1634 to
e73a8f9
Compare
Background (if you haven't followed the full-history work)
The v2 daemon (
internal/rpcv2) stores the chain's complete history locally, split into fixed ranges of ledgers called chunks:Ingestion has a hard real-time constraint: the network closes a ledger roughly every ~600 ms, and the same machine concurrently runs freezes and index builds, so per-ledger ingest latency needs a lot of headroom under worst-case load. Throughout this PR, "stress load" means a synthetic worst-case dataset with ~6,000 transactions per ledger at a 600 ms close cadence, on one 8-vCPU box.
Why this PR exists
Measured on that stress load, three things were broken or unsustainable:
What changed (12 theme-level commits; the numbered items below map onto them)
The branch is eleven commits, one per campaign theme: (1) bench+diagnostics, (2) hot-latency wave 1, (3) events-index memory [items 3-4], (4) zero-decompression freeze + window residue [items 5-6], (5) hot-latency wave 2 [items 7-10], (6) delta postings + un-materialized query — the cold
index.packformat change, (7) events-index follow-ons (one-pass bloom/fence + hardening), (8) the storage-layer consolidation series, (9) docs [item 14 + rulings], (10) thego-stellar-sdkpin, (11) forking the ledger encode before the caller's walk, (12) the events reorder-heap improvement — inline small bodies, artifact-derived backstop [item 15]. Each squashed commit's message carries its constituents' full narratives as titled sections, so the per-item reasoning below survives in the history itself.Commit (6) is deliberately isolated so it can be cherry-picked. Cold artifacts are permanent — there is no cheap migration for a chunk already frozen — so the
index.packrecord-layout change is the one piece that must be right in the first v2 release. It was split out of what had been a three-theme "events index follow-ons" commit; commit (7) holds the remainder (one-pass bloom/fence construction, the hardening pass, the EVR1→EVR2 run-file tag), all of which is hot-tier only and therefore has an upgrade path: discard the hot DB and let it rebuild. Commit (7) contains no query-path code — its functions are all write/seal/merge — so (6) stands alone with the full read path intact. The split was verified tree-identical to the pre-split history at both boundaries.Commits (10) and (11) replace what was previously a single "views-walk port" commit. (10) exists only after the rebase: #917 hoisted
ExtractLedgerTxPartsinto the caller, and this branch's zstd fork/join started insideIngestLedger— each correct alone, but composed, the encode no longer overlapped the walk it was supposed to hide behind. Every test stayed green and byte-identity held; only the phase trace showed it.Bench + diagnostics — the harness every number in this PR comes from: a per-ledger CSV trace decomposing ingest into phases, and an opt-in RocksDB statistics dump.
Hot ingest latency — four fixes: reuse zstd encode buffers (they were the single biggest allocator); write the events index as one packed row per ledger instead of one RocksDB key per term-event (removes tens of thousands of memtable keys per ledger); run ledger compression concurrently with the other per-ledger work instead of serially; and stop cloning roaring bitmaps on every dense-term update (new IDs accumulate in a small immutable "tail" that readers merge on demand; a real data race in the reader/writer protocol was found and fixed here, with a regression test that fails without the fix).
Cold index build without the RAM — instead of an in-memory map of all terms, (term, event-ID) pairs are written to sorted, checksummed spill files and merged in a streaming pass. Memory becomes O(buffer) instead of O(unique terms). The output is verified byte-identical to the old in-memory builder, which is kept solely as the test oracle.
A bounded in-memory index for the hot tier (the largest commit). The hot chunk's in-memory events index used to grow without bound as the chunk filled. It's replaced by: a window of the last 256 ledgers' packed rows, older data sealed into checksummed run files on disk (with bloom filters to keep lookups cheap, and periodic merging so no query ever touches more than 8 files), and a small in-memory overlay only for very hot terms. Crash recovery replays from RocksDB, anchored on a manifest. One invariant worth knowing: a not-yet-validated startup writes nothing durable — sealing is only enabled after the open-time consistency check passes, so a failed open can always be retried against unchanged state.
Zero-decompression freeze — the freeze now copies already-compressed data as-is: ledgers as verbatim zstd frames, tx-hashes by streaming the (already sorted) RocksDB column family, events by merging on-disk state. No ledger stream input at all (the function signature makes decompression impossible, not just avoided). Byte-identity with the walk path is enforced by tests for all three artifact types.
Scale checks + residue — a harness proving the tx-hash index build holds flat memory at terminal-window scale (7.5 B keys), plus write-smoothing on index output (
BytesPerSync) so artifact writes don't collide with the live WAL's fsync when co-located.7.–10. Hot ingest latency, second wave (four commits). After the first wave, the commit phase was still dominated by the tx-hash index: ~6,000 random 32-byte RocksDB keys per ledger, measured at ~11ms of memtable-insert CPU alone. These commits:
zstd_encode_workersconfig field (TOML[storage], default 2) enables libzstd's internal multithreading — still one standard, deterministic, frame-compatible frame (join-wait 7.4→1.9ms; total p50 33.4→29.2). Reviewer note: the setting is format-affecting (an MT frame's bytes differ from single-threaded), so ONE resolved value threads to BOTH the hot encoder and the walk/backfill cold writer — the commit fixes a latent hazard where the walk side hardcoded single-threaded encoding, which would have broken freeze-vs-walk byte identity the moment the default became 2; the identity gate now exercises MT frames on both sides.events: per-transaction payload shaping — the whole-ledger payload materialization becomes a per-transaction shaper (one pass, cursor order preserved exactly; the two-pass shape it replaces is pinned as the test oracle). Still on the current SDK.
ingest: raw-bytes threading — the hot and cold ingest loops thread the raw ledger bytes down instead of re-deriving them from views, and one extraction pass feeds both the tx-hash rows and the event payloads. Still on the current SDK.
SDK pin + the encode fork (two commits) — the views/Walk/streaming SDK redesign this item used to describe was prototyped, measured, and then abandoned: it bought ~4 ms of extract but required either a generated API surface growing quadratically in type count or runtime ordering knowledge from every caller, and it carried a hand-mirrored locate layer duplicating generated code. What ships instead is a pin to
tamirms/extract-easy-wins(ingest: size each element once on the extract path, and return element views (+ stress-density benchmark) go-stellar-sdk#5972) — fouringest/-only commits. Three are local optimizations; the fourth is a deliberate, narrow API change: the events products now hand back trimmed element views ([]xdr.TransactionEventView,[][]xdr.ContractEventView) instead of[][]byte, because element views ARE their wire bytes, so[]byte(elem)is free while a consumer reading a field is spared re-wrapping. Our one affected call site (the getEvents stage filter) gets shorter for it.The second commit forks the ledger zstd encode before the caller's
ExtractLedgerTxPartswalk. It exists only post-rebase: v2 ingestion: one walk per ledger — feed and rebuild the getFeeStats windows (#881, #888) #917 hoisted that walk into the caller while this branch's fork/join started insideIngestLedger, so composed, the encode no longer overlapped the walk it was designed to hide behind. Worth 3.5 ms, and invisible to the test suite — every test stayed green and byte-identity held; only the phase trace showed it.Measured cost of the trade, 1,000 paced ledgers at stress density, both arms run back-to-back on the same box:
extractp50 9.56 → 13.38 ms (+3.82),ingest_totalp50 26.80 → 29.74 ms (+11.0%), p99 33.07 → 37.61 ms, peak RSS 1.42 → 1.52 GB. The entire delta is theextractphase —ledgers,txhash,events, andapplyare flat, andcommitimproved 0.32 ms. That is the price of droppingmetaFacts/the offsets arena, paid deliberately for an SDK branch that carries no maintenance debt. Headroom against the ≤100 ms p99 target remains ~2.7×.Handoff doc — a design/state document and probe scripts for whoever continues this work. No code. Includes the newest finding: the residual mid-tail latency variance was traced to SMT-sibling scheduling collisions (the ingest thread and zstd workers landing on hyperthread twins) — eliminated entirely by one-thread-per-physical-core placement (total p90 37.8→29.2ms, p99 39.6→33.7ms, zero code); deployment guidance and the owed co-located validation are documented there.
Events reorder heap: inline small bodies + artifact-derived backstop (commit 12; co-authored with @marwen-abid, absorbing the review outcome of events: bound cold index reordering independently of posting size #931). Pass B of the streaming index build must emit records in MPHF slot order while its input arrives in key order; the two disagree only within one MPHF block, so strays wait in a bounded reorder heap. This commit splits the buffered representation by size: bodies at or under
inlineBodyMaxride the heap inline (the bytes are already in hand from the sequential scan), larger ones are buffered as (offset, length) references into the scratch file and re-read positionally at emit. The threshold is derived from the delta codec's ceiling so every delta-coded body inlines by construction and moves with any codec retune; only multi-MiB roaring bodies take the reference path. An all-reference design was measured first and rejected: ~99.7% of records stray (within-block slot order is effectively random), and one pread per strayed record cost +28.8% on the freeze's events arm at stress density. Measured end state (interleaved adjacent freeze cells, same box): events arm −3.1% vs the copy-everything baseline, lowest peak RSS of the variants tried; on a giant-body dataset (soroswap-shaped, ~few terms × multi-MiB postings) the reference path prices at zero. The heap's corruption backstop also stops being a transcribed constant: it derives from the openedindex.hashvia streamhash's newIndex.MaxBlockKeys()(index: export MaxBlockKeys, the format-level per-block key ceiling streamhash#13), and the bound is exact — a block's minimum-slot key always takes the fast path, so a legal heap never holds more thanMaxBlockKeys−1records and the guard fires on the first provably-impossible push. Hardening from two adversarial review passes rides along: one shared fd for the CRC-verified scan and the emit-time re-reads (same inode by construction),FinishbesideCreatein both builders, a zero-alloc CRC fold (~3M allocations per build removed), and the byte-identity gate now straddlesinlineBodyMaxwith premise-checked bodies on both sides.Consolidation series (commit 8, squashed from an 11-commit working series; every item designed by an adversarially-verified review panel and condition-reviewed at implementation). In landing order: a dirent-durability fix — the events engine could crash with the manifest durably naming a run whose directory entry was never journaled (txhash documented and paid this barrier; events silently violated it) — with the ordering pinned by test in both engines; the run-routing bloom extracted to one package (was a verbatim ~35-line duplicate); WriteColdBin becomes a loop over the freeze's stream writer (freeze-vs-walk .bin parity now structural, not test-maintained); housekeeping (five dead-code deletions, commit/close verb alignment); the seal-publish protocol extracted once into
stores/internal/runset(publish, dispose-on-failed-publish, orphan sweep, seq resume — the twin dispose branches c0991de patched twice are one implementation, manifest value bytes now golden-pinned); runspill gets one write path at the final name (the tree's lastos.Renameand the.tmptwo-state mental model deleted;WriteRunand the cross-goroutine buffer hand-back deleted); txhash sealed runs build routing in the write pass (no more re-reading ~55MB per seal beside live ingest) and gain one streaming reader — the fold surfaced that the freeze path silently lacked the warmup's seq-range check (gate added, proven to fail pre-fix); the seal and freeze merge heaps consolidate to one cached-key heap (the two hot-loop heaps stay separate, bench-gated, by measurement); the events freeze consumes manifest-listed sealed runs + the un-sealed tail instead of re-deriving the whole chunk (the txhash freeze shape; crash-state identity pinned by a new byte-identity gate mirroring txhash's, including poisoned-orphan and mid-merge states); a rulings ledger in the handoff; and a review-pass commit from the integrated four-dimension review (zero correctness defects found; the confirmed quality items, each adversarially verified).Results (same stress load and machine before/after)
Two corrections to earlier revisions of this section. The
STELLAR_RPC_ZSTD_WORKERS=2env knob is gone — two encode workers is now simply the default (ledger.DefaultZstdEncodeWorkers = 2), so its gain is already in the numbers above rather than being opt-in. And the "pending go-stellar-sdk change (fused single-pass xdr-view traversal)" was abandoned, not shipped — see item 13 for what replaced it and what that cost.Current-base measurement (
feature/full-history@ 9c195b5, which indexes two additional terms per event since #904), 1,000 paced ledgers at stress density, two runs: p50 32.92 / 33.06 ms, p99 40.51 / 40.64 ms, peak RSS 1.52 GB. The table above predates the rebases and the SDK swap; where they disagree, this line is the current number.How to review
Each of the twelve commits builds and passes its tests standalone. Reviewing commit-by-commit remains the intended path — the squashed commits' messages carry one titled section per original concern, and the focus notes below name the concern they apply to:
TestTailDelta_WriterReaderRace_MultiContainerpins the race this prevents.events.DecodeAscendingIDsis deliberately the only implementation of that validation), and the scratch-directory contract: scratch has no record anywhere, so both builders derive the same deterministic scratch path per chunk and wipe it on entry — that convention is the only thing preventing multi-GB leftovers after a crash.TestWarmup_FailedOpenLeavesDurableStateUntouchedfails without it). The ≤8-file merge cap is measurement-backed (multi-term queries against unmerged files blew the query budget) — please don't simplify it away.zstd.FrameHeaderValid), the density check that keeps pack positions equal to ledger sequence, big-endian→little-endian re-encode of the tx-hash values, and crash/retry semantics (all artifact writes follow the existing mark → write → fsync → flip protocol).bytes.Comparesort, and the retired map-based path remains in tests as the byte-identity oracle.zstd.WithWorkerspanics loudly if libzstd lacks multithreading support rather than silently running single-threaded; the frame stays standard andFrameHeaderValid-compatible, so the cold-inherits-hot verbatim-copy contract of commit 5 is untouched.inlineBodyMaxin one build); theMaxBlockKeys−1bound rests on the proof that a block's minimum-slot key always fast-paths (heap provably drains at every block boundary); and the count backstop deliberately derives from the format ceiling, not the artifact's own block data — the backstop must not trust the content it is guarding against.Results across hardware classes
The campaign box was an m6id.2xlarge (8 vCPU / 4 physical cores, 474GB instance NVMe). The full suite was independently reproduced on a c6id.8xlarge (32 vCPU / 16 cores, 1.9TB instance NVMe) — same dataset, same branch, default knobs. The c6id hot, freeze, and cold cells were re-validated at the current tip (post delta-postings); the co-location cells date from two tips earlier (their interference channels are device/bandwidth-level, and the paths they stress are unchanged). The c6id column reflects the branch tip including the consolidation series; the m6id column predates it:
Full-chunk hot RSS: steady ~1.4–2GB (the delta-postings tip retired a per-publish materialization; c6id steady-state 1.54→1.40GB); merge-generation peaks ~4.6GB (campaign box) / 4.8–4.9GB (c6id, paced) since the one-pass bloom/fence construction (the 5.1–5.2GB figures on both boxes predate that change) — see the handoff doc's RAM anchor.
Current-base re-measurement (2026-08-25, c6id.8xlarge)
Every regime measurable on this box class, re-run on the shipping branch at its
current base (
feature/full-history@ 9c195b5, which indexes two additionalper-event terms since #904; the constant-key lanes in the wave-2 commit absorb
most of their cost). Same dataset, same knobs. These supersede the two earlier
tables where they disagree:
Where p50/p99 sit above the prior rows, the delta decomposes into the two
extra #904 terms' irreducible post-lanes cost (~0.6 ms) and base/era drift
measured with zero code difference — the pre-rebase head itself re-measures
2–4 ms above its own recorded numbers in every regime on this box today. The
load-bearing claims all hold or improve: zero ledgers over 100 ms in every
regime including the stacked worst case (max 59.5, ~1.75x headroom), full
recovery the moment co-runners exit, flat index-build memory, and lower
full-chunk RSS. One regime-specific note: under co-location the
extractphase is the exposed surface — a longer extract (the SDK trade's ~+4 ms) rides
ahead of co-runner stalls, which is where that trade's cost shows beyond its
solo price.
Post-rebase re-measurement (2026-08-05, c6id.8xlarge)
The table above was measured before the rebase onto merged #917 and before the
SDK pin moved off the abandoned views-walk redesign. Re-run on the shipping
branch, same box, same dataset, same knobs — these supersede the c6id column
where they disagree:
extractphaseOn the +0.2GB RSS. This is the same SDK trade as the
extractregression,measured in memory rather than latency. The controlled 1k A/B isolates it with
one variable changed: steelman SDK 1.422GB vs easy-wins 1.522GB, +0.100GB. At
full-chunk depth the same effect reads +0.2GB (4.8-4.9 -> 5.02/5.09), roughly
double at 10x the ledgers, consistent with an allocation-rate difference
compounding across more merge generations. It is not a regression in this
branch's own memory work — the one-pass bloom/fence construction still delivers
its 5.22 -> 4.55GB win; the baseline it operates on moved up when the SDK pin
changed.
The stacked worst case is the load-bearing number for the deployment note
below, and it reproduces to 0.02ms. Solo p99 rose ~2ms while stacked p99 did
not: under the stacked load the bottleneck is co-runner contention, not
extract, so the SDK's extra ~3.8ms is absorbed rather than added.
On the one >100ms ledger. A second, instrumented full-chunk run
(
GODEBUG=gctrace=1plus 1Hz sampling of/proc/statsteal and/proc/vmstatcompaction counters) produced zero breaches in 10,000ledgers. Across both runs the figure is 1 in 20,000.
The two runs'
extractdistributions are near-identical — p50 12.81/13.40,p99 19.13/19.09, p99.9 24.72/24.93, p99.99 26.00/27.15 — and the clean run's
max is 27.58ms, i.e. simply the top of its own distribution. The outlier sat
5x above p99.99 with nothing between, which is the shape of a one-off
external event rather than a code path.
Two candidate mechanisms are excluded by the instrumentation: GC pauses
(max stop-the-world across 257 collections: 0.413ms) and hypervisor
steal (0.60s over 6,000s wall, 0.01%). The mechanism is therefore not
identified, only bounded — a residual candidate visible in the data is THP
direct compaction (848 stalls during the clean run, none of which produced a
breach). A systematic allocation/GC cost is present and measurable, but it
is the 20-26ms tail — several of its members adjacent ledger pairs, i.e. one
GC cycle spanning two ledgers — and it tops out well under 30ms. A
seal-boundary correlation was hypothesised and refuted: mean
distance-to-seal for the top-100 spikes is 62.4 vs 63.9 for all ledgers
(uniform ~64), and ledgers within 8 of a seal are indistinguishable (p99
35.18 vs 35.22).
The stated gate (p99 <= 100ms) holds with ~3x margin in both runs.
Cold artifact format: what changes on disk
index.pack.packevents.pack.bin.idxindex.packgained a codec byte per record. Each record is one MPHF slot:0x00is roaring (RunOptimize'd, as before);0x01is a delta-varint list —uvarint count, first event ID absolute, the rest strictly-positive deltas.
Terms at or below
deltaPostingMaxCardinality(1024) use the delta codec.That is the −48%
index.packreduction in the table above (1.498GB → 0.776GBat stress density). The threshold is a proxy: cardinality correlates with
run structure on pubnet event postings (fat terms are the clustered ones), so
it is a property of the data, not of the format — re-measure before retuning.
The reader dispatches on the codec byte and rejects unknown values
(
unknown codec 0x%02x), so it is forward-compatible. Backward: a readerbuilt before this PR, handed a new
index.pack, parses the codec byte as thehead of a roaring bitmap; roaring validates its serial cookie, so this fails
cleanly rather than returning wrong postings — but it surfaces as
invalid bitmap at slot N, i.e. shaped like corruption rather than like aversion skew. The packfile container version is deliberately left at 1: the
change is to the item body, not the trailer or index that the version's
"bump on any breaking change" note governs. Flagging it explicitly so the
choice is reviewed rather than inferred.
Not isolated in a single commit: it lives inside commit (6) alongside the
one-pass bloom/fence work and a hardening pass. The format change proper is
5 non-test files, +317/−92 (10 files, +657/−154 with tests) of that commit's
39 files, +2,286/−701.
Ledger frames differ but the format does not.
DefaultZstdEncodeWorkersis now 2 (libzstd internal multithreading), which changes the frame byte
stream — single-threaded and multithreaded encodes of identical input split
jobs differently. The output remains ONE standard zstd frame with the header
shape
FrameHeaderValidgates, so every decoder reads it unchanged and noreader needs to know. The reason it is called out at all is the
byte-identity contract: the freeze copies hot frames into the cold pack
verbatim while walk/backfill re-encodes independently, so both materializers
must use the same worker count. They cannot diverge —
daemon.goresolveszstd_encode_workersonce and feeds both the hot tuning and the walkmaterializer from that single value. The only residue is that a chunk frozen
by a pre-campaign binary is not byte-identical to one frozen by this build;
that matters solely to a consumer comparing artifacts across builds.
Deployment note
The recommended production class is c6id.8xlarge-equivalent (or better), which resolves the topology question: on that class, single-disk co-location of live ingest with freezes and full index rebuilds — including all three stacked simultaneously (p99 53.3ms, zero violations) — meets the ≤100ms p99 target with wide headroom and no pacing anywhere — the device write queue never saturates (freeze +≈3ms) and the memory-bandwidth knee is never approached (rebuild +≈11ms). The interference mechanisms and their fingerprints are documented in the handoff doc for anyone deploying on smaller classes, where the mitigations remain valid: hot/cold disk separation (universal guarantee for the write channel) and the BuildColdIndex consumption pacer (small-box parity for the bandwidth channel; deliberately not implemented in this PR since the recommended class doesn't need it). The freeze runs unthrottled by design; the write-smoothing residue that remains is beneficial on any topology.
Testing
-raceacross all touched packages, green at every commit individually, not just the tip.Deliberately not in this PR
🤖 Generated with Claude Code
https://claude.ai/code/session_01C3xaaoD9LhzyKPdnZCJa2N