Repository navigation
rpcv2: version the on-disk format before the first release freezes it - #967
Conversation
There was a problem hiding this comment.
Pull request overview
Establishes release-one storage-format safety through startup validation, self-describing artifacts, integrity metadata, and documented upgrade policy.
Changes:
- Adds catalog census validation and RocksDB format safeguards.
- Versions cold-storage metadata and enables packfile content hashes.
- Adds format goldens, compatibility tests, and upgrade documentation.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
design-docs/gettransaction-full-history-design.md |
Documents versioned txhash formats. |
design-docs/full-history-streaming-workflow.md |
Defines format upgrade policy. |
stores/txhash/cold_merge.go |
Validates merged .bin inputs. |
stores/txhash/cold_index.go |
Parses the new .bin header. |
stores/txhash/cold_index_test.go |
Updates malformed-header fixtures. |
stores/txhash/cold_format.go |
Versions index metadata. |
stores/txhash/cold_format_test.go |
Tests metadata version handling. |
stores/txhash/cold_bin.go |
Adds .bin magic and version. |
stores/txhash/cold_bin_test.go |
Pins the new binary layout. |
stores/ledger/cold_writer.go |
Adds app-data version and hash. |
stores/ledger/cold_writer_test.go |
Tests ledger hash generation. |
stores/ledger/cold_reader.go |
Validates app-data versions. |
stores/ledger/cold_reader_test.go |
Tests version rejection. |
stores/event/index.go |
Defines term schema identity. |
stores/event/format_golden_test.go |
Pins term-key bytes. |
stores/event/cold_writer.go |
Enables event content hashing. |
stores/event/cold_reader.go |
Validates index build stamps. |
stores/event/cold_index.go |
Writes stamps and hashes. |
stores/event/cold_index_test.go |
Tests stamps and hashes. |
stores/event/cold_format.go |
Adds shared version gates and stamp codec. |
stores/event/cold_format_test.go |
Updates decoder tests. |
stores/blobversion.go |
Adds shared blob-version validation. |
rpcv2test/rpcv2test.go |
Reads the new .bin layout. |
rocksdb/rocksdb.go |
Pins table format and translates errors. |
rocksdb/rocksdb_test.go |
Tests zero-tuning format pinning. |
geometry/keys.go |
Adds lifecycle-state registries. |
catalog/secret.go |
Centralizes secret width. |
catalog/secret_test.go |
Tests census-based secret rejection. |
catalog/kv_test.go |
Uses census-valid fixtures. |
catalog/census.go |
Implements startup catalog census. |
catalog/census_test.go |
Tests census acceptance and refusal. |
catalog/catalog.go |
Runs census before secret minting. |
bench/command.go |
Warns against live storage use. |
backfill/perf_test.go |
Pins streaming .bin format. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 33 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go:203
- The new schema/mask rejection path has no negative reader test: existing tests decode a valid stamp and reject a wrong pack format, but none opens an
index.packwhose stamp has a different schema or field mask. Add lookup-path tests for both mismatches (including an eventless chunk) so this release-critical capability gate cannot be bypassed by a later refactor.
if schema != TermSchemaVersion || mask != IndexedFieldMask {
return fmt.Errorf(
"events: %s was built under term schema %d with field mask %#x; this binary expects "+
"schema %d with mask %#x (rebuilt index required, or a binary matching the artifact)",
indexPackPath, schema, mask, TermSchemaVersion, IndexedFieldMask)
cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin_test.go:110
- This refusal test covers foreign magic and version only, leaving the newly added reserved-byte rejection untested in both
scanBinHeaderand the self-defending merge reader. Add a fixture with one of bytes 5–7 set and assert that both consumers reject it; otherwise the exact prelude vocabulary can regress without a failing test.
// TestColdBin_ScanRejectsForeignHeader pins the header scan's refusals: a
// foreign magic and a newer version byte each fail loudly instead of being
// misread as entry data.
func TestColdBin_ScanRejectsForeignHeader(t *testing.T) {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 34 out of 34 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
Previously missed (2) — in code that hasn't changed since the last review.
cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer.go:100
- No event-store test currently asserts that this hash is present or verifies it with
eventsPackDecoder; the existing event tests only read records and trailer fields. The analogous ledger and index changes both callContentHashandVerify. Add the same integration assertion forevents.packso removal or decoder/hash mismatches cannot silently change the release format.
// Items reach AppendItem in canonical (uncompressed) payload form, so
// the content hash is independent of the zstd encoder version.
ContentHash: true,
cmd/stellar-rpc/internal/rpcv2/stores/event/format_golden_test.go:28
- This golden calls
ComputeTermKeywith already-encoded bytes, so it pins the hash and field prefix but not the contract-ID/topic value encoding performed byTermsForBytes. A change from raw XDR topic bytes to another representation would leave this test green while changing persisted term identities. Add a fixed marshaledContractEventgolden throughTermsForBytesthat covers the contract ID and each topic position.
func TestComputeTermKey_Golden(t *testing.T) {
val := []byte("stellar-rpc-term-golden")
A catalog holding entries outside this binary's exact vocabulary was previously interpreted: unknown keys hard-errored mid-scan, unknown values were raw-cast into lifecycle states, and the resolver would treat a newer binary's artifacts as missing work and overwrite them. Now Open scans every key and value against the exact vocabulary this binary writes (the three state families, the earliest_ledger pin as a canonical round-trip, and the 32-byte meta/catalog-secret) and refuses to start on anything else, naming the offenders with the secret's value redacted and a two-sided message: either written by a newer stellar-rpc, so deploy that version or newer, or corrupted. The census runs inside Open, after the RocksDB open and BEFORE the secret mint, so a refused Open writes nothing into the tree it refuses. Two engine-layer companions: RocksDB open failures matching the known newer-RocksDB signatures are re-labeled with the same deploy-newer hint, since a library bump would otherwise read as corruption on rollback; and every column family now carries an explicit block-based-table format_version pin so a grocksdb upgrade cannot silently change the on-disk table format. Raising the pin is a declared storage-format change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Release-one artifact bytes are immutable forever, so every self-description
and integrity hook has to be in them from the first artifact. This lands the
finishing set:
- .bin gains a magic ("SBIN") and version prelude; it was the one cold file
with no self-description at all, and a same-width layout change was
silently misread. The header scan now rejects foreign and newer files.
- The txhash .idx metadata blob and the ledger pack's app-data gain leading
version bytes, completing the convention that every app-data blob is
self-describing independent of the container's Format id.
- index.pack's empty app-data slot gains an 11-byte build stamp: stamp
version, term-schema version, and the indexed-field bitmask. The cold
reader validates it against the compiled constants, so an index missing a
term family becomes distinguishable from one that matched nothing. Freeze
and walk write identical stamps (all constants), and decoding ignores
trailing bytes as extension room.
- The three pack writers enable the packfile content hash. Items reach the
hasher in canonical pre-compression form, so the stored hash is
independent of the zstd encoder version and audits can content-compare
without pinning compressors.
- Byte-level golden fixtures pin the term-key derivation and the routing-key
blinding: those bytes are on-disk format (every frozen index is built over
them), and until now a change to the hash inputs passed the suite because
tests compared the functions to themselves. A silent change now fails CI
with a demand to bump TermSchemaVersion or revert.
- bench-ingest's help warns that it overwrites cold files in place with no
catalog trace when pointed at a live deployment's roots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the policy the census implements: forward is seamless, backward refuses. The convergence guarantee is re-scoped to states this binary's own protocol can produce, since the census deliberately refuses rather than converges catalog content outside the compiled-in vocabulary. The new section covers where format identity lives (catalog values, with the state@id grammar reserved for the first bump; per-file self-description as witnesses), the bump razor and its non-obvious corollaries (term families bump hot too, RocksDB dependency bumps are format-touching, write-path switches flip walk and freeze together), and the per-tier upgrade shapes: cold is write-new read-old forever, hot discards and re-ingests, capability gaps are loud errors rather than silent empty results. The transactions design's stale .bin and .idx layouts ride along: both predate keyed routing, and the .bin block now shows the magic-and-version prelude, the recorded secret, and the blinded key. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Eight adjudicated findings from a high-effort review, applied; one refuted by design (the build stamp's exact-match refusal stays: it fails closed, and per-id read sets with query-time capability gating arrive with the format-id grammar, where this check is revised). - The newer-engine error wrap moves from catalog.Open into rocksdb.New, so hot-chunk opens (the likeliest place to meet a newer-engine DB after a rollback) inherit it too; its text now also names the mispointed-path alternative, since the column-families signature fires for both causes. - geometry exports the state-token registries (AllStates, AllHotStates, IsKnownState, IsKnownHotState) and the census and its tests derive their vocabulary from them, so a token added in geometry can never make the daemon refuse its own catalog. - The census's unknown-key refusal no longer prints the value (length only): under a newer binary an unknown key may hold key material, and the doc promises refusals never print secrets. Test pins it. - Version bytes are checked before lengths in the ledger AppData and the txhash cold metadata decoders, so a longer newer-format blob reports as an upgrade problem instead of a corruption-shaped size mismatch. - The index.pack format and build-stamp checks now run for eventless chunks too (pre-Soroban history is entirely eventless chunks, so the refusal contract must not be data-dependent). - Stale comments fixed: the BBTO install policy above applySharedTableOptions, the appData size in the wrong-size test, and the field-addition doctrine on TermSchemaVersion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Findings from four parallel quality reviews (reuse, simplification, efficiency, altitude), deduped and applied; the efficiency angle came back clean. - The leading-version-byte gate becomes one shared helper, stores.CheckBlobVersion, used by the ledger AppData, txhash metadata, and index.pack stamp decoders; the two pre-existing event decoders (LedgerOffsets, index.hash metadata) adopt the same version-before- length order, so the policy and its newer-binary phrasing live once. - ensureSecret's wrong-length branch is deleted: the census runs first on the only call path and owns that validation, and the branch's error had just lost its only test. The secret width now has a single source (catalogSecretLen types the arrays). - TestBlinding_Golden is dropped: stores/blind_test.go already pins BlindKey and DeriveIndexSecret with known-answer vectors, so the event-package copy duplicated a shared byte contract at the wrong level. The term-key goldens, which had no prior pins, stay. - wrapIfEngineTooNew is unexported; its whole design is that only rocksdb.New calls it. - The .bin magic is a plain "SBIN" string constant compared as bytes, instead of a byte-swapped uint32 that spelled NIBS; on-disk bytes are unchanged and the refusal message now prints the magic readably. - Two comment fixes (the Store.bbtos field, one per CF now) and the restored "Related documents" heading the doc edit had swallowed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six of eight round-three findings applied; two rejected (a census key-family registry as speculative generalization the roll-forward doctrine already covers, and the deliberate byte-pinned test parsers). - The newer-engine relabeling now also covers lazily-surfaced errors: with finite max_open_files RocksDB skips table-reader preload, so a newer-format SST first fails at read time; getPinnedWith and the iterator tails wrap those errors too. - scanBinHeader enforces the .bin reserved bytes as zero, making "reserved" a usable version-1-compatible signal instead of dead documentation, and the O_DIRECT merge reader verifies magic and version itself rather than trusting that scanAndValidate ran one file away. - The events Field enum gains an allFields registry: IndexedFieldMask derives from it and the term-key golden iterates it, so an appended field with no pin or mask bit fails tests instead of silently under-reporting in every build stamp (the previous guard was anchored to the last field and could not trip for exactly that case). - DecodeLedgerOffsets and decodeEventsMeta now call stores.CheckBlobVersion instead of hand-rolling the order it names, picking up the newer-binary hint; the LedgerOffsets unknown-version sentinel had no caller and is gone. - The index-pair validation comment states its real guarantee: lookup-path-only, with payload reads independent by design. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two review comments on the PR, both valid completions of earlier commits here rather than nitpicks. BatchMultiGet and LastKey were the two remaining read paths returning engine errors raw, so a newer-format SST met lazily through FetchEvents or LastSeq lost the deploy-newer hint the wrap promises; both now route through it. And the merge reader's self-defending prelude check now enforces the reserved bytes as zero, matching scanBinHeader so the two .bin consumers agree on the format. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The index.pack stamp gate moves out of OpenColdReader into checkIndexBuildStamp, beside its decoder. Same checks, same strings. - The .bin prelude check becomes one checkBinPrelude helper, called by scanBinHeader and by the merge reader. The foreign-header test now runs every mutation against both readers. - IsKnownState and IsKnownHotState name their constants in a switch, so a state added to the const block but not here fails the exhaustive linter instead of the next restart. - The census refusal table uses geometry.StateFrozen instead of a literal. - The ledger app-data size test adds the version-byte-only case. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 35 out of 35 changed files in this pull request and generated no new comments.
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb_test.go:767
- This assertion only proves that a BBTO was allocated; it still passes if
SetFormatVersionis removed or changed, so the new on-disk-format pin is not actually protected against regression. Add a persistence-level assertion (for example, inspect the generated RocksDB OPTIONS entry after opening the store and requireformat_version=6) so the test pins both application of the option and its release value.
|
Summary of the on-disk changes in this PR, the paths whose cost should be measured, and the effect on data directories written before it. 1. On-disk changes by artifact
2. Paths to measure
Expected ingestion impact is in the low single-digit percent range, based on SHA-256 throughput of roughly 2.5 GB/s on commodity hardware. 3. Data directories written before this PRThe catalog and the hot RocksDB open without error. The following cold artifacts do not:
The catalog continues to mark these chunks |
- pinnedTableFormatVersion says that 6 is librocksdb 10.10.1's default and that the pin is a deliberate choice against RocksDB's advice. The repeated "explicit BBTO keeps the format pinned" sentences are cut to one. - TermSchemaVersion's comment drops references to grammar that does not exist in code yet and points to the design doc instead. - appDataSize is written as 1 + 4 with field names. - ParseColdMetadata and DecodeLedgerOffsets wrap the version check in their own sentinel, so errors.Is holds for an empty blob. Tests assert the sentinel again. - Design doc: the format-identity paragraph states the per-blob extension rule, who verifies the content hash, and that a term-schema change ships as a new events format id. One doubled blank line removed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…, 10.9.1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
stellar-rpc#967 (4d9372b) put a version byte in front of the ledger-pack app data, so any build that contains it rejects the packs-v2 datasets at open with "AppData: unsupported version 0x00, want 0x01". The six datasets were re-frozen from packs-v2 with the 3440b2c writer on 2026-09-09 under <dataset>/packs-v3/cold; packs-v2/cold stays for builds with #910 and without #967, packs/cold for builds older than #910.
…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). --- the cold pack's content hash: computed at freeze time, over raw bytes The cold ledger pack carries a SHA-256 over its logical item stream (mainline 4d9372b, #967), and that hash is canonical because its input is the raw LCM bytes, not the compressed frame: an audit can content-compare two artifacts without pinning a zstd version, and a chunk frozen from a hot DB has to agree with the same chunk walked from the ledger stream. Raw mode gets that for free — AppendItem receives raw bytes and the record encoder runs after the hasher. PreCompressed hands the writer the FRAME, so the hash input has to be recovered. THE DECISION: it is recovered at FREEZE time, not carried from ingest. The PreCompressed writer supplies ContentHashExtract, which decompresses each item through the shared cold-pack Decompressor (a context pool, so it is safe on the packfile's worker goroutines) before it reaches the hasher. The stored bytes are untouched; only the hash input changes. THE TRADE, stated plainly: the freeze decodes once per ledger to hash it, and the live ingest loop never hashes. The alternative is to compute each ledger's digest at hot ingest, where the raw bytes are already in hand, store it beside the ledger and hand it to the writer — which buys the freeze's decode back by putting a SHA-256 fork on every ingest batch, under a companion column family that is a hot-tier format change (a hot DB written before it fails the freeze's read-only open). The freeze is a batch job with a whole machine behind it; the ingest loop is the p99 this campaign exists to protect. The cost goes where there is slack. The decode is not serialized. The packfile spawns max(Concurrency, 1) record workers whenever content hashing is on — encoder or no encoder — and each worker runs its own hash goroutine, which is where ContentHashExtract runs. The freeze arm therefore passes coldEncoderConcurrency, the same width the raw-mode (walk/backfill) writer encodes with, instead of leaving the option at its serial default; ColdWriterOptions.Concurrency's doc says what it parallelizes in a mode with no encoder. Hash equality is gated directly rather than inferred from the byte compare above: the freeze-built and walk-built packs must carry the same trailer hash, and Verify — which recomputes it from the stored items — must pass on both. That gate outlives byte identity, which a zstd library bump would end while leaving the content hash the thing the two paths must still agree on. --- 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
The v2 store writes immutable cold artifacts, so whatever the first release ships can never be retrofitted: a file written without self-description stays that way forever. And today nothing stops a binary from misreading — or destroying — data written by a different version: the resolver treats any catalog state it doesn't recognize as missing work, so rolling back to an older binary after a format change would overwrite the newer binary's artifacts.
This PR adds the minimum that must exist before the format freezes:
Version handshake.
catalog.Openchecks every catalog key and value against the exact vocabulary this binary writes (catalog/census.go) and refuses to start — before writing anything — when it meets an entry it doesn't know, with a message distinguishing "a newer binary wrote this" from corruption. Upgrades stay seamless; rollback across a format change refuses instead of destroying data. Format-touching releases are roll-forward-only, by policy.Self-describing files.
.bingains a magic + version prelude (it was the one cold file with none); txhash.idxmetadata and ledger-pack app data gain leading version bytes;index.packgains a build stamp (term-schema version + indexed-field bitmask), so "index lacks this term family" is distinguishable from "no matches". All three pack writers enable the packfile content hash, computed over pre-compression bytes so it survives zstd encoder changes.Pins against accidental format changes. Every RocksDB column family pins its block-based-table
format_version(a grocksdb upgrade could otherwise silently change on-disk bytes), and byte-level golden fixtures pin the term-key derivation, which is de facto on-disk format.The policy, in design docs: what forces a format bump (a new indexed term family bumps the hot format too; dependency bumps that change RocksDB defaults count; write-path switches must flip freeze and walk together) and the per-tier upgrade shapes. The rest of the versioning design —
state@idgrammar, per-format read sets, capability gating — deliberately waits for the first release that actually changes a format.Verification: the full
internal/rpcv2tree is green andgolangci-lint --new-from-rev=feature/full-historyis clean at every commit. New tests pin the acceptance/refusal matrices (including first-start crash residues and secret redaction), each decoder's newer-version refusal, stamp round-trips, and per-pack hash verification.Interactions: rebased onto #910; the build-stamp gate runs after #910's record-checksum gate in
OpenColdReader. #902's zero-decompression freeze will need a decompress extract or an ingest-time hash handoff for the ledgers content hash when it rebases (recorded in the doc section).🤖 Generated with Claude Code