diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go b/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go index f8e0cba11..c6c7dabe4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go @@ -28,7 +28,7 @@ func seedReadyHotChunk(t *testing.T, cat *catalog.Catalog, c chunk.ID, top uint3 Logger: silentLogger(), }) require.NoError(t, err) - h := ledger.NewWithStore(store) + h := ledger.NewWithStore(store, ledger.DefaultZstdEncodeWorkers) require.NoError(t, store.Batch(func(b *rocksdb.BatchWriter) error { return h.AddLedgerToBatch(b, ledger.Entry{Seq: top, Bytes: []byte("ledger")}) })) @@ -38,7 +38,9 @@ func seedReadyHotChunk(t *testing.T, cat *catalog.Catalog, c chunk.ID, top uint3 // TestBackfillSource_HotComplete: a "ready" hot DB whose committed frontier // reaches the chunk's last ledger IS the source — backfillSource returns it with -// NO backend configured, so success alone proves the hot branch was taken. +// NO backend configured, so success alone proves the hot branch was taken. The +// hot branch returns the DB WITHOUT a stream (exactly one of the two is +// non-nil): its chunks freeze by CF scan, so no ledger stream exists. func TestBackfillSource_HotComplete(t *testing.T) { cat, _ := testCatalog(t) cfg := testProcessConfig(t, cat) // no Backend @@ -46,9 +48,10 @@ func TestBackfillSource_HotComplete(t *testing.T) { c := chunk.ID(0) seedReadyHotChunk(t, cat, c, c.LastLedger()) // complete: maxSeq == last ledger - src, closeSrc, err := backfillSource(context.Background(), c, catalog.AllArtifacts(), cfg) + src, closeSrc, hotDB, err := backfillSource(context.Background(), c, catalog.AllArtifacts(), cfg) require.NoError(t, err, "complete hot tier is used; no bulk backend needed") - require.NotNil(t, src) + require.NotNil(t, hotDB, "the hot branch returns the DB") + require.Nil(t, src, "the hot branch has no ledger stream — nothing exists to drain") require.NoError(t, closeSrc()) } @@ -62,7 +65,7 @@ func TestBackfillSource_HotIncompleteFallsThrough(t *testing.T) { c := chunk.ID(0) seedReadyHotChunk(t, cat, c, c.FirstLedger()) // incomplete: maxSeq < last ledger - _, _, err := backfillSource(context.Background(), c, catalog.AllArtifacts(), cfg) + _, _, _, err := backfillSource(context.Background(), c, catalog.AllArtifacts(), cfg) require.Error(t, err) require.Contains(t, err.Error(), "no bulk backend", "an incomplete hot tier falls through; it is not itself an error") @@ -79,7 +82,7 @@ func TestBackfillSource_HotReadyButDirMissing(t *testing.T) { require.NoError(t, cat.PutHotTransient(c)) require.NoError(t, cat.FlipHotReady(c)) // ready key, NO dir on disk - _, _, err := backfillSource(context.Background(), c, catalog.AllArtifacts(), cfg) + _, _, _, err := backfillSource(context.Background(), c, catalog.AllArtifacts(), cfg) require.Error(t, err) require.Contains(t, err.Error(), "won't open") } diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go index 9b605e98f..e5c8e0165 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go @@ -17,10 +17,8 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/durable" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/ingest" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/txhash" ) // ErrBackendCoverageTimeout is returned when the bulk backend's tip never reaches the chunk in time. @@ -44,6 +42,12 @@ type ProcessConfig struct { // compaction and read a just-deleted file. Nil during the startup catch-up // (before ingestion starts, so no writer is open), where a read-only reopen is safe. HotHandle func(chunk.ID) (*hotchunk.DB, bool) + + // ZstdEncodeWorkers is the walk materializer's ledger-frame encode + // parallelism, resolved from the same daemon-config value that tunes the + // hot tier (FORMAT-AFFECTING — the two must agree; see hotchunk.Tuning + // and ingest.Config.ZstdEncodeWorkers, which this feeds). + ZstdEncodeWorkers int } func (cfg ProcessConfig) validate() error { @@ -56,26 +60,29 @@ func (cfg ProcessConfig) validate() error { return nil } -// ingestConfigFor maps an artifact set to ingest.Config. It lives here, not on +// ingestConfigFor maps an artifact set (plus the walk's format-affecting +// encode setting) to ingest.Config. It lives here, not on // catalog.ArtifactSet, so catalog needn't import ingest (the #824 split invariant). -// For a txhash or events request it resolves the chunk's per-index secret from the -// catalog (the same one the index build derives), so ingest keys stay consistent. +// +// The chunk's per-index secrets come from hotchunk.SecretsFor — the SAME +// expression the hot opens use — because the pairing is the point: a chunk's +// sealed runs and its cold artifacts must be keyed alike or the freeze +// produces something no query can route. Re-deriving them here independently +// would make "one derivation" a claim about the hot half only. func ingestConfigFor(s catalog.ArtifactSet, chunkID chunk.ID, cfg ProcessConfig) ingest.Config { c := ingest.Config{ - Ledgers: s.Has(geometry.KindLedgers), - Txhash: s.Has(geometry.KindTxHash), - Events: s.Has(geometry.KindEvents), + Ledgers: s.Has(geometry.KindLedgers), + Txhash: s.Has(geometry.KindTxHash), + Events: s.Has(geometry.KindEvents), + ZstdEncodeWorkers: cfg.ZstdEncodeWorkers, } if c.Txhash || c.Events { - catalogSecret := cfg.Catalog.Secret() + secrets := hotchunk.SecretsFor(cfg.Catalog, chunkID) if c.Txhash { - indexID := uint32(cfg.Catalog.TxHashIndexLayout().TxHashIndexID(chunkID)) - sec := txhash.ColdIndexSecret(catalogSecret[:], indexID) - c.TxhashSecret = sec[:] + c.TxhashSecret = secrets.Txhash[:] } if c.Events { - sec := event.ColdIndexSecret(catalogSecret[:], chunkID) - c.EventsSecret = sec[:] + c.EventsSecret = secrets.Events[:] } } return c @@ -110,7 +117,7 @@ func processChunk(ctx context.Context, chunkID chunk.ID, artifacts catalog.Artif // Choose the source before marking "freezing": a source error (a missing pack // or a coverage timeout) must not leave "freezing" debris for a chunk we then // refuse to produce. closeSource releases any opened hot DB after the pass. - src, closeSource, err := backfillSource(ctx, chunkID, artifacts, cfg) + src, closeSource, hotDB, err := backfillSource(ctx, chunkID, artifacts, cfg) if err != nil { return err } @@ -124,19 +131,27 @@ func processChunk(ctx context.Context, chunkID chunk.ID, artifacts catalog.Artif return fmt.Errorf("mark freezing chunk %s %s: %w", chunkID, artifacts, merr) } - // one-write:create — materialize this chunk's cold artifacts from the resolved - // source's raw ledger iterator. WriteColdChunk is source-blind. + // one-write:create — materialize this chunk's cold artifacts. Two + // materializers, dispatched on what backfillSource resolved: a complete + // hot DB freezes by CF scan/merge (FreezeColdChunk — no raw walk, no + // decompression; the closeSource defer keeps the DB open through the + // pass), any other source re-derives from its raw ledger iterator + // (WriteColdChunk, which is source-blind). dirs := ingest.ColdDirs{ LedgerPack: layout.LedgerPackPath(chunkID), TxhashBin: layout.TxHashBinPath(chunkID), EventsDir: layout.EventsBucketDir(chunkID), } - raw := src.RawLedgers(ctx, ledgerbackend.BoundedRange(chunkID.FirstLedger(), chunkID.LastLedger())) - ic := ingestConfigFor(artifacts, chunkID, cfg) - if rerr := ingest.WriteColdChunk( - ctx, cfg.Logger, chunkID, raw, dirs, cfg.Sink, ic, - ); rerr != nil { - return fmt.Errorf("cold ingest chunk %s %s: %w", chunkID, artifacts, rerr) + icfg := ingestConfigFor(artifacts, chunkID, cfg) + if hotDB != nil { + if rerr := ingest.FreezeColdChunk(ctx, cfg.Logger, chunkID, hotDB, dirs, cfg.Sink, icfg); rerr != nil { + return fmt.Errorf("freeze chunk %s %s: %w", chunkID, artifacts, rerr) + } + } else { + raw := src.RawLedgers(ctx, ledgerbackend.BoundedRange(chunkID.FirstLedger(), chunkID.LastLedger())) + if rerr := ingest.WriteColdChunk(ctx, cfg.Logger, chunkID, raw, dirs, cfg.Sink, icfg); rerr != nil { + return fmt.Errorf("cold ingest chunk %s %s: %w", chunkID, artifacts, rerr) + } } // one-write:barrier — fsync each file and its dirents before the keys flip. @@ -157,7 +172,7 @@ func processChunk(ctx context.Context, chunkID chunk.ID, artifacts catalog.Artif return nil } -// backfillSource picks a chunk's ledger source (+ a closer for an opened hot DB; +// backfillSource picks a chunk's source (+ a closer for an opened hot DB; // no-op otherwise), in preference order: // 1. a ready, COMPLETE hot tier (decision (a): maxCommittedSeq >= last ledger); // incomplete-but-present is staleness that falls through (re-derivation @@ -165,45 +180,52 @@ func processChunk(ctx context.Context, chunkID chunk.ID, artifacts catalog.Artif // (read-only open, never auto-healed); // 2. the frozen local .pack, unless ledgers is itself requested (circular); // 3. the bulk backend, gated by a bounded waitForCoverage on its Tip. +// +// EXACTLY ONE of (stream, hot DB) is non-nil on success: the hot branch +// returns the DB alone (its chunks freeze by CF scan — FreezeColdChunk — +// with no ledger stream in existence), the other branches return a raw +// stream alone. processChunk dispatches its materializer on that. func backfillSource( ctx context.Context, chunkID chunk.ID, artifacts catalog.ArtifactSet, cfg ProcessConfig, -) (ledgerbackend.LedgerStream, func() error, error) { +) (ledgerbackend.LedgerStream, func() error, *hotchunk.DB, error) { noClose := func() error { return nil } cat := cfg.Catalog layout := cat.Layout() // (1) Hot branch: only when the hot key is "ready". A "transient" key (mid-op // or recovery-demoted) is not a read source; an absent key falls through. - src, closer, used, herr := resolveHotSource(chunkID, cfg) + // A complete hot DB is returned WITHOUT a stream: all of its chunk's + // artifacts freeze straight from its CFs. + hot, closer, used, herr := resolveHotSource(chunkID, cfg) if herr != nil { - return nil, noClose, herr // hot-DB open failure — fails the run, never auto-healed + return nil, noClose, nil, herr // hot-DB open failure — fails the run, never auto-healed } if used { cfg.Logger.Debugf("backfillSource: chunk %s from complete hot tier", chunkID) - return src, closer, nil + return nil, closer, hot, nil } // (2) Frozen local .pack, only when ledgers is not requested (producing ledgers // from the pack we'd write would be circular). ledgersState, err := cat.State(chunkID, geometry.KindLedgers) if err != nil { - return nil, noClose, fmt.Errorf("read ledgers state chunk %s: %w", chunkID, err) + return nil, noClose, nil, fmt.Errorf("read ledgers state chunk %s: %w", chunkID, err) } if ledgersState == geometry.StateFrozen && !artifacts.Has(geometry.KindLedgers) { packPath := layout.LedgerPackPath(chunkID) if _, serr := os.Stat(packPath); serr == nil { cfg.Logger.Debugf("backfillSource: chunk %s re-derived from frozen .pack", chunkID) - return ledger.NewPackStream(packPath), noClose, nil + return ledger.NewPackStream(packPath), noClose, nil, nil } // frozen ⇒ file exists; a missing pack is a bug, not a re-download trigger. - return nil, noClose, fmt.Errorf( + return nil, noClose, nil, fmt.Errorf( "chunk %s ledgers is %q but pack file is missing at %s", chunkID, geometry.StateFrozen, packPath) } // (3) Bulk backend — the only source for a chunk with no local copy. if cfg.Backend == nil { - return nil, noClose, fmt.Errorf( + return nil, noClose, nil, fmt.Errorf( "chunk %s has no local copy and no bulk backend is configured", chunkID) } // The coverage wait is mandatory before reading the bulk backend: the freeze @@ -212,25 +234,25 @@ func backfillSource( if werr := waitForCoverage( ctx, cfg.Backend, chunkID.LastLedger(), defaultCoveragePollInterval, defaultCoverageTimeout, ); werr != nil { - return nil, noClose, werr + return nil, noClose, nil, werr } cfg.Logger.Debugf("backfillSource: chunk %s from bulk backend", chunkID) - return cfg.Backend, noClose, nil + return cfg.Backend, noClose, nil, nil } // resolveHotSource applies the hot branch end to end: it reads the hot key and, -// only when "ready", tries the hot tier. used=true → src/closer are the hot +// only when "ready", tries the hot tier. used=true → the DB/closer are the hot // source; used=false → no "ready" key or present-but-incomplete (caller falls // through); err → a "ready" DB that won't open (fails the run). Keeps backfillSource's // hot branch flat. func resolveHotSource( chunkID chunk.ID, cfg ProcessConfig, -) (ledgerbackend.LedgerStream, func() error, bool, error) { +) (*hotchunk.DB, func() error, bool, error) { // Prefer the registry's shared handle when it holds this chunk; read through it // and never close it — the registry owns the handle. if cfg.HotHandle != nil { if db, ok := cfg.HotHandle(chunkID); ok { - return db.Source(), func() error { return nil }, true, nil + return db, func() error { return nil }, true, nil } } hotState, err := cfg.Catalog.HotState(chunkID) @@ -250,10 +272,10 @@ func resolveHotSource( // — an ordinary run-failing error, detected lazily on the open. func tryHotSource( state geometry.HotState, chunkID chunk.ID, cfg ProcessConfig, -) (ledgerbackend.LedgerStream, func() error, bool, error) { +) (*hotchunk.DB, func() error, bool, error) { dir := cfg.Catalog.Layout().HotChunkPath(chunkID) // Open the chunk's shared multi-CF DB READ-ONLY via the single ready-open - // enforcement site: the freeze reads its ledgers to re-derive the cold artifacts + // enforcement site: the freeze scans its CFs to build the cold artifacts // and must never mutate it (the read-only open replays any un-synced WAL into // memtables but persists nothing). An absent or gutted "ready" DB fails the open // — fails the run, never auto-created. @@ -271,7 +293,7 @@ func tryHotSource( // decision (a): complete iff the single DB's maxCommittedSeq reaches the chunk's // last ledger. An empty DB (present==false) cannot be complete. if present && maxSeq >= chunkID.LastLedger() { - return hot.Source(), hot.Close, true, nil + return hot, hot.Close, true, nil } // Present but incomplete: legitimate staleness — caller falls through. cfg.Logger.Debugf("backfillSource: chunk %s hot tier present but incomplete; falling through", chunkID) diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go b/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go index c0f340d13..e949f8163 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go @@ -14,13 +14,16 @@ import ( "github.com/stretchr/testify/require" "github.com/stellar/go-stellar-sdk/ingest/ledgerbackend" + "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/catalog" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/ingest" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rpcv2test" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/txhash" ) // --------------------------------------------------------------------------- @@ -302,7 +305,7 @@ func TestBackfillSource_PrefersFrozenPackWhenLFSNotRequested(t *testing.T) { cfg.Backend = bulk set := catalog.NewArtifactSet(geometry.KindEvents, geometry.KindTxHash) // ledgers NOT requested - src, closeSrc, err := backfillSource(context.Background(), chunkID, set, cfg) + src, closeSrc, _, err := backfillSource(context.Background(), chunkID, set, cfg) require.NoError(t, err) defer func() { require.NoError(t, closeSrc()) }() // It is a pack stream (re-derivation without download); the bulk backend was @@ -327,7 +330,7 @@ func TestBackfillSource_DoesNotUsePackWhenLFSRequested(t *testing.T) { // ledgers IS requested — the pack branch is skipped (circular), so it goes to // the bulk backend (whose tip covers the chunk, so the wait passes). - src, closeSrc, err := backfillSource(context.Background(), chunkID, catalog.AllArtifacts(), cfg) + src, closeSrc, _, err := backfillSource(context.Background(), chunkID, catalog.AllArtifacts(), cfg) require.NoError(t, err) defer func() { require.NoError(t, closeSrc()) }() require.Same(t, bulk, src) @@ -343,7 +346,7 @@ func TestBackfillSource_BulkCoverageErrorAborts(t *testing.T) { chunkID := chunk.ID(0) cfg.Backend = &fakeBackend{t: t, gen: rpcv2test.ZeroTxLCMBytes, tipErr: errors.New("boom")} - _, _, err := backfillSource(context.Background(), chunkID, catalog.AllArtifacts(), cfg) + _, _, _, err := backfillSource(context.Background(), chunkID, catalog.AllArtifacts(), cfg) require.Error(t, err) require.Contains(t, err.Error(), "backend tip query") } @@ -353,7 +356,7 @@ func TestBackfillSource_NoBackendConfigured(t *testing.T) { cfg := testProcessConfig(t, cat) cfg.Backend = nil - _, _, err := backfillSource(context.Background(), chunk.ID(0), catalog.AllArtifacts(), cfg) + _, _, _, err := backfillSource(context.Background(), chunk.ID(0), catalog.AllArtifacts(), cfg) require.Error(t, err) require.Contains(t, err.Error(), "no bulk backend") } @@ -433,3 +436,116 @@ func writeRealPack(t *testing.T, cat *catalog.Catalog, chunkID chunk.ID) { ingest.NopSink{}, ingest.Config{Ledgers: true})) require.FileExists(t, cat.Layout().LedgerPackPath(chunkID)) } + +// --------------------------------------------------------------------------- +// The txhash secret is scoped to the INDEX, not the chunk. +// --------------------------------------------------------------------------- + +// txLCMAt returns a tx-bearing LCM for seq plus the transaction hash it +// carries. The fixture's source account is random, so the hash is only +// knowable after the fact — recover it from the marshaled meta rather than +// re-deriving it. +func txLCMAt(t *testing.T, seq uint32) ([]byte, [32]byte) { + t.Helper() + raw := rpcv2test.EventLCMBytes(t, seq) + var lcm xdr.LedgerCloseMeta + require.NoError(t, lcm.UnmarshalBinary(raw)) + require.Len(t, lcm.V2.TxProcessing, 1, "fixture must carry exactly one transaction") + return raw, lcm.V2.TxProcessing[0].Result.TransactionHash +} + +// TestProcessChunk_TxhashSecretIsPerIndex materializes TWO chunks that share +// ONE tx-hash index and then builds that index over both .bins. It pins the +// scope of the ingest-time blinding secret: ingestConfigFor must derive it from +// TxHashIndexID(chunkID), not from the chunk. Every other test in the tree runs +// where the two coincide (chunk 0 in index 0), so a per-CHUNK derivation passes +// the suite while breaking every real deployment — here it makes the two .bins +// disagree, which BuildColdIndex refuses outright ("keyed under a different +// index secret"), and even a lenient build could not resolve both hashes +// through one adopted secret. +func TestProcessChunk_TxhashSecretIsPerIndex(t *testing.T) { + cat, _ := testCatalog(t) + txLayout := cat.TxHashIndexLayout() + first, second := chunk.ID(0), chunk.ID(1) + require.Greater(t, geometry.ChunksPerTxhashIndex, uint32(1), + "premise: the default geometry must put several chunks in one index") + require.Equal(t, txLayout.TxHashIndexID(first), txLayout.TxHashIndexID(second), + "premise: both chunks must live in ONE index for the secrets to have to match") + + // One tx-bearing ledger per chunk; every other ledger is empty. + rawFirst, hashFirst := txLCMAt(t, first.FirstLedger()) + rawSecond, hashSecond := txLCMAt(t, second.FirstLedger()) + require.NotEqual(t, hashFirst, hashSecond) + gen := func(t *testing.T, seq uint32) []byte { + switch seq { + case first.FirstLedger(): + return rawFirst + case second.FirstLedger(): + return rawSecond + default: + return rpcv2test.ZeroTxLCMBytes(t, seq) + } + } + + cfg := testProcessConfig(t, cat) + cfg.Backend = &fakeBackend{t: t, gen: gen, tip: math.MaxUint32} + set := catalog.NewArtifactSet(geometry.KindLedgers, geometry.KindTxHash) + ctx := context.Background() + require.NoError(t, processChunk(ctx, first, set, cfg)) + require.NoError(t, processChunk(ctx, second, set, cfg)) + + layout := cat.Layout() + inputs := make([]string, 0, 2) + for _, c := range []chunk.ID{first, second} { + p := layout.TxHashBinPath(c) + require.Len(t, rpcv2test.ReadColdBin(t, p), 1, "chunk %s .bin holds its one transaction", c) + inputs = append(inputs, p) + } + + // The build adopts ONE secret for the window and refuses inputs that + // disagree — a per-chunk derivation dies right here. + idxPath := filepath.Join(t.TempDir(), "per-index.idx") + require.NoError(t, txhash.BuildColdIndex(ctx, inputs, idxPath, + first.FirstLedger(), second.LastLedger())) + + // ...and under that one secret a hash from EACH chunk resolves to its ledger. + reader, err := txhash.OpenColdReader(idxPath) + require.NoError(t, err) + t.Cleanup(func() { _ = reader.Close() }) + for _, want := range []struct { + hash [32]byte + seq uint32 + }{ + {hashFirst, first.FirstLedger()}, + {hashSecond, second.FirstLedger()}, + } { + got, gerr := reader.Get(want.hash) + require.NoError(t, gerr, "hash %x must resolve through the shared index", want.hash[:4]) + require.Equal(t, want.seq, got, "hash %x resolves to its ledger", want.hash[:4]) + } +} + +// TestIngestConfigFor_TxhashSecretIsIndexScoped is the other half of the scope +// contract, in the cheap direction the e2e above cannot reach without +// materializing a chunk a whole index away: chunks inside one index share a +// secret, and the first chunk of the NEXT index gets a different one. +func TestIngestConfigFor_TxhashSecretIsIndexScoped(t *testing.T) { + cat, _ := testCatalog(t) + cfg := testProcessConfig(t, cat) + set := catalog.NewArtifactSet(geometry.KindTxHash) + + secretFor := func(c chunk.ID) []byte { + ic := ingestConfigFor(set, c, cfg) + require.True(t, ic.Txhash) + require.Len(t, ic.TxhashSecret, stores.SecretLen) + return ic.TxhashSecret + } + + nextIndex := chunk.ID(geometry.ChunksPerTxhashIndex) + txLayout := cat.TxHashIndexLayout() + require.NotEqual(t, txLayout.TxHashIndexID(0), txLayout.TxHashIndexID(nextIndex), + "premise: chunk %s starts the next index", nextIndex) + + require.Equal(t, secretFor(0), secretFor(1), "chunks in one index share the secret") + require.NotEqual(t, secretFor(0), secretFor(nextIndex), "a different index gets a different secret") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/bench_test.go b/cmd/stellar-rpc/internal/rpcv2/bench/bench_test.go index cc6a79e76..2178a09b8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/bench_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/bench_test.go @@ -23,6 +23,10 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" ) +// linuxGOOS gates the assertions that exercise Linux-only readers (peak RSS +// via /proc): CI runs Linux; elsewhere the row is skipped. +const linuxGOOS = "linux" + // eventEvery: every eventEvery-th ledger of a fixture chunk carries one // transaction with one contract event; the rest are zero-tx ledgers. const eventEvery = 100 @@ -164,7 +168,7 @@ func TestRunColdFromPack(t *testing.T) { // The rss_test.go tests use fake readers; this is the only assertion that // exercises the real readPeakRSS. It only works on Linux — which is what // CI runs — so on other platforms the read fails and the row is skipped. - if runtime.GOOS == "linux" { + if runtime.GOOS == linuxGOOS { require.Contains(t, driver, "peak_rss_bytes") assert.EqualValues(t, 1, driver["peak_rss_bytes"]["n"]) assert.Positive(t, driver["peak_rss_bytes"]["total_ns"]) @@ -343,7 +347,7 @@ func TestRunHotFromPack(t *testing.T) { // An unpaced run must not add the pace_lag row — the CSV rows are a de // facto contract, and pace_lag belongs to --close-interval runs only. assert.NotContains(t, driver, "pace_lag") - if runtime.GOOS == "linux" { + if runtime.GOOS == linuxGOOS { require.Contains(t, driver, "peak_rss_bytes") assert.EqualValues(t, 1, driver["peak_rss_bytes"]["n"]) assert.Positive(t, driver["peak_rss_bytes"]["total_ns"]) diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/cold.go b/cmd/stellar-rpc/internal/rpcv2/bench/cold.go index 592caf3fd..05934d057 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/cold.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/cold.go @@ -15,6 +15,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/config" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" ) // maxChunkID is the last chunk ID whose LastLedger fits in a uint32 ledger @@ -131,7 +132,10 @@ func runCold(ctx context.Context, logger *supportlog.Entry, opts coldOptions) er Catalog: cat, Logger: logger, Metrics: sink, - Process: backfill.ProcessConfig{Sink: sink, Backend: backend}, + // The production-default encode workers: the backfill cell measures (and + // produces) production-shape packs (see hotchunk.Tuning on why the value + // is format-affecting). + Process: backfill.ProcessConfig{Sink: sink, Backend: backend, ZstdEncodeWorkers: ledger.DefaultZstdEncodeWorkers}, Workers: opts.Workers, // Benchmarks measure one clean attempt; retries would fold failure + // backoff time into the samples. diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/command.go b/cmd/stellar-rpc/internal/rpcv2/bench/command.go index 42599098c..4501c5709 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/command.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/command.go @@ -13,17 +13,44 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/backfill" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" ) // NewCommand returns the `bench-ingest` command tree: `cold` benchmarks the // daemon's backfill (backfill.RunBackfill), `hot` benchmarks the daemon's live -// ingestion loop. +// ingestion loop, `freeze` benchmarks the hot→cold chunk freeze route. func NewCommand() *cobra.Command { cmd := &cobra.Command{ Use: "bench-ingest", Short: "Benchmark full-history ingestion", } - cmd.AddCommand(newColdCommand(), newHotCommand()) + cmd.AddCommand(newColdCommand(), newHotCommand(), newFreezeCommand(), newTxindexCommand()) + return cmd +} + +func newTxindexCommand() *cobra.Command { + var ( + opts txindexOptions + prof profileFlags + ) + cmd := &cobra.Command{ + Use: "txindex", + Short: "Benchmark the rolling txhash index build (BuildColdIndex over a window of .bin files)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + cmd.SilenceUsage = true + ctx, stop, logger := benchContext() + defer stop() + return prof.around(logger, func() error { return runTxindex(ctx, logger, opts) }) + }, + } + fs := cmd.Flags() + fs.StringVar(&opts.BinDir, "bin-dir", "", "dir of .bin window inputs (required)") + fs.IntVar(&opts.NumBins, "num-bins", 0, "cap on .bins taken in chunk order (0 = all)") + fs.StringVar(&opts.IndexOut, "index-out", "", ".idx output path (required; overwritten)") + fs.StringVar(&opts.OutDir, "out", "bench-out", "CSV output dir") + prof.bind(cmd) + markRequired(cmd, "bin-dir", "index-out") return cmd } @@ -190,6 +217,8 @@ func newHotCommand() *cobra.Command { hotDir string catalogDir string closeInterval time.Duration + traceFile string + zstdWorkers int prof profileFlags ) cmd := newBenchCommand("hot", @@ -205,6 +234,8 @@ func newHotCommand() *cobra.Command { CatalogDir: catalogDir, CloseInterval: closeInterval, OutDir: outDir, + TraceFile: traceFile, + ZstdWorkers: zstdWorkers, }) }) fs := cmd.Flags() @@ -219,10 +250,53 @@ func newHotCommand() *cobra.Command { fs.DurationVar(&closeInterval, "close-interval", 0, "assumed time between ledger closes; >0 paces ingestion to that steady-state cadence "+ "and reports pace_lag (0 = ingest back-to-back, catch-up throughput)") + fs.StringVar(&traceFile, "trace", "", + "per-ledger trace CSV path: one wall-clock-stamped row per ingested ledger "+ + "with every phase duration (empty = off)") + fs.IntVar(&zstdWorkers, "zstd-workers", ledger.DefaultZstdEncodeWorkers, + "hot ledger-frame zstd encode workers (0 = single-threaded; format-affecting "+ + "in production — see hotchunk.Tuning)") markRequired(cmd, "start-chunk", "hot-dir") return cmd } +func newFreezeCommand() *cobra.Command { + var ( + src sourceFlags + chunkID uint32 + workDir string + catalogDir string + reuseHot bool + prof profileFlags + ) + cmd := newBenchCommand("freeze", + "Benchmark the chunk freeze: the daemon's hot→cold route (a complete hot DB "+ + "resolved as the backfill source, through the one-write protocol)", + &src, &prof, + func(ctx context.Context, logger *supportlog.Entry, outDir string) error { + return runFreeze(ctx, logger, freezeOptions{ + Source: src.config(), + Chunk: chunk.ID(chunkID), + WorkRoot: workDir, + CatalogDir: catalogDir, + ReuseHot: reuseHot, + OutDir: outDir, + }) + }) + fs := cmd.Flags() + fs.Uint32Var(&chunkID, "chunk", 0, "chunk ID to freeze (required)") + fs.StringVar(&workDir, "work-dir", "", + "single layout root for BOTH tiers (required): the hot DB is populated (or found, "+ + "with --reuse-hot) under it and the cold artifacts land under it") + fs.StringVar(&catalogDir, "catalog-dir", "", + "base dir for the run's scratch catalog; default: --work-dir") + fs.BoolVar(&reuseHot, "reuse-hot", false, + "skip the populate phase and adopt the hot DB a prior run left in --work-dir "+ + "(cheap iteration; freeze-only RSS row and CPU profile)") + markRequired(cmd, "chunk", "work-dir") + return cmd +} + // markRequired marks flags required, panicking on a nonexistent name — a // programming error caught by any test that builds the command. func markRequired(cmd *cobra.Command, names ...string) { diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go b/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go index 73c92f8c7..0a22240bb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go @@ -198,6 +198,12 @@ type csvSink struct { // LastCommitted measures each committed ledger's lag against it. Nil in // every other run — cold, or unpaced hot — which records no pace_lag. schedule *paceSchedule + + // trace, when non-nil (--trace), streams one CSV row per ingested ledger: + // HotPhase feeds it each phase duration and LastCommitted (which the loop + // calls right after each ledger's burst) flushes the row under seq. + // Guarded by mu. + trace *hotTrace } var ( @@ -220,10 +226,11 @@ func newCSVSink() *csvSink { // The phases partition the per-ledger IngestLedger wall-clock, so their sum IS // the per-ledger total — a number per-phase percentiles can't recover // (percentiles don't sum). PhaseExtract (always first) resets the accumulator -// for a new ledger; PhaseApply (terminal, emitted on the success path only — -// never the failed phase) records one ingest_total sample with items=1. A -// failed ledger never reaches PhaseApply, so it contributes nothing (the run -// aborts and partial CSVs are written anyway). +// for a new ledger; PhaseApply (terminal) records one ingest_total sample +// with items=1. A ledger that fails before the apply never reaches +// PhaseApply and contributes nothing; an apply-phase failure contributes its +// one sample to an already-aborted run's partial CSV — harmless, the run +// stops there. // // The accumulator is mutex-guarded so the sink stays safe under the MetricSink // contract regardless; only the production single-writer pattern yields @@ -239,6 +246,9 @@ func (s *csvSink) HotPhase(phase hotchunk.Phase, d time.Duration, items int, _ e } s.hotBurst += d total := s.hotBurst + if s.trace != nil { + s.trace.recordPhase(phase, d) + } s.mu.Unlock() if phase == hotchunk.PhaseApply { @@ -280,12 +290,21 @@ func (s *csvSink) Rebuild(d time.Duration) { } // LastCommitted tracks the hot loop's per-ledger committed gauge (see lastSeq), -// and — for a paced run — records that ledger's lag behind the close schedule. +// for a paced run records that ledger's lag behind the close schedule, and — +// with --trace — flushes the ledger's accumulated phase burst as one trace row +// (the loop calls LastCommitted right after each ledger's burst, so the pending +// row is exactly this seq's). func (s *csvSink) LastCommitted(lastCommitted uint32) { s.lastSeq.Store(lastCommitted) + var lag time.Duration if s.schedule != nil { - s.recordPaceLag(lastCommitted) + lag = s.recordPaceLag(lastCommitted) } + s.mu.Lock() + if s.trace != nil { + s.trace.writeRow(lastCommitted, lag) + } + s.mu.Unlock() } // The remaining observability signals carry no useful bench signal and are @@ -348,13 +367,15 @@ func (s *csvSink) sumDriver(name string) time.Duration { // with items=1. On pace, a ledger starts at its due time, so its lag at // commit ≈ its own ingest time; a lag that grows across the run means // ingestion cannot keep up with the close cadence and the backlog is growing. -func (s *csvSink) recordPaceLag(seq uint32) { +// The lag is also returned so LastCommitted can hand it to the --trace row. +func (s *csvSink) recordPaceLag(seq uint32) time.Duration { due, ok := s.schedule.dueForSeq(seq) if !ok { - return + return 0 } lag := max(s.schedule.clock().Sub(due), 0) s.observe(fileDriver, driverPaceLag, lag, 1) + return lag } // row is one aggregated CSV row. diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go b/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go new file mode 100644 index 000000000..5e9783234 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go @@ -0,0 +1,221 @@ +package bench + +import ( + "context" + "errors" + "fmt" + "os" + "time" + + supportlog "github.com/stellar/go-stellar-sdk/support/log" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/backfill" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/catalog" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/config" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" +) + +// freezeOptions configures one freeze benchmark run. +type freezeOptions struct { + // Source is the ledger source the populate phase reads from. Unused with + // ReuseHot (the freeze itself never touches a ledger source — that is the + // point of the measurement). + Source sourceConfig + + // Chunk is the single chunk to freeze. + Chunk chunk.ID + + // WorkRoot is the ONE layout root for both tiers: the hot DB lives at + // geometry.NewLayout(WorkRoot).HotChunkPath(Chunk) and the cold artifacts + // land under the same layout's cold trees — mirroring production, where + // one Layout spans both and backfillSource finds the hot DB through the + // catalog's own Layout. + WorkRoot string + + // CatalogDir is the base dir the run-scoped scratch catalog is created + // under. Empty means WorkRoot. + CatalogDir string + + // ReuseHot skips the populate phase and adopts the hot DB a prior run + // left in WorkRoot. Population costs a full paced-free hot ingest + // (~minutes on a real chunk), so reuse is how freeze iterations stay + // cheap — and it is also how a run gets a clean freeze-only RSS row and + // CPU profile (an in-process populate contaminates both). + ReuseHot bool + + // OutDir receives the CSV report. + OutDir string +} + +// validate checks the flags before runFreeze touches the filesystem. +func (o freezeOptions) validate() error { + if o.WorkRoot == "" { + return errors.New("--work-dir is required") + } + if o.Chunk > maxChunkID { + return fmt.Errorf("--chunk=%d is past the last valid chunk ID %d", uint32(o.Chunk), uint32(maxChunkID)) + } + if o.ReuseHot { + return nil // the freeze reads no ledger source; source flags are unused + } + if err := o.Source.validate(); err != nil { + return err + } + // Refuse re-packing a source pack tree in place, as runCold does: the + // freeze materializes a ledger pack under WorkRoot's ledgers tree. + if o.Source.Kind == sourcePack { + outLedgers := geometry.NewLayout(o.WorkRoot).LedgersRoot() + if samePath(o.Source.PackDir, outLedgers) { + return fmt.Errorf("--work-dir's ledgers tree (%s) must differ from --pack-dir", outLedgers) + } + } + return nil +} + +// runFreeze benchmarks the freeze route: the daemon's hot→cold chunk freeze +// (backfill.RunBackfill resolving a COMPLETE hot DB as the source, through the +// one-write protocol). Setup first materializes that state — populate the hot +// chunk through the production bounded ingestion loop, or adopt a prior run's +// DB with ReuseHot — then the measured RunBackfill runs with NO bulk backend +// configured: backfillSource can then only resolve the hot tier, so success +// alone proves the freeze route was what got measured (the +// TestBackfillSource_HotComplete contract); any fall-through fails the run +// loudly instead of silently benchmarking a different source. +func runFreeze(ctx context.Context, logger *supportlog.Entry, opts freezeOptions) error { + if err := opts.validate(); err != nil { + return err + } + // Surface an unwritable --out before the expensive run, not after it. + if err := os.MkdirAll(opts.OutDir, 0o755); err != nil { + return fmt.Errorf("create --out dir %s: %w", opts.OutDir, err) + } + layout := geometry.NewLayout(opts.WorkRoot) + // Create + fsync every root this run writes under — the daemon's own + // root prep, for both tiers. + if err := config.PrepareRoots( + layout.HotRoot(), layout.LedgersRoot(), layout.EventsRoot(), + layout.TxHashRawRoot(), layout.TxHashIndexRoot(), + ); err != nil { + return fmt.Errorf("prepare --work-dir roots: %w", err) + } + catalogBase := opts.CatalogDir + if catalogBase == "" { + catalogBase = opts.WorkRoot + } + cat, releaseCat, err := openScratchCatalog(catalogBase, layout, logger) + if err != nil { + return err + } + defer releaseCat() + + if opts.ReuseHot { + if err := adoptHotChunk(cat, opts.Chunk); err != nil { + return err + } + } else if err := populateHotChunk(ctx, logger, cat, opts); err != nil { + return err + } + + sink := newCSVSink() + start := time.Now() + err = backfill.RunBackfill(ctx, backfill.ExecConfig{ + Catalog: cat, + Logger: logger, + Metrics: sink, + // Backend deliberately nil — see the function comment: the complete + // hot DB must be the source, or the run fails. + // The default encode workers on BOTH halves (the populate below and this + // cold walk) — the format-affecting value must agree across them (see + // hotchunk.Tuning). + Process: backfill.ProcessConfig{Sink: sink, ZstdEncodeWorkers: ledger.DefaultZstdEncodeWorkers}, + Workers: 1, + // Benchmarks measure one clean attempt; retries would fold failure + + // backoff time into the samples. + MaxRetries: 0, + }, opts.Chunk, opts.Chunk) + // VmHWM never decreases, so it can be read right here — before the error + // check — and a failed run's partial CSV still gets the row. + recordPeakRSS(logger, sink, readPeakRSS) + if err != nil { + writePartialCSVs(logger, sink, opts.OutDir) + return fmt.Errorf("freeze chunk %s: %w", opts.Chunk, err) + } + freezeWall := time.Since(start) + + sink.logSummary(logger) + logger.Infof( + "freeze wall = %s (chunk %s; includes the range's txhash index build — driver.csv decomposes)", + freezeWall.Round(time.Millisecond), opts.Chunk) + if !opts.ReuseHot { + logger.Info("peak_rss_bytes includes the in-process populate phase; " + + "rerun with --reuse-hot for a freeze-only RSS row and profile") + } + written, err := sink.writeCSVs(opts.OutDir) + if err != nil { + return err + } + logger.Infof("wrote %d CSVs to %s", len(written), opts.OutDir) + return nil +} + +// populateHotChunk hot-ingests the chunk's full ledger range through the +// production bounded ingestion loop — create bracket, one atomic synced +// WriteBatch per ledger, boundary rotation — leaving a complete hot DB whose +// catalog key is "ready": exactly the state the daemon's lifecycle hands a +// freeze. This is setup, not measurement: it reports into a throwaway sink +// (only its last-committed gauge is read back, to verify completeness the way +// runHot does) and its wall-clock is logged separately. The boundary rotation +// also leaves the NEXT chunk's empty hot DB and "ready" key behind — inert +// scratch the single-chunk backfill range never plans. +func populateHotChunk( + ctx context.Context, logger *supportlog.Entry, cat *catalog.Catalog, opts freezeOptions, +) error { + backend, release, err := openSource(ctx, opts.Source) + if err != nil { + return err + } + // Released before the measured freeze runs (this function returns first), + // so no source machinery is alive during measurement. + defer release() + + first, last := opts.Chunk.FirstLedger(), opts.Chunk.LastLedger() + setupSink := newCSVSink() + start := time.Now() + if err := rpcv2.RunBoundedIngestionLoop(ctx, rpcv2.BoundedIngestConfig{ + Stream: boundedStream{inner: backend, first: first, last: last}, + Resume: first, + Catalog: cat, + Boundary: nopBoundary{}, + Logger: logger, + Metrics: setupSink, + Sink: setupSink, + Tuning: hotchunk.DefaultTuning(), + }); err != nil { + return fmt.Errorf("populate hot chunk %s: %w", opts.Chunk, err) + } + // The loop cannot tell a complete bounded stream from one that ran dry. + if got := setupSink.lastCommittedSeq(); got != last { + return fmt.Errorf("populate ended at seq %d, expected through %d", got, last) + } + logger.Infof("populated hot chunk %s (%d ledgers) in %s — setup, excluded from the freeze measurement", + opts.Chunk, last-first+1, time.Since(start).Round(time.Millisecond)) + return nil +} + +// adoptHotChunk points the fresh scratch catalog at a hot DB a prior run left +// in the work dir (ReuseHot): verify the dir exists, flip its key "ready" — +// the durable state a production restart would find. It deliberately never +// opens or wipes the DB: completeness is judged by the freeze's own +// tryHotSource gate, and with no bulk backend configured an incomplete DB +// fails the run loudly rather than being silently re-derived. +func adoptHotChunk(cat *catalog.Catalog, c chunk.ID) error { + dir := cat.Layout().HotChunkPath(c) + if _, err := os.Stat(dir); err != nil { + return fmt.Errorf("--reuse-hot: no hot DB at %s (run once without --reuse-hot to populate): %w", dir, err) + } + return cat.FlipHotReady(c) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/bench/freeze_test.go new file mode 100644 index 000000000..7fde73b85 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/freeze_test.go @@ -0,0 +1,90 @@ +package bench + +import ( + "context" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" +) + +// TestRunFreezeFromHot is the end-to-end freeze bench: fabricate a full +// chunk's source pack, populate the hot DB through the production bounded +// ingestion loop, then measure the freeze — RunBackfill with NO bulk backend, +// so the complete hot DB is the only source that can satisfy it. A second run +// with ReuseHot adopts the same hot DB without repopulating. The populate is a +// full LedgersPerChunk hot ingest (one synced WriteBatch per ledger), so this +// is one of the package's slower tests; both modes share the one populate. +func TestRunFreezeFromHot(t *testing.T) { + chunkID := chunk.ID(0) + packDir, txLedgers := writeSourcePack(t, t.TempDir(), chunkID, chunk.LedgersPerChunk) + workRoot := t.TempDir() + + runOnce := func(csvDir string, reuseHot bool) map[string]map[string]int64 { + t.Helper() + require.NoError(t, runFreeze(context.Background(), testLogger(), freezeOptions{ + Source: sourceConfig{Kind: sourcePack, PackDir: packDir}, + Chunk: chunkID, + WorkRoot: workRoot, + ReuseHot: reuseHot, + OutDir: csvDir, + })) + return readCSV(t, filepath.Join(csvDir, "driver.csv")) + } + + csvDir := filepath.Join(t.TempDir(), "csv") + driver := runOnce(csvDir, false) + + // The freeze route ran end to end: the scheduler's whole-run wall, the + // per-chunk engine total, the per-kind totals, and the window's index + // rebuild — one sample each, exactly as the cold report shapes them. + for _, name := range []string{ + "backfill_wall", "index_rebuild", "chunk_total", "ledgers_total", "txhash_total", "events_total", + } { + require.Contains(t, driver, name) + assert.EqualValues(t, 1, driver[name]["n"], name) + } + // THE zero-decompression acceptance assertion: the freeze route runs NO + // raw-ledger walk, so the shared extract row must be absent — its + // presence would mean the freeze decompressed the chunk to feed a + // derivation writer (the regression this bench exists to catch). + require.NotContains(t, driver, "cold_extract") + assert.EqualValues(t, chunk.LedgersPerChunk, driver["ledgers_total"]["n_items"]) + assert.EqualValues(t, txLedgers, driver["txhash_total"]["n_items"]) + + if runtime.GOOS == linuxGOOS { + require.Contains(t, driver, "peak_rss_bytes") + } + + // All cold artifacts landed at the Layout-resolved paths. + layout := geometry.NewLayout(workRoot) + assert.FileExists(t, layout.LedgerPackPath(chunkID)) + assert.FileExists(t, layout.TxHashBinPath(chunkID)) + for _, p := range layout.EventsPaths(chunkID) { + assert.FileExists(t, p) + } + + // ReuseHot: a fresh scratch catalog adopts the same hot DB — no + // repopulate — and the freeze reruns, overwriting the artifacts. + driver2 := runOnce(filepath.Join(t.TempDir(), "csv2"), true) + require.Contains(t, driver2, "backfill_wall") + assert.EqualValues(t, chunk.LedgersPerChunk, driver2["ledgers_total"]["n_items"]) +} + +// TestRunFreezeReuseHotMissing: ReuseHot against a work dir with no hot DB +// fails up front with the pointer to populate, not deep inside the backfill. +func TestRunFreezeReuseHotMissing(t *testing.T) { + err := runFreeze(context.Background(), testLogger(), freezeOptions{ + Chunk: chunk.ID(0), + WorkRoot: t.TempDir(), + ReuseHot: true, + OutDir: filepath.Join(t.TempDir(), "csv"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "--reuse-hot") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/hot.go b/cmd/stellar-rpc/internal/rpcv2/bench/hot.go index d16a195ff..977bbcc24 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/hot.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/hot.go @@ -15,6 +15,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/config" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // hotOptions configures one hot-ingest benchmark run. @@ -56,6 +57,18 @@ type hotOptions struct { // OutDir receives the CSV report. OutDir string + + // TraceFile, when non-empty, streams a per-ledger trace CSV to this path: + // one wall-clock-stamped row per ingested ledger with every phase duration, + // for correlating individual slow ledgers against external timelines + // (RocksDB LOG flush events, iostat). Empty = no trace. + TraceFile string + + // ZstdWorkers is the hot tier's ledger-frame encode parallelism + // (--zstd-workers; 0 = single-threaded). FORMAT-AFFECTING in production + // (see hotchunk.Tuning); for a hot bench it selects which production + // shape the run measures — default ledger.DefaultZstdEncodeWorkers. + ZstdWorkers int } // validate checks the flags and chunk range before runHot touches the @@ -77,9 +90,29 @@ func (o hotOptions) validate() error { if o.CloseInterval < 0 { return fmt.Errorf("--close-interval must be >= 0, got %s", o.CloseInterval) } + if o.ZstdWorkers < 0 { + return fmt.Errorf("--zstd-workers must be >= 0 (0 = single-threaded), got %d", o.ZstdWorkers) + } return nil } +// prepareHotRun validates opts and readies the out dir and hot root, so an +// unwritable path surfaces before the expensive run, not after it. +func prepareHotRun(opts hotOptions) (geometry.Layout, error) { + if err := opts.validate(); err != nil { + return geometry.Layout{}, err + } + if err := os.MkdirAll(opts.OutDir, 0o755); err != nil { + return geometry.Layout{}, fmt.Errorf("create --out dir %s: %w", opts.OutDir, err) + } + layout := geometry.NewLayout(opts.HotRoot) + // Create + fsync the hot root up front — the daemon's own root prep. + if err := config.PrepareRoots(layout.HotRoot()); err != nil { + return geometry.Layout{}, fmt.Errorf("prepare --hot-dir hot root: %w", err) + } + return layout, nil +} + // runHot benchmarks the hot path: the daemon's ingestion loop (via // rpcv2.RunBoundedIngestionLoop) over the range's ledgers, into fresh hot // DBs opened through a scratch catalog. A no-op boundary discards completed @@ -91,18 +124,10 @@ func (o hotOptions) validate() error { // close cadence: the run measures steady-state keep-up rather than catch-up // throughput, recording per-ledger pace_lag. func runHot(ctx context.Context, logger *supportlog.Entry, opts hotOptions) error { - if err := opts.validate(); err != nil { + layout, err := prepareHotRun(opts) + if err != nil { return err } - // Surface an unwritable --out before the expensive run, not after it. - if err := os.MkdirAll(opts.OutDir, 0o755); err != nil { - return fmt.Errorf("create --out dir %s: %w", opts.OutDir, err) - } - layout := geometry.NewLayout(opts.HotRoot) - // Create + fsync the hot root up front — the daemon's own root prep. - if err := config.PrepareRoots(layout.HotRoot()); err != nil { - return fmt.Errorf("prepare --hot-dir hot root: %w", err) - } catalogBase := opts.CatalogDir if catalogBase == "" { catalogBase = opts.HotRoot @@ -131,6 +156,20 @@ func runHot(ctx context.Context, logger *supportlog.Entry, opts hotOptions) erro sink := newCSVSink() stream, schedule := buildHotStream(backend, first, last, opts.CloseInterval) sink.schedule = schedule + if opts.TraceFile != "" { + trace, terr := newHotTrace(opts.TraceFile) + if terr != nil { + return terr + } + // Close on every exit so a failed run keeps its partial trace; a trace + // write error is logged, never a run failure (the trace is diagnostics). + defer func() { + if cerr := trace.close(); cerr != nil { + logger.Warnf("per-ledger trace: %v", cerr) + } + }() + sink.trace = trace + } start := time.Now() err = rpcv2.RunBoundedIngestionLoop(ctx, rpcv2.BoundedIngestConfig{ @@ -141,6 +180,7 @@ func runHot(ctx context.Context, logger *supportlog.Entry, opts hotOptions) erro Logger: logger, Metrics: sink, Sink: sink, + Tuning: hotchunk.Tuning{ZstdEncodeWorkers: opts.ZstdWorkers}, }) // VmHWM never decreases, so it can be read right here — before the // completion check — and a failed run's partial CSV still gets the row. diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/profile.go b/cmd/stellar-rpc/internal/rpcv2/bench/profile.go index 80d20d217..22e56a23d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/profile.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/profile.go @@ -3,7 +3,9 @@ package bench import ( "fmt" "os" + "runtime" "runtime/pprof" + "time" "github.com/spf13/cobra" @@ -13,8 +15,9 @@ import ( // profileFlags is the optional Go-profiling flag pair shared by both // subcommands. type profileFlags struct { - cpuProfile string - memProfile string + cpuProfile string + memProfile string + memProfileAt int } func (p *profileFlags) bind(cmd *cobra.Command) { @@ -22,6 +25,9 @@ func (p *profileFlags) bind(cmd *cobra.Command) { fs.StringVar(&p.cpuProfile, "cpuprofile", "", "write a Go CPU profile to PATH") fs.StringVar(&p.memProfile, "memprofile", "", "write a Go allocation profile to PATH (read it with `go tool pprof -alloc_space`)") + fs.IntVar(&p.memProfileAt, "memprofile-at", 0, + "ALSO write a live-heap snapshot (read with -inuse_space) to PATH.at after this many seconds — "+ + "for locating a mid-run peak the exit profile cannot see (requires --memprofile)") } // around runs fn under the optional profiles: the CPU profile spans the whole @@ -42,6 +48,24 @@ func (p *profileFlags) around(logger *supportlog.Entry, fn func() error) error { logger.Infof("wrote CPU profile to %s", p.cpuProfile) }() } + if p.memProfileAt > 0 && p.memProfile != "" { + go func() { + time.Sleep(time.Duration(p.memProfileAt) * time.Second) + path := p.memProfile + ".at" + f, err := os.Create(path) + if err != nil { + logger.Warnf("memprofile-at: create %s: %v", path, err) + return + } + defer f.Close() + runtime.GC() // settle live-set so inuse_space is the truth, not pacing slack + if err := pprof.WriteHeapProfile(f); err != nil { + logger.Warnf("memprofile-at: write %s: %v", path, err) + return + } + logger.Infof("wrote mid-run heap snapshot to %s", path) + }() + } err := fn() if p.memProfile != "" { p.writeHeapProfile(logger) diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/scratch.go b/cmd/stellar-rpc/internal/rpcv2/bench/scratch.go index e87dbdd33..465a9c5c0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/scratch.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/scratch.go @@ -13,6 +13,22 @@ import ( const catalogBaseDirPerm os.FileMode = 0o755 // owner rwx, group/others rx +// benchCatalogSecret pins every scratch catalog's cold-index secret to one +// fixed value. A scratch catalog is created and destroyed per bench run while +// the hot DBs under --work-dir are deliberately reused across runs (freeze +// --reuse-hot), and a hot chunk is bound to the secret its sealed runs were +// blinded with — a per-run mint would make an adopted chunk unfreezable. The +// value is arbitrary: benches measure the cost of keyed routing, and only a +// deployment's minted secret has to be unpredictable. +// The copy-into-a-zero-array form is deliberate: a []byte→[32]byte conversion +// would make the literal's LENGTH load-bearing and panic at process start — +// inside a shipped subcommand — if anyone edited the string by one character. +func benchCatalogSecret() [32]byte { + var s [32]byte + copy(s[:], "bench-catalog-secret-fixed-value") + return s +} + // openScratchCatalog creates a fresh catalog in a temp dir under catalogBase // and returns a release func that closes it and removes that temp dir. func openScratchCatalog( @@ -30,7 +46,8 @@ func openScratchCatalog( _ = os.RemoveAll(dir) return nil, nil, err } - cat, err := catalog.Open(filepath.Join(dir, "catalog"), layout, txLayout, logger) + cat, err := catalog.Open(filepath.Join(dir, "catalog"), layout, txLayout, logger, + catalog.WithSecret(benchCatalogSecret())) if err != nil { _ = os.RemoveAll(dir) return nil, nil, fmt.Errorf("open scratch catalog: %w", err) diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/trace.go b/cmd/stellar-rpc/internal/rpcv2/bench/trace.go new file mode 100644 index 000000000..f37b762c3 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/trace.go @@ -0,0 +1,116 @@ +package bench + +import ( + "bufio" + "fmt" + "os" + "time" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" +) + +// hotTraceHeader is the per-ledger trace CSV's header row: one row per +// ingested ledger, wall-clock stamped, with every phase duration — the +// row-level data the aggregated hot.csv percentiles are built from. wall_ns +// is Unix nanoseconds at the ledger's PhaseExtract signal (the burst's first), +// so rows can be correlated against external timelines (RocksDB LOG flush +// events, iostat samples). pace_lag_ns is 0 on unpaced runs. +const hotTraceHeader = "seq,wall_ns,extract_ns,ledgers_ns,txhash_ns,events_ns,commit_ns,apply_ns,total_ns,pace_lag_ns" + +// hotTrace streams one CSV row per ingested ledger to --trace. The csvSink +// feeds it under the sink's own mutex (recordPhase from HotPhase, writeRow +// from LastCommitted), so it adds no locking of its own. The production hot +// loop is a single goroutine emitting strict per-ledger phase bursts +// (extract → … → apply, then LastCommitted), which is what makes the +// pending-row accumulation correct. +type hotTrace struct { + f *os.File + w *bufio.Writer + + // pending accumulates the current ledger's phase durations; wallStart + // stamps the burst's first signal. complete latches on PhaseApply — the + // burst's terminal phase. PhaseApply is also emitted when the apply + // itself fails, but the ingest loop then aborts before LastCommitted, + // so writeRow still never attributes a partial burst to a committed seq. + pending [hotchunk.NumPhases]time.Duration + wallStart time.Time + complete bool + + // err records the first write failure; tracing stops reporting rows + // after it (close surfaces it once, the run itself is never failed). + err error +} + +// newHotTrace creates (truncates) path and writes the header row. +func newHotTrace(path string) (*hotTrace, error) { + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("create --trace %s: %w", path, err) + } + w := bufio.NewWriterSize(f, 1<<16) + if _, err := fmt.Fprintln(w, hotTraceHeader); err != nil { + _ = f.Close() + return nil, fmt.Errorf("write --trace header: %w", err) + } + return &hotTrace{f: f, w: w}, nil +} + +// recordPhase folds one phase signal into the pending row. PhaseExtract (the +// burst's first signal) resets the row and stamps its wall time; PhaseApply +// (terminal) marks it complete for writeRow. +func (t *hotTrace) recordPhase(phase hotchunk.Phase, d time.Duration) { + if phase == hotchunk.PhaseExtract { + t.pending = [hotchunk.NumPhases]time.Duration{} + t.wallStart = time.Now() + t.complete = false + } + t.pending[phase] = d + if phase == hotchunk.PhaseApply { + t.complete = true + } +} + +// writeRow emits the pending row as ledger seq's trace line and resets it. +// A row whose burst never completed (a failed ledger's partial burst, or a +// LastCommitted with no burst at all) is dropped — aggregates already carry +// partial-phase samples; the trace carries only whole ledgers. +func (t *hotTrace) writeRow(seq uint32, paceLag time.Duration) { + if !t.complete { + return + } + t.complete = false + if t.err != nil { + return + } + var total time.Duration + for _, d := range t.pending { + total += d + } + _, err := fmt.Fprintf(t.w, "%d,%d,%d,%d,%d,%d,%d,%d,%d,%d\n", + seq, t.wallStart.UnixNano(), + t.pending[hotchunk.PhaseExtract].Nanoseconds(), + t.pending[hotchunk.PhaseLedgers].Nanoseconds(), + t.pending[hotchunk.PhaseTxhash].Nanoseconds(), + t.pending[hotchunk.PhaseEvents].Nanoseconds(), + t.pending[hotchunk.PhaseCommit].Nanoseconds(), + t.pending[hotchunk.PhaseApply].Nanoseconds(), + total.Nanoseconds(), paceLag.Nanoseconds()) + if err != nil { + t.err = fmt.Errorf("write --trace row seq %d: %w", seq, err) + } +} + +// close flushes and closes the trace file, returning the first error the +// trace hit (a mid-run write failure, a flush failure, or the close itself). +func (t *hotTrace) close() error { + ferr := t.w.Flush() + cerr := t.f.Close() + switch { + case t.err != nil: + return t.err + case ferr != nil: + return fmt.Errorf("flush --trace: %w", ferr) + default: + return cerr + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/txindex.go b/cmd/stellar-rpc/internal/rpcv2/bench/txindex.go new file mode 100644 index 000000000..59e735f05 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/txindex.go @@ -0,0 +1,144 @@ +package bench + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + supportlog "github.com/stellar/go-stellar-sdk/support/log" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/txhash" +) + +// txindexOptions configures one rolling txhash-index build benchmark: the +// production BuildColdIndex (k-way .bin merge feeding the streamhash MPHF +// build) over a window of per-chunk .bin files. The production window is +// DefaultChunksPerIndex (1000) chunks; every earlier bench only ever handed +// it ONE .bin, so its wall/RSS at terminal-window scale was unmeasured. +type txindexOptions struct { + // BinDir holds the window's `.bin` files (synthetic + // fixtures or real artifacts; the builder cannot tell — the format is + // compile-time shared). + BinDir string + // NumBins caps how many .bins (in chunk-ID order from the dir's start) + // the window includes; 0 = all. The cap is how one fixture tree serves a + // whole scaling curve. + NumBins int + // IndexOut is the .idx output path. Scratch: overwritten per run. + IndexOut string + // OutDir receives the CSV report. + OutDir string +} + +func (o txindexOptions) validate() error { + if o.BinDir == "" { + return errors.New("--bin-dir is required") + } + if o.IndexOut == "" { + return errors.New("--index-out is required") + } + if o.NumBins < 0 { + return fmt.Errorf("--num-bins must be >= 0, got %d", o.NumBins) + } + return nil +} + +// runTxindex drives txhash.BuildColdIndex exactly as the backfill's window +// rebuild does (same call, default options, ledger range derived from the +// window's first/last chunk IDs) and reports wall, peak RSS, and output size. +func runTxindex(ctx context.Context, logger *supportlog.Entry, opts txindexOptions) error { + if err := opts.validate(); err != nil { + return err + } + if err := os.MkdirAll(opts.OutDir, 0o755); err != nil { + return fmt.Errorf("create --out dir %s: %w", opts.OutDir, err) + } + + bins, first, last, err := windowBins(opts.BinDir, opts.NumBins) + if err != nil { + return err + } + logger.Infof("txindex build: %d bins, chunks [%s, %s], ledgers [%d, %d]", + len(bins), first, last, first.FirstLedger(), last.LastLedger()) + + sink := newCSVSink() + start := time.Now() + err = txhash.BuildColdIndex(ctx, bins, opts.IndexOut, first.FirstLedger(), last.LastLedger()) + sink.Rebuild(time.Since(start)) + recordPeakRSS(logger, sink, readPeakRSS) + if err != nil { + writePartialCSVs(logger, sink, opts.OutDir) + return fmt.Errorf("BuildColdIndex over %d bins: %w", len(bins), err) + } + wall := time.Since(start) + + if st, serr := os.Stat(opts.IndexOut); serr == nil { + logger.Infof("index built: %s = %.2f GB in %s", + opts.IndexOut, float64(st.Size())/1e9, wall.Round(time.Millisecond)) + } + sink.logSummary(logger) + written, werr := sink.writeCSVs(opts.OutDir) + if werr != nil { + return werr + } + logger.Infof("wrote %d CSVs to %s", len(written), opts.OutDir) + return nil +} + +// windowBins lists dir's `.bin` files in chunk order, applies +// the cap, and returns the window's first/last chunk IDs. Chunk IDs must be +// contiguous — a gap would silently narrow the coverage the ledger range +// claims, so it is rejected. +func windowBins(dir string, maxBins int) ([]string, chunk.ID, chunk.ID, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, 0, 0, fmt.Errorf("read --bin-dir %s: %w", dir, err) + } + type binFile struct { + id chunk.ID + path string + } + var files []binFile + for _, e := range entries { + name := e.Name() + if !strings.HasSuffix(name, ".bin") { + continue + } + // geometry.ParsePadded is the canonical inverse of chunk.ID.String() + // (the producer of these names via txhash.ColdBinName) and enforces + // the fixed 8-digit width, keeping the bijection exact. + id, perr := geometry.ParsePadded(strings.TrimSuffix(name, ".bin")) + if perr != nil { + return nil, 0, 0, fmt.Errorf("non-chunk .bin name %q in %s: %w", name, dir, perr) + } + // Same guard every sibling harness applies: past maxChunkID the + // FirstLedger/LastLedger arithmetic wraps mod 2^32 and the window + // would build with silently wrong ledger coverage. + if chunk.ID(id) > maxChunkID { + return nil, 0, 0, fmt.Errorf("chunk ID %d in %s is past the last valid chunk ID %d", id, name, uint32(maxChunkID)) + } + files = append(files, binFile{id: chunk.ID(id), path: filepath.Join(dir, name)}) + } + if len(files) == 0 { + return nil, 0, 0, fmt.Errorf("no .bin files in %s", dir) + } + sort.Slice(files, func(i, j int) bool { return files[i].id < files[j].id }) + if maxBins > 0 && maxBins < len(files) { + files = files[:maxBins] + } + paths := make([]string, len(files)) + for i, f := range files { + paths[i] = f.path + if i > 0 && f.id != files[i-1].id+1 { + return nil, 0, 0, fmt.Errorf("chunk gap in window: %s then %s", files[i-1].id, f.id) + } + } + return paths, files[0].id, files[len(files)-1].id, nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go new file mode 100644 index 000000000..d0d1e8eb0 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go @@ -0,0 +1,93 @@ +package bench + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/txhash" +) + +// benchTestSecret keys the bench fixtures — any fixed non-zero value works; +// BuildColdIndex adopts whatever secret the .bin headers carry. +var benchTestSecret = [stores.SecretLen]byte{3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8, 9, 7, 9, 3} + +// writeFixtureBin writes chunk c's synthetic sorted .bin with n entries. + +func writeFixtureBin(t *testing.T, dir string, c chunk.ID, n int) { + t.Helper() + entries := make([]txhash.ColdEntry, n) + state := uint64(0xBEEF) + uint64(c)*0x9E3779B97F4A7C15 + for i := range entries { + for w := range 2 { + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + for b := range 8 { + entries[i].Key[w*8+b] = byte(state >> (56 - 8*b)) + } + } + entries[i].Seq = c.FirstLedger() + uint32(state%uint64(chunk.LedgersPerChunk)) + } + slices.SortFunc(entries, func(a, b txhash.ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) + require.NoError(t, + txhash.WriteColdBin(filepath.Join(dir, fmt.Sprintf("%08d.bin", uint32(c))), benchTestSecret, entries)) +} + +// TestRunTxindex: a three-chunk window builds through the production +// BuildColdIndex call and reports the index_rebuild row. +func TestRunTxindex(t *testing.T) { + binDir := t.TempDir() + for c := chunk.ID(0); c <= 2; c++ { + writeFixtureBin(t, binDir, c, 1000) + } + csvDir := filepath.Join(t.TempDir(), "csv") + idxPath := filepath.Join(t.TempDir(), "window.idx") + require.NoError(t, runTxindex(context.Background(), testLogger(), txindexOptions{ + BinDir: binDir, + IndexOut: idxPath, + OutDir: csvDir, + })) + st, err := os.Stat(idxPath) + require.NoError(t, err) + require.Positive(t, st.Size()) + driver := readCSV(t, filepath.Join(csvDir, "driver.csv")) + require.Contains(t, driver, "index_rebuild") +} + +// TestRunTxindex_NumBinsCap: the cap narrows the window from the front. +func TestRunTxindex_NumBinsCap(t *testing.T) { + binDir := t.TempDir() + for c := chunk.ID(0); c <= 4; c++ { + writeFixtureBin(t, binDir, c, 500) + } + require.NoError(t, runTxindex(context.Background(), testLogger(), txindexOptions{ + BinDir: binDir, + NumBins: 2, + IndexOut: filepath.Join(t.TempDir(), "w.idx"), + OutDir: filepath.Join(t.TempDir(), "csv"), + })) +} + +// TestRunTxindex_RejectsGap: a missing chunk in the window is a hard error, +// not a silently narrower coverage. +func TestRunTxindex_RejectsGap(t *testing.T) { + binDir := t.TempDir() + writeFixtureBin(t, binDir, 0, 100) + writeFixtureBin(t, binDir, 2, 100) // gap at 1 + err := runTxindex(context.Background(), testLogger(), txindexOptions{ + BinDir: binDir, + IndexOut: filepath.Join(t.TempDir(), "w.idx"), + OutDir: filepath.Join(t.TempDir(), "csv"), + }) + require.Error(t, err) + require.Contains(t, err.Error(), "chunk gap") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go b/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go index a1142a5e3..8fcba6d38 100644 --- a/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go +++ b/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go @@ -20,7 +20,8 @@ import ( // to disk paths and the tx-hash-index arithmetic) it adds the one-write // protocol (catalog_protocol.go) and the key-driven sweeps (catalog_sweep.go). // -// Every key names a file/dir state or a config pin; progress is derived, never +// Every key names a file/dir state, a config pin, or deployment +// metadata (the cold-index secret); progress is derived, never // stored. // // The read-then-act sequences in the write protocol and the sweeps carry no @@ -34,13 +35,40 @@ type Catalog struct { secret [32]byte // cold-index secret, minted once at Open then read-only } +// Option customizes Open. Deployments pass none. +type Option func(*openOptions) + +type openOptions struct { + secret *[32]byte +} + +// WithSecret pins the cold-index secret a FRESH catalog mints, instead of +// drawing a random one. A catalog that already has one keeps it — its keys +// are blinded under it — so this only ever decides the first open. +// +// Production never passes it: the mint is the whole point, and a random +// deployment-wide secret is what makes blinded routing unpredictable. It +// exists for harnesses whose catalog is scratch while their HOT DBs outlive +// it. A hot chunk is bound to the secret its runs were sealed under +// (hotchunk.DeriveSecrets — the engines blind at seal), so a harness that +// re-mints per run could not freeze, or even reopen, a chunk it populated in +// a previous run. +func WithSecret(secret [32]byte) Option { + return func(o *openOptions) { o.secret = &secret } +} + // Open opens the catalog's backing KV store at path (created if absent) and // binds the catalog to it, the on-disk layout, and the tx-hash-index // arithmetic. path and logger are required (rocksdb.New validates both). The // catalog owns the store: Close releases it. func Open( path string, layout geometry.Layout, txhashIndex geometry.TxHashIndexLayout, logger *supportlog.Entry, + opts ...Option, ) (*Catalog, error) { + var o openOptions + for _, apply := range opts { + apply(&o) + } store, err := rocksdb.New(rocksdb.Config{Path: path, Logger: logger}) if err != nil { return nil, err @@ -58,7 +86,7 @@ func Open( // Mint-or-load the cold-index secret up front (get-or-create is not atomic; // here it runs single-threaded) and cache it, so post-Open Secret() reads are // lock-free and cannot fail. - secret, err := c.ensureSecret() + secret, err := c.ensureSecret(o.secret) if err != nil { _ = c.Close() return nil, fmt.Errorf("catalog: ensure cold-index secret: %w", err) diff --git a/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go b/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go index ae825c722..2708f8a05 100644 --- a/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go +++ b/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go @@ -2,6 +2,7 @@ package catalog import ( "crypto/rand" + "errors" ) // catalogSecretStoreKey holds the deployment's cold-index secret. @@ -19,17 +20,41 @@ func (c *Catalog) Secret() [32]byte { return c.secret } // fresh random one on first call. Open runs it single-threaded, after the // census has already validated any persisted value's width, and caches the // result; nothing else should call it (get-or-create is not atomic). -func (c *Catalog) ensureSecret() ([32]byte, error) { +// +// pinned (catalog.WithSecret) replaces the random draw for a catalog that has +// none yet; a persisted secret always wins over it, because the catalog's +// keys are already blinded under the persisted one. +func (c *Catalog) ensureSecret(pinned *[32]byte) ([32]byte, error) { var s [32]byte v, found, err := c.get(catalogSecretStoreKey) if err != nil { return s, err } if found { + // No width check here: the census is the guard. Open runs it over + // every stored key before this function, and it refuses a + // catalogSecretStoreKey value that is not exactly len(s) bytes (as + // ErrForeignCatalog, before anything writes), so the copy below + // cannot zero-pad a short value into a plausible-looking secret. A + // caller that ever reaches ensureSecret without that census first + // owns the check. copy(s[:], v) + // An all-zero persisted secret is corruption or tampering, never a + // mint (crypto/rand). HKDF would launder it into non-zero per-index + // secrets that pass every downstream zero-check while making all + // blinded routing attacker-predictable — the exact threat the secret + // exists to close. Refuse to open. + if s == ([32]byte{}) { + return s, errors.New("persisted cold-index secret is all zero (corrupt store?)") + } return s, nil } - if _, err := rand.Read(s[:]); err != nil { + if pinned != nil { + s = *pinned + if s == ([32]byte{}) { + return s, errors.New("catalog: pinned cold-index secret is all zero") + } + } else if _, err := rand.Read(s[:]); err != nil { return s, err } if err := c.put(catalogSecretStoreKey, string(s[:])); err != nil { diff --git a/cmd/stellar-rpc/internal/rpcv2/catalog/secret_test.go b/cmd/stellar-rpc/internal/rpcv2/catalog/secret_test.go index e338b4b2e..020b4b338 100644 --- a/cmd/stellar-rpc/internal/rpcv2/catalog/secret_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/catalog/secret_test.go @@ -45,3 +45,24 @@ func TestSecret_RejectsCorruptedPersisted(t *testing.T) { require.ErrorContains(t, err, "value is 5 bytes, want 32") require.NotContains(t, err.Error(), "short", "the secret's value must never appear in the error") } + +// TestSecret_RejectsPersistedAllZero pins that an all-zero persisted secret is +// rejected at Open rather than adopted. It is the right LENGTH, so the +// corruption check above waves it through, but blinding under it is no +// blinding at all: every derived per-index secret becomes a constant an +// attacker can reproduce, so influenced keys can be steered into one block. +// A zeroed page from a torn write, or a deployment that never minted one, must +// fail loudly instead of silently serving an unblinded deployment. +func TestSecret_RejectsPersistedAllZero(t *testing.T) { + path := t.TempDir() + + cat, err := openKVAt(t, path) + require.NoError(t, err) + var zero [32]byte + require.NoError(t, cat.put(catalogSecretStoreKey, string(zero[:]))) + require.NoError(t, cat.Close()) + + _, err = openKVAt(t, path) + require.Error(t, err, "an all-zero persisted secret must fail Open, not be adopted") + require.ErrorContains(t, err, "all zero") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/config/config.go b/cmd/stellar-rpc/internal/rpcv2/config/config.go index 4b88d3545..eb2b64e70 100644 --- a/cmd/stellar-rpc/internal/rpcv2/config/config.go +++ b/cmd/stellar-rpc/internal/rpcv2/config/config.go @@ -15,6 +15,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/backfill" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" ) // Config is the on-disk --config TOML schema for the full-history daemon (design @@ -217,8 +218,8 @@ type RetentionConfig struct { // StorageConfig is [storage]: the data root plus one optional path per on-disk // tree (consolidating what were the separate [catalog] / [immutable_storage.*] / -// [streaming.hot_storage] sections). An empty per-store value defaults under -// default_data_dir. +// [streaming.hot_storage] sections), plus the stored-format settings of those +// trees. An empty per-store value defaults under default_data_dir. type StorageConfig struct { // Base dir for the catalog and default storage paths. Required. // (Moved here from [service] in #882 — it governs storage placement.) @@ -230,6 +231,21 @@ type StorageConfig struct { TxhashRaw string `toml:"txhash_raw"` // transient txhash .bin root TxhashIndex string `toml:"txhash_index"` // frozen txhash .idx root Hot string `toml:"hot"` // per-chunk hot RocksDB root + + // ZstdEncodeWorkers is the ledger-frame zstd encode parallelism: 0 = + // single-threaded, >=1 = libzstd's internal multithreading (>= 1 requires + // a ZSTD_MULTITHREAD libzstd build). Default + // ledger.DefaultZstdEncodeWorkers (2). + // + // FORMAT-AFFECTING, not a casual performance knob: single- and + // multi-threaded encodes produce different frame bytes for the SAME + // ledger, the freeze copies hot frames into the cold pack verbatim, and + // walk/backfill re-encodes independently — this one value feeds both + // encoders (hotchunk.Tuning and the backfill cold writer) precisely so + // they agree and a chunk's pack stays byte-identical whichever + // materializer built it. Change it only as a deliberate, deployment-wide + // decision. + ZstdEncodeWorkers *int `toml:"zstd_encode_workers"` } // BackfillConfig is [backfill] plus the nested [backfill.datastore] and [backfill.bsb]. @@ -565,6 +581,10 @@ func (cfg Config) WithDefaults() Config { v := backfill.DefaultWorkers() cfg.Backfill.Workers = &v } + if cfg.Storage.ZstdEncodeWorkers == nil { + v := ledger.DefaultZstdEncodeWorkers + cfg.Storage.ZstdEncodeWorkers = &v + } if cfg.Backfill.MaxRetries == nil { v := DefaultMaxRetries cfg.Backfill.MaxRetries = &v diff --git a/cmd/stellar-rpc/internal/rpcv2/config_validate.go b/cmd/stellar-rpc/internal/rpcv2/config_validate.go index ede712944..12d15a00e 100644 --- a/cmd/stellar-rpc/internal/rpcv2/config_validate.go +++ b/cmd/stellar-rpc/internal/rpcv2/config_validate.go @@ -73,6 +73,10 @@ func validateForm(cfg config.Config) error { if maxRetries := deref(cfg.Backfill.MaxRetries); maxRetries < 0 { return fmt.Errorf("max_retries must be >= 0 (got %d) — 0 means run once, no retry", maxRetries) } + if w := deref(cfg.Storage.ZstdEncodeWorkers); w < 0 { + return fmt.Errorf( + "storage.zstd_encode_workers must be >= 0 (got %d) — 0 means single-threaded ledger-frame encode", w) + } if err := validateBSB(cfg.Backfill.BSB); err != nil { return err } diff --git a/cmd/stellar-rpc/internal/rpcv2/config_validate_test.go b/cmd/stellar-rpc/internal/rpcv2/config_validate_test.go index 82b2b7766..415c80eb4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/config_validate_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/config_validate_test.go @@ -136,6 +136,7 @@ func TestValidateConfig_RejectsMalformed(t *testing.T) { } } +//nolint:funlen // one table, one rejection per row — splitting the table hides the coverage, not the length func TestValidateConfig_RejectsMalformedService(t *testing.T) { uintPtr := func(v uint) *uint { return &v } uint32Ptr := func(v uint32) *uint32 { return &v } diff --git a/cmd/stellar-rpc/internal/rpcv2/daemon.go b/cmd/stellar-rpc/internal/rpcv2/daemon.go index 2d1e7656d..4fed0d0ee 100644 --- a/cmd/stellar-rpc/internal/rpcv2/daemon.go +++ b/cmd/stellar-rpc/internal/rpcv2/daemon.go @@ -31,6 +31,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/ingest" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/observability" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/query" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // RunDaemon is the full-history daemon's process entrypoint: load config, lock @@ -385,6 +386,11 @@ func startConfig( serveReads func(context.Context, *query.Registry, net.Listener) error, metrics observability.Metrics, sink ingest.MetricSink, retention geometry.Retention, ) StartConfig { + // ONE resolved zstd_encode_workers value feeds BOTH ledger-frame encoders + // — the hot tier's (HotTuning) and the walk/backfill cold writer's + // (Process.ZstdEncodeWorkers) — because the setting is FORMAT-AFFECTING + // and the two must agree (see hotchunk.Tuning). + zstdWorkers := deref(cfg.Storage.ZstdEncodeWorkers) exec := backfill.ExecConfig{ Catalog: cat, Logger: logger, @@ -392,8 +398,9 @@ func startConfig( Workers: deref(cfg.Backfill.Workers), MaxRetries: deref(cfg.Backfill.MaxRetries), Process: backfill.ProcessConfig{ - Backend: backend, - Sink: sink, + Backend: backend, + Sink: sink, + ZstdEncodeWorkers: zstdWorkers, }, } return StartConfig{ @@ -401,6 +408,7 @@ func startConfig( Retention: retention, Core: core, ServeReads: serveReads, + HotTuning: hotchunk.Tuning{ZstdEncodeWorkers: zstdWorkers}, Endpoint: cfg.Service.Endpoint, } } diff --git a/cmd/stellar-rpc/internal/rpcv2/e2e_test.go b/cmd/stellar-rpc/internal/rpcv2/e2e_test.go index dbb022638..47356065d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/e2e_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/e2e_test.go @@ -260,6 +260,25 @@ func hashAt(n uint64) [32]byte { return h } +// reopenHotChunk reopens a chunk's hot DB after the daemon that owned it has +// stopped, retrying because RocksDB's process-level LOCK can linger +// momentarily after the writer closed — the same transient a production +// reader retries through. +func reopenHotChunk(t *testing.T, cat *catalog.Catalog, c chunk.ID) *hotchunk.DB { + t.Helper() + var db *hotchunk.DB + require.Eventually(t, func() bool { + opened, oerr := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), + hotchunk.DefaultTuning(), hotchunk.SecretsFor(cat, c)) + if oerr != nil { + return false + } + db = opened + return true + }, 10*time.Second, 50*time.Millisecond, "the chunk's hot DB must be reopenable after shutdown") + return db +} + // TestE2E_DaemonLifecycle_FirstStartIngestFreezeLookupRestartPrune drives the // whole daemon lifecycle in one process against the real stores and the fake // ledger source: @@ -432,17 +451,7 @@ func TestE2E_DaemonLifecycle_FirstStartIngestFreezeLookupRestartPrune(t *testing require.NoError(t, err) require.Equal(t, geometry.State(""), c2lfs, "the live chunk has no cold artifacts yet") - // Retry the open: RocksDB's process-level LOCK can linger momentarily after the - // writer closed (the same transient a production reader retries through). - var liveDB *hotchunk.DB - require.Eventually(t, func() bool { - db, oerr := hotchunk.Open(postCat.Layout().HotChunkPath(c2), c2, silentLogger()) - if oerr != nil { - return false - } - liveDB = db - return true - }, 10*time.Second, 50*time.Millisecond, "chunk 2's hot DB must be reopenable after shutdown") + liveDB := reopenHotChunk(t, postCat, c2) hotSeq, err := liveDB.Txhash().Get(hotHash) require.NoError(t, err, "the chunk-2 tx hash must resolve from the live hot CF") assert.Equal(t, c2First, hotSeq, "hot lookup returns the live tx's ledger") diff --git a/cmd/stellar-rpc/internal/rpcv2/feereplay_test.go b/cmd/stellar-rpc/internal/rpcv2/feereplay_test.go index b34fdf2a5..6f9a4a697 100644 --- a/cmd/stellar-rpc/internal/rpcv2/feereplay_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/feereplay_test.go @@ -13,6 +13,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/query" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rpcv2test" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // TestFeeWindowReplay_SpansChunkBoundary is issue #888's core case: a restart @@ -52,7 +53,7 @@ func TestFeeWindowReplay_SpansChunkBoundary(t *testing.T) { rpcv2test.IngestLedger(t, db1, seq, lcmFor(seq)) } - registry, err := query.OpenRegistry(cat, geometry.NewRetention(0, 0), db1, lastCommitted) + registry, err := query.OpenRegistry(cat, geometry.NewRetention(0, 0), db1, lastCommitted, hotchunk.Tuning{}) require.NoError(t, err) defer registry.Close() @@ -90,7 +91,7 @@ func TestFeeWindowReplay_FreshStartIsNoOp(t *testing.T) { db0 := openLiveHotDB(t, cat, c0) // empty live chunk lastCommitted := c0.FirstLedger() - 1 - registry, err := query.OpenRegistry(cat, geometry.NewRetention(0, 0), db0, lastCommitted) + registry, err := query.OpenRegistry(cat, geometry.NewRetention(0, 0), db0, lastCommitted, hotchunk.Tuning{}) require.NoError(t, err) defer registry.Close() @@ -113,7 +114,7 @@ func TestFeeWindowReplay_ShortHistoryClampsToOldest(t *testing.T) { rpcv2test.IngestLedger(t, db0, seq, rpcv2test.FeeTxLCMBytes(t, seq, int64(seq))) } - registry, err := query.OpenRegistry(cat, geometry.NewRetention(0, 0), db0, lastCommitted) + registry, err := query.OpenRegistry(cat, geometry.NewRetention(0, 0), db0, lastCommitted, hotchunk.Tuning{}) require.NoError(t, err) defer registry.Close() diff --git a/cmd/stellar-rpc/internal/rpcv2/hotloop.go b/cmd/stellar-rpc/internal/rpcv2/hotloop.go index e6ef031da..803549229 100644 --- a/cmd/stellar-rpc/internal/rpcv2/hotloop.go +++ b/cmd/stellar-rpc/internal/rpcv2/hotloop.go @@ -38,8 +38,16 @@ import ( // - "transient" or absent: wipe any leftover dir and create fresh // (transient -> fsync dir+parent -> ready), so a crash mid-create can't // fabricate a "ready but DB gone" open failure above. -func openHotDBForChunk(cat *catalog.Catalog, chunkID chunk.ID, logger *supportlog.Entry) (*hotchunk.DB, error) { +func openHotDBForChunk( + cat *catalog.Catalog, chunkID chunk.ID, logger *supportlog.Entry, tun hotchunk.Tuning, +) (*hotchunk.DB, error) { dir := cat.Layout().HotChunkPath(chunkID) + // The chunk's hot routing secrets, derived exactly as its COLD builds + // derive theirs (hotchunk.SecretsFor): both hot engines blind at seal, + // so their runs must be keyed with the secret the freeze will declare. + // A resumed chunk re-derives the same values and the open adopts them; a + // disagreement (a re-minted catalog secret) fails the open loudly. + secrets := hotchunk.SecretsFor(cat, chunkID) state, err := cat.HotState(chunkID) if err != nil { @@ -47,8 +55,11 @@ func openHotDBForChunk(cat *catalog.Catalog, chunkID chunk.ID, logger *supportlo } if state == geometry.HotReady { - // OpenReadyWrite is the single ready-open enforcement site. - return hotchunk.OpenReadyWrite(state, dir, chunkID, logger) + // Resume/boundary write handle for a chunk whose "ready" key promises the DB + // exists: must-exist, never-creating (a gutted DB fails restartably, never + // auto-heals into a fresh empty DB). OpenReadyWrite routes through the single + // ready-open enforcement site. + return hotchunk.OpenReadyWrite(state, dir, chunkID, logger, tun, secrets) } // The create bracket: BeginHotCreate wipes + marks transient; FinishHotCreate @@ -56,7 +67,7 @@ func openHotDBForChunk(cat *catalog.Catalog, chunkID chunk.ID, logger *supportlo if beginErr := cat.BeginHotCreate(chunkID); beginErr != nil { return nil, beginErr } - db, openErr := hotchunk.Open(dir, chunkID, logger) + db, openErr := hotchunk.Open(dir, chunkID, logger, tun, secrets) if openErr != nil { return nil, fmt.Errorf("create hot DB chunk %s: %w", chunkID, openErr) } @@ -98,6 +109,11 @@ type ingestionLoopConfig struct { // boundary rebuilds must not wipe fee history — and never resets it. nil // (the bounded bench loop) means fees are never computed. FeeWindows *feewindow.FeeWindows + + // Tuning is the write-open tuning for every hot DB the loop (re)opens at + // chunk boundaries; run() opens the initial HotDB with the same value. + // Its ZstdEncodeWorkers field is FORMAT-AFFECTING (see hotchunk.Tuning). + Tuning hotchunk.Tuning } // handleSink is the slice of the registry the loop publishes into. The daemon's @@ -202,7 +218,7 @@ func runIngestionLoop(ctx context.Context, cfg ingestionLoopConfig) error { // Chunk boundary: this seq is the chunk's last ledger. if closed := chunk.IDFromLedger(seq); seq == closed.LastLedger() { next := closed + 1 - nextDB, oerr := openHotDBForChunk(cfg.Catalog, next, cfg.Logger) + nextDB, oerr := openHotDBForChunk(cfg.Catalog, next, cfg.Logger, cfg.Tuning) if oerr != nil { return fmt.Errorf("open hot DB for chunk %s at boundary: %w", next, oerr) } @@ -252,6 +268,11 @@ type BoundedIngestConfig struct { Logger *supportlog.Entry Metrics observability.Metrics Sink ingest.MetricSink + // Tuning is the write-open tuning for every hot DB the run touches. Its + // ZstdEncodeWorkers field is FORMAT-AFFECTING (see hotchunk.Tuning); + // bench cells resolve it from --zstd-workers (default + // ledger.DefaultZstdEncodeWorkers). + Tuning hotchunk.Tuning } // RunBoundedIngestionLoop runs the ingestion loop over a bounded stream: it @@ -260,7 +281,7 @@ type BoundedIngestConfig struct { // expected termination (errStreamEnded), so unlike the daemon's unbounded run // it is remapped to a nil error rather than surfaced as a run failure. func RunBoundedIngestionLoop(ctx context.Context, cfg BoundedIngestConfig) error { - hotDB, err := openHotDBForChunk(cfg.Catalog, chunk.IDFromLedger(cfg.Resume), cfg.Logger) + hotDB, err := openHotDBForChunk(cfg.Catalog, chunk.IDFromLedger(cfg.Resume), cfg.Logger, cfg.Tuning) if err != nil { return fmt.Errorf("open hot DB for resume ledger %d: %w", cfg.Resume, err) } @@ -274,6 +295,7 @@ func RunBoundedIngestionLoop(ctx context.Context, cfg BoundedIngestConfig) error Metrics: cfg.Metrics, Sink: cfg.Sink, Registry: &closingSink{}, + Tuning: cfg.Tuning, }) if errors.Is(err, errStreamEnded) { return nil diff --git a/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go b/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go index 3149551e9..8e8171793 100644 --- a/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go @@ -131,7 +131,7 @@ func loopConfig( ) (ingestionLoopConfig, *recordingBoundary) { t.Helper() rec := &recordingBoundary{} - db, err := openHotDBForChunk(cat, chunk.IDFromLedger(resume), silentLogger()) + db, err := openHotDBForChunk(cat, chunk.IDFromLedger(resume), silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) return ingestionLoopConfig{ Stream: stream, @@ -162,7 +162,7 @@ func impliedResume(t *testing.T, db *hotchunk.DB) uint32 { // production opener, returning the handle and the catalog it lives under. func openLiveHotDB(t *testing.T, cat *catalog.Catalog, c chunk.ID) *hotchunk.DB { t.Helper() - db, err := openHotDBForChunk(cat, c, silentLogger()) + db, err := openHotDBForChunk(cat, c, silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) return db } @@ -195,7 +195,7 @@ func TestOpenHotTier_CreatesBracketAndDir(t *testing.T) { cat, _ := testCatalog(t) c := chunk.ID(3) - db, err := openHotDBForChunk(cat, c, silentLogger()) + db, err := openHotDBForChunk(cat, c, silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -218,7 +218,7 @@ func TestOpenHotTier_ReadyButDirMissingFailsOpen(t *testing.T) { require.NoError(t, cat.PutHotTransient(c)) require.NoError(t, cat.FlipHotReady(c)) // key says ready, but no dir created - _, err := openHotDBForChunk(cat, c, silentLogger()) + _, err := openHotDBForChunk(cat, c, silentLogger(), hotchunk.DefaultTuning()) require.Error(t, err) } @@ -229,7 +229,7 @@ func TestOpenHotTier_TransientRecreatesFresh(t *testing.T) { c := chunk.ID(2) require.NoError(t, cat.PutHotTransient(c)) // a crash left a transient key - db, err := openHotDBForChunk(cat, c, silentLogger()) + db, err := openHotDBForChunk(cat, c, silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -262,7 +262,8 @@ func TestRunIngestionLoop_LedgerLandsAcrossAllCFs(t *testing.T) { require.Error(t, err, "stream ran past the prefix and errored") // Reopen the (loop-closed) DB and assert every CF advanced together. - reopened, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + reopened, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) t.Cleanup(func() { _ = reopened.Close() }) @@ -434,7 +435,8 @@ func (p *fencePublisher) Publish() { c := chunk.ID(lastComplete) // (1) The closed chunk's write handle must be released: OpenExisting is read-write // and takes the LOCK, so it succeeds only if the loop already closed the handle. - db, err := hotchunk.OpenExisting(p.cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.OpenExisting(p.cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(p.cat, c)) released := err == nil if db != nil { _ = db.Close() @@ -467,7 +469,7 @@ func TestRunIngestionLoop_HandoffFenceClosesBeforeNextKey(t *testing.T) { }, endErr: errors.New("end")} // Build the loop config manually so the boundary publisher is our fence checker. - db, err := openHotDBForChunk(cat, chunk.IDFromLedger(resume), silentLogger()) + db, err := openHotDBForChunk(cat, chunk.IDFromLedger(resume), silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) fence := &fencePublisher{cat: cat} cfg := ingestionLoopConfig{ @@ -497,7 +499,7 @@ func TestRunIngestionLoop_BoundaryTransfersOwnershipToRegistry(t *testing.T) { c1.FirstLedger(): rpcv2test.ZeroTxLCMBytes(t, c1.FirstLedger()), // a ledger in chunk 1 }, endErr: errors.New("end")} - db, err := openHotDBForChunk(cat, chunk.IDFromLedger(resume), silentLogger()) + db, err := openHotDBForChunk(cat, chunk.IDFromLedger(resume), silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) registry := query.NewRegistry(cat, geometry.NewRetention(0, 0)) cfg := ingestionLoopConfig{ @@ -621,7 +623,7 @@ func TestRunIngestionLoop_RestartResumesFromLastCommitted(t *testing.T) { // The durable last-committed ledger now implies resume first+3 — exactly what // startup would derive on restart. Close the handle before the loop reopens the dir. - db2, err := openHotDBForChunk(cat, c, silentLogger()) + db2, err := openHotDBForChunk(cat, c, silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) resume := impliedResume(t, db2) assert.Equal(t, first+3, resume, "restart resumes one past the durable last-committed ledger") @@ -635,7 +637,8 @@ func TestRunIngestionLoop_RestartResumesFromLastCommitted(t *testing.T) { require.Error(t, err) assert.Equal(t, first+3, stream2.firstSeen.Load(), "second run resumed at last-committed+1") - reopened, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + reopened, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) t.Cleanup(func() { _ = reopened.Close() }) maxSeq, ok, err := reopened.MaxCommittedSeq() @@ -660,9 +663,9 @@ func eventCount(t *testing.T, r interface{ EventCount() (uint32, error) }) uint3 // stays open — the loop's own deferred close owns it. func TestClosingSink(t *testing.T) { cat, _ := testCatalog(t) - a, err := openHotDBForChunk(cat, 0, silentLogger()) + a, err := openHotDBForChunk(cat, 0, silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) - b, err := openHotDBForChunk(cat, 1, silentLogger()) + b, err := openHotDBForChunk(cat, 1, silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) t.Cleanup(func() { _ = b.Close() }) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/config.go b/cmd/stellar-rpc/internal/rpcv2/ingest/config.go index f56788d94..452912081 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/config.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/config.go @@ -7,8 +7,10 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) -// Config selects which data types the ingest drivers write. At least one of -// Ledgers/Txhash/Events must be enabled. +// Config selects which data types the ingest drivers write — for either +// materializer (WriteColdChunk's raw walk or FreezeColdChunk's CF scans; the +// hot-DB-vs-stream choice is the ENTRY POINT, not a config knob). At least +// one of Ledgers/Txhash/Events must be enabled. // // The view-based event path derives payloads from the LedgerCloseMetaView and // needs no network passphrase, so Config carries no passphrase. @@ -26,10 +28,20 @@ type Config struct { // index's routing — the caller derives it (event.ColdIndexSecret) so the // build is deterministic. Required when Events is set — no unkeyed fallback. EventsSecret []byte + + // ZstdEncodeWorkers is the walk materializer's ledger-frame encode + // parallelism (0 = single-threaded). FORMAT-AFFECTING: it must equal the + // hot tier's hotchunk.Tuning value, because the freeze copies hot frames + // into the cold pack verbatim while the walk re-encodes the same ledgers + // — the same chunk's pack must be byte-identical whichever materializer + // built it (the freeze-vs-walk identity gates arbitrate). One resolved + // daemon-config value (storage.zstd_encode_workers) feeds both. Ignored + // by FreezeColdChunk (verbatim copy, no encoder). + ZstdEncodeWorkers int } -// validate rejects a Config with no enabled data types, or a txhash/events -// config missing its cold-index secret. +// validate rejects a Config with no enabled data types, a txhash/events +// config missing its cold-index secret, or a negative encode-workers count. func (c Config) validate() error { if !c.Ledgers && !c.Txhash && !c.Events { return errors.New("ingest: Config enables no data types (set at least one of Ledgers/Txhash/Events)") @@ -51,6 +63,9 @@ func (c Config) validate() error { if c.Events && isZeroSecret(c.EventsSecret) { return errors.New("ingest: EventsSecret is all zero (blinding disabled — derive a per-index secret)") } + if c.ZstdEncodeWorkers < 0 { + return errors.New("ingest: Config.ZstdEncodeWorkers must be >= 0 (0 = single-threaded)") + } return nil } diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/config_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/config_test.go new file mode 100644 index 000000000..5aab75c38 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/config_test.go @@ -0,0 +1,85 @@ +package ingest + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" +) + +// zeroSecret is a right-length but all-zero secret: the shape a caller that +// forgot to derive one hands in (a zero [stores.SecretLen]byte sliced), which +// disables blinding entirely rather than failing a length check. +func zeroSecret() []byte { return make([]byte, stores.SecretLen) } + +// TestConfig_Validate covers every rejection validate() owns plus the +// fully-valid config it must accept. The secret guards are the load-bearing +// ones: without them a Txhash/Events pass can key its .bin/index under a nil +// or all-zero secret — the first silently unqueryable, the second reproducible +// by anyone who can influence the indexed keys. Both used to be silent. +func TestConfig_Validate(t *testing.T) { + for _, tc := range []struct { + name string + cfg Config + wantErr string // "" ⇒ must validate clean + }{ + { + name: "no data types enabled", + cfg: Config{}, + wantErr: "enables no data types", + }, + { + name: "txhash without a secret", + cfg: Config{Txhash: true}, + wantErr: "per-index secret required", + }, + { + name: "txhash with a short secret", + cfg: Config{Txhash: true, TxhashSecret: bytes.Repeat([]byte{0x5C}, 8)}, + wantErr: "per-index secret required", + }, + { + name: "txhash with an all-zero secret", + cfg: Config{Txhash: true, TxhashSecret: zeroSecret()}, + wantErr: "all zero", + }, + { + name: "events without a secret", + cfg: Config{Events: true}, + wantErr: "per-index secret required", + }, + { + name: "events with an all-zero secret", + cfg: Config{Events: true, EventsSecret: zeroSecret()}, + wantErr: "all zero", + }, + { + name: "negative encode workers", + cfg: Config{Ledgers: true, ZstdEncodeWorkers: -1}, + wantErr: "must be >= 0", + }, + { + name: "every data type with derived secrets", + cfg: Config{ + Ledgers: true, + Txhash: true, + Events: true, + TxhashSecret: testTxhashSecretBytes(), + EventsSecret: testEventsSecretBytes(), + ZstdEncodeWorkers: 0, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.validate() + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + }) + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/driver.go b/cmd/stellar-rpc/internal/rpcv2/ingest/driver.go index 86adc15e3..c6b97b70b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/driver.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/driver.go @@ -17,9 +17,9 @@ import ( // chunk on a local sequence counter, then verifies the full [first,last] range // was consumed — this runs before finalize, so a short stream never finalizes a // truncated artifact. The in-order contract is enforced at the SOURCE -// (packStream reads positionally by key; hotLedgerStream key-checks its own -// keyspace; the SDK backends validate their own output), so drain trusts the -// counter rather than re-parsing every view's sequence. Cancellation is the +// (packStream reads positionally by key; the SDK backends validate their own +// output), so drain trusts the counter rather than re-parsing every view's +// sequence. Cancellation is the // iterator's job (RawLedgers errors on a canceled ctx), so there is no ctx poll // here. func drain(ledgers iter.Seq2[[]byte, error], chunkID chunk.ID, cc *coldChunk) error { diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go index 0c7170c05..32fd99fe4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go @@ -5,6 +5,7 @@ import ( "fmt" "math" "os" + "path/filepath" "time" sdkingest "github.com/stellar/go-stellar-sdk/ingest" @@ -12,30 +13,37 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" ) // ───────────────────────── Cold writer ───────────────────────── // eventsCold models the backfill path: shared-walk output → payloads → -// term-index accumulate + cold append, then chunk-end Finish + WriteColdIndex. -// No HotStore is involved — it maintains an in-memory event.Bitmaps mirror via -// NewBitmaps + per-event TermsForBytes, and an event.LedgerOffsets to assign -// chunk-relative event IDs. +// term-index spill + cold append, then chunk-end Commit + the external +// streaming index build (WriteColdIndexFromRuns). No HotStore is involved — +// and no in-memory term mirror either: (term, eventID) pairs spill through a +// runspill.Spiller (bounded double-buffered slabs → sorted scratch runs), so +// the build's memory is O(slab), not O(unique terms). An +// event.LedgerOffsets assigns chunk-relative event IDs as before. type eventsCold struct { - chunkID chunk.ID - writer *event.ColdWriter - mirror event.Bitmaps - offsets *event.LedgerOffsets - bucketDir string - // secret is the chunk's deterministic routing secret (event.ColdIndexSecret), - // handed to WriteColdIndex at finalize. + chunkID chunk.ID + writer *event.ColdWriter + spiller *runspill.Spiller + scratchDir string + offsets *event.LedgerOffsets + bucketDir string + // secret is the chunk's deterministic routing secret (event.ColdIndexSecret). + // Every term key is blinded with it BEFORE it reaches the spiller, so the + // runs — and everything downstream of them (merge order, index.hash routing, + // index.pack record order and fingerprints) — are keyed by the blinded + // identity end to end. secret [stores.SecretLen]byte metrics coldMetrics - // failed latches any write error. A failed write can leave the mirror - // and the pack ahead of offsets (offsets is the per-ledger commit point, - // appended last), so a subsequent finalize would commit an index whose - // bitmaps reference event IDs past offsets.TotalEvents(). The latch makes - // finalize refuse instead — the chunk must be abandoned via close and + // failed latches any write error. A failed write can leave the spilled + // runs and the pack ahead of offsets (offsets is the per-ledger commit + // point, appended last), so a subsequent finalize would build an index + // whose bitmaps reference event IDs past offsets.TotalEvents(). The + // latch makes finalize refuse instead — the chunk must be abandoned via close and // retried from scratch (see coldChunk's contract). failed bool } @@ -58,17 +66,44 @@ func newEventsCold( if err != nil { return nil, fmt.Errorf("event.NewColdWriter: %w", err) } + // Index-spill scratch lives beside the artifacts (same NVMe), wiped by + // NewSpiller on entry so a crashed attempt's runs never leak into this + // one, and removed at finalize/close. + scratchDir := eventsScratchDir(bucketDir, chunkID) + sp, err := runspill.NewSpiller(scratchDir, indexSpillSlabBytes) + if err != nil { + _ = w.Close() + return nil, fmt.Errorf("runspill.NewSpiller: %w", err) + } return &eventsCold{ - chunkID: chunkID, - writer: w, - mirror: event.NewBitmaps(), - offsets: event.NewLedgerOffsets(chunkID.FirstLedger()), - bucketDir: bucketDir, - secret: secret, - metrics: newColdMetrics(sink, dataTypeEvents), + chunkID: chunkID, + writer: w, + spiller: sp, + scratchDir: scratchDir, + offsets: event.NewLedgerOffsets(chunkID.FirstLedger()), + bucketDir: bucketDir, + secret: secret, + metrics: newColdMetrics(sink, dataTypeEvents), }, nil } +// eventsScratchDir is THE scratch location for chunk c's events index build +// under eventsDir — ONE name, shared by every materializer of this chunk, so +// whichever build runs next wipes a crashed attempt's remains on entry (each +// build wipes this dir before use and removes it at finalize). Scratch has +// no catalog key — the key-driven sweeps can never find it — so the shared +// deterministic name is what keeps a crashed attempt's multi-GB spill dir +// from stranding invisibly in the cold bucket. +func eventsScratchDir(eventsDir string, c chunk.ID) string { + return filepath.Join(eventsDir, ".events-scratch-"+c.String()) +} + +// indexSpillSlabBytes sizes each of the Spiller's two slabs. 32MB holds +// ~1.6M (term, id) records per side — a spill every ~130 worst-case ledgers +// — keeping steady-state index memory ~64MB + sort scratch regardless of the +// chunk's unique-term count. +const indexSpillSlabBytes = 32 << 20 + // write ingests one ledger's events from the shared walk's output. txParts // aliases the source stream's borrowed buffer, valid only for this call — // everything retained is copied synchronously (see ingestSeq). @@ -83,8 +118,8 @@ func (e *eventsCold) write(seq uint32, closedAt int64, txParts []sdkingest.Ledge return nil } -// finalize writes the events.pack trailer (Finish) + materializes the cold -// index (WriteColdIndex). An eventless chunk (zero terms — the common case +// finalize writes the events.pack trailer (Commit) + materializes the cold +// index from the spilled runs (WriteColdIndexFromRuns). An eventless chunk (zero terms — the common case // for pre-Soroban backfill ranges) is handled inside WriteColdIndex, which // publishes a valid empty index, so all three cold artifacts exist for every // finalized chunk. An error from either step means the chunk did not durably @@ -98,30 +133,43 @@ func (e *eventsCold) finalize(ctx context.Context) error { // chunk whose mirror/pack may be ahead of the offsets commit point. return fmt.Errorf("events cold writer for chunk %s: finalize after failed write", e.chunkID) } - if err := e.writer.Finish(e.offsets); err != nil { - err = fmt.Errorf("events ColdWriter.Finish: %w", err) + if err := e.writer.Commit(e.offsets); err != nil { + err = fmt.Errorf("events ColdWriter.Commit: %w", err) + e.metrics.emit(time.Since(start), err) + return err + } + runs, err := e.spiller.Finish() + if err != nil { + err = fmt.Errorf("index spill finish: %w", err) e.metrics.emit(time.Since(start), err) return err } - if err := event.WriteColdIndex(ctx, e.chunkID, e.mirror, e.bucketDir, e.secret); err != nil { - // Finish already committed events.pack; the index-less pack is left + if err := event.WriteColdIndexFromRuns(ctx, e.chunkID, runs, e.scratchDir, e.bucketDir, e.secret); err != nil { + // Commit already published events.pack; the index-less pack is left // in place — without the orchestrator's completion record it is // inert scratch (see the package doc's artifact model), and the // retry's overwrite is the cleanup. - err = fmt.Errorf("WriteColdIndex: %w", err) + err = fmt.Errorf("WriteColdIndexFromRuns: %w", err) e.metrics.emit(time.Since(start), err) return err } + // Scratch removal is best-effort: the artifacts are already durable, and + // the next attempt's NewSpiller wipes this dir anyway — failing the chunk + // here would force a full re-materialization over a cosmetic unlink. + _ = e.spiller.Cleanup() e.metrics.sink.IngestStage(dataTypeEvents, stageFinalize, time.Since(start), 0) e.metrics.emit(time.Since(start), nil) return nil } -// close drops the partial events.pack when finalize never ran. It does NOT emit -// the cold metric: a terminal write error or finalize already emitted it, and a -// writer that never got that far (a rolled-back build) must produce no phantom -// sample. The writer.Close error is returned unchanged. +// close drops the partial events.pack when finalize never ran, and the index +// spill scratch with it. It does NOT emit the cold metric: a terminal write +// error or finalize already emitted it, and a writer that never got that far +// (a rolled-back build) must produce no phantom sample. The writer.Close +// error is returned unchanged; scratch removal is best-effort (the next +// attempt's NewSpiller wipes the dir regardless). func (e *eventsCold) close() error { + _ = e.spiller.Cleanup() return e.writer.Close() } @@ -166,7 +214,11 @@ func (e *eventsCold) ingestSeq(seq uint32, closedAt int64, txParts []sdkingest.L } eventID := startID + uint32(i) for _, k := range keys { - e.mirror.AddTo(k, eventID) + // The spilled key is the BLINDED routing identity (see the struct + // doc); raw term keys never reach the runs or the artifacts. + if serr := e.spiller.Add(stores.BlindKey(e.secret, k[:]), eventID); serr != nil { + return 0, fmt.Errorf("index spill seq %d eventIdx %d: %w", seq, i, serr) + } } termDur += time.Since(tstart) wstart := time.Now() diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go new file mode 100644 index 000000000..25e5872ef --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go @@ -0,0 +1,148 @@ +package ingest + +// freeze.go — FreezeColdChunk, the zero-decompression twin of WriteColdChunk +// for the ONE case where the chunk's source is its own complete hot DB. Where +// WriteColdChunk drains a raw-ledger stream (decompressing every hot frame to +// feed derivation writers), this entry point takes NO ledger stream at all: +// each artifact kind is built from the hot state that already holds it in +// final form — ledgers as verbatim zstd frames, txhash and events by merging +// each engine's manifest-listed sealed runs with its un-sealed CF tail. +// The signature is the invariant: with no stream parameter there is nothing +// to drain, so the freeze cannot decompress by accident. +// +// Artifact bytes are identical to WriteColdChunk's by construction and by +// test (the cross-path identity gates in each store package plus the +// composition gate in freeze_test.go). Failure semantics are WriteColdChunk's +// too: first error stops, earlier artifacts are inert scratch without the +// orchestrator's completion record, every builder overwrites on retry. + +import ( + "context" + "errors" + "fmt" + "time" + + supportlog "github.com/stellar/go-stellar-sdk/support/log" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" +) + +// FreezeColdChunk materializes ONE chunk's cold artifacts at the resolved +// paths named by dirs, directly from the chunk's complete (read-only) hot DB. +// Destination-dir creation is owned by each store's FreezeColdFromStore +// (mirroring the walk path, where the store-level constructors own it), so +// the first freeze into a fresh bucket cannot ENOENT. Metrics mirror the walk path's envelope: exactly one +// ColdChunkTotal per attempt (post-validate), one ColdIngest per enabled kind +// with the kind's real item count where the builder reports one, and a +// (kind, finalize) stage sample per success — all pre-existing series. +func FreezeColdChunk( + ctx context.Context, + logger *supportlog.Entry, + chunkID chunk.ID, + db *hotchunk.DB, + dirs ColdDirs, + sink MetricSink, + cfg Config, +) error { + if verr := cfg.validate(); verr != nil { + return verr + } + sink = orNop(sink) + + // Same invariant as WriteColdChunk: one ColdChunkTotal per chunk attempt, + // emitted for every post-validate return below (nil-DB included), none + // for a config error above. + start := time.Now() + defer func() { sink.ColdChunkTotal(time.Since(start)) }() + + if db == nil { + return errors.New("ingest: FreezeColdChunk with a nil hot DB") + } + + if cerr := ctx.Err(); cerr != nil { + return cerr + } + logger.Debugf("freeze chunk %d [%d, %d] from hot DB", + uint32(chunkID), chunkID.FirstLedger(), chunkID.LastLedger()) + + // Canonical ledgers→txhash→events order, sequential (a chunk's freeze is + // I/O-shaped; the worker pool provides cross-chunk parallelism). + if cfg.Ledgers { + if dirs.LedgerPack == "" { + return errors.New("ingest: ledgers enabled but its ColdDirs path is empty") + } + if err := freezeArm(sink, dataTypeLedgers, func() (int, error) { + // Concurrency buys hashing, not encoding: PreCompressed copies + // frames verbatim, but the pack's content hash is over RAW + // ledger bytes, so every frame is decompressed once on a + // packfile worker's hash goroutine. That decode is the freeze's + // only CPU stream, so it gets the same width the raw-mode + // (walk/backfill) writer encodes with. Writeback smoothing + // applies as in every batch build. The copy runs at full speed + // and is CORRECT on any topology; the RECOMMENDED deployment + // puts cold artifacts on their own device, because on a shared + // disk an unpaced copy stream queues ahead of the hot WAL + // fdatasync (measured: ~50ms median fsync waits → co-located + // ingest commit p50 +28ms for the arm's duration). + return db.FreezeLedgersCold(ctx, dirs.LedgerPack, ledger.ColdWriterOptions{ + Concurrency: coldEncoderConcurrency, + BytesPerSync: coldBytesPerSync, + }) + }); err != nil { + return err + } + } + if cfg.Txhash { + if dirs.TxhashBin == "" { + return errors.New("ingest: txhash enabled but its ColdDirs path is empty") + } + var secret [stores.SecretLen]byte + copy(secret[:], cfg.TxhashSecret) + if err := freezeArm(sink, dataTypeTxhash, func() (int, error) { + return db.FreezeTxhashCold(ctx, dirs.TxhashBin, secret) + }); err != nil { + return err + } + } + if cfg.Events { + if dirs.EventsDir == "" { + return errors.New("ingest: events enabled but its ColdDirs path is empty") + } + var secret [stores.SecretLen]byte + copy(secret[:], cfg.EventsSecret) + scratch := eventsScratchDir(dirs.EventsDir, chunkID) + if err := freezeArm(sink, dataTypeEvents, func() (int, error) { + // The events builder does not report an item count (its ColdIngest + // stays items=0, as the freeze-by-merge events build always has). + return 0, db.FreezeEventsCold(ctx, scratch, dirs.EventsDir, secret, event.ColdWriterOptions{ + Concurrency: coldEncoderConcurrency, + BytesPerSync: coldBytesPerSync, + }) + }); err != nil { + return err + } + } + return nil +} + +// freezeArm runs one kind's freeze build inside the walk path's signal shape: +// one ColdIngest with the build's wall and item count, plus the +// (kind, finalize) stage sample on success. A single observation needs no +// accumulator — the sink is called directly. +func freezeArm(sink MetricSink, dataType string, build func() (int, error)) error { + start := time.Now() + n, err := build() + if err != nil { + err = fmt.Errorf("freeze %s from hot DB: %w", dataType, err) + } + d := time.Since(start) + if err == nil { + sink.IngestStage(dataType, stageFinalize, d, n) + } + sink.ColdIngest(dataType, d, n, err) + return err +} diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go new file mode 100644 index 000000000..034d12a7f --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go @@ -0,0 +1,167 @@ +package ingest + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stellar/go-stellar-sdk/xdr" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rpcv2test" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" +) + +// TestFreezeColdChunk_ByteIdenticalToWalk is the composition half of the +// cross-path identity gate: one full chunk ingested hot through the +// production service, then materialized cold BOTH ways — WriteColdChunk +// draining the raw fixtures (the derivation path) and FreezeColdChunk +// scanning the hot DB's durable state (the zero-decompression path) — must +// produce byte-identical artifacts for all three kinds. The fixture is dense +// enough (eventEvery=10 → 1,000 event-bearing index rows) that the events +// engine seals at its production 256-row cadence several times, so the +// events freeze consumes manifest-listed sealed runs — asserted at the +// bottom — not just the un-sealed tail. +// +// The populate is a full LedgersPerChunk pass with a synced WriteBatch per +// ledger, so this is one of the package's slower tests. +func TestFreezeColdChunk_ByteIdenticalToWalk(t *testing.T) { + chunkID := chunk.ID(0) + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + const eventEvery = 10 // every eventEvery-th ledger carries one tx + one contract event + + fixtures := make([][]byte, 0, chunk.LedgersPerChunk) + for seq := first; seq <= last; seq++ { + if (seq-first)%eventEvery == 0 { + fixtures = append(fixtures, rpcv2test.EventLCMBytes(t, seq)) + } else { + fixtures = append(fixtures, rpcv2test.ZeroTxLCMBytes(t, seq)) + } + } + + // Hot DB populated through the production ingest service (compression, + // extraction, and CF layout exactly as a live chunk's). + ctx := context.Background() + dbPath := t.TempDir() + db, err := hotchunk.Open(dbPath, chunkID, hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + svc := NewHotService(db, nil, nil) + for i, raw := range fixtures { + _, ierr := svc.Ingest(first+uint32(i), xdr.LedgerCloseMetaView(raw)) + require.NoError(t, ierr) + } + + // The walk half must encode ledger frames with the SAME workers value the + // hot DB above was opened with (DefaultTuning) — the setting is + // format-affecting and this test is the freeze-vs-walk identity arbiter. + cfgAll := Config{ + Ledgers: true, Txhash: true, Events: true, + ZstdEncodeWorkers: ledger.DefaultZstdEncodeWorkers, + TxhashSecret: testTxhashSecretBytes(), + EventsSecret: testEventsSecretBytes(), + } + dirsFor := func(root string) ColdDirs { + return ColdDirs{ + LedgerPack: filepath.Join(root, "ledgers", "chunk.pack"), + TxhashBin: filepath.Join(root, "txhash", "chunk.bin"), + EventsDir: filepath.Join(root, "events"), + } + } + + // Walk path: drain the same fixtures WriteColdChunk-style. + walkDirs := dirsFor(t.TempDir()) + rawIter := func(yield func([]byte, error) bool) { + for _, b := range fixtures { + if !yield(b, nil) { + return + } + } + } + require.NoError(t, WriteColdChunk(ctx, hotTestLogger(), chunkID, rawIter, walkDirs, nil, cfgAll)) + + // Freeze path: no ledger stream exists to hand over. + freezeDirs := dirsFor(t.TempDir()) + require.NoError(t, FreezeColdChunk(ctx, hotTestLogger(), chunkID, db, freezeDirs, nil, cfgAll)) + + requireSameBytes(t, walkDirs.LedgerPack, freezeDirs.LedgerPack) + requireSameBytes(t, walkDirs.TxhashBin, freezeDirs.TxhashBin) + + // Events: both bucket dirs must hold the same artifact set (scratch dirs + // are removed on success) with identical bytes. + walkNames := dirEntryNames(t, walkDirs.EventsDir) + require.Equal(t, walkNames, dirEntryNames(t, freezeDirs.EventsDir)) + require.NotEmpty(t, walkNames) + for _, name := range walkNames { + requireSameBytes(t, filepath.Join(walkDirs.EventsDir, name), filepath.Join(freezeDirs.EventsDir, name)) + } + + // Tripwire: the fixture must actually have crossed the events engine's + // sealed frontier — a non-empty run manifest — or this gate silently + // stops covering the freeze's manifest-runs input shape and regresses to + // tail-only coverage. Read the manifest from a bare re-open of the + // frozen DB (the closed hot DB's durable state, exactly what a + // production freeze source sees). + require.NoError(t, db.Close()) + raw, err := rocksdb.New(rocksdb.Config{ + Path: dbPath, + ColumnFamilies: hotchunk.ColumnFamilies(), + Logger: hotTestLogger(), + ReadOnly: true, + }) + require.NoError(t, err) + t.Cleanup(func() { _ = raw.Close() }) + sealedRuns, _, err := event.ManifestRuns(raw) + require.NoError(t, err) + require.NotEmpty(t, sealedRuns, "full-chunk fixture must publish sealed runs for the freeze to consume") +} + +// TestEventsScratchDir_SharedAcrossMaterializers pins the scratch-lifecycle +// contract: both materializers (walk and freeze) resolve the SAME scratch +// dir for a chunk, so whichever path retries a crashed attempt wipes the +// other's remains on entry — scratch has no catalog key, and the shared +// deterministic name is its only cross-path cleanup. +func TestEventsScratchDir_SharedAcrossMaterializers(t *testing.T) { + dir := t.TempDir() + id := chunk.ID(123) + got := eventsScratchDir(dir, id) + require.Equal(t, filepath.Join(dir, ".events-scratch-"+id.String()), got) + require.Equal(t, got, eventsScratchDir(dir, id)) +} + +// TestFreezeColdChunk_NilDB: a nil hot DB is a caller bug, reported before +// any artifact path is touched. +func TestFreezeColdChunk_NilDB(t *testing.T) { + err := FreezeColdChunk(context.Background(), hotTestLogger(), chunk.ID(0), nil, + ColdDirs{LedgerPack: "x", TxhashBin: "y", EventsDir: "z"}, + nil, Config{Ledgers: true}) + require.Error(t, err) + require.Contains(t, err.Error(), "nil hot DB") +} + +func requireSameBytes(t *testing.T, a, b string) { + t.Helper() + ab, err := os.ReadFile(a) + require.NoError(t, err) + bb, err := os.ReadFile(b) + require.NoError(t, err) + require.Len(t, bb, len(ab), "%s vs %s: size", a, b) + require.Equal(t, string(ab), string(bb), "%s vs %s: bytes diverge", a, b) +} + +func dirEntryNames(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go index ced205ecd..0a085e665 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go @@ -55,6 +55,14 @@ func testEventsSecret() [stores.SecretLen]byte { return event.ColdIndexSecret(te // testEventsSecretBytes is testEventsSecret as a slice, for ingest.Config.EventsSecret. func testEventsSecretBytes() []byte { s := testEventsSecret(); return s[:] } +// testHotSecrets is the hot-DB half of the same pair: a chunk's sealed runs +// are keyed with these, and the freeze copies those runs into artifacts +// declaring Config's secrets — so the two MUST be the same values, which is +// what makes the freeze-vs-walk identity gate meaningful. +func testHotSecrets() hotchunk.Secrets { + return hotchunk.Secrets{Txhash: testTxhashSecret(), Events: testEventsSecret()} +} + // ───────────────────────── test metric sink ───────────────────────── type hotPhaseCall struct { @@ -489,7 +497,7 @@ func TestLedgerColdWriter_Readback(t *testing.T) { raw := marshalLCM(t, seq) coldDir := t.TempDir() - ing, err := newLedgerCold(packPath(coldDir, chunkID), chunkID, nil) + ing, err := newLedgerCold(packPath(coldDir, chunkID), chunkID, nil, ledger.DefaultZstdEncodeWorkers) require.NoError(t, err) defer func() { require.NoError(t, ing.close()) }() @@ -518,7 +526,7 @@ func TestTxhashColdWriter_Bin(t *testing.T) { first := chunkID.FirstLedger() coldDir := t.TempDir() - ing, err := newTxhashCold(txhashBinPath(coldDir), chunkID, nil, testTxhashSecret()) + ing, err := newTxhashCold(txhashBinPath(coldDir), nil, testTxhashSecret()) require.NoError(t, err) defer func() { require.NoError(t, ing.close()) }() @@ -560,10 +568,10 @@ func TestEventsColdWriter_Readback(t *testing.T) { cnt, err := cr.EventCount() require.NoError(t, err) require.Equal(t, uint32(2), cnt) - bms, err := cr.LookupKeys(context.Background(), []event.TermKey{term}) + post, err := cr.LookupKeys(context.Background(), []event.TermKey{term}) require.NoError(t, err) - require.NotNil(t, bms[0]) - require.Equal(t, uint64(2), bms[0].GetCardinality()) + require.True(t, post[0].Present()) + require.Equal(t, uint64(2), post[0].Cardinality()) } // ───────────────────────── V0 (pre-Soroban) events handling ───────────────────────── @@ -618,8 +626,8 @@ func TestEventsColdWriter_V0KeepsOffsetsContiguous(t *testing.T) { // And the event is queryable by its term. bms, err := cr.LookupKeys(context.Background(), []event.TermKey{term}) require.NoError(t, err) - require.NotNil(t, bms[0]) - require.Equal(t, uint64(1), bms[0].GetCardinality()) + require.True(t, bms[0].Present()) + require.Equal(t, uint64(1), bms[0].Cardinality()) } // TestWriteColdChunk_EventlessChunk_FullyReadable drives a full cold chunk of V0 @@ -664,7 +672,7 @@ func TestWriteColdChunk_EventlessChunk_FullyReadable(t *testing.T) { anyKey := event.ComputeTermKey([]byte("any"), event.FieldContractID) bms, lerr := cr.LookupKeys(context.Background(), []event.TermKey{anyKey}) require.NoError(t, lerr) - require.Nil(t, bms[0], "a term with no matching events misses cleanly (nil bitmap)") + require.False(t, bms[0].Present(), "a term with no matching events misses cleanly") // Metrics still fired: one aggregate per-chunk, one (clean) per-ingester. require.Equal(t, 1, sink.coldChunkTotals, "ColdChunkTotal must fire for an eventless chunk") @@ -718,7 +726,7 @@ func TestColdChunk_Success(t *testing.T) { defer func() { require.NoError(t, ecr.Close()) }() bms, err := ecr.LookupKeys(context.Background(), []event.TermKey{term}) require.NoError(t, err) - require.Equal(t, uint64(2), bms[0].GetCardinality()) + require.Equal(t, uint64(2), bms[0].Cardinality()) // Txhash .bin count. binEntries := rpcv2test.ReadColdBin(t, txhashBinPath(filepath.Join(coldDir, dataTypeTxhash))) @@ -927,7 +935,7 @@ func eventRichLedger(t *testing.T, seq uint32) []byte { // several rich sentinel ledgers and compares the artifacts to INDEPENDENT // reference computations: // -// - txhash .bin vs. sorted, truncated entries built from an independent +// - txhash .bin vs. sorted, blinded entries built from an independent // ExtractLedgerTxParts run over the same fixtures (`.Hash`/`.InnerHash` per // element) — so an entry-by-entry match proves the ingester's shared walk // dropped or reshaped no hash. @@ -1025,8 +1033,8 @@ func TestWriteColdChunk_ByteIdentity_SharedWalk(t *testing.T) { bms, err := ecr.LookupKeys(context.Background(), terms) require.NoError(t, err) for i, k := range terms { - require.NotNil(t, bms[i], "term %d present in reference must resolve", i) - require.Equal(t, wantTermIDs[k], bms[i].ToArray(), + require.True(t, bms[i].Present(), "term %d present in reference must resolve", i) + require.Equal(t, wantTermIDs[k], bms[i].Bitmap().ToArray(), "cold term bitmap must carry the same event IDs as the shaping reference") } } @@ -1112,16 +1120,17 @@ func TestWriteColdChunk_EventsCold_Readback(t *testing.T) { require.Equal(t, uint32(len(evSeqs)), cnt) bms, err := cr.LookupKeys(context.Background(), []event.TermKey{term}) require.NoError(t, err) - require.NotNil(t, bms[0]) - require.Equal(t, uint64(len(evSeqs)), bms[0].GetCardinality()) + require.True(t, bms[0].Present()) + require.Equal(t, uint64(len(evSeqs)), bms[0].Cardinality()) } // ───────────────────────── drain stream errors ───────────────────────── // // The per-seq order guard the shared cursor used to run in drain moved to the -// SOURCE (packStream reads positionally; hotLedgerStream key-checks its keyspace, -// see TestSource_RejectsGap; the SDK backends validate their own output), so drain -// keeps only its overrun + completeness checks on a local counter. The tests that +// SOURCE (packStream reads positionally; the SDK backends validate their own +// output; the hot-DB freeze path takes no stream at all — FreezeColdChunk +// scans CFs with its own key-derived checks), so drain keeps only its overrun +// + completeness checks on a local counter. The tests that // fed an artificially mis-ordered stream to drain were deleted with the cursor. // TestWriteColdChunk_DrainStreamError_NoArtifact exercises the drain mid-stream error @@ -1169,7 +1178,7 @@ func hotTestLogger() *supportlog.Entry { // TestHotService_FeedsFeeWindowsAfterCommit: a successfully committed ledger's // fee observations land in the injected windows. func TestHotService_FeedsFeeWindowsAfterCommit(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.Tuning{}, testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1191,7 +1200,7 @@ func TestHotService_FeedsFeeWindowsAfterCommit(t *testing.T) { // next-expected seq) contributes nothing to the windows, so its retry cannot // double-count. func TestHotService_RejectedLedgerFeedsNoFees(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.Tuning{}, testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1218,7 +1227,7 @@ func TestHotService_RejectedLedgerFeedsNoFees(t *testing.T) { // the ledger is durably committed, the windows stay unfed, and the loop's // caller sees the error (tip-only trusted input, mirroring extract failures). func TestHotService_FeeClassificationErrorFailsCommittedLedger(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.Tuning{}, testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1242,7 +1251,7 @@ func TestHotService_FeeClassificationErrorFailsCommittedLedger(t *testing.T) { // once, the write phases carry per-type volume (extract/commit/apply carry none), // and no phase carries an error. func TestHotService_EmitsEveryPhaseOnSuccess(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1269,7 +1278,7 @@ func TestHotService_EmitsEveryPhaseOnSuccess(t *testing.T) { // DB) surfaces the error on the commit phase — by construction, not by a // separately-maintained label — and emits no items on the failure path. func TestHotService_CommitErrorLandsOnCommitPhase(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) require.NoError(t, db.Close()) // closed => the batch commit fails @@ -1293,7 +1302,7 @@ func TestHotService_CommitErrorLandsOnCommitPhase(t *testing.T) { // failure lands by construction — and emits ONLY that phase (no batch was opened, so // no later phase ran). func TestHotService_ExtractFailureLandsOnExtractPhase(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1317,7 +1326,7 @@ func TestHotService_ExtractFailureLandsOnExtractPhase(t *testing.T) { // Failed's zero value, a NON-zero Failed is the discriminator that proves the phase was // actually assigned rather than defaulted. func TestHotService_EventsQueueFailureLandsOnEventsPhase(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1344,7 +1353,7 @@ func TestHotService_EventsQueueFailureLandsOnEventsPhase(t *testing.T) { // that completed earlier phases carry theirs too. Uses the same out-of-order failure as // above, whose failed phase is the events queue. func TestHotService_FailedPhaseCarriesPartialDuration(t *testing.T) { - db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1373,7 +1382,7 @@ func TestTxhashColdWriter_BinContent(t *testing.T) { first := chunkID.FirstLedger() coldDir := t.TempDir() - ing, err := newTxhashCold(txhashBinPath(coldDir), chunkID, nil, testTxhashSecret()) + ing, err := newTxhashCold(txhashBinPath(coldDir), nil, testTxhashSecret()) require.NoError(t, err) defer func() { require.NoError(t, ing.close()) }() @@ -1531,15 +1540,15 @@ func TestWriteColdChunk_ConstructorFailure_EmitsAggregate(t *testing.T) { require.Zero(t, countCleanColdIngests(sink), "no clean ColdIngest on the rollback path") } -// ───────────────────────── events Finish-then-WriteColdIndex failure ───────────────────────── +// ───────────────────────── events Commit-then-WriteColdIndex failure ───────────────────────── -// TestEventsCold_FinishThenIndexFails_LeavesInertPack forces WriteColdIndex to -// fail AFTER writer.Finish has committed events.pack, by planting a directory +// TestEventsCold_CommitThenIndexFails_LeavesInertPack forces WriteColdIndex to +// fail AFTER writer.Commit has published events.pack, by planting a directory // where the index.hash file must be written (buildMPHF then hits EISDIR). // Finalize must surface the error; the index-less events.pack stays on disk — // without the orchestrator's completion record it is inert scratch (see the // package doc's artifact model), and a retry's overwrite is the cleanup. -func TestEventsCold_FinishThenIndexFails_LeavesInertPack(t *testing.T) { +func TestEventsCold_CommitThenIndexFails_LeavesInertPack(t *testing.T) { chunkID := chunk.ID(0) first := chunkID.FirstLedger() coldDir := t.TempDir() @@ -1562,7 +1571,7 @@ func TestEventsCold_FinishThenIndexFails_LeavesInertPack(t *testing.T) { require.Error(t, ferr, "Finalize must fail when WriteColdIndex fails") require.Contains(t, ferr.Error(), "WriteColdIndex") - // The committed events.pack stays in place as inert scratch (Finish ran, + // The committed events.pack stays in place as inert scratch (Commit ran, // so the later Close does not drop it either). packPath := filepath.Join(bucketDir, event.EventsPackName(chunkID)) _, statErr := os.Stat(packPath) @@ -1571,7 +1580,7 @@ func TestEventsCold_FinishThenIndexFails_LeavesInertPack(t *testing.T) { // Close is still safe/idempotent afterwards and does not remove the pack. require.NoError(t, ing.close()) _, statErr = os.Stat(packPath) - require.NoError(t, statErr, "close after a committed Finish must not drop the pack") + require.NoError(t, statErr, "close after a successful Commit must not drop the pack") } // TestEventsCold_FinalizeAfterFailedIngest_Refuses asserts the failed-write @@ -1806,7 +1815,7 @@ func TestTxhashColdWriter_FeeBumpBothHashes(t *testing.T) { seq := chunkID.FirstLedger() coldDir := t.TempDir() - ing, err := newTxhashCold(txhashBinPath(coldDir), chunkID, nil, testTxhashSecret()) + ing, err := newTxhashCold(txhashBinPath(coldDir), nil, testTxhashSecret()) require.NoError(t, err) defer func() { require.NoError(t, ing.close()) }() diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go index ae1bfa52e..0fe588b8a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go @@ -10,6 +10,7 @@ import ( "github.com/stellar/go-stellar-sdk/xdr" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/packfile" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) @@ -37,9 +38,11 @@ type coldChunk struct { sink MetricSink } -// openColdChunk opens one cold writer per enabled type at its resolved path — -// the single definition site of the canonical ledgers→txhash→events order and -// the build rollback. An enabled type with an empty ColdDirs path is a config +// openColdChunk opens one cold writer per enabled type at its resolved path, +// in the canonical ledgers→txhash→events order, with build rollback. (The +// freeze path re-encodes the same order and per-kind dispatch in +// FreezeColdChunk — an order or kind change must touch both entry points.) +// An enabled type with an empty ColdDirs path is a config // error. On any failure the already-opened writers are closed; they never // ingested or finalized, and close emits no per-writer ColdIngest sample, so a // rolled-back build produces no phantom-success sample. @@ -50,7 +53,7 @@ func openColdChunk(dirs ColdDirs, chunkID chunk.ID, sink MetricSink, cfg Config) if dirs.LedgerPack == "" { return fail(errors.New("ingest: ledgers enabled but its ColdDirs path is empty")) } - w, err := newLedgerCold(dirs.LedgerPack, chunkID, sink) + w, err := newLedgerCold(dirs.LedgerPack, chunkID, sink, cfg.ZstdEncodeWorkers) if err != nil { return fail(fmt.Errorf("open ledgers cold writer: %w", err)) } @@ -65,7 +68,7 @@ func openColdChunk(dirs ColdDirs, chunkID chunk.ID, sink MetricSink, cfg Config) } var secret [stores.SecretLen]byte copy(secret[:], cfg.TxhashSecret) - w, err := newTxhashCold(dirs.TxhashBin, chunkID, sink, secret) + w, err := newTxhashCold(dirs.TxhashBin, sink, secret) if err != nil { return fail(fmt.Errorf("open txhash cold writer: %w", err)) } @@ -208,5 +211,5 @@ const ( // coldBytesPerSync triggers background writeback every 1 MiB so Commit/Finish // doesn't flush a whole pack's dirty pages at once (a large win on networked // storage, per the writer docs). - coldBytesPerSync = 1 << 20 + coldBytesPerSync = packfile.DefaultBytesPerSync ) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ledgers.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ledgers.go index 9fb3dbe9d..8a42964c0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ledgers.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ledgers.go @@ -26,14 +26,18 @@ type ledgerCold struct { // caller's geometry.Layout.LedgerPackPath(chunkID), so the write path is Layout's // single derivation, not a second copy. The writer opts into the batch tuning // (coldEncoderConcurrency/coldBytesPerSync): WriteColdChunk, the sole production -// caller, is always a batch freeze/backfill. -func newLedgerCold(packPath string, chunkID chunk.ID, sink MetricSink) (*ledgerCold, error) { +// caller, is always a batch freeze/backfill. zstdWorkers is the +// FORMAT-AFFECTING frame encode setting (Config.ZstdEncodeWorkers) — it must +// match the hot tier's so the walk-built pack is byte-identical to a +// freeze-built one. +func newLedgerCold(packPath string, chunkID chunk.ID, sink MetricSink, zstdWorkers int) (*ledgerCold, error) { if err := os.MkdirAll(filepath.Dir(packPath), 0o755); err != nil { return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(packPath), err) } w, err := ledger.NewColdWriter(packPath, chunkID.FirstLedger(), ledger.ColdWriterOptions{ - Concurrency: coldEncoderConcurrency, - BytesPerSync: coldBytesPerSync, + Concurrency: coldEncoderConcurrency, + BytesPerSync: coldBytesPerSync, + ZstdEncodeWorkers: zstdWorkers, }) if err != nil { return nil, fmt.Errorf("ledger.NewColdWriter %s: %w", packPath, err) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/service.go b/cmd/stellar-rpc/internal/rpcv2/ingest/service.go index fefd3f704..2fdc61597 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/service.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/service.go @@ -61,17 +61,22 @@ func NewHotService(db *hotchunk.DB, windows *feewindow.FeeWindows, sink MetricSi // ledger and fails run() before the loop starts — the daemon wedges rather // than serve fee stats that silently miss a ledger. func (s *HotService) Ingest(seq uint32, lcmView xdr.LedgerCloseMetaView) (int64, error) { + // Fork the ledger-bytes encode BEFORE the walk so it overlaps the walk — + // the largest step it can hide behind. IngestLedger consumes the handle + // and owns its Discard; the walk-failure path below owns it here. + pending := s.db.StartCompress(seq, lcmView) walkStart := time.Now() txParts, err := sdkingest.ExtractLedgerTxParts(lcmView) walkDur := time.Since(walkStart) if err != nil { + pending.Discard() // The walk failed before any batch opened: the extract phase is the only // one that ran, mirroring hotchunk's own pre-batch failures. s.sink.HotPhase(hotchunk.PhaseExtract, walkDur, 0, err) return 0, fmt.Errorf("extract ledger tx parts seq %d: %w", seq, err) } - rep, err := s.db.IngestLedger(seq, lcmView, txParts) + rep, err := s.db.IngestLedger(seq, lcmView, txParts, pending) rep.Phases[hotchunk.PhaseExtract].Dur += walkDur last := hotchunk.NumPhases - 1 diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go index 713d53981..38827b02d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go @@ -1,17 +1,14 @@ package ingest import ( - "bytes" "context" "fmt" "os" "path/filepath" - "slices" "time" sdkingest "github.com/stellar/go-stellar-sdk/ingest" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/txhash" ) @@ -19,7 +16,7 @@ import ( // ───────────────────────── Cold writer ───────────────────────── // txhashCold accumulates (routing key, seq) tuples per ledger — each stored -// key is stores.BlindKey(secret, txhash[:ColdKeySize]), keyed at ingest so +// key is txhash.RoutingKey(secret, hash), keyed at ingest so // the deferred SortedBuilder index build consumes an already-keyed, sorted // .bin unchanged. At finalize time it lex-sorts by the (keyed) key and writes // a per-chunk sorted .bin file under //.bin @@ -29,7 +26,6 @@ import ( // this package) turns these .bin files into the queryable cold MPHF index. type txhashCold struct { binPath string - chunkID chunk.ID secret [stores.SecretLen]byte entries []txhash.ColdEntry metrics coldMetrics @@ -41,9 +37,7 @@ type txhashCold struct { // (overwriting any prior attempt's file — see the package doc's artifact model). // secret is the chunk's per-index secret — the same one the index build derives // (txhash.ColdIndexSecret) — that the stored keys are blinded with. -func newTxhashCold( - binPath string, chunkID chunk.ID, sink MetricSink, secret [stores.SecretLen]byte, -) (*txhashCold, error) { +func newTxhashCold(binPath string, sink MetricSink, secret [stores.SecretLen]byte) (*txhashCold, error) { if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil { return nil, fmt.Errorf("mkdir %s: %w", filepath.Dir(binPath), err) } @@ -52,7 +46,6 @@ func newTxhashCold( // and a busy chunk just pays a few amortized growths. t := &txhashCold{ binPath: binPath, - chunkID: chunkID, entries: make([]txhash.ColdEntry, 0, 1<<16), metrics: newColdMetrics(sink, dataTypeTxhash), } @@ -63,7 +56,7 @@ func newTxhashCold( // write accumulates one ledger's tx hashes — one entry per hash, two for a // fee-bump (outer + inner). They come from coldChunk's shared // ExtractLedgerTxParts walk, in apply order. Each is keyed -// (BlindKey(secret, hash[:ColdKeySize])) and appended STRAIGHT into the +// (txhash.RoutingKey — the one blind-and-truncate site) and appended STRAIGHT into the // accumulator — no intermediate per-ledger entry slice; over a ~3M-tx chunk // that intermediate would be hundreds of MB of transient garbage. The // extraction itself is metered once, ledger-scoped, as the ColdExtract signal; @@ -74,12 +67,12 @@ func (t *txhashCold) write(seq uint32, txParts []sdkingest.LedgerTxParts) error before := len(t.entries) for i := range txParts { t.entries = append(t.entries, txhash.ColdEntry{ - Key: stores.BlindKey(t.secret, txParts[i].Hash[:txhash.ColdKeySize]), + Key: txhash.RoutingKey(t.secret, txParts[i].Hash[:]), Seq: seq, }) if txParts[i].FeeBump { t.entries = append(t.entries, txhash.ColdEntry{ - Key: stores.BlindKey(t.secret, txParts[i].InnerHash[:txhash.ColdKeySize]), + Key: txhash.RoutingKey(t.secret, txParts[i].InnerHash[:]), Seq: seq, }) } @@ -93,11 +86,11 @@ func (t *txhashCold) write(seq uint32, txParts []sdkingest.LedgerTxParts) error // pkg/stores/txhash/cold_bin.go pins the layout). func (t *txhashCold) finalize(_ context.Context) error { start := time.Now() - // slices.SortFunc over sort.Slice: reflection-free, meaningfully faster - // on a ~3M-element sort. - slices.SortFunc(t.entries, func(a, b txhash.ColdEntry) int { - return bytes.Compare(a.Key[:], b.Key[:]) - }) + // SortColdEntries is the .bin's stored order — the ONE comparator the + // hot tier's seal sorts through too, so this path and the freeze (which + // streams those sealed records verbatim) agree by construction, down to + // the duplicate-key tie-break. + txhash.SortColdEntries(t.entries) err := txhash.WriteColdBin(t.binPath, t.secret, t.entries) if err == nil { t.metrics.sink.IngestStage(dataTypeTxhash, stageFinalize, time.Since(start), len(t.entries)) diff --git a/cmd/stellar-rpc/internal/rpcv2/lifecycle/helpers_test.go b/cmd/stellar-rpc/internal/rpcv2/lifecycle/helpers_test.go index 880bd3d25..f41d6bc58 100644 --- a/cmd/stellar-rpc/internal/rpcv2/lifecycle/helpers_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/lifecycle/helpers_test.go @@ -82,7 +82,8 @@ func openHotDBForChunk(cat *catalog.Catalog, chunkID chunk.ID, logger *supportlo if err := cat.PutHotTransient(chunkID); err != nil { return nil, fmt.Errorf("mark hot transient chunk %s: %w", chunkID, err) } - db, err := hotchunk.Open(dir, chunkID, logger) + db, err := hotchunk.Open(dir, chunkID, logger, hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, chunkID)) if err != nil { return nil, fmt.Errorf("create hot DB chunk %s: %w", chunkID, err) } diff --git a/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go b/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go index 61126ee07..559012587 100644 --- a/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go @@ -8,8 +8,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stellar/go-stellar-sdk/ingest/ledgerbackend" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/catalog" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" @@ -336,7 +334,8 @@ func TestDeferredDeletion_BusyHandleRetriedAcrossRuns(t *testing.T) { const c chunk.ID = 0 freezeKinds(t, cat, c, geometry.KindLedgers, geometry.KindEvents, geometry.KindTxHash) freezeCoverage(t, cat, cat.TxHashIndexLayout().TxHashIndexID(c), c, c) - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) require.NoError(t, cat.FlipHotReady(c)) @@ -352,9 +351,7 @@ func TestDeferredDeletion_BusyHandleRetriedAcrossRuns(t *testing.T) { go func() { defer close(done) first := true - for _, ierr := range db.Source().RawLedgers( - context.Background(), ledgerbackend.BoundedRange(c.FirstLedger(), c.FirstLedger()), - ) { + for _, ierr := range db.Ledgers().IterateLedgers(c.FirstLedger(), c.FirstLedger()) { if ierr != nil { return } diff --git a/cmd/stellar-rpc/internal/rpcv2/observability/observability.go b/cmd/stellar-rpc/internal/rpcv2/observability/observability.go index 697972728..992e0c596 100644 --- a/cmd/stellar-rpc/internal/rpcv2/observability/observability.go +++ b/cmd/stellar-rpc/internal/rpcv2/observability/observability.go @@ -13,6 +13,8 @@ import ( // Metrics is the daemon's control-plane sink — the derived-progress gauges plus // per-phase wall-clock timings; distinct from the per-data-type ingest.MetricSink. // All methods must be safe for concurrent use. +// +//nolint:interfacebloat // one cohesive metrics sink; every method is a distinct exported signal, not hidden coupling type Metrics interface { // LastCommitted sets the derived last-committed ledger gauge. Owned by the two // call sites that know the TRUE value: startup/backfill (as history advances) diff --git a/cmd/stellar-rpc/internal/rpcv2/packfile/reader.go b/cmd/stellar-rpc/internal/rpcv2/packfile/reader.go index 4503a6a76..0a9308c06 100644 --- a/cmd/stellar-rpc/internal/rpcv2/packfile/reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/packfile/reader.go @@ -76,10 +76,14 @@ type ReaderOptions struct { // ContentHashExtract mirrors WriterOptions.ContentHashExtract. When the // file was written with an extract function, Verify must apply the same // transformation before hashing or the recomputed digest will not match - // the stored one. Caller responsibility to keep this in sync with the - // writer-side option, same as for the codec itself. + // the stored one — UNLESS RecordDecoder already inverts it. Verify hashes + // items as the read path returns them, after decoding, so the pair only + // has to reproduce the bytes the writer hashed between them: a pack + // written pre-compressed with a decompressing extract, and read with a + // decompressing RecordDecoder, needs no extract here. Caller + // responsibility either way, same as for the codec itself. // - // If nil, items are hashed as read from disk. + // If nil, items are hashed as the read path returns them. ContentHashExtract func(item []byte) ([]byte, error) // Concurrency sets the max parallel goroutines for ReadItems. The diff --git a/cmd/stellar-rpc/internal/rpcv2/packfile/writer.go b/cmd/stellar-rpc/internal/rpcv2/packfile/writer.go index 49805981d..3f9acd903 100644 --- a/cmd/stellar-rpc/internal/rpcv2/packfile/writer.go +++ b/cmd/stellar-rpc/internal/rpcv2/packfile/writer.go @@ -28,6 +28,14 @@ const recordCRCLen = 4 // been closed (successfully finalized or aborted). var ErrWriterClosed = errors.New("packfile: writer is closed") +// DefaultBytesPerSync is the writeback cadence every production cold-artifact +// writer uses (1 MiB): frequent enough that Finish's fsync never flushes a +// multi-GB accumulation in one burst — the packfile writer docs' networked/ +// slow-storage rationale — and coarse enough to stay off the write hot path. +// It lives here so the per-package constants that opt in cannot silently +// diverge. +const DefaultBytesPerSync = 1 << 20 + // RecordChecksum selects the integrity check a record carries. It is an axis // of its own rather than a codec: whether the bytes on disk are already // self-checking is a property of the encoded bytes, not of the Format the diff --git a/cmd/stellar-rpc/internal/rpcv2/progress_realdb_test.go b/cmd/stellar-rpc/internal/rpcv2/progress_realdb_test.go index c3a57af47..2d890af31 100644 --- a/cmd/stellar-rpc/internal/rpcv2/progress_realdb_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/progress_realdb_test.go @@ -25,7 +25,7 @@ func seedLedgersCF(t *testing.T, cat *catalog.Catalog, c chunk.ID, entries ...le Logger: silentLogger(), }) require.NoError(t, err) - h := ledger.NewWithStore(store) + h := ledger.NewWithStore(store, ledger.DefaultZstdEncodeWorkers) require.NoError(t, store.Batch(func(b *rocksdb.BatchWriter) error { for _, e := range entries { if berr := h.AddLedgerToBatch(b, e); berr != nil { diff --git a/cmd/stellar-rpc/internal/rpcv2/query/event_page_test.go b/cmd/stellar-rpc/internal/rpcv2/query/event_page_test.go index 227cfaac0..c00b6552a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/event_page_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/event_page_test.go @@ -59,7 +59,8 @@ func seedEventChunks( r := NewRegistry(cat, geometry.NewRetention(0, earliest)) dbs := make(map[chunk.ID]*hotchunk.DB, len(specs)) for _, s := range specs { - db, err := hotchunk.Open(cat.Layout().HotChunkPath(s.c), s.c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(s.c), s.c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, s.c)) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) for i, evs := range s.ledgers { @@ -953,7 +954,7 @@ type fakeEventReader struct { chunkID chunk.ID ofs *event.LedgerOffsets payloads []event.Payload // indexed by ordinal - bitmaps map[event.TermKey]*roaring.Bitmap + postings map[event.TermKey]event.Postings } func (f *fakeEventReader) ChunkID() chunk.ID { return f.chunkID } @@ -964,10 +965,10 @@ func (f *fakeEventReader) EventCount() (uint32, error) { func (f *fakeEventReader) Offsets() (*event.LedgerOffsets, error) { return f.ofs, nil } -func (f *fakeEventReader) LookupKeys(_ context.Context, keys []event.TermKey) ([]*roaring.Bitmap, error) { - out := make([]*roaring.Bitmap, len(keys)) +func (f *fakeEventReader) LookupKeys(_ context.Context, keys []event.TermKey) ([]event.Postings, error) { + out := make([]event.Postings, len(keys)) for i, k := range keys { - out[i] = f.bitmaps[k] + out[i] = f.postings[k] } return out, nil } @@ -1008,7 +1009,7 @@ func TestEventScan_DropsDoNotStallTheChunk(t *testing.T) { require.NoError(t, ofs.Append(f, total)) fake := &fakeEventReader{ chunkID: c, ofs: ofs, - bitmaps: map[event.TermKey]*roaring.Bitmap{}, + postings: map[event.TermKey]event.Postings{}, } for i := range total { label := fmt.Sprintf("noise%d", i) @@ -1028,7 +1029,7 @@ func TestEventScan_DropsDoNotStallTheChunk(t *testing.T) { // are collision-style false positives the post-filter must drop. all := roaring.New() all.AddRange(0, total) - fake.bitmaps[event.ComputeTermKey(cidA[:], event.FieldContractID)] = all + fake.postings[event.ComputeTermKey(cidA[:], event.FieldContractID)] = event.BitmapPostings(all) got, err := scanChunk(context.Background(), eventPart{Chunk: c, Reader: fake, From: f, To: f}, diff --git a/cmd/stellar-rpc/internal/rpcv2/query/ledger_scan_test.go b/cmd/stellar-rpc/internal/rpcv2/query/ledger_scan_test.go index adbe6737c..02fd53291 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/ledger_scan_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/ledger_scan_test.go @@ -20,11 +20,15 @@ import ( // zero-tx ledgers, marks the chunk ready, and publishes the handle on r. func seedHotLedgers(t *testing.T, cat *catalog.Catalog, r *Registry, c chunk.ID, seqs ...uint32) { t.Helper() - lcms := make([][]byte, 0, len(seqs)) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) for _, seq := range seqs { - lcms = append(lcms, rpcv2test.ZeroTxLCMBytes(t, seq)) + rpcv2test.IngestLedger(t, db, seq, rpcv2test.ZeroTxLCMBytes(t, seq)) } - rpcv2test.SeedHotChunkLCMs(t, cat, c, func(db *hotchunk.DB) { r.PublishHandle(c, db) }, lcms...) + require.NoError(t, cat.FlipHotReady(c)) + r.PublishHandle(c, db) } // borderFixture seeds two adjacent hot chunks, each with its first two ledgers @@ -138,7 +142,8 @@ func TestScanLedgers_ColdHotBorder(t *testing.T) { require.NoError(t, cat.FlipChunkFrozen(c0, geometry.KindLedgers)) // c0 is ALSO ready-hot with a published (empty) handle: cold must win. - emptyHot, err := hotchunk.Open(cat.Layout().HotChunkPath(c0), c0, silentLogger()) + emptyHot, err := hotchunk.Open(cat.Layout().HotChunkPath(c0), c0, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c0)) require.NoError(t, err) t.Cleanup(func() { _ = emptyHot.Close() }) require.NoError(t, cat.FlipHotReady(c0)) diff --git a/cmd/stellar-rpc/internal/rpcv2/query/registry.go b/cmd/stellar-rpc/internal/rpcv2/query/registry.go index 8a951f7cf..473411e98 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/registry.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/registry.go @@ -130,9 +130,10 @@ func (h *handleSet) clone() *handleSet { // is closed. func OpenRegistry( cat *catalog.Catalog, retention geometry.Retention, live *hotchunk.DB, lastCommitted uint32, + tuning hotchunk.Tuning, ) (*Registry, error) { r := NewRegistry(cat, retention) - if err := r.publishReadyHandles(live.ChunkID(), cat.Logger()); err != nil { + if err := r.publishReadyHandles(live.ChunkID(), cat.Logger(), tuning); err != nil { r.Close() return nil, err } @@ -347,7 +348,14 @@ func (r *Registry) NewReadView() (*ReadView, error) { // the events facade is warmed (a read-only open is ledgers-only), and the // registry closes them at discard. Runs at startup before any read view is // acquired. -func (r *Registry) publishReadyHandles(liveChunk chunk.ID, logger *supportlog.Entry) error { +// +// A read-write open adopts the chunk's routing secrets, so each is derived +// from the catalog exactly as its cold build derives it +// (hotchunk.SecretsFor) — a chunk resumed here must land on the same keys +// it sealed under, or the open fails loudly. +func (r *Registry) publishReadyHandles( + liveChunk chunk.ID, logger *supportlog.Entry, tuning hotchunk.Tuning, +) error { ready, err := r.catalog.ReadyHotChunkKeys() if err != nil { return fmt.Errorf("bootstrap: read ready hot chunks: %w", err) @@ -356,7 +364,9 @@ func (r *Registry) publishReadyHandles(liveChunk chunk.ID, logger *supportlog.En if c == liveChunk { continue } - db, err := hotchunk.OpenReadyWrite(geometry.HotReady, r.catalog.Layout().HotChunkPath(c), c, logger) + db, err := hotchunk.OpenReadyWrite( + geometry.HotReady, r.catalog.Layout().HotChunkPath(c), c, logger, tuning, + hotchunk.SecretsFor(r.catalog, c)) if err != nil { return fmt.Errorf("bootstrap: open hot chunk %s: %w", c, err) } diff --git a/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go b/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go index 71d506e62..bd6480f35 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go @@ -2,14 +2,12 @@ package query import ( "bytes" - "context" "testing" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stellar/go-stellar-sdk/ingest/ledgerbackend" supportlog "github.com/stellar/go-stellar-sdk/support/log" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/catalog" @@ -46,7 +44,8 @@ func newTestRegistry(t *testing.T, size uint32, earliest chunk.ID) (*Registry, * // leaving no open handle — the on-disk state OpenRegistry reopens. func makeReadyHotChunk(t *testing.T, cat *catalog.Catalog, c chunk.ID) { t.Helper() - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) require.NoError(t, db.Close()) require.NoError(t, cat.FlipHotReady(c)) @@ -61,10 +60,11 @@ func TestOpenRegistry(t *testing.T) { for _, c := range []chunk.ID{5, 6, 7} { makeReadyHotChunk(t, cat, c) } - live, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(7), 7, silentLogger()) + live, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(7), 7, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, 7)) require.NoError(t, err) - r, err := OpenRegistry(cat, geometry.NewRetention(0, 0), live, 70_500) + r, err := OpenRegistry(cat, geometry.NewRetention(0, 0), live, 70_500, hotchunk.DefaultTuning()) require.NoError(t, err) defer r.Close() @@ -276,7 +276,8 @@ func TestHotHandles_CopyOnWrite(t *testing.T) { func publishReadyHandle(t *testing.T, r *Registry, cat *catalog.Catalog, c chunk.ID) { t.Helper() makeReadyHotChunk(t, cat, c) - db, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) r.PublishHandle(c, db) } @@ -312,7 +313,8 @@ func TestTryCloseHandle_BusyRetainsThenRetryDrains(t *testing.T) { r := NewRegistry(cat, geometry.NewRetention(0, 0)) const c chunk.ID = 5 - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) r.PublishHandle(c, db) @@ -323,9 +325,7 @@ func TestTryCloseHandle_BusyRetainsThenRetryDrains(t *testing.T) { go func() { defer close(done) first := true - for _, ierr := range db.Source().RawLedgers( - context.Background(), ledgerbackend.BoundedRange(c.FirstLedger(), c.FirstLedger()), - ) { + for _, ierr := range db.Ledgers().IterateLedgers(c.FirstLedger(), c.FirstLedger()) { if ierr != nil { return } @@ -474,15 +474,17 @@ func TestOpenRegistry_ErrorClosesOpenedHandles(t *testing.T) { cat := openTestCatalog(t, silentLogger()) makeReadyHotChunk(t, cat, 5) // opens fine require.NoError(t, cat.FlipHotReady(6)) // ready key with NO dir: the open fails - live, err := hotchunk.Open(cat.Layout().HotChunkPath(9), 9, silentLogger()) + live, err := hotchunk.Open(cat.Layout().HotChunkPath(9), 9, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, 9)) require.NoError(t, err) t.Cleanup(func() { _ = live.Close() }) - _, err = OpenRegistry(cat, geometry.NewRetention(0, 0), live, 100) + _, err = OpenRegistry(cat, geometry.NewRetention(0, 0), live, 100, hotchunk.DefaultTuning()) require.Error(t, err) require.ErrorContains(t, err, "bootstrap: open hot chunk") - db5, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(5), 5, silentLogger()) + db5, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(5), 5, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, 5)) require.NoError(t, err, "chunk 5's LOCK is free: the error path closed the handle it opened") _ = db5.Close() diff --git a/cmd/stellar-rpc/internal/rpcv2/query/resolve_test.go b/cmd/stellar-rpc/internal/rpcv2/query/resolve_test.go index 93554b133..b0fc5c58b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/resolve_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/resolve_test.go @@ -104,7 +104,8 @@ func TestLedgerReader_Hot(t *testing.T) { const c chunk.ID = 5 cat := openTestCatalog(t, silentLogger()) r := NewRegistry(cat, geometry.NewRetention(0, 0)) - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) @@ -129,7 +130,8 @@ func TestEventReader_Hot(t *testing.T) { const c chunk.ID = 5 cat := openTestCatalog(t, silentLogger()) r := NewRegistry(cat, geometry.NewRetention(0, 0)) - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) @@ -257,7 +259,8 @@ func TestUnavailableResolves_CountsNoServingStore(t *testing.T) { func TestReadViewWithLedger_SubGenesisIsNotFound(t *testing.T) { const c chunk.ID = 5 a, _ := viewFor(t, func(cat *catalog.Catalog, r *Registry) { - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) require.NoError(t, cat.FlipHotReady(c)) diff --git a/cmd/stellar-rpc/internal/rpcv2/query/tx_lookup_test.go b/cmd/stellar-rpc/internal/rpcv2/query/tx_lookup_test.go index f0c36cf02..b3f2a65cf 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/tx_lookup_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/tx_lookup_test.go @@ -30,7 +30,8 @@ func TestHotTxHashIndexes(t *testing.T) { dbs := map[chunk.ID]*hotchunk.DB{} for _, c := range []chunk.ID{5, 6, 7} { - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) r.PublishHandle(c, db) diff --git a/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go b/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go index edb3dfde4..4f81c5af1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go +++ b/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go @@ -139,6 +139,12 @@ type Store struct { cache *grocksdb.Cache bbtos []*grocksdb.BlockBasedTableOptions + // statsEnabled records that RocksDB statistics collection was switched on + // at open (STELLAR_RPC_ROCKSDB_STATS=1 — a debug/bench knob, never set by + // production config); Close then dumps the tickers + histograms to + // STATISTICS.txt in the DB dir. + statsEnabled bool + // mu is a lifecycle / memory-safety lock at the C boundary, not // a data-consistency lock (RocksDB is already thread-safe). // Every op (Put/Get/Delete/Iterate/Batch/Flush) takes RLock for @@ -201,6 +207,11 @@ func resolveCFNames(cfg Config) []string { return out } +// Path returns the store's on-disk directory (as configured; not +// canonicalized). The events hot index places its run files under it so the +// chunk lifecycle's directory wipe covers them too. +func (s *Store) Path() string { return s.cfg.Path } + // Put writes one (key, value) to cf. cf == "" normalizes to "default". // For atomic multi-write, use Batch. func (s *Store) Put(cf string, key, value []byte) error { @@ -538,6 +549,18 @@ func (s *Store) teardownLocked() { s.cfg.Logger.Warnf("rocksdb: closing %s with %d unreleased snapshot(s)", s.cfg.Path, n) } + // Statistics were requested at open (STELLAR_RPC_ROCKSDB_STATS): dump them + // before the C teardown below invalidates opts. Best-effort — a failed dump + // warns and Close proceeds. + if s.statsEnabled { + statsPath := filepath.Join(s.cfg.Path, "STATISTICS.txt") + if werr := os.WriteFile(statsPath, []byte(s.opts.GetStatisticsString()), 0o600); werr != nil { + s.cfg.Logger.WithError(werr).Warnf("rocksdb: writing %s", statsPath) + } else { + s.cfg.Logger.Infof("rocksdb: statistics dumped to %s", statsPath) + } + } + for _, cfh := range s.cfHandles { cfh.Destroy() } @@ -706,6 +729,15 @@ func (s *Store) constructAndOpen() error { opts.SetCreateIfMissing(true) opts.SetCreateIfMissingColumnFamilies(true) } + // STELLAR_RPC_ROCKSDB_STATS=1 — debug/bench knob: collect RocksDB + // statistics (tickers + histograms: WAL sync micros, write micros, stall + // micros, ...) and dump them to STATISTICS.txt in the DB dir on Close. + // Costs a few percent of write throughput; intentionally not a Tuning + // field so production configs can never turn it on. + if os.Getenv("STELLAR_RPC_ROCKSDB_STATS") == "1" { + opts.EnableStatistics() + s.statsEnabled = true + } cfOpts := make([]*grocksdb.Options, len(cfNames)) for i := range cfOpts { diff --git a/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go b/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go index 0c6ff3992..0a8d302d6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go +++ b/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go @@ -5,13 +5,11 @@ package rpcv2test import ( - "bytes" "context" "encoding/binary" "io" "os" "path/filepath" - "slices" "testing" "github.com/sirupsen/logrus" @@ -76,7 +74,8 @@ func SeedHotChunkLCMs( t *testing.T, cat *catalog.Catalog, c chunk.ID, publish func(*hotchunk.DB), lcms ...[]byte, ) { t.Helper() - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, SilentLogger()) + db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, SilentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) for i, raw := range lcms { @@ -135,7 +134,8 @@ func IngestLedger(t *testing.T, db *hotchunk.DB, seq uint32, raw []byte) { t.Helper() txParts, err := sdkingest.ExtractLedgerTxParts(xdr.LedgerCloseMetaView(raw)) require.NoError(t, err) - _, err = db.IngestLedger(seq, xdr.LedgerCloseMetaView(raw), txParts) + _, err = db.IngestLedger(seq, xdr.LedgerCloseMetaView(raw), txParts, + db.StartCompress(seq, xdr.LedgerCloseMetaView(raw))) require.NoError(t, err) } @@ -219,11 +219,11 @@ func WriteColdTxIndexFile( cold := make([]txhash.ColdEntry, 0, len(entries)) for h, seq := range entries { var e txhash.ColdEntry - e.Key = stores.BlindKey(secret, h[:txhash.ColdKeySize]) + e.Key = txhash.RoutingKey(secret, h[:]) e.Seq = seq cold = append(cold, e) } - slices.SortFunc(cold, func(a, b txhash.ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) + txhash.SortColdEntries(cold) // the production stored order, not a second comparator bin := filepath.Join(t.TempDir(), txhash.ColdBinName(cov.Lo)) require.NoError(t, txhash.WriteColdBin(bin, secret, cold)) diff --git a/cmd/stellar-rpc/internal/rpcv2/startup.go b/cmd/stellar-rpc/internal/rpcv2/startup.go index b2e1d7efc..e1d31f311 100644 --- a/cmd/stellar-rpc/internal/rpcv2/startup.go +++ b/cmd/stellar-rpc/internal/rpcv2/startup.go @@ -20,6 +20,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/lifecycle" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/observability" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/query" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // run is the daemon's startup, in two steps: (1) BACKFILL to the tip, then @@ -89,7 +90,7 @@ func run(ctx context.Context, cfg StartConfig) error { // deferred close owns it (and g.Wait joins before run returns, so there is no // window where neither owns it). Restarts re-enter run() from the top, so this // stays the single initial-open site; the loop still reopens at each boundary. - hotDB, err := openHotDBForChunk(cat, chunk.IDFromLedger(resumeLedger), logger) + hotDB, err := openHotDBForChunk(cat, chunk.IDFromLedger(resumeLedger), logger, cfg.HotTuning) if err != nil { return fmt.Errorf("startup open resume hot tier for ledger %d: %w", resumeLedger, err) } @@ -128,8 +129,8 @@ func run(ctx context.Context, cfg StartConfig) error { // acquired before the first commit is correct. The ingestion loop advances the // latest ledger and publishes handles from here, the freeze reads through them // (HotHandle), and the lifecycle retires them at discard. Constructed per run — - // no query survives a restart; ServeReads below serves this run's reads from it. - registry, err := query.OpenRegistry(cat, cfg.Retention, hotDB, lastCommitted) + // no query survives a restart. It will back the read server (#772). + registry, err := query.OpenRegistry(cat, cfg.Retention, hotDB, lastCommitted, cfg.HotTuning) if err != nil { return fmt.Errorf("startup open registry: %w", err) } @@ -201,6 +202,7 @@ func run(ctx context.Context, cfg StartConfig) error { Sink: cfg.Exec.Process.Sink, Registry: registry, FeeWindows: cfg.FeeWindows, + Tuning: cfg.HotTuning, }) if err == nil { // WithContext cancels gctx (unblocking the lifecycle sibling in g.Wait) @@ -379,6 +381,13 @@ type StartConfig struct { // Core starts captive core and yields the ingestion getter. Required. Core CoreOpener + // HotTuning is the write-open tuning for every hot DB the daemon opens + // (the startup resume open and the loop's boundary reopens). Its + // ZstdEncodeWorkers field is FORMAT-AFFECTING (see hotchunk.Tuning) and + // resolves from the daemon config's storage.zstd_encode_workers; the + // backfill Exec carries the matching value for the walk's cold encode. + HotTuning hotchunk.Tuning + // Endpoint is the read server's listen address. run() carries it because // run() owns the bind (a taken port fails startup before the loops // launch); everything else about serving belongs to the ServeReads diff --git a/cmd/stellar-rpc/internal/rpcv2/startup_test.go b/cmd/stellar-rpc/internal/rpcv2/startup_test.go index 50b5ad14a..fe1d25a32 100644 --- a/cmd/stellar-rpc/internal/rpcv2/startup_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/startup_test.go @@ -20,6 +20,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/geometry" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/query" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rpcv2test" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // --------------------------------------------------------------------------- @@ -510,7 +511,7 @@ func TestRun_BindFailureSurfaces(t *testing.T) { // run() opened the resume hot DB before serving and closed it on the error path // (the loop never took ownership): reopening it succeeds (LOCK released). - db, err := openHotDBForChunk(cat, chunk.IDFromLedger(chunk.FirstLedgerSeq), silentLogger()) + db, err := openHotDBForChunk(cat, chunk.IDFromLedger(chunk.FirstLedgerSeq), silentLogger(), hotchunk.DefaultTuning()) require.NoError(t, err, "the resume hot DB is reopenable — run released its LOCK") require.NoError(t, db.Close()) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom.go b/cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom.go new file mode 100644 index 000000000..e5c71bed9 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom.go @@ -0,0 +1,61 @@ +// Package bloom holds the run-routing bloom filter BOTH hot engines probe +// before they touch a sealed run file (stores/event's term runs, +// stores/txhash's packed-row runs). Geometry: ~10 bits/key, 7 probes +// double-hashed from a 64-bit fingerprint, bit count rounded up to a power of +// two so indexing is a mask. One []uint64 and no pointers — GC-invisible. +// +// The filter is key-agnostic: callers hash their own key (events xxhash the +// 16-byte term, txhash the 32-byte hash) and pass the fingerprint, so nothing +// here has to know either engine's key shape. +// +// Filters are RAM-only — 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, and displaced generations' filters are freed only at chunk +// Close (design-docs/full-history-campaign-handoff.md, "RAM anchor"). Tune the +// geometry here, once, for both engines. +package bloom + +// probes is how many bits one key sets and one probe tests. +const probes = 7 + +// Filter is a plain double-hashed bloom over 64-bit fingerprints. A copy +// aliases the original's bits (both see every later Add), and concurrent Add +// is unsynchronized — the engines build a filter on one goroutine and only +// read it once its run is published. +type Filter struct { + bits []uint64 + mask uint64 +} + +// New sizes a filter for n keys at ~10 bits/key, rounded up to a power of two +// (mask-indexable). n must be at least 1. +func New(n int) Filter { + bits := 1 + for bits < n*10 { + bits <<= 1 + } + return Filter{bits: make([]uint64, bits/64+1), mask: uint64(bits - 1)} //nolint:gosec // bits >= 1 +} + +// Add records the fingerprint fp. +func (b *Filter) Add(fp uint64) { + h2 := fp>>33 | fp<<31 | 1 // odd second hash + for i := range probes { + bit := (fp + uint64(i)*h2) & b.mask + b.bits[bit/64] |= 1 << (bit % 64) + } +} + +// MayContain reports whether fp may have been added: false is definitive, true +// carries the false-positive rate the sizing bought. +func (b *Filter) MayContain(fp uint64) bool { + h2 := fp>>33 | fp<<31 | 1 + for i := range probes { + bit := (fp + uint64(i)*h2) & b.mask + if b.bits[bit/64]&(1<<(bit%64)) == 0 { + return false + } + } + return true +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom_test.go new file mode 100644 index 000000000..9952fa90a --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom_test.go @@ -0,0 +1,69 @@ +package bloom + +import ( + "math/bits" + "math/rand/v2" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testRNG(seed uint64) *rand.Rand { + return rand.New(rand.NewPCG(seed, seed*7919+1)) +} + +// TestFilter_NoFalseNegatives pins the property every run lookup rests on: a +// negative MayContain skips the run's file entirely, so an added fingerprint +// must NEVER answer false. +func TestFilter_NoFalseNegatives(t *testing.T) { + for _, n := range []int{1, 2, 63, 64, 65, 1000, 100_000} { + rng := testRNG(uint64(n)) + fps := make([]uint64, n) + f := New(n) + for i := range fps { + fps[i] = rng.Uint64() + f.Add(fps[i]) + } + for i, fp := range fps { + require.Truef(t, f.MayContain(fp), "n=%d key %d", n, i) + } + } +} + +// TestNew_SizesPowerOfTwoAtTenBitsPerKey pins the sizing geometry: a +// mask-indexable power of two at or just above 10 bits/key, with a word array +// long enough for the highest reachable bit. +func TestNew_SizesPowerOfTwoAtTenBitsPerKey(t *testing.T) { + for _, n := range []int{1, 7, 100, 1024, 3_000_000} { + f := New(n) + size := f.mask + 1 + assert.Equalf(t, 1, bits.OnesCount64(size), "n=%d: %d bits is not a power of two", n, size) + assert.GreaterOrEqualf(t, size, uint64(10*n), "n=%d: fewer than 10 bits/key", n) + assert.Lessf(t, size, uint64(20*n), "n=%d: more than 20 bits/key", n) + assert.GreaterOrEqualf(t, uint64(len(f.bits)), size/64, "n=%d: word array too short", n) + } +} + +// TestFilter_FalsePositiveRate guards the probe geometry against the sizing. +// The bound is deliberately loose — an honest re-tune of bits/key or probes +// should not have to touch it — but it catches a filter whose probes stop +// spreading: this shape measures 0.21% (420/200000), one probe measures 7.4%. +func TestFilter_FalsePositiveRate(t *testing.T) { + const keys = 20_000 + rng := testRNG(42) + f := New(keys) + for range keys { + f.Add(rng.Uint64()) + } + positives := 0 + const probeCount = 200_000 + for range probeCount { + // Absent with overwhelming probability: a 64-bit fingerprint drawn + // from the same stream collides with an added one at ~2^-44 per pair. + if f.MayContain(rng.Uint64()) { + positives++ + } + } + assert.Lessf(t, positives, probeCount/50, "false-positive rate above 2%%: %d/%d", positives, probeCount) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/errors.go b/cmd/stellar-rpc/internal/rpcv2/stores/errors.go index 1f9db2c69..774f42386 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/errors.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/errors.go @@ -1,6 +1,7 @@ // Package stores hosts shared per-domain store packages (ledger, -// txhash, event, hotchunk) and the cross-cutting error sentinels they -// emit. Per-domain stores translate their backing primitive's +// txhash, event, hotchunk), the RAM-only primitives more than one of +// them routes through (bloom), and the cross-cutting error sentinels +// they emit. Per-domain stores translate their backing primitive's // errors (rpcv2/rocksdb, rpcv2/packfile, os) into these sentinels at // their public-method boundaries, so callers depend only on // stores sentinels regardless of which backend served the diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/append_terms_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/append_terms_test.go new file mode 100644 index 000000000..a7fff5520 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/append_terms_test.go @@ -0,0 +1,91 @@ +package event + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/go-stellar-sdk/xdr" +) + +// TestAppendTerms_GoldenAgainstTermsForBytes is the Count()+Raw() walk's +// golden gate: AppendTerms must be observably identical to the +// TopicsView.All path TermsForBytes still uses — byte-identical TermKeys on +// every accept, reject exactly when the old path rejects. The sweep +// truncates each fixture's raw XDR at EVERY length, which covers the +// mandated cases by construction: truncation inside an in-cap topic, +// truncation inside an over-cap topic (the overCap fixture's topics 4 and 5 +// — Raw sizes every element, so over-cap truncation must still reject), and +// harmless truncation past the topics array (both paths accept). +func TestAppendTerms_GoldenAgainstTermsForBytes(t *testing.T) { + var cid xdr.ContractId + cid[0], cid[1] = 0xab, 0xcd + fixtures := map[string]xdr.ContractEvent{ + "cidNoTopics": symTopicEvent(&cid), + "cidOneTopic": symTopicEvent(&cid, "transfer"), + "noCidTopics": symTopicEvent(nil, "a", "b"), + "cidMaxTopics": symTopicEvent(&cid, "t0", "t1", "t2", "t3"), + "cidOverCap": symTopicEvent(&cid, "t0", "t1", "t2", "t3", "over", "flow"), + } + for name, ev := range fixtures { + t.Run(name, func(t *testing.T) { + raw := marshaledEvent(t, ev) + sawReject := false + for n := 0; n <= len(raw); n++ { + prefix := raw[:n] + want, wantErr := TermsForBytes(prefix) + got, gotErr := AppendTerms(nil, prefix) + if wantErr != nil { + sawReject = true + require.Error(t, gotErr, "prefix of %d bytes: old path rejected, new must too", n) + continue + } + require.NoError(t, gotErr, "prefix of %d bytes: old path accepted, new must too", n) + assert.Equal(t, want, got, "keys diverged at prefix of %d bytes", n) + } + require.True(t, sawReject, "sweep must hit at least one truncation reject") + }) + } +} + +// TestAppendTerms_UnsupportedBodyVersionHardFails mirrors the TermsForBytes +// pin: a future ContractEvent body version is a hard error on the arena +// path too — never a silent contractID-only index. +func TestAppendTerms_UnsupportedBodyVersionHardFails(t *testing.T) { + // Layout without a contract ID: + // ext.V (4) || contractId flag (4, =0) || type (4) || body.V (4). + raw := marshaledEvent(t, symTopicEvent(nil, "transfer")) + binary.BigEndian.PutUint32(raw[12:16], 1) + + _, err := AppendTerms(nil, raw) + require.ErrorContains(t, err, "unsupported ContractEvent body version 1") +} + +// TestAppendTerms_ArenaAppendAndBound pins the arena contract: AppendTerms +// APPENDS (existing dst content untouched, backing array reused within +// capacity) and contributes at most MaxTermsPerEvent keys per event — the +// protocol bound (contract ID + protocol.MaxTopicCount topics) per-ledger +// arenas are sized around. +func TestAppendTerms_ArenaAppendAndBound(t *testing.T) { + var cid xdr.ContractId + cid[0] = 0x77 + raw := marshaledEvent(t, symTopicEvent(&cid, "t0", "t1", "t2", "t3", "extra")) + + dst := make([]TermKey, 0, 2*MaxTermsPerEvent) + dst, err := AppendTerms(dst, raw) + require.NoError(t, err) + require.Len(t, dst, MaxTermsPerEvent, + "contract ID + MaxTopicCount topics is the per-event maximum (extras not indexed)") + first := append([]TermKey(nil), dst...) + + // A second event appends AFTER the first's keys, in the same array. + base := &dst[0] + dst, err = AppendTerms(dst, raw) + require.NoError(t, err) + require.Len(t, dst, 2*MaxTermsPerEvent) + assert.Equal(t, first, dst[:MaxTermsPerEvent], "existing arena content must be untouched") + assert.Equal(t, first, dst[MaxTermsPerEvent:], "same event must yield the same keys") + assert.Same(t, base, &dst[0], "within capacity the arena must not reallocate") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go index da09a6b99..f801031e0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -90,9 +90,10 @@ const ( const indexPackChecksum = packfile.ChecksumCRC32C // IndexRecordFingerprintLen is the byte width of the leading -// fingerprint in every index.pack record. The cold reader checks -// this against the queried term's first four bytes to filter MPHF -// false positives before deserializing the bitmap. +// fingerprint in every index.pack record — the ROUTED (blinded) +// key's first four bytes. The cold reader checks it against the +// routed query key to filter MPHF false positives before decoding +// the postings. const IndexRecordFingerprintLen = 4 // ────────────────────────────────────────────────────────────────── @@ -184,6 +185,13 @@ const eventsPackItemsPerRecord = 128 // anyway. const indexPackItemsPerRecord = 128 +// indexPackBytesPerSync smooths the index.pack build's dirty pages into +// 1MiB background-writeback initiations, matching the events.pack build's +// coldBytesPerSync and the packfile writer docs' rationale: spreading +// writeback keeps the final Finish fsync from flushing a multi-GB burst, +// a large win on networked/slow cold storage. +const indexPackBytesPerSync = packfile.DefaultBytesPerSync + // newEventsPackEncoder constructs a fresh zstd encoder for one // packfile writer goroutine. RecordEncoder is not safe for concurrent // use, so the packfile writer invokes this per worker. @@ -304,8 +312,12 @@ func DecodeLedgerOffsets(data []byte) (*LedgerOffsets, error) { // (see stores/blind.go). The wrapper therefore feeds // streamhash stores.BlindKey(secret, TermKey) at both build and // query, with the deterministic per-chunk secret (ColdIndexSecret) -// stored in index.hash's user metadata. The 4-byte app fingerprint in index.pack and the -// downstream post-filter stay on the ORIGINAL TermKey bytes. +// stored in index.hash's user metadata. The 4-byte app fingerprint +// in index.pack is the ROUTED (blinded) key's prefix — the one +// identity the streaming builder's pass B has in hand — and the +// reader compares it against the routed query key. Only the +// downstream post-filter still sees original bytes: it verifies raw +// event FIELDS, never TermKeys. // ────────────────────────────────────────────────────────────────── // index.hash user-metadata wire format (streamhash WithMetadata): @@ -417,7 +429,7 @@ func buildMPHF( if err = ctx.Err(); err != nil { return nil, fmt.Errorf("events: build MPHF canceled after %d keys: %w", i, err) } - rk := stores.BlindKey(secret, key[:]) + rk := blindTerm(secret, key) if err = builder.AddKey(rk[:], 0); err != nil { return nil, fmt.Errorf("events: add key %d: %w", i, err) } @@ -461,7 +473,9 @@ func openMPHF(path string) (*mphf, error) { return &mphf{idx: idx, secret: secret}, nil } -// Lookup returns the dense slot in [0, N) that key maps to. +// Lookup blinds key with the index's routing secret and returns its +// dense slot in [0, N) plus the routed key — the identity every +// record and fingerprint downstream is keyed by. // // streamhash returns ErrKeyNotFound for keys its routing-stage check // can prove were never in the build set; callers should treat this @@ -470,8 +484,16 @@ func openMPHF(path string) (*mphf, error) { // 4-byte fingerprint stored alongside the bitmap at that slot in // index.pack — an MPHF can map an unseen key to a valid build-set // slot, and only the fingerprint catches that residual collision. -func (m *mphf) Lookup(key TermKey) (uint32, error) { - rk := stores.BlindKey(m.secret, key[:]) +func (m *mphf) Lookup(key TermKey) (uint32, TermKey, error) { + rk := blindTerm(m.secret, key) + slot, err := m.LookupRouted(rk) + return slot, rk, err +} + +// LookupRouted returns the dense slot for an ALREADY-ROUTED key. The +// streaming build's pass B feeds run keys that were blinded when they +// entered the spill (ingest keys the runs), so it must not blind again. +func (m *mphf) LookupRouted(rk TermKey) (uint32, error) { slot, err := m.idx.QueryRank(rk[:]) if err != nil { if errors.Is(err, streamhash.ErrNotFound) { @@ -493,6 +515,12 @@ func (m *mphf) Close() error { return m.idx.Close() } +// MaxBlockKeys reports the opened index's format-level per-block key +// ceiling (streamhash.Index.MaxBlockKeys). The streaming builder's pass B +// derives its reorder backstop from it, so the bound tracks the artifact's +// format instead of transcribing a streamhash internal here. +func (m *mphf) MaxBlockKeys() uint32 { return m.idx.MaxBlockKeys() } + // isEmpty reports whether the index holds zero terms (an eventless chunk). func (m *mphf) isEmpty() bool { return m.numKeys() == 0 } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format_test.go index fbd335040..c2c94ba46 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format_test.go @@ -145,7 +145,7 @@ func TestBuild_KnownKeysGetUniqueSlotsInRange(t *testing.T) { seen := make(map[uint32]int, n) for i := range n { - slot, err := m.Lookup(keyFor(i)) + slot, _, err := m.Lookup(keyFor(i)) require.NoError(t, err) assert.Less(t, slot, uint32(n), "slot %d out of range for key %d", slot, i) if prev, dup := seen[slot]; dup { @@ -165,10 +165,10 @@ func TestBuild_LookupIsDeterministic(t *testing.T) { for i := range n { k := keyFor(i) - first, err := m.Lookup(k) + first, _, err := m.Lookup(k) require.NoError(t, err) for range 5 { - repeat, err := m.Lookup(k) + repeat, _, err := m.Lookup(k) require.NoError(t, err) assert.Equal(t, first, repeat, "Lookup must be deterministic across calls") } @@ -198,7 +198,7 @@ func TestLookup_UnseenKeyBehavior(t *testing.T) { fmt.Appendf(nil, "never-added-%d", i), FieldTopic0, ) - slot, err := m.Lookup(unseen) + slot, _, err := m.Lookup(unseen) switch { case errors.Is(err, ErrKeyNotFound): fastNoMatch++ @@ -224,7 +224,7 @@ func TestBuild_EmptyIndexSucceeds(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = m.Close() }) assert.True(t, m.isEmpty()) - _, lerr := m.Lookup(ComputeTermKey([]byte("anything"), FieldContractID)) + _, _, lerr := m.Lookup(ComputeTermKey([]byte("anything"), FieldContractID)) assert.ErrorIs(t, lerr, ErrKeyNotFound) } @@ -241,7 +241,7 @@ func TestOpen_RoundTripsBuiltFile(t *testing.T) { expected := make(map[TermKey]uint32, n) for i := range n { k := keyFor(i) - slot, err := built.Lookup(k) + slot, _, err := built.Lookup(k) require.NoError(t, err) expected[k] = slot } @@ -252,7 +252,7 @@ func TestOpen_RoundTripsBuiltFile(t *testing.T) { t.Cleanup(func() { _ = reopened.Close() }) for k, want := range expected { - got, err := reopened.Lookup(k) + got, _, err := reopened.Lookup(k) require.NoError(t, err) assert.Equal(t, want, got, "slot for key %x must round-trip via Open", k) } @@ -272,7 +272,7 @@ func TestBuild_AcceptsManyKeys(t *testing.T) { seen := make(map[uint32]struct{}, n) for i := range n { - slot, err := m.Lookup(keyFor(i)) + slot, _, err := m.Lookup(keyFor(i)) require.NoError(t, err) assert.Less(t, slot, uint32(n)) seen[slot] = struct{}{} @@ -349,7 +349,7 @@ func TestBuild_WritesKeyedRoutingMetadata(t *testing.T) { rk := stores.BlindKey(secret, k[:]) want, err := raw.QueryRank(rk[:]) require.NoError(t, err, "routed key %d must be in the build set", i) - got, err := m.Lookup(k) + got, _, err := m.Lookup(k) require.NoError(t, err) assert.Equal(t, uint32(want), got, "Lookup must equal QueryRank over the routed key") } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go new file mode 100644 index 000000000..0be5f524e --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -0,0 +1,291 @@ +package event + +// cold_freeze.go — freeze-by-merge: build a completed hot chunk's cold events +// artifacts DIRECTLY from its durable hot state instead of re-deriving them +// from raw ledgers. The hot tier already holds exactly what cold needs: +// +// - events_data values ARE the canonical marshaled payloads (the same +// MarshalInto bytes ColdWriter.Append would produce) → stream them into +// events.pack verbatim, in eventID order (the CF's BE-key order). +// - events_offsets IS the per-ledger count sequence → LedgerOffsets. +// - the term index IS the hot engine's published run set: the +// manifest-listed sealed runs (union-merged, CRC-framed EVR3 files beside +// the chunk DB, read in place — already keyed by the blinded routing +// identity, because the engine blinds at seal) plus the un-sealed +// events_index tail past the sealed frontier, windowed into tail-only +// scratch runs that blind at insert. Both feed WriteColdIndexFromRuns' +// one MergeRuns — the same trust rule as warmup (hot_store.go): sealed +// rows' derived runs are the CRC-verified authority, packed rows replay +// from lastSealed+1. This is the txhash freeze's shape +// (txhash/cold_freeze.go): merge the engine's durable runs with its +// un-sealed tail. +// +// Byte identity with the walk-derived build is structural, not incidental: +// per-term event IDs are strictly ascending and globally duplicate-free, so +// MergeRuns' per-term union is invariant across ANY partition of the ledger +// rows into runs, and [sealed runs + tail rows] hold exactly the fold of all +// events_index rows. Crash states change the partition, never the content: +// an empty manifest makes the whole chunk tail (the degenerate full scan — +// this same path with zero manifest runs, no separate fallback), and an +// orphan run (a crash between run write and manifest Put, or a discarded +// merge) is unlisted, therefore ignored, and re-covered by the tail. A +// corrupt or missing listed run fails the freeze loudly (the merge drains +// every run to its CRC trailer — runspill.ErrCorruptRun). Live runs are +// strictly read-only inputs — only scratchDir, outside the chunk DB, is ever +// wiped — so the freeze is idempotent. All pinned by cold_freeze_test.go's +// identity gates and the composition gate (ingest/freeze_test.go). + +import ( + "context" + "encoding/binary" + "fmt" + "math" + "os" + "path/filepath" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" +) + +// replayOffsets rebuilds the chunk's ledger offsets from OffsetsCF through +// the shared scanner (one shape/decode trust boundary with warmup); range, +// overflow, and sequencing checks stay beside the accumulator they guard. +func replayOffsets(store *rocksdb.Store, chunkID chunk.ID) (*LedgerOffsets, error) { + offsets := NewLedgerOffsets(chunkID.FirstLedger()) + if err := scanOffsetsCF(store, func(ledger, count uint32) error { + if ledger > chunkID.LastLedger() { + return fmt.Errorf("events: freeze offsets: ledger %d past chunk %s", ledger, chunkID) + } + if uint64(offsets.TotalEvents())+uint64(count) > math.MaxUint32 { + return fmt.Errorf("events: freeze offsets: cumulative overflow at ledger %d", ledger) + } + // Append validates the in-sequence invariant itself (untrusted rows). + if aerr := offsets.Append(ledger, count); aerr != nil { + return fmt.Errorf("events: freeze offsets: %w", aerr) + } + return nil + }); err != nil { + return nil, err + } + return offsets, nil +} + +// copyDataCF streams the DataCF verbatim into the cold writer in eventID +// order, enforcing key shape and ID density, and returns the copied count. +func copyDataCF(ctx context.Context, store *rocksdb.Store, w *ColdWriter) (uint64, error) { + var copied uint64 + next := uint32(0) + for entry, ierr := range store.Iterate(DataCF, nil) { + if ierr != nil { + return copied, fmt.Errorf("events: freeze scan %s: %w", DataCF, ierr) + } + // ctx.Err takes a mutex; polling per event costs real time over + // millions of event. Same cadence as the sibling freeze scans. + if next%256 == 0 { + if cerr := ctx.Err(); cerr != nil { + return copied, cerr + } + } + if len(entry.Key) != dataKeyLen { + return copied, fmt.Errorf("events: freeze %s key length %d (want %d)", DataCF, len(entry.Key), dataKeyLen) + } + // The hot writer assigns dense chunk-relative IDs; a gap here is real + // corruption, and events.pack positions must equal IDs. + if id := binary.BigEndian.Uint32(entry.Key); id != next { + return copied, fmt.Errorf("events: freeze %s: event id %d, expected %d", DataCF, id, next) + } + next++ + copied++ + if aerr := w.AppendMarshaled(entry.Value); aerr != nil { + return copied, aerr + } + } + return copied, nil +} + +// freezeIndexWindowBytes caps how many DECODED window bytes (key + ids, as +// foldBlindedRow accounts them) accumulate in the window map before it +// flushes as one spill run. The tail is normally under +// one seal window (≤windowLedgers event-bearing rows → a single small run); +// the cap bounds the tail window — largest for a crash-inherited backlog, up +// to the whole chunk when the manifest is empty — where ~32MB windows keep +// freeze memory bounded. +const freezeIndexWindowBytes = 32 << 20 + +// FreezeColdFromStore builds all three cold events artifacts for chunkID in +// bucketDir from the chunk's (read-only) hot store. scratchDir hosts the tail +// spill runs and terms.run; it is wiped on entry and removed on success, and +// must lie OUTSIDE the chunk DB (production: the cold events bucket) — the +// manifest-listed runs beside the DB's CFs are consumed in place, strictly +// read-only. opts tunes the events.pack writer exactly as the walk-driven +// build does. +// +// secret is the chunk's routing secret (ColdIndexSecret): the walk path +// blinds term keys before its spiller, and this path's inputs are blinded by +// the time they get here — the sealed runs at seal time (blind at seal), the +// tail's window map at insert — so both builds are keyed by one blinded +// identity and no run is ever re-keyed. The sealed runs' keys came from the +// secret the chunk DB adopted at its first read-write open, so a caller's +// secret that disagrees would merge two keyspaces into one index; +// requireStoreSecret rejects that before any artifact is written. +func FreezeColdFromStore( + ctx context.Context, + chunkID chunk.ID, + store *rocksdb.Store, + scratchDir, bucketDir string, + secret [stores.SecretLen]byte, + opts ColdWriterOptions, +) (err error) { + if err := os.MkdirAll(bucketDir, 0o755); err != nil { + return fmt.Errorf("events: mkdir freeze bucket %s: %w", bucketDir, err) + } + if err := requireStoreSecret(store, secret); err != nil { + return fmt.Errorf("events: freeze chunk %s: %w", chunkID, err) + } + if err := os.RemoveAll(scratchDir); err != nil { + return fmt.Errorf("events: wipe freeze scratch %s: %w", scratchDir, err) + } + if err := os.MkdirAll(scratchDir, 0o755); err != nil { + return fmt.Errorf("events: mkdir freeze scratch %s: %w", scratchDir, err) + } + + // ── events.pack: verbatim DataCF copy in eventID order. ── + w, err := NewColdWriter(chunkID, bucketDir, opts) + if err != nil { + return err + } + // Close after a successful Commit is a documented no-op returning nil, so + // the unconditional defer needs no lifecycle flag of its own. + defer func() { + if cerr := w.Close(); err == nil { + err = cerr + } + }() + + copied, err := copyDataCF(ctx, store, w) + if err != nil { + return err + } + + // ── offsets: OffsetsCF replay through the shared scanner (one shape/ + // decode trust boundary with warmup); range, overflow, and sequencing + // checks stay here with the accumulator they guard. ── + offsets, err := replayOffsets(store, chunkID) + if err != nil { + return err + } + if total := uint64(offsets.TotalEvents()); total != copied { + return fmt.Errorf("events: freeze: offsets count %d events, data CF holds %d", total, copied) + } + if err := w.Commit(offsets); err != nil { + return fmt.Errorf("events: freeze Commit: %w", err) + } + + // ── index: the engine's durable sealed runs + the un-sealed tail. ── + runs, err := freezeIndexInputs(ctx, store, chunkID, scratchDir, secret) + if err != nil { + return err + } + if err := WriteColdIndexFromRuns(ctx, chunkID, runs, scratchDir, bucketDir, secret); err != nil { + return err + } + return os.RemoveAll(scratchDir) +} + +// freezeIndexInputs assembles the index merge's run list: the +// manifest-listed sealed runs IN PLACE — already blinded, so the merge reads +// them where they lie, read-only, and never moves, re-keys, or deletes one — +// followed by the tail scratch runs from freezeIndexRuns. Only manifest-named +// runs qualify: an orphan a crash left beside them holds nothing the tail +// past the frontier doesn't re-cover, while a missing or corrupt LISTED run +// fails the merge loudly rather than silently thinning the index. +func freezeIndexInputs( + ctx context.Context, store *rocksdb.Store, chunkID chunk.ID, scratchDir string, + secret [stores.SecretLen]byte, +) ([]string, error) { + names, lastSealed, err := rocksdbManifest{store: store}.GetRuns() + if err != nil { + return nil, fmt.Errorf("events: freeze manifest: %w", err) + } + runDir := filepath.Join(store.Path(), hotIndexRunDir) + runs := make([]string, 0, len(names)) + for _, name := range names { + runs = append(runs, filepath.Join(runDir, name)) + } + tail, err := freezeIndexRuns(ctx, store, chunkID, scratchDir, lastSealed, secret) + if err != nil { + return nil, err + } + return append(runs, tail...), nil +} + +// freezeIndexRuns scans the UN-SEALED IndexCF tail — per-ledger packed rows +// past the sealed frontier, the same lastSealed+1 replay rule warmup applies +// — unions the rows in a byte-bounded window map, and flushes each full +// window as one scratch spill run. Every row is term-sorted with per-term +// ascending IDs, and ledger rows arrive in ledger order, so per-term appends +// stay ascending within a window; cross-window (and sealed-run/tail) +// duplicates union in the merge. An empty manifest reads as lastSealed 0 — +// the whole chunk is tail and this is the fresh-crash full scan, not a +// separate path. +func freezeIndexRuns( + ctx context.Context, store *rocksdb.Store, chunkID chunk.ID, scratchDir string, lastSealed uint32, + secret [stores.SecretLen]byte, +) ([]string, error) { + var ( + window = make(map[TermKey][]uint32, 1<<16) + windowBytes int + runs []string + ) + flush := func() error { + if len(window) == 0 { + return nil + } + // Stream the window in sorted-term order through the seal path's + // shared writer — no whole-payload buffer (the same trim the + // seal/merge path got in the streaming rework). + path := filepath.Join(scratchDir, fmt.Sprintf("freeze-%06d.run", len(runs))) + if _, werr := writeSortedRun(window, path, nil); werr != nil { + return werr + } + runs = append(runs, path) + clear(window) + windowBytes = 0 + return nil + } + for entry, ierr := range store.IterateRange(IndexCF, rocksdb.EncodeUint32(lastSealed+1), nil) { + if ierr != nil { + return nil, fmt.Errorf("events: freeze scan %s: %w", IndexCF, ierr) + } + if cerr := ctx.Err(); cerr != nil { + return nil, cerr + } + if len(entry.Key) != packedIndexKeyLen { + return nil, fmt.Errorf("events: freeze %s key length %d (want %d)", IndexCF, len(entry.Key), packedIndexKeyLen) + } + // Range-check the row's ledger against the chunk, mirroring the txhash + // arm: a stray row past the chunk bound would otherwise fold into the + // index silently. + if seq := binary.BigEndian.Uint32(entry.Key); seq > chunkID.LastLedger() { + return nil, fmt.Errorf("events: freeze %s row seq %d outside chunk %s", IndexCF, seq, chunkID) + } + // Blind at the run boundary through the SEAL's own fold — the + // premise of merging these tail runs in place beside the sealed ones + // (see the freeze doc). + added, derr := foldBlindedRow(window, entry.Value, secret) + if derr != nil { + return nil, fmt.Errorf("events: freeze ledger %d row: %w", binary.BigEndian.Uint32(entry.Key), derr) + } + windowBytes += added + if windowBytes >= freezeIndexWindowBytes { + if ferr := flush(); ferr != nil { + return nil, ferr + } + } + } + if err := flush(); err != nil { + return nil, err + } + return runs, nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze_test.go new file mode 100644 index 000000000..9420f0943 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze_test.go @@ -0,0 +1,457 @@ +package event + +import ( + "bytes" + "context" + "fmt" + "math/rand" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" +) + +// freezeLedger is one fixture ledger: its sequence and payloads (empty for +// an eventless ledger, which writes an offsets row but no index row). +type freezeLedger struct { + seq uint32 + payloads []Payload +} + +// freezeEventsPopulation builds n consecutive ledgers exercising every freeze +// shape: eventless ledgers, multi-event ledgers, terms shared across ledgers +// (so sealed runs and the tail hold postings for the SAME term — the union +// the merge must reassemble), fresh singleton terms, and one dense ledger +// that promotes the shared contract term into the overlay (dense postings +// also flow into rows/runs, so promotion must not change the frozen bytes). +func freezeEventsPopulation(first uint32, n int) []freezeLedger { + rng := rand.New(rand.NewSource(0xEE)) + shared := []string{"transfer", "mint", "burn", "swap", "approve"} + ledgers := make([]freezeLedger, n) + for i := range ledgers { + ledgers[i].seq = first + uint32(i) + if i%7 == 3 { + continue // eventless ledger + } + count := rng.Intn(5) + 2 + if i == 11 { + count = densePromoteWindowCount + 8 // dense-promotes the contract term + } + for e := range count { + sym := shared[rng.Intn(len(shared))] + if e%2 == 1 { + sym = fmt.Sprintf("solo-%d-%d", i, e) // singleton term + } + p, _ := makePayload(sym) + p.LedgerSequence = ledgers[i].seq + ledgers[i].payloads = append(ledgers[i].payloads, p) + } + } + return ledgers +} + +// lastEventLedger returns the sequence of the last event-BEARING ledger — +// the row that must sit past the sealed frontier for the population to leave +// an un-sealed tail. +func lastEventLedger(t *testing.T, ledgers []freezeLedger) uint32 { + t.Helper() + for i := len(ledgers) - 1; i >= 0; i-- { + if len(ledgers[i].payloads) > 0 { + return ledgers[i].seq + } + } + t.Fatal("population has no event-bearing ledger") + return 0 +} + +// walkArtifacts builds the three cold artifacts the way the walk path +// derives them from raw payloads — ColdWriter appends in event order, +// TermsForBytes → in-memory Bitmaps → WriteColdIndex (the retained oracle) — +// fully independent of the hot engine's rows, runs, and manifest. +func walkArtifacts(t *testing.T, chunkID chunk.ID, ledgers []freezeLedger) map[string][]byte { + t.Helper() + dir := t.TempDir() + w, err := NewColdWriter(chunkID, dir, ColdWriterOptions{}) + require.NoError(t, err) + offsets := NewLedgerOffsets(chunkID.FirstLedger()) + mirror := NewBitmaps() + var nextID uint32 + for _, l := range ledgers { + for i := range l.payloads { + keys, terr := TermsForBytes(l.payloads[i].ContractEventBytes) + require.NoError(t, terr) + for _, k := range keys { + mirror.AddTo(k, nextID) + } + require.NoError(t, w.Append(l.payloads[i])) + nextID++ + } + require.NoError(t, offsets.Append(l.seq, uint32(len(l.payloads)))) + } + require.NoError(t, w.Commit(offsets)) + require.NoError(t, WriteColdIndex(context.Background(), chunkID, mirror, dir, testIndexSecret)) + return readColdArtifacts(t, dir, chunkID) +} + +// readColdArtifacts loads the three artifacts' bytes, keyed by filename. +func readColdArtifacts(t *testing.T, dir string, chunkID chunk.ID) map[string][]byte { + t.Helper() + out := map[string][]byte{} + for _, name := range []string{EventsPackName(chunkID), IndexPackName(chunkID), IndexHashName(chunkID)} { + raw, err := os.ReadFile(filepath.Join(dir, name)) + require.NoError(t, err) + out[name] = raw + } + return out +} + +func requireSameArtifacts(t *testing.T, want, got map[string][]byte) { + t.Helper() + require.Len(t, got, len(want)) + for name, wb := range want { + require.True(t, bytes.Equal(wb, got[name]), "%s bytes diverge from the walk path", name) + } +} + +// openSealingStoreAt opens a chunk hot DB at dir with a shrunken seal window +// (and merge threshold) so tens-of-ledger populations cross the sealed +// frontier — the events twin of the txhash freeze tests' openPackedStoreAt. +func openSealingStoreAt( + t *testing.T, dir string, chunkID chunk.ID, sealEvery, maxRuns int, +) (*HotStore, *rocksdb.Store) { + t.Helper() + hot, raw := openHotStoreForTestAt(t, dir, chunkID) + hot.hotIdx.sealEvery = sealEvery + hot.hotIdx.maxRuns = maxRuns + return hot, raw +} + +// populateDeterministic ingests every fixture ledger, settling the background +// sealer after each one, so seal boundaries land identically on every test +// run. The seal cadence counts event-bearing ledgers (rows), not sequences. +func populateDeterministic(t *testing.T, s *HotStore, ledgers []freezeLedger) { + t.Helper() + for _, l := range ledgers { + require.NoError(t, ingestLedgerEvents(s, l.seq, l.payloads)) + require.NoError(t, s.hotIdx.reapSeal(true)) + } +} + +// freezeArtifacts runs the freeze against store into fresh scratch and +// bucket dirs and returns the artifact bytes. +func freezeArtifacts(t *testing.T, chunkID chunk.ID, store *rocksdb.Store) map[string][]byte { + t.Helper() + bucket := t.TempDir() + require.NoError(t, FreezeColdFromStore(context.Background(), chunkID, store, + filepath.Join(t.TempDir(), "scratch"), bucket, testIndexSecret, ColdWriterOptions{})) + return readColdArtifacts(t, bucket, chunkID) +} + +// TestFreezeColdFromStore_ByteIdenticalToWalk is the events half of the +// cross-path identity gate under the sorted-run tier: the freeze — +// manifest-listed sealed runs merged with tail-only runs windowed from the +// un-sealed IndexCF rows — must reproduce the walk path's payload-derived +// artifacts byte-for-byte, across eventless ledgers, dense-promoted terms, +// terms spanning seal windows, and singletons. +func TestFreezeColdFromStore_ByteIdenticalToWalk(t *testing.T) { + chunkID := chunk.ID(0) + ledgers := freezeEventsPopulation(chunkID.FirstLedger(), 48) + want := walkArtifacts(t, chunkID, ledgers) + + hot, store := openSealingStoreAt(t, t.TempDir(), chunkID, 8, 3) + populateDeterministic(t, hot, ledgers) + + // The merge must draw from BOTH tiers: sealed manifest runs (through a + // merge fold at maxRuns=3) AND a non-empty un-sealed tail. + names, lastSealed, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.Len(t, names, 2, "five 8-row seals at maxRuns=3 fold to one merged run plus the trailing seal") + require.Contains(t, names[0], "merge-", "the sealed tier must include a merge fold's output: %v", names) + require.Less(t, lastSealed, lastEventLedger(t, ledgers), "population must leave an un-sealed tail") + _, keys := makePayload("transfer") // keys[0] is the shared contract term + require.True(t, slices.ContainsFunc(keys, hot.hotIdx.overlay.Has), + "fixture must dense-promote a term — the gate is what pins that promoted postings "+ + "still flow into rows/runs (the freeze reads no overlay; see hotindex.go's tier map)") + + requireSameArtifacts(t, want, freezeArtifacts(t, chunkID, store)) +} + +// writePoisonRun writes a well-formed run whose single record is a marker +// term with an ID the fixture never committed — bait no correct freeze may +// swallow. Union semantics would hide a faithful duplicate of committed +// rows, so only poison makes "unlisted files are ignored" observable. +func writePoisonRun(t *testing.T, path string) { + t.Helper() + var marker TermKey + for i := range marker { + marker[i] = 0xEE + } + _, err := writeSortedRun(map[TermKey][]uint32{marker: {7}}, path, nil) + require.NoError(t, err) +} + +// TestFreezeColdFromStore_IdenticalAcrossCrashStates pins the +// freeze-at-crash-point gate: the same committed rows must freeze to +// identical bytes whatever durable seal state a crash left behind — +// mid-window (empty manifest, the whole chunk is tail), post-manifest (runs +// sealed and named, tail past the frontier), and post-seal-pre-manifest (a +// written-but-unnamed orphan run, which the freeze must ignore in favor of +// the CF tail that covers it). +func TestFreezeColdFromStore_IdenticalAcrossCrashStates(t *testing.T) { + chunkID := chunk.ID(0) + ledgers := freezeEventsPopulation(chunkID.FirstLedger(), 48) + want := walkArtifacts(t, chunkID, ledgers) + + // The reopen mirrors the production freeze source: a bare store handle, + // no facade, no warmup — the freeze needs neither. + reopenAndFreeze := func(dir string) map[string][]byte { + store := openRawHotChunkForTest(t, dir, chunkID) + got := freezeArtifacts(t, chunkID, store) + require.NoError(t, store.Close()) + return got + } + + // Mid-window: the seal window never fills — nothing durable but the + // rows, and the degenerate full-tail scan covers the whole chunk. + dirA := t.TempDir() + hotA, storeA := openSealingStoreAt(t, dirA, chunkID, 1<<20, 8) + populateDeterministic(t, hotA, ledgers) + names, _, err := rocksdbManifest{store: storeA}.GetRuns() + require.NoError(t, err) + require.Empty(t, names, "nothing may seal under a huge window") + hotA.Shutdown() + require.NoError(t, storeA.Close()) + requireSameArtifacts(t, want, reopenAndFreeze(dirA)) + + // Post-manifest: runs sealed, named, and frontier advanced. + dirB := t.TempDir() + hotB, storeB := openSealingStoreAt(t, dirB, chunkID, 8, 8) + populateDeterministic(t, hotB, ledgers) + hotB.Shutdown() + require.NoError(t, storeB.Close()) + requireSameArtifacts(t, want, reopenAndFreeze(dirB)) + + // Post-seal-pre-manifest: a crash after a run file's fsync but before + // the manifest Put leaves a well-formed ORPHAN run. Only manifest-named + // runs may feed the merge; the poison marker would otherwise surface as + // an extra index term and diverge every index byte. + dirC := t.TempDir() + hotC, storeC := openSealingStoreAt(t, dirC, chunkID, 8, 8) + populateDeterministic(t, hotC, ledgers) + writePoisonRun(t, filepath.Join(storeC.Path(), hotIndexRunDir, "seal-000099.run")) + hotC.Shutdown() + require.NoError(t, storeC.Close()) + requireSameArtifacts(t, want, reopenAndFreeze(dirC)) +} + +// TestFreezeColdFromStore_IgnoresUnreapedMerge pins the remaining crash +// state, post-merge-pre-reap: the sealer's goroutine sealed AND merged (both +// files durably on disk under their final names) but the writer never reaped +// the hand-back, so the manifest still lists the merge's inputs and the +// frontier still precedes the orphaned seal's rows. Close discards the +// result without publishing or unlinking; the freeze must trust the manifest +// — the listed inputs plus the CF tail that re-covers the orphaned seal — +// and ignore both unlisted files. +func TestFreezeColdFromStore_IgnoresUnreapedMerge(t *testing.T) { + chunkID := chunk.ID(0) + // 37 fixture ledgers hold exactly 32 event-bearing rows, so the LAST + // ledger's apply fills the fourth 8-row window: with maxRuns=3 its seal + // folds everything into a merge (seal-000003 + merge-000004 on disk). + ledgers := freezeEventsPopulation(chunkID.FirstLedger(), 37) + rows := 0 + for _, l := range ledgers { + if len(l.payloads) > 0 { + rows++ + } + } + require.Equal(t, 32, rows, "fixture must fill exactly four 8-row windows") + want := walkArtifacts(t, chunkID, ledgers) + + dir := t.TempDir() + hot, store := openSealingStoreAt(t, dir, chunkID, 8, 3) + populateDeterministic(t, hot, ledgers[:len(ledgers)-1]) + // The final ledger starts the seal+merge; no settle — Shutdown drains + // the completed job and discards it unpublished (the crash). + last := ledgers[len(ledgers)-1] + require.NoError(t, ingestLedgerEvents(hot, last.seq, last.payloads)) + hot.Shutdown() + + names, lastSealed, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.Len(t, names, 3, "manifest must still list the merge's inputs") + onDisk, err := os.ReadDir(filepath.Join(store.Path(), hotIndexRunDir)) + require.NoError(t, err) + require.Len(t, onDisk, 5, "3 listed inputs + orphaned seal + orphaned merge") + require.Less(t, lastSealed, last.seq, "frontier must precede the orphaned seal's rows") + require.NoError(t, store.Close()) + + reopened := openRawHotChunkForTest(t, dir, chunkID) + requireSameArtifacts(t, want, freezeArtifacts(t, chunkID, reopened)) + require.NoError(t, reopened.Close()) +} + +// TestFreezeColdFromStore_RejectsForeignSecret: the freeze may not build an +// index under a secret the chunk's sealed runs were not blinded with — they +// are merged in place, so a mismatch would fold two keyspaces into one index +// that no query could route. +func TestFreezeColdFromStore_RejectsForeignSecret(t *testing.T) { + chunkID := chunk.ID(0) + ledgers := freezeEventsPopulation(chunkID.FirstLedger(), 12) + hot, store := openSealingStoreAt(t, t.TempDir(), chunkID, 4, 8) + populateDeterministic(t, hot, ledgers) + + other := testIndexSecret + other[0] ^= 0xFF + err := FreezeColdFromStore(context.Background(), chunkID, store, + filepath.Join(t.TempDir(), "scratch"), t.TempDir(), other, ColdWriterOptions{}) + require.ErrorContains(t, err, "keyed under a different routing secret") + + // The DB's own secret freezes fine. + require.NoError(t, FreezeColdFromStore(context.Background(), chunkID, store, + filepath.Join(t.TempDir(), "scratch"), t.TempDir(), testIndexSecret, ColdWriterOptions{})) +} + +// TestFreezeColdFromStore_RejectsCorruptRunCRC: a manifest-listed run whose +// payload CRC does not verify fails the freeze loudly (the merge drains +// every listed run to its trailer) — and identically on retry, since +// artifacts without the orchestrator's completion record are inert scratch. +func TestFreezeColdFromStore_RejectsCorruptRunCRC(t *testing.T) { + chunkID := chunk.ID(0) + dir := t.TempDir() + ledgers := freezeEventsPopulation(chunkID.FirstLedger(), 48) + hot, store := openSealingStoreAt(t, dir, chunkID, 8, 8) + populateDeterministic(t, hot, ledgers) + names, _, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names) + runPath := filepath.Join(store.Path(), hotIndexRunDir, names[0]) + hot.Shutdown() + require.NoError(t, store.Close()) + + // Flip one bit of the stored CRC trailer: the payload stays valid, so + // the failure is attributable to the CRC verification alone. + raw, err := os.ReadFile(runPath) + require.NoError(t, err) + raw[len(raw)-1] ^= 0x01 + require.NoError(t, os.WriteFile(runPath, raw, 0o644)) + + reopened := openRawHotChunkForTest(t, dir, chunkID) + err = FreezeColdFromStore(context.Background(), chunkID, reopened, + filepath.Join(t.TempDir(), "scratch"), t.TempDir(), testIndexSecret, ColdWriterOptions{}) + require.ErrorIs(t, err, runspill.ErrCorruptRun) + require.ErrorContains(t, err, "crc mismatch") +} + +// TestStaleRunFormatTripwire is the events arm's version tripwire. The +// blind-at-seal flip changed what a sealed run's records are KEYED by +// (raw term → BlindKey(secret, term)) without changing a single byte of the +// container, so no structural check can tell a pre-flip run from this one: +// the CRC verifies, the fences route, the merge would happily fold its +// raw-keyed postings in under a blinded tail and produce an index whose +// queries miss ~the whole chunk. The magic bump (EVR2→EVR3) IS the tripwire, +// and it has to fire on BOTH doors — the read-write open that would otherwise +// stamp a secret onto the stale DB and launder it, and the freeze, which +// production reaches through a read-ONLY open that never warms at all. +func TestStaleRunFormatTripwire(t *testing.T) { + chunkID := chunk.ID(0) + dir := t.TempDir() + ledgers := freezeEventsPopulation(chunkID.FirstLedger(), 48) + hot, store := openSealingStoreAt(t, dir, chunkID, 8, 8) + populateDeterministic(t, hot, ledgers) + names, _, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names, "fixture must leave a manifest-listed run to stale out") + runPath := filepath.Join(store.Path(), hotIndexRunDir, names[0]) + hot.Shutdown() + require.NoError(t, store.Close()) + + // Rewrite ONLY the magic: every other byte stays a valid run, so the + // rejection is attributable to the version gate alone. + raw, err := os.ReadFile(runPath) + require.NoError(t, err) + copy(raw[:4], "EVR2") + require.NoError(t, os.WriteFile(runPath, raw, 0o644)) + + // Door 1: the read-write open (warmup reopens every manifest-listed run). + _, _, err = tryOpenHotStoreForTest(t, dir, chunkID) + require.ErrorIs(t, err, runspill.ErrCorruptRun) + require.ErrorContains(t, err, "stale pre-release run format") + require.ErrorContains(t, err, "re-ingest the chunk") + + // Door 2: the freeze, which never opens read-write and so never sees a + // warmup — this is the door the bench's --reuse-hot path walks through. + reopened := openRawHotChunkForTest(t, dir, chunkID) + err = FreezeColdFromStore(context.Background(), chunkID, reopened, + filepath.Join(t.TempDir(), "scratch"), t.TempDir(), testIndexSecret, ColdWriterOptions{}) + require.ErrorIs(t, err, runspill.ErrCorruptRun) + require.ErrorContains(t, err, "stale pre-release run format") + require.ErrorContains(t, err, "re-ingest the chunk") + require.NoError(t, reopened.Close()) +} + +// TestFreezeColdFromStore_EmptyChunk: a zero-row chunk (no rows, no +// manifest) freezes to the same three artifacts the walk path writes for an +// eventless chunk — a payload-less events.pack plus a real empty index pair. +func TestFreezeColdFromStore_EmptyChunk(t *testing.T) { + chunkID := chunk.ID(0) + want := walkArtifacts(t, chunkID, nil) + store := openRawHotChunkForTest(t, t.TempDir(), chunkID) + requireSameArtifacts(t, want, freezeArtifacts(t, chunkID, store)) +} + +// TestFreezeColdFromStore_IdempotentReadOnlyInputs pins the design +// constraint that live runs are strictly read-only freeze inputs: freezing +// the same store twice yields identical bytes, and no cleanup path — the +// scratch wipe on entry, terms.run removal, the scratch removal on success — +// ever reaches the manifest-listed files (hotindex-runs/ is untouched, name +// for name and byte for byte). +func TestFreezeColdFromStore_IdempotentReadOnlyInputs(t *testing.T) { + chunkID := chunk.ID(0) + dir := t.TempDir() + ledgers := freezeEventsPopulation(chunkID.FirstLedger(), 48) + hot, store := openSealingStoreAt(t, dir, chunkID, 8, 3) + populateDeterministic(t, hot, ledgers) + hot.Shutdown() + require.NoError(t, store.Close()) + + reopened := openRawHotChunkForTest(t, dir, chunkID) + runDir := filepath.Join(reopened.Path(), hotIndexRunDir) + before := snapshotDir(t, runDir) + require.NotEmpty(t, before) + + scratch := filepath.Join(t.TempDir(), "scratch") // shared: each entry wipes it + freezeOnce := func() map[string][]byte { + bucket := t.TempDir() + require.NoError(t, FreezeColdFromStore(context.Background(), chunkID, reopened, + scratch, bucket, testIndexSecret, ColdWriterOptions{})) + _, serr := os.Stat(scratch) + require.True(t, os.IsNotExist(serr), "scratch must be removed on success") + return readColdArtifacts(t, bucket, chunkID) + } + got1 := freezeOnce() + require.Equal(t, before, snapshotDir(t, runDir), "first freeze touched a live run") + got2 := freezeOnce() + require.Equal(t, before, snapshotDir(t, runDir), "second freeze touched a live run") + requireSameArtifacts(t, got1, got2) + require.NoError(t, reopened.Close()) +} + +// snapshotDir maps a directory's file names to their full contents. +func snapshotDir(t *testing.T, dir string) map[string][]byte { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + out := make(map[string][]byte, len(entries)) + for _, e := range entries { + raw, rerr := os.ReadFile(filepath.Join(dir, e.Name())) + require.NoError(t, rerr) + out[e.Name()] = raw + } + return out +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go index c3a38b5cb..4e7d30c95 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -4,12 +4,11 @@ package event // produces index.pack (per-slot bitmap records) + index.hash (the // serialized MPHF) inside a Chunk's cold directory. // -// The events.pack writer half lives in cold_writer.go. Shared format +// The pack writer half lives in cold_writer.go. Shared format // constants, the LedgerOffsets app-data wire format, and the // MPHF wrapper live in cold_format.go. import ( - "bytes" "context" "errors" "fmt" @@ -36,6 +35,17 @@ func ColdIndexSecret(catalogSecret []byte, chunkID chunk.ID) [stores.SecretLen]b return stores.DeriveIndexSecret(catalogSecret, coldRoutingDomain, uint32(chunkID)) } +// blindTerm is THE events routing key: the blinded form of a raw term key. +// Every keyed surface derives it through here — the hot tier's sparse probe, +// the seal's fold, the freeze tail's fold, the MPHF build and its query — so +// "one blinded identity" is a property of one function instead of an +// agreement between five call sites. Terms are already fixed-width +// (TermKey is 16 bytes), so unlike txhash's RoutingKey there is nothing to +// truncate: this is purely the naming of the identity. +func blindTerm(secret [stores.SecretLen]byte, term TermKey) TermKey { + return TermKey(stores.BlindKey(secret, term[:])) +} + // WriteColdIndex produces index.pack + index.hash for chunkID inside // bucketDir. Both files are fsync'd before the function returns. // bucketDir is the chunk's bucket directory; filenames are composed @@ -45,25 +55,19 @@ func ColdIndexSecret(catalogSecret []byte, chunkID chunk.ID) [stores.SecretLen]b // A zero-term bitmaps (an eventless chunk, e.g. a pre-Soroban // backfill range) produces a real (empty) index.hash over zero terms // plus a zero-record index.pack. The cold reader resolves every -// LookupKeys entry against it to a nil-bitmap miss through the -// ordinary path, so neither readers nor orchestrators need a +// LookupKeys entry against it to a clean miss through the ordinary +// path, so neither readers nor orchestrators need a // pack-without-index special case. // -// bitmaps is the complete term index for the Chunk, uniquely owned by -// the caller (no concurrent reader holds a pointer to any of its -// bitmaps). WriteColdIndex mutates each bitmap in place via -// RunOptimize before MarshalBinary — RunOptimize re-encodes long runs -// of set bits as RUN containers, which MarshalBinary then serializes -// more compactly. For chunk 5999 the on-disk shrink is ~14% (108MB → -// 93MB), concentrated in dense, clustered terms (popular contracts, -// common topic[0] verbs). This pairs with the fastaggregation.go fix -// in RoaringBitmap/roaring#81 — without that fix, the RUN containers -// hit a slow (*Bitmap).lazyOR path at query time and K≥12 regresses -// catastrophically. +// bitmaps is the complete term index for the Chunk. It is read, not +// modified: encodeIndexBody serializes through a bitmap of its own. // -// Both cold backfill and the live-chunk freeze build a Bitmaps single-threaded by -// re-deriving terms from raw LCMs (per-event TermsForBytes + Bitmaps.AddTo) and -// hand it directly here. +// PRODUCTION-DEAD, KEPT AS THE ORACLE: no shipping path calls this anymore — +// both cold backfill and the live-chunk freeze stream from spill runs via +// WriteColdIndexFromRuns. This in-memory builder is retained because the +// byte-identity gate (cold_index_stream_test.go) pins the streaming build's +// output against it; a format change must be mirrored here or the gate stops +// meaning anything. // // index.hash is the MPHF serialized via buildMPHF. // @@ -71,12 +75,12 @@ func ColdIndexSecret(catalogSecret []byte, chunkID chunk.ID) [stores.SecretLen]b // order. Each record is: // // offset size field -// 0 4 fingerprint (first 4 bytes of the TermKey hash) -// 4 N serialized roaring bitmap (Bitmap.MarshalBinary) +// 0 4 fingerprint (first 4 bytes of the ROUTED/blinded key) +// 4 N RunOptimize'd roaring bitmap (Bitmap.WriteTo bytes) // // The cold reader uses mphf.Lookup(term) → slot to find the record // position, packfile.Reader.ReadItem(slot, ...) to read the bytes, -// verifies the 4-byte fingerprint against term[:4], and then +// verifies the 4-byte fingerprint against the routed query key's prefix, and then // deserializes the bitmap on match. Unseen terms still produce a // slot (vanilla MPHF semantics) but their fingerprint mismatches — // the cold reader rejects them at that point. @@ -124,18 +128,21 @@ func WriteColdIndex( entries := make([]indexEntry, 0, len(bitmaps)) for term, bitmap := range bitmaps { - slot, lerr := m.Lookup(term) + slot, rk, lerr := m.Lookup(term) if lerr != nil { return fmt.Errorf("events: MPHF lookup during index.pack build: %w", lerr) } - // App fingerprint stays on the ORIGINAL term key, not the routed key. + // App fingerprint comes from the ROUTED (blinded) key: the streaming + // twin's pass B only ever holds run keys, which are blinded at ingest, + // so the blinded bytes are the one identity both builders share (and + // the reader compares against the routed query key). var fp [IndexRecordFingerprintLen]byte - copy(fp[:], term[:IndexRecordFingerprintLen]) - // Mutate in place — bitmaps is uniquely owned by the caller, built - // single-threaded either way: cold backfill from the .pack, or the freeze - // from the read-only hot DB. - bitmap.RunOptimize() - entries = append(entries, indexEntry{slot: slot, fp: fp, bitmap: bitmap}) + copy(fp[:], rk[:IndexRecordFingerprintLen]) + body, berr := encodeIndexBody(nil, bitmap.ToArray(), nil) + if berr != nil { + return berr + } + entries = append(entries, indexEntry{slot: slot, fp: fp, body: body}) } sort.Slice(entries, func(i, j int) bool { return entries[i].slot < entries[j].slot }) @@ -157,7 +164,7 @@ func WriteColdIndex( // container-encodes (array / bitmap / RLE) the underlying data, // and a second compression pass slows the query hot path // measurably (~3.6× lookup latency in measurement) for marginal - // byte savings. Contrast events.pack, where XDR payloads grouped + // byte savings. Contrast pack, where XDR payloads grouped // at 128/record offer plenty of compression headroom. // Skipping compression is also why indexPackChecksum exists. pw, err := packfile.Create(indexPackPath, packfile.WriterOptions{ @@ -166,12 +173,24 @@ func WriteColdIndex( ContentHash: true, Overwrite: true, RecordChecksum: indexPackChecksum, + // Smooth the multi-GB build's dirty pages into background writeback + // instead of one Finish-time flush burst (the burst stalls a + // co-located hot tier's WAL fdatasync — 2026-07-24 freeze traces). + BytesPerSync: indexPackBytesPerSync, }) if err != nil { return fmt.Errorf("events: create index.pack at %s: %w", indexPackPath, err) } writerErr := writeIndexPackEntries(pw, entries) + if writerErr == nil { + // Finish lives beside Create: writeIndexPackEntries only emits + // records, so no early return added to it can ship an unfinalized + // index.pack. The build stamp is the app-data both builders write. + if finishErr := pw.Finish(encodeIndexBuildStamp()); finishErr != nil { + writerErr = fmt.Errorf("events: finish index.pack: %w", finishErr) + } + } if writerErr != nil { // pw.Close removes the partial index.pack. Join its error so a // cleanup failure surfaces alongside the original write error, @@ -185,29 +204,63 @@ func WriteColdIndex( } // indexEntry is one assembled index.pack record: the slot it lands at, -// the 4-byte fingerprint, and the bitmap to serialize. +// the 4-byte fingerprint, and the encoded item body (the serialized +// bitmap). type indexEntry struct { - slot uint32 - fp [IndexRecordFingerprintLen]byte - bitmap *roaring.Bitmap + slot uint32 + fp [IndexRecordFingerprintLen]byte + body []byte +} + +// encodeIndexBody appends the index.pack item body for ids — the +// RunOptimize'd roaring serialization — to dst. ids must be ascending and +// deduped. +// +// Both builders call this, which is what keeps the byte-identity gate +// meaningful: they cannot disagree about a term's encoding because there is +// only one. It takes ids rather than a bitmap because the streaming builder +// has a sorted slice and never materializes one, while a bitmap is cheap to +// build from ids. +// +// scratch, when non-nil, is the bitmap the ids are loaded into: the streaming +// builder hands the same one back for every term so a multi-million-term +// build does not allocate a bitmap per term. It is cleared first, so the +// caller need not, and its contents are meaningless afterwards. nil allocates +// one per call — for tests and the in-memory oracle, where per-term +// allocation does not matter. +func encodeIndexBody(dst []byte, ids []uint32, scratch *roaring.Bitmap) ([]byte, error) { + if len(ids) == 0 { + // A zero-posting term has no reason to exist: every term reaches the + // index because some event carried it. + return nil, errors.New("events: index body for a term with no postings") + } + if scratch == nil { + scratch = roaring.New() + } else { + scratch.Clear() + } + scratch.AddMany(ids) + // RunOptimize re-encodes long runs of set bits as RUN containers, which + // the serialization then stores more compactly: ~14% on chunk 5999, + // concentrated in dense clustered terms. It is also what makes the + // serialization canonical. + scratch.RunOptimize() + b, err := scratch.ToBytes() + if err != nil { + return nil, fmt.Errorf("events: serialize bitmap: %w", err) + } + return append(dst, b...), nil } // writeIndexPackEntries appends every assembled record to the index.pack -// writer in slot order and finishes the pack. +// writer in slot order. The caller owns the writer's lifecycle (Finish +// beside Create) — the same convention as the streaming twin's +// writeSlotOrdered, so neither emitter can ship an unfinalized pack. func writeIndexPackEntries(pw *packfile.Writer, entries []indexEntry) error { - // Serialize each bitmap into one reused buffer rather than a fresh - // MarshalBinary slice per record. AppendItem copies its input, so the - // buffer is safe to reuse across iterations; roaring's WriteTo emits - // the same bytes MarshalBinary would, so the pack is byte-identical. - var buf bytes.Buffer for _, e := range entries { - buf.Reset() - if _, werr := e.bitmap.WriteTo(&buf); werr != nil { - return fmt.Errorf("events: serialize bitmap at slot %d: %w", e.slot, werr) - } - if err := pw.AppendItem(e.fp[:], buf.Bytes()); err != nil { + if err := pw.AppendItem(e.fp[:], e.body); err != nil { return fmt.Errorf("events: write slot %d to index.pack: %w", e.slot, err) } } - return pw.Finish(encodeIndexBuildStamp()) + return nil } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go new file mode 100644 index 000000000..77b84afc6 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -0,0 +1,545 @@ +package event + +// cold_index_stream.go is the external-memory twin of cold_index.go's +// WriteColdIndex: it produces the SAME two artifacts (index.pack + +// index.hash, byte-identical) from spill runs instead of an in-memory +// Bitmaps — the piece that removes the cold build's O(unique terms) +// RAM. Shape (design doc: ~/bench-artifacts/cold-ingest-design.md): +// +// 1. MergeRuns → one scratch terms.run: per unique term, the final +// index.pack item body under a 16B term and a uvarint length, CRC-framed, +// so pass B copies the body through untouched. +// Unique-term count N falls out — streamhash needs it exactly, up front. +// 2. Pass A: stream terms.run keys → streamhash.NewSortedBuilder(N) → +// index.hash. Memcmp-sorted 16-byte keys are valid block-sorted input, +// and with default options the result is byte-identical to the unsorted +// builder's (pinned by streamhash's own lifecycle tests). +// 3. Pass B: re-stream terms.run → mphf.Lookup per term → index.pack in +// dense slot order via a bounded reorder heap: bodies at or under +// inlineBodyMax ride the heap inline, larger ones are buffered as +// references and re-read from terms.run at emit. Slots deviate from +// key order only within one MPHF block; an entry-count backstop bounds +// the heap's RAM when slot assignment is corrupt, and a +// stranded-records check catches non-dense output. + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/RoaringBitmap/roaring/v2" + + "github.com/stellar/streamhash" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/packfile" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" +) + +// inlineBodyMax is the copy/reference boundary for buffered records: bodies +// at or under it are copied into the heap entry at push time, larger ones +// are re-read from terms.run at emit. 10 KiB approximates roaring's worst +// case for a ~1,024-posting term spread across maximal containers — one +// posting per container: ~16B of header, ~8B of bookkeeping per container, +// and 2B per posting ≈ 10 KiB — so the small-term population that always +// inlined under the old cardinality-derived bound still inlines; only fat +// terms — multi-MiB at the extreme, and unbounded in aggregate per block — +// take the reference path. Straying into the heap is the norm, not the +// exception (~99.7% of records: within-block slot order is effectively +// random), so without the inline copy every record pays an emit-time pread +// — measured on sac-6000 (c6id.8xlarge NVMe, page-cache-hot) at ~1.4µs +// each, the all-reference extreme costing +29% on the freeze's events arm. +// That ±% was measured against the previous, smaller body sizes, so +// re-measure before retuning. +// RAM: the heap provably drains at every block boundary, so occupancy is +// one MPHF block (≲3.5K entries ≈ 35MB with every body at the ceiling); +// the enforced ceiling — the derived backstop × inlineBodyMax ≈ 640MiB — +// is reachable only with a corrupt index.hash, which the backstop then +// turns into a loud abort. +const inlineBodyMax = 10 * 1024 + +var errCorruptTermsRun = errors.New("events: corrupt terms.run scratch") + +// WriteColdIndexFromRuns builds index.pack + index.hash for chunkID in +// bucketDir from term-sorted spill runs (runspill format). scratchDir hosts +// the intermediate terms.run; the caller owns scratch lifecycle (wipe on +// retry). Artifacts are byte-identical to WriteColdIndex fed the equivalent +// Bitmaps — pinned by TestWriteColdIndexFromRuns_ByteIdentical. +// secret is the chunk's routing secret: the run KEYS are already blinded +// with it (ingest blinds before the spill), so it is not applied to any key +// here — it is recorded in index.hash's user metadata so readers can route +// queries identically. +func WriteColdIndexFromRuns( + ctx context.Context, chunkID chunk.ID, runPaths []string, scratchDir, bucketDir string, + secret [stores.SecretLen]byte, +) (err error) { + indexPackPath := filepath.Join(bucketDir, IndexPackName(chunkID)) + indexHashPath := filepath.Join(bucketDir, IndexHashName(chunkID)) + termsRunPath := filepath.Join(scratchDir, "terms.run") + + // Match WriteColdIndex's failure contract: no orphan index.hash on error. + defer func() { + if err == nil { + return + } + if rmErr := os.Remove(indexHashPath); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) { + err = errors.Join(err, fmt.Errorf("events: remove orphan %s: %w", indexHashPath, rmErr)) + } + }() + + total, err := writeTermsRun(termsRunPath, runPaths) + if err != nil { + return err + } + // Scratch removal is best-effort and MUST NOT write to err: this defer + // runs before the orphan-index.hash guard above (LIFO), so a scratch + // unlink failure surfacing as err would delete the finalized index.hash + // while index.pack survives — a torn artifact pair on an otherwise + // successful build. The caller owns scratchDir and wipes it wholesale. + defer func() { _ = os.Remove(termsRunPath) }() + + // Pass A: keys → SortedBuilder → index.hash. The run keys are already + // blinded (see the function doc); the secret rides only in the metadata. + if err = buildSortedHash(ctx, termsRunPath, indexHashPath, total, secret); err != nil { + return err + } + m, err := openMPHF(indexHashPath) + if err != nil { + return err + } + defer m.Close() + + // Pass B: records → slot order → index.pack. + pw, err := packfile.Create(indexPackPath, packfile.WriterOptions{ + Format: indexPackFormat, + ItemsPerRecord: indexPackItemsPerRecord, + ContentHash: true, + Overwrite: true, + // index.pack's on-disk identity, shared with the in-memory builder: + // the two emitters write the same artifact, so every option that + // reaches the bytes has to match or the byte-identity gate fails — + // and an unchecked index.pack is refused at open. + RecordChecksum: indexPackChecksum, + // Same smoothing rationale as the non-streaming builder: never let + // the index.pack accumulate as one Finish-time flush burst. + BytesPerSync: indexPackBytesPerSync, + }) + if err != nil { + return fmt.Errorf("events: create index.pack at %s: %w", indexPackPath, err) + } + // ONE cleanup for every post-Create failure (Close after a successful + // Finish is a no-op, so success passes through untouched); pw.Close + // removes the partial index.pack. + defer func() { + if err == nil { + return + } + if closeErr := pw.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("events: close partial index.pack: %w", closeErr)) + } + }() + if err = writeSlotOrdered(pw, termsRunPath, m); err != nil { + return err + } + // Finish lives beside Create: writeSlotOrdered only emits records, so + // no early return added to it can ever ship an unfinalized index.pack + // that reports success. The build stamp is byte-identical to the + // in-memory builder's — same compiled constants — so the two emitters' + // packs stay comparable byte for byte. + if err = pw.Finish(encodeIndexBuildStamp()); err != nil { + return fmt.Errorf("events: finish index.pack: %w", err) + } + return nil +} + +// buildSortedHash is pass A: every terms.run key feeds a SortedBuilder and +// index.hash is finalized. Default options only — that is what pins +// byte-identity with buildMPHF's unsorted output. +func buildSortedHash( + ctx context.Context, termsRunPath, indexHashPath string, total uint64, + secret [stores.SecretLen]byte, +) error { + // Options must mirror buildMPHF's exactly (metadata included) — that is + // what pins byte-identity with the unsorted oracle's output. + builder, err := streamhash.NewSortedBuilder(ctx, indexHashPath, total, + streamhash.WithMetadata(encodeEventsMeta(secret))) + if err != nil { + return fmt.Errorf("events: create sorted streamhash builder: %w", err) + } + finished := false + defer func() { + if !finished { + _ = builder.Close() + } + }() + f, err := os.Open(termsRunPath) + if err != nil { + return fmt.Errorf("events: open %s: %w", termsRunPath, err) + } + defer f.Close() + var fed int + if err := streamTermsRun(f, func(term TermKey, _ []byte, _ int64) error { + // ctx.Err takes a mutex; per-term polling costs tens of ms over + // millions of terms. Poll on the freeze scans' shared cadence. + if fed%256 == 0 { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + } + fed++ + return builder.AddKey(term[:], 0) + }); err != nil { + return fmt.Errorf("events: feed sorted builder: %w", err) + } + if err := builder.Finish(); err != nil { + return fmt.Errorf("events: finalize index.hash at %s: %w", indexHashPath, err) + } + finished = true + return nil +} + +// writeTermsRun merges the spill runs into one terms.run scratch file and +// returns the unique-term count. Record: 16B term ‖ uvarint len ‖ the +// index.pack item body. File framing: magic ‖ u64 record count ‖ records ‖ +// CRC-32C(records) — the count doubles as streamhash's totalKeys. +func writeTermsRun(path string, runPaths []string) (uint64, error) { + f, err := os.Create(path) + if err != nil { + return 0, fmt.Errorf("events: create %s: %w", path, err) + } + w := bufio.NewWriterSize(f, 1<<20) + var hdr [12]byte + copy(hdr[:4], termsRunMagic[:]) + if _, err := w.Write(hdr[:]); err != nil { // count patched below + _ = f.Close() + return 0, fmt.Errorf("events: write %s header: %w", path, err) + } + + var ( + count uint64 + crc uint32 + bodyBuf []byte + recBuf []byte + // One bitmap for the whole merge: encodeIndexBody clears and refills + // it per term, so a multi-million-term chunk allocates none. + scratch = roaring.New() + mergeED = func(rawTerm [16]byte, ids []uint32) error { + term := TermKey(rawTerm) + var berr error + if bodyBuf, berr = encodeIndexBody(bodyBuf[:0], ids, scratch); berr != nil { + return berr + } + recBuf = recBuf[:0] + recBuf = append(recBuf, term[:]...) + recBuf = binary.AppendUvarint(recBuf, uint64(len(bodyBuf))) + recBuf = append(recBuf, bodyBuf...) + crc = crc32.Update(crc, termsRunCRC, recBuf) + count++ + _, werr := w.Write(recBuf) + return werr + } + ) + if err := runspill.MergeRuns(runPaths, mergeED); err != nil { + _ = f.Close() + return 0, err + } + var tr [4]byte + binary.BigEndian.PutUint32(tr[:], crc) + _, err = w.Write(tr[:]) + if err == nil { + err = w.Flush() + } + if err == nil { + binary.BigEndian.PutUint64(hdr[4:], count) + _, err = f.WriteAt(hdr[4:12], 4) // patch the record count + } + if err == nil { + err = f.Sync() + } + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + return 0, fmt.Errorf("events: write %s: %w", path, err) + } + return count, nil +} + +var ( + termsRunMagic = [4]byte{'E', 'T', 'R', '1'} //nolint:gochecknoglobals // fixed format tag + termsRunCRC = crc32.MakeTable(crc32.Castagnoli) //nolint:gochecknoglobals // fixed table +) + +// crcFoldReader is an io.ByteReader that folds every byte it yields into the +// running CRC — binary.ReadUvarint's adapter for CRC-framed streams. +type crcFoldReader struct { + br *bufio.Reader + crc uint32 + n int64 + scratch [1]byte +} + +func (c *crcFoldReader) ReadByte() (byte, error) { + b, err := c.br.ReadByte() + if err != nil { + return 0, err + } + c.scratch[0] = b + c.crc = crc32.Update(c.crc, termsRunCRC, c.scratch[:]) + c.n++ + return b, nil +} + +// streamTermsRun replays terms.run from f (opened and owned by the caller, +// which lets pass B share ONE fd between this sequential scan and its +// positional emit re-reads — same inode by construction), calling emit per +// record with the term, item body (reused buffer), and absolute file offset +// of the body. Integrity (CRC over all records) is verified before +// returning nil — both passes fully drain, so a CRC-detectable corrupt +// scratch can never yield a completed build. Bytes re-read positionally at +// emit are covered by this scan's verification of the same fd, not +// re-hashed at emit. +func streamTermsRun(f *os.File, emit func(term TermKey, body []byte, bodyOff int64) error) error { + if _, err := f.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("events: seek %s: %w", f.Name(), err) + } + br := bufio.NewReaderSize(f, 1<<20) + var hdr [12]byte + if _, err := io.ReadFull(br, hdr[:]); err != nil { + return fmt.Errorf("%w: short header", errCorruptTermsRun) + } + if !bytes.Equal(hdr[:4], termsRunMagic[:]) { + return fmt.Errorf("%w: bad magic", errCorruptTermsRun) + } + count := binary.BigEndian.Uint64(hdr[4:]) + + var ( + crc uint32 + buf []byte + fileOff int64 = 12 + // One CRC-folding varint reader for the whole replay — per-record + // state is reset in place, so the loop allocates nothing for it. + cbr = &crcFoldReader{br: br} + ) + for i := range count { + var term TermKey + if _, err := io.ReadFull(br, term[:]); err != nil { + return fmt.Errorf("%w: record %d term: %w", errCorruptTermsRun, i, err) + } + crc = crc32.Update(crc, termsRunCRC, term[:]) + fileOff += int64(len(term)) + // stdlib varint over the CRC-folding ByteReader. + cbr.crc, cbr.n = crc, 0 + length, rerr := binary.ReadUvarint(cbr) + if rerr != nil { + return fmt.Errorf("%w: record %d length: %w", errCorruptTermsRun, i, rerr) + } + crc = cbr.crc + fileOff += cbr.n + if length > 1<<31 { + return fmt.Errorf("%w: record %d body length %d", errCorruptTermsRun, i, length) + } + if uint64(cap(buf)) < length { + buf = make([]byte, length) + } + buf = buf[:length] + if _, err := io.ReadFull(br, buf); err != nil { + return fmt.Errorf("%w: record %d body: %w", errCorruptTermsRun, i, err) + } + crc = crc32.Update(crc, termsRunCRC, buf) + if err := emit(term, buf, fileOff); err != nil { + return err + } + fileOff += int64(length) + } + var tr [4]byte + if _, err := io.ReadFull(br, tr[:]); err != nil { + return fmt.Errorf("%w: short trailer", errCorruptTermsRun) + } + if got := binary.BigEndian.Uint32(tr[:]); got != crc { + return fmt.Errorf("%w: crc mismatch (file %08x computed %08x)", errCorruptTermsRun, got, crc) + } + return nil +} + +// materializeBody returns rec's index.pack item body: the inline copy when +// the record carries one, otherwise a positional re-read from terms.run +// into readBuf — grown as needed and returned for the caller to reuse. +func materializeBody(termsRun *os.File, rec slotRecord, readBuf []byte) ([]byte, []byte, error) { + if rec.inline != nil { + return rec.inline, readBuf, nil + } + if cap(readBuf) < int(rec.n) { + readBuf = make([]byte, rec.n) + } + body := readBuf[:rec.n] + if _, err := termsRun.ReadAt(body, rec.off); err != nil { + return nil, readBuf, fmt.Errorf("events: read slot %d body at offset %d: %w", rec.slot, rec.off, err) + } + return body, readBuf, nil +} + +// slotRecord is one reorder-heap element waiting for its slot's turn: a +// small body rides inline (copied at push), a large one is referenced and +// re-read from terms.run at (off, n) on emit. +type slotRecord struct { + slot uint32 + fp [IndexRecordFingerprintLen]byte + off int64 + n uint32 + // inline holds the body copied at buffering time when it fits + // inlineBodyMax; nil means emit re-reads terms.run at (off, n). + inline []byte +} + +// slotHeap is a typed slice-backed binary min-heap of buffered records keyed +// by MPHF slot — a value heap, so a push neither boxes the record into an +// `any` nor routes its compare through an interface. Dense MPHF slots are +// unique, so the key needs no tie-break and the pop order is the slot order. +type slotHeap []slotRecord + +func (h *slotHeap) peek() slotRecord { return (*h)[0] } + +func (h *slotHeap) push(rec slotRecord) { + *h = append(*h, rec) + h.siftUp(len(*h) - 1) +} + +// popMin removes and returns the lowest-slot record. +func (h *slotHeap) popMin() slotRecord { + old := *h + minRec := old[0] + last := len(old) - 1 + old[0] = old[last] + // Zero the vacated slot: stale headers lingering in the capacity tail + // would pin already-emitted inline bodies (and any heap-backed field + // slotRecord grows later) until the heap regrows over them. + old[last] = slotRecord{} + *h = old[:last] + h.siftDown(0) + return minRec +} + +func (h *slotHeap) siftUp(i int) { + h2 := *h + for i > 0 { + parent := (i - 1) / 2 + if h2[parent].slot <= h2[i].slot { + break + } + h2[i], h2[parent] = h2[parent], h2[i] + i = parent + } +} + +func (h *slotHeap) siftDown(i int) { + h2 := *h + n := len(h2) + for { + left := 2*i + 1 + if left >= n { + break + } + j := left + if right := left + 1; right < n && h2[right].slot < h2[j].slot { + j = right + } + if h2[i].slot <= h2[j].slot { // heap property already holds + break + } + h2[i], h2[j] = h2[j], h2[i] + i = j + } +} + +// writeSlotOrdered replays terms.run, looks up each term's dense slot, and +// appends records to pw in exact slot order via the bounded reorder heap. +func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { + // ONE fd serves both the sequential scan (streamTermsRun) and the + // emit-time positional re-reads: pread leaves the file offset alone, + // and sharing the handle pins scan and re-read to the same inode. + termsRun, err := os.Open(termsRunPath) + if err != nil { + return fmt.Errorf("events: open %s for slot reorder: %w", termsRunPath, err) + } + defer termsRun.Close() + + // The heap's backstop, derived from the artifact itself: sorted keys + // route to non-decreasing blocks and a block's minimum-slot key always + // takes the fast path, so a legal heap never holds more than + // MaxBlockKeys−1 records. One more push is provably impossible — + // corrupt slot assignment — and aborts loudly (the chunk retry + // contract rebuilds). Deriving the bound from the opened index keeps + // it correct across format revisions with no transcribed constant. + maxBuffered := int(m.MaxBlockKeys()) - 1 + + var ( + h slotHeap + next uint32 + readBuf []byte + ) + flush := func() error { + for len(h) > 0 && h.peek().slot == next { + rec := h.popMin() + body, buf, rerr := materializeBody(termsRun, rec, readBuf) + if rerr != nil { + return rerr + } + readBuf = buf + // The streaming pass folds these bytes into the CRC and fully drains + // before the build succeeds; positional reads need not fold them again. + if err := pw.AppendItem(rec.fp[:], body); err != nil { + return fmt.Errorf("events: write slot %d to index.pack: %w", rec.slot, err) + } + next++ + } + return nil + } + err = streamTermsRun(termsRun, func(term TermKey, body []byte, bodyOff int64) error { + slot, lerr := m.LookupRouted(term) + if lerr != nil { + return fmt.Errorf("events: MPHF lookup during index.pack build: %w", lerr) + } + var fp [IndexRecordFingerprintLen]byte + copy(fp[:], term[:IndexRecordFingerprintLen]) + if slot == next { + // Fast path: already in order — write through, then drain any + // buffered successors. + if err := pw.AppendItem(fp[:], body); err != nil { + return fmt.Errorf("events: write slot %d to index.pack: %w", slot, err) + } + next++ + return flush() + } + if len(h) >= maxBuffered { + return fmt.Errorf("events: slot reorder heap exceeded %d records at slot %d — "+ + "MPHF slots deviate beyond one block (non-dense or corrupt index.hash)", maxBuffered, slot) + } + n := uint32(len(body)) //nolint:gosec // streamTermsRun limits body lengths to 1 << 31. + rec := slotRecord{slot: slot, fp: fp, off: bodyOff, n: n} + if n <= inlineBodyMax { + rec.inline = append([]byte(nil), body...) + } + h.push(rec) + return nil + }) + if err != nil { + return err + } + if err := flush(); err != nil { + return err + } + if len(h) != 0 { + return fmt.Errorf("events: %d index.pack records stranded (non-dense MPHF slots?)", len(h)) + } + return nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream_test.go new file mode 100644 index 000000000..281e37bf2 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream_test.go @@ -0,0 +1,480 @@ +package event + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "fmt" + "hash/crc32" + "math/rand" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/packfile" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" +) + +// synthTerms builds a synthetic term→ids corpus: a firehose term holding +// every ID (run-container territory), several mid-frequency terms, and a +// long tail of singletons — the shape that exercises RunOptimize, multi-run +// unioning, and the reorder heap. +func synthTerms(n int, seed int64) map[TermKey][]uint32 { + rng := rand.New(rand.NewSource(seed)) + out := map[TermKey][]uint32{} + // Real TermKeys are xxh3 hashes — uniformly random bytes. Clustered + // synthetic keys (shared prefixes, zero bytes) overload streamhash's + // block routing and fail its seed search, so every key here is fully + // random; only the multiplicity structure is synthetic. + randKey := func() TermKey { + var k TermKey + rng.Read(k[:]) + return k + } + fire := randKey() + mids := make([]TermKey, 20) + for i := range mids { + mids[i] = randKey() + } + for id := range uint32(n) { + out[fire] = append(out[fire], id) + mid := mids[rng.Intn(len(mids))] + out[mid] = append(out[mid], id) + single := randKey() + out[single] = append(out[single], id) + } + return out +} + +// spillBlinded feeds (key, id) through the spiller under the ingest rule: +// the spilled key is the BLINDED routing identity. +func spillBlinded(t *testing.T, sp *runspill.Spiller, k TermKey, id uint32) { + t.Helper() + require.NoError(t, sp.Add(stores.BlindKey(testIndexSecret, k[:]), id)) +} + +func largeTermsCorpus(seed int64, count int) ([]TermKey, *rand.Rand) { + rng := rand.New(rand.NewSource(seed)) + keys := make([]TermKey, count) + for i := range keys { + _, _ = rng.Read(keys[i][:]) + } + slices.SortFunc(keys, func(a, b TermKey) int { + return bytes.Compare(a[:], b[:]) + }) + return keys, rng +} + +func writeLargeTermsRun(t *testing.T, path string, seed int64, count, bodySize int) []TermKey { + t.Helper() + keys, rng := largeTermsCorpus(seed, count) + f, err := os.Create(path) + require.NoError(t, err) + defer f.Close() + w := bufio.NewWriterSize(f, 1<<20) + + var hdr [12]byte + copy(hdr[:4], termsRunMagic[:]) + binary.BigEndian.PutUint64(hdr[4:], uint64(count)) + _, err = w.Write(hdr[:]) + require.NoError(t, err) + + body := make([]byte, bodySize) + length := binary.AppendUvarint(nil, uint64(bodySize)) + var crc uint32 + for _, key := range keys { + _, err = rng.Read(body) + require.NoError(t, err) + crc = crc32.Update(crc, termsRunCRC, key[:]) + crc = crc32.Update(crc, termsRunCRC, length) + crc = crc32.Update(crc, termsRunCRC, body) + _, err = w.Write(key[:]) + require.NoError(t, err) + _, err = w.Write(length) + require.NoError(t, err) + _, err = w.Write(body) + require.NoError(t, err) + } + var trailer [4]byte + binary.BigEndian.PutUint32(trailer[:], crc) + _, err = w.Write(trailer[:]) + require.NoError(t, err) + require.NoError(t, w.Flush()) + require.NoError(t, f.Close()) + return keys +} + +// simulatedCopyPeak computes the byte mass the reorder heap would hold if +// every buffered record carried its body inline — the corpus premise check: +// deep reordering must actually occur, not fast-path luck, for the +// reference path to be exercised at multi-MiB scale. +func simulatedCopyPeak(t *testing.T, keys []TermKey, bodySize int, m *mphf) int64 { + t.Helper() + waiting := make(map[uint32]int64, len(keys)) + var next uint32 + var buffered, peak int64 + for _, key := range keys { + // Synthetic run keys are the routed identities — see LookupRouted. + slot, err := m.LookupRouted(key) + require.NoError(t, err) + if slot != next { + waiting[slot] = int64(bodySize) + buffered += int64(bodySize) + } else { + next++ + for { + n, ok := waiting[next] + if !ok { + break + } + delete(waiting, next) + buffered -= n + next++ + } + } + peak = max(peak, buffered) + } + return peak +} + +func TestWriteSlotOrdered_MultiMiBReferenceBodies(t *testing.T) { + if testing.Short() { + t.Skip("writes about 200 MiB of temporary data") + } + const ( + seed = int64(29) + count = 20 + bodySize = 5 << 20 + ) + dir := t.TempDir() + termsRunPath := filepath.Join(dir, "terms.run") + hashPath := filepath.Join(dir, "index.hash") + packPath := filepath.Join(dir, "index.pack") + keys := writeLargeTermsRun(t, termsRunPath, seed, count, bodySize) + + require.NoError(t, buildSortedHash(context.Background(), termsRunPath, hashPath, count, testIndexSecret)) + m, err := openMPHF(hashPath) + require.NoError(t, err) + defer m.Close() + require.Greater(t, bodySize, inlineBodyMax, "bodies must ride the reference path") + peak := simulatedCopyPeak(t, keys, bodySize, m) + require.Greater(t, peak, int64(64<<20), "corpus must force deep reorder buffering, not fast-path luck") + + pw, err := packfile.Create(packPath, packfile.WriterOptions{ + Format: indexPackFormat, + ItemsPerRecord: indexPackItemsPerRecord, + Overwrite: true, + BytesPerSync: indexPackBytesPerSync, + }) + require.NoError(t, err) + defer pw.Close() + require.NoError(t, writeSlotOrdered(pw, termsRunPath, m)) + // writeSlotOrdered only emits records; the writer's lifecycle stays + // with whoever created it (WriteColdIndexFromRuns in production). + require.NoError(t, pw.Finish(nil)) + + records := loadIndexPack(t, packPath) + require.Len(t, records, count) + verifyKeys, rng := largeTermsCorpus(seed, count) + body := make([]byte, bodySize) + want := make([]byte, IndexRecordFingerprintLen+bodySize) + for _, key := range verifyKeys { + _, err = rng.Read(body) + require.NoError(t, err) + copy(want, key[:IndexRecordFingerprintLen]) + copy(want[IndexRecordFingerprintLen:], body) + // The synthetic run keys ARE the routed identities (this test drives + // pass B below the blinding boundary), so query the routed entry point. + slot, lookupErr := m.LookupRouted(key) + require.NoError(t, lookupErr) + require.Equal(t, want, records[int(slot)]) + } + t.Logf("simulated copy peak: %d bytes (seed %d)", peak, seed) +} + +func TestStreamTermsRun_Offsets(t *testing.T) { + corpus := synthTerms(100, 27) + // One body past 127 bytes so a length varint spans two bytes: the + // offset accounting (crcFoldReader.n) is otherwise exercised only for + // single-byte varints. Premise-checked below. + fat := ComputeTermKey([]byte("offsets-two-byte-varint"), FieldTopic1) + fatIDs := make([]uint32, 300) + for j := range fatIDs { + fatIDs[j] = uint32(j * 7) + } + corpus[fat] = fatIDs + fatBody, err := encodeIndexBody(nil, fatIDs, nil) + require.NoError(t, err) + require.GreaterOrEqual(t, len(fatBody), 128, "corpus must include a two-byte length varint") + + scratch := t.TempDir() + sp, err := runspill.NewSpiller(scratch, 1<<14) + require.NoError(t, err) + for term, ids := range corpus { + for _, id := range ids { + require.NoError(t, sp.Add(term, id)) + } + } + runs, err := sp.Finish() + require.NoError(t, err) + + termsRunPath := filepath.Join(scratch, "terms.run") + count, err := writeTermsRun(termsRunPath, runs) + require.NoError(t, err) + require.Equal(t, uint64(len(corpus)), count) + + termsRun, err := os.Open(termsRunPath) + require.NoError(t, err) + defer termsRun.Close() + + var streamed int + require.NoError(t, streamTermsRun(termsRun, func(_ TermKey, body []byte, bodyOff int64) error { + got := make([]byte, len(body)) + _, readErr := termsRun.ReadAt(got, bodyOff) + require.NoError(t, readErr) + require.Equal(t, body, got) + streamed++ + return nil + })) + require.Equal(t, len(corpus), streamed) +} + +// TestWriteColdIndexFromRuns_ByteIdentical is the cold design's gate: the +// streaming build's index.pack + index.hash must be bit-for-bit equal to +// WriteColdIndex fed the equivalent in-memory Bitmaps. +func TestWriteColdIndexFromRuns_ByteIdentical(t *testing.T) { + const chunkID = chunk.ID(3) + corpus := synthTerms(2000, 42) + + // Add terms sitting either side of inlineBodyMax, so this one build + // drains a heap holding BOTH inline and reference records — pinning the + // discriminator and readBuf reuse across mixed pops. Body size follows + // roaring's container layout rather than cardinality alone (a term whose + // ids fit one container caps out at a bitmap container's 8 KiB however + // many it holds), so the wide-stride terms below spread across several + // containers to clear the boundary. The straddle is premise-checked + // afterwards, not assumed. + minBody, maxBody := int(^uint(0)>>1), 0 + for i, spec := range []struct{ card, stride int }{ + {2400, 3}, {2700, 3}, // one container each — inline path + {6000, 29}, {7000, 29}, // several containers each — reference path + } { + k := ComputeTermKey(fmt.Appendf(nil, "inline-straddle-%d", i), FieldTopic1) + ids := make([]uint32, spec.card) + for j := range ids { + ids[j] = uint32(j*spec.stride + 1) + } + corpus[k] = ids + body, berr := encodeIndexBody(nil, ids, nil) + require.NoError(t, berr) + minBody = min(minBody, len(body)) + maxBody = max(maxBody, len(body)) + } + require.LessOrEqual(t, minBody, inlineBodyMax, "corpus must include an inline-path body") + require.Greater(t, maxBody, inlineBodyMax, "corpus must include a reference-path body") + + // Reference: today's in-memory mirror path. + refDir := t.TempDir() + mirror := NewBitmaps() + for k, ids := range corpus { + mirror.AddTo(k, ids...) + } + require.NoError(t, WriteColdIndex(context.Background(), chunkID, mirror, refDir, testIndexSecret)) + + // Streaming: spill the same corpus through several runs (interleaved so + // terms span runs), then the external build. + streamDir := t.TempDir() + scratch := filepath.Join(t.TempDir(), "scratch") + sp, err := runspill.NewSpiller(scratch, 1<<14) // small slab → many runs + require.NoError(t, err) + for k, ids := range corpus { + for _, id := range ids { + spillBlinded(t, sp, k, id) + } + } + runs, err := sp.Finish() + require.NoError(t, err) + require.Greater(t, len(runs), 3) + require.NoError(t, WriteColdIndexFromRuns(context.Background(), chunkID, runs, scratch, streamDir, testIndexSecret)) + + for _, name := range []string{IndexHashName(chunkID), IndexPackName(chunkID)} { + ref, err := os.ReadFile(filepath.Join(refDir, name)) + require.NoError(t, err) + got, err := os.ReadFile(filepath.Join(streamDir, name)) + require.NoError(t, err) + assert.Len(t, got, len(ref), "%s length", name) + assert.Equal(t, string(ref), string(got), "%s must be byte-identical", name) + } + + // terms.run scratch must be gone on success. + _, serr := os.Stat(filepath.Join(scratch, "terms.run")) + assert.True(t, os.IsNotExist(serr), "terms.run scratch must be removed") +} + +// TestSlotHeapPopClearsVacatedRef pins popMin's tail clear: without it the +// vacated slot in the capacity tail keeps an already-emitted inline body +// reachable, and no black-box test can see the difference (output is +// byte-identical either way — this retention is memory-only). +func TestSlotHeapPopClearsVacatedRef(t *testing.T) { + var h slotHeap + h.push(slotRecord{slot: 2, inline: []byte{2}}) + h.push(slotRecord{slot: 1, inline: []byte{1}}) + got := h.popMin() + require.Equal(t, uint32(1), got.slot) + require.Equal(t, uint32(2), h[0].slot, "root must be the surviving record") + require.NotNil(t, h[0].inline, "surviving record keeps its body") + tail := h[:cap(h)][len(h)] + require.Equal(t, slotRecord{}, tail, "vacated slot must be fully zeroed — a stale header would pin an emitted body") +} + +// TestWriteColdIndexFromRuns_EmptyChunk mirrors the eventless-chunk contract: +// zero runs still produce a valid empty index pair, identical to the +// mirror path's empty output. +func TestWriteColdIndexFromRuns_EmptyChunk(t *testing.T) { + const chunkID = chunk.ID(7) + refDir := t.TempDir() + require.NoError(t, WriteColdIndex(context.Background(), chunkID, NewBitmaps(), refDir, testIndexSecret)) + + streamDir := t.TempDir() + require.NoError(t, WriteColdIndexFromRuns( + context.Background(), chunkID, nil, t.TempDir(), streamDir, testIndexSecret)) + + for _, name := range []string{IndexHashName(chunkID), IndexPackName(chunkID)} { + ref, err := os.ReadFile(filepath.Join(refDir, name)) + require.NoError(t, err) + got, err := os.ReadFile(filepath.Join(streamDir, name)) + require.NoError(t, err) + assert.Equal(t, ref, got, "%s (empty chunk) must match", name) + } +} + +// writeEventsPackForRuns writes the events.pack half of a cold artifact set: +// n events on the chunk's first ledger, matching the [0, n) ID space the +// streaming index is built over. The ColdReader cross-checks index.hash +// against events.pack before serving a lookup, so the reader-level tests need +// the real pack, not just the index pair. +func writeEventsPackForRuns(t *testing.T, chunkID chunk.ID, dir string, n int) { + t.Helper() + cw, err := NewColdWriter(chunkID, dir, ColdWriterOptions{}) + require.NoError(t, err) + t.Cleanup(func() { _ = cw.Close() }) + + first := chunkID.FirstLedger() + for i := range n { + require.NoError(t, cw.Append(makeColdPayload(first, 1, fmt.Sprintf("e%d", i)))) + } + offsets := NewLedgerOffsets(first) + require.NoError(t, offsets.Append(first, uint32(n))) + require.NoError(t, cw.Commit(offsets)) +} + +// TestWriteColdIndexFromRuns_ReadsBack: the streaming build's artifacts must +// serve reads through the production ColdReader — every term resolves to its +// exact ID set, and an absent term misses cleanly. +func TestWriteColdIndexFromRuns_ReadsBack(t *testing.T) { + const ( + chunkID = chunk.ID(5) + events = 1200 + ) + corpus := synthTerms(events, 9) + + // Premise: the corpus must span the container shapes roaring switches + // between, so this one read-back drives run/bitmap and array containers + // alike through the reader — the firehose term holds every ID, the tail + // is singletons. + fattest, thinnest := 0, int(^uint(0)>>1) + for _, ids := range corpus { + fattest = max(fattest, len(ids)) + thinnest = min(thinnest, len(ids)) + } + require.Equal(t, events, fattest, + "corpus must include the firehose term — every ID, one run container after RunOptimize") + require.Equal(t, 1, thinnest, "corpus must include a one-posting term — a lone array container") + + dir := t.TempDir() + scratch := filepath.Join(t.TempDir(), "s") + sp, err := runspill.NewSpiller(scratch, 1<<14) + require.NoError(t, err) + for k, ids := range corpus { + for _, id := range ids { + spillBlinded(t, sp, k, id) + } + } + runs, err := sp.Finish() + require.NoError(t, err) + require.NoError(t, WriteColdIndexFromRuns(context.Background(), chunkID, runs, scratch, dir, testIndexSecret)) + writeEventsPackForRuns(t, chunkID, dir, events) + + cr, err := OpenColdReader(chunkID, dir, ColdReaderOptions{}) + require.NoError(t, err) + t.Cleanup(func() { _ = cr.Close() }) + + // Every spilled term, resolved in ONE batch through the production reader: + // the blinding, the MPHF routing, the fingerprint check and the bitmap + // decode all have to agree for the exact ID set to come back. + keys := make([]TermKey, 0, len(corpus)) + for k := range corpus { + keys = append(keys, k) + } + got, err := cr.LookupKeys(context.Background(), keys) + require.NoError(t, err) + require.Len(t, got, len(keys)) + for i, k := range keys { + require.True(t, got[i].Present(), "term %x must resolve", k[:8]) + assert.Equal(t, corpus[k], got[i].Bitmap().ToArray(), "term %x ID set", k[:8]) + assert.Nil(t, got[i].IDs(), "cold postings are bitmap-backed at every cardinality") + } + + // A term that was never spilled misses cleanly: absent postings, no error. + // (Whichever miss path it takes — streamhash no-match or a residual + // collision caught by the fingerprint — the outcome must be the same.) + absent := ComputeTermKey([]byte("never-spilled"), FieldTopic1) + _, inCorpus := corpus[absent] + require.False(t, inCorpus, "premise: the miss key must not be in the corpus") + miss, err := cr.LookupKeys(context.Background(), []TermKey{absent}) + require.NoError(t, err) + require.Len(t, miss, 1) + assert.False(t, miss[0].Present(), "an absent term must miss") + assert.Zero(t, miss[0].Cardinality()) + assert.Nil(t, lookupOne(t, cr, absent), "the shared single-term path must agree") +} + +// TestWriteColdIndexFromRuns_EmptyChunkReadsBack opens an EVENTLESS chunk's +// streamed artifacts through the production reader. The byte comparison in +// TestWriteColdIndexFromRuns_EmptyChunk pins the bytes but cannot pin the +// read: a zero-term index takes a branch of its own in OpenColdReader — the +// empty index is cross-checked against events.pack's count, and no MPHF +// routing ever runs — so the streamed build's eventless output reaches the +// reader through code the eventful read-back test never touches. +func TestWriteColdIndexFromRuns_EmptyChunkReadsBack(t *testing.T) { + const chunkID = chunk.ID(9) + dir := t.TempDir() + require.NoError(t, WriteColdIndexFromRuns( + context.Background(), chunkID, nil, t.TempDir(), dir, testIndexSecret)) + writeEventsPackForRuns(t, chunkID, dir, 0) + + cr, err := OpenColdReader(chunkID, dir, ColdReaderOptions{}) + require.NoError(t, err) + t.Cleanup(func() { _ = cr.Close() }) + + cnt, err := cr.EventCount() + require.NoError(t, err) + assert.Zero(t, cnt, "an eventless chunk's pack holds no events") + + // Every term misses cleanly: absent postings and no error, rather than a + // routing failure against an index with nothing to route to. + absent := ComputeTermKey([]byte("never-spilled"), FieldTopic1) + got, err := cr.LookupKeys(context.Background(), []TermKey{absent}) + require.NoError(t, err) + require.Len(t, got, 1) + assert.False(t, got[0].Present(), "an eventless chunk resolves every term to a miss") + assert.Zero(t, got[0].Cardinality()) + assert.Nil(t, lookupOne(t, cr, absent), "the shared single-term path must agree") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_test.go index cc7c034c5..a965e24f8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_test.go @@ -3,6 +3,7 @@ package event import ( "context" "encoding/binary" + "encoding/hex" "errors" "fmt" "os" @@ -15,6 +16,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/packfile" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) // indexTestChunkID is the chunk ID every WriteColdIndex test uses for @@ -117,21 +119,20 @@ func TestWriteIndex_RoundTripsBitmapsPerTerm(t *testing.T) { fmt.Appendf(nil, "term-%d", i), FieldContractID, ) - slot, err := m.Lookup(term) + slot, rk, err := m.Lookup(term) require.NoError(t, err, "lookup term-%d", i) record, ok := records[int(slot)] require.True(t, ok, "record missing at slot %d (term-%d)", slot, i) - require.GreaterOrEqual(t, len(record), IndexRecordFingerprintLen, "record at slot %d too short", slot) - // Fingerprint must match term[:4]. - assert.Equal(t, term[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen], - "fingerprint mismatch at slot %d", slot) - - // Deserialize bitmap. - bm := roaring.New() - require.NoError(t, bm.UnmarshalBinary(record[IndexRecordFingerprintLen:])) - assert.Equal(t, uint64(2), bm.GetCardinality(), "term-%d bitmap card", i) + // Read it back the way the cold reader does, which also checks the + // fingerprint against the ROUTED key's prefix before unmarshalling + // the bitmap. + post, derr := verifyAndDecodePostings(record, rk, slot) + require.NoError(t, derr, "term-%d", i) + require.True(t, post.Present(), "term-%d must resolve, not read as a fingerprint miss", i) + bm := post.Bitmap() + assert.Equal(t, uint64(2), post.Cardinality(), "term-%d posting count", i) assert.True(t, bm.Contains(uint32(i*10)), "term-%d missing event id %d", i, i*10) assert.True(t, bm.Contains(uint32(i*10+1)), "term-%d missing event id %d", i, i*10+1) } @@ -161,7 +162,7 @@ func TestWriteIndex_UnseenTermFingerprintMismatches(t *testing.T) { fmt.Appendf(nil, "never-seen-%d", i), FieldTopic0, ) - slot, err := m.Lookup(unseen) + slot, unseenRK, err := m.Lookup(unseen) if errors.Is(err, ErrKeyNotFound) { continue } @@ -171,7 +172,7 @@ func TestWriteIndex_UnseenTermFingerprintMismatches(t *testing.T) { record, ok := records[int(slot)] require.True(t, ok) recordFP := record[:IndexRecordFingerprintLen] - if string(recordFP) != string(unseen[:IndexRecordFingerprintLen]) { + if string(recordFP) != string(unseenRK[:IndexRecordFingerprintLen]) { mismatches++ } } @@ -223,7 +224,7 @@ func TestWriteIndex_ZeroTerms_WritesEmptyIndex(t *testing.T) { m, err := openMPHF(filepath.Join(dir, IndexHashName(indexTestChunkID))) require.NoError(t, err) t.Cleanup(func() { _ = m.Close() }) - _, lerr := m.Lookup(ComputeTermKey([]byte("anything"), FieldContractID)) + _, _, lerr := m.Lookup(ComputeTermKey([]byte("anything"), FieldContractID)) assert.ErrorIs(t, lerr, ErrKeyNotFound) } @@ -266,7 +267,7 @@ func TestWriteIndex_SlotsAreDense(t *testing.T) { fmt.Appendf(nil, "term-%d", i), FieldContractID, ) - slot, err := m.Lookup(term) + slot, _, err := m.Lookup(term) require.NoError(t, err) assert.Less(t, slot, uint32(n)) seen[slot] = struct{}{} @@ -299,18 +300,18 @@ func TestWriteIndex_LargeIndex(t *testing.T) { fmt.Appendf(nil, "term-%d", i), FieldContractID, ) - slot, err := m.Lookup(term) + slot, rk, err := m.Lookup(term) require.NoError(t, err) record, ok := records[int(slot)] require.True(t, ok) - assert.Equal(t, term[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen]) + assert.Equal(t, rk[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen]) } } func TestWriteIndex_RecordEncoding(t *testing.T) { - // Lock the on-disk record format: fingerprint || roaring bitmap. - // Future readers (PR-3a) rely on this layout; if it ever changes - // silently, this test fails. + // Lock the on-disk record format: routed-key fingerprint || roaring + // bitmap, with nothing between them. The cold reader relies on this + // layout; if it ever changes silently, this test fails. dir := t.TempDir() idx := NewBitmaps() idx.AddTo(ComputeTermKey([]byte("only"), FieldContractID), 42) @@ -324,12 +325,21 @@ func TestWriteIndex_RecordEncoding(t *testing.T) { require.Greater(t, len(record), IndexRecordFingerprintLen) term := ComputeTermKey([]byte("only"), FieldContractID) - assert.Equal(t, term[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen]) + rk := TermKey(stores.BlindKey(testIndexSecret, term[:])) + assert.Equal(t, rk[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen]) + // The body is a bare roaring serialization starting at offset 4 — no + // codec byte, no framing of our own. bm := roaring.New() - require.NoError(t, bm.UnmarshalBinary(record[IndexRecordFingerprintLen:])) - assert.Equal(t, uint64(1), bm.GetCardinality()) - assert.True(t, bm.Contains(42)) + require.NoError(t, bm.UnmarshalBinary(record[IndexRecordFingerprintLen:]), + "the body must parse as roaring with no header of ours in front of it") + assert.Equal(t, []uint32{42}, bm.ToArray()) + + post, derr := verifyAndDecodePostings(record, rk, 0) + require.NoError(t, derr) + require.True(t, post.Present()) + assert.Equal(t, uint64(1), post.Cardinality()) + assert.True(t, post.Contains(42)) // Defensive: the fingerprint occupies bytes 0..3 in little-endian // the way TermKey itself encodes — read it back via binary helpers @@ -368,3 +378,112 @@ func TestWriteColdIndex_StampAndContentHash(t *testing.T) { assert.True(t, hashed, "index.pack carries a content hash") require.NoError(t, r.Verify(context.Background())) } + +// TestWriteIndex_BodiesAreRoaringAtEveryCardinality pins that the record body +// is a roaring bitmap whatever the term's cardinality — the format carries no +// per-term encoding choice, so a one-posting term and a fat one are read back +// by the same two lines. The cardinalities either side of 1024 are corpus, +// not a boundary: nothing dispatches on them. +func TestWriteIndex_BodiesAreRoaringAtEveryCardinality(t *testing.T) { + dir := t.TempDir() + idx := NewBitmaps() + + term := func(name string, card int) TermKey { + k := ComputeTermKey([]byte(name), FieldContractID) + ids := make([]uint32, card) + for i := range ids { + ids[i] = uint32(i * 7) + } + idx.AddTo(k, ids...) + return k + } + single := term("single", 1) + small := term("small", 1024) + fat := term("fat", 1025) + + require.NoError(t, WriteColdIndex(context.Background(), indexTestChunkID, idx, dir, testIndexSecret)) + + m, err := openMPHF(filepath.Join(dir, IndexHashName(indexTestChunkID))) + require.NoError(t, err) + defer m.Close() + records := loadIndexPack(t, filepath.Join(dir, IndexPackName(indexTestChunkID))) + + wantCard := []uint64{1, 1024, 1025} + for i, k := range []TermKey{single, small, fat} { + want := wantCard[i] + slot, rk, lerr := m.Lookup(k) + require.NoError(t, lerr) + record := records[int(slot)] + + // Straight off the disk bytes: body at offset 4 is roaring, always. + bm := roaring.New() + require.NoError(t, bm.UnmarshalBinary(record[IndexRecordFingerprintLen:]), + "term %d body must parse as roaring", i) + assert.Equal(t, want, bm.GetCardinality(), "term %d on-disk cardinality", i) + + // And through the reader's decode, which must agree. + post, derr := verifyAndDecodePostings(record, rk, slot) + require.NoError(t, derr, "term %d", i) + require.True(t, post.Present(), "term %d must resolve", i) + assert.Equal(t, want, post.Cardinality(), "term %d cardinality", i) + assert.True(t, post.Contains(0), "term %d must hold its first posting", i) + } +} + +// TestEncodeIndexBodyRejectsEmpty pins that the encoder refuses a term with no +// postings rather than writing a record for a term that cannot exist. +func TestEncodeIndexBodyRejectsEmpty(t *testing.T) { + _, err := encodeIndexBody(nil, nil, nil) + require.Error(t, err) + + body, err := encodeIndexBody(nil, []uint32{7}, nil) + require.NoError(t, err) + bm := roaring.New() + require.NoError(t, bm.UnmarshalBinary(body), "one posting must round-trip") + require.Equal(t, []uint32{7}, bm.ToArray()) +} + +// TestEncodeIndexBodyScratchReuse pins the streaming builder's optimization +// against the byte-identity gate's requirement: a scratch bitmap carried +// across terms must encode exactly what a fresh one does, including after a +// fatter term has grown its containers. +func TestEncodeIndexBodyScratchReuse(t *testing.T) { + corpus := [][]uint32{{7}, {1, 2, 3, 4, 5}, nil, {9, 400000}} + corpus[2] = make([]uint32, 5000) // dense, container-growing + for i := range corpus[2] { + corpus[2][i] = uint32(i * 3) + } + + scratch := roaring.New() + for i, ids := range corpus { + want, err := encodeIndexBody(nil, ids, nil) + require.NoError(t, err) + got, err := encodeIndexBody(nil, ids, scratch) + require.NoError(t, err) + require.Equal(t, want, got, "corpus %d must encode identically with a reused scratch", i) + } +} + +// TestVerifyAndDecodePostings_TrustsBytes pins the trust model: the decode +// path does not validate bitmap structure. roaring's UnmarshalBinary accepts a +// run container holding no intervals, which reads back as a bitmap with +// containers but no postings. Byte integrity belongs to the packfile checksum +// layer, and per-decode validation is super-linear on run-dense terms, so such +// a record decodes cleanly and simply resolves as a present term matching +// nothing. +func TestVerifyAndDecodePostings_TrustsBytes(t *testing.T) { + body, err := hex.DecodeString("3b3000000100008713000000008713") + require.NoError(t, err) + require.NoError(t, roaring.New().UnmarshalBinary(body), + "roaring must accept these bytes, else this test proves nothing") + + key := ComputeTermKey([]byte("corrupt"), FieldContractID) + record := append(append([]byte{}, key[:IndexRecordFingerprintLen]...), body...) + + post, derr := verifyAndDecodePostings(record, key, 7) + require.NoError(t, derr, "a structurally hollow bitmap must not fail the decode") + assert.True(t, post.Present(), "the term is present — it just matches nothing") + assert.Equal(t, uint64(0), post.Cardinality()) + assert.False(t, post.Contains(0)) + assert.Empty(t, post.SelectIDs(0, false), "a hollow bitmap must iterate no postings") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go index 9c0a3efa9..6ee6d7cfa 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go @@ -320,35 +320,45 @@ func (c *ColdReader) Offsets() (*LedgerOffsets, error) { return m.offsets, nil } -// verifyAndDeserializeBitmap checks the index.pack record's leading -// fingerprint against key's prefix and, on match, unmarshals a fresh -// bitmap. On fingerprint mismatch (residual MPHF collision on an -// unseen key) it returns (nil, nil) — the caller treats nil as -// not-found. record is valid only inside ReadItem's callback; -// UnmarshalBinary copies into roaring's internal state so the -// returned bitmap outlives the callback safely. -func verifyAndDeserializeBitmap(record []byte, key TermKey, slot uint32) (*roaring.Bitmap, error) { - if len(record) < IndexRecordFingerprintLen { - return nil, fmt.Errorf("events: index.pack record at slot %d truncated (%d bytes)", slot, len(record)) +// verifyAndDecodePostings checks the index.pack record's leading fingerprint +// against the ROUTED (blinded) key's prefix — the identity the builders +// fingerprint by — and, on match, decodes its postings. On fingerprint +// mismatch (residual MPHF collision on an unseen key) it returns the zero +// Postings, which the caller treats as not-found. +// +// record is valid only inside ReadItems' callback; UnmarshalBinary copies the +// bytes into roaring's own state rather than aliasing them, so the decoded +// postings outlive the callback safely. +func verifyAndDecodePostings(record []byte, key TermKey, slot uint32) (Postings, error) { + if len(record) <= IndexRecordFingerprintLen { + return Postings{}, fmt.Errorf( + "events: index.pack record at slot %d truncated (%d bytes)", slot, len(record)) } if !bytes.Equal(record[:IndexRecordFingerprintLen], key[:IndexRecordFingerprintLen]) { - return nil, nil //nolint:nilnil // not-found signaled by nil bitmap, no error + return Postings{}, nil } + body := record[IndexRecordFingerprintLen:] bm := roaring.New() - if err := bm.UnmarshalBinary(record[IndexRecordFingerprintLen:]); err != nil { - return nil, fmt.Errorf("events: unmarshal bitmap at slot %d: %w", slot, err) - } - return bm, nil + if err := bm.UnmarshalBinary(body); err != nil { + return Postings{}, fmt.Errorf("events: unmarshal bitmap at slot %d: %w", slot, err) + } + // No Bitmap.Validate here: byte integrity is the packfile checksum layer's + // job, and per-decode validation is super-linear on run-dense terms — the + // exact terms the query hot path reads most. A structurally hollow bitmap + // that UnmarshalBinary accepts (a run container holding no intervals, say) + // resolves as a present term matching nothing, which every caller already + // handles. + return BitmapPostings(bm), nil } -// LookupKeys returns bitmaps for each key, aligned positionally with -// the input slice (result[i] corresponds to keys[i]). See -// Reader.LookupKeys for the semantics. +// LookupKeys returns each key's postings, aligned positionally with the input +// slice (result[i] corresponds to keys[i]). See Reader.LookupKeys for the +// semantics. // // Cold-side implementation: // // 1. MPHF-resolve every key. Keys rejected at the routing stage -// (streamhash ErrKeyNotFound) get result[i] = nil and never +// (streamhash ErrKeyNotFound) leave result[i] absent and never // touch index.pack. // 2. Sort the surviving (key, slot) pairs by slot and dedupe — // pathological residual collisions can map two distinct keys @@ -358,11 +368,11 @@ func verifyAndDeserializeBitmap(record []byte, key TermKey, slot uint32) (*roari // calls and fans out across the worker count configured via // ColdReaderOptions.Concurrency. // 4. In the callback, verify each pending key's fingerprint -// against the record header and unmarshal a fresh bitmap per -// match. Misses (fingerprint mismatch) leave result[i] = nil. +// against the record header and decode its postings per match. +// Misses (fingerprint mismatch) leave result[i] absent. // //nolint:cyclop // the four documented steps above, inline; splitting obscures the pass structure -func (c *ColdReader) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring.Bitmap, error) { +func (c *ColdReader) LookupKeys(ctx context.Context, keys []TermKey) ([]Postings, error) { if c.closed.Load() { return nil, stores.ErrStoreClosed } @@ -381,22 +391,23 @@ func (c *ColdReader) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring return nil, err } - results := make([]*roaring.Bitmap, len(keys)) + results := make([]Postings, len(keys)) type pendingKey struct { outIdx int slot uint32 + routed TermKey // blinded key — what records and fingerprints are keyed by } pending := make([]pendingKey, 0, len(keys)) for i, key := range keys { - slot, err := mphf.Lookup(key) + slot, rk, err := mphf.Lookup(key) if err != nil { if errors.Is(err, ErrKeyNotFound) { - continue // result[i] stays nil + continue } return nil, fmt.Errorf("events: LookupKeys MPHF for chunk %s: %w", c.chunkID, err) } - pending = append(pending, pendingKey{outIdx: i, slot: slot}) + pending = append(pending, pendingKey{outIdx: i, slot: slot, routed: rk}) } if len(pending) == 0 { return results, nil @@ -420,16 +431,15 @@ func (c *ColdReader) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring if err := c.index.ReadItems(ctx, positions, func(readIdx int, record []byte) error { // Multiple pending keys may share this slot (residual MPHF - // collision). verifyAndDeserializeBitmap returns a fresh - // bitmap per match and (nil, nil) on fingerprint mismatch — - // leaving results[outIdx] = nil for misses. + // collision). verifyAndDecodePostings decodes per match and returns + // the zero Postings on fingerprint mismatch, leaving that key absent. for _, pIdx := range pendingBySlot[readIdx] { p := pending[pIdx] - bm, err := verifyAndDeserializeBitmap(record, keys[p.outIdx], p.slot) + post, err := verifyAndDecodePostings(record, p.routed, p.slot) if err != nil { return err } - results[p.outIdx] = bm + results[p.outIdx] = post } return nil }); err != nil { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader_test.go index 5e504f631..ee48d0943 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader_test.go @@ -79,7 +79,7 @@ func buildColdFixture(t *testing.T, chunkID chunk.ID, eventsPerLedger, ledgersPe require.NoError(t, offsets.Append(ledgerSeq, count)) } - require.NoError(t, cw.Finish(offsets)) + require.NoError(t, cw.Commit(offsets)) require.NoError(t, WriteColdIndex(context.Background(), chunkID, idx, dir, testIndexSecret)) return dir, payloads } @@ -161,30 +161,6 @@ func TestColdReader_LookupUnseenTermReturnsNil(t *testing.T) { } } -func TestColdReader_LookupKeysReturnsFreshBitmaps(t *testing.T) { - // Two LookupKeys calls for the same term return independent - // bitmaps — mutating one must not affect the other. The interface - // contract says cold-side results are freshly unmarshaled and - // logically owned by the caller; this pins that a mutation cannot - // bleed into a later lookup. - const chunkID = chunk.ID(0) - dir, payloads := buildColdFixture(t, chunkID, 8, 1) - - cr, err := OpenColdReader(chunkID, dir, ColdReaderOptions{}) - require.NoError(t, err) - t.Cleanup(func() { _ = cr.Close() }) - - key := contractTermKey(payloads[0]) - first := lookupOne(t, cr, key) - require.NotNil(t, first) - first.Add(999_999) - - second := lookupOne(t, cr, key) - require.NotNil(t, second) - assert.False(t, second.Contains(999_999), - "a LookupKeys result mutation must not bleed into the next lookup") -} - func TestColdReader_FetchEventsRoundTrip(t *testing.T) { const chunkID = chunk.ID(0) dir, payloads := buildColdFixture(t, chunkID, 5, 1) @@ -276,13 +252,13 @@ func TestColdReader_EventlessChunk(t *testing.T) { require.NoError(t, err) assert.Zero(t, cnt) - // Term-filtered paths miss cleanly (nil bitmap, no error) instead + // Term-filtered paths miss cleanly (absent postings, no error) instead // of surfacing a filesystem error. someTerm := ComputeTermKey([]byte("any"), FieldContractID) - bms, err := cr.LookupKeys(context.Background(), []TermKey{someTerm}) + got, err := cr.LookupKeys(context.Background(), []TermKey{someTerm}) require.NoError(t, err) - require.Len(t, bms, 1) - assert.Nil(t, bms[0]) + require.Len(t, got, 1) + assert.False(t, got[0].Present()) // Full-scan path yields nothing. for _, err := range cr.All(context.Background()) { @@ -551,26 +527,28 @@ func TestColdReader_LookupKeys(t *testing.T) { missing := ComputeTermKey([]byte("never-added"), FieldTopic1) keys := []TermKey{contractKey, missing, topicKey, missing} - bms, err := cr.LookupKeys(context.Background(), keys) + got, err := cr.LookupKeys(context.Background(), keys) require.NoError(t, err) - require.Len(t, bms, len(keys)) + require.Len(t, got, len(keys)) - require.NotNil(t, bms[0], "contract key should hit") - assert.Equal(t, uint64(len(payloads)), bms[0].GetCardinality()) + require.True(t, got[0].Present(), "contract key should hit") + assert.Equal(t, uint64(len(payloads)), got[0].Cardinality()) - assert.Nil(t, bms[1], "missing key should be nil") + assert.False(t, got[1].Present(), "missing key should be absent") - require.NotNil(t, bms[2], "topic key should hit") - assert.Equal(t, uint64(1), bms[2].GetCardinality()) - assert.True(t, bms[2].Contains(0)) + require.True(t, got[2].Present(), "topic key should hit") + assert.Equal(t, uint64(1), got[2].Cardinality()) + assert.True(t, got[2].Contains(0)) - assert.Nil(t, bms[3], "second missing must also be nil") + assert.False(t, got[3].Present(), "second missing must also be absent") } -// TestColdReader_LookupKeysClonesAcrossCalls pins that two -// LookupKeys batches for the same key return independent bitmaps — -// mutating one must not bleed into the next. -func TestColdReader_LookupKeysClonesAcrossCalls(t *testing.T) { +// TestColdReader_LookupKeysIsolatesCalls pins that two LookupKeys batches for +// the same key return independent postings, so scribbling on one cannot bleed +// into the next. Cold postings are bitmap-backed, and Bitmap() hands back the +// store's own bitmap rather than a copy, so writing through it is the mutation +// that could actually reach a shared buffer. +func TestColdReader_LookupKeysIsolatesCalls(t *testing.T) { const chunkID = chunk.ID(0) dir, payloads := buildColdFixture(t, chunkID, 8, 1) @@ -582,14 +560,17 @@ func TestColdReader_LookupKeysClonesAcrossCalls(t *testing.T) { first, err := cr.LookupKeys(context.Background(), []TermKey{key}) require.NoError(t, err) require.Len(t, first, 1) - require.NotNil(t, first[0]) - first[0].Add(999_999) + require.True(t, first[0].Present(), "the fixture's contract term must hit") + + want := first[0].Bitmap().ToArray() + first[0].Bitmap().Add(999_999) + require.True(t, first[0].Contains(999_999), + "premise: the scribble must land on the returned postings") second, err := cr.LookupKeys(context.Background(), []TermKey{key}) require.NoError(t, err) require.Len(t, second, 1) - require.NotNil(t, second[0]) - assert.False(t, second[0].Contains(999_999), + assert.Equal(t, want, second[0].Bitmap().ToArray(), "a LookupKeys result mutation must not bleed into the next LookupKeys") } @@ -800,11 +781,11 @@ func TestColdReader_RejectsMispairedOffsets(t *testing.T) { for i := range 3 { require.NoError(t, cw.Append(makeColdPayload(first, 1, fmt.Sprintf("e%d", i)))) } - // Offsets sum to 2 events; the pack holds 3. ColdWriter.Finish takes + // Offsets sum to 2 events; the pack holds 3. ColdWriter.Commit takes // the caller's offsets on trust — the reader-side check is the guard. offsets := NewLedgerOffsets(first) require.NoError(t, offsets.Append(first, 2)) - require.NoError(t, cw.Finish(offsets)) + require.NoError(t, cw.Commit(offsets)) cr, err := OpenColdReader(chunkID, dir, ColdReaderOptions{}) require.NoError(t, err) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer.go index fa540be7e..fa3c29544 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer.go @@ -11,7 +11,7 @@ package event // // ColdWriter is intentionally thin: it owns no closed-state of its // own. Lifecycle, idempotency of Close, removal of the partial file -// on abort, and rejection of Append / Finish after the writer is +// on abort, and rejection of Append / Commit after the writer is // done all delegate to packfile.Writer (see packfile/writer.go). import ( @@ -32,22 +32,22 @@ import ( // serialized as packfile app data so the cold reader can resolve // ledger→eventID ranges without a second file. // -// ColdWriter.Finish takes the LedgerOffsets externally rather than +// ColdWriter.Commit takes the LedgerOffsets externally rather than // inferring boundaries from the payload stream. This honestly // represents the contract: the caller knows ledger boundaries // (including any empty ledgers that produce no Append calls) and // supplies them at finalization. Both consumers fit this naturally: // // - Freeze: hot.Offsets() already has entries for every ledger -// in the Chunk, including empty ones. Hand it to Finish. +// in the Chunk, including empty ones. Hand it to Commit. // - Backfill: maintains a *LedgerOffsets incrementally as it -// processes LCMs. Hand it to Finish. +// processes LCMs. Hand it to Commit. // // ColdWriter doesn't produce the index files (index.pack + // index.hash). Those come from WriteColdIndex below. // // Concurrency: ColdWriter is not safe for concurrent use. Append, -// Finish, and Close must be called from a single goroutine (or with +// Commit, and Close must be called from a single goroutine (or with // external synchronization). The underlying packfile.Writer has its // own internal worker pool for compression, but the writer's // public API is single-producer. @@ -69,7 +69,7 @@ type ColdWriterOptions struct { Concurrency int // BytesPerSync triggers background fdatasync every N bytes during - // the write so the final Finish doesn't flush all dirty pages at + // the write so the final Commit doesn't flush all dirty pages at // once. Zero disables (single final fdatasync). On networked // storage (EBS) a 1 MB cadence (1<<20) cuts the final-flush // latency dramatically; on NVMe the win is smaller but still @@ -81,7 +81,7 @@ type ColdWriterOptions struct { // bucketDir must already exist — like the sibling stores, directory // creation belongs to the ingest layer. The filename is // {chunkID:08d}-events.pack per the backfill design doc. The returned -// ColdWriter must be closed via either Finish (on success) or Close +// ColdWriter must be closed via either Commit (on success) or Close // (on abort) — leaving a ColdWriter open leaks the underlying // packfile.Writer's worker goroutines. // @@ -113,7 +113,7 @@ func NewColdWriter(chunkID chunk.ID, bucketDir string, opts ColdWriterOptions) ( // order — packfile records this as item position 0, 1, 2, ... and // the cold reader reads them back the same way. // -// Returns packfile.ErrWriterClosed if called after Finish or Close. +// Returns packfile.ErrWriterClosed if called after Commit or Close. func (w *ColdWriter) Append(p Payload) error { // Marshal into a reusable scratch buffer. AppendItem copies the bytes // into the packfile's record buffer synchronously, so the scratch is @@ -126,13 +126,24 @@ func (w *ColdWriter) Append(p Payload) error { return w.pw.AppendItem(b) } -// Finish serializes offsets as packfile app data and finalizes -// events.pack. After Finish returns nil, the file is fsync'd and -// safe to fence with an atomic durability flag. +// AppendMarshaled writes an ALREADY-MARSHALED payload verbatim — the +// freeze-by-merge path streams events_data CF values here, which are the +// same canonical MarshalInto bytes Append would produce, so the resulting +// pack is byte-identical to a walk-driven build. The same eventID-order +// contract as Append applies. +func (w *ColdWriter) AppendMarshaled(b []byte) error { + return w.pw.AppendItem(b) +} + +// Commit serializes offsets as packfile app data and finalizes +// events.pack — the publication half of the two-phase writer pair the +// domain writers share (runspill.RunWriter carries the pattern doc). +// After Commit returns nil, the file is fsync'd and safe to fence with +// an atomic durability flag. // -// Finish must not be called more than once. A second call (or a +// Commit must not be called more than once. A second call (or a // call after Close) returns packfile.ErrWriterClosed. -func (w *ColdWriter) Finish(offsets *LedgerOffsets) error { +func (w *ColdWriter) Commit(offsets *LedgerOffsets) error { appData, err := encodeLedgerOffsets(offsets) if err != nil { return fmt.Errorf("events: encode offsets: %w", err) @@ -141,8 +152,8 @@ func (w *ColdWriter) Finish(offsets *LedgerOffsets) error { } // Close aborts the ColdWriter, releasing the underlying -// packfile.Writer's resources. If Finish has succeeded, Close is a -// no-op (the file remains as a valid packfile). If Finish has not +// packfile.Writer's resources. If Commit has succeeded, Close is a +// no-op (the file remains as a valid packfile). If Commit has not // succeeded, Close removes the partial events.pack file. Idempotent; // safe to call from defer paths. func (w *ColdWriter) Close() error { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer_test.go index 662b45254..39b99a962 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer_test.go @@ -51,7 +51,7 @@ func makeColdPayload(ledgerSeq, txIdx uint32, symbol string) Payload { } } -func TestWriter_AppendThenFinishProducesReadablePackfile(t *testing.T) { +func TestWriter_AppendThenCommitProducesReadablePackfile(t *testing.T) { const chunkID = chunk.ID(0) dir := t.TempDir() @@ -73,7 +73,7 @@ func TestWriter_AppendThenFinishProducesReadablePackfile(t *testing.T) { require.NoError(t, offsets.Append(2, 2)) require.NoError(t, offsets.Append(3, 1)) require.NoError(t, offsets.Append(4, 0)) // empty ledger - require.NoError(t, w.Finish(offsets)) + require.NoError(t, w.Commit(offsets)) // Read back via packfile.Reader. path := filepath.Join(dir, EventsPackName(chunkID)) @@ -123,7 +123,7 @@ func TestWriter_EmptyChunkStillFinalizes(t *testing.T) { require.NoError(t, err) offsets := NewLedgerOffsets(chunkID.FirstLedger()) - require.NoError(t, w.Finish(offsets)) + require.NoError(t, w.Commit(offsets)) reader := packfile.Open( filepath.Join(dir, EventsPackName(chunkID)), @@ -141,26 +141,26 @@ func TestWriter_EmptyChunkStillFinalizes(t *testing.T) { assert.Equal(t, uint32(0), decoded.TotalEvents()) } -func TestWriter_AppendAfterFinishErrors(t *testing.T) { +func TestWriter_AppendAfterCommitErrors(t *testing.T) { const chunkID = chunk.ID(0) w, err := NewColdWriter(chunkID, t.TempDir(), ColdWriterOptions{}) require.NoError(t, err) offsets := NewLedgerOffsets(chunkID.FirstLedger()) - require.NoError(t, w.Finish(offsets)) + require.NoError(t, w.Commit(offsets)) err = w.Append(makeColdPayload(2, 1, "x")) - require.ErrorIs(t, err, packfile.ErrWriterClosed, "Append after Finish must return packfile.ErrWriterClosed") + require.ErrorIs(t, err, packfile.ErrWriterClosed, "Append after Commit must return packfile.ErrWriterClosed") - err = w.Finish(offsets) - require.ErrorIs(t, err, packfile.ErrWriterClosed, "double Finish must return packfile.ErrWriterClosed") + err = w.Commit(offsets) + require.ErrorIs(t, err, packfile.ErrWriterClosed, "double Commit must return packfile.ErrWriterClosed") } -func TestWriter_FailedFinishCleansUpViaClose(t *testing.T) { - // Regression: if Finish errors, a subsequent Close (typical defer +func TestWriter_FailedCommitCleansUpViaClose(t *testing.T) { + // Regression: if Commit errors, a subsequent Close (typical defer // pattern) must still tear down the underlying packfile.Writer // and remove the partial events.pack file. A previous version of - // Finish set w.closed=true before invoking pw.Finish, which made + // Commit set w.closed=true before invoking pw.Finish, which made // the deferred Close a no-op and leaked workers + the partial file. const chunkID = chunk.ID(0) dir := t.TempDir() @@ -170,18 +170,18 @@ func TestWriter_FailedFinishCleansUpViaClose(t *testing.T) { require.NoError(t, err) // Force an encode error by passing nil offsets. - require.Error(t, w.Finish(nil)) + require.Error(t, w.Commit(nil)) // Deferred-style cleanup. After Close, the partial events.pack // must be gone (packfile.Writer.Close removes the file unless - // Finish succeeded). + // Commit succeeded). require.NoError(t, w.Close()) _, statErr := os.Stat(path) assert.True(t, os.IsNotExist(statErr), - "events.pack should be removed after a failed Finish + Close, got stat err = %v", statErr) + "events.pack should be removed after a failed Commit + Close, got stat err = %v", statErr) } -func TestWriter_CloseWithoutFinishAborts(t *testing.T) { +func TestWriter_CloseWithoutCommitAborts(t *testing.T) { const chunkID = chunk.ID(0) dir := t.TempDir() w, err := NewColdWriter(chunkID, dir, ColdWriterOptions{}) @@ -214,7 +214,7 @@ func TestWriter_PreservesEventIDOrder(t *testing.T) { } offsets := NewLedgerOffsets(chunkID.FirstLedger()) require.NoError(t, offsets.Append(chunkID.FirstLedger(), uint32(n))) - require.NoError(t, w.Finish(offsets)) + require.NoError(t, w.Commit(offsets)) reader := packfile.Open( filepath.Join(dir, EventsPackName(chunkID)), @@ -264,7 +264,7 @@ func TestEventsPack_TrailerPinsFormatAndRecordSize(t *testing.T) { require.NoError(t, w.Append(makeColdPayload(chunkID.FirstLedger(), 1, "x"))) offsets := NewLedgerOffsets(chunkID.FirstLedger()) require.NoError(t, offsets.Append(chunkID.FirstLedger(), 1)) - require.NoError(t, w.Finish(offsets)) + require.NoError(t, w.Commit(offsets)) reader := packfile.Open( filepath.Join(dir, EventsPackName(chunkID)), diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps.go index ad2d2cefd..01ded218d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps.go @@ -58,8 +58,14 @@ type ConcurrentBitmaps struct { terms map[TermKey]*atomic.Pointer[termState] } -// NewConcurrentBitmapsFromBitmaps takes ownership of a Bitmaps built -// by warmup or backfill. The input must not be used afterwards. +// NewConcurrentBitmapsFromBitmaps takes ownership of b. The input must not +// be used afterwards. +// +// It is the only constructor, and production's only caller hands it an EMPTY +// Bitmaps: the hot index's dense overlay starts empty and self-fills via +// promotion, and a warmed-up chunk rebuilds the overlay by replaying its rows +// and sealed runs, never by handing a built Bitmaps over. The conversion path +// below is what the tests that pin the ownership contract drive. // // Terms below promotionThreshold become sparse lists, the same // representation AddTo gives them. Terms at or above it keep their @@ -82,11 +88,17 @@ func NewConcurrentBitmapsFromBitmaps(b Bitmaps) *ConcurrentBitmaps { return cb } -// Get returns the bitmap for key, or (nil, nil) when key is not -// indexed. The result is read-only: dense terms share one bitmap -// across all concurrent readers, and the index never mutates it. +// Get returns the postings for key, or the zero Postings when key is not +// indexed. The result is read-only: a dense term hands back the snapshot +// every concurrent reader of that term shares, and a sparse term hands back +// the store's own published id slice. The index mutates neither. +// +// A sparse entry is returned as its id slice, un-materialized, since that is +// the form it is stored in and the form Intersect can drive from. AddTo +// never writes into a published slice — it builds a fresh one per publish — +// so holding it is safe for as long as the caller wants it. // -// Forbidden on the returned bitmap — these mutate internal state a +// Forbidden on a returned bitmap — these mutate internal state a // concurrent reader or the writer may also be touching: // - Clone, CloneCopyOnWriteContainers (COW is on: Clone writes its // source's copy-on-write flags, so two readers cloning race) @@ -96,26 +108,36 @@ func NewConcurrentBitmapsFromBitmaps(b Bitmaps) *ConcurrentBitmaps { // - single-input roaring.FastAnd / roaring.FastOr (roaring takes a // Clone-the-input shortcut when there is only one input) // +// The same rule applies to a returned id slice: read it, never write to it. +// // Safe: any non-mutating read (Contains, GetCardinality, Iterator, // ToArray, IsEmpty, Minimum, Maximum) plus roaring.And / FastAnd / -// FastOr with 2+ inputs. +// FastOr with 2+ inputs (Intersect and Union guard their single-input +// cases before calling the aggregators). // // A Get that starts after an AddTo returns sees that AddTo's IDs, and -// the pointer stays valid for as long as the caller holds it. -func (s *ConcurrentBitmaps) Get(key TermKey) (*roaring.Bitmap, error) { +// what it returns stays valid for as long as the caller holds it. +func (s *ConcurrentBitmaps) Get(key TermKey) (Postings, error) { s.rwmu.RLock() p := s.terms[key] s.rwmu.RUnlock() if p == nil { - return nil, nil //nolint:nilnil // not-found is signaled by nil bitmap, no error + return Postings{}, nil } st := p.Load() if st.dense != nil { - return st.dense.snapshot(), nil + return BitmapPostings(st.dense.snapshot()), nil } - bm := roaring.New() - bm.AddMany(st.ids) - return bm, nil + return IDPostings(st.ids), nil +} + +// Has reports whether key is tracked, without materializing anything — the +// hot index's per-ledger dense-overlay membership probe. +func (s *ConcurrentBitmaps) Has(key TermKey) bool { + s.rwmu.RLock() + _, ok := s.terms[key] + s.rwmu.RUnlock() + return ok } // snapshot returns the term's current immutable bitmap. If a write diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_test.go index 2234c5d33..eb2572880 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_test.go @@ -25,8 +25,8 @@ func TestConcurrentBitmaps_AddToAndGet(t *testing.T) { bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.Equal(t, uint64(3), bm.GetCardinality()) + require.True(t, bm.Present()) + assert.Equal(t, uint64(3), bm.Cardinality()) assert.True(t, bm.Contains(0)) assert.True(t, bm.Contains(1)) assert.True(t, bm.Contains(2)) @@ -37,7 +37,7 @@ func TestConcurrentBitmaps_GetMissing(t *testing.T) { key := ComputeTermKey([]byte("missing"), FieldTopic0) bm, err := s.Get(key) require.NoError(t, err) - assert.Nil(t, bm) + assert.False(t, bm.Present()) } func TestConcurrentBitmaps_ListMode(t *testing.T) { @@ -55,10 +55,18 @@ func TestConcurrentBitmaps_ListMode(t *testing.T) { assert.Nil(t, st.dense, "must still be in list mode") assert.Len(t, st.ids, promotionThreshold-1) - bm, err := s.Get(key) + post, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.Equal(t, uint64(promotionThreshold-1), bm.GetCardinality()) + require.True(t, post.Present()) + assert.Equal(t, uint64(promotionThreshold-1), post.Cardinality()) + + // A sparse term is handed back UN-MATERIALIZED and ZERO-COPY: the ids + // slice is the store's own published one, not a bitmap built for the + // caller. Intersect drives straight off it, which is the whole point of + // keeping small terms as delta postings. + require.NotNil(t, post.IDs(), "a sparse term's postings must stay ID-backed") + assert.Same(t, &st.ids[0], &post.IDs()[0], + "a sparse Get must alias the published ids, not copy them") // Get must not have promoted. assert.Nil(t, p.Load().dense) @@ -77,9 +85,10 @@ func TestConcurrentBitmaps_Promotion(t *testing.T) { st := p.Load() require.NotNil(t, st.dense) assert.Empty(t, st.ids, "sparse ids cleared after promotion") - bm, err := s.Get(key) + post, err := s.Get(key) require.NoError(t, err) - assert.Equal(t, uint64(promotionThreshold), bm.GetCardinality()) + assert.Equal(t, uint64(promotionThreshold), post.Cardinality()) + assert.Nil(t, post.IDs(), "a dense term's postings are bitmap-backed, never ID-backed") } func TestConcurrentBitmaps_AddAfterPromotion(t *testing.T) { @@ -94,7 +103,7 @@ func TestConcurrentBitmaps_AddAfterPromotion(t *testing.T) { bm, err := s.Get(key) require.NoError(t, err) - assert.Equal(t, uint64(promotionThreshold+2), bm.GetCardinality()) + assert.Equal(t, uint64(promotionThreshold+2), bm.Cardinality()) assert.True(t, bm.Contains(1000)) assert.True(t, bm.Contains(2000)) } @@ -107,8 +116,8 @@ func TestConcurrentBitmaps_BatchAddTo(t *testing.T) { bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.Equal(t, uint64(5), bm.GetCardinality()) + require.True(t, bm.Present()) + assert.Equal(t, uint64(5), bm.Cardinality()) assert.True(t, bm.Contains(0)) assert.True(t, bm.Contains(4)) } @@ -130,7 +139,7 @@ func TestConcurrentBitmaps_BatchAddToPromotion(t *testing.T) { require.NotNil(t, st.dense, "single-batch over threshold must promote immediately") bm, err := s.Get(key) require.NoError(t, err) - assert.Equal(t, uint64(promotionThreshold+10), bm.GetCardinality()) + assert.Equal(t, uint64(promotionThreshold+10), bm.Cardinality()) } // TestConcurrentBitmaps_GetReturnsImmutableSnapshot pins the @@ -150,13 +159,13 @@ func TestConcurrentBitmaps_GetReturnsImmutableSnapshot(t *testing.T) { before, err := s.Get(key) require.NoError(t, err) - beforeCard := before.GetCardinality() + beforeCard := before.Cardinality() // New AddTo publishes a new snapshot via atomic.Store. s.AddTo(key, 9_999_999) // before still observes the pre-AddTo cardinality. - assert.Equal(t, beforeCard, before.GetCardinality(), + assert.Equal(t, beforeCard, before.Cardinality(), "AddTo published a new snapshot; the borrowed pointer must remain unchanged") after, err := s.Get(key) @@ -195,7 +204,7 @@ func TestConcurrentBitmaps_ConcurrentGetIsSafe(t *testing.T) { for _, k := range keys { bm, err := s.Get(k) require.NoError(t, err) - require.NotNil(t, bm) + require.True(t, bm.Present()) _ = bm.Contains(0) } } @@ -207,7 +216,7 @@ func TestConcurrentBitmaps_ConcurrentGetIsSafe(t *testing.T) { for _, k := range keys { bm, err := s.Get(k) require.NoError(t, err) - require.NotNil(t, bm) + require.True(t, bm.Present()) } } @@ -249,8 +258,8 @@ func TestConcurrentBitmaps_ConcurrentReadWrite(t *testing.T) { for _, key := range keys { bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.Equal(t, uint64(numEvents/numTerms), bm.GetCardinality()) + require.True(t, bm.Present()) + assert.Equal(t, uint64(numEvents/numTerms), bm.Cardinality()) } } @@ -312,7 +321,7 @@ func TestConcurrentBitmaps_GetDuringPromotionNeverReturnsNil(t *testing.T) { // The term was seeded with promotionThreshold-1 // ids and the writer only appends — Get must // always observe a non-nil bitmap. - require.NotNil(t, bm, "Get returned nil during promotion window") + require.True(t, bm.Present(), "Get returned nil during promotion window") } } }) @@ -346,8 +355,8 @@ func TestConcurrentBitmaps_AddToIsIdempotent(t *testing.T) { bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.Equal(t, uint64(4), bm.GetCardinality()) + require.True(t, bm.Present()) + assert.Equal(t, uint64(4), bm.Cardinality()) for _, id := range []uint32{0, 1, 2, 3} { assert.True(t, bm.Contains(id)) } @@ -371,7 +380,7 @@ func TestConcurrentBitmaps_AddToIsIdempotent(t *testing.T) { bm, err := s.Get(key) require.NoError(t, err) - assert.Equal(t, uint64(promotionThreshold), bm.GetCardinality()) + assert.Equal(t, uint64(promotionThreshold), bm.Cardinality()) }) } @@ -388,8 +397,8 @@ func assertDenseCOW(t *testing.T, s *ConcurrentBitmaps, key TermKey, msg string) assert.True(t, d.wbm.GetCopyOnWrite(), msg+" (writer bitmap)") bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.True(t, bm.GetCopyOnWrite(), msg+" (published snapshot)") + require.True(t, bm.Present()) + assert.True(t, bm.Bitmap().GetCopyOnWrite(), msg+" (published snapshot)") } // TestConcurrentBitmaps_DenseAddToSetsCopyOnWrite pins that the @@ -461,26 +470,55 @@ func TestNewConcurrentBitmapsFromBitmaps_DirectlyPinsContract(t *testing.T) { bmSparse, err := cb.Get(keySparse) require.NoError(t, err) - require.NotNil(t, bmSparse) - assert.Equal(t, uint64(5), bmSparse.GetCardinality()) + require.True(t, bmSparse.Present()) + assert.Equal(t, uint64(5), bmSparse.Cardinality()) assert.Nil(t, cb.terms[keySparse].Load().dense, "a sub-threshold warmup term must be sparse") bmDense, err := cb.Get(keyDense) require.NoError(t, err) - require.NotNil(t, bmDense) - assert.Equal(t, uint64(len(dense)), bmDense.GetCardinality()) - assert.True(t, bmDense.GetCopyOnWrite()) + require.True(t, bmDense.Present()) + assert.Equal(t, uint64(len(dense)), bmDense.Cardinality()) + assert.True(t, bmDense.Bitmap().GetCopyOnWrite()) assert.NotNil(t, cb.terms[keyDense].Load().dense, "a warmup term at the threshold must be dense") bmNil, err := cb.Get(keyNil) require.NoError(t, err) - assert.Nil(t, bmNil, "nil source entries must be skipped, not panicked") + assert.False(t, bmNil.Present(), "nil source entries must be skipped, not panicked") cb.AddTo(keyDense, 999_999) post, err := cb.Get(keyDense) require.NoError(t, err) - require.NotNil(t, post) - assert.True(t, post.GetCopyOnWrite()) - assert.Equal(t, uint64(len(dense)+1), post.GetCardinality()) - assert.Equal(t, uint64(len(dense)), bmDense.GetCardinality(), "earlier snapshot stays immutable") + require.True(t, post.Present()) + assert.True(t, post.Bitmap().GetCopyOnWrite()) + assert.Equal(t, uint64(len(dense)+1), post.Cardinality()) + assert.Equal(t, uint64(len(dense)), bmDense.Cardinality(), "earlier snapshot stays immutable") +} + +// TestConcurrentBitmaps_ContentEquivalenceAcrossTransitions drives one term +// through every representation transition — sparse, promotion, and a long +// run of dense appends — and checks Get's exact contents against a reference +// set at each step. The batch widths straddle promotionThreshold and the id +// stride leaves gaps, so a range-shaped off-by-one cannot hide behind a +// contiguous run, and the assertion is on the ids themselves rather than on +// a cardinality. +func TestConcurrentBitmaps_ContentEquivalenceAcrossTransitions(t *testing.T) { + cb := newTestConcurrentBitmaps() + key := ComputeTermKey([]byte("equivalence"), FieldTopic0) + var want []uint32 + + next := uint32(0) + for _, batch := range []int{1, 63, 100, 4000, 4200, 8192, 9000, 17} { + ids := make([]uint32, batch) + for i := range ids { + ids[i] = next + next += 2 // gaps, so ranges don't mask off-by-ones + } + cb.AddTo(key, ids...) + want = append(want, ids...) + + post, err := cb.Get(key) + require.NoError(t, err) + require.True(t, post.Present()) + assert.Equal(t, want, post.Bitmap().ToArray(), "after batch of %d", batch) + } } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_writer_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_writer_test.go index cfd493caa..e3a5abcb3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_writer_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps_writer_test.go @@ -27,10 +27,10 @@ func TestConcurrentBitmaps_SnapshotSurvivesMultiContainerWrites(t *testing.T) { held, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, held) - heldCard := held.GetCardinality() + require.True(t, held.Present()) + heldCard := held.Cardinality() require.Equal(t, uint64(promotionThreshold), heldCard) - heldCopy := held.ToArray() + heldCopy := held.Bitmap().ToArray() // 1_000 more ids spread over 16 fresh containers, written one // small batch per "ledger" so the writer touches an already-shared @@ -45,9 +45,9 @@ func TestConcurrentBitmaps_SnapshotSurvivesMultiContainerWrites(t *testing.T) { added = append(added, ids...) } - assert.Equal(t, heldCard, held.GetCardinality(), + assert.Equal(t, heldCard, held.Cardinality(), "a snapshot returned by Get must not be mutated by later AddTo calls") - assert.Equal(t, heldCopy, held.ToArray(), + assert.Equal(t, heldCopy, held.Bitmap().ToArray(), "a snapshot returned by Get must keep exactly its original ids") for _, id := range added { assert.False(t, held.Contains(id), @@ -56,8 +56,8 @@ func TestConcurrentBitmaps_SnapshotSurvivesMultiContainerWrites(t *testing.T) { fresh, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, fresh) - assert.Equal(t, heldCard+uint64(len(added)), fresh.GetCardinality(), + require.True(t, fresh.Present()) + assert.Equal(t, heldCard+uint64(len(added)), fresh.Cardinality(), "a Get after AddTo must observe every write") for _, id := range added { require.True(t, fresh.Contains(id), "fresh snapshot missing id %d", id) @@ -76,8 +76,8 @@ func TestConcurrentBitmaps_GetAfterAddToIsFresh(t *testing.T) { s.AddTo(key, i) bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - require.Equal(t, uint64(i+1), bm.GetCardinality(), + require.True(t, bm.Present()) + require.Equal(t, uint64(i+1), bm.Cardinality(), "Get after AddTo(%d) must see every id added so far", i) require.True(t, bm.Contains(i)) } @@ -100,6 +100,14 @@ func TestConcurrentBitmaps_WriterReaderRace(t *testing.T) { const perLedger = 8 const numReaders = 8 const minReads = 64 + // The writer's ids stride across containers instead of packing into one: + // each term ends up spanning numContainers roaring containers, so a + // publish has to copy a multi-container bitmap while the readers are + // inside it. ledgersPerContainer keeps the ids ASCENDING (AddTo's sparse + // mode drops an id that does not exceed the last one), which a + // round-robin over containers would not. + const numContainers = 16 + const ledgersPerContainer = numLedgers / numContainers keys := make([]TermKey, numTerms) for i := range keys { @@ -114,13 +122,15 @@ func TestConcurrentBitmaps_WriterReaderRace(t *testing.T) { wg.Go(func() { defer done.Store(true) ready.Wait() - var next uint32 - for range numLedgers { + for ledger := range numLedgers { + // Container index advances every ledgersPerContainer ledgers; + // within one container the ids climb by perLedger per ledger. + base := uint32(ledger/ledgersPerContainer)*65_536 + + uint32(ledger%ledgersPerContainer)*perLedger for _, key := range keys { ids := make([]uint32, perLedger) for j := range ids { - ids[j] = next - next++ + ids[j] = base + uint32(j) } s.AddTo(key, ids...) } @@ -133,14 +143,14 @@ func TestConcurrentBitmaps_WriterReaderRace(t *testing.T) { for i, n := 0, 0; !done.Load() || n < minReads; i++ { key := keys[(i+r)%numTerms] bm, err := s.Get(key) - if err != nil || bm == nil { + if err != nil || !bm.Present() { continue } n++ reads.Add(1) - _ = bm.GetCardinality() + _ = bm.Cardinality() _ = bm.Contains(uint32(i)) - it := bm.Iterator() + it := bm.Bitmap().Iterator() for n := 0; n < 64 && it.HasNext(); n++ { _ = it.Next() } @@ -156,8 +166,8 @@ func TestConcurrentBitmaps_WriterReaderRace(t *testing.T) { for _, key := range keys { bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.Equal(t, want, bm.GetCardinality()) + require.True(t, bm.Present()) + assert.Equal(t, want, bm.Cardinality()) } } @@ -195,12 +205,12 @@ func TestConcurrentBitmaps_PromotionMidStreamUnderReaders(t *testing.T) { var last uint64 for n := 0; !done.Load() || n < minReads; { bm, err := s.Get(key) - if err != nil || bm == nil { + if err != nil || !bm.Present() { continue } n++ reads.Add(1) - card := bm.GetCardinality() + card := bm.Cardinality() assert.GreaterOrEqual(t, card, last, "a term's observed cardinality must never go backwards") assert.LessOrEqual(t, card, uint64(writes.Load())+1, @@ -216,8 +226,8 @@ func TestConcurrentBitmaps_PromotionMidStreamUnderReaders(t *testing.T) { bm, err := s.Get(key) require.NoError(t, err) - require.NotNil(t, bm) - assert.Equal(t, uint64(total), bm.GetCardinality()) + require.True(t, bm.Present()) + assert.Equal(t, uint64(total), bm.Cardinality()) } // TestConcurrentBitmaps_WarmupThenAddToThenGet covers the warmup @@ -242,24 +252,24 @@ func TestConcurrentBitmaps_WarmupThenAddToThenGet(t *testing.T) { // Read first, then write, then read again. first, err := cb.Get(keyRead) require.NoError(t, err) - require.NotNil(t, first) - require.Equal(t, uint64(len(seed)), first.GetCardinality()) + require.True(t, first.Present()) + require.Equal(t, uint64(len(seed)), first.Cardinality()) cb.AddTo(keyRead, 999_999) - assert.Equal(t, uint64(len(seed)), first.GetCardinality(), + assert.Equal(t, uint64(len(seed)), first.Cardinality(), "the warmup snapshot handed out earlier must stay immutable") second, err := cb.Get(keyRead) require.NoError(t, err) - assert.Equal(t, uint64(len(seed)+1), second.GetCardinality()) + assert.Equal(t, uint64(len(seed)+1), second.Cardinality()) assert.True(t, second.Contains(999_999)) // Write before the first read ever happens. cb.AddTo(keyWrite, 888_888) only, err := cb.Get(keyWrite) require.NoError(t, err) - require.NotNil(t, only) - assert.Equal(t, uint64(len(seed)+1), only.GetCardinality()) + require.True(t, only.Present()) + assert.Equal(t, uint64(len(seed)+1), only.Cardinality()) assert.True(t, only.Contains(888_888)) - assert.True(t, only.GetCopyOnWrite(), + assert.True(t, only.Bitmap().GetCopyOnWrite(), "warmup snapshots must keep CopyOnWrite so republishing stays shallow") } @@ -293,16 +303,16 @@ func TestConcurrentBitmaps_UnreadTermNeverClones(t *testing.T) { bm, err := s.Get(key) require.NoError(t, err) - assert.Equal(t, uint64(promotionThreshold+5_000), bm.GetCardinality()) + assert.Equal(t, uint64(promotionThreshold+5_000), bm.Cardinality()) assert.Same(t, entry, s.terms[key].Load(), "a dense term's termState is published once and never replaced") - assert.Same(t, bm, d.pub.Load(), + assert.Same(t, bm.Bitmap(), d.pub.Load(), "the first Get after writes must publish the snapshot it returns") // A second Get with no intervening write returns the same pointer. again, err := s.Get(key) require.NoError(t, err) - assert.Same(t, bm, again, + assert.Same(t, bm.Bitmap(), again.Bitmap(), "Get must return the same pointer when nothing changed") } @@ -326,7 +336,7 @@ func TestConcurrentBitmaps_DenseStateAfterAddToAndGet(t *testing.T) { first, err := s.Get(key) require.NoError(t, err) - assert.Same(t, first, d.pub.Load(), + assert.Same(t, first.Bitmap(), d.pub.Load(), "the publishing reader stores the snapshot it returns") s.AddTo(key, 12_345) @@ -336,7 +346,7 @@ func TestConcurrentBitmaps_DenseStateAfterAddToAndGet(t *testing.T) { second, err := s.Get(key) require.NoError(t, err) assert.True(t, second.Contains(12_345), "the next Get observes the write") - assert.Same(t, second, d.pub.Load()) + assert.Same(t, second.Bitmap(), d.pub.Load()) assert.False(t, first.Contains(12_345), "the earlier snapshot is still frozen") } @@ -434,7 +444,7 @@ func TestConcurrentBitmaps_FreshnessUnderConcurrentPublishers(t *testing.T) { continue } bm, err := s.Get(key) - if err != nil || bm == nil { + if err != nil || !bm.Present() { continue } reads.Add(1) @@ -446,7 +456,7 @@ func TestConcurrentBitmaps_FreshnessUnderConcurrentPublishers(t *testing.T) { if !bm.Contains(want) { t.Errorf("Get returned a snapshot missing id %d, "+ "committed before this Get started (cardinality %d)", - want, bm.GetCardinality()) + want, bm.Cardinality()) return } storeMax(&observed, want) @@ -483,7 +493,7 @@ func TestConcurrentBitmaps_WarmupSubThresholdTermStaysSparse(t *testing.T) { bm, err := cb.Get(key) require.NoError(t, err) - assert.Equal(t, uint64(len(seed)), bm.GetCardinality()) + assert.Equal(t, uint64(len(seed)), bm.Cardinality()) next := seed[len(seed)-1] + 1 cb.AddTo(key, next) @@ -493,6 +503,6 @@ func TestConcurrentBitmaps_WarmupSubThresholdTermStaysSparse(t *testing.T) { require.NotNil(t, cb.terms[key].Load().dense, "reaching the threshold promotes") bm, err = cb.Get(key) require.NoError(t, err) - assert.Equal(t, uint64(len(seed)+2), bm.GetCardinality()) + assert.Equal(t, uint64(len(seed)+2), bm.Cardinality()) assert.True(t, bm.Contains(next+1)) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go index ec2e3730c..ab562facc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go @@ -7,13 +7,15 @@ import ( "fmt" "iter" "math" + "path/filepath" + "strings" - "github.com/RoaringBitmap/roaring/v2" "github.com/linxGnu/grocksdb" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" ) // Column-family names used inside one chunk's hot RocksDB DB. The @@ -31,12 +33,17 @@ const ( // typically 2-3× on XDR) and read in batches via // BatchedMultiGetCF. Larger blocks give zstd more context per // compression unit and align with batch-fetch shapes. -// - IndexCF stores 20-byte (term_hash || event_id) keys with -// empty values — nothing in the values to compress, and small -// blocks reduce wasted I/O per random Lookup miss (each Lookup -// reads one block to find one key). +// - IndexCF stores ONE packed row per ledger (4-byte ledger_seq key, +// value = sorted term_hash ‖ uvarint event-ID runs — see +// AppendPackedRow). One skiplist insert per ledger instead of +// one per (term, event) pair — the per-(term,event) 20-byte-key +// format it replaces put tens of thousands of keys per ledger into +// the shared commit batch's memtable insert, which dominated hot +// commit latency. Only warmup reads this CF (queries hit the +// in-memory mirror), so block size just needs to suit a sequential +// scan of ~100-300KB values. // - OffsetsCF stores 8-byte (ledger_seq -> event_count) rows in -// the tens-of-thousands per chunk — same shape as IndexCF. +// the tens-of-thousands per chunk. const ( dataCFBlockSize = 32 * 1024 indexCFBlockSize = 4 * 1024 @@ -63,10 +70,13 @@ func CFNames() []string { return []string{DataCF, IndexCF, OffsetsCF} } func CFOptions() map[string]rocksdb.CFOptions { return hotStoreCFOptions() } const ( - dataKeyLen = 4 // event_id (chunk encoded by per-Chunk DB directory) - indexKeyLen = 16 + 4 // term hash || event_id - offsetKeyLen = 4 // ledger_seq - offsetValLen = 4 // per-ledger event count (uint32 BE) + dataKeyLen = 4 // event_id (chunk encoded by per-Chunk DB directory) + // packedIndexKeyLen is the per-ledger packed index key: the ledger + // sequence. (The pre-release legacy per-(term,event) 20-byte format is + // gone — no deployed chunks exist.) + packedIndexKeyLen = 4 + offsetKeyLen = 4 // ledger_seq + offsetValLen = 4 // per-ledger event count (uint32 BE) ) // ErrLedgerOutOfRange is returned by IngestLedgerToBatch when the @@ -101,8 +111,14 @@ var ErrLedgerOutOfOrder = errors.New("events: ledger out of order") type HotStore struct { chunkStore *rocksdb.Store chunkID chunk.ID - mirror *ConcurrentBitmaps + hotIdx *HotIndex offsets *ConcurrentLedgerOffsets + + // scratch holds the writer-owned flat-pairs arenas reused across ledgers + // (single-writer ingest contract). The post-commit hook borrows views over + // it, so the hook for ledger N must run before IngestLedgerToBatch for + // N+1 — the hotchunk driver's ingest → commit → hook sequence. + scratch ledgerScratch } // Compile-time guard: *HotStore satisfies Reader. @@ -114,15 +130,22 @@ var _ Reader = (*HotStore)(nil) // hotchunk.DB composes this facade over the shared per-chunk DB and closes that // DB once. The store must have CFNames() registered + CFOptions() applied. // A warmup failure returns the error WITHOUT closing the caller-owned store. -func NewWithStore(store *rocksdb.Store, chunkID chunk.ID) (*HotStore, error) { - mirror, offsets, err := warmup(store, chunkID) +// +// secret is the chunk's routing secret — the SAME value the cold index is +// keyed with (event.ColdIndexSecret), because the runs this store seals are +// merged into that index verbatim. The first open persists it in the DB; +// every later open must present the same one (adoptSecret). +func NewWithStore( + store *rocksdb.Store, chunkID chunk.ID, secret [stores.SecretLen]byte, +) (*HotStore, error) { + hotIdx, offsets, err := warmup(store, chunkID, secret) if err != nil { return nil, fmt.Errorf("events: warmup chunk %s: %w", chunkID, err) } return &HotStore{ chunkStore: store, chunkID: chunkID, - mirror: mirror, + hotIdx: hotIdx, offsets: offsets, }, nil } @@ -144,16 +167,16 @@ func (h *HotStore) EventCount() (uint32, error) { return h.offsets.TotalEvents(), nil } -// Offsets returns a point-in-time view of the ledger-offset cache, -// the query side's source for translating ledger bounds into -// event-id windows (see Reader.Offsets). +// Offsets returns a point-in-time view of the ledger-offset cache. +// The coordinator uses this to stitch a multi-ledger query range +// into chunk-relative event-id ranges (see Reader.Offsets). // // Implementation: returns a *LedgerOffsets sharing the live // backing array, capped at the count visible at call time -// (~24-byte allocation per Matches call). A concurrent IngestLedgerToBatch +// (~24-byte allocation per Query). A concurrent IngestLedgerToBatch // may extend the backing past the cap, but the returned view's // slice stays bounded to what was visible when Offsets returned. -// Callers (Matches) take the view once at entry and pass it through +// Callers (Query) take the view once at entry and pass it through // their helpers. // // Read-only: the returned view's underlying slice shares memory @@ -168,16 +191,18 @@ func (h *HotStore) Offsets() (*LedgerOffsets, error) { return h.offsets.View(), nil } -// LookupKeys returns bitmaps for each key, aligned positionally with -// the input slice. result[i] is nil if keys[i] has no matching -// events. See Reader.LookupKeys for the semantics — in particular -// the borrowed-bitmap contract (callers must not mutate). +// LookupKeys returns each key's postings, aligned positionally with the input +// slice. result[i] is the zero Postings if keys[i] has no matching +// events. See Reader.LookupKeys for the semantics — in particular the +// borrowed-result contract (callers must not mutate). // -// Hot-side implementation is N in-memory mirror lookups — no I/O -// to batch — but exposing this method satisfies the Reader +// Hot-side implementation is N hot-index lookups: dense terms answer +// from the in-memory overlay; sparse terms materialize from the +// retained window rows plus bounded preads of the sealed run files — +// cheap, but NOT I/O-free. Exposing this method satisfies the Reader // interface so callers can program against batched lookups // uniformly. -func (h *HotStore) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring.Bitmap, error) { +func (h *HotStore) LookupKeys(ctx context.Context, keys []TermKey) ([]Postings, error) { if h.chunkStore.IsClosed() { return nil, stores.ErrStoreClosed } @@ -187,13 +212,13 @@ func (h *HotStore) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring.B if len(keys) == 0 { return nil, nil } - results := make([]*roaring.Bitmap, len(keys)) + results := make([]Postings, len(keys)) for i, key := range keys { - bm, err := h.mirror.Get(key) + p, err := h.hotIdx.Get(key) if err != nil { return nil, fmt.Errorf("events: LookupKeys for chunk %s: %w", h.chunkID, err) } - results[i] = bm // nil for misses — Get already returns nil bitmap for not-found + results[i] = p } return results, nil } @@ -212,10 +237,10 @@ func (h *HotStore) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring.B // honored at the top of the call; the underlying CGO call is not // cancellable mid-flight. // -// A missing row is an error: every caller passes ids that name -// stored events (see Reader.FetchEvents), implying RocksDB has -// them. A miss indicates corruption or a writer/reader mismatch, -// not a normal not-found case. +// A missing row is an error: eventIDs only reach this path through +// LookupKeys, which only returns IDs the mirror knows about — +// implying RocksDB also has them. A miss indicates corruption or a +// writer/reader mismatch, not a normal not-found case. // // After the caller-owned store is closed, returns stores.ErrStoreClosed. func (h *HotStore) FetchEvents(ctx context.Context, eventIDs []uint32) ([]Payload, error) { @@ -340,12 +365,12 @@ func (h *HotStore) FetchRange(ctx context.Context, start, count uint32) iter.Seq } // All streams every event in this Chunk in chunk-relative eventID -// order — the Reader full-scan. The freeze re-derives cold event -// artifacts from raw LCMs and never calls this, so it has no -// production caller yet: it's the intended read seam for the v2 -// cutover (#772), exercised by tests until then. Thin wrapper over -// FetchRange; its yielded Payloads are likewise borrowed (valid only -// for the step). +// order — the Reader full-scan. The freeze builds cold event +// artifacts directly from the hot DB's durable state (cold_freeze.go) +// and never calls this, so it has no production caller yet: it's the +// intended read seam for the v2 cutover (#772), exercised by tests +// until then. Thin wrapper over FetchRange; its yielded Payloads are +// likewise borrowed (valid only for the step). // // The committed event count is read inside the returned closure body, so a // concurrent ingest between r.All(ctx) returning the Seq2 and the @@ -366,14 +391,16 @@ func (h *HotStore) All(ctx context.Context) iter.Seq2[Payload, error] { // IngestLedgerToBatch validates one ledger's events, marshals them, and queues // their CF Puts into the SHARED batch b, returning the post-commit apply hook the -// caller runs AFTER b commits (decision (a)). Validation + term derivation happen -// before any Put; on any error Store.Batch discards the whole WriteBatch, so a -// rejected ledger never leaves committed rows behind. +// caller runs AFTER b commits (decision (a)). Sequence validation happens before +// any Put; marshal and term derivation are fused per event, and on any error +// Store.Batch discards the whole WriteBatch, so a rejected ledger never leaves +// committed rows behind. // // payloads is produced by PayloadsFromLedgerEvents, which emits each ledger's // events in ascending getEvents cursor order — write order here IS the cursor -// contract (event IDs are assigned by arrival position). Terms are derived via -// TermsForBytes on each payload's ContractEventBytes. +// contract (event IDs are assigned by arrival position). Terms are derived +// from each payload's ContractEventBytes into the writer-owned pair arenas +// and constant-key side lanes (appendEventTerms). // // Sequence validation, before any Put or mirror mutation: // @@ -391,9 +418,10 @@ func (h *HotStore) All(ctx context.Context) iter.Seq2[Payload, error] { // close + reopen. func (h *HotStore) IngestLedgerToBatch( b *rocksdb.BatchWriter, ledgerSeq uint32, payloads []Payload, -) (func(), error) { - // Validate BEFORE any Put. On error Store.Batch discards the whole WriteBatch, - // so a mid-loop failure never orphans rows — no separate staging buffer needed. +) (func() error, error) { + // Sequence validation BEFORE any Put. Per-event errors below are equally + // safe: on any error Store.Batch discards the whole WriteBatch, so a + // mid-loop failure never orphans rows — no separate staging buffer needed. if ledgerSeq < h.chunkID.FirstLedger() || ledgerSeq > h.chunkID.LastLedger() { return nil, fmt.Errorf("%w: ledger %d not in chunk %s [%d, %d]", ErrLedgerOutOfRange, ledgerSeq, h.chunkID, @@ -405,27 +433,23 @@ func (h *HotStore) IngestLedgerToBatch( ErrLedgerOutOfOrder, expected, ledgerSeq) } - // Derive term keys per payload up front (a TermsForBytes error rejects the - // ledger without any Put) and retain them for the post-commit mirror update. - termKeys := make([][]TermKey, len(payloads)) - for i := range payloads { - keys, err := TermsForBytes(payloads[i].ContractEventBytes) - if err != nil { - return nil, fmt.Errorf("derive terms for payload %d in ledger %d: %w", i, ledgerSeq, err) - } - termKeys[i] = keys - } - startID := h.offsets.TotalEvents() if uint64(startID)+uint64(len(payloads)) > math.MaxUint32 { return nil, fmt.Errorf("chunk %s would overflow uint32 event-id space at ledger %d", h.chunkID, ledgerSeq) } - // Marshal + queue each event directly into b. BatchWriter.Put copies - // synchronously, so ONE reused scratch buffer serves every event — the caller - // opens exactly one batch per ledger, so no row must outlive this call. + // Marshal + derive terms + queue each event in ONE pass over the payload + // bytes. BatchWriter.Put copies key and value synchronously, so one reused + // value scratch AND one reused 4-byte key scratch serve every event (the + // per-call encodeDataKey array escaped to the heap once per event — ~6k + // 4-byte objects/ledger). Term derivation appends flat (term, eventID) + // pairs into the writer-owned arenas — no per-ledger map, no per-event + // key slices. Event IDs are assigned strictly increasing here; the sort + // in buildRuns depends on that arrival order for per-term ID order. var scratch []byte + var dataKey [dataKeyLen]byte + h.scratch.reset() for i := range payloads { blob, err := payloads[i].MarshalInto(scratch[:0]) if err != nil { @@ -433,84 +457,116 @@ func (h *HotStore) IngestLedgerToBatch( } scratch = blob eventID := startID + uint32(i) - b.Put(DataCF, encodeDataKey(eventID), blob) - for _, key := range termKeys[i] { - b.Put(IndexCF, encodeIndexKey(key, eventID), nil) + binary.BigEndian.PutUint32(dataKey[:], eventID) + b.Put(DataCF, dataKey[:], blob) + if err := h.scratch.appendEventTerms(eventID, payloads[i].ContractEventBytes); err != nil { + return nil, fmt.Errorf("derive terms for payload %d in ledger %d: %w", i, ledgerSeq, err) } } + // ONE packed index row per ledger instead of a row per (term, event) — + // the memtable insert cost of the shared commit batch scales with key + // count, and this removes tens of thousands of keys per ledger from it. + // A ledger with no events writes no index row. The row bytes are RETAINED + // for the post-commit hot-index apply (the window keeps the slice; + // BatchWriter.Put copied it into the batch already), so rowLen's exact + // sizing is retained memory, not just churn. + runs := h.scratch.buildRuns() + var rowBytes []byte + if len(runs.terms) > 0 { + rowBytes = runs.appendRow(make([]byte, 0, runs.rowLen())) + b.Put(IndexCF, encodePackedIndexKey(ledgerSeq), rowBytes) + } //nolint:gosec // len bounded by the overflow guard above b.Put(OffsetsCF, encodeOffsetKey(ledgerSeq), encodeLedgerEventCount(uint32(len(payloads)))) - return func() { h.applyLedger(startID, termKeys) }, nil + //nolint:gosec // len bounded by the overflow guard above + eventCount := uint32(len(payloads)) + return func() error { + // runs borrows the writer-owned arenas — valid here because the + // driver runs the hook before the next IngestLedgerToBatch resets + // them, and ApplyLedger does not retain the view. + if rowBytes != nil { + if err := h.hotIdx.ApplyLedger(ledgerSeq, rowBytes, runs); err != nil { + return err + } + } + h.offsets.Append(eventCount) + return nil + }, nil } -// index returns the in-memory term mirror. Test-only write hook: no production -// path reads it. Kept unexported until #772 decides whether the v2 read path -// hooks into it. -func (h *HotStore) index() *ConcurrentBitmaps { return h.mirror } +// hotIndexRunDir is the run-file directory name inside the chunk DB dir — +// covered by the chunk lifecycle's directory wipe like every other chunk +// artifact. RocksDB ignores foreign subdirectories in its dir. +const hotIndexRunDir = "hotindex-runs" -// applyLedger updates the mirror + offsets for a ledger whose rows are durable. -// Infallible by construction (IngestLedgerToBatch validated seq under the -// single-writer contract); the only non-completion is a crash, after which warmup -// rebuilds. -// -// Ordering invariant: mirror BEFORE offsets. A concurrent Matches call that snapshots -// offsets then reads the mirror must see either the prior state or a consistent -// later one. Reversing it would let a reader see an offsets count including IDs -// the mirror hasn't published — FetchEvents would then miss them, silently. -func (h *HotStore) applyLedger(startID uint32, termKeys [][]TermKey) { - // Batch by key so each key takes one AddTo per ledger instead of one per - // event, saving per-term lock round-trips. Cap 64 ≈ a few × unique-terms - // per ledger; the map grows past that. - perKeyIDs := make(map[TermKey][]uint32, 64) - for i, keys := range termKeys { - eventID := startID + uint32(i) - for _, key := range keys { - perKeyIDs[key] = append(perKeyIDs[key], eventID) - } - } - for key, ids := range perKeyIDs { - h.mirror.AddTo(key, ids...) - } - //nolint:gosec // len bounded by IngestLedgerToBatch's overflow guard - h.offsets.Append(uint32(len(termKeys))) +// rocksdbManifest persists the hot index's live-run list in the chunk DB's +// default CF under one key. Every Put rides the store's pinned synced +// WriteOptions, so a manifest write is durable before reapSeal publishes. +type rocksdbManifest struct { + store *rocksdb.Store } -// ────────────────────────────────────────────────────────────────── -// Warmup — reconstructs the in-memory mirror + offsets from the -// per-Chunk DB's on-disk CFs. Called by NewWithStore. -// ────────────────────────────────────────────────────────────────── +var hotIndexManifestKey = []byte("hotindex:manifest") //nolint:gochecknoglobals // fixed key -// warmup rebuilds the in-memory mirrors for chunkID by prefix-scanning -// the chunk's two on-disk caches once each: -// -// - events_index → *ConcurrentBitmaps — every -// (TermKey, eventID) row replayed into a fresh in-memory -// bitmap mirror. -// - events_offsets → *ConcurrentLedgerOffsets — every -// (ledger_seq, per_ledger_count) row replayed into a fresh -// offset cache. -// -// chunkID seeds ConcurrentLedgerOffsets.StartLedger for empty -// chunks; on-disk rows carry the full ledger sequence themselves. -// Both mirrors are empty for fresh chunks. -func warmup( - chunkStore *rocksdb.Store, chunkID chunk.ID, -) (*ConcurrentBitmaps, *ConcurrentLedgerOffsets, error) { - mirror, indexUpperBound, err := warmupIndex(chunkStore) - if err != nil { - return nil, nil, err +func (m rocksdbManifest) PutRuns(names []string, lastSealed uint32) error { + val := make([]byte, 4, 4+len(names)*16) + binary.BigEndian.PutUint32(val, lastSealed) + val = append(val, strings.Join(names, ",")...) + return m.store.Put("", hotIndexManifestKey, val) +} + +func (m rocksdbManifest) GetRuns() ([]string, uint32, error) { + val, found, err := m.store.Get("", hotIndexManifestKey) + if err != nil || !found { + return nil, 0, err } - offsets, err := warmupOffsets(chunkStore, chunkID) - if err != nil { - return nil, nil, err + if len(val) < 4 { + return nil, 0, fmt.Errorf("events: hotindex manifest value too short (%d bytes)", len(val)) } - if err := verifyChunkConsistency(chunkStore, offsets.TotalEvents(), indexUpperBound); err != nil { - return nil, nil, err + lastSealed := binary.BigEndian.Uint32(val) + rest := string(val[4:]) + if rest == "" { + return nil, lastSealed, nil } - return mirror, offsets, nil + return strings.Split(rest, ","), lastSealed, nil +} + +// hotIndexSecretKey holds the chunk DB's adopted events routing secret in the +// default CF, beside the run manifest. One key per store: the txhash engine +// keeps its own under its own name. +var hotIndexSecretKey = []byte("hotindex:secret") //nolint:gochecknoglobals // fixed key + +// adoptSecret and requireStoreSecret thread this engine's key into the +// shared secret protocol (stores/internal/runset/secret.go), which carries +// the rule, the errors, and the reasoning both engines run on. There is no +// migration and no re-keying: a re-minted catalog secret means the chunk +// must be re-ingested (pre-release posture); the EVR3 magic is what makes a +// pre-blinding chunk's runs a loud failure rather than a silent re-key +// (runspill.runMagicPre). +func adoptSecret(store *rocksdb.Store, secret [stores.SecretLen]byte) error { + return runset.AdoptSecret(store, hotIndexSecretKey, "events", secret[:]) +} + +func requireStoreSecret(store *rocksdb.Store, secret [stores.SecretLen]byte) error { + return runset.RequireSecret(store, hotIndexSecretKey, secret[:]) +} + +// ManifestRuns reads the chunk store's hot-index run manifest — the run +// names and sealed frontier, the same read the freeze anchors its index +// inputs on (freezeIndexInputs) and warmup anchors its replay on. Exported +// for the composition identity gate (ingest's freeze_test.go), which asserts +// its fixture actually crosses the sealed frontier — a non-empty manifest — +// so the freeze-vs-walk comparison keeps covering the manifest-runs input +// shape and cannot silently regress to tail-only coverage. +func ManifestRuns(store *rocksdb.Store) ([]string, uint32, error) { + return rocksdbManifest{store: store}.GetRuns() } +// Shutdown drains the hot index's background seal. hotchunk.DB.Close calls it +// before closing the shared store so no seal goroutine outlives the DB. +func (h *HotStore) Shutdown() { h.hotIdx.Close() } + // verifyChunkConsistency cross-checks the three on-disk CFs after warmup, // turning a torn or tampered chunk into a loud open failure instead of a // silently inconsistent in-memory cache. The CFs are written in one @@ -559,62 +615,91 @@ func verifyChunkConsistency(chunkStore *rocksdb.Store, total uint32, indexUpperB return nil } -// warmupIndex scans the events_index CF and replays every -// (TermKey, eventID) row into a fresh ConcurrentBitmaps. -// Design doc §12 step 3. +// ────────────────────────────────────────────────────────────────── +// Warmup — reconstructs the in-memory mirror + offsets from the +// per-Chunk DB's on-disk CFs. Called by NewWithStore. +// ────────────────────────────────────────────────────────────────── + +// warmup rebuilds the in-memory mirrors for chunkID by prefix-scanning +// the chunk's two on-disk caches once each: // -// Implementation: build into a single-threaded Bitmaps via -// per-term batching (rocksdb's byte-sorted iteration delivers all -// rows for term K consecutively, so a small buffer flushes when the -// term changes), then convert to ConcurrentBitmaps at the end. This -// keeps warmup off the concurrent index's per-term locking and -// promotion path — one AddTo per term instead of one per index row, -// ~50M for a 10M-event chunk. +// - events_index → *ConcurrentBitmaps — every +// (TermKey, eventID) row replayed into a fresh in-memory +// bitmap mirror. +// - events_offsets → *ConcurrentLedgerOffsets — every +// (ledger_seq, per_ledger_count) row replayed into a fresh +// offset cache. // -// Also returns the exclusive upper bound of indexed event IDs (max + 1, -// or 0 if the index is empty; uint64 so max+1 can't wrap — see -// verifyChunkConsistency) for warmup's cross-check against the -// committed event count. -func warmupIndex(chunkStore *rocksdb.Store) (*ConcurrentBitmaps, uint64, error) { - builder := NewBitmaps() - var ( - hasPrev bool - prevTerm TermKey - buf []uint32 - indexUpperBound uint64 // max indexed event ID + 1; 0 if no rows - ) - flush := func() { - if !hasPrev || len(buf) == 0 { - return - } - builder.AddTo(prevTerm, buf...) - buf = buf[:0] +// chunkID seeds ConcurrentLedgerOffsets.StartLedger for empty +// chunks; on-disk rows carry the full ledger sequence themselves. +// Both mirrors are empty for fresh chunks. +func warmup( + chunkStore *rocksdb.Store, chunkID chunk.ID, secret [stores.SecretLen]byte, +) (*HotIndex, *ConcurrentLedgerOffsets, error) { + // Adoption first: a mismatch means every sealed run is keyed for someone + // else, so nothing below is worth doing. It is also the only durable + // write a warmup makes, and only on a chunk that has no secret yet — + // what makes "first open decides the key" true. + if err := adoptSecret(chunkStore, secret); err != nil { + return nil, nil, err } - - for entry, err := range chunkStore.Iterate(IndexCF, nil) { - if err != nil { - return nil, 0, fmt.Errorf("events: warmup scan %s: %w", IndexCF, err) + offsets, err := warmupOffsets(chunkStore, chunkID) + if err != nil { + return nil, nil, err + } + manifest := rocksdbManifest{store: chunkStore} + runDir := filepath.Join(chunkStore.Path(), hotIndexRunDir) + hotIdx, lastSealed, err := OpenHotIndex(runDir, manifest, secret) + if err != nil { + return nil, nil, err + } + // Replay the UN-SEALED tail: only packed rows past the sealed frontier + // are read — sealed rows are never touched (their derived runs are the + // CRC-verified authority, and they were validated when applied live), so + // warmup memory and time genuinely no longer scale with the chunk. + // indexUpperBound (the consistency tripwire's input) derives from the + // replayed tail; the tripwire is an upper bound, so a tail-only value + // can under-approximate but never false-alarm. + var indexUpperBound uint64 + // runs arenas are reused across rows: ApplyLedger borrows the view and + // never retains it — the same contract the ingest-path scratch relies on. + var runs termRuns + for entry, ierr := range chunkStore.IterateRange(IndexCF, rocksdb.EncodeUint32(lastSealed+1), nil) { + if ierr != nil { + hotIdx.Close() + return nil, nil, fmt.Errorf("events: warmup scan %s: %w", IndexCF, ierr) } - if len(entry.Key) != indexKeyLen { - return nil, 0, fmt.Errorf("events: warmup unexpected %s key length %d (want %d)", - IndexCF, len(entry.Key), indexKeyLen) + if len(entry.Key) != packedIndexKeyLen { + hotIdx.Close() + return nil, nil, fmt.Errorf("events: warmup unexpected %s key length %d (want %d)", + IndexCF, len(entry.Key), packedIndexKeyLen) } - var term TermKey - copy(term[:], entry.Key[0:16]) - eventID := binary.BigEndian.Uint32(entry.Key[16:20]) - if uint64(eventID)+1 > indexUpperBound { - indexUpperBound = uint64(eventID) + 1 + seq := binary.BigEndian.Uint32(entry.Key) + runs.reset() + if derr := DecodePackedRow(entry.Value, func(term TermKey, ids []uint32) { + runs.addRun(term, ids) + if last := uint64(ids[len(ids)-1]) + 1; last > indexUpperBound { + indexUpperBound = last + } + }); derr != nil { + hotIdx.Close() + return nil, nil, fmt.Errorf("events: warmup %s ledger %d: %w", IndexCF, seq, derr) } - if hasPrev && term != prevTerm { - flush() + if aerr := hotIdx.ApplyLedger(seq, append([]byte(nil), entry.Value...), runs); aerr != nil { + hotIdx.Close() + return nil, nil, aerr } - prevTerm = term - hasPrev = true - buf = append(buf, eventID) } - flush() - - return NewConcurrentBitmapsFromBitmaps(builder), indexUpperBound, nil + if err := verifyChunkConsistency(chunkStore, offsets.TotalEvents(), indexUpperBound); err != nil { + hotIdx.Close() + return nil, nil, err + } + // The open is validated — only now may the engine write durable index + // state (seal runs, manifest). The replay above ran disarmed, so a + // failed open leaves the manifest and frontier exactly as it found + // them, and every retry faces the same tripwire over the same rows. + hotIdx.ArmSealing() + return hotIdx, offsets, nil } // warmupOffsets scans events_offsets and replays every (ledger_seq, @@ -632,39 +717,54 @@ func warmupIndex(chunkStore *rocksdb.Store) (*ConcurrentBitmaps, uint64, error) func warmupOffsets(chunkStore *rocksdb.Store, chunkID chunk.ID) (*ConcurrentLedgerOffsets, error) { offsets := NewConcurrentLedgerOffsets(chunkID.FirstLedger()) - for entry, err := range chunkStore.Iterate(OffsetsCF, nil) { - if err != nil { - return nil, fmt.Errorf("events: warmup scan %s: %w", OffsetsCF, err) - } - if len(entry.Key) != offsetKeyLen { - return nil, fmt.Errorf("events: warmup unexpected %s key length %d (want %d)", - OffsetsCF, len(entry.Key), offsetKeyLen) - } - if len(entry.Value) != offsetValLen { - return nil, fmt.Errorf("events: warmup unexpected %s value length %d (want %d)", - OffsetsCF, len(entry.Value), offsetValLen) - } - ledger := binary.BigEndian.Uint32(entry.Key) - eventCount := binary.BigEndian.Uint32(entry.Value) + if err := scanOffsetsCF(chunkStore, func(ledger, eventCount uint32) error { // Each row must be the next sequential ledger and within the // chunk. The first test catches a gap, an out-of-order row, or a // wrong start; the second catches an excess row past the chunk // (which would otherwise append past capacity and panic). if expected := offsets.EndLedger(); ledger != expected || ledger > chunkID.LastLedger() { - return nil, fmt.Errorf("events: warmup offsets: chunk %s expected ledger %d, got %d", + return fmt.Errorf("events: warmup offsets: chunk %s expected ledger %d, got %d", chunkID, expected, ledger) } // On-disk counts are untrusted: guard the cumulative against uint32 // overflow, the same check the ingest path makes up front. if uint64(offsets.TotalEvents())+uint64(eventCount) > math.MaxUint32 { - return nil, fmt.Errorf("events: warmup offsets: chunk %s cumulative event count overflow at ledger %d", + return fmt.Errorf("events: warmup offsets: chunk %s cumulative event count overflow at ledger %d", chunkID, ledger) } offsets.Append(eventCount) + return nil + }); err != nil { + return nil, err } return offsets, nil } +// scanOffsetsCF iterates the OffsetsCF, validating each row's SHAPE (key and +// value widths, big-endian decode) and yielding (ledger, count) to emit. +// It is the one trust boundary for the row format; sequencing, range, and +// overflow policy belong to each caller's accumulator (warmup's concurrent +// offsets vs the freeze's LedgerOffsets differ deliberately). +func scanOffsetsCF(store *rocksdb.Store, emit func(ledger, count uint32) error) error { + for entry, err := range store.Iterate(OffsetsCF, nil) { + if err != nil { + return fmt.Errorf("events: scan %s: %w", OffsetsCF, err) + } + if len(entry.Key) != offsetKeyLen { + return fmt.Errorf("events: unexpected %s key length %d (want %d)", + OffsetsCF, len(entry.Key), offsetKeyLen) + } + if len(entry.Value) != offsetValLen { + return fmt.Errorf("events: unexpected %s value length %d (want %d)", + OffsetsCF, len(entry.Value), offsetValLen) + } + if err := emit(binary.BigEndian.Uint32(entry.Key), binary.BigEndian.Uint32(entry.Value)); err != nil { + return err + } + } + return nil +} + // ────────────────────────────────────────────────────────────────── // Key encoding helpers — RocksDB key layouts for the per-Chunk DB. // ────────────────────────────────────────────────────────────────── @@ -675,12 +775,10 @@ func encodeDataKey(eventID uint32) []byte { return key[:] } -func encodeIndexKey(term TermKey, eventID uint32) []byte { - var key [indexKeyLen]byte - copy(key[:16], term[:]) - binary.BigEndian.PutUint32(key[16:], eventID) - return key[:] -} +// encodePackedIndexKey delegates to rocksdb.EncodeUint32 — the same call +// warmup's tail seek uses on this CF, so writer and reader keys agree by +// construction rather than by inspection. +func encodePackedIndexKey(ledgerSeq uint32) []byte { return rocksdb.EncodeUint32(ledgerSeq) } func encodeOffsetKey(ledgerSeq uint32) []byte { var key [offsetKeyLen]byte diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store_test.go index 2a2a2180d..9ae03a589 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store_test.go @@ -65,7 +65,7 @@ func openHotStoreForTestAt(t *testing.T, dir string, chunkID chunk.ID) (*HotStor func tryOpenHotStoreForTest(t *testing.T, dir string, chunkID chunk.ID) (*HotStore, *rocksdb.Store, error) { t.Helper() raw := openRawHotChunkForTest(t, dir, chunkID) - hot, err := NewWithStore(raw, chunkID) + hot, err := NewWithStore(raw, chunkID, testIndexSecret) if err != nil { _ = raw.Close() return nil, nil, err @@ -160,11 +160,16 @@ func TestHotStore_IngestLedgerWritesAllCFs(t *testing.T) { require.NoError(t, decoded.Unmarshal(got)) assert.Equal(t, p.TxHash, decoded.TxHash) - // events_index row per term. + // events_index: ONE packed row for the ledger, carrying every term. + idxVal, found, err := h.store.chunkStore.Get(IndexCF, encodePackedIndexKey(2)) + require.NoError(t, err) + require.True(t, found, "missing packed index row for ledger 2") + decodedTerms := map[TermKey][]uint32{} + require.NoError(t, DecodePackedRow(idxVal, func(term TermKey, ids []uint32) { + decodedTerms[term] = append([]uint32(nil), ids...) + })) for _, key := range keys { - _, found, err := h.store.chunkStore.Get(IndexCF, encodeIndexKey(key, 0)) - require.NoError(t, err) - assert.True(t, found, "missing index row for term %x", key) + assert.Equal(t, []uint32{0}, decodedTerms[key], "missing/wrong ids for term %x", key) } // events_offsets: cumulative = 1. @@ -400,8 +405,8 @@ func TestHotStore_PostCloseReadsError(t *testing.T) { require.NoError(t, h.raw.Close()) // LookupKeys must error rather than silently returning cached bitmaps. - bms, err := h.store.LookupKeys(context.Background(), []TermKey{keys[0]}) - assert.Nil(t, bms) + post, err := h.store.LookupKeys(context.Background(), []TermKey{keys[0]}) + assert.Nil(t, post) require.ErrorIs(t, err, stores.ErrStoreClosed) // FetchEvents returns ErrStoreClosed. @@ -610,12 +615,18 @@ func firstIterError(seq iter.Seq2[Payload, error]) error { // hot/cold/query tests that assert on one term at a time. It requires // LookupKeys to succeed, so closed/corrupt-path tests must call // LookupKeys directly and assert on the error. +// +// A bitmap-backed result comes back as the store's own bitmap, so the tests +// pinning the mirror's immutable-snapshot contract still see it. func lookupOne(t *testing.T, r Reader, key TermKey) *roaring.Bitmap { t.Helper() - bms, err := r.LookupKeys(context.Background(), []TermKey{key}) + got, err := r.LookupKeys(context.Background(), []TermKey{key}) require.NoError(t, err) - require.Len(t, bms, 1) - return bms[0] + require.Len(t, got, 1) + if !got[0].Present() { + return nil + } + return got[0].Bitmap() } func TestHotStore_FetchRangeMidRange(t *testing.T) { @@ -717,7 +728,7 @@ func ingestLedgerEvents(h *HotStore, ledgerSeq uint32, payloads []Payload) error if h.chunkStore.IsClosed() { return stores.ErrStoreClosed } - var apply func() + var apply func() error if err := h.chunkStore.Batch(func(b *rocksdb.BatchWriter) error { a, aerr := h.IngestLedgerToBatch(b, ledgerSeq, payloads) apply = a @@ -726,7 +737,7 @@ func ingestLedgerEvents(h *HotStore, ledgerSeq uint32, payloads []Payload) error return err } if apply != nil { - apply() + return apply() } return nil } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_warmup_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_warmup_test.go index 64e58f836..4089da120 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_warmup_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_warmup_test.go @@ -1,8 +1,12 @@ package event import ( + "context" "fmt" "math" + "os" + "path/filepath" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -10,6 +14,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) // These tests exercise the (unexported) warmup() function indirectly @@ -22,10 +27,10 @@ func TestWarmup_FreshChunkProducesEmptyMirrorsViaNewWithStore(t *testing.T) { h := openHotStoreForTest(t, chunkID) // A fresh mirror is empty: probing any term is a clean miss - // (nil bitmap, no error). - bm, err := h.store.mirror.Get(ComputeTermKey([]byte("any"), FieldContractID)) + // (absent postings, no error). + post, err := h.store.hotIdx.Get(ComputeTermKey([]byte("any"), FieldContractID)) require.NoError(t, err) - assert.Nil(t, bm) + assert.False(t, post.Present()) assert.Zero(t, h.store.offsets.LedgerCount()) assert.Equal(t, uint32(0), h.store.offsets.TotalEvents()) assert.Equal(t, chunkID.FirstLedger(), h.store.offsets.StartLedger()) @@ -58,10 +63,10 @@ func TestWarmup_RebuildsMirrorFromIngestedRows(t *testing.T) { } expected := make(map[TermKey]uint64) for _, k := range seededKeys { - bm, err := hot1.mirror.Get(k) + post, err := hot1.hotIdx.Get(k) require.NoError(t, err) - require.NotNil(t, bm, "seeded term missing from the pre-close mirror") - expected[k] = bm.GetCardinality() + require.True(t, post.Present(), "seeded term missing from the pre-close mirror") + expected[k] = post.Cardinality() } require.NoError(t, raw1.Close()) @@ -70,10 +75,10 @@ func TestWarmup_RebuildsMirrorFromIngestedRows(t *testing.T) { got := make(map[TermKey]uint64) for k := range expected { - bm, err := hot2.mirror.Get(k) + post, err := hot2.hotIdx.Get(k) require.NoError(t, err) - require.NotNil(t, bm, "seeded term missing from the reopened mirror") - got[k] = bm.GetCardinality() + require.True(t, post.Present(), "seeded term missing from the reopened mirror") + got[k] = post.Cardinality() } assert.Equal(t, expected, got) } @@ -127,6 +132,125 @@ func TestWarmup_OffsetsReconstructedAcrossLedgers(t *testing.T) { assert.Equal(t, uint32(3), end) } +// postingIDs materializes a Postings' event ids in ascending order, whether +// it came back as an id list (sparse) or a bitmap (dense overlay). +func postingIDs(t *testing.T, p Postings) []uint32 { + t.Helper() + if ids := p.IDs(); ids != nil { + return append([]uint32(nil), ids...) + } + require.NotNil(t, p.Bitmap(), "a present Postings must carry ids or a bitmap") + return p.Bitmap().ToArray() +} + +// TestHotStore_LookupKeysSpansBlindedRunsAndRawWindow is the events half of +// the blind-at-seal read gate: sealed runs store BLINDED term keys while the +// window rows (and the dense overlay) stay RAW, so one LookupKeys with raw +// query terms must reassemble a term's postings across both tiers — the +// blinded probe for the runs, the raw one for the window, derived from the +// same query key. +// +// The fixture deliberately puts each shared term on BOTH sides of the sealed +// frontier: a term found only through the window (or only through the runs) +// would still look right if one of the two probes were mis-keyed. +func TestHotStore_LookupKeysSpansBlindedRunsAndRawWindow(t *testing.T) { + const chunkID = chunk.ID(0) + dir := t.TempDir() + hot, raw := openHotStoreForTestAt(t, dir, chunkID) + hot.hotIdx.sealEvery = 4 + hot.hotIdx.maxRuns = 2 // a merge fold too: merged runs stay blinded + + first := chunkID.FirstLedger() + shared, sharedKeys := makePayload("transfer") + term := sharedKeys[0] + // 14 event-bearing ledgers at sealEvery=4: three seals (12 rows, one of + // them a merge fold) plus a 2-row un-sealed window. The expectation is + // derived from the payloads themselves — every event whose own terms + // include the queried one, in event-id order. + var want []uint32 + var nextID uint32 + for i := range uint32(14) { + p := shared + p.LedgerSequence = first + i + solo, _ := makePayload(fmt.Sprintf("solo-%d", i)) + solo.LedgerSequence = first + i + for _, pl := range []Payload{p, solo} { + keys, kerr := TermsForBytes(pl.ContractEventBytes) + require.NoError(t, kerr) + if slices.Contains(keys, term) { + want = append(want, nextID) + } + nextID++ + } + require.NoError(t, ingestLedgerEvents(hot, first+i, []Payload{p, solo})) + require.NoError(t, hot.hotIdx.reapSeal(true)) + } + require.NotEmpty(t, want) + require.Len(t, hot.hotIdx.view.Load().rows, 2, "the last two ledgers must stay un-sealed") + require.NotEmpty(t, hot.hotIdx.view.Load().runs, "the fixture must have sealed runs") + + // The sealed runs really are keyed under the blinded identity: the raw + // term must NOT be findable in them, only its blinded form. + blinded := TermKey(stores.BlindKey(testIndexSecret, term[:])) + for _, r := range hot.hotIdx.view.Load().runs { + ids, err := r.lookup(nil, term, fp64(term)) + require.NoError(t, err) + require.Nil(t, ids, "a sealed run must not answer a RAW term") + ids, err = r.lookup(nil, blinded, fp64(blinded)) + require.NoError(t, err) + require.NotEmpty(t, ids, "a sealed run must answer the blinded term") + } + + // The facade query is by RAW term and must span both tiers. + got, err := hot.LookupKeys(context.Background(), []TermKey{term}) + require.NoError(t, err) + require.Len(t, got, 1) + require.True(t, got[0].Present()) + ids := postingIDs(t, got[0]) + require.Equal(t, want, ids, "postings must cover every ledger, sealed and un-sealed") + + // Same after a restart: warmup adopts the blinded runs as they are and + // replays the raw tail. + require.NoError(t, raw.Close()) + hot2, _ := openHotStoreForTestAt(t, dir, chunkID) + got, err = hot2.LookupKeys(context.Background(), []TermKey{term}) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, ids, postingIDs(t, got[0]), "postings changed across a restart") +} + +// TestWarmup_SecretAdoption is this engine's THREADING case for the shared +// secret protocol: NewWithStore hands the caller's secret to +// runset.AdoptSecret against the events key, so a reopen under the same +// secret still answers and a foreign one is a loud open failure. The +// protocol's own semantics (first-open-persists, zero refused, no durable +// state on rejection) are the table test beside it, runset/secret_test.go. +func TestWarmup_SecretAdoption(t *testing.T) { + const chunkID = chunk.ID(0) + dir := t.TempDir() + + hot, raw := openHotStoreForTestAt(t, dir, chunkID) + p, keys := makePayload("alpha") + require.NoError(t, ingestLedgerEvents(hot, chunkID.FirstLedger(), []Payload{p})) + require.NoError(t, raw.Close()) + + // Same secret: adopted, postings intact. + hot2, raw2 := openHotStoreForTestAt(t, dir, chunkID) + got, err := hot2.LookupKeys(context.Background(), []TermKey{keys[0]}) + require.NoError(t, err) + require.True(t, got[0].Present()) + require.NoError(t, raw2.Close()) + + // Foreign secret: loud open failure. + other := testIndexSecret + other[0] ^= 0xFF + raw3 := openRawHotChunkForTest(t, dir, chunkID) + _, err = NewWithStore(raw3, chunkID, other) + require.ErrorContains(t, err, "keyed under a different routing secret") + require.ErrorContains(t, err, "re-ingest the chunk") + require.NoError(t, raw3.Close()) +} + // corruptHotChunk reopens chunkID's raw per-chunk DB (bypassing warmup), // applies mutate, and closes it — used to inject on-disk inconsistencies // that warmup's verifyChunkConsistency must reject. The HotStore for @@ -184,6 +308,59 @@ func TestWarmup_RejectsOffsetsGap(t *testing.T) { require.ErrorContains(t, err, "expected ledger 3, got 4") } +// TestWarmup_FailedOpenLeavesDurableStateUntouched pins the seal-arming gate +// end-to-end: a tail longer than the seal window replays during warmup +// WITHOUT sealing, so a poisoned tail row fails the open with the consistency +// tripwire on EVERY attempt — no warmup seal may advance the durable frontier +// past the poison and blind a later open's tripwire. (The orphan sweep may +// still remove unreferenced run files — idempotent garbage collection; the +// manifest and CFs must be untouched.) +func TestWarmup_FailedOpenLeavesDurableStateUntouched(t *testing.T) { + const chunkID = chunk.ID(0) + dir := t.TempDir() + + // A full seal window and change, one event per ledger — enough rows that + // the reopen's replay crosses the seal trigger. + hot1, raw1 := openHotStoreForTestAt(t, dir, chunkID) + first := chunkID.FirstLedger() + const n = windowLedgers + 20 + for i := range uint32(n) { + p, _ := makePayload("x") + require.NoError(t, ingestLedgerEvents(hot1, first+i, []Payload{p})) + } + hot1.Shutdown() + require.NoError(t, raw1.Close()) + + // The crashed-with-long-unsealed-tail shape: erase the sealed frontier so + // the reopen replays the ENTIRE tail, and poison its last row with an + // event ID far beyond the committed total. + poisoned := AppendPackedRow(nil, map[TermKey][]uint32{{0xEE}: {1 << 20}}) + corruptHotChunk(t, dir, chunkID, func(raw *rocksdb.Store) { + require.NoError(t, raw.Delete("", hotIndexManifestKey)) + require.NoError(t, raw.Put(IndexCF, encodePackedIndexKey(first+n-1), poisoned)) + }) + + _, _, err := tryOpenHotStoreForTest(t, dir, chunkID) + require.ErrorContains(t, err, "index references event") + + // The failed open wrote nothing durable: no run files (the pre-existing + // ones were swept as unreferenced garbage, none created), manifest absent. + runDir := filepath.Join(dir, chunkID.String(), hotIndexRunDir) + if entries, rerr := os.ReadDir(runDir); rerr == nil { + require.Empty(t, entries, "failed open must not create run files") + } + corruptHotChunk(t, dir, chunkID, func(raw *rocksdb.Store) { + _, ok, gerr := raw.Get("", hotIndexManifestKey) + require.NoError(t, gerr) + require.False(t, ok, "failed open must not write the manifest") + }) + + // Same rows, same tripwire, same loud failure: the corruption gate + // cannot be blinded by a prior failed attempt. + _, _, err = tryOpenHotStoreForTest(t, dir, chunkID) + require.ErrorContains(t, err, "index references event") +} + func TestWarmup_RejectsOffsetsOverflow(t *testing.T) { const chunkID = chunk.ID(0) dir := t.TempDir() @@ -258,7 +435,8 @@ func TestWarmup_RejectsIndexBeyondCommitted(t *testing.T) { corruptHotChunk(t, dir, chunkID, func(raw *rocksdb.Store) { var term TermKey term[0] = 0x99 - require.NoError(t, raw.Put(IndexCF, encodeIndexKey(term, 2), nil)) + require.NoError(t, raw.Put(IndexCF, encodePackedIndexKey(4), + AppendPackedRow(nil, map[TermKey][]uint32{term: {2}}))) }) _, _, err := tryOpenHotStoreForTest(t, dir, chunkID) @@ -302,7 +480,8 @@ func TestWarmup_RejectsIndexRowAtMaxUint32(t *testing.T) { corruptHotChunk(t, dir, chunkID, func(raw *rocksdb.Store) { var term TermKey term[0] = 0x99 - require.NoError(t, raw.Put(IndexCF, encodeIndexKey(term, math.MaxUint32), nil)) + require.NoError(t, raw.Put(IndexCF, encodePackedIndexKey(4), + AppendPackedRow(nil, map[TermKey][]uint32{term: {math.MaxUint32}}))) }) _, _, err := tryOpenHotStoreForTest(t, dir, chunkID) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go new file mode 100644 index 000000000..0ee000e80 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -0,0 +1,397 @@ +package event + +// hotindex.go — the Sorted-Run Tier: the hot chunk's term index with a +// hard-bounded live set, replacing the O(unique terms) in-memory mirror for +// sparse terms (design: ~/bench-artifacts/mirror-memory-design.md). +// +// Structure (all published through ONE atomic view pointer): +// +// - WINDOW: the last ≤windowLedgers ledgers' packed index rows, RETAINED +// as-is (IngestLedgerToBatch already allocates each row for the commit +// batch; keeping the slice is free) in a ring, plus one pointerless +// "accel" pair of arrays per row (sorted fp64 + record offset) built by a +// single linear scan — rows are term-sorted, so the accel is sorted by +// construction. +// - RUNS: immutable flat files beside the chunk DB (runspill format: +// 16-byte keys, CRC-framed), sealed from the window every windowLedgers +// ledgers on a background goroutine, each with an in-RAM bloom + fence +// array (sidecar-derived; rebuildable by draining the run). A single-level +// merge caps live runs at maxLiveRuns. Run keys are BLINDED (blind at +// seal): the sealed run IS the cold index's input, so the freeze merges +// the manifest's runs in place instead of re-spilling the whole chunk +// under blinded keys. +// - DENSE OVERLAY: terms promoted by a per-ledger admission policy keep +// their roaring bitmaps in the existing ConcurrentBitmaps — popular-term +// queries stay memory-fast. +// Dense postings ALSO flow into rows/runs (set-union dedupes), so the +// overlay is rebuildable and runs are self-contained. +// +// Reads are exact: fp64 hits verify the full 16-byte key before IDs count. +// Rows and the overlay are keyed RAW (they are what warmup and the freeze +// replay), runs by the blinded key — so a lookup carries both forms of the +// queried term, deriving the blinded one once (lookupSparse). +// Ingest-side work per ledger is O(row bytes) with two pointerless +// allocations — no per-term map or bitmap mutation for sparse terms. +// +// Concurrency: single-writer (the ingest goroutine calls ApplyLedger; seals +// and merges run on ONE background goroutine owned here); readers Load the +// view and use immutable state lock-free. The publish-run-before-trim-window +// ordering guarantees a term is always findable in window ∪ runs. + +import ( + "bytes" + "cmp" + "fmt" + "os" + "slices" + "sort" + "sync" + "sync/atomic" + + "github.com/cespare/xxhash/v2" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/durable" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/bloom" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" +) + +const ( + // windowLedgers is the seal cadence: rows retained before a background + // seal folds them into a run. 256 ledgers ≈ 2.5min at 600ms cadence. + windowLedgers = 256 + + // maxLiveRuns triggers the single-level merge: more than this many live + // runs would push rare-term probes past the cold-query budget. + maxLiveRuns = 8 + + // densePromoteWindowCount promotes a term to the dense overlay when ONE + // ledger brings it at least this many event. Firehose terms (every + // event) promote on their first ledger; the long tail never does — + // exactness for it comes from window+runs. Deliberately conservative: + // a missed promotion costs read speed on that term, never correctness. + densePromoteWindowCount = 32 + + // fenceEvery is the record granularity of a run's fence array: one + // (term, offset) fence per fenceEvery records bounds a probe's decode + // walk. + fenceEvery = 64 + + // fenceSpanBytes byte-caps a fence window on top of fenceEvery's record + // cap: a window closes once it spans this much payload, and a single + // record this large or larger is fenced into a window of its own. Keeps + // a probe's pread under ~2x this for multi-record windows — dense + // terms' records in merged runs reach megabytes, and every term sorting + // beside one would otherwise pread (and varint-skip) it on each lookup. + fenceSpanBytes = 64 << 10 +) + +// hotIndexView is the immutable read view: everything a query needs, swapped +// atomically as ledgers apply, seals publish, and merges land. +type hotIndexView struct { + // rows[i] is the i-th UNSEALED ledger row (oldest first): the retained + // packed-row bytes and its accel arrays. Never mutated after publish. + rows []windowRow + // runs are the live sealed runs, oldest first. + runs []*sealedRun +} + +// windowRow is one retained per-ledger packed row plus its accel: fps and +// offs are parallel arrays, sorted by fp (row order), mapping each term's +// fp64 to its record offset in bytes. +type windowRow struct { + seq uint32 // the ledger this row belongs to + bytes []byte + fps []uint64 + offs []uint32 +} + +// sealedRun is one live run file with its in-RAM routing state and an open +// handle for concurrent ReadAt lookups. +type sealedRun struct { + path string + bloom bloom.Filter + fences []fence // sorted by term; placed by fenceBuilder + final end sentinel + terms int // record count; sizes a future merge output's bloom up front + file *os.File +} + +// fence marks a record boundary: the first term at offset off (byte offset +// into the run file's payload region). +type fence struct { + term TermKey + off int64 +} + +// HotIndex is the engine. One per open hot chunk (read-write opens). +type HotIndex struct { + dir string // run-file directory (inside the chunk's DB dir) + + // secret keys every sealed run this engine writes and probes (blind at + // seal). Fixed for the life of the chunk DB — persisted there and + // adoption-checked before the engine is constructed (hot_store.go) — so + // runs written across restarts stay probeable and the freeze can merge + // them straight into a cold index declaring the same secret. + secret [stores.SecretLen]byte + + // overlay is a PARTIAL view: it holds only promoted (dense) terms, so a + // lookup against it alone silently misses every sparse term (those live + // in the window rows and sealed runs). Read through Get. + overlay *ConcurrentBitmaps + view atomic.Pointer[hotIndexView] + + // Writer-owned state (single-writer contract). sealEvery/maxRuns default + // to the package consts; tests shrink them to exercise seals and merges + // with small inputs. The seal trigger compares sealEvery against + // len(view.rows) — exactly one row per ApplyLedger, and a reap trims the + // rows it sealed — so the window size is structural here, not a counter + // that could drift from it. Note the cadence is in ROWS, i.e. in + // event-bearing ledgers: an eventless ledger writes no index row at all + // (hot_store.go), unlike the txhash twin's dense chain. + sealEvery int + maxRuns int + sealSeq int + pendingSeal chan sealResult // capacity 1: at most one seal in flight + sealInFlight bool + manifest runset.Manifest + + // fsyncDir runs in every background job (openDurable), making the fresh + // run's dirent durable BEFORE the hand-back can reach reapSeal: PutRuns + // rides a synced write, so without the barrier a crash could leave the + // manifest durably naming a run whose dirent was never journaled — an + // unrecoverable warmup failure. Production is durable.FsyncDir, which + // counts a vanished dir as success (no dirent left to journal) — + // tolerable only because the follow-on open fails loudly when the + // directory, and the run with it, is gone. Tests swap in a recorder to + // pin the barrier-before-PutRuns order. Neither engine barriers h.dir's + // own dirent in its parent. + fsyncDir func(dir string) error + + // sealArmed gates the seal trigger. The engine starts DISARMED so an + // open's warmup replay is pure in-memory reconstruction — no run files, + // no manifest writes — and the index's durable state cannot change + // before verifyChunkConsistency has judged the open. A failed open is + // then identically retryable, and the tripwire's inputs can never be + // moved by the very open it is guarding. warmup arms via ArmSealing + // only after verification passes. + sealArmed bool + + // retired holds run handles displaced by a merge fold. They are NOT + // closed at fold time: lock-free readers may still hold the previous + // view and probe them, so the handles stay open (their files already + // unlinked) until Close, when no reader can remain. Writer-owned. + retired []*sealedRun + + // closed makes Close a one-shot: it drains the in-flight seal (if any) + // and releases every run handle this index still owns. + closed sync.Once +} + +// sealResult is the background sealer's hand-back. An errored result carries +// no resources: the goroutine disposes of anything it opened before handing +// back, so run is nil and obsolete empty whenever err is set and the +// consumers need no cleanup on the error path. +type sealResult struct { + run *sealedRun + rows int // number of window rows the seal covered + lastSeq uint32 // highest ledger the seal covered (manifest frontier) + replaceAll bool // run replaces ALL live runs (a merge happened) + obsolete []*sealedRun + err error +} + +// NewHotIndex creates the engine for a fresh chunk. dir is created. The +// manifest (rocksdbManifest in production, hot_store.go) is the +// crash-recovery authority on which runs are live — see runset.Manifest. +func NewHotIndex(dir string, manifest runset.Manifest, secret [stores.SecretLen]byte) (*HotIndex, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("events: hotindex mkdir %s: %w", dir, err) + } + h := &HotIndex{ + dir: dir, + secret: secret, + overlay: NewConcurrentBitmapsFromBitmaps(Bitmaps{}), + pendingSeal: make(chan sealResult, 1), + manifest: manifest, + fsyncDir: durable.FsyncDir, + sealEvery: windowLedgers, + maxRuns: maxLiveRuns, + } + h.view.Store(&hotIndexView{}) + return h, nil +} + +// ArmSealing enables the background seal machinery (see sealArmed): sealing +// rights follow validation, the same rule the catalog applies to artifact +// state. The first armed ApplyLedger that finds the window over-full seals +// ALL of it (startSeal covers the whole window), so a backlog replayed while +// disarmed drains in one background seal — no separate drain path. Writer +// goroutine only. +func (h *HotIndex) ArmSealing() { h.sealArmed = true } + +// fp64 is the accel/bloom fingerprint of a term. +func fp64(term TermKey) uint64 { return xxhash.Sum64(term[:]) } + +// ApplyLedger ingests one committed ledger's index state: rowBytes is the +// SAME packed-row slice the commit batch carried (retained, not copied — +// the caller must not reuse it), runs the per-term view the write path +// already built. runs is BORROWED — consumed synchronously, never retained +// (the overlay copies every ID it keeps), so callers may back it with +// reused arenas. Writer goroutine only. +func (h *HotIndex) ApplyLedger(seq uint32, rowBytes []byte, runs termRuns) error { + // Fold any completed seal FIRST so its run becomes visible before the + // window trims past its ledgers on this call's publish. + if err := h.reapSeal(false); err != nil { + return err + } + + // Dense admission + overlay feed: promoted terms get their IDs in RAM. + // Per-term decisions are independent, so iterating byte-sorted runs + // instead of the retired map changes nothing — the overlay-equivalence + // differential in termsort_test.go pins it. + for r := range runs.terms { + term := runs.terms[r] + ids := runs.run(r) + if h.overlay.Has(term) { + h.overlay.AddTo(term, ids...) + } else if len(ids) >= densePromoteWindowCount { + // Late promotion: backfill the term's earlier IDs from the + // current view (window+runs) so the overlay is complete, then + // add this ledger's. + prior, err := h.lookupSparse(h.view.Load(), term) + if err != nil { + return fmt.Errorf("events: hotindex promote %x: %w", term, err) + } + h.overlay.AddTo(term, prior...) + h.overlay.AddTo(term, ids...) + } + } + + row := buildWindowRow(rowBytes, len(runs.terms)) + row.seq = seq + old := h.view.Load() + rows := make([]windowRow, 0, len(old.rows)+1) + rows = append(rows, old.rows...) + rows = append(rows, row) + h.view.Store(&hotIndexView{rows: rows, runs: old.runs}) + + if h.sealArmed && len(rows) >= h.sealEvery && !h.sealInFlight { + h.startSeal(rows) + } + return nil +} + +// buildWindowRow scans a packed row once, emitting the accel arrays. The row +// is term-sorted, so fps comes out sorted (fp64 is not order-preserving, so +// we sort the pair arrays — ~24k entries, microseconds). nTerms sizes the +// scratch (the caller's run count is exact); this runs post-commit on +// the ingest goroutine, inside per-ledger latency. +func buildWindowRow(rowBytes []byte, nTerms int) windowRow { + type pair struct { + fp uint64 + off uint32 + } + pairs := make([]pair, 0, nTerms) + off := 0 + rest := rowBytes + for len(rest) > 0 { + var term TermKey + copy(term[:], rest[:16]) + pairs = append(pairs, pair{fp: fp64(term), off: uint32(off)}) + adv := PackedRecordLen(rest) + rest = rest[adv:] + off += adv + } + slices.SortFunc(pairs, func(a, b pair) int { return cmp.Compare(a.fp, b.fp) }) + row := windowRow{bytes: rowBytes, fps: make([]uint64, len(pairs)), offs: make([]uint32, len(pairs))} + for i, p := range pairs { + row.fps[i] = p.fp + row.offs[i] = p.off + } + return row +} + +// Get returns the term's postings (the zero value if absent): overlay fast +// path, else window+runs. Concurrent-safe. +// +// Neither path materializes an id list into a bitmap. +func (h *HotIndex) Get(term TermKey) (Postings, error) { + if post, err := h.overlay.Get(term); err != nil || post.Present() { + return post, err + } + ids, err := h.lookupSparse(h.view.Load(), term) + if err != nil { + return Postings{}, err + } + return IDPostings(ids), nil +} + +// Close drains any in-flight seal (discarding its result — warmup rebuilds +// deterministically) and releases every run handle this index still owns: +// the live view's runs, the handles retired by merge folds (kept open for +// lock-free readers), and a drained seal's freshly opened run. Without this, +// every chunk close leaked up to maxRuns open fds whose unlinked files — +// multi-GB after a late-chunk merge — stayed pinned on disk until process +// exit. One-shot; runs after ingestion and readers have stopped. +func (h *HotIndex) Close() { + h.closed.Do(func() { + if h.sealInFlight { + res := <-h.pendingSeal + h.sealInFlight = false + if res.err == nil { + res.run.close() + closeRuns(res.obsolete) + } + } + closeRuns(h.view.Load().runs) + closeRuns(h.retired) + h.retired = nil + }) +} + +// lookupSparse collects a term's IDs from the view's runs (oldest first) +// then window rows (oldest first) — ascending ID order overall, since runs +// hold strictly older ledgers than the window. +// +// The term is carried in BOTH forms: runs are keyed by the blinded key +// (blind at seal), window rows by the raw term they were written with. The +// blinding happens once per call, outside the run loop, and so does the +// blinded key's fingerprint — every run's bloom probes the same one. +// +// out is ONE accumulator threaded through every source: each decode appends +// straight into it, so a term's ids are written once, never per-record and +// copied. A source that lacks the term must hand the accumulator back +// untouched (see sealedRun.lookup); on error the partial accumulator dies +// with the error return. +func (h *HotIndex) lookupSparse(v *hotIndexView, term TermKey) ([]uint32, error) { + var out []uint32 + var err error + fp := fp64(term) + rk := blindTerm(h.secret, term) + rkfp := fp64(rk) + for _, r := range v.runs { + if out, err = r.lookup(out, rk, rkfp); err != nil { + return nil, err + } + } + for i := range v.rows { + row := &v.rows[i] + // Binary search the accel for fp; verify the full term at each hit + // (fp64 collisions are possible, exactness is not negotiable). + j := sort.Search(len(row.fps), func(k int) bool { return row.fps[k] >= fp }) + for ; j < len(row.fps) && row.fps[j] == fp; j++ { + rec := row.bytes[row.offs[j]:] + if !bytes.Equal(rec[:16], term[:]) { + continue + } + if out, _, err = appendRecordIDs(out, rec); err != nil { + return nil, fmt.Errorf("events: hotindex window row: %w", err) + } + } + } + // Compact is the whole dedup: out is ascending, and the only possible + // duplicates are adjacent. Runs cover strictly older ledgers than the + // window, so window/run overlap cannot happen, and a dense term never + // reaches here (the overlay serves it) — reads stay defensive anyway. + return slices.Compact(out), nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go new file mode 100644 index 000000000..6c7ba3214 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go @@ -0,0 +1,577 @@ +package event + +// hotindex_seal.go — the Sorted-Run Tier's background half: sealing the +// window into run files, the single-level merge that caps live runs, the +// run lookup path (bloom → fence → pread → verify), and manifest-anchored +// warmup. One background job runs at a time (writer-owned lifecycle); +// results fold back in on the writer goroutine (reapSeal). + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "sort" + "sync" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/bloom" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" +) + +// ─────────────────────────── sealing ─────────────────────────── + +// startSeal kicks the background job: fold the given window rows into one +// run and, if that would push the live-run count past maxLiveRuns, merge +// everything into one. Writer goroutine only; h.sealInFlight guards the +// single-job invariant. +func (h *HotIndex) startSeal(rows []windowRow) { + runsSnapshot := h.view.Load().runs + // Read on the writer goroutine, like runsSnapshot: the job below closes + // over no mutable HotIndex state but h.fsyncDir (fixed at construction). + secret := h.secret + sealed := len(rows) + // Both run names follow runset.NextSealSeq's grammar (-%06d.run, + // the prefixes OpenHotIndex resumes from); changing either shape silently + // breaks the seal-sequence resume. + name := filepath.Join(h.dir, fmt.Sprintf("seal-%06d.run", h.sealSeq)) + h.sealSeq++ + h.sealInFlight = true + go func() { + res := sealResult{rows: sealed, lastSeq: rows[sealed-1].seq} + res.run, res.err = sealWindow(rows, name, secret, h.fsyncDir) + if res.err == nil && len(runsSnapshot)+1 > h.maxRuns { + merged, obsolete, merr := mergeSealedRuns( + append(append([]*sealedRun{}, runsSnapshot...), res.run), + filepath.Join(h.dir, fmt.Sprintf("merge-%06d.run", h.sealSeq)), // NextSealSeq's grammar + h.fsyncDir) + if merr != nil { + // The sealed run just became garbage — dispose of it here, + // while it is known to be unpublished (see sealResult: + // errors carry no resources). + res.run.close() + _ = os.Remove(res.run.path) + res.run = nil + res.err = merr + } else { + res.run = merged + res.obsolete = obsolete + res.replaceAll = true + } + } + h.pendingSeal <- res + }() +} + +// reapSeal folds a completed background job into the view: manifest first +// (the durability point), then ONE atomic view swap that adds the run(s) and +// trims the sealed window rows — readers never see a coverage gap — then +// obsolete file deletion (crash between manifest and delete leaves orphans +// for warmup's sweep). block=true waits for an in-flight job (Close/tests). +func (h *HotIndex) reapSeal(block bool) error { + if !h.sealInFlight { + return nil + } + var res sealResult + if block { + res = <-h.pendingSeal + } else { + select { + case res = <-h.pendingSeal: + default: + return nil + } + } + h.sealInFlight = false + if res.err != nil { + return fmt.Errorf("events: hotindex seal: %w", res.err) + } + + old := h.view.Load() + // A merge fold's run replaces ALL live runs; a plain seal joins them. + live := old.runs + if res.replaceAll { + live = nil + } + // A failed Publish disposes of the un-listed run itself (errors carry no + // resources); the obsolete inputs stay untouched — they are still live + // in the view and listed by the manifest. + if err := runset.Publish(h.manifest, live, res.run, res.lastSeq); err != nil { + return fmt.Errorf("events: hotindex manifest: %w", err) + } + runs := append(append([]*sealedRun{}, live...), res.run) + h.view.Store(&hotIndexView{rows: old.rows[res.rows:], runs: runs}) + // Unlink obsolete runs now (the merged run replaces them durably) but do + // NOT close their handles: a lock-free reader may still hold the previous + // view and probe them mid-lookup. The open handle keeps the unlinked file + // readable; Close releases them when no reader can remain. + for _, r := range res.obsolete { + _ = os.Remove(r.path) + } + h.retired = append(h.retired, res.obsolete...) + return nil +} + +// runRouting accumulates a run's in-RAM routing state (bloom + fences + +// record count) record by record in one pass. The seal and merge feed it in +// the SAME pass that writes the file — freshly written runs are trusted +// without a post-write re-read (owner-accepted: RocksDB/file-level integrity +// plus the corruption gates cover it) — and warmup (openSealedRun) feeds it +// from the drain-verify of pre-existing files, which remains the +// crash-recovery trust anchor. +// +// The bloom is sized up front so fingerprints stream straight into it and no +// whole-run fp accumulation is ever retained: seals size from the window +// map's exact term count, merges from the inputs' summed counts — an upper +// bound on the union, which bloom.New's power-of-two rounding absorbs (an +// over-sized bloom only lowers the false-positive rate) — and warmup from +// the header's validated record count. +type runRouting struct { + bloom bloom.Filter + fb fenceBuilder +} + +func newRunRouting(expectedTerms int) *runRouting { + return &runRouting{ + bloom: bloom.New(max(expectedTerms, 1)), + // Cadence fences + sentinel; byte-cap extras may tail-grow. Matters + // at merge scale (~1M fences), where regrowing from nil churned + // ~100MB of transient allocation. + fb: fenceBuilder{fences: make([]fence, 0, expectedTerms/fenceEvery+2)}, + } +} + +// observe records one written record: term, at payload-relative offset off. +// Must be called once per record, in emission order, with the writer's +// pre-Append offset. +func (rt *runRouting) observe(term TermKey, off int64) { + rt.fb.observe(term, off) + rt.bloom.Add(fp64(term)) +} + +// open finalizes the routing state over the just-closed run file at path: +// the end-sentinel fence at payloadLen plus the open handle for lookups. +func (rt *runRouting) open(path string, payloadLen int64) (*sealedRun, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + return &sealedRun{ + path: path, + bloom: rt.bloom, + fences: rt.fb.finish(payloadLen), + terms: rt.fb.records, + file: f, + }, nil +} + +// openDurable is the seal's and the merge's shared post-Commit epilogue: the +// fsyncDir dirent barrier first — the hand-back's next stop is the manifest +// write that names the run (see HotIndex.fsyncDir) — then the open handle for +// lookups. Either failure unlinks the committed artifact rather than +// stranding it until the next warmup's orphan sweep, and neither leaves a +// handle open, so an error carries no resources (sealResult contract). +// Warmup opens through rt.open instead: those files are already durable. +func (rt *runRouting) openDurable( + path string, payloadLen int64, fsyncDir func(string) error, +) (*sealedRun, error) { + if err := fsyncDir(filepath.Dir(path)); err != nil { + _ = os.Remove(path) + return nil, err + } + run, err := rt.open(path, payloadLen) + if err != nil { + _ = os.Remove(path) + return nil, err + } + return run, nil +} + +// fenceBuilder accumulates a run's fence array under two caps: fenceEvery +// records (bounds a probe's decode walk) and fenceSpanBytes of payload +// (bounds a probe's pread). A record of fenceSpanBytes or more is fenced on +// both sides into a window of its own, so a term that sorts beside a dense +// term's multi-MB record never preads it. Seal, merge, and warmup all build +// fences through this one type — spacing cannot drift between a freshly +// written run and its reopened form. Single-use: the fences finish returns +// may share the builder's backing array, so drop the builder afterwards. +type fenceBuilder struct { + fences []fence + lastFenceOff int64 + prevTerm TermKey + prevOff int64 + records int +} + +func (fb *fenceBuilder) observe(term TermKey, off int64) { + fb.isolatePrev(off) + if fb.records%fenceEvery == 0 || off-fb.lastFenceOff >= fenceSpanBytes { + fb.put(term, off) + } + fb.prevTerm, fb.prevOff = term, off + fb.records++ +} + +// isolatePrev retroactively fences the just-ended record (end is its +// exclusive boundary) when it was oversized, so the window holding it holds +// nothing else. +func (fb *fenceBuilder) isolatePrev(end int64) { + if fb.records > 0 && end-fb.prevOff >= fenceSpanBytes { + fb.put(fb.prevTerm, fb.prevOff) + } +} + +// put appends a fence at off unless one is already there — the single +// dedupe point for every placement rule. +func (fb *fenceBuilder) put(term TermKey, off int64) { + if len(fb.fences) > 0 && fb.lastFenceOff == off { + return + } + fb.fences = append(fb.fences, fence{term: term, off: off}) + fb.lastFenceOff = off +} + +// finish isolates an oversized final record, then closes the array with the +// end sentinel at payloadLen. +func (fb *fenceBuilder) finish(payloadLen int64) []fence { + fb.isolatePrev(payloadLen) + return append(fb.fences, fence{off: payloadLen}) +} + +// foldBlindedRow decodes ONE packed index row and unions its postings into a +// blinded window map, returning how many DECODED bytes the window grew by +// (16-byte key + 4 bytes per id — what the map actually holds, which the +// varint-encoded row length undercounts several-fold on dense rows). +// +// This is the blind-at-seal step itself, written once for its two callers: +// the seal, which folds a whole window of rows and ignores the byte count, +// and the freeze's tail scan, which folds rows until the count crosses its +// window cap (cold_freeze.go). The in-place merge those two feed is only +// correct because the seal's key and the freeze tail's key are the same +// function of the same term — so they are the same expression. +func foldBlindedRow(window map[TermKey][]uint32, row []byte, secret [stores.SecretLen]byte) (int, error) { + added := 0 + if err := DecodePackedRow(row, func(term TermKey, ids []uint32) { + bk := blindTerm(secret, term) + window[bk] = append(window[bk], ids...) + added += 16 + 4*len(ids) + }); err != nil { + return 0, err + } + return added, nil +} + +// sealWindow folds window rows into one run file, building its in-RAM +// routing state in the same pass as the write, and hands back through +// openDurable — so the run's dirent is durable before the caller ever sees +// it. Runs on the background goroutine over immutable inputs. +// +// The fold is where a term stops being raw and becomes the blinded routing +// key the cold index is built on (blind at seal): the packed rows keep their +// raw terms — they are what warmup replays and what the freeze's tail scan +// reads — while everything downstream of the fold (the run's records, its +// fences, its bloom, and the cold index the freeze merges them into) is +// keyed by BlindKey(secret, term). Blinding at the fold, rather than at the +// write, keeps writeSortedRun key-agnostic: the freeze's tail windows blind +// at their own insert and stream through the same writer. +func sealWindow( + rows []windowRow, path string, secret [stores.SecretLen]byte, fsyncDir func(string) error, +) (*sealedRun, error) { + window := make(map[TermKey][]uint32, 1<<15) + for i := range rows { + if _, err := foldBlindedRow(window, rows[i].bytes, secret); err != nil { + return nil, err + } + } + rt := newRunRouting(len(window)) // exact: one record per window term + payloadLen, err := writeSortedRun(window, path, rt.observe) + if err != nil { + return nil, err + } + return rt.openDurable(path, payloadLen, fsyncDir) +} + +// writeSortedRun streams a folded (key → ids) window to path as one +// key-sorted run file — no whole-payload buffer — and returns the payload +// length. observe, when non-nil, sees each record's key and payload-relative +// offset just before it is written (one-pass routing-state construction). +// Shared by the seal and by the events freeze's window flushes (observe=nil), +// so the fold-and-stream shape has one implementation. +// +// Deliberately key-AGNOSTIC: both callers hand it a window that is already +// keyed the way the run must store it (blinded — the seal blinds in its +// fold, the freeze's tail scan at its insert), so record order here is +// exactly the caller's key order and nothing re-keys behind their back. +func writeSortedRun( + window map[TermKey][]uint32, path string, observe func(term TermKey, off int64), +) (int64, error) { + terms := make([]TermKey, 0, len(window)) + for k := range window { + terms = append(terms, k) + } + slices.SortFunc(terms, func(a, b TermKey) int { return bytes.Compare(a[:], b[:]) }) + rw, err := runspill.NewRunWriter(path) + if err != nil { + return 0, err + } + defer rw.Close() + for _, t := range terms { + if observe != nil { + observe(t, rw.Written()) + } + if err := rw.Append(t, window[t]); err != nil { + return 0, err + } + } + return rw.Written(), rw.Commit() +} + +// mergeSealedRuns merges live runs into one new run file (union semantics — +// runspill.MergeRuns), returning it plus the now-obsolete inputs; like +// sealWindow it hands back through openDurable's dirent barrier. Routing +// state is built as records stream out — no post-write re-read, no whole-run +// fingerprint accumulation (the old fps slice was the merge window's largest +// RSS transient). +func mergeSealedRuns( + runs []*sealedRun, path string, fsyncDir func(string) error, +) (*sealedRun, []*sealedRun, error) { + paths := make([]string, len(runs)) + expected := 0 + for i, r := range runs { + paths[i] = r.path + expected += r.terms + } + rt := newRunRouting(expected) // upper bound on the union's term count + // Stream the merged output straight to disk: the merged run can cover + // most of the chunk's terms (~GBs late-chunk), and buffering it whole was + // the acceptance run's only RSS spike. + rw, err := runspill.NewRunWriter(path) + if err != nil { + return nil, nil, err + } + defer rw.Close() + emit := func(rawTerm [16]byte, ids []uint32) error { + term := TermKey(rawTerm) + rt.observe(term, rw.Written()) + return rw.Append(term, ids) + } + if err := runspill.MergeRuns(paths, emit); err != nil { + return nil, nil, err + } + payloadLen := rw.Written() + if err := rw.Commit(); err != nil { + return nil, nil, err + } + merged, err := rt.openDurable(path, payloadLen, fsyncDir) + if err != nil { + return nil, nil, err + } + return merged, runs, nil +} + +// openSealedRun drains a PRE-EXISTING run file (verifying its CRC), building +// the bloom + fences + open file handle for lookups. Warmup-only: it is the +// crash-recovery trust anchor for files that survived a restart — routing +// state for those is always rebuilt from the verified file, never trusted +// from elsewhere. Freshly WRITTEN runs (seal/merge) build routing in the +// write pass instead (runRouting) and are not re-read. +func openSealedRun(path string) (*sealedRun, error) { + r, err := runspill.OpenRun(path) + if err != nil { + return nil, err + } + // The header's record count (validated at open, cross-checked by the + // drain) sizes the routing state up front — the same one-pass runRouting + // shape the write side uses, here fed from the verified drain. + rt := newRunRouting(int(r.Records())) //nolint:gosec // bounded by payload/minRecordBytes at open + for { + off := r.Offset() + term, _, nerr := r.Next() + if errors.Is(nerr, io.EOF) { + break + } + if nerr != nil { + _ = r.Close() + return nil, nerr + } + rt.observe(term, off) + } + end := r.Offset() + if err := r.Close(); err != nil { + return nil, err + } + return rt.open(path, end) // end = payload length +} + +// maxProbeBufBytes caps what the probe-buffer pool retains. A fence window +// holding more than one record spans strictly less than 2*fenceSpanBytes: +// its last record starts less than fenceSpanBytes past the opening fence (or +// the byte cap would already have closed the window there) and is itself +// smaller than fenceSpanBytes (or isolatePrev would have fenced it into a +// window of its own). Anything bigger IS such an isolated record, whose size +// is unbounded — dense terms' records in merged runs reach megabytes. +const maxProbeBufBytes = 2 * fenceSpanBytes + +// probeBuf is one pooled fence-window scratch buffer, boxed so returning it +// costs no interface allocation. +type probeBuf struct{ b []byte } + +// probeBufPool recycles the fence-window scratch a run probe preads into: +// one buffer per in-flight lookup, released before the probe returns. It +// deletes a large (≥32 KiB ⇒ mheap allocation plus a full memclr) make per +// probe from the query path. +// +// SIZE CAP: putProbeBuf drops any buffer over maxProbeBufBytes instead of +// retaining it. sync.Pool has no size classes, so one probe of an +// isolatePrev window would otherwise ratchet a multi-MB buffer into every +// P's private slot — a resident floor of hundreds of MB on a 32-P box, +// against a design whose whole point was killing an O(unique sparse terms) +// footprint. +// +// This is byte scratch on a bounded size class, released before return and +// never aliased by the decoded uint32s. That is what separates it from the +// two in-tree rejections of sync.Pool (zstd.go, ledger/hot_store.go): those +// held expensive STATEFUL objects that had to survive across ledger +// boundaries, and lost them to sync.Pool's GC-emptied semantics. Losing a +// scratch buffer here costs one make. +// +//nolint:gochecknoglobals // process-wide scratch pool, no state +var probeBufPool = sync.Pool{New: func() any { return new(probeBuf) }} + +func getProbeBuf(n int) *probeBuf { + p, _ := probeBufPool.Get().(*probeBuf) + if cap(p.b) < n { + p.b = make([]byte, n) + } + p.b = p.b[:n] + return p +} + +func putProbeBuf(p *probeBuf) { + if cap(p.b) > maxProbeBufBytes { + p.b = nil // oversized window: keep the box, drop the bytes + } + probeBufPool.Put(p) +} + +// lookup probes the run for one term, APPENDING its ids to dst: bloom reject +// → fence window → pread → one linear walk with full-term verification. +// fp is the caller's fp64(term) — lookupSparse already holds it, and it is +// the same value for every run in the view. Concurrent-safe (ReadAt). +// +// Every absent path returns dst, never nil: dst is the caller's accumulator +// across ALL live runs, so a nil return would silently drop the postings +// older runs already contributed — wrong answers, no error, no metric. On a +// decode error dst may hold a partial record's ids; lookupSparse discards +// the accumulator along with the error. +func (r *sealedRun) lookup(dst []uint32, term TermKey, fp uint64) ([]uint32, error) { + if !r.bloom.MayContain(fp) { + return dst, nil + } + // Last fence with fence.term <= term (fences[0].off == 0 always). + i := sort.Search(len(r.fences)-1, func(k int) bool { + return bytes.Compare(r.fences[k].term[:], term[:]) > 0 + }) + if i == 0 { + return dst, nil // term sorts before the first record + } + start, end := r.fences[i-1].off, r.fences[i].off + p := getProbeBuf(int(end - start)) + defer putProbeBuf(p) + // Fence offsets are payload-relative; the payload begins after the run + // header (runspill.HeaderLen keeps this pread aligned with the framing). + if _, err := r.file.ReadAt(p.b, runspill.HeaderLen+start); err != nil { + return nil, fmt.Errorf("events: hotindex run pread %s: %w", r.path, err) + } + // Fences land on record boundaries, so a well-formed window never ends + // mid-record; the ≥16 bound turns a truncated tail into an absent probe + // instead of an over-read — of the key compare just below, and of the + // length walk, which reads its count from b[16:]. + for b := p.b; len(b) >= 16; { + c := bytes.Compare(b[:16], term[:]) + if c == 0 { + out, _, err := appendRecordIDs(dst, b) + if err != nil { + return nil, fmt.Errorf("events: hotindex run %s: %w", r.path, err) + } + return out, nil + } + if c > 0 { + return dst, nil // sorted: passed the slot, absent + } + // Only the skip branch pays a length walk: the found record's length + // falls out of its decode, and the absent exit above never needs one. + b = b[PackedRecordLen(b):] + } + return dst, nil +} + +func (r *sealedRun) close() { + if r.file != nil { + _ = r.file.Close() + } +} + +// runset.Run adapters: the publish protocol addresses a sealed run only by +// path and disposal. +func (r *sealedRun) RunPath() string { return r.path } +func (r *sealedRun) CloseRun() { r.close() } + +// ─────────────────────────── warmup ─────────────────────────── + +// OpenHotIndex rebuilds the engine from its manifest after a restart: every +// manifest-listed run is re-opened (drain-verified — corruption is a loud +// failure, never auto-healed), unreferenced files in dir are swept as +// orphans, and the caller then replays the un-sealed tail of packed rows +// (ledgers past the sealed frontier) through ApplyLedger. Sealed runs are +// adopted AS THEY ARE — already blinded under the chunk DB's adopted secret +// (hot_store.go), which is the same secret passed here — while the replayed +// rows are raw, exactly as they were live. The dense overlay +// starts empty and self-heals: a firehose term re-promotes on its first +// ledger, backfilling its history from window+runs. +func OpenHotIndex( + dir string, manifest runset.Manifest, secret [stores.SecretLen]byte, +) (*HotIndex, uint32, error) { + h, err := NewHotIndex(dir, manifest, secret) + if err != nil { + return nil, 0, err + } + names, lastSealed, err := manifest.GetRuns() + if err != nil { + return nil, 0, fmt.Errorf("events: hotindex manifest read: %w", err) + } + runs := make([]*sealedRun, 0, len(names)) + // Cleanup is exit-invariant: any return before the caller takes ownership + // releases every run opened so far, however the open failed. + opened := false + defer func() { + if !opened { + closeRuns(runs) + } + }() + for _, name := range names { + r, oerr := openSealedRun(filepath.Join(dir, name)) + if oerr != nil { + return nil, 0, fmt.Errorf("events: hotindex: manifest run %s: %w", name, oerr) + } + runs = append(runs, r) + } + if err := runset.SweepOrphans(dir, names); err != nil { + return nil, 0, err + } + h.view.Store(&hotIndexView{runs: runs}) + // Both run-name prefixes this engine writes (startSeal). + h.sealSeq = runset.NextSealSeq(names, "seal", "merge") + opened = true + return h, lastSealed, nil +} + +func closeRuns(runs []*sealedRun) { + for _, r := range runs { + r.close() + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go new file mode 100644 index 000000000..86f880766 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -0,0 +1,944 @@ +package event + +import ( + "bytes" + "encoding/binary" + "errors" + "math/rand" + "os" + "path/filepath" + "slices" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runsettest" +) + +// runsFromMap converts a per-term map to the termRuns view ApplyLedger +// takes, in the byte-sorted term order AppendPackedRow emits. +func runsFromMap(per map[TermKey][]uint32) termRuns { + terms := make([]TermKey, 0, len(per)) + for k := range per { + terms = append(terms, k) + } + slices.SortFunc(terms, func(a, b TermKey) int { return bytes.Compare(a[:], b[:]) }) + var runs termRuns + runs.reset() + for _, k := range terms { + runs.addRun(k, per[k]) + } + return runs +} + +// fakeManifest is an in-memory runset.Manifest. putErr, when set, fails +// every PutRuns without recording anything. +type fakeManifest struct { + mu sync.Mutex + names []string + lastSealed uint32 + putErr error +} + +func (m *fakeManifest) PutRuns(names []string, lastSealed uint32) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.putErr != nil { + return m.putErr + } + m.names = append([]string(nil), names...) + m.lastSealed = lastSealed + return nil +} + +func (m *fakeManifest) GetRuns() ([]string, uint32, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.names...), m.lastSealed, nil +} + +// hotIndexHarness drives a HotIndex like the ingest path does: per ledger, +// build perKeyIDs + the packed row, feed ApplyLedger, and mirror everything +// into a reference model. +type hotIndexHarness struct { + t *testing.T + h *HotIndex + ref map[TermKey][]uint32 + seq uint32 + nexID uint32 + rng *rand.Rand +} + +func newHarness(t *testing.T, h *HotIndex) *hotIndexHarness { + return &hotIndexHarness{t: t, h: h, ref: map[TermKey][]uint32{}, rng: rand.New(rand.NewSource(5))} +} + +func hiKey(rng *rand.Rand) TermKey { + var k TermKey + rng.Read(k[:]) + return k +} + +// ledger applies one synthetic ledger: a firehose term (every event), a few +// mid terms, and fresh singletons — the sac shape. +func (hh *hotIndexHarness) ledger(fire TermKey, mids []TermKey, eventsN int) { + per := map[TermKey][]uint32{} + for range eventsN { + id := hh.nexID + hh.nexID++ + per[fire] = append(per[fire], id) + mid := mids[hh.rng.Intn(len(mids))] + per[mid] = append(per[mid], id) + single := hiKey(hh.rng) + per[single] = append(per[single], id) + } + row := AppendPackedRow(nil, per) + require.NoError(hh.t, hh.h.ApplyLedger(hh.seq, row, runsFromMap(per))) + for k, ids := range per { + hh.ref[k] = append(hh.ref[k], ids...) + } + hh.seq++ +} + +// verifyAll checks EVERY reference term resolves exactly, plus absent-term +// misses. +func (hh *hotIndexHarness) verifyAll() { + hh.t.Helper() + for k, want := range hh.ref { + post, err := hh.h.Get(k) + require.NoError(hh.t, err) + require.True(hh.t, post.Present(), "term %x missing", k) + require.Equal(hh.t, want, post.Bitmap().ToArray(), "term %x wrong ids", k) + + // An ID-backed result goes to the planner un-materialized, where + // Intersect cursor-walks it and Contains binary-searches it. + // Both need it strictly ascending, and Bitmap().ToArray() above would + // launder a violation, so assert the slice itself. + if ids := post.IDs(); ids != nil { + require.Equal(hh.t, want, ids, "term %x ID slice wrong", k) + } + } + for range 20 { + absent := hiKey(hh.rng) + post, err := hh.h.Get(absent) + require.NoError(hh.t, err) + assert.False(hh.t, post.Present(), "absent term %x must miss", absent) + } +} + +// settle forces any pending seal to complete and fold in. +func (hh *hotIndexHarness) settle() { + require.NoError(hh.t, hh.h.reapSeal(true)) +} + +func testHotIndex(t *testing.T, dir string, m runset.Manifest) *HotIndex { + h, err := NewHotIndex(dir, m, testIndexSecret) + require.NoError(t, err) + h.sealEvery = 8 // tiny window: many seals + h.maxRuns = 3 // frequent merges + h.ArmSealing() // tests emulate a validated live engine + return h +} + +// TestHotIndex_EquivalenceAcrossSealsAndMerges is the engine's core gate: +// content equivalence with a reference model through window fills, many +// seals, and several merges, for firehose/mid/singleton term shapes. +func TestHotIndex_EquivalenceAcrossSealsAndMerges(t *testing.T) { + m := &fakeManifest{} + h := testHotIndex(t, t.TempDir(), m) + defer h.Close() + hh := newHarness(t, h) + + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng), hiKey(hh.rng), hiKey(hh.rng)} + for l := range 100 { // 12+ seals, several merges at maxRuns=3 + hh.ledger(fire, mids, 40) // fire gets 40/ledger → dense-promotes + if l%17 == 0 { + hh.settle() + hh.verifyAll() + } + } + hh.settle() + hh.verifyAll() + + // The firehose term must be served by the overlay (memory-fast path). + assert.True(t, h.overlay.Has(fire), "firehose term must have dense-promoted") + // Live runs stayed capped. + assert.LessOrEqual(t, len(h.view.Load().runs), 3) +} + +// TestHotIndex_WarmupRebuild: close, reopen from the manifest, replay the +// unsealed tail, verify full equivalence — including the dense overlay +// self-healing on the next firehose ledger. +func TestHotIndex_WarmupRebuild(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + hh := newHarness(t, h) + + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng), hiKey(hh.rng)} + tailRows := make([][]byte, 0, len(h.view.Load().rows)) + tailPer := make([]map[TermKey][]uint32, 0, len(h.view.Load().rows)) + + for range 30 { + hh.ledger(fire, mids, 40) + } + hh.settle() + // Capture the UNSEALED tail (rows still in the window) for replay — in + // production these come from the IndexCF packed rows past the sealed + // frontier. + for _, row := range h.view.Load().rows { + tailRows = append(tailRows, row.bytes) + per := map[TermKey][]uint32{} + require.NoError(t, DecodePackedRow(row.bytes, func(k TermKey, ids []uint32) { + per[k] = append(per[k], ids...) + })) + tailPer = append(tailPer, per) + } + h.Close() + + h2, lastSealed, err := OpenHotIndex(dir, m, testIndexSecret) + require.NoError(t, err) + defer h2.Close() + h2.sealEvery, h2.maxRuns = 8, 3 + for i, row := range tailRows { + seq := lastSealed + 1 + uint32(i) + require.NoError(t, h2.ApplyLedger(seq, row, runsFromMap(tailPer[i]))) + } + h2.ArmSealing() // production order: replay disarmed, verify, then arm + hh.h = h2 + hh.verifyAll() +} + +// TestHotIndex_SealingDisarmedUntilArmed pins the validation gate every +// open starts behind: a DISARMED engine applies far more than sealEvery +// rows without writing anything durable — no run files, no manifest — and +// the first armed ApplyLedger drains the whole backlog in ONE seal. +func TestHotIndex_SealingDisarmedUntilArmed(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h, err := NewHotIndex(dir, m, testIndexSecret) + require.NoError(t, err) + defer h.Close() + h.sealEvery, h.maxRuns = 8, 3 // NOT armed — the warmup-replay state + hh := newHarness(t, h) + + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng)} + for range 40 { // 5× the seal window + hh.ledger(fire, mids, 20) + } + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries, "disarmed engine must write no run files") + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + assert.Empty(t, names, "disarmed engine must not touch the manifest") + assert.Zero(t, lastSealed) + assert.Len(t, h.view.Load().rows, 40, "backlog retained in the window") + hh.verifyAll() // reads are fully served from the window while disarmed + + // Arm + one more ledger: the over-full window seals in one pass. + h.ArmSealing() + hh.ledger(fire, mids, 20) + hh.settle() + names, lastSealed, err = m.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names, "armed engine must seal the backlog") + assert.Equal(t, hh.seq-1, lastSealed, "one seal must cover the whole backlog") + assert.Empty(t, h.view.Load().rows, "window drained by the backlog seal") + hh.verifyAll() +} + +// TestHotIndex_WarmupSweepsOrphans: unreferenced run files are deleted at +// open; a manifest-referenced file that is MISSING fails loudly. +func TestHotIndex_WarmupSweepsOrphans(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + hh := newHarness(t, h) + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng)} + for range 20 { + hh.ledger(fire, mids, 20) + } + hh.settle() + h.Close() + + orphan := filepath.Join(dir, "seal-999999.run") + require.NoError(t, os.WriteFile(orphan, []byte("junk"), 0o644)) + h2, _, err := OpenHotIndex(dir, m, testIndexSecret) + require.NoError(t, err) + h2.Close() + _, serr := os.Stat(orphan) + assert.True(t, os.IsNotExist(serr), "orphan must be swept at warmup") + + // Missing referenced run: loud failure. + names, _, _ := m.GetRuns() + require.NotEmpty(t, names) + require.NoError(t, os.Remove(filepath.Join(dir, names[0]))) + _, _, err = OpenHotIndex(dir, m, testIndexSecret) + require.Error(t, err, "missing manifest-referenced run must fail open") +} + +// TestHotIndex_WriterReaderRace: concurrent Gets (incl. held-view reads) +// while the writer applies, seals, and merges. Run with -race. +func TestHotIndex_WriterReaderRace(t *testing.T) { + m := &fakeManifest{} + h := testHotIndex(t, t.TempDir(), m) + defer h.Close() + hh := newHarness(t, h) + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng), hiKey(hh.rng)} + + stop := make(chan struct{}) + var wg sync.WaitGroup + for r := range 3 { + wg.Add(1) + go func(seed int64) { + defer wg.Done() + rng := rand.New(rand.NewSource(seed)) + for { + select { + case <-stop: + return + default: + } + if post, err := h.Get(fire); err == nil && post.Present() { + _ = post.Cardinality() // full read of a held snapshot + } + _, _ = h.Get(hiKey(rng)) // absent probes exercise blooms/fences + } + }(int64(r)) + } + for range 60 { + hh.ledger(fire, mids, 30) + } + hh.settle() + close(stop) + wg.Wait() + hh.verifyAll() +} + +// runExtent is one record's place in a written run, reconstructed from the +// window that produced it. +type runExtent struct { + term TermKey + off int64 + len int64 +} + +func runExtents(window map[TermKey][]uint32) ([]runExtent, int64) { + terms := make([]TermKey, 0, len(window)) + for k := range window { + terms = append(terms, k) + } + slices.SortFunc(terms, func(a, b TermKey) int { return bytes.Compare(a[:], b[:]) }) + recs := make([]runExtent, len(terms)) + var off int64 + for i, k := range terms { + l := int64(TermPostingsLen(window[k])) + recs[i] = runExtent{term: k, off: off, len: l} + off += l + } + return recs, off +} + +// assertFencePolicy checks the two caps over a run's fence array: no window +// holds more than fenceEvery records, no multi-record window spans 2x +// fenceSpanBytes, and every oversized record has fences at both edges. +func assertFencePolicy(t *testing.T, fences []fence, recs []runExtent, payloadLen int64) { + t.Helper() + require.Equal(t, int64(0), fences[0].off) + require.Equal(t, payloadLen, fences[len(fences)-1].off) + for i := 0; i+1 < len(fences); i++ { + start, end := fences[i].off, fences[i+1].off + require.Less(t, start, end) + inWindow := 0 + for _, r := range recs { + if r.off >= start && r.off < end { + inWindow++ + } + } + require.LessOrEqual(t, inWindow, fenceEvery) + if end-start >= 2*fenceSpanBytes { + require.Equal(t, 1, inWindow, "span [%d,%d) exceeds 2x cap with >1 record", start, end) + } + } + for _, r := range recs { + if r.len < fenceSpanBytes { + continue + } + i := slices.IndexFunc(fences, func(x fence) bool { return x.off == r.off }) + require.GreaterOrEqual(t, i, 0, "oversized record at %d must start a window", r.off) + require.Equal(t, r.off+r.len, fences[i+1].off, "oversized record at %d must end its window", r.off) + } +} + +// TestRunFences_ByteCapAndIsolation pins the fence policy: multi-record +// windows stay under 2x fenceSpanBytes, every record of fenceSpanBytes or +// more sits alone in its window (including one at the end of the run), the +// write pass and the warmup drain build identical fences, and lookups on and +// around the oversized records stay exact. +func TestRunFences_ByteCapAndIsolation(t *testing.T) { + mkTerm := func(hi byte, lo uint16) TermKey { + var k TermKey + k[0] = hi + binary.BigEndian.PutUint16(k[1:3], lo) + return k + } + seqIDs := func(n int) []uint32 { + ids := make([]uint32, n) + for i := range ids { + ids[i] = uint32(i) + } + return ids + } + + // Sort order: jumboFirst < 150 small terms < exactMid < 100 medium terms + // < jumboMid < 150 small terms < jumboEnd. Jumbo postings delta-encode + // to ~1 byte per ID: jumboFirst encodes to EXACTLY fenceSpanBytes (the + // inclusive boundary) and sits at record 0, whose start fence + // pre-exists — its isolation exercises put's dedupe. The medium stretch + // accumulates past the cap between cadence points, the byte cap's + // ordinary trigger. + window := map[TermKey][]uint32{} + jumboFirst := mkTerm(0x00, 0) + window[jumboFirst] = seqIDs(fenceSpanBytes - 19) // 16+3+1 framing + count-1 deltas + for i := range 150 { + window[mkTerm(0x10, uint16(i))] = []uint32{1, 5, 9} + window[mkTerm(0x90, uint16(i))] = []uint32{2, 6} + } + // A second exactly-at-cap record whose start is mid-window (record 151: + // off-cadence, a few hundred bytes past the last fence) — isolated only + // by isolatePrev's retro fence, unlike jumboFirst whose start fence + // pre-exists. + exactMid := mkTerm(0x18, 0) + window[exactMid] = seqIDs(fenceSpanBytes - 19) + for i := range 100 { + window[mkTerm(0x20, uint16(i))] = seqIDs(2500) + } + jumboMid := mkTerm(0x80, 0) + jumboEnd := mkTerm(0xFF, 0xFFFF) + window[jumboMid] = seqIDs(5 * fenceSpanBytes) + window[jumboEnd] = seqIDs(5 * fenceSpanBytes) + + path := filepath.Join(t.TempDir(), "cap.run") + rt := newRunRouting(len(window)) + payloadLen, err := writeSortedRun(window, path, rt.observe) + require.NoError(t, err) + written, err := rt.open(path, payloadLen) + require.NoError(t, err) + defer written.close() + + reopened, err := openSealedRun(path) + require.NoError(t, err) + defer reopened.close() + + // The write pass and the warmup drain must route identically — fences, + // record count, and the bloom bit for bit (its size comes from the window + // map's term count on the write pass, the header count at reopen). + require.Equal(t, written.fences, reopened.fences) + require.Equal(t, written.terms, reopened.terms) + require.Equal(t, written.bloom, reopened.bloom) + + recs, total := runExtents(window) + require.Equal(t, payloadLen, total) + assertFencePolicy(t, written.fences, recs, payloadLen) + + probes := []TermKey{ + jumboFirst, exactMid, jumboMid, jumboEnd, + mkTerm(0x10, 149), mkTerm(0x20, 50), mkTerm(0x90, 0), + } + for _, k := range probes { + ids, lerr := written.lookup(nil, k, fp64(k)) + require.NoError(t, lerr) + require.Equal(t, window[k], ids, "term %x", k) + } + absent := mkTerm(0x70, 7) + miss, err := written.lookup(nil, absent, fp64(absent)) + require.NoError(t, err) + require.Nil(t, miss) +} + +// TestRunFences_SmallRecordsKeepRecordCadence pins that the byte cap is +// inert for ordinary shapes: with small records the fence array is exactly +// the per-fenceEvery cadence plus the end sentinel. +func TestRunFences_SmallRecordsKeepRecordCadence(t *testing.T) { + window := map[TermKey][]uint32{} + rng := rand.New(rand.NewSource(7)) + for range 200 { + window[hiKey(rng)] = []uint32{3, 4, 8} + } + path := filepath.Join(t.TempDir(), "small.run") + rt := newRunRouting(len(window)) + payloadLen, err := writeSortedRun(window, path, rt.observe) + require.NoError(t, err) + run, err := rt.open(path, payloadLen) + require.NoError(t, err) + defer run.close() + + require.Len(t, run.fences, 200/fenceEvery+2) // records 0,64,128,192 + sentinel + recs, total := runExtents(window) + for i, fc := range run.fences[:len(run.fences)-1] { + require.Equal(t, recs[i*fenceEvery].off, fc.off) + require.Equal(t, recs[i*fenceEvery].term, fc.term) + } + require.Equal(t, total, run.fences[len(run.fences)-1].off) +} + +// TestHotIndex_MergeFailureDisposesSealedRun pins the sealResult contract: +// an errored seal job hands back no resources. The merge is failed for real +// (a live run corrupted on disk fails its CRC drain), and the freshly sealed +// run the goroutine had already produced must be closed and removed rather +// than leaked to a GC finalizer and the next warmup's sweep. +func TestHotIndex_MergeFailureDisposesSealedRun(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) // sealEvery=8, maxRuns=3 + defer h.Close() + hh := newHarness(t, h) + + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng)} + for range 3 { // three settled seals: the merge triggers on the fourth + for range 8 { + hh.ledger(fire, mids, 20) + } + hh.settle() + } + names, sealedBefore, err := m.GetRuns() + require.NoError(t, err) + require.Len(t, names, 3) + + // Flip the CRC trailer's last byte of one live run: structurally intact, + // so the merge fails only at that source's drain-to-EOF verification. + victim := filepath.Join(dir, names[0]) + blob, err := os.ReadFile(victim) + require.NoError(t, err) + blob[len(blob)-1] ^= 0xFF + require.NoError(t, os.WriteFile(victim, blob, 0o644)) + + for range 8 { + hh.ledger(fire, mids, 20) // eighth ApplyLedger starts seal #4 + merge + } + err = h.reapSeal(true) + require.ErrorContains(t, err, "hotindex seal") + + // The failed job's seal file is disposed of, the merge's partial output + // cleaned up on this failure path, and durable state untouched. + entries, err := os.ReadDir(dir) + require.NoError(t, err) + onDisk := make([]string, 0, len(entries)) + for _, e := range entries { + onDisk = append(onDisk, e.Name()) + } + slices.Sort(onDisk) + want := append([]string(nil), names...) + slices.Sort(want) + require.Equal(t, want, onDisk, "only the three manifest runs may remain") + afterNames, afterSealed, err := m.GetRuns() + require.NoError(t, err) + require.Equal(t, names, afterNames) + require.Equal(t, sealedBefore, afterSealed) + require.Len(t, h.view.Load().runs, 3) +} + +// mkStagedRun builds a real committed run in dir for tests that stage sealer +// hand-back state directly. +func mkStagedRun(t *testing.T, dir, name string) *sealedRun { + t.Helper() + var term TermKey + term[0] = name[0] + window := map[TermKey][]uint32{term: {1, 2}} + path := filepath.Join(dir, name) + rt := newRunRouting(len(window)) + payloadLen, err := writeSortedRun(window, path, rt.observe) + require.NoError(t, err) + run, err := rt.open(path, payloadLen) + require.NoError(t, err) + return run +} + +// TestHotIndex_CloseClosesUnreapedSealHandbacks pins Close's drain branch: a +// successful seal result that was never reaped hands back the fresh run plus +// the merge's obsolete inputs, and Close must close every one of those +// handles. When the follow-on merge succeeded, obsolete includes the freshly +// sealed, never-published run, which the view close cannot cover. +func TestHotIndex_CloseClosesUnreapedSealHandbacks(t *testing.T) { + dir := t.TempDir() + h := testHotIndex(t, dir, &fakeManifest{}) + + merged := mkStagedRun(t, dir, "merge-000001.run") + sealed := mkStagedRun(t, dir, "seal-000001.run") + + // The state startSeal's goroutine leaves behind when its merge completed + // but reapSeal never ran: result pending, sealInFlight still set. + h.pendingSeal <- sealResult{run: merged, obsolete: []*sealedRun{sealed}, replaceAll: true} + h.sealInFlight = true + h.Close() + + require.ErrorIs(t, merged.file.Close(), os.ErrClosed, "drained result's run must be closed") + require.ErrorIs(t, sealed.file.Close(), os.ErrClosed, "drained result's obsolete handles must be closed") +} + +// TestHotIndex_DirentBarrierPrecedesManifestPut drives this engine through +// both of its publish shapes — three plain seals, then the merge fold at +// maxRuns — and pins the dirent-barrier-before-PutRuns order with the shared +// runsettest helper (the rationale lives on AssertBarrierPrecedesEveryPut; +// the txhash twin pins the same invariant over its one shape). +func TestHotIndex_DirentBarrierPrecedesManifestPut(t *testing.T) { + dir := t.TempDir() + log := &runsettest.PublishLog{} + m := &runsettest.RecordingManifest{Log: log} + h := testHotIndex(t, dir, m) // sealEvery=8, maxRuns=3 + defer h.Close() + h.fsyncDir = log.FsyncDir + hh := newHarness(t, h) + + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng)} + for range 4 { // three plain seals, then one that folds in a merge + for range 8 { + hh.ledger(fire, mids, 20) + } + hh.settle() + } + require.Len(t, h.view.Load().runs, 1, "the fourth cycle must have merged") + + runsettest.AssertBarrierPrecedesEveryPut(t, log, dir, 4) +} + +// TestRocksdbManifest_PublishGoldenBytes pins the manifest VALUE bytes end +// to end through runset.Publish and this engine's codec: 4B BE lastSealed ‖ +// csv of run basenames — live order then fresh for a plain seal, the merged +// run alone for a replaceAll fold. No byte-identity gate covers manifest +// values (all six are cold-artifact gates), so this golden value is the +// compatibility pin for warmups reading manifests written before the +// publish protocol moved into runset. (Publish's dispose-on-failure contract +// is pinned structurally in the runset package's own tests.) +func TestRocksdbManifest_PublishGoldenBytes(t *testing.T) { + raw := openRawHotChunkForTest(t, t.TempDir(), 0) + defer func() { require.NoError(t, raw.Close()) }() + m := rocksdbManifest{store: raw} + dir := t.TempDir() + live := mkStagedRun(t, dir, "seal-000001.run") + defer live.close() + fresh := mkStagedRun(t, dir, "seal-000002.run") + defer fresh.close() + + require.NoError(t, runset.Publish(m, []*sealedRun{live}, fresh, 0x01020304)) + val, found, err := raw.Get("", hotIndexManifestKey) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, append([]byte{0x01, 0x02, 0x03, 0x04}, "seal-000001.run,seal-000002.run"...), val) + + // The merge fold's replaceAll shape: the merged run is the only survivor. + merged := mkStagedRun(t, dir, "merge-000003.run") + defer merged.close() + require.NoError(t, runset.Publish(m, nil, merged, 0x01020305)) + val, found, err = raw.Get("", hotIndexManifestKey) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, append([]byte{0x01, 0x02, 0x03, 0x05}, "merge-000003.run"...), val) +} + +// TestHotIndex_BarrierFailureDisposesRun pins openDurable's barrier branch in +// BOTH publish shapes: a run whose directory fsync failed was never made +// durable, so the background job disposes of it — nothing new left in the run +// dir, nothing new named in the manifest, and the seal surfaces the error to +// the writer. The live runs a failed merge was folding are untouched: they +// are still in the view and still manifest-listed. +func TestHotIndex_BarrierFailureDisposesRun(t *testing.T) { + for _, tc := range []struct { + name string + cycles int // settled seal cycles before the failing one + failOn int // the barrier call that fails, counted from the failing cycle + }{ + {name: "plain seal", cycles: 0, failOn: 1}, + {name: "merge fold", cycles: 3, failOn: 2}, // seal barrier ok, merge's fails + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) // sealEvery=8, maxRuns=3 + defer h.Close() + hh := newHarness(t, h) + fire := hiKey(hh.rng) + mids := []TermKey{hiKey(hh.rng)} + for range tc.cycles { + for range 8 { + hh.ledger(fire, mids, 20) + } + hh.settle() + } + namesBefore, sealedBefore, err := m.GetRuns() + require.NoError(t, err) + + calls := 0 + h.fsyncDir = func(string) error { + calls++ + if calls >= tc.failOn { + return errors.New("dirent barrier failed") + } + return nil + } + for range 8 { + hh.ledger(fire, mids, 20) + } + require.ErrorContains(t, h.reapSeal(true), "dirent barrier failed", + "a failed dirent barrier must fail the seal") + + onDisk, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, onDisk, tc.cycles, "only the previously published runs may remain") + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + require.Equal(t, namesBefore, names, "an undurable run must never be named") + require.Equal(t, sealedBefore, lastSealed) + require.Len(t, h.view.Load().runs, tc.cycles, "the view keeps exactly its live runs") + }) + } +} + +// TestHotIndex_ManifestFailureLeavesObsoleteRuns pins the engine-local half +// of reapSeal's manifest-failure branch, the half runset.Publish cannot see: +// a merge fold's obsolete inputs are still live in the view and still named +// by the previous manifest value, so a failed publish must leave their +// handles open and their files on disk — the unlink and the retired append +// happen only once the merged run is durably named. (Publish's own contract, +// disposing of the un-listed fresh run, is pinned by runset's +// TestPublish_FailureDisposesFresh.) +func TestHotIndex_ManifestFailureLeavesObsoleteRuns(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + defer h.Close() + + merged := mkStagedRun(t, dir, "merge-000002.run") + obsolete := mkStagedRun(t, dir, "seal-000002.run") + h.pendingSeal <- sealResult{run: merged, obsolete: []*sealedRun{obsolete}, replaceAll: true} + h.sealInFlight = true + m.putErr = errors.New("manifest unavailable") + + require.ErrorContains(t, h.reapSeal(true), "hotindex manifest") + require.NoError(t, obsolete.file.Close(), "obsolete inputs must stay open for live readers") + _, serr := os.Stat(obsolete.path) + require.NoError(t, serr, "obsolete inputs' files must remain") + require.Empty(t, h.retired, "nothing retires until the merged run is durably named") +} + +// probeTerm builds a TermKey from its two leading bytes — run records sort +// by the key's bytes, so the prefix alone fixes the order. +func probeTerm(hi, lo byte) TermKey { + var k TermKey + k[0], k[1] = hi, lo + return k +} + +// runForProbe writes a one-off sealed run over window. bloomFP seeds extra +// fingerprints into the run's bloom so an ABSENT term still reaches the +// fence walk — the bloom's false-positive path, which otherwise cannot be +// reached on purpose. +func runForProbe(t *testing.T, path string, window map[TermKey][]uint32, bloomFP ...TermKey) *sealedRun { + t.Helper() + rt := newRunRouting(len(window)) + payloadLen, err := writeSortedRun(window, path, rt.observe) + require.NoError(t, err) + for _, k := range bloomFP { + rt.bloom.Add(fp64(k)) + } + run, err := rt.open(path, payloadLen) + require.NoError(t, err) + t.Cleanup(run.close) + return run +} + +// TestSealedRunLookup_AbsentRunsKeepTheAccumulator pins lookup's accumulator +// contract: EVERY absent path hands dst back, never nil. lookupSparse +// threads ONE accumulator through the view's runs oldest-first, so a nil +// return from a newer run silently drops what older runs already +// contributed — wrong answers, no error, no metric. All three absent exits +// are covered: the bloom reject, the sorted-past-the-slot exit inside the +// window walk, and window exhaustion. +func TestSealedRunLookup_AbsentRunsKeepTheAccumulator(t *testing.T) { + dir := t.TempDir() + term := probeTerm(0x50, 0) + want := []uint32{3, 7, 9} + + // The term's own run: found between two neighbors. + found := runForProbe(t, filepath.Join(dir, "found.run"), map[TermKey][]uint32{ + probeTerm(0x40, 0): {1, 2}, + term: want, + probeTerm(0x60, 0): {4}, + }) + // Absent, walk runs off the end of the window (every record sorts BEFORE + // the term). + exhausted := runForProbe(t, filepath.Join(dir, "exhausted.run"), map[TermKey][]uint32{ + probeTerm(0x10, 0): {11}, + probeTerm(0x20, 0): {12}, + }, term) + // Absent, walk passes the slot: one record before the term (so the fence + // search does not exit early) and one after it. + passed := runForProbe(t, filepath.Join(dir, "passed.run"), map[TermKey][]uint32{ + probeTerm(0x40, 0): {13}, + probeTerm(0x60, 0): {14}, + }, term) + // Absent, and the bloom already says so (no seeded fingerprint). + rejected := runForProbe(t, filepath.Join(dir, "rejected.run"), map[TermKey][]uint32{ + probeTerm(0x40, 0): {15}, + }) + + // The exits really are the ones this test claims to cover. + require.True(t, exhausted.bloom.MayContain(fp64(term)), "fixture: bloom must pass") + require.True(t, passed.bloom.MayContain(fp64(term)), "fixture: bloom must pass") + require.False(t, rejected.bloom.MayContain(fp64(term)), "fixture: bloom must reject") + + out, err := found.lookup(nil, term, fp64(term)) + require.NoError(t, err) + require.Equal(t, want, out) + for i, r := range []*sealedRun{exhausted, passed, rejected} { + out, err = r.lookup(out, term, fp64(term)) + require.NoError(t, err) + require.Equal(t, want, out, "absent run %d truncated the accumulator", i) + } +} + +// TestSealedRunLookup_TruncatedWindowNeverPanics: a fence window that does +// not end on a record boundary — which a well-formed run cannot produce, and +// which nothing in the probe path validates — must come back absent or as an +// error, never as an out-of-range panic: the len(b) >= 16 bound is what +// stands between a cut-short window and an over-read, both in the key +// compare and in the length walk that reads its count from b[16:]. +func TestSealedRunLookup_TruncatedWindowNeverPanics(t *testing.T) { + term := probeTerm(0x50, 0) + want := []uint32{4, 5, 6} + run := runForProbe(t, filepath.Join(t.TempDir(), "trunc.run"), map[TermKey][]uint32{ + probeTerm(0x40, 0): {1, 2, 3}, + term: want, + }, term) + payloadLen := run.fences[len(run.fences)-1].off + require.Equal(t, int64(40), payloadLen, "fixture: two records of 16+1+3 bytes") + + for _, end := range []int64{1, 8, 15, 16, 17, 20, payloadLen - 2, payloadLen - 1} { + // One window, [0, end): a first fence sorting before the term so the + // fence search enters the walk, and a sentinel that cuts mid-record. + run.fences = []fence{{term: probeTerm(0x00, 0), off: 0}, {off: end}} + var ( + out []uint32 + err error + ) + require.NotPanics(t, func() { out, err = run.lookup(nil, term, fp64(term)) }, "end=%d", end) + if err != nil { + require.Nil(t, out, "end=%d: an errored probe returns no ids", end) + continue + } + require.True(t, out == nil || slices.Equal(out, want), + "end=%d: a truncated window must answer absent or exactly, got %v", end, out) + } +} + +// TestProbeBufPool_DropsOversizedWindows pins the pool's size cap. +// fenceBuilder.isolatePrev gives a record of fenceSpanBytes or more a window +// of its OWN, of arbitrary size (dense terms' records in merged runs reach +// megabytes), and sync.Pool has no size classes — so one such probe would +// otherwise ratchet a multi-MB buffer into every P's private slot. Ordinary +// windows, bounded by maxProbeBufBytes, stay pooled. +func TestProbeBufPool_DropsOversizedWindows(t *testing.T) { + // Nothing else probes a run here, so the buffers can be inspected after + // they go back. + big := getProbeBuf(maxProbeBufBytes + 1) + require.Len(t, big.b, maxProbeBufBytes+1) + putProbeBuf(big) + require.Nil(t, big.b, "an isolated oversized window must not be retained") + + ordinary := getProbeBuf(maxProbeBufBytes) + require.Len(t, ordinary.b, maxProbeBufBytes) + putProbeBuf(ordinary) + require.NotNil(t, ordinary.b, "a window at the cap must stay pooled") + require.Equal(t, maxProbeBufBytes, cap(ordinary.b)) +} + +// TestHotIndex_MultiRunAccumulation drives the accumulator through +// lookupSparse itself: two sealed runs plus a live window, one term in ALL +// three tiers and one term the newest run does not hold at all. Every other +// lookup test in this package probes a single run and is blind to a newer +// run clobbering an older one's postings. +func TestHotIndex_MultiRunAccumulation(t *testing.T) { + h := testHotIndex(t, t.TempDir(), &fakeManifest{}) // sealEvery=8, maxRuns=3 + defer h.Close() + + spread := probeTerm(0xA0, 0) // every ledger: both runs and the window + oldOnly := probeTerm(0xB0, 0) // the first run and the window only + var nextID uint32 + var wantSpread, wantOldOnly []uint32 + apply := func(seq uint32, terms ...TermKey) { + per := map[TermKey][]uint32{} + for _, k := range terms { + per[k] = []uint32{nextID} + switch k { + case spread: + wantSpread = append(wantSpread, nextID) + case oldOnly: + wantOldOnly = append(wantOldOnly, nextID) + } + nextID++ + } + require.NoError(t, h.ApplyLedger(seq, AppendPackedRow(nil, per), runsFromMap(per))) + } + + // Fresh singleton terms give each run records on both sides of the two + // probed ones, so neither probe is answered by a one-record run. + rng := rand.New(rand.NewSource(11)) + var seq uint32 + for range 8 { // seal #1: both terms + apply(seq, spread, oldOnly, hiKey(rng), hiKey(rng)) + seq++ + } + require.NoError(t, h.reapSeal(true)) + for range 8 { // seal #2: the newest run never sees oldOnly + apply(seq, spread, hiKey(rng), hiKey(rng)) + seq++ + } + require.NoError(t, h.reapSeal(true)) + for range 3 { // un-sealed window rows on top + apply(seq, spread, oldOnly) + seq++ + } + + v := h.view.Load() + require.Len(t, v.runs, 2, "fixture: two live runs, no merge") + require.Len(t, v.rows, 3, "fixture: an un-sealed window") + // The fixture's premise: the NEWEST run really is absent for oldOnly, + // and really does hold spread. + rk := blindTerm(h.secret, oldOnly) + missing, err := v.runs[1].lookup(nil, rk, fp64(rk)) + require.NoError(t, err) + require.Nil(t, missing, "fixture: the newest run must not hold the term") + sk := blindTerm(h.secret, spread) + for i := range v.runs { + ids, lerr := v.runs[i].lookup(nil, sk, fp64(sk)) + require.NoError(t, lerr) + require.NotEmpty(t, ids, "fixture: run %d must hold the spread term", i) + } + + for _, tc := range []struct { + name string + term TermKey + want []uint32 + }{ + {"across both runs and the window", spread, wantSpread}, + {"present only in the older run and the window", oldOnly, wantOldOnly}, + } { + t.Run(tc.name, func(t *testing.T) { + post, err := h.Get(tc.term) + require.NoError(t, err) + require.True(t, post.Present()) + require.Equal(t, tc.want, post.IDs(), "postings were truncated") + }) + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go index 189f8423c..360369154 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go @@ -68,7 +68,8 @@ func IndexedFieldMask() uint64 { return indexedFieldMask } // // Routed through streamhash.PreHashInPlace so the hash function and // byte encoding stay aligned with the MPHF builder downstream -// (mphf.go). Including the field byte in the hash input ensures the +// (the mphf wrapper in cold_format.go; streamhash routes on BlindKey +// output, so this hash is the pre-blind identity). Including the field byte in the hash input ensures the // same value in different fields produces different keys. func ComputeTermKey(value []byte, field Field) TermKey { // Prepend field byte to value for hashing. @@ -146,18 +147,141 @@ func TopicCountTermKeysAtLeast(n int) []TermKey { return keys } -// maxTermsPerEvent is what TermsForBytes emits for an event carrying -// the most topics a filter can name: type, topic count, contract ID, -// and one term per topic position. -const maxTermsPerEvent = 3 + protocol.MaxTopicCount +// MaxTermsPerEvent is the most term keys one event can contribute to the +// index: its type, its topic count, its contract ID, and topics +// 0..protocol.MaxTopicCount-1 — the protocol bound on queryable topic +// positions (topicField's panic guard pins the mapping to exactly that +// range). Arena-owning callers size per-ledger scratch with it. +const MaxTermsPerEvent = 3 + protocol.MaxTopicCount -// TermsForBytes returns the term keys for a marshaled ContractEvent, -// navigating the raw XDR via xdr.ContractEventView instead of a full -// UnmarshalBinary: its type, its topic count, its contract ID when it -// has one, and its topics 0..MaxTopicCount-1. +// AppendTerms appends a marshaled ContractEvent's term keys to dst and +// returns the extended slice — TermsForBytes for arena callers: at most +// MaxTermsPerEvent keys are appended, so a writer-owned dst reused across +// events makes this path allocation-free (the hot ingest loop's shape). +// Same accept/reject decisions and key bytes as TermsForBytes (the golden +// sweep in append_terms_test.go pins the equivalence); only the topics walk +// differs — see appendTopicTerms. +func AppendTerms(dst []TermKey, eventBytes []byte) ([]TermKey, error) { + return appendTerms(dst, eventBytes, nil) +} + +// appendTerms is AppendTerms' walk with one divergence: a non-nil lanes +// DIVERTS the two closed-alphabet terms — the event's type and its +// topic-count bucket — out of dst and into lanes as lane indices, hashing +// neither (see termlanes.go). Both entry points share this body so the +// accept/reject decisions the golden sweep pins cannot drift between them. +func appendTerms(dst []TermKey, eventBytes []byte, lanes *eventLanes) ([]TermKey, error) { + ev := xdr.ContractEventView(eventBytes) + + typeView, err := ev.Type() + if err != nil { + return nil, fmt.Errorf("events: view Type: %w", err) + } + // A type outside the enum is a hard indexing error — the alternative is + // indexing under a bucket no filter can name (see TermsForBytes). + eventType, err := typeView.Value() + if err != nil { + return nil, fmt.Errorf("events: view Type value: %w", err) + } + if lanes != nil { + lanes.eventType = eventTypeLane(eventType) + } else { + dst = append(dst, EventTypeTermKey(eventType)) + } + + cidOpt, err := ev.ContractId() + if err != nil { + return nil, fmt.Errorf("events: view ContractId: %w", err) + } + cidView, present, err := cidOpt.Unwrap() + if err != nil { + return nil, fmt.Errorf("events: view ContractId unwrap: %w", err) + } + if present { + cid, cerr := cidView.Value() + if cerr != nil { + return nil, fmt.Errorf("events: view ContractId value: %w", cerr) + } + dst = append(dst, ComputeTermKey(cid[:], FieldContractID)) + } + + body, err := ev.Body() + if err != nil { + return nil, fmt.Errorf("events: view ContractEvent.Body: %w", err) + } + bodyVVal, err := body.V() + if err != nil { + return nil, fmt.Errorf("events: view Body.V: %w", err) + } + // Only Body discriminant V=0 carries topics. A future body version is a + // hard error matching TermsForBytes (and the SQLite backend) — a silently + // contractID-only index would make topic queries miss real events with no + // signal. + if bodyVVal != 0 { + return nil, fmt.Errorf("events: unsupported ContractEvent body version %d", bodyVVal) + } + v0, err := body.V0() + if err != nil { + return nil, fmt.Errorf("events: view Body.V0: %w", err) + } + topics, err := v0.Topics() + if err != nil { + return nil, fmt.Errorf("events: view Body.V0.Topics: %w", err) + } + return appendTopicTerms(dst, topics, lanes) +} + +// appendTopicTerms hashes the first protocol.MaxTopicCount topics into dst +// via a manual Count()+Raw() walk — the allocation-free replacement for +// TopicsView.All (which builds a per-event view slice). Correctness of the +// walk rests on two view-API facts: +// +// - Raw() sizes EVERY element of the vec — including ones past the +// indexing cap — and errors when the total extent overruns the buffer, +// so an event truncated anywhere inside its topics array is rejected +// exactly like All() rejected it. +// - At()/Iter() yield UNTRIMMED views (fat slices running to the end of +// the buffer); only exact-extent Raw() bytes may be hashed, or trailing +// bytes would fold into the term key. +// +// lanes carries appendTerms' divert through: a non-nil one takes the +// topic-count term, never a topic term. +func appendTopicTerms( + dst []TermKey, topics xdr.ContractEventV0TopicsView, lanes *eventLanes, +) ([]TermKey, error) { + // Count() applies the checked count-vs-buffer guard All() applied, so a + // hostile count rejects here before any element walk. + count, err := topics.Count() + if err != nil { + return nil, fmt.Errorf("events: view Body.V0.Topics count: %w", err) + } + raw, err := topics.Raw() + if err != nil { + return nil, fmt.Errorf("events: view Body.V0.Topics raw: %w", err) + } + if lanes != nil { + lanes.topicCount = topicCountLane(count) + } else { + dst = append(dst, TopicCountTermKey(count)) + } + off := 4 // the vec's count header; raw is trimmed to the vec's exact extent + for i := range min(count, protocol.MaxTopicCount) { + topic, terr := xdr.ScValView(raw[off:]).Raw() + if terr != nil { + return nil, fmt.Errorf("events: view Body.V0.Topics[%d] raw: %w", i, terr) + } + dst = append(dst, ComputeTermKey(topic, topicField(i))) + off += len(topic) + } + return dst, nil +} + +// TermsForBytes returns the term keys (contract ID + topics +// 0..MaxTopicCount-1) for a marshaled ContractEvent, navigating the raw +// XDR via xdr.ContractEventView instead of a full UnmarshalBinary. func TermsForBytes(eventBytes []byte) ([]TermKey, error) { ev := xdr.ContractEventView(eventBytes) - keys := make([]TermKey, 0, maxTermsPerEvent) + keys := make([]TermKey, 0, MaxTermsPerEvent) typeView, err := ev.Type() if err != nil { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go new file mode 100644 index 000000000..a27898fff --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go @@ -0,0 +1,177 @@ +package event + +import ( + "cmp" + "slices" + + "github.com/RoaringBitmap/roaring/v2" +) + +// mergeRatio is the length ratio at which probing a sorted ID list switches +// from a cursored sequential walk to binary search over the unconsumed tail. +// +// Per driver ID a walk costs O/D comparisons amortized and a search log2(O), +// so on count alone they cross at O/D = log2(O). Both are cursored, so the +// search skips ground it has covered too, and measured per comparison the walk +// is the more expensive of the two (1.65ns against 1.02ns on scattered +// postings, whose values mispredict the walk's data-dependent branch every +// iteration). That puts the wall-clock crossover at 3 to 6 across driver sizes +// from 4 to 256, and 4 sits on the safe side of a shallow optimum: the walk +// gives up at most 1.28x there, against 4.1x to 4.7x at a ratio of 32. +const mergeRatio = 4 + +// Intersect returns the intersection of ps, or the zero Postings when any +// input is absent or nothing survives. It may reorder ps. +// +// Where the postings come back as ID lists — every term at or below the index's +// delta-coding threshold — intersecting them directly beats materializing them +// into bitmaps first, by 4.3x at cardinality 4, 2.8x at 64 and 1.6x at 1024. +// That is the whole reason the stores hand out un-materialized postings. +// +// The smallest input drives, so the work is bounded by the smallest side and +// the result is at most that large. When every input is already a bitmap there +// is nothing to save and roaring's container-wise FastAnd wins instead. +func Intersect(ps []Postings) Postings { + if len(ps) == 0 { + return Postings{} + } + for _, p := range ps { + if !p.Present() { + return Postings{} + } + } + // See ConcurrentBitmaps.Get: FastAnd Clones a lone input. + if len(ps) == 1 { + return ps[0] + } + + slices.SortFunc(ps, func(a, b Postings) int { + return cmp.Compare(a.Cardinality(), b.Cardinality()) + }) + + if !slices.ContainsFunc(ps, func(p Postings) bool { return p.ids != nil }) { + bms := make([]*roaring.Bitmap, len(ps)) + for i, p := range ps { + bms[i] = p.bm + } + return BitmapPostings(roaring.FastAnd(bms...)) + } + + // The driver is the smallest side, so reading a bitmap one out costs an + // allocation bounded by the smallest cardinality — cheap next to the + // materialization of every other side that this avoids. + driver := ps[0].ids + if driver == nil { + driver = ps[0].bm.ToArray() + } + + probes := make([]prober, len(ps)-1) + for i, p := range ps[1:] { + probes[i] = prober{ + ids: p.ids, + bm: p.bm, + sequential: len(p.ids) <= mergeRatio*len(driver), + } + } + + hits := make([]uint32, 0, len(driver)) +next: + for _, id := range driver { + for i := range probes { + if !probes[i].contains(id) { + continue next + } + } + hits = append(hits, id) + } + return IDPostings(hits) +} + +// prober tests membership in one intersect input. For an ID list it carries a +// cursor, since the driver ascends and so no probe ever needs to look behind +// the last ID it matched. +type prober struct { + ids []uint32 + bm *roaring.Bitmap + cursor int + sequential bool +} + +func (p *prober) contains(id uint32) bool { + if p.ids == nil { + return p.bm.Contains(id) + } + if p.sequential { + for p.cursor < len(p.ids) && p.ids[p.cursor] < id { + p.cursor++ + } + return p.cursor < len(p.ids) && p.ids[p.cursor] == id + } + i, found := slices.BinarySearch(p.ids[p.cursor:], id) + p.cursor += i + return found +} + +// Union returns the union of ps, or the zero Postings when none are present. +// It compacts ps in place, dropping absent entries and zeroing the tail, so a +// caller that reads ps afterwards sees neither its original order nor its +// original length. +// +// All-list inputs are merged rather than materialized. Building a bitmap from a +// scattered id list costs an allocation per container it touches, which is what +// dominates: 2 filters of 16 postings measure 14.8us and 493 allocations +// through FastOr against 114ns and 2 merged, and 5 filters of 1024 drop from +// 3271 allocations to 9. Keeping the result a list is most of that win, since +// the clip and the selection downstream are far cheaper on one. +// +// Anything else goes to FastOr, which also covers the region where it wins: a +// bitmap input means that term is above the index's delta threshold. +func Union(ps []Postings) Postings { + ps = slices.DeleteFunc(ps, func(p Postings) bool { return !p.Present() }) + switch len(ps) { + case 0: + return Postings{} + case 1: + // See ConcurrentBitmaps.Get: FastOr Clones a lone input. + return ps[0] + } + + if !slices.ContainsFunc(ps, func(p Postings) bool { return p.ids == nil }) { + merged := ps[0].ids + for _, p := range ps[1:] { + merged = mergeAscending(make([]uint32, 0, len(merged)+len(p.ids)), merged, p.ids) + } + return IDPostings(merged) + } + + bms := make([]*roaring.Bitmap, len(ps)) + for i, p := range ps { + bms[i] = p.Bitmap() + } + return BitmapPostings(roaring.FastOr(bms...)) +} + +// mergeAscending appends the union of two ascending deduped lists to dst. +// Merging pairwise beats a k-way heap for the handful of inputs a filter set +// produces: 5 lists of 1024 measure 46us pairwise against 96us through a heap. +// Deliberate duplicate of runspill.unionAscending (runspill/merge.go), which +// documents the ownership-contract split that keeps the two separate. +func mergeAscending(dst, a, b []uint32) []uint32 { + i, j := 0, 0 + for i < len(a) && j < len(b) { + switch { + case a[i] < b[j]: + dst = append(dst, a[i]) + i++ + case a[i] > b[j]: + dst = append(dst, b[j]) + j++ + default: + dst = append(dst, a[i]) + i++ + j++ + } + } + dst = append(dst, a[i:]...) + return append(dst, b[j:]...) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect_test.go new file mode 100644 index 000000000..3cfe17538 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect_test.go @@ -0,0 +1,186 @@ +package event + +import ( + "math/rand" + "slices" + "testing" + + "github.com/RoaringBitmap/roaring/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// makeTestPostings builds card postings scattered over span, in the requested +// form, and returns the equivalent bitmap for cross-checking. +func makeTestPostings(tb testing.TB, rng *rand.Rand, card int, span uint32, asIDs bool) (Postings, *roaring.Bitmap) { + tb.Helper() + // Without this the distinct-value loop below never terminates. + require.LessOrEqual(tb, uint32(card), span, "cardinality must fit in the span") + set := make(map[uint32]struct{}, card) + for len(set) < card { + set[uint32(rng.Int31n(int32(span)))] = struct{}{} + } + ids := make([]uint32, 0, len(set)) + for id := range set { + ids = append(ids, id) + } + slices.Sort(ids) + bm := roaring.BitmapOf(ids...) + if asIDs { + return IDPostings(ids), bm + } + return BitmapPostings(bm), bm +} + +// TestIntersectMatchesFastAnd cross-checks Intersect against roaring.FastAnd, +// the operation it replaces. The span is kept tight relative to cardinality so +// intersections are usually non-empty — with postings spread thinly, most +// rounds would compare one empty result against another and prove nothing. +func TestIntersectMatchesFastAnd(t *testing.T) { + rng := rand.New(rand.NewSource(7)) + + for round := range 300 { + inputs := make([]Postings, 2+rng.Intn(3)) + want := make([]*roaring.Bitmap, len(inputs)) + for i := range inputs { + // Mixed forms and a wide cardinality spread, so the driver is + // sometimes an ID list and sometimes a bitmap, and both probe + // strategies get exercised. + inputs[i], want[i] = makeTestPostings(t, rng, 1+rng.Intn(60), 100, rng.Intn(2) == 0) + } + + got := Intersect(inputs) + expected := roaring.FastAnd(want...) + assert.Equal(t, expected.ToArray(), got.Bitmap().ToArray(), "round %d", round) + assert.Equal(t, !expected.IsEmpty(), got.Present(), "round %d presence", round) + } +} + +// TestIntersectDriverStrategies covers the shapes that select each probe +// strategy, including the ratio at which a cursored walk gives way to binary +// search and the case where the smallest side is a bitmap. +func TestIntersectDriverStrategies(t *testing.T) { + rng := rand.New(rand.NewSource(11)) + + for _, tc := range []struct { + name string + driverCard int + otherCard int + driverAsIDs bool + otherAsIDs bool + }{ + {"equal lists take the walk", 400, 400, true, true}, + {"lopsided lists take the search", 4, 400, true, true}, + {"tiny list driver forces the search", 1, 2000, true, true}, + {"list driver against a bitmap", 40, 2000, true, false}, + {"bitmap driver against a list", 40, 2000, false, true}, + {"all bitmaps fall back to FastAnd", 40, 2000, false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + a, abm := makeTestPostings(t, rng, tc.driverCard, 4000, tc.driverAsIDs) + b, bbm := makeTestPostings(t, rng, tc.otherCard, 4000, tc.otherAsIDs) + + // Pass largest-first: Intersect owns the ordering. + got := Intersect([]Postings{b, a}) + assert.Equal(t, roaring.And(abm, bbm).ToArray(), got.Bitmap().ToArray()) + }) + } +} + +func TestIntersectDegenerate(t *testing.T) { + present := IDPostings([]uint32{1, 2, 3}) + + assert.False(t, Intersect(nil).Present()) + assert.False(t, Intersect([]Postings{}).Present()) + assert.False(t, Intersect([]Postings{present, {}}).Present(), + "one absent term empties the intersection") + assert.False(t, Intersect([]Postings{IDPostings([]uint32{1}), IDPostings([]uint32{2})}).Present(), + "disjoint inputs are absent, not an empty present set") + + // A single input passes through untouched, so a lone bitmap-backed term is + // not materialized on the way past. + bm := roaring.BitmapOf(1, 2, 3) + single := Intersect([]Postings{BitmapPostings(bm)}) + require.True(t, single.Present()) + assert.Same(t, bm, single.Bitmap()) +} + +// TestUnionMatchesFastOr cross-checks the merge path against roaring.FastOr, +// which is both the operation it replaces and the fallback it still uses when +// any input is bitmap-backed. +func TestUnionMatchesFastOr(t *testing.T) { + rng := rand.New(rand.NewSource(13)) + + for round := range 400 { + inputs := make([]Postings, 2+rng.Intn(4)) + want := make([]*roaring.Bitmap, len(inputs)) + // Mixed forms so both the merge and the FastOr fallback run, and + // occasionally all-lists so the merge runs on every input. + allLists := rng.Intn(3) == 0 + for i := range inputs { + asIDs := allLists || rng.Intn(2) == 0 + inputs[i], want[i] = makeTestPostings(t, rng, 1+rng.Intn(80), 200, asIDs) + } + + got := Union(inputs) + expected := roaring.FastOr(want...) + require.True(t, got.Present(), "round %d", round) + assert.Equal(t, expected.ToArray(), got.Bitmap().ToArray(), "round %d", round) + if allLists { + ids := got.IDs() + require.NotNil(t, ids, "round %d: all-list inputs must merge, not materialize", round) + // Assert on the raw slice, not through Bitmap(): that sorts and + // dedups, so it would launder a merge that emitted duplicates or + // went backwards. IDPostings does not check the precondition, and + // violating it answers wrongly rather than failing. + assert.Equal(t, expected.ToArray(), ids, "round %d: merged ids", round) + } + } +} + +// TestUnionMergeBoundaries covers the shapes a randomized sweep reaches rarely: +// full overlap, no overlap, one list a prefix or suffix of the other, and +// duplicate values across every input. +func TestUnionMergeBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + in [][]uint32 + want []uint32 + }{ + {"identical", [][]uint32{{1, 2, 3}, {1, 2, 3}}, []uint32{1, 2, 3}}, + {"disjoint interleaved", [][]uint32{{1, 3, 5}, {2, 4, 6}}, []uint32{1, 2, 3, 4, 5, 6}}, + {"disjoint blocks", [][]uint32{{1, 2}, {8, 9}}, []uint32{1, 2, 8, 9}}, + {"prefix", [][]uint32{{1, 2}, {1, 2, 3, 4}}, []uint32{1, 2, 3, 4}}, + {"suffix", [][]uint32{{3, 4}, {1, 2, 3, 4}}, []uint32{1, 2, 3, 4}}, + {"singletons", [][]uint32{{5}, {5}, {5}}, []uint32{5}}, + {"three-way", [][]uint32{{1, 4}, {2, 5}, {3, 6}}, []uint32{1, 2, 3, 4, 5, 6}}, + {"extremes", [][]uint32{{0}, {4294967295}}, []uint32{0, 4294967295}}, + } { + t.Run(tc.name, func(t *testing.T) { + ps := make([]Postings, len(tc.in)) + for i, ids := range tc.in { + ps[i] = IDPostings(ids) + } + got := Union(ps) + require.NotNil(t, got.IDs(), "all-list inputs must merge") + assert.Equal(t, tc.want, got.SelectIDs(0, false)) + }) + } +} + +func TestUnion(t *testing.T) { + a := IDPostings([]uint32{1, 5}) + b := BitmapPostings(roaring.BitmapOf(5, 9)) + + assert.False(t, Union(nil).Present()) + assert.False(t, Union([]Postings{{}, {}}).Present()) + + // A lone present input passes through, keeping its form. + single := Union([]Postings{{}, a, {}}) + require.True(t, single.Present()) + assert.NotNil(t, single.IDs(), "a passed-through input must keep its form") + + both := Union([]Postings{a, b}) + require.True(t, both.Present()) + assert.Equal(t, []uint32{1, 5, 9}, both.SelectIDs(0, false)) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go index d263667dd..b54fb371b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go @@ -13,14 +13,11 @@ package event import ( "bytes" - "cmp" "context" "fmt" "iter" "slices" - "github.com/RoaringBitmap/roaring/v2" - protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" "github.com/stellar/go-stellar-sdk/xdr" ) @@ -280,7 +277,7 @@ func Matches( streamRange(ctx, r, window, descending, firstBatch, yield) return } - if union.IsEmpty() { + if !union.Present() { return } streamUnion(ctx, r, filters, union, descending, firstBatch, yield) @@ -321,7 +318,7 @@ func validateMatchCall(ctx context.Context, r Reader, filters []Filter, window I // path), so downstream iteration is safe. func unionForFilters( ctx context.Context, r Reader, filters []Filter, window IDRange, -) (*roaring.Bitmap, bool, error) { +) (Postings, bool, error) { // ───── 1. Dedupe terms across filters ───── // // filterPlans[i] holds the slots filter i needs out of the batched @@ -333,7 +330,7 @@ func unionForFilters( // an unconstrained filter from intersecting nothing and coming back // empty instead. if len(filters) == 0 { - return nil, true, nil + return Postings{}, true, nil } filterPlans := make([]termPlan, len(filters)) var uniqueKeys []TermKey @@ -341,7 +338,7 @@ func unionForFilters( for i := range filters { groups := filters[i].termGroups() if len(groups) == 0 { - return nil, true, nil + return Postings{}, true, nil } plan := make(termPlan, len(groups)) for g, keys := range groups { @@ -355,9 +352,9 @@ func unionForFilters( } // ───── 2. Single batched lookup for all unique terms ───── - bitmaps, err := r.LookupKeys(ctx, uniqueKeys) + postings, err := r.LookupKeys(ctx, uniqueKeys) if err != nil { - return nil, false, fmt.Errorf("events: query lookup: %w", err) + return Postings{}, false, fmt.Errorf("events: query lookup: %w", err) } // ───── 3. Per-filter intersect ───── @@ -375,13 +372,13 @@ func unionForFilters( // the rest of the function. FastAnd never mutates its inputs // either, so the same bitmap may appear across multiple filters // safely. - perFilter := make([]*roaring.Bitmap, 0, len(filterPlans)) + perFilter := make([]Postings, 0, len(filterPlans)) for _, plan := range filterPlans { - inputs := make([]*roaring.Bitmap, 0, len(plan)) + inputs := make([]Postings, 0, len(plan)) missed := false for _, slots := range plan { - group := unionSlots(bitmaps, slots) - if group == nil { + group := unionSlots(postings, slots) + if !group.Present() { missed = true break } @@ -390,56 +387,32 @@ func unionForFilters( if missed { continue } - if len(inputs) == 1 { - perFilter = append(perFilter, inputs[0]) - continue + // Intersect returns absent if any input is empty, so a filter naming an + // unindexed term drops out of the union here. + if hits := Intersect(inputs); hits.Present() { + perFilter = append(perFilter, hits) } - // FastAnd intersects left-to-right — putting the smallest - // bitmap first shrinks the accumulator fastest. roaring's own - // docs call this out as the recommended caller-side prep. - slices.SortFunc(inputs, func(a, b *roaring.Bitmap) int { - return cmp.Compare(a.GetCardinality(), b.GetCardinality()) - }) - perFilter = append(perFilter, roaring.FastAnd(inputs...)) } if len(perFilter) == 0 { - return roaring.New(), false, nil - } - - // ───── 4. Union across filters ───── - // Single-filter case: FastOr would Clone — skip it and use the - // already-computed bitmap directly. That bitmap may be borrowed - // (from LookupKeys), so step 5's window And uses the fresh-result - // variant on that path to avoid mutating shared state. - var union *roaring.Bitmap - singleFilter := len(perFilter) == 1 - if singleFilter { - union = perFilter[0] - } else { - union = roaring.FastOr(perFilter...) + return Postings{}, false, nil } - // ───── 5. Apply the event-ID window ───── + // ───── 4. Union across filters, 5. clip to the window ───── // - // The window AND enforces the caller's pinned range. It also clips - // phantom IDs from a concurrent hot-store ingest: the mirror - // publishes entries before offsets, so LookupKeys can briefly - // surface IDs past EventCount. The AND keeps the stream strictly - // within the snapshot the caller pinned at request entry. + // Entries in perFilter may be BORROWED store state (a single-constraint, + // single-filter chain hands LookupKeys' postings straight through), and + // Union and ClipRange never mutate their inputs, so borrowed entries stay + // valid for the store's lifetime rules. // - // This covers the multi-term group too. Its bitmaps are separate - // mirror snapshots taken at different instants, but an event never - // moves between the terms of one group once ingested, so a torn read - // across them can only surface IDs past the pinned End. - rangeBM := roaring.New() - rangeBM.AddRange(uint64(window.Start), uint64(window.End)) - if singleFilter { - union = roaring.And(union, rangeBM) // fresh result; union may be borrowed - } else { - union.And(rangeBM) // FastOr output is owned; in-place is fine - } - return union, false, nil + // The clip enforces the caller's pinned range. It also drops phantom IDs + // from a concurrent hot-store ingest: the mirror publishes entries before + // offsets, so LookupKeys can briefly surface IDs past EventCount. The same + // covers multi-term groups — their postings are separate snapshots, but an + // event never moves between one group's terms, so a torn read only ever + // surfaces IDs past the pinned End. + union := Union(perFilter) + return union.ClipRange(window.Start, window.End), false, nil } // streamUnion walks the union bitmap in internal batches: collect @@ -453,17 +426,14 @@ func unionForFilters( // fetched matches are flipped back. Stepping one id at a time is fine // here: the fetch I/O dominates a 512-step loop. func streamUnion( - ctx context.Context, r Reader, filters []Filter, union *roaring.Bitmap, + ctx context.Context, r Reader, filters []Filter, union Postings, descending bool, firstBatch int, yield func(Match, error) bool, ) { - var it interface { - HasNext() bool - Next() uint32 - } + // SelectIDs is read-only and possibly borrowed; the walk below only reads. + all := union.SelectIDs(0, false) + pos := 0 if descending { - it = union.ReverseIterator() - } else { - it = union.Iterator() + pos = len(all) } batch, rest := batchSizes(firstBatch) ids := make([]uint32, 0, batch) @@ -472,10 +442,7 @@ func streamUnion( yield(Match{}, err) return } - ids = ids[:0] - for it.HasNext() && len(ids) < batch { - ids = append(ids, it.Next()) - } + ids, pos = collectBatch(ids[:0], all, pos, batch, descending) batch = rest if len(ids) == 0 { return @@ -505,6 +472,24 @@ func streamUnion( } } +// collectBatch takes up to batch ids from all starting at pos, walking down +// when descending (ids come out highest-first) and up otherwise, returning the +// batch and the new position. +func collectBatch(ids, all []uint32, pos, batch int, descending bool) ([]uint32, int) { + if descending { + for pos > 0 && len(ids) < batch { + pos-- + ids = append(ids, all[pos]) + } + return ids, pos + } + for pos < len(all) && len(ids) < batch { + ids = append(ids, all[pos]) + pos++ + } + return ids, pos +} + // ValidateFilters rejects filters that would silently never match // because of a malformed value. ContractId must be the canonical // 32-byte xdr.Hash form (or absent); a short ContractId would @@ -580,24 +565,17 @@ func CountDistinctTerms(filters []Filter) int { // of them is absent from the index. A lone present bitmap is borrowed // rather than cloned, like the single-constraint path in // unionForFilters. -func unionSlots(bitmaps []*roaring.Bitmap, slots []int) *roaring.Bitmap { +func unionSlots(postings []Postings, slots []int) Postings { if len(slots) == 1 { - return bitmaps[slots[0]] + return postings[slots[0]] } - present := make([]*roaring.Bitmap, 0, len(slots)) + present := make([]Postings, 0, len(slots)) for _, s := range slots { - if bitmaps[s] != nil { - present = append(present, bitmaps[s]) + if postings[s].Present() { + present = append(present, postings[s]) } } - switch len(present) { - case 0: - return nil - case 1: - return present[0] - default: - return roaring.FastOr(present...) - } + return Union(present) } // indexOfOrAddTerm returns the index of key inside *keys, appending diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go index 671bc2718..99f45c4eb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go @@ -7,7 +7,6 @@ import ( "iter" "testing" - "github.com/RoaringBitmap/roaring/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -54,7 +53,7 @@ type countingReader struct { totalKeys int } -func (c *countingReader) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring.Bitmap, error) { +func (c *countingReader) LookupKeys(ctx context.Context, keys []TermKey) ([]Postings, error) { c.lookupKeysCalls++ c.totalKeys += len(keys) return c.Reader.LookupKeys(ctx, keys) @@ -550,6 +549,47 @@ func TestQuery_DescendingWithRange(t *testing.T) { dataSyms(t, got)) } +// TestMatches_PostFilterRejectsTermHashCollision pins the defensive +// post-filter: if a bitmap entry survives the index lookup but the +// underlying event's bytes don't actually match the filter clause, +// Query must drop it. TermKey is xxh3_128(field || value), a +// non-cryptographic hash on attacker-controllable values; a +// collision (or a corrupt index) could otherwise leak the wrong +// event through Query. +// +// We force the case by injecting a false-positive entry directly +// into the mirror's bitmap for the "topic1 == gamma" term — +// equivalent to what a real collision would produce. +func TestMatches_PostFilterRejectsTermHashCollision(t *testing.T) { + fx := newQueryFixture(t) + + // gamma's term legitimately matches id=1 only (evt-a-ac with + // topic1=gamma). Inject id=4 (evt-a-b — topic0=beta only, + // no topic1) into the same bitmap to simulate a collision. + gammaKey := ComputeTermKey(fx.t0cRaw, FieldTopic1) + before := lookupOne(t, fx.store, gammaKey) + require.True(t, before.Contains(1), "fixture sanity: id=1 indexes topic1=gamma") + require.False(t, before.Contains(4), "fixture sanity: id=4 not yet in topic1=gamma bitmap") + + // Inject through the hot index's dense overlay. An overlay entry SHADOWS + // window+runs for its term, so it must be complete — the engine's + // promotion path guarantees this via history backfill; the injection + // mirrors that by carrying the legitimate id=1 alongside the collision. + fx.store.hotIdx.overlay.AddTo(gammaKey, 1, 4) + + after := lookupOne(t, fx.store, gammaKey) + require.True(t, after.Contains(4), "fixture sanity: collision id=4 is now in the bitmap") + + // Query with the colliding term: only id=1 should survive the + // post-filter; id=4's bytes don't actually have topic1=gamma. + got, err := Query(context.Background(), fx.store, + []Filter{{Topics: [protocol.MaxTopicCount][]byte{nil, fx.t0cRaw}}}, + QueryOptions{Range: wholeChunk(t, fx.store)}) + require.NoError(t, err) + assert.Equal(t, []string{"evt-a-ac"}, dataSyms(t, got), + "post-filter must drop the collision-injected id=4") +} + // ─── Additional coverage: gap-closing tests ──────────────────────────────── // TestQuery_MixedSuccessFilterList exercises a filter list where one @@ -1131,19 +1171,19 @@ func TestQuery_InvalidFilterRejected(t *testing.T) { // multi-term group, and the overflow bucket is populated in any chunk holding // an event with topics. func TestUnionSlots(t *testing.T) { - first := roaring.BitmapOf(1, 2) - second := roaring.BitmapOf(3) - bitmaps := []*roaring.Bitmap{first, nil, second, nil} - - assert.Same(t, first, unionSlots(bitmaps, []int{0}), - "a lone bitmap is borrowed, not cloned") - assert.Nil(t, unionSlots(bitmaps, []int{1})) - assert.Nil(t, unionSlots(bitmaps, []int{1, 3}), + first := IDPostings([]uint32{1, 2}) + second := IDPostings([]uint32{3}) + postings := []Postings{first, {}, second, {}} + + assert.Equal(t, first, unionSlots(postings, []int{0}), + "a lone postings entry is borrowed, not cloned") + assert.False(t, unionSlots(postings, []int{1}).Present()) + assert.False(t, unionSlots(postings, []int{1, 3}).Present(), "a group absent from the index empties the filter") - assert.Same(t, second, unionSlots(bitmaps, []int{1, 2}), - "the one present bitmap in a group is borrowed too") - assert.Equal(t, []uint32{1, 2, 3}, unionSlots(bitmaps, []int{0, 2}).ToArray()) - assert.Equal(t, []uint32{1, 2}, first.ToArray(), "inputs must not be mutated") + assert.Equal(t, second, unionSlots(postings, []int{1, 2}), + "the one present entry in a group is borrowed too") + assert.Equal(t, []uint32{1, 2, 3}, unionSlots(postings, []int{0, 2}).SelectIDs(0, false)) + assert.Equal(t, []uint32{1, 2}, first.SelectIDs(0, false), "inputs must not be mutated") } // ─── Cold-reader parity coverage ──────────────────────────────────────── @@ -1236,7 +1276,7 @@ func freezeFixtureToColdReader(t *testing.T, hot *HotStore, chunkID chunk.ID) *C } } - require.NoError(t, cw.Finish(coldOffsets)) + require.NoError(t, cw.Commit(coldOffsets)) require.NoError(t, WriteColdIndex(context.Background(), chunkID, idx, dir, testIndexSecret)) cr, err := OpenColdReader(chunkID, dir, ColdReaderOptions{}) @@ -1573,16 +1613,16 @@ func TestMatches_FirstBatchHintSizesIO(t *testing.T) { // stream internally with no yield, so a consumer sees exactly the true // matches no matter how many false positives the index returns between // them. False positives are injected directly into the term bitmap, -// the same technique as TestQuery_PostFilterRejectsTermHashCollision. +// the same technique as TestMatches_PostFilterRejectsTermHashCollision. func TestMatches_DropsAreInvisible(t *testing.T) { fx := newQueryFixture(t) // topic1 == gamma legitimately matches id 1 only. Inject ids 2, 3, // and 4 as false positives, so the candidate set is {1, 2, 3, 4} // and every candidate after the true match drops. gammaKey := ComputeTermKey(fx.t0cRaw, FieldTopic1) - for _, fp := range []uint32{2, 3, 4} { - fx.store.index().AddTo(gammaKey, fp) - } + // An overlay entry holds the term's FULL id set, so the injection carries + // the true id 1 alongside the false positives. + fx.store.hotIdx.overlay.AddTo(gammaKey, 1, 2, 3, 4) filters := []Filter{{Topics: [protocol.MaxTopicCount][]byte{nil, fx.t0cRaw}}} for _, batch := range []int{512, 1} { @@ -1645,8 +1685,9 @@ func TestMatches_WindowANDLeavesBorrowedBitmapUntouched(t *testing.T) { // Promote contract A's term (real matches: ids 0, 1, 4) to dense // mode. The injected ids sit above the chunk's EventCount and every // window below, so they are clipped before any fetch. + fx.store.hotIdx.overlay.AddTo(key, 0, 1, 4) for id := uint32(100); id < 200; id++ { - fx.store.index().AddTo(key, id) + fx.store.hotIdx.overlay.AddTo(key, id) } before := lookupOne(t, fx.store, key) require.GreaterOrEqual(t, before.GetCardinality(), uint64(100), @@ -1670,52 +1711,6 @@ func TestMatches_WindowANDLeavesBorrowedBitmapUntouched(t *testing.T) { assert.Equal(t, []uint32{0, 1, 4}, matchOrdinals(full)) } -// TestQuery_PostFilterRejectsTermHashCollision pins the defensive -// post-filter: if a bitmap entry survives the index lookup but the -// underlying event's bytes don't actually match the filter clause, -// Query must drop it. TermKey is xxh3_128(field || value), a -// non-cryptographic hash on attacker-controllable values; a -// collision (or a corrupt index) could otherwise leak the wrong -// event through Query. -// -// We force the case by injecting a false-positive entry directly -// into the mirror's bitmap for the "topic1 == gamma" term, -// equivalent to what a real collision would produce. -func TestQuery_PostFilterRejectsTermHashCollision(t *testing.T) { - fx := newQueryFixture(t) - - // gamma's term legitimately matches id=1 only (evt-a-ac with - // topic1=gamma). Inject id=4 (evt-a-b: topic0=beta only, - // no topic1) into the same bitmap to simulate a collision. - gammaKey := ComputeTermKey(fx.t0cRaw, FieldTopic1) - before := lookupOne(t, fx.store, gammaKey) - require.True(t, before.Contains(1), "fixture sanity: id=1 indexes topic1=gamma") - require.False(t, before.Contains(4), "fixture sanity: id=4 not yet in topic1=gamma bitmap") - - filters := []Filter{{Topics: [protocol.MaxTopicCount][]byte{nil, fx.t0cRaw}}} - want, err := Query(context.Background(), fx.store, filters, - QueryOptions{Range: wholeChunk(t, fx.store)}) - require.NoError(t, err) - require.Len(t, want, 1, "fixture sanity: exactly one true match before injection") - - // ConcurrentBitmaps.AddTo is the writer-side API the ingest path uses - // to register (term, eventID) pairs. No concurrent ingest is running - // in this test, so the single-writer contract is satisfied. - fx.store.index().AddTo(gammaKey, 4) - - after := lookupOne(t, fx.store, gammaKey) - require.True(t, after.Contains(4), "fixture sanity: collision id=4 is now in the bitmap") - - // Query with the colliding term: the result must be invariant under - // the injection: only id=1 survives the post-filter, since id=4's - // bytes don't actually have topic1=gamma. - got, err := Query(context.Background(), fx.store, filters, - QueryOptions{Range: wholeChunk(t, fx.store)}) - require.NoError(t, err) - assert.Equal(t, dataSyms(t, want), dataSyms(t, got), - "post-filter must drop the collision-injected id=4") -} - // TestPostFilter_OrdinalAlignmentWithLeadingDrop pins that a surviving // match carries its own ordinal when a dropped candidate is at a // LOWER ordinal in the same batch. Survivor-index alignment (the diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go new file mode 100644 index 000000000..c913966db --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go @@ -0,0 +1,139 @@ +package event + +import ( + "encoding/binary" + "math" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func decodeAll(t *testing.T, val []byte) map[TermKey][]uint32 { + t.Helper() + out := map[TermKey][]uint32{} + require.NoError(t, DecodePackedRow(val, func(term TermKey, ids []uint32) { + out[term] = append([]uint32(nil), ids...) + })) + return out +} + +func TestPackedIndexRow_RoundTrip(t *testing.T) { + var t1, t2 TermKey + t1[0] = 0x01 + t2[0] = 0x02 + in := map[TermKey][]uint32{ + t1: {0, 1, 2, 500}, + // Non-contiguous, large absolute IDs exercise multi-byte varints. + t2: {7, 1 << 20, 1<<20 + 1}, + } + assert.Equal(t, in, decodeAll(t, AppendPackedRow(nil, in))) +} + +func TestPackedIndexRow_RejectsCorruption(t *testing.T) { + var term TermKey + term[0] = 0xaa + good := AppendPackedRow(nil, map[TermKey][]uint32{term: {3, 4}}) + + nop := func(TermKey, []uint32) {} + // Truncated term header. + require.ErrorContains(t, DecodePackedRow(good[:10], nop), "trailing bytes") + // id count exceeding remaining bytes (truncate the ID area). + require.ErrorContains(t, DecodePackedRow(good[:len(good)-1], nop), "exceeds") + // Zero delta = duplicate ID within a term. + dup := make([]byte, 0, len(term)+3) + dup = append(dup, term[:]...) + dup = append(dup, 2 /* count */, 3 /* first id */, 0 /* zero delta */) + require.ErrorContains(t, DecodePackedRow(dup, nop), "zero delta") +} + +// TestAppendRecordIDs_ShortTail pins the guard the sealed-run probe's walk +// leans on: a fragment shorter than the 16-byte term key is an error, not an +// over-read of the window buffer. +func TestAppendRecordIDs_ShortTail(t *testing.T) { + var term TermKey + term[0] = 0xaa + rec := AppendTermPostings(nil, term, []uint32{3, 4}) + for n := range 16 { + ids, adv, err := appendRecordIDs(nil, rec[:n]) + require.ErrorContains(t, err, "want 16-byte term", "%d-byte fragment", n) + assert.Zero(t, adv) + assert.Nil(t, ids) + } + ids, adv, err := appendRecordIDs(nil, rec) + require.NoError(t, err) + assert.Equal(t, []uint32{3, 4}, ids) + assert.Equal(t, len(rec), adv) +} + +// FuzzAppendRecordIDs is the pin that lets a SECOND decoder exist beside the +// shared codec (codec.go's one-definition-site rule: the near-2^64 wrap fix +// once had to be applied to both). Over arbitrary bytes, appendRecordIDs and +// decodePostings — which delegates to runspill.DecodeAscendingIDs, THE +// validation core — must agree on accept-vs-reject, on the ids produced, and +// on how many bytes the record spans. That is a stronger guarantee over the +// input space than shared code plus table tests. +func FuzzAppendRecordIDs(f *testing.F) { + var term TermKey + term[0] = 0xaa + rec := func(tail ...byte) []byte { + return append(append([]byte{}, term[:]...), tail...) + } + seq := make([]uint32, 300) + for i := range seq { + seq[i] = uint32(i) * 3 + } + // Real records, built by the package's own encoder. + for _, ids := range [][]uint32{ + {0}, + {7}, + {0, 1, 2, 500}, + {3, 4, 8}, + {1 << 20, 1<<20 + 1, 1 << 21}, + {0, math.MaxUint32}, + {math.MaxUint32}, + seq, + } { + f.Add(AppendTermPostings(nil, term, ids)) + } + // Two records back to back: only the first may be consumed. + f.Add(AppendTermPostings(AppendTermPostings(nil, term, []uint32{1, 2}), term, []uint32{3})) + // Structural corruption: empty, short tails, a count of zero, a count + // past the remaining bytes, a zero delta, and a delta near 2^64 (the + // wrap the raw-varint reject exists for). + f.Add([]byte{}) + f.Add(make([]byte, 15)) + f.Add(make([]byte, 16)) + f.Add(rec(0x00)) + f.Add(rec(0x7f, 0x01)) + f.Add(rec(0x02, 0x03, 0x00)) + f.Add(rec(append([]byte{0x01}, binary.AppendUvarint(nil, math.MaxUint64)...)...)) + f.Add(rec(append([]byte{0x02, 0x01}, binary.AppendUvarint(nil, math.MaxUint64-1)...)...)) + f.Add(rec(append([]byte{0x02, 0x01}, binary.AppendUvarint(nil, math.MaxUint32)...)...)) + + f.Fuzz(func(t *testing.T, in []byte) { + got, n, err := appendRecordIDs(nil, in) + if len(in) < 16 { + require.Error(t, err, "a fragment shorter than a term key must be rejected") + return + } + want, rest, wantErr := decodePostings(in[16:], nil) + if wantErr != nil { + require.Error(t, err, "the shared codec rejected what the fused decoder accepted") + return + } + require.NoError(t, err, "the fused decoder rejected what the shared codec accepted") + require.Equal(t, want, got, "ids differ from the shared codec's") + require.Equal(t, len(in)-len(rest), n, "record length differs from the bytes the codec consumed") + require.Equal(t, PackedRecordLen(in), n, "record length differs from PackedRecordLen's walk") + + // The accumulator contract: ids land after whatever dst already + // holds, and nothing before them moves. + pre := []uint32{9, 9, 9} + out, n2, err2 := appendRecordIDs(slices.Clone(pre), in) + require.NoError(t, err2) + require.Equal(t, n, n2) + require.Equal(t, append(slices.Clone(pre), want...), out) + }) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go new file mode 100644 index 000000000..b2f646abd --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go @@ -0,0 +1,236 @@ +package event + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "math" + "slices" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" +) + +// This file is the SHARED packed-row codec: the byte encoding for a +// term-sorted batch of (term → ascending event IDs) postings. It is used by +// +// - the hot events store's per-ledger index row (one row per ledger in the +// hot chunk DB — the durable truth the hot sorted-run tier replays), and +// - the cold build's spill runs (the sorted runs the spill-and-merge +// external index build writes and k-way merges), +// +// which is what lets the freeze path merge hot rows and cold runs with one +// codec. Layout, per term (terms byte-sorted, so rows are mergeable): +// +// 16-byte term ‖ uvarint id-count ‖ ids as uvarints +// (first absolute, then strictly-positive deltas) +// +// Event IDs are strictly ascending per term, so deltas are always ≥ 1 — +// DecodePackedRow rejects a zero delta as corruption. + +// AppendPackedRow appends one packed row for perKeyIDs to dst and returns it. +// Each term's ID list must already be ascending (the single-writer ingest +// order); terms are byte-sorted here so the encoding is deterministic and +// merge-friendly. Delta-varint keeps a term with k sequential IDs to ~1 byte +// per ID. +func AppendPackedRow(dst []byte, perKeyIDs map[TermKey][]uint32) []byte { + terms := make([]TermKey, 0, len(perKeyIDs)) + for k := range perKeyIDs { + terms = append(terms, k) + } + slices.SortFunc(terms, func(a, b TermKey) int { return bytes.Compare(a[:], b[:]) }) + for _, t := range terms { + dst = AppendTermPostings(dst, t, perKeyIDs[t]) + } + return dst +} + +// DecodePackedRow parses a packed row, yielding each term's absolute +// event-ID list into add. IDs within a term must be strictly increasing +// (zero deltas rejected); every structural violation is an error — rows are +// untrusted input to warmup and to the cold merge. ids is reused across +// terms; add must consume it before returning (callers copy or fold). +func DecodePackedRow(val []byte, add func(term TermKey, ids []uint32)) error { + var ids []uint32 + for len(val) > 0 { + if len(val) < 16 { + return fmt.Errorf("events: packed row: %d trailing bytes, want 16-byte term", len(val)) + } + var term TermKey + copy(term[:], val[:16]) + var err error + if ids, val, err = decodePostings(val[16:], ids); err != nil { + return fmt.Errorf("events: packed row: %w", err) + } + add(term, ids) + } + return nil +} + +// decodePostings decodes the posting list at the head of b — uvarint count, +// then the IDs — into ids, and returns the unconsumed remainder. Errors are +// unwrapped; callers add their own context, matching DecodeAscendingIDs. +func decodePostings(b []byte, ids []uint32) ([]uint32, []byte, error) { + count, n := binary.Uvarint(b) + if n <= 0 { + return nil, nil, errors.New("bad id-count uvarint") + } + b = b[n:] + // Each ID takes ≥1 byte, so a count beyond the remaining bytes is + // structurally impossible — reject before allocating for it. + if count == 0 || count > uint64(len(b)) { + return nil, nil, fmt.Errorf("id count %d exceeds %d remaining bytes", count, len(b)) + } + next := func() (uint64, error) { + v, k := binary.Uvarint(b) + if k <= 0 { + return 0, errors.New("bad event-id uvarint") + } + b = b[k:] + return v, nil + } + // Grow once rather than letting append double its way there. count is + // bounded by the remaining bytes above, so it cannot force a large + // allocation from a small record. + if uint64(cap(ids)) < count { + ids = make([]uint32, 0, count) + } + out, err := DecodeAscendingIDs(next, count, ids[:0]) + if err != nil { + return nil, nil, err + } + return out, b, nil +} + +// appendRecordIDs decodes ONE packed record (16-byte term ‖ uvarint count +// ‖ delta uvarints) at the head of rec, appending its ids to dst, and +// returns the record's byte length. It is decodePostings' loop with the +// closure fused away: the byte cursor is a local index rather than a +// captured slice header (the closure's escaped one costs a funcval call and +// a heap slice-header write per id), and the ids land in the caller's +// accumulator rather than in a per-record buffer that is then copied out. +// One walk, one write — the sealed-run probe (sealedRun.lookup) neither +// pre-sizes the record with PackedRecordLen nor re-copies its ids. +// +// The validation is character-for-character DecodeAscendingIDs': raw-varint +// reject BEFORE accumulation, zero delta, uint32 overflow, plus the +// pre-allocation guard on count (a THIRD copy of that guard, beside +// decodePostings above and runspill's RunReader). A second decoder beside +// the shared codec is exactly what codec.go's one-definition-site rule warns +// about — the near-2^64 wrap fix once had to be applied twice — so +// FuzzAppendRecordIDs pins the two against each other over arbitrary bytes: +// identical ids on accept, identical accept/reject classification. That is +// the pin; keep it green rather than letting the two drift. +// +// Errors are unwrapped (callers add their own context) and leave dst holding +// whatever the partial record contributed — the caller discards the +// accumulator with the error. +func appendRecordIDs(dst []uint32, rec []byte) ([]uint32, int, error) { + // A run's fences land on record boundaries, so a well-formed window + // cannot end mid-key; the guard keeps a truncated one an error instead + // of an out-of-range panic in the 16-byte key slice below. + if len(rec) < 16 { + return dst, 0, fmt.Errorf("%d bytes, want 16-byte term", len(rec)) + } + count, m := binary.Uvarint(rec[16:]) + if m <= 0 { + return dst, 0, errors.New("bad id-count uvarint") + } + i := 16 + m + // Each ID takes ≥1 byte, so a count beyond the remaining bytes is + // structurally impossible — reject before growing for it. + //nolint:gosec // i ≤ len(rec): binary.Uvarint never reports more bytes than it was given + if count == 0 || count > uint64(len(rec)-i) { + return dst, 0, fmt.Errorf("id count %d exceeds %d remaining bytes", count, len(rec)-i) + } + // Grow once, off the exact decoded count, so the shared accumulator does + // not re-grow per record. count is bounded by the remaining bytes above, + // so it cannot force a large allocation from a small record. + dst = slices.Grow(dst, int(count)) //nolint:gosec // count ≤ len(rec)-i, an int + var prev uint64 + for j := range count { + v, k := binary.Uvarint(rec[i:]) + if k <= 0 { + return dst, 0, errors.New("bad event-id uvarint") + } + i += k + // Reject the raw varint before accumulating: a crafted delta near + // 2^64 would wrap prev+v back under MaxUint32 and smuggle a + // non-ascending ID past the overflow check (postings are untrusted). + if v > math.MaxUint32 { + return dst, 0, fmt.Errorf("delta %d overflows uint32", v) + } + abs := v + if j > 0 { + if v == 0 { + return dst, 0, errors.New("zero delta") + } + abs = prev + v + } + if abs > math.MaxUint32 { + return dst, 0, fmt.Errorf("event id %d overflows uint32", abs) + } + dst = append(dst, uint32(abs)) + prev = abs + } + return dst, i, nil +} + +// DecodePostings decodes one term's ID list as written by AppendPostings. b +// must hold exactly one posting list and nothing else: trailing bytes are +// rejected, because the cold index sizes its records from the writer and any +// slack means the record was not written by this codec. +// +// The result is freshly allocated, so it can outlive the buffer it was decoded +// from and cannot alias a sibling decode. +func DecodePostings(b []byte) ([]uint32, error) { + out, rest, err := decodePostings(b, nil) + if err != nil { + return nil, fmt.Errorf("events: postings: %w", err) + } + if len(rest) != 0 { + return nil, fmt.Errorf("events: postings: %d trailing bytes", len(rest)) + } + return out, nil +} + +// PackedRecordLen returns the byte length of the packed record at the head +// of b (16-byte term + uvarint count + count delta uvarints). b must hold a +// whole, previously validated record — callers use this to walk or slice +// rows whose contents were validated at decode time. +func PackedRecordLen(b []byte) int { + n := 16 + count, m := binary.Uvarint(b[n:]) + n += m + for range count { + _, m := binary.Uvarint(b[n:]) + n += m + } + return n +} + +// The postings codec (AppendTermPostings / TermPostingsLen / DecodeAscendingIDs) +// lives in runspill, the innermost package that needs it — this package and +// runspill share one body, imported downward, so the run files and the packed +// index rows cannot drift apart. These delegates keep this package's API. + +// AppendTermPostings appends ONE term's record (term ‖ uvarint count ‖ +// delta-varint ascending ids) to dst. +func AppendTermPostings(dst []byte, term TermKey, ids []uint32) []byte { + return runspill.AppendTermPostings(dst, term, ids) +} + +// TermPostingsLen returns the encoded byte length AppendTermPostings would +// produce for ids without encoding. +func TermPostingsLen(ids []uint32) int { return runspill.TermPostingsLen(ids) } + +// AppendPostings appends one posting list (uvarint count, then delta-varint +// ascending ids) to dst — the term-less half of AppendTermPostings, for +// consumers that already know which term they are reading. +func AppendPostings(dst []byte, ids []uint32) []byte { return runspill.AppendPostings(dst, ids) } + +// DecodeAscendingIDs decodes count delta-varint ids via next, validating +// ascending order and uint32 range (postings are untrusted input). +func DecodeAscendingIDs(next func() (uint64, error), count uint64, ids []uint32) ([]uint32, error) { + return runspill.DecodeAscendingIDs(next, count, ids) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/postings.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/postings.go new file mode 100644 index 000000000..8df0ddeb9 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/postings.go @@ -0,0 +1,171 @@ +package event + +import ( + "slices" + + "github.com/RoaringBitmap/roaring/v2" +) + +// Postings is one term's event IDs in whichever form the store already had +// them: an ascending ID slice for a delta-coded term, a bitmap for a roaring +// one. Intersect explains why the un-materialized form is worth carrying. +// +// The zero value means the term is absent. A present term always holds at +// least one posting, so that cannot be confused with an empty set. +// +// Treat a Postings and anything it returns as READ-ONLY: a bitmap-backed one +// may be a store's live mirror snapshot, so mutating it corrupts the store. +type Postings struct { + ids []uint32 + bm *roaring.Bitmap +} + +// IDPostings wraps an ID slice, which MUST be strictly ascending. That is not +// checked, and violating it does not error: ClipRange binary-searches the slice +// and Intersect walks it with a cursor that never looks back, so the result is +// silently wrong. +// +// An empty slice is absent. +func IDPostings(ids []uint32) Postings { + if len(ids) == 0 { + return Postings{} + } + return Postings{ids: ids} +} + +// BitmapPostings wraps a bitmap. A nil or empty bitmap is absent. +func BitmapPostings(bm *roaring.Bitmap) Postings { + if bm == nil || bm.IsEmpty() { + return Postings{} + } + return Postings{bm: bm} +} + +// Present reports whether the term was found. +func (p Postings) Present() bool { return p.ids != nil || p.bm != nil } + +// Cardinality returns the number of postings. +func (p Postings) Cardinality() uint64 { + if p.ids != nil { + return uint64(len(p.ids)) + } + if p.bm != nil { + return p.bm.GetCardinality() + } + return 0 +} + +// IDs returns the backing ID slice, or nil when this Postings is bitmap-backed. +func (p Postings) IDs() []uint32 { return p.ids } + +// Contains reports membership. +func (p Postings) Contains(id uint32) bool { + if p.ids != nil { + _, ok := slices.BinarySearch(p.ids, id) + return ok + } + return p.bm != nil && p.bm.Contains(id) +} + +// Bitmap returns a read-only bitmap over these postings. A bitmap-backed +// Postings hands back the store's own; an ID-backed one materializes. +// +// Materializing does not retain p.ids, and it must not: for a sparse term +// those are the store's LIVE published ids (ConcurrentBitmaps.Get hands the +// slice straight out). roaring v2.18.2's BitmapOf is NewBitmap followed by +// AddMany, and AddMany walks the slice copying each value into a container — +// the bitmap keeps no reference to the argument. That is why there is no +// defensive clone here: it would allocate a second copy of every sparse +// term's ids for nothing. +func (p Postings) Bitmap() *roaring.Bitmap { + if p.bm != nil { + return p.bm + } + return roaring.BitmapOf(p.ids...) +} + +// ClipRange returns the postings that fall in [start, end). The result is +// read-only like the receiver, and may share its backing memory. +func (p Postings) ClipRange(start, end uint32) Postings { + if start >= end || !p.Present() { + return Postings{} + } + if p.ids != nil { + lo, _ := slices.BinarySearch(p.ids, start) + hi, _ := slices.BinarySearch(p.ids, end) + // Capacity is clamped so a downstream append cannot overwrite a + // posting another reader can still see. + return IDPostings(p.ids[lo:hi:hi]) + } + if p.bm.GetCardinality() == 0 { + // Minimum and Maximum fault on a bitmap that holds containers but no + // postings. Producers do not make one and the cold decode rejects + // them, so this is insurance, paid once per query rather than per + // term. + return Postings{} + } + if p.bm.Minimum() >= start && p.bm.Maximum() < end { + // Nothing to clip. Worth checking because the window bitmap below + // costs O(range width) rather than O(cardinality) — spanning a whole + // chunk it is ~10us and 286 allocations, whatever the postings are — + // and a full-range query is the common one. + return p + } + window := roaring.New() + window.AddRange(uint64(start), uint64(end)) + // Fresh result: p.bm may be the store's live snapshot, so it cannot be + // intersected in place. + return BitmapPostings(roaring.And(p.bm, window)) +} + +// SelectIDs returns up to maxIDs postings in ascending order, keeping the +// lowest when descending is false and the highest when it is true. maxIDs <= 0 +// returns all of them. The result is read-only and may share the receiver's +// memory. +// +// The cap lands here rather than after the caller's own filtering, so a short +// result does not mean the postings are exhausted. +func (p Postings) SelectIDs(maxIDs int, descending bool) []uint32 { + if p.ids != nil { + // Capacities are clamped for the reason ClipRange gives. + n := len(p.ids) + if maxIDs <= 0 || maxIDs >= n { + return p.ids[:n:n] + } + if descending { + return p.ids[n-maxIDs : n : n] + } + return p.ids[:maxIDs:maxIDs] + } + if p.bm == nil { + return nil + } + + card := p.bm.GetCardinality() + limit := card + if maxIDs > 0 && uint64(maxIDs) < limit { + limit = uint64(maxIDs) + } + // limit <= card (a chunk's event-id space, max ~10M today) and <= maxIDs + // (int), so the int conversions below are safe. + if descending && limit < card { + ids := make([]uint32, 0, limit) + ri := p.bm.ReverseIterator() + for ri.HasNext() && uint64(len(ids)) < limit { + ids = append(ids, ri.Next()) + } + slices.Reverse(ids) // reverse-iterator order is descending, we want ascending + return ids + } + ids := make([]uint32, limit) + mi := p.bm.ManyIterator() + filled := 0 + for filled < int(limit) { //nolint:gosec // limit <= maxIDs (int) <= MaxInt + n := mi.NextMany(ids[filled:]) + if n == 0 { + break + } + filled += n + } + return ids[:filled] +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/postings_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/postings_test.go new file mode 100644 index 000000000..3dac609bd --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/postings_test.go @@ -0,0 +1,169 @@ +package event + +import ( + "encoding/hex" + "testing" + + "github.com/RoaringBitmap/roaring/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPostingsForms(t *testing.T) { + ids := []uint32{3, 9, 40, 41, 9000} + + for _, tc := range []struct { + name string + post Postings + wantIDs bool + }{ + {"ids", IDPostings(ids), true}, + {"bitmap", BitmapPostings(roaring.BitmapOf(ids...)), false}, + } { + t.Run(tc.name, func(t *testing.T) { + require.True(t, tc.post.Present()) + assert.Equal(t, uint64(len(ids)), tc.post.Cardinality()) + assert.Equal(t, tc.wantIDs, tc.post.IDs() != nil, + "IDs() is the planner's signal that it can drive from this side") + + for _, id := range ids { + assert.True(t, tc.post.Contains(id), "missing %d", id) + } + for _, id := range []uint32{0, 2, 4, 39, 42, 8999, 9001} { + assert.False(t, tc.post.Contains(id), "unexpected %d", id) + } + + assert.Equal(t, ids, tc.post.Bitmap().ToArray()) + }) + } +} + +// TestPostingsAbsent pins the zero value as the miss signal, which is what +// LookupKeys returns for a key the index does not hold. +func TestPostingsAbsent(t *testing.T) { + for _, tc := range []struct { + name string + post Postings + }{ + {"zero value", Postings{}}, + {"nil ids", IDPostings(nil)}, + {"empty ids", IDPostings([]uint32{})}, + {"nil bitmap", BitmapPostings(nil)}, + {"empty bitmap", BitmapPostings(roaring.New())}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.False(t, tc.post.Present()) + assert.Zero(t, tc.post.Cardinality()) + assert.False(t, tc.post.Contains(0)) + assert.True(t, tc.post.Bitmap().IsEmpty()) + }) + } +} + +// TestPostingsBitmapBorrows pins that a bitmap-backed Postings hands back the +// store's own bitmap rather than a copy. The hot store's dense overlay relies +// on that to answer without a per-key Clone, which is also why callers must +// treat the result as read-only. +func TestPostingsBitmapBorrows(t *testing.T) { + bm := roaring.BitmapOf(1, 2, 3) + assert.Same(t, bm, BitmapPostings(bm).Bitmap()) + + // An ID-backed one has nothing to borrow, so it materializes fresh and + // mutating the result cannot reach the postings. + ids := []uint32{1, 2, 3} + p := IDPostings(ids) + p.Bitmap().Add(4) + assert.False(t, p.Contains(4)) + assert.Equal(t, []uint32{1, 2, 3}, ids) +} + +// TestPostingsClipRangeAndSelect checks both forms agree, since the ID form +// takes a completely different route: two binary searches and a subslice +// against a window bitmap and an intersection. +func TestPostingsClipRangeAndSelect(t *testing.T) { + ids := []uint32{3, 9, 40, 41, 9000} + + forms := map[string]Postings{ + "ids": IDPostings(ids), + "bitmap": BitmapPostings(roaring.BitmapOf(ids...)), + } + + for _, tc := range []struct { + name string + start, end uint32 + want []uint32 + }{ + {"everything", 0, 10000, ids}, + {"exact bounds are half-open", 3, 9000, []uint32{3, 9, 40, 41}}, + {"interior", 9, 42, []uint32{9, 40, 41}}, + {"between postings", 42, 8999, nil}, + {"empty range", 40, 40, nil}, + {"inverted range", 100, 10, nil}, + {"below everything", 0, 3, nil}, + {"above everything", 9001, 99999, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + for form, p := range forms { + got := p.ClipRange(tc.start, tc.end) + if tc.want == nil { + assert.False(t, got.Present(), form) + continue + } + require.True(t, got.Present(), form) + assert.Equal(t, tc.want, got.SelectIDs(0, false), form) + } + }) + } + + for _, tc := range []struct { + name string + maxIDs int + descending bool + want []uint32 + }{ + {"uncapped", 0, false, ids}, + {"uncapped descending is still ascending", 0, true, ids}, + {"cap keeps the lowest", 2, false, []uint32{3, 9}}, + {"descending cap keeps the highest", 2, true, []uint32{41, 9000}}, + {"cap above cardinality", 99, false, ids}, + {"negative cap means all", -1, false, ids}, + } { + t.Run(tc.name, func(t *testing.T) { + for form, p := range forms { + assert.Equal(t, tc.want, p.SelectIDs(tc.maxIDs, tc.descending), form) + } + }) + } +} + +// emptyRunContainerBitmap is a bitmap roaring's UnmarshalBinary accepts but +// that holds containers and no postings: one run container with zero +// intervals. Minimum and Maximum fault on it. +func emptyRunContainerBitmap(t *testing.T) *roaring.Bitmap { + t.Helper() + raw, err := hex.DecodeString("3b3000000100008713000000008713") + require.NoError(t, err) + bm := roaring.New() + require.NoError(t, bm.UnmarshalBinary(raw), "roaring must accept it, else this test proves nothing") + require.False(t, bm.IsEmpty(), "must have containers") + require.Zero(t, bm.GetCardinality(), "and no postings") + return bm +} + +// TestPostingsClipRangeToleratesPostingless pins that ClipRange does not fault +// on a bitmap with containers but no postings. Producers never make one, and +// the cold reader rejects it at decode, so this is the last line rather than +// the first. +func TestPostingsClipRangeToleratesPostingless(t *testing.T) { + p := BitmapPostings(emptyRunContainerBitmap(t)) + + assert.NotPanics(t, func() { + assert.False(t, p.ClipRange(0, 1_000_000).Present()) + }) + assert.NotPanics(t, func() { + assert.Empty(t, p.SelectIDs(0, false)) + }) + assert.NotPanics(t, func() { + assert.False(t, Intersect([]Postings{p, IDPostings([]uint32{1})}).Present()) + }) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/reader.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/reader.go index 870c7ca88..8b13fa31b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/reader.go @@ -11,8 +11,6 @@ import ( "fmt" "iter" - "github.com/RoaringBitmap/roaring/v2" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" ) @@ -90,38 +88,43 @@ type Reader interface { // Returns (nil, stores.ErrStoreClosed) after Close. Offsets() (*LedgerOffsets, error) - // LookupKeys returns bitmaps for each key, aligned positionally + // LookupKeys returns each key's postings, aligned positionally // with the input slice (result[i] corresponds to keys[i]). - // result[i] is nil if keys[i] has no matching events in this - // chunk — a per-key miss is not an error. + // result[i] is the zero Postings if keys[i] has no + // matching events in this chunk — a per-key miss is not an error. + // + // Ownership: callers MUST treat the result as read-only. Some of + // what comes back is the hot store's live state, either a bitmap or + // the store's own id slice, so writing to it corrupts the store for + // every other reader. // // ColdReader coalesces the underlying packfile reads into a // single ReadItems pass, fanning out across the worker count - // configured via ColdReaderOptions.Concurrency. HotStore returns - // snapshots of the live mirror shared by all readers of a term; - // a dense term written since its last lookup is cloned once, by - // the first reader to look it up, and that clone is then shared. - // - // Callers MUST treat returned bitmaps as read-only. Dense hot - // snapshots are shared with other readers; sparse hot terms - // return a fresh caller-owned bitmap per lookup; cold-path - // bitmaps are freshly unmarshaled and owned by the caller. See - // ConcurrentBitmaps.Get. + // configured via ColdReaderOptions.Concurrency. HotStore hands + // back the live mirror's published state: a dense term comes back + // as the snapshot shared by every reader of that term — one + // written since its last lookup is cloned once, by the first + // reader to look it up — and a sparse term as the store's own id + // slice, un-materialized. Cold-path postings are freshly decoded + // and owned by the caller. See ConcurrentBitmaps.Get. // // ctx cancels in-flight I/O on the cold path (MPHF load, // index.pack ReadAt); hot side checks ctx as a fast guard before // touching the in-memory mirror. - LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring.Bitmap, error) + LookupKeys(ctx context.Context, keys []TermKey) ([]Postings, error) // FetchEvents decodes events for the supplied chunk-relative // eventIDs and returns them positionally aligned with the input // slice (result[i] corresponds to eventIDs[i]). // - // eventIDs MUST be sorted ascending with no duplicates. Matches - // iterating a bitmap intersection (roaring.Bitmap.Iterator yields - // ascending) satisfies this for free. Both implementations - // validate the precondition up front and return a wrapped - // ErrUnsortedEventIDs on violation. + // eventIDs is READ-ONLY: it may be a window onto the hot store's + // published postings, so sorting or appending in place corrupts + // live store state, not just this call. + // + // eventIDs MUST be sorted ascending with no duplicates. + // Postings.SelectIDs yields ascending, so the coordinator satisfies + // this for free. Both implementations validate the precondition up + // front and return a wrapped ErrUnsortedEventIDs on violation. // // ctx cancels in-flight I/O; the cold path checks ctx between // scattered-read batches, the hot path checks between Gets. diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/codec.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/codec.go new file mode 100644 index 000000000..95848c755 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/codec.go @@ -0,0 +1,99 @@ +package runspill + +import ( + "encoding/binary" + "errors" + "fmt" + "math" +) + +// AppendTermPostings appends ONE term's record (term ‖ uvarint count ‖ +// delta-varint ascending ids) to dst — the streaming primitive under +// AppendPackedRow, used directly by producers that emit terms one at a time +// in already-sorted order (the cold build's spill slab and run merge). +func AppendTermPostings(dst []byte, term [16]byte, ids []uint32) []byte { + dst = append(dst, term[:]...) + return AppendPostings(dst, ids) +} + +// AppendPostings appends one term's ID list — uvarint count, then the IDs +// (first absolute, then strictly-positive deltas) — WITHOUT the 16-byte term +// prefix, for consumers that already know which term they are reading. +func AppendPostings(dst []byte, ids []uint32) []byte { + dst = binary.AppendUvarint(dst, uint64(len(ids))) + var prev uint32 + for j, id := range ids { + if j == 0 { + dst = binary.AppendUvarint(dst, uint64(id)) + } else { + dst = binary.AppendUvarint(dst, uint64(id-prev)) + } + prev = id + } + return dst +} + +// DecodeAscendingIDs decodes count event IDs (first absolute, then +// strictly-positive deltas) into ids, drawing raw uvarints from next. It is +// THE validation core for the packed-row ID stream — the slice-based +// DecodePackedRow above and runspill's streaming RunReader both delegate +// here, so the security-relevant invariants (raw-varint reject BEFORE +// accumulation, zero-delta reject, uint32 overflow) cannot drift between the +// two decoders again (the near-2^64 wrap fix once had to be applied to +// both). Errors are unwrapped; callers add their own context. +func DecodeAscendingIDs(next func() (uint64, error), count uint64, ids []uint32) ([]uint32, error) { + var prev uint64 + for i := range count { + v, err := next() + if err != nil { + return nil, err + } + // Reject the raw varint before accumulating: a crafted delta near + // 2^64 would wrap prev+v back under MaxUint32 and smuggle a + // non-ascending ID past the overflow check (postings are untrusted). + if v > math.MaxUint32 { + return nil, fmt.Errorf("delta %d overflows uint32", v) + } + abs := v + if i > 0 { + if v == 0 { + return nil, errors.New("zero delta") + } + abs = prev + v + } + if abs > math.MaxUint32 { + return nil, fmt.Errorf("event id %d overflows uint32", abs) + } + ids = append(ids, uint32(abs)) + prev = abs + } + return ids, nil +} + +// TermPostingsLen returns the encoded byte length AppendTermPostings would +// produce for ids (16-byte term + uvarint count + delta uvarints) without +// encoding — the length arithmetic lives beside the encoder so it cannot +// drift from it. +func TermPostingsLen(ids []uint32) int { + n := 16 + uvarintLen(uint64(len(ids))) + var prev uint32 + for i, id := range ids { + if i == 0 { + n += uvarintLen(uint64(id)) + } else { + n += uvarintLen(uint64(id - prev)) + } + prev = id + } + return n +} + +// uvarintLen is binary.PutUvarint's length without the buffer. +func uvarintLen(v uint64) int { + n := 1 + for v >= 0x80 { + v >>= 7 + n++ + } + return n +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go new file mode 100644 index 000000000..27e3baa53 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go @@ -0,0 +1,202 @@ +package runspill + +import ( + "bytes" + "errors" + "fmt" + "io" + "slices" +) + +// merge.go — the k-way run merge that feeds the cold index finalize: streams +// every run's (term, ids) records in global term order, UNIONING the postings +// of a term that appears in several runs, and emits each unique term exactly +// once. Single-goroutine by design: a per-chunk finalize merges ≤~100 runs in +// seconds, which does not earn the pipelined fan-in tree the txhash +// multi-chunk index build uses (txhash/cold_merge.go) — this borrows that +// code's proven heap discipline (siftDown min-heap, deterministic order, +// drain-to-EOF-verifies-integrity) without its machinery. +// +// Determinism: the heap orders by (term, run index), and per-term ID lists +// are unioned ascending, so the emitted stream is byte-deterministic +// regardless of goroutine scheduling or run enumeration order — the property +// the byte-identical-artifacts gate relies on. + +// mergeSource is one run's cursor in the heap. +type mergeSource struct { + r *RunReader + term [16]byte + ids []uint32 // owned copy of the reader's current (reused) ids slice +} + +// heapEntry is one heap slot: the source index, ordered by (term, idx). +// +// This stays a copy of the discipline, not an import of it. txhash's two +// BACKGROUND merges now share one heap (stores/txhash/merge_heap.go) because +// they share a package; hoisting siftDown across the engine boundary would +// first have to hold cold_merge.go's 30-36M keys/s under a benchstat of the +// bench `txindex` cell (bench/txindex.go) — that is the loop that pays. +type heapEntry struct { + term [16]byte + idx int +} + +func (e heapEntry) less(o heapEntry) bool { + if c := bytes.Compare(e.term[:], o.term[:]); c != 0 { + return c < 0 + } + return e.idx < o.idx +} + +func siftDown(h []heapEntry, i, n int) { + for { + left := 2*i + 1 + if left >= n { + break + } + j := left + if right := left + 1; right < n && h[right].less(h[j]) { + j = right + } + if !h[j].less(h[i]) { + break + } + h[i], h[j] = h[j], h[i] + i = j + } +} + +// MergeRuns opens every run in paths and streams the merged postings to emit +// in ascending term order, one call per unique term, IDs ascending and +// deduplicated across runs. An emit error aborts the merge and is returned +// verbatim; any run corruption (including a checksum mismatch, verified at +// each run's EOF) aborts with ErrCorruptRun — the caller abandons the chunk +// build. The ids slice passed to emit is reused; consume before returning. +func MergeRuns(paths []string, emit func(term [16]byte, ids []uint32) error) (err error) { + sources, h, oerr := openSources(paths) + defer func() { + for _, s := range sources { + if cerr := s.r.Close(); err == nil && cerr != nil { + err = cerr + } + } + }() + if oerr != nil { + return oerr + } + n := len(h) + for i := n/2 - 1; i >= 0; i-- { + siftDown(h, i, n) + } + + var out []uint32 + for n > 0 { + cur := h[0].term + out = out[:0] + // Drain every source currently positioned at cur (they surface at + // the heap root consecutively because term is the primary order). + for n > 0 && h[0].term == cur { + s := sources[h[0].idx] + out = unionAscending(out, s.ids) + ok, aerr := advanceSource(s) + if aerr != nil { + return aerr + } + if ok { + h[0] = heapEntry{term: s.term, idx: h[0].idx} + siftDown(h, 0, n) + } else { + n-- + if n > 0 { + h[0] = h[n] + siftDown(h, 0, n) + } + } + } + if err := emit(cur, out); err != nil { + return err + } + } + return nil +} + +// openSources opens every run and seeds the merge heap with each source's +// first term. On error the already-opened sources are still returned so the +// caller's deferred close releases them. +func openSources(paths []string) ([]*mergeSource, []heapEntry, error) { + sources := make([]*mergeSource, 0, len(paths)) + h := make([]heapEntry, 0, len(paths)) + for _, p := range paths { + r, oerr := OpenRun(p) + if oerr != nil { + return sources, nil, oerr + } + s := &mergeSource{r: r} + sources = append(sources, s) + ok, aerr := advanceSource(s) + if aerr != nil { + return sources, nil, fmt.Errorf("%s: %w", p, aerr) + } + if ok { + h = append(h, heapEntry{term: s.term, idx: len(sources) - 1}) + } + } + return sources, h, nil +} + +// advanceSource pulls the source's next record, copying the reader's reused +// ids into the source-owned slice. ok=false at clean EOF (checksum verified). +func advanceSource(s *mergeSource) (bool, error) { + term, ids, err := s.r.Next() + if errors.Is(err, io.EOF) { + return false, nil + } + if err != nil { + return false, err + } + s.term = term + s.ids = append(s.ids[:0], ids...) + return true, nil +} + +// unionAscending merges two ascending, internally-deduped ID lists into acc +// (which the caller owns), preserving ascending order and dropping +// cross-list duplicates. The common cases — acc empty, or disjoint ranges — +// stay O(len(b)). +// +// Deliberate duplicate of events.mergeAscending (events/intersect.go): the +// two carry different ownership contracts — an accumulator this one owns and +// may extend in place, versus a caller-supplied dst over read-only Postings +// backing — so there is nothing to version in lockstep. +func unionAscending(acc, b []uint32) []uint32 { + if len(acc) == 0 { + return append(acc, b...) + } + if len(b) == 0 { + return acc + } + // Fast path: strictly after the current tail (runs usually cover + // disjoint ledger ranges, so IDs concatenate). + if b[0] > acc[len(acc)-1] { + return append(acc, b...) + } + merged := make([]uint32, 0, len(acc)+len(b)) + i, j := 0, 0 + for i < len(acc) && j < len(b) { + switch { + case acc[i] < b[j]: + merged = append(merged, acc[i]) + i++ + case acc[i] > b[j]: + merged = append(merged, b[j]) + j++ + default: + merged = append(merged, acc[i]) + i++ + j++ + } + } + merged = append(merged, acc[i:]...) + merged = append(merged, b[j:]...) + return slices.Clip(merged) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go new file mode 100644 index 000000000..3aacfaa78 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go @@ -0,0 +1,128 @@ +package runspill + +import ( + "bytes" + "math/rand" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// spillRun writes one run from (term -> ids) pairs appended in map order. +func spillRun(t *testing.T, dir, name string, recs map[[16]byte][]uint32) string { + t.Helper() + slab := NewSlab(4 << 20) + for k, ids := range recs { + for _, id := range ids { + require.True(t, slab.Append(k, id)) + } + } + path := filepath.Join(dir, name) + spillSlab(t, slab, path) + return path +} + +func collectMerge(t *testing.T, paths []string) map[[16]byte][]uint32 { + t.Helper() + out := map[[16]byte][]uint32{} + var order [][16]byte + require.NoError(t, MergeRuns(paths, func(term [16]byte, ids []uint32) error { + _, dup := out[term] + require.False(t, dup, "term emitted twice") + out[term] = append([]uint32(nil), ids...) + order = append(order, term) + return nil + })) + for i := 1; i < len(order); i++ { + require.Equal(t, -1, cmpTermCompat(order[i-1], order[i]), "terms must emit ascending") + } + return out +} + +// TestMergeRuns_UnionsAcrossRuns: overlapping and disjoint terms across three +// runs, including a term present in all runs with interleaved and duplicate +// IDs — the union must be ascending and deduped. +func TestMergeRuns_UnionsAcrossRuns(t *testing.T) { + dir := t.TempDir() + shared := key(1) + paths := []string{ + spillRun(t, dir, "a.run", map[[16]byte][]uint32{ + shared: {0, 3, 6}, key(2): {10, 11}, + }), + spillRun(t, dir, "b.run", map[[16]byte][]uint32{ + shared: {1, 3, 7}, key(3): {20}, + }), + spillRun(t, dir, "c.run", map[[16]byte][]uint32{ + shared: {2, 8}, key(2): {12}, + }), + } + got := collectMerge(t, paths) + assert.Equal(t, map[[16]byte][]uint32{ + shared: {0, 1, 2, 3, 6, 7, 8}, + key(2): {10, 11, 12}, + key(3): {20}, + }, got) +} + +// TestMergeRuns_MatchesReference: randomized many-run property test against +// a reference union. +func TestMergeRuns_MatchesReference(t *testing.T) { + rng := rand.New(rand.NewSource(11)) + dir := t.TempDir() + want := map[[16]byte][]uint32{} + paths := make([]string, 0, 12) + nextID := uint32(0) + for run := range 12 { + recs := map[[16]byte][]uint32{} + for range 500 { + k := key(byte(rng.Intn(60))) + id := nextID + nextID += uint32(rng.Intn(2)) // duplicates across appends allowed + recs[k] = append(recs[k], id) + } + paths = append(paths, spillRun(t, dir, filepath.Base(t.Name())+string(rune('a'+run))+".run", recs)) + for k, ids := range recs { + want[k] = unionAscending(want[k], dedupAscending(ids)) + } + } + assert.Equal(t, want, collectMerge(t, paths)) +} + +// dedupAscending mirrors the slab's dedup for the reference model (input is +// ascending with possible repeats). +func dedupAscending(ids []uint32) []uint32 { + out := ids[:0:0] + for _, id := range ids { + if n := len(out); n == 0 || out[n-1] != id { + out = append(out, id) + } + } + return out +} + +// TestMergeRuns_SingleRunPassThrough: one run streams through unchanged. +func TestMergeRuns_SingleRunPassThrough(t *testing.T) { + dir := t.TempDir() + recs := map[[16]byte][]uint32{key(5): {1, 2, 3}, key(9): {7}} + got := collectMerge(t, []string{spillRun(t, dir, "one.run", recs)}) + assert.Equal(t, recs, got) +} + +// TestMergeRuns_EmptyInput: no runs, no emits, no error. +func TestMergeRuns_EmptyInput(t *testing.T) { + require.NoError(t, MergeRuns(nil, func([16]byte, []uint32) error { + t.Fatal("emit must not be called") + return nil + })) +} + +func TestUnionAscending(t *testing.T) { + assert.Equal(t, []uint32{1, 2, 3}, unionAscending(nil, []uint32{1, 2, 3})) + assert.Equal(t, []uint32{1, 2, 3, 4}, unionAscending([]uint32{1, 2}, []uint32{3, 4})) + assert.Equal(t, []uint32{1, 2, 3, 5}, unionAscending([]uint32{1, 3}, []uint32{2, 3, 5})) + assert.Equal(t, []uint32{1, 2}, unionAscending([]uint32{1, 2}, nil)) +} + +func cmpTermCompat(a, b [16]byte) int { return bytes.Compare(a[:], b[:]) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go new file mode 100644 index 000000000..5cace8d01 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go @@ -0,0 +1,476 @@ +// Package runspill is the cold events build's external-memory spill: a +// pointer-free, byte-capped slab of (term, eventID) records that sorts in +// place and spills as a term-sorted run file in the shared packed-row +// encoding (AppendTermPostings). Runs are scratch — non-durable, +// wiped on retry — but checksummed: the merge that consumes them must fail +// loudly on corruption rather than build a wrong index (the chunk retry +// contract rebuilds from scratch). +// +// The slab holds fixed 20-byte records (16-byte term ‖ 4-byte big-endian +// event ID). Big-endian IDs make the composite record memcmp-ordered: +// sorting by plain bytes.Compare yields (term ascending, ID ascending), +// which is exactly the (term-sorted, per-term-ascending-IDs) order the +// packed-row encoding requires. +package runspill + +import ( + "bufio" + "bytes" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "os" + "sort" +) + +// RecordSize is one slab record: 16-byte term ‖ 4-byte BE event ID. +const RecordSize = 16 + 4 + +// runMagic heads every run file; a version bump changes the letter. +var runMagic = [4]byte{'E', 'V', 'R', '3'} //nolint:gochecknoglobals // fixed format tag + +// runMagicPre is the pre-release format this one replaced: the SAME framing +// and record encoding, but records keyed by the RAW term instead of +// BlindKey(secret, term) — the blind-at-seal flip. Nothing in the container +// can tell the two apart (a run is just bytes; the reader never interprets a +// key), so the magic IS the tripwire: without the bump a chunk sealed by the +// pre-flip build would merge its raw-keyed runs under a blinded tail and +// produce an index whose queries silently miss ~the whole chunk. There is no +// migration — a chunk carrying one is a loud open failure (OpenRun), the +// same posture as txhash's TXHRUN01. +var runMagicPre = [4]byte{'E', 'V', 'R', '2'} //nolint:gochecknoglobals // fixed format tag + +// HeaderLen is the run-file header size: magic (4) ‖ u64 payload length (8) ‖ +// u64 record count (8). Record offsets within the payload are relative to the +// END of this header — exported so consumers that pread records directly (the +// hot index's sealed-run lookup) stay aligned with the framing if it ever +// changes. The record count lets a reopen size its routing state (bloom, +// fences) exactly before draining, instead of buffering per-record state for +// a second pass; OpenRun bounds it and the drain cross-checks it. +const HeaderLen = 20 + +// minRecordBytes is the smallest possible encoded record: 16-byte term ‖ +// 1-byte id count ‖ 1-byte id. Bounds how many records a payload can hold, +// so a corrupt header count is rejected before it can size an allocation. +const minRecordBytes = 18 + +// crcTable is CRC-32C (Castagnoli), the stdlib-available integrity check for +// scratch runs. +var crcTable = crc32.MakeTable(crc32.Castagnoli) //nolint:gochecknoglobals // fixed table + +// ErrCorruptRun reports a run file whose structure or checksum does not +// verify. The consumer must abandon the chunk build (retry rebuilds from +// scratch); there is nothing to repair. +var ErrCorruptRun = errors.New("runspill: corrupt run file") + +// Slab is the byte-capped in-memory record buffer. Not safe for concurrent +// use: the producer appends on one goroutine and hands the full slab to a +// background sorter (double-buffering is the caller's composition). +type Slab struct { + buf []byte + // ids is EmitSorted's reused postings buffer. Slab-owned so that its + // lifetime rides the same hand-off that protects buf: whoever holds the + // slab holds the buffer, and nothing else can touch either. + ids []uint32 +} + +// NewSlab returns a slab that accepts records until capBytes is reached. +// Capacity is allocated up front — the slab never reallocates, so a +// handed-off slab's memory is stable for the background sorter. +func NewSlab(capBytes int) *Slab { + capBytes -= capBytes % RecordSize + return &Slab{buf: make([]byte, 0, capBytes)} +} + +// Append adds one record. It reports false — WITHOUT appending — when the +// slab is full; the caller rotates slabs and retries on the fresh one. +func (s *Slab) Append(term [16]byte, id uint32) bool { + if len(s.buf)+RecordSize > cap(s.buf) { + return false + } + s.buf = append(s.buf, term[:]...) + s.buf = binary.BigEndian.AppendUint32(s.buf, id) + return true +} + +// Records returns the number of buffered records. +func (s *Slab) Records() int { return len(s.buf) / RecordSize } + +// Reset empties the slab for reuse, keeping its allocation. +func (s *Slab) Reset() { s.buf = s.buf[:0] } + +// slabRecords adapts the slab's bytes to sort.Interface: memcmp order over +// whole 20-byte records = (term asc, ID asc) thanks to the BE ID encoding. +type slabRecords struct { + buf []byte + scratch [RecordSize]byte +} + +func (r *slabRecords) Len() int { return len(r.buf) / RecordSize } +func (r *slabRecords) Less(i, j int) bool { + return bytes.Compare(r.buf[i*RecordSize:(i+1)*RecordSize], r.buf[j*RecordSize:(j+1)*RecordSize]) < 0 +} + +func (r *slabRecords) Swap(i, j int) { + a := r.buf[i*RecordSize : (i+1)*RecordSize] + b := r.buf[j*RecordSize : (j+1)*RecordSize] + copy(r.scratch[:], a) + copy(a, b) + copy(b, r.scratch[:]) +} + +// EmitSorted sorts the slab in place (unstable — exact duplicate records are +// collapsed anyway), dedups, and walks the result grouped by term: one emit +// per unique term, terms ascending, IDs within a term ascending by +// construction of the composite record order. The ids slice is slab-owned +// and reused across emits — consume it before the next call. Streamed into +// RunWriter.Append this IS the spill: the same record-at-a-time idiom every +// other run producer uses, with no whole-payload buffer in between. +func (s *Slab) EmitSorted(emit func(term [16]byte, ids []uint32) error) error { + sort.Sort(&slabRecords{buf: s.buf}) + n := s.Records() + var ( + curTerm [16]byte + haveTerm bool + prevRec []byte + ) + s.ids = s.ids[:0] + flush := func() error { + if !haveTerm { + return nil + } + return emit(curTerm, s.ids) + } + for i := range n { + rec := s.buf[i*RecordSize : (i+1)*RecordSize] + if prevRec != nil && bytes.Equal(prevRec, rec) { + continue // exact duplicate record (defensive; ingest never emits them) + } + prevRec = rec + var term [16]byte + copy(term[:], rec[:16]) + id := binary.BigEndian.Uint32(rec[16:]) + if !haveTerm || term != curTerm { + if err := flush(); err != nil { + return err + } + curTerm = term + haveTerm = true + s.ids = s.ids[:0] + } + s.ids = append(s.ids, id) + } + return flush() +} + +// RunWriter streams records into a run file without buffering the payload: +// incremental CRC, header patched at Commit. Everything that produces a run +// writes through here — the spiller's sorted-slab walk, the seal, the hot +// tier's late-chunk merges (~GBs), the freeze scan — one write path, one +// container implementation. +// +// Two-phase lifecycle: the file is created at its FINAL name and is not a run +// until Commit — flush, patch header, fsync, close — has returned nil; Close +// (a no-op once either has run) unlinks it. Call sites `defer rw.Close()` and +// commit explicitly on success: abandonment is the default, publication the +// deliberate act. +// +// There is no tmp+rename step, because nothing anywhere trusts a run by NAME +// — the discipline the rest of the tree already follows (ingest/doc.go's +// artifact model, txhash's run ladder). The hot engines trust only the +// manifest, which may name a run solely after its Commit and the dirent +// barrier that follows (stores/internal/runset), and warmup deletes every +// file the manifest does not list; the cold build's runs live in a scratch +// dir wiped before its first write (NewSpiller, FreezeColdFromStore), so a +// crashed attempt's leftovers cannot be mistaken for this attempt's; and a +// torn payload fails the drain's CRC. A half-written file under a final name +// is garbage nothing reads, exactly as its .tmp form was. +type RunWriter struct { + path string + f *os.File + w *bufio.Writer + crc uint32 + written uint64 + records uint64 + buf []byte + done bool +} + +// NewRunWriter creates path — the run's FINAL name — with a placeholder +// header. os.Create truncates, which is safe because a run name is never +// reused while a run of that name exists: the hot engines' seal sequence +// resumes past every manifest-listed run (runset.NextSealSeq) and only rises +// within a process, and the cold build's scratch dirs are wiped before their +// first write. +func NewRunWriter(path string) (*RunWriter, error) { + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("runspill: create %s: %w", path, err) + } + w := bufio.NewWriterSize(f, 1<<20) + var hdr [HeaderLen]byte + copy(hdr[:4], runMagic[:]) + if _, err := w.Write(hdr[:]); err != nil { + _ = f.Close() + _ = os.Remove(path) + return nil, fmt.Errorf("runspill: write header %s: %w", path, err) + } + return &RunWriter{path: path, f: f, w: w}, nil +} + +// Append writes one term's postings record. Terms must arrive in ascending +// order with ascending IDs — the merge's natural emission order. +func (rw *RunWriter) Append(term [16]byte, ids []uint32) error { + rw.buf = AppendTermPostings(rw.buf[:0], term, ids) + rw.records++ + return rw.writeRaw(rw.buf) +} + +// Written returns the payload bytes appended so far — i.e. the NEXT record's +// payload-relative offset. Consumers that build routing state (fences) in the +// same pass as the write read it before each Append, so their offsets can +// never drift from the bytes actually written. +func (rw *RunWriter) Written() int64 { + return int64(rw.written) //nolint:gosec // payload length, far below 2^63 +} + +// Commit publishes the run: trailer, patched header (payload length and +// record count), fsync, close — the same ladder txhash's writeRun and the +// cold .bin stream writer run, and the reason no run needs a rename. Every +// step is load-bearing: the flush precedes the WriteAt because the +// placeholder header may still be sitting in the buffer and a later flush +// would lay it back over the patch; the patch precedes the Sync so one Sync +// covers header and payload; the Close error is checked because on many +// filesystems ENOSPC/EIO surface only when the fd is consumed. Any failure +// routes through Close, so a failed Commit leaves nothing behind. +func (rw *RunWriter) Commit() error { + fail := func(err error) error { + rw.Close() + return err + } + var tr [4]byte + binary.BigEndian.PutUint32(tr[:], rw.crc) + if _, err := rw.w.Write(tr[:]); err != nil { + return fail(fmt.Errorf("runspill: write trailer: %w", err)) + } + if err := rw.w.Flush(); err != nil { + return fail(fmt.Errorf("runspill: flush: %w", err)) + } + var hdr [16]byte + binary.BigEndian.PutUint64(hdr[:8], rw.written) + binary.BigEndian.PutUint64(hdr[8:], rw.records) + if _, err := rw.f.WriteAt(hdr[:], 4); err != nil { + return fail(fmt.Errorf("runspill: patch header: %w", err)) + } + if err := rw.f.Sync(); err != nil { + return fail(fmt.Errorf("runspill: sync: %w", err)) + } + if err := rw.f.Close(); err != nil { + return fail(fmt.Errorf("runspill: close %s: %w", rw.path, err)) + } + rw.done = true + return nil +} + +// Close abandons the run: it closes the writer and unlinks the file. Every +// other two-phase writer in the tree spells the abandonment verb the same +// way (packfile.Writer, ledger.ColdWriter, event.ColdWriter, txhash's cold +// .bin stream); the first three unlink too, while the .bin stream only +// closes — its completion record, not the file's absence, is the authority +// on existence, so the partial is inert scratch the retry overwrites. +// A no-op after Commit or a prior Close, so call sites defer it +// unconditionally. +func (rw *RunWriter) Close() { + if rw.done { + return + } + rw.done = true + _ = rw.f.Close() + _ = os.Remove(rw.path) +} + +// writeRaw sends pre-encoded payload bytes through the container accounting — +// incremental CRC, payload-length tally, buffered write. The ONE site that +// touches those fields: whatever grows the payload grows the CRC with it. +func (rw *RunWriter) writeRaw(p []byte) error { + rw.crc = crc32.Update(rw.crc, crcTable, p) + rw.written += uint64(len(p)) + _, err := rw.w.Write(p) + return err +} + +// RunReader streams a run file's (term, ids) records in term order. The +// CRC is accumulated while streaming and verified when the payload is +// exhausted — a consumer that reads to io.EOF has verified integrity; a +// consumer that stops early has not (fine for the merge, which always +// drains or aborts the whole build). +type RunReader struct { + f *os.File + br *bufio.Reader + payloadLen uint64 + remain uint64 + records uint64 + read uint64 + crc uint32 + ids []uint32 + trailer bool +} + +// OpenRun opens path and validates its header: magic, payload length against +// the file's actual size, and record count against the smallest possible +// record — both bounds hold for any file the writer produced, so a violation +// is corruption caught before either number can size anything. +func OpenRun(path string) (*RunReader, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("runspill: open %s: %w", path, err) + } + fail := func(format string, args ...any) (*RunReader, error) { + _ = f.Close() + return nil, fmt.Errorf("%w: %s: %s", ErrCorruptRun, path, fmt.Sprintf(format, args...)) + } + br := bufio.NewReaderSize(f, 1<<20) + var hdr [HeaderLen]byte + if _, err := io.ReadFull(br, hdr[:]); err != nil { + return fail("short header") + } + if !bytes.Equal(hdr[:4], runMagic[:]) { + if bytes.Equal(hdr[:4], runMagicPre[:]) { + return fail("stale pre-release run format (raw-keyed records; no migration; re-ingest the chunk)") + } + return fail("bad magic") + } + payloadLen := binary.BigEndian.Uint64(hdr[4:12]) + records := binary.BigEndian.Uint64(hdr[12:20]) + fi, err := f.Stat() + if err != nil { + _ = f.Close() + return nil, fmt.Errorf("runspill: stat %s: %w", path, err) + } + maxPayload := fi.Size() - HeaderLen - 4 + if maxPayload < 0 || payloadLen > uint64(maxPayload) { + return fail("payload length %d exceeds file capacity %d", payloadLen, max(maxPayload, 0)) + } + if records > payloadLen/minRecordBytes { + return fail("record count %d exceeds payload capacity %d", records, payloadLen/minRecordBytes) + } + return &RunReader{f: f, br: br, payloadLen: payloadLen, remain: payloadLen, records: records}, nil +} + +// Records returns the header's record count: how many Next calls a full +// drain yields. Validated against payload capacity at open and against the +// actual drain at io.EOF, so routing state may be sized from it up front. +func (r *RunReader) Records() uint64 { return r.records } + +// Offset returns payload bytes consumed so far — between Next calls, the +// payload-relative offset of the next record, the read side of +// RunWriter.Written. Consumers that build routing state read it before each +// Next, mirroring the write pass, so offsets cannot drift between the two. +func (r *RunReader) Offset() int64 { + return int64(r.payloadLen - r.remain) //nolint:gosec // bounded by payloadLen, checked against file size at open +} + +// Next returns the next term's postings. The ids slice is reused across +// calls — consume before the next Next. Returns io.EOF after the last +// record, at which point the CRC has been verified. +func (r *RunReader) Next() ([16]byte, []uint32, error) { + var term [16]byte + if r.remain == 0 { + if err := r.finish(); err != nil { + return term, nil, err + } + return term, nil, io.EOF + } + if r.remain < 16 { + return term, nil, fmt.Errorf("%w: %d trailing payload bytes", ErrCorruptRun, r.remain) + } + if err := r.consume(term[:]); err != nil { + return term, nil, err + } + count, err := r.uvarint() + if err != nil { + return term, nil, err + } + if count == 0 || count > r.remain { + return term, nil, fmt.Errorf("%w: id count %d exceeds %d remaining", ErrCorruptRun, count, r.remain) + } + // The ID-stream validation (raw-varint reject before accumulation, zero + // deltas, uint32 overflow) is the shared DecodeAscendingIDs core — + // one definition site for both this streaming decoder and the slice-based + // DecodePackedRow. r.uvarint feeds it through the CRC/budget accounting. + ids, err := DecodeAscendingIDs(r.uvarint, count, r.ids[:0]) + if err != nil { + if !errors.Is(err, ErrCorruptRun) { // r.uvarint errors arrive pre-wrapped + //nolint:errorlint // opaque on purpose: io.EOF inside corruption must not read as clean EOF + err = fmt.Errorf("%w: %v", ErrCorruptRun, err) + } + return term, nil, err + } + r.ids = ids + r.read++ + return term, r.ids, nil +} + +// Close releases the file. It does NOT imply integrity — only draining to +// io.EOF does. +func (r *RunReader) Close() error { return r.f.Close() } + +// consume reads len(p) payload bytes, folding them into the CRC. +func (r *RunReader) consume(p []byte) error { + if uint64(len(p)) > r.remain { + return fmt.Errorf("%w: truncated payload", ErrCorruptRun) + } + if _, err := io.ReadFull(r.br, p); err != nil { + //nolint:errorlint // opaque on purpose: io.EOF inside corruption must not read as clean EOF + return fmt.Errorf("%w: %v", ErrCorruptRun, err) + } + r.crc = crc32.Update(r.crc, crcTable, p) + r.remain -= uint64(len(p)) + return nil +} + +// uvarint reads one payload uvarint through the CRC accounting, delegating +// the varint state machine to the stdlib via the crcByteReader adapter. +func (r *RunReader) uvarint() (uint64, error) { + v, err := binary.ReadUvarint((*crcByteReader)(r)) + if err != nil { + //nolint:errorlint // opaque on purpose: io.EOF inside corruption must not read as clean EOF + return 0, fmt.Errorf("%w: uvarint: %v", ErrCorruptRun, err) + } + return v, nil +} + +// crcByteReader adapts RunReader's consume (payload budget + CRC accounting) +// to io.ByteReader for binary.ReadUvarint. +type crcByteReader RunReader + +func (c *crcByteReader) ReadByte() (byte, error) { + var one [1]byte + if err := (*RunReader)(c).consume(one[:]); err != nil { + return 0, err + } + return one[0], nil +} + +// finish runs the end-of-payload checks: the CRC trailer, then the header's +// record count against the records actually drained. The latch is set only +// once every check has passed. +func (r *RunReader) finish() error { + if r.trailer { + return nil + } + var tr [4]byte + if _, err := io.ReadFull(r.br, tr[:]); err != nil { + return fmt.Errorf("%w: short trailer", ErrCorruptRun) + } + if got := binary.BigEndian.Uint32(tr[:]); got != r.crc { + return fmt.Errorf("%w: crc mismatch (file %08x, computed %08x)", ErrCorruptRun, got, r.crc) + } + if r.read != r.records { + return fmt.Errorf("%w: header says %d records, drained %d", ErrCorruptRun, r.records, r.read) + } + r.trailer = true + return nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go new file mode 100644 index 000000000..0d717e500 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go @@ -0,0 +1,355 @@ +package runspill + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "math/rand" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func key(b byte) [16]byte { + var k [16]byte + k[0] = b + return k +} + +// spillSlab writes slab to path through the production write path — one +// RunWriter, EmitSorted streaming into Append, Commit. +func spillSlab(t *testing.T, slab *Slab, path string) { + t.Helper() + rw, err := NewRunWriter(path) + require.NoError(t, err) + defer rw.Close() + require.NoError(t, slab.EmitSorted(rw.Append)) + require.NoError(t, rw.Commit()) +} + +// drain reads a run file fully, asserting clean EOF (which verifies CRC). +func drain(t *testing.T, path string) map[[16]byte][]uint32 { + t.Helper() + r, err := OpenRun(path) + require.NoError(t, err) + defer r.Close() + out := map[[16]byte][]uint32{} + for { + term, ids, err := r.Next() + if errors.Is(err, io.EOF) { + return out + } + require.NoError(t, err) + out[term] = append(out[term], ids...) + } +} + +func TestSlab_SpillRoundTrip(t *testing.T) { + // Random records across a small term space, appended unsorted, with + // per-term ascending IDs (the ingest contract). + rng := rand.New(rand.NewSource(7)) + want := map[[16]byte][]uint32{} + slab := NewSlab(1 << 20) + nextID := map[byte]uint32{} + for range 20_000 { + b := byte(rng.Intn(50)) + id := nextID[b] + nextID[b] = id + uint32(rng.Intn(3)) + 1 + require.True(t, slab.Append(key(b), id)) + k := key(b) + if n := len(want[k]); n == 0 || want[k][n-1] != id { + want[k] = append(want[k], id) + } + } + path := filepath.Join(t.TempDir(), "00.run") + spillSlab(t, slab, path) + assert.Equal(t, want, drain(t, path)) +} + +func TestSlab_AppendRejectsWhenFull(t *testing.T) { + slab := NewSlab(RecordSize * 2) + require.True(t, slab.Append(key(1), 1)) + require.True(t, slab.Append(key(1), 2)) + require.False(t, slab.Append(key(1), 3), "full slab must reject, not grow") + assert.Equal(t, 2, slab.Records()) + slab.Reset() + require.True(t, slab.Append(key(1), 4)) +} + +func TestSlab_DedupsExactDuplicates(t *testing.T) { + slab := NewSlab(1 << 12) + require.True(t, slab.Append(key(9), 5)) + require.True(t, slab.Append(key(9), 5)) + require.True(t, slab.Append(key(9), 6)) + path := filepath.Join(t.TempDir(), "d.run") + spillSlab(t, slab, path) + assert.Equal(t, map[[16]byte][]uint32{key(9): {5, 6}}, drain(t, path)) +} + +// TestSlab_EmitSortedContract pins the emit callback's two contract points: +// an emit error aborts the walk and surfaces unchanged, and the ids slice is +// the slab's reused buffer — valid only until the next call. +func TestSlab_EmitSortedContract(t *testing.T) { + slab := NewSlab(1 << 12) + require.True(t, slab.Append(key(2), 7)) + require.True(t, slab.Append(key(1), 5)) + require.True(t, slab.Append(key(1), 6)) + + boom := errors.New("boom") + calls := 0 + err := slab.EmitSorted(func([16]byte, []uint32) error { + calls++ + return boom + }) + require.ErrorIs(t, err, boom) + assert.Equal(t, 1, calls, "an emit error must abort the walk") + + var first *uint32 + emits := 0 + require.NoError(t, slab.EmitSorted(func(_ [16]byte, ids []uint32) error { + emits++ + if first == nil { + first = &ids[0] + } else { + assert.Same(t, first, &ids[0], "ids must be the slab's reused buffer") + } + return nil + })) + assert.Equal(t, 2, emits) +} + +func TestRunReader_DetectsCorruption(t *testing.T) { + slab := NewSlab(1 << 16) + for i := range uint32(1000) { + require.True(t, slab.Append(key(byte(i%7)), i)) + } + dir := t.TempDir() + path := filepath.Join(dir, "c.run") + spillSlab(t, slab, path) + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + // Flip one payload byte: reader must fail (structurally or via CRC at + // drain end), never silently return wrong postings as a clean EOF. + for _, pos := range []int{20, len(raw) / 2, len(raw) - 6} { + mut := append([]byte(nil), raw...) + mut[pos] ^= 0xff + p := filepath.Join(dir, "mut.run") + require.NoError(t, os.WriteFile(p, mut, 0o644)) + r, err := OpenRun(p) + if err != nil { + continue // header corruption — rejected at open, fine + } + sawErr := false + for { + _, _, nerr := r.Next() + if errors.Is(nerr, io.EOF) { + break + } + if nerr != nil { + sawErr = true + break + } + } + _ = r.Close() + assert.True(t, sawErr, "corruption at byte %d must surface an error", pos) + } + + // Truncation must also fail — at open now that the header's payload + // length is bounded by the file's actual capacity. + p := filepath.Join(dir, "trunc.run") + require.NoError(t, os.WriteFile(p, raw[:len(raw)-9], 0o644)) + _, err = OpenRun(p) + require.ErrorIs(t, err, ErrCorruptRun, "truncated run must surface an error") + + // The magic is a version gate: the header relayout rode the EVR1→EVR2 + // bump and the blind-at-seal flip rode EVR2→EVR3, so an older tag must be + // rejected, not parsed as if it were this format. + prev := append([]byte(nil), raw...) + prev[3] = '1' + pPrev := filepath.Join(dir, "evr1.run") + require.NoError(t, os.WriteFile(pPrev, prev, 0o644)) + _, err = OpenRun(pPrev) + require.ErrorIs(t, err, ErrCorruptRun, "EVR1 magic must be rejected") + + // EVR2 is byte-compatible framing with RAW-keyed records: nothing but the + // magic can tell it apart, so the rejection names it and says what to do. + stale := append([]byte(nil), raw...) + copy(stale[:4], runMagicPre[:]) + pStale := filepath.Join(dir, "evr2.run") + require.NoError(t, os.WriteFile(pStale, stale, 0o644)) + _, err = OpenRun(pStale) + require.ErrorIs(t, err, ErrCorruptRun, "EVR2 magic must be rejected") + require.ErrorContains(t, err, "stale pre-release run format") + require.ErrorContains(t, err, "re-ingest the chunk") +} + +func TestSlab_OutputIsTermSorted(t *testing.T) { + slab := NewSlab(1 << 16) + for i := 200; i > 0; i-- { // reverse insertion order + require.True(t, slab.Append(key(byte(i)), uint32(i))) + } + path := filepath.Join(t.TempDir(), "s.run") + spillSlab(t, slab, path) + r, err := OpenRun(path) + require.NoError(t, err) + defer r.Close() + var prev [16]byte + first := true + for { + term, _, err := r.Next() + if errors.Is(err, io.EOF) { + break + } + require.NoError(t, err) + if !first { + assert.Equal(t, 1, bytes.Compare(term[:], prev[:]), "terms must stream in ascending order") + } + prev = term + first = false + } +} + +// TestRunHeader_RecordCount pins the header's record-count field: counted by +// Append, patched in at Commit, exposed pre-drain, bounded at open, and +// cross-checked against the actual drain at EOF. +func TestRunHeader_RecordCount(t *testing.T) { + path := filepath.Join(t.TempDir(), "h.run") + rw, err := NewRunWriter(path) + require.NoError(t, err) + require.NoError(t, rw.Append(key(1), []uint32{1, 2})) + require.NoError(t, rw.Append(key(2), []uint32{7})) + require.NoError(t, rw.Commit()) + + r, err := OpenRun(path) + require.NoError(t, err) + assert.Equal(t, uint64(2), r.Records()) + require.NoError(t, r.Close()) + assert.Len(t, drain(t, path), 2) + + raw, err := os.ReadFile(path) + require.NoError(t, err) + + // A count beyond the payload's structural capacity is rejected at open, + // before it can size anything. + big := append([]byte(nil), raw...) + binary.BigEndian.PutUint64(big[12:], 1<<40) + pBig := filepath.Join(t.TempDir(), "big.run") + require.NoError(t, os.WriteFile(pBig, big, 0o644)) + _, err = OpenRun(pBig) + require.ErrorIs(t, err, ErrCorruptRun) + + // A plausible-but-wrong count passes open and is caught by the drain + // cross-check at EOF (the payload and CRC are intact). + off := append([]byte(nil), raw...) + binary.BigEndian.PutUint64(off[12:], 1) + pOff := filepath.Join(t.TempDir(), "off.run") + require.NoError(t, os.WriteFile(pOff, off, 0o644)) + r2, err := OpenRun(pOff) + require.NoError(t, err) + sawErr := false + for { + _, _, nerr := r2.Next() + if errors.Is(nerr, io.EOF) { + break + } + if nerr != nil { + require.ErrorIs(t, nerr, ErrCorruptRun) + sawErr = true + break + } + } + assert.True(t, sawErr, "count mismatch must fail the drain") + _, _, again := r2.Next() + require.ErrorIs(t, again, ErrCorruptRun, "the failure must be sticky, not a clean EOF") + _ = r2.Close() + + // A payload length beyond the file's capacity is rejected at open. + long := append([]byte(nil), raw...) + binary.BigEndian.PutUint64(long[4:], uint64(len(raw))) + pLong := filepath.Join(t.TempDir(), "long.run") + require.NoError(t, os.WriteFile(pLong, long, 0o644)) + _, err = OpenRun(pLong) + require.ErrorIs(t, err, ErrCorruptRun) + + // The capacity bound accounts for the trailer: one byte past the true + // payload length must already be rejected. + graze := append([]byte(nil), raw...) + binary.BigEndian.PutUint64(graze[4:], uint64(len(raw)-HeaderLen-4+1)) + pGraze := filepath.Join(t.TempDir(), "graze.run") + require.NoError(t, os.WriteFile(pGraze, graze, 0o644)) + _, err = OpenRun(pGraze) + require.ErrorIs(t, err, ErrCorruptRun) +} + +// dirEntries lists dir's entry names (os.ReadDir sorts them). +func dirEntries(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + return names +} + +// TestRunWriter_CloseLeavesNothing pins the two-phase lifecycle now that a +// writer creates its file at the FINAL name: the run is visible while it is +// still being written (visibility is not validity — nothing trusts a run by +// name), no sidecar is ever created, an abandoned writer leaves nothing at +// all, and Close after Commit must not touch the committed run. +func TestRunWriter_CloseLeavesNothing(t *testing.T) { + dir := t.TempDir() + + path := filepath.Join(dir, "aborted.run") + rw, err := NewRunWriter(path) + require.NoError(t, err) + require.NoError(t, rw.Append(key(1), []uint32{1})) + assert.Equal(t, []string{"aborted.run"}, dirEntries(t, dir), "the run is written under its final name") + rw.Close() + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist, "an abandoned run must not survive") + assert.Empty(t, dirEntries(t, dir), "abandonment must leave nothing behind") + + committed := filepath.Join(dir, "committed.run") + rw2, err := NewRunWriter(committed) + require.NoError(t, err) + require.NoError(t, rw2.Append(key(2), []uint32{4, 9})) + require.NoError(t, rw2.Commit()) + rw2.Close() // deferred-Close idiom: must be a no-op now + assert.Equal(t, map[[16]byte][]uint32{key(2): {4, 9}}, drain(t, committed)) +} + +// TestRunWriter_CommitFailureRemovesTheFile: the rename that used to be the +// convenient fault-injection point is gone, so the fault is closing the fd +// underneath the writer, which fails Commit at its flush. The run is written +// past the bufio capacity first, so real bytes are already on disk under the +// final name when Commit fails — the exposure create-at-final-name introduces +// — and the error must surface with nothing left at that name. +func TestRunWriter_CommitFailureRemovesTheFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "blocked.run") + rw, err := NewRunWriter(path) + require.NoError(t, err) + // Exceed the writer's 1MiB buffer so at least one flush has hit the disk. + ids := make([]uint32, 4<<10) + for i := range ids { + ids[i] = uint32(i + 1) + } + for i := 0; rw.written < 2<<20; i++ { + require.NoError(t, rw.Append(key(byte(i)), ids)) + } + st, err := os.Stat(path) + require.NoError(t, err) + require.Positive(t, st.Size(), "flushed bytes must already sit at the final name") + require.NoError(t, rw.f.Close()) // the tail of the payload is still buffered + require.Error(t, rw.Commit()) + _, serr := os.Stat(path) + require.ErrorIs(t, serr, os.ErrNotExist, + "failed Commit must remove the file, including bytes already flushed") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go new file mode 100644 index 000000000..7b179b328 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go @@ -0,0 +1,144 @@ +package runspill + +import ( + "fmt" + "os" + "path/filepath" +) + +// Spiller is the double-buffered producer facade over Slab + RunWriter: the +// ingest goroutine Adds records; when the active slab fills, the Spiller +// swaps in the spare and sorts+spills the full one on a background +// goroutine, so ingest never stalls on a sort or a write (it can only stall +// if a spill is still running when the NEXT rotation arrives — by +// construction sorting+writing ~a slab is faster than refilling one, so a +// stall means the device or CPU is saturated and backpressure is correct). +// +// Single-producer: Add/Finish are called from one goroutine (the cold +// build's per-chunk ingest loop). Not safe for concurrent Add. +type Spiller struct { + dir string + active *Slab + spare *Slab + inflight chan error + pending bool // a spill goroutine is running; the spare slab is its + runs []string + failed error +} + +// NewSpiller creates the scratch dir (wiping any leftover — scratch is +// non-durable and a previous crashed attempt's files must not be mistaken +// for this attempt's) and returns a Spiller with two slabBytes-capped slabs. +func NewSpiller(dir string, slabBytes int) (*Spiller, error) { + if err := os.RemoveAll(dir); err != nil { + return nil, fmt.Errorf("runspill: wipe scratch %s: %w", dir, err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("runspill: mkdir scratch %s: %w", dir, err) + } + return &Spiller{ + dir: dir, + active: NewSlab(slabBytes), + spare: NewSlab(slabBytes), + inflight: make(chan error, 1), + }, nil +} + +// Add appends one record, rotating slabs when the active one fills. The +// only blocking is waiting out a still-running previous spill at rotation. +func (s *Spiller) Add(term [16]byte, id uint32) error { + if s.failed != nil { + return s.failed + } + if s.active.Append(term, id) { + return nil + } + if err := s.rotate(); err != nil { + return err + } + if !s.active.Append(term, id) { + // A fresh slab rejecting means slabBytes < RecordSize — a + // construction bug, not a runtime condition. + s.failed = fmt.Errorf("runspill: slab capacity %d cannot hold one record", cap(s.active.buf)) + return s.failed + } + return nil +} + +// Finish spills the remaining records, waits everything out, and returns +// the ordered run paths for MergeRuns. The Spiller is spent afterwards. +// Finish hands results back; it is NOT the writers' publication verb (that +// is RunWriter.Commit), so the name deliberately stays outside the domain +// writers' Commit/abandon algebra. +func (s *Spiller) Finish() ([]string, error) { + if s.failed != nil { + return nil, s.failed + } + if s.active.Records() > 0 { + if err := s.rotate(); err != nil { + return nil, err + } + } + if err := s.wait(); err != nil { + return nil, err + } + return s.runs, nil +} + +// Cleanup removes the scratch dir. Call after the merge has consumed the +// runs (or on abandon); errors are returned for logging, nothing depends on +// them (the next attempt's NewSpiller wipes again). +func (s *Spiller) Cleanup() error { + return os.RemoveAll(s.dir) +} + +// rotate waits for any in-flight spill, swaps slabs, and kicks the full one +// off to the background spill goroutine. The goroutine touches nothing but +// the slab it was handed and the channel — no Spiller field is shared with +// it, so the slab hand-off is the whole concurrency story. +func (s *Spiller) rotate() error { + if err := s.wait(); err != nil { + return err + } + full := s.active + s.active, s.spare = s.spare, s.active + s.active.Reset() + + path := filepath.Join(s.dir, fmt.Sprintf("%06d.run", len(s.runs))) + s.runs = append(s.runs, path) + + s.pending = true + go func() { + s.inflight <- spill(path, full) + }() + return nil +} + +// spill sorts the full slab and streams it to path as one committed run: +// NewRunWriter, EmitSorted into Append, Commit — the same write path every +// other run producer takes, record at a time, no whole-payload buffer. +func spill(path string, full *Slab) error { + rw, err := NewRunWriter(path) + if err != nil { + return err + } + defer rw.Close() + if err := full.EmitSorted(rw.Append); err != nil { + return fmt.Errorf("runspill: write %s: %w", path, err) + } + return rw.Commit() +} + +// wait blocks out any in-flight spill. At most one is pending by +// construction (rotate waits before kicking the next), so one receive +// settles it; the channel receive is the happens-before edge that hands the +// spilled slab (buf and ids both) back to the producer for reuse. +func (s *Spiller) wait() error { + if s.pending { + if err := <-s.inflight; err != nil && s.failed == nil { + s.failed = err + } + s.pending = false + } + return s.failed +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller_test.go new file mode 100644 index 000000000..178e2d110 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller_test.go @@ -0,0 +1,81 @@ +package runspill + +import ( + "math/rand" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSpiller_EndToEnd drives many records through a tiny slab (forcing many +// rotations and background spills) and checks the merged result against a +// reference union — the full spill→merge pipeline under -race. +func TestSpiller_EndToEnd(t *testing.T) { + dir := filepath.Join(t.TempDir(), "scratch") + sp, err := NewSpiller(dir, 64*RecordSize) // tiny: ~forces dozens of spills + require.NoError(t, err) + + rng := rand.New(rand.NewSource(3)) + want := map[[16]byte][]uint32{} + id := uint32(0) + for range 5_000 { + k := key(byte(rng.Intn(40))) + require.NoError(t, sp.Add(k, id)) + want[k] = append(want[k], id) + id += uint32(rng.Intn(2)) + 1 + } + runs, err := sp.Finish() + require.NoError(t, err) + require.Greater(t, len(runs), 10, "tiny slab must have produced many runs") + + got := collectMerge(t, runs) + assert.Equal(t, want, got) + require.NoError(t, sp.Cleanup()) +} + +// TestSpiller_EmptyFinish: no records → no runs, no files. +func TestSpiller_EmptyFinish(t *testing.T) { + sp, err := NewSpiller(filepath.Join(t.TempDir(), "s"), 1<<16) + require.NoError(t, err) + runs, err := sp.Finish() + require.NoError(t, err) + assert.Empty(t, runs) +} + +// TestSpiller_WipesLeftoverScratch: a prior attempt's files must not survive +// NewSpiller. +func TestSpiller_WipesLeftoverScratch(t *testing.T) { + dir := filepath.Join(t.TempDir(), "s") + sp1, err := NewSpiller(dir, 1<<16) + require.NoError(t, err) + require.NoError(t, sp1.Add(key(1), 1)) + _, err = sp1.Finish() + require.NoError(t, err) + + sp2, err := NewSpiller(dir, 1<<16) + require.NoError(t, err) + runs, err := sp2.Finish() + require.NoError(t, err) + assert.Empty(t, runs, "leftover runs from a previous attempt must be wiped") +} + +// TestSpiller_SurfacesWriteError: an unwritable scratch dir surfaces at the +// next rotation or Finish, never silently. +func TestSpiller_SurfacesWriteError(t *testing.T) { + dir := filepath.Join(t.TempDir(), "s") + sp, err := NewSpiller(dir, 2*RecordSize) + require.NoError(t, err) + // Yank the scratch dir out from under the spiller. + require.NoError(t, sp.Cleanup()) + for i := range uint32(100) { + if err = sp.Add(key(1), i); err != nil { + break + } + } + if err == nil { + _, err = sp.Finish() + } + require.Error(t, err, "write failure must surface") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/termlanes.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/termlanes.go new file mode 100644 index 000000000..942c60ee2 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/termlanes.go @@ -0,0 +1,98 @@ +package event + +// termlanes.go — constant-key side lanes for the two terms EVERY event +// carries: its ContractEventType and its topic-count bucket. Both draw from +// a closed alphabet — the three enum members, and one bucket per topic count +// a filter can name plus the overflow one — so their term keys are known +// before any ledger arrives, and an event's entire contribution to them is +// one ascending ID appended to a fixed slot. +// +// That is what takes them off the flat-pairs path (termsort.go), where they +// were the two most expensive terms in the ledger. At ~6k events/ledger they +// were 12k of the ~30k (term, eventID) pairs the arenas held, and the worst +// 12k: sortPairPerm buckets on key byte 0, so a firehose term's 6k IDENTICAL +// keys all collapse into ONE bucket, where the comparison sort burns +// O(m log m) on pairs that arrival order already left in the required order. +// A lane skips the hash, the pair, the scatter, the sort and the group scan +// alike; buildRuns merges the lanes back into the hashed terms' run sequence +// at their byte-order positions, so the packed row is byte-identical to +// hashing them (pinned in termsort_test.go). + +import ( + "bytes" + "fmt" + "slices" + + "github.com/stellar/go-stellar-sdk/xdr" +) + +const ( + // numEventTypeLanes covers exactly the ContractEventType members + // ContractEventTypeView.Value admits — the only types the ingest walk + // can reach, since AppendTerms hard-fails on any other. + numEventTypeLanes = int(xdr.ContractEventTypeDiagnostic) + 1 + // numTopicCountLanes covers buckets 0..topicCountOverflowBucket. + numTopicCountLanes = topicCountOverflowBucket + 1 + numLanes = numEventTypeLanes + numTopicCountLanes +) + +// laneKeys is every lane's term key: the event types first (lane == the enum +// value), then the topic-count buckets (lane == numEventTypeLanes + bucket). +// Built by calling the same two constructors every reader calls, so a lane +// key cannot drift from what the query path — or a cold artifact already on +// disk — means by these terms. +// +//nolint:gochecknoglobals // derived constant table, computed once +var laneKeys = func() [numLanes]TermKey { + var keys [numLanes]TermKey + for eventType := range numEventTypeLanes { + keys[eventType] = EventTypeTermKey(xdr.ContractEventType(eventType)) + } + for bucket := range numTopicCountLanes { + keys[numEventTypeLanes+bucket] = TopicCountTermKey(bucket) + } + return keys +}() + +// laneOrder lists the lanes byte-sorted by their keys — the order buildRuns +// merges them into the hashed terms, which are byte-sorted by the same total +// order the packed row's terms are. +// +//nolint:gochecknoglobals // derived constant table, computed once +var laneOrder = func() [numLanes]uint8 { + var order [numLanes]uint8 + for lane := range order { + order[lane] = uint8(lane) + } + slices.SortFunc(order[:], func(a, b uint8) int { + return bytes.Compare(laneKeys[a][:], laneKeys[b][:]) + }) + return order +}() + +// eventLanes is how appendTerms reports one event's two closed-alphabet +// terms to a caller that wants them diverted: lane indices, no keys, no +// hashing. Both fields are set on every non-error return. +type eventLanes struct { + eventType int + topicCount int +} + +// eventTypeLane maps a ContractEventType to its lane — the enum value +// itself, which is the bound ContractEventTypeView.Value already enforces on +// everything the ingest walk sees. A type outside it panics loudly during +// tests rather than silently filing every event under lane 0, the same call +// topicField makes for a topic position past the indexed range. +func eventTypeLane(eventType xdr.ContractEventType) int { + if eventType < 0 || int(eventType) >= numEventTypeLanes { + panic(fmt.Sprintf("eventTypeLane: unknown ContractEventType %d", eventType)) + } + return int(eventType) +} + +// topicCountLane maps an event's topic count to its bucket lane, clamping at +// the overflow bucket exactly as TopicCountTermKey does. count comes from a +// checked vec header, so it is never negative. +func topicCountLane(count int) int { + return numEventTypeLanes + min(count, topicCountOverflowBucket) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort.go new file mode 100644 index 000000000..02234dac5 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort.go @@ -0,0 +1,255 @@ +package event + +// termsort.go — the flat-pairs term pipeline for hot ingest: writer-owned +// (term, eventID) pair arenas, a keys-only MSD sort, the constant-key side +// lanes that route the two terms every event carries around both +// (termlanes.go), and the per-term runs view that feeds the packed index row +// and the post-commit hot-index apply alike. Replaces the per-ledger +// map[TermKey][]uint32 accumulation — the map assign/grow and the per-term +// posting-slice churn were the events phase's top measured CPU and +// allocation lines — while producing byte-identical packed rows +// (differential-tested against the retired map path in termsort_test.go). + +import ( + "cmp" + "encoding/binary" + "slices" +) + +// termRuns is one ledger's per-term posting lists: terms[r] owns +// ids[starts[r]:starts[r+1]], IDs ascending within each run (the packed +// row's ascending-ID/tail-delta contract). Terms are byte-sorted for +// writer-built runs (buildRuns) and row-ordered for decoded rows (addRun); +// consumers do not depend on term order. The slices are views over reused +// arenas: a termRuns is BORROWED, valid only until its producer's next +// reset, and consumers must not retain any slice past the call +// (ConcurrentBitmaps.AddTo copies every ID it keeps). +type termRuns struct { + terms []TermKey + ids []uint32 + starts []int // len(terms)+1 offsets into ids; starts[0] == 0 +} + +// reset empties the runs for the next ledger, keeping arena capacity. +func (t *termRuns) reset() { + t.terms, t.ids, t.starts = t.terms[:0], t.ids[:0], t.starts[:0] + t.starts = append(t.starts, 0) +} + +// addRun appends one term's ascending IDs as the next run — the builder for +// producers that already hold per-term postings (warmup's decoded rows +// arrive term-by-term; ingest builds from sorted pairs via buildRuns). +func (t *termRuns) addRun(term TermKey, ids []uint32) { + t.terms = append(t.terms, term) + t.ids = append(t.ids, ids...) + t.starts = append(t.starts, len(t.ids)) +} + +// run returns term r's ID list. +func (t *termRuns) run(r int) []uint32 { return t.ids[t.starts[r]:t.starts[r+1]] } + +// rowLen is the exact packed-row byte length appendRow would emit — +// delegating per term to TermPostingsLen so the arithmetic cannot +// drift from the encoder. The write path exact-sizes the RETAINED row +// allocation with it. +func (t *termRuns) rowLen() int { + n := 0 + for r := range t.terms { + n += TermPostingsLen(t.run(r)) + } + return n +} + +// appendRow appends the packed index row for the runs to dst — one +// AppendTermPostings record per term, in runs order. For buildRuns +// output (terms byte-sorted, IDs ascending per term) this is byte-identical +// to the retired map path's AppendPackedRow output; the differential +// test in termsort_test.go pins the identity. +func (t *termRuns) appendRow(dst []byte) []byte { + for r := range t.terms { + dst = AppendTermPostings(dst, t.terms[r], t.run(r)) + } + return dst +} + +// ledgerScratch owns the flat (term, eventID) pair arenas one ledger's +// ingest accumulates, the constant-key side lanes the two per-event terms +// bypass them through, plus the permutation buffer and runs view built from +// both. Everything is reused across ledgers under the single-writer ingest +// contract: the post-commit hook borrows the runs view, so the hook for +// ledger N must run before ledger N+1's reset — the hotchunk driver's +// ingest → commit → hook sequence guarantees it. +type ledgerScratch struct { + keys []TermKey // pair i: keys[i] pairs with ids[i] + ids []uint32 + perm []uint32 // sortPairPerm's reused permutation buffer + lanes [numLanes][]uint32 // constant-key side lanes; see termlanes.go + runs termRuns +} + +// reset empties the pair arenas and the side lanes for the next ledger, +// keeping capacity. +func (s *ledgerScratch) reset() { + s.keys, s.ids = s.keys[:0], s.ids[:0] + for lane := range s.lanes { + s.lanes[lane] = s.lanes[lane][:0] + } +} + +// appendEventTerms derives eventID's term keys straight into the pair +// arenas: at most MaxTermsPerEvent keys per event, each paired with +// eventID. Callers must append events in ascending eventID order — arrival +// order is what makes per-term IDs ascending after the stable keys-only +// sort (see cmpPairKeys). +// +// The two terms every event carries — its type and its topic-count bucket — +// never reach the arenas: their keys are a closed alphabet, so appendTerms +// hands back lane indices instead and the event's whole contribution is one +// ID appended to a fixed slot. Ascending arrival order makes each lane a +// finished posting list, so buildRuns merges them in without sorting +// anything (see termlanes.go). +func (s *ledgerScratch) appendEventTerms(eventID uint32, eventBytes []byte) error { + before := len(s.keys) + var lanes eventLanes + keys, err := appendTerms(s.keys, eventBytes, &lanes) + if err != nil { + return err + } + s.keys = keys + for range len(keys) - before { + s.ids = append(s.ids, eventID) + } + s.lanes[lanes.eventType] = append(s.lanes[lanes.eventType], eventID) + s.lanes[lanes.topicCount] = append(s.lanes[lanes.topicCount], eventID) + return nil +} + +// buildRuns sorts the accumulated pairs keys-only and materializes the +// per-term runs view: the same grouping the map accumulation produced — +// unique terms byte-sorted, each with its IDs in arrival (ascending) order +// — without a map. The returned view is borrowed (see ledgerScratch). +// +// The side lanes are merged in as it goes, each at its key's byte-order +// position among the hashed terms, so the run sequence is byte-sorted end to +// end exactly as if the lanes' terms had been hashed into the pairs. The +// merge assumes no lane key equals a hashed key — the 128-bit +// collision-freedom the index already rests on, since two terms sharing a +// key share their postings. +func (s *ledgerScratch) buildRuns() termRuns { + s.runs.reset() + s.perm = sortPairPerm(s.keys, s.perm) + r := &s.runs + ln := 0 + i := 0 + for i < len(s.perm) { + key := s.keys[s.perm[i]] + for ln < len(laneOrder) && keyLess(&laneKeys[laneOrder[ln]], &key) { + s.appendLaneRun(r, laneOrder[ln]) + ln++ + } + r.terms = append(r.terms, key) + for ; i < len(s.perm) && s.keys[s.perm[i]] == key; i++ { + r.ids = append(r.ids, s.ids[s.perm[i]]) + } + r.starts = append(r.starts, len(r.ids)) + } + // The lanes sorting after every hashed term — and, for a ledger whose + // events carry neither a contract ID nor a topic, every lane there is. + for ; ln < len(laneOrder); ln++ { + s.appendLaneRun(r, laneOrder[ln]) + } + return s.runs +} + +// appendLaneRun appends lane's IDs as the next run. A lane no event fed is +// not a term: the map path never held an empty posting list, so neither may +// the runs. +func (s *ledgerScratch) appendLaneRun(r *termRuns, lane uint8) { + if ids := s.lanes[lane]; len(ids) > 0 { + r.addRun(laneKeys[lane], ids) + } +} + +// sortPairPerm builds a permutation of [0, len(keys)) ordering keys +// ascending by bytes.Compare, equal keys by arrival index — WITHOUT moving +// the pairs themselves (sorting the 20-byte (key, id) structs directly +// measured 2-2.5x slower than keys-only). Two stages, the measured winner +// for the production shape (~24k hashed 16-byte keys, ~-52% vs +// slices.SortFunc+bytes.Compare): +// +// - a 256-bucket MSD counting scatter on key byte 0 — stable by +// construction (indices land in arrival order within each bucket) and +// it leaves each bucket a ~N/256 problem; +// - a comparison sort per bucket with a big-endian two-uint64-word +// comparator, byte-identical ordering to bytes.Compare on 16 bytes. +// +// perm is the caller's reused buffer; the returned slice is perm resized. +// The property test in termsort_test.go pins the ordering against a stable +// bytes.Compare reference, duplicates and shared prefixes included. +func sortPairPerm(keys []TermKey, perm []uint32) []uint32 { + n := len(keys) + if cap(perm) < n { + perm = make([]uint32, n) + } + perm = perm[:n] + + var count [256]int + for i := range keys { + count[keys[i][0]]++ + } + var pos [256]int + sum := 0 + for b, c := range count { + pos[b] = sum + sum += c + } + for i := range keys { + b := keys[i][0] + perm[pos[b]] = uint32(i) + pos[b]++ + } + // pos[b] is now bucket b's END offset. Sort each bucket; byte 0 is equal + // within a bucket, so the full-key comparator just re-confirms it. + cmpIdx := func(x, y uint32) int { return cmpPairKeys(keys, x, y) } + start := 0 + for b := range pos { + end := pos[b] + if end-start > 1 { + slices.SortFunc(perm[start:end], cmpIdx) + } + start = end + } + return perm +} + +// cmpPairKeys orders two pair indices by their 16-byte keys — two +// big-endian uint64 word compares, the same order bytes.Compare yields — +// with the index itself as tiebreak. Pairs are appended in ascending +// eventID order, so the index tiebreak IS per-term ascending ID order: +// stable-sort equivalence without paying for a stable sort. +// +// The word compares are spelled out here rather than shared with keyLess: +// the comparator is too big to inline either way, so a shared helper would +// add a real call to every comparison in every bucket sort. +func cmpPairKeys(keys []TermKey, x, y uint32) int { + a, b := &keys[x], &keys[y] + if c := cmp.Compare(binary.BigEndian.Uint64(a[:8]), binary.BigEndian.Uint64(b[:8])); c != 0 { + return c + } + if c := cmp.Compare(binary.BigEndian.Uint64(a[8:]), binary.BigEndian.Uint64(b[8:])); c != 0 { + return c + } + return cmp.Compare(x, y) +} + +// keyLess reports whether a sorts before b by bytes — the same order +// bytes.Compare yields on 16 bytes, as the two-way test buildRuns' lane +// merge needs. Plain uint64 comparisons, no cmp.Compare: the merge asks +// once per term, so it must stay inlinable. +func keyLess(a, b *TermKey) bool { + ahi, bhi := binary.BigEndian.Uint64(a[:8]), binary.BigEndian.Uint64(b[:8]) + if ahi != bhi { + return ahi < bhi + } + return binary.BigEndian.Uint64(a[8:]) < binary.BigEndian.Uint64(b[8:]) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go new file mode 100644 index 000000000..5acf60bf8 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go @@ -0,0 +1,481 @@ +package event + +// termsort_test.go — the flat-pairs pipeline's gates: +// +// - the sort engine's ordering is byte-identical to a STABLE bytes.Compare +// sort (the stage-1 property check carried in as a regression test); +// - the pipeline's packed rows are byte-identical to the retired +// map-accumulation write path's rows, both for pairs fed straight into +// the arenas and for whole events fed through the ingest derivation, +// whose two constant-key terms bypass the arenas into side lanes; +// - a HotIndex driven through the runs-based ApplyLedger is +// promotion-and-content equivalent to one driven through the retired +// map-based apply. +// +// The retired map path lives HERE, as unexported reference implementations — +// never in production code. + +import ( + "bytes" + "cmp" + "fmt" + "math/rand" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + protocol "github.com/stellar/go-stellar-sdk/protocols/rpc" + "github.com/stellar/go-stellar-sdk/xdr" +) + +// refSortedPerm is the ordering oracle: a STABLE sort of pair indices by +// bytes.Compare. Stability makes equal-key order the arrival order — exactly +// what cmpPairKeys' index tiebreak must reproduce, so the whole permutation +// (not just the key sequence) must match. +func refSortedPerm(keys []TermKey) []uint32 { + perm := make([]uint32, len(keys)) + for i := range perm { + perm[i] = uint32(i) + } + slices.SortStableFunc(perm, func(a, b uint32) int { return bytes.Compare(keys[a][:], keys[b][:]) }) + return perm +} + +func randKeys(rng *rand.Rand, n int) []TermKey { + keys := make([]TermKey, n) + for i := range keys { + rng.Read(keys[i][:]) + } + return keys +} + +// sharedPrefixKeys returns n keys agreeing on their first prefixLen bytes — +// the shape that forces the comparator past the first word (prefixLen >= 8) +// and into the final bytes (prefixLen == 15). +func sharedPrefixKeys(rng *rand.Rand, n, prefixLen int) []TermKey { + var prefix TermKey + rng.Read(prefix[:]) + keys := make([]TermKey, n) + for i := range keys { + keys[i] = prefix + rng.Read(keys[i][prefixLen:]) + } + return keys +} + +// duplicateHeavyKeys draws n keys from a small pool, guaranteeing many exact +// duplicates — the stability-sensitive shape. +func duplicateHeavyKeys(rng *rand.Rand, n, poolSize int) []TermKey { + pool := randKeys(rng, poolSize) + keys := make([]TermKey, n) + for i := range keys { + keys[i] = pool[rng.Intn(len(pool))] + } + return keys +} + +// TestSortPairPerm_MatchesStableBytesCompare is the sort engine's regression +// gate: the MSD scatter + word-comparator permutation must EXACTLY equal the +// stable bytes.Compare reference — same key order, same arrival order for +// duplicates — on random keys, crafted shared prefixes (including ones +// spanning the uint64 word boundary at bytes 7/8), heavy duplicates, and +// degenerate sizes. +func TestSortPairPerm_MatchesStableBytesCompare(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + cases := map[string][]TermKey{ + "single": randKeys(rng, 1), + "pair": randKeys(rng, 2), + "random10k": randKeys(rng, 10_000), + "sharedPrefix8": sharedPrefixKeys(rng, 4096, 8), + "sharedPrefix15": sharedPrefixKeys(rng, 4096, 15), + "wordBoundary7": sharedPrefixKeys(rng, 2048, 7), + "singleBucket": sharedPrefixKeys(rng, 2048, 1), + "duplicateHeavy": duplicateHeavyKeys(rng, 4096, 50), + "allEqual": duplicateHeavyKeys(rng, 512, 1), + } + for name, keys := range cases { + t.Run(name, func(t *testing.T) { + assert.Equal(t, refSortedPerm(keys), sortPairPerm(keys, nil)) + }) + } + + // Degenerate empty input, and buffer reuse across differently-sized + // inputs — the arena shape the ingest loop drives. + assert.Empty(t, sortPairPerm(nil, nil)) + perm := make([]uint32, 0, 16) + for _, n := range []int{100, 3, 1000} { + keys := randKeys(rng, n) + perm = sortPairPerm(keys, perm) + require.Equal(t, refSortedPerm(keys), perm, "n=%d with reused buffer", n) + } +} + +// TestCmpPairKeys_AgreesWithBytesCompare checks the word-comparator half of +// the engine directly: sign agreement with bytes.Compare on distinct keys, +// index tiebreak on equal keys. +func TestCmpPairKeys_AgreesWithBytesCompare(t *testing.T) { + rng := rand.New(rand.NewSource(2)) + keys := append(randKeys(rng, 256), sharedPrefixKeys(rng, 256, 12)...) + keys = append(keys, duplicateHeavyKeys(rng, 128, 16)...) + for range 20_000 { + x, y := uint32(rng.Intn(len(keys))), uint32(rng.Intn(len(keys))) + got := cmpPairKeys(keys, x, y) + if want := bytes.Compare(keys[x][:], keys[y][:]); want != 0 { + require.Equal(t, want, cmp.Compare(got, 0), "keys %x vs %x", keys[x], keys[y]) + } else { + require.Equal(t, cmp.Compare(x, y), got, "equal keys must break ties by index") + } + } +} + +// referencePackedRow is the RETIRED map-accumulation write path, preserved +// here as the differential oracle: accumulate per-term IDs into a map +// exactly as the old IngestLedgerToBatch did, exact-size, then +// AppendPackedRow. +func referencePackedRow(keys []TermKey, ids []uint32) []byte { + per := make(map[TermKey][]uint32) + for i := range keys { + per[keys[i]] = append(per[keys[i]], ids[i]) + } + if len(per) == 0 { + return nil + } + size := 0 + for _, v := range per { + size += TermPostingsLen(v) + } + return AppendPackedRow(make([]byte, 0, size), per) +} + +// pairLedger generates one ledger's flat pairs under the ingest invariant: +// event IDs strictly increasing (startID..), each event contributing 1.. +// maxTerms DISTINCT terms from pool — so per-term IDs are ascending and the +// same ID appears under several terms. +func pairLedger( + rng *rand.Rand, pool []TermKey, startID uint32, nEvents, maxTerms int, +) ([]TermKey, []uint32) { + keys := make([]TermKey, 0, nEvents*maxTerms) + ids := make([]uint32, 0, nEvents*maxTerms) + for e := range nEvents { + id := startID + uint32(e) + want := min(1+rng.Intn(maxTerms), len(pool)) + seen := make(map[TermKey]bool, want) + for len(seen) < want { + key := pool[rng.Intn(len(pool))] + if seen[key] { + continue + } + seen[key] = true + keys = append(keys, key) + ids = append(ids, id) + } + } + return keys, ids +} + +// TestBuildRuns_PackedRowByteIdentical is the write path's differential +// gate: the flat-pairs pipeline must emit BYTE-IDENTICAL packed rows to the +// retired map path across the shapes that stress ordering and stability — +// shared-prefix keys, a single term, a single event, the ~24k-term stress +// density, and IDs duplicated across terms. The scratch is shared across +// subtests to exercise the cross-ledger arena reuse the production writer +// performs. +func TestBuildRuns_PackedRowByteIdentical(t *testing.T) { + rng := rand.New(rand.NewSource(3)) + shapes := []struct { + name string + pool []TermKey + startID uint32 + nEvents, maxTerms int + }{ + {"typical", randKeys(rng, 3000), 0, 6000, 5}, + {"sharedPrefix", sharedPrefixKeys(rng, 512, 12), 6000, 2000, 5}, + {"singleTerm", randKeys(rng, 1), 8000, 500, 1}, + {"singleEvent", randKeys(rng, 64), 8500, 1, 5}, + {"stress24kTerms", randKeys(rng, 24_000), 8501, 6000, 5}, + {"denseDupIDs", randKeys(rng, 8), 14_501, 1000, 5}, + {"bigIDs", randKeys(rng, 200), 40_000_000, 300, 5}, // multi-byte first-ID varints + } + var s ledgerScratch + for _, sh := range shapes { + t.Run(sh.name, func(t *testing.T) { + keys, ids := pairLedger(rng, sh.pool, sh.startID, sh.nEvents, sh.maxTerms) + s.reset() + s.keys = append(s.keys, keys...) + s.ids = append(s.ids, ids...) + runs := s.buildRuns() + + var got []byte + if len(runs.terms) > 0 { + got = runs.appendRow(make([]byte, 0, runs.rowLen())) + } + require.Equal(t, referencePackedRow(keys, ids), got) + assert.Len(t, got, runs.rowLen(), "rowLen must exact-size the row") + }) + } + + // Empty ledger: no pairs, no row. + s.reset() + runs := s.buildRuns() + assert.Empty(t, runs.terms) + assert.Zero(t, runs.rowLen()) +} + +// TestLaneKeys_MatchTermConstructorsAndSortOrder pins the side lanes' +// alphabet: each lane's key is exactly what the constructor every reader +// calls yields for that value, the lanes are pairwise distinct, and +// laneOrder is the byte order buildRuns merges by. A lane whose key or +// position drifted would index a chunk under a term no query names, or +// break the run sequence's sort. +func TestLaneKeys_MatchTermConstructorsAndSortOrder(t *testing.T) { + for _, eventType := range eventTypes() { + assert.Equal(t, EventTypeTermKey(eventType), laneKeys[eventTypeLane(eventType)], + "event type %d", eventType) + } + // Every count a filter can name, the overflow bucket, and counts past it + // (which share the overflow lane). + for count := range protocol.MaxTopicCount + 4 { + assert.Equal(t, TopicCountTermKey(count), laneKeys[topicCountLane(count)], + "topic count %d", count) + } + + distinct := map[TermKey]struct{}{} + for _, key := range laneKeys { + distinct[key] = struct{}{} + } + assert.Len(t, distinct, numLanes, "every lane must be its own term") + + ordered := make([]TermKey, 0, numLanes) + for _, lane := range laneOrder { + ordered = append(ordered, laneKeys[lane]) + } + assert.True(t, slices.IsSortedFunc(ordered, func(a, b TermKey) int { + return bytes.Compare(a[:], b[:]) + }), "laneOrder must be the byte order the packed row's terms are in") +} + +// TestAppendEventTerms_LaneMergeByteIdentical is the side lanes' differential +// gate: the FULL ingest derivation — appendEventTerms' diversion plus +// buildRuns' merge — must emit byte-identical packed rows to the map path fed +// by TermsForBytes, the oracle every other consumer is pinned against. The +// first ledger carries only events with neither a contract ID nor a topic, so +// every term it indexes comes from a lane and the merge runs its drain-only +// path; the rest mix every event type with every topic-count bucket against +// hashed terms that sort both before and after the lane keys. The scratch is +// shared across ledgers, so a lane left unreset would double-post. +func TestAppendEventTerms_LaneMergeByteIdentical(t *testing.T) { + const eventsPerLedger = 50 + rng := rand.New(rand.NewSource(5)) + var s ledgerScratch + nextID := uint32(0) + for ledger, laneOnly := range []bool{true, false, false, false} { + s.reset() + keys := make([]TermKey, 0, eventsPerLedger*MaxTermsPerEvent) + ids := make([]uint32, 0, eventsPerLedger*MaxTermsPerEvent) + fed := map[uint8]bool{} + for range eventsPerLedger { + eventType := eventTypes()[rng.Intn(numEventTypeLanes)] + topicCount, withCID := 0, false + if !laneOnly { + // MaxTopicCount+3 counts: every named bucket, plus counts + // that share the overflow one. + topicCount, withCID = rng.Intn(protocol.MaxTopicCount+3), rng.Intn(4) > 0 + } + raw := marshaledEvent(t, laneEvent(rng, eventType, topicCount, withCID)) + fed[uint8(eventTypeLane(eventType))] = true + fed[uint8(topicCountLane(topicCount))] = true + + id := nextID + nextID++ + require.NoError(t, s.appendEventTerms(id, raw)) + want, err := TermsForBytes(raw) + require.NoError(t, err) + for _, key := range want { + keys = append(keys, key) + ids = append(ids, id) + } + } + + runs := s.buildRuns() + got := runs.appendRow(make([]byte, 0, runs.rowLen())) + require.Equal(t, referencePackedRow(keys, ids), got, "ledger %d", ledger) + assert.Len(t, got, runs.rowLen(), "ledger %d: rowLen must exact-size the row", ledger) + for r := 1; r < len(runs.terms); r++ { + require.Negative(t, bytes.Compare(runs.terms[r-1][:], runs.terms[r][:]), + "ledger %d: run %d must sort strictly after its predecessor", ledger, r) + } + // Non-vacuity: the merge really did emit the lanes, not just agree + // with the oracle on the hashed terms. + for lane := range fed { + assert.Contains(t, runs.terms, laneKeys[lane], + "ledger %d: lane %d was fed and must be a run", ledger, lane) + } + } +} + +// eventTypes lists the closed alphabet the type lane covers. +func eventTypes() []xdr.ContractEventType { + return []xdr.ContractEventType{ + xdr.ContractEventTypeSystem, + xdr.ContractEventTypeContract, + xdr.ContractEventTypeDiagnostic, + } +} + +// laneEvent builds one event for a lane combination: its type, its topic +// count, and whether it carries a contract ID. Topic values are drawn from a +// small pool so the hashed terms repeat across events the way real ledgers' +// do, instead of degenerating into singleton runs. +func laneEvent( + rng *rand.Rand, eventType xdr.ContractEventType, topicCount int, withCID bool, +) xdr.ContractEvent { + topics := make([]string, topicCount) + for i := range topics { + topics[i] = fmt.Sprintf("t%d-%d", i, rng.Intn(8)) + } + var cid *xdr.ContractId + if withCID { + cid = &xdr.ContractId{} + cid[0] = byte(rng.Intn(4)) + } + ev := symTopicEvent(cid, topics...) + ev.Type = eventType + return ev +} + +// applyLedgerFromMap reproduces the RETIRED map-based ApplyLedger body — the +// pre-flat-pairs apply — as the equivalence oracle for promotion decisions +// and overlay content. It must mirror ApplyLedger except for the container +// it iterates. +func applyLedgerFromMap(h *HotIndex, seq uint32, rowBytes []byte, per map[TermKey][]uint32) error { + if err := h.reapSeal(false); err != nil { + return err + } + for term, ids := range per { + if h.overlay.Has(term) { + h.overlay.AddTo(term, ids...) + } else if len(ids) >= densePromoteWindowCount { + prior, err := h.lookupSparse(h.view.Load(), term) + if err != nil { + return fmt.Errorf("promote %x: %w", term, err) + } + h.overlay.AddTo(term, prior...) + h.overlay.AddTo(term, ids...) + } + } + row := buildWindowRow(rowBytes, len(per)) + row.seq = seq + old := h.view.Load() + rows := make([]windowRow, 0, len(old.rows)+1) + rows = append(rows, old.rows...) + rows = append(rows, row) + h.view.Store(&hotIndexView{rows: rows, runs: old.runs}) + if h.sealArmed && len(rows) >= h.sealEvery && !h.sealInFlight { + h.startSeal(rows) + } + return nil +} + +// TestApplyLedger_OverlayEquivalentToMapPath drives two HotIndexes over the +// SAME randomized ledger stream — one through the retired map apply, one +// through the flat-pairs pipeline — and requires identical promotion +// decisions and identical Get contents for every term ever seen. The stream +// covers: a firehose term (first-ledger promotion), a term that turns dense +// mid-stream (late promotion + window backfill of its sparse prefix), +// mid-rate terms, and per-event singletons. Engines stay DISARMED (the +// warmup-replay state) so both windows retain every row deterministically; +// seal/merge equivalence is hotindex_test.go's job. +func TestApplyLedger_OverlayEquivalentToMapPath(t *testing.T) { + hMap, err := NewHotIndex(t.TempDir(), &fakeManifest{}, testIndexSecret) + require.NoError(t, err) + defer hMap.Close() + hRuns, err := NewHotIndex(t.TempDir(), &fakeManifest{}, testIndexSecret) + require.NoError(t, err) + defer hRuns.Close() + + rng := rand.New(rand.NewSource(4)) + fire := hiKey(rng) + late := hiKey(rng) + mids := []TermKey{hiKey(rng), hiKey(rng), hiKey(rng)} + seen := map[TermKey]bool{fire: true, late: true} + + var s ledgerScratch + nextID := uint32(0) + for l := range uint32(30) { + nEvents := 40 + rng.Intn(20) + keys := make([]TermKey, 0, nEvents*4) + ids := make([]uint32, 0, nEvents*4) + add := func(k TermKey, id uint32) { + keys = append(keys, k) + ids = append(ids, id) + seen[k] = true + } + for i := range nEvents { + id := nextID + nextID++ + add(fire, id) + // late: a sub-threshold trickle for 10 ledgers (stays sparse), + // then full-rate — crossing densePromoteWindowCount forces the + // late-promotion backfill against the accumulated window. + if i < 5 || l >= 10 { + add(late, id) + } + add(mids[rng.Intn(len(mids))], id) + add(hiKey(rng), id) + } + + per := make(map[TermKey][]uint32) + for i := range keys { + per[keys[i]] = append(per[keys[i]], ids[i]) + } + rowMap := referencePackedRow(keys, ids) + + s.reset() + s.keys = append(s.keys, keys...) + s.ids = append(s.ids, ids...) + runs := s.buildRuns() + rowRuns := runs.appendRow(make([]byte, 0, runs.rowLen())) + require.Equal(t, rowMap, rowRuns, "ledger %d: rows must be byte-identical", l) + + require.NoError(t, applyLedgerFromMap(hMap, l, rowMap, per)) + require.NoError(t, hRuns.ApplyLedger(l, rowRuns, runs)) + } + + // The fixture must actually exercise both promotion arms, or the + // equivalence below would pass vacuously. + require.True(t, hMap.overlay.Has(fire), "fixture must first-ledger-promote the firehose term") + require.True(t, hMap.overlay.Has(late), "fixture must late-promote with backfill") + + requireIndexesEquivalent(t, hMap, hRuns, seen, rng) +} + +// requireIndexesEquivalent asserts identical promotion decisions and Get +// contents for every seen term, plus agreeing misses on absent probes. +func requireIndexesEquivalent( + t *testing.T, hMap, hRuns *HotIndex, seen map[TermKey]bool, rng *rand.Rand, +) { + t.Helper() + for term := range seen { + assert.Equal(t, hMap.overlay.Has(term), hRuns.overlay.Has(term), + "promotion decision diverged for term %x", term) + bmMap, gerr := hMap.Get(term) + require.NoError(t, gerr) + bmRuns, gerr := hRuns.Get(term) + require.NoError(t, gerr) + require.True(t, bmMap.Present(), "term %x missing from map-path index", term) + require.True(t, bmRuns.Present(), "term %x missing from runs-path index", term) + assert.Equal(t, bmMap.Bitmap().ToArray(), bmRuns.Bitmap().ToArray(), + "contents diverged for term %x", term) + } + for range 20 { + absent := hiKey(rng) + bmMap, gerr := hMap.Get(absent) + require.NoError(t, gerr) + bmRuns, gerr := hRuns.Get(absent) + require.NoError(t, gerr) + assert.False(t, bmMap.Present()) + assert.False(t, bmRuns.Present()) + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index c9f2b63e5..735f88a57 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -13,13 +13,11 @@ package hotchunk import ( "context" "fmt" - "iter" "maps" "slices" "time" sdkingest "github.com/stellar/go-stellar-sdk/ingest" - "github.com/stellar/go-stellar-sdk/ingest/ledgerbackend" supportlog "github.com/stellar/go-stellar-sdk/support/log" "github.com/stellar/go-stellar-sdk/xdr" @@ -33,8 +31,8 @@ import ( ) // DB is one chunk's hot tier: a single multi-CF rocksdb.Store plus the typed -// facades composed over it — all three on a read-write open; a read-only open -// leaves events nil (see OpenReadOnly). It owns the store (Close closes it +// facades composed over it — all three warmed on a read-write open; a read-only +// open leaves events nil and txhash query-disabled (see OpenReadOnly). It owns the store (Close closes it // once); the facades wrap it without owning it. // // Concurrency: ingestion is single-writer; IngestLedger is not safe to call @@ -68,9 +66,12 @@ func dbTuning() rocksdb.Tuning { MaxBackgroundJobs: 8, MaxOpenFiles: 10_000, - // 512 MB block cache — txhash bloom-filter blocks are the hot - // working set; the cache needs to hold recently-touched bloom - // blocks at scale. + // 512 MB block cache. Sized when the txhash CF's bloom blocks were + // the hot working set; the packed-row redesign removed that CF's + // bloom entirely (queries are served by the in-RAM txhash HotIndex, + // never CF point-reads), so today the cache mostly holds events + // data/offsets blocks and scan readahead. Kept at 512 until a + // measured resize says otherwise. BlockCacheMB: 512, // 1 GB WAL cap. Graceful Close auto-Flushes (see @@ -98,11 +99,89 @@ func config(path string, logger *supportlog.Entry, readOnly, mustExist bool) roc } } +// Tuning is the caller-supplied half of the shared hot DB's configuration — +// the knobs the daemon config (and the bench harness) own, as opposed to the +// calibrated internal dbTuning. Write opens (Open / OpenExisting / +// OpenReadyWrite) take it; read-only opens don't (a read-only view never +// encodes, so no tuning applies). +type Tuning struct { + // ZstdEncodeWorkers is the ledger-frame encode parallelism + // (zstd.WithWorkers): 0 = single-threaded, >=1 = libzstd's internal + // multithreading. FORMAT-AFFECTING for the STORED ledger frames, not a + // casual performance knob: single-threaded and multithreaded encodes + // produce different frame bytes, the freeze copies hot frames into the + // cold pack VERBATIM, and the walk/backfill materializer re-encodes the + // same ledgers independently — so hot ingest and walk/backfill must + // encode with the SAME value or the same chunk's pack stops being + // byte-identical across materializers (the artifact model's identity + // contract, pinned by the freeze-vs-walk gates). Change it only as a + // deliberate, deployment-wide decision. + ZstdEncodeWorkers int +} + +// DefaultTuning returns the settled production tuning +// (ledger.DefaultZstdEncodeWorkers). +func DefaultTuning() Tuning { + return Tuning{ZstdEncodeWorkers: ledger.DefaultZstdEncodeWorkers} +} + +// Secrets carries the two routing secrets a read-WRITE hot chunk needs. Both +// hot engines blind at seal — a sealed run holds the same keys the chunk's +// cold artifact will — so each engine's secret must be the one its COLD +// build uses, and the pairing is per chunk, not per deployment: txhash's is +// per tx-hash INDEX (a contiguous run of chunks), events' per chunk. +// DeriveSecrets is the derivation; the values are separate fields, never one +// shared secret, because the two domains are separately derived +// (stores.DeriveIndexSecret) and must not become interchangeable by accident. +// +// A read-ONLY open takes none: it warms nothing and probes nothing (see +// OpenReadOnly), and the freezes take their secret as an explicit argument, +// checked against the one the chunk DB adopted. +type Secrets struct { + Txhash [stores.SecretLen]byte + Events [stores.SecretLen]byte +} + +// DeriveSecrets derives a chunk's hot routing secrets from the deployment's +// catalog secret — the SAME derivations the cold builds use +// (txhash.ColdIndexSecret over the chunk's tx-hash index id, +// event.ColdIndexSecret over the chunk id), so a chunk's sealed runs and its +// frozen artifacts are always keyed alike. This is the ONE derivation; every +// site that needs a chunk's secrets, hot or cold, reaches it — the hot opens +// through SecretsFor below, the cold materializer through it too +// (backfill's ingestConfigFor). +func DeriveSecrets(catalogSecret []byte, txLayout geometry.TxHashIndexLayout, chunkID chunk.ID) Secrets { + return Secrets{ + Txhash: txhash.ColdIndexSecret(catalogSecret, uint32(txLayout.TxHashIndexID(chunkID))), + Events: event.ColdIndexSecret(catalogSecret, chunkID), + } +} + +// SecretSource is the half of a catalog the derivation reads: the deployment +// secret and the tx-hash index layout. Declared as an interface, and here +// rather than in catalog, so hotchunk needn't import catalog (*catalog.Catalog +// satisfies it structurally) — the same #824 split invariant backfill keeps. +type SecretSource interface { + Secret() [32]byte + TxHashIndexLayout() geometry.TxHashIndexLayout +} + +// SecretsFor derives chunkID's hot routing secrets from a catalog. It exists +// because Go cannot slice a call's array result: without it every caller +// spells the same two-line `s := cat.Secret(); DeriveSecrets(s[:], ...)` +// dance, which is a derivation repeated at every open site rather than one. +func SecretsFor(src SecretSource, chunkID chunk.ID) Secrets { + secret := src.Secret() + return DeriveSecrets(secret[:], src.TxHashIndexLayout(), chunkID) +} + // Open opens (or creates) the chunk's shared multi-CF hot DB read-WRITE // (ingestion's handle for a NEW chunk) and composes the three facades over it. On // any facade-construction failure the shared store is closed before returning. -func Open(path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, error) { - return open(path, chunkID, logger, false, false) +func Open( + path string, chunkID chunk.ID, logger *supportlog.Entry, tun Tuning, secrets Secrets, +) (*DB, error) { + return open(path, chunkID, logger, tun, secrets, false, false) } // OpenExisting opens an EXISTING hot DB read-WRITE with create-if-missing OFF — @@ -110,8 +189,10 @@ func Open(path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, error) // A missing or gutted DB fails the open instead of silently fabricating a fresh // empty one (the "never auto-heal" rule); the caller treats that failure as an // ordinary run-failing error. -func OpenExisting(path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, error) { - return open(path, chunkID, logger, false, true) +func OpenExisting( + path string, chunkID chunk.ID, logger *supportlog.Entry, tun Tuning, secrets Secrets, +) (*DB, error) { + return open(path, chunkID, logger, tun, secrets, false, true) } // OpenReadOnly opens an EXISTING hot DB read-only — the freeze source's view AND @@ -121,30 +202,42 @@ func OpenExisting(path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, // refinement DEPENDS on that replay to read a correct MaxCommittedSeq. (An // unsynced tail is exactly what a crash loses, and is not recovered.) // -// A read-only open is a LEDGERS-ONLY view: it composes the ledger + txhash facades -// but SKIPS the events facade, because both read-only callers (freeze re-derives the -// cold artifacts from raw LCMs via Source(); the startup refiner reads only -// MaxCommittedSeq()) touch the ledgers CF alone and never the events mirror/offsets. -// Composing the events facade would run the event store's unconditional warmup — a full -// index-CF scan plus bitmap/offsets rebuild — discarded unread at Close (#834). The -// skip is enforced structurally: a read-only DB has no events facade, so Events() -// panics and IngestLedger errors rather than serving a cold, unwarmed surface. +// A read-only open is a LEDGERS-ONLY view: it composes the ledger facade, a +// QUERY-DISABLED txhash facade, and no events facade, because neither read-only +// caller needs anything warm — the freeze scans CFs and run files directly +// through the shared store (each FreezeColdFromStore takes d.store, not a +// facade), and the startup refiner reads only MaxCommittedSeq(). Both touch +// facade state for the ledgers CF alone. Composing the warmed facades would run +// their unconditional warmups — the events index-CF scan plus bitmap/offsets +// rebuild, and the txhash run reopen plus tail replay — discarded unread at +// Close (#834). The skip is enforced structurally: a read-only DB has no events +// facade (Events() panics), its txhash facade's Get panics, and IngestLedger +// errors rather than serving a cold, unwarmed surface. func OpenReadOnly(path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, error) { - return open(path, chunkID, logger, true, false) + // Zero Tuning and zero Secrets: a read-only view composes the ledger + // facade for reads alone — its encoder is never invoked and neither hot + // index is warmed, so neither applies. + return open(path, chunkID, logger, Tuning{}, Secrets{}, true, false) } // OpenReadyWrite opens a "ready" chunk's hot DB read-WRITE — ingestion's handle // for a resumed chunk (OpenExisting underneath). openReady enforces the ready-open // rule. -func OpenReadyWrite(state geometry.HotState, path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, error) { - return openReady(state, path, chunkID, logger, false) +func OpenReadyWrite( + state geometry.HotState, path string, chunkID chunk.ID, logger *supportlog.Entry, + tun Tuning, secrets Secrets, +) (*DB, error) { + return openReady(state, path, chunkID, logger, + func(p string, c chunk.ID, l *supportlog.Entry) (*DB, error) { + return OpenExisting(p, c, l, tun, secrets) + }) } // OpenReadyView opens a "ready" chunk's hot DB read-only — the freeze source's // and the last-committed refiner's view (OpenReadOnly underneath). openReady // enforces the ready-open rule. func OpenReadyView(state geometry.HotState, path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, error) { - return openReady(state, path, chunkID, logger, true) + return openReady(state, path, chunkID, logger, OpenReadOnly) } // openReady is the single enforcement site for the "ready key ⇒ must-exist, @@ -154,17 +247,19 @@ func OpenReadyView(state geometry.HotState, path string, chunkID chunk.ID, logge // the catalog considers ready. Either way a missing or gutted "ready" DB fails // the open — never auto-healed into a fresh empty one — wrapped in the uniform // won't-open error so every ready-open site reports it identically. +// +// What it does NOT take is the write open's configuration: openFn is the +// caller's already-bound opener, so the read-only path carries no zero Tuning +// or zero Secrets it would never use. The rule enforced here is about the +// ready KEY; the shape of the open belongs to the two exported wrappers. func openReady( - state geometry.HotState, path string, chunkID chunk.ID, logger *supportlog.Entry, readOnly bool, + state geometry.HotState, path string, chunkID chunk.ID, logger *supportlog.Entry, + openFn func(string, chunk.ID, *supportlog.Entry) (*DB, error), ) (*DB, error) { if state != geometry.HotReady { return nil, fmt.Errorf( "hotchunk: ready-open requires chunk %s key %q, got %q", chunkID, geometry.HotReady, state) } - openFn := OpenExisting - if readOnly { - openFn = OpenReadOnly - } db, err := openFn(path, chunkID, logger) if err != nil { return nil, fmt.Errorf("chunk %s is %q but its hot DB won't open: %w", chunkID, geometry.HotReady, err) @@ -172,13 +267,19 @@ func openReady( return db, nil } -func open(path string, chunkID chunk.ID, logger *supportlog.Entry, readOnly, mustExist bool) (*DB, error) { +func open( + path string, chunkID chunk.ID, logger *supportlog.Entry, tun Tuning, secrets Secrets, + readOnly, mustExist bool, +) (*DB, error) { if path == "" { return nil, stores.ErrInvalidConfig } if logger == nil { return nil, stores.ErrInvalidConfig } + if tun.ZstdEncodeWorkers < 0 { + return nil, fmt.Errorf("%w: Tuning.ZstdEncodeWorkers must be >= 0", stores.ErrInvalidConfig) + } store, err := rocksdb.New(config(path, logger, readOnly, mustExist)) if err != nil { return nil, fmt.Errorf("open chunk %s: %w", chunkID, err) @@ -187,18 +288,27 @@ func open(path string, chunkID chunk.ID, logger *supportlog.Entry, readOnly, mus db := &DB{ store: store, chunkID: chunkID, - ledger: ledger.NewWithStore(store), - txhash: txhash.NewWithStore(store), + ledger: ledger.NewWithStore(store, tun.ZstdEncodeWorkers), } // A read-only open is a ledgers-only freeze/probe view (see OpenReadOnly): it - // never reads events, so skip composing the events facade and its unconditional - // warmup scan. Read-WRITE opens (ingestion) MUST warm — the write path assigns - // event IDs off the warmed offsets — so they always compose it. + // never queries events or txhash, so skip both warmups — txhash composes its + // query-disabled read-only variant (Get panics), events is absent entirely. + // Read-WRITE opens (ingestion) MUST warm both — the write paths assign event + // IDs off the warmed offsets and packed rows off the warmed dense chain — so + // they always compose the warmed facades. if readOnly { + db.txhash = txhash.NewReadOnlyWithStore(store, chunkID) return db, nil } - es, err := event.NewWithStore(store, chunkID) + th, err := txhash.NewWithStore(store, chunkID, secrets.Txhash) if err != nil { + _ = store.Close() + return nil, fmt.Errorf("compose txhash facade for chunk %s: %w", chunkID, err) + } + db.txhash = th + es, err := event.NewWithStore(store, chunkID, secrets.Events) + if err != nil { + th.Shutdown() _ = store.Close() return nil, fmt.Errorf("compose events facade for chunk %s: %w", chunkID, err) } @@ -216,6 +326,10 @@ func (d *DB) Ledgers() *ledger.HotStore { return d.ledger } // Txhash returns the txhash read/write facade over the shared store. The write // side feeds the ingestion loop; the read side probes it via // query.ReadView.HotTxHashIndexes. +// +// On a read-only DB the returned facade is query-disabled: its Get panics +// (no warmup ran — see txhash.NewReadOnlyWithStore), the same structural +// rule as Events() below. func (d *DB) Txhash() *txhash.HotStore { return d.txhash } // Events returns the events read/write facade over the shared store. Writes feed @@ -232,19 +346,63 @@ func (d *DB) Events() *event.HotStore { return d.events } -// Source streams the chunk's LCMs from the ledgers CF as a ledgerbackend.LedgerStream -// the cold writer (backfill's WriteColdChunk) drains, so a just-closed chunk freezes -// straight from its hot DB without a refetch. The freeze reads through the -// registry's shared handle when one is published; the read-only reopen is the -// fallback for the startup catch-up, where no writer is open. -func (d *DB) Source() ledgerbackend.LedgerStream { - return &hotLedgerStream{store: d.ledger} +// FreezeEventsCold builds the chunk's three cold events artifacts in +// bucketDir directly from THIS hot DB's durable events state — freeze-by- +// merge, the same shape as FreezeTxhashCold: the data CF's values are the +// canonical marshaled payloads, the offsets CF is the ledger-count sequence, +// and the term index replays the hot engine's manifest-listed sealed runs +// plus the un-sealed packed-row tail through the cold run merge — no ledger +// re-extraction, no whole-chunk index re-derivation, and no re-keying (the +// runs were blinded at seal); index memory is the freeze's byte-bounded tail +// windows (event.FreezeColdFromStore). +// Valid on a read-only view; the DB must be complete through the chunk's +// last ledger (the freeze's source resolution already guarantees it). +func (d *DB) FreezeEventsCold( + ctx context.Context, scratchDir, bucketDir string, secret [stores.SecretLen]byte, + opts event.ColdWriterOptions, +) error { + return event.FreezeColdFromStore(ctx, d.chunkID, d.store, scratchDir, bucketDir, secret, opts) } -// Close releases the shared store exactly once. Idempotent, and safe against -// in-flight reads/writes: the store drains them under its lock before freeing, -// and any operation arriving after gets ErrStoreClosed. -func (d *DB) Close() error { return d.store.Close() } +// FreezeLedgersCold builds the chunk's cold ledger .pack at packPath by +// copying THIS hot DB's ledgers-CF zstd frames verbatim (PreCompressed cold +// writer): nothing is re-encoded, and the one decode left is the pack +// writer's own — its hashing workers decompress each frame once to feed the +// content hash, which is over RAW ledger bytes. Valid on a read-only view; +// the DB must be complete through the chunk's last ledger. Returns the +// ledgers written. +func (d *DB) FreezeLedgersCold( + ctx context.Context, packPath string, opts ledger.ColdWriterOptions, +) (int, error) { + return ledger.FreezeColdFromStore(ctx, d.chunkID, d.store, packPath, opts) +} + +// FreezeTxhashCold builds the chunk's cold txhash .bin at binPath by +// freeze-by-merge over THIS hot DB's durable txhash state: the +// manifest-listed sealed runs (already blinded and key-sorted at seal, so +// their records ARE .bin entries) plus the un-sealed packed-row tail, k-way +// merged straight into the file. Valid on a read-only view (it never touches +// the query facade); same completeness contract as the other freezes. +// Returns the entries written. +func (d *DB) FreezeTxhashCold( + ctx context.Context, binPath string, secret [stores.SecretLen]byte, +) (int, error) { + return txhash.FreezeColdFromStore(ctx, d.chunkID, d.store, binPath, secret) +} + +// Close releases the shared store exactly once. Idempotent. Must not be called +// concurrently with in-flight reads/writes. Both facades' background seals +// (events, txhash) are drained first so no goroutine outlives the store; the +// two shutdowns are independent. +func (d *DB) Close() error { + if d.events != nil { + d.events.Shutdown() + } + if d.txhash != nil { + d.txhash.Shutdown() + } + return d.store.Close() +} // CloseIfIdle is the non-blocking Close deferred deletion uses to reclaim a // discarded chunk: it closes only when no operation is in flight and otherwise @@ -263,16 +421,22 @@ func (d *DB) MaxCommittedSeq() (uint32, bool, error) { // unrepresentable — no string label to mistype and no map lookup to nil-panic in a // sink. The phases partition the per-ledger wall-clock: // - PhaseExtract: the product reads over the caller's shared -// ExtractLedgerTxParts walk (txhash-entry build, events extraction, event +// ExtractLedgerTxParts walk (txhash row pack, events extraction, event // shaping — all pre-batch, so every decode failure lands here by // construction); HotService.Ingest folds the walk's own duration into this // phase, keeping it "the walk + product reads"; -// - PhaseLedgers/PhaseTxhash/PhaseEvents: each facade's queue-into-batch step; +// - PhaseLedgers/PhaseTxhash/PhaseEvents: each facade's queue-into-batch step. +// PhaseLedgers is the JOIN on the background ledger compression forked at +// IngestLedger entry (wait-for-encode + Put) — near-zero when the encode +// finished within the other steps' window; // - PhaseCommit: the RocksDB batch write (WAL append + fsync + memtable) = the // whole Batch call minus the three queue steps — the fsync wait pprof can't see. -// - PhaseApply: the post-commit in-memory mirror/offsets apply (the events -// copy-on-write bitmap clones). It runs only after the batch is durable, so it -// is emitted on the success path only and Failed is never PhaseApply. +// - PhaseApply: the post-commit hot-index applies — txhash first, then +// events (window retention, overlay/seal folding; the two are +// independent). They run only after the batch is durable; the hooks are +// fallible, so an error here reports Failed = PhaseApply — the ledger IS +// committed, and a restart rebuilds the indexes deterministically from +// the committed rows. type Phase uint8 const ( @@ -329,8 +493,8 @@ type LedgerReport struct { // IngestLedger commits ONE ledger as a SINGLE atomic synced WriteBatch across all // hot CFs (decision (a)): queue ledgers, txhash, and events rows into one -// BatchWriter, commit once, and only then apply the events in-memory mirror/offsets -// update. +// BatchWriter, commit once, and only then run the txhash and events in-memory +// applies. // // txParts is the caller's ExtractLedgerTxParts output for lcmView. The walk // lives one level up (HotService.Ingest) because its output feeds BOTH this @@ -342,8 +506,20 @@ type LedgerReport struct { // copies what it retains, so neither need outlive this call. Store.Batch's // lifecycle RLock + checkOpen is the authoritative closed-store guard, so there // is no separate pre-check here. + +// StartCompress forks the ledger-bytes zstd encode. The caller starts it +// BEFORE its own TxProcessing walk so the encode overlaps that walk — the +// walk is the largest step the encode can hide behind, and forking inside +// IngestLedger (after the caller has already walked) forfeits it. The +// returned handle is consumed by IngestLedger, which owns its Discard; a +// caller that fails before calling IngestLedger must Discard it itself. +func (d *DB) StartCompress(seq uint32, lcmView xdr.LedgerCloseMetaView) *ledger.PendingCompression { + return d.ledger.StartCompress(ledger.Entry{Seq: seq, Bytes: []byte(lcmView)}) +} + func (d *DB) IngestLedger( seq uint32, lcmView xdr.LedgerCloseMetaView, txParts []sdkingest.LedgerTxParts, + pending *ledger.PendingCompression, ) (LedgerReport, error) { var rep LedgerReport @@ -355,6 +531,15 @@ func (d *DB) IngestLedger( return rep, fmt.Errorf("chunk %s: IngestLedger on a read-only (ledgers-only) hot DB", d.chunkID) } + // Fork the ledger-bytes zstd encode FIRST: it is independent of every other + // step and, unforked, was the second-largest serial slice (~20ms). It runs + // concurrent with extract and the queue steps (all read-only on lcmView) and is + // joined as the batch's LAST queue step, by which point it has usually + // finished — PhaseLedgers then measures only the join wait + Put. The + // deferred Discard bounds the borrowed lcmView on every early-error path: + // both join and Discard block until the encoder is done with the bytes. + defer pending.Discard() + // Pre-extract anything that can fail BEFORE opening the batch, so a decode // error rejects the ledger without a half-built batch. // @@ -373,12 +558,16 @@ func (d *DB) IngestLedger( // RunBackfill's "reported even on failure"), so the error is never emitted with // a zero-duration sample. extractStart := time.Now() - txEntries := make([]txhash.Entry, 0, len(txParts)) - for i := range txParts { - txEntries = append(txEntries, txhash.Entry{Hash: txParts[i].Hash, LedgerSeq: seq}) - if txParts[i].FeeBump { - txEntries = append(txEntries, txhash.Entry{Hash: txParts[i].InnerHash, LedgerSeq: seq}) - } + // Pack the ledger's tx hashes into the ONE sorted row the txhash CF + // stores per ledger. EncodeRow is fallible pre-batch work (sort + + // intra-ledger duplicate reject), so a corrupt ledger fails here, before + // the batch opens. + txHashes := ledgerTxHashes(txParts) + txRow, err := txhash.EncodeRow(txHashes) + if err != nil { + rep.Phases[PhaseExtract].Dur = time.Since(extractStart) + rep.Failed = PhaseExtract + return rep, fmt.Errorf("pack tx hashes seq %d: %w", seq, err) } closedAt, err := lcmView.LedgerCloseTime() @@ -398,33 +587,31 @@ func (d *DB) IngestLedger( rep.Phases[PhaseExtract].Dur = time.Since(extractStart) // Per-type write volume lives on the write phases (emitted on success). rep.Phases[PhaseLedgers].Items = 1 - rep.Phases[PhaseTxhash].Items = len(txEntries) + rep.Phases[PhaseTxhash].Items = len(txHashes) rep.Phases[PhaseEvents].Items = len(payloads) // The events facade validates + marshals inside the batch callback (so a // rejected ledger never leaves committed rows) and returns the post-commit - // apply hook. Under decision (a) resume is always MaxCommittedSeq+1, so seq is - // never a duplicate — the hook is always non-nil on success. Each facade's queue + // apply hook; the txhash facade queues its pre-validated packed row and + // returns its own. Under decision (a) resume is always MaxCommittedSeq+1, so seq is + // never a duplicate — the hooks are always non-nil on success. Each facade's queue // step is timed individually; Commit (below) is the whole Batch minus those — // the RocksDB write (WAL append + fsync + memtable). - var applyEvents func() + var applyTxhash, applyEvents func() error // A batch error not attributed to a specific queue step below is the commit // itself (the RocksDB write); a queue-step error narrows Failed to its phase. failed := PhaseCommit batchStart := time.Now() cerr := d.store.Batch(func(b *rocksdb.BatchWriter) error { - ls := time.Now() - if err := d.ledger.AddLedgerToBatch(b, ledger.Entry{Seq: seq, Bytes: []byte(lcmView)}); err != nil { - rep.Phases[PhaseLedgers].Dur = time.Since(ls) - failed = PhaseLedgers - return fmt.Errorf("queue ledger seq %d: %w", seq, err) - } - rep.Phases[PhaseLedgers].Dur = time.Since(ls) - + // Queue order: txhash and events first, the compression JOIN last — + // maximizing the window the background encode has to finish, so the + // join usually waits ~0. Rows land in one atomic batch regardless of + // queue order; only the emission order of the phase metrics is fixed + // (by the Phase constants), not the execution order here. + // One packed-row Put per ledger — EVERY ledger, an empty row for a + // tx-less one, keeping the txhash CF's dense chain. ts := time.Now() - if len(txEntries) > 0 { - d.txhash.AddEntriesToBatch(b, txEntries) - } + applyTxhash = d.txhash.AddLedgerToBatch(b, seq, txRow, len(txHashes)) rep.Phases[PhaseTxhash].Dur = time.Since(ts) es := time.Now() @@ -436,6 +623,14 @@ func (d *DB) IngestLedger( } rep.Phases[PhaseEvents].Dur = time.Since(es) applyEvents = apply + + ls := time.Now() + if err := d.ledger.AddPendingToBatch(b, pending); err != nil { + rep.Phases[PhaseLedgers].Dur = time.Since(ls) + failed = PhaseLedgers + return fmt.Errorf("queue ledger seq %d: %w", seq, err) + } + rep.Phases[PhaseLedgers].Dur = time.Since(ls) return nil }) // Commit is the whole Batch call minus the three queue steps: the RocksDB write @@ -451,67 +646,38 @@ func (d *DB) IngestLedger( return rep, fmt.Errorf("commit ledger %d to chunk %s: %w", seq, d.chunkID, cerr) } - // Batch is durable — now and only now apply the events mirror/offsets update. - // PhaseApply times this post-commit in-memory work (the events mirror's - // copy-on-write bitmap clones), which otherwise lands in no phase. + // Batch is durable — now and only now run the post-commit hot-index + // applies: txhash first (window publish + seal folding), then events + // (window retention, dense-overlay feed, seal folding). The two applies + // are INDEPENDENT — each publishes its own index from its own rows, and + // each publish is the ONLY thing making its data hot-queryable — so + // txhash-first is convention, not a dependency. PhaseApply times this + // post-commit work; either failure reports Failed = PhaseApply — the + // ledger IS committed, and a restart rebuilds both indexes + // deterministically from the committed rows. applyStart := time.Now() - applyEvents() + aerr := applyTxhash() + if aerr == nil { + aerr = applyEvents() + } rep.Phases[PhaseApply].Dur = time.Since(applyStart) + if aerr != nil { + rep.Failed = PhaseApply + return rep, fmt.Errorf("apply hot indexes for ledger %d: %w", seq, aerr) + } return rep, nil } -// hotLedgerStream is a ledgerbackend.LedgerStream over a ledger.HotStore, so the -// source-blind cold pipeline freezes a just-closed chunk from its hot DB. -type hotLedgerStream struct { - store *ledger.HotStore -} - -var _ ledgerbackend.LedgerStream = (*hotLedgerStream)(nil) - -// RawLedgers yields the range's wire bytes from the hot store. IterateLedgers -// yields BORROWED buffers (valid only to the next step); the drain loop consumes -// each fully before the next yield, so the borrow is safe. ctx cancellation is -// observed between ledgers (the LedgerStream contract drain relies on). -// -// It enforces the LedgerStream in-order contract at the source (so the shared -// cursor could be deleted): the hot store is the SOLE writer of recent history, so -// a gap in its keyspace is a real defect, caught here by a key-derived seq check -// (no XDR parse). An unbounded range self-bounds at the store's committed frontier -// (LastSeq), mirroring packStream, so callers can pass UnboundedRange(from). -func (st *hotLedgerStream) RawLedgers( - ctx context.Context, r ledgerbackend.Range, _ ...ledgerbackend.StreamOption, -) iter.Seq2[[]byte, error] { - return func(yield func([]byte, error) bool) { - to := r.To() - if !r.Bounded() { - maxSeq, ok, err := st.store.LastSeq() - if err != nil { - yield(nil, fmt.Errorf("hotLedgerStream: read committed frontier: %w", err)) - return - } - if !ok { - return // empty store: nothing to yield - } - to = maxSeq - } - expected := r.From() - for e, ierr := range st.store.IterateLedgers(r.From(), to) { - if cerr := ctx.Err(); cerr != nil { - yield(nil, cerr) - return - } - if ierr != nil { - yield(nil, ierr) - return - } - if e.Seq != expected { - yield(nil, fmt.Errorf("hotLedgerStream: gap at seq %d, expected %d", e.Seq, expected)) - return - } - if !yield(e.Bytes, nil) { - return - } - expected++ +// ledgerTxHashes collects one ledger's indexable tx hashes in apply order — +// a fee-bump transaction contributes BOTH its outer and inner hash (#862), +// so either resolves to the ledger. +func ledgerTxHashes(txParts []sdkingest.LedgerTxParts) [][32]byte { + hashes := make([][32]byte, 0, len(txParts)) + for i := range txParts { + hashes = append(hashes, txParts[i].Hash) + if txParts[i].FeeBump { + hashes = append(hashes, txParts[i].InnerHash) } } + return hashes } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go index 5da4976d5..85777ffe0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go @@ -3,6 +3,9 @@ package hotchunk import ( "bytes" "context" + "os" + "path/filepath" + "slices" "testing" "github.com/linxGnu/grocksdb" @@ -11,7 +14,6 @@ import ( "github.com/stretchr/testify/require" sdkingest "github.com/stellar/go-stellar-sdk/ingest" - "github.com/stellar/go-stellar-sdk/ingest/ledgerbackend" "github.com/stellar/go-stellar-sdk/keypair" "github.com/stellar/go-stellar-sdk/network" supportlog "github.com/stellar/go-stellar-sdk/support/log" @@ -33,6 +35,24 @@ func silentLogger() *supportlog.Entry { return log } +// testMasterSecret stands in for the deployment's catalog secret: this +// package's tests derive per-chunk routing secrets from it exactly as +// DeriveSecrets does from the real one, so a hot DB opened here is keyed like +// a production chunk's and its freeze output can be compared against the walk +// shape under the same secret. +var testMasterSecret = []byte("hotchunk-test-master") + +// testSecrets derives the hot routing secrets for chunk 0 — every fixture in +// this package. It mirrors DeriveSecrets without a geometry layout, which is +// exactly why it is pinned to one chunk: chunk 0's tx-hash index id is 0, so +// the layout drops out of the derivation. +func testSecrets() Secrets { + return Secrets{ + Txhash: txhash.ColdIndexSecret(testMasterSecret, 0), + Events: event.ColdIndexSecret(testMasterSecret, chunk.ID(0)), + } +} + // ingestRaw runs the caller's half of the production write path — the shared // ExtractLedgerTxParts walk — and hands its output to IngestLedger, so these // tests keep driving the storage write from raw LCM bytes. @@ -40,13 +60,14 @@ func ingestRaw(t *testing.T, db *DB, seq uint32, raw []byte) (LedgerReport, erro t.Helper() txParts, err := sdkingest.ExtractLedgerTxParts(xdr.LedgerCloseMetaView(raw)) require.NoError(t, err) - return db.IngestLedger(seq, xdr.LedgerCloseMetaView(raw), txParts) + return db.IngestLedger(seq, xdr.LedgerCloseMetaView(raw), txParts, + db.StartCompress(seq, xdr.LedgerCloseMetaView(raw))) } // openTestDB opens a fresh hot DB bound to chunk 0 (every test uses chunk 0). func openTestDB(t *testing.T) *DB { t.Helper() - db, err := Open(t.TempDir(), chunk.ID(0), silentLogger()) + db, err := Open(t.TempDir(), chunk.ID(0), silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) return db @@ -72,7 +93,8 @@ func TestConfig_PerCFOptionRouting(t *testing.T) { perCF := config(t.TempDir(), silentLogger(), false, false).PerCFOptions txCF := txhash.CFNames()[0] - assert.Equal(t, 12, perCF[txCF].BloomFilterBitsPerKey, "txhash CF keeps its bloom") + assert.Zero(t, perCF[txCF].BloomFilterBitsPerKey, + "txhash CF gets no bloom (write-once 4-byte seq keys, never point-probed)") assert.Equal(t, 64, perCF[txCF].WriteBufferMB, "txhash CF keeps its write buffer") assert.True(t, perCF[txCF].DisableAutoCompactions, "txhash CF keeps compaction off") @@ -96,10 +118,10 @@ func TestConfig_DBWideTuningStaysShared(t *testing.T) { } func TestOpen_ValidatesInputs(t *testing.T) { - _, err := Open("", chunk.ID(0), silentLogger()) + _, err := Open("", chunk.ID(0), silentLogger(), DefaultTuning(), testSecrets()) require.ErrorIs(t, err, stores.ErrInvalidConfig) - _, err = Open(t.TempDir(), chunk.ID(0), nil) + _, err = Open(t.TempDir(), chunk.ID(0), nil, DefaultTuning(), testSecrets()) require.ErrorIs(t, err, stores.ErrInvalidConfig) } @@ -158,10 +180,10 @@ func TestIngestLedger_AllCFsAdvanceTogether(t *testing.T) { require.NoError(t, err) assert.Equal(t, first+1, seqB) // events CFs. - bms, err := db.Events().LookupKeys(context.Background(), []event.TermKey{termA}) + post, err := db.Events().LookupKeys(context.Background(), []event.TermKey{termA}) require.NoError(t, err) - require.NotNil(t, bms[0]) - assert.Equal(t, uint64(2), bms[0].GetCardinality(), "both ledgers share the event term") + require.True(t, post[0].Present()) + assert.Equal(t, uint64(2), post[0].Cardinality(), "both ledgers share the event term") assert.Equal(t, uint32(2), eventCount(t, db.Events())) // The single authoritative committed frontier equals the last committed seq. @@ -200,7 +222,7 @@ func TestIngestLedger_RejectedLedgerPersistsNothingAcrossAnyCF(t *testing.T) { // events CFs — no term indexed, no event committed (clean miss = nil bitmap). bms, lerr := db.Events().LookupKeys(context.Background(), []event.TermKey{term}) require.NoError(t, lerr) - require.Nil(t, bms[0]) + require.False(t, bms[0].Present()) assert.Equal(t, uint32(0), eventCount(t, db.Events())) // The single committed frontier is still empty — nothing committed. @@ -218,7 +240,7 @@ func TestIngestLedger_MidBatchCommitFailurePersistsNothing(t *testing.T) { first := chunkID.FirstLedger() dir := t.TempDir() - db, err := Open(dir, chunkID, silentLogger()) + db, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) // Commit one good ledger so there is a known last committed seq, then close the DB. @@ -228,7 +250,7 @@ func TestIngestLedger_MidBatchCommitFailurePersistsNothing(t *testing.T) { require.NoError(t, db.Close()) // Reopen and confirm the last committed seq survived (sync=true durability). - db2, err := Open(dir, chunkID, silentLogger()) + db2, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db2.Close() }) @@ -246,7 +268,7 @@ func TestIngestLedger_MidBatchCommitFailurePersistsNothing(t *testing.T) { // Reopen a third time: the failed ledger left NO trace in any CF, and the // last committed seq is still the last good seq. - db3, err := Open(dir, chunkID, silentLogger()) + db3, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db3.Close() }) @@ -309,56 +331,6 @@ func TestSharedBatch_DirectRocksAbortAcrossCFs(t *testing.T) { // package, so no production accessor is needed). func storeOf(db *DB) *rocksdb.Store { return db.store } -// TestSource_SelfBoundsUnboundedRange confirms the freeze source (hotLedgerStream) -// yields the store's committed ledgers in order and self-bounds an UNBOUNDED range -// at the committed frontier (mirroring packStream), so drain can pass -// UnboundedRange(from) rather than a pre-computed bound. -func TestSource_SelfBoundsUnboundedRange(t *testing.T) { - db := openTestDB(t) - first := chunk.ID(0).FirstLedger() - for i := range uint32(3) { - _, err := ingestRaw(t, db, first+i, zeroTxLCM(t, first+i)) - require.NoError(t, err) - } - - var got []uint32 - for raw, err := range db.Source().RawLedgers(context.Background(), ledgerbackend.UnboundedRange(first)) { - require.NoError(t, err) - seq, serr := xdr.LedgerCloseMetaView(raw).LedgerSequence() - require.NoError(t, serr) - got = append(got, seq) - } - require.Equal(t, []uint32{first, first + 1, first + 2}, got, "self-bounds at the frontier, in order") -} - -// TestSource_RejectsGap pins the source-side in-order guard that replaced the -// shared cursor: a gap in the hot store's keyspace (the sole writer of recent -// history) is a real defect and must surface as an error, not a silent skip. -func TestSource_RejectsGap(t *testing.T) { - db := openTestDB(t) - first := chunk.ID(0).FirstLedger() - // Seed the ledgers CF directly with a GAP (first, first+2), bypassing - // IngestLedger's contiguity so the source-level guard is what's exercised. - require.NoError(t, storeOf(db).Batch(func(b *rocksdb.BatchWriter) error { - for _, s := range []uint32{first, first + 2} { - if err := db.Ledgers().AddLedgerToBatch(b, ledger.Entry{Seq: s, Bytes: []byte("x")}); err != nil { - return err - } - } - return nil - })) - - var lastErr error - for _, err := range db.Source().RawLedgers(context.Background(), ledgerbackend.BoundedRange(first, first+2)) { - if err != nil { - lastErr = err - break - } - } - require.Error(t, lastErr) - require.Contains(t, lastErr.Error(), "gap") -} - // TestIngestLedger_WritesEveryHotType confirms the hot tier always writes all // three hot data types; per-type disabling is not a supported hot DB mode. func TestIngestLedger_WritesEveryHotType(t *testing.T) { @@ -380,8 +352,8 @@ func TestIngestLedger_WritesEveryHotType(t *testing.T) { assert.Equal(t, first, seq) bms, err := db.Events().LookupKeys(context.Background(), []event.TermKey{term}) require.NoError(t, err) - require.NotNil(t, bms[0]) - assert.Equal(t, uint64(1), bms[0].GetCardinality()) + require.True(t, bms[0].Present()) + assert.Equal(t, uint64(1), bms[0].Cardinality()) } // TestIngestLedger_EventlessTxStillIndexesHash pins the post-merge txhash @@ -422,25 +394,29 @@ func TestIngestLedger_EventlessTxStillIndexesHash(t *testing.T) { assert.Equal(t, uint32(1), eventCount(t, db.Events())) } -// TestReopen_RecoversEventsMirror confirms the events facade's warmup runs over -// the shared store on reopen (the mirror/offsets are reconstructed from the -// events CFs), so a reopened DB assigns event IDs continuing from disk. +// TestReopen_RecoversEventsMirror confirms both facades' warmups run over +// the shared store on reopen: the events mirror/offsets are reconstructed +// from the events CFs (event IDs continue from disk), and the txhash tail +// replay makes the committed hashes hot-queryable again. func TestReopen_RecoversEventsMirror(t *testing.T) { chunkID := chunk.ID(0) first := chunkID.FirstLedger() dir := t.TempDir() - db, err := Open(dir, chunkID, silentLogger()) + db, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) - raw, _, _ := lcmWithEvent(t, first) + raw, hash, _ := lcmWithEvent(t, first) _, err = ingestRaw(t, db, first, raw) require.NoError(t, err) require.NoError(t, db.Close()) - db2, err := Open(dir, chunkID, silentLogger()) + db2, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db2.Close() }) assert.Equal(t, uint32(1), eventCount(t, db2.Events()), "warmup recovered the events offsets") + seq, err := db2.Txhash().Get(hash) + require.NoError(t, err, "txhash warmup must replay the committed tail") + assert.Equal(t, first, seq) } // TestOpenReadOnly_ReadsCommittedAndRejectsWrites pins the freeze source's @@ -453,7 +429,7 @@ func TestOpenReadOnly_ReadsCommittedAndRejectsWrites(t *testing.T) { dir := t.TempDir() // Writer: ingest two ledgers, then close (flushes the WAL into SST). - db, err := Open(dir, chunkID, silentLogger()) + db, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) for _, seq := range []uint32{first, first + 1} { _, ierr := ingestRaw(t, db, seq, zeroTxLCM(t, seq)) @@ -488,7 +464,7 @@ func TestOpenReadOnly_SkipsEventsWarmup(t *testing.T) { dir := t.TempDir() // Writer: ingest two ledgers, capture the exact wire bytes, then close. - db, err := Open(dir, chunkID, silentLogger()) + db, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) want := map[uint32][]byte{} for _, seq := range []uint32{first, first + 1} { @@ -508,7 +484,7 @@ func TestOpenReadOnly_SkipsEventsWarmup(t *testing.T) { require.NoError(t, raw.Close()) // Read-WRITE open warms → the poisoned index row fails the scan. - _, werr := OpenExisting(dir, chunkID, silentLogger()) + _, werr := OpenExisting(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.ErrorContains(t, werr, "events_index key length", "a read-write open must run the events warmup scan and reject the poison") @@ -523,18 +499,117 @@ func TestOpenReadOnly_SkipsEventsWarmup(t *testing.T) { require.True(t, ok) assert.Equal(t, first+1, seq) - // Freeze surface: Source() yields the committed ledgers byte-for-byte. + // Freeze surface: the ledgers facade serves the committed ledgers + // byte-for-byte through the warmup-skipped open (the freeze itself scans + // the CF via the shared store — FreezeLedgersCold — but the byte-identity + // claim is the same either way). got := map[uint32][]byte{} - for b, err := range ro.Source().RawLedgers(context.Background(), ledgerbackend.UnboundedRange(first)) { + for e, err := range ro.Ledgers().IterateLedgers(first, first+1) { require.NoError(t, err) - s, serr := xdr.LedgerCloseMetaView(b).LedgerSequence() - require.NoError(t, serr) - got[s] = append([]byte(nil), b...) // b is borrowed; clone before the next step + got[e.Seq] = append([]byte(nil), e.Bytes...) // borrowed; clone before the next step } assert.Equal(t, want, got, "freeze reads are byte-identical through the warmup-skipped open") - // Structural safety: the ledgers-only view has no events facade to hand out. + // Structural safety: the ledgers-only view has no events facade to hand + // out, and its txhash facade is query-disabled (no warmup ran). assert.Panics(t, func() { ro.Events() }, "Events() on a ledgers-only view must fail loudly") + assert.Panics(t, func() { _, _ = ro.Txhash().Get([32]byte{1}) }, + "Txhash().Get on a ledgers-only view must fail loudly") +} + +// TestOpenReadOnly_TxhashFreezeWorksWithoutQueries pins the read-only +// contract's two halves together: Txhash().Get is structurally disabled +// (panics — no warmup ran), yet FreezeTxhashCold works, because the freeze +// reads the manifest, run files, and CF rows through the shared store and +// never touches the query facade. +func TestOpenReadOnly_TxhashFreezeWorksWithoutQueries(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + dir := t.TempDir() + + db, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) + require.NoError(t, err) + hashes := make([][32]byte, 0, 2) + for _, seq := range []uint32{first, first + 1} { + raw, hash, _ := lcmWithEvent(t, seq) + hashes = append(hashes, hash) + _, ierr := ingestRaw(t, db, seq, raw) + require.NoError(t, ierr) + } + require.NoError(t, db.Close()) + + ro, err := OpenReadOnly(dir, chunkID, silentLogger()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, ro.Close()) }) + + assert.Panics(t, func() { _, _ = ro.Txhash().Get(hashes[0]) }, + "txhash queries must be disabled on a read-only open") + + secret := testSecrets().Txhash + binPath := filepath.Join(t.TempDir(), "chunk.bin") + n, err := ro.FreezeTxhashCold(context.Background(), binPath, secret) + require.NoError(t, err, "the freeze must work on a read-only open — it never calls Get") + require.Equal(t, 2, n) + + // The frozen .bin matches the walk shape over the two committed hashes + // (blinded keys — the ingest rule). + entries := make([]txhash.ColdEntry, 0, 2) + for i, h := range hashes { + entries = append(entries, txhash.ColdEntry{ + Key: stores.BlindKey(secret, h[:txhash.ColdKeySize]), + Seq: first + uint32(i), + }) + } + slices.SortStableFunc(entries, func(a, b txhash.ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) + walkPath := filepath.Join(t.TempDir(), "walk.bin") + require.NoError(t, txhash.WriteColdBin(walkPath, secret, entries)) + want, err := os.ReadFile(walkPath) + require.NoError(t, err) + got, err := os.ReadFile(binPath) + require.NoError(t, err) + require.Equal(t, want, got, "read-only freeze bytes diverge from the walk shape") +} + +// TestIngestLedger_TxhashVisibleAtReturnAndSkipRejected is the hook-order + +// dense-chain gate at the hotchunk level: (a) a committed ledger's hashes +// resolve through Txhash().Get the moment IngestLedger returns — the +// post-commit apply hook ran; (b) a skipped sequence fails loudly BEFORE +// commit — the events facade's in-batch out-of-order check aborts the shared +// batch, which is what keeps a gap out of the txhash CF's dense chain (the +// txhash apply's own dense tripwire, pinned at the store level, backstops at +// PhaseApply) — leaving no trace in any CF; (c) the correct next ledger then +// commits cleanly. +func TestIngestLedger_TxhashVisibleAtReturnAndSkipRejected(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + db := openTestDB(t) + + rawA, hashA, _ := lcmWithEvent(t, first) + _, err := ingestRaw(t, db, first, rawA) + require.NoError(t, err) + seq, err := db.Txhash().Get(hashA) + require.NoError(t, err, "hash must be hot-queryable the moment IngestLedger returns") + assert.Equal(t, first, seq) + + // Skip first+1: loud reject, nothing committed anywhere. + rawSkip, hashSkip, _ := lcmWithEvent(t, first+2) + rep, err := ingestRaw(t, db, first+2, rawSkip) + require.ErrorIs(t, err, event.ErrLedgerOutOfOrder) + assert.Equal(t, PhaseEvents, rep.Failed, "the in-batch sequence guard rejects pre-commit") + _, gerr := db.Txhash().Get(hashSkip) + require.ErrorIs(t, gerr, stores.ErrNotFound, "a rejected ledger's hash must not become visible") + maxSeq, ok, err := db.MaxCommittedSeq() + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, first, maxSeq, "the skipped ledger must not commit") + + // The dense chain is intact: the correct next ledger commits and serves. + rawB, hashB, _ := lcmWithEvent(t, first+1) + _, err = ingestRaw(t, db, first+1, rawB) + require.NoError(t, err) + seq, err = db.Txhash().Get(hashB) + require.NoError(t, err) + assert.Equal(t, first+1, seq) } // TestIngestLedger_ClosedDBFails confirms a closed shared DB rejects ingest. The @@ -543,7 +618,7 @@ func TestOpenReadOnly_SkipsEventsWarmup(t *testing.T) { // rocksdb.ErrStoreClosed. func TestIngestLedger_ClosedDBFails(t *testing.T) { chunkID := chunk.ID(0) - db, err := Open(t.TempDir(), chunkID, silentLogger()) + db, err := Open(t.TempDir(), chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) require.NoError(t, db.Close()) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset.go b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset.go new file mode 100644 index 000000000..b16f78a9a --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset.go @@ -0,0 +1,115 @@ +// Package runset is the record-blind half of the hot engines' seal-publish +// protocol — the invariants stores/event and stores/txhash share word for +// word and had to keep fixing in lockstep while each carried its own copy: +// +// - a run file and its dirent are durable BEFORE the manifest names it +// (each engine's fsyncDir barrier, upstream of Publish); +// - Manifest.PutRuns is THE durability point: one synced write that +// atomically replaces the live-run list; +// - a run a failed PutRuns could not list is unpublished garbage — Publish +// disposes of it before returning, so a failed publish hands back an +// error carrying no resources; +// - warmup trusts only the manifest: files it does not list are garbage +// (SweepOrphans), and the run-name sequence resumes past every listed +// run (NextSealSeq) so a future seal can never overwrite a live one. +// +// It also owns the routing-SECRET protocol both engines run over their chunk +// DB (secret.go): a cross-engine invariant, not a per-engine format, so it is +// one rule here instead of two that have to keep agreeing. +// +// Deliberately NOT here: everything record-aware or view-aware — run opening +// and drain-verification, txhash's dense-chain check, view swaps, window +// trims, retired-handle bookkeeping. Those stay per-engine (the two-engines +// posture, txhash/hotindex.go); folding them behind callbacks would relocate +// that code, not delete it. +package runset + +import ( + "fmt" + "os" + "path/filepath" +) + +// Manifest persists which runs are live — the crash-recovery authority. +// Implemented over each engine's chunk RocksDB (their rocksdbManifest: +// deliberate per-engine duplicates — two engines, two keys, nothing to +// version in lockstep); faked in tests. +type Manifest interface { + // PutRuns atomically replaces the live-run list and records the highest + // sealed ledger (warmup replays packed rows PAST it). It must be durable + // when it returns — PutRuns is the publish protocol's durability point. + PutRuns(names []string, lastSealed uint32) error + // GetRuns returns the live-run list and the sealed frontier (zero when + // nothing sealed). + GetRuns() ([]string, uint32, error) +} + +// Run is the least a publishable sealed run must expose: where it lives and +// how to release it. Both engines' *sealedRun satisfy it with two-line +// adapters — nothing here can read a run. +type Run interface { + // RunPath returns the run file's path; Publish stores its basename. + RunPath() string + // CloseRun releases the run's resources (its open lookup handle). + CloseRun() +} + +// Publish makes fresh the newest live run: one PutRuns naming every +// surviving run — live order then fresh, oldest first, the order warmup +// reopens them — at the new sealed frontier. A merge fold that replaces all +// live runs passes live=nil. On failure the fresh run was never named +// anywhere durable, so Publish closes and unlinks it before returning: the +// caller sees an error carrying no resources, structurally. The live runs +// are untouched either way — still in the caller's view, still listed by +// the previous manifest value. +func Publish[R Run](m Manifest, live []R, fresh R, frontier uint32) error { + names := make([]string, 0, len(live)+1) + for _, r := range live { + names = append(names, filepath.Base(r.RunPath())) + } + names = append(names, filepath.Base(fresh.RunPath())) + if err := m.PutRuns(names, frontier); err != nil { + fresh.CloseRun() + _ = os.Remove(fresh.RunPath()) + return err + } + return nil +} + +// SweepOrphans deletes files present in dir but absent from names — garbage +// by definition under the publish protocol (a crash between run write and +// manifest Put, or between a replacing Put and the obsolete files' removal). +// Directories are left alone. +func SweepOrphans(dir string, names []string) error { + referenced := make(map[string]bool, len(names)) + for _, name := range names { + referenced[name] = true + } + entries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("runset: sweep %s: %w", dir, err) + } + for _, e := range entries { + if !e.IsDir() && !referenced[e.Name()] { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } + return nil +} + +// NextSealSeq resumes the run-name sequence past every listed run so a +// future seal can never overwrite a live one: one past the highest numeric +// suffix among names matching any -%06d.run — numeric suffixes are +// monotone within each prefix. +func NextSealSeq(names []string, prefixes ...string) int { + maxSeq := 0 + for _, name := range names { + for _, prefix := range prefixes { + var n int + if _, err := fmt.Sscanf(name, prefix+"-%06d.run", &n); err == nil && n > maxSeq { + maxSeq = n + } + } + } + return maxSeq + 1 +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset_test.go new file mode 100644 index 000000000..621ce4e73 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset_test.go @@ -0,0 +1,158 @@ +package runset + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeManifest is an in-memory Manifest. putErr, when set, fails every +// PutRuns without recording anything. +type fakeManifest struct { + names []string + lastSealed uint32 + putErr error +} + +func (m *fakeManifest) PutRuns(names []string, lastSealed uint32) error { + if m.putErr != nil { + return m.putErr + } + m.names = append([]string(nil), names...) + m.lastSealed = lastSealed + return nil +} + +func (m *fakeManifest) GetRuns() ([]string, uint32, error) { + return append([]string(nil), m.names...), m.lastSealed, nil +} + +// fileRun is a minimal Run over a real file, recording disposal. +type fileRun struct { + path string + closed bool +} + +func (r *fileRun) RunPath() string { return r.path } +func (r *fileRun) CloseRun() { r.closed = true } + +func mkRun(t *testing.T, dir, name string) *fileRun { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte("run"), 0o644)) + return &fileRun{path: path} +} + +// TestPublish_NamesLiveThenFresh pins the manifest NAME list a publish +// stores: basenames only, live order then fresh, at the given frontier — +// the exact list warmup will reopen, in order. +func TestPublish_NamesLiveThenFresh(t *testing.T) { + dir := t.TempDir() + live := []*fileRun{mkRun(t, dir, "seal-000001.run"), mkRun(t, dir, "seal-000002.run")} + fresh := mkRun(t, dir, "merge-000003.run") + m := &fakeManifest{} + + require.NoError(t, Publish(m, live, fresh, 42)) + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + assert.Equal(t, []string{"seal-000001.run", "seal-000002.run", "merge-000003.run"}, names) + assert.Equal(t, uint32(42), lastSealed) + for _, r := range append(live, fresh) { + assert.False(t, r.closed, "a successful publish closes nothing") + _, serr := os.Stat(r.path) + assert.NoError(t, serr, "a successful publish unlinks nothing") + } +} + +// TestPublish_ReplaceAllListsOnlyFresh pins the merge fold's shape: with no +// surviving live runs the merged run is the manifest's only entry. +func TestPublish_ReplaceAllListsOnlyFresh(t *testing.T) { + dir := t.TempDir() + merged := mkRun(t, dir, "merge-000007.run") + m := &fakeManifest{} + + require.NoError(t, Publish(m, nil, merged, 7)) + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + assert.Equal(t, []string{"merge-000007.run"}, names) + assert.Equal(t, uint32(7), lastSealed) +} + +// TestPublish_FailureDisposesFresh pins the protocol's failure contract +// (formerly the twin per-engine ManifestFailureDisposesRun tests): a run the +// manifest never listed is unpublished — closed and unlinked — while the +// live runs stay open and on disk, still listed by the previous manifest +// value. +func TestPublish_FailureDisposesFresh(t *testing.T) { + dir := t.TempDir() + live := []*fileRun{mkRun(t, dir, "seal-000001.run")} + fresh := mkRun(t, dir, "seal-000002.run") + m := &fakeManifest{putErr: errors.New("manifest unavailable")} + + err := Publish(m, live, fresh, 9) + require.ErrorIs(t, err, m.putErr, "PutRuns's error must surface unwrapped") + assert.True(t, fresh.closed, "un-listed run's handle must be closed") + _, serr := os.Stat(fresh.path) + require.ErrorIs(t, serr, os.ErrNotExist, "un-listed run's file must be removed") + assert.False(t, live[0].closed, "live runs must stay open for readers") + _, serr = os.Stat(live[0].path) + require.NoError(t, serr, "live runs' files must remain") + names, _, gerr := m.GetRuns() + require.NoError(t, gerr) + assert.Empty(t, names, "a failed publish must not have recorded anything") +} + +// TestSweepOrphans pins the warmup sweep: unreferenced files go, +// manifest-listed files and directories stay. +func TestSweepOrphans(t *testing.T) { + dir := t.TempDir() + listed := mkRun(t, dir, "seal-000001.run") + orphan := mkRun(t, dir, "seal-000002.run") + sub := filepath.Join(dir, "subdir") + require.NoError(t, os.Mkdir(sub, 0o755)) + + require.NoError(t, SweepOrphans(dir, []string{"seal-000001.run"})) + _, err := os.Stat(listed.path) + assert.NoError(t, err, "listed run must survive the sweep") + _, err = os.Stat(orphan.path) + assert.ErrorIs(t, err, os.ErrNotExist, "orphan must be swept") + _, err = os.Stat(sub) + assert.NoError(t, err, "directories are left alone") + + require.Error(t, SweepOrphans(filepath.Join(dir, "missing"), nil), + "an unreadable run directory must fail loudly") +} + +// TestNextSealSeq pins the sequence resume: one past the highest suffix +// across exactly the given prefixes, ignoring foreign and malformed names. +func TestNextSealSeq(t *testing.T) { + names := []string{"seal-000005.run", "merge-000009.run", "junk", "seal-x.run"} + assert.Equal(t, 1, NextSealSeq(nil, "seal"), "empty manifest starts the sequence") + assert.Equal(t, 6, NextSealSeq(names, "seal"), "foreign prefixes must not bump the sequence") + assert.Equal(t, 10, NextSealSeq(names, "seal", "merge"), "the max ranges over all given prefixes") + assert.Equal(t, 1, NextSealSeq([]string{"junk", "seal-x.run"}, "seal", "merge")) +} + +// TestNextSealSeq_ParsesTheEnginesOwnNames closes the format→parse loop the +// grammar rests on: the producers are three fmt.Sprintf sites in the engines' +// startSeal (stores/event, stores/txhash), the parser is here, and nothing +// else pins the agreement. Names are formatted EXACTLY as those sites format +// them — same verb, same zero-pad width, same extension — so a change to +// either half that silently stops the sequence from resuming (a fresh seal +// then overwrites a live run) fails here. +func TestNextSealSeq_ParsesTheEnginesOwnNames(t *testing.T) { + name := func(prefix string, seq int) string { return fmt.Sprintf("%s-%06d.run", prefix, seq) } + for _, seq := range []int{1, 7, 42, 999999} { + assert.Equal(t, seq+1, NextSealSeq([]string{name("seal", seq)}, "seal"), + "a name the txhash engine wrote must resume at one past its sequence") + } + // The events engine writes both prefixes off one counter (startSeal's + // seal, then the merge fold's), and warmup resumes past whichever is + // newest. + assert.Equal(t, 13, NextSealSeq([]string{name("seal", 11), name("merge", 12)}, "seal", "merge")) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runsettest/runsettest.go b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runsettest/runsettest.go new file mode 100644 index 000000000..57cf7974c --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runsettest/runsettest.go @@ -0,0 +1,99 @@ +// Package runsettest is shared test support for the seal-publish protocol +// runset owns: a publish log that totally orders dirent barriers against +// manifest writes, an in-memory recording Manifest, and the one assertion +// both hot engines pin their barrier-before-PutRuns order with. The barrier +// is the half of the protocol runset deliberately leaves per-engine (each +// engine's fsyncDir seam, upstream of Publish), so its ordering gate lives +// here as one implementation instead of a hand-rolled copy per engine — the +// drift shape the runset extraction exists to kill. Imported only from _test +// files; nothing in it ships in the daemon binary. +package runsettest + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// PublishLog totally orders barrier and manifest calls for the ordering +// pins. Each engine's pendingSeal hand-off already sequences the sealer +// goroutine's barrier before the writer goroutine's PutRuns; the mutex keeps +// the log valid under any schedule, so a regression shows up as misordered +// events, never as a race. +type PublishLog struct { + mu sync.Mutex + events []string +} + +// FsyncDir records a dirent barrier over dir and succeeds. Assign it to the +// engine's fsyncDir seam in place of durable.FsyncDir. +func (l *PublishLog) FsyncDir(dir string) error { + l.add("fsync " + dir) + return nil +} + +func (l *PublishLog) add(ev string) { + l.mu.Lock() + defer l.mu.Unlock() + l.events = append(l.events, ev) +} + +func (l *PublishLog) snapshot() []string { + l.mu.Lock() + defer l.mu.Unlock() + return append([]string(nil), l.events...) +} + +// RecordingManifest is an in-memory runset.Manifest that logs every PutRuns +// into the same publish log the barrier recorder writes to — the two sides +// of the ordering pin share one clock. Log must be set. +type RecordingManifest struct { + Log *PublishLog + + mu sync.Mutex + names []string + lastSealed uint32 +} + +// PutRuns logs "put", then stores the list like each engine's in-memory fake. +func (m *RecordingManifest) PutRuns(names []string, lastSealed uint32) error { + m.Log.add("put") + m.mu.Lock() + defer m.mu.Unlock() + m.names = append([]string(nil), names...) + m.lastSealed = lastSealed + return nil +} + +// GetRuns returns the stored run list and sealed frontier. +func (m *RecordingManifest) GetRuns() ([]string, uint32, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.names...), m.lastSealed, nil +} + +// AssertBarrierPrecedesEveryPut pins the seal-publish order the manifest's +// authority rests on: every PutRuns — a synced write, durable the moment it +// returns — must be preceded by a dirent barrier over dir, the run +// directory, since the previous publish, and exactly wantPuts publishes must +// have landed. Creating a run under its final name makes it visible, not +// durable, so without the barrier a crash right after the manifest write can +// leave it durably naming a run whose dirent was never journaled — an +// unrecoverable warmup failure on a hot chunk. +func AssertBarrierPrecedesEveryPut(t *testing.T, log *PublishLog, dir string, wantPuts int) { + t.Helper() + puts, barriersSincePut := 0, 0 + for _, ev := range log.snapshot() { + if ev == "put" { + puts++ + require.Positive(t, barriersSincePut, + "PutRuns #%d ran without a dirent barrier since the previous publish", puts) + barriersSincePut = 0 + continue + } + require.Equal(t, "fsync "+dir, ev, "the barrier must cover the run directory") + barriersSincePut++ + } + require.Equal(t, wantPuts, puts, "every settled seal cycle must publish exactly once") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret.go b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret.go new file mode 100644 index 000000000..cc8abdefb --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret.go @@ -0,0 +1,113 @@ +package runset + +// secret.go — the routing-secret half of the protocol the two hot engines +// share. Like the publish protocol above it, this was one rule written +// twice: byte-identical adopt/require pairs in stores/txhash and +// stores/event that differed only in a store key and an error prefix. +// +// Unlike the manifest twins (deliberate — two engines, two keys, nothing to +// version in lockstep), this pair encodes a CROSS-engine invariant: +// hotchunk.Secrets hands both engines their secret from one derivation, and +// a chunk is only correctly keyed if BOTH adopt and require it by the same +// rule. Two copies of that rule is a thing to keep in agreement, so it lives +// here once. + +import ( + "bytes" + "errors" + "fmt" +) + +// KV is the least of a chunk store this protocol needs: one namespaced get +// and one DURABLE put. Deliberately an interface over two methods rather +// than a *rocksdb.Store, so runset stays import-free of the storage layer +// (and a fake KV can drive the table test). Both engines satisfy it with +// their shared *rocksdb.Store; the durability of Put is the store's job +// (rocksdb.Store.Put rides a synced write, which is what makes adoption +// durable before any run keyed with it can exist). +type KV interface { + Get(cf string, key []byte) ([]byte, bool, error) + Put(cf string, key, value []byte) error +} + +// secretCF is the column family the adopted secret lives in: the default +// one, beside each engine's run manifest. The KEY is the engine's (one per +// store), the CF is shared. +const secretCF = "" + +// AdoptSecret pins secret as THE routing secret of this chunk DB's engine +// state: the first open persists it, every later open must present the same +// one. Sealed runs are blinded with it and the chunk's cold artifact +// inherits their bytes (txhash copies run records into the .bin verbatim; +// events merges run keys into the index), so a chunk whose runs were written +// under one secret and frozen under another would produce an artifact no +// query could route — a silent, undetectable-at-read wrong answer. The +// mismatch is loud instead. +// +// engine prefixes the errors ("txhash", "events"); key is the engine's +// secret key. There is no migration and no re-keying: a re-minted catalog +// secret means the chunk must be re-ingested (pre-release posture — hot +// chunks are bound to the catalog secret by design, while cold artifacts +// self-describe and survive it). +func AdoptSecret(kv KV, key []byte, engine string, secret []byte) error { + if allZero(secret) { + return fmt.Errorf("%s: all-zero routing secret (blinding disabled — derive a per-index secret)", engine) + } + stored, found, err := kv.Get(secretCF, key) + if err != nil { + return fmt.Errorf("%s: read routing secret: %w", engine, err) + } + if !found { + if perr := kv.Put(secretCF, key, secret); perr != nil { + return fmt.Errorf("%s: persist routing secret: %w", engine, perr) + } + return nil + } + if !bytes.Equal(stored, secret) { + return fmt.Errorf("%s: hot DB is keyed under a different routing secret "+ + "(catalog remint? no migration — re-ingest the chunk)", engine) + } + return nil +} + +// RequireSecret is the read-ONLY half of the same rule, for the freeze: it +// may not adopt (the store is read-only, and nothing about a freeze should +// mint durable state), only insist that the secret it was handed is the one +// the DB's sealed runs are keyed with. Errors carry no engine prefix — every +// caller already wraps them with its own engine and chunk. +// +// A DB with no persisted secret is ACCEPTED, and the reason that is safe is +// the run-format magic, not the absence itself. A pre-release build of +// either engine sealed RAW-keyed runs into a DB it never stamped a secret +// onto; nothing about those runs' bytes distinguishes them from this build's +// blinded ones, so this check cannot tell them apart and does not try. What +// stops them is that such a run declares an older magic — txhash TXHRUN01, +// events EVR2 — and dies at open, in the freeze's own run opener, before a +// byte of artifact is written. So the only DBs this acceptance lets through +// are one whose runs this build wrote (magic current, keys blinded, secret +// stamped, and then the equality check below applies) and one with no runs +// at all, which is all raw tail the freeze blinds itself with exactly this +// secret. +func RequireSecret(kv KV, key []byte, secret []byte) error { + stored, found, err := kv.Get(secretCF, key) + if err != nil { + return fmt.Errorf("read routing secret: %w", err) + } + if found && !bytes.Equal(stored, secret) { + return errors.New("hot DB is keyed under a different routing secret than this freeze " + + "(catalog remint? no migration — re-ingest the chunk)") + } + return nil +} + +// allZero reports whether secret carries no key material at all — the +// "blinding effectively disabled" shape, refused outright so a zero-valued +// Secrets struct can never key a chunk. An empty slice counts as zero. +func allZero(secret []byte) bool { + for _, b := range secret { + if b != 0 { + return false + } + } + return true +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret_test.go new file mode 100644 index 000000000..c670b8d23 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret_test.go @@ -0,0 +1,156 @@ +package runset + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" +) + +// fakeKV is an in-memory KV over one CF's worth of keys, with an injectable +// read failure. Keys are compared as strings (the protocol's keys are fixed +// literals). +type fakeKV struct { + data map[string][]byte + getErr error + putErr error + puts int +} + +func newFakeKV() *fakeKV { return &fakeKV{data: map[string][]byte{}} } + +func (f *fakeKV) Get(_ string, key []byte) ([]byte, bool, error) { + if f.getErr != nil { + return nil, false, f.getErr + } + v, ok := f.data[string(key)] + return v, ok, nil +} + +func (f *fakeKV) Put(_ string, key, value []byte) error { + if f.putErr != nil { + return f.putErr + } + f.puts++ + f.data[string(key)] = append([]byte(nil), value...) + return nil +} + +var ( + testKey = []byte("engine:secret") + secretA = []byte{1, 2, 3, 4} + secretB = []byte{1, 2, 3, 5} + zeroSecret = []byte{0, 0, 0, 0} +) + +// TestAdoptSecret is the whole adoption lifecycle in one table: the first +// open decides the key, a later open must present the same one, a +// disagreement is loud and durable-state-free, and an all-zero secret is +// refused whatever the DB holds. Both hot engines run exactly this rule — +// each keeps only a threading case of its own (TestHotStore_SecretAdoption, +// TestWarmup_SecretAdoption), because what could drift between them is which +// secret they hand in, not what happens to it. +func TestAdoptSecret(t *testing.T) { + boom := errors.New("boom") + for _, tc := range []struct { + name string + stored []byte // pre-existing persisted secret ("" = none) + secret []byte // what this open presents + getErr error + putErr error + wantErr string + wantPuts int + }{ + {name: "first open persists", secret: secretA, wantPuts: 1}, + {name: "same secret re-adopts without writing", stored: secretA, secret: secretA}, + { + name: "different secret is loud", stored: secretA, secret: secretB, + wantErr: "engine: hot DB is keyed under a different routing secret", + }, + { + name: "zero secret refused on a fresh DB", secret: zeroSecret, + wantErr: "engine: all-zero routing secret", + }, + { + name: "zero secret refused on an adopted DB", stored: secretA, secret: zeroSecret, + wantErr: "engine: all-zero routing secret", + }, + { + name: "read failure surfaces", secret: secretA, getErr: boom, + wantErr: "engine: read routing secret: boom", + }, + { + name: "persist failure surfaces", secret: secretA, putErr: boom, + wantErr: "engine: persist routing secret: boom", + }, + } { + t.Run(tc.name, func(t *testing.T) { + kv := newFakeKV() + if tc.stored != nil { + require.NoError(t, kv.Put("", testKey, tc.stored)) + kv.puts = 0 + } + kv.getErr, kv.putErr = tc.getErr, tc.putErr + + err := AdoptSecret(kv, testKey, "engine", tc.secret) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + if tc.getErr == nil && tc.putErr == nil { + require.Equal(t, tc.wantPuts, kv.puts, "unexpected durable writes") + } + // A rejected adoption never re-keys what is already there. + if tc.stored != nil && tc.getErr == nil { + got, found, gerr := kv.Get("", testKey) + require.NoError(t, gerr) + require.True(t, found) + require.Equal(t, tc.stored, got) + } + }) + } +} + +// TestRequireSecret is the freeze's read-only half: it may not adopt, it +// accepts a DB that persisted nothing (see RequireSecret's doc — the run +// magic, not this check, is what rejects a pre-release chunk), and it +// rejects a disagreement before any artifact byte is written. +func TestRequireSecret(t *testing.T) { + boom := errors.New("boom") + for _, tc := range []struct { + name string + stored []byte + secret []byte + getErr error + wantErr string + }{ + {name: "no persisted secret is accepted", secret: secretA}, + {name: "matching secret is accepted", stored: secretA, secret: secretA}, + { + name: "mismatch is loud", stored: secretA, secret: secretB, + wantErr: "hot DB is keyed under a different routing secret than this freeze", + }, + { + name: "read failure surfaces", secret: secretA, getErr: boom, + wantErr: "read routing secret: boom", + }, + } { + t.Run(tc.name, func(t *testing.T) { + kv := newFakeKV() + if tc.stored != nil { + require.NoError(t, kv.Put("", testKey, tc.stored)) + kv.puts = 0 + } + kv.getErr = tc.getErr + + err := RequireSecret(kv, testKey, tc.secret) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + require.Zero(t, kv.puts, "the freeze half must mint no durable state") + }) + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze.go new file mode 100644 index 000000000..9a1ee258d --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze.go @@ -0,0 +1,96 @@ +package ledger + +// cold_freeze.go — the ledgers half of the zero-decompression freeze: build a +// completed hot chunk's cold .pack DIRECTLY from its ledgers CF. The CF's +// values ARE the pack's records — one internal/rpcv2/zstd frame per ledger, the +// same level and checksum the raw-mode cold writer would produce — so the +// freeze copies frames verbatim (PreCompressed mode) instead of +// decompressing ~every ledger only to recompress it identically. What used +// to be the freeze's largest CPU stream (per the 2026-07-24 baseline +// profile, ~200s of ZSTD_compress2 per chunk plus the decompress feeding +// it) becomes a checked copy. +// +// Not quite zero decompression, since mainline hashes every cold pack's +// content: the hash is over RAW ledger bytes, so the packfile writer +// decompresses each frame ONCE on its hash goroutines (cold_writer.go's +// ContentHashExtract) to feed the hasher. The freeze pays that decode on as +// many workers as the caller's Concurrency buys; the recompression, and the +// decode that used to feed it on the live path, are what stay deleted. +// +// Correctness relies on three checks, not on trust: RocksDB block CRCs +// verify the read side of the copy; AppendCompressedLedger validates each +// frame header and enforces seq contiguity against the KEY-derived sequence +// (the pack resolves seqs positionally, so a CF hole must abort the freeze, +// never shift the tail); and the final count check refuses to Commit a scan +// that stopped short of the chunk's last ledger. + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" +) + +// freezeCtxPollEvery is how many ledgers the freeze scan copies between +// context checks — frequent enough that cancellation lands in well under a +// second, rare enough to keep ctx.Err off the per-ledger fast path. +const freezeCtxPollEvery = 256 + +// FreezeColdFromStore builds the chunk's cold ledger .pack at packPath from +// the chunk's (read-only) hot store, copying the ledgers CF's zstd frames +// verbatim. opts tunes writeback exactly as the walk-driven build does; +// PreCompressed is forced on. Returns the number of ledgers written. +func FreezeColdFromStore( + ctx context.Context, + chunkID chunk.ID, + store *rocksdb.Store, + packPath string, + opts ColdWriterOptions, +) (int, error) { + n := 0 + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + if err := os.MkdirAll(filepath.Dir(packPath), 0o755); err != nil { + return 0, fmt.Errorf("cold freeze %s: mkdir: %w", chunkID, err) + } + opts.PreCompressed = true + w, werr := NewColdWriter(packPath, first, opts) + if werr != nil { + return 0, werr + } + // Close after a failed pass drops the partial pack; after Commit it is + // a no-op release. + defer func() { _ = w.Close() }() + + for entry, ierr := range store.IterateRange(LedgersCF, rocksdb.EncodeUint32(first), rocksdb.EncodeUint32(last)) { + if ierr != nil { + return n, fmt.Errorf("cold freeze %s: scan %s: %w", chunkID, LedgersCF, ierr) + } + if n%freezeCtxPollEvery == 0 { + if cerr := ctx.Err(); cerr != nil { + return n, cerr + } + } + if len(entry.Key) != 4 { + return n, fmt.Errorf("cold freeze %s: %s key length %d (want 4)", chunkID, LedgersCF, len(entry.Key)) + } + // The seq comes from the KEY, never a local counter: the writer's + // contiguity check must see a CF hole as a mismatch and abort. + if aerr := w.AppendCompressedLedger(rocksdb.DecodeUint32(entry.Key), entry.Value); aerr != nil { + return n, aerr + } + n++ + } + // Contiguity from firstSeq plus this count check pins the exact range + // [first, last]: a truncated CF (or one that never reached last) must + // not produce a committable, silently-short pack. + if want := int(last-first) + 1; n != want { + return n, fmt.Errorf("cold freeze %s: copied %d ledgers, want %d", chunkID, n, want) + } + if cerr := w.Commit(); cerr != nil { + return n, cerr + } + return n, nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze_test.go new file mode 100644 index 000000000..adfa0abbb --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze_test.go @@ -0,0 +1,202 @@ +package ledger + +import ( + "bytes" + "context" + "encoding/binary" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/packfile" +) + +// freezePayload fabricates a deterministic, compressible pseudo-ledger for +// seq — enough structure that zstd does real work, small enough that a full +// chunk's worth stays fast. +func freezePayload(seq uint32) []byte { + b := make([]byte, 0, 2048) + var s [4]byte + binary.BigEndian.PutUint32(s[:], seq) + for len(b) < 2048 { + b = append(b, s[:]...) + b = append(b, "ledger-close-meta-filler-"...) + } + return b +} + +// populateFreezeChunk writes the chunk's full ledger range into the hot +// store through the production compress-and-batch path, in batches. +func populateFreezeChunk(t *testing.T, h *HotStore, chunkID chunk.ID) { + t.Helper() + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + const batch = 1000 + entries := make([]Entry, 0, batch) + for seq := first; ; seq++ { + entries = append(entries, Entry{Seq: seq, Bytes: freezePayload(seq)}) + if len(entries) == batch || seq == last { + require.NoError(t, addLedgers(h, entries...)) + entries = entries[:0] + } + if seq == last { + return + } + } +} + +// TestFreezeColdFromStore_ByteIdenticalToWalk is the ledgers half of the +// cross-path identity gate: the freeze-written pack (verbatim hot-CF frame +// copy) must be byte-for-byte identical to the walk-written pack (raw bytes +// through the cold writer's own encoder). It holds because both sides +// compress with the same internal/rpcv2/zstd configuration and the packfile's +// passthrough mode records exactly what the encoder mode would have +// produced. Any drift between the hot compressor and the cold encoder — +// level, checksum, library — fails HERE, not in production. +func TestFreezeColdFromStore_ByteIdenticalToWalk(t *testing.T) { + chunkID := chunk.ID(0) + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + + // Walk-written pack: raw payloads through the encoder path. + walkPath := filepath.Join(t.TempDir(), "walk.pack") + w, err := NewColdWriter(walkPath, first, ColdWriterOptions{}) + require.NoError(t, err) + for seq := first; seq <= last; seq++ { + require.NoError(t, w.AppendLedger(seq, freezePayload(seq))) + } + require.NoError(t, w.Commit()) + require.NoError(t, w.Close()) + + // Freeze-written pack: the same payloads ingested hot, then copied. + h, store := openTestHotStoreAt(t, t.TempDir()) + populateFreezeChunk(t, h, chunkID) + freezePath := filepath.Join(t.TempDir(), "freeze.pack") + n, err := FreezeColdFromStore(context.Background(), chunkID, store, freezePath, ColdWriterOptions{}) + require.NoError(t, err) + require.EqualValues(t, chunk.LedgersPerChunk, n) + + walkBytes, err := os.ReadFile(walkPath) + require.NoError(t, err) + freezeBytes, err := os.ReadFile(freezePath) + require.NoError(t, err) + require.Len(t, freezeBytes, len(walkBytes), "pack sizes diverge") + require.True(t, bytes.Equal(walkBytes, freezeBytes), "pack bytes diverge") + + // And the freeze-written pack round-trips through the ordinary reader. + cr, err := OpenColdReader(freezePath) + require.NoError(t, err) + defer func() { _ = cr.Close() }() + for _, seq := range []uint32{first, first + 4999, last} { + got, gerr := readLedgerRaw(cr, seq) + require.NoError(t, gerr) + require.Equal(t, freezePayload(seq), got, "seq %d", seq) + } +} + +// TestFreezeColdFromStore_GapAborts: a hole in the ledgers CF must abort the +// freeze via the contiguity check — pack positions imply seqs, so a silent +// skip would shift every later ledger. +func TestFreezeColdFromStore_GapAborts(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + h, store := openTestHotStoreAt(t, t.TempDir()) + for _, seq := range []uint32{first, first + 1, first + 3} { // hole at first+2 + require.NoError(t, addLedgers(h, Entry{Seq: seq, Bytes: freezePayload(seq)})) + } + _, err := FreezeColdFromStore(context.Background(), chunkID, store, + filepath.Join(t.TempDir(), "gap.pack"), ColdWriterOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "expected seq") +} + +// TestFreezeColdFromStore_ShortAborts: a CF that never reaches the chunk's +// last ledger passes every contiguity check and must still refuse to commit. +func TestFreezeColdFromStore_ShortAborts(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + h, store := openTestHotStoreAt(t, t.TempDir()) + for seq := first; seq < first+10; seq++ { + require.NoError(t, addLedgers(h, Entry{Seq: seq, Bytes: freezePayload(seq)})) + } + _, err := FreezeColdFromStore(context.Background(), chunkID, store, + filepath.Join(t.TempDir(), "short.pack"), ColdWriterOptions{}) + require.Error(t, err) + require.Contains(t, err.Error(), "copied 10 ledgers") +} + +// TestColdWriter_ModeMisuse: each append method is an immediate error on the +// other mode's writer. +func TestColdWriter_ModeMisuse(t *testing.T) { + dir := t.TempDir() + + raw, err := NewColdWriter(filepath.Join(dir, "raw.pack"), 2, ColdWriterOptions{}) + require.NoError(t, err) + defer func() { _ = raw.Close() }() + require.ErrorContains(t, raw.AppendCompressedLedger(2, []byte{0x28, 0xB5, 0x2F, 0xFD, 0}), + "raw-mode writer") + + pre, err := NewColdWriter(filepath.Join(dir, "pre.pack"), 2, ColdWriterOptions{PreCompressed: true}) + require.NoError(t, err) + defer func() { _ = pre.Close() }() + require.ErrorContains(t, pre.AppendLedger(2, []byte("raw")), "PreCompressed writer") + + // And a non-frame payload is rejected before it can reach the pack. + require.ErrorContains(t, pre.AppendCompressedLedger(2, []byte("definitely not zstd")), "magic") +} + +// openFreezeTestPack opens a finished ledger pack through the raw packfile +// reader, decoding records with the shared cold-pack decompressor. +func openFreezeTestPack(t *testing.T, path string) *packfile.Reader { + t.Helper() + r := packfile.Open(path, packfile.ReaderOptions{RecordDecoder: coldPackDecoder}) + t.Cleanup(func() { _ = r.Close() }) + return r +} + +// TestFreezeColdFromStore_ContentHashMatchesWalk pins the hash half of the +// cross-path identity gate independently of the byte half: the content hash +// is over RAW LCM bytes, so a pack frozen from a hot DB (verbatim frames) +// and a pack walked from the same raw ledgers (encoder path) must carry the +// SAME trailer hash, and Verify — which recomputes it from the stored items +// — must pass on both. +// +// This is the gate that outlives byte identity. The hash is what an audit +// content-compares two artifacts with, and what a freeze that stops +// producing byte-identical frames (a zstd library bump, say) would still +// have to agree on. +func TestFreezeColdFromStore_ContentHashMatchesWalk(t *testing.T) { + chunkID := chunk.ID(0) + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + + walkPath := filepath.Join(t.TempDir(), "walk.pack") + w, err := NewColdWriter(walkPath, first, ColdWriterOptions{}) + require.NoError(t, err) + for seq := first; seq <= last; seq++ { + require.NoError(t, w.AppendLedger(seq, freezePayload(seq))) + } + require.NoError(t, w.Commit()) + require.NoError(t, w.Close()) + + h, store := openTestHotStoreAt(t, t.TempDir()) + populateFreezeChunk(t, h, chunkID) + freezePath := filepath.Join(t.TempDir(), "freeze.pack") + n, err := FreezeColdFromStore(context.Background(), chunkID, store, freezePath, ColdWriterOptions{}) + require.NoError(t, err) + require.EqualValues(t, chunk.LedgersPerChunk, n) + + walkHash, walkHashed, err := openFreezeTestPack(t, walkPath).ContentHash() + require.NoError(t, err) + require.True(t, walkHashed, "the walk-written pack must carry a content hash") + freezeHash, freezeHashed, err := openFreezeTestPack(t, freezePath).ContentHash() + require.NoError(t, err) + require.True(t, freezeHashed, "the freeze-written pack must carry a content hash") + require.Equal(t, walkHash, freezeHash, + "freeze and walk must hash the same raw ledgers to the same digest") + + // Verify recomputes the hash from the stored items, so it fails if the + // writer hashed anything other than what it wrote. + require.NoError(t, openFreezeTestPack(t, walkPath).Verify(context.Background())) + require.NoError(t, openFreezeTestPack(t, freezePath).Verify(context.Background())) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go index 99b0950ac..f772f53c1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go @@ -10,31 +10,76 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/zstd" ) -// newColdPackEncoder constructs a fresh zstd encoder for one -// packfile writer goroutine. packfile.RecordEncoder is not safe for -// concurrent use, so the writer invokes this per worker. -func newColdPackEncoder() packfile.RecordEncoder { return zstd.NewCompressor() } +// newColdPackEncoder constructs a fresh zstd encoder (with the +// FORMAT-AFFECTING workers setting applied) for one packfile writer +// goroutine. packfile.RecordEncoder is not safe for concurrent use, so the +// writer invokes this per worker. +func newColdPackEncoder(zstdWorkers int) func() packfile.RecordEncoder { + return func() packfile.RecordEncoder { + return zstd.NewCompressor(encoderOptions(zstdWorkers)...) + } +} + +// extractRawLedger recovers a stored frame's raw LCM bytes for the content +// hasher. It is the PreCompressed writer's ContentHashExtract, so it may run +// on several packfile worker goroutines at once; the shared cold-pack +// Decompressor is a context pool and safe for exactly that. Each call +// allocates its own destination: the extracted bytes are held until the +// hasher has consumed them, and a shared buffer would be racing. +func extractRawLedger(frame []byte) ([]byte, error) { + raw, err := coldPackDecoder.Decode(nil, frame) + if err != nil { + return nil, fmt.Errorf("cold: decompress frame for content hash: %w", err) + } + return raw, nil +} // ColdWriterOptions configures the underlying packfile writer. -// The zero value is a sensible default (serial encoding, no +// The zero value is a sensible default (serial single-threaded encoding, no // background writeback). type ColdWriterOptions struct { - // Concurrency sets parallel record-encoder workers. 0 means 1 + // Concurrency sets the packfile's parallel record workers. 0 means 1 // (serial). Bump for large backfills where zstd encoding is - // CPU-bound; pick a value <= NumCPU. + // CPU-bound; pick a value <= NumCPU. It applies in PreCompressed + // mode too, where there is no encoder to run: the content hash is + // over RAW ledger bytes, so each worker's hash goroutine decompresses + // the frame it hashes, and these workers are what parallelizes that + // decode. Concurrency int + // ZstdEncodeWorkers is the per-frame libzstd multithreading setting + // (0 = single-threaded; see zstd.WithWorkers). FORMAT-AFFECTING: it + // selects the frame byte stream, and a chunk pack must be + // byte-identical whichever materializer built it — the freeze copies + // hot frames verbatim, so a raw-mode (walk/backfill) writer MUST pass + // the same value the hot tier encodes with (hotchunk.Tuning's field + // doc owns the contract). Ignored in PreCompressed mode. + ZstdEncodeWorkers int + // BytesPerSync triggers background dirty-page writeback every // N bytes (Linux: sync_file_range, non-blocking). Spreads I/O // across the write phase so the final fsync in Commit has less // to flush. 0 disables. BytesPerSync int + + // PreCompressed selects the verbatim-frame write mode: the caller + // appends ledgers ALREADY compressed as internal/rpcv2/zstd frames (the hot + // ledgers CF's values) via AppendCompressedLedger, and the packfile + // records them untouched (nil record encoder). The on-disk pack is + // structurally identical to raw mode's — one zstd frame per record, + // same format constant — and is read by the same ColdReader; only who + // ran the compressor differs. AppendLedger errors in this mode, and + // AppendCompressedLedger errors outside it, so a mode mismatch is an + // immediate API-time failure rather than a corrupt pack. + PreCompressed bool } // ColdWriter is two-phase: Commit finalizes; Close cleans up a -// partial pack when Commit hasn't run. A ColdWriter must be used by -// a single goroutine — AppendLedger, Commit, and Close are not safe -// for concurrent invocation. Idiomatic use: +// partial pack when Commit hasn't run — the lifecycle every domain +// writer here shares (runspill.RunWriter carries the pattern doc). +// A ColdWriter must be used by a single goroutine — AppendLedger, +// Commit, and Close are not safe for concurrent invocation. +// Idiomatic use: // // w, _ := NewColdWriter(path, firstSeq, ledger.ColdWriterOptions{}) // defer w.Close() @@ -45,10 +90,11 @@ type ColdWriterOptions struct { // } // return w.Commit() type ColdWriter struct { - pw *packfile.Writer - firstSeq uint32 - nextSeq uint32 - path string + pw *packfile.Writer + firstSeq uint32 + nextSeq uint32 + path string + preCompressed bool } // NewColdWriter truncates any pre-existing file at path so a crashed @@ -61,43 +107,74 @@ func NewColdWriter(path string, firstSeq uint32, opts ColdWriterOptions) (*ColdW if path == "" { return nil, stores.ErrInvalidConfig } - if opts.Concurrency < 0 || opts.BytesPerSync < 0 { - return nil, fmt.Errorf("%w: Concurrency and BytesPerSync must be non-negative", stores.ErrInvalidConfig) + if opts.Concurrency < 0 || opts.BytesPerSync < 0 || opts.ZstdEncodeWorkers < 0 { + return nil, fmt.Errorf( + "%w: Concurrency, BytesPerSync, and ZstdEncodeWorkers must be non-negative", stores.ErrInvalidConfig) + } + // PreCompressed appends final on-disk bytes, so the record encoder is + // nil (packfile passthrough); raw mode compresses per record with the + // format-affecting workers setting. + newEncoder := newColdPackEncoder(opts.ZstdEncodeWorkers) + var extract func([]byte) ([]byte, error) + if opts.PreCompressed { + newEncoder = nil + // The content hash is over RAW LCM bytes, which is what makes it + // canonical — independent of the zstd encoder version, so a frozen + // pack and a walked pack of the same ledgers carry the same hash. + // Raw mode gets that for free (AppendItem receives raw bytes and the + // encoder runs after the hasher). PreCompressed hands the writer the + // FRAME, so the hash input has to be recovered by decompressing it. + extract = extractRawLedger } pw, err := packfile.Create(path, packfile.WriterOptions{ ItemsPerRecord: 1, Format: formatLedgerCold, Overwrite: true, - NewRecordEncoder: newColdPackEncoder, - // Items reach AppendItem as raw LCM bytes, so the content hash is + NewRecordEncoder: newEncoder, + // Items reach the hasher as raw LCM bytes, so the content hash is // independent of the zstd encoder version. - ContentHash: true, - Concurrency: opts.Concurrency, - BytesPerSync: opts.BytesPerSync, + ContentHash: true, + ContentHashExtract: extract, + Concurrency: opts.Concurrency, + BytesPerSync: opts.BytesPerSync, }) if err != nil { return nil, fmt.Errorf("cold: create packfile %q: %w", path, err) } return &ColdWriter{ - pw: pw, - firstSeq: firstSeq, - nextSeq: firstSeq, - path: path, + pw: pw, + firstSeq: firstSeq, + nextSeq: firstSeq, + path: path, + preCompressed: opts.PreCompressed, }, nil } -// AppendLedger appends one ledger. seq must equal the writer's -// current nextSeq; a gap or out-of-order seq returns an error -// without advancing internal state. +// AppendLedger appends one RAW ledger (the record encoder compresses it). +// seq must equal the writer's current nextSeq; a gap or out-of-order seq +// returns an error without advancing internal state. Errors in +// PreCompressed mode — use AppendCompressedLedger there. func (w *ColdWriter) AppendLedger(seq uint32, ledgerBytes []byte) error { - if seq != w.nextSeq { - return fmt.Errorf("cold %q: expected seq %d, got %d", w.path, w.nextSeq, seq) + if w.preCompressed { + return fmt.Errorf("cold %q: AppendLedger on a PreCompressed writer", w.path) } - if err := w.pw.AppendItem(ledgerBytes); err != nil { - return translateWriterErr(err) + return w.append(seq, ledgerBytes) +} + +// AppendCompressedLedger appends one ledger already compressed as an +// internal/rpcv2/zstd frame (a hot ledgers-CF value), written to the pack +// verbatim. Only valid on a PreCompressed writer. Beyond the shared +// seq-contiguity check, the frame header is validated (magic, recorded +// content size, no dictionary, checksum flag) so the coupling to the hot +// tier's compression shape fails HERE, at freeze time, not at cold read. +func (w *ColdWriter) AppendCompressedLedger(seq uint32, frame []byte) error { + if !w.preCompressed { + return fmt.Errorf("cold %q: AppendCompressedLedger on a raw-mode writer", w.path) } - w.nextSeq++ - return nil + if err := zstd.FrameHeaderValid(frame); err != nil { + return fmt.Errorf("cold %q: ledger %d: %w", w.path, seq, err) + } + return w.append(seq, frame) } // Commit writes firstSeq into AppData, finalizes the trailer, and @@ -126,3 +203,16 @@ func translateWriterErr(err error) error { } return err } + +// append is the shared tail of both append modes: contiguity check, item +// write, seq advance. +func (w *ColdWriter) append(seq uint32, item []byte) error { + if seq != w.nextSeq { + return fmt.Errorf("cold %q: expected seq %d, got %d", w.path, w.nextSeq, seq) + } + if err := w.pw.AppendItem(item); err != nil { + return translateWriterErr(err) + } + w.nextSeq++ + return nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store.go b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store.go index 81593fc0f..d235c8dca 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store.go @@ -9,6 +9,7 @@ import ( "iter" "slices" "sync" + "sync/atomic" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" @@ -36,17 +37,28 @@ type Entry struct { // before freezing; it does not itself range-check writes (the driver's drain loop // already validates every sequence against the chunk). // -// Concurrency: all methods are safe for concurrent use, including use alongside -// the caller-owned rocksdb.Store.Close. A read/write racing Close either completes -// first or observes the closed store and returns stores.ErrStoreClosed. HotStore -// adds no unguarded state of its own — the compressor pool and decompressor are -// both concurrent-safe. +// Concurrency: all READ methods are safe for concurrent use — with each +// other, with the write side, and alongside the caller-owned +// rocksdb.Store.Close (a read racing Close either completes first or +// observes the closed store and returns stores.ErrStoreClosed). The WRITE +// side (StartCompress / AddPendingToBatch / AddLedgerToBatch / Discard) is +// SINGLE-FLIGHT per store: at most one compression may be in flight at a +// time — hotchunk's single-writer ingest loop is the sole production +// caller. The store owns one reusable encode state under that contract; +// violations panic loudly (see encBusy) rather than racing silently. type HotStore struct { store *rocksdb.Store dec *zstd.Decompressor - // compPool — per-store pool of zstd.Compressors; each concurrent - // AddLedgerToBatch borrows one for its Encode call. - compPool sync.Pool + // enc — the store's single OWNED zstd encode state (context + retained + // dst buffer). The write side is single-flight (see the concurrency + // contract above), so one state suffices and can never be dropped — + // its sync.Pool predecessor was measured losing the state ~1-in-5 + // ledgers to GC pool-emptying, re-allocating a worst-case dst each + // time. encBusy makes a contract violation loud instead of a silent + // data race. BatchWriter.Put copies synchronously, so the buffer is + // safe to reuse on the next ledger. + enc *zstd.EncoderState + encBusy atomic.Bool // scratch — decode buffers WithLedger lends. Pooled as *[]byte so a buffer // the decode had to grow goes back in place of the one that was lent. scratch sync.Pool @@ -58,40 +70,126 @@ type HotStore struct { // 64MiB is several times the largest raw ledgers on the heaviest profile. const maxPooledLedgerBytes = 64 << 20 +// DefaultZstdEncodeWorkers is the settled ledger-frame encode parallelism +// (zstd.WithWorkers): 2 measured equal to 3 within noise on the hot-ingest +// cell (total p50 33.4 -> 29.2ms, join 7.35 -> 1.9ms vs single-threaded) +// while claiming one fewer core. It is the default every configuration +// surface resolves to (daemon TOML, bench --zstd-workers, hotchunk +// DefaultTuning). +const DefaultZstdEncodeWorkers = 2 + // NewWithStore wraps an ALREADY-OPEN rocksdb.Store as a ledger HotStore on // LedgersCF. The store is owned by the caller — in production, hotchunk.DB // composes this facade over the shared multi-CF DB and closes that DB once. The // store must have LedgersCF registered. -func NewWithStore(store *rocksdb.Store) *HotStore { +// +// zstdEncodeWorkers is FORMAT-AFFECTING (see hotchunk.Tuning's field doc): +// it selects the stored ledger frames' encode mode (0 = single-threaded, +// >=1 = libzstd multithreaded — a different frame byte stream), and the +// walk/backfill cold writer must encode with the SAME value because the +// freeze copies these frames into the cold pack verbatim. +func NewWithStore(store *rocksdb.Store, zstdEncodeWorkers int) *HotStore { return &HotStore{ store: store, dec: zstd.NewDecompressor(), - compPool: sync.Pool{ - New: func() any { return zstd.NewCompressor() }, - }, + enc: zstd.NewEncoderState(encoderOptions(zstdEncodeWorkers)...), scratch: sync.Pool{ New: func() any { return new([]byte) }, }, } } -// AddLedgerToBatch compresses one ledger and queues its Put into b on LedgersCF -// — the building block hotchunk uses to fold the ledger write into the one -// shared per-ledger WriteBatch (decision (a)). Does not commit (caller owns the -// batch). Compresses into a fresh buffer BatchWriter.Put copies, so e.Bytes need -// not outlive this call. The caller runs inside Store.Batch, whose lifecycle -// RLock + checkOpen is the authoritative closed-store guard, so this adds none. -func (h *HotStore) AddLedgerToBatch(b *rocksdb.BatchWriter, e Entry) error { - c, _ := h.compPool.Get().(*zstd.Compressor) - defer h.compPool.Put(c) - compressed, err := c.Encode(nil, e.Bytes) - if err != nil { - return err +// encoderOptions maps the resolved workers count to the encoder's option set: +// <=0 = single-threaded encode, >=1 = libzstd's internal multithreading +// (zstd.WithWorkers). Validation (>=0) lives at the configuration +// boundaries; here any non-positive value is simply single-threaded. +func encoderOptions(workers int) []zstd.CompressorOption { + if workers > 0 { + return []zstd.CompressorOption{zstd.WithWorkers(workers)} + } + return nil +} + +// PendingCompression is an in-flight background compression started by +// StartCompress: the fork half of the fork/join that takes the ~20ms zstd +// encode off the hot loop's critical path (it runs concurrent with extract +// and the other queue steps, all read-only on the ledger bytes). +// +// Exactly one of AddPendingToBatch (the join) or Discard must be called, on +// the same goroutine that started it; both block until the encode goroutine +// has finished with the input bytes, so the caller's borrowed ledger view +// never outlives its call even on error paths. +type PendingCompression struct { + seq uint32 + h *HotStore + done chan struct{} + + compressed []byte + err error + consumed bool +} + +// StartCompress begins compressing e.Bytes into a pooled buffer on its own +// goroutine. e.Bytes is read until the returned pending resolves — join or +// Discard before invalidating it. +func (h *HotStore) StartCompress(e Entry) *PendingCompression { + if !h.encBusy.CompareAndSwap(false, true) { + panic("ledger: concurrent StartCompress violates the single-flight write contract") + } + p := &PendingCompression{seq: e.Seq, h: h, done: make(chan struct{})} + go func() { + defer close(p.done) + p.compressed, p.err = h.enc.Encode(e.Bytes) + }() + return p +} + +// AddPendingToBatch joins p and queues its compressed ledger into b on +// LedgersCF — the deferred-join twin of AddLedgerToBatch. Put copies +// synchronously, so the pooled buffer is released for reuse before returning. +func (h *HotStore) AddPendingToBatch(b *rocksdb.BatchWriter, p *PendingCompression) error { + <-p.done + p.consumed = true + defer p.release() + if p.err != nil { + return p.err } - b.Put(LedgersCF, rocksdb.EncodeUint32(e.Seq), compressed) + b.Put(LedgersCF, rocksdb.EncodeUint32(p.seq), p.compressed) return nil } +// Discard joins p and drops its result, returning the pooled state. No-op if +// the pending was already consumed — safe to defer unconditionally alongside +// a conditional AddPendingToBatch. +func (p *PendingCompression) Discard() { + if p.consumed { + return + } + <-p.done + p.consumed = true + p.release() +} + +// release clears the single-flight latch. Callers reach here only after +// joining p.done, so the encode goroutine is finished with the owned state. +func (p *PendingCompression) release() { + p.h.encBusy.Store(false) + p.compressed = nil +} + +// AddLedgerToBatch compresses one ledger and queues its Put into b on +// LedgersCF — the synchronous convenience over the StartCompress / +// AddPendingToBatch fork-join pair (production ingest uses the pair +// directly to overlap compression with the other batch arms; this +// composition keeps ONE write path for the ledger row). Does not commit +// (caller owns the batch). Neither e.Bytes nor the pooled buffer need +// outlive this call. The caller runs inside Store.Batch, whose lifecycle +// RLock + checkOpen is the authoritative closed-store guard, so this adds +// none. +func (h *HotStore) AddLedgerToBatch(b *rocksdb.BatchWriter, e Entry) error { + return h.AddPendingToBatch(b, h.StartCompress(e)) +} + // WithLedger calls fn with seq's decoded bytes; see query.LedgerReader for the // loan rule. The buffer returns to the store's pool as fn returns. func (h *HotStore) WithLedger(seq uint32, fn func(raw []byte) error) error { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store_test.go index 5961aee9d..97ae020e3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store_test.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "path/filepath" + "runtime" "sync" "sync/atomic" "testing" @@ -40,12 +41,12 @@ func openTestHotStoreAt(t *testing.T, path string) (*HotStore, *rocksdb.Store) { t.Helper() store, err := rocksdb.New(rocksdb.Config{ Path: path, - ColumnFamilies: []string{LedgersCF}, + ColumnFamilies: CFNames(), Logger: silentLogger(), }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) - return NewWithStore(store), store + return NewWithStore(store, DefaultZstdEncodeWorkers), store } func TestHotStore_AddGetRoundTripVerbatim(t *testing.T) { @@ -326,13 +327,16 @@ func TestHotStore_ConcurrentOpsAndCloseRaceFree(t *testing.T) { var wg sync.WaitGroup var stop atomic.Bool - const workers = 4 - for w := range workers { - wg.Go(func() { - for i := uint32(0); !stop.Load(); i++ { - _ = addLedgers(h, Entry{Seq: uint32(w)*1_000_000 + i, Bytes: []byte("v")}) - } - }) + const readers = 4 + // The write side is SINGLE-FLIGHT by contract: exactly one writer + // goroutine. Reads stay fully concurrent — with each other, with the + // writer, and with Close. + wg.Go(func() { + for i := uint32(0); !stop.Load(); i++ { + _ = addLedgers(h, Entry{Seq: 1_000_000 + i, Bytes: []byte("v")}) + } + }) + for range readers { wg.Go(func() { for i := uint32(0); !stop.Load(); i++ { _, _ = readLedgerRaw(h, i%50) @@ -544,3 +548,47 @@ func readLedgerRaw(r interface { }) return out, err } + +// TestStartCompress_StateSurvivesGC pins the owned-state guarantee that +// replaced the sync.Pool: the encode state (CGo context + retained dst +// buffer) must survive arbitrary GC cycles between ledgers. The pool +// predecessor was measured losing it ~1-in-5 ledgers to sync.Pool's +// GC-emptying, re-allocating a worst-case dst (~15MB at stress) each time. +func TestStartCompress_StateSurvivesGC(t *testing.T) { + h := NewWithStore(nil, DefaultZstdEncodeWorkers) // StartCompress/Discard never touch the store + payload := bytes.Repeat([]byte("ledger bytes "), 1<<16) // ~832KB + + warm := h.StartCompress(Entry{Seq: 1, Bytes: payload}) + warm.Discard() + + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + for i := range 20 { + runtime.GC() + runtime.GC() // two cycles: what evicted the sync.Pool state + p := h.StartCompress(Entry{Seq: uint32(i + 2), Bytes: payload}) + p.Discard() + } + runtime.ReadMemStats(&after) + // 20 encodes may allocate goroutine/pending scaffolding (KBs) but must + // never re-allocate a dst-buffer-sized block. + require.Less(t, after.TotalAlloc-before.TotalAlloc, uint64(len(payload)), + "encode state was dropped and re-allocated across GC cycles") +} + +// TestStartCompress_SingleFlightGuard pins the narrowed write contract: a +// second in-flight compression panics loudly instead of racing the owned +// state; the latch releases on join/Discard. +func TestStartCompress_SingleFlightGuard(t *testing.T) { + h := NewWithStore(nil, DefaultZstdEncodeWorkers) + payload := []byte("x") + + first := h.StartCompress(Entry{Seq: 1, Bytes: payload}) + require.Panics(t, func() { h.StartCompress(Entry{Seq: 2, Bytes: payload}) }, + "second in-flight StartCompress must trip the single-flight latch") + first.Discard() + + third := h.StartCompress(Entry{Seq: 3, Bytes: payload}) + third.Discard() // latch released by the join — reusable again +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go index 9827b1ac3..af76f99ab 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go @@ -18,17 +18,23 @@ package txhash // entry ColdKeySize B blinded txhash[:ColdKeySize] // uint32 LE absolute ledger seq // -// Entries are lex-sorted by (blinded) key. Duplicate keys are written -// verbatim, but the downstream streamhash build fails on them — with -// 16-byte blinded keys a collision is astronomically unlikely, and -// if one ever occurs the index build rejects it loudly rather than -// serving an ambiguous key. +// Entries are lex-sorted by (blinded) key, ties by ascending ledger +// (SortColdEntries) — the one stored order every producer emits: the walk +// writer's finalize sort, the seal's run records (which the freeze copies +// verbatim), and the freeze merge's source order. Duplicate keys are written +// verbatim; the downstream streamhash build fails on them, so the tie-break +// exists to keep the producers byte-identical, not to make duplicates +// servable. import ( "bufio" + "bytes" + "cmp" "encoding/binary" "fmt" "os" + "slices" + "sync" "github.com/stellar/streamhash" @@ -89,6 +95,24 @@ type ColdEntry struct { Seq uint32 } +// blindRow appends one packed CF row's hashes to dst as cold entries, all +// carrying the row's ledger seq: the "blind one raw row into stored records" +// step, written once for its two callers — the seal, which folds a whole +// window of rows this way (hotindex_seal.go), and the freeze, which folds one +// un-sealed tail row per merge source (cold_freeze.go). Both then sort through +// SortColdEntries, which is what makes the run records the freeze copies +// verbatim and the tail records it merges beside them literally the same +// function of the same bytes. +// +// A tx-less ledger's row is empty and contributes nothing. Trailing bytes past +// the last whole hash are ignored here; validateRow rejects them upstream. +func blindRow(dst []ColdEntry, row []byte, seq uint32, secret [stores.SecretLen]byte) []ColdEntry { + for off := 0; off+rowHashLen <= len(row); off += rowHashLen { + dst = append(dst, ColdEntry{Key: RoutingKey(secret, row[off:]), Seq: seq}) + } + return dst +} + // ColdBinName returns the .bin filename for chunkID (`.bin`). // Bucket-directory composition ({bucketID:05d}/) is the orchestrator's job, // mirroring the event store cold-format split. @@ -96,67 +120,150 @@ func ColdBinName(chunkID chunk.ID) string { return chunkID.String() + ".bin" } -// WriteColdBin writes the .bin file directly to path, truncating any prior -// attempt's file (os.Create is O_TRUNC). There is no tmp+rename step: the -// orchestrator's completion record — written only after WriteColdBin returns — -// is the sole authority on whether the artifact exists, so a partial file -// from a failed or crashed attempt is inert scratch the retry overwrites -// (and scanBinHeader's header-vs-size check rejects loudly if one is -// ever opened). +// compareColdEntries is THE comparator: ascending blinded key, equal keys +// ascending ledger. Nothing in the package orders cold entries any other +// way — SortColdEntries sorts through it, its parallel merge merges through +// it, and the freeze's k-way merge reproduces the same order by construction +// (runs oldest first, keys ascending within each). // -// secret is the index secret entries' keys were blinded with; it is recorded in -// the header so the deferred build adopts it instead of re-deriving one that -// might disagree. +// The ledger tie-break costs nothing (it runs only on equal keys, which are +// the cross-ledger duplicate shape) and buys determinism: without it +// duplicate keys land in an unspecified order, which the byte-identity gates +// would then only pass by accident of the fixture. +func compareColdEntries(a, b ColdEntry) int { + if c := bytes.Compare(a.Key[:], b.Key[:]); c != 0 { + return c + } + return cmp.Compare(a.Seq, b.Seq) +} + +const ( + // sortShards is the parallel sort's fan-out: the slice splits into this + // many contiguous shards, each sorted on its own goroutine, then merged + // back. A power of two so the merge ladder is exactly log2(sortShards) + // passes — 4 here, an EVEN number, which is what lets the ping-pong land + // its result back in the caller's slice with no final copy. + sortShards = 16 + + // sortParallelMin is the smallest input worth the fan-out: below it the + // scratch allocation and goroutine round-trip cost more than the sort + // they save, so the plain single-threaded path runs. Chunk-scale + // accumulators (a stress chunk is ~60M entries) are far above it; test + // fixtures and ordinary seal windows are far below. A seal window on + // genuinely heavy traffic can cross it, which is harmless — the output is + // identical either way and the scratch is proportional to the WINDOW, not + // the chunk. + sortParallelMin = 1 << 20 +) + +// SortColdEntries sorts entries in place into the .bin's stored order +// (compareColdEntries). It is the ONE definition of that order — the walk +// writer's finalize and the hot tier's seal both sort through it, and the +// freeze merge's source order reproduces it — so byte parity between the +// paths is a property of one comparator instead of an agreement between +// three. // -// entries must already be sorted (lex by Key, non-decreasing); this function -// writes them verbatim. +// Large inputs take a parallel path: sortShards contiguous shards sorted +// concurrently, then merged back with a bottom-up ladder. The OUTPUT IS +// IDENTICAL to the sequential sort, not merely equivalent — compareColdEntries +// is a TOTAL order on distinct records (a ColdEntry is exactly (Key, Seq), so +// records that compare equal are byte-identical), and a shard-then-merge over +// a total order produces the same sequence of bytes whatever the tie-break. +// That is what makes the parallelism free of the byte-identity gates. // -// Sync runs before Close, and the Close error is explicitly checked: the -// completion record must only be written once the data is durable, and on -// many filesystems ENOSPC/EIO only surface at fd close — a silently -// truncated .bin would produce a wrong index without any signal. -func WriteColdBin(path string, secret [stores.SecretLen]byte, entries []ColdEntry) error { - f, cerr := os.Create(path) - if cerr != nil { - return fmt.Errorf("txhash: create %s: %w", path, cerr) - } - // closed guards the deferred Close against double-closing after the - // explicit error-checked Close below. - closed := false - defer func() { - if !closed { - _ = f.Close() - } - }() +// It costs one scratch slice of len(entries) — ~1.2GB transient on a 60M-entry +// stress chunk — held only for the merge. That is acceptable where it is paid: +// the caller that reaches chunk scale is the walk path's finalize +// (ingest/txhash.go), which is already holding the whole-chunk accumulator +// being sorted, so the peak grows by one slice rather than by a new order of +// magnitude. The freeze path allocates none at all — it holds no accumulator, +// only merge cursors (cold_freeze.go). The motivation is measured: a stress +// chunk's finalize sort is a single-core MINUTE sequentially and seconds +// sharded. +func SortColdEntries(entries []ColdEntry) { + if len(entries) < sortParallelMin { + slices.SortFunc(entries, compareColdEntries) + return + } - bw := bufio.NewWriterSize(f, 1<<20) - var header [coldBinHeaderSize]byte - copy(header[:4], coldBinMagic) - header[4] = coldBinVersion - binary.LittleEndian.PutUint64(header[coldBinPreludeSize:], uint64(len(entries))) - copy(header[coldBinPreludeSize+coldBinCountSize:], secret[:]) - if _, werr := bw.Write(header[:]); werr != nil { - return fmt.Errorf("txhash: write header: %w", werr) - } - var entryBuf [coldBinEntrySize]byte - for _, e := range entries { - copy(entryBuf[:ColdKeySize], e.Key[:]) - binary.LittleEndian.PutUint32(entryBuf[ColdKeySize:], e.Seq) - if _, werr := bw.Write(entryBuf[:]); werr != nil { - return fmt.Errorf("txhash: write entry: %w", werr) + // Shard bounds over the arrival-order slice: contiguous, so each shard + // sorts in place and the merge below needs no index mapping. + bounds := make([]int, sortShards+1) + for i := range bounds { + bounds[i] = i * len(entries) / sortShards + } + var wg sync.WaitGroup + for i := range sortShards { + lo, hi := bounds[i], bounds[i+1] + wg.Go(func() { slices.SortFunc(entries[lo:hi], compareColdEntries) }) + } + wg.Wait() + + // Bottom-up merge ladder, ping-ponging between entries and one scratch + // slice: each pass halves the run count and merges adjacent pairs + // concurrently. sortShards is a power of two, so the pass count is even + // and the last pass writes into entries — the caller's slice ends up + // sorted with no copy-back. + scratch := make([]ColdEntry, len(entries)) + src, dst := entries, scratch + for len(bounds) > 2 { + next := make([]int, 0, len(bounds)/2+1) + var pass sync.WaitGroup + for j := 0; j+2 < len(bounds); j += 2 { + lo, mid, hi := bounds[j], bounds[j+1], bounds[j+2] + next = append(next, lo) + pass.Go(func() { mergeColdEntries(src[lo:mid], src[mid:hi], dst[lo:hi]) }) } + pass.Wait() + next = append(next, bounds[len(bounds)-1]) + bounds = next + src, dst = dst, src } - if ferr := bw.Flush(); ferr != nil { - return fmt.Errorf("txhash: flush: %w", ferr) +} + +// mergeColdEntries merges two compareColdEntries-sorted runs into out, which +// must hold exactly len(a)+len(b) entries and must not alias either input. +// Ties take from a, which keeps the merge equal to a stable one — though the +// distinction cannot show in the bytes, since equal entries ARE equal bytes. +func mergeColdEntries(a, b, out []ColdEntry) { + i, j := 0, 0 + for k := range out { + switch { + case i == len(a): + out[k] = b[j] + j++ + case j == len(b): + out[k] = a[i] + i++ + case compareColdEntries(b[j], a[i]) < 0: + out[k] = b[j] + j++ + default: + out[k] = a[i] + i++ + } } - if serr := f.Sync(); serr != nil { - return fmt.Errorf("txhash: sync %s: %w", path, serr) +} + +// WriteColdBin writes the .bin file at path from entries the caller already +// holds whole. It is a loop over the same coldBinStream the freeze drives, so +// every producer's bytes are identical by construction; see that type for the +// create semantics and commit for the durability ladder. +// +// entries must already be in the stored order (SortColdEntries); this +// function writes them verbatim. +func WriteColdBin(path string, secret [stores.SecretLen]byte, entries []ColdEntry) error { + w, err := newColdBinStream(path, secret) + if err != nil { + return err } - closed = true - if clerr := f.Close(); clerr != nil { - return fmt.Errorf("txhash: close %s: %w", path, clerr) + defer w.close() // a no-op once commit has consumed the fd + for i := range entries { + if aerr := w.append(entries[i]); aerr != nil { + return aerr + } } - return nil + return w.commit() } // coldBinCount validates a .bin file's byte size against its declared header @@ -178,3 +285,102 @@ func coldBinCount(path string, size int64, count uint64) (uint64, error) { } return count, nil } + +// coldBinStream is THE .bin writer: placeholder header, entries appended in +// caller order, then commit patches the leading count and makes the file +// durable. It has two entry points and no second implementation — the freeze +// streams its merge output straight in, WriteColdBin loops a whole slice +// through it — so the freeze's byte parity with the walk path is structural +// rather than a property two serializers have to keep agreeing on. +// +// The file is created with os.Create, truncating any prior attempt (O_TRUNC). +// There is no tmp+rename step: the artifact's completion record — written +// only after the writer returns — is the sole authority on whether the +// artifact exists, so a partial file from a failed or crashed attempt is +// inert scratch the retry overwrites (and scanBinHeader's header-vs-size +// check rejects loudly if one is ever opened). +// +// Two-phase commit/close like the domain's other writers — runspill.RunWriter +// carries the pattern doc. +type coldBinStream struct { + f *os.File + bw *bufio.Writer + // entryBuf is the per-append encode scratch. It lives on the writer + // because bufio can hand the slice through to the underlying io.Writer, + // which escapes a local array to the heap — an allocation per entry over + // a ~3M-entry chunk. + entryBuf [coldBinEntrySize]byte + count uint64 + done bool +} + +func newColdBinStream(path string, secret [stores.SecretLen]byte) (*coldBinStream, error) { + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("txhash: create %s: %w", path, err) + } + bw := bufio.NewWriterSize(f, 1<<20) + // Prelude and secret are final at create time; only the count is patched + // in commit, once the entries have been streamed. + var header [coldBinHeaderSize]byte + copy(header[:4], coldBinMagic) + header[4] = coldBinVersion + copy(header[coldBinPreludeSize+coldBinCountSize:], secret[:]) + if _, werr := bw.Write(header[:]); werr != nil { + _ = f.Close() + return nil, fmt.Errorf("txhash: write header: %w", werr) + } + return &coldBinStream{f: f, bw: bw}, nil +} + +func (w *coldBinStream) append(e ColdEntry) error { + copy(w.entryBuf[:ColdKeySize], e.Key[:]) + binary.LittleEndian.PutUint32(w.entryBuf[ColdKeySize:], e.Seq) + if _, err := w.bw.Write(w.entryBuf[:]); err != nil { + return fmt.Errorf("txhash: write entry: %w", err) + } + w.count++ + return nil +} + +// commit completes the file: flush the buffer, patch the leading count now +// that it is known, then the durability ladder every .bin depends on — Sync +// before Close, with the Close error explicitly checked. Every step of that +// order is load-bearing. The flush precedes the patch because a short chunk +// leaves the placeholder header sitting in the buffer, and flushing after the +// WriteAt would lay those zeros back over the count. The patch precedes the +// Sync so one Sync covers the header and the entries. The Sync precedes the +// Close, and the Close error is not discarded, because the artifact's +// completion record must only be written once the data is durable and on many +// filesystems ENOSPC/EIO only surface at fd close — a silently truncated .bin +// would produce a wrong index without any signal. +func (w *coldBinStream) commit() error { + if err := w.bw.Flush(); err != nil { + return fmt.Errorf("txhash: flush: %w", err) + } + // Patch ONLY the count: the prelude and the secret were written at create + // time and a whole-header rewrite would wipe them. + var count [coldBinCountSize]byte + binary.LittleEndian.PutUint64(count[:], w.count) + if _, err := w.f.WriteAt(count[:], coldBinPreludeSize); err != nil { + return fmt.Errorf("txhash: patch header count: %w", err) + } + if err := w.f.Sync(); err != nil { + return fmt.Errorf("txhash: sync %s: %w", w.f.Name(), err) + } + // done only once Close consumes the fd — an earlier error must leave + // close() responsible for releasing it. + w.done = true + if err := w.f.Close(); err != nil { + return fmt.Errorf("txhash: close %s: %w", w.f.Name(), err) + } + return nil +} + +// close releases the file on the error path; a no-op after commit. The +// partial file is inert scratch per the artifact model (retry overwrites). +func (w *coldBinStream) close() { + if !w.done { + _ = w.f.Close() + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin_test.go index c3a71a715..5a568d38c 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin_test.go @@ -8,6 +8,7 @@ import ( "io" "os" "path/filepath" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -210,3 +211,32 @@ func TestBuildColdIndex_RejectsMixedSecrets(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "different index secret") } + +// TestSortColdEntries_ParallelMatchesSequential pins the parallel path's +// whole claim: above sortParallelMin the shard-sort-and-merge ladder produces +// the SAME slice, entry for entry, as the plain sequential sort. The fixture +// is deliberately duplicate-heavy — keys drawn from a small space, so equal +// keys are common and the (Key, Seq) tie-break is exercised across shard +// boundaries — because duplicates are the only place a merge could reorder +// anything, and they are exactly what the byte-identity gates ride on. +func TestSortColdEntries_ParallelMatchesSequential(t *testing.T) { + if testing.Short() { + t.Skip("allocates ~2 × sortParallelMin entries") + } + const n = sortParallelMin + 12345 // not a multiple of sortShards + rng := testRNG(0x5037) + entries := make([]ColdEntry, n) + for i := range entries { + // A 3-byte key space over n ≫ 2^24/8 entries guarantees many exact + // key collisions, and a small seq space guarantees exact (Key, Seq) + // duplicates too. + binary.LittleEndian.PutUint32(entries[i].Key[:4], uint32(rng.UintN(1<<24))) + entries[i].Seq = uint32(rng.UintN(64)) + } + want := append([]ColdEntry(nil), entries...) + slices.SortFunc(want, compareColdEntries) + + require.GreaterOrEqual(t, len(entries), sortParallelMin, "fixture must take the parallel path") + SortColdEntries(entries) + require.Equal(t, want, entries) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_format.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_format.go index 4e1fa305a..d3a0a449b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_format.go @@ -7,10 +7,9 @@ package txhash // One MPHF spans many chunks because a hash lookup has no ledger to narrow // on. The reader is in cold_reader.go, the build in cold_index.go. // -// Routing is secret-keyed: the .bin producer stores -// stores.BlindKey(secret, txhash[:ColdKeySize]) as each entry's key, the -// build feeds those keys verbatim, and the reader keys its queries the same -// way. secret = ColdIndexSecret(catalogSecret, indexID) — deterministic, and +// Routing is secret-keyed: the .bin producer stores RoutingKey(secret, hash) +// as each entry's key, the build feeds those keys verbatim, and the reader +// keys its queries through the same function. secret = ColdIndexSecret(catalogSecret, indexID) — deterministic, and // stored in the index metadata so queries never need the master key. import ( @@ -60,6 +59,17 @@ func ColdIndexSecret(catalogSecret []byte, indexID uint32) [stores.SecretLen]byt return stores.DeriveIndexSecret(catalogSecret, coldRoutingDomain, indexID) } +// RoutingKey is THE txhash routing key: the blinded first ColdKeySize bytes +// of a transaction hash. Callers hand it whatever holds the hash — a full +// 32-byte hash, or a packed row's 32-byte slot — and the TRUNCATION lives +// here, once, so "the key is blinded and truncated in exactly one place" is a +// property of this function rather than an agreement between the walk writer, +// the seal, the freeze's tail sources, and the cold reader. hash must hold at +// least ColdKeySize bytes. +func RoutingKey(secret [stores.SecretLen]byte, hash []byte) [ColdKeySize]byte { + return stores.BlindKey(secret, hash[:ColdKeySize]) +} + // EncodeColdMetadata packs [version, minLedger, maxLedger, secret] into the // metadata blob. func EncodeColdMetadata(minLedger, maxLedger uint32, secret [stores.SecretLen]byte) []byte { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go new file mode 100644 index 000000000..e1598c58d --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -0,0 +1,358 @@ +package txhash + +// cold_freeze.go — the txhash half of the zero-decompression freeze: build a +// completed hot chunk's cold .bin DIRECTLY from its durable hot state, as a +// pure STREAM. The packed-row tier seals its runs already blinded and +// key-sorted (blind-at-seal, hotindex_seal.go), i.e. holding the .bin's own +// records, so the .bin's order is recovered by a k-way merge over +// already-sorted sources: the manifest-listed sealed runs (20-byte blinded +// records, CRC64-framed) copied verbatim, plus each un-sealed tail row — +// raw in the CF — blinded and sorted into its own small merge source. No +// whole-chunk accumulator and no whole-chunk sort: freeze RAM here is the +// merge's cursors plus one tail row's keys, and the merge output streams +// straight into the .bin writer. Orphan run files (a crash between run write +// and manifest Put) are ignored by construction: only manifest-named runs +// feed the merge, and the CF rows past the sealed frontier cover everything +// an orphan holds — freeze output is identical from every crash state. +// +// Two byte-discipline rules, unchanged from the CF-scan freeze this +// replaces: +// - truncation to ColdKeySize happens exactly once, inside RoutingKey +// (cold_format.go) — the one place any producer blinds; +// - sequences are decoded and re-encoded (BE row key / LE run record → +// LE .bin), never memcpy'd. +// +// Duplicate emission is VERBATIM — equal blinded keys merge in ledger order +// (run-then-tail source order) with no dedup and no error: byte parity with +// the walk path is the freeze's gate, and the downstream streamhash build +// already rejects a duplicate-key collision loudly (cold_bin.go). An +// intra-ledger duplicate was rejected at write time and cannot reach here. + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" +) + +// freezeCtxPollEvery is how many entries the merge emits between context +// checks. Entries are ~20 bytes of work apiece, so a coarse cadence still +// cancels promptly. +const freezeCtxPollEvery = 4096 + +// FreezeColdFromStore builds the chunk's cold txhash .bin at binPath from +// the chunk's hot store (read-only opens included — nothing here needs a +// warmed facade, and nothing is mutated). The merge streams straight into +// the walk path's own writer (coldBinStream, which WriteColdBin loops a +// whole slice over), so the byte-parity gate's only remaining subject is +// entry ORDER — and both paths sort through SortColdEntries' comparator. +// Returns the entries written. +// +// secret is the chunk's per-index routing secret: every stored key is +// RoutingKey(secret, hash) — the walk writer's rule. The +// sealed runs were keyed with the secret the chunk DB adopted at its first +// read-write open, so a caller's secret that disagrees with the persisted +// one would silently mix two keyspaces into one .bin; requireStoreSecret +// rejects that before a byte is written. +func FreezeColdFromStore( + ctx context.Context, + chunkID chunk.ID, + store *rocksdb.Store, + binPath string, + secret [stores.SecretLen]byte, +) (int, error) { + if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil { + return 0, fmt.Errorf("txhash freeze %s: mkdir: %w", chunkID, err) + } + if err := requireStoreSecret(store, secret); err != nil { + return 0, fmt.Errorf("txhash freeze %s: %w", chunkID, err) + } + sources, err := openFreezeSources(ctx, store, chunkID, secret) + if err != nil { + return 0, err + } + defer closeFreezeSources(sources) + w, err := newColdBinStream(binPath, secret) + if err != nil { + return 0, err + } + defer w.close() + n, err := mergeFreezeSources(ctx, chunkID, sources, w) + if err != nil { + return 0, err + } + if ferr := w.commit(); ferr != nil { + return 0, ferr + } + return n, nil +} + +// openFreezeSources assembles the merge inputs in tie-break order: the +// manifest-listed sealed runs (oldest first), then the un-sealed tail rows +// (ledger order) — so equal keys emit in global ledger order. +// +// It re-validates the manifest's run CHAIN while opening, because that chain +// is what the tie-break order MEANS. mergeFreezeSources emits equal keys by +// (key, source index) and calls the result (key, ledger); the two are the +// same order only while source index rises with ledger — i.e. while each run +// starts where the previous ended and the newest ends at the sealed frontier. +// OpenHotIndex enforces exactly that, but a production freeze never runs it: +// it opens through OpenReadyView → OpenReadOnly, which warms nothing. Without +// the check here, a manifest whose runs were listed out of order would +// silently produce a .bin whose duplicate-key order diverges from the walk +// path's — surfacing much later as a streamhash duplicate-key build failure +// or a byte-parity gate miss, with nothing pointing at the manifest. +func openFreezeSources( + ctx context.Context, store *rocksdb.Store, chunkID chunk.ID, secret [stores.SecretLen]byte, +) ([]freezeSource, error) { + names, lastSealed, err := rocksdbManifest{store: store}.GetRuns() + if err != nil { + return nil, fmt.Errorf("txhash freeze %s: manifest: %w", chunkID, err) + } + runDir := filepath.Join(store.Path(), txhashRunDir) + sources := make([]freezeSource, 0, len(names)) + var prev runHeader + for i, name := range names { + rs, oerr := openRunSource(filepath.Join(runDir, name)) + if oerr != nil { + closeFreezeSources(sources) + return nil, fmt.Errorf("txhash freeze %s: %w", chunkID, oerr) + } + // Append BEFORE the chain check so the cleanup below covers this + // handle too, however the check exits. + sources = append(sources, rs) + if i > 0 && rs.hdr.first != prev.last+1 { + closeFreezeSources(sources) + return nil, fmt.Errorf( + "txhash freeze %s: manifest run %s starts at %d, previous run ends at %d "+ + "(the merge's source order IS its ledger order — see openFreezeSources)", + chunkID, name, rs.hdr.first, prev.last) + } + prev = rs.hdr + } + if len(names) > 0 && prev.last != lastSealed { + closeFreezeSources(sources) + return nil, fmt.Errorf( + "txhash freeze %s: newest manifest run ends at %d, sealed frontier is %d "+ + "(the tail scan would re-cover or skip rows)", chunkID, prev.last, lastSealed) + } + tails, terr := collectTailSources(ctx, store, chunkID, lastSealed, secret) + if terr != nil { + closeFreezeSources(sources) + return nil, terr + } + return append(sources, tails...), nil +} + +// collectTailSources copies every un-sealed packed row (ledgers past the +// sealed frontier) out of the CF as its own merge source, enforcing the +// same properties warmup does: 4-byte keys (a longer key is the stale +// pre-release format), a dense chain from the frontier, and EncodeRow +// shape. Values are copied — the iterator buffer is borrowed. +func collectTailSources( + ctx context.Context, store *rocksdb.Store, chunkID chunk.ID, lastSealed uint32, + secret [stores.SecretLen]byte, +) ([]freezeSource, error) { + expected := max(lastSealed+1, chunkID.FirstLedger()) + var sources []freezeSource + for entry, ierr := range store.IterateRange(txhashCF, rocksdb.EncodeUint32(expected), nil) { + if ierr != nil { + return nil, fmt.Errorf("txhash freeze %s: scan %s: %w", chunkID, txhashCF, ierr) + } + // Per row, like the events twin's tail scan: len(sources) counts + // APPENDED sources, not iterations, so it cannot drive pollCtx's + // cadence (tx-less ledgers never bump it). + if cerr := ctx.Err(); cerr != nil { + return nil, cerr + } + if len(entry.Key) != rowKeyLen { + return nil, fmt.Errorf("txhash freeze %s: %d-byte key in %s: stale pre-release txhash format", + chunkID, len(entry.Key), txhashCF) + } + seq := rocksdb.DecodeUint32(entry.Key) + if seq != expected { + return nil, fmt.Errorf("txhash freeze %s: tail row %d breaks the dense chain (want %d)", + chunkID, seq, expected) + } + expected++ + if _, verr := validateRow(entry.Value); verr != nil { + return nil, fmt.Errorf("txhash freeze %s: tail row %d: %w", chunkID, seq, verr) + } + if len(entry.Value) == 0 { + continue // tx-less ledger: nothing to merge + } + sources = append(sources, newTailRowSource(entry.Value, seq, secret)) + } + return sources, nil +} + +// mergeFreezeSources runs the k-way merge, appending each record to w as a +// cold entry — key already blinded and truncated by its source — with the +// seq range-checked against the chunk. Returns the entry count written. +func mergeFreezeSources( + ctx context.Context, chunkID chunk.ID, sources []freezeSource, w *coldBinStream, +) (int, error) { + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + h, err := newFreezeHeap(sources) + if err != nil { + return 0, fmt.Errorf("txhash freeze %s: %w", chunkID, err) + } + n := 0 + for h.len() > 0 { + if cerr := pollCtx(ctx, n); cerr != nil { + return 0, cerr + } + key, seq := h.min() + if seq < first || seq > last { + return 0, fmt.Errorf("txhash freeze %s: entry seq %d outside [%d, %d]", chunkID, seq, first, last) + } + // Copy the key out of the source's buffer BEFORE the step + // invalidates it. + if aerr := w.append(ColdEntry{Key: [ColdKeySize]byte(key), Seq: seq}); aerr != nil { + return 0, aerr + } + n++ + if serr := h.step(); serr != nil { + return 0, fmt.Errorf("txhash freeze %s: %w", chunkID, serr) + } + } + return n, nil +} + +// pollCtx checks ctx once every freezeCtxPollEvery iterations. +func pollCtx(ctx context.Context, i int) error { + if i%freezeCtxPollEvery == 0 { + return ctx.Err() + } + return nil +} + +// ─────────────────────────── merge sources ─────────────────────────── + +// freezeSource is one key-sorted stream of (blinded key, seq) records +// feeding the freeze merge: a sealed run file (runSource, run_reader.go) or +// a single un-sealed tail row. Sources start positioned BEFORE their first +// record; advance steps to the next one. +type freezeSource interface { + // key returns the current record's blinded routing key — valid only + // until the next advance. + key() []byte + // seq returns the current record's ledger sequence. + seq() uint32 + // advance steps to the next record, reporting whether one is now + // current. (false, nil) is clean end-of-stream — for a run source that + // includes the whole-payload CRC64 verification passing. + advance() (bool, error) + // close releases resources; safe in any state. + close() +} + +// closeFreezeSources releases every source (error path and normal exit). +func closeFreezeSources(sources []freezeSource) { + for _, s := range sources { + s.close() + } +} + +// tailRowSource walks ONE un-sealed packed row's records with a fixed ledger. +// The row is raw and hash-sorted; blinding reorders it, so the source blinds +// the whole row up front and sorts — a row is one ledger's transactions +// (thousands at the extreme, 20 bytes each), so this is a bounded per-row +// buffer, not a chunk-scale accumulator. It runs the seal's own two steps, +// blindRow then SortColdEntries, which is why a tail row's records and a +// sealed run's are the same bytes in the same order. off starts at -1 (before +// the first record) per the freezeSource contract. +type tailRowSource struct { + entries []ColdEntry + rowSeq uint32 + off int +} + +// newTailRowSource blinds and sorts one CF row's hashes. row is borrowed +// (the iterator's buffer); nothing of it is retained. +func newTailRowSource(row []byte, seq uint32, secret [stores.SecretLen]byte) *tailRowSource { + entries := blindRow(make([]ColdEntry, 0, len(row)/rowHashLen), row, seq, secret) + SortColdEntries(entries) + return &tailRowSource{entries: entries, rowSeq: seq, off: -1} +} + +func (t *tailRowSource) key() []byte { return t.entries[t.off].Key[:] } + +func (t *tailRowSource) seq() uint32 { return t.rowSeq } + +func (t *tailRowSource) advance() (bool, error) { + t.off++ + return t.off < len(t.entries), nil +} + +func (t *tailRowSource) close() {} + +// ─────────────────────────── merge heap ─────────────────────────── + +// freezeHeap is the package's key-ordered heap (merge_heap.go) over +// freezeSources: entries carry each live source's CURRENT key beside its +// source index, so a compare never calls back through the freezeSource +// interface. The index tie-break emits equal cross-source duplicate keys in +// source order (runs oldest first, then tail rows in ledger order), and equal +// keys WITHIN a run are already in ledger order from the seal's sort — so +// global duplicate order is ascending ledger, deterministically: the .bin's +// stored order (SortColdEntries), reproduced by streaming. +// +// A cached key is the source's own buffer, valid only until that source +// advances: runSource.key() aliases the record buffer it re-reads into, and +// tailRowSource.key() re-slices as its offset moves. Only the root's source +// ever advances, and step refreshes the root's entry from it before sifting, +// so no entry outlives its bytes. +type freezeHeap struct { + sources []freezeSource + heap keyHeap +} + +// newFreezeHeap primes every source (first advance) and heapifies the +// non-empty ones. An empty source that fails its end-of-stream verification +// (torn empty run) fails here. +func newFreezeHeap(sources []freezeSource) (*freezeHeap, error) { + h := &freezeHeap{sources: sources, heap: make(keyHeap, 0, len(sources))} + for i, s := range sources { + ok, err := s.advance() + if err != nil { + return nil, err + } + if ok { + h.heap = append(h.heap, keyEntry{key: s.key(), idx: i}) + } + } + h.heap.heapify() + return h, nil +} + +func (h *freezeHeap) len() int { return len(h.heap) } + +// min returns the smallest current record — its cached key (valid until the +// next step) and its ledger. +func (h *freezeHeap) min() ([]byte, uint32) { + e := h.heap[0] + return e.key, h.sources[e.idx].seq() +} + +// step advances the minimum source past its emitted record, refilling the root +// with its next key — or dropping the source once drained (its end-of-stream +// verification ran inside advance). +func (h *freezeHeap) step() error { + src := h.sources[h.heap[0].idx] + ok, err := src.advance() + if err != nil { + return err + } + if !ok { + h.heap = h.heap.dropRoot() + return nil + } + h.heap[0].key = src.key() + h.heap.siftDown(0) + return nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go new file mode 100644 index 000000000..2cb3b076b --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go @@ -0,0 +1,439 @@ +package txhash + +import ( + "bytes" + "context" + "encoding/binary" + "hash/crc64" + "os" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" +) + +// freezePopulation builds n ledgers of hashes exercising every freeze shape: +// tx-less ledgers (empty rows), multi-hash ledgers (fee-bump-style +// outer+inner pairs), and cross-ledger duplicate full hashes (the same hash +// committed in two ledgers — legal, emitted verbatim). +func freezePopulation(n int) [][][32]byte { + rng := testRNG(0xF00D) + var pool [][32]byte + ledgers := make([][][32]byte, n) + for i := range ledgers { + if i%7 == 3 { + continue // tx-less ledger + } + inLedger := map[[32]byte]bool{} + for range rng.IntN(6) + 2 { + h := randHash(rng) + ledgers[i] = append(ledgers[i], h) + inLedger[h] = true + pool = append(pool, h) + } + if i%4 == 2 { // cross-ledger duplicate draw + for range 3 { + if h := pool[rng.IntN(len(pool))]; !inLedger[h] { + ledgers[i] = append(ledgers[i], h) + break + } + } + } + } + return ledgers +} + +func populationSize(ledgers [][][32]byte) int { + total := 0 + for _, l := range ledgers { + total += len(l) + } + return total +} + +// expectedBinBytes is the walk-path expectation, constructed INDEPENDENTLY of +// the production comparator: accumulate entries in ledger order, blind +// (BlindKey over the truncated hash — the ingest rule), then STABLE-sort by +// blinded key alone. Stability over ledger-ordered input expresses exactly +// the order SortColdEntries produces (key, then ledger) without calling it — +// which is the point, since SortColdEntries is what both production paths +// sort through (ingest/txhash.go's finalize and the seal), so re-running it +// here would gate nothing. +func expectedBinBytes(t *testing.T, ledgers [][][32]byte, first uint32) []byte { + t.Helper() + entries := make([]ColdEntry, 0, populationSize(ledgers)) + for i, hashes := range ledgers { + for _, h := range hashes { + entries = append(entries, ColdEntry{ + Key: stores.BlindKey(testBinSecret, h[:ColdKeySize]), + Seq: first + uint32(i), + }) + } + } + slices.SortStableFunc(entries, func(a, b ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) + path := filepath.Join(t.TempDir(), "walk.bin") + require.NoError(t, WriteColdBin(path, testBinSecret, entries)) + raw, err := os.ReadFile(path) + require.NoError(t, err) + return raw +} + +// populateDeterministic ingests ledgers settling after EVERY ledger, so seal +// boundaries land identically on every test run: with sealEvery=8 a run +// seals at every 8th ledger and the final partial window stays as the +// un-sealed tail — no dependence on background-seal timing. +func populateDeterministic(t *testing.T, s *HotStore, first uint32, ledgers [][][32]byte) { + t.Helper() + for i, hashes := range ledgers { + ingestLedger(t, s, first+uint32(i), hashes) + settle(t, s) + } +} + +// freezeToBytes runs the freeze against store and returns the .bin bytes, +// asserting the reported entry count. +func freezeToBytes(t *testing.T, chunkID chunk.ID, store *rocksdb.Store, wantEntries int) []byte { + t.Helper() + binPath := filepath.Join(t.TempDir(), "freeze.bin") + n, err := FreezeColdFromStore(context.Background(), chunkID, store, binPath, testBinSecret) + require.NoError(t, err) + require.Equal(t, wantEntries, n) + raw, err := os.ReadFile(binPath) + require.NoError(t, err) + return raw +} + +// TestFreezeColdFromStore_ByteIdenticalToWalk is the txhash half of the +// cross-path identity gate under the packed-row tier: the freeze merge over +// sealed runs + un-sealed tail rows must reproduce the walk path's +// accumulate-truncate-sort .bin byte-for-byte, across empty ledgers, +// fee-bump-style multi-hash ledgers, and cross-ledger duplicates. +func TestFreezeColdFromStore_ByteIdenticalToWalk(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + ledgers := freezePopulation(43) // 43 % 8 != 0: runs AND a live tail + want := expectedBinBytes(t, ledgers, first) + + s, store := openPackedStoreAt(t, t.TempDir(), chunkID, 8) + populateDeterministic(t, s, first, ledgers) + + // The merge must draw from BOTH tiers: sealed runs and a non-empty tail. + names, lastSealed, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names, "population must have sealed runs") + require.Less(t, lastSealed, first+42, "population must leave an un-sealed tail") + + got := freezeToBytes(t, chunkID, store, populationSize(ledgers)) + require.True(t, bytes.Equal(want, got), ".bin bytes diverge from the walk path") +} + +// TestFreezeColdFromStore_IdenticalAcrossCrashStates pins the +// freeze-at-crash-point gate: the same committed rows must freeze to +// identical bytes whatever durable seal state a crash left behind — +// mid-window (nothing sealed), post-manifest (runs sealed and named), and +// post-seal-pre-manifest (a written-but-unnamed orphan run, which the +// freeze must ignore in favor of the CF tail that covers it). +func TestFreezeColdFromStore_IdenticalAcrossCrashStates(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + ledgers := freezePopulation(30) // 30 % 8 = 6: a non-empty tail + want := expectedBinBytes(t, ledgers, first) + total := populationSize(ledgers) + + // The reopen mirrors the production freeze source: a bare store handle, + // no facade, no warmup — the freeze never needs either. + reopenAndFreeze := func(path string) []byte { + store := openBareStore(t, path) + raw := freezeToBytes(t, chunkID, store, total) + require.NoError(t, store.Close()) + return raw + } + + // Mid-window: the seal window never fills, nothing durable but the rows. + pathA := t.TempDir() + sA, storeA := openPackedStoreAt(t, pathA, chunkID, 1<<20) + populateDeterministic(t, sA, first, ledgers) + sA.Shutdown() + require.NoError(t, storeA.Close()) + require.Equal(t, want, reopenAndFreeze(pathA), "mid-window state diverged") + + // Post-manifest: runs sealed, named, and frontier advanced. + pathB := t.TempDir() + sB, storeB := openPackedStoreAt(t, pathB, chunkID, 8) + populateDeterministic(t, sB, first, ledgers) + sB.Shutdown() + require.NoError(t, storeB.Close()) + require.Equal(t, want, reopenAndFreeze(pathB), "post-manifest state diverged") + + // Post-seal-pre-manifest: a crash after the run file's fsync but before + // the manifest Put leaves a well-formed ORPHAN run duplicating tail + // ledgers. Only manifest-named runs may feed the merge, or those + // entries would emit twice. + pathC := t.TempDir() + sC, storeC := openPackedStoreAt(t, pathC, chunkID, 8) + populateDeterministic(t, sC, first, ledgers) + tailRows := append([]windowRow(nil), sC.hotIdx.view.Load().rows...) + require.NotEmpty(t, tailRows, "population must leave a tail to orphan") + orphan := filepath.Join(storeC.Path(), txhashRunDir, "seal-000099.run") + _, err := writeRun(tailRows, orphan, testBinSecret) + require.NoError(t, err) + sC.Shutdown() + require.NoError(t, storeC.Close()) + require.Equal(t, want, reopenAndFreeze(pathC), "orphan-run state diverged") +} + +// TestFreezeColdFromStore_EmptyChunk: a zero-row chunk freezes to the same +// header-only .bin the walk path writes. +func TestFreezeColdFromStore_EmptyChunk(t *testing.T) { + store := openBareStore(t, t.TempDir()) + freezePath := filepath.Join(t.TempDir(), "empty.bin") + n, err := FreezeColdFromStore(context.Background(), chunk.ID(0), store, freezePath, testBinSecret) + require.NoError(t, err) + require.Zero(t, n) + + walkPath := filepath.Join(t.TempDir(), "walk.bin") + require.NoError(t, WriteColdBin(walkPath, testBinSecret, nil)) + walkBytes, err := os.ReadFile(walkPath) + require.NoError(t, err) + freezeBytes, err := os.ReadFile(freezePath) + require.NoError(t, err) + require.Equal(t, walkBytes, freezeBytes) +} + +// TestFreezeColdFromStore_RejectsOutOfRangeSeq: a merged record pointing +// outside the chunk's ledger range is corruption, not data. +func TestFreezeColdFromStore_RejectsOutOfRangeSeq(t *testing.T) { + chunkID := chunk.ID(0) + store := openBareStore(t, t.TempDir()) + + badSeq := chunkID.LastLedger() + 5 + row, err := EncodeRow([][32]byte{randHash(testRNG(7))}) + require.NoError(t, err) + runDir := filepath.Join(store.Path(), txhashRunDir) + require.NoError(t, os.MkdirAll(runDir, 0o755)) + _, err = writeRun([]windowRow{{seq: badSeq, bytes: row}}, filepath.Join(runDir, "seal-000000.run"), testBinSecret) + require.NoError(t, err) + require.NoError(t, rocksdbManifest{store: store}.PutRuns([]string{"seal-000000.run"}, badSeq)) + + _, err = FreezeColdFromStore(context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.ErrorContains(t, err, "entry seq ", "the merge's chunk-range check owns this rejection") +} + +// TestFreezeColdFromStore_RejectsMalformedRow: a wrong-shape CF row fails +// loudly rather than truncating into a plausible entry. +func TestFreezeColdFromStore_RejectsMalformedRow(t *testing.T) { + chunkID := chunk.ID(0) + store := openBareStore(t, t.TempDir()) + require.NoError(t, store.Put(txhashCF, rocksdb.EncodeUint32(chunkID.FirstLedger()), make([]byte, rowHashLen+1))) + + _, err := FreezeColdFromStore(context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.ErrorContains(t, err, "multiple") +} + +// TestFreezeColdFromStore_RejectsStaleFormat: a pre-release hash-keyed row +// in the CF fails the freeze loudly (same tripwire as the open). +func TestFreezeColdFromStore_RejectsStaleFormat(t *testing.T) { + chunkID := chunk.ID(0) + store := openBareStore(t, t.TempDir()) + stale := randHash(testRNG(8)) + stale[0] |= 0x80 // sorts past any 4-byte seq key the scan starts from + require.NoError(t, store.Put(txhashCF, stale[:], rocksdb.EncodeUint32(chunkID.FirstLedger()))) + + _, err := FreezeColdFromStore(context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.ErrorContains(t, err, "stale pre-release txhash format") +} + +// TestFreezeColdFromStore_RejectsDenseGap: a missing tail row is corruption +// — the freeze must not silently produce a .bin missing that ledger's +// hashes. +func TestFreezeColdFromStore_RejectsDenseGap(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + store := openBareStore(t, t.TempDir()) + row, err := EncodeRow([][32]byte{randHash(testRNG(9))}) + require.NoError(t, err) + require.NoError(t, store.Batch(func(b *rocksdb.BatchWriter) error { + b.Put(txhashCF, rocksdb.EncodeUint32(first), row) + b.Put(txhashCF, rocksdb.EncodeUint32(first+2), row) // gap at first+1 + return nil + })) + + _, err = FreezeColdFromStore(context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.ErrorContains(t, err, "dense chain") +} + +// TestFreezeColdFromStore_RejectsCorruptRunCRC: a manifest-named run whose +// payload CRC does not verify fails the freeze loudly — and identically on +// retry, since the freeze writes nothing on error (the partial .bin is +// inert scratch). +func TestFreezeColdFromStore_RejectsCorruptRunCRC(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + path := t.TempDir() + ledgers := freezePopulation(16) // seals two full runs, no tail + s, store := openPackedStoreAt(t, path, chunkID, 8) + populateDeterministic(t, s, first, ledgers) + names, _, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names) + s.Shutdown() + require.NoError(t, store.Close()) + + // Corrupt the stored CRC itself: the payload (hash order, seqs) stays + // valid, so the failure is attributable to the CRC verification alone. + runPath := filepath.Join(path, txhashRunDir, names[0]) + raw, err := os.ReadFile(runPath) + require.NoError(t, err) + raw[runCRCOff] ^= 0x01 + require.NoError(t, os.WriteFile(runPath, raw, 0o644)) + + reopened := openBareStore(t, path) + _, err = FreezeColdFromStore( + context.Background(), chunkID, reopened, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.ErrorContains(t, err, "crc mismatch") +} + +// TestFreezeColdFromStore_RejectsBrokenManifestChain pins the ordering +// dependency the freeze merge's tie-break rests on: source index must rise +// with ledger, which is true only while the manifest lists runs in ascending +// ledger order ending at the sealed frontier. Warmup enforces that chain +// (OpenHotIndex), but a production freeze never warms — it opens read-only — +// so the freeze re-checks it while opening its own sources. Both halves are +// exercised: a swapped pair, and a frontier that does not match the newest +// run. +func TestFreezeColdFromStore_RejectsBrokenManifestChain(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + path := t.TempDir() + ledgers := freezePopulation(16) // seals two full runs, no tail + s, store := openPackedStoreAt(t, path, chunkID, 8) + populateDeterministic(t, s, first, ledgers) + names, lastSealed, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.Len(t, names, 2, "fixture must seal two runs to have a chain to break") + s.Shutdown() + require.NoError(t, store.Close()) + + reopened := openBareStore(t, path) + m := rocksdbManifest{store: reopened} + binPath := filepath.Join(t.TempDir(), "x.bin") + + // In manifest order the runs freeze fine — the baseline the two breaks + // are attributable against. + _, err = FreezeColdFromStore(context.Background(), chunkID, reopened, binPath, testBinSecret) + require.NoError(t, err) + + // Newest-first: every run file is intact, only the LIST is wrong. + require.NoError(t, m.PutRuns([]string{names[1], names[0]}, lastSealed)) + _, err = FreezeColdFromStore(context.Background(), chunkID, reopened, binPath, testBinSecret) + require.ErrorContains(t, err, "previous run ends at") + + // Right order, wrong frontier: the tail scan would re-cover sealed rows. + require.NoError(t, m.PutRuns(names, lastSealed-1)) + _, err = FreezeColdFromStore(context.Background(), chunkID, reopened, binPath, testBinSecret) + require.ErrorContains(t, err, "sealed frontier is") +} + +// TestFreezeAndWarmup_RejectSeqOutsideRunHeaderRange pins the run reader's +// per-record seq tripwire on BOTH its drivers: a record whose seq falls +// outside the run header's [first,last] — but INSIDE the chunk's ledger +// range, so the freeze's coarser chunk-range emit check cannot catch it — +// must fail the warmup drain and the freeze merge alike. The header CRC64 +// is re-patched over the corrupted payload, so the failure is attributable +// to the seq-range check alone (the same attribution discipline as +// TestFreezeColdFromStore_RejectsCorruptRunCRC). +func TestFreezeAndWarmup_RejectSeqOutsideRunHeaderRange(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + path := t.TempDir() + ledgers := freezePopulation(16) // seals two full runs, no tail + s, store := openPackedStoreAt(t, path, chunkID, 8) + populateDeterministic(t, s, first, ledgers) + names, _, err := rocksdbManifest{store: store}.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names) + s.Shutdown() + require.NoError(t, store.Close()) + + // Rewrite record 0's seq to one past the run's own ledger range and + // re-patch the payload CRC over the corrupted bytes. The bad seq stays + // inside the chunk range by construction: the run covers an 8-ledger + // prefix of a 10k-ledger chunk. + runPath := filepath.Join(path, txhashRunDir, names[0]) + raw, err := os.ReadFile(runPath) + require.NoError(t, err) + badSeq := binary.LittleEndian.Uint32(raw[runLastOff:]) + 1 + require.Less(t, badSeq, chunkID.LastLedger(), "bad seq must stay inside the chunk range") + binary.LittleEndian.PutUint32(raw[runHeaderLen+ColdKeySize:], badSeq) + crc := crc64.New(crcRunTable) + _, _ = crc.Write(raw[runHeaderLen:]) + binary.LittleEndian.PutUint64(raw[runCRCOff:], crc.Sum64()) + require.NoError(t, os.WriteFile(runPath, raw, 0o644)) + + reopened := openBareStore(t, path) + _, _, err = OpenHotIndex(filepath.Join(path, txhashRunDir), first, rocksdbManifest{store: reopened}, testBinSecret) + // "record N seq" is the run reader's own tripwire (run_reader.go); the + // merge's chunk-range check says "entry seq", so matching bare "outside" + // would let either mechanism satisfy both halves. + require.ErrorContains(t, err, "record 0 seq ", "warmup must reject the out-of-header-range seq") + + _, err = FreezeColdFromStore( + context.Background(), chunkID, reopened, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.ErrorContains(t, err, "record 0 seq ", "freeze must reject the out-of-header-range seq") +} + +// TestFreezeAndGet_OuterInnerResolveAcrossTiers is the #862 gate under the +// packed-row tier: a fee-bump's outer and inner hash resolve to the same +// ledger through the new Get while the ledger sits in the WINDOW, after it +// seals into a RUN, and from the COLD index post-freeze. +func TestFreezeAndGet_OuterInnerResolveAcrossTiers(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + rng := testRNG(0x862) + outerA, innerA := randHash(rng), randHash(rng) + outerB, innerB := randHash(rng), randHash(rng) + + s, store := openPackedStoreAt(t, t.TempDir(), chunkID, 4) + ledgers := [][][32]byte{ + {outerA, innerA}, // ledger first — sealed into a run below + {randHash(rng)}, // filler + nil, // tx-less + {randHash(rng)}, // filler; window full → seal + {outerB, innerB}, // ledger first+4 — stays in the window + } + populateDeterministic(t, s, first, ledgers) + + v := s.hotIdx.view.Load() + require.Len(t, v.runs, 1, "ledger %d must have sealed into a run", first) + require.Len(t, v.rows, 1, "ledger %d must still be in the window", first+4) + + ref := map[[32]byte]uint32{ + outerA: first, innerA: first, // run tier + outerB: first + 4, innerB: first + 4, // window tier + } + for h, want := range ref { + got, err := s.Get(h) + require.NoError(t, err) + require.Equal(t, want, got, "hot Get for hash %x", h) + } + + // Cold post-freeze: .bin → MPHF index → ColdReader. + binPath := filepath.Join(t.TempDir(), "chunk.bin") + _, err := FreezeColdFromStore(context.Background(), chunkID, store, binPath, testBinSecret) + require.NoError(t, err) + idxPath := filepath.Join(t.TempDir(), indexFileName(chunkID)) + require.NoError(t, BuildColdIndex(context.Background(), []string{binPath}, idxPath, first, first+4)) + rd, err := OpenColdReader(idxPath) + require.NoError(t, err) + t.Cleanup(func() { _ = rd.Close() }) + for h, want := range ref { + got, gerr := rd.Get(h) + require.NoError(t, gerr) + require.Equal(t, want, got, "cold Get for hash %x", h) + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go index e1b811e9c..115e010ac 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go @@ -9,8 +9,7 @@ package txhash // The merge requires each file's entries pre-sorted ascending by the // big-endian uint64 of their first 8 key bytes — the block order streamhash // routes on (for the first 8 bytes this is identical to the lex key order -// WriteColdBin guarantees). The .bin keys are already the keyed routing keys -// (see cold_format.go), so the build feeds them verbatim — no keying here. +// WriteColdBin guarantees). import ( "context" @@ -33,21 +32,18 @@ import ( // (MinLedger/MaxLedger report it). The span must fit the 3-byte payload. // // The .bin files are k-way merged (cold_merge.go) and fed single-pass to -// streamhash. By default the block build uses runtime.NumCPU()/2 workers -// (~2.7x over single-threaded); caller opts override. Removes the partial -// output on error, and honors ctx cancellation. -// -// The index secret written into the metadata is ADOPTED from the .bin headers -// (the secret the .bin producer keyed the inputs with), not re-derived — so the -// index can never disagree with its inputs' keying. All inputs must share one -// secret (scanAndValidate enforces it). That secret is deterministic, so an -// ErrBlockOverflow here recurs identically on rebuild — non-retryable by design. +// streamhash — the keys are already the blinded routing keys, so the build +// feeds them verbatim (no keying here). The index secret written into the +// metadata is ADOPTED from the .bin headers (the secret the producer keyed +// the inputs with), not re-derived — an index can never disagree with its +// inputs' keying, and scanAndValidate rejects mixed-secret inputs. The block +// build uses runtime.NumCPU()/2 workers (~2.7x over single-threaded). +// Removes the partial output on error, and honors ctx cancellation. func BuildColdIndex( ctx context.Context, inputs []string, outputPath string, minLedger, maxLedger uint32, - opts ...streamhash.BuildOption, ) (err error) { if maxLedger < minLedger { return fmt.Errorf("txhash: maxLedger %d < minLedger %d", maxLedger, minLedger) @@ -62,13 +58,9 @@ func BuildColdIndex( return err } - // The cold format options go last so they win: a caller can override the - // default WithWorkers (its opt precedes the format ones, which don't set - // workers) but cannot change the pinned payload/fingerprint/metadata. - buildOpts := make([]streamhash.BuildOption, 0, len(opts)+4) - buildOpts = append(buildOpts, streamhash.WithWorkers(defaultBuildWorkers())) - buildOpts = append(buildOpts, opts...) - buildOpts = append(buildOpts, ColdBuildOptions(minLedger, maxLedger, secret)...) + buildOpts := append( + []streamhash.BuildOption{streamhash.WithWorkers(defaultBuildWorkers())}, + ColdBuildOptions(minLedger, maxLedger, secret)...) builder, berr := streamhash.NewSortedBuilder(ctx, outputPath, total, buildOpts...) if berr != nil { return fmt.Errorf("txhash: create cold index builder at %s: %w", outputPath, berr) @@ -103,9 +95,8 @@ func BuildColdIndex( return nil } -// defaultBuildWorkers is the streamhash block-build parallelism used when -// the caller doesn't override it. NumCPU/2 — see maxMergeLeaves for the -// joint (leaves, workers) sweep that picked it. +// defaultBuildWorkers is the streamhash block-build parallelism. NumCPU/2 — +// see maxMergeLeaves for the joint (leaves, workers) sweep that picked it. func defaultBuildWorkers() int { return max(1, runtime.NumCPU()/2) } @@ -120,13 +111,14 @@ func maxMergeLeaves() int { return max(1, runtime.NumCPU()/2) } -// scanAndValidate sums the per-file header counts, cross-checking each against -// the file length (an understated count would otherwise silently drop a file's -// trailing entries — the merge reads to EOF), and returns the index secret the -// inputs were blinded with. Every input in a window must carry the SAME secret; -// a mismatch means the .bin files were keyed under different secrets (a catalog -// remint or geometry drift between ingest passes), so the build stops rather -// than silently producing an index no query can hit. +// scanAndValidate sums the per-file header counts, cross-checking each +// against the file length (an understated count would otherwise silently +// drop a file's trailing entries — the merge reads to EOF), and returns the +// index secret the inputs were blinded with. Every input in a window must +// carry the SAME secret; a mismatch means the .bin files were keyed under +// different secrets (a catalog remint or geometry drift between ingest +// passes), so the build stops rather than silently producing an index no +// query can hit. func scanAndValidate(inputs []string) (uint64, [stores.SecretLen]byte, error) { var total uint64 var secret [stores.SecretLen]byte @@ -142,17 +134,16 @@ func scanAndValidate(inputs []string) (uint64, [stores.SecretLen]byte, error) { secret = s } else if s != secret { return 0, secret, fmt.Errorf( - "txhash: %s was blinded with a different index secret than %s — inputs must share one secret", - path, inputs[0]) + "txhash: %s is keyed under a different index secret than %s — refusing to build", path, inputs[0]) } total += count } return total, secret, nil } -// scanBinHeader opens path, reads its declared entry count and index secret, -// and verifies its byte size matches that count via coldBinCount (the shared, -// overflow-safe header check). +// scanBinHeader opens path, reads its declared entry count and the index +// secret its keys were blinded with, and verifies its byte size matches the +// count via coldBinCount (the shared, overflow-safe header check). func scanBinHeader(path string) (uint64, [stores.SecretLen]byte, error) { var secret [stores.SecretLen]byte f, err := os.Open(path) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index_test.go index a96f19537..3efe4bb56 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index_test.go @@ -14,8 +14,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/stellar/streamhash" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) @@ -353,33 +351,6 @@ func TestBuildColdIndex_CoverageSpanExceedsBudgetErrors(t *testing.T) { assert.NoFileExists(t, idxPath) } -func TestBuildColdIndex_CallerOptsCannotOverrideFormat(t *testing.T) { - // A caller opt that tries to repoint the cold format (here, bogus - // metadata) must not win — ColdBuildOptions is pinned last, so the - // reader still recovers the real coverage and resolves seqs correctly. - dir := t.TempDir() - entries := makeFixtureEntries(64) - inputs := writeFixtureBins(t, dir, entries) - idxPath := filepath.Join(dir, indexFileName(fixtureBaseChunk)) - err := BuildColdIndex(context.Background(), inputs, idxPath, - fixtureMinLedger(), fixtureMaxLedger(), - streamhash.WithMetadata(EncodeColdMetadata(7, 7, testSecret()))) - require.NoError(t, err) - - r, err := OpenColdReader(idxPath) - require.NoError(t, err) - t.Cleanup(func() { _ = r.Close() }) - - gotMin, gotMax := r.MinLedger(), r.MaxLedger() - assert.Equal(t, fixtureMinLedger(), gotMin, "pinned metadata must win over a caller's WithMetadata") - assert.Equal(t, fixtureMaxLedger(), gotMax) - for _, e := range entries { - got, err := r.Get(e.hash) - require.NoError(t, err) - assert.Equal(t, e.seq, got) - } -} - func TestBuildColdIndex_TruncatedFileErrors(t *testing.T) { // Header OVERSTATES the count (claims more entries than the body // holds): the open-time size cross-check rejects it; no index. @@ -441,6 +412,60 @@ func TestBuildColdIndex_HeaderOverflowRejected(t *testing.T) { assert.NoFileExists(t, idxPath) } +func TestBuildColdIndex_RejectsPreSecretBinLayout(t *testing.T) { + // A .bin in the PRE-SECRET layout: an 8-byte LE count followed by whole + // 20-byte entries, with neither the magic-and-version prelude nor an + // index secret in the header. Such a stale fixture must be rejected, not + // adopted: a lenient build would read entry key bytes as "the secret" and + // produce an index no query can hit. A non-empty one is refused by the + // prelude check, which finds the count's low bytes where the magic + // belongs; an empty one is shorter than a header outright. The size check + // backs both up — the old body measures 8+20c-32 = 20c-24 bytes, and + // 20c-24 mod 20 == 16 for every c >= 1. + require.Equal(t, 32, coldBinHeaderSize, + "premise: today's header is a prelude, a count, and a 16-byte secret") + require.Equal(t, 20, coldBinEntrySize, "premise: today's entry is a 16-byte key plus a uint32 seq") + + for _, tc := range []struct { + name string + count uint64 + wantErr string + }{ + { + name: "entries present", + count: 3, + wantErr: "is not a cold txhash .bin (magic", + }, + { + // 8 bytes total: the header read itself runs off the end. + name: "empty", + count: 0, + wantErr: "read header of", + }, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + var buf bytes.Buffer + var legacyHdr [coldBinCountSize]byte // count only — the pre-secret header + binary.LittleEndian.PutUint64(legacyHdr[:], tc.count) + buf.Write(legacyHdr[:]) + buf.Write(make([]byte, coldBinEntrySize*int(tc.count))) + require.Equal(t, coldBinCountSize+coldBinEntrySize*int(tc.count), buf.Len(), + "premise: the fixture is the old [count][entries] layout, byte for byte") + + p := filepath.Join(dir, "00000005.bin") + require.NoError(t, os.WriteFile(p, buf.Bytes(), 0o600)) + + idxPath := filepath.Join(dir, indexFileName(fixtureBaseChunk)) + err := BuildColdIndex(context.Background(), []string{p}, idxPath, + fixtureMinLedger(), fixtureMaxLedger()) + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + assert.NoFileExists(t, idxPath) + }) + } +} + func TestBuildColdIndex_InputOrderIndependent(t *testing.T) { // The merge sorts globally, so feeding the same files in reversed // order must produce a byte-identical index. diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_merge.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_merge.go index a1c96ddfc..b9c3ea401 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_merge.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_merge.go @@ -149,6 +149,12 @@ func newBatchPool() chan *mergeBatch { // mergeEntry is one source's heap slot: the two big-endian halves of its // current 16-byte key plus the source index (into the readers/streams // slice of the owning merge node). +// +// Deliberately NOT the package's shared hash heap (merge_heap.go), which the +// seal and freeze merges do share: that one compares byte slices, this one two +// uint64s, and this is the tree's hottest loop (30-36M keys/s) rather than a +// background merge. Folding the two together is gated on a benchstat of the +// bench `txindex` cell (bench/txindex.go) holding that baseline. type mergeEntry struct { k0, k1 uint64 idx int diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_reader.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_reader.go index 2859edcfa..9c0fcf899 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_reader.go @@ -59,7 +59,7 @@ func (r *ColdReader) Get(hash [32]byte) (uint32, error) { if r.closed.Load() { return 0, stores.ErrStoreClosed } - rk := stores.BlindKey(r.secret, hash[:ColdKeySize]) + rk := RoutingKey(r.secret, hash[:]) _, payload, err := r.idx.QueryPayload(rk[:]) if err != nil { if errors.Is(err, streamhash.ErrNotFound) { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows.go new file mode 100644 index 000000000..0b961e3b6 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows.go @@ -0,0 +1,72 @@ +package txhash + +// hot_rows.go — the packed per-ledger row codec for the hot tier (design: +// ~/bench-artifacts/txhash-onerow-design.md): ONE txhash-CF row per ledger, +// key rocksdb.EncodeUint32(seq), value = the ledger's full 32-byte tx hashes +// (fee-bump outer AND inner), sorted ascending bytewise, concatenated. A row +// exists for EVERY ledger — empty value for tx-less ones — so the CF itself +// is a dense chain: a missing sequence is corruption, never ambiguity. + +import ( + "bytes" + "fmt" + "slices" +) + +const ( + // rowHashLen is the row stride: full 32-byte hashes, never truncated. + // Hot indexes are exact; truncation happens exactly once, at the cold + // .bin emit (cold_freeze.go). + rowHashLen = 32 + + // maxRowHashes caps one row's hash count at a u16's range. Real ledgers + // carry a few thousand transactions at the extreme; a row claiming more + // is corrupt input, rejected before it can reach a batch (write) or the + // window (warmup). + maxRowHashes = 65535 +) + +// EncodeRow packs one ledger's tx hashes into a row value, sorting hashes in +// place. An intra-ledger duplicate is a loud reject: the write path feeds +// this pre-batch, so corrupt input fails the ledger before it can touch the +// commit batch. The same hash in DIFFERENT ledgers is legal and stored +// verbatim — the cross-ledger fee-bump shape. Empty input encodes the legal +// empty row. +func EncodeRow(hashes [][32]byte) ([]byte, error) { + if len(hashes) > maxRowHashes { + return nil, fmt.Errorf("txhash: ledger has %d hashes (max %d)", len(hashes), maxRowHashes) + } + slices.SortFunc(hashes, func(a, b [32]byte) int { return bytes.Compare(a[:], b[:]) }) + row := make([]byte, 0, len(hashes)*rowHashLen) + var prev [32]byte + for i, h := range hashes { + if i > 0 && h == prev { + return nil, fmt.Errorf("txhash: duplicate hash %x within one ledger", h) + } + prev = h + row = append(row, h[:]...) + } + return row, nil +} + +// validateRow re-checks a row value read back from the CF — 32-byte +// alignment, strictly ascending hashes, the encode-side count bound — and +// returns its hash count. Warmup's tail replay is the caller: ApplyRow +// trusts its input because both feeding paths (EncodeRow at write time, +// validateRow at warmup) have already enforced this shape. +func validateRow(row []byte) (int, error) { + if len(row)%rowHashLen != 0 { + return 0, fmt.Errorf("txhash: row length %d is not a multiple of %d", len(row), rowHashLen) + } + n := len(row) / rowHashLen + if n > maxRowHashes { + return 0, fmt.Errorf("txhash: row has %d hashes (max %d)", n, maxRowHashes) + } + for i := 1; i < n; i++ { + prev, cur := row[(i-1)*rowHashLen:i*rowHashLen], row[i*rowHashLen:(i+1)*rowHashLen] + if bytes.Compare(prev, cur) >= 0 { + return 0, fmt.Errorf("txhash: row hashes not strictly ascending at record %d", i) + } + } + return n, nil +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows_test.go new file mode 100644 index 000000000..0b5f325d3 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows_test.go @@ -0,0 +1,77 @@ +package txhash + +import ( + "bytes" + "encoding/binary" + "slices" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeRow_RoundTripSorted(t *testing.T) { + rng := testRNG(0x11) + hashes := make([][32]byte, 500) + for i := range hashes { + hashes[i] = randHash(rng) + } + row, err := EncodeRow(hashes) + require.NoError(t, err) + require.Len(t, row, 500*rowHashLen) + + n, err := validateRow(row) + require.NoError(t, err) + assert.Equal(t, 500, n) + for i := 1; i < n; i++ { + prev, cur := row[(i-1)*rowHashLen:i*rowHashLen], row[i*rowHashLen:(i+1)*rowHashLen] + require.Negative(t, bytes.Compare(prev, cur), "row not strictly ascending at %d", i) + } + for _, h := range hashes { + require.True(t, rowContains(row, h), "hash %x lost by the codec", h) + } + assert.False(t, rowContains(row, randHash(rng))) +} + +func TestEncodeRow_EmptyLedger(t *testing.T) { + row, err := EncodeRow(nil) + require.NoError(t, err) + assert.Empty(t, row) + n, err := validateRow(row) + require.NoError(t, err) + assert.Zero(t, n) +} + +func TestEncodeRow_RejectsIntraLedgerDuplicate(t *testing.T) { + h1, h2 := [32]byte{1}, [32]byte{2} + _, err := EncodeRow([][32]byte{h1, h2, h1}) + require.ErrorContains(t, err, "duplicate") +} + +func TestEncodeRow_CountBounds(t *testing.T) { + over := make([][32]byte, maxRowHashes+1) + for i := range over { + binary.BigEndian.PutUint32(over[i][:4], uint32(i)) + } + _, err := EncodeRow(over) + require.ErrorContains(t, err, "max") + + row, err := EncodeRow(over[:maxRowHashes]) + require.NoError(t, err, "exactly maxRowHashes is legal") + n, err := validateRow(row) + require.NoError(t, err) + assert.Equal(t, maxRowHashes, n) +} + +func TestValidateRow_RejectsMalformedRows(t *testing.T) { + a, b := [32]byte{1}, [32]byte{2} + + _, err := validateRow(make([]byte, rowHashLen+1)) + require.ErrorContains(t, err, "multiple") + + _, err = validateRow(slices.Concat(b[:], a[:])) + require.ErrorContains(t, err, "ascending") + + _, err = validateRow(slices.Concat(a[:], a[:])) + require.ErrorContains(t, err, "ascending", "an intra-row duplicate is an ordering violation") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store.go index b7b65a288..dffdf5030 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store.go @@ -1,43 +1,154 @@ -// Package txhash holds the hot transaction-hash store (RocksDB-backed, a single -// txhash CF), the cold artifacts (the raw per-chunk .bin file and the +// Package txhash holds the hot transaction-hash tier (one packed row per +// ledger in a RocksDB CF, served by the in-RAM HotIndex over window + sealed +// runs), the cold artifacts (the raw per-chunk .bin file and the // streamhash-MPHF-backed cold index reader/builder), and the read-assembly // layer that resolves a hash across tiers. package txhash import ( + "fmt" + "path/filepath" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" ) -// txhashCF is the single column family holding every (txhash → ledgerSeq) -// entry for the chunk, per the design's hot-tier spec (one `txhash` CF). -const txhashCF = "txhash" +const ( + // txhashCF is the single column family holding ONE packed row per + // ledger: key rocksdb.EncodeUint32(seq), value = the ledger's sorted + // full 32-byte tx hashes concatenated (hot_rows.go). A row exists for + // EVERY committed ledger — empty value for tx-less ones — so the CF is + // a dense chain. It is the durable authority warmup and the freeze + // rebuild from; queries never point-read it (Get is served entirely by + // the HotIndex). + txhashCF = "txhash" -// Entry — one (txhash → ledgerSeq) mapping. -type Entry struct { - Hash [32]byte - LedgerSeq uint32 -} + // rowKeyLen is the packed-row key width (rocksdb.EncodeUint32). Any + // other key length in the CF is the pre-release hash-keyed format — a + // loud open failure, never migrated (see checkRowFormat). + rowKeyLen = 4 -// HotStore — RocksDB-backed hot transaction-hash store. A single txhash CF -// holding the full 32-byte hash as key and the big-endian ledgerSeq as value. -// The CF name and encoding are internal. + // txhashRunDir is the sealed-run directory name inside the chunk's DB + // dir — wiped with the chunk directory like every other chunk artifact + // (RocksDB ignores foreign subdirectories in its dir). Mirrors the + // events facade's hotIndexRunDir. + txhashRunDir = "txhash-runs" +) + +// HotStore is the txhash facade over one chunk's shared hot RocksDB store: +// the write path queues ONE packed-row Put per ledger into the shared commit +// batch, and reads are answered by the HotIndex (window + sealed runs) +// rebuilt from the CF at open. The store is owned by the caller +// (hotchunk.DB); this facade wraps it without owning it. // -// Like every hot store, a HotStore instance is chunk-bound: it -// accumulates exactly one chunk's (txhash → seq) tuples before being -// frozen into the chunk's cold .bin artifact. The store does not itself -// range-check writes (the driver's drain loop already validates every ledger -// sequence against the chunk). +// Concurrency: writes (AddLedgerToBatch + the returned apply hook) are +// single-writer — the hotchunk ingest goroutine; Get is safe alongside that +// writer (the HotIndex publishes through one atomic view pointer). type HotStore struct { - store *rocksdb.Store + store *rocksdb.Store + chunkID chunk.ID + + // hotIdx serves Get and absorbs the post-commit applies. nil on a + // read-only open, which structurally disables queries (Get panics — + // see NewReadOnlyWithStore). + hotIdx *HotIndex +} + +// NewWithStore wraps an ALREADY-OPEN rocksdb.Store as a read-WRITE txhash +// HotStore on the single txhash CF (CFNames()), running the mandatory warmup: +// adopt the routing secret, reopen the sealed runs from the manifest, replay +// the un-sealed packed-row tail into the window, and only then arm sealing. A +// warmup failure returns the error WITHOUT closing the caller-owned store. +// The store must have CFNames() registered. +// +// secret is the chunk's per-index routing secret — the SAME value the cold +// .bin is keyed with (txhash.ColdIndexSecret), because the runs this store +// seals become that .bin's records verbatim. The first open persists it in +// the DB; every later open must present the same one (adoptSecret). +func NewWithStore(store *rocksdb.Store, chunkID chunk.ID, secret [stores.SecretLen]byte) (*HotStore, error) { + hotIdx, err := warmupHotIndex(store, chunkID, secret) + if err != nil { + return nil, fmt.Errorf("txhash: warmup chunk %s: %w", chunkID, err) + } + return &HotStore{store: store, chunkID: chunkID, hotIdx: hotIdx}, nil +} + +// NewReadOnlyWithStore wraps an already-open store as a QUERY-DISABLED +// txhash facade: no warmup runs (the whole point of a read-only open — see +// hotchunk.OpenReadOnly), so there is no HotIndex to answer Get, and calling +// it panics. The two read-only callers need nothing warm: the freeze reads +// the CF and run files through the shared store (FreezeColdFromStore), and +// the startup refiner reads only the ledgers CF. A warmed read-only variant +// is a named prerequisite for #772 coverage-lag serving — it must not land +// by silently serving from a cold index. +func NewReadOnlyWithStore(store *rocksdb.Store, chunkID chunk.ID) *HotStore { + return &HotStore{store: store, chunkID: chunkID} +} + +// AddLedgerToBatch queues seq's ONE packed-row Put into the shared batch b +// and returns the post-commit apply hook: run it only after b commits +// durably — it publishes the row into the HotIndex window (the ONLY thing +// that makes these hashes hot-queryable) and drives sealing. rowBytes must +// be EncodeRow output for exactly this ledger and count its hash count +// (validated against each other and the dense chain by ApplyRow). The +// window RETAINS rowBytes — BatchWriter.Put copies synchronously, so the +// caller must not reuse the slice after this call (the same retained-row +// discipline as the events packed row). A row is queued for EVERY ledger; +// the empty value for a tx-less one keeps the CF's dense chain. +// +// Read-WRITE opens only: hotchunk never routes ingest at a read-only facade +// (IngestLedger rejects it before any queue step). +func (h *HotStore) AddLedgerToBatch( + b *rocksdb.BatchWriter, seq uint32, rowBytes []byte, count int, +) func() error { + b.Put(txhashCF, rocksdb.EncodeUint32(seq), rowBytes) + return func() error { return h.hotIdx.ApplyRow(seq, rowBytes, count) } +} + +// Get returns the ledger sequence the hash was committed in, or +// (0, stores.ErrNotFound) on miss — served entirely from the HotIndex +// (window rows, then sealed runs). Safe alongside the single writer. +// +// A window hit is exact (full 32-byte compare against the raw row); a sealed +// RUN hit is a CANDIDATE, because a run holds blinded keys over the hash's +// first 16 bytes (blind-at-seal). This facade does NOT re-verify it, and +// deliberately so: the read assembly already validates every exact-index +// answer at USE. read_assembly.go's scan(exact=true) fetches the named +// ledger and runs LedgerTransactionViewByHash over all 32 bytes; an absence +// there is ErrInconsistent, which is what raises the operator's +// TxIndexInconsistency metric (adapters/transaction_reader.go). That is the +// same 32-byte check, on a ledger the caller was going to read anyway. +// +// A facade-level re-verify was tried and removed: it cost a point Get of the +// candidate ledger's WHOLE packed row — on a CF built with no bloom filter +// precisely because queries never point-probe it (CFOptions) — for ~97% of +// hot hits (the window covers 256 of a chunk's 10,000 ledgers), squarely on +// the p99 path; and it DOWNGRADED the failure, turning the assembly's hard +// ErrInconsistent (and its metric) into a soft per-index skip. It also could +// not repair the search it guarded: HotIndex.Get returns on the first run +// hit, so a genuine blinded-key collision would still shadow the older run +// holding the real hash. +// +// Panics on a read-only open: txhash queries are disabled there because no +// warmup ran — reaching for them is a programming error, never silently +// answered from a cold index (mirrors hotchunk.DB.Events on a read-only DB). +func (h *HotStore) Get(hash [32]byte) (uint32, error) { + if h.hotIdx == nil { + panic(fmt.Sprintf("txhash: Get on read-only chunk %s: txhash queries are disabled on read-only opens "+ + "(no warmup ran; open read-write for a query-capable facade)", h.chunkID)) + } + return h.hotIdx.Get(hash) } -// NewWithStore wraps an ALREADY-OPEN rocksdb.Store as a txhash HotStore on the -// single txhash CF (CFNames()). The store is owned by the caller — in production, -// hotchunk.DB composes this facade over the shared per-chunk DB and closes that DB -// once. The store must have CFNames() registered. -func NewWithStore(store *rocksdb.Store) *HotStore { - return &HotStore{store: store} +// Shutdown drains the hot index's background seal and releases its run +// handles. hotchunk.DB.Close calls it before closing the shared store so no +// seal goroutine outlives the DB. No-op on a read-only open. +func (h *HotStore) Shutdown() { + if h.hotIdx != nil { + h.hotIdx.Close() + } } // CFNames returns the single txhash CF name this facade owns. Exported so @@ -45,22 +156,26 @@ func NewWithStore(store *rocksdb.Store) *HotStore { func CFNames() []string { return []string{txhashCF} } // CFOptions returns the calibration the hotchunk opener installs on the -// txhash CF alone. The workload is write-once / point-lookup; only it -// earned this tuning — the other CFs ride on RocksDB defaults. +// txhash CF alone. The workload is write-once, seq-keyed packed rows: one +// Put per ledger in ascending key order, read back only by warmup's tail +// scan and the freeze's sequential scan — never point-probed (Get is served +// by the HotIndex). No bloom filter: on 4-byte ascending keys probed only +// by scans it is pure memory/build cost. func CFOptions() map[string]rocksdb.CFOptions { return map[string]rocksdb.CFOptions{ txhashCF: { // 64 MB memtable so one flush produces one ~64 MB SST under - // uniform writes. + // the append-only row stream. WriteBufferMB: 64, MaxWriteBufferNumber: 2, // L0 triggers pinned high + DisableAutoCompactions=true: - // compaction would re-write the same data with no reordering - // benefit (txhash is write-once, random-key, point-lookup). - // The L0 999s match DisableAutoCompactions so even if a future - // flush somehow exceeded the trigger, the engine still - // wouldn't try to compact. NOTE: DisableAutoCompactions and + // compaction would re-write the same data for nothing — + // write-once ascending keys mean L0 SSTs are already + // non-overlapping and every read is a scan. The L0 999s match + // DisableAutoCompactions so even if a future flush somehow + // exceeded the trigger, the engine still wouldn't try to + // compact. NOTE: DisableAutoCompactions and // Tuning.MaxBackgroundJobs are orthogonal — the former turns // compaction off entirely, the latter only caps the thread // budget for background work. @@ -70,46 +185,106 @@ func CFOptions() map[string]rocksdb.CFOptions { DisableAutoCompactions: true, // 64 MB target file matches WriteBufferMB so one memtable - // flush produces one ~64 MB SST — fewer bloom checks per - // query at no-compaction scale. + // flush produces one ~64 MB SST. TargetFileSizeMB: 64, - - // 12 bits/key bloom (~0.4% false-positive) is tighter than - // the standard 10 bits/key because every false positive at - // no-compaction SST count costs a disk seek across many SSTs. - // Only this CF is probed for keys it may not hold. - BloomFilterBitsPerKey: 12, }, } } -// AddEntriesToBatch queues each (txhash → ledgerSeq) Put into b on the txhash -// CF — the building block hotchunk uses to fold the tx-hash writes into the one -// shared per-ledger WriteBatch (decision (a)). Does not commit (caller owns the -// batch). It cannot fail: BatchWriter.Put latches any CF error, surfaced by the -// enclosing Store.Batch, whose lifecycle RLock + checkOpen is the authoritative -// closed-store guard. -func (h *HotStore) AddEntriesToBatch(b *rocksdb.BatchWriter, entries []Entry) { - for _, e := range entries { - b.Put(txhashCF, e.Hash[:], rocksdb.EncodeUint32(e.LedgerSeq)) +// warmupHotIndex rebuilds the read-WRITE facade's HotIndex from the chunk's +// durable state: adopt the routing secret FIRST (a mismatch means every +// sealed run is keyed for someone else — nothing below is worth doing), then +// manifest-listed runs (drain-verified), the un-sealed packed-row tail, the +// stale-format tripwire, and ArmSealing LAST — the replay runs disarmed so a +// failed open writes nothing durable and every retry faces the same state. +// +// Adoption is the only durable write a warmup makes, and only on a chunk +// that has none: it is what makes "first open decides the key" true, so the +// later opens the mismatch check protects have something to compare against. +func warmupHotIndex( + store *rocksdb.Store, chunkID chunk.ID, secret [stores.SecretLen]byte, +) (*HotIndex, error) { + if err := adoptSecret(store, secret); err != nil { + return nil, err + } + runDir := filepath.Join(store.Path(), txhashRunDir) + hotIdx, lastSealed, err := OpenHotIndex(runDir, chunkID.FirstLedger(), rocksdbManifest{store: store}, secret) + if err != nil { + return nil, err + } + if err := replayTail(store, hotIdx, max(lastSealed+1, chunkID.FirstLedger())); err != nil { + hotIdx.Close() + return nil, err + } + if err := checkRowFormat(store); err != nil { + hotIdx.Close() + return nil, err + } + hotIdx.ArmSealing() + return hotIdx, nil +} + +// replayTail replays the un-sealed packed rows (ledgers from the sealed +// frontier's successor onward) into the window: the same validateRow + +// ApplyRow pair the write path runs, with the row bytes COPIED out of the +// borrowed iterator buffer (the window retains them). ApplyRow's anchored +// dense-chain check makes a gap, disorder, or wrong first ledger a loud open +// failure. Sealed rows are never touched — their CRC-verified runs are the +// authority — so warmup time scales with the tail, not the chunk. +func replayTail(store *rocksdb.Store, hotIdx *HotIndex, from uint32) error { + for entry, ierr := range store.IterateRange(txhashCF, rocksdb.EncodeUint32(from), nil) { + if ierr != nil { + return fmt.Errorf("txhash: warmup scan %s: %w", txhashCF, ierr) + } + if len(entry.Key) != rowKeyLen { + return fmt.Errorf("txhash: warmup: %d-byte key in %s: stale pre-release txhash format", + len(entry.Key), txhashCF) + } + seq := rocksdb.DecodeUint32(entry.Key) + count, verr := validateRow(entry.Value) + if verr != nil { + return fmt.Errorf("txhash: warmup row %d: %w", seq, verr) + } + if aerr := hotIdx.ApplyRow(seq, append([]byte(nil), entry.Value...), count); aerr != nil { + return aerr + } } + return nil } -// Get returns the ledger sequence the hash was committed in, or -// (0, stores.ErrNotFound) on miss. -func (h *HotStore) Get(hash [32]byte) (uint32, error) { - // The value is four bytes and is decoded before the pinned block is - // released, so this probe — one per hot chunk per lookup — copies nothing. - var seq uint32 - found, err := h.store.GetPinned(txhashCF, hash[:], func(v []byte) error { - seq = rocksdb.DecodeUint32(v) - return nil - }) +// checkRowFormat is the open tripwire against the pre-release hash-keyed +// txhash format (one 32-byte-hash-keyed Put per tx): the packed-row CF only +// ever holds 4-byte seq keys, and under byte-lex ordering a 32-byte key +// sorts after any 4-byte key sharing its prefix, so a stale CF's LAST key +// is (with the tail scan's own key-length check covering keys past the +// frontier) reliably long. There is no migration — a stale chunk is a loud +// open failure, never silently reindexed. +func checkRowFormat(store *rocksdb.Store) error { + last, found, err := store.LastKey(txhashCF) if err != nil { - return 0, err + return fmt.Errorf("txhash: warmup last-key check: %w", err) } - if !found { - return 0, stores.ErrNotFound + if found && len(last) != rowKeyLen { + return fmt.Errorf("txhash: %d-byte key in %s: stale pre-release txhash format (no migration; "+ + "re-ingest the chunk)", len(last), txhashCF) } - return seq, nil + return nil +} + +// txhashSecretKey holds the chunk DB's adopted txhash routing secret in the +// default CF, beside the run manifest (hotindex_seal.go). One key per store: +// the events engine keeps its own under its own name. +var txhashSecretKey = []byte("txhash:secret") //nolint:gochecknoglobals // fixed key + +// adoptSecret and requireStoreSecret thread this engine's key into the +// shared secret protocol (stores/internal/runset/secret.go), which carries +// the rule, the errors, and the reasoning both engines run on. There is no +// migration and no re-keying: a re-minted catalog secret means the chunk +// must be re-ingested (pre-release posture, the same as checkRowFormat's). +func adoptSecret(store *rocksdb.Store, secret [stores.SecretLen]byte) error { + return runset.AdoptSecret(store, txhashSecretKey, "txhash", secret[:]) +} + +func requireStoreSecret(store *rocksdb.Store, secret [stores.SecretLen]byte) error { + return runset.RequireSecret(store, txhashSecretKey, secret[:]) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store_test.go index 63697b675..0129156af 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store_test.go @@ -2,10 +2,10 @@ package txhash import ( "bytes" + "context" + "path/filepath" "sync" - "sync/atomic" "testing" - "time" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -13,6 +13,7 @@ import ( supportlog "github.com/stellar/go-stellar-sdk/support/log" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) @@ -25,25 +26,10 @@ func silentLogger() *supportlog.Entry { return log } -// txhashFor builds a distinct 32-byte hash from a (high-nibble, tag) pair — -// a convenient generator of many distinct keys for the single txhash CF. -func txhashFor(nibble, tag byte) [32]byte { - var h [32]byte - h[0] = nibble << 4 - h[1] = tag - for i := 2; i < 32; i++ { - h[i] = byte(i) - } - return h -} - -func openTestHotStore(t *testing.T) *HotStore { - t.Helper() - s, _ := openTestHotStoreAt(t, t.TempDir()) - return s -} - -func openTestHotStoreAt(t *testing.T, path string) (*HotStore, *rocksdb.Store) { +// openBareStore opens the shared-store shape (txhash CF registered, facade +// NOT composed) — the freeze's view of a chunk DB, and the seeding handle +// for corruption fixtures. +func openBareStore(t *testing.T, path string) *rocksdb.Store { t.Helper() store, err := rocksdb.New(rocksdb.Config{ Path: path, @@ -53,160 +39,303 @@ func openTestHotStoreAt(t *testing.T, path string) (*HotStore, *rocksdb.Store) { }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) - return NewWithStore(store), store + return store } -func TestHotStore_AddGetRoundTrip(t *testing.T) { - s := openTestHotStore(t) - - h := txhashFor(0xa, 1) - - // Missing hash. - _, err := s.Get(h) - require.ErrorIs(t, err, stores.ErrNotFound) - - // Single-entry AddEntries. - require.NoError(t, addEntries(s, []Entry{{Hash: h, LedgerSeq: 12345}})) - got, err := s.Get(h) +// openPackedStoreAt opens (or, on a second call for the same path, REOPENS — +// the warmup path) the chunk-bound read-write facade, shrinking the seal +// window so tests exercise seals with small inputs. +func openPackedStoreAt(t *testing.T, path string, chunkID chunk.ID, sealEvery int) (*HotStore, *rocksdb.Store) { + t.Helper() + store := openBareStore(t, path) + s, err := NewWithStore(store, chunkID, testBinSecret) require.NoError(t, err) - assert.Equal(t, uint32(12345), got) + s.hotIdx.sealEvery = sealEvery + t.Cleanup(s.Shutdown) + return s, store +} - // Overwrite via a second AddEntries. - require.NoError(t, addEntries(s, []Entry{{Hash: h, LedgerSeq: 67890}})) - got, err = s.Get(h) +// ingestLedger commits one ledger through the production write shape: +// EncodeRow → one batch Put via AddLedgerToBatch → post-commit apply. +func ingestLedger(t *testing.T, s *HotStore, seq uint32, hashes [][32]byte) { + t.Helper() + row, err := EncodeRow(hashes) require.NoError(t, err) - assert.Equal(t, uint32(67890), got) + var apply func() error + require.NoError(t, s.store.Batch(func(b *rocksdb.BatchWriter) error { + apply = s.AddLedgerToBatch(b, seq, row, len(hashes)) + return nil + })) + require.NoError(t, apply()) +} - // Empty slice — no-op, no error. - require.NoError(t, addEntries(s, nil)) - require.NoError(t, addEntries(s, []Entry{})) +// settle forces any pending background seal to complete and fold in +// (manifest write + view swap), so a test's durable state is deterministic. +func settle(t *testing.T, s *HotStore) { + t.Helper() + require.NoError(t, s.hotIdx.reapSeal(true)) } -func TestHotStore_ManyDistinctKeys(t *testing.T) { - s := openTestHotStore(t) +func TestHotStore_WriteGetRoundTrip(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + s, _ := openPackedStoreAt(t, t.TempDir(), chunkID, windowLedgers) - const n = 16 - entries := make([]Entry, n) - for i := range n { - entries[i] = Entry{ - Hash: txhashFor(byte(i), 1), - LedgerSeq: uint32(i) * 100, - } + rng := testRNG(1) + byLedger := map[uint32][][32]byte{ + first: {randHash(rng), randHash(rng)}, + first + 1: nil, // tx-less ledger: the dense chain's empty row + first + 2: {randHash(rng)}, + } + for seq := first; seq <= first+2; seq++ { + ingestLedger(t, s, seq, byLedger[seq]) } - require.NoError(t, addEntries(s, entries)) - for i := range n { - got, err := s.Get(entries[i].Hash) - require.NoError(t, err, "key %d", i) - assert.Equal(t, uint32(i)*100, got, "key %d", i) + for seq, hashes := range byLedger { + for _, h := range hashes { + got, err := s.Get(h) + require.NoError(t, err) + assert.Equal(t, seq, got) + } } + _, err := s.Get(randHash(rng)) + require.ErrorIs(t, err, stores.ErrNotFound) } -func TestHotStore_AddEntriesMultiple(t *testing.T) { - s := openTestHotStore(t) +// TestHotStore_AnchorsAtChunkFirstLedger pins the open-time anchoring fix: a +// fresh chunk's engine demands exactly chunkID.FirstLedger() as its first +// row — a mis-sequenced first ledger cannot silently anchor the chain. +func TestHotStore_AnchorsAtChunkFirstLedger(t *testing.T) { + chunkID := chunk.ID(3) + first := chunkID.FirstLedger() + s, _ := openPackedStoreAt(t, t.TempDir(), chunkID, windowLedgers) - entries := []Entry{ - {Hash: txhashFor(0x0, 1), LedgerSeq: 10}, - {Hash: txhashFor(0x4, 1), LedgerSeq: 20}, - {Hash: txhashFor(0x8, 1), LedgerSeq: 30}, - {Hash: txhashFor(0xc, 1), LedgerSeq: 40}, - {Hash: txhashFor(0xf, 1), LedgerSeq: 50}, - } - require.NoError(t, addEntries(s, entries)) + row, err := EncodeRow(nil) + require.NoError(t, err) + var apply func() error + require.NoError(t, s.store.Batch(func(b *rocksdb.BatchWriter) error { + apply = s.AddLedgerToBatch(b, first+1, row, 0) + return nil + })) + require.Error(t, apply(), "a fresh chunk must reject any first ledger but FirstLedger()") - for _, e := range entries { - got, err := s.Get(e.Hash) - require.NoError(t, err) - assert.Equal(t, e.LedgerSeq, got) + ingestLedger(t, s, first, nil) // the correct anchor is accepted +} + +// TestHotStore_WarmupReplaysTailAndServes is the store-level warmup gate: a +// chunk-end restart (runs sealed + un-sealed tail, including a discarded +// in-flight seal — the crash-inherited-tail shape) replays the tail through +// NewWithStore and serves every hash. +func TestHotStore_WarmupReplaysTailAndServes(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + path := t.TempDir() + s, store := openPackedStoreAt(t, path, chunkID, 8) + + rng := testRNG(2) + ref := map[[32]byte]uint32{} + seq := first + addLedgers := func(s *HotStore, n int) { + for range n { + hashes := make([][32]byte, rng.IntN(5)+1) + for i := range hashes { + hashes[i] = randHash(rng) + ref[hashes[i]] = seq + } + ingestLedger(t, s, seq, hashes) + seq++ + } } - // Overwrite via a second multi-entry AddEntries. - updated := make([]Entry, len(entries)) - for i, e := range entries { - updated[i] = Entry{Hash: e.Hash, LedgerSeq: e.LedgerSeq + 1000} + // 20 ledgers: seals at 8 and 16, tail of 4. NO settle before close: an + // in-flight seal's result is discarded by Shutdown, so the reopen may + // inherit a tail larger than the window remainder plus an orphan run + // file — exactly the crash shape warmup must absorb. + addLedgers(s, 20) + s.Shutdown() + require.NoError(t, store.Close()) + + s2, store2 := openPackedStoreAt(t, path, chunkID, 8) + for h, want := range ref { + got, err := s2.Get(h) + require.NoError(t, err, "hash %x lost across restart", h) + assert.Equal(t, want, got) } - require.NoError(t, addEntries(s, updated)) - for _, e := range updated { - got, err := s.Get(e.Hash) + _, err := s2.Get(randHash(rng)) + require.ErrorIs(t, err, stores.ErrNotFound) + + // The dense chain continues exactly where the committed rows end. + addLedgers(s2, 3) + settle(t, s2) + s2.Shutdown() + require.NoError(t, store2.Close()) + + // Second restart from the settled state (manifest-named runs + tail). + s3, _ := openPackedStoreAt(t, path, chunkID, 8) + for h, want := range ref { + got, err := s3.Get(h) require.NoError(t, err) - assert.Equal(t, e.LedgerSeq, got) + assert.Equal(t, want, got) } } -func TestHotStore_PostCloseOps(t *testing.T) { - s, store := openTestHotStoreAt(t, t.TempDir()) - require.NoError(t, store.Close()) +// TestHotStore_SecretAdoption is this engine's THREADING case for the shared +// secret protocol: NewWithStore hands the caller's secret to +// runset.AdoptSecret against the txhash key, so the chunk's own sealed runs +// stay probeable across a reopen and a foreign secret is a loud open failure. +// The protocol's own semantics (first-open-persists, zero refused, no durable +// state on rejection) are the table test beside it, runset/secret_test.go. +func TestHotStore_SecretAdoption(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + path := t.TempDir() - h := txhashFor(0x5, 1) - require.ErrorIs(t, addEntries(s, []Entry{{Hash: h, LedgerSeq: 1}}), rocksdb.ErrStoreClosed) - _, err := s.Get(h) - require.ErrorIs(t, err, rocksdb.ErrStoreClosed) + s, store := openPackedStoreAt(t, path, chunkID, 2) + hash := randHash(testRNG(0x5EC)) + ingestLedger(t, s, first, [][32]byte{hash}) + ingestLedger(t, s, first+1, nil) + settle(t, s) // a sealed run now exists, keyed under testBinSecret + s.Shutdown() + require.NoError(t, store.Close()) - require.ErrorIs(t, addEntries(s, nil), rocksdb.ErrStoreClosed) - require.ErrorIs(t, addEntries(s, []Entry{}), rocksdb.ErrStoreClosed) + // Same secret: adopted, and the sealed run still answers. + s2, store2 := openPackedStoreAt(t, path, chunkID, 2) + got, err := s2.Get(hash) + require.NoError(t, err) + require.Equal(t, first, got) + s2.Shutdown() + require.NoError(t, store2.Close()) + + // Different secret: loud open failure, before any run is touched. + other := testBinSecret + other[0] ^= 0xFF + store3 := openBareStore(t, path) + _, err = NewWithStore(store3, chunkID, other) + require.ErrorContains(t, err, "keyed under a different routing secret") + require.ErrorContains(t, err, "re-ingest the chunk") + require.NoError(t, store3.Close()) + + // The failed open changed nothing: the original secret still opens. + s4, _ := openPackedStoreAt(t, path, chunkID, 2) + got, err = s4.Get(hash) + require.NoError(t, err) + require.Equal(t, first, got) } -func TestHotStore_GracefulCloseAndReopenRoundTrips(t *testing.T) { +// TestFreezeColdFromStore_RejectsForeignSecret: the freeze may not key a .bin +// with a secret the chunk's sealed runs were not blinded under — their +// records are copied into the file verbatim, so a mismatch would mix two +// keyspaces into one artifact. +func TestFreezeColdFromStore_RejectsForeignSecret(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() path := t.TempDir() - first, firstStore := openTestHotStoreAt(t, path) - for n := range 16 { - require.NoError(t, addEntries(first, []Entry{ - {Hash: txhashFor(byte(n), 1), LedgerSeq: uint32(n) + 1}, - })) - } - require.NoError(t, firstStore.Close()) + s, store := openPackedStoreAt(t, path, chunkID, 2) + ingestLedger(t, s, first, [][32]byte{randHash(testRNG(0xF00))}) + ingestLedger(t, s, first+1, nil) + settle(t, s) - second, _ := openTestHotStoreAt(t, path) + other := testBinSecret + other[15] ^= 0x01 + _, err := FreezeColdFromStore( + context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), other) + require.ErrorContains(t, err, "keyed under a different routing secret") - for n := range 16 { - got, err := second.Get(txhashFor(byte(n), 1)) - require.NoError(t, err) - assert.Equal(t, uint32(n)+1, got) - } + // The DB's own secret freezes fine. + _, err = FreezeColdFromStore( + context.Background(), chunkID, store, filepath.Join(t.TempDir(), "ok.bin"), testBinSecret) + require.NoError(t, err) } -func TestHotStore_ConcurrentOpsAndCloseRaceFree(t *testing.T) { - s, store := openTestHotStoreAt(t, t.TempDir()) - // Pre-populate a spread of distinct keys. - pre := make([]Entry, 16) - for n := range 16 { - pre[n] = Entry{Hash: txhashFor(byte(n), 1), LedgerSeq: uint32(n)} - } - require.NoError(t, addEntries(s, pre)) +// TestHotStore_ReadOnlyGetPanics pins the read-only contract: no warmup runs +// on a read-only open, so txhash queries are structurally disabled — loudly, +// never a silent wrong answer from a cold index. +func TestHotStore_ReadOnlyGetPanics(t *testing.T) { + chunkID := chunk.ID(0) + store := openBareStore(t, t.TempDir()) + s := NewReadOnlyWithStore(store, chunkID) + + defer func() { + r := recover() + require.NotNil(t, r, "Get on a read-only facade must panic") + require.Contains(t, r, "txhash queries are disabled on read-only opens") + }() + _, _ = s.Get([32]byte{1}) +} + +// TestHotStore_StaleFormatTripwire: a pre-release hash-keyed row (32-byte +// key) fails the open loudly — there is no migration. +func TestHotStore_StaleFormatTripwire(t *testing.T) { + chunkID := chunk.ID(0) + store := openBareStore(t, t.TempDir()) + stale := randHash(testRNG(3)) + require.NoError(t, store.Put(txhashCF, stale[:], rocksdb.EncodeUint32(chunkID.FirstLedger()))) + _, err := NewWithStore(store, chunkID, testBinSecret) + require.ErrorContains(t, err, "stale pre-release txhash format") +} + +// TestHotStore_WarmupRejectsDenseGap: a missing row inside the tail is +// corruption, not data — the open fails loudly and identically on retry. +func TestHotStore_WarmupRejectsDenseGap(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + store := openBareStore(t, t.TempDir()) + row, err := EncodeRow([][32]byte{{1}}) + require.NoError(t, err) + require.NoError(t, store.Batch(func(b *rocksdb.BatchWriter) error { + b.Put(txhashCF, rocksdb.EncodeUint32(first), row) + b.Put(txhashCF, rocksdb.EncodeUint32(first+2), row) // gap at first+1 + return nil + })) + + _, err = NewWithStore(store, chunkID, testBinSecret) + require.ErrorContains(t, err, "dense chain") +} + +// TestHotStore_ConcurrentGetsDuringIngest exercises Get against live applies +// and seal publishes at the store level. Run with -race. +func TestHotStore_ConcurrentGetsDuringIngest(t *testing.T) { + chunkID := chunk.ID(0) + first := chunkID.FirstLedger() + s, _ := openPackedStoreAt(t, t.TempDir(), chunkID, 8) + + rng := testRNG(4) + known := randHash(rng) + ingestLedger(t, s, first, [][32]byte{known}) + + stop := make(chan struct{}) var wg sync.WaitGroup - var stop atomic.Bool - const workers = 4 - for w := range workers { - wg.Go(func() { - for i := byte(0); !stop.Load(); i++ { - _ = addEntries(s, []Entry{ - {Hash: txhashFor(i%16, byte(w+5)), LedgerSeq: uint32(i)}, - }) - } - }) + for r := range 3 { + seed := uint64(r + 100) wg.Go(func() { - for i := byte(0); !stop.Load(); i++ { - _, _ = s.Get(txhashFor(i%16, 1)) + prng := testRNG(seed) + for { + select { + case <-stop: + return + default: + } + _, _ = s.Get(known) + _, _ = s.Get(randHash(prng)) } }) } - time.Sleep(50 * time.Millisecond) - require.NoError(t, store.Close()) - stop.Store(true) + ref := map[[32]byte]uint32{known: first} + for i := range uint32(40) { + h := randHash(rng) + ref[h] = first + 1 + i + ingestLedger(t, s, first+1+i, [][32]byte{h}) + } + settle(t, s) + close(stop) wg.Wait() - postClose := []Entry{{Hash: txhashFor(0x1, 1), LedgerSeq: 1}} - require.ErrorIs(t, addEntries(s, postClose), rocksdb.ErrStoreClosed) -} - -// addEntries commits entries through AddEntriesToBatch in one batch — the -// production write shape, reduced to a test seeding call. -func addEntries(h *HotStore, entries []Entry) error { - return h.store.Batch(func(b *rocksdb.BatchWriter) error { - h.AddEntriesToBatch(b, entries) - return nil - }) + for h, want := range ref { + got, err := s.Get(h) + require.NoError(t, err) + assert.Equal(t, want, got) + } } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go new file mode 100644 index 000000000..54facf1bb --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go @@ -0,0 +1,275 @@ +package txhash + +// hotindex.go — the packed-row hot tier: the hot chunk's (tx hash → ledger +// seq) index with a hard-bounded live set, replacing the one-Put-per-hash +// mirror CF (design: ~/bench-artifacts/txhash-onerow-design.md). A +// deliberate simplification-clone of the events Sorted-Run Tier +// (event/hotindex.go) minus its three hardest parts — no dense overlay, no +// merge tier, no retired handles: a txhash probe is a single-hit +// bloom-routed point lookup, not a per-term union, so runs never need +// displacing. +// +// Structure (all published through ONE atomic view pointer): +// +// - WINDOW: the last ≤sealEvery ledgers' packed rows, RETAINED as-is (the +// commit batch already allocates each row; keeping the slice is free). +// A row is one ledger's full 32-byte hashes, sorted, concatenated — +// directly binary-searchable, no accel arrays. +// - RUNS: immutable flat files beside the chunk DB (TXHRUN02 format: +// 20-byte BLINDED-key‖seq records, key-sorted, CRC64-framed), sealed +// from the window every sealEvery ledgers on a background goroutine, +// each with an in-RAM bloom + page ladder built in the seal's write pass +// and rebuilt from the verified file bytes at warmup (one funnel: +// runRouting, hotindex_seal.go). +// +// Rows are RAW, runs are BLINDED (blind-at-seal, hotindex_seal.go): a window +// probe is a full 32-byte hash compare and answers exactly, while a run +// probe compares the blinded key and answers a CANDIDATE ledger. The +// candidate is validated at USE, not here and not in the facade: the read +// assembly extracts the tx from the named ledger by its full hash and calls +// an absence ErrInconsistent (read_assembly.go, HotStore.Get's doc). The row +// chain is DENSE — one row per ledger, empty value for tx-less ledgers — so +// the CF is self-validating: a gap is corruption, never ambiguity. +// +// Concurrency: single-writer (the ingest goroutine calls ApplyRow; seals run +// on ONE background goroutine owned here); readers Load the view and use +// immutable state lock-free. The publish-run-before-trim-window ordering +// guarantees a hash is always findable in window ∪ runs. + +import ( + "bytes" + "fmt" + "os" + "sort" + "sync" + "sync/atomic" + + "github.com/cespare/xxhash/v2" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/durable" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/bloom" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" +) + +// windowLedgers is the seal cadence: rows retained before a background seal +// folds them into a run. 256 ledgers ≈ 2.5min at 600ms cadence — the same +// cadence the events tier landed with. +const windowLedgers = 256 + +// hotIndexView is the immutable read view: everything a probe needs, swapped +// atomically as ledgers apply and seals publish. +type hotIndexView struct { + // rows[i] is the i-th UNSEALED ledger row (oldest first): the retained + // packed-row bytes. Never mutated after publish. + rows []windowRow + // runs are the live sealed runs, oldest first. + runs []*sealedRun +} + +// windowRow is one retained per-ledger packed row: the ledger it belongs to +// and its EncodeRow bytes (32-byte stride, strictly ascending). +type windowRow struct { + seq uint32 + bytes []byte +} + +// sealedRun is one live run file with its in-RAM routing state and an open +// handle for concurrent ReadAt lookups. first/last is the ledger range the +// run covers; records is the payload record count — observed by the write +// pass at seal, drain-verified at warmup (it bounds the final page's pread). +type sealedRun struct { + path string + bloom bloom.Filter + ladder [][ColdKeySize]byte // the blinded key of each page's first record + file *os.File + first uint32 + last uint32 + records int +} + +// HotIndex is the engine. One per open hot chunk (read-write opens). +type HotIndex struct { + dir string // run-file directory (inside the chunk's DB dir) + view atomic.Pointer[hotIndexView] + + // secret keys every sealed run this engine writes and probes (blind at + // seal). It is fixed for the life of the chunk DB — persisted there and + // adoption-checked before the engine is constructed (hot_store.go) — so + // runs written across restarts stay probeable and the freeze can copy + // them into a .bin declaring the same secret. + secret [stores.SecretLen]byte + + // Writer-owned state (single-writer contract). sealEvery defaults to + // windowLedgers; tests shrink it to exercise seals with small inputs. The + // seal trigger compares it against len(view.rows), which IS the window's + // ledger count — the row chain is dense, one row per ApplyRow (see the + // file doc), so no separate counter can drift from it. + sealEvery int + sealSeq int + pendingSeal chan sealResult // capacity 1: at most one seal in flight + sealInFlight bool + manifest runset.Manifest + + // fsyncDir runs at the tail of the background seal, making the fresh + // run's dirent durable BEFORE the hand-back can reach reapSeal: PutRuns + // rides a synced write, so without the barrier a crash could leave the + // manifest durably naming a run whose dirent was never journaled — an + // unrecoverable warmup failure. Production is durable.FsyncDir; a + // vanished dir counts as success there, which is tolerable because the + // dir only vanishes at chunk teardown, taking the manifest with it — + // any surviving manifest naming the vanished run fails the next warmup + // loudly. Tests swap in a recorder to pin the barrier-before-PutRuns + // order — the same seam as the events twin. + fsyncDir func(dir string) error + + // sealArmed gates the seal trigger. The engine starts DISARMED so an + // open's warmup replay is pure in-memory reconstruction — no run files, + // no manifest writes — and the index's durable state cannot change + // before the open has been judged valid. A failed open is then + // identically retryable, and the open tripwire's inputs can never be + // moved by the very open it is guarding. Warmup arms via ArmSealing + // only after verification passes. + sealArmed bool + + // expectedNext pins the dense row chain: ApplyRow accepts exactly this + // sequence, loudly rejecting anything else. Zero = unanchored (a fresh + // NewHotIndex engine's first row anchors the chain; OpenHotIndex anchors + // at max(lastSealed+1, chunk first ledger)). + expectedNext uint32 + + // closed makes Close a one-shot: it drains the in-flight seal (if any) + // and releases every run handle this index still owns. + closed sync.Once +} + +// sealResult is the background sealer's hand-back. An errored result carries +// no resources (run is nil whenever err is set) — the same contract as the +// events twin: sealWindow returns no run on failure, and a failed dirent +// barrier disposes of the just-sealed run before handing back (startSeal). +type sealResult struct { + run *sealedRun + rows int // number of window rows the seal covered + lastSeq uint32 // highest ledger the seal covered (manifest frontier) + err error +} + +// NewHotIndex creates the engine for a fresh chunk. dir is created. The +// manifest (rocksdbManifest in production, hotindex_seal.go) is the +// crash-recovery authority on which runs are live — see runset.Manifest. +func NewHotIndex(dir string, manifest runset.Manifest, secret [stores.SecretLen]byte) (*HotIndex, error) { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("txhash: hotindex mkdir %s: %w", dir, err) + } + h := &HotIndex{ + dir: dir, + secret: secret, + pendingSeal: make(chan sealResult, 1), + manifest: manifest, + fsyncDir: durable.FsyncDir, + sealEvery: windowLedgers, + } + h.view.Store(&hotIndexView{}) + return h, nil +} + +// ArmSealing enables the background seal machinery (see sealArmed): sealing +// rights follow validation, the same rule the events engine applies. The +// first armed ApplyRow that finds the window over-full seals ALL of it +// (startSeal covers the whole window), so a backlog replayed while disarmed +// drains in one background seal — no separate drain path. Writer goroutine +// only. +func (h *HotIndex) ArmSealing() { h.sealArmed = true } + +// ApplyRow ingests one committed ledger's packed row: rowBytes is the SAME +// slice the commit batch carried (retained, not copied — the caller must not +// reuse it), count its hash count. The row must be EncodeRow/validateRow +// clean — both feeding paths (write, warmup replay) validate before +// applying. Writer goroutine only. +func (h *HotIndex) ApplyRow(seq uint32, rowBytes []byte, count int) error { + // Fold any completed seal FIRST so its run becomes visible before the + // window trims past its ledgers on this call's publish. + if err := h.reapSeal(false); err != nil { + return err + } + if len(rowBytes) != count*rowHashLen { + return fmt.Errorf("txhash: hotindex row %d: %d bytes for %d hashes", seq, len(rowBytes), count) + } + if seq == 0 || (h.expectedNext != 0 && seq != h.expectedNext) { + return fmt.Errorf("txhash: hotindex row %d breaks the dense chain (want %d)", seq, h.expectedNext) + } + + old := h.view.Load() + rows := make([]windowRow, 0, len(old.rows)+1) + rows = append(rows, old.rows...) + rows = append(rows, windowRow{seq: seq, bytes: rowBytes}) + h.view.Store(&hotIndexView{rows: rows, runs: old.runs}) + h.expectedNext = seq + 1 + + if h.sealArmed && len(rows) >= h.sealEvery && !h.sealInFlight { + h.startSeal(rows) + } + return nil +} + +// Get returns the ledger holding hash, or stores.ErrNotFound. Window rows +// are probed newest→oldest (raw, exact), then runs newest→oldest (by blinded +// key) — recent transactions are the hot hits. Correctness is +// order-independent: a hash lives in one ledger, and for the legal +// cross-ledger duplicate any containing ledger is a correct answer +// (GetTransaction verifies against the ledger itself). Concurrent-safe. +// +// A run answer is a candidate (16-byte blinded-key compare), validated where +// the ledger is read — see HotStore.Get. The blinding is deferred to the run +// loop so a window hit and a miss on a run-less chunk pay nothing for it. +func (h *HotIndex) Get(hash [32]byte) (uint32, error) { + v := h.view.Load() + for i := len(v.rows) - 1; i >= 0; i-- { + if rowContains(v.rows[i].bytes, hash) { + return v.rows[i].seq, nil + } + } + if len(v.runs) > 0 { + rk := RoutingKey(h.secret, hash[:]) + for i := len(v.runs) - 1; i >= 0; i-- { + seq, ok, err := v.runs[i].lookup(rk) + if err != nil { + return 0, err + } + if ok { + return seq, nil + } + } + } + return 0, stores.ErrNotFound +} + +// Close drains any in-flight seal (discarding its result — warmup rebuilds +// deterministically) and releases every run handle this index still owns. +// Runs are never displaced (no merge tier), so the live view's handles are +// all there are. One-shot; runs after ingestion and readers have stopped. +func (h *HotIndex) Close() { + h.closed.Do(func() { + if h.sealInFlight { + res := <-h.pendingSeal + h.sealInFlight = false + if res.err == nil { + res.run.close() + } + } + closeRuns(h.view.Load().runs) + }) +} + +// fp64 is the bloom fingerprint of a run's blinded key. +func fp64(key [ColdKeySize]byte) uint64 { return xxhash.Sum64(key[:]) } + +// rowContains binary-searches one packed row (32-byte stride, strictly +// ascending) for hash. +func rowContains(row []byte, hash [32]byte) bool { + n := len(row) / rowHashLen + i := sort.Search(n, func(k int) bool { + return bytes.Compare(row[k*rowHashLen:(k+1)*rowHashLen], hash[:]) >= 0 + }) + return i < n && bytes.Equal(row[i*rowHashLen:(i+1)*rowHashLen], hash[:]) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go new file mode 100644 index 000000000..452fe82b3 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go @@ -0,0 +1,595 @@ +package txhash + +// hotindex_seal.go — the packed-row tier's background half: sealing the +// window into run files, the run lookup path (bloom → ladder → pread → +// verify), the run-list manifest, and manifest-anchored warmup. One +// background job runs at a time (writer-owned lifecycle); results fold back +// in on the writer goroutine (reapSeal). +// +// Run file format (/seal-%06d.run) — one sealed window, every +// (blinded key, seq) record of its ledgers sorted by blinded key: +// +// header (40B): magic "TXHRUN02" ‖ count u64 LE ‖ firstSeq u32 LE ‖ +// lastSeq u32 LE ‖ crc64-ECMA(payload) LE, patched at +// finish ‖ 8 reserved zero bytes (ignored on read) +// payload: count × 20B records BlindKey(secret, hash[:16])[16] ‖ +// seq u32 LE (equal keys: ledger order) +// +// BLIND-AT-SEAL. A record is byte-for-byte the cold .bin's entry +// (cold_bin.go): sealing is where a hash stops being raw and becomes the +// routing key the cold artifact stores, so the freeze streams every sealed +// run into the .bin verbatim instead of re-keying and re-sorting the whole +// chunk. The durable ROWS stay raw — they are the verification source +// (HotStore.Get re-checks a run hit against the ledger's row) and the tail +// source. The secret is fixed per chunk DB, persisted beside the manifest +// and adoption-checked at every open (hot_store.go), so a run's keys can +// never disagree with the .bin the freeze writes from them. +// +// Durability ladder: buffered payload → flush → patch CRC → fsync file +// (writeRun) → fsync the run DIRECTORY (the h.fsyncDir barrier at the seal +// goroutine's tail, startSeal) → only then may the manifest name the run +// (reapSeal) → only then does the view swap trim the sealed rows. Until the +// manifest lists a file it is an unreferenced orphan warmup sweeps, so no +// temp+rename dance is needed. + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "hash/crc64" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/bloom" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" +) + +// ─────────────────────────── run format ─────────────────────────── + +const ( + // runMagic heads every run file; a version bump changes the digits. + runMagic = "TXHRUN02" + + // runMagicRaw is the pre-release format this one replaced: 36-byte + // records holding RAW 32-byte hashes. There is no migration — a chunk + // carrying one is a loud open failure (readRunHeader), the same posture + // as the hash-keyed CF tripwire (checkRowFormat). + runMagicRaw = "TXHRUN01" + + // Header field offsets. The fields end at byte 32; the header is padded + // to its fixed 40-byte size (the format's payload offset) with reserved + // zeros. + runCountOff = 8 + runFirstOff = 16 + runLastOff = 20 + runCRCOff = 24 + runHeaderLen = 40 + + // runRecordLen is one payload record — deliberately the cold .bin's + // entry width (blinded key ‖ seq u32 LE), because it holds the same + // bytes: the freeze copies a sealed run's records straight into the + // .bin. + runRecordLen = coldBinEntrySize + + // pageRecords is the ladder's page: the most whole records inside a + // 4KiB I/O budget (204 × 20B = 4080B). Pages are record-aligned so the + // in-page binary search strides whole records; each page's first record + // key routes a probe to ONE page pread. + pageRecords = 4096 / runRecordLen +) + +// crcRunTable is the CRC64-ECMA table framing every run payload. +var crcRunTable = crc64.MakeTable(crc64.ECMA) //nolint:gochecknoglobals // fixed table + +// runHeader is the decoded fixed header. +type runHeader struct { + count uint64 + first, last uint32 + crc uint64 +} + +// ─────────────────────────── sealing ─────────────────────────── + +// startSeal kicks the background job: fold the given window rows into one +// run and make its dirent durable (the h.fsyncDir barrier) before the +// hand-back — reapSeal's next stop is the manifest write that names it. +// Writer goroutine only; h.sealInFlight guards the single-job invariant. +func (h *HotIndex) startSeal(rows []windowRow) { + // Read on the writer goroutine: the seal below takes the secret as an + // argument rather than reaching into the engine from its goroutine. + secret := h.secret + // The name follows runset.NextSealSeq's grammar (-%06d.run, the + // prefix OpenHotIndex resumes from); changing its shape silently breaks + // the seal-sequence resume. + name := filepath.Join(h.dir, fmt.Sprintf("seal-%06d.run", h.sealSeq)) + h.sealSeq++ + h.sealInFlight = true + go func() { + res := sealResult{rows: len(rows), lastSeq: rows[len(rows)-1].seq} + res.run, res.err = sealWindow(rows, name, secret) + // The ladder's dirent barrier — the manifest may only ever name a + // run whose existence survives a crash (see HotIndex.fsyncDir). + if res.err == nil { + if err := h.fsyncDir(h.dir); err != nil { + res.run.close() // errors carry no resources (sealResult contract) + _ = os.Remove(res.run.path) + res.run = nil + res.err = err + } + } + h.pendingSeal <- res + }() +} + +// reapSeal folds a completed background job into the view: manifest first +// (the durability point), then ONE atomic view swap that appends the run and +// trims the sealed window rows — readers never see a coverage gap. +// block=true waits for an in-flight job (Close/tests). +func (h *HotIndex) reapSeal(block bool) error { + if !h.sealInFlight { + return nil + } + var res sealResult + if block { + res = <-h.pendingSeal + } else { + select { + case res = <-h.pendingSeal: + default: + return nil + } + } + h.sealInFlight = false + if res.err != nil { + return fmt.Errorf("txhash: hotindex seal: %w", res.err) + } + + old := h.view.Load() + // A failed Publish disposes of the un-listed run itself (errors carry no + // resources); the view is untouched. + if err := runset.Publish(h.manifest, old.runs, res.run, res.lastSeq); err != nil { + return fmt.Errorf("txhash: hotindex manifest: %w", err) + } + runs := append(append([]*sealedRun{}, old.runs...), res.run) + h.view.Store(&hotIndexView{rows: old.rows[res.rows:], runs: runs}) + return nil +} + +// runRouting accumulates a run's in-RAM routing state (bloom + page ladder + +// record count) record by record in one pass. The seal feeds it in the SAME +// pass that writes the file (writeRunPayload) — freshly written runs are +// trusted without a post-write re-read (owner-accepted, the events ruling +// extended to this engine: file-level integrity plus the corruption gates +// and the freeze's independent CRC64 drain of every cold-feeding run cover +// it) — and warmup (openSealedRun) feeds it from the drain-verify of +// pre-existing files, which remains the crash-recovery trust anchor. +// +// The bloom is sized up front so fingerprints stream straight into it: the +// seal sizes from the summed window-row hash counts (exactly the count the +// header writes), warmup from the header's validated record count. Seal and +// warmup build routing through this one type — ladder cadence and bloom +// geometry cannot drift between a freshly written run and its reopened form. +type runRouting struct { + bloom bloom.Filter + // ladder holds each page's first record key at its FULL ColdKeySize + // width — not a prefix of it — so a ladder compare is exact and only + // genuinely equal keys tie. + ladder [][ColdKeySize]byte + n int // records observed so far +} + +// newRunRouting sizes the routing state for records payload records. +func newRunRouting(records int) *runRouting { + return &runRouting{ + bloom: bloom.New(max(records, 1)), + ladder: make([][ColdKeySize]byte, 0, records/pageRecords+1), + } +} + +// observe folds in one payload record (blinded key ‖ seq u32 LE). Must be +// called once per record, in emission order — the ladder keys page +// boundaries by observation index. Bloom and ladder are both keyed by the +// BLINDED key, the only key a run is ever probed with. +func (rt *runRouting) observe(rec *[runRecordLen]byte) { + if rt.n%pageRecords == 0 { + rt.ladder = append(rt.ladder, [ColdKeySize]byte(rec[:ColdKeySize])) + } + rt.bloom.Add(fp64([ColdKeySize]byte(rec[:ColdKeySize]))) + rt.n++ +} + +// run finalizes the routing state into a sealedRun covering ledgers +// [first,last]. The caller attaches the open file handle. +func (rt *runRouting) run(path string, first, last uint32) *sealedRun { + return &sealedRun{ + path: path, bloom: rt.bloom, ladder: rt.ladder, + first: first, last: last, records: rt.n, + } +} + +// sealWindow folds window rows into one run file, building its in-RAM +// routing state in the same pass as the write (runRouting) — no post-write +// re-read — then opens the run for lookups. If that open fails, the durable +// file is deliberately left behind: errors carry no resources (the +// sealResult contract), a failed seal publishes nothing so the manifest +// never names the file, and warmup's orphan sweep owns unlisted files — the +// same posture writeRun's sync/close failures already take. Runs on the +// background goroutine over immutable inputs. +func sealWindow(rows []windowRow, path string, secret [stores.SecretLen]byte) (*sealedRun, error) { + rt, err := writeRun(rows, path, secret) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("txhash: open run %s: %w", path, err) + } + run := rt.run(path, rows[0].seq, rows[len(rows)-1].seq) + run.file = f + return run, nil +} + +// writeRun streams the window rows' records into path, hash-sorted, and +// fsyncs the file. The dirent half of the durability ladder is NOT here: the +// seal goroutine runs the h.fsyncDir barrier after sealWindow returns +// (startSeal), strictly before the hand-back that can reach PutRuns — so +// the manifest still never names a run whose dirent could vanish with a +// crash. This engine barriers AFTER its open, unlike the events twin +// (openDurable barriers before opening), because sealWindow fuses write and +// open; the shared runsettest pin gates the invariant that matters — +// barrier before PutRuns — in both engines. Returns the routing state the +// write pass accumulated (writeRunPayload). +func writeRun(rows []windowRow, path string, secret [stores.SecretLen]byte) (*runRouting, error) { + f, err := os.Create(path) + if err != nil { + return nil, fmt.Errorf("txhash: create run %s: %w", path, err) + } + rt, err := writeRunPayload(f, rows, secret) + if err != nil { + _ = f.Close() + _ = os.Remove(path) + return nil, fmt.Errorf("txhash: write run %s: %w", path, err) + } + if err := f.Sync(); err != nil { + _ = f.Close() + return nil, fmt.Errorf("txhash: sync run %s: %w", path, err) + } + if err := f.Close(); err != nil { + return nil, fmt.Errorf("txhash: close run %s: %w", path, err) + } + return rt, nil +} + +// writeRunPayload writes the header and the window's records — every row's +// hashes BLINDED and re-sorted — then patches the payload CRC into the +// header, feeding every record through the run's routing state beside the +// CRC (runRouting — one-pass construction). The records stream out through a +// bufio writer; only the (key, seq) pairs are buffered. +// +// Blinding destroys each row's raw-hash order, so the window cannot be +// k-way merged: the pairs are collected and sorted instead. That buffer is +// the seal's whole working set and is bounded by the seal window — 256 +// ledgers of hashes, ~30MB at stress density — not by the chunk. +// +// The sort is by (blinded key, ledger), which is exactly the .bin's stored +// order: rows arrive in ledger order, so equal keys — one hash committed in +// several of the window's ledgers, the legal cross-ledger shape — emit +// oldest ledger first, the run's byte-level duplicate contract and the same +// tie-break the walk writer and the freeze merge produce. +func writeRunPayload(f *os.File, rows []windowRow, secret [stores.SecretLen]byte) (*runRouting, error) { + count := 0 + for i := range rows { + count += len(rows[i].bytes) / rowHashLen + } + hdr := make([]byte, runHeaderLen) + copy(hdr, runMagic) + binary.LittleEndian.PutUint64(hdr[runCountOff:], uint64(count)) + binary.LittleEndian.PutUint32(hdr[runFirstOff:], rows[0].seq) + binary.LittleEndian.PutUint32(hdr[runLastOff:], rows[len(rows)-1].seq) + if _, err := f.Write(hdr); err != nil { + return nil, err + } + recs := make([]ColdEntry, 0, count) + for i := range rows { + recs = blindRow(recs, rows[i].bytes, rows[i].seq, secret) + } + SortColdEntries(recs) + + rt := newRunRouting(count) + w := bufio.NewWriterSize(f, 128<<10) + crc := crc64.New(crcRunTable) + var rec [runRecordLen]byte + for i := range recs { + copy(rec[:ColdKeySize], recs[i].Key[:]) + binary.LittleEndian.PutUint32(rec[ColdKeySize:], recs[i].Seq) + _, _ = crc.Write(rec[:]) + rt.observe(&rec) + if _, err := w.Write(rec[:]); err != nil { + return nil, err + } + } + // The header promised count records; the write must have emitted exactly + // that (the seal-time cross-check the deleted re-read used to run). + if rt.n != count { + return nil, fmt.Errorf("wrote %d records, header claims %d", rt.n, count) + } + if err := w.Flush(); err != nil { + return nil, err + } + var crcb [8]byte + binary.LittleEndian.PutUint64(crcb[:], crc.Sum64()) + if _, err := f.WriteAt(crcb[:], runCRCOff); err != nil { + return nil, err + } + return rt, nil +} + +// ─────────────────────────── sealed runs ─────────────────────────── + +// openSealedRun drain-verifies a PRE-EXISTING run file and builds its lookup +// state plus an open handle. Warmup-only: it is the crash-recovery trust +// anchor for files that survived a restart — corruption is a loud failure, +// never auto-healed. Freshly WRITTEN runs (sealWindow) build routing in the +// write pass instead (runRouting) and are not re-read. +// +// The drain is the one run streaming reader (runSource, run_reader.go) +// — the same verify loop the freeze merges through — with every verified +// record folded through the write pass's runRouting funnel: routing state +// for runs that crossed a restart is always rebuilt from the verified file +// bytes, never trusted from elsewhere. Clean end-of-stream means CRC64 and +// record count checked; the drained fd is handed off to serve the lookup +// preads. +func openSealedRun(path string) (*sealedRun, error) { + rs, err := openRunSource(path) + if err != nil { + return nil, err + } + // The validated header count sizes the routing state up front — the same + // one-pass runRouting shape the write side uses, here fed from the + // verified drain. + rt := newRunRouting(rs.recs) + for { + ok, aerr := rs.advance() + if aerr != nil { + rs.close() + return nil, aerr + } + if !ok { + break + } + rt.observe(&rs.rec) + } + run := rt.run(path, rs.hdr.first, rs.hdr.last) + run.file = rs.handoff() // last: nothing may fail past the handoff + return run, nil +} + +// readRunHeader reads and structurally validates the fixed header. +func readRunHeader(f *os.File, path string) (runHeader, error) { + var b [runHeaderLen]byte + if _, err := io.ReadFull(f, b[:]); err != nil { + return runHeader{}, fmt.Errorf("txhash: run %s: short header: %w", path, err) + } + if string(b[:len(runMagic)]) != runMagic { + if string(b[:len(runMagicRaw)]) == runMagicRaw { + return runHeader{}, fmt.Errorf( + "txhash: run %s: stale pre-release run format (raw 36-byte records; no migration; "+ + "re-ingest the chunk)", path) + } + return runHeader{}, fmt.Errorf("txhash: run %s: bad magic %q", path, b[:len(runMagic)]) + } + hdr := runHeader{ + count: binary.LittleEndian.Uint64(b[runCountOff:]), + first: binary.LittleEndian.Uint32(b[runFirstOff:]), + last: binary.LittleEndian.Uint32(b[runLastOff:]), + crc: binary.LittleEndian.Uint64(b[runCRCOff:]), + } + if hdr.first > hdr.last { + return runHeader{}, fmt.Errorf("txhash: run %s: ledger range [%d,%d] inverted", path, hdr.first, hdr.last) + } + return hdr, nil +} + +// runRecordCount cross-checks the header count against the file's actual +// size — a torn tail or a hostile count fails here, before the drain +// allocates anything proportional to it. +func runRecordCount(f *os.File, path string, hdr runHeader) (int, error) { + fi, err := f.Stat() + if err != nil { + return 0, fmt.Errorf("txhash: run %s: stat: %w", path, err) + } + payload := fi.Size() - runHeaderLen + if payload < 0 || payload%runRecordLen != 0 || uint64(payload/runRecordLen) != hdr.count { + return 0, fmt.Errorf( + "txhash: run %s: count %d does not match %d payload bytes (torn tail, or a foreign record "+ + "width — no migration; re-ingest the chunk)", path, hdr.count, payload) + } + return int(payload / runRecordLen), nil +} + +// lookup probes the run for one BLINDED key: bloom reject → ladder route → +// one record-aligned page pread (two on a boundary tie) → in-buffer stride +// binary search → full 16-byte key verify → LE seq. Concurrent-safe +// (ReadAt). +// +// A hit is a CANDIDATE, not an answer: the run holds no raw hash to compare +// against, and the key blinds only the hash's first 16 bytes. The full-hash +// check happens where the ledger is read, in the read assembly — see +// HotStore.Get. +func (r *sealedRun) lookup(rk [ColdKeySize]byte) (uint32, bool, error) { + if len(r.ladder) == 0 || !r.bloom.MayContain(fp64(rk)) { + return 0, false, nil + } + page, pages := r.route(rk) + start := page * pageRecords + n := min(pages*pageRecords, r.records-start) + buf := make([]byte, n*runRecordLen) + if _, err := r.file.ReadAt(buf, int64(runHeaderLen+start*runRecordLen)); err != nil { + return 0, false, fmt.Errorf("txhash: run pread %s: %w", r.path, err) + } + i := sort.Search(n, func(k int) bool { + return bytes.Compare(buf[k*runRecordLen:k*runRecordLen+ColdKeySize], rk[:]) >= 0 + }) + if i == n || !bytes.Equal(buf[i*runRecordLen:i*runRecordLen+ColdKeySize], rk[:]) { + return 0, false, nil + } + return binary.LittleEndian.Uint32(buf[i*runRecordLen+ColdKeySize:]), true, nil +} + +// route picks the page(s) a key can live in: the last page whose ladder key +// sorts strictly below the probe — the equal-key record range begins inside +// that page, or exactly at the next page boundary, in which case the next +// page is read too (the boundary tie). Two pages suffice: equal keys mean +// the same hash committed in several of the window's ledgers (or, at 2^-64, +// two hashes sharing a 16-byte prefix), any of whose records is a correct +// candidate, and the covered pages hold at least one. Only a key repeated +// past a whole page — one hash in >pageRecords of a window's ledgers, which +// the window's ledger count bounds — could span further, and even then the +// covered pages still hold a correct candidate. +func (r *sealedRun) route(rk [ColdKeySize]byte) (int, int) { + page := sort.Search(len(r.ladder), func(k int) bool { + return bytes.Compare(r.ladder[k][:], rk[:]) >= 0 + }) + if page > 0 { + page-- + } + pages := 1 + if page+1 < len(r.ladder) && bytes.Equal(r.ladder[page+1][:], rk[:]) { + pages = 2 + } + return page, pages +} + +func (r *sealedRun) close() { + if r.file != nil { + _ = r.file.Close() + } +} + +// runset.Run adapters: the publish protocol addresses a sealed run only by +// path and disposal. +func (r *sealedRun) RunPath() string { return r.path } +func (r *sealedRun) CloseRun() { r.close() } + +// ─────────────────────────── manifest ─────────────────────────── + +// rocksdbManifest persists the live-run list in the chunk DB's default CF +// under one key. Every Put rides the store's pinned synced WriteOptions, so +// a manifest write is durable before reapSeal publishes. A deliberate +// duplicate of the events one (event/hot_store.go) — two engines, two keys, +// nothing to version in lockstep. +type rocksdbManifest struct { + store *rocksdb.Store +} + +var txhashManifestKey = []byte("txhash:manifest") //nolint:gochecknoglobals // fixed key + +func (m rocksdbManifest) PutRuns(names []string, lastSealed uint32) error { + val := make([]byte, 4, 4+len(names)*16) + binary.BigEndian.PutUint32(val, lastSealed) + val = append(val, strings.Join(names, ",")...) + return m.store.Put("", txhashManifestKey, val) +} + +func (m rocksdbManifest) GetRuns() ([]string, uint32, error) { + val, found, err := m.store.Get("", txhashManifestKey) + if err != nil || !found { + return nil, 0, err + } + if len(val) < 4 { + return nil, 0, fmt.Errorf("txhash: manifest value too short (%d bytes)", len(val)) + } + lastSealed := binary.BigEndian.Uint32(val) + rest := string(val[4:]) + if rest == "" { + return nil, lastSealed, nil + } + return strings.Split(rest, ","), lastSealed, nil +} + +// ─────────────────────────── warmup ─────────────────────────── + +// OpenHotIndex rebuilds the engine from its manifest after a restart: every +// manifest-listed run is re-opened (drain-verified — corruption is a loud +// failure, never auto-healed), the run chain is checked dense against the +// sealed frontier, and unreferenced files in dir are swept as orphans. The +// caller then replays the un-sealed tail of packed rows (ledgers past +// lastSealed) through ApplyRow and — only after judging the open valid — +// arms sealing: the engine returns DISARMED, so a failed open writes nothing +// durable and every retry faces the same state. +// +// The dense chain is the run half of that judgement: consecutive seals cover +// consecutive ledger ranges and the newest run ends exactly at the sealed +// frontier. A violated chain means the manifest and the run files disagree +// about history — a loud open failure, like every other warmup inconsistency. +// +// secret is the chunk DB's adopted routing secret (hot_store.go): every run +// this engine writes or probes is keyed under it. Warmup itself is +// content-blind — the drain verifies CRC64 and record shape, never a key — +// so a wrong secret cannot corrupt the open; it would only make probes miss, +// which is why the secret is adoption-checked against the DB before the +// engine is built at all. +// +// firstLedger anchors the dense chain for a chunk with nothing sealed yet: +// expectedNext = max(lastSealed+1, firstLedger), so a fresh/empty-manifest +// chunk demands exactly the chunk's first ledger as its first row — an +// unanchored engine would instead let an arbitrary mis-sequenced first row +// anchor the whole chain. +func OpenHotIndex( + dir string, firstLedger uint32, manifest runset.Manifest, secret [stores.SecretLen]byte, +) (*HotIndex, uint32, error) { + h, err := NewHotIndex(dir, manifest, secret) + if err != nil { + return nil, 0, err + } + names, lastSealed, err := manifest.GetRuns() + if err != nil { + return nil, 0, fmt.Errorf("txhash: hotindex manifest read: %w", err) + } + runs := make([]*sealedRun, 0, len(names)) + // Cleanup is exit-invariant: any return before the caller takes ownership + // releases every run opened so far, however the open failed. + opened := false + defer func() { + if !opened { + closeRuns(runs) + } + }() + for _, name := range names { + r, oerr := openSealedRun(filepath.Join(dir, name)) + if oerr != nil { + return nil, 0, fmt.Errorf("txhash: hotindex: manifest run %s: %w", name, oerr) + } + // Append BEFORE the chain check so the defer covers this handle too. + runs = append(runs, r) + if n := len(runs); n > 1 && r.first != runs[n-2].last+1 { + return nil, 0, fmt.Errorf("txhash: hotindex: run %s starts at %d, previous run ends at %d", + name, r.first, runs[n-2].last) + } + } + if n := len(runs); n > 0 && runs[n-1].last != lastSealed { + return nil, 0, fmt.Errorf("txhash: hotindex: newest run ends at %d, sealed frontier is %d", + runs[n-1].last, lastSealed) + } + if err := runset.SweepOrphans(dir, names); err != nil { + return nil, 0, fmt.Errorf("txhash: hotindex: %w", err) + } + h.view.Store(&hotIndexView{runs: runs}) + h.expectedNext = max(lastSealed+1, firstLedger) + // The only run-name prefix this engine writes (startSeal). + h.sealSeq = runset.NextSealSeq(names, "seal") + opened = true + return h, lastSealed, nil +} + +func closeRuns(runs []*sealedRun) { + for _, r := range runs { + r.close() + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go new file mode 100644 index 000000000..d97a646a1 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go @@ -0,0 +1,84 @@ +package txhash + +// merge_heap.go — the key-ordered k-way merge heap behind the freeze's fold +// of sealed runs plus un-sealed tail rows into the cold .bin +// (cold_freeze.go). Sources are key-sorted and ties break on the SOURCE +// index, which is what makes duplicate emission ledger-ordered and +// deterministic. (The seal no longer merges: blinding reorders each row, so +// it sorts pairs instead — hotindex_seal.go.) +// +// Discipline (the cold build's, cold_merge.go): value entries carrying their +// own CACHED key, replace-root only — no up(), no index indirection back into +// the source on every compare. A driver keeps its own cursor state, seeds one +// entry per non-empty source, heapifies, and then loops: read the root, emit, +// advance that source, refill the root (refresh) or dropRoot (drained). +// +// The cached key copies nothing: it is a slice the source owns and may +// invalidate on its next advance. That is safe only because the root's source +// is the ONLY one that ever advances, and the same step refreshes the root's +// entry from it. A missed refresh is a silent misorder, which is why the +// driver is held to a byte-identity gate +// (TestFreezeColdFromStore_ByteIdenticalToWalk). + +import "bytes" + +// keyEntry is one live source's heap slot: that source's CURRENT key, +// cached in the entry so a compare never chases back through the source, plus +// the source's index — the tie-break, and the driver's handle on its cursor. +type keyEntry struct { + key []byte + idx int +} + +// less orders entries by (key, source index). Indices are unique per live +// source, so this is a TOTAL order: the pop sequence is a pure function of the +// inputs, independent of how the heap happens to be laid out. +func (e keyEntry) less(o keyEntry) bool { + if c := bytes.Compare(e.key, o.key); c != 0 { + return c < 0 + } + return e.idx < o.idx +} + +// keyHeap is a slice-backed binary min-heap of keyEntries. The driver owns +// the slice: heapify once, then refill-and-siftDown or dropRoot per record. +type keyHeap []keyEntry + +// heapify orders an arbitrary slice of entries into a min-heap. +func (h keyHeap) heapify() { + for i := len(h)/2 - 1; i >= 0; i-- { + h.siftDown(i) + } +} + +// siftDown restores the min-heap rooted at i. +func (h keyHeap) siftDown(i int) { + n := len(h) + for { + left := 2*i + 1 + if left >= n { + break + } + j := left + if right := left + 1; right < n && h[right].less(h[j]) { + j = right + } + if !h[j].less(h[i]) { // h[i] <= h[j]: heap property already holds + break + } + h[i], h[j] = h[j], h[i] + i = j + } +} + +// dropRoot removes the root — its source is drained — and returns the +// shortened heap with the property restored. +func (h keyHeap) dropRoot() keyHeap { + last := len(h) - 1 + h[0] = h[last] + h = h[:last] + if len(h) > 0 { + h.siftDown(0) + } + return h +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly.go index f4454d23c..43485c49b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly.go @@ -117,7 +117,9 @@ func (r *TxReader) scan( } if exact { return ingest.LedgerTransactionView{}, false, - fmt.Errorf("txhash: exact index mapped tx to ledger %d that does not contain it: %w", seq, ErrInconsistent) + fmt.Errorf("txhash: exact index mapped tx to ledger %d that does not contain it "+ + "(corrupt index or store; a 16-byte blinded-key collision is the ~2^-128 alternative "+ + "and reproduces deterministically on retry): %w", seq, ErrInconsistent) } } return ingest.LedgerTransactionView{}, false, nil diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly_test.go index 18ab1cfb2..8ab5f4859 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/read_assembly_test.go @@ -389,10 +389,12 @@ func TestTxReader_ExactSourceUnavailableLedgerErrors(t *testing.T) { func TestTxReader_HotAndColdFederation(t *testing.T) { hotSeq := chunk.ID(10).FirstLedger() flHot := buildLedgers(t, []uint32{hotSeq}, 1) - hotStore := openTestHotStore(t) - for h, seq := range flHot.byHash { - require.NoError(t, addEntries(hotStore, []Entry{{Hash: h, LedgerSeq: seq}})) + hotStore, _ := openPackedStoreAt(t, t.TempDir(), chunk.ID(10), windowLedgers) + hotHashes := make([][32]byte, 0, len(flHot.byHash)) + for h := range flHot.byHash { + hotHashes = append(hotHashes, h) } + ingestLedger(t, hotStore, hotSeq, hotHashes) coldSeq := chunk.ID(5).FirstLedger() flCold := buildLedgers(t, []uint32{coldSeq}, 1) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go new file mode 100644 index 000000000..cb7f6d004 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go @@ -0,0 +1,117 @@ +package txhash + +// run_reader.go — the ONE streaming reader for TXHRUN02 run files (format +// and header gates live beside the writer in hotindex_seal.go). runSource +// drains a run sequentially through a 128KiB buffer, verifying as it +// streams: CRC64 folded over the payload (checked against the header at +// end-of-stream), key sortedness, and per-record seq containment in the +// header's ledger range — with the header count already cross-checked +// against the file size at open. Two drivers consume it: warmup's routing +// rebuild (openSealedRun) drains one run and takes the verified fd for the +// lookup path (handoff), and the freeze merge (cold_freeze.go) drains every +// manifest-listed run as one k-way source, copying its records into the +// .bin verbatim. +// +// The reader is content-blind: it never blinds or interprets a key, so a +// wrong secret is invisible here (it is caught at open — hot_store.go). + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "hash" + "hash/crc64" + "io" + "os" +) + +// runSource streams one sealed run file sequentially, folding CRC64 over +// the drain: the CRC (and the header count vs file size) is verified by the +// time the source reports end-of-stream, so a torn or corrupt run fails its +// consumer loudly. Per-record key order is checked as it streams (merge +// correctness and the .bin's sort order both ride on it), and every seq +// must sit inside the header's promised ledger range. +type runSource struct { + path string + f *os.File + br *bufio.Reader + crc hash.Hash64 + hdr runHeader + recs int + remaining int + rec [runRecordLen]byte + prev [ColdKeySize]byte +} + +// openRunSource opens and structurally validates a run for sequential +// draining: header magic/shape (readRunHeader) and count vs file size +// (runRecordCount), the shared open gates. +func openRunSource(path string) (*runSource, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("run %s: %w", path, err) + } + hdr, err := readRunHeader(f, path) + if err != nil { + _ = f.Close() + return nil, err + } + records, err := runRecordCount(f, path, hdr) + if err != nil { + _ = f.Close() + return nil, err + } + return &runSource{ + path: path, f: f, br: bufio.NewReaderSize(f, 128<<10), + crc: crc64.New(crcRunTable), hdr: hdr, recs: records, remaining: records, + }, nil +} + +func (r *runSource) key() []byte { return r.rec[:ColdKeySize] } + +func (r *runSource) seq() uint32 { return binary.LittleEndian.Uint32(r.rec[ColdKeySize:]) } + +func (r *runSource) advance() (bool, error) { + if r.remaining == 0 { + if r.crc.Sum64() != r.hdr.crc { + return false, fmt.Errorf("run %s: payload crc mismatch (file %016x, computed %016x)", + r.path, r.hdr.crc, r.crc.Sum64()) + } + return false, nil + } + idx := r.recs - r.remaining + if idx > 0 { + copy(r.prev[:], r.rec[:ColdKeySize]) + } + if _, err := io.ReadFull(r.br, r.rec[:]); err != nil { + return false, fmt.Errorf("run %s: record %d: %w", r.path, idx, err) + } + _, _ = r.crc.Write(r.rec[:]) + if idx > 0 && bytes.Compare(r.prev[:], r.rec[:ColdKeySize]) > 0 { + return false, fmt.Errorf("run %s: record %d out of key order", r.path, idx) + } + if seq := r.seq(); seq < r.hdr.first || seq > r.hdr.last { + return false, fmt.Errorf("run %s: record %d seq %d outside [%d,%d]", + r.path, idx, seq, r.hdr.first, r.hdr.last) + } + r.remaining-- + return true, nil +} + +// close releases the file handle; safe in any state, a no-op after handoff. +func (r *runSource) close() { + if r.f != nil { + _ = r.f.Close() + } +} + +// handoff surrenders the open file handle — the fd whose bytes the drain +// verified — and neuters close(), so the verified fd IS the fd that serves +// lookup preads (openSealedRun). Must be the caller's last call on the +// source. +func (r *runSource) handoff() *os.File { + f := r.f + r.f = nil + return f +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go new file mode 100644 index 000000000..2ff8304e0 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go @@ -0,0 +1,784 @@ +package txhash + +import ( + "bytes" + "encoding/binary" + "hash/crc64" + "math/rand/v2" + "os" + "path/filepath" + "sort" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runsettest" +) + +// fakeManifest is an in-memory runset.Manifest. +type fakeManifest struct { + mu sync.Mutex + names []string + lastSealed uint32 +} + +func (m *fakeManifest) PutRuns(names []string, lastSealed uint32) error { + m.mu.Lock() + defer m.mu.Unlock() + m.names = append([]string(nil), names...) + m.lastSealed = lastSealed + return nil +} + +func (m *fakeManifest) GetRuns() ([]string, uint32, error) { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.names...), m.lastSealed, nil +} + +// hotIndexHarness drives a HotIndex like the ingest path will: per ledger, +// EncodeRow the hashes, feed ApplyRow, and mirror every hash into a +// reference model. +type hotIndexHarness struct { + t *testing.T + h *HotIndex + ref map[[32]byte][]uint32 + seen [][32]byte // pool for cross-ledger duplicate draws + seq uint32 + rng *rand.Rand +} + +func newHarness(t *testing.T, h *HotIndex, firstSeq uint32) *hotIndexHarness { + return &hotIndexHarness{t: t, h: h, ref: map[[32]byte][]uint32{}, seq: firstSeq, rng: testRNG(5)} +} + +// ledger applies one ledger's hashes through the production shape — EncodeRow +// then ApplyRow — and mirrors them into the reference model. +func (hh *hotIndexHarness) ledger(hashes [][32]byte) { + hh.t.Helper() + row, err := EncodeRow(hashes) + require.NoError(hh.t, err) + require.NoError(hh.t, hh.h.ApplyRow(hh.seq, row, len(hashes))) + for _, h := range hashes { + hh.ref[h] = append(hh.ref[h], hh.seq) + } + hh.seq++ +} + +// randomLedger applies one synthetic ledger: fresh random hashes plus a few +// re-used from earlier ledgers (fee-bump-style cross-ledger duplicates — +// legal; only the intra-ledger repeat is a reject, so reuse draws skip +// hashes already in this ledger). +func (hh *hotIndexHarness) randomLedger(fresh, reuse int) { + hh.t.Helper() + hashes := make([][32]byte, 0, fresh+reuse) + inLedger := make(map[[32]byte]bool, fresh+reuse) + for range fresh { + h := randHash(hh.rng) + hashes = append(hashes, h) + inLedger[h] = true + hh.seen = append(hh.seen, h) + } + for range reuse { + if len(hh.seen) == 0 { + break + } + h := hh.seen[hh.rng.IntN(len(hh.seen))] + if inLedger[h] { + continue + } + hashes = append(hashes, h) + inLedger[h] = true + } + hh.ledger(hashes) +} + +// verifyAll checks EVERY reference hash resolves to a containing ledger +// (exactly, when the hash lives in one), plus absent-hash misses. The +// reference model IS the verification a run hit gets here: HotStore re-checks +// a run's candidate against the ledger's raw row (hot_store.go), and these +// engine-level tests hold no CF to read, so they check the answer against the +// model that recorded which ledgers really carry the hash. +func (hh *hotIndexHarness) verifyAll() { + hh.t.Helper() + for h, seqs := range hh.ref { + got, err := hh.h.Get(h) + require.NoError(hh.t, err, "hash %x", h) + if len(seqs) == 1 { + require.Equal(hh.t, seqs[0], got, "hash %x wrong ledger", h) + } else { + require.Contains(hh.t, seqs, got, "hash %x resolved outside its ledgers", h) + } + } + for range 20 { + absent := randHash(hh.rng) + _, err := hh.h.Get(absent) + require.ErrorIs(hh.t, err, stores.ErrNotFound, "absent hash %x must miss", absent) + } +} + +// settle forces any pending seal to complete and fold in. +func (hh *hotIndexHarness) settle() { + require.NoError(hh.t, hh.h.reapSeal(true)) +} + +func testHotIndex(t *testing.T, dir string, m runset.Manifest) *HotIndex { + h, err := NewHotIndex(dir, m, testBinSecret) + require.NoError(t, err) + h.sealEvery = 8 // tiny window: many seals + h.ArmSealing() // tests emulate a validated live engine + return h +} + +// TestHotIndex_EquivalenceAcrossSeals is the engine's core gate: content +// equivalence with a reference model through window fills and many seals, +// including tx-less ledgers (empty rows) and cross-ledger duplicates. +func TestHotIndex_EquivalenceAcrossSeals(t *testing.T) { + m := &fakeManifest{} + h := testHotIndex(t, t.TempDir(), m) + defer h.Close() + hh := newHarness(t, h, 5000) + + for l := range 100 { + if l%9 == 4 { + hh.ledger(nil) // tx-less ledger: the dense chain's empty row + } else { + hh.randomLedger(hh.rng.IntN(40)+1, 2) + } + if l%17 == 0 { + hh.settle() + hh.verifyAll() + } + } + hh.settle() + hh.verifyAll() + // No merge tier: runs only accumulate, none are ever displaced. + assert.GreaterOrEqual(t, len(h.view.Load().runs), 2) +} + +// repeatedHash builds the hash whose 32 bytes are all b — hand-written +// fixtures whose sort order is obvious by inspection. +func repeatedHash(b byte) [32]byte { + var h [32]byte + for i := range h { + h[i] = b + } + return h +} + +// TestWriteRun_CrossRowDuplicateGoldenBytes pins EVERY byte of a run whose +// window carries the same full hash in THREE different ledgers alongside a +// tx-less ledger — the seal's duplicate contract, which nothing else gates +// directly. TestHotIndex_EquivalenceAcrossSeals only asserts a duplicate +// resolves to SOME containing ledger (require.Contains is order-blind) and +// the run reader only checks keys are non-decreasing, so intra-run duplicate +// ORDER — the (key, ledger) tie-break that emits equal keys oldest-ledger- +// first, the .bin's stored order — was otherwise protected solely by RNG +// draws in the freeze fixture, transitively and by accident. +// +// The expectation is built independently of the writer: blind the fixture's +// (hash, ledger) pairs, then STABLE-sort by key alone over ledger-ordered +// input — a different construction that must agree with the production +// comparator's (key, ledger) order. Blinding also means the emission order is +// no longer eyeball-obvious from the fixture, so the pin is written as that +// construction rather than as a hand-listed sequence. +func TestWriteRun_CrossRowDuplicateGoldenBytes(t *testing.T) { + encode := func(hashes ...[32]byte) []byte { + row, err := EncodeRow(hashes) + require.NoError(t, err) + return row + } + a, b, c := repeatedHash(0x11), repeatedHash(0x22), repeatedHash(0x33) + rows := []windowRow{ + {seq: 100, bytes: encode(a, b)}, + {seq: 101, bytes: encode()}, // tx-less ledger: the empty row, skipped + {seq: 102, bytes: encode(c, a)}, + {seq: 103, bytes: encode(b, a)}, + } + path := filepath.Join(t.TempDir(), "seal-000000.run") + _, err := writeRun(rows, path, testBinSecret) + require.NoError(t, err) + got, err := os.ReadFile(path) + require.NoError(t, err) + + // Ledger order in, blinded, stable-sorted by key: equal keys (the same + // hash in three ledgers) must keep ascending ledger. + type rec struct { + key [ColdKeySize]byte + seq uint32 + } + var emitted []rec + for _, l := range []struct { + seq uint32 + hashes [][32]byte + }{ + {100, [][32]byte{a, b}}, {102, [][32]byte{c, a}}, {103, [][32]byte{b, a}}, + } { + for _, h := range l.hashes { + emitted = append(emitted, rec{key: stores.BlindKey(testBinSecret, h[:ColdKeySize]), seq: l.seq}) + } + } + sort.SliceStable(emitted, func(i, j int) bool { + return bytes.Compare(emitted[i].key[:], emitted[j].key[:]) < 0 + }) + payload := make([]byte, 0, len(emitted)*runRecordLen) + for _, e := range emitted { + payload = append(payload, e.key[:]...) + payload = binary.LittleEndian.AppendUint32(payload, e.seq) + } + // The 40-byte header is written out with LITERAL magic and offsets, not + // the writer's own constants: a version bump or a relayout has to be + // re-declared here rather than passing silently on both sides. + want := make([]byte, 40, 40+len(payload)) + copy(want, "TXHRUN02") + binary.LittleEndian.PutUint64(want[8:], uint64(len(emitted))) + binary.LittleEndian.PutUint32(want[16:], 100) + binary.LittleEndian.PutUint32(want[20:], 103) + binary.LittleEndian.PutUint64(want[24:], crc64.Checksum(payload, crcRunTable)) + want = append(want, payload...) + require.Equal(t, want, got, "run bytes drifted from the pinned seal output") + require.Equal(t, 20, runRecordLen, "a record is the cold .bin's entry: 16-byte key + LE seq") + + // The pinned bytes are a VALID run: the drain re-verifies CRC64, key + // order, seq containment and the header's promised count. + run, err := openSealedRun(path) + require.NoError(t, err) + defer run.close() + assert.Equal(t, len(emitted), run.records) + + // The magic is a gate nothing else in the package covers. Corrupt the + // FIXED prefix, so this case outlives any version bump — the "TXHRUN02" + // literal above is the version pin. + bad := append([]byte(nil), got...) + bad[0] = 'X' + badPath := filepath.Join(t.TempDir(), "seal-000001.run") + require.NoError(t, os.WriteFile(badPath, bad, 0o644)) + _, err = openSealedRun(badPath) + require.ErrorContains(t, err, "bad magic", "a foreign magic must not be parsed with our offsets") +} + +// TestOpenHotIndex_RejectsPreReleaseRunFormat pins the stale-format tripwire +// on the path an operator actually meets it: warmup, opening a +// manifest-named run left by the pre-release build (36-byte records holding +// raw hashes, magic TXHRUN01). There is no migration and no re-keying — a +// raw run cannot be probed with a blinded key — so the open must fail by +// NAME, saying what to do, rather than surfacing as a record-count mismatch. +func TestOpenHotIndex_RejectsPreReleaseRunFormat(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + hh := newHarness(t, h, 500) + for range 8 { + hh.randomLedger(6, 0) + } + hh.settle() + h.Close() + + names, _, err := m.GetRuns() + require.NoError(t, err) + require.Len(t, names, 1) + runPath := filepath.Join(dir, names[0]) + raw, err := os.ReadFile(runPath) + require.NoError(t, err) + copy(raw, "TXHRUN01") // the pre-release magic, nothing else touched + require.NoError(t, os.WriteFile(runPath, raw, 0o644)) + + _, _, err = OpenHotIndex(dir, 500, m, testBinSecret) + require.ErrorContains(t, err, "stale pre-release run format") + require.ErrorContains(t, err, "re-ingest the chunk") + + // A record width that is neither format's fails too, just less + // specifically — the size gate BEHIND the magic. Restoring the current + // magic first is what makes the extra byte reach that gate; with + // TXHRUN01 still in place the assertion would pass on the stale-format + // message above and gate nothing. + copy(raw, runMagic) + require.NoError(t, os.WriteFile(runPath, append(raw, 0x00), 0o644)) + _, _, err = OpenHotIndex(dir, 500, m, testBinSecret) + require.ErrorContains(t, err, "does not match") + require.ErrorContains(t, err, "re-ingest the chunk") +} + +// TestHotIndex_DenseChainViolationRejected pins ApplyRow's write-time +// tripwires: the dense chain (anchored by the first row) and the +// count/bytes cross-check. +func TestHotIndex_DenseChainViolationRejected(t *testing.T) { + m := &fakeManifest{} + h := testHotIndex(t, t.TempDir(), m) + defer h.Close() + + require.Error(t, h.ApplyRow(0, nil, 0), "seq 0 can never anchor the chain") + require.NoError(t, h.ApplyRow(100, nil, 0), "first row anchors the chain") + require.Error(t, h.ApplyRow(100, nil, 0), "repeated ledger") + require.Error(t, h.ApplyRow(102, nil, 0), "gap") + require.Error(t, h.ApplyRow(99, nil, 0), "regression") + require.NoError(t, h.ApplyRow(101, nil, 0)) + + row, err := EncodeRow([][32]byte{{1}}) + require.NoError(t, err) + require.Error(t, h.ApplyRow(102, row, 2), "count/bytes mismatch must not publish") + require.NoError(t, h.ApplyRow(102, row, 1)) +} + +// TestHotIndex_SealingDisarmedUntilArmed pins the validation gate every +// open starts behind: a DISARMED engine applies far more than sealEvery +// rows without writing anything durable — no run files, no manifest — and +// the first armed ApplyRow drains the whole backlog in ONE seal. +func TestHotIndex_SealingDisarmedUntilArmed(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h, err := NewHotIndex(dir, m, testBinSecret) + require.NoError(t, err) + defer h.Close() + h.sealEvery = 8 // NOT armed — the warmup-replay state + hh := newHarness(t, h, 40) + + for range 40 { // 5× the seal window + hh.randomLedger(10, 1) + } + entries, err := os.ReadDir(dir) + require.NoError(t, err) + assert.Empty(t, entries, "disarmed engine must write no run files") + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + assert.Empty(t, names, "disarmed engine must not touch the manifest") + assert.Zero(t, lastSealed) + assert.Len(t, h.view.Load().rows, 40, "backlog retained in the window") + hh.verifyAll() // reads are fully served from the window while disarmed + + // Arm + one more ledger: the over-full window seals in one pass. + h.ArmSealing() + hh.randomLedger(10, 1) + hh.settle() + names, lastSealed, err = m.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names, "armed engine must seal the backlog") + assert.Equal(t, hh.seq-1, lastSealed, "one seal must cover the whole backlog") + assert.Empty(t, h.view.Load().rows, "window drained by the backlog seal") + hh.verifyAll() +} + +// TestHotIndex_WarmupRebuild: close, reopen from the manifest, replay the +// unsealed tail (validateRow + ApplyRow, the production warmup shape), +// verify full equivalence and the re-anchored dense chain. +func TestHotIndex_WarmupRebuild(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + hh := newHarness(t, h, 900) + + for range 8 { + hh.randomLedger(15, 2) + } + hh.settle() // one seal: ledgers 900..907 + for range 5 { + hh.randomLedger(15, 2) // unsealed tail: 908..912 + } + // Capture the UNSEALED tail (rows still in the window) for replay — in + // production these come from the txhash CF rows past the sealed + // frontier. + tail := append([]windowRow(nil), h.view.Load().rows...) + h.Close() + + h2, lastSealed, err := OpenHotIndex(dir, 900, m, testBinSecret) + require.NoError(t, err) + defer h2.Close() + h2.sealEvery = 8 + require.Equal(t, uint32(907), lastSealed) + + // The chain re-anchors at the frontier: a tail replay may not skip. + require.Error(t, h2.ApplyRow(lastSealed+2, nil, 0)) + + for _, row := range tail { + n, verr := validateRow(row.bytes) + require.NoError(t, verr) + require.NoError(t, h2.ApplyRow(row.seq, row.bytes, n)) + } + h2.ArmSealing() // production order: replay disarmed, verify, then arm + hh.h = h2 + hh.verifyAll() +} + +// TestHotIndex_WarmupSweepsOrphans: unreferenced run files are deleted at +// open; a manifest-referenced file that is MISSING fails loudly. +func TestHotIndex_WarmupSweepsOrphans(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + hh := newHarness(t, h, 60) + for range 8 { + hh.randomLedger(10, 0) + } + hh.settle() + h.Close() + + orphan := filepath.Join(dir, "seal-999999.run") + require.NoError(t, os.WriteFile(orphan, []byte("junk"), 0o644)) + h2, _, err := OpenHotIndex(dir, 60, m, testBinSecret) + require.NoError(t, err) + h2.Close() + _, serr := os.Stat(orphan) + assert.True(t, os.IsNotExist(serr), "orphan must be swept at warmup") + + // Missing referenced run: loud failure. + names, _, _ := m.GetRuns() + require.NotEmpty(t, names) + require.NoError(t, os.Remove(filepath.Join(dir, names[0]))) + _, _, err = OpenHotIndex(dir, 60, m, testBinSecret) + require.Error(t, err, "missing manifest-referenced run must fail open") +} + +// TestHotIndex_WarmupRejectsCorruptRun: a flipped payload bit and a +// truncated tail both fail the drain-verify loudly — and identically on +// retry, since a failed open writes nothing. +func TestHotIndex_WarmupRejectsCorruptRun(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + hh := newHarness(t, h, 300) + for range 8 { + hh.randomLedger(20, 0) + } + hh.settle() + h.Close() + + names, _, err := m.GetRuns() + require.NoError(t, err) + require.NotEmpty(t, names) + path := filepath.Join(dir, names[0]) + raw, err := os.ReadFile(path) + require.NoError(t, err) + + flipped := append([]byte(nil), raw...) + flipped[runHeaderLen+5] ^= 0x01 + require.NoError(t, os.WriteFile(path, flipped, 0o644)) + _, _, err = OpenHotIndex(dir, 300, m, testBinSecret) + require.Error(t, err, "corrupt payload must fail the open loudly") + + require.NoError(t, os.WriteFile(path, raw[:len(raw)-runRecordLen], 0o644)) + _, _, err = OpenHotIndex(dir, 300, m, testBinSecret) + require.Error(t, err, "truncated payload must fail the open loudly") +} + +// TestOpenHotIndex_RejectsBrokenRunChain: the manifest and the run headers +// must agree on one dense history — reordered runs and a frontier that +// disagrees with the newest run both fail the open. +func TestOpenHotIndex_RejectsBrokenRunChain(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + hh := newHarness(t, h, 100) + for range 8 { + hh.randomLedger(5, 0) + } + hh.settle() + for range 8 { + hh.randomLedger(5, 0) + } + hh.settle() + h.Close() + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + require.Len(t, names, 2) + + require.NoError(t, m.PutRuns([]string{names[1], names[0]}, lastSealed)) + _, _, err = OpenHotIndex(dir, 100, m, testBinSecret) + require.Error(t, err, "reordered runs break the dense chain") + + require.NoError(t, m.PutRuns(names, lastSealed+3)) + _, _, err = OpenHotIndex(dir, 100, m, testBinSecret) + require.Error(t, err, "frontier disagreeing with the newest run is loud") + + require.NoError(t, m.PutRuns(names, lastSealed)) + h2, gotLast, err := OpenHotIndex(dir, 100, m, testBinSecret) + require.NoError(t, err) + assert.Equal(t, lastSealed, gotLast) + h2.Close() +} + +// TestHotIndex_EmptyLedgersSealEmptyRun: an all-tx-less window seals to a +// count-0 run that round-trips the drain-verify at reopen — the dense chain +// exists independently of transaction volume. +func TestHotIndex_EmptyLedgersSealEmptyRun(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + h.sealEvery = 4 + for seq := uint32(20); seq < 24; seq++ { + require.NoError(t, h.ApplyRow(seq, nil, 0)) + } + require.NoError(t, h.reapSeal(true)) + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + require.Len(t, names, 1) + assert.Equal(t, uint32(23), lastSealed) + _, err = h.Get(randHash(testRNG(1))) + require.ErrorIs(t, err, stores.ErrNotFound) + h.Close() + + h2, lastSealed2, err := OpenHotIndex(dir, 20, m, testBinSecret) + require.NoError(t, err) + defer h2.Close() + assert.Equal(t, uint32(23), lastSealed2) + _, err = h2.Get(randHash(testRNG(2))) + require.ErrorIs(t, err, stores.ErrNotFound) +} + +// prefixedHash builds a hash whose first 16 bytes repeat p and whose last 4 +// carry a counter — many DISTINCT hashes that nevertheless share ONE blinded +// key, since a run's key blinds hash[:ColdKeySize]. That is the only way to +// craft equal run keys on demand, and it is the shape the ladder's tie +// geometry exists for (the natural one is a hash committed in several of the +// window's ledgers). +func prefixedHash(p byte, n uint32) [32]byte { + var h [32]byte + for i := range ColdKeySize { + h[i] = p + } + binary.BigEndian.PutUint32(h[28:], n) + return h +} + +// blindPrefix is the run key every prefixedHash(p, _) collapses to. +func blindPrefix(p byte) [ColdKeySize]byte { + h := prefixedHash(p, 0) + return stores.BlindKey(testBinSecret, h[:ColdKeySize]) +} + +// TestHotIndex_LadderBoundaryTie crafts a run whose page boundary splits a +// range of records sharing one key: the probe must read the NEXT page too +// when the target equals its ladder key, or every record past the boundary +// would be unfindable. +func TestHotIndex_LadderBoundaryTie(t *testing.T) { + m := &fakeManifest{} + h := testHotIndex(t, t.TempDir(), m) + defer h.Close() + h.sealEvery = 1 // the crafted ledger seals into exactly one run + + // 150 hashes under key 0x11, 100 under key 0x22: 250 records, so the + // page boundary at pageRecords (204) falls strictly inside whichever + // group sorts second — blinding decides which, and either way the + // boundary splits an equal-key range. + hashes := make([][32]byte, 0, 250) + for i := range uint32(150) { + hashes = append(hashes, prefixedHash(0x11, i)) + } + for i := range uint32(100) { + hashes = append(hashes, prefixedHash(0x22, i)) + } + row, err := EncodeRow(hashes) + require.NoError(t, err) + require.NoError(t, h.ApplyRow(7, row, len(hashes))) + require.NoError(t, h.reapSeal(true)) + + v := h.view.Load() + require.Empty(t, v.rows, "the crafted row must have sealed") + require.Len(t, v.runs, 1) + require.Len(t, v.runs[0].ladder, 2, "crafted run must span two pages") + k11, k22 := blindPrefix(0x11), blindPrefix(0x22) + tieKey := k22 // the second group holds record 204 either way + if bytes.Compare(k22[:], k11[:]) < 0 { + tieKey = k11 + } + require.Equal(t, tieKey, v.runs[0].ladder[1], "page boundary must split the equal-key range") + + require.Empty(t, h.view.Load().rows, "every hit below must come from the run, not the window") + for _, hash := range hashes { + got, gerr := h.Get(hash) + require.NoError(t, gerr, "hash %x", hash) + require.Equal(t, uint32(7), got, "hash %x", hash) + } + // A hash whose key is in NEITHER group misses outright; one sharing a + // crafted prefix is a genuine key collision, so the run answers a + // CANDIDATE ledger — resolved, not verified. The 32-byte check happens + // where the ledger is read (read_assembly.go), which is why the engine + // returns the candidate rather than a not-found. + _, err = h.Get(prefixedHash(0x33, 0)) + require.ErrorIs(t, err, stores.ErrNotFound) + seq, err := h.Get(prefixedHash(0x22, 200)) + require.NoError(t, err) + require.Equal(t, uint32(7), seq, "a key collision resolves to the run's record, unverified") +} + +// TestHotIndex_WritePassRoutingEqualsDrainRebuild pins the one-pass trust +// rule (runRouting): the routing state sealWindow builds while WRITING a run +// — no post-write re-read — deep-equals what the warmup drain +// (openSealedRun) rebuilds from the verified file: bloom bits, ladder +// contents, record count, ledger range. The crafted window spans three +// pages, splits equal-key ranges across both page boundaries (the ladder's +// tie geometry), and spreads the hashes over two ledgers so the seal's sort +// — not input order — dictates observation order. +func TestHotIndex_WritePassRoutingEqualsDrainRebuild(t *testing.T) { + // 200 hashes under key 0x11, 200 under 0x22, 100 under 0x33: 500 records + // = two full pages + a tail. Blinding permutes the three groups, but no + // permutation puts a group boundary (100/200/300/400) on a page boundary + // (204, 408), so both boundaries always split an equal-key range. + all := make([][32]byte, 0, 500) + for i := range uint32(200) { + all = append(all, prefixedHash(0x11, i)) + } + for i := range uint32(200) { + all = append(all, prefixedHash(0x22, i)) + } + for i := range uint32(100) { + all = append(all, prefixedHash(0x33, i)) + } + var a, b [][32]byte + for i, h := range all { + if i%2 == 0 { + a = append(a, h) + } else { + b = append(b, h) + } + } + rowA, err := EncodeRow(a) + require.NoError(t, err) + rowB, err := EncodeRow(b) + require.NoError(t, err) + + path := filepath.Join(t.TempDir(), "seal-000000.run") + fresh, err := sealWindow([]windowRow{{seq: 9, bytes: rowA}, {seq: 10, bytes: rowB}}, path, testBinSecret) + require.NoError(t, err) + defer fresh.close() + require.Len(t, fresh.ladder, 3, "crafted run must span three pages") + + reread, err := openSealedRun(path) + require.NoError(t, err) + defer reread.close() + assert.Equal(t, reread.bloom, fresh.bloom, "write-pass bloom diverges from drain rebuild") + assert.Equal(t, reread.ladder, fresh.ladder, "write-pass ladder diverges from drain rebuild") + assert.Equal(t, reread.records, fresh.records) + assert.Equal(t, reread.first, fresh.first) + assert.Equal(t, reread.last, fresh.last) +} + +// TestHotIndex_WriterReaderRace: concurrent Gets (present + absent) while +// the writer applies and seals. Run with -race. +func TestHotIndex_WriterReaderRace(t *testing.T) { + m := &fakeManifest{} + h := testHotIndex(t, t.TempDir(), m) + defer h.Close() + hh := newHarness(t, h, 700) + known := randHash(hh.rng) + hh.ledger([][32]byte{known}) + + stop := make(chan struct{}) + var wg sync.WaitGroup + for r := range 3 { + seed := uint64(r) + wg.Go(func() { + rng := testRNG(seed) + for { + select { + case <-stop: + return + default: + } + _, _ = h.Get(known) + _, _ = h.Get(randHash(rng)) // absent probes exercise blooms/ladders + } + }) + } + for range 60 { + hh.randomLedger(20, 2) + } + hh.settle() + close(stop) + wg.Wait() + hh.verifyAll() +} + +// TestHotIndex_DirentBarrierPrecedesManifestPut drives this engine through +// its one publish shape — plain seals; no merge tier — and pins the +// dirent-barrier-before-PutRuns order with the shared runsettest helper (the +// rationale lives on AssertBarrierPrecedesEveryPut; the events twin pins the +// same invariant over both of its shapes). +func TestHotIndex_DirentBarrierPrecedesManifestPut(t *testing.T) { + dir := t.TempDir() + log := &runsettest.PublishLog{} + m := &runsettest.RecordingManifest{Log: log} + h := testHotIndex(t, dir, m) // sealEvery=8 + defer h.Close() + h.fsyncDir = log.FsyncDir + hh := newHarness(t, h, 100) + + for range 3 { + for range 8 { + hh.randomLedger(5, 0) + } + hh.settle() + } + require.Len(t, h.view.Load().runs, 3, "three settled cycles must have sealed three runs") + + runsettest.AssertBarrierPrecedesEveryPut(t, log, dir, 3) +} + +// TestRocksdbManifest_RoundTrip covers the RocksDB-backed manifest the +// integration layer will hand to OpenHotIndex in step 2. +func TestRocksdbManifest_RoundTrip(t *testing.T) { + store, err := rocksdb.New(rocksdb.Config{ + Path: t.TempDir(), + ColumnFamilies: CFNames(), + Logger: silentLogger(), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + m := rocksdbManifest{store: store} + + names, lastSealed, err := m.GetRuns() + require.NoError(t, err) + assert.Empty(t, names) + assert.Zero(t, lastSealed) + + require.NoError(t, m.PutRuns([]string{"seal-000000.run", "seal-000001.run"}, 42)) + names, lastSealed, err = m.GetRuns() + require.NoError(t, err) + assert.Equal(t, []string{"seal-000000.run", "seal-000001.run"}, names) + assert.Equal(t, uint32(42), lastSealed) + + // A frontier with an empty run list round-trips (the engine never + // writes it, but the codec must not conflate it with "no manifest"). + require.NoError(t, m.PutRuns(nil, 7)) + names, lastSealed, err = m.GetRuns() + require.NoError(t, err) + assert.Empty(t, names) + assert.Equal(t, uint32(7), lastSealed) +} + +// TestRocksdbManifest_PublishGoldenBytes pins the manifest VALUE bytes end +// to end through runset.Publish and this engine's codec: 4B BE lastSealed ‖ +// csv of run basenames, live order then fresh. No byte-identity gate covers +// manifest values (all six are cold-artifact gates), so this golden value is +// the compatibility pin for warmups reading manifests written before the +// publish protocol moved into runset. (Publish's dispose-on-failure contract +// is pinned structurally in the runset package's own tests.) +func TestRocksdbManifest_PublishGoldenBytes(t *testing.T) { + store, err := rocksdb.New(rocksdb.Config{ + Path: t.TempDir(), + ColumnFamilies: CFNames(), + Logger: silentLogger(), + }) + require.NoError(t, err) + t.Cleanup(func() { _ = store.Close() }) + + dir := t.TempDir() + row, err := EncodeRow([][32]byte{{1}, {2}}) + require.NoError(t, err) + live, err := sealWindow([]windowRow{{seq: 4, bytes: row}}, filepath.Join(dir, "seal-000000.run"), testBinSecret) + require.NoError(t, err) + defer live.close() + fresh, err := sealWindow([]windowRow{{seq: 5, bytes: row}}, filepath.Join(dir, "seal-000001.run"), testBinSecret) + require.NoError(t, err) + defer fresh.close() + + require.NoError(t, runset.Publish(rocksdbManifest{store: store}, []*sealedRun{live}, fresh, 0x01020304)) + val, found, err := store.Get("", txhashManifestKey) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, append([]byte{0x01, 0x02, 0x03, 0x04}, "seal-000000.run,seal-000001.run"...), val) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go b/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go index 838154088..25384efed 100644 --- a/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go +++ b/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go @@ -32,6 +32,7 @@ package zstd import "C" import ( + "bytes" "errors" "fmt" "math" @@ -59,11 +60,55 @@ type Compressor struct { ctx *C.ZSTD_CCtx } +// EncoderState pairs one compressor with its retained destination buffer — +// the reuse idiom for zstd encode state (a CGo context is expensive to +// create; a fresh worst-case dst per Encode was measured at 43% of hot +// ingestion's allocations before reuse). NOT safe for concurrent use: the +// owner serializes Encodes (the ledger hot store's single-flight +// compression). Deliberately NOT a sync.Pool: the state is one expensive, +// long-lived object, and sync.Pool's GC-emptied semantics were measured +// dropping it ~1-in-5 ledgers under per-ledger GC cadence — a ~15MB dst +// re-allocation plus a CGo context re-init each time. Callers' consumers +// must copy synchronously (e.g. rocksdb BatchWriter.Put, packfile +// AppendItem do). +type EncoderState struct { + comp *Compressor + buf []byte +} + +// NewEncoderState returns an encoder state with the given compressor options. +func NewEncoderState(opts ...CompressorOption) *EncoderState { + return &EncoderState{comp: NewCompressor(opts...)} +} + +// Encode compresses src into the retained buffer and returns the encoded +// bytes, which are valid until this state's next Encode. +func (s *EncoderState) Encode(src []byte) ([]byte, error) { + out, err := s.comp.Encode(s.buf[:0], src) + if err != nil { + return nil, err + } + s.buf = out[:cap(out)] + return out, nil +} + // CompressorOption configures a Compressor. type CompressorOption func(*compressorConfig) type compressorConfig struct { checksum bool + workers int +} + +// WithWorkers enables zstd's internal multithreaded compression (n worker +// threads inside libzstd). The output is ONE standard frame — same header +// shape FrameHeaderValid gates, deterministic for fixed input+params — so +// the cold-inherits-hot frame contract is unaffected; the trade is a ≲1% +// ratio cost from job splitting. Requires a ZSTD_MULTITHREAD build of +// libzstd; NewCompressor panics loudly if the library refuses the +// parameter (a silent single-threaded fallback would fake the experiment). +func WithWorkers(n int) CompressorOption { + return func(c *compressorConfig) { c.workers = n } } // WithoutChecksum disables the zstd content checksum. @@ -91,6 +136,11 @@ func NewCompressor(opts ...CompressorOption) *Compressor { if cfg.checksum { flag = 1 } + if cfg.workers > 0 { + if rc := C.ZSTD_CCtx_setParameter(ctx, C.ZSTD_c_nbWorkers, C.int(cfg.workers)); C.ZSTD_isError(rc) != 0 { + panic("zstd: libzstd built without ZSTD_MULTITHREAD — WithWorkers unavailable") + } + } if rc := C.ZSTD_CCtx_setParameter(ctx, C.ZSTD_c_checksumFlag, flag); C.ZSTD_isError(rc) != 0 { C.ZSTD_freeCCtx(ctx) panic("zstd: set checksum flag: " + C.GoString(C.ZSTD_getErrorName(rc))) @@ -258,3 +308,53 @@ func (d *Decompressor) Decode(dst, src []byte) ([]byte, error) { func Decode(dst, src []byte) ([]byte, error) { return NewDecompressor().Decode(dst, src) } + +// Frame_Header_Descriptor bit layout (RFC 8878 §3.1.1.1.1): bits 0-1 are the +// Dictionary_ID_flag, bit 2 the Content_Checksum_flag. +const ( + frameDescriptorDictIDMask = 0x03 + frameDescriptorChecksumFlag = 0x04 +) + +// frameMagic is the zstd frame magic number 0xFD2FB528 as it appears on disk +// (little-endian). +var frameMagic = []byte{0x28, 0xB5, 0x2F, 0xFD} //nolint:gochecknoglobals // immutable format constant + +// FrameHeaderValid checks — without decompressing — that src is a single +// zstd frame this package's other half can serve: magic number, a recorded +// frame content size (present and <= MaxUint32, the bound packfile item +// lengths live under), no dictionary ID (the shared Decompressor is +// dictionary-less), and the content checksum flag set (Compressor's default; +// the checksum is what makes a later corrupt read loud). +// +// This is the freeze-time guard for verbatim frame copies (hot ledgers CF → +// cold pack): it pins the invariant that hot ledger values remain plain, +// dictionary-less, content-sized, checksummed single frames. Any hot-side +// change that breaks one of these must revisit the cold ledger format in the +// same commit. +func FrameHeaderValid(src []byte) error { + if len(src) < 5 { + return fmt.Errorf("zstd: frame header: %d bytes, want >= 5", len(src)) + } + if !bytes.Equal(src[:4], frameMagic) { + return errors.New("zstd: frame header: bad magic") + } + descriptor := src[4] + if descriptor&frameDescriptorDictIDMask != 0 { + return errors.New("zstd: frame header: dictionary ID present; the shared decompressor is dictionary-less") + } + if descriptor&frameDescriptorChecksumFlag == 0 { + return errors.New("zstd: frame header: content checksum flag unset") + } + fcs := C.ZSTD_getFrameContentSize(unsafe.Pointer(&src[0]), C.size_t(len(src))) + switch fcs { + case C.ZSTD_CONTENTSIZE_ERROR: + return errors.New("zstd: frame header invalid") + case C.ZSTD_CONTENTSIZE_UNKNOWN: + return errors.New("zstd: frame header carries no content size") + } + if uint64(fcs) > math.MaxUint32 { + return fmt.Errorf("zstd: frame claims content size %d > MaxUint32", uint64(fcs)) + } + return nil +} diff --git a/cmd/stellar-rpc/rpcv2/rpc-v2-sample-config.toml b/cmd/stellar-rpc/rpcv2/rpc-v2-sample-config.toml index 2071c524b..92e686112 100644 --- a/cmd/stellar-rpc/rpcv2/rpc-v2-sample-config.toml +++ b/cmd/stellar-rpc/rpcv2/rpc-v2-sample-config.toml @@ -47,6 +47,14 @@ default_data_dir = "/var/stellar/rpc-v2" #txhash_index = "" # {default_data_dir}/txhash/index — frozen per-window .idx files #hot = "" # {default_data_dir}/hot — per-chunk hot RocksDB databases +# Ledger-frame zstd encode parallelism: 0 = single-threaded, >= 1 uses libzstd's +# internal multithreading (requires a ZSTD_MULTITHREAD build). FORMAT-AFFECTING, +# not a casual performance knob: the same value feeds the hot encoder and the +# walk/backfill cold writer so a chunk's pack stays byte-identical whichever +# materializer built it — change it only as a deliberate, deployment-wide +# decision. Default 2. +#zstd_encode_workers = 2 + # ----------------------------------------------------------------------------- # [retention] — how much history this daemon keeps # ----------------------------------------------------------------------------- diff --git a/design-docs/full-history-campaign-handoff.md b/design-docs/full-history-campaign-handoff.md new file mode 100644 index 000000000..9279dba47 --- /dev/null +++ b/design-docs/full-history-campaign-handoff.md @@ -0,0 +1,357 @@ +# Full-history performance campaign — handoff (2026-07-26) + +Written for the next agent/machine picking this up. The bench artifacts and +per-machine memory files do NOT travel; this document carries everything +load-bearing. The previous box was an m6id.2xlarge (8 vCPU, 32GB, one 441GB +instance-store NVMe at /mnt/data) — verify any new box with lsblk/nproc +before trusting assumptions. + +## Where things stand + +**Goal:** hot-ingest per-ledger `ingest_total` p99 ≤ 100ms at Phase-3 load +(sac-6000: 6,000 tx/ledger, 600ms cadence), knob-free, at all times. + +**Status: met on a single-disk box for everything except the txhash index +rebuild window**, and the settled architecture (below) closes that too: + +| regime | ingest p99 | notes | +|---|---|---| +| solo steady-state | 63.7-64.1ms | flat per-decile across a full 10k chunk; RSS: steady ~2GB, transient merge-generation peaks ~4.6GB (see RAM anchor below) | +| during freeze (unpaced, single disk, post-review) | 111.0 | commit p99 82; 1.25% ledgers >100ms; co-located freeze wall ~8min; pre-review basis was ~122-134 — the streamed .bin improved it; superseded by #932 key blinding — re-measure | +| during freeze (old walk freeze, like-for-like) | 141.9 | for the full 10min cycle — the new freeze is strictly better | +| during txindex rebuild (single disk) | ~152, p50 +29 | recurs EVERY chunk boundary; see architecture | +| post-co-runner | instantly solo | every co-runner, every trace | + +**Post-review confirmation (2026-07-24, single-disk, after the fh/07 review +fixes):** hot solo p99 65.9 (p50 49.7, max 70.2 — within the ±5% band of the +63.7 baseline; the SRT reader-race/leak fixes and sort swap cost nothing), +hot RSS 1.15GB; freeze --reuse-hot wall 7m23, **peak RSS 8.4→6.4GB** (the +streamed .bin deleted the ~1.2GB whole-chunk accumulator — measured pre-keyed-routing; #932's key blinding briefly reinstated a whole-chunk blinded-sort accumulator in the txhash freeze arm and a blinded re-spill pass in the events one, but blind-at-seal deleted BOTH: freeze RAM is now the merge's cursors plus one tail row (txhash) and the 32MB tail windows (events). Re-measure — these anchors predate all of it). +Post-review co-location (single disk, unpaced): freeze-window-conditional +ingest p99 111.0 / commit p99 82.4 / 1.25% of ledgers >100ms; post-freeze +p99 65.0 = solo. ~11-23ms better than the pre-review 122-134 basis — +consistent with the streamed .bin removing the txhash arm's end-of-arm +write burst. WATCH-ITEM: the freeze's events arm read 4m44 solo vs its +4m25-28 baseline — single sample just outside noise while other arms +matched exactly; re-measure early in the 2-disk campaign, noting that arm +still owns one bench-gated follow-up (RunReader per-byte decode; the +writer-side bloom/fence collection has since landed in c5e5fed4 — see +"What to do next" §5). + +**Wave 2 (2026-07-26, the four commits before this one):** a second +hot-latency pass — txhash one packed row per ledger (a window+sealed-runs +engine replacing ~6k random 32B memtable keys; commit p50 20.5→7.9ms, +freeze txhash arm 4.7× faster, point lookups 2-3× faster with zero +mismatches over 800k verified lookups), an events flat-pair/bucketed-sort +marshal rewrite (~30k write-path allocations/ledger → ~1), and an +config-selected multithreaded zstd encode (`storage.zstd_encode_workers`, +default 2; removes the ~21ms pre-commit encode floor; format-affecting — +one resolved value feeds both the hot and walk encoders so they agree). Measured in this PR alone: solo +p50 48.8→33.7 / p99 62.9→46.8. With the redesigned views SDK (the +`views-walk` branch — pushed, pin-bumped in this stack; the successor of +the fused-views experiment) plus workers=2: **p50 29.3 / p99 40.9**. Co-location +re-verified on the new stack: outside-freeze = solo exactly, blended p99 +98.8 on ONE disk, window-conditional 124.4 (same write-collision class as +before — the separate-disks architecture still owns that residual). New +trade to know: opening a full-density chunk drain-verifies all sealed +txhash runs (~7.5s per process start). + +**Views-walk port (2026-07-27, the three commits before this one):** the +SDK's redesigned views/streaming API (two-tier visitor: one generated +Walk under a streaming `StreamLedgerEvents` extractor), consumed via +per-transaction shaping (`events.PayloadShaper`) with no whole-ledger +slice materialized. Four-cell A/B vs the previous stack: hot +ingest_total p50/p99 34.43/47.61 → 28.74/39.58, extract −46%, cold chunk +build 8m27s → 7m44s, RAM flat. Artifact bytes unchanged (freeze-vs-walk +and shared-walk identity gates arbitrate). + +**SMT finding (encoder mid-tail):** the multithreaded encoder's mid-tail +contention was confirmed as SMT-sibling collisions — it disappears with +one-thread-per-physical-core pinning (measured: total p90 37.8→29.2, p99 +39.6→33.7, events p99 back to 7.9). Deployment guidance: give ingest +exclusive PHYSICAL cores; co-located validation owed at the 2-disk +campaign. + +**Branch:** 21 commits, one per concern: bench diagnostics → hot-latency +wave 1 → the events Sorted-Run Tier and its spill-and-merge cold build → +the zero-decompression freeze (which also owns the cold pack's freeze-time +content hash) → hot-latency wave 2 → delta postings → the storage-layer +consolidation → the docs commit that first added this document, mid-stack +→ keyed cold routing and blind-at-seal → the lint follow-ups → the +go-stellar-sdk pin, second from the tip → a second docs commit at the tip +(this paragraph's own). The views-walk port is not in the stack — it is in +the base now — so that pin is a go.mod/go.sum-only commit with no call-site +adaptation behind it. Every commit builds; `stores/ledger` and +`stores/txhash` pass at every commit from the freeze onward, and the tip +passes `go test -race ./cmd/...`. Byte-identity gates (freeze-written vs +walk-written artifacts, per-store AND full-composition) pass and must stay +as permanent gates. + +**RAM anchor (corrected 2026-07-27; the earlier "~1.5GB" was a 1k-ledger +number that ends before the first run-merge).** Full-chunk hot RSS is +merge-generation PEAKS, not a floor: steady state runs ~1.5-2.1GB and spikes +at each sorted-run merge (~every 2,300 ledgers; largest at chunk end: +~4.6GB since the one-pass bloom/fence construction landed (pre-change: +5.22GB unpaced here, 5.1GB paced on a second box — pacing barely moves +the peak; the one-pass change is what cut it). Decomposition at peak: merged-run drain transients (fingerprint +slice ~264MB + jumbo concurrent seal fold) x2 GC headroom + ~1GB flat +native; RocksDB metadata is FLAT (write path never populates the block +cache) and the dense-term overlay measured zero on this dataset. Two budget +notes: run blooms exceed the design's worst-case on stress-density term +cardinality (241MB vs 120MB budgeted) and displaced generations' blooms are +freed only at chunk Close (~40-60%% of late-chunk bloom memory is retired +routing state — a known, bounded wart). DANGER: GOGC=1000 composes with +~2.2GB merge-window live heap into a >20GB target — never ship raised GOGC +without GOMEMLIMIT. Optional caps if a smaller box ever demands them: +GOMEMLIMIT ~3.5GiB (env-only; peak -> ~3.5-4GB) and epoch-based +retired-run release (small code, ~-0.5GB); the one-pass bloom build that +used to sit on this list landed (c5e5fed4) and is already in the peak. + +## The settled architecture (tamir's rulings) + +1. **Hot DB and cold artifacts on SEPARATE devices.** Kills the entire + write-interference channel (probe-proven: an unpaced copy stream makes a + bystander fdatasync wait ~50ms at the MEDIAN; at 200MB/s it is innocent + at 1.2ms). All freeze write pacing was therefore REMOVED (commit + 79a2390a-era; freeze runs full speed). Deployment detail: m6id.2xlarge + has ONE instance store — separation needs EBS or a 2-NVMe type. EBS + bonus: cold artifacts survive instance stops. +2. **The rolling txhash index rebuild stays monolithic** (tiles REJECTED — + per-lookup fan-out cost; gettransaction design doc §6.3's one-live-index + invariant). Its bandwidth interference (memory-DRAM, unfixable by any + disk topology; thread-capping REFUTED, 8→4 cores barely moved + damage×duration) is handled by: +3. **A consumption pacer in BuildColdIndex** — NOT YET IMPLEMENTED; the one + required mitigation. Duty/rate throttling proven superlinearly effective + (membw hog beside paced hot: 100% duty → p99 271; 50% → 111; 25% → 86 ≈ + near-solo; the memory subsystem has a congestion knee). Design: pace on + input .bin bytes at the merge's AddKey consumer (~120-150MB/s ≈ 25-30% + draw); channel backpressure throttles the whole tree; O_DIRECT leaf + readers stall clean. ~15 lines + a min-sleep quantum (batches are 82KB + → accumulate debt, sleep ≥5ms slices). Gate: rerun the hot-beside-build + colo cell, expect during-build p99 ≤100 (was 152/142). +4. **Coverage lag is the scheduling relief valve** — designed-in, not a + hack: a frozen-but-unindexed chunk keeps its hot DB (discard is gated on + FrozenIndexCovers) and serves queries from it (gettransaction doc: "the + hot tier serves the live chunk plus any frozen chunk the window index + doesn't cover yet... Coverage can lag the chunk's freeze by a while"). + Lag equilibrium L ≈ T_build/(B − T_freeze) ≈ 1.4 boundaries at + stress-density window-end; self-resets each window rollover; cost = L + extra retained hot DBs (~40GB each at stress density). + +## Key mechanism findings (all probe/trace-verified) + +- **Writes interfere via the device write queue**; fsync waits behind deep + async writeback. Initiate-only sync_file_range does NOT protect + (windowed-WAIT refuted for queues — it bounds dirty pages, not queue + occupancy); only rate reduction or device separation does. +- **Reads are innocent** — buffered AND O_DIRECT floods leave a concurrent + fdatasync at its floor. Proven twice (trace arm analysis + dedicated + dd cells). +- **Memory bandwidth is the channel no disk topology fixes** — a zero-I/O + streamer hog costs hot p99 271 at full duty. Rate-controllable + (superlinear knee). The index build is the one bandwidth-heavy workload; + captive core may be the second (UNMEASURED). +- **Dirty-page pool is global across devices** — a multi-GB buffered write + draining to a SLOW cold device (EBS) can throttle hot writers through + kernel dirty limits. In practice covered by the BuildColdIndex pacer + (which caps .idx production rate) and by small per-boundary write sizes + at pubnet density; the streamhash .idx writer itself is library-internal + and unsmoothed (see deferred list). +- **Rolling rebuild frequency**: the window index rebuilds at EVERY chunk + boundary, growing 1→1000 chunks (O(window²) total merge I/O — accepted + waste, off the hot device under separation). Terminal window ≈ 3B keys + pubnet ≈ 2min at ~24M keys/s; RSS FLAT (~200MB at 7.5B keys — sorted + builder streams; measured, no OOM risk). + +## Rulings — consolidation pass (2026-08-02; series landed 2026-08-03) + +A design sweep for consolidation/unification wins across the storage layer. +The accepted items landed as the 13-commit series on c0991ded; these are +the paid-for NEGATIVE results plus post-landing rulings, recorded so the +next pass does not re-derive them. + +- **packfile content-hash subsystem — KEEP; a proposed deletion was + overruled by the owner before it shipped.** The panel read "tamir + accepted the detection-at-first-cold-read trade" as a standing rejection + of read-back verification and judged the subsystem dead code (zero + production callers); the owner's actual intent is that it is designed, + kept infrastructure for the deferred freeze read-back verify option + (next-steps §6 below). Deletions of designed-but-unwired infrastructure + require an explicit owner confirm. +- **Heap unification — 3 of 5 landed** (stores/txhash/merge_heap.go absorbs + the seal and freeze heaps; the slot reorder heap went typed). The two + hot-loop holdouts (txhash cold merge, runspill merge) stay separate, + bench-gated — their in-code notes are the durable record. +- **ETR1/EVR2 container fold — rung ladder, one rung per landing.** Rung 0 + (one runspill write path, WriteRun deleted) landed; the terms.run fold is + the next rung and lands only behind BOTH bench cells 067d7f9f names — + the freeze-wall events arm AND the backfill chunk-build wall, since + WriteColdIndexFromRuns runs on both paths. Never land-then-supersede two + rungs in one pass. + +- **A re-minted catalog secret bricks every existing HOT chunk — accepted + posture, not an oversight.** Hot chunks are bound to the catalog secret by + design: the first read-write open stamps each engine's derived secret into + the chunk DB, and every later open must present the same one, because the + chunk's sealed runs are already blinded with it and the freeze copies those + bytes into the cold artifact. Cold artifacts, by contrast, self-describe — + the `.bin` header and `index.hash`'s user metadata each carry the secret + they were keyed with — so they survive a remint and stay queryable. A + remint therefore means re-ingesting the hot chunks, and nothing else. There + is no re-keying path and no adopt-from-DB fallback: silently accepting the + DB's own secret would let a mis-derived caller freeze an artifact under a + key its catalog cannot reproduce. + +The standing bar is 83a6ae47's: +elegance is FEWER mental models, not more abstraction — a change that +relocates a model rather than removing one fails even when it is free. + +- **Cold-routing trust model (ruling, 2026-08-27):** indexes are routing + hints; ledger data is the truth. Every candidate from any tier is verified + at use by locating the tx in the fetched ledger by its FULL hash. Cold + indexes false-positive routinely (MPHF answers for absent keys; 1-byte + fingerprint) and scan as soft candidates. Hot sealed runs store real + 16-byte blinded keys, so a probe matches only on same-hash or a ~2^-128 + keyed collision — a failed hot verification is treated as corruption (hard + ErrInconsistent + operator metric, no fallthrough). A TRUE collision is + moot at read time regardless: the same duplicate blinded key + deterministically fails the chunk's cold index build (streamhash rejects + duplicates loudly), so the read path cannot save a colliding chunk — only + report it sooner. Verified-candidate fallthrough and per-hit row + verification were both considered and rejected (the latter measured: a + point-read on ~97% of hot hits). +- **A shared `Cursor[K]` interface over the merge cursors — REJECTED.** The + four cursors carry four different end/error/cancel models: txhash cold's + `streamReader.advance` returns a bare bool and the caller re-reads + `m.ctx.Err()` to tell cancellation from clean end (stores/txhash/ + cold_merge.go); `runspill.RunReader` and txhash's `runSource` treat + reaching EOF as the integrity point (the CRC is verified there, so a + cursor that stops early has verified nothing); `tailRowSource` is + infallible RAM. One interface would have to carry the union of all four, + and `mergeStream`/`finalMerge` already resist it in-file. +- **A shared fence/ladder router (events fenceBuilder + txhash page ladder) + — REJECTED.** The two geometries are earned, not incidental: byte- and + record-capped fences for variable-width EVR3 records, fixed-cadence WHOLE + routing keys for TXHRUN02's fixed-width ones. +- **Rewriting the events seal as a k-way merge (txhash's shape) — + REJECTED.** The map-fold/sort asymmetry is by design (events aggregate + per-term across the window; txhash rows arrive pre-sorted), and this is a + live-ingest perf path. +- **A cross-family run container (folding ETR1's terms.run into EVR2) — + DEFERRED** until the ETR1 cluster lands; revisit with that work, not + before. +- **Exporting `events.MergeAscending` to replace runspill's + `unionAscending` general case — REJECTED.** Grounds, corrected against + the proposal's own cost model: it is allocation-NEUTRAL (the general case + already allocates fresh — runspill/merge.go's `make` + `slices.Clip`), so + there is no perf motive in either direction; and "it's a cold path" is + not a valid dismissal either, since `MergeRuns` sits on the freeze's + dominant events arm (stores/event/cold_index_stream.go) AND the merge + fold that runs concurrent with live ingest (stores/event/ + hotindex_seal.go). What is left is −17 LOC of frozen two-pointer code + bought with an exported cross-package aliasing contract (dst must not + alias a or b) — the varint precedent (83a6ae47) rules that out. The two + stay deliberate duplicates with a cross-reference comment each; neither + is a lockstep-versioning hazard, since each is independently pinned + (TestWriteColdIndexFromRuns_ByteIdentical covers the build side). + +## What to do next (in order) + +0. **Merge the SDK `views-walk` branch** into go-stellar-sdk main. This + stack already pins its pushed tip + (v0.6.1-0.20260727053836-9156a311aae9), so the PR is deliberately + unmergeable until that lands. (The zstd + workers knob is already a config field — `storage.zstd_encode_workers` + / bench `--zstd-workers`, default 2 — threaded to BOTH the hot and + walk encoders, which must agree because it is format-affecting.) +1. **RESOLVED (2026-07-27): hardware class supersedes the 2-disk campaign.** + Cross-box measurement on a c6id.8xlarge (16 physical cores, 1.9TB NVMe) + showed single-disk co-location meets every gate with no pacing: freeze + beside hot = +~3ms window p99 (device queue never saturates), full + 999-chunk txindex rebuild beside hot = p99 48.3ms, zero >100ms (memory- + bandwidth knee never approached; build itself 35.9M keys/s, RSS 351MB). + Production recommendation: c6id.8xlarge-class or better; separate disks + and the BuildColdIndex pacer remain documented mitigations for smaller + classes only. The interference-channel fingerprints (disk-queue vs + bandwidth vs SMT) are recorded above for diagnosing any new class. +2. **Implement + validate the BuildColdIndex pacer** (design above). Wire + as a parameter production passes from the backfill call site; bench flag + for A/B; probe scripts in scripts/probe/ rebuild the duty-response curve + on the new box if its knee differs. +3. **Captive-core characterization** — the LAST unmeasured mandatory + co-runner. Method: scripts/probe/prober.py (WAL-shaped fsync prober, + 4/s × 2MiB) beside core doing steady close-following, then a catchup; + plus a 300-ledger paced hot run beside each. If bandwidth-heavy, the + duty-throttle class is proven; if write-heavy, separate its buckets + onto the cold device. +4. **Deferred cold-device batch** (when EBS vs 2nd-NVMe is decided): + writeback smoothing for the two non-packfile cold writers (txhash .bin + stream, streamhash .idx — needs a streamhash build option), and the + packfile mechanism-default BytesPerSync question (review skip-note). +5. **Review skip-notes worth revisiting** (from the simplify/reuse pass, + commit aa599c12): RunReader per-byte varint decode (~5-10s/merge, + bench-gated — and note 83a6ae47 already reverted one batched attempt on + complexity-bar grounds), terms.run frame generalization (now rung 1 of + the ETR1 ladder — see §Rulings), and ledger.AddLedgerToBatch deletion + (STILL PENDING — the housekeeping commit did not take it; its callers + are test-only today, ledger/hot_store.go). **Writer-side bloom/fence + collection LANDED** (c5e5fed4's one-pass `runRouting` in + stores/event/hotindex_seal.go, extended to txhash by the consolidation + series; explicitly retained by 83a6ae47's revert of the rider above + it). **Heap unification landed 3-of-5** — see §Rulings for the scoped + terms and the two bench-gated holdouts. +6. **Optional:** freeze read-back verify flag (XXH64 copy-hash + + post-Commit sequential compare — stronger than the old freeze's + verification; tamir accepted the detection-at-first-cold-read trade). +7. **#856 query-side work** — includes the UNMEASURED writes→reads + direction (does a write burst hurt cold-read latency on the cold + device) and cold-query first-touch costs under any fadvise decisions. +8. **Duplicate-key sort exposure (profile-gated follow-on).** + `sortPairPerm`'s per-bucket comparison sort degenerates on any + high-multiplicity term: the stability tiebreak makes equal keys never + compare equal, defeating pdqsort's duplicate handling, so a term + emitted by every event costs O(m log m) on pairs whose arrival order + is already correct. Constant-key terms (event type, topic count) are + routed around the pipeline via the side lanes; *hashed* firehose terms + (a dominant contract ID, a hot topic — sac-6000 carries three) still + pay it, inside all recorded numbers. Before generalizing (equal-run + detection in the bucket sort), get one CPU profile of a pubnet-shaped + workload and read `sortPairPerm`'s self time; the lanes' differential + gates are the template for validating any change there. + +## Bench quick reference + +- Hot: `stellar-rpc bench-ingest hot --source=pack --pack-dir= + --start-chunk=1 --num-ledgers=1000 --close-interval=600ms --hot-dir= + --out= [--trace=]` — solo p99 baseline 63.7; ±5% tail noise; + ≥300 ledgers minimum for p99 claims. +- Freeze: `bench-ingest freeze --chunk=1 --work-dir= [--reuse-hot]` + — populate once (~10min), reuse thereafter; no bulk backend is configured + so success PROVES the hot-DB freeze route ran; `--reuse-hot` gives clean + freeze-only RSS/profile. cold_extract row must be ABSENT (asserted). +- Txindex: `bench-ingest txindex --bin-dir= --num-bins=N --index-out=` + — fixtures via the generator pattern in the txindex bench tests (60M + entries/chunk = sac-6000 density; 3M = pubnet). +- Dataset: gs://rpc-full-history/synthetic-ledgers/2026-07-18-apply-load-20k/ + (sac-6000; ~12min download; issue #867). +- Probe kit: scripts/probe/ — prober.py (paced WAL-shaped fsync prober), + writer.py (copy-signature + windowed-WAIT + rate-limited writers), + hog.py (duty-cycled membw hog). Escalating-pollution ordering, sync + + dirty-drain (<200MB) + ≥30s idle between cells; never pkill -f a pattern + contained in your own launcher's cmdline (it self-kills the harness task). + +## Traps the hard way (do not relearn) + +- Compare like-for-like only: co-location numbers are only comparable at + the same co-runner, duty, and window basis (two wrong-baseline incidents). +- The severity model drives design ranking — verify frequency assumptions + against the design docs BEFORE architecting (the once-per-69-days error + inverted an architecture recommendation; the rebuild is per-boundary). +- Read design-docs/ first: gettransaction-full-history-design.md answered + the coverage-lag question that nearly motivated a redesign. +- Benchmarks are box-serial: nothing heavy (including go build) beside a + measuring cell. Background tasks may be reaped by the harness — launch + long runs with setsid + nohup fully detached, monitor via status files. +- sac-6000 is a STRESS dataset (20× pubnet tx density). Size window/RAM + claims at pubnet density unless stress-testing deliberately. diff --git a/design-docs/full-history-streaming-workflow.md b/design-docs/full-history-streaming-workflow.md index b2fccf167..91e961b87 100644 --- a/design-docs/full-history-streaming-workflow.md +++ b/design-docs/full-history-streaming-workflow.md @@ -202,7 +202,7 @@ CFs share the instance's WAL, so each ledger commits as **one atomic WriteBatch ### Catalog keys -The catalog holds three groups of keys: per-chunk artifact state keys, hot DB state keys, and the config pin. +The catalog holds four groups of keys (including the deployment's cold-index secret under meta/): per-chunk artifact state keys, hot DB state keys, and the config pin. **Artifact state keys**: @@ -992,7 +992,7 @@ Properties we rely on the underlying storage to provide: - **Sync WAL.** All catalog puts and deletes that the invariants depend on use RocksDB's `WriteOptions.sync = true`, which fsyncs the WAL before the write returns. Multi-key commits — the index commit batch, the sweeps' key-delete batches — are single atomic synced WriteBatches: all-or-nothing across keys. - **Per-ledger durability.** The chunk hot DB's synced WriteBatch (atomic across all CFs) is the sole per-ledger durability boundary; the last committed ledger is derived from it. Per-artifact: the per-chunk files **and the directory entries naming them** are fsynced before the key flips to `"frozen"`, and an index coverage's `.idx` (and the directory entries naming it, including a just-created window dir's own entry) is fsynced before the commit batch freezes its key. - **Read-side WAL recovery.** A read-only open of a hot DB recovers the synced write-ahead log, so everything the last synced batch committed is visible to a reader — the freeze's completeness check and the startup derivation both depend on this, not just on the write side of the sync-WAL guarantee. -- **Deterministic, idempotent writes.** Re-applying any write from the same source produces byte-identical state: tx-hash entries are keyed on consensus-fixed transaction hashes (deterministic from any backend), and the streamhash index is byte-identical from byte-identical sorted inputs. Ledger bytes are deterministic per source: a lake serves the LCM bytes it stores, but captive-core replay is not guaranteed byte-stable across core versions — byte-level comparisons must use the same source and version. +- **Deterministic, idempotent writes.** Re-applying any write from the same source produces byte-identical state: tx-hash entries are keyed on SipHash-blinded transaction-hash prefixes — deterministic from any backend GIVEN the deployment's catalog secret, and the streamhash index is byte-identical from byte-identical sorted inputs. Ledger bytes are deterministic per source: a lake serves the LCM bytes it stores, but captive-core replay is not guaranteed byte-stable across core versions — byte-level comparisons must use the same source and version. - **Monotonic progress.** Within a process run, ingestion only moves forward: each synced batch extends the last, and the last-complete-chunk it hands the lifecycle climbs with it (strictly increasing chunk ids). Across a crash, the startup derivation equals exactly the durable state — the pre-crash value, or a hair above it (a batch that committed in the instant before the crash). It lands *below* the pre-crash value only when state was demoted or lost: surgical recovery shrinks the derivation's inputs by demoting them. For example, demoting hot state to `"transient"` rewinds the derivation to the durable cold boundary; demoting a finished window's index on a daemon interrupted during its first backfill (no hot DBs to anchor the derivation) can drop it below that window until backfill rebuilds the index — re-deriving the untainted chunks from their on-disk `.pack`s and re-fetching only the tainted ones. ### Design invariants @@ -1021,7 +1021,7 @@ INV-1 holds at every point the daemon is serving reads — transient states are **Why every hot DB at or above the taint, not just the tainted one.** A demoted hot DB is never a source, so demoted *complete* chunks are re-derived as cold files by backfill; only the live partial chunk is repaired by re-ingestion, which replays **forward** from the last committed ledger. That watermark derives from the durable cold artifacts plus the highest `"ready"` hot DB — so demoting only the tainted chunk would leave a higher `"ready"` chunk, ultimately the live one, pinning the watermark above the taint, and the replay would never reach it. Once every hot DB at or above the lowest tainted chunk is demoted, the watermark falls to the highest durable boundary below the taint, backfill re-derives the demoted complete chunks from the backend, and re-ingestion replays the live tail forward. Every recovery demotes; nothing is removed by hand — the daemon's own sweeps and `openHotDBForChunk` handle the dirs in their existing crash-safe order. 4. **First deployment / downtime between restarts.** The last committed ledger derives to `max(frozen/hot maxima, earliest_ledger - 1)`, ensuring `resumeLedger ≥ earliest_ledger`. Backfill fills `[retentionFloorChunk(target), target]` per pass, if anything is missing (usually a no-op for an `earliest_ledger = "now"` first deployment — unless the tip has already completed the pinned chunk). -5. **LedgerBackend choice or mid-flight swap.** The LedgerBackend contract guarantees canonical LCM bytes for any range, so any conformant backend produces byte-identical artifacts. Different backends differ in performance, not behavior. An operator using BSB for backfill and CaptiveCore for hot DB ingestion, or swapping mid-deployment, satisfies all four invariants. +5. **LedgerBackend choice or mid-flight swap.** The LedgerBackend contract guarantees canonical LCM bytes for any range, so any conformant backend produces byte-identical artifacts WITHIN a deployment (cold index routing is keyed by the deployment's persisted catalog secret, so cross-deployment artifact bytes differ by design). Different backends differ in performance, not behavior. An operator using BSB for backfill and CaptiveCore for hot DB ingestion, or swapping mid-deployment, satisfies all four invariants. 6. **Crash at any point during any of the above.** Sync WAL plus per-ledger durability ordering mean the catalog on next start is internally coherent and the derived last committed ledger equals exactly what the last synced batch committed. Idempotency means re-running any half-finished op is safe. Convergence finishes whatever the crash interrupted. ### What a bug looks like diff --git a/design-docs/getevents-full-history-design.md b/design-docs/getevents-full-history-design.md index 13b1706e1..fffe47d87 100644 --- a/design-docs/getevents-full-history-design.md +++ b/design-docs/getevents-full-history-design.md @@ -130,7 +130,7 @@ For each unique value seen in an indexed field, the system stores a roaring bitm (topic0, 0x7b2c...) → {0, 1, 5, 12, 13, 14, 98, 102, ...} ``` -Each term is identified by a 16-byte key: `hash(value bytes || field byte)`, where the field byte encodes the field type (contractId, topic0–3). Including the field byte in the hash input ensures uniqueness across fields. The resulting 16-byte key is used directly as a lookup key in the in-memory index and can be fed into the MPHF without re-hashing. +Each term is identified by a 16-byte key: `hash(value bytes || field byte)`, where the field byte encodes the field type (contractId, topic0–3). Including the field byte in the hash input ensures uniqueness across fields. The resulting 16-byte key is used directly as a lookup key in the in-memory index and can be blinded with the chunk's routing secret (stores.BlindKey) and fed into the MPHF. Roaring bitmaps are used because term density varies widely across the data. Most terms match very few events while common terms like "transfer" can match millions. Roaring bitmaps handle this efficiently across the full range: array containers for sparse terms (where a full bitmap would be mostly empty) and compressed bitmap containers for dense terms (where a sorted array would be too large). diff --git a/design-docs/gettransaction-full-history-design.md b/design-docs/gettransaction-full-history-design.md index a4414caaa..ffe4f2f03 100644 --- a/design-docs/gettransaction-full-history-design.md +++ b/design-docs/gettransaction-full-history-design.md @@ -76,16 +76,36 @@ Window 0 spans ledgers 2–10,000,001 (chunks 0–999), window N spans N×10M+2 ### 5.1 Storage -The hot tier is a plain key-value table, one per chunk, stored as a `txhash` column family in that chunk's RocksDB: - -- **Key**: the full 32-byte transaction hash. -- **Value**: the 4-byte ledger sequence. - -Storing the full hash makes the hot tier **exact**: a lookup either finds the hash or it doesn't. There are no false positives to screen out and nothing to verify. The table is tuned for point lookups. +The hot tier is a packed-row engine, one per chunk, layered over a `txhash` +column family in that chunk's RocksDB (the hot-latency campaign's wave 2 +replaced the original one-key-per-hash table, whose ~6,000 random 32-byte +memtable keys per ledger dominated commit latency): + +- **CF row**: one seq-keyed row per ledger holding that ledger's hashes, + sorted (`hot_rows.go`); written in the same atomic batch as the rest of + the ledger, so a ledger's hashes remain all-or-nothing. +- **In-memory window**: the last 256 ledgers' rows, merged and sealed into + checksummed, blinded-key-sorted run files (`TXHRUN02`) with bloom + + page-ladder routing built in the write pass; a manifest names the live runs + and warmup drain-verifies them (see `stores/txhash/hotindex_seal.go`). + +**Rows stay raw; runs blind at seal.** The CF rows hold full 32-byte hashes, +so a window probe compares all 32 bytes and answers exactly. A sealed run +holds `BlindKey(secret, hash[:16])` instead — the same keys the chunk's cold +`.bin` will hold, which is what lets the freeze copy run records verbatim — +so a run hit names a CANDIDATE ledger. Nothing re-reads the row to confirm +it: the read assembly already extracts the transaction from that ledger by +its full hash, and an absence there is `ErrInconsistent` +(`stores/txhash/read_assembly.go`). The 32-byte check happens once, where the +ledger is read. ### 5.2 Write path -Writing is straightforward. As each ledger is ingested, one `(hash, seq)` entry is added for every transaction hash in it — two entries for a fee-bump (outer and inner) — in the same atomic write that stores the rest of the ledger. So a ledger's hashes are written all-or-nothing, together with the rest of the ledger. +As each ledger is ingested, its transaction hashes — two for a fee-bump +(outer and inner) — are encoded into that ledger's packed row in the same +atomic write that stores the rest of the ledger. Every 256 ledgers the +window seals into a run file off the ingest goroutine; the freeze later +merges the manifest-listed runs with the un-sealed tail rows verbatim. ### 5.3 Lifetime @@ -110,7 +130,7 @@ uint64 LE entry count entry × count 20 bytes each: [key: 16][seq: 4 LE] ``` -- `key` is the **secret-blinded first 16 bytes of the transaction hash** (blinded with the index secret recorded in the header, so the deferred build always adopts the secret its inputs were keyed with). The index uses only these 16 bytes to place and find a transaction; what happens when two hashes share a 16-byte prefix is in §8.2. +- `key` is **`BlindKey(secret, hash[:16])`** — the SipHash-2-4-128 blinding of the hash's first 16 bytes under the index's routing secret (stores/blind.go; the secret derives per index from the catalog secret and rides in the `.bin` header and index metadata). The index uses only this 16-byte blinded key to place and find a transaction; what happens when two hashes share a 16-byte prefix is in §8.2. - Entries are sorted ascending by `key`, **bytewise over all 16 bytes** — a total order, so the same entries always produce byte-identical files (rebuilds are deterministic). The `.bin` is a pre-sorted file, and a lookup never reads it directly. It is sorted because streamhash builds an index **much faster, and with much less memory, when its keys arrive already sorted** — its *sorted-builder mode*. @@ -196,7 +216,7 @@ The cold tier **probes every in-retention window's `.idx`**. A hash gives no hin ``` for each in-retention window (its live index → {lo}-{hi}.idx): - → MPHF probe on the hash's 16-byte prefix + → MPHF probe on the blinded 16-byte routing key (BlindKey of the hash prefix) → fingerprint check (1 byte) — miss ⇒ skip this window → on a fingerprint hit: seq = MinLedger + payload (3 bytes) diff --git a/go.mod b/go.mod index aa066f5ba..b76ed8424 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 - github.com/stellar/go-stellar-sdk v0.7.4-0.20260918190431-7092f4b101ef + github.com/stellar/go-stellar-sdk v0.7.4-0.20260922153802-dec600e04cda github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b github.com/stretchr/testify v1.12.1 ) @@ -99,7 +99,7 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go v1.55.8 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 github.com/creachadair/mds v0.25.10 // indirect github.com/djherbis/fscache v0.10.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect diff --git a/go.sum b/go.sum index 76139f86e..487ee724f 100644 --- a/go.sum +++ b/go.sum @@ -347,8 +347,8 @@ github.com/spf13/viper v1.17.0 h1:I5txKw7MJasPL/BrfkbA0Jyo/oELqVmux4pR/UxOMfI= github.com/spf13/viper v1.17.0/go.mod h1:BmMMMLQXSbcHK6KAOiFLz0l5JHrU89OdIRHvsk0+yVI= github.com/spiffe/go-spiffe/v2 v2.7.0 h1:uXe1MflJoHw58wAUvxVlcM7WpKtijWG7I1UidcGh6g4= github.com/spiffe/go-spiffe/v2 v2.7.0/go.mod h1:47Q0Q9/AqGha8QLHp+kxpH4Wca7X7EnOtlIJy3mxZ3U= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260918190431-7092f4b101ef h1:vR1EoiV0gUXSAhi1Ouv//m3qR4Mg/WJvGLyjXT3VpoA= -github.com/stellar/go-stellar-sdk v0.7.4-0.20260918190431-7092f4b101ef/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260922153802-dec600e04cda h1:Fs2l0HCms7F4EF114C7FL5kPUGNKFmNwioGafqhC2xs= +github.com/stellar/go-stellar-sdk v0.7.4-0.20260922153802-dec600e04cda/go.mod h1:bcErD+KvYtyrGqjaSjQeT7raOmlENi0WaORRwIJmfFE= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a h1:40YIhQusSioBKDW5Tr+YdjoXphcVeu7eXUS2ibMQBTs= github.com/stellar/go-xdr v0.0.0-20260806060815-dc590f17552a/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= github.com/stellar/streamhash v0.1.1-0.20260826155856-77556799545b h1:+qwFEBRESdIqVbLLMUtKf6RTnh7MDbNbyoP6T2WPfJc= diff --git a/scripts/probe/hog.py b/scripts/probe/hog.py new file mode 100644 index 000000000..1bf0fd5ce --- /dev/null +++ b/scripts/probe/hog.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Memory-bandwidth hog: N worker processes each streaming copies over a +fixed 170MB buffer (allocated once — no page-cache or reclaim involvement). +Usage: hog.py [workers=6]""" +import multiprocessing +import sys +import time + + +def worker(secs: float, duty: float) -> None: + b = bytearray(170 * 1024 * 1024) + v = memoryview(b) + step = 1 << 20 + t_end = time.time() + secs + while time.time() < t_end: + t0 = time.time() + for off in range(0, len(b) - 2 * step, step): + v[off:off + step] = v[off + step:off + 2 * step] + if duty < 1.0: + burst = time.time() - t0 + time.sleep(burst * (1.0 - duty) / duty) + + +if __name__ == "__main__": + secs = float(sys.argv[1]) + n = int(sys.argv[2]) if len(sys.argv) > 2 else 6 + duty = float(sys.argv[3]) if len(sys.argv) > 3 else 1.0 + procs = [multiprocessing.Process(target=worker, args=(secs, duty)) for _ in range(n)] + for p in procs: + p.start() + for p in procs: + p.join() diff --git a/scripts/probe/prober.py b/scripts/probe/prober.py new file mode 100755 index 000000000..497992297 --- /dev/null +++ b/scripts/probe/prober.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""WAL-shaped fsync prober: 2MiB append + fdatasync every 250ms (~8MB/s, the +hot WAL's rate class). Logs one CSV row per op: wall_s,write_ms,sync_ms. +Self-throttling is visible: achieved rate prints in the 5s summaries on stderr. +Usage: prober.py """ +import os +import sys +import time + +path, secs, out = sys.argv[1], float(sys.argv[2]), sys.argv[3] +buf = b"\xa5" * (2 << 20) +ROTATE = 1 << 30 +fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) +csv = open(out, "w", buffering=1) +csv.write("wall_s,write_ms,sync_ms\n") +written = 0 +t_end = time.time() + secs +last_report, ops = time.time(), 0 +while time.time() < t_end: + t0 = time.time() + os.write(fd, buf) + t1 = time.time() + os.fdatasync(fd) + t2 = time.time() + csv.write(f"{t0:.3f},{(t1 - t0) * 1e3:.3f},{(t2 - t1) * 1e3:.3f}\n") + written += len(buf) + ops += 1 + if written >= ROTATE: + os.close(fd) + os.unlink(path) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + written = 0 + if t2 - last_report >= 5: + print(f"prober: {ops / (t2 - last_report):.1f} syncs/s", file=sys.stderr) + last_report, ops = t2, 0 + time.sleep(max(0.0, 0.25 - (time.time() - t0))) +os.close(fd) +try: + os.unlink(path) +except FileNotFoundError: + pass diff --git a/scripts/probe/writer.py b/scripts/probe/writer.py new file mode 100755 index 000000000..fa9a43a05 --- /dev/null +++ b/scripts/probe/writer.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Synthetic freeze-signature writers. +Mode p2c: unthrottled buffered 2MiB writes + initiate-only sync_file_range + every 1MiB — byte-for-byte the pack copy's write signature. +Mode p3b: same, plus every 64MiB a WAIT_BEFORE|WRITE|WAIT_AFTER + sync_file_range on the completed window — bounded outstanding + writeback, the mitigation preview. +Rotates (unlink+recreate) at 6GiB to respect the box's ~25GB free. +Usage: writer.py """ +import ctypes +import os +import sys +import time + +mode, path, secs = sys.argv[1], sys.argv[2], float(sys.argv[3]) +rate_mbps = float(sys.argv[4]) if len(sys.argv) > 4 else 0.0 +libc = ctypes.CDLL("libc.so.6", use_errno=True) +SFR_WAIT_BEFORE, SFR_WRITE, SFR_WAIT_AFTER = 1, 2, 4 + + +def sfr(fd, off, n, flags): + libc.sync_file_range(fd, ctypes.c_long(off), ctypes.c_long(n), flags) + + +buf = b"\x5a" * (2 << 20) +ROTATE = 6 << 30 +WINDOW = 64 << 20 +fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) +pos = last_initiate = last_window = 0 +total = 0 +t_end = time.time() + secs +t_start = time.time() +while time.time() < t_end: + os.write(fd, buf) + pos += len(buf) + total += len(buf) + while pos - last_initiate >= (1 << 20): + sfr(fd, last_initiate, 1 << 20, SFR_WRITE) + last_initiate += 1 << 20 + if mode == "p3b": + while pos - last_window >= WINDOW: + sfr(fd, last_window, WINDOW, SFR_WAIT_BEFORE | SFR_WRITE | SFR_WAIT_AFTER) + last_window += WINDOW + if rate_mbps > 0: + expected = total / (rate_mbps * 1e6) + ahead = expected - (time.time() - t_start) + if ahead > 0: + time.sleep(ahead) + if pos >= ROTATE: + os.close(fd) + os.unlink(path) + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644) + pos = last_initiate = last_window = 0 +elapsed = time.time() - t_start +print(f"writer {mode}: {total / elapsed / 1e6:.0f} MB/s achieved", file=sys.stderr) +os.close(fd) +try: + os.unlink(path) +except FileNotFoundError: + pass