From dc635d2ed4db7a4c3ab05bfb05bde43e00fc15a9 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Sat, 25 Jul 2026 08:56:11 +0000 Subject: [PATCH 01/21] bench: add per-ledger --trace CSV and RocksDB statistics dump Two diagnostics used to decompose hot-ingest latency: - bench-ingest hot --trace=PATH streams one wall-clock-stamped row per ingested ledger with every phase duration, so individual slow ledgers can be correlated against external timelines (RocksDB LOG flush events, iostat). Partial traces survive failed runs. - STELLAR_RPC_ROCKSDB_STATS=1 enables RocksDB statistics at store open and dumps tickers + histograms to STATISTICS.txt in the DB dir on Close. Env-gated debug knob, deliberately not a Tuning field so production configs cannot turn it on. This is what attributed hot commit time ~92% to memtable insertion (fsync is ~2.4ms of ~60ms). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C3xaaoD9LhzyKPdnZCJa2N --- .../internal/rpcv2/bench/command.go | 5 + .../internal/rpcv2/bench/csvsink.go | 28 ++++- cmd/stellar-rpc/internal/rpcv2/bench/hot.go | 49 ++++++-- cmd/stellar-rpc/internal/rpcv2/bench/trace.go | 115 ++++++++++++++++++ .../internal/rpcv2/rocksdb/rocksdb.go | 27 ++++ 5 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/bench/trace.go diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/command.go b/cmd/stellar-rpc/internal/rpcv2/bench/command.go index 42599098c..7ddd560a1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/command.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/command.go @@ -190,6 +190,7 @@ func newHotCommand() *cobra.Command { hotDir string catalogDir string closeInterval time.Duration + traceFile string prof profileFlags ) cmd := newBenchCommand("hot", @@ -205,6 +206,7 @@ func newHotCommand() *cobra.Command { CatalogDir: catalogDir, CloseInterval: closeInterval, OutDir: outDir, + TraceFile: traceFile, }) }) fs := cmd.Flags() @@ -219,6 +221,9 @@ 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)") markRequired(cmd, "start-chunk", "hot-dir") return cmd } diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go b/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go index 73c92f8c7..92cc47391 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 ( @@ -239,6 +245,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 +289,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 +366,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/hot.go b/cmd/stellar-rpc/internal/rpcv2/bench/hot.go index d16a195ff..ef4ab048c 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/hot.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/hot.go @@ -56,6 +56,12 @@ 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 } // validate checks the flags and chunk range before runHot touches the @@ -80,6 +86,23 @@ func (o hotOptions) validate() error { 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 +114,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 +146,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{ 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..3ace3ecf4 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/trace.go @@ -0,0 +1,115 @@ +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, emitted on success only — so writeRow never + // emits a half-observed ledger. + 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, success-only) 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/rocksdb/rocksdb.go b/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go index edb3dfde4..91b1d1c44 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 @@ -538,6 +544,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()), 0o644); 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 +724,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 { From 847612fd08f72a0465520cf0b6391027e5565675 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Sat, 25 Jul 2026 08:56:11 +0000 Subject: [PATCH 02/21] hot latency: pooled encode, packed index row, compression fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cuts at the hot ingest tail, in causal order. The zstd Encode destination is pooled — a fresh ~compressBound dst per ledger was ~43% of hot ingestion's allocations — and the pool is promoted to the canonical internal/rpcv2/zstd.Pool (CGo context + retained dst buffer; the consumer contract is synchronous copy, which BatchWriter.Put honors). The events hot index writes ONE packed row per ledger instead of a row per (term, event) — tens of thousands of memtable keys per ledger gone from the shared commit batch. The ~21ms ledger compression forks off the critical path at IngestLedger entry and joins as the batch's last queue step (AddLedgerToBatch remains as the composition of the pair, one write path). A fourth cut in the original branch made the dense ConcurrentBitmaps terms publish through an immutable tail, so a small AddTo never cloned the base bitmap. It is dropped here: #973 landed on the base branch first and removes the same per-write clone by snapshotting a dense term lazily under its own mutex, so the clone population that cut existed to delete is already gone, and carrying a second mechanism for it would mean two designs for one problem. sac-6000 @600ms, 1k: ingest_total p99 303 -> ~83ms across the four cuts as originally measured, with the dense-overlay share of that now coming from #973 instead (final figures per constituent benches; GOGC-free). The encode state is store-OWNED, not sync.Pool'd: the write side is single-flight by contract (one compression in flight, hotchunk's single-writer loop; loud CAS latch), reads stay fully concurrent with the writer. A sync.Pool here was measured losing its lone expensive state (~15MB dst + CGo context) about 1-in-5 ledgers to GC pool-emptying and silently re-allocating it; the GC-survival regression test pins the guarantee. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01C3xaaoD9LhzyKPdnZCJa2N --- .../internal/rpcv2/stores/event/hot_store.go | 228 ++++++++++++++---- .../rpcv2/stores/event/hot_store_test.go | 13 +- .../rpcv2/stores/event/packed_index_test.go | 100 ++++++++ .../rpcv2/stores/hotchunk/hotchunk.go | 36 ++- .../internal/rpcv2/stores/ledger/hot_store.go | 121 ++++++++-- .../rpcv2/stores/ledger/hot_store_test.go | 62 ++++- cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go | 32 +++ 7 files changed, 500 insertions(+), 92 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go 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..2dac6ec7e 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go @@ -1,12 +1,14 @@ package event import ( + "bytes" "context" "encoding/binary" "errors" "fmt" "iter" "math" + "slices" "github.com/RoaringBitmap/roaring/v2" "github.com/linxGnu/grocksdb" @@ -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 +// appendPackedIndexRow). 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,17 @@ 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) + // indexKeyLen is the LEGACY per-(term,event) index key: term hash || + // event_id, one row per pair. Still read by warmup (a restarted daemon + // may hold a chunk with legacy rows) but no longer written. + indexKeyLen = 16 + 4 + // packedIndexKeyLen is the current per-ledger packed index key: the + // ledger sequence. The key length is the format discriminator in + // warmupIndex — legacy rows are 20 bytes, packed rows 4. + packedIndexKeyLen = 4 + offsetKeyLen = 4 // ledger_seq + offsetValLen = 4 // per-ledger event count (uint32 BE) ) // ErrLedgerOutOfRange is returned by IngestLedgerToBatch when the @@ -425,7 +439,11 @@ func (h *HotStore) IngestLedgerToBatch( // 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. + // The per-term event-ID lists are accumulated once here and serve BOTH the + // packed index row and the post-commit mirror apply (which previously + // rebuilt the same grouping). var scratch []byte + perKeyIDs := make(map[TermKey][]uint32, 64) for i := range payloads { blob, err := payloads[i].MarshalInto(scratch[:0]) if err != nil { @@ -435,13 +453,22 @@ func (h *HotStore) IngestLedgerToBatch( eventID := startID + uint32(i) b.Put(DataCF, encodeDataKey(eventID), blob) for _, key := range termKeys[i] { - b.Put(IndexCF, encodeIndexKey(key, eventID), nil) + perKeyIDs[key] = append(perKeyIDs[key], eventID) } } + // ONE packed index row per ledger (see appendPackedIndexRow) 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. + if len(perKeyIDs) > 0 { + b.Put(IndexCF, encodePackedIndexKey(ledgerSeq), appendPackedIndexRow(nil, perKeyIDs)) + } //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() { h.applyLedger(perKeyIDs, eventCount) }, nil } // index returns the in-memory term mirror. Test-only write hook: no production @@ -458,22 +485,15 @@ func (h *HotStore) index() *ConcurrentBitmaps { return h.mirror } // 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) - } - } +func (h *HotStore) applyLedger(perKeyIDs map[TermKey][]uint32, eventCount uint32) { + // perKeyIDs is the write path's per-term grouping, reused here so each + // key takes one AddTo per ledger instead of one per event — saving the + // per-term lock round-trips, and the grouping work itself happens once + // per ledger instead of twice. 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))) + h.offsets.Append(eventCount) } // ────────────────────────────────────────────────────────────────── @@ -559,17 +579,19 @@ 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. +// warmupIndex scans the events_index CF and replays every index row into a +// fresh ConcurrentBitmaps. Design doc §12 step 3. Two row formats +// (discriminated by key length): the current packed per-ledger row — one key +// per ledger, terms with ID runs in the value — and the legacy +// per-(term,event) row a pre-upgrade daemon may have left in the same chunk. // -// 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. +// Implementation: build into a single-threaded Bitmaps, 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). Packed rows arrive +// pre-grouped per term; legacy rows are grouped via the prevTerm/buf +// accumulator (byte-sorted iteration delivers a term's legacy rows +// consecutively). // // 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 @@ -595,22 +617,41 @@ func warmupIndex(chunkStore *rocksdb.Store) (*ConcurrentBitmaps, uint64, error) if err != nil { return nil, 0, fmt.Errorf("events: warmup scan %s: %w", IndexCF, err) } - if len(entry.Key) != indexKeyLen { - return nil, 0, fmt.Errorf("events: warmup unexpected %s key length %d (want %d)", - IndexCF, len(entry.Key), indexKeyLen) - } - 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 - } - if hasPrev && term != prevTerm { + switch len(entry.Key) { + case packedIndexKeyLen: + // Packed per-ledger row: replay every term's ID run. Flush the + // legacy accumulator first — AddTo appends, so a term split + // across formats merges correctly either way; flushing just + // keeps the legacy batching invariant simple. flush() + if derr := decodePackedIndexRow(entry.Value, func(term TermKey, ids []uint32) { + builder.AddTo(term, ids...) + if last := uint64(ids[len(ids)-1]) + 1; last > indexUpperBound { + indexUpperBound = last + } + }); derr != nil { + return nil, 0, fmt.Errorf("events: warmup %s ledger %d: %w", + IndexCF, binary.BigEndian.Uint32(entry.Key), derr) + } + case indexKeyLen: + // Legacy per-(term,event) row — written by pre-packed-format + // daemons; a chunk can hold both after a mid-chunk upgrade. + 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 + } + if hasPrev && term != prevTerm { + flush() + } + prevTerm = term + hasPrev = true + buf = append(buf, eventID) + default: + return nil, 0, fmt.Errorf("events: warmup unexpected %s key length %d (want %d or %d)", + IndexCF, len(entry.Key), packedIndexKeyLen, indexKeyLen) } - prevTerm = term - hasPrev = true - buf = append(buf, eventID) } flush() @@ -675,6 +716,8 @@ func encodeDataKey(eventID uint32) []byte { return key[:] } +// encodeIndexKey builds a LEGACY per-(term,event) index key. Kept for warmup +// tests and the dual-format read path; the write path emits packed rows. func encodeIndexKey(term TermKey, eventID uint32) []byte { var key [indexKeyLen]byte copy(key[:16], term[:]) @@ -682,6 +725,95 @@ func encodeIndexKey(term TermKey, eventID uint32) []byte { return key[:] } +func encodePackedIndexKey(ledgerSeq uint32) []byte { + var key [packedIndexKeyLen]byte + binary.BigEndian.PutUint32(key[:], ledgerSeq) + return key[:] +} + +// appendPackedIndexRow appends one ledger's packed index-row value to dst and +// returns it: for each term (byte-sorted, so the encoding is deterministic), +// +// 16-byte term hash ‖ uvarint id-count ‖ ids as uvarints +// (first absolute, then strictly-positive deltas) +// +// Event IDs are appended by the caller in ascending order per term, so the +// deltas are always ≥ 1 — decodePackedIndexRow rejects a zero delta as +// corruption. Delta-varint keeps the run for a term with k sequential IDs to +// ~1 byte per ID. +func appendPackedIndexRow(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 { + ids := perKeyIDs[t] + dst = append(dst, t[:]...) + 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 +} + +// decodePackedIndexRow parses a packed index-row value, 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 — +// on-disk rows are untrusted input to warmup. ids is reused across terms; add +// must consume it before returning (warmup's builder.AddTo copies). +func decodePackedIndexRow(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 index row: %d trailing bytes, want 16-byte term", len(val)) + } + var term TermKey + copy(term[:], val[:16]) + val = val[16:] + count, n := binary.Uvarint(val) + if n <= 0 { + return errors.New("events: packed index row: bad id-count uvarint") + } + val = val[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(val)) { + return fmt.Errorf("events: packed index row: id count %d exceeds %d remaining bytes", count, len(val)) + } + ids = ids[:0] + var prev uint64 + for i := uint64(0); i < count; i++ { + v, n := binary.Uvarint(val) + if n <= 0 { + return errors.New("events: packed index row: bad event-id uvarint") + } + val = val[n:] + abs := v + if i > 0 { + if v == 0 { + return errors.New("events: packed index row: zero delta") + } + abs = prev + v + } + if abs > math.MaxUint32 { + return fmt.Errorf("events: packed index row: event id %d overflows uint32", abs) + } + ids = append(ids, uint32(abs)) + prev = abs + } + add(term, ids) + } + return nil +} + func encodeOffsetKey(ledgerSeq uint32) []byte { var key [offsetKeyLen]byte binary.BigEndian.PutUint32(key[:], ledgerSeq) 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..08ba46dcf 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 @@ -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[events.TermKey][]uint32{} + require.NoError(t, decodePackedIndexRow(idxVal, func(term events.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. 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..11e616767 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go @@ -0,0 +1,100 @@ +package event + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/events" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" +) + +func decodeAll(t *testing.T, val []byte) map[events.TermKey][]uint32 { + t.Helper() + out := map[events.TermKey][]uint32{} + require.NoError(t, decodePackedIndexRow(val, func(term events.TermKey, ids []uint32) { + out[term] = append([]uint32(nil), ids...) + })) + return out +} + +func TestPackedIndexRow_RoundTrip(t *testing.T) { + var t1, t2 events.TermKey + t1[0] = 0x01 + t2[0] = 0x02 + in := map[events.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, appendPackedIndexRow(nil, in))) +} + +func TestPackedIndexRow_RejectsCorruption(t *testing.T) { + var term events.TermKey + term[0] = 0xaa + good := appendPackedIndexRow(nil, map[events.TermKey][]uint32{term: {3, 4}}) + + nop := func(events.TermKey, []uint32) {} + // Truncated term header. + require.ErrorContains(t, decodePackedIndexRow(good[:10], nop), "trailing bytes") + // id count exceeding remaining bytes (truncate the ID area). + require.ErrorContains(t, decodePackedIndexRow(good[:len(good)-1], nop), "exceeds") + // Zero delta = duplicate ID within a term. + var dup []byte + dup = append(dup, term[:]...) + dup = append(dup, 2 /* count */, 3 /* first id */, 0 /* zero delta */) + require.ErrorContains(t, decodePackedIndexRow(dup, nop), "zero delta") +} + +// TestWarmup_MixedLegacyAndPackedRows simulates a mid-chunk daemon upgrade: +// ledger 2's index rows are in the legacy per-(term,event) format (as a +// pre-upgrade daemon left them), ledger 3's arrive packed. Warmup must merge +// both formats into one mirror — including a term that spans the formats. +func TestWarmup_MixedLegacyAndPackedRows(t *testing.T) { + const chunkID = chunk.ID(0) + dir := t.TempDir() + + // Ingest ledger 2 (event 0) with the current packed writer. + hot1, raw1 := openHotStoreForTestAt(t, dir, chunkID) + p1, keys1 := makePayload("alpha") + require.NoError(t, ingestLedgerEvents(hot1, 2, []events.Payload{p1})) + require.NoError(t, raw1.Close()) + + // Rewrite ledger 2's index into the LEGACY format, exactly as an old + // daemon would have written it. + corruptHotChunk(t, dir, chunkID, func(raw *rocksdb.Store) { + val, found, err := raw.Get(IndexCF, encodePackedIndexKey(2)) + require.NoError(t, err) + require.True(t, found) + require.NoError(t, raw.Delete(IndexCF, encodePackedIndexKey(2))) + require.NoError(t, decodePackedIndexRow(val, func(term events.TermKey, ids []uint32) { + for _, id := range ids { + require.NoError(t, raw.Put(IndexCF, encodeIndexKey(term, id), nil)) + } + })) + }) + + // Reopen (warmup: legacy-only) and ingest ledger 3 (event 1) packed. + hot2, raw2 := openHotStoreForTestAt(t, dir, chunkID) + p2, keys2 := makePayload("beta") + require.NoError(t, ingestLedgerEvents(hot2, 3, []events.Payload{p2})) + require.NoError(t, raw2.Close()) + + // Final warmup sees both formats in one CF. + hot3, _ := openHotStoreForTestAt(t, dir, chunkID) + assert.Equal(t, uint32(2), mustEventCount(t, hot3)) + + // Per-payload symbol terms: one event each, from their own format. + assert.True(t, lookupOne(t, hot3, keys1[len(keys1)-1]).Contains(0)) + assert.True(t, lookupOne(t, hot3, keys2[len(keys2)-1]).Contains(1)) + + // The shared contract-ID term (same contract in both payloads) must hold + // BOTH events — one arrived via a legacy row, one via a packed row. + shared := lookupOne(t, hot3, keys1[0]) + require.NotNil(t, shared) + assert.True(t, shared.Contains(0) && shared.Contains(1), + "cross-format merge lost an event for the shared term") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index c9f2b63e5..74ed1ab57 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -267,7 +267,10 @@ func (d *DB) MaxCommittedSeq() (uint32, bool, error) { // 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 @@ -355,6 +358,16 @@ 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. + pending := d.ledger.StartCompress(ledger.Entry{Seq: seq, Bytes: []byte(lcmView)}) + 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. // @@ -413,14 +426,11 @@ func (d *DB) IngestLedger( 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. ts := time.Now() if len(txEntries) > 0 { d.txhash.AddEntriesToBatch(b, txEntries) @@ -436,6 +446,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 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..d95ca4278 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 @@ -66,32 +78,93 @@ func NewWithStore(store *rocksdb.Store) *HotStore { return &HotStore{ store: store, dec: zstd.NewDecompressor(), - compPool: sync.Pool{ - New: func() any { return zstd.NewCompressor() }, - }, + enc: zstd.NewEncoderState(), 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 +// 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..7096a5165 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" @@ -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) // 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) + 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/zstd/zstd.go b/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go index 838154088..74a218de6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go +++ b/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go @@ -59,6 +59,38 @@ 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 a default-configured encoder state. +func NewEncoderState() *EncoderState { + return &EncoderState{comp: NewCompressor()} +} + +// 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) From d3c7d0bfdb5356ef8496acbd495f72e0ff87cf07 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Mon, 3 Aug 2026 23:49:41 +0000 Subject: [PATCH 03/21] events index: spill-and-merge cold build, bounded sorted-run hot tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the events-index memory architecture: the cold build stops holding every term in RAM, and the hot tier stops growing without bound. --- cold memory: spill-and-merge events build, freeze-by-merge The cold events index build drops its in-memory mirror for external memory: the packed-row codec is extracted as the shared run format (AppendPackedRow/AppendTermPostings, stdlib varints, with events.DecodeAscendingIDs as the ONE definition site of the delta-varint ID validation), and (term, id) pairs spill through byte-capped slabs into CRC-framed sorted run files (double-buffered Spiller; k-way merge with cross-run term union). The cold index builds by streaming the merged runs (WriteColdIndexFromRuns, CRC-folding stdlib varint reads) — byte-identical to the in-memory builder, which is retained as the test oracle. Freeze-by-merge builds cold events straight from the hot DB's CFs. Events scratch placement is owned by eventsScratchDir — one deterministic name per chunk, shared by any materializer, since scratch has no catalog key and wipe-on-entry by name is its only cleanup. Full-chunk A/B: peak RSS 24.1GB -> 5.39GB, 28% faster (12m -> 8m36s), artifacts cmp-identical. --- sorted-run tier: bounded hot events index over window + runs + overlay The hot chunk's sparse-term index with a hard-bounded live set: one atomic view publishes a WINDOW of retained per-ledger packed rows (pointerless fp64 accel), immutable CRC-framed RUN files sealed every 256 ledgers on one background goroutine (bloom + fence routing, single-level-merged at 8 live runs — the merge cap is verdict-mandated for multi-term ANDs), and ConcurrentBitmaps as a dense overlay (>=32-events-in-one-ledger promotes, backfilling history so the overlay self-heals after restart). The mirror and its warmup full-scan are gone; warmup is manifest-anchored with bounded tail replay. The engine is born DISARMED: sealing rights follow validation (ArmSealing after verifyChunkConsistency), so an unvalidated open writes no durable index state and a failed open retries against unmoved tripwire inputs — pinned by the poisoned-tail warmup test. Seal output and merges stream through runspill.RunWriter (no whole-payload buffers; framing shared via runspill.HeaderLen; varint reads delegate to the stdlib through the CRC-accounting adapter), and run-file decode delegates to events.DecodeAscendingIDs. Acceptance: 10k full chunk knob-free p50 49.4 / p99 64.1ms, per-decile p99 flat, steady RSS ~1.5GB (old arch: 783ms and falling behind). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou --- .../internal/rpcv2/backfill/hotsource_test.go | 6 +- .../internal/rpcv2/backfill/process.go | 50 +- .../internal/rpcv2/backfill/process_test.go | 8 +- .../internal/rpcv2/bench/csvsink.go | 9 +- cmd/stellar-rpc/internal/rpcv2/bench/trace.go | 7 +- .../internal/rpcv2/ingest/config.go | 10 + .../internal/rpcv2/ingest/events.go | 159 +++++- .../internal/rpcv2/ingest/ingester.go | 28 +- .../internal/rpcv2/rocksdb/rocksdb.go | 7 +- .../rpcv2/stores/event/cold_format.go | 16 +- .../rpcv2/stores/event/cold_format_test.go | 18 +- .../rpcv2/stores/event/cold_freeze.go | 227 +++++++++ .../internal/rpcv2/stores/event/cold_index.go | 22 +- .../rpcv2/stores/event/cold_index_stream.go | 370 ++++++++++++++ .../stores/event/cold_index_stream_test.go | 152 ++++++ .../rpcv2/stores/event/cold_index_test.go | 25 +- .../rpcv2/stores/event/cold_reader.go | 10 +- .../rpcv2/stores/event/cold_writer.go | 9 + .../rpcv2/stores/event/concurrent_bitmaps.go | 19 +- .../internal/rpcv2/stores/event/hot_store.go | 467 ++++++++---------- .../rpcv2/stores/event/hot_store_test.go | 8 +- .../rpcv2/stores/event/hot_warmup_test.go | 67 ++- .../internal/rpcv2/stores/event/hotindex.go | 361 ++++++++++++++ .../rpcv2/stores/event/hotindex_seal.go | 360 ++++++++++++++ .../rpcv2/stores/event/hotindex_test.go | 289 +++++++++++ .../internal/rpcv2/stores/event/match_test.go | 98 ++-- .../rpcv2/stores/event/packed_index_test.go | 80 +-- .../internal/rpcv2/stores/event/packedrow.go | 129 +++++ .../rpcv2/stores/event/runspill/codec.go | 92 ++++ .../rpcv2/stores/event/runspill/merge.go | 191 +++++++ .../rpcv2/stores/event/runspill/merge_test.go | 128 +++++ .../rpcv2/stores/event/runspill/runspill.go | 368 ++++++++++++++ .../stores/event/runspill/runspill_test.go | 163 ++++++ .../rpcv2/stores/event/runspill/spiller.go | 129 +++++ .../stores/event/runspill/spiller_test.go | 81 +++ .../rpcv2/stores/hotchunk/hotchunk.go | 50 +- 36 files changed, 3704 insertions(+), 509 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/codec.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go b/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go index f8e0cba11..d751af50e 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go @@ -46,7 +46,7 @@ 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, _, 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.NoError(t, closeSrc()) @@ -62,7 +62,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 +79,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..0aab0f3d2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go @@ -110,7 +110,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 } @@ -132,9 +132,13 @@ func processChunk(ctx context.Context, chunkID chunk.ID, artifacts catalog.Artif EventsDir: layout.EventsBucketDir(chunkID), } raw := src.RawLedgers(ctx, ledgerbackend.BoundedRange(chunkID.FirstLedger(), chunkID.LastLedger())) - ic := ingestConfigFor(artifacts, chunkID, cfg) + // When the source IS the chunk's complete hot DB, the events artifacts are + // built by merging its CFs (freeze-by-merge) rather than re-deriving from + // the raw walk; the closeSource defer keeps the DB open through finalize. + icfg := ingestConfigFor(artifacts, chunkID, cfg) + icfg.EventsFreezeDB = hotDB if rerr := ingest.WriteColdChunk( - ctx, cfg.Logger, chunkID, raw, dirs, cfg.Sink, ic, + ctx, cfg.Logger, chunkID, raw, dirs, cfg.Sink, icfg, ); rerr != nil { return fmt.Errorf("cold ingest chunk %s %s: %w", chunkID, artifacts, rerr) } @@ -167,43 +171,45 @@ func processChunk(ctx context.Context, chunkID chunk.ID, artifacts catalog.Artif // 3. the bulk backend, gated by a bounded waitForCoverage on its Tip. 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) + // The hot DB handle rides along so the freeze can build the cold events + // artifacts by merging its CFs (freeze-by-merge) instead of re-deriving. + hot, src, 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 src, 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,10 +218,10 @@ 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, @@ -225,20 +231,20 @@ func backfillSource( // hot branch flat. func resolveHotSource( chunkID chunk.ID, cfg ProcessConfig, -) (ledgerbackend.LedgerStream, func() error, bool, error) { +) (*hotchunk.DB, ledgerbackend.LedgerStream, 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, db.Source(), func() error { return nil }, true, nil } } hotState, err := cfg.Catalog.HotState(chunkID) if err != nil { - return nil, nil, false, fmt.Errorf("read hot state chunk %s: %w", chunkID, err) + return nil, nil, nil, false, fmt.Errorf("read hot state chunk %s: %w", chunkID, err) } if hotState != geometry.HotReady { - return nil, nil, false, nil // "transient"/absent: not a read source + return nil, nil, nil, false, nil // "transient"/absent: not a read source } return tryHotSource(hotState, chunkID, cfg) } @@ -250,7 +256,7 @@ 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, ledgerbackend.LedgerStream, 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 @@ -259,22 +265,22 @@ func tryHotSource( // — fails the run, never auto-created. hot, err := hotchunk.OpenReadyView(state, dir, chunkID, cfg.Logger) if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } maxSeq, present, merr := hot.MaxCommittedSeq() if merr != nil { _ = hot.Close() // A read error against an opened DB: the DB opened but cannot answer its // own progress. Surface it (fails the run), don't treat as staleness. - return nil, nil, false, fmt.Errorf("chunk %s: read hot max committed seq: %w", chunkID, merr) + return nil, nil, nil, false, fmt.Errorf("chunk %s: read hot max committed seq: %w", chunkID, merr) } // 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.Source(), 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) _ = hot.Close() - return nil, nil, false, nil + return nil, nil, nil, false, nil } diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go b/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go index c0f340d13..c30c44354 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go @@ -302,7 +302,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 +327,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 +343,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 +353,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") } diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go b/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go index 92cc47391..0a22240bb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/csvsink.go @@ -226,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 diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/trace.go b/cmd/stellar-rpc/internal/rpcv2/bench/trace.go index 3ace3ecf4..f37b762c3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/trace.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/trace.go @@ -29,8 +29,9 @@ type hotTrace struct { // pending accumulates the current ledger's phase durations; wallStart // stamps the burst's first signal. complete latches on PhaseApply — the - // burst's terminal phase, emitted on success only — so writeRow never - // emits a half-observed ledger. + // 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 @@ -56,7 +57,7 @@ func newHotTrace(path string) (*hotTrace, error) { // 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, success-only) marks it complete for writeRow. +// (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{} diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/config.go b/cmd/stellar-rpc/internal/rpcv2/ingest/config.go index f56788d94..466554cb2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/config.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/config.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // Config selects which data types the ingest drivers write. At least one of @@ -26,6 +27,15 @@ 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 + + // EventsFreezeDB, when non-nil, switches the events writer to + // freeze-by-merge: cold events artifacts are built directly from this + // (complete, read-only) hot chunk DB's CFs at finalize instead of from + // the raw-ledger walk — no per-ledger events shaping, no term memory. + // Only the freeze path sets it (its source IS this hot DB, so the CFs + // cover exactly the chunk); backfill leaves it nil. Ignored unless + // Events is enabled. + EventsFreezeDB *hotchunk.DB } // validate rejects a Config with no enabled data types, or a txhash/events diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go index 0c7170c05..e776eefe9 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,23 +13,31 @@ 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" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // ───────────────────────── 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 Finish + 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 @@ -40,7 +49,7 @@ type eventsCold struct { failed bool } -// newEventsCold opens a per-chunk events.pack cold writer in bucketDir — +// newEventsCold opens a per-chunk event.pack cold writer in bucketDir — // the caller's geometry.Layout.EventsBucketDir(chunkID), so the write path is // Layout's single derivation. The writer opts into the batch tuning // (coldEncoderConcurrency/coldBytesPerSync): WriteColdChunk, the sole @@ -58,17 +67,97 @@ 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, + 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 + +// eventsFreeze is the freeze-by-merge events writer: it takes NO per-ledger +// feed (openColdChunk leaves coldChunk.events nil, so the walk never shapes +// events for it) and produces all three cold events artifacts at finalize +// straight from the complete hot chunk DB's CFs (hotchunk.FreezeEventsCold). +type eventsFreeze struct { + chunkID chunk.ID + db *hotchunk.DB + bucketDir string + // secret is the chunk's routing secret (event.ColdIndexSecret) — the + // freeze build blinds term keys with it at its run boundary, mirroring + // the walk path's blind-before-spill. + secret [stores.SecretLen]byte + metrics coldMetrics +} + +func newEventsFreeze( + bucketDir string, chunkID chunk.ID, db *hotchunk.DB, sink MetricSink, + secret [stores.SecretLen]byte, +) (*eventsFreeze, error) { + if err := os.MkdirAll(bucketDir, 0o755); err != nil { + return nil, fmt.Errorf("mkdir %s: %w", bucketDir, err) + } + return &eventsFreeze{ chunkID: chunkID, - writer: w, - mirror: event.NewBitmaps(), - offsets: event.NewLedgerOffsets(chunkID.FirstLedger()), + db: db, bucketDir: bucketDir, secret: secret, metrics: newColdMetrics(sink, dataTypeEvents), }, nil } +// finalize builds event.pack + index.pack + index.hash from the hot DB. One +// ColdIngest sample covers the whole build (there are no per-ledger writes to +// observe on this path). +func (e *eventsFreeze) finalize(ctx context.Context) error { + start := time.Now() + scratch := filepath.Join(e.bucketDir, ".freeze-scratch-"+e.chunkID.String()) + err := e.db.FreezeEventsCold(ctx, scratch, e.bucketDir, e.secret, event.ColdWriterOptions{ + Concurrency: coldEncoderConcurrency, + BytesPerSync: coldBytesPerSync, + }) + if err != nil { + err = fmt.Errorf("freeze events from hot DB: %w", err) + } + e.metrics.emit(time.Since(start), err) + return err +} + +// close is a no-op: the freeze writer holds no partial state of its own — +// artifact overwrite-on-retry and scratch wiping are FreezeColdFromStore's +// contract, and the hot DB is owned by the caller. +func (e *eventsFreeze) close() error { return nil } + // 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,7 +172,7 @@ func (e *eventsCold) write(seq uint32, closedAt int64, txParts []sdkingest.Ledge return nil } -// finalize writes the events.pack trailer (Finish) + materializes the cold +// finalize writes the event.pack trailer (Finish) + materializes the cold // index (WriteColdIndex). 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 @@ -103,12 +192,25 @@ func (e *eventsCold) finalize(ctx context.Context) error { 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 + 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.WriteColdIndexFromRuns(ctx, e.chunkID, runs, e.scratchDir, e.bucketDir, e.secret); err != nil { + // Finish already committed event.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 + } + if err := e.spiller.Cleanup(); err != nil { + // Scratch removal is best-effort: the artifacts are already durable, + // and the next attempt's NewSpiller wipes this dir anyway. + err = fmt.Errorf("index spill cleanup: %w", err) e.metrics.emit(time.Since(start), err) return err } @@ -117,11 +219,14 @@ func (e *eventsCold) finalize(ctx context.Context) error { 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 event.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() } @@ -145,7 +250,7 @@ func (e *eventsCold) ingestSeq(seq uint32, closedAt int64, txParts []sdkingest.L // Per payload: derive term keys from the raw ContractEvent XDR and AddTo the // in-memory mirror under the chunk-relative event ID (term_index stage), then - // append the payload to events.pack (write stage). Both reads of the borrowed + // append the payload to event.pack (write stage). Both reads of the borrowed // ContractEventBytes are synchronous (TermsForBytes does not retain them; // Append marshals into a scratch buffer copied synchronously), so the borrow // is safe. On any error here offsets is not advanced below — but the mirror and @@ -166,7 +271,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/ingester.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go index ae1bfa52e..482f79db2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go @@ -34,7 +34,12 @@ type coldChunk struct { ledgers *ledgerCold txhash *txhashCold events *eventsCold - sink MetricSink + // eventsFreeze is the freeze-by-merge events writer (Config.EventsFreezeDB): + // finalize-only — it consumes the hot DB's CFs, so it takes no per-ledger + // feed and leaves `events` nil, which also lets a txhash-less freeze skip + // the extract walk entirely. + eventsFreeze *eventsFreeze + sink MetricSink } // openColdChunk opens one cold writer per enabled type at its resolved path — @@ -80,11 +85,19 @@ func openColdChunk(dirs ColdDirs, chunkID chunk.ID, sink MetricSink, cfg Config) } var secret [stores.SecretLen]byte copy(secret[:], cfg.EventsSecret) - w, err := newEventsCold(dirs.EventsDir, chunkID, sink, secret) - if err != nil { - return fail(fmt.Errorf("open events cold writer: %w", err)) + if cfg.EventsFreezeDB != nil { + w, err := newEventsFreeze(dirs.EventsDir, chunkID, cfg.EventsFreezeDB, sink, secret) + if err != nil { + return fail(fmt.Errorf("open events freeze writer: %w", err)) + } + cc.eventsFreeze = w + } else { + w, err := newEventsCold(dirs.EventsDir, chunkID, sink, secret) + if err != nil { + return fail(fmt.Errorf("open events cold writer: %w", err)) + } + cc.events = w } - cc.events = w } return cc, nil } @@ -165,6 +178,11 @@ func (c *coldChunk) finalize(ctx context.Context) error { return fmt.Errorf("finalize: %w", err) } } + if c.eventsFreeze != nil { + if err := c.eventsFreeze.finalize(ctx); err != nil { + return fmt.Errorf("finalize: %w", err) + } + } return nil } diff --git a/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go b/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go index 91b1d1c44..4f81c5af1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go +++ b/cmd/stellar-rpc/internal/rpcv2/rocksdb/rocksdb.go @@ -207,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 { @@ -549,7 +554,7 @@ func (s *Store) teardownLocked() { // warns and Close proceeds. if s.statsEnabled { statsPath := filepath.Join(s.cfg.Path, "STATISTICS.txt") - if werr := os.WriteFile(statsPath, []byte(s.opts.GetStatisticsString()), 0o644); werr != nil { + 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) 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..55d376761 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -461,7 +461,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 +472,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 := TermKey(stores.BlindKey(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) { 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..ed6351378 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -0,0 +1,227 @@ +package event + +// cold_freeze.go — freeze-by-merge: build a completed hot chunk's cold events +// artifacts DIRECTLY from its hot DB's column families 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 +// event.pack verbatim, in eventID order (the CF's BE-key order). +// - events_offsets IS the per-ledger count sequence → LedgerOffsets. +// - events_index packed rows are term-sorted runs → window-merge them into +// spill runs and finalize through WriteColdIndexFromRuns. +// +// This deletes the freeze's ExtractLedgerEvents shaping, TermsForBytes +// hashing, and every per-term allocation from the window where the freeze +// runs BESIDE live ingestion — the design's freeze-synergy lens +// (~/bench-artifacts/cold-ingest-design.md). Artifacts remain byte-identical +// to the walk-derived build by construction of each input. + +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 event.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 packed-row bytes accumulate in the +// window map before it flushes as one spill run. ~32MB keeps freeze memory +// bounded (window + map overhead) while producing ~40-80 runs for a +// worst-case chunk — well inside the merge's comfort zone. +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 +// intermediate spill runs and terms.run; it is wiped on entry and removed on +// success. opts tunes the event.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, so this path blinds them at its own +// run boundary — the IndexCF window map — keeping both builds keyed by the +// same blinded identity. +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 := 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) + } + + // ── event.pack: verbatim DataCF copy in eventID order. ── + w, err := NewColdWriter(chunkID, bucketDir, opts) + if err != nil { + return err + } + closed := false + defer func() { + if !closed { + err2 := w.Close() + if err == nil { + err = err2 + } + } + }() + + 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.Finish(offsets); err != nil { + return fmt.Errorf("events: freeze Finish: %w", err) + } + closed = true + + // ── index: window-merge IndexCF packed rows into spill runs. ── + runs, err := freezeIndexRuns(ctx, store, scratchDir, secret) + if err != nil { + return err + } + if err := WriteColdIndexFromRuns(ctx, chunkID, runs, scratchDir, bucketDir, secret); err != nil { + return err + } + return os.RemoveAll(scratchDir) +} + +// freezeIndexRuns scans the IndexCF's per-ledger packed rows, unions them in +// a byte-bounded window map, and flushes each full window as one 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 duplicates union in the merge. +func freezeIndexRuns( + ctx context.Context, store *rocksdb.Store, scratchDir string, 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); werr != nil { + return werr + } + runs = append(runs, path) + clear(window) + windowBytes = 0 + return nil + } + for entry, ierr := range store.Iterate(IndexCF, 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) + } + if derr := DecodePackedRow(entry.Value, func(term TermKey, ids []uint32) { + // The run key is the BLINDED routing identity — the same rule the + // walk applies before its spiller. Blinding is injective, so the + // per-term grouping and ascending-ID invariants are unchanged. + bk := TermKey(stores.BlindKey(secret, term[:])) + window[bk] = append(window[bk], ids...) + }); derr != nil { + return nil, fmt.Errorf("events: freeze ledger %d row: %w", binary.BigEndian.Uint32(entry.Key), derr) + } + windowBytes += len(entry.Value) + 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_index.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go index c3a38b5cb..22500f482 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -4,7 +4,7 @@ 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. @@ -61,9 +61,12 @@ func ColdIndexSecret(catalogSecret []byte, chunkID chunk.ID) [stores.SecretLen]b // hit a slow (*Bitmap).lazyOR path at query time and K≥12 regresses // catastrophically. // -// 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. // @@ -124,13 +127,16 @@ 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]) + copy(fp[:], rk[: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. @@ -157,7 +163,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{ 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..95dc90724 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -0,0 +1,370 @@ +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 RunOptimize'd +// serialized bitmap (16B term ‖ uvarint len ‖ bitmap bytes), CRC-framed. +// 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 (slots deviate from key +// order only within one MPHF block; an entry-count backstop turns a +// corrupt slot assignment into a loud abort — the chunk retry +// contract). + +import ( + "bufio" + "bytes" + "container/heap" + "context" + "encoding/binary" + "errors" + "fmt" + "hash/crc32" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/RoaringBitmap/roaring/v2" + + "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" + "github.com/stellar/streamhash" +) + +// maxReorderEntries bounds pass B's slot-reorder heap by RECORD COUNT — the +// quantity the block contract actually bounds: slots stray from stream order +// only within one MPHF block, and streamhash blocks hold at most 65,535 keys +// (per-block cumulative counts are uint16). Crossing this means non-dense or +// corrupt slot assignment — abort loudly, the retry contract rebuilds. A +// byte bound would be wrong in kind: a legitimate block's byte mass is +// unbounded (few terms with multi-MiB bodies), only its key count is capped. +const maxReorderEntries = 1 << 16 + +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 + } + defer func() { + if rmErr := os.Remove(termsRunPath); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) && err == nil { + err = fmt.Errorf("events: remove %s: %w", termsRunPath, rmErr) + } + }() + + // Pass A: keys → SortedBuilder → index.hash. 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) + } + defer func() { + if err != nil { + _ = builder.Close() + } + }() + var fed int + if err = streamTermsRun(termsRunPath, func(term TermKey, _ []byte) 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) + } + 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, + }) + if err != nil { + return fmt.Errorf("events: create index.pack at %s: %w", indexPackPath, err) + } + if err = writeSlotOrdered(pw, termsRunPath, m); err != nil { + if closeErr := pw.Close(); closeErr != nil { + err = errors.Join(err, fmt.Errorf("events: close partial index.pack: %w", closeErr)) + } + return err + } + return nil +} + +// writeTermsRun merges the spill runs into one terms.run scratch file and +// returns the unique-term count. Record: 16B term ‖ uvarint len ‖ +// RunOptimize'd serialized bitmap. 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 + bm = roaring.New() + bmBuf bytes.Buffer + recBuf []byte + mergeED = func(rawTerm [16]byte, ids []uint32) error { + term := TermKey(rawTerm) + bm.Clear() + bm.AddMany(ids) + bm.RunOptimize() // canonical serialization — the byte-identity keystone + bmBuf.Reset() + if _, werr := bm.WriteTo(&bmBuf); werr != nil { + return fmt.Errorf("events: serialize bitmap: %w", werr) + } + recBuf = recBuf[:0] + recBuf = append(recBuf, term[:]...) + recBuf = binary.AppendUvarint(recBuf, uint64(bmBuf.Len())) + recBuf = append(recBuf, bmBuf.Bytes()...) + 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) + if _, err := w.Write(tr[:]); 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'} + 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 +} + +func (c *crcFoldReader) ReadByte() (byte, error) { + b, err := c.br.ReadByte() + if err != nil { + return 0, err + } + c.crc = crc32.Update(c.crc, termsRunCRC, []byte{b}) + return b, nil +} + +// streamTermsRun replays terms.run, calling emit per record with the term +// and the serialized bitmap bytes (reused buffer). Integrity (CRC over all +// records) is verified before returning nil — both passes fully drain, so a +// corrupt scratch can never produce artifacts. +func streamTermsRun(path string, emit func(term TermKey, bitmapBytes []byte) error) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("events: open %s: %w", path, err) + } + defer f.Close() + 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 + ) + for i := uint64(0); i < count; i++ { + var term TermKey + if _, err := io.ReadFull(br, term[:]); err != nil { + return fmt.Errorf("%w: record %d term: %v", errCorruptTermsRun, i, err) + } + crc = crc32.Update(crc, termsRunCRC, term[:]) + // stdlib varint over a CRC-folding ByteReader. + cbr := &crcFoldReader{br: br, crc: crc} + length, rerr := binary.ReadUvarint(cbr) + if rerr != nil { + return fmt.Errorf("%w: record %d length: %v", errCorruptTermsRun, i, rerr) + } + crc = cbr.crc + if length > 1<<31 { + return fmt.Errorf("%w: record %d bitmap 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 bitmap: %v", errCorruptTermsRun, i, err) + } + crc = crc32.Update(crc, termsRunCRC, buf) + if err := emit(term, buf); err != nil { + return err + } + } + 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 +} + +// slotRecord is one reorder-heap element: an assembled index.pack record +// waiting for its slot's turn. +type slotRecord struct { + slot uint32 + fp [IndexRecordFingerprintLen]byte + bm []byte // owned copy +} + +type slotHeap []slotRecord + +func (h slotHeap) Len() int { return len(h) } +func (h slotHeap) Less(i, j int) bool { return h[i].slot < h[j].slot } +func (h slotHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } +func (h *slotHeap) Push(x any) { *h = append(*h, x.(slotRecord)) } +func (h *slotHeap) Pop() any { old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x } +func (h slotHeap) peek() slotRecord { return h[0] } + +// 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 { + var ( + h slotHeap + next uint32 + ) + flush := func() error { + for len(h) > 0 && h.peek().slot == next { + rec := heap.Pop(&h).(slotRecord) + if err := pw.AppendItem(rec.fp[:], rec.bm); err != nil { + return fmt.Errorf("events: write slot %d to index.pack: %w", rec.slot, err) + } + next++ + } + return nil + } + err := streamTermsRun(termsRunPath, func(term TermKey, bitmapBytes []byte) 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[:], bitmapBytes); err != nil { + return fmt.Errorf("events: write slot %d to index.pack: %w", slot, err) + } + next++ + return flush() + } + if len(h) >= maxReorderEntries { + 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)", maxReorderEntries, slot) + } + heap.Push(&h, slotRecord{slot: slot, fp: fp, bm: append([]byte(nil), bitmapBytes...)}) + 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 pw.Finish(encodeIndexBuildStamp()) +} 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..23ddf724e --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream_test.go @@ -0,0 +1,152 @@ +package event + +import ( + "context" + "math/rand" + "os" + "path/filepath" + "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/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)) +} + +// 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) + + // 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") +} + +// 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) + } +} + +// 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) + corpus := synthTerms(500, 9) + + 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)) + + m, err := openMPHF(filepath.Join(dir, IndexHashName(chunkID))) + require.NoError(t, err) + defer m.Close() + for k, want := range corpus { + slot, _, err := m.Lookup(k) + require.NoError(t, err) + _ = slot + _ = want + break // slot resolution exercised; full read path is covered by cold_reader tests + } +} 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..495f39950 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 @@ -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/packfile" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) // indexTestChunkID is the chunk ID every WriteColdIndex test uses for @@ -117,15 +118,16 @@ 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 must match the ROUTED (blinded) key's prefix — the one + // identity both builders and the reader share. + assert.Equal(t, rk[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen], "fingerprint mismatch at slot %d", slot) // Deserialize bitmap. @@ -161,7 +163,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 +173,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 +225,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 +268,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,16 +301,16 @@ 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. + // Lock the on-disk record format: routed-key fingerprint || roaring bitmap. // Future readers (PR-3a) rely on this layout; if it ever changes // silently, this test fails. dir := t.TempDir() @@ -324,7 +326,8 @@ 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]) bm := roaring.New() require.NoError(t, bm.UnmarshalBinary(record[IndexRecordFingerprintLen:])) 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..ede942bde 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go @@ -321,7 +321,8 @@ func (c *ColdReader) Offsets() (*LedgerOffsets, error) { } // verifyAndDeserializeBitmap checks the index.pack record's leading -// fingerprint against key's prefix and, on match, unmarshals a fresh +// fingerprint against the ROUTED (blinded) key's prefix — the identity +// the builders fingerprint by — 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; @@ -386,17 +387,18 @@ func (c *ColdReader) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring 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 } 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 @@ -425,7 +427,7 @@ func (c *ColdReader) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring // leaving results[outIdx] = nil for misses. for _, pIdx := range pendingBySlot[readIdx] { p := pending[pIdx] - bm, err := verifyAndDeserializeBitmap(record, keys[p.outIdx], p.slot) + bm, err := verifyAndDeserializeBitmap(record, p.routed, p.slot) if err != nil { return 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..ada3b52c4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_writer.go @@ -126,6 +126,15 @@ func (w *ColdWriter) Append(p Payload) error { return w.pw.AppendItem(b) } +// 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) +} + // 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. 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..30482ab82 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 @@ -118,6 +124,15 @@ func (s *ConcurrentBitmaps) Get(key TermKey) (*roaring.Bitmap, error) { return bm, 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 // landed since the last snapshot it clones wbm once and publishes // the clone. 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 2dac6ec7e..689fe7242 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go @@ -1,14 +1,14 @@ package event import ( - "bytes" "context" "encoding/binary" "errors" "fmt" "iter" "math" - "slices" + "path/filepath" + "strings" "github.com/RoaringBitmap/roaring/v2" "github.com/linxGnu/grocksdb" @@ -35,7 +35,7 @@ const ( // compression unit and align with batch-fetch shapes. // - IndexCF stores ONE packed row per ledger (4-byte ledger_seq key, // value = sorted term_hash ‖ uvarint event-ID runs — see -// appendPackedIndexRow). One skiplist insert per ledger instead of +// 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 @@ -71,13 +71,9 @@ func CFOptions() map[string]rocksdb.CFOptions { return hotStoreCFOptions() } const ( dataKeyLen = 4 // event_id (chunk encoded by per-Chunk DB directory) - // indexKeyLen is the LEGACY per-(term,event) index key: term hash || - // event_id, one row per pair. Still read by warmup (a restarted daemon - // may hold a chunk with legacy rows) but no longer written. - indexKeyLen = 16 + 4 - // packedIndexKeyLen is the current per-ledger packed index key: the - // ledger sequence. The key length is the format discriminator in - // warmupIndex — legacy rows are 20 bytes, packed rows 4. + // 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) @@ -115,8 +111,15 @@ var ErrLedgerOutOfOrder = errors.New("events: ledger out of order") type HotStore struct { chunkStore *rocksdb.Store chunkID chunk.ID - mirror *ConcurrentBitmaps + hotIdx *HotIndex offsets *ConcurrentLedgerOffsets + + // prevTermCount carries the previous ledger's unique-term count as the + // next ledger's map-size hint: successive ledgers at a given load have + // similar term density, and a fixed small hint costs ~9 incremental + // rehashes of a ~24k-entry map per stress-density ledger, pre-commit. + // Writer goroutine only (single-writer ingest contract). + prevTermCount int } // Compile-time guard: *HotStore satisfies Reader. @@ -129,14 +132,14 @@ var _ Reader = (*HotStore)(nil) // 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) + hotIdx, offsets, err := warmup(store, chunkID) 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 } @@ -158,16 +161,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 @@ -184,11 +187,15 @@ func (h *HotStore) Offsets() (*LedgerOffsets, error) { // 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 +// +// See Reader.LookupKeys for the semantics — in particular +// // the borrowed-bitmap 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) { @@ -203,7 +210,7 @@ func (h *HotStore) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring.B } results := make([]*roaring.Bitmap, len(keys)) for i, key := range keys { - bm, err := h.mirror.Get(key) + bm, err := h.hotIdx.Get(key) if err != nil { return nil, fmt.Errorf("events: LookupKeys for chunk %s: %w", h.chunkID, err) } @@ -226,10 +233,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) { @@ -405,7 +412,7 @@ 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) { +) (func() error, 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. if ledgerSeq < h.chunkID.FirstLedger() || ledgerSeq > h.chunkID.LastLedger() { @@ -443,7 +450,7 @@ func (h *HotStore) IngestLedgerToBatch( // packed index row and the post-commit mirror apply (which previously // rebuilt the same grouping). var scratch []byte - perKeyIDs := make(map[TermKey][]uint32, 64) + perKeyIDs := make(map[TermKey][]uint32, max(64, h.prevTermCount)) for i := range payloads { blob, err := payloads[i].MarshalInto(scratch[:0]) if err != nil { @@ -456,81 +463,82 @@ func (h *HotStore) IngestLedgerToBatch( perKeyIDs[key] = append(perKeyIDs[key], eventID) } } - // ONE packed index row per ledger (see appendPackedIndexRow) instead of a + // ONE packed index row per ledger (AppendPackedRow) 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. + // 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). + h.prevTermCount = len(perKeyIDs) + var rowBytes []byte if len(perKeyIDs) > 0 { - b.Put(IndexCF, encodePackedIndexKey(ledgerSeq), appendPackedIndexRow(nil, perKeyIDs)) + // Exact-size the row: one right-sized allocation instead of ~18 + // doubling regrows for a stress-density ledger — and since the window + // RETAINS this slice, exact fit is retained memory, not just churn. + size := 0 + for _, ids := range perKeyIDs { + size += TermPostingsLen(ids) + } + rowBytes = AppendPackedRow(make([]byte, 0, size), perKeyIDs) + b.Put(IndexCF, encodePackedIndexKey(ledgerSeq), rowBytes) } //nolint:gosec // len bounded by the overflow guard above b.Put(OffsetsCF, encodeOffsetKey(ledgerSeq), encodeLedgerEventCount(uint32(len(payloads)))) //nolint:gosec // len bounded by the overflow guard above eventCount := uint32(len(payloads)) - return func() { h.applyLedger(perKeyIDs, eventCount) }, nil + return func() error { + if rowBytes != nil { + if err := h.hotIdx.ApplyLedger(ledgerSeq, rowBytes, perKeyIDs); 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(perKeyIDs map[TermKey][]uint32, eventCount uint32) { - // perKeyIDs is the write path's per-term grouping, reused here so each - // key takes one AddTo per ledger instead of one per event — saving the - // per-term lock round-trips, and the grouping work itself happens once - // per ledger instead of twice. - for key, ids := range perKeyIDs { - h.mirror.AddTo(key, ids...) - } - h.offsets.Append(eventCount) +// 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 } +// 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 @@ -579,83 +587,88 @@ func verifyChunkConsistency(chunkStore *rocksdb.Store, total uint32, indexUpperB return nil } -// warmupIndex scans the events_index CF and replays every index row into a -// fresh ConcurrentBitmaps. Design doc §12 step 3. Two row formats -// (discriminated by key length): the current packed per-ledger row — one key -// per ledger, terms with ID runs in the value — and the legacy -// per-(term,event) row a pre-upgrade daemon may have left in the same chunk. +// overlay returns the hot index's dense-term overlay — a PARTIAL view +// holding only promoted (dense) terms; sparse terms live in the window rows +// and sealed runs and are absent here. Test-only write hook: no production +// path reads it, and no read path should — a lookup against the overlay +// alone silently misses every sparse term (use HotIndex.Get / LookupKeys). +func (h *HotStore) overlay() *ConcurrentBitmaps { return h.hotIdx.overlay } + +// ────────────────────────────────────────────────────────────────── +// 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, 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). Packed rows arrive -// pre-grouped per term; legacy rows are grouped via the prevTerm/buf -// accumulator (byte-sorted iteration delivers a term's legacy rows -// consecutively). +// - 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, +) (*HotIndex, *ConcurrentLedgerOffsets, error) { + offsets, err := warmupOffsets(chunkStore, chunkID) + if 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) + manifest := rocksdbManifest{store: chunkStore} + runDir := filepath.Join(chunkStore.Path(), hotIndexRunDir) + hotIdx, lastSealed, err := OpenHotIndex(runDir, manifest) + 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 + 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) } - switch len(entry.Key) { - case packedIndexKeyLen: - // Packed per-ledger row: replay every term's ID run. Flush the - // legacy accumulator first — AddTo appends, so a term split - // across formats merges correctly either way; flushing just - // keeps the legacy batching invariant simple. - flush() - if derr := decodePackedIndexRow(entry.Value, func(term TermKey, ids []uint32) { - builder.AddTo(term, ids...) - if last := uint64(ids[len(ids)-1]) + 1; last > indexUpperBound { - indexUpperBound = last - } - }); derr != nil { - return nil, 0, fmt.Errorf("events: warmup %s ledger %d: %w", - IndexCF, binary.BigEndian.Uint32(entry.Key), derr) - } - case indexKeyLen: - // Legacy per-(term,event) row — written by pre-packed-format - // daemons; a chunk can hold both after a mid-chunk upgrade. - 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 - } - if hasPrev && term != prevTerm { - flush() + 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) + } + seq := binary.BigEndian.Uint32(entry.Key) + per := make(map[TermKey][]uint32, 1<<10) + if derr := DecodePackedRow(entry.Value, func(term TermKey, ids []uint32) { + per[term] = append([]uint32(nil), ids...) + if last := uint64(ids[len(ids)-1]) + 1; last > indexUpperBound { + indexUpperBound = last } - prevTerm = term - hasPrev = true - buf = append(buf, eventID) - default: - return nil, 0, fmt.Errorf("events: warmup unexpected %s key length %d (want %d or %d)", - IndexCF, len(entry.Key), packedIndexKeyLen, indexKeyLen) + }); derr != nil { + hotIdx.Close() + return nil, nil, fmt.Errorf("events: warmup %s ledger %d: %w", IndexCF, seq, derr) + } + if aerr := hotIdx.ApplyLedger(seq, append([]byte(nil), entry.Value...), per); aerr != nil { + hotIdx.Close() + return nil, nil, aerr } } - 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, @@ -673,39 +686,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. // ────────────────────────────────────────────────────────────────── @@ -716,103 +744,10 @@ func encodeDataKey(eventID uint32) []byte { return key[:] } -// encodeIndexKey builds a LEGACY per-(term,event) index key. Kept for warmup -// tests and the dual-format read path; the write path emits packed rows. -func encodeIndexKey(term TermKey, eventID uint32) []byte { - var key [indexKeyLen]byte - copy(key[:16], term[:]) - binary.BigEndian.PutUint32(key[16:], eventID) - return key[:] -} - -func encodePackedIndexKey(ledgerSeq uint32) []byte { - var key [packedIndexKeyLen]byte - binary.BigEndian.PutUint32(key[:], ledgerSeq) - return key[:] -} - -// appendPackedIndexRow appends one ledger's packed index-row value to dst and -// returns it: for each term (byte-sorted, so the encoding is deterministic), -// -// 16-byte term hash ‖ uvarint id-count ‖ ids as uvarints -// (first absolute, then strictly-positive deltas) -// -// Event IDs are appended by the caller in ascending order per term, so the -// deltas are always ≥ 1 — decodePackedIndexRow rejects a zero delta as -// corruption. Delta-varint keeps the run for a term with k sequential IDs to -// ~1 byte per ID. -func appendPackedIndexRow(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 { - ids := perKeyIDs[t] - dst = append(dst, t[:]...) - 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 -} - -// decodePackedIndexRow parses a packed index-row value, 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 — -// on-disk rows are untrusted input to warmup. ids is reused across terms; add -// must consume it before returning (warmup's builder.AddTo copies). -func decodePackedIndexRow(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 index row: %d trailing bytes, want 16-byte term", len(val)) - } - var term TermKey - copy(term[:], val[:16]) - val = val[16:] - count, n := binary.Uvarint(val) - if n <= 0 { - return errors.New("events: packed index row: bad id-count uvarint") - } - val = val[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(val)) { - return fmt.Errorf("events: packed index row: id count %d exceeds %d remaining bytes", count, len(val)) - } - ids = ids[:0] - var prev uint64 - for i := uint64(0); i < count; i++ { - v, n := binary.Uvarint(val) - if n <= 0 { - return errors.New("events: packed index row: bad event-id uvarint") - } - val = val[n:] - abs := v - if i > 0 { - if v == 0 { - return errors.New("events: packed index row: zero delta") - } - abs = prev + v - } - if abs > math.MaxUint32 { - return fmt.Errorf("events: packed index row: event id %d overflows uint32", abs) - } - ids = append(ids, uint32(abs)) - prev = abs - } - add(term, ids) - } - return nil -} +// 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 08ba46dcf..ea1faba4a 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 @@ -164,8 +164,8 @@ func TestHotStore_IngestLedgerWritesAllCFs(t *testing.T) { 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[events.TermKey][]uint32{} - require.NoError(t, decodePackedIndexRow(idxVal, func(term events.TermKey, ids []uint32) { + decodedTerms := map[TermKey][]uint32{} + require.NoError(t, DecodePackedRow(idxVal, func(term TermKey, ids []uint32) { decodedTerms[term] = append([]uint32(nil), ids...) })) for _, key := range keys { @@ -722,7 +722,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 @@ -731,7 +731,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..423376b30 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 @@ -3,6 +3,8 @@ package event import ( "fmt" "math" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -23,7 +25,7 @@ func TestWarmup_FreshChunkProducesEmptyMirrorsViaNewWithStore(t *testing.T) { // 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)) + bm, err := h.store.hotIdx.Get(ComputeTermKey([]byte("any"), FieldContractID)) require.NoError(t, err) assert.Nil(t, bm) assert.Zero(t, h.store.offsets.LedgerCount()) @@ -58,7 +60,7 @@ func TestWarmup_RebuildsMirrorFromIngestedRows(t *testing.T) { } expected := make(map[TermKey]uint64) for _, k := range seededKeys { - bm, err := hot1.mirror.Get(k) + bm, 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() @@ -70,7 +72,7 @@ func TestWarmup_RebuildsMirrorFromIngestedRows(t *testing.T) { got := make(map[TermKey]uint64) for k := range expected { - bm, err := hot2.mirror.Get(k) + bm, err := hot2.hotIdx.Get(k) require.NoError(t, err) require.NotNil(t, bm, "seeded term missing from the reopened mirror") got[k] = bm.GetCardinality() @@ -184,6 +186,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 +313,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 +358,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..3a4b25e58 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -0,0 +1,361 @@ +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: full +// 16-byte terms, 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. +// - 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 term before IDs count. +// 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/RoaringBitmap/roaring/v2" + "github.com/cespare/xxhash/v2" +) + +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 pread + + // decode span. + fenceEvery = 64 +) + +// 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 bloomFilter + fences []fence // sorted by term; one per fenceEvery records + final end sentinel + 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) + 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. + sealEvery int + maxRuns int + ledgersInWindow int + sealSeq int + pendingSeal chan sealResult // capacity 1: at most one seal in flight + sealInFlight bool + manifest manifestStore + + // 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. +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 +} + +// manifestStore persists which runs are live — the crash-recovery authority. +// Implemented over the chunk's RocksDB by the integration layer; faked in +// tests. +type manifestStore interface { + // PutRuns atomically replaces the live-run list and records the highest + // sealed ledger (warmup replays packed rows PAST it). + PutRuns(names []string, lastSealed uint32) error + // GetRuns returns the live-run list and the sealed frontier (zero when + // nothing sealed). + GetRuns() ([]string, uint32, error) +} + +// NewHotIndex creates the engine for a fresh chunk. dir is created. +func NewHotIndex(dir string, manifest manifestStore) (*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, + overlay: NewConcurrentBitmapsFromBitmaps(Bitmaps{}), + pendingSeal: make(chan sealResult, 1), + manifest: manifest, + 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), perKeyIDs the per-term grouping the write +// path already built. Writer goroutine only. +func (h *HotIndex) ApplyLedger(seq uint32, rowBytes []byte, perKeyIDs map[TermKey][]uint32) 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. + for term, ids := range perKeyIDs { + 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(perKeyIDs)) + 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}) + h.ledgersInWindow++ + + if h.sealArmed && h.ledgersInWindow >= 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 perKeyIDs 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 bitmap (nil if absent): dense overlay fast path, +// else window+runs materialization. Concurrent-safe. +func (h *HotIndex) Get(term TermKey) (*roaring.Bitmap, error) { + if bm, err := h.overlay.Get(term); err != nil || bm != nil { + return bm, err + } + ids, err := h.lookupSparse(h.view.Load(), term) + if err != nil { + return nil, err + } + if len(ids) == 0 { + return nil, nil //nolint:nilnil // absent term = nil bitmap, like the mirror + } + bm := roaring.New() + bm.AddMany(ids) + return bm, nil +} + +// decodeRecordIDs decodes ONE packed record's ID list. +func decodeRecordIDs(rec []byte) ([]uint32, error) { + var out []uint32 + err := DecodePackedRow(rec[:PackedRecordLen(rec)], func(_ TermKey, ids []uint32) { + out = append(out, ids...) + }) + return out, err +} + +// dedupAscendingIDs dedups an ascending-with-possible-overlap ID list (a +// dense term's postings appear in both a run and the overlay path never hits +// here; window/run overlap cannot happen — but keep reads defensive). +func dedupAscendingIDs(ids []uint32) []uint32 { + return slices.Compact(ids) +} + +// 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() + for _, r := range res.obsolete { + r.close() + } + } + } + for _, r := range h.view.Load().runs { + r.close() + } + for _, r := range h.retired { + r.close() + } + 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. +func (h *HotIndex) lookupSparse(v *hotIndexView, term TermKey) ([]uint32, error) { + var out []uint32 + fp := fp64(term) + for _, r := range v.runs { + ids, err := r.lookup(term) + if err != nil { + return nil, err + } + out = append(out, ids...) + } + 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 + } + ids, err := decodeRecordIDs(rec) + if err != nil { + return nil, err + } + out = append(out, ids...) + } + } + return dedupAscendingIDs(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..bebd6f597 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go @@ -0,0 +1,360 @@ +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" + + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" +) + +// ─────────────────────────── bloom filter ─────────────────────────── + +// bloomFilter is a plain double-hashed bloom over term fp64s: ~10 bits/key, +// 7 probes. Pointerless (one []uint64), GC-invisible. +type bloomFilter struct { + bits []uint64 + mask uint64 +} + +const bloomProbes = 7 + +// newBloom sizes for n keys at ~10 bits/key, rounded up to a power of two +// (mask-indexable). +func newBloom(n int) bloomFilter { + bits := 1 + for bits < n*10 { + bits <<= 1 + } + return bloomFilter{bits: make([]uint64, bits/64+1), mask: uint64(bits - 1)} //nolint:gosec // bits >= 1 +} + +func (b *bloomFilter) add(fp uint64) { + h2 := fp>>33 | fp<<31 | 1 // odd second hash + for i := range bloomProbes { + bit := (fp + uint64(i)*h2) & b.mask + b.bits[bit/64] |= 1 << (bit % 64) + } +} + +func (b *bloomFilter) mayContain(fp uint64) bool { + h2 := fp>>33 | fp<<31 | 1 + for i := range bloomProbes { + bit := (fp + uint64(i)*h2) & b.mask + if b.bits[bit/64]&(1<<(bit%64)) == 0 { + return false + } + } + return true +} + +// ─────────────────────────── 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 + sealed := len(rows) + 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) + 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))) + if merr != 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() + var runs []*sealedRun + if res.replaceAll { + runs = []*sealedRun{res.run} + } else { + runs = append(append([]*sealedRun{}, old.runs...), res.run) + } + names := make([]string, len(runs)) + for i, r := range runs { + names[i] = filepath.Base(r.path) + } + if err := h.manifest.PutRuns(names, res.lastSeq); err != nil { + return fmt.Errorf("events: hotindex manifest: %w", err) + } + h.view.Store(&hotIndexView{rows: old.rows[res.rows:], runs: runs}) + h.ledgersInWindow -= res.rows + // 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 +} + +// sealWindow folds window rows into one run file and builds its in-RAM +// routing state. Runs on the background goroutine over immutable inputs. +func sealWindow(rows []windowRow, path string) (*sealedRun, error) { + window := make(map[TermKey][]uint32, 1<<15) + for i := range rows { + if err := DecodePackedRow(rows[i].bytes, func(term TermKey, ids []uint32) { + window[term] = append(window[term], ids...) + }); err != nil { + return nil, err + } + } + if err := writeSortedRun(window, path); err != nil { + return nil, err + } + return openSealedRun(path) +} + +// writeSortedRun streams a folded (term → ids) window to path as one +// term-sorted run file — no whole-payload buffer. Shared by the seal and by +// the events freeze's window flushes, so the fold-and-stream shape has one +// implementation. +func writeSortedRun(window map[TermKey][]uint32, path string) 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 err + } + for _, t := range terms { + if err := rw.Append(t, window[t]); err != nil { + _ = rw.Close() + _ = os.Remove(path) + return err + } + } + return rw.Close() +} + +// mergeSealedRuns merges live runs into one new run file (union semantics — +// runspill.MergeRuns), returning it plus the now-obsolete inputs. +func mergeSealedRuns(runs []*sealedRun, path string) (*sealedRun, []*sealedRun, error) { + paths := make([]string, len(runs)) + for i, r := range runs { + paths[i] = r.path + } + // 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 + } + if err := runspill.MergeRuns(paths, rw.Append); err != nil { + _ = rw.Close() + _ = os.Remove(path) + return nil, nil, err + } + if err := rw.Close(); err != nil { + return nil, nil, err + } + merged, err := openSealedRun(path) + if err != nil { + return nil, nil, err + } + return merged, runs, nil +} + +// openSealedRun drains a run file (verifying its CRC), building the bloom + +// fences + open file handle for lookups. Used at seal, merge, and warmup — +// the routing state is always rebuilt from the verified file, never trusted +// from elsewhere. +func openSealedRun(path string) (*sealedRun, error) { + r, err := runspill.OpenRun(path) + if err != nil { + return nil, err + } + var ( + nTerms int + fences []fence + off int64 + ) + // First pass metadata: term count for bloom sizing requires a drain, so + // collect fences+offsets in the same pass and blooms in a second cheap + // pass over the recorded fps. + var fps []uint64 + for { + term, ids, nerr := r.Next() + if errors.Is(nerr, io.EOF) { + break + } + if nerr != nil { + _ = r.Close() + return nil, nerr + } + if nTerms%fenceEvery == 0 { + fences = append(fences, fence{term: term, off: off}) + } + off += int64(TermPostingsLen(ids)) + fps = append(fps, fp64(term)) + nTerms++ + } + if err := r.Close(); err != nil { + return nil, err + } + fences = append(fences, fence{off: off}) // end sentinel: off = payload length + bloom := newBloom(max(nTerms, 1)) + for _, fp := range fps { + bloom.add(fp) + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + return &sealedRun{path: path, bloom: bloom, fences: fences, file: f}, nil +} + +// lookup probes the run for one term: bloom reject → fence window → pread → +// linear decode with full-term verification. Concurrent-safe (ReadAt). +func (r *sealedRun) lookup(term TermKey) ([]uint32, error) { + if !r.bloom.mayContain(fp64(term)) { + return nil, 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 nil, nil // term sorts before the first record + } + start, end := r.fences[i-1].off, r.fences[i].off + buf := make([]byte, end-start) + // 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(buf, runspill.HeaderLen+start); err != nil { + return nil, fmt.Errorf("events: hotindex run pread %s: %w", r.path, err) + } + for len(buf) > 0 { + c := bytes.Compare(buf[:16], term[:]) + recLen := PackedRecordLen(buf) + if c == 0 { + return decodeRecordIDs(buf[:recLen]) + } + if c > 0 { + return nil, nil // sorted: passed the slot, absent + } + buf = buf[recLen:] + } + return nil, nil +} + +func (r *sealedRun) close() { + if r.file != nil { + _ = r.file.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. 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 manifestStore) (*HotIndex, uint32, error) { + h, err := NewHotIndex(dir, manifest) + 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) + } + referenced := make(map[string]bool, len(names)) + runs := make([]*sealedRun, 0, len(names)) + for _, name := range names { + referenced[name] = true + r, oerr := openSealedRun(filepath.Join(dir, name)) + if oerr != nil { + for _, r2 := range runs { + r2.close() + } + return nil, 0, fmt.Errorf("events: hotindex: manifest run %s: %w", name, oerr) + } + runs = append(runs, r) + } + // Orphan sweep: files present but unreferenced are garbage by definition + // (crash between rename and manifest, or between manifest and delete). + entries, err := os.ReadDir(dir) + if err != nil { + return nil, 0, err + } + for _, e := range entries { + if !e.IsDir() && !referenced[e.Name()] { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } + h.view.Store(&hotIndexView{runs: runs}) + // Resume the name sequence past every live run so a future seal can never + // overwrite one (numeric suffixes are monotone within each prefix). + maxSeq := 0 + for _, name := range names { + var n int + if _, serr := fmt.Sscanf(name, "seal-%06d.run", &n); serr == nil && n > maxSeq { + maxSeq = n + } + if _, serr := fmt.Sscanf(name, "merge-%06d.run", &n); serr == nil && n > maxSeq { + maxSeq = n + } + } + h.sealSeq = maxSeq + 1 + return h, lastSealed, nil +} 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..bf6ec8b13 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -0,0 +1,289 @@ +package event + +import ( + "math/rand" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeManifest is an in-memory manifestStore. +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 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, 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 { + bm, err := hh.h.Get(k) + require.NoError(hh.t, err) + require.NotNil(hh.t, bm, "term %x missing", k) + require.Equal(hh.t, want, bm.ToArray(), "term %x wrong ids", k) + } + for range 20 { + absent := hiKey(hh.rng) + bm, err := hh.h.Get(absent) + require.NoError(hh.t, err) + assert.Nil(hh.t, bm, "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 manifestStore) *HotIndex { + h, err := NewHotIndex(dir, m) + 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) + 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, 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) + 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) + 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) + 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 bm, err := h.Get(fire); err == nil && bm != nil { + _ = bm.GetCardinality() // 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() +} 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..8c7fdad61 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go @@ -550,6 +550,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 dense overlay (the overlay() hook). 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.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 @@ -1573,16 +1614,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.overlay().AddTo(gammaKey, 1, 2, 3, 4) filters := []Filter{{Topics: [protocol.MaxTopicCount][]byte{nil, fx.t0cRaw}}} for _, batch := range []int{512, 1} { @@ -1645,8 +1686,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.overlay().AddTo(key, 0, 1, 4) for id := uint32(100); id < 200; id++ { - fx.store.index().AddTo(key, id) + fx.store.overlay().AddTo(key, id) } before := lookupOne(t, fx.store, key) require.GreaterOrEqual(t, before.GetCardinality(), uint64(100), @@ -1670,52 +1712,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 index 11e616767..354c7b4ca 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go @@ -5,96 +5,42 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/events" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" ) -func decodeAll(t *testing.T, val []byte) map[events.TermKey][]uint32 { +func decodeAll(t *testing.T, val []byte) map[TermKey][]uint32 { t.Helper() - out := map[events.TermKey][]uint32{} - require.NoError(t, decodePackedIndexRow(val, func(term events.TermKey, ids []uint32) { + 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 events.TermKey + var t1, t2 TermKey t1[0] = 0x01 t2[0] = 0x02 - in := map[events.TermKey][]uint32{ + 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, appendPackedIndexRow(nil, in))) + assert.Equal(t, in, decodeAll(t, AppendPackedRow(nil, in))) } func TestPackedIndexRow_RejectsCorruption(t *testing.T) { - var term events.TermKey + var term TermKey term[0] = 0xaa - good := appendPackedIndexRow(nil, map[events.TermKey][]uint32{term: {3, 4}}) + good := AppendPackedRow(nil, map[TermKey][]uint32{term: {3, 4}}) - nop := func(events.TermKey, []uint32) {} + nop := func(TermKey, []uint32) {} // Truncated term header. - require.ErrorContains(t, decodePackedIndexRow(good[:10], nop), "trailing bytes") + require.ErrorContains(t, DecodePackedRow(good[:10], nop), "trailing bytes") // id count exceeding remaining bytes (truncate the ID area). - require.ErrorContains(t, decodePackedIndexRow(good[:len(good)-1], nop), "exceeds") + require.ErrorContains(t, DecodePackedRow(good[:len(good)-1], nop), "exceeds") // Zero delta = duplicate ID within a term. - var dup []byte + 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, decodePackedIndexRow(dup, nop), "zero delta") -} - -// TestWarmup_MixedLegacyAndPackedRows simulates a mid-chunk daemon upgrade: -// ledger 2's index rows are in the legacy per-(term,event) format (as a -// pre-upgrade daemon left them), ledger 3's arrive packed. Warmup must merge -// both formats into one mirror — including a term that spans the formats. -func TestWarmup_MixedLegacyAndPackedRows(t *testing.T) { - const chunkID = chunk.ID(0) - dir := t.TempDir() - - // Ingest ledger 2 (event 0) with the current packed writer. - hot1, raw1 := openHotStoreForTestAt(t, dir, chunkID) - p1, keys1 := makePayload("alpha") - require.NoError(t, ingestLedgerEvents(hot1, 2, []events.Payload{p1})) - require.NoError(t, raw1.Close()) - - // Rewrite ledger 2's index into the LEGACY format, exactly as an old - // daemon would have written it. - corruptHotChunk(t, dir, chunkID, func(raw *rocksdb.Store) { - val, found, err := raw.Get(IndexCF, encodePackedIndexKey(2)) - require.NoError(t, err) - require.True(t, found) - require.NoError(t, raw.Delete(IndexCF, encodePackedIndexKey(2))) - require.NoError(t, decodePackedIndexRow(val, func(term events.TermKey, ids []uint32) { - for _, id := range ids { - require.NoError(t, raw.Put(IndexCF, encodeIndexKey(term, id), nil)) - } - })) - }) - - // Reopen (warmup: legacy-only) and ingest ledger 3 (event 1) packed. - hot2, raw2 := openHotStoreForTestAt(t, dir, chunkID) - p2, keys2 := makePayload("beta") - require.NoError(t, ingestLedgerEvents(hot2, 3, []events.Payload{p2})) - require.NoError(t, raw2.Close()) - - // Final warmup sees both formats in one CF. - hot3, _ := openHotStoreForTestAt(t, dir, chunkID) - assert.Equal(t, uint32(2), mustEventCount(t, hot3)) - - // Per-payload symbol terms: one event each, from their own format. - assert.True(t, lookupOne(t, hot3, keys1[len(keys1)-1]).Contains(0)) - assert.True(t, lookupOne(t, hot3, keys2[len(keys2)-1]).Contains(1)) - - // The shared contract-ID term (same contract in both payloads) must hold - // BOTH events — one arrived via a legacy row, one via a packed row. - shared := lookupOne(t, hot3, keys1[0]) - require.NotNil(t, shared) - assert.True(t, shared.Contains(0) && shared.Contains(1), - "cross-format merge lost an event for the shared term") + require.ErrorContains(t, DecodePackedRow(dup, nop), "zero delta") } 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..8527fcf68 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go @@ -0,0 +1,129 @@ +package event + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" + "slices" +) + +// 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 + next := func() (uint64, error) { + v, n := binary.Uvarint(val) + if n <= 0 { + return 0, errors.New("bad event-id uvarint") + } + val = val[n:] + return v, nil + } + 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]) + val = val[16:] + count, n := binary.Uvarint(val) + if n <= 0 { + return errors.New("events: packed row: bad id-count uvarint") + } + val = val[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(val)) { + return fmt.Errorf("events: packed row: id count %d exceeds %d remaining bytes", count, len(val)) + } + var err error + if ids, err = DecodeAscendingIDs(next, count, ids[:0]); err != nil { + return fmt.Errorf("events: packed row: %w", err) + } + add(term, ids) + } + return 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 +} + +// uvarintLen is the byte width binary.AppendUvarint would use for v. +func uvarintLen(v uint64) int { + n := 1 + for v >= 0x80 { + v >>= 7 + n++ + } + 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) } + +// DecodeAscendingIDs decodes count delta-varint ids via next, validating +// ascending order and uint32 range (postings are untrusted input). +var DecodeAscendingIDs = runspill.DecodeAscendingIDs 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..a09c45a2e --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/codec.go @@ -0,0 +1,92 @@ +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[:]...) + 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..cc7fa9740 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go @@ -0,0 +1,191 @@ +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). +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)). +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..10f788b0b --- /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) + require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + 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..baba38e32 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go @@ -0,0 +1,368 @@ +// 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', '1'} //nolint:gochecknoglobals // fixed format tag + +// HeaderLen is the run-file header size: magic (4) ‖ u64 payload length (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. +const HeaderLen = 12 + +// 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 +} + +// 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[:]) +} + +// SortEncode sorts the slab in place (unstable — duplicates are collapsed +// anyway), dedups exact duplicate records, and encodes the result as +// packed-row postings into dst (reused across spills), returning it. IDs +// within a term come out ascending by construction of the composite order. +func (s *Slab) SortEncode(dst []byte) []byte { + sort.Sort(&slabRecords{buf: s.buf}) + n := s.Records() + var ( + curTerm [16]byte + ids []uint32 + haveTerm bool + prevRec []byte + ) + flush := func() { + if haveTerm { + dst = AppendTermPostings(dst, curTerm, 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 { + flush() + curTerm = term + haveTerm = true + ids = ids[:0] + } + ids = append(ids, id) + } + flush() + return dst +} + +// WriteRun writes payload (a SortEncode result) to path as one run file: +// magic ‖ u64 payload length ‖ payload ‖ CRC-32C(payload). The file is +// written via a temp name and renamed, then synced — runs are scratch, but a +// half-written file must never be mistaken for a short valid one. +func WriteRun(path string, payload []byte) error { + rw, err := NewRunWriter(path) + if err != nil { + return err + } + // One raw write through RunWriter's framing (incremental CRC, patched + // header, temp+rename in Close) — byte-identical to the historical + // whole-payload writer, with a single implementation of the container. + if werr := rw.writeRaw(payload); werr != nil { + _ = rw.f.Close() + _ = os.Remove(rw.path + ".tmp") + return fmt.Errorf("runspill: write %s: %w", path, werr) + } + return rw.Close() +} + +// RunWriter streams records into a run file without buffering the payload: +// incremental CRC, header length patched at Close, temp+rename like WriteRun. +// The record-at-a-time transient replaces WriteRun's whole-payload buffer — +// the hot tier's late-chunk merges write ~GBs through here. +type RunWriter struct { + path string + f *os.File + w *bufio.Writer + crc uint32 + written uint64 + buf []byte +} + +// NewRunWriter creates path (via a temp name) with a placeholder header. +func NewRunWriter(path string) (*RunWriter, error) { + f, err := os.Create(path + ".tmp") + if err != nil { + return nil, fmt.Errorf("runspill: create %s.tmp: %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 + ".tmp") + return nil, fmt.Errorf("runspill: write header %s.tmp: %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) + return rw.writeRaw(rw.buf) +} + +// Close writes the trailer, patches the header's payload length, syncs, and +// renames into place. On error the temp file is removed. +func (rw *RunWriter) Close() error { + fail := func(err error) error { + _ = rw.f.Close() + _ = os.Remove(rw.path + ".tmp") + 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 lenb [8]byte + binary.BigEndian.PutUint64(lenb[:], rw.written) + if _, err := rw.f.WriteAt(lenb[:], 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(err) + } + if err := os.Rename(rw.path+".tmp", rw.path); err != nil { + _ = os.Remove(rw.path + ".tmp") + return fmt.Errorf("runspill: rename %s: %w", rw.path, err) + } + return nil +} + +// writeRaw sends pre-encoded payload bytes through the container accounting — +// incremental CRC, payload-length tally, buffered write. The ONE site that +// touches those fields, so Append and WriteRun cannot drift. +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 + remain uint64 + crc uint32 + ids []uint32 + trailer bool +} + +// OpenRun opens path and validates its header. +func OpenRun(path string) (*RunReader, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("runspill: open %s: %w", path, err) + } + br := bufio.NewReaderSize(f, 1<<20) + var hdr [HeaderLen]byte + if _, err := io.ReadFull(br, hdr[:]); err != nil { + _ = f.Close() + return nil, fmt.Errorf("%w: %s: short header", ErrCorruptRun, path) + } + if !bytes.Equal(hdr[:4], runMagic[:]) { + _ = f.Close() + return nil, fmt.Errorf("%w: %s: bad magic", ErrCorruptRun, path) + } + return &RunReader{f: f, br: br, remain: binary.BigEndian.Uint64(hdr[4:])}, nil +} + +// 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 !r.trailer { + if err := r.verifyTrailer(); 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 + 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 +} + +// verifyTrailer reads and checks the CRC after the payload is exhausted. +func (r *RunReader) verifyTrailer() error { + 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) + } + 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..4cdb6791a --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go @@ -0,0 +1,163 @@ +package runspill + +import ( + "bytes" + "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 +} + +// 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") + require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + 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") + require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + assert.Equal(t, map[[16]byte][]uint32{key(9): {5, 6}}, drain(t, path)) +} + +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") + require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + + 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. + p := filepath.Join(dir, "trunc.run") + require.NoError(t, os.WriteFile(p, raw[:len(raw)-9], 0o644)) + r, err := OpenRun(p) + require.NoError(t, err) + 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, "truncated run must surface an error") +} + +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") + require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + 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 + } +} 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..6db7e8608 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go @@ -0,0 +1,129 @@ +package runspill + +import ( + "fmt" + "os" + "path/filepath" +) + +// Spiller is the double-buffered producer facade over Slab + WriteRun: 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 + encBuf []byte // reused SortEncode destination, handed between spills + inflight chan error + pending bool // a spill goroutine is running; encBuf/spare are 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. +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. +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) + + buf := s.encBuf[:0] + s.pending = true + go func() { + payload := full.SortEncode(buf) + err := WriteRun(path, payload) + s.encBuf = payload // safe: producer reads encBuf only after the + s.inflight <- err // blocking receive below (channel happens-before) + }() + return nil +} + +// 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 makes the +// goroutine's encBuf hand-back visible to the producer. +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/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index 74ed1ab57..58ab4fdfa 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -232,6 +232,20 @@ func (d *DB) Events() *event.HotStore { return d.events } +// FreezeEventsCold builds the chunk's three cold events artifacts in +// bucketDir directly from THIS hot DB's events CFs — freeze-by-merge: the +// data CF's values are the canonical marshaled payloads, the offsets CF is +// the ledger-count sequence, and the packed index rows are term-sorted runs, +// so no ledger re-extraction (and no per-term memory) is needed. 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) +} + // 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 @@ -241,10 +255,15 @@ func (d *DB) Source() ledgerbackend.LedgerStream { return &hotLedgerStream{store: d.ledger} } -// 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() } +// Close releases the shared store exactly once. Idempotent. Must not be called +// concurrently with in-flight reads/writes. The events facade's background +// seal is drained first so no goroutine outlives the store. +func (d *DB) Close() error { + if d.events != nil { + d.events.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 @@ -273,9 +292,11 @@ func (d *DB) MaxCommittedSeq() (uint32, bool, error) { // 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 events hot-index apply (window retention, +// dense-overlay feed, seal folding). It runs only after the batch is +// durable; the apply hook is fallible, so an error here reports +// Failed = PhaseApply — the ledger IS committed, and a restart rebuilds +// the index deterministically from the committed rows. type Phase uint8 const ( @@ -420,7 +441,7 @@ func (d *DB) IngestLedger( // never a duplicate — the hook is 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 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 @@ -469,12 +490,17 @@ 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 apply the events hot-index update + // (window retention, dense-overlay feed, seal folding). PhaseApply times + // this post-commit work; an apply error is restartable (warmup rebuilds + // deterministically from the committed rows). applyStart := time.Now() - applyEvents() + aerr := applyEvents() rep.Phases[PhaseApply].Dur = time.Since(applyStart) + if aerr != nil { + rep.Failed = PhaseApply + return rep, fmt.Errorf("apply events index for ledger %d: %w", seq, aerr) + } return rep, nil } From 5dc53d97b7276ffbd1e6a924db4fd12c2ca90ecd Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Mon, 3 Aug 2026 23:49:41 +0000 Subject: [PATCH 04/21] freeze: zero-decompression cold artifacts; window-scale harness and writeback residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- zero-decompression freeze: cold artifacts by CF scan, no re-encode FreezeColdChunk materializes a complete chunk's cold artifacts straight from its hot DB with no ledger stream and no decompression: ledgers copy as verbatim zstd frames into a PreCompressed packfile (zstd.FrameHeaderValid gates compatibility; density check pins the positional pack), txhash streams the pre-sorted hash CF into the .bin via coldBinStream (BE CF value re-encoded LE; header patched at finish, fd released on every error path), and events freeze by CF merge. The backfill dispatcher picks the freeze route whenever a ready hot DB covers the chunk (walk plumbing deleted); the freeze bench subcommand puts the route under measurement. The events arm resolves scratch via the shared eventsScratchDir name, so a crashed attempt is wiped by whichever materializer retries. Destination-dir creation is owned by each store's FreezeColdFromStore, mirroring the walk path. Byte-identity gates: every artifact kind compares identical between the walk and freeze paths. Solo refreeze 10m02 -> 7m03 (-30%), cold-protect unchanged; co-located: strictly better than the old freeze like-for-like (141.9 -> ~115-134 window p99 unpaced). --- the cold pack's content hash: computed at freeze time, over raw bytes The cold ledger pack carries a SHA-256 over its logical item stream (mainline 4d9372be, #967), and that hash is canonical because its input is the raw LCM bytes, not the compressed frame: an audit can content-compare two artifacts without pinning a zstd version, and a chunk frozen from a hot DB has to agree with the same chunk walked from the ledger stream. Raw mode gets that for free — AppendItem receives raw bytes and the record encoder runs after the hasher. PreCompressed hands the writer the FRAME, so the hash input has to be recovered. THE DECISION: it is recovered at FREEZE time, not carried from ingest. The PreCompressed writer supplies ContentHashExtract, which decompresses each item through the shared cold-pack Decompressor (a context pool, so it is safe on the packfile's worker goroutines) before it reaches the hasher. The stored bytes are untouched; only the hash input changes. THE TRADE, stated plainly: the freeze decodes once per ledger to hash it, and the live ingest loop never hashes. The alternative is to compute each ledger's digest at hot ingest, where the raw bytes are already in hand, store it beside the ledger and hand it to the writer — which buys the freeze's decode back by putting a SHA-256 fork on every ingest batch, under a companion column family that is a hot-tier format change (a hot DB written before it fails the freeze's read-only open). The freeze is a batch job with a whole machine behind it; the ingest loop is the p99 this campaign exists to protect. The cost goes where there is slack. The decode is not serialized. The packfile spawns max(Concurrency, 1) record workers whenever content hashing is on — encoder or no encoder — and each worker runs its own hash goroutine, which is where ContentHashExtract runs. The freeze arm therefore passes coldEncoderConcurrency, the same width the raw-mode (walk/backfill) writer encodes with, instead of leaving the option at its serial default; ColdWriterOptions.Concurrency's doc says what it parallelizes in a mode with no encoder. Hash equality is gated directly rather than inferred from the byte compare above: the freeze-built and walk-built packs must carry the same trailer hash, and Verify — which recomputes it from the stored items — must pass on both. That gate outlives byte identity, which a zstd library bump would end while leaving the content hash the thing the two paths must still agree on. --- window scale + separation residue: txindex harness, writeback cadence The txindex window-scale harness drives the production BuildColdIndex over a directory of per-chunk .bin files (names parsed by geometry.ParsePadded, capped at maxChunkID) — the terminal-window build was previously unmeasured past a single bin. Result: RSS flat over 0.6B->7.5B keys, wall linear ~24M keys/s; the co-location cells that sized the separation decision came from here. Separation residue after the two-disk architecture ruling: index.pack writers take BytesPerSync writeback smoothing (one shared default, packfile.DefaultBytesPerSync), and the deployment notes record the topology decision and its rationale in the freeze-arm comments. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou --- .../internal/rpcv2/backfill/hotsource_test.go | 9 +- .../internal/rpcv2/backfill/process.go | 63 ++--- .../internal/rpcv2/bench/bench_test.go | 8 +- .../internal/rpcv2/bench/command.go | 67 +++++- .../internal/rpcv2/bench/freeze.go | 215 ++++++++++++++++++ .../internal/rpcv2/bench/freeze_test.go | 90 ++++++++ .../internal/rpcv2/bench/txindex.go | 144 ++++++++++++ .../internal/rpcv2/bench/txindex_test.go | 86 +++++++ .../internal/rpcv2/ingest/config.go | 16 +- .../internal/rpcv2/ingest/driver.go | 6 +- .../internal/rpcv2/ingest/events.go | 24 +- .../internal/rpcv2/ingest/freeze.go | 148 ++++++++++++ .../internal/rpcv2/ingest/freeze_test.go | 136 +++++++++++ .../internal/rpcv2/ingest/ingest_test.go | 7 +- .../internal/rpcv2/ingest/ingester.go | 39 +--- .../rpcv2/lifecycle/lifecycle_test.go | 6 +- .../internal/rpcv2/packfile/writer.go | 8 + .../internal/rpcv2/query/registry_test.go | 6 +- .../rpcv2/stores/event/cold_format.go | 7 + .../internal/rpcv2/stores/event/cold_index.go | 4 + .../rpcv2/stores/event/cold_index_stream.go | 120 ++++++---- .../rpcv2/stores/hotchunk/hotchunk.go | 90 ++------ .../rpcv2/stores/hotchunk/hotchunk_test.go | 62 +---- .../rpcv2/stores/ledger/cold_freeze.go | 96 ++++++++ .../rpcv2/stores/ledger/cold_freeze_test.go | 202 ++++++++++++++++ .../rpcv2/stores/ledger/cold_writer.go | 120 ++++++++-- .../internal/rpcv2/stores/txhash/cold_bin.go | 73 ++++++ .../rpcv2/stores/txhash/cold_freeze.go | 98 ++++++++ .../rpcv2/stores/txhash/cold_freeze_test.go | 115 ++++++++++ cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go | 51 +++++ 30 files changed, 1822 insertions(+), 294 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/bench/freeze.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/bench/freeze_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/bench/txindex.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_freeze_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go b/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go index d751af50e..c22336d06 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go @@ -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()) } diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go index 0aab0f3d2..83767faab 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go @@ -124,23 +124,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())) - // When the source IS the chunk's complete hot DB, the events artifacts are - // built by merging its CFs (freeze-by-merge) rather than re-deriving from - // the raw walk; the closeSource defer keeps the DB open through finalize. icfg := ingestConfigFor(artifacts, chunkID, cfg) - icfg.EventsFreezeDB = hotDB - 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) + 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. @@ -161,7 +165,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 @@ -169,6 +173,11 @@ 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, *hotchunk.DB, error) { @@ -178,15 +187,15 @@ func backfillSource( // (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. - // The hot DB handle rides along so the freeze can build the cold events - // artifacts by merging its CFs (freeze-by-merge) instead of re-deriving. - hot, 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, 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, hot, nil + return nil, closer, hot, nil } // (2) Frozen local .pack, only when ledgers is not requested (producing ledgers @@ -225,26 +234,26 @@ func backfillSource( } // 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, -) (*hotchunk.DB, 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, db.Source(), func() error { return nil }, true, nil + return db, func() error { return nil }, true, nil } } hotState, err := cfg.Catalog.HotState(chunkID) if err != nil { - return nil, nil, nil, false, fmt.Errorf("read hot state chunk %s: %w", chunkID, err) + return nil, nil, false, fmt.Errorf("read hot state chunk %s: %w", chunkID, err) } if hotState != geometry.HotReady { - return nil, nil, nil, false, nil // "transient"/absent: not a read source + return nil, nil, false, nil // "transient"/absent: not a read source } return tryHotSource(hotState, chunkID, cfg) } @@ -256,31 +265,31 @@ func resolveHotSource( // — an ordinary run-failing error, detected lazily on the open. func tryHotSource( state geometry.HotState, chunkID chunk.ID, cfg ProcessConfig, -) (*hotchunk.DB, 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. hot, err := hotchunk.OpenReadyView(state, dir, chunkID, cfg.Logger) if err != nil { - return nil, nil, nil, false, err + return nil, nil, false, err } maxSeq, present, merr := hot.MaxCommittedSeq() if merr != nil { _ = hot.Close() // A read error against an opened DB: the DB opened but cannot answer its // own progress. Surface it (fails the run), don't treat as staleness. - return nil, nil, nil, false, fmt.Errorf("chunk %s: read hot max committed seq: %w", chunkID, merr) + return nil, nil, false, fmt.Errorf("chunk %s: read hot max committed seq: %w", chunkID, merr) } // 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, 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) _ = hot.Close() - return nil, nil, nil, false, nil + return nil, nil, false, nil } 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/command.go b/cmd/stellar-rpc/internal/rpcv2/bench/command.go index 7ddd560a1..3b80cb5cc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/command.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/command.go @@ -17,13 +17,39 @@ import ( // 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 } @@ -228,6 +254,43 @@ func newHotCommand() *cobra.Command { 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/freeze.go b/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go new file mode 100644 index 000000000..9b5ebc3fa --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go @@ -0,0 +1,215 @@ +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" +) + +// 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. + Process: backfill.ProcessConfig{Sink: sink}, + 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, + }); 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/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..c02eb1c58 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go @@ -0,0 +1,86 @@ +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/txhash" +) + +// 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))), 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/ingest/config.go b/cmd/stellar-rpc/internal/rpcv2/ingest/config.go index 466554cb2..a63169beb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/config.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/config.go @@ -5,11 +5,12 @@ import ( "fmt" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) -// 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. @@ -27,15 +28,6 @@ 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 - - // EventsFreezeDB, when non-nil, switches the events writer to - // freeze-by-merge: cold events artifacts are built directly from this - // (complete, read-only) hot chunk DB's CFs at finalize instead of from - // the raw-ledger walk — no per-ledger events shaping, no term memory. - // Only the freeze path sets it (its source IS this hot DB, so the CFs - // cover exactly the chunk); backfill leaves it nil. Ignored unless - // Events is enabled. - EventsFreezeDB *hotchunk.DB } // validate rejects a Config with no enabled data types, or a txhash/events 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 e776eefe9..048b6399f 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go @@ -40,16 +40,16 @@ type eventsCold struct { // 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 } -// newEventsCold opens a per-chunk event.pack cold writer in bucketDir — +// newEventsCold opens a per-chunk events.pack cold writer in bucketDir — // the caller's geometry.Layout.EventsBucketDir(chunkID), so the write path is // Layout's single derivation. The writer opts into the batch tuning // (coldEncoderConcurrency/coldBytesPerSync): WriteColdChunk, the sole @@ -136,7 +136,7 @@ func newEventsFreeze( }, nil } -// finalize builds event.pack + index.pack + index.hash from the hot DB. One +// finalize builds events.pack + index.pack + index.hash from the hot DB. One // ColdIngest sample covers the whole build (there are no per-ledger writes to // observe on this path). func (e *eventsFreeze) finalize(ctx context.Context) error { @@ -172,8 +172,8 @@ func (e *eventsCold) write(seq uint32, closedAt int64, txParts []sdkingest.Ledge return nil } -// finalize writes the event.pack trailer (Finish) + materializes the cold -// index (WriteColdIndex). An eventless chunk (zero terms — the common case +// finalize writes the events.pack trailer (Finish) + 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 @@ -199,7 +199,7 @@ func (e *eventsCold) finalize(ctx context.Context) error { return err } if err := event.WriteColdIndexFromRuns(ctx, e.chunkID, runs, e.scratchDir, e.bucketDir, e.secret); err != nil { - // Finish already committed event.pack; the index-less pack is left + // Finish already committed 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. @@ -219,7 +219,7 @@ func (e *eventsCold) finalize(ctx context.Context) error { return nil } -// close drops the partial event.pack when finalize never ran, and the index +// 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 @@ -250,7 +250,7 @@ func (e *eventsCold) ingestSeq(seq uint32, closedAt int64, txParts []sdkingest.L // Per payload: derive term keys from the raw ContractEvent XDR and AddTo the // in-memory mirror under the chunk-relative event ID (term_index stage), then - // append the payload to event.pack (write stage). Both reads of the borrowed + // append the payload to events.pack (write stage). Both reads of the borrowed // ContractEventBytes are synchronous (TermsForBytes does not retain them; // Append marshals into a scratch buffer copied synchronously), so the borrow // is safe. On any error here offsets is not advanced below — but the mirror and 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..89dcd6f5e --- /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 by a sequential scan of the CF that already +// holds it in final form — ledgers as verbatim zstd frames, txhash as a +// pre-sorted entry stream, events by CF merge (the landed freeze-by-merge). +// 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/event" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" + "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..132afd1ae --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go @@ -0,0 +1,136 @@ +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/rpcv2test" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" +) + +// 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 CFs (the zero-decompression path) — must produce +// byte-identical artifacts for all three kinds. This also retroactively +// gates the already-landed events freeze-by-merge against the walk build. +// +// 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 = 100 // 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() + db, err := hotchunk.Open(t.TempDir(), chunkID, hotTestLogger()) + 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) + } + + cfgAll := Config{ + Ledgers: true, Txhash: true, Events: true, + 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)) + } +} + +// 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..1f7dbe06b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go @@ -1119,9 +1119,10 @@ func TestWriteColdChunk_EventsCold_Readback(t *testing.T) { // ───────────────────────── 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 diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go index 482f79db2..0e6303a37 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" ) @@ -34,17 +35,14 @@ type coldChunk struct { ledgers *ledgerCold txhash *txhashCold events *eventsCold - // eventsFreeze is the freeze-by-merge events writer (Config.EventsFreezeDB): - // finalize-only — it consumes the hot DB's CFs, so it takes no per-ledger - // feed and leaves `events` nil, which also lets a txhash-less freeze skip - // the extract walk entirely. - eventsFreeze *eventsFreeze - sink MetricSink + 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. @@ -85,19 +83,11 @@ func openColdChunk(dirs ColdDirs, chunkID chunk.ID, sink MetricSink, cfg Config) } var secret [stores.SecretLen]byte copy(secret[:], cfg.EventsSecret) - if cfg.EventsFreezeDB != nil { - w, err := newEventsFreeze(dirs.EventsDir, chunkID, cfg.EventsFreezeDB, sink, secret) - if err != nil { - return fail(fmt.Errorf("open events freeze writer: %w", err)) - } - cc.eventsFreeze = w - } else { - w, err := newEventsCold(dirs.EventsDir, chunkID, sink, secret) - if err != nil { - return fail(fmt.Errorf("open events cold writer: %w", err)) - } - cc.events = w + w, err := newEventsCold(dirs.EventsDir, chunkID, sink, secret) + if err != nil { + return fail(fmt.Errorf("open events cold writer: %w", err)) } + cc.events = w } return cc, nil } @@ -178,11 +168,6 @@ func (c *coldChunk) finalize(ctx context.Context) error { return fmt.Errorf("finalize: %w", err) } } - if c.eventsFreeze != nil { - if err := c.eventsFreeze.finalize(ctx); err != nil { - return fmt.Errorf("finalize: %w", err) - } - } return nil } @@ -226,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/lifecycle/lifecycle_test.go b/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go index 61126ee07..f5aa12453 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" @@ -352,9 +350,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/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/query/registry_test.go b/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go index 71d506e62..78ae48ac4 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" @@ -323,9 +321,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 } 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 55d376761..2e6311189 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -184,6 +184,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. 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 22500f482..1dff7a7e3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -172,6 +172,10 @@ 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) 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 index 95dc90724..10419e515 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -27,6 +27,7 @@ import ( "encoding/binary" "errors" "fmt" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" "hash/crc32" "io" "io/fs" @@ -35,11 +36,11 @@ import ( "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" - "github.com/stellar/streamhash" ) // maxReorderEntries bounds pass B's slot-reorder heap by RECORD COUNT — the @@ -90,35 +91,10 @@ func WriteColdIndexFromRuns( } }() - // Pass A: keys → SortedBuilder → index.hash. 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) - } - defer func() { - if err != nil { - _ = builder.Close() - } - }() - var fed int - if err = streamTermsRun(termsRunPath, func(term TermKey, _ []byte) 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) + // 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 { @@ -137,6 +113,9 @@ func WriteColdIndexFromRuns( // 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) @@ -150,6 +129,47 @@ func WriteColdIndexFromRuns( 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() + } + }() + var fed int + if err := streamTermsRun(termsRunPath, func(term TermKey, _ []byte) 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 ‖ // RunOptimize'd serialized bitmap. File framing: magic ‖ u64 record count ‖ @@ -184,7 +204,7 @@ func writeTermsRun(path string, runPaths []string) (uint64, error) { } recBuf = recBuf[:0] recBuf = append(recBuf, term[:]...) - recBuf = binary.AppendUvarint(recBuf, uint64(bmBuf.Len())) + recBuf = binary.AppendUvarint(recBuf, uint64(bmBuf.Len())) //nolint:gosec // Len is non-negative recBuf = append(recBuf, bmBuf.Bytes()...) crc = crc32.Update(crc, termsRunCRC, recBuf) count++ @@ -198,7 +218,8 @@ func writeTermsRun(path string, runPaths []string) (uint64, error) { } var tr [4]byte binary.BigEndian.PutUint32(tr[:], crc) - if _, err := w.Write(tr[:]); err == nil { + _, err = w.Write(tr[:]) + if err == nil { err = w.Flush() } if err == nil { @@ -218,7 +239,7 @@ func writeTermsRun(path string, runPaths []string) (uint64, error) { } var ( - termsRunMagic = [4]byte{'E', 'T', 'R', '1'} + termsRunMagic = [4]byte{'E', 'T', 'R', '1'} //nolint:gochecknoglobals // fixed format tag termsRunCRC = crc32.MakeTable(crc32.Castagnoli) //nolint:gochecknoglobals // fixed table ) @@ -262,17 +283,17 @@ func streamTermsRun(path string, emit func(term TermKey, bitmapBytes []byte) err crc uint32 buf []byte ) - for i := uint64(0); i < count; i++ { + for i := range count { var term TermKey if _, err := io.ReadFull(br, term[:]); err != nil { - return fmt.Errorf("%w: record %d term: %v", errCorruptTermsRun, i, err) + return fmt.Errorf("%w: record %d term: %w", errCorruptTermsRun, i, err) } crc = crc32.Update(crc, termsRunCRC, term[:]) // stdlib varint over a CRC-folding ByteReader. cbr := &crcFoldReader{br: br, crc: crc} length, rerr := binary.ReadUvarint(cbr) if rerr != nil { - return fmt.Errorf("%w: record %d length: %v", errCorruptTermsRun, i, rerr) + return fmt.Errorf("%w: record %d length: %w", errCorruptTermsRun, i, rerr) } crc = cbr.crc if length > 1<<31 { @@ -283,7 +304,7 @@ func streamTermsRun(path string, emit func(term TermKey, bitmapBytes []byte) err } buf = buf[:length] if _, err := io.ReadFull(br, buf); err != nil { - return fmt.Errorf("%w: record %d bitmap: %v", errCorruptTermsRun, i, err) + return fmt.Errorf("%w: record %d bitmap: %w", errCorruptTermsRun, i, err) } crc = crc32.Update(crc, termsRunCRC, buf) if err := emit(term, buf); err != nil { @@ -310,12 +331,18 @@ type slotRecord struct { type slotHeap []slotRecord -func (h slotHeap) Len() int { return len(h) } -func (h slotHeap) Less(i, j int) bool { return h[i].slot < h[j].slot } -func (h slotHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } -func (h *slotHeap) Push(x any) { *h = append(*h, x.(slotRecord)) } -func (h *slotHeap) Pop() any { old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x } -func (h slotHeap) peek() slotRecord { return h[0] } +func (h *slotHeap) Len() int { return len(*h) } +func (h *slotHeap) Less(i, j int) bool { return (*h)[i].slot < (*h)[j].slot } +func (h *slotHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] } +func (h *slotHeap) Push(x any) { + rec, ok := x.(slotRecord) + if !ok { // heap.Push only ever receives slotRecord + panic("events: foreign type pushed into slotHeap") + } + *h = append(*h, rec) +} +func (h *slotHeap) Pop() any { old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x } +func (h *slotHeap) peek() slotRecord { return (*h)[0] } // 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. @@ -326,7 +353,10 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { ) flush := func() error { for len(h) > 0 && h.peek().slot == next { - rec := heap.Pop(&h).(slotRecord) + rec, ok := heap.Pop(&h).(slotRecord) + if !ok { // Push admits only slotRecord + panic("events: slotHeap yielded a foreign type") + } if err := pw.AppendItem(rec.fp[:], rec.bm); err != nil { return fmt.Errorf("events: write slot %d to index.pack: %w", rec.slot, err) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index 58ab4fdfa..f6b1ade61 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" @@ -122,9 +120,10 @@ func OpenExisting(path string, chunkID chunk.ID, logger *supportlog.Entry) (*DB, // 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. +// but SKIPS the events facade, because neither read-only caller needs it warm — +// the freeze scans CFs directly through the shared store (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 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() @@ -246,13 +245,24 @@ func (d *DB) FreezeEventsCold( return event.FreezeColdFromStore(ctx, d.chunkID, d.store, scratchDir, bucketDir, secret, opts) } -// 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} +// FreezeLedgersCold builds the chunk's cold ledger .pack at packPath by +// copying THIS hot DB's ledgers-CF zstd frames verbatim (PreCompressed cold +// writer) — no decompression. 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 +// streaming THIS hot DB's txhash CF, which is already in the .bin's sort +// order. Valid on a read-only view; 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 @@ -503,59 +513,3 @@ func (d *DB) IngestLedger( } 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++ - } - } -} 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..85007be08 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go @@ -11,7 +11,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" @@ -309,56 +308,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) { @@ -523,13 +472,14 @@ 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") 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..2714aa022 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go @@ -15,13 +15,31 @@ import ( // concurrent use, so the writer invokes this per worker. func newColdPackEncoder() packfile.RecordEncoder { return zstd.NewCompressor() } +// 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 // 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 // BytesPerSync triggers background dirty-page writeback every @@ -29,6 +47,17 @@ type ColdWriterOptions struct { // 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 @@ -45,10 +74,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 @@ -64,40 +94,69 @@ func NewColdWriter(path string, firstSeq uint32, opts ColdWriterOptions) (*ColdW if opts.Concurrency < 0 || opts.BytesPerSync < 0 { return nil, fmt.Errorf("%w: Concurrency and BytesPerSync 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. + newEncoder := newColdPackEncoder + 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 +185,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/txhash/cold_bin.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go index 9827b1ac3..677be4084 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go @@ -178,3 +178,76 @@ func coldBinCount(path string, size int64, count uint64) (uint64, error) { } return count, nil } + +// coldBinStream writes a .bin incrementally: header with a placeholder +// count (the secret is written up front — it is known before the first +// entry), streamed entries, then finish patches the leading count and syncs +// — byte-identical to WriteColdBin's output without holding a chunk's +// entries in memory. The freeze is its user; the walk path keeps the +// slice-based writer (its accumulator exists anyway for the finalize sort). +type coldBinStream struct { + f *os.File + bw *bufio.Writer + 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 finish, 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 { + var buf [coldBinEntrySize]byte + copy(buf[:ColdKeySize], e.Key[:]) + binary.LittleEndian.PutUint32(buf[ColdKeySize:], e.Seq) + if _, err := w.bw.Write(buf[:]); err != nil { + return fmt.Errorf("txhash: write entry: %w", err) + } + w.count++ + return nil +} + +// finish flushes, patches the header count, syncs, and closes — the same +// durability ladder as WriteColdBin (sync before close, close error checked). +func (w *coldBinStream) finish() 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 + // abort() responsible for releasing it. + w.done = true + return w.f.Close() +} + +// abort releases the file on the error path; a no-op after finish. The +// partial file is inert scratch per the artifact model (retry overwrites). +func (w *coldBinStream) abort() { + if !w.done { + _ = w.f.Close() + } +} 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..d112dc9e0 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -0,0 +1,98 @@ +package txhash + +// cold_freeze.go — the txhash half of the zero-decompression freeze: build a +// completed hot chunk's cold .bin DIRECTLY from its txhash CF. The CF's keys +// are full 32-byte hashes in RocksDB's bytewise order, and truncating a +// bytewise-sorted sequence to its 16-byte prefix preserves that order — so +// the CF scan yields the .bin's entries ALREADY lex-sorted, deleting both +// the walk path's whole-chunk accumulate-then-sort (the baseline's ~40s, +// ~1.2GB-live finalize at sac-6000 density) and its share of the raw-ledger +// decompress walk. Two encodings meet here and must not be conflated: the +// CF value is a BIG-endian seq (rocksdb.EncodeUint32), the .bin stores +// LITTLE-endian — every value is decoded and re-encoded, never memcpy'd. +// +// Duplicate-prefix semantics are unchanged from the walk path: entries are +// written verbatim and the downstream streamhash build rejects a truncated- +// key collision loudly. + +import ( + "context" + "fmt" + "bytes" + "os" + "slices" + "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 freeze scan copies between +// context checks. Entries are 36 bytes of work apiece, so a coarser cadence +// than the ledgers scan still cancels promptly. +const freezeCtxPollEvery = 4096 + +// FreezeColdFromStore builds the chunk's cold txhash .bin at binPath from +// the chunk's (read-only) hot store. Entries stream from the CF straight to +// the .bin — no whole-chunk accumulator (hundreds of MB at stress density); +// the header's leading count is patched in once the scan completes, and the +// bytes are identical to WriteColdBin's. Returns the entries written. +// secret is the chunk's per-index routing secret: every stored key is +// stores.BlindKey(secret, hash[:ColdKeySize]), exactly the walk writer's +// rule. Blinding destroys the CF's raw sort order, so this path — unlike the +// unkeyed design it replaces — must collect and re-sort before writing: the +// .bin's contract is lex order BY BLINDED KEY. The accumulator is the same +// size class the walk path already carries for its finalize sort. +func FreezeColdFromStore( + ctx context.Context, + chunkID chunk.ID, + store *rocksdb.Store, + binPath string, + secret [stores.SecretLen]byte, +) (int, error) { + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil { + return 0, fmt.Errorf("txhash freeze %s: mkdir: %w", chunkID, err) + } + entries := make([]ColdEntry, 0, 1<<16) + for entry, ierr := range store.Iterate(txhashCF, nil) { + if ierr != nil { + return 0, fmt.Errorf("txhash freeze %s: scan %s: %w", chunkID, txhashCF, ierr) + } + if len(entries)%freezeCtxPollEvery == 0 { + if cerr := ctx.Err(); cerr != nil { + return 0, cerr + } + } + if len(entry.Key) != 32 || len(entry.Value) != 4 { + return 0, fmt.Errorf("txhash freeze %s: row shape %d/%d (want 32/4)", + chunkID, len(entry.Key), len(entry.Value)) + } + seq := rocksdb.DecodeUint32(entry.Value) + if seq < first || seq > last { + return 0, fmt.Errorf("txhash freeze %s: entry seq %d outside [%d, %d]", chunkID, seq, first, last) + } + entries = append(entries, ColdEntry{ + Key: stores.BlindKey(secret, entry.Key[:ColdKeySize]), + Seq: seq, + }) + } + slices.SortFunc(entries, func(a, b ColdEntry) int { + return bytes.Compare(a.Key[:], b.Key[:]) + }) + w, err := newColdBinStream(binPath, secret) + if err != nil { + return 0, err + } + defer w.abort() + for _, ce := range entries { + if werr := w.append(ce); werr != nil { + return 0, werr + } + } + if ferr := w.finish(); ferr != nil { + return 0, ferr + } + return len(entries), 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..b3c48bdf4 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go @@ -0,0 +1,115 @@ +package txhash + +import ( + "bytes" + "context" + "encoding/binary" + "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" +) + +// freezeEntries fabricates n deterministic pseudo-random entries with seqs +// inside chunkID's range, in a deliberately unsorted insertion order. +func freezeEntries(chunkID chunk.ID, n int) []Entry { + first := chunkID.FirstLedger() + span := chunk.LedgersPerChunk + entries := make([]Entry, n) + state := uint64(0x9E3779B97F4A7C15) + for i := range entries { + var h [32]byte + for w := range 4 { + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + binary.BigEndian.PutUint64(h[w*8:], state) + } + entries[i] = Entry{Hash: h, LedgerSeq: first + uint32(state)%span} + } + return entries +} + +// TestFreezeColdFromStore_ByteIdenticalToWalk is the txhash half of the +// cross-path identity gate: the freeze-written .bin must be byte-for-byte +// identical to the walk path's accumulate-then-sort output. Both paths blind +// with the same secret and sort by the blinded key, so identity reduces to +// the sort's determinism (keys are unique — no tie-break sensitivity). +func TestFreezeColdFromStore_ByteIdenticalToWalk(t *testing.T) { + chunkID := chunk.ID(0) + entries := freezeEntries(chunkID, 5000) + + // Walk path: blind, sort, write — exactly what ingest's finalize does + // (the stored key is BlindKey(secret, hash[:ColdKeySize])). + walk := make([]ColdEntry, len(entries)) + for i, e := range entries { + walk[i].Key = stores.BlindKey(testBinSecret, e.Hash[:ColdKeySize]) + walk[i].Seq = e.LedgerSeq + } + slices.SortFunc(walk, func(a, b ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) + walkPath := filepath.Join(t.TempDir(), "walk.bin") + require.NoError(t, WriteColdBin(walkPath, testBinSecret, walk)) + + // Freeze path: same entries through the hot CF, streamed out sorted. + h, store := openTestHotStoreAt(t, t.TempDir()) + require.NoError(t, addEntries(h, entries)) + freezePath := filepath.Join(t.TempDir(), "freeze.bin") + n, err := FreezeColdFromStore(context.Background(), chunkID, store, freezePath, testBinSecret) + require.NoError(t, err) + require.Equal(t, len(entries), n) + + walkBytes, err := os.ReadFile(walkPath) + require.NoError(t, err) + freezeBytes, err := os.ReadFile(freezePath) + require.NoError(t, err) + require.True(t, bytes.Equal(walkBytes, freezeBytes), ".bin bytes diverge") +} + +// TestFreezeColdFromStore_EmptyChunk: a zero-tx chunk freezes to the same +// header-only .bin the walk path writes. +func TestFreezeColdFromStore_EmptyChunk(t *testing.T) { + _, store := openTestHotStoreAt(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: an entry pointing outside the +// chunk's ledger range is corruption, not data. +func TestFreezeColdFromStore_RejectsOutOfRangeSeq(t *testing.T) { + chunkID := chunk.ID(0) + h, store := openTestHotStoreAt(t, t.TempDir()) + require.NoError(t, addEntries(h, []Entry{{Hash: txhashFor(1, 1), LedgerSeq: chunkID.LastLedger() + 1}})) + _, err := FreezeColdFromStore(context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.Error(t, err) + require.Contains(t, err.Error(), "outside") +} + +// 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 := openTestHotStoreAt(t, t.TempDir()) + require.NoError(t, store.Batch(func(b *rocksdb.BatchWriter) error { + b.Put(txhashCF, []byte("short-key"), rocksdb.EncodeUint32(chunkID.FirstLedger())) + return nil + })) + _, err := FreezeColdFromStore(context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) + require.Error(t, err) + require.Contains(t, err.Error(), "row shape") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go b/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go index 74a218de6..2cf65354f 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" @@ -290,3 +291,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 +} From b014d2598702d95aa5b1da1950f5e44cd950bcb5 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Mon, 3 Aug 2026 23:49:41 +0000 Subject: [PATCH 05/21] hot-ingest latency, wave 2: packed-row txhash engine, flat-pair events marshal, multithreaded zstd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second latency pass: the commit phase's remaining floors — ~6,000 random txhash memtable keys, ~30k events write-path allocations, and the serial ledger encode — each go. --- txhash: packed-row hot engine — window + sealed runs, point lookups The hot tx-hash tier's storage engine, standalone (nothing wired): one sorted 32B-hash row per ledger in the window, sealed every 256 ledgers into hash-sorted 36B-record run files (TXHRUN01, CRC64, fsync-dir ladder), routed by per-run blooms and a 16B-prefix page ladder — one aligned pread per hit, full-hash verify, boundary-tie two-page rule. Manifest-anchored recovery with drain-verified opens; the engine is born DISARMED (sealing rights follow validation, the events-tier rule) and enforces a dense per-ledger chain. No merge tier, no overlay, no retired handles: a point lookup needs none of them. --- txhash: one packed row per ledger — flip ingest, freeze, and reads The atomic flip onto the packed-row engine. Ingest writes ONE seq-keyed row per ledger (hashes sorted in PhaseExtract, fee-bump outer+inner per #862) instead of ~6,000 hash-keyed Puts — the measured ~10.9ms of per-ledger memtable-insert CPU those random 32B keys cost (commit p50 20.5 -> 7.9ms in the 1k solo cell). Warmup replays the dense row chain, trips loudly on the old format, and arms sealing only after validation; the post-commit hook applies txhash before events (independent, by convention). Read-only opens disable txhash queries structurally — the freeze never queries. The freeze merges the sealed runs with each tail row as its own sorted source (RAM flat at any tail size) and emits duplicates verbatim: cold .bin bytes stay identical to the walk path, gated by the composition test and freeze-at-crash-point identity tests. Freeze txhash arm: 1m25 -> 17.9s at full chunk scale. --- events: flat-pair term accumulation, bucketed sort, derivation arenas The events queue step drops its per-ledger map: the marshal loop appends (term, id) pairs into writer-owned arenas (AppendTerms writes term keys straight into a caller arena; topics walk via Count()+Raw() with All()'s reject-on-truncation semantics preserved and golden-swept; the per-event data key is one hoisted scratch), then a 256-bucket MSD scatter + big-endian word-comparator sort over KEYS ONLY (sorting 20B pairs measured 2-2.5x slower; stability = index tiebreak, property- tested as the exact permutation of a stable bytes.Compare) and one linear pass emits the exact-sized packed row. ApplyLedger takes the sorted term-runs directly; promotion and overlay semantics are pinned equivalent to the retired map path, which survives as test-only reference code for the byte-identity differential gates. Phase p50 8.0 -> 5.9ms and ~30k write-path allocations/ledger -> ~1. --- zstd: multithreaded ledger encode, config-selected (default 2 workers) After the txhash/events/extract cuts, the ~21ms single-threaded zstd encode became the binding floor of the pre-commit section (its join wait absorbed further wins). WithWorkers enables libzstd's internal multithreading — still ONE standard deterministic frame, so FrameHeaderValid and the cold-inherits-hot verbatim-copy contract are untouched, at a measured ~0.1% ratio cost. The workers count is a real configuration field, not an env experiment: measurement settled on default 2 (equal to 3 within noise — total p50 33.4 -> 29.2ms, join 7.35 -> 1.9ms — while claiming one fewer core). It is FORMAT-AFFECTING for the stored ledger frames, so ONE resolved value (storage.zstd_encode_workers in the daemon TOML, --zstd-workers on the bench hot cell; 0 = explicit single-threaded, validated >= 0) feeds BOTH encoders — hotchunk.Tuning.ZstdEncodeWorkers for hot ingest and ingest.Config.ZstdEncodeWorkers for the walk/backfill cold writer — because the freeze copies hot frames into the cold pack verbatim while the walk re-encodes the same ledgers, and a chunk's pack must stay byte-identical whichever materializer built it (the freeze-vs-walk gates arbitrate, now exercising the MT default on both sides). NewCompressor panics loudly on a non-MT libzstd rather than silently degrading the format. Route the two constant-key event terms (event type, topic count) around the pair sort: every event emits the same few keys, so their pairs collapse into one radix bucket and the comparison sort re-derives an order the arena already has. termlanes.go collects their ids in per-key ascending lanes and the run build merges them at their byte-order positions; rows are byte-identical (differential-tested). Measured on sac6000: -1.4ms/ledger in the events phase. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou --- .../internal/rpcv2/backfill/hotsource_test.go | 2 +- .../internal/rpcv2/backfill/process.go | 16 +- cmd/stellar-rpc/internal/rpcv2/bench/cold.go | 6 +- .../internal/rpcv2/bench/command.go | 6 + .../internal/rpcv2/bench/freeze.go | 8 +- cmd/stellar-rpc/internal/rpcv2/bench/hot.go | 11 + .../internal/rpcv2/config/config.go | 24 +- .../internal/rpcv2/config_validate.go | 4 + cmd/stellar-rpc/internal/rpcv2/daemon.go | 12 +- cmd/stellar-rpc/internal/rpcv2/e2e_test.go | 2 +- .../internal/rpcv2/feereplay_test.go | 7 +- cmd/stellar-rpc/internal/rpcv2/hotloop.go | 28 +- .../internal/rpcv2/hotloop_test.go | 26 +- .../internal/rpcv2/ingest/config.go | 17 +- .../internal/rpcv2/ingest/freeze_test.go | 10 +- .../internal/rpcv2/ingest/ingest_test.go | 18 +- .../internal/rpcv2/ingest/ingester.go | 2 +- .../internal/rpcv2/ingest/ledgers.go | 12 +- .../internal/rpcv2/lifecycle/helpers_test.go | 2 +- .../rpcv2/lifecycle/lifecycle_test.go | 2 +- .../internal/rpcv2/progress_realdb_test.go | 2 +- .../internal/rpcv2/query/event_page_test.go | 2 +- .../internal/rpcv2/query/ledger_scan_test.go | 11 +- .../internal/rpcv2/query/registry.go | 10 +- .../internal/rpcv2/query/registry_test.go | 16 +- .../internal/rpcv2/query/resolve_test.go | 6 +- .../internal/rpcv2/query/tx_lookup_test.go | 2 +- .../internal/rpcv2/rpcv2test/rpcv2test.go | 2 +- cmd/stellar-rpc/internal/rpcv2/startup.go | 15 +- .../internal/rpcv2/startup_test.go | 3 +- .../rpcv2/stores/event/append_terms_test.go | 91 +++ .../internal/rpcv2/stores/event/hot_store.go | 103 ++- .../internal/rpcv2/stores/event/hotindex.go | 19 +- .../rpcv2/stores/event/hotindex_test.go | 22 +- .../internal/rpcv2/stores/event/index.go | 132 ++++ .../internal/rpcv2/stores/event/termlanes.go | 98 +++ .../internal/rpcv2/stores/event/termsort.go | 255 +++++++ .../rpcv2/stores/event/termsort_test.go | 481 +++++++++++++ .../rpcv2/stores/hotchunk/hotchunk.go | 272 ++++++-- .../rpcv2/stores/hotchunk/hotchunk_test.go | 142 +++- .../rpcv2/stores/ledger/cold_writer.go | 34 +- .../internal/rpcv2/stores/ledger/hot_store.go | 29 +- .../rpcv2/stores/ledger/hot_store_test.go | 6 +- .../rpcv2/stores/txhash/cold_freeze.go | 421 ++++++++++-- .../rpcv2/stores/txhash/cold_freeze_test.go | 345 ++++++++-- .../internal/rpcv2/stores/txhash/hot_rows.go | 72 ++ .../rpcv2/stores/txhash/hot_rows_test.go | 77 +++ .../internal/rpcv2/stores/txhash/hot_store.go | 251 +++++-- .../rpcv2/stores/txhash/hot_store_test.go | 334 +++++---- .../internal/rpcv2/stores/txhash/hotindex.go | 234 +++++++ .../rpcv2/stores/txhash/hotindex_seal.go | 649 ++++++++++++++++++ .../rpcv2/stores/txhash/read_assembly_test.go | 8 +- .../stores/txhash/txhash_hotindex_test.go | 500 ++++++++++++++ cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go | 23 +- .../rpcv2/rpc-v2-sample-config.toml | 8 + go.mod | 2 +- 56 files changed, 4338 insertions(+), 554 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/append_terms_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/termlanes.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/termsort.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_rows_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go b/cmd/stellar-rpc/internal/rpcv2/backfill/hotsource_test.go index c22336d06..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")}) })) diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go index 83767faab..0136cfddc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go +++ b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go @@ -44,6 +44,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,15 +62,17 @@ 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. 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() 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 3b80cb5cc..4501c5709 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/command.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/command.go @@ -13,6 +13,7 @@ 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 @@ -217,6 +218,7 @@ func newHotCommand() *cobra.Command { catalogDir string closeInterval time.Duration traceFile string + zstdWorkers int prof profileFlags ) cmd := newBenchCommand("hot", @@ -233,6 +235,7 @@ func newHotCommand() *cobra.Command { CloseInterval: closeInterval, OutDir: outDir, TraceFile: traceFile, + ZstdWorkers: zstdWorkers, }) }) fs := cmd.Flags() @@ -250,6 +253,9 @@ func newHotCommand() *cobra.Command { 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 } diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go b/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go index 9b5ebc3fa..5e9783234 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/freeze.go @@ -15,6 +15,8 @@ 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" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/ledger" ) // freezeOptions configures one freeze benchmark run. @@ -126,7 +128,10 @@ func runFreeze(ctx context.Context, logger *supportlog.Entry, opts freezeOptions Metrics: sink, // Backend deliberately nil — see the function comment: the complete // hot DB must be the source, or the run fails. - Process: backfill.ProcessConfig{Sink: sink}, + // 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. @@ -188,6 +193,7 @@ func populateHotChunk( Logger: logger, Metrics: setupSink, Sink: setupSink, + Tuning: hotchunk.DefaultTuning(), }); err != nil { return fmt.Errorf("populate hot chunk %s: %w", opts.Chunk, err) } diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/hot.go b/cmd/stellar-rpc/internal/rpcv2/bench/hot.go index ef4ab048c..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. @@ -62,6 +63,12 @@ type hotOptions struct { // 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 @@ -83,6 +90,9 @@ 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 } @@ -170,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/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/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..d2b3608d6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/e2e_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/e2e_test.go @@ -436,7 +436,7 @@ func TestE2E_DaemonLifecycle_FirstStartIngestFreezeLookupRestartPrune(t *testing // 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()) + db, oerr := hotchunk.Open(postCat.Layout().HotChunkPath(c2), c2, silentLogger(), hotchunk.DefaultTuning()) if oerr != nil { return false } 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..3e1918765 100644 --- a/cmd/stellar-rpc/internal/rpcv2/hotloop.go +++ b/cmd/stellar-rpc/internal/rpcv2/hotloop.go @@ -38,7 +38,9 @@ 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) state, err := cat.HotState(chunkID) @@ -47,8 +49,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) } // The create bracket: BeginHotCreate wipes + marks transient; FinishHotCreate @@ -56,7 +61,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) if openErr != nil { return nil, fmt.Errorf("create hot DB chunk %s: %w", chunkID, openErr) } @@ -98,6 +103,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 +212,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 +262,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 +275,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 +289,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..2fa5d7f7b 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,7 @@ 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()) require.NoError(t, err) t.Cleanup(func() { _ = reopened.Close() }) @@ -434,7 +434,7 @@ 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()) released := err == nil if db != nil { _ = db.Close() @@ -467,7 +467,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 +497,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 +621,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 +635,7 @@ 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()) require.NoError(t, err) t.Cleanup(func() { _ = reopened.Close() }) maxSeq, ok, err := reopened.MaxCommittedSeq() @@ -660,9 +660,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 a63169beb..452912081 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/config.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/config.go @@ -28,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)") @@ -53,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/freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go index 132afd1ae..118e796f6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go @@ -13,6 +13,7 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rpcv2test" "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 @@ -42,7 +43,7 @@ func TestFreezeColdChunk_ByteIdenticalToWalk(t *testing.T) { // Hot DB populated through the production ingest service (compression, // extraction, and CF layout exactly as a live chunk's). ctx := context.Background() - db, err := hotchunk.Open(t.TempDir(), chunkID, hotTestLogger()) + db, err := hotchunk.Open(t.TempDir(), chunkID, hotTestLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) svc := NewHotService(db, nil, nil) @@ -51,9 +52,14 @@ func TestFreezeColdChunk_ByteIdenticalToWalk(t *testing.T) { 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, - TxhashSecret: testTxhashSecretBytes(), EventsSecret: testEventsSecretBytes(), + ZstdEncodeWorkers: ledger.DefaultZstdEncodeWorkers, + TxhashSecret: testTxhashSecretBytes(), + EventsSecret: testEventsSecretBytes(), } dirsFor := func(root string) ColdDirs { return ColdDirs{ diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go index 1f7dbe06b..e3332b731 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go @@ -489,7 +489,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()) }() @@ -1170,7 +1170,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{}) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1192,7 +1192,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{}) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1219,7 +1219,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{}) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1243,7 +1243,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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1270,7 +1270,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()) require.NoError(t, err) require.NoError(t, db.Close()) // closed => the batch commit fails @@ -1294,7 +1294,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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1318,7 +1318,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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1345,7 +1345,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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go index 0e6303a37..dc25f7d20 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go @@ -53,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)) } 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/lifecycle/helpers_test.go b/cmd/stellar-rpc/internal/rpcv2/lifecycle/helpers_test.go index 880bd3d25..9e13ec313 100644 --- a/cmd/stellar-rpc/internal/rpcv2/lifecycle/helpers_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/lifecycle/helpers_test.go @@ -82,7 +82,7 @@ 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()) 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 f5aa12453..dd63df75a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go @@ -334,7 +334,7 @@ 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()) require.NoError(t, err) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) require.NoError(t, cat.FlipHotReady(c)) 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..dcf89c9f4 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,7 @@ 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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) for i, evs := range s.ledgers { 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..2574e1c05 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,14 @@ 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()) + 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 +141,7 @@ 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()) 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..b95f8c860 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,9 @@ 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 { +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 +359,8 @@ 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) 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 78ae48ac4..0d6786e37 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go @@ -44,7 +44,7 @@ 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()) require.NoError(t, err) require.NoError(t, db.Close()) require.NoError(t, cat.FlipHotReady(c)) @@ -59,10 +59,10 @@ 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()) 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() @@ -274,7 +274,7 @@ 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()) require.NoError(t, err) r.PublishHandle(c, db) } @@ -310,7 +310,7 @@ 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()) require.NoError(t, err) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) r.PublishHandle(c, db) @@ -470,15 +470,15 @@ 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()) 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()) 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..ce4572773 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/resolve_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/resolve_test.go @@ -104,7 +104,7 @@ 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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) @@ -129,7 +129,7 @@ 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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) rpcv2test.IngestLedger(t, db, c.FirstLedger(), rpcv2test.ZeroTxLCMBytes(t, c.FirstLedger())) @@ -257,7 +257,7 @@ 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()) 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..cf23f6c97 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,7 @@ 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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) r.PublishHandle(c, db) diff --git a/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go b/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go index 0c6ff3992..a5e54f4d8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go +++ b/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go @@ -76,7 +76,7 @@ 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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) for i, raw := range lcms { 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/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/hot_store.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go index 689fe7242..a6e98e36d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go @@ -114,12 +114,11 @@ type HotStore struct { hotIdx *HotIndex offsets *ConcurrentLedgerOffsets - // prevTermCount carries the previous ledger's unique-term count as the - // next ledger's map-size hint: successive ledgers at a given load have - // similar term density, and a fixed small hint costs ~9 incremental - // rehashes of a ~24k-entry map per stress-density ledger, pre-commit. - // Writer goroutine only (single-writer ingest contract). - prevTermCount int + // 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. @@ -387,14 +386,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: // @@ -413,8 +414,9 @@ func (h *HotStore) All(ctx context.Context) iter.Seq2[Payload, error] { func (h *HotStore) IngestLedgerToBatch( b *rocksdb.BatchWriter, ledgerSeq uint32, payloads []Payload, ) (func() error, 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. + // 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, @@ -426,31 +428,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. - // The per-term event-ID lists are accumulated once here and serve BOTH the - // packed index row and the post-commit mirror apply (which previously - // rebuilt the same grouping). + // 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 - perKeyIDs := make(map[TermKey][]uint32, max(64, h.prevTermCount)) + var dataKey [dataKeyLen]byte + h.scratch.reset() for i := range payloads { blob, err := payloads[i].MarshalInto(scratch[:0]) if err != nil { @@ -458,28 +452,23 @@ func (h *HotStore) IngestLedgerToBatch( } scratch = blob eventID := startID + uint32(i) - b.Put(DataCF, encodeDataKey(eventID), blob) - for _, key := range termKeys[i] { - perKeyIDs[key] = append(perKeyIDs[key], eventID) + 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 (AppendPackedRow) 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). - h.prevTermCount = len(perKeyIDs) + // 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(perKeyIDs) > 0 { - // Exact-size the row: one right-sized allocation instead of ~18 - // doubling regrows for a stress-density ledger — and since the window - // RETAINS this slice, exact fit is retained memory, not just churn. - size := 0 - for _, ids := range perKeyIDs { - size += TermPostingsLen(ids) - } - rowBytes = AppendPackedRow(make([]byte, 0, size), perKeyIDs) + 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 @@ -488,8 +477,11 @@ func (h *HotStore) IngestLedgerToBatch( //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, perKeyIDs); err != nil { + if err := h.hotIdx.ApplyLedger(ledgerSeq, rowBytes, runs); err != nil { return err } } @@ -633,6 +625,9 @@ func warmup( // 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() @@ -644,9 +639,9 @@ func warmup( IndexCF, len(entry.Key), packedIndexKeyLen) } seq := binary.BigEndian.Uint32(entry.Key) - per := make(map[TermKey][]uint32, 1<<10) + runs.reset() if derr := DecodePackedRow(entry.Value, func(term TermKey, ids []uint32) { - per[term] = append([]uint32(nil), ids...) + runs.addRun(term, ids) if last := uint64(ids[len(ids)-1]) + 1; last > indexUpperBound { indexUpperBound = last } @@ -654,7 +649,7 @@ func warmup( hotIdx.Close() return nil, nil, fmt.Errorf("events: warmup %s ledger %d: %w", IndexCF, seq, derr) } - if aerr := hotIdx.ApplyLedger(seq, append([]byte(nil), entry.Value...), per); aerr != nil { + if aerr := hotIdx.ApplyLedger(seq, append([]byte(nil), entry.Value...), runs); aerr != nil { hotIdx.Close() return nil, nil, aerr } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go index 3a4b25e58..a12584f44 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -193,9 +193,11 @@ 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), perKeyIDs the per-term grouping the write -// path already built. Writer goroutine only. -func (h *HotIndex) ApplyLedger(seq uint32, rowBytes []byte, perKeyIDs map[TermKey][]uint32) error { +// 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 { @@ -203,7 +205,12 @@ func (h *HotIndex) ApplyLedger(seq uint32, rowBytes []byte, perKeyIDs map[TermKe } // Dense admission + overlay feed: promoted terms get their IDs in RAM. - for term, ids := range perKeyIDs { + // 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 { @@ -219,7 +226,7 @@ func (h *HotIndex) ApplyLedger(seq uint32, rowBytes []byte, perKeyIDs map[TermKe } } - row := buildWindowRow(rowBytes, len(perKeyIDs)) + row := buildWindowRow(rowBytes, len(runs.terms)) row.seq = seq old := h.view.Load() rows := make([]windowRow, 0, len(old.rows)+1) @@ -237,7 +244,7 @@ func (h *HotIndex) ApplyLedger(seq uint32, rowBytes []byte, perKeyIDs map[TermKe // 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 perKeyIDs count is exact); this runs post-commit on +// 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 { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go index bf6ec8b13..334a842b8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -1,9 +1,11 @@ package event import ( + "bytes" "math/rand" "os" "path/filepath" + "slices" "sync" "testing" @@ -11,6 +13,22 @@ import ( "github.com/stretchr/testify/require" ) +// 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 manifestStore. type fakeManifest struct { mu sync.Mutex @@ -68,7 +86,7 @@ func (hh *hotIndexHarness) ledger(fire TermKey, mids []TermKey, eventsN int) { per[single] = append(per[single], id) } row := AppendPackedRow(nil, per) - require.NoError(hh.t, hh.h.ApplyLedger(hh.seq, row, 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...) } @@ -171,7 +189,7 @@ func TestHotIndex_WarmupRebuild(t *testing.T) { h2.sealEvery, h2.maxRuns = 8, 3 for i, row := range tailRows { seq := lastSealed + 1 + uint32(i) - require.NoError(t, h2.ApplyLedger(seq, row, tailPer[i])) + require.NoError(t, h2.ApplyLedger(seq, row, runsFromMap(tailPer[i]))) } h2.ArmSealing() // production order: replay disarmed, verify, then arm hh.h = h2 diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go index 189f8423c..78f9731b2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go @@ -155,6 +155,138 @@ const maxTermsPerEvent = 3 + protocol.MaxTopicCount // 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. +// 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 + +// 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(int(count)) + } else { + dst = append(dst, TopicCountTermKey(int(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) 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..a9084a20c --- /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}) + h.ledgersInWindow++ + if h.sealArmed && h.ledgersInWindow >= 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{}) + require.NoError(t, err) + defer hMap.Close() + hRuns, err := NewHotIndex(t.TempDir(), &fakeManifest{}) + 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.NotNil(t, bmMap, "term %x missing from map-path index", term) + require.NotNil(t, bmRuns, "term %x missing from runs-path index", term) + assert.Equal(t, bmMap.ToArray(), bmRuns.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.Nil(t, bmMap) + assert.Nil(t, bmRuns) + } +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index f6b1ade61..ce3763a28 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -13,11 +13,13 @@ 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" @@ -31,8 +33,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 @@ -66,9 +68,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 @@ -96,11 +101,37 @@ 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} +} + // 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) (*DB, error) { + return open(path, chunkID, logger, tun, false, false) } // OpenExisting opens an EXISTING hot DB read-WRITE with create-if-missing OFF — @@ -108,8 +139,8 @@ 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) (*DB, error) { + return open(path, chunkID, logger, tun, false, true) } // OpenReadOnly opens an EXISTING hot DB read-only — the freeze source's view AND @@ -119,31 +150,37 @@ 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 neither read-only caller needs it warm — -// the freeze scans CFs directly through the shared store (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 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: a read-only view composes the ledger facade for reads + // alone — its encoder is never invoked, so no encode tuning applies. + return open(path, chunkID, logger, Tuning{}, 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, +) (*DB, error) { + return openReady(state, path, chunkID, logger, tun, false) } // 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, Tuning{}, true) } // openReady is the single enforcement site for the "ready key ⇒ must-exist, @@ -154,13 +191,13 @@ func OpenReadyView(state geometry.HotState, path string, chunkID chunk.ID, logge // 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. 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, tun Tuning, readOnly bool, ) (*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 + openFn := func(p string, c chunk.ID, l *supportlog.Entry) (*DB, error) { return OpenExisting(p, c, l, tun) } if readOnly { openFn = OpenReadOnly } @@ -171,13 +208,16 @@ 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, 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) @@ -186,18 +226,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 } + th, err := txhash.NewWithStore(store, chunkID) + 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) if err != nil { + th.Shutdown() _ = store.Close() return nil, fmt.Errorf("compose events facade for chunk %s: %w", chunkID, err) } @@ -215,6 +264,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 @@ -245,6 +298,13 @@ func (d *DB) FreezeEventsCold( return event.FreezeColdFromStore(ctx, d.chunkID, d.store, scratchDir, bucketDir, secret, opts) } +// Source streams the chunk's LCMs from the ledgers CF as a ledgerbackend.LedgerStream. +// The freeze no longer uses it (it copies CF frames verbatim); it remains the seam +// tests drive a hot DB's ledgers through. +func (d *DB) Source() ledgerbackend.LedgerStream { + return &hotLedgerStream{store: d.ledger} +} + // FreezeLedgersCold builds the chunk's cold ledger .pack at packPath by // copying THIS hot DB's ledgers-CF zstd frames verbatim (PreCompressed cold // writer) — no decompression. Valid on a read-only view; the DB must be @@ -256,8 +316,10 @@ func (d *DB) FreezeLedgersCold( } // FreezeTxhashCold builds the chunk's cold txhash .bin at binPath by -// streaming THIS hot DB's txhash CF, which is already in the .bin's sort -// order. Valid on a read-only view; same completeness contract as the other +// freeze-by-merge over THIS hot DB's durable txhash state: the +// manifest-listed sealed runs plus the un-sealed packed-row tail, k-way +// merged back into the .bin's hash order. 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, @@ -266,12 +328,16 @@ func (d *DB) FreezeTxhashCold( } // Close releases the shared store exactly once. Idempotent. Must not be called -// concurrently with in-flight reads/writes. The events facade's background -// seal is drained first so no goroutine outlives the store. +// 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() } @@ -292,7 +358,7 @@ 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"; @@ -302,11 +368,12 @@ func (d *DB) MaxCommittedSeq() (uint32, bool, error) { // 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 events hot-index apply (window retention, -// dense-overlay feed, seal folding). It runs only after the batch is -// durable; the apply hook is fallible, so an error here reports -// Failed = PhaseApply — the ledger IS committed, and a restart rebuilds -// the index deterministically from the committed rows. +// - 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 ( @@ -363,8 +430,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 @@ -417,12 +484,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() @@ -442,16 +513,17 @@ 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() error + 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 @@ -462,10 +534,10 @@ func (d *DB) IngestLedger( // 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() @@ -500,16 +572,94 @@ 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 hot-index update - // (window retention, dense-overlay feed, seal folding). PhaseApply times - // this post-commit work; an apply error is restartable (warmup rebuilds - // deterministically from the committed rows). + // 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() - aerr := 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 events index for ledger %d: %w", seq, aerr) + return rep, fmt.Errorf("apply hot indexes for ledger %d: %w", seq, aerr) } return rep, nil } + +// 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 +} + +// 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++ + } + } +} 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 85007be08..945418d53 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" @@ -45,7 +48,7 @@ func ingestRaw(t *testing.T, db *DB, seq uint32, raw []byte) (LedgerReport, erro // 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()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) return db @@ -71,7 +74,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") @@ -95,10 +99,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()) require.ErrorIs(t, err, stores.ErrInvalidConfig) - _, err = Open(t.TempDir(), chunk.ID(0), nil) + _, err = Open(t.TempDir(), chunk.ID(0), nil, DefaultTuning()) require.ErrorIs(t, err, stores.ErrInvalidConfig) } @@ -217,7 +221,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()) require.NoError(t, err) // Commit one good ledger so there is a known last committed seq, then close the DB. @@ -227,7 +231,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()) require.NoError(t, err) t.Cleanup(func() { _ = db2.Close() }) @@ -245,7 +249,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()) require.NoError(t, err) t.Cleanup(func() { _ = db3.Close() }) @@ -371,25 +375,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()) 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()) 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 @@ -402,7 +410,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()) require.NoError(t, err) for _, seq := range []uint32{first, first + 1} { _, ierr := ingestRaw(t, db, seq, zeroTxLCM(t, seq)) @@ -437,7 +445,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()) require.NoError(t, err) want := map[uint32][]byte{} for _, seq := range []uint32{first, first + 1} { @@ -457,7 +465,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()) require.ErrorContains(t, werr, "events_index key length", "a read-write open must run the events warmup scan and reject the poison") @@ -483,8 +491,106 @@ func TestOpenReadOnly_SkipsEventsWarmup(t *testing.T) { } 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()) + 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 := stores.DeriveIndexSecret([]byte("hotchunk-test-master"), "txhash", uint32(chunkID)) + 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 @@ -493,7 +599,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()) require.NoError(t, err) require.NoError(t, db.Close()) 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 2714aa022..357090b3a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go @@ -10,10 +10,15 @@ 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 @@ -30,7 +35,7 @@ func extractRawLedger(frame []byte) ([]byte, error) { } // 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 the packfile's parallel record workers. 0 means 1 @@ -42,6 +47,15 @@ type ColdWriterOptions struct { // 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 @@ -91,12 +105,14 @@ 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. - newEncoder := newColdPackEncoder + // 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 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 d95ca4278..d235c8dca 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/hot_store.go @@ -70,21 +70,46 @@ 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(), - enc: zstd.NewEncoderState(), + enc: zstd.NewEncoderState(encoderOptions(zstdEncodeWorkers)...), scratch: sync.Pool{ New: func() any { return new([]byte) }, }, } } +// 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 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 7096a5165..1cfc8f9cf 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 @@ -46,7 +46,7 @@ func openTestHotStoreAt(t *testing.T, path string) (*HotStore, *rocksdb.Store) { }) require.NoError(t, err) t.Cleanup(func() { _ = store.Close() }) - return NewWithStore(store), store + return NewWithStore(store, DefaultZstdEncodeWorkers), store } func TestHotStore_AddGetRoundTripVerbatim(t *testing.T) { @@ -555,7 +555,7 @@ func readLedgerRaw(r interface { // 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) // StartCompress/Discard never touch the store + 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}) @@ -581,7 +581,7 @@ func TestStartCompress_StateSurvivesGC(t *testing.T) { // 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) + h := NewWithStore(nil, DefaultZstdEncodeWorkers) payload := []byte("x") first := h.StartCompress(Entry{Seq: 1, Bytes: payload}) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go index d112dc9e0..e2b626588 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -1,26 +1,41 @@ package txhash // cold_freeze.go — the txhash half of the zero-decompression freeze: build a -// completed hot chunk's cold .bin DIRECTLY from its txhash CF. The CF's keys -// are full 32-byte hashes in RocksDB's bytewise order, and truncating a -// bytewise-sorted sequence to its 16-byte prefix preserves that order — so -// the CF scan yields the .bin's entries ALREADY lex-sorted, deleting both -// the walk path's whole-chunk accumulate-then-sort (the baseline's ~40s, -// ~1.2GB-live finalize at sac-6000 density) and its share of the raw-ledger -// decompress walk. Two encodings meet here and must not be conflated: the -// CF value is a BIG-endian seq (rocksdb.EncodeUint32), the .bin stores -// LITTLE-endian — every value is decoded and re-encoded, never memcpy'd. +// completed hot chunk's cold .bin DIRECTLY from its durable hot state. The +// packed-row tier stores hashes ledger-keyed (hot_store.go), so the .bin's +// hash order is recovered by a k-way merge over already-sorted sources: the +// manifest-listed sealed runs (hash-sorted 36-byte records, CRC64-framed) +// plus each un-sealed tail row (hash-sorted by EncodeRow) fed AS ITS OWN +// merge source — no tail explode-and-sort, so freeze RAM stays flat even for +// a crash-inherited tail larger than one seal window. 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. // -// Duplicate-prefix semantics are unchanged from the walk path: entries are -// written verbatim and the downstream streamhash build rejects a truncated- -// key collision loudly. +// Two byte-discipline rules, unchanged from the CF-scan freeze this +// replaces: +// - truncation to ColdKeySize happens exactly once, at the .bin emit; +// - sequences are decoded and re-encoded (BE row key / LE run record → +// LE .bin), never memcpy'd. +// +// Duplicate emission is VERBATIM — equal full hashes 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 truncated-key collision loudly (cold_bin.go). An +// intra-ledger duplicate was rejected at write time and cannot reach here. import ( + "slices" + "bufio" + "bytes" "context" + "encoding/binary" "fmt" - "bytes" + "hash" + "hash/crc64" + "io" "os" - "slices" "path/filepath" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" @@ -28,22 +43,20 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) -// freezeCtxPollEvery is how many entries the freeze scan copies between -// context checks. Entries are 36 bytes of work apiece, so a coarser cadence -// than the ledgers scan still cancels promptly. +// freezeCtxPollEvery is how many entries the merge emits between context +// checks. Entries are ~36 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 (read-only) hot store. Entries stream from the CF straight to -// the .bin — no whole-chunk accumulator (hundreds of MB at stress density); -// the header's leading count is patched in once the scan completes, and the -// bytes are identical to WriteColdBin's. Returns the entries written. -// secret is the chunk's per-index routing secret: every stored key is -// stores.BlindKey(secret, hash[:ColdKeySize]), exactly the walk writer's -// rule. Blinding destroys the CF's raw sort order, so this path — unlike the -// unkeyed design it replaces — must collect and re-sort before writing: the -// .bin's contract is lex order BY BLINDED KEY. The accumulator is the same -// size class the walk path already carries for its finalize sort. +// the chunk's hot store (read-only opens included — nothing here needs a +// warmed facade, and nothing is mutated). The source merge yields entries in +// RAW-key order; each key is blinded at emit (stores.BlindKey — the walk +// writer's rule), which destroys that order, so the blinded entries are +// collected and re-sorted before the write: the .bin's contract is lex order +// BY BLINDED KEY. The accumulator is the same size class the walk path +// already carries for its finalize sort; the bytes are identical to +// WriteColdBin's over the same entries. Returns the entries written. func FreezeColdFromStore( ctx context.Context, chunkID chunk.ID, @@ -51,34 +64,21 @@ func FreezeColdFromStore( binPath string, secret [stores.SecretLen]byte, ) (int, error) { - first, last := chunkID.FirstLedger(), chunkID.LastLedger() if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil { return 0, fmt.Errorf("txhash freeze %s: mkdir: %w", chunkID, err) } - entries := make([]ColdEntry, 0, 1<<16) - for entry, ierr := range store.Iterate(txhashCF, nil) { - if ierr != nil { - return 0, fmt.Errorf("txhash freeze %s: scan %s: %w", chunkID, txhashCF, ierr) - } - if len(entries)%freezeCtxPollEvery == 0 { - if cerr := ctx.Err(); cerr != nil { - return 0, cerr - } - } - if len(entry.Key) != 32 || len(entry.Value) != 4 { - return 0, fmt.Errorf("txhash freeze %s: row shape %d/%d (want 32/4)", - chunkID, len(entry.Key), len(entry.Value)) - } - seq := rocksdb.DecodeUint32(entry.Value) - if seq < first || seq > last { - return 0, fmt.Errorf("txhash freeze %s: entry seq %d outside [%d, %d]", chunkID, seq, first, last) - } - entries = append(entries, ColdEntry{ - Key: stores.BlindKey(secret, entry.Key[:ColdKeySize]), - Seq: seq, - }) + sources, err := openFreezeSources(ctx, store, chunkID) + if err != nil { + return 0, err + } + defer closeFreezeSources(sources) + entries, err := mergeFreezeSources(ctx, chunkID, sources, secret) + if err != nil { + return 0, err } - slices.SortFunc(entries, func(a, b ColdEntry) int { + // STABLE: duplicate full hashes share a blinded key; the merge emitted + // them in global ledger order and the .bin keeps that tie-break. + slices.SortStableFunc(entries, func(a, b ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) w, err := newColdBinStream(binPath, secret) @@ -96,3 +96,322 @@ func FreezeColdFromStore( } return len(entries), 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 hashes emit in global ledger order. +func openFreezeSources( + ctx context.Context, store *rocksdb.Store, chunkID chunk.ID, +) ([]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)) + for _, 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) + } + sources = append(sources, rs) + } + tails, terr := collectTailSources(ctx, store, chunkID, lastSealed) + 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, +) ([]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) + } + if cerr := pollCtx(ctx, len(sources)); 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, &tailRowSource{ + row: append([]byte(nil), entry.Value...), rowSeq: seq, off: -rowHashLen, + }) + } + return sources, nil +} + +// mergeFreezeSources runs the k-way merge, emitting each record as a cold +// entry: hash truncated to ColdKeySize and BLINDED with the index secret, +// seq range-checked against the chunk. The returned slice is in the merge's +// raw-key order — the caller owns the blinded-order sort. +func mergeFreezeSources( + ctx context.Context, chunkID chunk.ID, sources []freezeSource, secret [stores.SecretLen]byte, +) ([]ColdEntry, error) { + first, last := chunkID.FirstLedger(), chunkID.LastLedger() + h, err := newFreezeHeap(sources) + if err != nil { + return nil, fmt.Errorf("txhash freeze %s: %w", chunkID, err) + } + entries := make([]ColdEntry, 0, 1<<16) + for h.len() > 0 { + if cerr := pollCtx(ctx, len(entries)); cerr != nil { + return nil, cerr + } + src := h.min() + seq := src.seq() + if seq < first || seq > last { + return nil, fmt.Errorf("txhash freeze %s: entry seq %d outside [%d, %d]", chunkID, seq, first, last) + } + entries = append(entries, ColdEntry{ + Key: stores.BlindKey(secret, src.hash()[:ColdKeySize]), + Seq: seq, + }) + if serr := h.step(); serr != nil { + return nil, fmt.Errorf("txhash freeze %s: %w", chunkID, serr) + } + } + return entries, 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 hash-sorted stream of (full hash, seq) records feeding +// the freeze merge: a sealed run file or a single un-sealed tail row. +// Sources start positioned BEFORE their first record; advance steps to the +// next one. +type freezeSource interface { + // hash returns the current record's full 32-byte hash — valid only + // until the next advance. + hash() []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() + } +} + +// runSource streams one sealed run file sequentially through a 128KiB +// buffer, 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 the freeze loudly before the .bin is finished. +// Per-record hash order is checked as it streams (the merge's correctness +// AND the .bin's sort order both ride on it). +type runSource struct { + path string + f *os.File + br *bufio.Reader + crc hash.Hash64 + hdr runHeader + records int + remaining int + rec [runRecordLen]byte + prev [rowHashLen]byte +} + +// openRunSource opens and structurally validates a run for sequential +// draining (header magic/range, count vs file size — the same gates +// openSealedRun applies before ITS drain). +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, records: records, remaining: records, + }, nil +} + +func (r *runSource) hash() []byte { return r.rec[:rowHashLen] } + +func (r *runSource) seq() uint32 { return binary.LittleEndian.Uint32(r.rec[rowHashLen:]) } + +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.records - r.remaining + if idx > 0 { + copy(r.prev[:], r.rec[:rowHashLen]) + } + 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[:rowHashLen]) > 0 { + return false, fmt.Errorf("run %s: record %d out of hash order", r.path, idx) + } + r.remaining-- + return true, nil +} + +func (r *runSource) close() { _ = r.f.Close() } + +// tailRowSource walks one retained packed row (32-byte stride, already +// hash-sorted by EncodeRow) with a fixed ledger. off starts at -rowHashLen +// (before the first hash) per the freezeSource contract. +type tailRowSource struct { + row []byte + rowSeq uint32 + off int +} + +func (t *tailRowSource) hash() []byte { return t.row[t.off : t.off+rowHashLen] } + +func (t *tailRowSource) seq() uint32 { return t.rowSeq } + +func (t *tailRowSource) advance() (bool, error) { + t.off += rowHashLen + return t.off < len(t.row), nil +} + +func (t *tailRowSource) close() {} + +// ─────────────────────────── merge heap ─────────────────────────── + +// freezeHeap is a slice-backed min-heap of source indices ordered by +// (current hash, source index) — the same shape as the seal path's +// mergeHeap, over freezeSources instead of window rows. The index tie-break +// emits equal cross-source duplicate hashes in source order (runs oldest +// first, then tail rows in ledger order), and equal hashes WITHIN a run are +// already in ledger order from the seal merge — so global duplicate order is +// ascending ledger, deterministically. +type freezeHeap struct { + sources []freezeSource + heap []int +} + +// 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([]int, 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, i) + h.up(len(h.heap) - 1) + } + } + return h, nil +} + +func (h *freezeHeap) len() int { return len(h.heap) } + +// min returns the source holding the smallest current record. +func (h *freezeHeap) min() freezeSource { return h.sources[h.heap[0]] } + +// step advances the minimum source past its emitted record, dropping the +// source once drained (its end-of-stream verification ran inside advance). +func (h *freezeHeap) step() error { + ok, err := h.sources[h.heap[0]].advance() + if err != nil { + return err + } + if !ok { + lastIdx := len(h.heap) - 1 + h.heap[0] = h.heap[lastIdx] + h.heap = h.heap[:lastIdx] + } + if len(h.heap) > 0 { + h.down(0) + } + return nil +} + +// less orders heap positions a, b by (current hash, source index). +func (h *freezeHeap) less(a, b int) bool { + sa, sb := h.heap[a], h.heap[b] + if c := bytes.Compare(h.sources[sa].hash(), h.sources[sb].hash()); c != 0 { + return c < 0 + } + return sa < sb +} + +func (h *freezeHeap) up(pos int) { + for pos > 0 { + parent := (pos - 1) / 2 + if !h.less(pos, parent) { + return + } + h.heap[pos], h.heap[parent] = h.heap[parent], h.heap[pos] + pos = parent + } +} + +func (h *freezeHeap) down(pos int) { + for { + child := 2*pos + 1 + if child >= len(h.heap) { + return + } + if r := child + 1; r < len(h.heap) && h.less(r, child) { + child = r + } + if !h.less(child, pos) { + return + } + h.heap[pos], h.heap[child] = h.heap[child], h.heap[pos] + pos = child + } +} 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 index b3c48bdf4..08fab3032 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go @@ -3,7 +3,6 @@ package txhash import ( "bytes" "context" - "encoding/binary" "os" "path/filepath" "slices" @@ -16,65 +15,178 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) -// freezeEntries fabricates n deterministic pseudo-random entries with seqs -// inside chunkID's range, in a deliberately unsorted insertion order. -func freezeEntries(chunkID chunk.ID, n int) []Entry { - first := chunkID.FirstLedger() - span := chunk.LedgersPerChunk - entries := make([]Entry, n) - state := uint64(0x9E3779B97F4A7C15) - for i := range entries { - var h [32]byte - for w := range 4 { - state ^= state << 13 - state ^= state >> 7 - state ^= state << 17 - binary.BigEndian.PutUint64(h[w*8:], state) +// 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 + } + } } - entries[i] = Entry{Hash: h, LedgerSeq: first + uint32(state)%span} } - return entries + return ledgers } -// TestFreezeColdFromStore_ByteIdenticalToWalk is the txhash half of the -// cross-path identity gate: the freeze-written .bin must be byte-for-byte -// identical to the walk path's accumulate-then-sort output. Both paths blind -// with the same secret and sort by the blinded key, so identity reduces to -// the sort's determinism (keys are unique — no tie-break sensitivity). -func TestFreezeColdFromStore_ByteIdenticalToWalk(t *testing.T) { - chunkID := chunk.ID(0) - entries := freezeEntries(chunkID, 5000) - - // Walk path: blind, sort, write — exactly what ingest's finalize does - // (the stored key is BlindKey(secret, hash[:ColdKeySize])). - walk := make([]ColdEntry, len(entries)) - for i, e := range entries { - walk[i].Key = stores.BlindKey(testBinSecret, e.Hash[:ColdKeySize]) - walk[i].Seq = e.LedgerSeq +func populationSize(ledgers [][][32]byte) int { + total := 0 + for _, l := range ledgers { + total += len(l) } - slices.SortFunc(walk, func(a, b ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) - walkPath := filepath.Join(t.TempDir(), "walk.bin") - require.NoError(t, WriteColdBin(walkPath, testBinSecret, walk)) + return total +} - // Freeze path: same entries through the hot CF, streamed out sorted. - h, store := openTestHotStoreAt(t, t.TempDir()) - require.NoError(t, addEntries(h, entries)) - freezePath := filepath.Join(t.TempDir(), "freeze.bin") - n, err := FreezeColdFromStore(context.Background(), chunkID, store, freezePath, testBinSecret) +// expectedBinBytes is the walk-path expectation: accumulate entries in +// ledger order, blind (BlindKey over the truncated hash — the ingest rule), +// sort by blinded key, WriteColdBin. The sort is +// STABLE so cross-ledger duplicate full hashes keep ledger order — exactly +// the order the freeze merge's run-then-tail source tie-break produces +// (ingest's walk writer sorts unstably, which only ever differs on those +// duplicates, where its order is unspecified rather than conflicting). +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) - require.Equal(t, len(entries), n) + return raw +} - walkBytes, err := os.ReadFile(walkPath) +// 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) - freezeBytes, err := os.ReadFile(freezePath) + 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.True(t, bytes.Equal(walkBytes, freezeBytes), ".bin bytes diverge") + 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_EmptyChunk: a zero-tx chunk freezes to the same +// 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") + require.NoError(t, writeRun(tailRows, orphan)) + 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 := openTestHotStoreAt(t, t.TempDir()) + 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) @@ -89,27 +201,144 @@ func TestFreezeColdFromStore_EmptyChunk(t *testing.T) { require.Equal(t, walkBytes, freezeBytes) } -// TestFreezeColdFromStore_RejectsOutOfRangeSeq: an entry pointing outside the -// chunk's ledger range is corruption, not data. +// 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) - h, store := openTestHotStoreAt(t, t.TempDir()) - require.NoError(t, addEntries(h, []Entry{{Hash: txhashFor(1, 1), LedgerSeq: chunkID.LastLedger() + 1}})) - _, err := FreezeColdFromStore(context.Background(), chunkID, store, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) - require.Error(t, err) - require.Contains(t, err.Error(), "outside") + 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)) + require.NoError(t, writeRun([]windowRow{{seq: badSeq, bytes: row}}, filepath.Join(runDir, "seal-000000.run"))) + 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, "outside") } // 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 := openTestHotStoreAt(t, t.TempDir()) + 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, []byte("short-key"), rocksdb.EncodeUint32(chunkID.FirstLedger())) + 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.Error(t, err) - require.Contains(t, err.Error(), "row shape") + + _, 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") +} + +// 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/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..cbcdc2e63 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,128 @@ -// 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" ) -// 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 + + // 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 — 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. +// 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: +// 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. +func NewWithStore(store *rocksdb.Store, chunkID chunk.ID) (*HotStore, error) { + hotIdx, err := warmupHotIndex(store, chunkID) + 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; every route ends in a full 32-byte +// compare). Safe alongside the single writer. +// +// 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 +130,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 +159,78 @@ 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: manifest-listed runs (drain-verified), then the un-sealed +// packed-row tail, then 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. +func warmupHotIndex(store *rocksdb.Store, chunkID chunk.ID) (*HotIndex, error) { + runDir := filepath.Join(store.Path(), txhashRunDir) + hotIdx, lastSealed, err := OpenHotIndex(runDir, chunkID.FirstLedger(), rocksdbManifest{store: store}) + 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 } -// 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 - }) +// 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 +} + +// 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 } 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..9c4b2870d 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 @@ -3,9 +3,7 @@ package txhash import ( "bytes" "sync" - "sync/atomic" "testing" - "time" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -13,6 +11,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 +24,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 +37,234 @@ 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) 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()) - - 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) - - require.ErrorIs(t, addEntries(s, nil), rocksdb.ErrStoreClosed) - require.ErrorIs(t, addEntries(s, []Entry{}), rocksdb.ErrStoreClosed) +// 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}) } -func TestHotStore_GracefulCloseAndReopenRoundTrips(t *testing.T) { - path := t.TempDir() +// 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()))) - 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()) + _, err := NewWithStore(store, chunkID) + require.ErrorContains(t, err, "stale pre-release txhash format") +} - second, _ := openTestHotStoreAt(t, path) +// 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 + })) - for n := range 16 { - got, err := second.Get(txhashFor(byte(n), 1)) - require.NoError(t, err) - assert.Equal(t, uint32(n)+1, got) - } + _, err = NewWithStore(store, chunkID) + require.ErrorContains(t, err, "dense chain") } -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_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 { + for r := range 3 { + seed := uint64(r + 100) wg.Go(func() { - for i := byte(0); !stop.Load(); i++ { - _ = addEntries(s, []Entry{ - {Hash: txhashFor(i%16, byte(w+5)), LedgerSeq: uint32(i)}, - }) - } - }) - 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..696021522 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go @@ -0,0 +1,234 @@ +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 (TXHRUN01 format: +// 36-byte hash‖seq records, hash-sorted, CRC64-framed), sealed from the +// window every sealEvery ledgers on a background goroutine, each with +// an in-RAM bloom + page ladder rebuilt from the verified file bytes. +// +// Reads are exact: every route ends in a full 32-byte hash compare before a +// sequence is returned. 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/stores" +) + +// 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 drain-verified payload record count (it bounds +// the final page's pread). +type sealedRun struct { + path string + bloom bloomFilter + ladder [][ladderKeyLen]byte // first 16B 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] + + // Writer-owned state (single-writer contract). sealEvery defaults to + // windowLedgers; tests shrink it to exercise seals with small inputs. + sealEvery int + ledgersInWindow int + sealSeq int + pendingSeal chan sealResult // capacity 1: at most one seal in flight + sealInFlight bool + manifest manifestStore + + // 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. +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. +func NewHotIndex(dir string, manifest manifestStore) (*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, + pendingSeal: make(chan sealResult, 1), + manifest: manifest, + 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 + h.ledgersInWindow++ + + if h.sealArmed && h.ledgersInWindow >= h.sealEvery && !h.sealInFlight { + h.startSeal(rows) + } + return nil +} + +// Get returns the ledger holding hash, or stores.ErrNotFound. Window rows +// are probed newest→oldest, then runs newest→oldest — 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. +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 + } + } + for i := len(v.runs) - 1; i >= 0; i-- { + seq, ok, err := v.runs[i].lookup(hash) + 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() + } + } + for _, r := range h.view.Load().runs { + r.close() + } + }) +} + +// fp64 is the bloom fingerprint of a hash. +func fp64(hash [32]byte) uint64 { return xxhash.Sum64(hash[:]) } + +// 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..fbb47e705 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go @@ -0,0 +1,649 @@ +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 +// (hash, seq) record of its ledgers sorted by full 32-byte hash: +// +// header (40B): magic "TXHRUN01" ‖ 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 × 36B records hash[32] ‖ seq u32 LE (equal hashes: +// ledger order) +// +// Durability ladder: buffered payload → flush → patch CRC → fsync file → +// fsync the run DIRECTORY → 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" +) + +// ─────────────────────────── bloom filter ─────────────────────────── + +// bloomFilter is a plain double-hashed bloom over hash fp64s: ~10 bits/key, +// 7 probes. Pointerless (one []uint64), GC-invisible. +type bloomFilter struct { + bits []uint64 + mask uint64 +} + +const bloomProbes = 7 + +// newBloom sizes for n keys at ~10 bits/key, rounded up to a power of two +// (mask-indexable). +func newBloom(n int) bloomFilter { + bits := 1 + for bits < n*10 { + bits <<= 1 + } + return bloomFilter{bits: make([]uint64, bits/64+1), mask: uint64(bits - 1)} //nolint:gosec // bits >= 1 +} + +func (b *bloomFilter) add(fp uint64) { + h2 := fp>>33 | fp<<31 | 1 // odd second hash + for i := range bloomProbes { + bit := (fp + uint64(i)*h2) & b.mask + b.bits[bit/64] |= 1 << (bit % 64) + } +} + +func (b *bloomFilter) mayContain(fp uint64) bool { + h2 := fp>>33 | fp<<31 | 1 + for i := range bloomProbes { + bit := (fp + uint64(i)*h2) & b.mask + if b.bits[bit/64]&(1<<(bit%64)) == 0 { + return false + } + } + return true +} + +// ─────────────────────────── run format ─────────────────────────── + +const ( + // runMagic heads every run file; a version bump changes the digits. + runMagic = "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: hash[32] ‖ seq u32 LE. + runRecordLen = rowHashLen + 4 + + // pageRecords is the ladder's page: the most whole records inside a + // 4KiB I/O budget (113 × 36B = 4068B). Pages are record-aligned so the + // in-page binary search strides whole records; the first ladderKeyLen + // bytes of each page's first record route a probe to ONE page pread. + pageRecords = 4096 / runRecordLen + ladderKeyLen = 16 +) + +// 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. Writer goroutine only; h.sealInFlight guards the single-job +// invariant. +func (h *HotIndex) startSeal(rows []windowRow) { + 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) + 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() + runs := append(append([]*sealedRun{}, old.runs...), res.run) + names := make([]string, len(runs)) + for i, r := range runs { + names[i] = filepath.Base(r.path) + } + if err := h.manifest.PutRuns(names, res.lastSeq); err != nil { + return fmt.Errorf("txhash: hotindex manifest: %w", err) + } + h.view.Store(&hotIndexView{rows: old.rows[res.rows:], runs: runs}) + h.ledgersInWindow -= res.rows + return nil +} + +// sealWindow folds window rows into one run file and drain-verifies the +// result open. Runs on the background goroutine over immutable inputs. +func sealWindow(rows []windowRow, path string) (*sealedRun, error) { + if err := writeRun(rows, path); err != nil { + return nil, err + } + return openSealedRun(path) +} + +// writeRun streams the window rows' records into path, hash-sorted, honoring +// the durability ladder: the file AND its directory entry must be durable +// before the caller may name the run in the manifest. +func writeRun(rows []windowRow, path string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("txhash: create run %s: %w", path, err) + } + if err := writeRunPayload(f, rows); err != nil { + _ = f.Close() + _ = os.Remove(path) + return fmt.Errorf("txhash: write run %s: %w", path, err) + } + if err := f.Sync(); err != nil { + _ = f.Close() + return fmt.Errorf("txhash: sync run %s: %w", path, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("txhash: close run %s: %w", path, err) + } + return syncDir(filepath.Dir(path)) +} + +// writeRunPayload writes the header and the k-way-merged records (each row +// is already sorted; ≤window-size cursors), then patches the payload CRC +// into the header. No whole-payload buffer: the merge streams straight +// through a bufio writer. +func writeRunPayload(f *os.File, rows []windowRow) 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 err + } + w := bufio.NewWriterSize(f, 128<<10) + crc := crc64.New(crcRunTable) + var rec [runRecordLen]byte + for m := newMergeHeap(rows); ; { + hash, seq, ok := m.next() + if !ok { + break + } + copy(rec[:rowHashLen], hash) + binary.LittleEndian.PutUint32(rec[rowHashLen:], seq) + _, _ = crc.Write(rec[:]) + if _, err := w.Write(rec[:]); err != nil { + return err + } + } + if err := w.Flush(); err != nil { + return err + } + var crcb [8]byte + binary.LittleEndian.PutUint64(crcb[:], crc.Sum64()) + _, err := f.WriteAt(crcb[:], runCRCOff) + return err +} + +// syncDir fsyncs a directory so a just-written file's entry is durable — the +// manifest may only ever name a run whose existence survives a crash. +func syncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return fmt.Errorf("txhash: open dir %s: %w", dir, err) + } + if err := d.Sync(); err != nil { + _ = d.Close() + return fmt.Errorf("txhash: sync dir %s: %w", dir, err) + } + return d.Close() +} + +// mergeHeap is a slice-backed binary min-heap of window-row cursors ordered +// by (current hash, row index): the row-index tie-break emits equal +// cross-ledger duplicate hashes in ledger order, so run bytes are +// deterministic. +type mergeHeap struct { + rows []windowRow + offs []int // per-row cursor: byte offset of the row's current hash + heap []int // row indices, min-heap by (current hash, row index) +} + +func newMergeHeap(rows []windowRow) *mergeHeap { + m := &mergeHeap{rows: rows, offs: make([]int, len(rows)), heap: make([]int, 0, len(rows))} + for i := range rows { + if len(rows[i].bytes) > 0 { + m.heap = append(m.heap, i) + m.up(len(m.heap) - 1) + } + } + return m +} + +// next pops the smallest current record — its hash (a slice into the +// immutable row bytes) and its row's ledger — advancing that cursor; +// ok=false when every row is drained. +func (m *mergeHeap) next() ([]byte, uint32, bool) { + if len(m.heap) == 0 { + return nil, 0, false + } + i := m.heap[0] + hash := m.cur(i) + m.offs[i] += rowHashLen + if m.offs[i] == len(m.rows[i].bytes) { + last := len(m.heap) - 1 + m.heap[0] = m.heap[last] + m.heap = m.heap[:last] + } + if len(m.heap) > 0 { + m.down(0) + } + return hash, m.rows[i].seq, true +} + +func (m *mergeHeap) cur(row int) []byte { + return m.rows[row].bytes[m.offs[row] : m.offs[row]+rowHashLen] +} + +// less orders heap positions a, b. +func (m *mergeHeap) less(a, b int) bool { + ra, rb := m.heap[a], m.heap[b] + if c := bytes.Compare(m.cur(ra), m.cur(rb)); c != 0 { + return c < 0 + } + return ra < rb +} + +func (m *mergeHeap) up(pos int) { + for pos > 0 { + parent := (pos - 1) / 2 + if !m.less(pos, parent) { + return + } + m.heap[pos], m.heap[parent] = m.heap[parent], m.heap[pos] + pos = parent + } +} + +func (m *mergeHeap) down(pos int) { + for { + child := 2*pos + 1 + if child >= len(m.heap) { + return + } + if r := child + 1; r < len(m.heap) && m.less(r, child) { + child = r + } + if !m.less(child, pos) { + return + } + m.heap[pos], m.heap[child] = m.heap[child], m.heap[pos] + pos = child + } +} + +// ─────────────────────────── sealed runs ─────────────────────────── + +// openSealedRun drain-verifies a run file and builds its lookup state plus +// an open handle. Used at seal completion and warmup — corruption is a loud +// failure, never auto-healed. +func openSealedRun(path string) (*sealedRun, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("txhash: open run %s: %w", path, err) + } + hdr, err := readRunHeader(f, path) + if err != nil { + _ = f.Close() + return nil, err + } + run, err := drainRun(f, path, hdr) + if err != nil { + _ = f.Close() + return nil, err + } + run.file = f + 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 { + 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", path, hdr.count, payload) + } + return int(payload / runRecordLen), nil +} + +// drainRun reads and verifies the WHOLE payload — CRC64, record count vs +// file size, hash sortedness (non-decreasing: equal cross-ledger duplicate +// hashes are legal within one window), per-record seq ∈ [first,last] — and +// builds the bloom and ladder FROM THE VERIFIED BYTES ONLY: routing state is +// always rebuilt from the verified file, never trusted from elsewhere. +func drainRun(f *os.File, path string, hdr runHeader) (*sealedRun, error) { + records, err := runRecordCount(f, path, hdr) + if err != nil { + return nil, err + } + br := bufio.NewReaderSize(f, 128<<10) + crc := crc64.New(crcRunTable) + fps := make([]uint64, 0, records) + ladder := make([][ladderKeyLen]byte, 0, records/pageRecords+1) + var rec [runRecordLen]byte + var prev [rowHashLen]byte + for i := range records { + if _, rerr := io.ReadFull(br, rec[:]); rerr != nil { + return nil, fmt.Errorf("txhash: run %s: record %d: %w", path, i, rerr) + } + _, _ = crc.Write(rec[:]) + if i > 0 && bytes.Compare(prev[:], rec[:rowHashLen]) > 0 { + return nil, fmt.Errorf("txhash: run %s: record %d out of hash order", path, i) + } + copy(prev[:], rec[:rowHashLen]) + if seq := binary.LittleEndian.Uint32(rec[rowHashLen:]); seq < hdr.first || seq > hdr.last { + return nil, fmt.Errorf("txhash: run %s: record %d seq %d outside [%d,%d]", + path, i, seq, hdr.first, hdr.last) + } + if i%pageRecords == 0 { + ladder = append(ladder, [ladderKeyLen]byte(rec[:ladderKeyLen])) + } + fps = append(fps, fp64([rowHashLen]byte(rec[:rowHashLen]))) + } + if crc.Sum64() != hdr.crc { + return nil, fmt.Errorf("txhash: run %s: payload crc mismatch (file %016x, computed %016x)", + path, hdr.crc, crc.Sum64()) + } + bloom := newBloom(max(len(fps), 1)) + for _, fp := range fps { + bloom.add(fp) + } + return &sealedRun{ + path: path, bloom: bloom, ladder: ladder, + first: hdr.first, last: hdr.last, records: records, + }, nil +} + +// lookup probes the run for one hash: bloom reject → ladder route → one +// record-aligned page pread (two on a boundary tie) → in-buffer stride +// binary search → full 32-byte verify → LE seq. Concurrent-safe (ReadAt). +func (r *sealedRun) lookup(hash [32]byte) (uint32, bool, error) { + if len(r.ladder) == 0 || !r.bloom.mayContain(fp64(hash)) { + return 0, false, nil + } + page, pages := r.route(hash) + 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+rowHashLen], hash[:]) >= 0 + }) + if i == n || !bytes.Equal(buf[i*runRecordLen:i*runRecordLen+rowHashLen], hash[:]) { + return 0, false, nil + } + return binary.LittleEndian.Uint32(buf[i*runRecordLen+rowHashLen:]), true, nil +} + +// route picks the page(s) a hash can live in: the last page whose ladder key +// sorts strictly below the hash's 16-byte prefix — the equal-prefix 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 always +// suffice for equal FULL hashes (any match is a correct answer, and the +// covered pages contain at least one); only >pageRecords DISTINCT hashes +// sharing a 16-byte prefix — a 2^-64-per-pair event, crafted inputs only — +// could defeat the cap. +func (r *sealedRun) route(hash [32]byte) (int, int) { + page := sort.Search(len(r.ladder), func(k int) bool { + return bytes.Compare(r.ladder[k][:], hash[:ladderKeyLen]) >= 0 + }) + if page > 0 { + page-- + } + pages := 1 + if page+1 < len(r.ladder) && bytes.Equal(r.ladder[page+1][:], hash[:ladderKeyLen]) { + pages = 2 + } + return page, pages +} + +func (r *sealedRun) close() { + if r.file != nil { + _ = r.file.Close() + } +} + +// ─────────────────────────── manifest ─────────────────────────── + +// manifestStore persists which runs are live — the crash-recovery authority. +// Implemented over the chunk's RocksDB by the integration layer; faked in +// tests. +type manifestStore interface { + // PutRuns atomically replaces the live-run list and records the highest + // sealed ledger (warmup replays packed rows PAST it). + PutRuns(names []string, lastSealed uint32) error + // GetRuns returns the live-run list and the sealed frontier (zero when + // nothing sealed). + GetRuns() ([]string, uint32, error) +} + +// 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. +// +// 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 manifestStore) (*HotIndex, uint32, error) { + h, err := NewHotIndex(dir, manifest) + 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, err := openManifestRuns(dir, names, lastSealed) + if err != nil { + return nil, 0, err + } + if err := sweepOrphans(dir, names); err != nil { + closeRuns(runs) + return nil, 0, err + } + h.view.Store(&hotIndexView{runs: runs}) + h.expectedNext = max(lastSealed+1, firstLedger) + h.sealSeq = nextSealSeq(names) + return h, lastSealed, nil +} + +// openManifestRuns drain-verifies every manifest-listed run and checks the +// chain is dense: 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. +func openManifestRuns(dir string, names []string, lastSealed uint32) ([]*sealedRun, error) { + runs := make([]*sealedRun, 0, len(names)) + for _, name := range names { + r, err := openSealedRun(filepath.Join(dir, name)) + if err != nil { + closeRuns(runs) + return nil, fmt.Errorf("txhash: hotindex: manifest run %s: %w", name, err) + } + if n := len(runs); n > 0 && r.first != runs[n-1].last+1 { + closeRuns(append(runs, r)) + return nil, fmt.Errorf("txhash: hotindex: run %s starts at %d, previous run ends at %d", + name, r.first, runs[n-1].last) + } + runs = append(runs, r) + } + if n := len(runs); n > 0 && runs[n-1].last != lastSealed { + closeRuns(runs) + return nil, fmt.Errorf("txhash: hotindex: newest run ends at %d, sealed frontier is %d", + runs[n-1].last, lastSealed) + } + return runs, nil +} + +func closeRuns(runs []*sealedRun) { + for _, r := range runs { + r.close() + } +} + +// sweepOrphans deletes files present in dir but unreferenced by the +// manifest — garbage by definition (a crash between run write and manifest +// Put). +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("txhash: hotindex 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 live run so a future +// seal can never overwrite one (numeric suffixes are monotone). +func nextSealSeq(names []string) int { + maxSeq := 0 + for _, name := range names { + var n int + if _, err := fmt.Sscanf(name, "seal-%06d.run", &n); err == nil && n > maxSeq { + maxSeq = n + } + } + return maxSeq + 1 +} 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/txhash_hotindex_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go new file mode 100644 index 000000000..5ae15f56d --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go @@ -0,0 +1,500 @@ +package txhash + +import ( + "encoding/binary" + "math/rand/v2" + "os" + "path/filepath" + "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" +) + +// fakeManifest is an in-memory manifestStore. +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. +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 manifestStore) *HotIndex { + h, err := NewHotIndex(dir, m) + 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) +} + +// 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) + 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) + 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) + 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) + 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) + 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) + 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) + require.Error(t, err, "reordered runs break the dense chain") + + require.NoError(t, m.PutRuns(names, lastSealed+3)) + _, _, err = OpenHotIndex(dir, 100, m) + 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) + 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) + 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 sharing one ladder prefix. +func prefixedHash(p byte, n uint32) [32]byte { + var h [32]byte + for i := range ladderKeyLen { + h[i] = p + } + binary.BigEndian.PutUint32(h[28:], n) + return h +} + +// TestHotIndex_LadderBoundaryTie crafts a run whose page boundary splits a +// range of records sharing one 16-byte ladder prefix: the probe must read +// the NEXT page too when the target prefix 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 + + // 100 hashes with prefix 0x11, then 50 with prefix 0x22: the page + // boundary at record pageRecords (113) falls inside the 0x22 range. + hashes := make([][32]byte, 0, 150) + for i := range uint32(100) { + hashes = append(hashes, prefixedHash(0x11, i)) + } + for i := range uint32(50) { + 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") + var tieKey [ladderKeyLen]byte + for i := range tieKey { + tieKey[i] = 0x22 + } + require.Equal(t, tieKey, v.runs[0].ladder[1], "page boundary must split the equal-prefix range") + + 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) + } + _, err = h.Get(prefixedHash(0x22, 200)) + require.ErrorIs(t, err, stores.ErrNotFound) +} + +// 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() +} + +// 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) +} diff --git a/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go b/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go index 2cf65354f..25384efed 100644 --- a/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go +++ b/cmd/stellar-rpc/internal/rpcv2/zstd/zstd.go @@ -76,9 +76,9 @@ type EncoderState struct { buf []byte } -// NewEncoderState returns a default-configured encoder state. -func NewEncoderState() *EncoderState { - return &EncoderState{comp: NewCompressor()} +// 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 @@ -97,6 +97,18 @@ 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. @@ -124,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))) 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/go.mod b/go.mod index aa066f5ba..c1dc911f4 100644 --- a/go.mod +++ b/go.mod @@ -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 From 3433a2dd6be34368e0782fb6c0b8249a5dd2dfd9 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Wed, 5 Aug 2026 15:42:11 +0000 Subject: [PATCH 06/21] events: store small terms as delta postings and query them un-materialized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit index.pack stored every term's postings as a roaring bitmap. Measured on a real pubnet chunk (8.65M events, 204,752 terms), per-term min(roaring, delta) is 22.23MB against 37.74MB for roaring alone, a 41% saving, and 71% of it sits below 1024 postings where roaring costs 1.7x to 3.8x a delta list. Above that, run and bitmap containers pull ahead, reaching 12.5x on the nine terms holding more than a million postings. So an index.pack item is now `fingerprint ‖ codec ‖ postings`, delta-varint at or below 1024 and roaring above. Storing them that way alone would have made queries slower: decoding a 1024-posting delta term into a bitmap costs 20.6us against 5.7us to unmarshal an equivalent roaring body. The saving only materializes if the planner stops materializing, so the second half of this change is events.Postings, which carries a term in whichever form the store already had it, and the set operations that work on either. - Reader.LookupKeys and HotIndex.Get return Postings instead of bitmaps. ConcurrentBitmaps.Get does too, which stops materializing its sparse entries: that state already holds an id list, and building a bitmap out of it on every lookup was pure loss. A dense term still hands back the snapshot #973 publishes for it, now wrapped as Postings. - events.Intersect drives from the smallest side whatever its form and probes the rest, since probing beats materialize-then-FastAnd by 4.3x at cardinality 4, 2.8x at 64 and 1.6x at 1024. - events.Union merges ascending lists rather than building a bitmap per input, and keeps the result a list. - Postings.ClipRange and Postings.SelectIDs replace the range bitmap and the bitmap drain. query.go no longer imports roaring. Both builders (in-memory WriteColdIndex and streaming WriteColdIndexFromRuns) share one encoder, so the byte-identity gate between them stays meaningful. One unbounded allocation goes away on the list path: selectEventIDs used to do make([]uint32, cardinality) whenever MaxEvents was 0, which is 34MB per query on a real chunk. Two defects found in review and fixed here rather than shipped: ClipRange and SelectIDs handed out subslices of the store's live posting list without clamping capacity, so a downstream append would have overwritten a posting other readers could still see, invisible to -race because it writes past a published length; and roaring accepts a run container holding no intervals, which reads back as a bitmap that reports itself non-empty with zero postings, so the cold decode now validates and rejects it. FORMAT: this changes the on-disk index.pack record layout. Split out of the "events index follow-ons" commit so it can be evaluated, cherry-picked or reverted independently of the bloom/fence and hardening work it shipped alongside — cold artifacts are permanent, so this is the piece that must be right in the first v2 release. Co-Authored-By: Claude Opus 5 --- .../internal/rpcv2/ingest/ingest_test.go | 22 +-- .../internal/rpcv2/query/event_page_test.go | 12 +- .../rpcv2/stores/event/cold_format.go | 30 +++ .../internal/rpcv2/stores/event/cold_index.go | 82 ++++---- .../rpcv2/stores/event/cold_index_stream.go | 47 ++--- .../stores/event/cold_index_stream_test.go | 19 ++ .../rpcv2/stores/event/cold_index_test.go | 144 ++++++++++++-- .../rpcv2/stores/event/cold_reader.go | 85 +++++--- .../rpcv2/stores/event/cold_reader_test.go | 70 +++---- .../rpcv2/stores/event/concurrent_bitmaps.go | 31 +-- .../stores/event/concurrent_bitmaps_test.go | 106 ++++++---- .../event/concurrent_bitmaps_writer_test.go | 86 ++++---- .../internal/rpcv2/stores/event/hot_store.go | 19 +- .../rpcv2/stores/event/hot_store_test.go | 16 +- .../rpcv2/stores/event/hot_warmup_test.go | 18 +- .../internal/rpcv2/stores/event/hotindex.go | 22 +-- .../rpcv2/stores/event/hotindex_test.go | 14 +- .../internal/rpcv2/stores/event/intersect.go | 175 ++++++++++++++++ .../rpcv2/stores/event/intersect_test.go | 186 ++++++++++++++++++ .../internal/rpcv2/stores/event/match.go | 127 +++++------- .../internal/rpcv2/stores/event/match_test.go | 27 ++- .../internal/rpcv2/stores/event/packedrow.go | 79 ++++++-- .../internal/rpcv2/stores/event/postings.go | 171 ++++++++++++++++ .../rpcv2/stores/event/postings_test.go | 169 ++++++++++++++++ .../internal/rpcv2/stores/event/reader.go | 45 +++-- .../rpcv2/stores/event/runspill/codec.go | 7 + .../rpcv2/stores/event/termsort_test.go | 11 +- .../rpcv2/stores/hotchunk/hotchunk_test.go | 12 +- 28 files changed, 1389 insertions(+), 443 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/intersect_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/postings.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/postings_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go index e3332b731..796af49bd 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go @@ -560,10 +560,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 +618,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 +664,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 +718,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))) @@ -1025,8 +1025,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,8 +1112,8 @@ 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 ───────────────────────── 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 dcf89c9f4..ce8491e4d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/event_page_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/event_page_test.go @@ -953,7 +953,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 +964,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 +1008,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 +1028,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/stores/event/cold_format.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go index 2e6311189..158f54369 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -89,6 +89,36 @@ const ( // zstd frames. const indexPackChecksum = packfile.ChecksumCRC32C +// index.pack item codecs — the codec byte in the layout cold_index.go +// documents. +const ( + // itemCodecRoaring bodies are a serialized roaring bitmap, RunOptimize'd + // before Bitmap.WriteTo. + itemCodecRoaring byte = 0x00 + // itemCodecDelta bodies are AppendPostings output: a uvarint count + // then the event IDs, first absolute and the rest strictly-positive + // deltas. + itemCodecDelta byte = 0x01 +) + +// deltaPostingMaxCardinality is the term cardinality at or below which +// postings are stored as a delta-varint list instead of a roaring bitmap. +// +// Measured on a real pubnet chunk (10,000 ledgers, 8.65M events, 204,752 +// terms): below ~1024 postings roaring costs 1.7x to 3.8x a delta list, and +// above it roaring's run and bitmap containers pull ahead, reaching 12.5x on +// the nine terms holding more than a million postings. 99.2% of terms fall +// below the threshold and hold 71% of the byte saving. +// +// Cardinality is a proxy for whichever encoding is actually smaller, not a +// derivation of it: a contiguous 1024-posting term is far smaller as a run +// container than as deltas, and a scattered 262K-posting term is smaller as +// deltas than as roaring. The proxy holds on pubnet event postings because +// cardinality correlates with run structure there, fat terms being the +// clustered ones. That is a property of the data, so re-measure before +// retuning. +const deltaPostingMaxCardinality = 1024 + // 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 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 1dff7a7e3..11bc7f817 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -9,7 +9,6 @@ package event // MPHF wrapper live in cold_format.go. import ( - "bytes" "context" "errors" "fmt" @@ -45,21 +44,13 @@ 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 builds its own bitmap for the terms that +// need one. // // 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 @@ -75,7 +66,8 @@ func ColdIndexSecret(catalogSecret []byte, chunkID chunk.ID) [stores.SecretLen]b // // offset size field // 0 4 fingerprint (first 4 bytes of the TermKey hash) -// 4 N serialized roaring bitmap (Bitmap.MarshalBinary) +// 4 1 codec (itemCodecRoaring | itemCodecDelta) +// 5 N postings in that codec // // The cold reader uses mphf.Lookup(term) → slot to find the record // position, packfile.Reader.ReadItem(slot, ...) to read the bytes, @@ -137,11 +129,11 @@ func WriteColdIndex( // the reader compares against the routed query key). var fp [IndexRecordFingerprintLen]byte copy(fp[:], rk[: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}) + body, berr := encodeIndexBody(nil, bitmap.ToArray()) + 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 }) @@ -195,27 +187,49 @@ 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 (codec ‖ postings). 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 codec byte, +// then the postings in that codec — 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 delta codec needs a +// sorted slice and the streaming builder has one, while a bitmap is cheap to +// build from ids for the terms that need one. +func encodeIndexBody(dst []byte, ids []uint32) ([]byte, error) { + if len(ids) == 0 { + // A zero-posting term has no reason to exist, and the delta codec + // cannot represent one: DecodePostings rejects a zero count, so + // writing it would produce a record no reader can decode. + return nil, errors.New("events: index body for a term with no postings") + } + if len(ids) <= deltaPostingMaxCardinality { + return AppendPostings(append(dst, itemCodecDelta), ids), nil + } + // RunOptimize re-encodes long runs of set bits as RUN containers, which + // MarshalBinary then serializes more compactly: ~14% on chunk 5999, + // concentrated in dense clustered terms. It is also what makes the + // serialization canonical. + bm := roaring.BitmapOf(ids...) + bm.RunOptimize() + b, err := bm.ToBytes() + if err != nil { + return nil, fmt.Errorf("events: serialize bitmap: %w", err) + } + return append(append(dst, itemCodecRoaring), b...), nil } // writeIndexPackEntries appends every assembled record to the index.pack // writer in slot order and finishes the 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) } } 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 index 10419e515..d4bbfeee4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -6,8 +6,9 @@ package event // 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 RunOptimize'd -// serialized bitmap (16B term ‖ uvarint len ‖ bitmap bytes), CRC-framed. +// 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, @@ -34,8 +35,6 @@ import ( "os" "path/filepath" - "github.com/RoaringBitmap/roaring/v2" - "github.com/stellar/streamhash" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" @@ -171,9 +170,9 @@ func buildSortedHash( } // writeTermsRun merges the spill runs into one terms.run scratch file and -// returns the unique-term count. Record: 16B term ‖ uvarint len ‖ -// RunOptimize'd serialized bitmap. File framing: magic ‖ u64 record count ‖ -// records ‖ CRC-32C(records) — the count doubles as streamhash's totalKeys. +// 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 { @@ -190,22 +189,18 @@ func writeTermsRun(path string, runPaths []string) (uint64, error) { var ( count uint64 crc uint32 - bm = roaring.New() - bmBuf bytes.Buffer + bodyBuf []byte recBuf []byte mergeED = func(rawTerm [16]byte, ids []uint32) error { term := TermKey(rawTerm) - bm.Clear() - bm.AddMany(ids) - bm.RunOptimize() // canonical serialization — the byte-identity keystone - bmBuf.Reset() - if _, werr := bm.WriteTo(&bmBuf); werr != nil { - return fmt.Errorf("events: serialize bitmap: %w", werr) + var berr error + if bodyBuf, berr = encodeIndexBody(bodyBuf[:0], ids); berr != nil { + return berr } recBuf = recBuf[:0] recBuf = append(recBuf, term[:]...) - recBuf = binary.AppendUvarint(recBuf, uint64(bmBuf.Len())) //nolint:gosec // Len is non-negative - recBuf = append(recBuf, bmBuf.Bytes()...) + recBuf = binary.AppendUvarint(recBuf, uint64(len(bodyBuf))) + recBuf = append(recBuf, bodyBuf...) crc = crc32.Update(crc, termsRunCRC, recBuf) count++ _, werr := w.Write(recBuf) @@ -260,10 +255,10 @@ func (c *crcFoldReader) ReadByte() (byte, error) { } // streamTermsRun replays terms.run, calling emit per record with the term -// and the serialized bitmap bytes (reused buffer). Integrity (CRC over all +// and the item body (reused buffer). Integrity (CRC over all // records) is verified before returning nil — both passes fully drain, so a // corrupt scratch can never produce artifacts. -func streamTermsRun(path string, emit func(term TermKey, bitmapBytes []byte) error) error { +func streamTermsRun(path string, emit func(term TermKey, body []byte) error) error { f, err := os.Open(path) if err != nil { return fmt.Errorf("events: open %s: %w", path, err) @@ -297,14 +292,14 @@ func streamTermsRun(path string, emit func(term TermKey, bitmapBytes []byte) err } crc = cbr.crc if length > 1<<31 { - return fmt.Errorf("%w: record %d bitmap length %d", errCorruptTermsRun, i, length) + 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 bitmap: %w", errCorruptTermsRun, i, err) + return fmt.Errorf("%w: record %d body: %w", errCorruptTermsRun, i, err) } crc = crc32.Update(crc, termsRunCRC, buf) if err := emit(term, buf); err != nil { @@ -326,7 +321,7 @@ func streamTermsRun(path string, emit func(term TermKey, bitmapBytes []byte) err type slotRecord struct { slot uint32 fp [IndexRecordFingerprintLen]byte - bm []byte // owned copy + body []byte // owned copy } type slotHeap []slotRecord @@ -357,14 +352,14 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { if !ok { // Push admits only slotRecord panic("events: slotHeap yielded a foreign type") } - if err := pw.AppendItem(rec.fp[:], rec.bm); err != nil { + if err := pw.AppendItem(rec.fp[:], rec.body); err != nil { return fmt.Errorf("events: write slot %d to index.pack: %w", rec.slot, err) } next++ } return nil } - err := streamTermsRun(termsRunPath, func(term TermKey, bitmapBytes []byte) error { + err := streamTermsRun(termsRunPath, func(term TermKey, body []byte) error { slot, lerr := m.LookupRouted(term) if lerr != nil { return fmt.Errorf("events: MPHF lookup during index.pack build: %w", lerr) @@ -374,7 +369,7 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { if slot == next { // Fast path: already in order — write through, then drain any // buffered successors. - if err := pw.AppendItem(fp[:], bitmapBytes); err != nil { + if err := pw.AppendItem(fp[:], body); err != nil { return fmt.Errorf("events: write slot %d to index.pack: %w", slot, err) } next++ @@ -384,7 +379,7 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { 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)", maxReorderEntries, slot) } - heap.Push(&h, slotRecord{slot: slot, fp: fp, bm: append([]byte(nil), bitmapBytes...)}) + heap.Push(&h, slotRecord{slot: slot, fp: fp, body: append([]byte(nil), body...)}) return nil }) if err != 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 index 23ddf724e..f9ef81e9e 100644 --- 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 @@ -2,6 +2,7 @@ package event import ( "context" + "fmt" "math/rand" "os" "path/filepath" @@ -60,6 +61,24 @@ func TestWriteColdIndexFromRuns_ByteIdentical(t *testing.T) { const chunkID = chunk.ID(3) corpus := synthTerms(2000, 42) + // Add terms sitting either side of deltaPostingMaxCardinality. The two + // builders pick a codec from cardinality independently — one from + // len(ids), one from a bitmap built out of the same ids — so the threshold + // is where they could disagree, and synthTerms alone never lands near it. + for i, card := range []int{ + deltaPostingMaxCardinality - 1, + deltaPostingMaxCardinality, + deltaPostingMaxCardinality + 1, + deltaPostingMaxCardinality + 2, + } { + k := ComputeTermKey(fmt.Appendf(nil, "straddle-%d", i), FieldTopic1) + ids := make([]uint32, card) + for j := range ids { + ids[j] = uint32(j * 3) + } + corpus[k] = ids + } + // Reference: today's in-memory mirror path. refDir := t.TempDir() mirror := NewBitmaps() 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 495f39950..2a33d7d7f 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" @@ -123,17 +124,15 @@ func TestWriteIndex_RoundTripsBitmapsPerTerm(t *testing.T) { 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 the ROUTED (blinded) key's prefix — the one - // identity both builders and the reader share. - assert.Equal(t, rk[: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 and dispatches on the + // codec byte. + 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) } @@ -310,9 +309,9 @@ func TestWriteIndex_LargeIndex(t *testing.T) { } func TestWriteIndex_RecordEncoding(t *testing.T) { - // Lock the on-disk record format: routed-key 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 || codec || postings. + // 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) @@ -329,10 +328,16 @@ func TestWriteIndex_RecordEncoding(t *testing.T) { rk := TermKey(stores.BlindKey(testIndexSecret, term[:])) assert.Equal(t, rk[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen]) - bm := roaring.New() - require.NoError(t, bm.UnmarshalBinary(record[IndexRecordFingerprintLen:])) - assert.Equal(t, uint64(1), bm.GetCardinality()) - assert.True(t, bm.Contains(42)) + // A single-posting term takes the delta codec, and its body is the codec + // byte plus AppendPostings output — no roaring container framing. + assert.Equal(t, itemCodecDelta, record[IndexRecordFingerprintLen], + "a 1-posting term must use the delta codec") + 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)) + assert.NotNil(t, post.IDs(), "a delta term must come back as an ID list") // Defensive: the fingerprint occupies bytes 0..3 in little-endian // the way TermKey itself encodes — read it back via binary helpers @@ -371,3 +376,108 @@ func TestWriteColdIndex_StampAndContentHash(t *testing.T) { assert.True(t, hashed, "index.pack carries a content hash") require.NoError(t, r.Verify(context.Background())) } + +// TestDeltaPostingThresholdPinned pins the threshold's value. Every other +// assertion names the constant symbolically, so they all hold at any value, +// including one that would silently cost 40% of the pack. +func TestDeltaPostingThresholdPinned(t *testing.T) { + require.Equal(t, 1024, deltaPostingMaxCardinality, + "retuning the threshold changes index.pack size materially; re-measure a real pubnet chunk first") +} + +// TestWriteIndex_CodecDispatch pins the cardinality dispatch at the threshold +// and round-trips both codecs through the real cold reader. +func TestWriteIndex_CodecDispatch(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) + atThreshold := term("at", deltaPostingMaxCardinality) + overThreshold := term("over", deltaPostingMaxCardinality+1) + + 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))) + + codecOf := func(k TermKey) byte { + slot, _, lerr := m.Lookup(k) + require.NoError(t, lerr) + return records[int(slot)][IndexRecordFingerprintLen] + } + assert.Equal(t, itemCodecDelta, codecOf(atThreshold), + "exactly deltaPostingMaxCardinality postings must still be delta") + assert.Equal(t, itemCodecRoaring, codecOf(overThreshold), + "one posting past the threshold must be roaring") + + // Both codecs must come back through the reader's dispatch with the right + // postings. (The full ColdReader path over a complete artifact set is + // covered by TestWriteColdIndexFromRuns_ReadsBack, whose small terms + // exercise the delta codec end to end.) + wantCard := []uint64{1, deltaPostingMaxCardinality, deltaPostingMaxCardinality + 1} + for i, k := range []TermKey{single, atThreshold, overThreshold} { + want := wantCard[i] + slot, rk, lerr := m.Lookup(k) + require.NoError(t, lerr) + post, derr := verifyAndDecodePostings(records[int(slot)], 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) + assert.Equal(t, want <= deltaPostingMaxCardinality, post.IDs() != nil, + "term %d form must follow its codec", i) + } + + // An unknown codec byte must fail loudly rather than decode as something. + bad := append([]byte(nil), records[0]...) + bad[IndexRecordFingerprintLen] = 0x7f + var key TermKey + copy(key[:], bad[:IndexRecordFingerprintLen]) + _, derr := verifyAndDecodePostings(bad, key, 0) + require.ErrorContains(t, derr, "unknown codec") +} + +// TestEncodeIndexBodyRejectsEmpty pins that the encoder refuses a term with no +// postings rather than writing a record the reader cannot decode: the delta +// codec's count is a uvarint and DecodePostings rejects zero. +func TestEncodeIndexBodyRejectsEmpty(t *testing.T) { + _, err := encodeIndexBody(nil, nil) + require.Error(t, err) + + body, err := encodeIndexBody(nil, []uint32{7}) + require.NoError(t, err) + ids, err := DecodePostings(body[1:]) + require.NoError(t, err, "one posting must round-trip") + require.Equal(t, []uint32{7}, ids) +} + +// TestVerifyAndDecodePostings_RejectsInvalidBitmap pins the trust boundary. +// roaring's UnmarshalBinary accepts a run container holding no intervals, which +// reads back as a bitmap with containers but no postings — a shape no producer +// makes, so it means the record is corrupt. It must be rejected here, naming +// the slot, rather than reaching a caller that assumes a present term holds at +// least one posting. +func TestVerifyAndDecodePostings_RejectsInvalidBitmap(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]...), itemCodecRoaring) + record = append(record, body...) + + _, derr := verifyAndDecodePostings(record, key, 7) + require.ErrorContains(t, derr, "invalid bitmap at slot 7") +} 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 ede942bde..e312880c3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go @@ -320,36 +320,60 @@ func (c *ColdReader) Offsets() (*LedgerOffsets, error) { return m.offsets, nil } -// verifyAndDeserializeBitmap checks the index.pack record's leading -// fingerprint against the ROUTED (blinded) key's prefix — the identity -// the builders fingerprint by — 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. +// +// A delta term is returned un-materialized (see Intersect). record is +// valid only inside ReadItems' callback, and neither codec aliases it, so the +// result outlives 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 - } - 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 Postings{}, nil + } + body := record[IndexRecordFingerprintLen:] + switch body[0] { + case itemCodecRoaring: + bm := roaring.New() + if err := bm.UnmarshalBinary(body[1:]); err != nil { + return Postings{}, fmt.Errorf("events: unmarshal bitmap at slot %d: %w", slot, err) + } + // UnmarshalBinary accepts shapes that are structurally invalid rather + // than merely unexpected, such as a run container holding no + // intervals, which reads back as a bitmap with containers but no + // postings. Reject them here, where the bytes are untrusted and the + // slot is known, rather than downstream where a caller assuming a + // present term holds at least one posting would fault. + if err := bm.Validate(); err != nil { + return Postings{}, fmt.Errorf("events: invalid bitmap at slot %d: %w", slot, err) + } + return BitmapPostings(bm), nil + case itemCodecDelta: + ids, err := DecodePostings(body[1:]) + if err != nil { + return Postings{}, fmt.Errorf("events: decode postings at slot %d: %w", slot, err) + } + return IDPostings(ids), nil + default: + return Postings{}, fmt.Errorf( + "events: index.pack record at slot %d has unknown codec 0x%02x", slot, body[0]) } - return 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 @@ -359,11 +383,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 } @@ -382,7 +406,7 @@ 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 @@ -394,7 +418,7 @@ func (c *ColdReader) LookupKeys(ctx context.Context, keys []TermKey) ([]*roaring 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) } @@ -422,16 +446,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, p.routed, 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..4f50a3f45 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 @@ -8,6 +8,7 @@ import ( "hash/crc32" "os" "path/filepath" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -161,30 +162,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 +253,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 +528,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. The fixture's term is small, so it comes back as an ID slice; +// mutating the slice is the mutation that could actually reach a shared buffer, +// whereas Bitmap() on an ID-backed term hands back a throwaway. +func TestColdReader_LookupKeysIsolatesCalls(t *testing.T) { const chunkID = chunk.ID(0) dir, payloads := buildColdFixture(t, chunkID, 8, 1) @@ -582,14 +561,15 @@ 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.NotNil(t, first[0].IDs(), "a small term must come back as an ID slice") + + want := slices.Clone(first[0].IDs()) + first[0].IDs()[0] = 999_999 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].IDs(), "a LookupKeys result mutation must not bleed into the next LookupKeys") } 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 30482ab82..01ded218d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/concurrent_bitmaps.go @@ -88,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. // -// Forbidden on the returned bitmap — these mutate internal state a +// 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 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) @@ -102,26 +108,27 @@ 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 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 a6e98e36d..d7e3c41b8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go @@ -10,7 +10,6 @@ import ( "path/filepath" "strings" - "github.com/RoaringBitmap/roaring/v2" "github.com/linxGnu/grocksdb" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" @@ -184,12 +183,10 @@ 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 -// -// 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 hot-index lookups: dense terms answer // from the in-memory overlay; sparse terms materialize from the @@ -197,7 +194,7 @@ func (h *HotStore) Offsets() (*LedgerOffsets, error) { // 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 } @@ -207,13 +204,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.hotIdx.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 } 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 ea1faba4a..e95762553 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 @@ -405,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. @@ -615,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) { 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 423376b30..6954124bf 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 @@ -24,10 +24,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.hotIdx.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()) @@ -60,10 +60,10 @@ func TestWarmup_RebuildsMirrorFromIngestedRows(t *testing.T) { } expected := make(map[TermKey]uint64) for _, k := range seededKeys { - bm, err := hot1.hotIdx.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()) @@ -72,10 +72,10 @@ func TestWarmup_RebuildsMirrorFromIngestedRows(t *testing.T) { got := make(map[TermKey]uint64) for k := range expected { - bm, err := hot2.hotIdx.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) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go index a12584f44..a71f98214 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -42,7 +42,6 @@ import ( "sync" "sync/atomic" - "github.com/RoaringBitmap/roaring/v2" "github.com/cespare/xxhash/v2" ) @@ -271,22 +270,19 @@ func buildWindowRow(rowBytes []byte, nTerms int) windowRow { return row } -// Get returns the term's bitmap (nil if absent): dense overlay fast path, -// else window+runs materialization. Concurrent-safe. -func (h *HotIndex) Get(term TermKey) (*roaring.Bitmap, error) { - if bm, err := h.overlay.Get(term); err != nil || bm != nil { - return bm, err +// 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 nil, err - } - if len(ids) == 0 { - return nil, nil //nolint:nilnil // absent term = nil bitmap, like the mirror + return Postings{}, err } - bm := roaring.New() - bm.AddMany(ids) - return bm, nil + return IDPostings(ids), nil } // decodeRecordIDs decodes ONE packed record's ID list. diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go index 334a842b8..825b1b36f 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -98,16 +98,16 @@ func (hh *hotIndexHarness) ledger(fire TermKey, mids []TermKey, eventsN int) { func (hh *hotIndexHarness) verifyAll() { hh.t.Helper() for k, want := range hh.ref { - bm, err := hh.h.Get(k) + post, err := hh.h.Get(k) require.NoError(hh.t, err) - require.NotNil(hh.t, bm, "term %x missing", k) - require.Equal(hh.t, want, bm.ToArray(), "term %x wrong ids", k) + require.True(hh.t, post.Present(), "term %x missing", k) + require.Equal(hh.t, want, post.Bitmap().ToArray(), "term %x wrong ids", k) } for range 20 { absent := hiKey(hh.rng) - bm, err := hh.h.Get(absent) + post, err := hh.h.Get(absent) require.NoError(hh.t, err) - assert.Nil(hh.t, bm, "absent term %x must miss", absent) + assert.False(hh.t, post.Present(), "absent term %x must miss", absent) } } @@ -290,8 +290,8 @@ func TestHotIndex_WriterReaderRace(t *testing.T) { return default: } - if bm, err := h.Get(fire); err == nil && bm != nil { - _ = bm.GetCardinality() // full read of a held snapshot + 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 } 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..ece07634a --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go @@ -0,0 +1,175 @@ +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. +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..b40351408 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 + return Postings{}, 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...) - } - - // ───── 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) @@ -473,8 +443,16 @@ func streamUnion( return } ids = ids[:0] - for it.HasNext() && len(ids) < batch { - ids = append(ids, it.Next()) + if descending { + for pos > 0 && len(ids) < batch { + pos-- + ids = append(ids, all[pos]) + } + } else { + for pos < len(all) && len(ids) < batch { + ids = append(ids, all[pos]) + pos++ + } } batch = rest if len(ids) == 0 { @@ -580,24 +558,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 8c7fdad61..f6d247d61 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) @@ -1172,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 ──────────────────────────────────────── diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go index 8527fcf68..be37d02f9 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go @@ -50,33 +50,14 @@ func AppendPackedRow(dst []byte, perKeyIDs map[TermKey][]uint32) []byte { // 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 - next := func() (uint64, error) { - v, n := binary.Uvarint(val) - if n <= 0 { - return 0, errors.New("bad event-id uvarint") - } - val = val[n:] - return v, nil - } 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]) - val = val[16:] - count, n := binary.Uvarint(val) - if n <= 0 { - return errors.New("events: packed row: bad id-count uvarint") - } - val = val[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(val)) { - return fmt.Errorf("events: packed row: id count %d exceeds %d remaining bytes", count, len(val)) - } var err error - if ids, err = DecodeAscendingIDs(next, count, ids[:0]); err != nil { + if ids, val, err = decodePostings(val[16:], ids); err != nil { return fmt.Errorf("events: packed row: %w", err) } add(term, ids) @@ -84,6 +65,59 @@ func DecodePackedRow(val []byte, add func(term TermKey, ids []uint32)) error { 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 +} + +// 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 @@ -124,6 +158,11 @@ func AppendTermPostings(dst []byte, term TermKey, ids []uint32) []byte { // 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, shared +// with the cold index's delta-codec records. +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). var DecodeAscendingIDs = runspill.DecodeAscendingIDs 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 index a09c45a2e..95848c755 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/codec.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/codec.go @@ -13,6 +13,13 @@ import ( // 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 { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go index a9084a20c..2a301e37f 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go @@ -465,9 +465,10 @@ func requireIndexesEquivalent( require.NoError(t, gerr) bmRuns, gerr := hRuns.Get(term) require.NoError(t, gerr) - require.NotNil(t, bmMap, "term %x missing from map-path index", term) - require.NotNil(t, bmRuns, "term %x missing from runs-path index", term) - assert.Equal(t, bmMap.ToArray(), bmRuns.ToArray(), "contents diverged for term %x", term) + 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) @@ -475,7 +476,7 @@ func requireIndexesEquivalent( require.NoError(t, gerr) bmRuns, gerr := hRuns.Get(absent) require.NoError(t, gerr) - assert.Nil(t, bmMap) - assert.Nil(t, bmRuns) + assert.False(t, bmMap.Present()) + assert.False(t, bmRuns.Present()) } } 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 945418d53..bb2397a77 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go @@ -161,10 +161,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. @@ -203,7 +203,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. @@ -333,8 +333,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 From df55cc5b01a5dafa5d4f4c1303ea28c1b2f30bbc Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Wed, 5 Aug 2026 15:42:31 +0000 Subject: [PATCH 07/21] events index follow-ons: one-pass bloom/fence construction and a hardening pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- events/SRT: one-pass bloom/fence construction at seal and merge Sealing a window and merging runs used to write the output file and then RE-READ it in full (openSealedRun) to build the bloom + fences, with that re-read also buffering a whole-run fingerprint slice (fps []uint64, ~264MB at terminal merges — the merge window's largest RSS transient). Routing state is now built incrementally in the same pass that writes the file (runRouting): fences take their offsets from RunWriter.Written() so they cannot drift from the bytes on disk, fingerprints stream straight into a bloom sized up front, and the post-write re-read is gone entirely. Boundary kept: openSealedRun's open-time drain-verify of PRE-EXISTING run files during warmup is byte-for-byte unchanged in behavior — it remains the crash-recovery trust anchor. Only freshly WRITTEN output is now trusted without read-back. Measured: unpaced 10k hot run, peak RSS 5.22GB -> 4.55GB; freeze --reuse-hot wall 6m01 -> 5m47.5, events arm 271.8s -> 257.6s. --- events,txhash,runspill: hardening pass — run lifecycle, failure paths, fence caps A post-review hardening batch over the sorted-run machinery: sealed-run fence spans gain a byte cap alongside the record cap (dense terms handed hash-adjacent lookups multi-MB preads); a sealed run is disposed of when the follow-on merge fails and when a failed manifest write cannot list it; the events run format tag moves EVR1 -> EVR2. Note the run-file tag bump is a HOT-tier format change only — sealed runs live in the hot chunk DB and are rebuilt from it, so the upgrade path is to discard the hot DB, unlike the cold index.pack change now split into its own commit beneath this one. Co-Authored-By: Claude Opus 5 --- .../rpcv2/stores/event/cold_freeze.go | 2 +- .../internal/rpcv2/stores/event/hotindex.go | 32 +- .../rpcv2/stores/event/hotindex_seal.go | 244 +++++++++++--- .../rpcv2/stores/event/hotindex_test.go | 303 +++++++++++++++++- .../rpcv2/stores/event/runspill/merge_test.go | 3 +- .../rpcv2/stores/event/runspill/runspill.go | 181 ++++++++--- .../stores/event/runspill/runspill_test.go | 153 +++++++-- .../rpcv2/stores/event/runspill/spiller.go | 4 +- .../rpcv2/stores/hotchunk/hotchunk.go | 65 ---- .../internal/rpcv2/stores/txhash/hotindex.go | 5 +- .../rpcv2/stores/txhash/hotindex_seal.go | 13 +- .../stores/txhash/txhash_hotindex_test.go | 33 +- scripts/probe/hog.py | 32 ++ scripts/probe/prober.py | 41 +++ scripts/probe/writer.py | 60 ++++ 15 files changed, 958 insertions(+), 213 deletions(-) create mode 100644 scripts/probe/hog.py create mode 100755 scripts/probe/prober.py create mode 100755 scripts/probe/writer.py diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go index ed6351378..9c60ae19b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -186,7 +186,7 @@ func freezeIndexRuns( // 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); werr != nil { + if _, werr := writeSortedRun(window, path, nil); werr != nil { return werr } runs = append(runs, path) diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go index a71f98214..b1c0d5b2b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -62,9 +62,17 @@ const ( densePromoteWindowCount = 32 // fenceEvery is the record granularity of a run's fence array: one - // (term, offset) fence per fenceEvery records bounds a probe's pread + - // decode span. + // (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 @@ -92,7 +100,8 @@ type windowRow struct { type sealedRun struct { path string bloom bloomFilter - fences []fence // sorted by term; one per fenceEvery records + final end sentinel + 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 } @@ -140,7 +149,10 @@ type HotIndex struct { closed sync.Once } -// sealResult is the background sealer's hand-back. +// 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 @@ -315,17 +327,11 @@ func (h *HotIndex) Close() { h.sealInFlight = false if res.err == nil { res.run.close() - for _, r := range res.obsolete { - r.close() - } + closeRuns(res.obsolete) } } - for _, r := range h.view.Load().runs { - r.close() - } - for _, r := range h.retired { - r.close() - } + closeRuns(h.view.Load().runs) + closeRuns(h.retired) h.retired = 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 index bebd6f597..c6eeb5a84 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go @@ -79,6 +79,12 @@ func (h *HotIndex) startSeal(rows []windowRow) { append(append([]*sealedRun{}, runsSnapshot...), res.run), filepath.Join(h.dir, fmt.Sprintf("merge-%06d.run", h.sealSeq))) 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 @@ -126,6 +132,11 @@ func (h *HotIndex) reapSeal(block bool) error { names[i] = filepath.Base(r.path) } if err := h.manifest.PutRuns(names, res.lastSeq); err != nil { + // The un-listed run is unpublished: dispose of it like any failed + // job's output. The obsolete inputs stay untouched — they are still + // live in the view and listed by the manifest. + res.run.close() + _ = os.Remove(res.run.path) return fmt.Errorf("events: hotindex manifest: %w", err) } h.view.Store(&hotIndexView{rows: old.rows[res.rows:], runs: runs}) @@ -141,8 +152,113 @@ func (h *HotIndex) reapSeal(block bool) error { return nil } -// sealWindow folds window rows into one run file and builds its in-RAM -// routing state. Runs on the background goroutine over immutable inputs. +// 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 newBloom'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 bloomFilter + fb fenceBuilder +} + +func newRunRouting(expectedTerms int) *runRouting { + return &runRouting{ + bloom: newBloom(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 +} + +// 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}) +} + +// sealWindow folds window rows into one run file, building its in-RAM +// routing state in the same pass as the write. Runs on the background +// goroutine over immutable inputs. func sealWindow(rows []windowRow, path string) (*sealedRun, error) { window := make(map[TermKey][]uint32, 1<<15) for i := range rows { @@ -152,17 +268,28 @@ func sealWindow(rows []windowRow, path string) (*sealedRun, error) { return nil, err } } - if err := writeSortedRun(window, path); err != nil { + rt := newRunRouting(len(window)) // exact: one record per window term + payloadLen, err := writeSortedRun(window, path, rt.observe) + if err != nil { + return nil, err + } + run, err := rt.open(path, payloadLen) + if err != nil { + _ = os.Remove(path) return nil, err } - return openSealedRun(path) + return run, nil } // writeSortedRun streams a folded (term → ids) window to path as one -// term-sorted run file — no whole-payload buffer. Shared by the seal and by -// the events freeze's window flushes, so the fold-and-stream shape has one -// implementation. -func writeSortedRun(window map[TermKey][]uint32, path string) error { +// term-sorted run file — no whole-payload buffer — and returns the payload +// length. observe, when non-nil, sees each record's term 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. +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) @@ -170,25 +297,33 @@ func writeSortedRun(window map[TermKey][]uint32, path string) error { slices.SortFunc(terms, func(a, b TermKey) int { return bytes.Compare(a[:], b[:]) }) rw, err := runspill.NewRunWriter(path) if err != nil { - return err + return 0, err } + defer rw.Discard() for _, t := range terms { + if observe != nil { + observe(t, rw.Written()) + } if err := rw.Append(t, window[t]); err != nil { - _ = rw.Close() - _ = os.Remove(path) - return err + return 0, err } } - return rw.Close() + 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. +// runspill.MergeRuns), returning it plus the now-obsolete inputs. 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) (*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. @@ -196,41 +331,47 @@ func mergeSealedRuns(runs []*sealedRun, path string) (*sealedRun, []*sealedRun, if err != nil { return nil, nil, err } - if err := runspill.MergeRuns(paths, rw.Append); err != nil { - _ = rw.Close() - _ = os.Remove(path) + defer rw.Discard() + 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 } - if err := rw.Close(); err != nil { + payloadLen := rw.Written() + if err := rw.Commit(); err != nil { return nil, nil, err } - merged, err := openSealedRun(path) + merged, err := rt.open(path, payloadLen) if err != nil { + // Committed but unopenable: discard the artifact rather than strand + // it until the next warmup's orphan sweep. + _ = os.Remove(path) return nil, nil, err } return merged, runs, nil } -// openSealedRun drains a run file (verifying its CRC), building the bloom + -// fences + open file handle for lookups. Used at seal, merge, and warmup — -// the routing state is always rebuilt from the verified file, never trusted -// from elsewhere. +// 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 } - var ( - nTerms int - fences []fence - off int64 - ) - // First pass metadata: term count for bloom sizing requires a drain, so - // collect fences+offsets in the same pass and blooms in a second cheap - // pass over the recorded fps. - var fps []uint64 + // 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 { - term, ids, nerr := r.Next() + off := r.Offset() + term, _, nerr := r.Next() if errors.Is(nerr, io.EOF) { break } @@ -238,26 +379,13 @@ func openSealedRun(path string) (*sealedRun, error) { _ = r.Close() return nil, nerr } - if nTerms%fenceEvery == 0 { - fences = append(fences, fence{term: term, off: off}) - } - off += int64(TermPostingsLen(ids)) - fps = append(fps, fp64(term)) - nTerms++ + rt.observe(term, off) } + end := r.Offset() if err := r.Close(); err != nil { return nil, err } - fences = append(fences, fence{off: off}) // end sentinel: off = payload length - bloom := newBloom(max(nTerms, 1)) - for _, fp := range fps { - bloom.add(fp) - } - f, err := os.Open(path) - if err != nil { - return nil, err - } - return &sealedRun{path: path, bloom: bloom, fences: fences, file: f}, nil + return rt.open(path, end) // end = payload length } // lookup probes the run for one term: bloom reject → fence window → pread → @@ -320,13 +448,18 @@ func OpenHotIndex(dir string, manifest manifestStore) (*HotIndex, uint32, error) } referenced := make(map[string]bool, len(names)) 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 { referenced[name] = true r, oerr := openSealedRun(filepath.Join(dir, name)) if oerr != nil { - for _, r2 := range runs { - r2.close() - } return nil, 0, fmt.Errorf("events: hotindex: manifest run %s: %w", name, oerr) } runs = append(runs, r) @@ -356,5 +489,12 @@ func OpenHotIndex(dir string, manifest manifestStore) (*HotIndex, uint32, error) } } h.sealSeq = maxSeq + 1 + 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 index 825b1b36f..4189489c1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -2,6 +2,8 @@ package event import ( "bytes" + "encoding/binary" + "errors" "math/rand" "os" "path/filepath" @@ -29,16 +31,21 @@ func runsFromMap(per map[TermKey][]uint32) termRuns { return runs } -// fakeManifest is an in-memory manifestStore. +// fakeManifest is an in-memory manifestStore. 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 @@ -102,6 +109,14 @@ func (hh *hotIndexHarness) verifyAll() { 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) @@ -305,3 +320,289 @@ func TestHotIndex_WriterReaderRace(t *testing.T) { 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's size (the header count feeds it at reopen). + require.Equal(t, written.fences, reopened.fences) + require.Equal(t, written.terms, reopened.terms) + require.Equal(t, written.bloom.mask, reopened.bloom.mask) + + 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(k) + require.NoError(t, lerr) + require.Equal(t, window[k], ids, "term %x", k) + } + miss, err := written.lookup(mkTerm(0x70, 7)) + 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_ManifestFailureDisposesRun pins reapSeal's manifest-failure +// branch: a run the manifest never listed is unpublished and must be closed +// and removed, while the obsolete inputs (still live in the view, still +// manifest-listed) stay open. +func TestHotIndex_ManifestFailureDisposesRun(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") + + err := h.reapSeal(true) + require.ErrorContains(t, err, "hotindex manifest") + require.ErrorIs(t, merged.file.Close(), os.ErrClosed, "un-listed run's handle must be closed") + _, serr := os.Stat(merged.path) + require.ErrorIs(t, serr, os.ErrNotExist, "un-listed run's file must be removed") + 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") +} 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 index 10f788b0b..8b390d3f9 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go @@ -20,7 +20,8 @@ func spillRun(t *testing.T, dir, name string, recs map[[16]byte][]uint32) string } } path := filepath.Join(dir, name) - require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + payload, records := slab.SortEncode(nil) + require.NoError(t, WriteRun(path, payload, records)) return path } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go index baba38e32..4522da0f5 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go @@ -29,13 +29,21 @@ import ( const RecordSize = 16 + 4 // runMagic heads every run file; a version bump changes the letter. -var runMagic = [4]byte{'E', 'V', 'R', '1'} //nolint:gochecknoglobals // fixed format tag +var runMagic = [4]byte{'E', 'V', 'R', '2'} //nolint:gochecknoglobals // fixed format tag -// HeaderLen is the run-file header size: magic (4) ‖ u64 payload length (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. -const HeaderLen = 12 +// 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. @@ -100,9 +108,10 @@ func (r *slabRecords) Swap(i, j int) { // SortEncode sorts the slab in place (unstable — duplicates are collapsed // anyway), dedups exact duplicate records, and encodes the result as -// packed-row postings into dst (reused across spills), returning it. IDs -// within a term come out ascending by construction of the composite order. -func (s *Slab) SortEncode(dst []byte) []byte { +// packed-row postings into dst (reused across spills), returning it plus the +// number of encoded records (WriteRun's header count). IDs within a term +// come out ascending by construction of the composite order. +func (s *Slab) SortEncode(dst []byte) ([]byte, uint64) { sort.Sort(&slabRecords{buf: s.buf}) n := s.Records() var ( @@ -110,10 +119,12 @@ func (s *Slab) SortEncode(dst []byte) []byte { ids []uint32 haveTerm bool prevRec []byte + encoded uint64 ) flush := func() { if haveTerm { dst = AppendTermPostings(dst, curTerm, ids) + encoded++ } } for i := range n { @@ -134,40 +145,47 @@ func (s *Slab) SortEncode(dst []byte) []byte { ids = append(ids, id) } flush() - return dst + return dst, encoded } -// WriteRun writes payload (a SortEncode result) to path as one run file: -// magic ‖ u64 payload length ‖ payload ‖ CRC-32C(payload). The file is -// written via a temp name and renamed, then synced — runs are scratch, but a -// half-written file must never be mistaken for a short valid one. -func WriteRun(path string, payload []byte) error { +// WriteRun writes payload (a SortEncode result of records records) to path as +// one run file: magic ‖ u64 payload length ‖ u64 record count ‖ payload ‖ +// CRC-32C(payload). The file is written via a temp name and renamed, then +// synced — runs are scratch, but a half-written file must never be mistaken +// for a short valid one. +func WriteRun(path string, payload []byte, records uint64) error { rw, err := NewRunWriter(path) if err != nil { return err } + defer rw.Discard() // One raw write through RunWriter's framing (incremental CRC, patched - // header, temp+rename in Close) — byte-identical to the historical - // whole-payload writer, with a single implementation of the container. + // header, temp+rename in Commit) — one implementation of the container. if werr := rw.writeRaw(payload); werr != nil { - _ = rw.f.Close() - _ = os.Remove(rw.path + ".tmp") return fmt.Errorf("runspill: write %s: %w", path, werr) } - return rw.Close() + rw.records = records + return rw.Commit() } // RunWriter streams records into a run file without buffering the payload: -// incremental CRC, header length patched at Close, temp+rename like WriteRun. -// The record-at-a-time transient replaces WriteRun's whole-payload buffer — -// the hot tier's late-chunk merges write ~GBs through here. +// incremental CRC, header patched at Commit, temp+rename like WriteRun. The +// record-at-a-time transient replaces WriteRun's whole-payload buffer — the +// hot tier's late-chunk merges write ~GBs through here. +// +// Two-phase lifecycle: nothing is visible at the final name until Commit; +// Discard (a no-op once either has run) removes the temp file, so call sites +// `defer rw.Discard()` and commit explicitly on success — abandonment is the +// default, publication the deliberate act. type RunWriter struct { path string f *os.File w *bufio.Writer crc uint32 written uint64 + records uint64 buf []byte + done bool } // NewRunWriter creates path (via a temp name) with a placeholder header. @@ -191,15 +209,23 @@ func NewRunWriter(path string) (*RunWriter, error) { // 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) } -// Close writes the trailer, patches the header's payload length, syncs, and -// renames into place. On error the temp file is removed. -func (rw *RunWriter) Close() error { +// 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 writes the trailer, patches the header's payload length and record +// count, syncs, and renames into place. On error the temp file is removed. +func (rw *RunWriter) Commit() error { fail := func(err error) error { - _ = rw.f.Close() - _ = os.Remove(rw.path + ".tmp") + rw.Discard() return err } var tr [4]byte @@ -210,9 +236,10 @@ func (rw *RunWriter) Close() error { if err := rw.w.Flush(); err != nil { return fail(fmt.Errorf("runspill: flush: %w", err)) } - var lenb [8]byte - binary.BigEndian.PutUint64(lenb[:], rw.written) - if _, err := rw.f.WriteAt(lenb[:], 4); err != nil { + 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 { @@ -222,12 +249,24 @@ func (rw *RunWriter) Close() error { return fail(err) } if err := os.Rename(rw.path+".tmp", rw.path); err != nil { - _ = os.Remove(rw.path + ".tmp") - return fmt.Errorf("runspill: rename %s: %w", rw.path, err) + return fail(fmt.Errorf("runspill: rename %s: %w", rw.path, err)) } + rw.done = true return nil } +// Discard closes the writer and removes its temp file — abandonment without +// publication. A no-op after Commit or a prior Discard, so it is safe to +// defer unconditionally. +func (rw *RunWriter) Discard() { + if rw.done { + return + } + rw.done = true + _ = rw.f.Close() + _ = os.Remove(rw.path + ".tmp") +} + // writeRaw sends pre-encoded payload bytes through the container accounting — // incremental CRC, payload-length tally, buffered write. The ONE site that // touches those fields, so Append and WriteRun cannot drift. @@ -244,31 +283,66 @@ func (rw *RunWriter) writeRaw(p []byte) error { // 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 - remain uint64 - crc uint32 - ids []uint32 - trailer bool + 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. +// 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 { - _ = f.Close() - return nil, fmt.Errorf("%w: %s: short header", ErrCorruptRun, path) + return fail("short header") } if !bytes.Equal(hdr[:4], runMagic[:]) { + 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("%w: %s: bad magic", ErrCorruptRun, path) + 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, remain: binary.BigEndian.Uint64(hdr[4:])}, nil + 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 @@ -277,10 +351,8 @@ func OpenRun(path string) (*RunReader, error) { func (r *RunReader) Next() ([16]byte, []uint32, error) { var term [16]byte if r.remain == 0 { - if !r.trailer { - if err := r.verifyTrailer(); err != nil { - return term, nil, err - } + if err := r.finish(); err != nil { + return term, nil, err } return term, nil, io.EOF } @@ -310,6 +382,7 @@ func (r *RunReader) Next() ([16]byte, []uint32, error) { return term, nil, err } r.ids = ids + r.read++ return term, r.ids, nil } @@ -354,8 +427,13 @@ func (c *crcByteReader) ReadByte() (byte, error) { return one[0], nil } -// verifyTrailer reads and checks the CRC after the payload is exhausted. -func (r *RunReader) verifyTrailer() error { +// 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) @@ -363,6 +441,9 @@ func (r *RunReader) verifyTrailer() error { 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 index 4cdb6791a..3b4e9c939 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go @@ -2,6 +2,7 @@ package runspill import ( "bytes" + "encoding/binary" "errors" "io" "math/rand" @@ -54,7 +55,8 @@ func TestSlab_SpillRoundTrip(t *testing.T) { } } path := filepath.Join(t.TempDir(), "00.run") - require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + payload, records := slab.SortEncode(nil) + require.NoError(t, WriteRun(path, payload, records)) assert.Equal(t, want, drain(t, path)) } @@ -74,7 +76,8 @@ func TestSlab_DedupsExactDuplicates(t *testing.T) { require.True(t, slab.Append(key(9), 5)) require.True(t, slab.Append(key(9), 6)) path := filepath.Join(t.TempDir(), "d.run") - require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + payload, records := slab.SortEncode(nil) + require.NoError(t, WriteRun(path, payload, records)) assert.Equal(t, map[[16]byte][]uint32{key(9): {5, 6}}, drain(t, path)) } @@ -85,7 +88,8 @@ func TestRunReader_DetectsCorruption(t *testing.T) { } dir := t.TempDir() path := filepath.Join(dir, "c.run") - require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + payload, records := slab.SortEncode(nil) + require.NoError(t, WriteRun(path, payload, records)) raw, err := os.ReadFile(path) require.NoError(t, err) @@ -116,24 +120,21 @@ func TestRunReader_DetectsCorruption(t *testing.T) { assert.True(t, sawErr, "corruption at byte %d must surface an error", pos) } - // Truncation must also fail. + // 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)) - r, err := OpenRun(p) - require.NoError(t, err) - 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, "truncated run must surface an error") + _, 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, so the old tag must be rejected, not parsed with new offsets. + 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") } func TestSlab_OutputIsTermSorted(t *testing.T) { @@ -142,7 +143,8 @@ func TestSlab_OutputIsTermSorted(t *testing.T) { require.True(t, slab.Append(key(byte(i)), uint32(i))) } path := filepath.Join(t.TempDir(), "s.run") - require.NoError(t, WriteRun(path, slab.SortEncode(nil))) + payload, records := slab.SortEncode(nil) + require.NoError(t, WriteRun(path, payload, records)) r, err := OpenRun(path) require.NoError(t, err) defer r.Close() @@ -161,3 +163,114 @@ func TestSlab_OutputIsTermSorted(t *testing.T) { first = false } } + +// TestRunHeader_RecordCount pins the header's record-count field: written by +// both writer paths, 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) +} + +// TestRunWriter_DiscardLeavesNothing pins the two-phase lifecycle: a +// discarded writer publishes nothing and leaves no temp file, and Discard +// after Commit must not touch the committed file. +func TestRunWriter_DiscardLeavesNothing(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})) + rw.Discard() + _, err = os.Stat(path) + require.ErrorIs(t, err, os.ErrNotExist, "discarded run must not be published") + _, err = os.Stat(path + ".tmp") + require.ErrorIs(t, err, os.ErrNotExist, "discarded run must not leave its temp file") + + 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.Discard() // deferred-Discard idiom: must be a no-op now + assert.Equal(t, map[[16]byte][]uint32{key(2): {4, 9}}, drain(t, committed)) +} + +// TestRunWriter_CommitFailureRemovesTemp forces Commit's rename step to fail +// (the final name is occupied by a directory) and requires the failed Commit +// to surface the error without leaving its temp file behind. +func TestRunWriter_CommitFailureRemovesTemp(t *testing.T) { + path := filepath.Join(t.TempDir(), "blocked.run") + rw, err := NewRunWriter(path) + require.NoError(t, err) + require.NoError(t, rw.Append(key(1), []uint32{1})) + require.NoError(t, os.Mkdir(path, 0o755)) // rename's destination is now a directory + require.Error(t, rw.Commit()) + _, serr := os.Stat(path + ".tmp") + require.ErrorIs(t, serr, os.ErrNotExist, "failed Commit must remove the temp file") +} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go index 6db7e8608..629045a3b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go @@ -106,8 +106,8 @@ func (s *Spiller) rotate() error { buf := s.encBuf[:0] s.pending = true go func() { - payload := full.SortEncode(buf) - err := WriteRun(path, payload) + payload, records := full.SortEncode(buf) + err := WriteRun(path, payload, records) s.encBuf = payload // safe: producer reads encBuf only after the s.inflight <- err // blocking receive below (channel happens-before) }() diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index ce3763a28..01053dc68 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" @@ -298,13 +296,6 @@ func (d *DB) FreezeEventsCold( return event.FreezeColdFromStore(ctx, d.chunkID, d.store, scratchDir, bucketDir, secret, opts) } -// Source streams the chunk's LCMs from the ledgers CF as a ledgerbackend.LedgerStream. -// The freeze no longer uses it (it copies CF frames verbatim); it remains the seam -// tests drive a hot DB's ledgers through. -func (d *DB) Source() ledgerbackend.LedgerStream { - return &hotLedgerStream{store: d.ledger} -} - // FreezeLedgersCold builds the chunk's cold ledger .pack at packPath by // copying THIS hot DB's ledgers-CF zstd frames verbatim (PreCompressed cold // writer) — no decompression. Valid on a read-only view; the DB must be @@ -607,59 +598,3 @@ func ledgerTxHashes(txParts []sdkingest.LedgerTxParts) [][32]byte { } return hashes } - -// 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++ - } - } -} diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go index 696021522..ced8cbc01 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go @@ -113,7 +113,10 @@ type HotIndex struct { closed sync.Once } -// sealResult is the background sealer's hand-back. +// 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, held structurally here since sealWindow is the only fallible +// step and returns no run on failure. type sealResult struct { run *sealedRun rows int // number of window rows the seal covered diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go index fbb47e705..f697a416d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go @@ -158,6 +158,10 @@ func (h *HotIndex) reapSeal(block bool) error { names[i] = filepath.Base(r.path) } if err := h.manifest.PutRuns(names, res.lastSeq); err != nil { + // The un-listed run is unpublished: dispose of it like any failed + // job's output. + res.run.close() + _ = os.Remove(res.run.path) return fmt.Errorf("txhash: hotindex manifest: %w", err) } h.view.Store(&hotIndexView{rows: old.rows[res.rows:], runs: runs}) @@ -407,7 +411,8 @@ func drainRun(f *os.File, path string, hdr runHeader) (*sealedRun, error) { } br := bufio.NewReaderSize(f, 128<<10) crc := crc64.New(crcRunTable) - fps := make([]uint64, 0, records) + // The validated header count sizes the bloom up front. + bloom := newBloom(max(records, 1)) ladder := make([][ladderKeyLen]byte, 0, records/pageRecords+1) var rec [runRecordLen]byte var prev [rowHashLen]byte @@ -427,16 +432,12 @@ func drainRun(f *os.File, path string, hdr runHeader) (*sealedRun, error) { if i%pageRecords == 0 { ladder = append(ladder, [ladderKeyLen]byte(rec[:ladderKeyLen])) } - fps = append(fps, fp64([rowHashLen]byte(rec[:rowHashLen]))) + bloom.add(fp64([rowHashLen]byte(rec[:rowHashLen]))) } if crc.Sum64() != hdr.crc { return nil, fmt.Errorf("txhash: run %s: payload crc mismatch (file %016x, computed %016x)", path, hdr.crc, crc.Sum64()) } - bloom := newBloom(max(len(fps), 1)) - for _, fp := range fps { - bloom.add(fp) - } return &sealedRun{ path: path, bloom: bloom, ladder: ladder, first: hdr.first, last: hdr.last, records: records, 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 index 5ae15f56d..36c762eac 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go @@ -2,6 +2,7 @@ package txhash import ( "encoding/binary" + "errors" "math/rand/v2" "os" "path/filepath" @@ -15,16 +16,21 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) -// fakeManifest is an in-memory manifestStore. +// fakeManifest is an in-memory manifestStore. 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 @@ -498,3 +504,28 @@ func TestRocksdbManifest_RoundTrip(t *testing.T) { assert.Empty(t, names) assert.Equal(t, uint32(7), lastSealed) } + +// TestHotIndex_ManifestFailureDisposesRun pins reapSeal's manifest-failure +// branch: a run the manifest never listed is unpublished and must be closed +// and removed — the twin of the events contract. +func TestHotIndex_ManifestFailureDisposesRun(t *testing.T) { + dir := t.TempDir() + m := &fakeManifest{} + h := testHotIndex(t, dir, m) + defer h.Close() + + row, err := EncodeRow([][32]byte{{1}, {2}}) + require.NoError(t, err) + run, err := sealWindow([]windowRow{{seq: 5, bytes: row}}, filepath.Join(dir, "seal-000009.run")) + require.NoError(t, err) + + h.pendingSeal <- sealResult{run: run, rows: 1, lastSeq: 5} + h.sealInFlight = true + m.putErr = errors.New("manifest unavailable") + + err = h.reapSeal(true) + require.ErrorContains(t, err, "hotindex manifest") + require.ErrorIs(t, run.file.Close(), os.ErrClosed, "un-listed run's handle must be closed") + _, serr := os.Stat(run.path) + require.ErrorIs(t, serr, os.ErrNotExist, "un-listed run's file must be removed") +} 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 From 6eba28bd44ca894ab99b2dcdf1ad736604bb7781 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Mon, 3 Aug 2026 23:49:42 +0000 Subject: [PATCH 08/21] =?UTF-8?q?storage=20layer:=20one=20protocol,=20one?= =?UTF-8?q?=20bloom,=20one=20writer,=20one=20heap,=20one=20reader=20?= =?UTF-8?q?=E2=80=94=20and=20a=20dirent-durability=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reviewed consolidation series over the storage layer, squashed here to one commit: every item was designed by an adversarially-verified panel, condition-reviewed at implementation, and the integrated result re-reviewed (zero correctness defects). Leads with a real fix: the events engine could crash with the manifest durably naming a run whose directory entry was never journaled. --- events,txhash: fsync the run dirent before the manifest names it The events seal published through runspill's tmp→rename with no directory fsync anywhere in the tree, while reapSeal's follow-on PutRuns rides a synced RocksDB write: a crash after the manifest commit could leave it durably naming a run whose dirent was never journaled — a loud, unrecoverable warmup failure on a hot chunk. txhash states the violated invariant verbatim (the manifest may only ever name a run whose existence survives a crash) and pays syncDir in writeRun; events now runs the same barrier. The barrier sits at the tails of sealWindow and mergeSealedRuns — before the hand-back can reach reapSeal — through an injectable HotIndex.fsyncDir defaulting to durable.FsyncDir. A barrier failure disposes the just-opened run like every other error branch there (sealResult: errors carry no resources). txhash's private syncDir becomes the same durable.FsyncDir; unlike syncDir it counts a vanished directory as success, tolerable in the seal path because the follow-on open fails loudly when the dir — and the run with it — is gone. The new ordering test records barrier and PutRuns events through the seam plus a recording manifest and pins barrier-before-publish across both publish shapes (plain seals and the merge fold); it fails against the barrier-less engine. Neither engine yet barriers the run directory's own dirent in its parent — this brings events to txhash parity, no further. --- events,txhash: extract the run-routing bloom into one package Both hot engines carried the same ~35-line bloom filter, byte-identical apart from one word of doc comment ("term" vs "hash" fingerprints), so the geometry every sealed-run probe rests on had two homes and no single place to tune. stores/bloom now holds it: a verbatim move of the type, the 7 probes, the ~10 bits/key power-of-two sizing and the double-hashed probe walk. Callers keep their own fp64 — the filter only ever sees a 64-bit fingerprint, so neither engine's key shape leaks into it — and the stores doc names the RAM-only tier the new package starts. Filters are never serialized, so bits/key and probe count carry no format compatibility; they are, however, a live RAM anchor (run blooms measured 241MB against the 120MB budgeted at stress-density term cardinality), which the package doc records beside the geometry so the next tune happens once for both engines. The package's own tests pin what the move must preserve: no false negatives, power-of-two sizing at 10 bits/key, and a deliberately loose false-positive bound that a collapsed probe walk fails (this shape measures 0.21%, a single probe 7.4%). The events fence test compared the written and reopened runs' bloom masks; with the field out of package it now compares the two filters whole, which is strictly stronger. --- txhash: reduce WriteColdBin to a loop over the .bin stream writer The .bin format had two serializers — WriteColdBin encoding a caller's whole slice, coldBinStream encoding the freeze's merge output entry by entry — whose byte-equality is the freeze's acceptance gate but was a property only a test kept confirming. WriteColdBin is now a loop over coldBinStream, so walk-path and freeze-path bytes are identical by construction and the entry ORDER the merge produces is all TestFreezeColdFromStore_ByteIdenticalToWalk still has to gate. The merged path keeps both writers' guarantees. The count is patched after the entries (no pre-count needed, and one Sync covers header and body), and finish's Close error is now wrapped like the explicit close check WriteColdBin documented: ENOSPC/EIO that surface only at fd close would otherwise let a completion record name a truncated .bin. The durability ladder and the no-tmp+rename artifact model each live in one place now, on finish and on the writer type, and the ladder's comment spells out why each of its four steps is ordered where it is — the flush-before-patch step in particular, since a chunk small enough to leave the placeholder header buffered would otherwise flush zeros back over the count. The per-append encode scratch moves onto the writer, because bufio can hand the slice through to the file and that escaped the local array — otherwise the walk path would have traded its one buffer for an allocation per entry at ~3M entries a chunk; the freeze sheds that allocation too. Measured over 100k entries: 7 allocations with the scratch on the writer, 100007 with it local. Fixtures written by the old and the merged code compare byte-identical at 0, 1, 2, 3, 4095, 4096, 4097, 5000 and 300000 entries and for a nil slice — the last of those past the 1MiB buffer, where the header has already reached the file before finish patches it. --- housekeeping: five dead-code deletions, one writer verb pair Five sites, each verified dead by grep across the tree: - BuildColdIndex's `opts ...streamhash.BuildOption` and the format-options-go-last precedence block that existed to constrain it. Every production caller (backfill/txindex.go, bench/txindex.go) passes none; the one caller that did was TestBuildColdIndex_CallerOptsCannotOverrideFormat, which handed in a bogus WithMetadata solely to pin the precedence property. Capability, test, and the doc sentence promising that caller opts override all go together — the builder now sees the same option sequence the empty-opts case always produced. - txhashCold.chunkID: written by the constructor, read by nothing. The constructor parameter goes with it (the bin path is already the caller's Layout derivation). - HotStore.overlay(), self-labelled "Test-only write hook", whose one caller is query_test.go and now reaches hotIdx.overlay directly. Its warning — a lookup against the overlay alone silently misses every sparse term — moves onto the field, where the next reader of the struct meets it instead of a deleted accessor. - dedupAscendingIDs: a one-line wrapper over slices.Compact with a single call site, which absorbs both the call and the reasoning. - ledgersInWindow, in BOTH engines plus termsort_test.go's mirror of the trigger. The seal trigger becomes len(rows) >= sealEvery over the slice ApplyRow/ApplyLedger already holds. The counter equalled len(view.rows) at every mutation site — NewHotIndex and OpenHotIndex publish a rows-empty view into a freshly built engine; each Apply paired one increment with one row append, after the reap that may have trimmed; each reapSeal paired `-= res.rows` with trimming exactly res.rows, and skipped both together when the manifest write failed — so a cross-file invariant maintained by hand in two packages becomes a structural one. sealResult.rows stays; only the counter dies. No byte-identity gate covers hot seal-run boundaries (all six guard freeze/cold/build paths), so that equality is the safety argument of record, with both engines' hotindex tests and TestApplyLedger_OverlayEquivalentToMapPath as the behavioural net. One asymmetry the counter's name hid, now stated per engine: txhash's row chain is dense (one row per ledger, empty rows included), while an eventless ledger writes no events index row at all — so the events cadence has always been in event-bearing ledgers. len(rows) is exactly what the counter counted in both. Writer verbs: event.ColdWriter.Finish(offsets) becomes Commit. It wraps packfile.Finish exactly as ledger.ColdWriter.Commit does, two sibling stores exposing one operation to the same coldChunk orchestrator under two names. The rename is compiler-gated; its blast radius is ingest/events.go, event/cold_freeze.go, cold_writer_test.go, cold_reader_test.go and query_test.go. txhash's coldBinStream finish/abort become commit/close for the same reason. runspill.Spiller.Finish stays Finish: it hands back the run paths for MergeRuns and publishes nothing. A note on the method says so, so a later uniformity pass leaves it alone. runspill.RunWriter also keeps Commit/Discard here — whether Discard becomes Close, which would override f3509d22's deliberate naming, belongs to the follow-on that settles it in one place rather than to a deletion pass. The two-phase pattern doc already lives on RunWriter; nothing is added to it, only cross-references from ledger.ColdWriter, event.ColdWriter and coldBinStream. Adjudicated and recorded rather than implemented: exporting events.MergeAscending to replace runspill's unionAscending general case is rejected. It is allocation-neutral (the general case already allocates fresh), and MergeRuns is not the cold path that would excuse the coupling in either direction — it sits on the freeze's dominant events arm and on the merge fold that runs concurrent with live ingest. What is left is -17 LOC of frozen two-pointer code bought with an exported cross-package aliasing contract, which is what 83a6ae47 ruled against. The two stay deliberate duplicates, each carrying one cross-reference to the other. The campaign handoff doc gains the pass's negative results — Cursor[K], a shared fence/ladder router, an events-seal k-way rewrite, a cross-family run container, and the union above — so they are not re-litigated; repoints its skip-note's nonexistent "review: simplify/reuse/efficiency pass" citation at aa599c12; and marks writer-side bloom/fence collection landed, which 747bcc37's one-pass runRouting did and 83a6ae47's revert note explicitly kept. --- events,txhash: extract the seal-publish runset into one package Both hot engines carried the record-blind half of the seal-publish protocol as hand-rolled twins that demonstrably version in lockstep: c0991ded fixed the identical dispose-on-failure bug in both engines in one commit, 7a67983b fixed exit-invariance in events only, and the manifestStore interfaces, orphan sweeps and Sscanf seq-resume loops were word-for-word copies. stores/internal/runset now owns exactly those pieces: - Manifest, the moved interface (each engine's rocksdbManifest and key stay put — the codec's "nothing to version in lockstep" ruling stands; c0991ded is evidence about the publish protocol, which never touched the codec bytes); - Publish, which builds the name list (live order then fresh, basenames) and makes the dispose of a run a failed PutRuns could not list structural — the twin branches both engines' reapSeal carried are deleted, so that bug class cannot be fixed in one engine and forgotten in the other again; - SweepOrphans and NextSealSeq, the warmup pieces that need no knowledge of what a run contains. Everything record- or view-aware stays per-engine, per the two-engines posture (txhash/hotindex.go): the open loops (txhash's dense-chain check included), the view swaps, reapSeal's trim/retired handling, and the fsyncDir barrier upstream of Publish. Folding those behind callbacks would relocate the code, not delete it. The twin ManifestFailureDisposesRun tests collapse into runset's unit tests (failure disposes fresh, live runs untouched). Manifest VALUES were covered by no gate — all six byte-identity gates guard cold artifacts — so each engine gains a golden-bytes test pinning the stored value (4B BE lastSealed ‖ csv basenames, live-then-fresh, and merged-only for events' replaceAll fold) through runset.Publish and the real rocksdbManifest; txhash's round-trip test and events' full warmup-through-store tests stay as the read-side pins. --- runspill: one write path at the final name — Commit publishes, Close abandons Two deletions leave runspill with a single way to produce a run. The temp name goes first. NewRunWriter creates the run under the name it will keep, and Commit is the ladder every other writer in the tree already runs: flush, patch the header's payload length and record count, fsync, close with the close error checked (coldBinStream.commit; txhash's writeRun). The temp name, the rename, and the two states a reader of this package had to hold — ".tmp is being written, the bare name is a run" — go with it. That was the tree's only os.Rename; there is now none. What makes it safe is that nothing anywhere trusts a run by NAME, the discipline ingest/doc.go already rules for cold artifacts and txhash's run header states for hot ones: the hot engines trust only the manifest; the manifest may name a run solely after its Commit and the dirent barrier that follows; warmup deletes every file the manifest does not list (runset.SweepOrphans) and resumes the seal sequence past every listed run (runset.NextSealSeq), so os.Create's truncation can never land on a live run. The cold build's runs live in a scratch dir wiped before its first write, a torn payload fails the drain's CRC, and a half-written file under a final name is garbage nothing reads — exactly what its .tmp form was. The stage order was load-bearing in one direction: this lands AFTER the dirent-barrier fix, or deleting the rename — previously the only thing that made a run's dirent appear at publish time — would have re-created the bug that commit fixed. Discard becomes Close, overriding f3509d22, which named it Discard for exactly these semantics. The census has moved since: the verb and the abandon semantics — release a writer whose Commit did not succeed, no-op after one that did, idempotent — are held by packfile.Writer.Close, ledger.ColdWriter.Close, event.ColdWriter.Close and coldBinStream.close. Three of those four also unlink the partial file, as RunWriter.Close does; the fourth leaves it because the artifact model makes it inert scratch. On the signature the census splits the other way — the three exported precedents return error, the one void precedent is unexported — so void RunWriter.Close is a deliberate departure, not parity: its only failure mode is an unlink a caller that has already failed can do nothing about, and the void shape lets both engines' `defer rw.Close()` stand without discarding a value. The bulk write path goes second (rung 0 of the reviewed deletion ladder for runspill's two redundancies; the terms.run container stays, gated on BOTH the freeze-wall events arm and the backfill chunk-build wall). WriteRun existed for exactly one production caller, Spiller.rotate, which paid for it twice: SortEncode materialized the whole encoded payload (~32MB at production slab size) before a single byte hit the file, and handing that buffer to the spill goroutine and back required a documented happens-before dance on a Spiller field. Every other run producer — the seal, the late-chunk merges, the freeze scan — already streams records through RunWriter.Append. Now the spiller does too: Slab.SortEncode becomes Slab.EmitSorted — the same in-place sort and dedup/group walk, each term to a callback — and Spiller.rotate's goroutine is NewRunWriter → EmitSorted(rw.Append) → Commit. WriteRun, the dst threading, the encBuf field and its hand-back invariant are deleted; the slab hand-off is the whole concurrency story. The per-term ids buffer is slab-owned, so its reuse rides the existing hand-off. Run bytes are unchanged on both counts: containers come out of the same writeRaw/Commit ladder, and the gates that consume runs read committed files by explicit path. The lifecycle tests pin the new exposures: CloseLeavesNothing (a run IS visible under its final name mid-write — visibility is not validity; no sidecar; abandonment leaves the dir empty; Close after Commit is inert) and CommitFailureRemovesTheFile (the run is written past the bufio capacity so real bytes already sit on disk under the final name, then the fd is closed underneath the writer — the error surfaces and nothing is left at that name, flushed bytes included). The round-trip suite is ported to the streaming API verbatim via a spillSlab helper; EmitSorted's contract (error aborts the walk, ids valid only until the next call) gets its own pins. --- txhash: sealed runs — routing built in the write pass, one streaming reader Two halves of one change to how TXHRUN01 files are produced and consumed; the second depends on the first. Write pass. Sealing a window used to write the run file and then RE-READ every byte it just produced (openSealedRun: CRC64, order, seq-range) purely to build the bloom + page ladder — a full second sequential pass per seal (~55MB at stress density, every 256 ledgers) on the background goroutine, contending with live ingest. Routing state is now built incrementally in the same pass that writes the file, through a runRouting funnel (bloom + ladder + record count, observe per record) fed by BOTH writeRunPayload's merge loop and drainRun's verify loop — one construction site, so ladder cadence and bloom geometry cannot drift between a freshly written run and its reopened form (the same single-funnel shape as events' fenceBuilder). The merge loop also cross-checks its emitted record count against the header's promise, standing in for the stat cross-check the deleted re-read used to run. This extends the events one-pass trust rule (c5e5fed4, owner-accepted; extension approved with this program) to the engine that is its declared simplification-clone. Boundary kept: warmup's drain-verify of PRE-EXISTING files is behaviorally unchanged — the crash-recovery trust anchor — and freshly written runs are trusted without read-back; if the post-write open fails, the durable file is left for warmup's orphan sweep (a failed seal publishes nothing). Reader. With the seal-time re-drain gone, TXHRUN01 is read in exactly two places — warmup and freeze — and those were two hand-synced verify loops running the identical ReadFull / CRC64 / hash-order / count discipline over the same 36-byte records. Except the freeze had drifted: it never checked a record's seq against the header's [first,last] promise, and its coarser chunk-range emit check cannot catch a seq that stays inside the chunk. TestFreezeAndWarmup_RejectSeqOutsideRunHeaderRange pins that exact gap (it fails against the pre-fold freeze) by rewriting one record's seq and re-patching the header CRC64. runSource moves to run_reader.go as the one streaming reader and gains the seq-range tripwire — the freeze becomes strictly stricter, and no valid input is rejected: writeRunPayload sets the header range from the window's first and last rows, so containment holds by construction. Warmup's openSealedRun becomes a fold over the same cursor, feeding each verified record through the write pass's runRouting funnel; the drained-and- verified fd is handed off (handoff nils the handle, close is nil-guarded, nothing may fail past the handoff) to serve the lookup preads — the verified fd IS the fd that serves reads, no double-open. drainRun's duplicated loop is deleted; readRunHeader and runRecordCount stay the shared open gates. Run-file bytes are untouched — the write loop's emission statements did not move, only observation was added beside the CRC — and routing is RAM-only derived state, bit-identical by construction; pinned by TestHotIndex_WritePassRoutingEqualsDrainRebuild on top of the existing gates (EquivalenceAcrossSeals, LadderBoundaryTie, warmup/corruption, FreezeColdFromStore byte-identity). Both reads stay off the ingest path; the freeze adds one two-compare check per record. The error substrings the gates match ("crc mismatch", "outside", the per-record index) are preserved. --- events,txhash: one cached-key merge heap off the hot loop Three of the tree's five heaps ran three different disciplines for the same job. txhash's seal (mergeHeap) and freeze (freezeHeap) were both INDEX heaps: the slice held source indices and every compare chased back through the source to re-derive its current key — heap[i]→rowidx→offs[row]→rows[row].bytes for the seal, two interface hash() calls per compare for the freeze — and both built themselves with an up()-per-insert the steady-state loop never uses again. events' slot reorder buffer went the other way entirely, through container/heap: five interface methods, an `any` box per buffered record, and two defensive panics for a foreign type that the only caller cannot produce. They collapse to one discipline, the cold build's (cold_merge.go): value entries carrying their own CACHED key, heapify once, replace-root forever. txhash's two entry types were already structurally identical — {hash, source index}, bytes.Compare then index — so they become ONE concrete same-package heap (merge_heap.go, hashEntry/hashHeap). The seal's per-row cursors move into writeRunPayload, where they are three lines beside the loop that uses them; mergeHeap and freezeHeap's up/ down/less bodies are deleted. events' slotHeap becomes a typed value heap (push/popMin/siftUp/siftDown), which deletes the container/heap import — the tree's only one — the boxing alloc per buffered record on the freeze-dominating events arm, and both unreachable panics. Emitted bytes are unchanged everywhere, and not by luck: (hash, source index) and (slot) are TOTAL orders — indices are unique per live source, dense MPHF slots are unique — so a replace-root min-heap's pop sequence is a pure function of its inputs. Heap layout, and therefore up()-per-insert versus heapify, cannot reach the output. The seal's duplicate contract was the one link with no direct gate: TestHotIndex_EquivalenceAcrossSeals asserts only that a duplicate resolves to SOME containing ledger (require.Contains is order-blind) and the run reader only checks hashes are non-decreasing, so intra-run duplicate ORDER was protected transitively, by RNG draws in the freeze fixture. TestWriteRun_CrossRowDuplicateGoldenBytes now pins every byte of a run built from one hash in three ledgers plus a tx-less ledger, against a by-hand emission order — it passes against the pre-change merge, and inverting the tie-break fails it (and TestFreezeColdFromStore_ByteIdenticalToWalk with it). Strictly less work on every touched path. The freeze's compare drops from two interface calls to zero; the seal's drops two pointer chases per compare side; neither had a benchmark to regress, and the seal never blocks live ingest (ApplyRow's reap is non-blocking, the trigger is skipped while a job is in flight). H1 (cold_merge.go, 30-36M keys/s) and events/runspill keep their own siftDown: their keys are uint64 pairs and a TermKey, and folding them in is gated on a benchstat of the bench `txindex` cell — a one-line note at each records that the two-discipline state is deliberate, so it is not "tidied up" without the measurement. --- events: the freeze consumes sealed runs + un-sealed tail, not the whole chunk The txhash freeze reads its engine's durable state — manifest-listed sealed runs plus the un-sealed CF tail — while the events freeze ignored its engine's sealed runs entirely and re-derived the WHOLE chunk: every IndexCF packed row re-read, re-folded, and re-encoded into 40-80 fresh scratch runs, ~2x the index bytes written and read back, on the 272s arm that dominates the ~6min freeze wall. The sealed prefix already exists on disk as union-merged, CRC-framed EVR2 runs, and warmup already codifies the trust rule: sealed rows' derived runs are the CRC-verified authority; packed rows replay from lastSealed+1. The freeze now anchors on that same rule (freezeIndexInputs): the manifest-listed runs feed WriteColdIndexFromRuns in place, strictly read-only, and freezeIndexRuns windows ONLY the tail — IterateRange from lastSealed+1, warmup's exact seek — into scratch runs. Both engines' freezes are now the same sentence: merge the engine's durable runs with its un-sealed tail. Deleted in the normal case: the whole-chunk IndexCF read, the ~GBs of scratch re-encode/re-read, and the "40-80 windowed runs per chunk" sizing concern (windows are tail-only; the 32MB cap now matters only for a crash-inherited backlog). Byte identity is structural, not incidental: per-term event IDs are strictly ascending and globally duplicate-free (an event lives in exactly one ledger row), so MergeRuns' per-term union is invariant across ANY partition of the rows into runs, and [sealed runs + tail] hold exactly the fold of all IndexCF rows. Crash states move the partition, never the content: an empty manifest degenerates to the full-tail scan (this same path with zero manifest runs — no fallback concept), and orphan runs — post-seal-pre-manifest, or a Close-discarded merge whose inputs the manifest still lists — are unlisted, ignored, and re-covered by the tail. Only reapSeal writes the manifest and the production freeze handle composes no events facade, so no sealer can race the freeze. No txhash merge_heap instantiation: the events freeze reuses runspill.MergeRuns through WriteColdIndexFromRuns unchanged. The two merges want different contracts — txhash emits VERBATIM duplicates in ledger order (hashHeap's source-index tie-break), events dedups terms and unions IDs — so feeding tail rows through the cached-key heap would have meant rebuilding union semantics beside the merge that already owns them. New permanent gates, the events mirror of txhash/cold_freeze_test.go: TestFreezeColdFromStore_ByteIdenticalToWalk (sealEvery=8 over 48 ledgers → 5 seals, a merge fold, and a non-empty tail, all asserted, against an independent walk oracle: payloads → TermsForBytes → Bitmaps → WriteColdIndex); _IdenticalAcrossCrashStates (mid-window/empty manifest, post-manifest, and a POISONED post-seal-pre-manifest orphan — union semantics would hide a faithful duplicate, so the orphan carries a marker term no correct freeze may emit); _IgnoresUnreapedMerge (post-merge-pre-reap: the manifest still lists the merge's three inputs, seal and merge orphans both on disk); _RejectsCorruptRunCRC (flipped CRC trailer → runspill.ErrCorruptRun); _EmptyChunk; _IdempotentReadOnlyInputs (freeze twice → identical bytes, hotindex-runs/ untouched name-for-name and byte-for-byte — no cleanup path can reach manifest-listed files). Gate teeth proven by perturbation before landing: dropping one manifest run from the inputs fails ByteIdenticalToWalk and IdenticalAcrossCrashStates; skipping the first tail row fails ByteIdenticalToWalk. The composition gate's fixture (ingest TestFreezeColdChunk_ ByteIdenticalToWalk) densifies from eventEvery=100 — 100 window rows, below the 256-row seal cadence, so it NEVER crossed the sealed frontier and the manifest-runs input shape had zero permanent gates (the design sketch misattributed an events TestFreezeColdFromStore_ByteIdenticalToWalk; until this commit that name lived only in txhash and ledger) — to eventEvery=10: 1,000 rows, several production-cadence seals, plus a manifest-non-empty tripwire (event.ManifestRuns over a bare re-open of the frozen DB) so the gate cannot silently regress to tail-only coverage. Freeze-side work strictly drops — one sequential pass over ≤8 manifest runs plus 1-3 tail runs replaces the full CF iterate, the windowed re-encode, and the scratch re-read — and the protected paths (hot ingest, both seal paths, H1/M1) are untouched. The named cells — the freeze-wall bench (272s events arm) and hot ingest_total p50/p99 — run at landing. --- events,txhash: review pass — one seal epilogue, one warmup idiom, a shared barrier-ordering pin The integrated review of this series (four dimensions, each finding adversarially verified) confirmed no correctness defect and a batch of quality items; this commit is that batch. Nothing changes an artifact byte; three previously uncovered branches gain gates. Seal epilogue (events). sealWindow and mergeSealedRuns hand-duplicated the post-Commit epilogue, so the "errors carry no resources" invariant was maintained at two sites. Both now call runRouting.openDurable, which puts the fsyncDir dirent barrier BEFORE the run is opened. A new TestHotIndex_BarrierFailureDisposesRun drives the h.fsyncDir seam for the plain-seal and merge-fold shapes; neither branch had any coverage (dropping the unlink from openDurable fails both subtests). Barrier seam and pin (txhash). runset's package doc lists "run file and dirent durable BEFORE the manifest names it" as invariant #1 of the protocol it owns, but the barrier itself is deliberately per-engine, and only events carried a seam and an ordering gate; txhash's barrier was a hard-coded durable.FsyncDir inside writeRun with no test pinning it ahead of PutRuns. txhash now mirrors the events seam: the barrier moves to the tail of startSeal's goroutine through a new HotIndex.fsyncDir field defaulting to durable.FsyncDir, with the same dispose-on-failure branch; writeRun's signature and callers stay untouched. The barrier stays on the background seal goroutine — deliberately NOT folded into runset.Publish, which runs on the live-ingest writer goroutine: that would put a synchronous fsync onto the exact path under the p99 campaign. The ordering gate itself is now shared: a runsettest package (PublishLog, RecordingManifest, AssertBarrierPrecedesEveryPut) hosts the scaffolding both engines' pins drive, the events test shrinks onto it with no assertion lost, and txhash gains the same pin (neutralizing the barrier fails it with "PutRuns #1 ran without a dirent barrier"). One deliberate asymmetry, stated where it lives: events barriers before its open (openDurable owns both steps), txhash after its fused write+open (sealWindow); the shared pin gates the invariant that matters — barrier before PutRuns — in both engines, and txhash's vanished-dir tolerance is argued from chunk teardown plus next-warmup loudness rather than a follow-on open. Warmup (txhash). openManifestRuns folds into OpenHotIndex with the exit-invariant `opened` defer events adopted in 7a67983b, appending each run before the dense-chain check so the defer covers the just-opened handle. That deletes four hand-placed closeRuns calls, the throwaway `closeRuns(append(runs, r))` slice, and a read of .last off an already-closed run. Close collapses to closeRuns(view.runs), so the engine has one cleanup idiom. Freeze. events' FreezeColdFromStore drops its `closed` flag for the unconditional deferred Close — packfile.Writer already owns that state and Close after Commit is a documented nil no-op. txhash's collectTailSources polls ctx per row: it was passing len(sources), the appended-source count, to pollCtx, so the cadence fired on every leading tx-less ledger and never between multiples. Test honesty. The TXHRUN01 golden test hardcodes the 40-byte header as literal bytes (a magic bump or relayout now fails it, and a bad-magic gate rides along) instead of deriving them from the writer's own constants. The freeze fixture asserts the merge fold it claims (manifest of 2 with a "merge-" name) and gains an overlay-promotion tripwire for the docstring's dense-ledger claim. Over-broad error substrings tighten to the exact messages ("entry seq ", "record 0 seq "). The run-name grammar shared by three engine Sprintf sites and runset's parser gets cross-reference comments and a round-trip pin (TestNextSealSeq_ParsesTheEnginesOwnNames). The events fakeManifest keeps putErr with a live setter again (ManifestFailureLeavesObsoleteRuns — asserting what runset's own tests cannot see: obsolete handles stay open, files stay on disk, nothing is retired); txhash's copy, which no test sets, loses the field. A dangling doc reference to the deleted WriteRun is reworded. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou --- .../internal/rpcv2/bench/txindex_test.go | 7 +- .../internal/rpcv2/ingest/events.go | 10 +- .../internal/rpcv2/ingest/freeze.go | 6 +- .../internal/rpcv2/ingest/freeze_test.go | 35 +- .../internal/rpcv2/ingest/ingest_test.go | 18 +- .../internal/rpcv2/ingest/ingester.go | 2 +- .../internal/rpcv2/ingest/txhash.go | 9 +- .../internal/rpcv2/stores/bloom/bloom.go | 61 ++ .../internal/rpcv2/stores/bloom/bloom_test.go | 69 +++ .../internal/rpcv2/stores/errors.go | 5 +- .../rpcv2/stores/event/cold_freeze.go | 192 +++++-- .../rpcv2/stores/event/cold_freeze_test.go | 389 +++++++++++++ .../rpcv2/stores/event/cold_index_stream.go | 72 ++- .../stores/event/cold_index_stream_test.go | 16 + .../rpcv2/stores/event/cold_reader_test.go | 6 +- .../rpcv2/stores/event/cold_writer.go | 32 +- .../rpcv2/stores/event/cold_writer_test.go | 34 +- .../internal/rpcv2/stores/event/hot_store.go | 30 +- .../internal/rpcv2/stores/event/hotindex.go | 78 +-- .../rpcv2/stores/event/hotindex_seal.go | 169 +++--- .../rpcv2/stores/event/hotindex_test.go | 156 ++++- .../internal/rpcv2/stores/event/intersect.go | 2 + .../internal/rpcv2/stores/event/match_test.go | 14 +- .../rpcv2/stores/event/runspill/merge.go | 11 + .../rpcv2/stores/event/runspill/merge_test.go | 3 +- .../rpcv2/stores/event/runspill/runspill.go | 137 +++-- .../stores/event/runspill/runspill_test.go | 123 +++- .../rpcv2/stores/event/runspill/spiller.go | 37 +- .../rpcv2/stores/event/termsort_test.go | 3 +- .../rpcv2/stores/hotchunk/hotchunk.go | 14 +- .../rpcv2/stores/internal/runset/runset.go | 111 ++++ .../stores/internal/runset/runset_test.go | 158 ++++++ .../internal/runset/runsettest/runsettest.go | 99 ++++ .../rpcv2/stores/ledger/cold_writer.go | 8 +- .../internal/rpcv2/stores/txhash/cold_bin.go | 137 ++--- .../rpcv2/stores/txhash/cold_freeze.go | 224 +++----- .../rpcv2/stores/txhash/cold_freeze_test.go | 57 +- .../rpcv2/stores/txhash/cold_index.go | 61 +- .../rpcv2/stores/txhash/cold_index_test.go | 29 - .../rpcv2/stores/txhash/cold_merge.go | 6 + .../internal/rpcv2/stores/txhash/hotindex.go | 59 +- .../rpcv2/stores/txhash/hotindex_seal.go | 534 +++++++----------- .../rpcv2/stores/txhash/merge_heap.go | 85 +++ .../rpcv2/stores/txhash/run_reader.go | 113 ++++ .../stores/txhash/txhash_hotindex_test.go | 222 +++++++- 45 files changed, 2566 insertions(+), 1077 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/bloom/bloom_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset_test.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runsettest/runsettest.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go index c02eb1c58..50bdb7b92 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go @@ -12,10 +12,15 @@ import ( "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" ) // writeFixtureBin writes chunk c's synthetic sorted .bin with n entries. +// 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} + func writeFixtureBin(t *testing.T, dir string, c chunk.ID, n int) { t.Helper() entries := make([]txhash.ColdEntry, n) @@ -32,7 +37,7 @@ func writeFixtureBin(t *testing.T, dir string, c chunk.ID, n int) { 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))), entries)) + 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 diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go index 048b6399f..06f503786 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go @@ -20,7 +20,7 @@ import ( // ───────────────────────── Cold writer ───────────────────────── // eventsCold models the backfill path: shared-walk output → payloads → -// term-index spill + cold append, then chunk-end Finish + the external +// 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 @@ -172,7 +172,7 @@ func (e *eventsCold) write(seq uint32, closedAt int64, txParts []sdkingest.Ledge return nil } -// finalize writes the events.pack trailer (Finish) + materializes the cold +// 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 @@ -187,8 +187,8 @@ 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 } @@ -199,7 +199,7 @@ func (e *eventsCold) finalize(ctx context.Context) error { return err } if err := event.WriteColdIndexFromRuns(ctx, e.chunkID, runs, e.scratchDir, e.bucketDir, e.secret); err != nil { - // Finish already committed events.pack; the index-less pack is left + // 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. diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go index 89dcd6f5e..007af64fd 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go @@ -4,9 +4,9 @@ package ingest // 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 by a sequential scan of the CF that already -// holds it in final form — ledgers as verbatim zstd frames, txhash as a -// pre-sorted entry stream, events by CF merge (the landed freeze-by-merge). +// 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. // diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go index 118e796f6..31cc331f4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go @@ -11,7 +11,9 @@ 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/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" ) @@ -20,16 +22,19 @@ import ( // 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 CFs (the zero-decompression path) — must produce -// byte-identical artifacts for all three kinds. This also retroactively -// gates the already-landed events freeze-by-merge against the walk build. +// 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 = 100 // every eventEvery-th ledger carries one tx + one contract event + 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++ { @@ -43,7 +48,8 @@ func TestFreezeColdChunk_ByteIdenticalToWalk(t *testing.T) { // Hot DB populated through the production ingest service (compression, // extraction, and CF layout exactly as a live chunk's). ctx := context.Background() - db, err := hotchunk.Open(t.TempDir(), chunkID, hotTestLogger(), hotchunk.DefaultTuning()) + dbPath := t.TempDir() + db, err := hotchunk.Open(dbPath, chunkID, hotTestLogger(), hotchunk.DefaultTuning()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) svc := NewHotService(db, nil, nil) @@ -95,6 +101,25 @@ func TestFreezeColdChunk_ByteIdenticalToWalk(t *testing.T) { 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 diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go index 796af49bd..3f23171dc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go @@ -518,7 +518,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()) }() @@ -1374,7 +1374,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()) }() @@ -1532,15 +1532,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() @@ -1563,7 +1563,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) @@ -1572,7 +1572,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 @@ -1807,7 +1807,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 dc25f7d20..0fe588b8a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingester.go @@ -68,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)) } diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go index 713d53981..cee921bd7 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go @@ -11,7 +11,6 @@ import ( 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" ) @@ -29,8 +28,7 @@ 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 + secret [stores.SecretLen]byte entries []txhash.ColdEntry metrics coldMetrics } @@ -41,9 +39,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 +48,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), } 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/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go index 9c60ae19b..a8d82f3dc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -1,23 +1,40 @@ package event // cold_freeze.go — freeze-by-merge: build a completed hot chunk's cold events -// artifacts DIRECTLY from its hot DB's column families instead of re-deriving -// them from raw ledgers. The hot tier already holds exactly what cold needs: +// 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 -// event.pack verbatim, in eventID order (the CF's BE-key order). +// events.pack verbatim, in eventID order (the CF's BE-key order). // - events_offsets IS the per-ledger count sequence → LedgerOffsets. -// - events_index packed rows are term-sorted runs → window-merge them into -// spill runs and finalize through WriteColdIndexFromRuns. +// - the term index IS the hot engine's published run set: the +// manifest-listed sealed runs (union-merged, CRC-framed EVR2 files beside +// the chunk DB, read in place) plus the un-sealed events_index tail past +// the sealed frontier, windowed into tail-only scratch runs. 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. // -// This deletes the freeze's ExtractLedgerEvents shaping, TermsForBytes -// hashing, and every per-term allocation from the window where the freeze -// runs BESIDE live ingestion — the design's freeze-synergy lens -// (~/bench-artifacts/cold-ingest-design.md). Artifacts remain byte-identical -// to the walk-derived build by construction of each input. +// 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 ( + "slices" "context" "encoding/binary" "fmt" @@ -28,6 +45,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" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" ) // replayOffsets rebuilds the chunk's ledger offsets from OffsetsCF through @@ -73,7 +91,7 @@ func copyDataCF(ctx context.Context, store *rocksdb.Store, w *ColdWriter) (uint6 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 event.pack positions must equal IDs. + // 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) } @@ -87,20 +105,24 @@ func copyDataCF(ctx context.Context, store *rocksdb.Store, w *ColdWriter) (uint6 } // freezeIndexWindowBytes caps how many packed-row bytes accumulate in the -// window map before it flushes as one spill run. ~32MB keeps freeze memory -// bounded (window + map overhead) while producing ~40-80 runs for a -// worst-case chunk — well inside the merge's comfort zone. +// 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 matters 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 -// intermediate spill runs and terms.run; it is wiped on entry and removed on -// success. opts tunes the event.pack writer exactly as the walk-driven -// build does. +// 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, so this path blinds them at its own -// run boundary — the IndexCF window map — keeping both builds keyed by the -// same blinded identity. +// blinds term keys before its spiller, so this path blinds at ITS run +// boundaries — the sealed runs are re-spilled under blinded keys (they serve +// raw-key hot lookups and cannot be blinded in place) and the tail's window +// map blinds at insert — keeping both builds keyed by one blinded identity. func FreezeColdFromStore( ctx context.Context, chunkID chunk.ID, @@ -119,18 +141,16 @@ func FreezeColdFromStore( return fmt.Errorf("events: mkdir freeze scratch %s: %w", scratchDir, err) } - // ── event.pack: verbatim DataCF copy in eventID order. ── + // ── events.pack: verbatim DataCF copy in eventID order. ── w, err := NewColdWriter(chunkID, bucketDir, opts) if err != nil { return err } - closed := false + // 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 !closed { - err2 := w.Close() - if err == nil { - err = err2 - } + if cerr := w.Close(); err == nil { + err = cerr } }() @@ -149,13 +169,12 @@ func FreezeColdFromStore( 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.Finish(offsets); err != nil { - return fmt.Errorf("events: freeze Finish: %w", err) + if err := w.Commit(offsets); err != nil { + return fmt.Errorf("events: freeze Commit: %w", err) } - closed = true - // ── index: window-merge IndexCF packed rows into spill runs. ── - runs, err := freezeIndexRuns(ctx, store, scratchDir, secret) + // ── index: the engine's durable sealed runs + the un-sealed tail. ── + runs, err := freezeIndexInputs(ctx, store, scratchDir, secret) if err != nil { return err } @@ -165,13 +184,104 @@ func FreezeColdFromStore( return os.RemoveAll(scratchDir) } -// freezeIndexRuns scans the IndexCF's per-ledger packed rows, unions them in -// a byte-bounded window map, and flushes each full window as one 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 duplicates union in the merge. -func freezeIndexRuns( +// freezeIndexInputs assembles the index merge's run list: the manifest-listed +// sealed runs (in place under the chunk DB's run dir — read-only inputs, +// never copied, moved, or deleted) followed by the tail-only 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, 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) + sealed := make([]string, 0, len(names)) + for _, name := range names { + sealed = append(sealed, filepath.Join(runDir, name)) + } + runs, err := respillBlinded(ctx, sealed, scratchDir, secret) + if err != nil { + return nil, err + } + tail, err := freezeIndexRuns(ctx, store, scratchDir, lastSealed, secret) + if err != nil { + return nil, err + } + return append(runs, tail...), nil +} + +// respillBlinded merges the manifest-listed sealed runs (raw term keys, read +// in place) and re-spills them as BLINDED scratch runs — the freeze's half of +// the ingest rule: cold artifacts are keyed by the blinded routing identity, +// while the hot tier's sealed runs serve raw-key lookups and cannot be +// blinded in place. One MergeRuns pass dedups terms across the sealed runs +// (per-term union, ascending — the merge's own contract), so each blinded +// term lands in the window map exactly once; the byte-bounded flush mirrors +// the tail scan's. +func respillBlinded( + ctx context.Context, sealed []string, scratchDir string, secret [stores.SecretLen]byte, +) ([]string, error) { + if len(sealed) == 0 { + return nil, nil + } + var ( + window = make(map[TermKey][]uint32, 1<<16) + windowBytes int + runs []string + ) + flush := func() error { + if len(window) == 0 { + return nil + } + path := filepath.Join(scratchDir, fmt.Sprintf("blind-%06d.run", len(runs))) + if _, werr := writeSortedRun(window, path, nil); werr != nil { + return werr + } + runs = append(runs, path) + clear(window) + windowBytes = 0 + return nil + } + emitted := 0 + if err := runspill.MergeRuns(sealed, func(term [16]byte, ids []uint32) error { + if emitted%256 == 0 { + if cerr := ctx.Err(); cerr != nil { + return cerr + } + } + emitted++ + // MergeRuns reuses the ids buffer across emits — copy before keeping. + window[TermKey(stores.BlindKey(secret, term[:]))] = slices.Clone(ids) + windowBytes += len(term) + 4*len(ids) + if windowBytes >= freezeIndexWindowBytes { + return flush() + } + return nil + }); err != nil { + return nil, fmt.Errorf("events: freeze re-key sealed runs: %w", err) + } + if err := flush(); err != nil { + return nil, err + } + return runs, 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, scratchDir string, lastSealed uint32, + secret [stores.SecretLen]byte, ) ([]string, error) { var ( window = make(map[TermKey][]uint32, 1<<16) @@ -194,7 +304,7 @@ func freezeIndexRuns( windowBytes = 0 return nil } - for entry, ierr := range store.Iterate(IndexCF, 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) } @@ -205,9 +315,7 @@ func freezeIndexRuns( return nil, fmt.Errorf("events: freeze %s key length %d (want %d)", IndexCF, len(entry.Key), packedIndexKeyLen) } if derr := DecodePackedRow(entry.Value, func(term TermKey, ids []uint32) { - // The run key is the BLINDED routing identity — the same rule the - // walk applies before its spiller. Blinding is injective, so the - // per-term grouping and ascending-ID invariants are unchanged. + // Blind at the run boundary — the ingest rule (see the freeze doc). bk := TermKey(stores.BlindKey(secret, term[:])) window[bk] = append(window[bk], ids...) }); derr != 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..fa1553d4e --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze_test.go @@ -0,0 +1,389 @@ +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_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") +} + +// 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_stream.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go index d4bbfeee4..b375e035f 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -23,7 +23,6 @@ package event import ( "bufio" "bytes" - "container/heap" "context" "encoding/binary" "errors" @@ -324,20 +323,64 @@ type slotRecord struct { body []byte // owned copy } +// 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) Len() int { return len(*h) } -func (h *slotHeap) Less(i, j int) bool { return (*h)[i].slot < (*h)[j].slot } -func (h *slotHeap) Swap(i, j int) { (*h)[i], (*h)[j] = (*h)[j], (*h)[i] } -func (h *slotHeap) Push(x any) { - rec, ok := x.(slotRecord) - if !ok { // heap.Push only ever receives slotRecord - panic("events: foreign type pushed into slotHeap") - } +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: a stale record lingering in the capacity tail + // would pin its body allocation until the heap regrows over it. + 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 + } } -func (h *slotHeap) Pop() any { old := *h; n := len(old); x := old[n-1]; *h = old[:n-1]; return x } -func (h *slotHeap) peek() slotRecord { return (*h)[0] } // 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. @@ -348,10 +391,7 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { ) flush := func() error { for len(h) > 0 && h.peek().slot == next { - rec, ok := heap.Pop(&h).(slotRecord) - if !ok { // Push admits only slotRecord - panic("events: slotHeap yielded a foreign type") - } + rec := h.popMin() if err := pw.AppendItem(rec.fp[:], rec.body); err != nil { return fmt.Errorf("events: write slot %d to index.pack: %w", rec.slot, err) } @@ -379,7 +419,7 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { 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)", maxReorderEntries, slot) } - heap.Push(&h, slotRecord{slot: slot, fp: fp, body: append([]byte(nil), body...)}) + h.push(slotRecord{slot: slot, fp: fp, body: append([]byte(nil), body...)}) return nil }) if err != 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 index f9ef81e9e..2ba6ee873 100644 --- 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 @@ -169,3 +169,19 @@ func TestWriteColdIndexFromRuns_ReadsBack(t *testing.T) { break // slot resolution exercised; full read path is covered by cold_reader tests } } + +// TestSlotHeapPopClearsVacatedRef pins popMin's tail clear: without it the +// vacated slot in the capacity tail keeps an already-emitted 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, body: []byte{2}}) + h.push(slotRecord{slot: 1, body: []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].body, "surviving record keeps its body") + tail := h[:cap(h)][len(h)] + require.Equal(t, slotRecord{}, tail, "vacated slot must be fully zeroed — a stale record would pin an emitted body") +} 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 4f50a3f45..81003a941 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 @@ -80,7 +80,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 } @@ -780,11 +780,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 ada3b52c4..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 @@ -135,13 +135,15 @@ func (w *ColdWriter) AppendMarshaled(b []byte) 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. +// 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) @@ -150,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/hot_store.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go index d7e3c41b8..e9badbdf0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go @@ -357,12 +357,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 @@ -524,6 +524,17 @@ func (m rocksdbManifest) GetRuns() ([]string, uint32, error) { return strings.Split(rest, ","), lastSealed, nil } +// 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() } @@ -576,13 +587,6 @@ func verifyChunkConsistency(chunkStore *rocksdb.Store, total uint32, indexUpperB return nil } -// overlay returns the hot index's dense-term overlay — a PARTIAL view -// holding only promoted (dense) terms; sparse terms live in the window rows -// and sealed runs and are absent here. Test-only write hook: no production -// path reads it, and no read path should — a lookup against the overlay -// alone silently misses every sparse term (use HotIndex.Get / LookupKeys). -func (h *HotStore) overlay() *ConcurrentBitmaps { return h.hotIdx.overlay } - // ────────────────────────────────────────────────────────────────── // Warmup — reconstructs the in-memory mirror + offsets from the // per-Chunk DB's on-disk CFs. Called by NewWithStore. diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go index b1c0d5b2b..8a6f57087 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -43,6 +43,10 @@ import ( "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/bloom" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" ) const ( @@ -99,7 +103,7 @@ type windowRow struct { // handle for concurrent ReadAt lookups. type sealedRun struct { path string - bloom bloomFilter + 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 @@ -114,20 +118,39 @@ type fence struct { // 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) + dir string // run-file directory (inside the chunk's DB dir) + // 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. - sealEvery int - maxRuns int - ledgersInWindow int - sealSeq int - pendingSeal chan sealResult // capacity 1: at most one seal in flight - sealInFlight bool - manifest manifestStore + // 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, @@ -162,20 +185,10 @@ type sealResult struct { err error } -// manifestStore persists which runs are live — the crash-recovery authority. -// Implemented over the chunk's RocksDB by the integration layer; faked in -// tests. -type manifestStore interface { - // PutRuns atomically replaces the live-run list and records the highest - // sealed ledger (warmup replays packed rows PAST it). - PutRuns(names []string, lastSealed uint32) error - // GetRuns returns the live-run list and the sealed frontier (zero when - // nothing sealed). - GetRuns() ([]string, uint32, error) -} - -// NewHotIndex creates the engine for a fresh chunk. dir is created. -func NewHotIndex(dir string, manifest manifestStore) (*HotIndex, 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) (*HotIndex, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("events: hotindex mkdir %s: %w", dir, err) } @@ -184,6 +197,7 @@ func NewHotIndex(dir string, manifest manifestStore) (*HotIndex, error) { overlay: NewConcurrentBitmapsFromBitmaps(Bitmaps{}), pendingSeal: make(chan sealResult, 1), manifest: manifest, + fsyncDir: durable.FsyncDir, sealEvery: windowLedgers, maxRuns: maxLiveRuns, } @@ -244,9 +258,8 @@ func (h *HotIndex) ApplyLedger(seq uint32, rowBytes []byte, runs termRuns) error rows = append(rows, old.rows...) rows = append(rows, row) h.view.Store(&hotIndexView{rows: rows, runs: old.runs}) - h.ledgersInWindow++ - if h.sealArmed && h.ledgersInWindow >= h.sealEvery && !h.sealInFlight { + if h.sealArmed && len(rows) >= h.sealEvery && !h.sealInFlight { h.startSeal(rows) } return nil @@ -306,13 +319,6 @@ func decodeRecordIDs(rec []byte) ([]uint32, error) { return out, err } -// dedupAscendingIDs dedups an ascending-with-possible-overlap ID list (a -// dense term's postings appear in both a run and the overlay path never hits -// here; window/run overlap cannot happen — but keep reads defensive). -func dedupAscendingIDs(ids []uint32) []uint32 { - return slices.Compact(ids) -} - // 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 @@ -366,5 +372,9 @@ func (h *HotIndex) lookupSparse(v *hotIndexView, term TermKey) ([]uint32, error) out = append(out, ids...) } } - return dedupAscendingIDs(out), nil + // 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 index c6eeb5a84..ac26210e8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go @@ -16,49 +16,11 @@ import ( "slices" "sort" + "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" ) -// ─────────────────────────── bloom filter ─────────────────────────── - -// bloomFilter is a plain double-hashed bloom over term fp64s: ~10 bits/key, -// 7 probes. Pointerless (one []uint64), GC-invisible. -type bloomFilter struct { - bits []uint64 - mask uint64 -} - -const bloomProbes = 7 - -// newBloom sizes for n keys at ~10 bits/key, rounded up to a power of two -// (mask-indexable). -func newBloom(n int) bloomFilter { - bits := 1 - for bits < n*10 { - bits <<= 1 - } - return bloomFilter{bits: make([]uint64, bits/64+1), mask: uint64(bits - 1)} //nolint:gosec // bits >= 1 -} - -func (b *bloomFilter) add(fp uint64) { - h2 := fp>>33 | fp<<31 | 1 // odd second hash - for i := range bloomProbes { - bit := (fp + uint64(i)*h2) & b.mask - b.bits[bit/64] |= 1 << (bit % 64) - } -} - -func (b *bloomFilter) mayContain(fp uint64) bool { - h2 := fp>>33 | fp<<31 | 1 - for i := range bloomProbes { - bit := (fp + uint64(i)*h2) & b.mask - if b.bits[bit/64]&(1<<(bit%64)) == 0 { - return false - } - } - return true -} - // ─────────────────────────── sealing ─────────────────────────── // startSeal kicks the background job: fold the given window rows into one @@ -68,16 +30,20 @@ func (b *bloomFilter) mayContain(fp uint64) bool { func (h *HotIndex) startSeal(rows []windowRow) { runsSnapshot := h.view.Load().runs 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) + res.run, res.err = sealWindow(rows, name, 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))) + 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: @@ -121,26 +87,19 @@ func (h *HotIndex) reapSeal(block bool) error { } old := h.view.Load() - var runs []*sealedRun + // A merge fold's run replaces ALL live runs; a plain seal joins them. + live := old.runs if res.replaceAll { - runs = []*sealedRun{res.run} - } else { - runs = append(append([]*sealedRun{}, old.runs...), res.run) + live = nil } - names := make([]string, len(runs)) - for i, r := range runs { - names[i] = filepath.Base(r.path) - } - if err := h.manifest.PutRuns(names, res.lastSeq); err != nil { - // The un-listed run is unpublished: dispose of it like any failed - // job's output. The obsolete inputs stay untouched — they are still - // live in the view and listed by the manifest. - res.run.close() - _ = os.Remove(res.run.path) + // 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}) - h.ledgersInWindow -= res.rows // 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 @@ -163,17 +122,17 @@ func (h *HotIndex) reapSeal(block bool) error { // 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 newBloom's power-of-two rounding absorbs (an +// 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 bloomFilter + bloom bloom.Filter fb fenceBuilder } func newRunRouting(expectedTerms int) *runRouting { return &runRouting{ - bloom: newBloom(max(expectedTerms, 1)), + 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. @@ -186,7 +145,7 @@ func newRunRouting(expectedTerms int) *runRouting { // pre-Append offset. func (rt *runRouting) observe(term TermKey, off int64) { rt.fb.observe(term, off) - rt.bloom.add(fp64(term)) + rt.bloom.Add(fp64(term)) } // open finalizes the routing state over the just-closed run file at path: @@ -205,6 +164,28 @@ func (rt *runRouting) open(path string, payloadLen int64) (*sealedRun, error) { }, 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 @@ -257,9 +238,10 @@ func (fb *fenceBuilder) finish(payloadLen int64) []fence { } // sealWindow folds window rows into one run file, building its in-RAM -// routing state in the same pass as the write. Runs on the background -// goroutine over immutable inputs. -func sealWindow(rows []windowRow, path string) (*sealedRun, error) { +// 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. +func sealWindow(rows []windowRow, path string, fsyncDir func(string) error) (*sealedRun, error) { window := make(map[TermKey][]uint32, 1<<15) for i := range rows { if err := DecodePackedRow(rows[i].bytes, func(term TermKey, ids []uint32) { @@ -273,12 +255,7 @@ func sealWindow(rows []windowRow, path string) (*sealedRun, error) { if err != nil { return nil, err } - run, err := rt.open(path, payloadLen) - if err != nil { - _ = os.Remove(path) - return nil, err - } - return run, nil + return rt.openDurable(path, payloadLen, fsyncDir) } // writeSortedRun streams a folded (term → ids) window to path as one @@ -299,7 +276,7 @@ func writeSortedRun( if err != nil { return 0, err } - defer rw.Discard() + defer rw.Close() for _, t := range terms { if observe != nil { observe(t, rw.Written()) @@ -312,11 +289,14 @@ func writeSortedRun( } // mergeSealedRuns merges live runs into one new run file (union semantics — -// runspill.MergeRuns), returning it plus the now-obsolete inputs. Routing +// 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) (*sealedRun, []*sealedRun, error) { +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 { @@ -331,7 +311,7 @@ func mergeSealedRuns(runs []*sealedRun, path string) (*sealedRun, []*sealedRun, if err != nil { return nil, nil, err } - defer rw.Discard() + defer rw.Close() emit := func(rawTerm [16]byte, ids []uint32) error { term := TermKey(rawTerm) rt.observe(term, rw.Written()) @@ -344,11 +324,8 @@ func mergeSealedRuns(runs []*sealedRun, path string) (*sealedRun, []*sealedRun, if err := rw.Commit(); err != nil { return nil, nil, err } - merged, err := rt.open(path, payloadLen) + merged, err := rt.openDurable(path, payloadLen, fsyncDir) if err != nil { - // Committed but unopenable: discard the artifact rather than strand - // it until the next warmup's orphan sweep. - _ = os.Remove(path) return nil, nil, err } return merged, runs, nil @@ -391,7 +368,7 @@ func openSealedRun(path string) (*sealedRun, error) { // lookup probes the run for one term: bloom reject → fence window → pread → // linear decode with full-term verification. Concurrent-safe (ReadAt). func (r *sealedRun) lookup(term TermKey) ([]uint32, error) { - if !r.bloom.mayContain(fp64(term)) { + if !r.bloom.MayContain(fp64(term)) { return nil, nil } // Last fence with fence.term <= term (fences[0].off == 0 always). @@ -428,6 +405,11 @@ func (r *sealedRun) 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 @@ -437,7 +419,7 @@ func (r *sealedRun) close() { // (ledgers past the sealed frontier) through ApplyLedger. 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 manifestStore) (*HotIndex, uint32, error) { +func OpenHotIndex(dir string, manifest runset.Manifest) (*HotIndex, uint32, error) { h, err := NewHotIndex(dir, manifest) if err != nil { return nil, 0, err @@ -446,7 +428,6 @@ func OpenHotIndex(dir string, manifest manifestStore) (*HotIndex, uint32, error) if err != nil { return nil, 0, fmt.Errorf("events: hotindex manifest read: %w", err) } - referenced := make(map[string]bool, len(names)) 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. @@ -457,38 +438,18 @@ func OpenHotIndex(dir string, manifest manifestStore) (*HotIndex, uint32, error) } }() for _, name := range names { - referenced[name] = true 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) } - // Orphan sweep: files present but unreferenced are garbage by definition - // (crash between rename and manifest, or between manifest and delete). - entries, err := os.ReadDir(dir) - if err != nil { + if err := runset.SweepOrphans(dir, names); err != nil { return nil, 0, err } - for _, e := range entries { - if !e.IsDir() && !referenced[e.Name()] { - _ = os.Remove(filepath.Join(dir, e.Name())) - } - } h.view.Store(&hotIndexView{runs: runs}) - // Resume the name sequence past every live run so a future seal can never - // overwrite one (numeric suffixes are monotone within each prefix). - maxSeq := 0 - for _, name := range names { - var n int - if _, serr := fmt.Sscanf(name, "seal-%06d.run", &n); serr == nil && n > maxSeq { - maxSeq = n - } - if _, serr := fmt.Sscanf(name, "merge-%06d.run", &n); serr == nil && n > maxSeq { - maxSeq = n - } - } - h.sealSeq = maxSeq + 1 + // Both run-name prefixes this engine writes (startSeal). + h.sealSeq = runset.NextSealSeq(names, "seal", "merge") opened = true return h, lastSealed, nil } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go index 4189489c1..52f9e0b05 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -13,6 +13,9 @@ import ( "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 @@ -31,8 +34,8 @@ func runsFromMap(per map[TermKey][]uint32) termRuns { return runs } -// fakeManifest is an in-memory manifestStore. putErr, when set, fails every -// PutRuns without recording anything. +// fakeManifest is an in-memory runset.Manifest. putErr, when set, fails +// every PutRuns without recording anything. type fakeManifest struct { mu sync.Mutex names []string @@ -131,7 +134,7 @@ func (hh *hotIndexHarness) settle() { require.NoError(hh.t, hh.h.reapSeal(true)) } -func testHotIndex(t *testing.T, dir string, m manifestStore) *HotIndex { +func testHotIndex(t *testing.T, dir string, m runset.Manifest) *HotIndex { h, err := NewHotIndex(dir, m) require.NoError(t, err) h.sealEvery = 8 // tiny window: many seals @@ -437,10 +440,11 @@ func TestRunFences_ByteCapAndIsolation(t *testing.T) { defer reopened.close() // The write pass and the warmup drain must route identically — fences, - // record count, and the bloom's size (the header count feeds it at reopen). + // 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.mask, reopened.bloom.mask) + require.Equal(t, written.bloom, reopened.bloom) recs, total := runExtents(window) require.Equal(t, payloadLen, total) @@ -581,11 +585,134 @@ func TestHotIndex_CloseClosesUnreapedSealHandbacks(t *testing.T) { require.ErrorIs(t, sealed.file.Close(), os.ErrClosed, "drained result's obsolete handles must be closed") } -// TestHotIndex_ManifestFailureDisposesRun pins reapSeal's manifest-failure -// branch: a run the manifest never listed is unpublished and must be closed -// and removed, while the obsolete inputs (still live in the view, still -// manifest-listed) stay open. -func TestHotIndex_ManifestFailureDisposesRun(t *testing.T) { +// 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) @@ -597,12 +724,9 @@ func TestHotIndex_ManifestFailureDisposesRun(t *testing.T) { h.sealInFlight = true m.putErr = errors.New("manifest unavailable") - err := h.reapSeal(true) - require.ErrorContains(t, err, "hotindex manifest") - require.ErrorIs(t, merged.file.Close(), os.ErrClosed, "un-listed run's handle must be closed") - _, serr := os.Stat(merged.path) - require.ErrorIs(t, serr, os.ErrNotExist, "un-listed run's file must be removed") + 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) + _, 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") } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go index ece07634a..a27898fff 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/intersect.go @@ -154,6 +154,8 @@ func Union(ps []Postings) Postings { // 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) { 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 f6d247d61..99f45c4eb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match_test.go @@ -571,11 +571,11 @@ func TestMatches_PostFilterRejectsTermHashCollision(t *testing.T) { 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 dense overlay (the overlay() hook). An overlay entry - // SHADOWS window+runs for its term, so it must be complete — the engine's + // 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.overlay().AddTo(gammaKey, 1, 4) + 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") @@ -1276,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{}) @@ -1622,7 +1622,7 @@ func TestMatches_DropsAreInvisible(t *testing.T) { gammaKey := ComputeTermKey(fx.t0cRaw, FieldTopic1) // An overlay entry holds the term's FULL id set, so the injection carries // the true id 1 alongside the false positives. - fx.store.overlay().AddTo(gammaKey, 1, 2, 3, 4) + 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} { @@ -1685,9 +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.overlay().AddTo(key, 0, 1, 4) + fx.store.hotIdx.overlay.AddTo(key, 0, 1, 4) for id := uint32(100); id < 200; id++ { - fx.store.overlay().AddTo(key, id) + fx.store.hotIdx.overlay.AddTo(key, id) } before := lookupOne(t, fx.store, key) require.GreaterOrEqual(t, before.GetCardinality(), uint64(100), diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go index cc7fa9740..27e3baa53 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge.go @@ -30,6 +30,12 @@ type mergeSource struct { } // 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 @@ -157,6 +163,11 @@ func advanceSource(s *mergeSource) (bool, error) { // (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...) 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 index 8b390d3f9..3aacfaa78 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/merge_test.go @@ -20,8 +20,7 @@ func spillRun(t *testing.T, dir, name string, recs map[[16]byte][]uint32) string } } path := filepath.Join(dir, name) - payload, records := slab.SortEncode(nil) - require.NoError(t, WriteRun(path, payload, records)) + spillSlab(t, slab, path) return path } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go index 4522da0f5..046fc0e39 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go @@ -59,6 +59,10 @@ var ErrCorruptRun = errors.New("runspill: corrupt run file") // 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. @@ -106,26 +110,27 @@ func (r *slabRecords) Swap(i, j int) { copy(b, r.scratch[:]) } -// SortEncode sorts the slab in place (unstable — duplicates are collapsed -// anyway), dedups exact duplicate records, and encodes the result as -// packed-row postings into dst (reused across spills), returning it plus the -// number of encoded records (WriteRun's header count). IDs within a term -// come out ascending by construction of the composite order. -func (s *Slab) SortEncode(dst []byte) ([]byte, uint64) { +// 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 - ids []uint32 haveTerm bool prevRec []byte - encoded uint64 ) - flush := func() { - if haveTerm { - dst = AppendTermPostings(dst, curTerm, ids) - encoded++ + 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] @@ -137,46 +142,40 @@ func (s *Slab) SortEncode(dst []byte) ([]byte, uint64) { copy(term[:], rec[:16]) id := binary.BigEndian.Uint32(rec[16:]) if !haveTerm || term != curTerm { - flush() + if err := flush(); err != nil { + return err + } curTerm = term haveTerm = true - ids = ids[:0] + s.ids = s.ids[:0] } - ids = append(ids, id) + s.ids = append(s.ids, id) } - flush() - return dst, encoded -} - -// WriteRun writes payload (a SortEncode result of records records) to path as -// one run file: magic ‖ u64 payload length ‖ u64 record count ‖ payload ‖ -// CRC-32C(payload). The file is written via a temp name and renamed, then -// synced — runs are scratch, but a half-written file must never be mistaken -// for a short valid one. -func WriteRun(path string, payload []byte, records uint64) error { - rw, err := NewRunWriter(path) - if err != nil { - return err - } - defer rw.Discard() - // One raw write through RunWriter's framing (incremental CRC, patched - // header, temp+rename in Commit) — one implementation of the container. - if werr := rw.writeRaw(payload); werr != nil { - return fmt.Errorf("runspill: write %s: %w", path, werr) - } - rw.records = records - return rw.Commit() + return flush() } // RunWriter streams records into a run file without buffering the payload: -// incremental CRC, header patched at Commit, temp+rename like WriteRun. The -// record-at-a-time transient replaces WriteRun's whole-payload buffer — the -// hot tier's late-chunk merges write ~GBs through here. +// 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: nothing is visible at the final name until Commit; -// Discard (a no-op once either has run) removes the temp file, so call sites -// `defer rw.Discard()` and commit explicitly on success — abandonment is the -// default, publication the deliberate act. +// 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 @@ -188,19 +187,24 @@ type RunWriter struct { done bool } -// NewRunWriter creates path (via a temp name) with a placeholder header. +// 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 + ".tmp") + f, err := os.Create(path) if err != nil { - return nil, fmt.Errorf("runspill: create %s.tmp: %w", path, err) + 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 + ".tmp") - return nil, fmt.Errorf("runspill: write header %s.tmp: %w", path, err) + _ = os.Remove(path) + return nil, fmt.Errorf("runspill: write header %s: %w", path, err) } return &RunWriter{path: path, f: f, w: w}, nil } @@ -221,11 +225,18 @@ func (rw *RunWriter) Written() int64 { return int64(rw.written) //nolint:gosec // payload length, far below 2^63 } -// Commit writes the trailer, patches the header's payload length and record -// count, syncs, and renames into place. On error the temp file is removed. +// 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.Discard() + rw.Close() return err } var tr [4]byte @@ -246,30 +257,32 @@ func (rw *RunWriter) Commit() error { return fail(fmt.Errorf("runspill: sync: %w", err)) } if err := rw.f.Close(); err != nil { - return fail(err) - } - if err := os.Rename(rw.path+".tmp", rw.path); err != nil { - return fail(fmt.Errorf("runspill: rename %s: %w", rw.path, err)) + return fail(fmt.Errorf("runspill: close %s: %w", rw.path, err)) } rw.done = true return nil } -// Discard closes the writer and removes its temp file — abandonment without -// publication. A no-op after Commit or a prior Discard, so it is safe to -// defer unconditionally. -func (rw *RunWriter) Discard() { +// 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 + ".tmp") + _ = 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, so Append and WriteRun cannot drift. +// 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)) 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 index 3b4e9c939..e8ef8416b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go @@ -20,6 +20,17 @@ func key(b byte) [16]byte { 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() @@ -55,8 +66,7 @@ func TestSlab_SpillRoundTrip(t *testing.T) { } } path := filepath.Join(t.TempDir(), "00.run") - payload, records := slab.SortEncode(nil) - require.NoError(t, WriteRun(path, payload, records)) + spillSlab(t, slab, path) assert.Equal(t, want, drain(t, path)) } @@ -76,11 +86,42 @@ func TestSlab_DedupsExactDuplicates(t *testing.T) { require.True(t, slab.Append(key(9), 5)) require.True(t, slab.Append(key(9), 6)) path := filepath.Join(t.TempDir(), "d.run") - payload, records := slab.SortEncode(nil) - require.NoError(t, WriteRun(path, payload, records)) + 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) { @@ -88,8 +129,7 @@ func TestRunReader_DetectsCorruption(t *testing.T) { } dir := t.TempDir() path := filepath.Join(dir, "c.run") - payload, records := slab.SortEncode(nil) - require.NoError(t, WriteRun(path, payload, records)) + spillSlab(t, slab, path) raw, err := os.ReadFile(path) require.NoError(t, err) @@ -143,8 +183,7 @@ func TestSlab_OutputIsTermSorted(t *testing.T) { require.True(t, slab.Append(key(byte(i)), uint32(i))) } path := filepath.Join(t.TempDir(), "s.run") - payload, records := slab.SortEncode(nil) - require.NoError(t, WriteRun(path, payload, records)) + spillSlab(t, slab, path) r, err := OpenRun(path) require.NoError(t, err) defer r.Close() @@ -164,9 +203,9 @@ func TestSlab_OutputIsTermSorted(t *testing.T) { } } -// TestRunHeader_RecordCount pins the header's record-count field: written by -// both writer paths, exposed pre-drain, bounded at open, and cross-checked -// against the actual drain at EOF. +// 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) @@ -236,41 +275,69 @@ func TestRunHeader_RecordCount(t *testing.T) { require.ErrorIs(t, err, ErrCorruptRun) } -// TestRunWriter_DiscardLeavesNothing pins the two-phase lifecycle: a -// discarded writer publishes nothing and leaves no temp file, and Discard -// after Commit must not touch the committed file. -func TestRunWriter_DiscardLeavesNothing(t *testing.T) { +// 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})) - rw.Discard() + 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, "discarded run must not be published") - _, err = os.Stat(path + ".tmp") - require.ErrorIs(t, err, os.ErrNotExist, "discarded run must not leave its temp file") + 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.Discard() // deferred-Discard idiom: must be a no-op now + 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_CommitFailureRemovesTemp forces Commit's rename step to fail -// (the final name is occupied by a directory) and requires the failed Commit -// to surface the error without leaving its temp file behind. -func TestRunWriter_CommitFailureRemovesTemp(t *testing.T) { +// 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) - require.NoError(t, rw.Append(key(1), []uint32{1})) - require.NoError(t, os.Mkdir(path, 0o755)) // rename's destination is now a directory + // 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 + ".tmp") - require.ErrorIs(t, serr, os.ErrNotExist, "failed Commit must remove the temp file") + _, 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 index 629045a3b..7b179b328 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/spiller.go @@ -6,7 +6,7 @@ import ( "path/filepath" ) -// Spiller is the double-buffered producer facade over Slab + WriteRun: the +// 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 @@ -20,9 +20,8 @@ type Spiller struct { dir string active *Slab spare *Slab - encBuf []byte // reused SortEncode destination, handed between spills inflight chan error - pending bool // a spill goroutine is running; encBuf/spare are its + pending bool // a spill goroutine is running; the spare slab is its runs []string failed error } @@ -68,6 +67,9 @@ func (s *Spiller) Add(term [16]byte, id uint32) error { // 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 @@ -91,7 +93,9 @@ func (s *Spiller) Cleanup() error { } // rotate waits for any in-flight spill, swaps slabs, and kicks the full one -// off to the background spill goroutine. +// 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 @@ -103,21 +107,32 @@ func (s *Spiller) rotate() error { path := filepath.Join(s.dir, fmt.Sprintf("%06d.run", len(s.runs))) s.runs = append(s.runs, path) - buf := s.encBuf[:0] s.pending = true go func() { - payload, records := full.SortEncode(buf) - err := WriteRun(path, payload, records) - s.encBuf = payload // safe: producer reads encBuf only after the - s.inflight <- err // blocking receive below (channel happens-before) + 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 makes the -// goroutine's encBuf hand-back visible to the producer. +// 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 { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go index 2a301e37f..ac5416f26 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go @@ -372,8 +372,7 @@ func applyLedgerFromMap(h *HotIndex, seq uint32, rowBytes []byte, per map[TermKe rows = append(rows, old.rows...) rows = append(rows, row) h.view.Store(&hotIndexView{rows: rows, runs: old.runs}) - h.ledgersInWindow++ - if h.sealArmed && h.ledgersInWindow >= h.sealEvery && !h.sealInFlight { + if h.sealArmed && len(rows) >= h.sealEvery && !h.sealInFlight { h.startSeal(rows) } return nil diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index 01053dc68..dcdaf1730 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -283,12 +283,14 @@ func (d *DB) Events() *event.HotStore { } // FreezeEventsCold builds the chunk's three cold events artifacts in -// bucketDir directly from THIS hot DB's events CFs — freeze-by-merge: the -// data CF's values are the canonical marshaled payloads, the offsets CF is -// the ledger-count sequence, and the packed index rows are term-sorted runs, -// so no ledger re-extraction (and no per-term memory) is needed. 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). +// 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, no per-term memory. +// 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, 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..a393a8102 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset.go @@ -0,0 +1,111 @@ +// 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. +// +// 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/ledger/cold_writer.go b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go index 357090b3a..f772f53c1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/ledger/cold_writer.go @@ -75,9 +75,11 @@ type ColdWriterOptions struct { } // 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() 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 677be4084..5b696de48 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go @@ -96,67 +96,26 @@ 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). -// -// 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. +// WriteColdBin writes the .bin file at path from entries the caller already +// holds whole — the walk path's finalize accumulator, which exists for its +// sort anyway. It is a loop over the same coldBinStream the freeze drives, so +// walk-path and freeze-path bytes are identical by construction; see that +// type for the create semantics and commit for the durability ladder. // // entries must already be sorted (lex by Key, non-decreasing); this function // writes them verbatim. -// -// 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() - } - }() - - 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) + w, err := newColdBinStream(path, secret) + if err != nil { + return err } - 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) + 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 } } - if ferr := bw.Flush(); ferr != nil { - return fmt.Errorf("txhash: flush: %w", ferr) - } - if serr := f.Sync(); serr != nil { - return fmt.Errorf("txhash: sync %s: %w", path, serr) - } - closed = true - if clerr := f.Close(); clerr != nil { - return fmt.Errorf("txhash: close %s: %w", path, clerr) - } - return nil + return w.commit() } // coldBinCount validates a .bin file's byte size against its declared header @@ -179,17 +138,32 @@ func coldBinCount(path string, size int64, count uint64) (uint64, error) { return count, nil } -// coldBinStream writes a .bin incrementally: header with a placeholder -// count (the secret is written up front — it is known before the first -// entry), streamed entries, then finish patches the leading count and syncs -// — byte-identical to WriteColdBin's output without holding a chunk's -// entries in memory. The freeze is its user; the walk path keeps the -// slice-based writer (its accumulator exists anyway for the finalize sort). +// 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 - count uint64 - done bool + 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) { @@ -199,7 +173,7 @@ func newColdBinStream(path string, secret [stores.SecretLen]byte) (*coldBinStrea } bw := bufio.NewWriterSize(f, 1<<20) // Prelude and secret are final at create time; only the count is patched - // in finish, once the entries have been streamed. + // in commit, once the entries have been streamed. var header [coldBinHeaderSize]byte copy(header[:4], coldBinMagic) header[4] = coldBinVersion @@ -212,19 +186,27 @@ func newColdBinStream(path string, secret [stores.SecretLen]byte) (*coldBinStrea } func (w *coldBinStream) append(e ColdEntry) error { - var buf [coldBinEntrySize]byte - copy(buf[:ColdKeySize], e.Key[:]) - binary.LittleEndian.PutUint32(buf[ColdKeySize:], e.Seq) - if _, err := w.bw.Write(buf[:]); err != nil { + 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 } -// finish flushes, patches the header count, syncs, and closes — the same -// durability ladder as WriteColdBin (sync before close, close error checked). -func (w *coldBinStream) finish() error { +// 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) } @@ -239,14 +221,17 @@ func (w *coldBinStream) finish() error { return fmt.Errorf("txhash: sync %s: %w", w.f.Name(), err) } // done only once Close consumes the fd — an earlier error must leave - // abort() responsible for releasing it. + // close() responsible for releasing it. w.done = true - return w.f.Close() + if err := w.f.Close(); err != nil { + return fmt.Errorf("txhash: close %s: %w", w.f.Name(), err) + } + return nil } -// abort releases the file on the error path; a no-op after finish. The +// 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) abort() { +func (w *coldBinStream) close() { if !w.done { _ = w.f.Close() } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go index e2b626588..cbcb32bd5 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -26,15 +26,10 @@ package txhash // intra-ledger duplicate was rejected at write time and cannot reach here. import ( - "slices" - "bufio" "bytes" + "slices" "context" - "encoding/binary" "fmt" - "hash" - "hash/crc64" - "io" "os" "path/filepath" @@ -50,13 +45,19 @@ 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 source merge yields entries in -// RAW-key order; each key is blinded at emit (stores.BlindKey — the walk -// writer's rule), which destroys that order, so the blinded entries are -// collected and re-sorted before the write: the .bin's contract is lex order -// BY BLINDED KEY. The accumulator is the same size class the walk path -// already carries for its finalize sort; the bytes are identical to -// WriteColdBin's over the same entries. Returns the entries written. +// warmed facade, and nothing is mutated). Entries stream from the source +// merge straight to the .bin — no whole-chunk accumulator; the header's +// leading count is patched in once the merge completes. Serialization is the +// walk path's own writer (coldBinStream, which WriteColdBin loops a whole +// slice over), so the byte-parity gate's only remaining subject is the entry +// ORDER this merge produces. Returns the entries written. +// secret is the chunk's per-index routing secret: every stored key is +// stores.BlindKey(secret, hash[:ColdKeySize]) — the walk writer's rule. +// Blinding destroys the merge's raw-key order, so the blinded entries are +// collected and re-sorted before the write (the .bin's contract is lex order +// BY BLINDED KEY; the sort is STABLE so duplicate full hashes keep the +// merge's global ledger-order tie-break). The accumulator is the same size +// class the walk path already carries for its finalize sort. func FreezeColdFromStore( ctx context.Context, chunkID chunk.ID, @@ -76,8 +77,6 @@ func FreezeColdFromStore( if err != nil { return 0, err } - // STABLE: duplicate full hashes share a blinded key; the merge emitted - // them in global ledger order and the .bin keeps that tie-break. slices.SortStableFunc(entries, func(a, b ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) @@ -85,13 +84,13 @@ func FreezeColdFromStore( if err != nil { return 0, err } - defer w.abort() - for _, ce := range entries { - if werr := w.append(ce); werr != nil { + defer w.close() + for i := range entries { + if werr := w.append(entries[i]); werr != nil { return 0, werr } } - if ferr := w.finish(); ferr != nil { + if ferr := w.commit(); ferr != nil { return 0, ferr } return len(entries), nil @@ -139,7 +138,10 @@ func collectTailSources( if ierr != nil { return nil, fmt.Errorf("txhash freeze %s: scan %s: %w", chunkID, txhashCF, ierr) } - if cerr := pollCtx(ctx, len(sources)); cerr != nil { + // 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 { @@ -166,9 +168,12 @@ func collectTailSources( } // mergeFreezeSources runs the k-way merge, emitting each record as a cold -// entry: hash truncated to ColdKeySize and BLINDED with the index secret, +// entry: hash truncated to ColdKeySize, seq range-checked against the chunk. +// mergeFreezeSources runs the k-way merge, emitting each record as a cold +// entry — hash truncated to ColdKeySize and BLINDED with the index secret — // seq range-checked against the chunk. The returned slice is in the merge's -// raw-key order — the caller owns the blinded-order sort. +// raw-key order (global ledger order on ties); the caller owns the +// blinded-order sort. func mergeFreezeSources( ctx context.Context, chunkID chunk.ID, sources []freezeSource, secret [stores.SecretLen]byte, ) ([]ColdEntry, error) { @@ -182,13 +187,14 @@ func mergeFreezeSources( if cerr := pollCtx(ctx, len(entries)); cerr != nil { return nil, cerr } - src := h.min() - seq := src.seq() + hash, seq := h.min() if seq < first || seq > last { return nil, fmt.Errorf("txhash freeze %s: entry seq %d outside [%d, %d]", chunkID, seq, first, last) } + // BlindKey hashes the source's buffer BEFORE the step invalidates it + // (the blind is itself the truncate-and-copy). entries = append(entries, ColdEntry{ - Key: stores.BlindKey(secret, src.hash()[:ColdKeySize]), + Key: stores.BlindKey(secret, hash[:ColdKeySize]), Seq: seq, }) if serr := h.step(); serr != nil { @@ -209,9 +215,9 @@ func pollCtx(ctx context.Context, i int) error { // ─────────────────────────── merge sources ─────────────────────────── // freezeSource is one hash-sorted stream of (full hash, seq) records feeding -// the freeze merge: a sealed run file or a single un-sealed tail row. -// Sources start positioned BEFORE their first record; advance steps to the -// next one. +// 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 { // hash returns the current record's full 32-byte hash — valid only // until the next advance. @@ -233,77 +239,6 @@ func closeFreezeSources(sources []freezeSource) { } } -// runSource streams one sealed run file sequentially through a 128KiB -// buffer, 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 the freeze loudly before the .bin is finished. -// Per-record hash order is checked as it streams (the merge's correctness -// AND the .bin's sort order both ride on it). -type runSource struct { - path string - f *os.File - br *bufio.Reader - crc hash.Hash64 - hdr runHeader - records int - remaining int - rec [runRecordLen]byte - prev [rowHashLen]byte -} - -// openRunSource opens and structurally validates a run for sequential -// draining (header magic/range, count vs file size — the same gates -// openSealedRun applies before ITS drain). -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, records: records, remaining: records, - }, nil -} - -func (r *runSource) hash() []byte { return r.rec[:rowHashLen] } - -func (r *runSource) seq() uint32 { return binary.LittleEndian.Uint32(r.rec[rowHashLen:]) } - -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.records - r.remaining - if idx > 0 { - copy(r.prev[:], r.rec[:rowHashLen]) - } - 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[:rowHashLen]) > 0 { - return false, fmt.Errorf("run %s: record %d out of hash order", r.path, idx) - } - r.remaining-- - return true, nil -} - -func (r *runSource) close() { _ = r.f.Close() } - // tailRowSource walks one retained packed row (32-byte stride, already // hash-sorted by EncodeRow) with a fixed ledger. off starts at -rowHashLen // (before the first hash) per the freezeSource contract. @@ -326,92 +261,65 @@ func (t *tailRowSource) close() {} // ─────────────────────────── merge heap ─────────────────────────── -// freezeHeap is a slice-backed min-heap of source indices ordered by -// (current hash, source index) — the same shape as the seal path's -// mergeHeap, over freezeSources instead of window rows. The index tie-break -// emits equal cross-source duplicate hashes in source order (runs oldest -// first, then tail rows in ledger order), and equal hashes WITHIN a run are -// already in ledger order from the seal merge — so global duplicate order is -// ascending ledger, deterministically. +// freezeHeap is the package's shared hash heap (merge_heap.go) over +// freezeSources: entries carry each live source's CURRENT hash beside its +// source index, so a compare never calls back through the freezeSource +// interface. The index tie-break emits equal cross-source duplicate hashes in +// source order (runs oldest first, then tail rows in ledger order), and equal +// hashes WITHIN a run are already in ledger order from the seal merge — so +// global duplicate order is ascending ledger, deterministically. +// +// A cached hash is the source's own buffer, valid only until that source +// advances: runSource.hash() aliases the record buffer it re-reads into, and +// tailRowSource.hash() 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 []int + heap hashHeap } // 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([]int, 0, len(sources))} + h := &freezeHeap{sources: sources, heap: make(hashHeap, 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, i) - h.up(len(h.heap) - 1) + h.heap = append(h.heap, hashEntry{hash: s.hash(), idx: i}) } } + h.heap.heapify() return h, nil } func (h *freezeHeap) len() int { return len(h.heap) } -// min returns the source holding the smallest current record. -func (h *freezeHeap) min() freezeSource { return h.sources[h.heap[0]] } +// min returns the smallest current record — its cached hash (valid until the +// next step) and its ledger. +func (h *freezeHeap) min() ([]byte, uint32) { + e := h.heap[0] + return e.hash, h.sources[e.idx].seq() +} -// step advances the minimum source past its emitted record, dropping the -// source once drained (its end-of-stream verification ran inside advance). +// step advances the minimum source past its emitted record, refilling the root +// with its next hash — or dropping the source once drained (its end-of-stream +// verification ran inside advance). func (h *freezeHeap) step() error { - ok, err := h.sources[h.heap[0]].advance() + src := h.sources[h.heap[0].idx] + ok, err := src.advance() if err != nil { return err } if !ok { - lastIdx := len(h.heap) - 1 - h.heap[0] = h.heap[lastIdx] - h.heap = h.heap[:lastIdx] - } - if len(h.heap) > 0 { - h.down(0) + h.heap = h.heap.dropRoot() + return nil } + h.heap[0].hash = src.hash() + h.heap.siftDown(0) return nil } - -// less orders heap positions a, b by (current hash, source index). -func (h *freezeHeap) less(a, b int) bool { - sa, sb := h.heap[a], h.heap[b] - if c := bytes.Compare(h.sources[sa].hash(), h.sources[sb].hash()); c != 0 { - return c < 0 - } - return sa < sb -} - -func (h *freezeHeap) up(pos int) { - for pos > 0 { - parent := (pos - 1) / 2 - if !h.less(pos, parent) { - return - } - h.heap[pos], h.heap[parent] = h.heap[parent], h.heap[pos] - pos = parent - } -} - -func (h *freezeHeap) down(pos int) { - for { - child := 2*pos + 1 - if child >= len(h.heap) { - return - } - if r := child + 1; r < len(h.heap) && h.less(r, child) { - child = r - } - if !h.less(child, pos) { - return - } - h.heap[pos], h.heap[child] = h.heap[child], h.heap[pos] - pos = child - } -} 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 index 08fab3032..3ccb7bfea 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go @@ -3,6 +3,8 @@ package txhash import ( "bytes" "context" + "encoding/binary" + "hash/crc64" "os" "path/filepath" "slices" @@ -177,7 +179,8 @@ func TestFreezeColdFromStore_IdenticalAcrossCrashStates(t *testing.T) { 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") - require.NoError(t, writeRun(tailRows, orphan)) + _, err := writeRun(tailRows, orphan) + require.NoError(t, err) sC.Shutdown() require.NoError(t, storeC.Close()) require.Equal(t, want, reopenAndFreeze(pathC), "orphan-run state diverged") @@ -212,11 +215,12 @@ func TestFreezeColdFromStore_RejectsOutOfRangeSeq(t *testing.T) { require.NoError(t, err) runDir := filepath.Join(store.Path(), txhashRunDir) require.NoError(t, os.MkdirAll(runDir, 0o755)) - require.NoError(t, writeRun([]windowRow{{seq: badSeq, bytes: row}}, filepath.Join(runDir, "seal-000000.run"))) + _, err = writeRun([]windowRow{{seq: badSeq, bytes: row}}, filepath.Join(runDir, "seal-000000.run")) + 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, "outside") + require.ErrorContains(t, err, "entry seq ", "the merge's chunk-range check owns this rejection") } // TestFreezeColdFromStore_RejectsMalformedRow: a wrong-shape CF row fails @@ -292,6 +296,53 @@ func TestFreezeColdFromStore_RejectsCorruptRunCRC(t *testing.T) { require.ErrorContains(t, err, "crc mismatch") } +// 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+rowHashLen:], 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}) + // "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 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..3eda95fe2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go @@ -9,13 +9,12 @@ 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 ( + "errors" "context" "encoding/binary" - "errors" "fmt" "io" "os" @@ -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..958923f71 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. 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/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go index ced8cbc01..911034103 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go @@ -18,7 +18,9 @@ package txhash // - RUNS: immutable flat files beside the chunk DB (TXHRUN01 format: // 36-byte hash‖seq records, hash-sorted, CRC64-framed), sealed from the // window every sealEvery ledgers on a background goroutine, each with -// an in-RAM bloom + page ladder rebuilt from the verified file bytes. +// 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). // // Reads are exact: every route ends in a full 32-byte hash compare before a // sequence is returned. The row chain is DENSE — one row per ledger, empty @@ -40,7 +42,10 @@ import ( "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 @@ -67,11 +72,11 @@ type windowRow struct { // 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 drain-verified payload record count (it bounds -// the final page's pread). +// 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 bloomFilter + bloom bloom.Filter ladder [][ladderKeyLen]byte // first 16B of each page's first record file *os.File first uint32 @@ -85,13 +90,27 @@ type HotIndex struct { view atomic.Pointer[hotIndexView] // Writer-owned state (single-writer contract). sealEvery defaults to - // windowLedgers; tests shrink it to exercise seals with small inputs. - sealEvery int - ledgersInWindow int - sealSeq int - pendingSeal chan sealResult // capacity 1: at most one seal in flight - sealInFlight bool - manifest manifestStore + // 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, @@ -115,8 +134,8 @@ type HotIndex struct { // 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, held structurally here since sealWindow is the only fallible -// step and returns no run on failure. +// 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 @@ -124,8 +143,10 @@ type sealResult struct { err error } -// NewHotIndex creates the engine for a fresh chunk. dir is created. -func NewHotIndex(dir string, manifest manifestStore) (*HotIndex, 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) (*HotIndex, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("txhash: hotindex mkdir %s: %w", dir, err) } @@ -133,6 +154,7 @@ func NewHotIndex(dir string, manifest manifestStore) (*HotIndex, error) { dir: dir, pendingSeal: make(chan sealResult, 1), manifest: manifest, + fsyncDir: durable.FsyncDir, sealEvery: windowLedgers, } h.view.Store(&hotIndexView{}) @@ -171,9 +193,8 @@ func (h *HotIndex) ApplyRow(seq uint32, rowBytes []byte, count int) error { rows = append(rows, windowRow{seq: seq, bytes: rowBytes}) h.view.Store(&hotIndexView{rows: rows, runs: old.runs}) h.expectedNext = seq + 1 - h.ledgersInWindow++ - if h.sealArmed && h.ledgersInWindow >= h.sealEvery && !h.sealInFlight { + if h.sealArmed && len(rows) >= h.sealEvery && !h.sealInFlight { h.startSeal(rows) } return nil @@ -217,9 +238,7 @@ func (h *HotIndex) Close() { res.run.close() } } - for _, r := range h.view.Load().runs { - r.close() - } + closeRuns(h.view.Load().runs) }) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go index f697a416d..2548bd1bf 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go @@ -15,8 +15,9 @@ package txhash // payload: count × 36B records hash[32] ‖ seq u32 LE (equal hashes: // ledger order) // -// Durability ladder: buffered payload → flush → patch CRC → fsync file → -// fsync the run DIRECTORY → only then may the manifest name the run +// 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. @@ -34,48 +35,10 @@ import ( "strings" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" + "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" ) -// ─────────────────────────── bloom filter ─────────────────────────── - -// bloomFilter is a plain double-hashed bloom over hash fp64s: ~10 bits/key, -// 7 probes. Pointerless (one []uint64), GC-invisible. -type bloomFilter struct { - bits []uint64 - mask uint64 -} - -const bloomProbes = 7 - -// newBloom sizes for n keys at ~10 bits/key, rounded up to a power of two -// (mask-indexable). -func newBloom(n int) bloomFilter { - bits := 1 - for bits < n*10 { - bits <<= 1 - } - return bloomFilter{bits: make([]uint64, bits/64+1), mask: uint64(bits - 1)} //nolint:gosec // bits >= 1 -} - -func (b *bloomFilter) add(fp uint64) { - h2 := fp>>33 | fp<<31 | 1 // odd second hash - for i := range bloomProbes { - bit := (fp + uint64(i)*h2) & b.mask - b.bits[bit/64] |= 1 << (bit % 64) - } -} - -func (b *bloomFilter) mayContain(fp uint64) bool { - h2 := fp>>33 | fp<<31 | 1 - for i := range bloomProbes { - bit := (fp + uint64(i)*h2) & b.mask - if b.bits[bit/64]&(1<<(bit%64)) == 0 { - return false - } - } - return true -} - // ─────────────────────────── run format ─────────────────────────── const ( @@ -115,15 +78,29 @@ type runHeader struct { // ─────────────────────────── sealing ─────────────────────────── // startSeal kicks the background job: fold the given window rows into one -// run. Writer goroutine only; h.sealInFlight guards the single-job -// invariant. +// 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) { + // 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) + // 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 }() } @@ -152,60 +129,129 @@ func (h *HotIndex) reapSeal(block bool) error { } old := h.view.Load() - runs := append(append([]*sealedRun{}, old.runs...), res.run) - names := make([]string, len(runs)) - for i, r := range runs { - names[i] = filepath.Base(r.path) - } - if err := h.manifest.PutRuns(names, res.lastSeq); err != nil { - // The un-listed run is unpublished: dispose of it like any failed - // job's output. - res.run.close() - _ = os.Remove(res.run.path) + // 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}) - h.ledgersInWindow -= res.rows return nil } -// sealWindow folds window rows into one run file and drain-verifies the -// result open. Runs on the background goroutine over immutable inputs. +// 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 [][ladderKeyLen]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([][ladderKeyLen]byte, 0, records/pageRecords+1), + } +} + +// observe folds in one payload record (hash[32] ‖ seq u32 LE). Must be +// called once per record, in emission order — the ladder keys page +// boundaries by observation index. +func (rt *runRouting) observe(rec *[runRecordLen]byte) { + if rt.n%pageRecords == 0 { + rt.ladder = append(rt.ladder, [ladderKeyLen]byte(rec[:ladderKeyLen])) + } + rt.bloom.Add(fp64([rowHashLen]byte(rec[:rowHashLen]))) + 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) (*sealedRun, error) { - if err := writeRun(rows, path); err != nil { + rt, err := writeRun(rows, path) + if err != nil { return nil, err } - return openSealedRun(path) + 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, honoring -// the durability ladder: the file AND its directory entry must be durable -// before the caller may name the run in the manifest. -func writeRun(rows []windowRow, path string) error { +// 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) (*runRouting, error) { f, err := os.Create(path) if err != nil { - return fmt.Errorf("txhash: create run %s: %w", path, err) + return nil, fmt.Errorf("txhash: create run %s: %w", path, err) } - if err := writeRunPayload(f, rows); err != nil { + rt, err := writeRunPayload(f, rows) + if err != nil { _ = f.Close() _ = os.Remove(path) - return fmt.Errorf("txhash: write run %s: %w", path, err) + return nil, fmt.Errorf("txhash: write run %s: %w", path, err) } if err := f.Sync(); err != nil { _ = f.Close() - return fmt.Errorf("txhash: sync run %s: %w", path, err) + return nil, fmt.Errorf("txhash: sync run %s: %w", path, err) } if err := f.Close(); err != nil { - return fmt.Errorf("txhash: close run %s: %w", path, err) + return nil, fmt.Errorf("txhash: close run %s: %w", path, err) } - return syncDir(filepath.Dir(path)) + return rt, nil } // writeRunPayload writes the header and the k-way-merged records (each row // is already sorted; ≤window-size cursors), then patches the payload CRC -// into the header. No whole-payload buffer: the merge streams straight -// through a bufio writer. -func writeRunPayload(f *os.File, rows []windowRow) error { +// into the header, feeding every record through the run's routing state +// beside the CRC (runRouting — one-pass construction). No whole-payload +// buffer: the merge streams straight through a bufio writer. +// +// The merge is the package's shared hash heap (merge_heap.go) over the rows' +// hash cursors: offs[i] is row i's byte offset and the heap caches each live +// row's CURRENT hash beside its row index. Rows arrive in ledger order, so the +// (hash, row) tie-break emits equal cross-ledger duplicate hashes in ledger +// order — the run's byte-level duplicate contract. +func writeRunPayload(f *os.File, rows []windowRow) (*runRouting, error) { count := 0 for i := range rows { count += len(rows[i].bytes) / rowHashLen @@ -216,150 +262,92 @@ func writeRunPayload(f *os.File, rows []windowRow) error { 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 err + return nil, err } + rt := newRunRouting(count) w := bufio.NewWriterSize(f, 128<<10) crc := crc64.New(crcRunTable) var rec [runRecordLen]byte - for m := newMergeHeap(rows); ; { - hash, seq, ok := m.next() - if !ok { - break + offs := make([]int, len(rows)) + h := make(hashHeap, 0, len(rows)) + for i := range rows { + if len(rows[i].bytes) > 0 { // a tx-less ledger's row holds no hash + h = append(h, hashEntry{hash: rows[i].bytes[:rowHashLen], idx: i}) } - copy(rec[:rowHashLen], hash) - binary.LittleEndian.PutUint32(rec[rowHashLen:], seq) + } + h.heapify() + for len(h) > 0 { + row := h[0].idx + // Emit-copy before the advance: the entry's hash is a slice INTO the + // row, and the refill below repoints it. + copy(rec[:rowHashLen], h[0].hash) + binary.LittleEndian.PutUint32(rec[rowHashLen:], rows[row].seq) _, _ = crc.Write(rec[:]) + rt.observe(&rec) if _, err := w.Write(rec[:]); err != nil { - return err + return nil, err + } + offs[row] += rowHashLen + if offs[row] == len(rows[row].bytes) { + h = h.dropRoot() + continue } + h[0].hash = rows[row].bytes[offs[row] : offs[row]+rowHashLen] + h.siftDown(0) + } + // The header promised count records; the merge 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("merged %d records, header claims %d", rt.n, count) } if err := w.Flush(); err != nil { - return err + return nil, err } var crcb [8]byte binary.LittleEndian.PutUint64(crcb[:], crc.Sum64()) - _, err := f.WriteAt(crcb[:], runCRCOff) - return err -} - -// syncDir fsyncs a directory so a just-written file's entry is durable — the -// manifest may only ever name a run whose existence survives a crash. -func syncDir(dir string) error { - d, err := os.Open(dir) - if err != nil { - return fmt.Errorf("txhash: open dir %s: %w", dir, err) - } - if err := d.Sync(); err != nil { - _ = d.Close() - return fmt.Errorf("txhash: sync dir %s: %w", dir, err) - } - return d.Close() -} - -// mergeHeap is a slice-backed binary min-heap of window-row cursors ordered -// by (current hash, row index): the row-index tie-break emits equal -// cross-ledger duplicate hashes in ledger order, so run bytes are -// deterministic. -type mergeHeap struct { - rows []windowRow - offs []int // per-row cursor: byte offset of the row's current hash - heap []int // row indices, min-heap by (current hash, row index) -} - -func newMergeHeap(rows []windowRow) *mergeHeap { - m := &mergeHeap{rows: rows, offs: make([]int, len(rows)), heap: make([]int, 0, len(rows))} - for i := range rows { - if len(rows[i].bytes) > 0 { - m.heap = append(m.heap, i) - m.up(len(m.heap) - 1) - } - } - return m -} - -// next pops the smallest current record — its hash (a slice into the -// immutable row bytes) and its row's ledger — advancing that cursor; -// ok=false when every row is drained. -func (m *mergeHeap) next() ([]byte, uint32, bool) { - if len(m.heap) == 0 { - return nil, 0, false - } - i := m.heap[0] - hash := m.cur(i) - m.offs[i] += rowHashLen - if m.offs[i] == len(m.rows[i].bytes) { - last := len(m.heap) - 1 - m.heap[0] = m.heap[last] - m.heap = m.heap[:last] - } - if len(m.heap) > 0 { - m.down(0) - } - return hash, m.rows[i].seq, true -} - -func (m *mergeHeap) cur(row int) []byte { - return m.rows[row].bytes[m.offs[row] : m.offs[row]+rowHashLen] -} - -// less orders heap positions a, b. -func (m *mergeHeap) less(a, b int) bool { - ra, rb := m.heap[a], m.heap[b] - if c := bytes.Compare(m.cur(ra), m.cur(rb)); c != 0 { - return c < 0 - } - return ra < rb -} - -func (m *mergeHeap) up(pos int) { - for pos > 0 { - parent := (pos - 1) / 2 - if !m.less(pos, parent) { - return - } - m.heap[pos], m.heap[parent] = m.heap[parent], m.heap[pos] - pos = parent - } -} - -func (m *mergeHeap) down(pos int) { - for { - child := 2*pos + 1 - if child >= len(m.heap) { - return - } - if r := child + 1; r < len(m.heap) && m.less(r, child) { - child = r - } - if !m.less(child, pos) { - return - } - m.heap[pos], m.heap[child] = m.heap[child], m.heap[pos] - pos = child + if _, err := f.WriteAt(crcb[:], runCRCOff); err != nil { + return nil, err } + return rt, nil } // ─────────────────────────── sealed runs ─────────────────────────── -// openSealedRun drain-verifies a run file and builds its lookup state plus -// an open handle. Used at seal completion and warmup — corruption is a loud -// failure, never auto-healed. +// 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 TXHRUN01 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) { - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("txhash: open run %s: %w", path, err) - } - hdr, err := readRunHeader(f, path) + rs, err := openRunSource(path) if err != nil { - _ = f.Close() return nil, err } - run, err := drainRun(f, path, hdr) - if err != nil { - _ = f.Close() - 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.records) + for { + ok, aerr := rs.advance() + if aerr != nil { + rs.close() + return nil, aerr + } + if !ok { + break + } + rt.observe(&rs.rec) } - run.file = f + run := rt.run(path, rs.hdr.first, rs.hdr.last) + run.file = rs.handoff() // last: nothing may fail past the handoff return run, nil } @@ -399,56 +387,11 @@ func runRecordCount(f *os.File, path string, hdr runHeader) (int, error) { return int(payload / runRecordLen), nil } -// drainRun reads and verifies the WHOLE payload — CRC64, record count vs -// file size, hash sortedness (non-decreasing: equal cross-ledger duplicate -// hashes are legal within one window), per-record seq ∈ [first,last] — and -// builds the bloom and ladder FROM THE VERIFIED BYTES ONLY: routing state is -// always rebuilt from the verified file, never trusted from elsewhere. -func drainRun(f *os.File, path string, hdr runHeader) (*sealedRun, error) { - records, err := runRecordCount(f, path, hdr) - if err != nil { - return nil, err - } - br := bufio.NewReaderSize(f, 128<<10) - crc := crc64.New(crcRunTable) - // The validated header count sizes the bloom up front. - bloom := newBloom(max(records, 1)) - ladder := make([][ladderKeyLen]byte, 0, records/pageRecords+1) - var rec [runRecordLen]byte - var prev [rowHashLen]byte - for i := range records { - if _, rerr := io.ReadFull(br, rec[:]); rerr != nil { - return nil, fmt.Errorf("txhash: run %s: record %d: %w", path, i, rerr) - } - _, _ = crc.Write(rec[:]) - if i > 0 && bytes.Compare(prev[:], rec[:rowHashLen]) > 0 { - return nil, fmt.Errorf("txhash: run %s: record %d out of hash order", path, i) - } - copy(prev[:], rec[:rowHashLen]) - if seq := binary.LittleEndian.Uint32(rec[rowHashLen:]); seq < hdr.first || seq > hdr.last { - return nil, fmt.Errorf("txhash: run %s: record %d seq %d outside [%d,%d]", - path, i, seq, hdr.first, hdr.last) - } - if i%pageRecords == 0 { - ladder = append(ladder, [ladderKeyLen]byte(rec[:ladderKeyLen])) - } - bloom.add(fp64([rowHashLen]byte(rec[:rowHashLen]))) - } - if crc.Sum64() != hdr.crc { - return nil, fmt.Errorf("txhash: run %s: payload crc mismatch (file %016x, computed %016x)", - path, hdr.crc, crc.Sum64()) - } - return &sealedRun{ - path: path, bloom: bloom, ladder: ladder, - first: hdr.first, last: hdr.last, records: records, - }, nil -} - // lookup probes the run for one hash: bloom reject → ladder route → one // record-aligned page pread (two on a boundary tie) → in-buffer stride // binary search → full 32-byte verify → LE seq. Concurrent-safe (ReadAt). func (r *sealedRun) lookup(hash [32]byte) (uint32, bool, error) { - if len(r.ladder) == 0 || !r.bloom.mayContain(fp64(hash)) { + if len(r.ladder) == 0 || !r.bloom.MayContain(fp64(hash)) { return 0, false, nil } page, pages := r.route(hash) @@ -495,19 +438,12 @@ func (r *sealedRun) close() { } } -// ─────────────────────────── manifest ─────────────────────────── +// 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() } -// manifestStore persists which runs are live — the crash-recovery authority. -// Implemented over the chunk's RocksDB by the integration layer; faked in -// tests. -type manifestStore interface { - // PutRuns atomically replaces the live-run list and records the highest - // sealed ledger (warmup replays packed rows PAST it). - PutRuns(names []string, lastSealed uint32) error - // GetRuns returns the live-run list and the sealed frontier (zero when - // nothing sealed). - GetRuns() ([]string, uint32, error) -} +// ─────────────────────────── 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 @@ -554,12 +490,17 @@ func (m rocksdbManifest) GetRuns() ([]string, uint32, error) { // 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. +// // 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 manifestStore) (*HotIndex, uint32, error) { +func OpenHotIndex(dir string, firstLedger uint32, manifest runset.Manifest) (*HotIndex, uint32, error) { h, err := NewHotIndex(dir, manifest) if err != nil { return nil, 0, err @@ -568,46 +509,40 @@ func OpenHotIndex(dir string, firstLedger uint32, manifest manifestStore) (*HotI if err != nil { return nil, 0, fmt.Errorf("txhash: hotindex manifest read: %w", err) } - runs, err := openManifestRuns(dir, names, lastSealed) - if err != nil { - return nil, 0, err - } - if err := sweepOrphans(dir, names); err != nil { - closeRuns(runs) - return nil, 0, err - } - h.view.Store(&hotIndexView{runs: runs}) - h.expectedNext = max(lastSealed+1, firstLedger) - h.sealSeq = nextSealSeq(names) - return h, lastSealed, nil -} - -// openManifestRuns drain-verifies every manifest-listed run and checks the -// chain is dense: 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. -func openManifestRuns(dir string, names []string, lastSealed uint32) ([]*sealedRun, error) { runs := make([]*sealedRun, 0, len(names)) - for _, name := range names { - r, err := openSealedRun(filepath.Join(dir, name)) - if err != nil { + // 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) - return nil, fmt.Errorf("txhash: hotindex: manifest run %s: %w", name, err) } - if n := len(runs); n > 0 && r.first != runs[n-1].last+1 { - closeRuns(append(runs, r)) - return nil, fmt.Errorf("txhash: hotindex: run %s starts at %d, previous run ends at %d", - name, r.first, runs[n-1].last) + }() + 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 { - closeRuns(runs) - return nil, fmt.Errorf("txhash: hotindex: newest run ends at %d, sealed frontier is %d", + return nil, 0, fmt.Errorf("txhash: hotindex: newest run ends at %d, sealed frontier is %d", runs[n-1].last, lastSealed) } - return runs, nil + 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) { @@ -615,36 +550,3 @@ func closeRuns(runs []*sealedRun) { r.close() } } - -// sweepOrphans deletes files present in dir but unreferenced by the -// manifest — garbage by definition (a crash between run write and manifest -// Put). -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("txhash: hotindex 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 live run so a future -// seal can never overwrite one (numeric suffixes are monotone). -func nextSealSeq(names []string) int { - maxSeq := 0 - for _, name := range names { - var n int - if _, err := fmt.Sscanf(name, "seal-%06d.run", &n); err == nil && n > maxSeq { - maxSeq = n - } - } - return maxSeq + 1 -} 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..1e3a67812 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go @@ -0,0 +1,85 @@ +package txhash + +// merge_heap.go — the ONE hash-ordered k-way merge heap this package's two +// background merges share: the seal folding window rows into a run +// (hotindex_seal.go) and the freeze folding runs plus tail rows into the cold +// .bin (cold_freeze.go). Both merge hash-sorted sources and both break ties on +// the SOURCE index, which is what makes duplicate emission ledger-ordered and +// deterministic — the same order under one implementation. +// +// 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 both +// drivers are held to byte-identity gates +// (TestFreezeColdFromStore_ByteIdenticalToWalk for the freeze, +// TestWriteRun_CrossRowDuplicateGoldenBytes for the seal). + +import "bytes" + +// hashEntry is one live source's heap slot: that source's CURRENT full hash, +// 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 hashEntry struct { + hash []byte + idx int +} + +// less orders entries by (hash, 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 hashEntry) less(o hashEntry) bool { + if c := bytes.Compare(e.hash, o.hash); c != 0 { + return c < 0 + } + return e.idx < o.idx +} + +// hashHeap is a slice-backed binary min-heap of hashEntries. The driver owns +// the slice: heapify once, then refill-and-siftDown or dropRoot per record. +type hashHeap []hashEntry + +// heapify orders an arbitrary slice of entries into a min-heap. +func (h hashHeap) heapify() { + for i := len(h)/2 - 1; i >= 0; i-- { + h.siftDown(i) + } +} + +// siftDown restores the min-heap rooted at i. +func (h hashHeap) 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 hashHeap) dropRoot() hashHeap { + 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/run_reader.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go new file mode 100644 index 000000000..120754697 --- /dev/null +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go @@ -0,0 +1,113 @@ +package txhash + +// run_reader.go — the ONE streaming reader for TXHRUN01 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), hash 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. + +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 hash 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 + records int + remaining int + rec [runRecordLen]byte + prev [rowHashLen]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, records: records, remaining: records, + }, nil +} + +func (r *runSource) hash() []byte { return r.rec[:rowHashLen] } + +func (r *runSource) seq() uint32 { return binary.LittleEndian.Uint32(r.rec[rowHashLen:]) } + +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.records - r.remaining + if idx > 0 { + copy(r.prev[:], r.rec[:rowHashLen]) + } + 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[:rowHashLen]) > 0 { + return false, fmt.Errorf("run %s: record %d out of hash 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 index 36c762eac..ee2ff5f98 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go @@ -2,7 +2,7 @@ package txhash import ( "encoding/binary" - "errors" + "hash/crc64" "math/rand/v2" "os" "path/filepath" @@ -14,23 +14,20 @@ import ( "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 manifestStore. putErr, when set, fails every -// PutRuns without recording anything. +// fakeManifest is an in-memory runset.Manifest. 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 @@ -124,7 +121,7 @@ func (hh *hotIndexHarness) settle() { require.NoError(hh.t, hh.h.reapSeal(true)) } -func testHotIndex(t *testing.T, dir string, m manifestStore) *HotIndex { +func testHotIndex(t *testing.T, dir string, m runset.Manifest) *HotIndex { h, err := NewHotIndex(dir, m) require.NoError(t, err) h.sealEvery = 8 // tiny window: many seals @@ -158,6 +155,95 @@ func TestHotIndex_EquivalenceAcrossSeals(t *testing.T) { 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 merge'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 hashes are non-decreasing, so +// intra-run duplicate ORDER — the (hash, row) tie-break that emits equal +// hashes oldest-ledger-first — was otherwise protected solely by RNG draws in +// the freeze fixture, transitively and by accident. +// +// The expectation is written out by hand as (hash, seq) pairs in emission +// order, so it pins the order the format PROMISES rather than whatever the +// merge currently produces. +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) + require.NoError(t, err) + got, err := os.ReadFile(path) + require.NoError(t, err) + + // Ascending hash; equal hashes in ASCENDING LEDGER. + emitted := []struct { + hash [32]byte + seq uint32 + }{ + {a, 100}, + {a, 102}, + {a, 103}, + {b, 100}, + {b, 103}, + {c, 102}, + } + payload := make([]byte, 0, len(emitted)*runRecordLen) + for _, e := range emitted { + payload = append(payload, e.hash[:]...) + 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, "TXHRUN01") + 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") + + // The pinned bytes are a VALID run: the drain re-verifies CRC64, hash + // 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 "TXHRUN01" + // 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") +} + // TestHotIndex_DenseChainViolationRejected pins ApplyRow's write-time // tripwires: the dense chain (anchored by the first row) and the // count/bytes cross-check. @@ -437,6 +523,57 @@ func TestHotIndex_LadderBoundaryTie(t *testing.T) { require.ErrorIs(t, err, stores.ErrNotFound) } +// 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-16-byte-prefix ranges across both page boundaries +// (the ladder's tie geometry), and spreads the hashes over two ledgers so +// the merge heap — not input order — dictates observation order. +func TestHotIndex_WritePassRoutingEqualsDrainRebuild(t *testing.T) { + // 120 hashes with prefix 0x11, 120 with 0x22, 60 with 0x33: 300 records + // = two full pages + a tail, with the page boundaries (records 113 and + // 226) inside the 0x11 and 0x22 ranges respectively. + all := make([][32]byte, 0, 300) + for i := range uint32(120) { + all = append(all, prefixedHash(0x11, i)) + } + for i := range uint32(120) { + all = append(all, prefixedHash(0x22, i)) + } + for i := range uint32(60) { + 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) + 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) { @@ -473,6 +610,31 @@ func TestHotIndex_WriterReaderRace(t *testing.T) { 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) { @@ -505,27 +667,35 @@ func TestRocksdbManifest_RoundTrip(t *testing.T) { assert.Equal(t, uint32(7), lastSealed) } -// TestHotIndex_ManifestFailureDisposesRun pins reapSeal's manifest-failure -// branch: a run the manifest never listed is unpublished and must be closed -// and removed — the twin of the events contract. -func TestHotIndex_ManifestFailureDisposesRun(t *testing.T) { - dir := t.TempDir() - m := &fakeManifest{} - h := testHotIndex(t, dir, m) - defer h.Close() +// 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) - run, err := sealWindow([]windowRow{{seq: 5, bytes: row}}, filepath.Join(dir, "seal-000009.run")) + live, err := sealWindow([]windowRow{{seq: 4, bytes: row}}, filepath.Join(dir, "seal-000000.run")) require.NoError(t, err) + defer live.close() + fresh, err := sealWindow([]windowRow{{seq: 5, bytes: row}}, filepath.Join(dir, "seal-000001.run")) + require.NoError(t, err) + defer fresh.close() - h.pendingSeal <- sealResult{run: run, rows: 1, lastSeq: 5} - h.sealInFlight = true - m.putErr = errors.New("manifest unavailable") - - err = h.reapSeal(true) - require.ErrorContains(t, err, "hotindex manifest") - require.ErrorIs(t, run.file.Close(), os.ErrClosed, "un-listed run's handle must be closed") - _, serr := os.Stat(run.path) - require.ErrorIs(t, serr, os.ErrNotExist, "un-listed run's file must be removed") + 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) } From c2700cec2d6c6b37f15f7c2e498e8c6117fa41d9 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Mon, 3 Aug 2026 23:49:42 +0000 Subject: [PATCH 09/21] docs: campaign handoff, RAM anchor, rulings ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handoff document for whoever continues this work — state, settled architecture, mechanism findings, next steps, bench quick reference — with its measurement-driven amendments folded (RAM re-anchor, hardware- class ruling) plus the consolidation series' rulings ledger and the design-doc catch-ups (gettransaction hot-tier description, packfile). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou --- design-docs/full-history-campaign-handoff.md | 325 ++++++++++++++++++ .../gettransaction-full-history-design.md | 28 +- 2 files changed, 346 insertions(+), 7 deletions(-) create mode 100644 design-docs/full-history-campaign-handoff.md diff --git a/design-docs/full-history-campaign-handoff.md b/design-docs/full-history-campaign-handoff.md new file mode 100644 index 000000000..bacfe83fc --- /dev/null +++ b/design-docs/full-history-campaign-handoff.md @@ -0,0 +1,325 @@ +# 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 | +| 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 — confirmed). +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:** the PR stack is one commit per concern (bench diagnostics → +hot-latency wave 1 → cold-memory redesign → Sorted-Run Tier → +zero-decompress freeze → window-scale bench + separation residue → +hot-latency wave 2 × 4 → views-walk port × 3, pin-bump fused with the +API swap since no intermediate state compiles → this docs commit). Each +boundary builds and +passes `-race` standalone; 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. + +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. + +- **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 EVR2 records, fixed-cadence 16B + prefixes for TXHRUN01'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/gettransaction-full-history-design.md b/design-docs/gettransaction-full-history-design.md index a4414caaa..7d52867b1 100644 --- a/design-docs/gettransaction-full-history-design.md +++ b/design-docs/gettransaction-full-history-design.md @@ -76,16 +76,30 @@ 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, hash-sorted run files (`TXHRUN01`) 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`). + +Full 32-byte hashes are stored throughout, so the hot tier stays **exact**: +a lookup either finds the hash or it doesn't — bloom routing only prunes +which run is read, never what a probe verifies against. ### 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 From d749affd21596939540f44e0cf75258ef4ec198a Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Tue, 4 Aug 2026 19:48:22 +0000 Subject: [PATCH 10/21] hot ingest: fork the ledger encode before the caller's walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HotService.Ingest so the caller can also feed the fee windows. The campaign forks the ledger-bytes zstd encode inside IngestLedger and joins it as the batch's LAST queue step, on the premise that it overlaps every other step — including the walk, which is by far the largest thing it can hide behind. Composing the two silently forfeited that overlap: after the hoist the walk completes before IngestLedger is entered, so StartCompress forked only in time to overlap the product reads and the queue steps. Measured on the 1k paced sac-6000 cell, the join went 1.90 -> 4.38ms p50 with p90 blowing out to 11.73ms, costing 3.5ms of ingest_total. DB.StartCompress now exposes the fork so HotService.Ingest starts it before its walk and hands the handle to IngestLedger, which still owns the Discard; a caller that fails before IngestLedger discards it itself. Restores ledgers to 1.08ms p50 / 1.97ms p90 — better than the pre-#917 baseline, since the encode now overlaps the whole walk. Both branches are correct alone; only the composition regressed, with every test passing and byte-identity intact. The symptom was a phase timing nothing asserts on. --- cmd/stellar-rpc/internal/rpcv2/ingest/service.go | 7 ++++++- .../internal/rpcv2/rpcv2test/rpcv2test.go | 3 ++- .../internal/rpcv2/stores/hotchunk/hotchunk.go | 12 +++++++++++- .../internal/rpcv2/stores/hotchunk/hotchunk_test.go | 3 ++- 4 files changed, 21 insertions(+), 4 deletions(-) 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/rpcv2test/rpcv2test.go b/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go index a5e54f4d8..8076e255f 100644 --- a/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go +++ b/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go @@ -135,7 +135,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) } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index dcdaf1730..218504260 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -436,8 +436,19 @@ 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 @@ -456,7 +467,6 @@ func (d *DB) IngestLedger( // 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. - pending := d.ledger.StartCompress(ledger.Entry{Seq: seq, Bytes: []byte(lcmView)}) defer pending.Discard() // Pre-extract anything that can fail BEFORE opening the batch, so a decode 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 bb2397a77..109032453 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go @@ -42,7 +42,8 @@ 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). From 5182ba176caa989c34e8700a103cf8fecd5067b5 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Sun, 16 Aug 2026 08:37:10 +0000 Subject: [PATCH 11/21] events: inline small bodies in the reorder heap; derive the backstop from the artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass B's reorder heap copied every buffered body, which is wasteful in exactly the case that matters: nearly every record strays (~99.7% — within-block slot order is effectively random), and on datasets shaped like soroswap a single block's bodies are multi-MiB roaring giants whose copies balloon the heap. Split the representation by size: bodies at or under inlineBodyMax ride the heap inline (the bytes are already in hand from the sequential scan), larger ones are buffered as (offset, length) references into terms.run and re-read positionally at emit. The threshold derives from the delta codec's ceiling (1 codec byte + a count varint + deltaPostingMaxCardinality posting varints = 5,126) so every delta-coded body inlines by construction and the boundary moves with any codec retune. Measured on sac-6000 (c6id.8xlarge, interleaved adjacent freeze cells): events arm −3.1% vs the copy-everything baseline, lowest peak RSS of the variants tried; on soroswap-1500 the reference path prices at zero (giant bodies dominate either way). An all-reference design was measured first and rejected: one pread per strayed record cost +28.8% on the events arm — the inline copy for the small-body common case is what recovers it. The backstop constant is gone: the bound now derives from the opened index itself via streamhash's new Index.MaxBlockKeys() (stellar/streamhash#13, pinned to its branch until merge). A legal heap never holds more than MaxBlockKeys−1 records — a block's minimum-slot key always takes the fast path — so the guard fires on the first provably-impossible push, and a future format revision cannot silently invalidate a transcribed ceiling. Hardening from two adversarial review passes (zero confirmed wrong-output bugs): ONE fd is shared between the sequential CRC-verified scan and the emit-time positional re-reads (same inode by construction); pw.Finish moves beside packfile.Create in BOTH builders so no emitter can ship an unfinalized pack; crcFoldReader folds through a reusable scratch byte and a hoisted reader (~3M allocations per build removed); the byte-identity gate now straddles inlineBodyMax with premise-checked roaring bodies on both sides; the offsets corpus covers two-byte length varints; and the multi-MiB corpus premise-checks deep reordering. Co-authored-by: Marwen Abid Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou --- .../rpcv2/stores/event/cold_format.go | 10 +- .../internal/rpcv2/stores/event/cold_index.go | 14 +- .../rpcv2/stores/event/cold_index_stream.go | 189 ++++++++++---- .../stores/event/cold_index_stream_test.go | 244 ++++++++++++++++-- 4 files changed, 394 insertions(+), 63 deletions(-) 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 158f54369..fe304025e 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -116,7 +116,9 @@ const ( // deltas than as roaring. The proxy holds on pubnet event postings because // cardinality correlates with run structure there, fat terms being the // clustered ones. That is a property of the data, so re-measure before -// retuning. +// retuning — and retuning also moves inlineBodyMax (cold_index_stream.go), +// which scales the reorder heap's corrupt-index inline ceiling +// (backstop × inlineBodyMax): redo that RAM math in the same change. const deltaPostingMaxCardinality = 1024 // IndexRecordFingerprintLen is the byte width of the leading @@ -540,6 +542,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_index.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go index 11bc7f817..0adef9ba7 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -174,6 +174,14 @@ func WriteColdIndex( } 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, @@ -226,12 +234,14 @@ func encodeIndexBody(dst []byte, ids []uint32) ([]byte, error) { } // 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 { for _, e := range entries { 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 index b375e035f..7d3ba01be 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -15,10 +15,13 @@ package event // 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 (slots deviate from key -// order only within one MPHF block; an entry-count backstop turns a -// corrupt slot assignment into a loud abort — the chunk retry -// contract). +// dense slot order via a bounded reorder heap: bodies at or under +// inlineBodyMax ride the heap inline (every delta-coded body does, by +// construction), 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" @@ -41,14 +44,23 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores" ) -// maxReorderEntries bounds pass B's slot-reorder heap by RECORD COUNT — the -// quantity the block contract actually bounds: slots stray from stream order -// only within one MPHF block, and streamhash blocks hold at most 65,535 keys -// (per-block cumulative counts are uint16). Crossing this means non-dense or -// corrupt slot assignment — abort loudly, the retry contract rebuilds. A -// byte bound would be wrong in kind: a legitimate block's byte mass is -// unbounded (few terms with multi-MiB bodies), only its key count is capped. -const maxReorderEntries = 1 << 16 +// 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. It is derived so that EVERY +// delta-coded body inlines by construction — 1 codec byte, a count varint, +// and up to deltaPostingMaxCardinality posting varints — and moves with +// that constant if the codec split is ever retuned; only roaring bodies +// — 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 = +29% on the freeze's events arm. Re-measure before retuning. +// RAM: the heap provably drains at every block boundary, so occupancy is +// one MPHF block (≲3.5K entries ≈ 17MB inline); the enforced ceiling — +// the derived backstop × inlineBodyMax ≈ 320MiB — is reachable only with +// a corrupt index.hash, which the backstop then turns into a loud abort. +const inlineBodyMax = 1 + binary.MaxVarintLen32 + deltaPostingMaxCardinality*binary.MaxVarintLen32 var errCorruptTermsRun = errors.New("events: corrupt terms.run scratch") @@ -118,12 +130,28 @@ func WriteColdIndexFromRuns( if err != nil { return fmt.Errorf("events: create index.pack at %s: %w", indexPackPath, err) } - if err = writeSlotOrdered(pw, termsRunPath, m); err != nil { + // 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 } @@ -147,8 +175,13 @@ func buildSortedHash( _ = 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(termsRunPath, func(term TermKey, _ []byte) error { + 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 { @@ -240,8 +273,10 @@ var ( // 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 + br *bufio.Reader + crc uint32 + n int64 + scratch [1]byte } func (c *crcFoldReader) ReadByte() (byte, error) { @@ -249,20 +284,25 @@ func (c *crcFoldReader) ReadByte() (byte, error) { if err != nil { return 0, err } - c.crc = crc32.Update(c.crc, termsRunCRC, []byte{b}) + c.scratch[0] = b + c.crc = crc32.Update(c.crc, termsRunCRC, c.scratch[:]) + c.n++ return b, nil } -// streamTermsRun replays terms.run, calling emit per record with the term -// and the item body (reused buffer). Integrity (CRC over all -// records) is verified before returning nil — both passes fully drain, so a -// corrupt scratch can never produce artifacts. -func streamTermsRun(path string, emit func(term TermKey, body []byte) error) error { - f, err := os.Open(path) - if err != nil { - return fmt.Errorf("events: open %s: %w", path, err) +// 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) } - defer f.Close() br := bufio.NewReaderSize(f, 1<<20) var hdr [12]byte if _, err := io.ReadFull(br, hdr[:]); err != nil { @@ -274,8 +314,12 @@ func streamTermsRun(path string, emit func(term TermKey, body []byte) error) err count := binary.BigEndian.Uint64(hdr[4:]) var ( - crc uint32 - buf []byte + 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 @@ -283,13 +327,15 @@ func streamTermsRun(path string, emit func(term TermKey, body []byte) error) err return fmt.Errorf("%w: record %d term: %w", errCorruptTermsRun, i, err) } crc = crc32.Update(crc, termsRunCRC, term[:]) - // stdlib varint over a CRC-folding ByteReader. - cbr := &crcFoldReader{br: br, crc: crc} + 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) } @@ -301,9 +347,10 @@ func streamTermsRun(path string, emit func(term TermKey, body []byte) error) err return fmt.Errorf("%w: record %d body: %w", errCorruptTermsRun, i, err) } crc = crc32.Update(crc, termsRunCRC, buf) - if err := emit(term, buf); err != nil { + 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 { @@ -315,12 +362,34 @@ func streamTermsRun(path string, emit func(term TermKey, body []byte) error) err return nil } -// slotRecord is one reorder-heap element: an assembled index.pack record -// waiting for its slot's turn. +// 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 - body []byte // owned copy + 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 @@ -342,8 +411,9 @@ func (h *slotHeap) popMin() slotRecord { minRec := old[0] last := len(old) - 1 old[0] = old[last] - // Zero the vacated slot: a stale record lingering in the capacity tail - // would pin its body allocation until the heap regrows over it. + // 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) @@ -385,21 +455,47 @@ func (h *slotHeap) siftDown(i int) { // 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 + h slotHeap + next uint32 + readBuf []byte ) flush := func() error { for len(h) > 0 && h.peek().slot == next { rec := h.popMin() - if err := pw.AppendItem(rec.fp[:], rec.body); err != nil { + 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(termsRunPath, func(term TermKey, body []byte) error { + 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) @@ -415,11 +511,16 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { next++ return flush() } - if len(h) >= maxReorderEntries { + 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)", maxReorderEntries, slot) + "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(slotRecord{slot: slot, fp: fp, body: append([]byte(nil), body...)}) + h.push(rec) return nil }) if err != nil { @@ -431,5 +532,5 @@ func writeSlotOrdered(pw *packfile.Writer, termsRunPath string, m *mphf) error { if len(h) != 0 { return fmt.Errorf("events: %d index.pack records stranded (non-dense MPHF slots?)", len(h)) } - return pw.Finish(encodeIndexBuildStamp()) + 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 index 2ba6ee873..1241b667a 100644 --- 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 @@ -1,17 +1,23 @@ 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" ) @@ -54,6 +60,192 @@ func spillBlinded(t *testing.T, sp *runspill.Spiller, k TermKey, id uint32) { 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) + 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. @@ -79,6 +271,26 @@ func TestWriteColdIndexFromRuns_ByteIdentical(t *testing.T) { corpus[k] = ids } + // Add roaring-coded 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. The + // straddle is premise-checked below, not assumed. + minBody, maxBody := int(^uint(0)>>1), 0 + for i, card := range []int{2400, 2500, 2600, 2700} { + k := ComputeTermKey(fmt.Appendf(nil, "inline-straddle-%d", i), FieldTopic1) + ids := make([]uint32, card) + for j := range ids { + ids[j] = uint32(j*3 + 1) + } + corpus[k] = ids + body, berr := encodeIndexBody(nil, ids) + 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 roaring body") + require.Greater(t, maxBody, inlineBodyMax, "corpus must include a reference-path roaring body") + // Reference: today's in-memory mirror path. refDir := t.TempDir() mirror := NewBitmaps() @@ -117,6 +329,22 @@ func TestWriteColdIndexFromRuns_ByteIdentical(t *testing.T) { 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. @@ -169,19 +397,3 @@ func TestWriteColdIndexFromRuns_ReadsBack(t *testing.T) { break // slot resolution exercised; full read path is covered by cold_reader tests } } - -// TestSlotHeapPopClearsVacatedRef pins popMin's tail clear: without it the -// vacated slot in the capacity tail keeps an already-emitted 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, body: []byte{2}}) - h.push(slotRecord{slot: 1, body: []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].body, "surviving record keeps its body") - tail := h[:cap(h)][len(h)] - require.Equal(t, slotRecord{}, tail, "vacated slot must be fully zeroed — a stale record would pin an emitted body") -} From e050ad342b8b770f7a82f77ecb9af633bf5e8fd6 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Tue, 25 Aug 2026 09:21:42 +0000 Subject: [PATCH 12/21] post-rebase lint pass: import grouping, delegate as func, dead code, doc reattachment The rebase surfaced golangci findings CI scopes to changed lines: gci/gofumpt import ordering in the moved codec files, the DecodeAscendingIDs delegate as a package global, orphaned uvarintLen, an unnecessary conversion, the long-dead eventsFreeze family (unused since the freeze-by-merge rework), a doc block fused across two symbols, and streamUnion one branch over the cyclop limit (batch collection extracted). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013zyTXU8wkocJgN6mBafnou --- .../internal/rpcv2/bench/txindex_test.go | 3 +- .../internal/rpcv2/ingest/events.go | 54 ------------------- .../internal/rpcv2/ingest/freeze.go | 2 +- .../internal/rpcv2/ingest/txhash.go | 2 +- .../rpcv2/stores/event/cold_freeze.go | 2 +- .../rpcv2/stores/event/cold_index_stream.go | 2 +- .../internal/rpcv2/stores/event/index.go | 15 ++---- .../internal/rpcv2/stores/event/match.go | 31 ++++++----- .../internal/rpcv2/stores/event/packedrow.go | 17 ++---- .../rpcv2/stores/hotchunk/hotchunk.go | 1 + .../rpcv2/stores/txhash/cold_freeze.go | 2 +- .../rpcv2/stores/txhash/cold_freeze_test.go | 6 ++- .../rpcv2/stores/txhash/cold_index.go | 2 +- 13 files changed, 40 insertions(+), 99 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go index 50bdb7b92..ff501d0b9 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go @@ -37,7 +37,8 @@ func writeFixtureBin(t *testing.T, dir string, c chunk.ID, n int) { 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)) + 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 diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go index 06f503786..bfb198bdf 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go @@ -14,7 +14,6 @@ import ( "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" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk" ) // ───────────────────────── Cold writer ───────────────────────── @@ -105,59 +104,6 @@ func eventsScratchDir(eventsDir string, c chunk.ID) string { // chunk's unique-term count. const indexSpillSlabBytes = 32 << 20 -// eventsFreeze is the freeze-by-merge events writer: it takes NO per-ledger -// feed (openColdChunk leaves coldChunk.events nil, so the walk never shapes -// events for it) and produces all three cold events artifacts at finalize -// straight from the complete hot chunk DB's CFs (hotchunk.FreezeEventsCold). -type eventsFreeze struct { - chunkID chunk.ID - db *hotchunk.DB - bucketDir string - // secret is the chunk's routing secret (event.ColdIndexSecret) — the - // freeze build blinds term keys with it at its run boundary, mirroring - // the walk path's blind-before-spill. - secret [stores.SecretLen]byte - metrics coldMetrics -} - -func newEventsFreeze( - bucketDir string, chunkID chunk.ID, db *hotchunk.DB, sink MetricSink, - secret [stores.SecretLen]byte, -) (*eventsFreeze, error) { - if err := os.MkdirAll(bucketDir, 0o755); err != nil { - return nil, fmt.Errorf("mkdir %s: %w", bucketDir, err) - } - return &eventsFreeze{ - chunkID: chunkID, - db: db, - bucketDir: bucketDir, - secret: secret, - metrics: newColdMetrics(sink, dataTypeEvents), - }, nil -} - -// finalize builds events.pack + index.pack + index.hash from the hot DB. One -// ColdIngest sample covers the whole build (there are no per-ledger writes to -// observe on this path). -func (e *eventsFreeze) finalize(ctx context.Context) error { - start := time.Now() - scratch := filepath.Join(e.bucketDir, ".freeze-scratch-"+e.chunkID.String()) - err := e.db.FreezeEventsCold(ctx, scratch, e.bucketDir, e.secret, event.ColdWriterOptions{ - Concurrency: coldEncoderConcurrency, - BytesPerSync: coldBytesPerSync, - }) - if err != nil { - err = fmt.Errorf("freeze events from hot DB: %w", err) - } - e.metrics.emit(time.Since(start), err) - return err -} - -// close is a no-op: the freeze writer holds no partial state of its own — -// artifact overwrite-on-retry and scratch wiping are FreezeColdFromStore's -// contract, and the hot DB is owned by the caller. -func (e *eventsFreeze) close() error { return nil } - // 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). diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go index 007af64fd..25e5872ef 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go @@ -25,8 +25,8 @@ 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/stores/event" "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" ) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go index cee921bd7..55f3bc0ba 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go @@ -28,7 +28,7 @@ import ( // this package) turns these .bin files into the queryable cold MPHF index. type txhashCold struct { binPath string - secret [stores.SecretLen]byte + secret [stores.SecretLen]byte entries []txhash.ColdEntry metrics coldMetrics } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go index a8d82f3dc..f0b320fc2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -34,13 +34,13 @@ package event // identity gates and the composition gate (ingest/freeze_test.go). import ( - "slices" "context" "encoding/binary" "fmt" "math" "os" "path/filepath" + "slices" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" 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 index 7d3ba01be..270911713 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -30,7 +30,6 @@ import ( "encoding/binary" "errors" "fmt" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" "hash/crc32" "io" "io/fs" @@ -42,6 +41,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" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" ) // inlineBodyMax is the copy/reference boundary for buffered records: bodies diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go index 78f9731b2..f15156468 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go @@ -146,15 +146,6 @@ 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 - -// 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. // 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 @@ -268,9 +259,9 @@ func appendTopicTerms( return nil, fmt.Errorf("events: view Body.V0.Topics raw: %w", err) } if lanes != nil { - lanes.topicCount = topicCountLane(int(count)) + lanes.topicCount = topicCountLane(count) } else { - dst = append(dst, TopicCountTermKey(int(count))) + 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) { @@ -289,7 +280,7 @@ func appendTopicTerms( // 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/match.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go index b40351408..b54fb371b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/match.go @@ -442,18 +442,7 @@ func streamUnion( yield(Match{}, err) return } - ids = ids[:0] - if descending { - for pos > 0 && len(ids) < batch { - pos-- - ids = append(ids, all[pos]) - } - } else { - for pos < len(all) && len(ids) < batch { - ids = append(ids, all[pos]) - pos++ - } - } + ids, pos = collectBatch(ids[:0], all, pos, batch, descending) batch = rest if len(ids) == 0 { return @@ -483,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 diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go index be37d02f9..07c5e4699 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go @@ -5,8 +5,9 @@ import ( "encoding/binary" "errors" "fmt" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" "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 @@ -133,16 +134,6 @@ func PackedRecordLen(b []byte) int { return n } -// uvarintLen is the byte width binary.AppendUvarint would use for v. -func uvarintLen(v uint64) int { - n := 1 - for v >= 0x80 { - v >>= 7 - n++ - } - 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 @@ -165,4 +156,6 @@ func AppendPostings(dst []byte, ids []uint32) []byte { return runspill.AppendPos // DecodeAscendingIDs decodes count delta-varint ids via next, validating // ascending order and uint32 range (postings are untrusted input). -var DecodeAscendingIDs = runspill.DecodeAscendingIDs +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/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index 218504260..6328048ae 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -436,6 +436,7 @@ 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 diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go index cbcb32bd5..dc16b4ada 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -27,11 +27,11 @@ package txhash import ( "bytes" - "slices" "context" "fmt" "os" "path/filepath" + "slices" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" 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 index 3ccb7bfea..80e54cabc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go @@ -292,7 +292,8 @@ func TestFreezeColdFromStore_RejectsCorruptRunCRC(t *testing.T) { 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) + _, err = FreezeColdFromStore( + context.Background(), chunkID, reopened, filepath.Join(t.TempDir(), "x.bin"), testBinSecret) require.ErrorContains(t, err, "crc mismatch") } @@ -339,7 +340,8 @@ func TestFreezeAndWarmup_RejectSeqOutsideRunHeaderRange(t *testing.T) { // 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) + _, 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") } 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 3eda95fe2..115e010ac 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_index.go @@ -12,9 +12,9 @@ package txhash // WriteColdBin guarantees). import ( - "errors" "context" "encoding/binary" + "errors" "fmt" "io" "os" From c0857edad0d5ecc0adf60035d3225c334353c2a8 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Wed, 26 Aug 2026 19:40:29 +0000 Subject: [PATCH 13/21] keyed cold routing: adversarial review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three independent review agents (design/security, new-code correctness, coverage/consistency) audited the #932 keying port. Fixes, in severity order: - WriteColdIndexFromRuns: a failed terms.run unlink no longer cascades into deleting the finalized index.hash while index.pack survives (defer-order interaction); scratch removal is best-effort, the caller wipes wholesale. - eventsCold.finalize: scratch cleanup matches its own best-effort contract instead of failing the chunk over a cosmetic unlink. - Walk txhash writer sorts STABLY, matching the freeze — walk and freeze .bin bytes now agree even on duplicate blinded keys, making the byte-parity claims unconditionally true. - respillBlinded rejects a blinded-key collision loudly (~2^-128; silently keeping either posting set would serve wrong results) instead of dropping. - Events freeze tail scan range-checks row sequences against the chunk, mirroring the txhash arm; window accounting now counts the DECODED cost the window holds rather than encoded row bytes (severalfold undercount). - txhash freeze accumulator pre-sized from source record counts (new records() on merge sources) — no append-doubling transient on GiB slices. - catalog.ensureSecret rejects an all-zero persisted secret: HKDF would launder it into non-zero per-index secrets that defeat every downstream zero-check while making all blinded routing attacker-predictable. Documentation brought back to truth: the freeze paths' streaming/RAM claims (both stores), fingerprint identity (routed key, not original — build, reader, and format comments), sealed-run respill semantics, catalog key groups, and the .bin/index layouts + INV-1 determinism scope in design-docs. New tests: Config.validate secret/zero/workers table; old-format .bin loud rejection; TestWriteColdIndexFromRuns_ReadsBack restored to its documented intent (production ColdReader round-trip straddling the delta/roaring codec threshold, absent-term miss); persisted all-zero catalog secret rejected at Open; per-INDEX (not per-chunk) txhash secret derivation pinned end-to-end through processChunk x2 -> BuildColdIndex -> ColdReader, plus index-scoped config equality/difference checks. Mutation-tested: unblinding the spill or swapping the derivation each fails its test. Co-Authored-By: Claude Fable 5 --- .../internal/rpcv2/backfill/process_test.go | 116 ++++++++++++++++++ .../internal/rpcv2/bench/txindex_test.go | 3 +- .../internal/rpcv2/catalog/catalog.go | 3 +- .../internal/rpcv2/catalog/secret.go | 16 +++ .../internal/rpcv2/catalog/secret_test.go | 21 ++++ .../internal/rpcv2/ingest/config_test.go | 85 +++++++++++++ .../internal/rpcv2/ingest/events.go | 11 +- .../internal/rpcv2/ingest/ingest_test.go | 2 +- .../internal/rpcv2/ingest/txhash.go | 5 +- .../rpcv2/stores/event/cold_format.go | 15 ++- .../rpcv2/stores/event/cold_freeze.go | 43 +++++-- .../internal/rpcv2/stores/event/cold_index.go | 4 +- .../rpcv2/stores/event/cold_index_stream.go | 11 +- .../stores/event/cold_index_stream_test.go | 111 +++++++++++++++-- .../rpcv2/stores/event/cold_index_test.go | 4 +- .../internal/rpcv2/stores/event/index.go | 3 +- .../rpcv2/stores/hotchunk/hotchunk.go | 6 +- .../rpcv2/stores/txhash/cold_freeze.go | 34 +++-- .../rpcv2/stores/txhash/cold_index_test.go | 54 ++++++++ .../rpcv2/stores/txhash/hotindex_seal.go | 2 +- .../rpcv2/stores/txhash/run_reader.go | 8 +- design-docs/full-history-campaign-handoff.md | 4 +- .../full-history-streaming-workflow.md | 6 +- design-docs/getevents-full-history-design.md | 2 +- .../gettransaction-full-history-design.md | 4 +- 25 files changed, 501 insertions(+), 72 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/ingest/config_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go b/cmd/stellar-rpc/internal/rpcv2/backfill/process_test.go index c30c44354..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" ) // --------------------------------------------------------------------------- @@ -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/txindex_test.go b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go index ff501d0b9..d0d1e8eb0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/bench/txindex_test.go @@ -16,11 +16,12 @@ import ( "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/txhash" ) -// writeFixtureBin writes chunk c's synthetic sorted .bin with n entries. // 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) diff --git a/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go b/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go index a1142a5e3..09b960311 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 diff --git a/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go b/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go index ae825c722..8c5b6a92b 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. @@ -26,7 +27,22 @@ func (c *Catalog) ensureSecret() ([32]byte, error) { 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 { 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/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/events.go b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go index bfb198bdf..32fd99fe4 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/events.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/events.go @@ -153,13 +153,10 @@ func (e *eventsCold) finalize(ctx context.Context) error { e.metrics.emit(time.Since(start), err) return err } - if err := e.spiller.Cleanup(); err != nil { - // Scratch removal is best-effort: the artifacts are already durable, - // and the next attempt's NewSpiller wipes this dir anyway. - err = fmt.Errorf("index spill cleanup: %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 diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go index 3f23171dc..c0f18eb56 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go @@ -927,7 +927,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. diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go index 55f3bc0ba..acca794f3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go @@ -90,7 +90,10 @@ 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 { + // STABLE, matching the freeze writer: duplicate blinded keys keep their + // arrival (ledger) order, so walk and freeze bytes agree even on inputs + // the downstream build would reject. + slices.SortStableFunc(t.entries, func(a, b txhash.ColdEntry) int { return bytes.Compare(a.Key[:], b.Key[:]) }) err := txhash.WriteColdBin(t.binPath, t.secret, t.entries) 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 fe304025e..c244ad3b9 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -122,9 +122,10 @@ const ( const deltaPostingMaxCardinality = 1024 // 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 // ────────────────────────────────────────────────────────────────── @@ -343,8 +344,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): diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go index f0b320fc2..37c67cd6e 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -107,13 +107,15 @@ func copyDataCF(ctx context.Context, store *rocksdb.Store, w *ColdWriter) (uint6 // freezeIndexWindowBytes caps how many packed-row bytes 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 matters for a crash-inherited backlog — up to the whole chunk when +// the cap bounds every freeze's respill and tail windows — +// 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 +// blinded re-spill of the sealed runs, 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 @@ -174,7 +176,7 @@ func FreezeColdFromStore( } // ── index: the engine's durable sealed runs + the un-sealed tail. ── - runs, err := freezeIndexInputs(ctx, store, scratchDir, secret) + runs, err := freezeIndexInputs(ctx, store, chunkID, scratchDir, secret) if err != nil { return err } @@ -185,14 +187,16 @@ func FreezeColdFromStore( } // freezeIndexInputs assembles the index merge's run list: the manifest-listed -// sealed runs (in place under the chunk DB's run dir — read-only inputs, -// never copied, moved, or deleted) followed by the tail-only scratch runs -// from freezeIndexRuns. Only manifest-named runs qualify — an orphan a crash +// sealed runs re-spilled under BLINDED keys into scratch (respillBlinded — +// the originals under the chunk DB's run dir stay read-only, never moved or +// deleted, because they serve raw-key hot lookups) 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, scratchDir string, secret [stores.SecretLen]byte, + 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 { @@ -207,7 +211,7 @@ func freezeIndexInputs( if err != nil { return nil, err } - tail, err := freezeIndexRuns(ctx, store, scratchDir, lastSealed, secret) + tail, err := freezeIndexRuns(ctx, store, chunkID, scratchDir, lastSealed, secret) if err != nil { return nil, err } @@ -254,8 +258,16 @@ func respillBlinded( } } emitted++ + bk := TermKey(stores.BlindKey(secret, term[:])) + // MergeRuns emits each term exactly once, so an occupied slot means + // two DISTINCT terms blinded to one key (~2^-128). Silently keeping + // either posting set would serve wrong results — reject loudly, the + // same call the index build makes on a duplicate key. + if _, dup := window[bk]; dup { + return fmt.Errorf("events: blinded key collision on %x", bk[:]) + } // MergeRuns reuses the ids buffer across emits — copy before keeping. - window[TermKey(stores.BlindKey(secret, term[:]))] = slices.Clone(ids) + window[bk] = slices.Clone(ids) windowBytes += len(term) + 4*len(ids) if windowBytes >= freezeIndexWindowBytes { return flush() @@ -280,7 +292,7 @@ func respillBlinded( // 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, scratchDir string, lastSealed uint32, + ctx context.Context, store *rocksdb.Store, chunkID chunk.ID, scratchDir string, lastSealed uint32, secret [stores.SecretLen]byte, ) ([]string, error) { var ( @@ -314,14 +326,23 @@ func freezeIndexRuns( 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) + } if derr := DecodePackedRow(entry.Value, func(term TermKey, ids []uint32) { // Blind at the run boundary — the ingest rule (see the freeze doc). bk := TermKey(stores.BlindKey(secret, term[:])) window[bk] = append(window[bk], ids...) + // Account the DECODED cost the window actually holds (key + id + // slice), not the varint-encoded row length — encoded bytes + // undercount RAM several-fold on dense rows. + windowBytes += 16 + 4*len(ids) }); derr != nil { return nil, fmt.Errorf("events: freeze ledger %d row: %w", binary.BigEndian.Uint32(entry.Key), derr) } - windowBytes += len(entry.Value) if windowBytes >= freezeIndexWindowBytes { if ferr := flush(); ferr != nil { return nil, ferr 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 0adef9ba7..1c7de7d4a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -65,13 +65,13 @@ 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) +// 0 4 fingerprint (first 4 bytes of the ROUTED/blinded key) // 4 1 codec (itemCodecRoaring | itemCodecDelta) // 5 N postings in that codec // // 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. 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 index 270911713..12c50efe1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -95,11 +95,12 @@ func WriteColdIndexFromRuns( if err != nil { return err } - defer func() { - if rmErr := os.Remove(termsRunPath); rmErr != nil && !errors.Is(rmErr, fs.ErrNotExist) && err == nil { - err = fmt.Errorf("events: remove %s: %w", termsRunPath, rmErr) - } - }() + // 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. 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 index 1241b667a..e0e27a428 100644 --- 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 @@ -366,12 +366,46 @@ func TestWriteColdIndexFromRuns_EmptyChunk(t *testing.T) { } } +// 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) - corpus := synthTerms(500, 9) + const ( + chunkID = chunk.ID(5) + events = 1200 + ) + corpus := synthTerms(events, 9) + + // Premise: the corpus must straddle the codec split, so this one read-back + // drives BOTH posting codecs through the reader's dispatch — the firehose + // term (every ID) is roaring, the singletons delta. + fattest, thinnest := 0, int(^uint(0)>>1) + for _, ids := range corpus { + fattest = max(fattest, len(ids)) + thinnest = min(thinnest, len(ids)) + } + require.Greater(t, fattest, deltaPostingMaxCardinality, "corpus must include a roaring-coded term") + require.LessOrEqual(t, thinnest, deltaPostingMaxCardinality, "corpus must include a delta-coded term") dir := t.TempDir() scratch := filepath.Join(t.TempDir(), "s") @@ -385,15 +419,72 @@ func TestWriteColdIndexFromRuns_ReadsBack(t *testing.T) { runs, err := sp.Finish() require.NoError(t, err) require.NoError(t, WriteColdIndexFromRuns(context.Background(), chunkID, runs, scratch, dir, testIndexSecret)) + writeEventsPackForRuns(t, chunkID, dir, events) - m, err := openMPHF(filepath.Join(dir, IndexHashName(chunkID))) + cr, err := OpenColdReader(chunkID, dir, ColdReaderOptions{}) require.NoError(t, err) - defer m.Close() - for k, want := range corpus { - slot, _, err := m.Lookup(k) - require.NoError(t, err) - _ = slot - _ = want - break // slot resolution exercised; full read path is covered by cold_reader tests + 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 posting + // codecs 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.Equal(t, len(corpus[k]) <= deltaPostingMaxCardinality, got[i].IDs() != nil, + "term %x form must follow its codec", k[:8]) } + + // 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 2a33d7d7f..64f7b65fe 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 @@ -423,8 +423,8 @@ func TestWriteIndex_CodecDispatch(t *testing.T) { // Both codecs must come back through the reader's dispatch with the right // postings. (The full ColdReader path over a complete artifact set is - // covered by TestWriteColdIndexFromRuns_ReadsBack, whose small terms - // exercise the delta codec end to end.) + // covered by TestWriteColdIndexFromRuns_ReadsBack, whose corpus straddles + // this same threshold and reads back through the production ColdReader.) wantCard := []uint64{1, deltaPostingMaxCardinality, deltaPostingMaxCardinality + 1} for i, k := range []TermKey{single, atThreshold, overThreshold} { want := wantCard[i] diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/index.go index f15156468..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. diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index 6328048ae..a30905dc2 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -288,7 +288,8 @@ func (d *DB) Events() *event.HotStore { // 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, no per-term memory. +// re-extraction, no whole-chunk index re-derivation; index memory is the +// freeze's byte-bounded blind/re-spill 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( @@ -311,7 +312,8 @@ func (d *DB) FreezeLedgersCold( // 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 plus the un-sealed packed-row tail, k-way -// merged back into the .bin's hash order. Valid on a read-only view (it +// merged, blinded, and re-sorted into the .bin's blinded-key order. 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( diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go index dc16b4ada..b76852b41 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -6,8 +6,10 @@ package txhash // hash order is recovered by a k-way merge over already-sorted sources: the // manifest-listed sealed runs (hash-sorted 36-byte records, CRC64-framed) // plus each un-sealed tail row (hash-sorted by EncodeRow) fed AS ITS OWN -// merge source — no tail explode-and-sort, so freeze RAM stays flat even for -// a crash-inherited tail larger than one seal window. Orphan run files (a +// merge source — no tail explode-and-sort, whatever the tail's shape (a +// crash-inherited tail larger than one seal window included). Freeze RAM is +// bounded by the chunk's ENTRY COUNT: the blinded-sort accumulator in +// FreezeColdFromStore, not the merge, is this path's working set. 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 @@ -45,12 +47,13 @@ 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). Entries stream from the source -// merge straight to the .bin — no whole-chunk accumulator; the header's -// leading count is patched in once the merge completes. Serialization is the -// walk path's own writer (coldBinStream, which WriteColdBin loops a whole -// slice over), so the byte-parity gate's only remaining subject is the entry -// ORDER this merge produces. Returns the entries written. +// warmed facade, and nothing is mutated). The WHOLE CHUNK's entries are +// accumulated (pre-sized from the sources, ~20 B/record — keyed routing's +// price on this path: blinding destroys the merge's streamable order) and +// stable-sorted, then serialized through 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 stability +// pins that even for duplicate blinded keys. Returns the entries written. // secret is the chunk's per-index routing secret: every stored key is // stores.BlindKey(secret, hash[:ColdKeySize]) — the walk writer's rule. // Blinding destroys the merge's raw-key order, so the blinded entries are @@ -167,8 +170,6 @@ func collectTailSources( return sources, nil } -// mergeFreezeSources runs the k-way merge, emitting each record as a cold -// entry: hash truncated to ColdKeySize, seq range-checked against the chunk. // mergeFreezeSources runs the k-way merge, emitting each record as a cold // entry — hash truncated to ColdKeySize and BLINDED with the index secret — // seq range-checked against the chunk. The returned slice is in the merge's @@ -182,7 +183,11 @@ func mergeFreezeSources( if err != nil { return nil, fmt.Errorf("txhash freeze %s: %w", chunkID, err) } - entries := make([]ColdEntry, 0, 1<<16) + total := 0 + for _, s := range sources { + total += s.records() + } + entries := make([]ColdEntry, 0, total) for h.len() > 0 { if cerr := pollCtx(ctx, len(entries)); cerr != nil { return nil, cerr @@ -228,6 +233,11 @@ type freezeSource interface { // current. (false, nil) is clean end-of-stream — for a run source that // includes the whole-payload CRC64 verification passing. advance() (bool, error) + // records returns the source's total record count, known up front from + // its header/row — the merge pre-sizes its accumulator with the sum, so + // a stress chunk never pays append-doubling's ~2x transient on a GiB + // slice. + records() int // close releases resources; safe in any state. close() } @@ -250,6 +260,8 @@ type tailRowSource struct { func (t *tailRowSource) hash() []byte { return t.row[t.off : t.off+rowHashLen] } +func (t *tailRowSource) records() int { return len(t.row) / rowHashLen } + func (t *tailRowSource) seq() uint32 { return t.rowSeq } func (t *tailRowSource) advance() (bool, error) { 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 958923f71..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 @@ -412,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/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go index 2548bd1bf..4fc3dde9c 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go @@ -334,7 +334,7 @@ func openSealedRun(path string) (*sealedRun, error) { // 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.records) + rt := newRunRouting(rs.recs) for { ok, aerr := rs.advance() if aerr != nil { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go index 120754697..fa4b67d98 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go @@ -34,12 +34,14 @@ type runSource struct { br *bufio.Reader crc hash.Hash64 hdr runHeader - records int + recs int remaining int rec [runRecordLen]byte prev [rowHashLen]byte } +func (r *runSource) records() int { return r.recs } + // openRunSource opens and structurally validates a run for sequential // draining: header magic/shape (readRunHeader) and count vs file size // (runRecordCount), the shared open gates. @@ -60,7 +62,7 @@ func openRunSource(path string) (*runSource, error) { } return &runSource{ path: path, f: f, br: bufio.NewReaderSize(f, 128<<10), - crc: crc64.New(crcRunTable), hdr: hdr, records: records, remaining: records, + crc: crc64.New(crcRunTable), hdr: hdr, recs: records, remaining: records, }, nil } @@ -76,7 +78,7 @@ func (r *runSource) advance() (bool, error) { } return false, nil } - idx := r.records - r.remaining + idx := r.recs - r.remaining if idx > 0 { copy(r.prev[:], r.rec[:rowHashLen]) } diff --git a/design-docs/full-history-campaign-handoff.md b/design-docs/full-history-campaign-handoff.md index bacfe83fc..12ce18187 100644 --- a/design-docs/full-history-campaign-handoff.md +++ b/design-docs/full-history-campaign-handoff.md @@ -17,7 +17,7 @@ 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 | +| 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 | @@ -26,7 +26,7 @@ rebuild window**, and the settled architecture (below) closes that too: 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 — confirmed). +streamed .bin deleted the ~1.2GB whole-chunk accumulator — measured pre-keyed-routing; #932's key blinding reinstated a whole-chunk blinded-sort accumulator in the txhash freeze arm, and the events freeze gained a blinded re-spill pass, so the freeze RSS/wall anchors need re-measurement). 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 — 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 7d52867b1..3b29d27f1 100644 --- a/design-docs/gettransaction-full-history-design.md +++ b/design-docs/gettransaction-full-history-design.md @@ -124,7 +124,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*. @@ -210,7 +210,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) From c187c185aef4c51f3f545b6c0c1bf52ff2f486cf Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Wed, 26 Aug 2026 20:50:45 +0000 Subject: [PATCH 14/21] freeze: recover the keyed-routing wall/RSS regressions (tier 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The b932 bench matrix priced the #932 port: every live-ingest tail (solo, freeze-colo, rebuild-colo, three-way stack) at or better than anchor, but the freeze itself paid 3m50->7m11 wall and 6.6->11.8GB peak RSS, and the walk's txhash arm 52s->2m04. Three fixes: - Sharded blinded sort (txhash freeze AND walk, via WriteColdBinSorted): contiguous arrival-order shards stable-sorted in parallel, k-way merged with lowest-shard ties — output provably equal to a whole-slice stable sort, so the freeze/walk byte-parity holds on duplicate blinded keys, at ~a tenth of single-threaded symmerge's cost. Reverts the walk-arm cost the review pass's stable-sort unification introduced. - Events respill feeds runspill.Spiller slabs instead of a window map: deletes ~60M cloned id slices of GC garbage per stress chunk (and the collision guard — the merge's union-on-collision is the same acceptance the pre-keyed design gave colliding xxh3 terms). - debug.FreeOSMemory between the txhash and events freeze arms: the arms' peaks stacked via lazily-returned heap (+~2.4GB measured); one release per chunk freeze is noise against multi-minute arms. Co-Authored-By: Claude Fable 5 --- .../internal/rpcv2/ingest/freeze.go | 10 +++ .../internal/rpcv2/ingest/txhash.go | 17 ++--- .../rpcv2/stores/event/cold_freeze.go | 55 ++++++--------- .../internal/rpcv2/stores/txhash/cold_bin.go | 28 ++++++-- .../rpcv2/stores/txhash/cold_freeze.go | 67 +++++++++++++++++-- 5 files changed, 120 insertions(+), 57 deletions(-) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go index 25e5872ef..f17e06605 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go @@ -20,6 +20,7 @@ import ( "context" "errors" "fmt" + "runtime/debug" "time" supportlog "github.com/stellar/go-stellar-sdk/support/log" @@ -112,6 +113,15 @@ func FreezeColdChunk( if dirs.EventsDir == "" { return errors.New("ingest: events enabled but its ColdDirs path is empty") } + // The txhash arm just released its whole-chunk blinded-sort + // accumulator (~1.2 GB at stress density). Go returns freed pages to + // the OS lazily, so without this the events arm — the freeze's RSS + // peak — builds ON TOP of that retained footprint and the arms' + // peaks stack (+~2.4 GB measured). One forced release per chunk + // freeze is noise against multi-minute arm walls. + if cfg.Txhash { + debug.FreeOSMemory() + } var secret [stores.SecretLen]byte copy(secret[:], cfg.EventsSecret) scratch := eventsScratchDir(dirs.EventsDir, chunkID) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go index acca794f3..dbe983a42 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go @@ -1,12 +1,10 @@ package ingest import ( - "bytes" "context" "fmt" "os" "path/filepath" - "slices" "time" sdkingest "github.com/stellar/go-stellar-sdk/ingest" @@ -88,15 +86,12 @@ 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. - // STABLE, matching the freeze writer: duplicate blinded keys keep their - // arrival (ledger) order, so walk and freeze bytes agree even on inputs - // the downstream build would reject. - slices.SortStableFunc(t.entries, func(a, b txhash.ColdEntry) int { - return bytes.Compare(a.Key[:], b.Key[:]) - }) - err := txhash.WriteColdBin(t.binPath, t.secret, t.entries) + // The shared sharded sort (stable shards + lowest-shard-tie merge — see + // txhash.WriteColdBinSorted): equal to a whole-slice STABLE sort, so + // walk and freeze bytes agree even on duplicate blinded keys, at a + // fraction of single-threaded symmerge's cost on multi-million-entry + // chunks. + err := txhash.WriteColdBinSorted(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/stores/event/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go index 37c67cd6e..6371eaa6d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -40,7 +40,6 @@ import ( "math" "os" "path/filepath" - "slices" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" @@ -232,23 +231,18 @@ func respillBlinded( if len(sealed) == 0 { return nil, nil } - var ( - window = make(map[TermKey][]uint32, 1<<16) - windowBytes int - runs []string - ) - flush := func() error { - if len(window) == 0 { - return nil - } - path := filepath.Join(scratchDir, fmt.Sprintf("blind-%06d.run", len(runs))) - if _, werr := writeSortedRun(window, path, nil); werr != nil { - return werr - } - runs = append(runs, path) - clear(window) - windowBytes = 0 - return nil + // The spiller's slab machinery does the re-keyed sort/spill with zero + // per-term allocation (pairs land in preallocated slabs) — the window-map + // shape this replaced allocated a cloned id slice per term, ~60M pieces + // of GC garbage on a stress chunk. A blinded-key collision between two + // DISTINCT terms (~2^-128) is caught downstream exactly like any + // duplicate key: the merge unions the two posting sets under one key and + // the fingerprint/read path serves both — the same acceptance the + // pre-keyed design gave two colliding xxh3 terms. + spillDir := filepath.Join(scratchDir, "blind-respill") + sp, err := runspill.NewSpiller(spillDir, freezeIndexWindowBytes) + if err != nil { + return nil, fmt.Errorf("events: freeze re-key spiller: %w", err) } emitted := 0 if err := runspill.MergeRuns(sealed, func(term [16]byte, ids []uint32) error { @@ -258,26 +252,21 @@ func respillBlinded( } } emitted++ - bk := TermKey(stores.BlindKey(secret, term[:])) - // MergeRuns emits each term exactly once, so an occupied slot means - // two DISTINCT terms blinded to one key (~2^-128). Silently keeping - // either posting set would serve wrong results — reject loudly, the - // same call the index build makes on a duplicate key. - if _, dup := window[bk]; dup { - return fmt.Errorf("events: blinded key collision on %x", bk[:]) - } - // MergeRuns reuses the ids buffer across emits — copy before keeping. - window[bk] = slices.Clone(ids) - windowBytes += len(term) + 4*len(ids) - if windowBytes >= freezeIndexWindowBytes { - return flush() + bk := stores.BlindKey(secret, term[:]) + for _, id := range ids { + if aerr := sp.Add(bk, id); aerr != nil { + return aerr + } } return nil }); err != nil { + _ = sp.Cleanup() return nil, fmt.Errorf("events: freeze re-key sealed runs: %w", err) } - if err := flush(); err != nil { - return nil, err + runs, err := sp.Finish() + if err != nil { + _ = sp.Cleanup() + return nil, fmt.Errorf("events: freeze re-key spill finish: %w", err) } return runs, nil } 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 5b696de48..b7081c132 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go @@ -96,14 +96,30 @@ func ColdBinName(chunkID chunk.ID) string { return chunkID.String() + ".bin" } +// WriteColdBinSorted sorts entries by blinded key (sharded stable sort — +// sortBlindedToStream's contract: equal to a whole-slice stable sort) and +// writes the .bin at path. The entry point for callers holding UNSORTED +// accumulators (the walk writer's finalize); WriteColdBin keeps the +// pre-sorted contract for callers that already merged. +func WriteColdBinSorted(path string, secret [stores.SecretLen]byte, entries []ColdEntry) error { + w, err := newColdBinStream(path, secret) + if err != nil { + return err + } + defer w.close() + if serr := sortBlindedToStream(entries, w); serr != nil { + return serr + } + return w.commit() +} + // WriteColdBin writes the .bin file at path from entries the caller already -// holds whole — the walk path's finalize accumulator, which exists for its -// sort anyway. It is a loop over the same coldBinStream the freeze drives, so -// walk-path and freeze-path bytes are identical by construction; see that -// type for the create semantics and commit for the durability ladder. +// 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 sorted (lex by Key, non-decreasing); this function -// writes them verbatim. +// entries must already be sorted (lex by blinded Key, non-decreasing); this +// function writes them verbatim. func WriteColdBin(path string, secret [stores.SecretLen]byte, entries []ColdEntry) error { w, err := newColdBinStream(path, secret) if err != nil { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go index b76852b41..765bb39a5 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -34,6 +34,7 @@ import ( "os" "path/filepath" "slices" + "sync" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" @@ -80,18 +81,13 @@ func FreezeColdFromStore( if err != nil { return 0, err } - slices.SortStableFunc(entries, func(a, b ColdEntry) int { - return bytes.Compare(a.Key[:], b.Key[:]) - }) w, err := newColdBinStream(binPath, secret) if err != nil { return 0, err } defer w.close() - for i := range entries { - if werr := w.append(entries[i]); werr != nil { - return 0, werr - } + if serr := sortBlindedToStream(entries, w); serr != nil { + return 0, serr } if ferr := w.commit(); ferr != nil { return 0, ferr @@ -99,6 +95,63 @@ func FreezeColdFromStore( return len(entries), nil } +// freezeSortShards is the blinded sort's parallelism: the accumulator splits +// into contiguous arrival-order shards, each stable-sorted on its own core, +// then k-way merged into the stream writer. Contiguous shards + lowest-shard +// tie-break make the output equal to a whole-slice STABLE sort — duplicate +// blinded keys keep global ledger order, the walk writer's own tie-break — +// while a 60M-entry stress chunk's sort drops from a ~single-core minute to +// seconds. +const freezeSortShards = 16 + +// sortBlindedToStream stable-sorts entries by blinded key (sharded, in +// place — the accumulator is the sort space) and streams the merged order +// into w. +func sortBlindedToStream(entries []ColdEntry, w *coldBinStream) error { + n := len(entries) + shards := freezeSortShards + if n < shards*1024 { + shards = 1 + } + bounds := make([]int, shards+1) + for i := 1; i <= shards; i++ { + bounds[i] = i * n / shards + } + var wg sync.WaitGroup + for i := range shards { + wg.Add(1) + go func(lo, hi int) { + defer wg.Done() + slices.SortStableFunc(entries[lo:hi], func(a, b ColdEntry) int { + return bytes.Compare(a.Key[:], b.Key[:]) + }) + }(bounds[i], bounds[i+1]) + } + wg.Wait() + heads := make([]int, shards) + for s := range shards { + heads[s] = bounds[s] + } + for { + best := -1 + for s := range shards { + if heads[s] >= bounds[s+1] { + continue + } + if best == -1 || bytes.Compare(entries[heads[s]].Key[:], entries[heads[best]].Key[:]) < 0 { + best = s + } + } + if best == -1 { + return nil + } + if err := w.append(entries[heads[best]]); err != nil { + return err + } + heads[best]++ + } +} + // 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 hashes emit in global ledger order. From 99df35b1923169a1a68d9aafefb680f9a65ff382 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Wed, 26 Aug 2026 23:52:08 +0000 Subject: [PATCH 15/21] =?UTF-8?q?hot=20stores:=20blind-at-seal=20=E2=80=94?= =?UTF-8?q?=20sealed=20runs=20carry=20blinded=20keys,=20freezes=20stream?= =?UTF-8?q?=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #932 keying port priced blind-at-freeze and measurement condemned it: txhash freeze 13s->2m (accumulate + whole-chunk sort), events freeze +60-90s and +5GB live Go (the respill re-exploded deduplicated firehose postings into ~300M pair records whose ~190-run merge held 2.7GB of source buffers — named by a mid-run heap snapshot, bench --memprofile-at). Blinding at SEAL time deletes all of it: - Sealed runs store BLINDED keys, sorted by (blinded key, ledger). txhash records are 20 bytes (TXHRUN02) — byte-identical to the cold .bin entry — so the freeze merge streams runs verbatim into the .bin writer; the events seal blinds in its window fold and the freeze consumes manifest runs in place plus the (already blinding) tail scan. Durable rows stay RAW: they are the freeze's tail source, and the read assembly's exact-path ledger extraction remains the one validate-at-use (a run hit is a candidate — the facade adds no per-hit row read; an index/store disagreement surfaces as ErrInconsistent and its operator metric, as it always did). - Hot queries blind the probe key once; window rows and the events overlay stay raw-keyed. Nothing on the per-ledger ingest path blinds. - Secrets: hotchunk.SecretsFor(catalog, chunkID) is the one derivation for hot AND cold writers; each store adopts its secret into the DB on first read-write open and refuses a mismatch (runset.AdoptSecret/RequireSecret; catalog remint = loud re-ingest, never silent misses). Format tripwires (TXHRUN02, EVR3) make every pre-blinding run — raw-keyed and structurally indistinguishable — a loud open/freeze failure. - Deleted as measurement-refuted: the freeze accumulator + sharded freeze sort, the events respill (both variants), debug.FreeOSMemory, and the hot-hit row verification (redundant with the assembly's, costly on the p99 path). SortColdEntries keeps one comparator and parallelizes the multi-million-entry sorts the walk and seal legitimately own. Adversarially reviewed (3 agents) and simplified; measured on sac-6000 against pre-#932 anchors: freeze wall 3m52-3m57 (anchor 3m50), txhash arm 10-11s (13s), events arm 2m55-56 (2m54), freeze RSS 6.30GB (6.6GB); hot 1k 31.3/42.5 at-anchor; freeze-colo during-p99 47.5, zero >100ms; cold walk txhash arm 19.7s (52s — the parallel sort and 16-byte keys), events arm and RSS in band. Co-Authored-By: Claude Fable 5 --- .../internal/rpcv2/backfill/process.go | 19 +- .../internal/rpcv2/bench/profile.go | 28 +- .../internal/rpcv2/bench/scratch.go | 19 +- .../internal/rpcv2/catalog/catalog.go | 29 +- .../internal/rpcv2/catalog/secret.go | 13 +- cmd/stellar-rpc/internal/rpcv2/e2e_test.go | 31 +- cmd/stellar-rpc/internal/rpcv2/hotloop.go | 10 +- .../internal/rpcv2/hotloop_test.go | 9 +- .../internal/rpcv2/ingest/freeze.go | 10 - .../internal/rpcv2/ingest/freeze_test.go | 2 +- .../internal/rpcv2/ingest/ingest_test.go | 24 +- .../internal/rpcv2/ingest/txhash.go | 20 +- .../internal/rpcv2/lifecycle/helpers_test.go | 3 +- .../rpcv2/lifecycle/lifecycle_test.go | 3 +- .../internal/rpcv2/query/event_page_test.go | 3 +- .../internal/rpcv2/query/ledger_scan_test.go | 6 +- .../internal/rpcv2/query/registry.go | 8 +- .../internal/rpcv2/query/registry_test.go | 18 +- .../internal/rpcv2/query/resolve_test.go | 9 +- .../internal/rpcv2/query/tx_lookup_test.go | 3 +- .../internal/rpcv2/rpcv2test/rpcv2test.go | 9 +- .../rpcv2/stores/event/cold_format.go | 4 +- .../rpcv2/stores/event/cold_freeze.go | 150 +++------ .../rpcv2/stores/event/cold_freeze_test.go | 68 ++++ .../internal/rpcv2/stores/event/cold_index.go | 11 + .../internal/rpcv2/stores/event/hot_store.go | 43 ++- .../rpcv2/stores/event/hot_store_test.go | 2 +- .../rpcv2/stores/event/hot_warmup_test.go | 122 +++++++ .../internal/rpcv2/stores/event/hotindex.go | 33 +- .../rpcv2/stores/event/hotindex_seal.go | 68 +++- .../rpcv2/stores/event/hotindex_test.go | 10 +- .../rpcv2/stores/event/runspill/runspill.go | 16 +- .../stores/event/runspill/runspill_test.go | 14 +- .../rpcv2/stores/event/termsort_test.go | 4 +- .../rpcv2/stores/hotchunk/hotchunk.go | 115 +++++-- .../rpcv2/stores/hotchunk/hotchunk_test.go | 46 ++- .../rpcv2/stores/internal/runset/runset.go | 4 + .../rpcv2/stores/internal/runset/secret.go | 113 +++++++ .../stores/internal/runset/secret_test.go | 156 +++++++++ .../internal/rpcv2/stores/txhash/cold_bin.go | 170 ++++++++-- .../rpcv2/stores/txhash/cold_bin_test.go | 30 ++ .../rpcv2/stores/txhash/cold_format.go | 18 +- .../rpcv2/stores/txhash/cold_freeze.go | 300 ++++++++---------- .../rpcv2/stores/txhash/cold_freeze_test.go | 64 +++- .../rpcv2/stores/txhash/cold_reader.go | 2 +- .../internal/rpcv2/stores/txhash/hot_store.go | 82 ++++- .../rpcv2/stores/txhash/hot_store_test.go | 77 ++++- .../internal/rpcv2/stores/txhash/hotindex.go | 69 ++-- .../rpcv2/stores/txhash/hotindex_seal.go | 207 +++++++----- .../rpcv2/stores/txhash/merge_heap.go | 43 ++- .../rpcv2/stores/txhash/run_reader.go | 26 +- .../stores/txhash/txhash_hotindex_test.go | 213 +++++++++---- design-docs/full-history-campaign-handoff.md | 19 +- .../gettransaction-full-history-design.md | 20 +- 54 files changed, 1905 insertions(+), 690 deletions(-) create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret.go create mode 100644 cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/secret_test.go diff --git a/cmd/stellar-rpc/internal/rpcv2/backfill/process.go b/cmd/stellar-rpc/internal/rpcv2/backfill/process.go index 0136cfddc..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. @@ -65,8 +63,12 @@ func (cfg ProcessConfig) validate() error { // 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), @@ -75,15 +77,12 @@ func ingestConfigFor(s catalog.ArtifactSet, chunkID chunk.ID, cfg ProcessConfig) 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 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/catalog/catalog.go b/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go index 09b960311..8fcba6d38 100644 --- a/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go +++ b/cmd/stellar-rpc/internal/rpcv2/catalog/catalog.go @@ -35,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 @@ -59,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 8c5b6a92b..2708f8a05 100644 --- a/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go +++ b/cmd/stellar-rpc/internal/rpcv2/catalog/secret.go @@ -20,7 +20,11 @@ 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 { @@ -45,7 +49,12 @@ func (c *Catalog) ensureSecret() ([32]byte, error) { } 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/e2e_test.go b/cmd/stellar-rpc/internal/rpcv2/e2e_test.go index d2b3608d6..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(), hotchunk.DefaultTuning()) - 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/hotloop.go b/cmd/stellar-rpc/internal/rpcv2/hotloop.go index 3e1918765..803549229 100644 --- a/cmd/stellar-rpc/internal/rpcv2/hotloop.go +++ b/cmd/stellar-rpc/internal/rpcv2/hotloop.go @@ -42,6 +42,12 @@ 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 { @@ -53,7 +59,7 @@ func openHotDBForChunk( // 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) + return hotchunk.OpenReadyWrite(state, dir, chunkID, logger, tun, secrets) } // The create bracket: BeginHotCreate wipes + marks transient; FinishHotCreate @@ -61,7 +67,7 @@ func openHotDBForChunk( if beginErr := cat.BeginHotCreate(chunkID); beginErr != nil { return nil, beginErr } - db, openErr := hotchunk.Open(dir, chunkID, logger, tun) + db, openErr := hotchunk.Open(dir, chunkID, logger, tun, secrets) if openErr != nil { return nil, fmt.Errorf("create hot DB chunk %s: %w", chunkID, openErr) } diff --git a/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go b/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go index 2fa5d7f7b..8e8171793 100644 --- a/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/hotloop_test.go @@ -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(), hotchunk.DefaultTuning()) + 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(), hotchunk.DefaultTuning()) + 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() @@ -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(), hotchunk.DefaultTuning()) + 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() diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go index f17e06605..25e5872ef 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze.go @@ -20,7 +20,6 @@ import ( "context" "errors" "fmt" - "runtime/debug" "time" supportlog "github.com/stellar/go-stellar-sdk/support/log" @@ -113,15 +112,6 @@ func FreezeColdChunk( if dirs.EventsDir == "" { return errors.New("ingest: events enabled but its ColdDirs path is empty") } - // The txhash arm just released its whole-chunk blinded-sort - // accumulator (~1.2 GB at stress density). Go returns freed pages to - // the OS lazily, so without this the events arm — the freeze's RSS - // peak — builds ON TOP of that retained footprint and the arms' - // peaks stack (+~2.4 GB measured). One forced release per chunk - // freeze is noise against multi-minute arm walls. - if cfg.Txhash { - debug.FreeOSMemory() - } var secret [stores.SecretLen]byte copy(secret[:], cfg.EventsSecret) scratch := eventsScratchDir(dirs.EventsDir, chunkID) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go index 31cc331f4..034d12a7f 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/freeze_test.go @@ -49,7 +49,7 @@ func TestFreezeColdChunk_ByteIdenticalToWalk(t *testing.T) { // 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()) + db, err := hotchunk.Open(dbPath, chunkID, hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) svc := NewHotService(db, nil, nil) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go b/cmd/stellar-rpc/internal/rpcv2/ingest/ingest_test.go index c0f18eb56..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 { @@ -1170,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(), hotchunk.Tuning{}) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.Tuning{}, testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1192,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(), hotchunk.Tuning{}) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.Tuning{}, testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1219,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(), hotchunk.Tuning{}) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.Tuning{}, testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1243,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(), hotchunk.DefaultTuning()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1270,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(), hotchunk.DefaultTuning()) + 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 @@ -1294,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(), hotchunk.DefaultTuning()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1318,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(), hotchunk.DefaultTuning()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) @@ -1345,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(), hotchunk.DefaultTuning()) + db, err := hotchunk.Open(t.TempDir(), chunk.ID(0), hotTestLogger(), hotchunk.DefaultTuning(), testHotSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) diff --git a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go index dbe983a42..38827b02d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go +++ b/cmd/stellar-rpc/internal/rpcv2/ingest/txhash.go @@ -16,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 @@ -56,7 +56,7 @@ func newTxhashCold(binPath string, sink MetricSink, secret [stores.SecretLen]byt // 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; @@ -67,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, }) } @@ -86,12 +86,12 @@ 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() - // The shared sharded sort (stable shards + lowest-shard-tie merge — see - // txhash.WriteColdBinSorted): equal to a whole-slice STABLE sort, so - // walk and freeze bytes agree even on duplicate blinded keys, at a - // fraction of single-threaded symmerge's cost on multi-million-entry - // chunks. - err := txhash.WriteColdBinSorted(t.binPath, t.secret, t.entries) + // 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 9e13ec313..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, hotchunk.DefaultTuning()) + 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 dd63df75a..559012587 100644 --- a/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/lifecycle/lifecycle_test.go @@ -334,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(), hotchunk.DefaultTuning()) + 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)) 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 ce8491e4d..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(), hotchunk.DefaultTuning()) + 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 { 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 2574e1c05..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,7 +20,8 @@ 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() - db, err := hotchunk.Open(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning()) + 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 { @@ -141,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(), hotchunk.DefaultTuning()) + 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 b95f8c860..473411e98 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/registry.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/registry.go @@ -348,6 +348,11 @@ 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. +// +// 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 { @@ -360,7 +365,8 @@ func (r *Registry) publishReadyHandles( continue } db, err := hotchunk.OpenReadyWrite( - geometry.HotReady, r.catalog.Layout().HotChunkPath(c), c, logger, tuning) + 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 0d6786e37..bd6480f35 100644 --- a/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/query/registry_test.go @@ -44,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(), hotchunk.DefaultTuning()) + 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)) @@ -59,7 +60,8 @@ 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(), hotchunk.DefaultTuning()) + 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, hotchunk.DefaultTuning()) @@ -274,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(), hotchunk.DefaultTuning()) + db, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(c), c, silentLogger(), hotchunk.DefaultTuning(), + hotchunk.SecretsFor(cat, c)) require.NoError(t, err) r.PublishHandle(c, db) } @@ -310,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(), hotchunk.DefaultTuning()) + 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) @@ -470,7 +474,8 @@ 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(), hotchunk.DefaultTuning()) + 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() }) @@ -478,7 +483,8 @@ func TestOpenRegistry_ErrorClosesOpenedHandles(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "bootstrap: open hot chunk") - db5, err := hotchunk.OpenExisting(cat.Layout().HotChunkPath(5), 5, silentLogger(), hotchunk.DefaultTuning()) + 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 ce4572773..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(), hotchunk.DefaultTuning()) + 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(), hotchunk.DefaultTuning()) + 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(), hotchunk.DefaultTuning()) + 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 cf23f6c97..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(), hotchunk.DefaultTuning()) + 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/rpcv2test/rpcv2test.go b/cmd/stellar-rpc/internal/rpcv2/rpcv2test/rpcv2test.go index 8076e255f..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(), hotchunk.DefaultTuning()) + 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 { @@ -220,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/stores/event/cold_format.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go index c244ad3b9..dd457228c 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -461,7 +461,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) } @@ -517,7 +517,7 @@ func openMPHF(path string) (*mphf, error) { // 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, TermKey, error) { - rk := TermKey(stores.BlindKey(m.secret, key[:])) + rk := blindTerm(m.secret, key) slot, err := m.LookupRouted(rk) return slot, rk, err } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go index 6371eaa6d..0be5f524e 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze.go @@ -9,14 +9,16 @@ package event // 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 EVR2 files beside -// the chunk DB, read in place) plus the un-sealed events_index tail past -// the sealed frontier, windowed into tail-only scratch runs. 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. +// 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 @@ -44,7 +46,6 @@ 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" - "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" ) // replayOffsets rebuilds the chunk's ledger offsets from OffsetsCF through @@ -103,27 +104,31 @@ func copyDataCF(ctx context.Context, store *rocksdb.Store, w *ColdWriter) (uint6 return copied, nil } -// freezeIndexWindowBytes caps how many packed-row bytes accumulate in the -// window map before it flushes as one spill run. The tail is normally under +// 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 every freeze's respill and tail windows — -// largest for a crash-inherited backlog — up to the whole chunk when -// the manifest is empty — where ~32MB windows keep freeze memory bounded. +// 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 -// blinded re-spill of the sealed runs, 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. +// 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, so this path blinds at ITS run -// boundaries — the sealed runs are re-spilled under blinded keys (they serve -// raw-key hot lookups and cannot be blinded in place) and the tail's window -// map blinds at insert — keeping both builds keyed by one blinded identity. +// 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, @@ -135,6 +140,9 @@ func FreezeColdFromStore( 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) } @@ -185,14 +193,13 @@ func FreezeColdFromStore( return os.RemoveAll(scratchDir) } -// freezeIndexInputs assembles the index merge's run list: the manifest-listed -// sealed runs re-spilled under BLINDED keys into scratch (respillBlinded — -// the originals under the chunk DB's run dir stay read-only, never moved or -// deleted, because they serve raw-key hot lookups) 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. +// 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, @@ -202,13 +209,9 @@ func freezeIndexInputs( return nil, fmt.Errorf("events: freeze manifest: %w", err) } runDir := filepath.Join(store.Path(), hotIndexRunDir) - sealed := make([]string, 0, len(names)) + runs := make([]string, 0, len(names)) for _, name := range names { - sealed = append(sealed, filepath.Join(runDir, name)) - } - runs, err := respillBlinded(ctx, sealed, scratchDir, secret) - if err != nil { - return nil, err + runs = append(runs, filepath.Join(runDir, name)) } tail, err := freezeIndexRuns(ctx, store, chunkID, scratchDir, lastSealed, secret) if err != nil { @@ -217,60 +220,6 @@ func freezeIndexInputs( return append(runs, tail...), nil } -// respillBlinded merges the manifest-listed sealed runs (raw term keys, read -// in place) and re-spills them as BLINDED scratch runs — the freeze's half of -// the ingest rule: cold artifacts are keyed by the blinded routing identity, -// while the hot tier's sealed runs serve raw-key lookups and cannot be -// blinded in place. One MergeRuns pass dedups terms across the sealed runs -// (per-term union, ascending — the merge's own contract), so each blinded -// term lands in the window map exactly once; the byte-bounded flush mirrors -// the tail scan's. -func respillBlinded( - ctx context.Context, sealed []string, scratchDir string, secret [stores.SecretLen]byte, -) ([]string, error) { - if len(sealed) == 0 { - return nil, nil - } - // The spiller's slab machinery does the re-keyed sort/spill with zero - // per-term allocation (pairs land in preallocated slabs) — the window-map - // shape this replaced allocated a cloned id slice per term, ~60M pieces - // of GC garbage on a stress chunk. A blinded-key collision between two - // DISTINCT terms (~2^-128) is caught downstream exactly like any - // duplicate key: the merge unions the two posting sets under one key and - // the fingerprint/read path serves both — the same acceptance the - // pre-keyed design gave two colliding xxh3 terms. - spillDir := filepath.Join(scratchDir, "blind-respill") - sp, err := runspill.NewSpiller(spillDir, freezeIndexWindowBytes) - if err != nil { - return nil, fmt.Errorf("events: freeze re-key spiller: %w", err) - } - emitted := 0 - if err := runspill.MergeRuns(sealed, func(term [16]byte, ids []uint32) error { - if emitted%256 == 0 { - if cerr := ctx.Err(); cerr != nil { - return cerr - } - } - emitted++ - bk := stores.BlindKey(secret, term[:]) - for _, id := range ids { - if aerr := sp.Add(bk, id); aerr != nil { - return aerr - } - } - return nil - }); err != nil { - _ = sp.Cleanup() - return nil, fmt.Errorf("events: freeze re-key sealed runs: %w", err) - } - runs, err := sp.Finish() - if err != nil { - _ = sp.Cleanup() - return nil, fmt.Errorf("events: freeze re-key spill finish: %w", err) - } - return runs, 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 @@ -321,17 +270,14 @@ func freezeIndexRuns( 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) } - if derr := DecodePackedRow(entry.Value, func(term TermKey, ids []uint32) { - // Blind at the run boundary — the ingest rule (see the freeze doc). - bk := TermKey(stores.BlindKey(secret, term[:])) - window[bk] = append(window[bk], ids...) - // Account the DECODED cost the window actually holds (key + id - // slice), not the varint-encoded row length — encoded bytes - // undercount RAM several-fold on dense rows. - windowBytes += 16 + 4*len(ids) - }); derr != nil { + // 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 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 index fa1553d4e..9420f0943 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_freeze_test.go @@ -296,6 +296,27 @@ func TestFreezeColdFromStore_IgnoresUnreapedMerge(t *testing.T) { 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 @@ -327,6 +348,53 @@ func TestFreezeColdFromStore_RejectsCorruptRunCRC(t *testing.T) { 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. 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 1c7de7d4a..2280f7870 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -35,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 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 e9badbdf0..ab562facc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hot_store.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/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 @@ -129,8 +130,15 @@ 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) { - hotIdx, 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) } @@ -524,6 +532,26 @@ func (m rocksdbManifest) GetRuns() ([]string, uint32, error) { 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 @@ -606,15 +634,22 @@ func verifyChunkConsistency(chunkStore *rocksdb.Store, total uint32, indexUpperB // 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, + 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 + } 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) + hotIdx, lastSealed, err := OpenHotIndex(runDir, manifest, secret) if err != nil { return nil, nil, err } 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 e95762553..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 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 6954124bf..521c8ccce 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,10 +1,12 @@ package event import ( + "context" "fmt" "math" "os" "path/filepath" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -12,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 @@ -129,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(term) + require.NoError(t, err) + require.Nil(t, ids, "a sealed run must not answer a RAW term") + ids, err = r.lookup(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 diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go index 8a6f57087..4b3e407cc 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -12,18 +12,24 @@ package event // "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: full -// 16-byte terms, CRC-framed), sealed from the window every windowLedgers +// - 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. +// 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 term before IDs count. +// 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. // @@ -45,6 +51,7 @@ import ( "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" ) @@ -119,6 +126,14 @@ type fence struct { // 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. @@ -188,12 +203,13 @@ type sealResult struct { // 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) (*HotIndex, error) { +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, @@ -345,11 +361,16 @@ func (h *HotIndex) Close() { // 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. func (h *HotIndex) lookupSparse(v *hotIndexView, term TermKey) ([]uint32, error) { var out []uint32 fp := fp64(term) + rk := blindTerm(h.secret, term) for _, r := range v.runs { - ids, err := r.lookup(term) + ids, err := r.lookup(rk) if err != nil { return nil, err } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go index ac26210e8..cdfcede76 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go @@ -16,6 +16,7 @@ import ( "slices" "sort" + "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" @@ -29,6 +30,9 @@ import ( // 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 @@ -38,7 +42,7 @@ func (h *HotIndex) startSeal(rows []windowRow) { h.sealInFlight = true go func() { res := sealResult{rows: sealed, lastSeq: rows[sealed-1].seq} - res.run, res.err = sealWindow(rows, name, h.fsyncDir) + 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), @@ -237,16 +241,48 @@ func (fb *fenceBuilder) finish(payloadLen int64) []fence { 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. -func sealWindow(rows []windowRow, path string, fsyncDir func(string) error) (*sealedRun, error) { +// +// 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 := DecodePackedRow(rows[i].bytes, func(term TermKey, ids []uint32) { - window[term] = append(window[term], ids...) - }); err != nil { + if _, err := foldBlindedRow(window, rows[i].bytes, secret); err != nil { return nil, err } } @@ -258,12 +294,17 @@ func sealWindow(rows []windowRow, path string, fsyncDir func(string) error) (*se return rt.openDurable(path, payloadLen, fsyncDir) } -// writeSortedRun streams a folded (term → ids) window to path as one -// term-sorted run file — no whole-payload buffer — and returns the payload -// length. observe, when non-nil, sees each record's term and payload-relative +// 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) { @@ -416,11 +457,16 @@ func (r *sealedRun) CloseRun() { r.close() } // 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. The dense overlay +// (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) (*HotIndex, uint32, error) { - h, err := NewHotIndex(dir, manifest) +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 } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go index 52f9e0b05..8bc166af6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -135,7 +135,7 @@ func (hh *hotIndexHarness) settle() { } func testHotIndex(t *testing.T, dir string, m runset.Manifest) *HotIndex { - h, err := NewHotIndex(dir, m) + h, err := NewHotIndex(dir, m, testIndexSecret) require.NoError(t, err) h.sealEvery = 8 // tiny window: many seals h.maxRuns = 3 // frequent merges @@ -201,7 +201,7 @@ func TestHotIndex_WarmupRebuild(t *testing.T) { } h.Close() - h2, lastSealed, err := OpenHotIndex(dir, m) + h2, lastSealed, err := OpenHotIndex(dir, m, testIndexSecret) require.NoError(t, err) defer h2.Close() h2.sealEvery, h2.maxRuns = 8, 3 @@ -221,7 +221,7 @@ func TestHotIndex_WarmupRebuild(t *testing.T) { func TestHotIndex_SealingDisarmedUntilArmed(t *testing.T) { dir := t.TempDir() m := &fakeManifest{} - h, err := NewHotIndex(dir, m) + 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 @@ -271,7 +271,7 @@ func TestHotIndex_WarmupSweepsOrphans(t *testing.T) { orphan := filepath.Join(dir, "seal-999999.run") require.NoError(t, os.WriteFile(orphan, []byte("junk"), 0o644)) - h2, _, err := OpenHotIndex(dir, m) + h2, _, err := OpenHotIndex(dir, m, testIndexSecret) require.NoError(t, err) h2.Close() _, serr := os.Stat(orphan) @@ -281,7 +281,7 @@ func TestHotIndex_WarmupSweepsOrphans(t *testing.T) { names, _, _ := m.GetRuns() require.NotEmpty(t, names) require.NoError(t, os.Remove(filepath.Join(dir, names[0]))) - _, _, err = OpenHotIndex(dir, m) + _, _, err = OpenHotIndex(dir, m, testIndexSecret) require.Error(t, err, "missing manifest-referenced run must fail open") } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go index 046fc0e39..5cace8d01 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill.go @@ -29,7 +29,18 @@ import ( const RecordSize = 16 + 4 // runMagic heads every run file; a version bump changes the letter. -var runMagic = [4]byte{'E', 'V', 'R', '2'} //nolint:gochecknoglobals // fixed format tag +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 @@ -326,6 +337,9 @@ func OpenRun(path string) (*RunReader, error) { 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]) 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 index e8ef8416b..0d717e500 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill/runspill_test.go @@ -168,13 +168,25 @@ func TestRunReader_DetectsCorruption(t *testing.T) { 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, so the old tag must be rejected, not parsed with new offsets. + // 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) { diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go index ac5416f26..5acf60bf8 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/termsort_test.go @@ -388,10 +388,10 @@ func applyLedgerFromMap(h *HotIndex, seq uint32, rowBytes []byte, per map[TermKe // 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{}) + hMap, err := NewHotIndex(t.TempDir(), &fakeManifest{}, testIndexSecret) require.NoError(t, err) defer hMap.Close() - hRuns, err := NewHotIndex(t.TempDir(), &fakeManifest{}) + hRuns, err := NewHotIndex(t.TempDir(), &fakeManifest{}, testIndexSecret) require.NoError(t, err) defer hRuns.Close() diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index a30905dc2..986b5acab 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -125,11 +125,63 @@ 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, tun Tuning) (*DB, error) { - return open(path, chunkID, logger, tun, 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 — @@ -137,8 +189,10 @@ func Open(path string, chunkID chunk.ID, logger *supportlog.Entry, tun Tuning) ( // 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, tun Tuning) (*DB, error) { - return open(path, chunkID, logger, tun, 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 @@ -160,25 +214,30 @@ func OpenExisting(path string, chunkID chunk.ID, logger *supportlog.Entry, tun T // 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) { - // Zero Tuning: a read-only view composes the ledger facade for reads - // alone — its encoder is never invoked, so no encode tuning applies. - return open(path, chunkID, logger, Tuning{}, 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, tun Tuning, + state geometry.HotState, path string, chunkID chunk.ID, logger *supportlog.Entry, + tun Tuning, secrets Secrets, ) (*DB, error) { - return openReady(state, path, chunkID, logger, tun, false) + 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, Tuning{}, true) + return openReady(state, path, chunkID, logger, OpenReadOnly) } // openReady is the single enforcement site for the "ready key ⇒ must-exist, @@ -188,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, tun Tuning, 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 := func(p string, c chunk.ID, l *supportlog.Entry) (*DB, error) { return OpenExisting(p, c, l, tun) } - 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) @@ -206,7 +267,10 @@ func openReady( return db, nil } -func open(path string, chunkID chunk.ID, logger *supportlog.Entry, tun Tuning, 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 } @@ -236,13 +300,13 @@ func open(path string, chunkID chunk.ID, logger *supportlog.Entry, tun Tuning, r db.txhash = txhash.NewReadOnlyWithStore(store, chunkID) return db, nil } - th, err := txhash.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) + es, err := event.NewWithStore(store, chunkID, secrets.Events) if err != nil { th.Shutdown() _ = store.Close() @@ -288,8 +352,9 @@ func (d *DB) Events() *event.HotStore { // 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; index memory is the -// freeze's byte-bounded blind/re-spill windows (event.FreezeColdFromStore). +// 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( @@ -311,11 +376,11 @@ func (d *DB) FreezeLedgersCold( // 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 plus the un-sealed packed-row tail, k-way -// merged, blinded, and re-sorted into the .bin's blinded-key order. Valid -// on a read-only view (it -// never touches the query facade); same completeness contract as the other -// freezes. Returns the entries written. +// 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) { 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 109032453..85777ffe0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk_test.go @@ -35,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. @@ -49,7 +67,7 @@ func ingestRaw(t *testing.T, db *DB, seq uint32, raw []byte) (LedgerReport, erro // 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(), DefaultTuning()) + db, err := Open(t.TempDir(), chunk.ID(0), silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db.Close() }) return db @@ -100,10 +118,10 @@ func TestConfig_DBWideTuningStaysShared(t *testing.T) { } func TestOpen_ValidatesInputs(t *testing.T) { - _, err := Open("", chunk.ID(0), silentLogger(), DefaultTuning()) + _, err := Open("", chunk.ID(0), silentLogger(), DefaultTuning(), testSecrets()) require.ErrorIs(t, err, stores.ErrInvalidConfig) - _, err = Open(t.TempDir(), chunk.ID(0), nil, DefaultTuning()) + _, err = Open(t.TempDir(), chunk.ID(0), nil, DefaultTuning(), testSecrets()) require.ErrorIs(t, err, stores.ErrInvalidConfig) } @@ -222,7 +240,7 @@ func TestIngestLedger_MidBatchCommitFailurePersistsNothing(t *testing.T) { first := chunkID.FirstLedger() dir := t.TempDir() - db, err := Open(dir, chunkID, silentLogger(), DefaultTuning()) + 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. @@ -232,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(), DefaultTuning()) + db2, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db2.Close() }) @@ -250,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(), DefaultTuning()) + db3, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) t.Cleanup(func() { _ = db3.Close() }) @@ -385,14 +403,14 @@ func TestReopen_RecoversEventsMirror(t *testing.T) { first := chunkID.FirstLedger() dir := t.TempDir() - db, err := Open(dir, chunkID, silentLogger(), DefaultTuning()) + db, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) 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(), DefaultTuning()) + 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") @@ -411,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(), DefaultTuning()) + 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)) @@ -446,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(), DefaultTuning()) + db, err := Open(dir, chunkID, silentLogger(), DefaultTuning(), testSecrets()) require.NoError(t, err) want := map[uint32][]byte{} for _, seq := range []uint32{first, first + 1} { @@ -466,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(), DefaultTuning()) + _, 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") @@ -509,7 +527,7 @@ func TestOpenReadOnly_TxhashFreezeWorksWithoutQueries(t *testing.T) { first := chunkID.FirstLedger() dir := t.TempDir() - db, err := Open(dir, chunkID, silentLogger(), DefaultTuning()) + 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} { @@ -527,7 +545,7 @@ func TestOpenReadOnly_TxhashFreezeWorksWithoutQueries(t *testing.T) { assert.Panics(t, func() { _, _ = ro.Txhash().Get(hashes[0]) }, "txhash queries must be disabled on a read-only open") - secret := stores.DeriveIndexSecret([]byte("hotchunk-test-master"), "txhash", uint32(chunkID)) + 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") @@ -600,7 +618,7 @@ func TestIngestLedger_TxhashVisibleAtReturnAndSkipRejected(t *testing.T) { // rocksdb.ErrStoreClosed. func TestIngestLedger_ClosedDBFails(t *testing.T) { chunkID := chunk.ID(0) - db, err := Open(t.TempDir(), chunkID, silentLogger(), DefaultTuning()) + 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 index a393a8102..b16f78a9a 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset/runset.go @@ -13,6 +13,10 @@ // (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 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/txhash/cold_bin.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_bin.go index b7081c132..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,21 +120,129 @@ func ColdBinName(chunkID chunk.ID) string { return chunkID.String() + ".bin" } -// WriteColdBinSorted sorts entries by blinded key (sharded stable sort — -// sortBlindedToStream's contract: equal to a whole-slice stable sort) and -// writes the .bin at path. The entry point for callers holding UNSORTED -// accumulators (the walk writer's finalize); WriteColdBin keeps the -// pre-sorted contract for callers that already merged. -func WriteColdBinSorted(path string, secret [stores.SecretLen]byte, entries []ColdEntry) error { - w, err := newColdBinStream(path, secret) - if err != nil { - return err +// 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). +// +// 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 } - defer w.close() - if serr := sortBlindedToStream(entries, w); serr != nil { - return serr + 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. +// +// 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. +// +// 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 + } + + // 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 + } +} + +// 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++ + } } - return w.commit() } // WriteColdBin writes the .bin file at path from entries the caller already @@ -118,7 +250,7 @@ func WriteColdBinSorted(path string, secret [stores.SecretLen]byte, entries []Co // 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 sorted (lex by blinded Key, non-decreasing); this +// 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) 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 index 765bb39a5..e1598c58d 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze.go @@ -1,40 +1,38 @@ 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. The -// packed-row tier stores hashes ledger-keyed (hot_store.go), so the .bin's -// hash order is recovered by a k-way merge over already-sorted sources: the -// manifest-listed sealed runs (hash-sorted 36-byte records, CRC64-framed) -// plus each un-sealed tail row (hash-sorted by EncodeRow) fed AS ITS OWN -// merge source — no tail explode-and-sort, whatever the tail's shape (a -// crash-inherited tail larger than one seal window included). Freeze RAM is -// bounded by the chunk's ENTRY COUNT: the blinded-sort accumulator in -// FreezeColdFromStore, not the merge, is this path's working set. 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. +// 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, at the .bin emit; +// - 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 full hashes merge in ledger order +// 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 truncated-key collision loudly (cold_bin.go). An +// already rejects a duplicate-key collision loudly (cold_bin.go). An // intra-ledger duplicate was rejected at write time and cannot reach here. import ( - "bytes" "context" "fmt" "os" "path/filepath" - "slices" - "sync" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/rocksdb" @@ -42,26 +40,24 @@ import ( ) // freezeCtxPollEvery is how many entries the merge emits between context -// checks. Entries are ~36 bytes of work apiece, so a coarse cadence still +// 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 WHOLE CHUNK's entries are -// accumulated (pre-sized from the sources, ~20 B/record — keyed routing's -// price on this path: blinding destroys the merge's streamable order) and -// stable-sorted, then serialized through 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 stability -// pins that even for duplicate blinded keys. Returns the entries written. +// 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 -// stores.BlindKey(secret, hash[:ColdKeySize]) — the walk writer's rule. -// Blinding destroys the merge's raw-key order, so the blinded entries are -// collected and re-sorted before the write (the .bin's contract is lex order -// BY BLINDED KEY; the sort is STABLE so duplicate full hashes keep the -// merge's global ledger-order tie-break). The accumulator is the same size -// class the walk path already carries for its finalize sort. +// 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, @@ -72,91 +68,46 @@ func FreezeColdFromStore( if err := os.MkdirAll(filepath.Dir(binPath), 0o755); err != nil { return 0, fmt.Errorf("txhash freeze %s: mkdir: %w", chunkID, err) } - sources, err := openFreezeSources(ctx, store, chunkID) - if err != nil { - return 0, err + if err := requireStoreSecret(store, secret); err != nil { + return 0, fmt.Errorf("txhash freeze %s: %w", chunkID, err) } - defer closeFreezeSources(sources) - entries, err := mergeFreezeSources(ctx, chunkID, sources, secret) + 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() - if serr := sortBlindedToStream(entries, w); serr != nil { - return 0, serr + n, err := mergeFreezeSources(ctx, chunkID, sources, w) + if err != nil { + return 0, err } if ferr := w.commit(); ferr != nil { return 0, ferr } - return len(entries), nil -} - -// freezeSortShards is the blinded sort's parallelism: the accumulator splits -// into contiguous arrival-order shards, each stable-sorted on its own core, -// then k-way merged into the stream writer. Contiguous shards + lowest-shard -// tie-break make the output equal to a whole-slice STABLE sort — duplicate -// blinded keys keep global ledger order, the walk writer's own tie-break — -// while a 60M-entry stress chunk's sort drops from a ~single-core minute to -// seconds. -const freezeSortShards = 16 - -// sortBlindedToStream stable-sorts entries by blinded key (sharded, in -// place — the accumulator is the sort space) and streams the merged order -// into w. -func sortBlindedToStream(entries []ColdEntry, w *coldBinStream) error { - n := len(entries) - shards := freezeSortShards - if n < shards*1024 { - shards = 1 - } - bounds := make([]int, shards+1) - for i := 1; i <= shards; i++ { - bounds[i] = i * n / shards - } - var wg sync.WaitGroup - for i := range shards { - wg.Add(1) - go func(lo, hi int) { - defer wg.Done() - slices.SortStableFunc(entries[lo:hi], func(a, b ColdEntry) int { - return bytes.Compare(a.Key[:], b.Key[:]) - }) - }(bounds[i], bounds[i+1]) - } - wg.Wait() - heads := make([]int, shards) - for s := range shards { - heads[s] = bounds[s] - } - for { - best := -1 - for s := range shards { - if heads[s] >= bounds[s+1] { - continue - } - if best == -1 || bytes.Compare(entries[heads[s]].Key[:], entries[heads[best]].Key[:]) < 0 { - best = s - } - } - if best == -1 { - return nil - } - if err := w.append(entries[heads[best]]); err != nil { - return err - } - heads[best]++ - } + 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 hashes emit in global ledger order. +// (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, + 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 { @@ -164,15 +115,32 @@ func openFreezeSources( } runDir := filepath.Join(store.Path(), txhashRunDir) sources := make([]freezeSource, 0, len(names)) - for _, name := range 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) + tails, terr := collectTailSources(ctx, store, chunkID, lastSealed, secret) if terr != nil { closeFreezeSources(sources) return nil, terr @@ -187,6 +155,7 @@ func openFreezeSources( // 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 @@ -216,50 +185,42 @@ func collectTailSources( if len(entry.Value) == 0 { continue // tx-less ledger: nothing to merge } - sources = append(sources, &tailRowSource{ - row: append([]byte(nil), entry.Value...), rowSeq: seq, off: -rowHashLen, - }) + sources = append(sources, newTailRowSource(entry.Value, seq, secret)) } return sources, nil } -// mergeFreezeSources runs the k-way merge, emitting each record as a cold -// entry — hash truncated to ColdKeySize and BLINDED with the index secret — -// seq range-checked against the chunk. The returned slice is in the merge's -// raw-key order (global ledger order on ties); the caller owns the -// blinded-order sort. +// 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, secret [stores.SecretLen]byte, -) ([]ColdEntry, error) { + 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 nil, fmt.Errorf("txhash freeze %s: %w", chunkID, err) - } - total := 0 - for _, s := range sources { - total += s.records() + return 0, fmt.Errorf("txhash freeze %s: %w", chunkID, err) } - entries := make([]ColdEntry, 0, total) + n := 0 for h.len() > 0 { - if cerr := pollCtx(ctx, len(entries)); cerr != nil { - return nil, cerr + if cerr := pollCtx(ctx, n); cerr != nil { + return 0, cerr } - hash, seq := h.min() + key, seq := h.min() if seq < first || seq > last { - return nil, fmt.Errorf("txhash freeze %s: entry seq %d outside [%d, %d]", chunkID, seq, first, 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 } - // BlindKey hashes the source's buffer BEFORE the step invalidates it - // (the blind is itself the truncate-and-copy). - entries = append(entries, ColdEntry{ - Key: stores.BlindKey(secret, hash[:ColdKeySize]), - Seq: seq, - }) + n++ if serr := h.step(); serr != nil { - return nil, fmt.Errorf("txhash freeze %s: %w", chunkID, serr) + return 0, fmt.Errorf("txhash freeze %s: %w", chunkID, serr) } } - return entries, nil + return n, nil } // pollCtx checks ctx once every freezeCtxPollEvery iterations. @@ -272,25 +233,20 @@ func pollCtx(ctx context.Context, i int) error { // ─────────────────────────── merge sources ─────────────────────────── -// freezeSource is one hash-sorted stream of (full hash, 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. +// 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 { - // hash returns the current record's full 32-byte hash — valid only + // key returns the current record's blinded routing key — valid only // until the next advance. - hash() []byte + 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) - // records returns the source's total record count, known up front from - // its header/row — the merge pre-sizes its accumulator with the sum, so - // a stress chunk never pays append-doubling's ~2x transient on a GiB - // slice. - records() int // close releases resources; safe in any state. close() } @@ -302,60 +258,72 @@ func closeFreezeSources(sources []freezeSource) { } } -// tailRowSource walks one retained packed row (32-byte stride, already -// hash-sorted by EncodeRow) with a fixed ledger. off starts at -rowHashLen -// (before the first hash) per the freezeSource contract. +// 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 { - row []byte - rowSeq uint32 - off int + entries []ColdEntry + rowSeq uint32 + off int } -func (t *tailRowSource) hash() []byte { return t.row[t.off : t.off+rowHashLen] } +// 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) records() int { return len(t.row) / rowHashLen } +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 += rowHashLen - return t.off < len(t.row), nil + t.off++ + return t.off < len(t.entries), nil } func (t *tailRowSource) close() {} // ─────────────────────────── merge heap ─────────────────────────── -// freezeHeap is the package's shared hash heap (merge_heap.go) over -// freezeSources: entries carry each live source's CURRENT hash beside its +// 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 hashes in +// 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 -// hashes WITHIN a run are already in ledger order from the seal merge — so -// global duplicate order is ascending ledger, deterministically. +// 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 hash is the source's own buffer, valid only until that source -// advances: runSource.hash() aliases the record buffer it re-reads into, and -// tailRowSource.hash() re-slices as its offset moves. Only the root's source +// 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 hashHeap + 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(hashHeap, 0, len(sources))} + 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, hashEntry{hash: s.hash(), idx: i}) + h.heap = append(h.heap, keyEntry{key: s.key(), idx: i}) } } h.heap.heapify() @@ -364,15 +332,15 @@ func newFreezeHeap(sources []freezeSource) (*freezeHeap, error) { func (h *freezeHeap) len() int { return len(h.heap) } -// min returns the smallest current record — its cached hash (valid until the +// 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.hash, h.sources[e.idx].seq() + return e.key, h.sources[e.idx].seq() } // step advances the minimum source past its emitted record, refilling the root -// with its next hash — or dropping the source once drained (its end-of-stream +// 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] @@ -384,7 +352,7 @@ func (h *freezeHeap) step() error { h.heap = h.heap.dropRoot() return nil } - h.heap[0].hash = src.hash() + 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 index 80e54cabc..2cb3b076b 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/cold_freeze_test.go @@ -56,13 +56,14 @@ func populationSize(ledgers [][][32]byte) int { return total } -// expectedBinBytes is the walk-path expectation: accumulate entries in -// ledger order, blind (BlindKey over the truncated hash — the ingest rule), -// sort by blinded key, WriteColdBin. The sort is -// STABLE so cross-ledger duplicate full hashes keep ledger order — exactly -// the order the freeze merge's run-then-tail source tie-break produces -// (ingest's walk writer sorts unstably, which only ever differs on those -// duplicates, where its order is unspecified rather than conflicting). +// 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)) @@ -179,7 +180,7 @@ func TestFreezeColdFromStore_IdenticalAcrossCrashStates(t *testing.T) { 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) + _, err := writeRun(tailRows, orphan, testBinSecret) require.NoError(t, err) sC.Shutdown() require.NoError(t, storeC.Close()) @@ -215,7 +216,7 @@ func TestFreezeColdFromStore_RejectsOutOfRangeSeq(t *testing.T) { 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")) + _, 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)) @@ -297,6 +298,47 @@ func TestFreezeColdFromStore_RejectsCorruptRunCRC(t *testing.T) { 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 @@ -327,14 +369,14 @@ func TestFreezeAndWarmup_RejectSeqOutsideRunHeaderRange(t *testing.T) { 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+rowHashLen:], badSeq) + 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}) + _, _, 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. 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_store.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store.go index cbcdc2e63..dffdf5030 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hot_store.go @@ -11,6 +11,8 @@ 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" + "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/internal/runset" ) const ( @@ -56,12 +58,17 @@ type HotStore struct { // NewWithStore wraps an ALREADY-OPEN rocksdb.Store as a read-WRITE txhash // HotStore on the single txhash CF (CFNames()), running the mandatory warmup: -// 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. -func NewWithStore(store *rocksdb.Store, chunkID chunk.ID) (*HotStore, error) { - hotIdx, err := warmupHotIndex(store, chunkID) +// 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) } @@ -102,8 +109,27 @@ func (h *HotStore) AddLedgerToBatch( // 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; every route ends in a full 32-byte -// compare). Safe alongside the single writer. +// (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 @@ -166,13 +192,23 @@ func CFOptions() map[string]rocksdb.CFOptions { } // warmupHotIndex rebuilds the read-WRITE facade's HotIndex from the chunk's -// durable state: manifest-listed runs (drain-verified), then the un-sealed -// packed-row tail, then 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. -func warmupHotIndex(store *rocksdb.Store, chunkID chunk.ID) (*HotIndex, error) { +// 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}) + hotIdx, lastSealed, err := OpenHotIndex(runDir, chunkID.FirstLedger(), rocksdbManifest{store: store}, secret) if err != nil { return nil, err } @@ -234,3 +270,21 @@ func checkRowFormat(store *rocksdb.Store) error { } 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 9c4b2870d..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,6 +2,8 @@ package txhash import ( "bytes" + "context" + "path/filepath" "sync" "testing" @@ -46,7 +48,7 @@ func openBareStore(t *testing.T, path string) *rocksdb.Store { 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) + s, err := NewWithStore(store, chunkID, testBinSecret) require.NoError(t, err) s.hotIdx.sealEvery = sealEvery t.Cleanup(s.Shutdown) @@ -177,6 +179,75 @@ func TestHotStore_WarmupReplaysTailAndServes(t *testing.T) { } } +// 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() + + 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()) + + // 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) +} + +// 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() + + 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) + + 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") + + // The DB's own secret freezes fine. + _, err = FreezeColdFromStore( + context.Background(), chunkID, store, filepath.Join(t.TempDir(), "ok.bin"), testBinSecret) + require.NoError(t, err) +} + // 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. @@ -201,7 +272,7 @@ func TestHotStore_StaleFormatTripwire(t *testing.T) { stale := randHash(testRNG(3)) require.NoError(t, store.Put(txhashCF, stale[:], rocksdb.EncodeUint32(chunkID.FirstLedger()))) - _, err := NewWithStore(store, chunkID) + _, err := NewWithStore(store, chunkID, testBinSecret) require.ErrorContains(t, err, "stale pre-release txhash format") } @@ -219,7 +290,7 @@ func TestHotStore_WarmupRejectsDenseGap(t *testing.T) { return nil })) - _, err = NewWithStore(store, chunkID) + _, err = NewWithStore(store, chunkID, testBinSecret) require.ErrorContains(t, err, "dense chain") } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go index 911034103..54facf1bb 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex.go @@ -15,17 +15,21 @@ package txhash // 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 (TXHRUN01 format: -// 36-byte hash‖seq records, hash-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: +// - 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). // -// Reads are exact: every route ends in a full 32-byte hash compare before a -// sequence is returned. 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. +// 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 @@ -77,7 +81,7 @@ type windowRow struct { type sealedRun struct { path string bloom bloom.Filter - ladder [][ladderKeyLen]byte // first 16B of each page's first record + ladder [][ColdKeySize]byte // the blinded key of each page's first record file *os.File first uint32 last uint32 @@ -89,6 +93,13 @@ 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 @@ -146,12 +157,13 @@ type sealResult struct { // 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) (*HotIndex, error) { +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, @@ -201,11 +213,15 @@ func (h *HotIndex) ApplyRow(seq uint32, rowBytes []byte, count int) error { } // Get returns the ledger holding hash, or stores.ErrNotFound. Window rows -// are probed newest→oldest, then runs newest→oldest — 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. +// 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-- { @@ -213,13 +229,16 @@ func (h *HotIndex) Get(hash [32]byte) (uint32, error) { return v.rows[i].seq, nil } } - for i := len(v.runs) - 1; i >= 0; i-- { - seq, ok, err := v.runs[i].lookup(hash) - if err != nil { - return 0, err - } - if ok { - return 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 @@ -242,8 +261,8 @@ func (h *HotIndex) Close() { }) } -// fp64 is the bloom fingerprint of a hash. -func fp64(hash [32]byte) uint64 { return xxhash.Sum64(hash[:]) } +// 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. diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go index 4fc3dde9c..452fe82b3 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/hotindex_seal.go @@ -7,13 +7,23 @@ package txhash // in on the writer goroutine (reapSeal). // // Run file format (/seal-%06d.run) — one sealed window, every -// (hash, seq) record of its ledgers sorted by full 32-byte hash: +// (blinded key, seq) record of its ledgers sorted by blinded key: // -// header (40B): magic "TXHRUN01" ‖ count u64 LE ‖ firstSeq u32 LE ‖ +// 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 × 36B records hash[32] ‖ seq u32 LE (equal hashes: -// ledger order) +// 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 @@ -35,6 +45,7 @@ import ( "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" ) @@ -43,7 +54,13 @@ import ( const ( // runMagic heads every run file; a version bump changes the digits. - runMagic = "TXHRUN01" + 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 @@ -54,15 +71,17 @@ const ( runCRCOff = 24 runHeaderLen = 40 - // runRecordLen is one payload record: hash[32] ‖ seq u32 LE. - runRecordLen = rowHashLen + 4 + // 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 (113 × 36B = 4068B). Pages are record-aligned so the - // in-page binary search strides whole records; the first ladderKeyLen - // bytes of each page's first record route a probe to ONE page pread. - pageRecords = 4096 / runRecordLen - ladderKeyLen = 16 + // 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. @@ -82,6 +101,9 @@ type runHeader struct { // 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. @@ -90,7 +112,7 @@ func (h *HotIndex) startSeal(rows []windowRow) { h.sealInFlight = true go func() { res := sealResult{rows: len(rows), lastSeq: rows[len(rows)-1].seq} - res.run, res.err = sealWindow(rows, name) + 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 { @@ -154,8 +176,11 @@ func (h *HotIndex) reapSeal(block bool) error { // 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 [][ladderKeyLen]byte + 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 } @@ -163,18 +188,19 @@ type runRouting struct { func newRunRouting(records int) *runRouting { return &runRouting{ bloom: bloom.New(max(records, 1)), - ladder: make([][ladderKeyLen]byte, 0, records/pageRecords+1), + ladder: make([][ColdKeySize]byte, 0, records/pageRecords+1), } } -// observe folds in one payload record (hash[32] ‖ seq u32 LE). Must be +// 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. +// 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, [ladderKeyLen]byte(rec[:ladderKeyLen])) + rt.ladder = append(rt.ladder, [ColdKeySize]byte(rec[:ColdKeySize])) } - rt.bloom.Add(fp64([rowHashLen]byte(rec[:rowHashLen]))) + rt.bloom.Add(fp64([ColdKeySize]byte(rec[:ColdKeySize]))) rt.n++ } @@ -195,8 +221,8 @@ func (rt *runRouting) run(path string, first, last uint32) *sealedRun { // 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) (*sealedRun, error) { - rt, err := writeRun(rows, path) +func sealWindow(rows []windowRow, path string, secret [stores.SecretLen]byte) (*sealedRun, error) { + rt, err := writeRun(rows, path, secret) if err != nil { return nil, err } @@ -219,12 +245,12 @@ func sealWindow(rows []windowRow, path string) (*sealedRun, error) { // 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) (*runRouting, error) { +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) + rt, err := writeRunPayload(f, rows, secret) if err != nil { _ = f.Close() _ = os.Remove(path) @@ -240,18 +266,23 @@ func writeRun(rows []windowRow, path string) (*runRouting, error) { return rt, nil } -// writeRunPayload writes the header and the k-way-merged records (each row -// is already sorted; ≤window-size cursors), then patches the payload CRC -// into the header, feeding every record through the run's routing state -// beside the CRC (runRouting — one-pass construction). No whole-payload -// buffer: the merge streams straight through a bufio writer. +// 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. // -// The merge is the package's shared hash heap (merge_heap.go) over the rows' -// hash cursors: offs[i] is row i's byte offset and the heap caches each live -// row's CURRENT hash beside its row index. Rows arrive in ledger order, so the -// (hash, row) tie-break emits equal cross-ledger duplicate hashes in ledger -// order — the run's byte-level duplicate contract. -func writeRunPayload(f *os.File, rows []windowRow) (*runRouting, error) { +// 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 @@ -264,41 +295,29 @@ func writeRunPayload(f *os.File, rows []windowRow) (*runRouting, error) { 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 - offs := make([]int, len(rows)) - h := make(hashHeap, 0, len(rows)) - for i := range rows { - if len(rows[i].bytes) > 0 { // a tx-less ledger's row holds no hash - h = append(h, hashEntry{hash: rows[i].bytes[:rowHashLen], idx: i}) - } - } - h.heapify() - for len(h) > 0 { - row := h[0].idx - // Emit-copy before the advance: the entry's hash is a slice INTO the - // row, and the refill below repoints it. - copy(rec[:rowHashLen], h[0].hash) - binary.LittleEndian.PutUint32(rec[rowHashLen:], rows[row].seq) + 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 } - offs[row] += rowHashLen - if offs[row] == len(rows[row].bytes) { - h = h.dropRoot() - continue - } - h[0].hash = rows[row].bytes[offs[row] : offs[row]+rowHashLen] - h.siftDown(0) } - // The header promised count records; the merge must have emitted exactly + // 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("merged %d records, header claims %d", 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 @@ -319,7 +338,7 @@ func writeRunPayload(f *os.File, rows []windowRow) (*runRouting, error) { // 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 TXHRUN01 streaming reader (runSource, run_reader.go) +// 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 @@ -358,6 +377,11 @@ func readRunHeader(f *os.File, path string) (runHeader, error) { 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{ @@ -382,19 +406,27 @@ func runRecordCount(f *os.File, path string, hdr runHeader) (int, error) { } 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", path, hdr.count, payload) + 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 hash: bloom reject → ladder route → one -// record-aligned page pread (two on a boundary tie) → in-buffer stride -// binary search → full 32-byte verify → LE seq. Concurrent-safe (ReadAt). -func (r *sealedRun) lookup(hash [32]byte) (uint32, bool, error) { - if len(r.ladder) == 0 || !r.bloom.MayContain(fp64(hash)) { +// 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(hash) + page, pages := r.route(rk) start := page * pageRecords n := min(pages*pageRecords, r.records-start) buf := make([]byte, n*runRecordLen) @@ -402,31 +434,33 @@ func (r *sealedRun) lookup(hash [32]byte) (uint32, bool, error) { 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+rowHashLen], hash[:]) >= 0 + return bytes.Compare(buf[k*runRecordLen:k*runRecordLen+ColdKeySize], rk[:]) >= 0 }) - if i == n || !bytes.Equal(buf[i*runRecordLen:i*runRecordLen+rowHashLen], hash[:]) { + if i == n || !bytes.Equal(buf[i*runRecordLen:i*runRecordLen+ColdKeySize], rk[:]) { return 0, false, nil } - return binary.LittleEndian.Uint32(buf[i*runRecordLen+rowHashLen:]), true, nil + return binary.LittleEndian.Uint32(buf[i*runRecordLen+ColdKeySize:]), true, nil } -// route picks the page(s) a hash can live in: the last page whose ladder key -// sorts strictly below the hash's 16-byte prefix — the equal-prefix 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 always -// suffice for equal FULL hashes (any match is a correct answer, and the -// covered pages contain at least one); only >pageRecords DISTINCT hashes -// sharing a 16-byte prefix — a 2^-64-per-pair event, crafted inputs only — -// could defeat the cap. -func (r *sealedRun) route(hash [32]byte) (int, int) { +// 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][:], hash[:ladderKeyLen]) >= 0 + 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][:], hash[:ladderKeyLen]) { + if page+1 < len(r.ladder) && bytes.Equal(r.ladder[page+1][:], rk[:]) { pages = 2 } return page, pages @@ -495,13 +529,22 @@ func (m rocksdbManifest) GetRuns() ([]string, uint32, error) { // 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) (*HotIndex, uint32, error) { - h, err := NewHotIndex(dir, manifest) +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 } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go index 1e3a67812..d97a646a1 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/merge_heap.go @@ -1,11 +1,11 @@ package txhash -// merge_heap.go — the ONE hash-ordered k-way merge heap this package's two -// background merges share: the seal folding window rows into a run -// (hotindex_seal.go) and the freeze folding runs plus tail rows into the cold -// .bin (cold_freeze.go). Both merge hash-sorted sources and both break ties on -// the SOURCE index, which is what makes duplicate emission ledger-ordered and -// deterministic — the same order under one implementation. +// 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 @@ -16,44 +16,43 @@ package txhash // 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 both -// drivers are held to byte-identity gates -// (TestFreezeColdFromStore_ByteIdenticalToWalk for the freeze, -// TestWriteRun_CrossRowDuplicateGoldenBytes for the seal). +// 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" -// hashEntry is one live source's heap slot: that source's CURRENT full hash, +// 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 hashEntry struct { - hash []byte - idx int +type keyEntry struct { + key []byte + idx int } -// less orders entries by (hash, source index). Indices are unique per live +// 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 hashEntry) less(o hashEntry) bool { - if c := bytes.Compare(e.hash, o.hash); c != 0 { +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 } -// hashHeap is a slice-backed binary min-heap of hashEntries. The driver owns +// 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 hashHeap []hashEntry +type keyHeap []keyEntry // heapify orders an arbitrary slice of entries into a min-heap. -func (h hashHeap) heapify() { +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 hashHeap) siftDown(i int) { +func (h keyHeap) siftDown(i int) { n := len(h) for { left := 2*i + 1 @@ -74,7 +73,7 @@ func (h hashHeap) siftDown(i int) { // dropRoot removes the root — its source is drained — and returns the // shortened heap with the property restored. -func (h hashHeap) dropRoot() hashHeap { +func (h keyHeap) dropRoot() keyHeap { last := len(h) - 1 h[0] = h[last] h = h[:last] diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go index fa4b67d98..cb7f6d004 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/run_reader.go @@ -1,15 +1,19 @@ package txhash -// run_reader.go — the ONE streaming reader for TXHRUN01 run files (format +// 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), hash sortedness, and per-record seq containment in the +// 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. +// 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" @@ -25,7 +29,7 @@ import ( // 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 hash order is checked as it streams (merge +// 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 { @@ -37,11 +41,9 @@ type runSource struct { recs int remaining int rec [runRecordLen]byte - prev [rowHashLen]byte + prev [ColdKeySize]byte } -func (r *runSource) records() int { return r.recs } - // openRunSource opens and structurally validates a run for sequential // draining: header magic/shape (readRunHeader) and count vs file size // (runRecordCount), the shared open gates. @@ -66,9 +68,9 @@ func openRunSource(path string) (*runSource, error) { }, nil } -func (r *runSource) hash() []byte { return r.rec[:rowHashLen] } +func (r *runSource) key() []byte { return r.rec[:ColdKeySize] } -func (r *runSource) seq() uint32 { return binary.LittleEndian.Uint32(r.rec[rowHashLen:]) } +func (r *runSource) seq() uint32 { return binary.LittleEndian.Uint32(r.rec[ColdKeySize:]) } func (r *runSource) advance() (bool, error) { if r.remaining == 0 { @@ -80,14 +82,14 @@ func (r *runSource) advance() (bool, error) { } idx := r.recs - r.remaining if idx > 0 { - copy(r.prev[:], r.rec[:rowHashLen]) + 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[:rowHashLen]) > 0 { - return false, fmt.Errorf("run %s: record %d out of hash order", r.path, idx) + 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]", 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 index ee2ff5f98..2ff8304e0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/txhash/txhash_hotindex_test.go @@ -1,11 +1,13 @@ package txhash import ( + "bytes" "encoding/binary" "hash/crc64" "math/rand/v2" "os" "path/filepath" + "sort" "sync" "testing" @@ -97,7 +99,11 @@ func (hh *hotIndexHarness) randomLedger(fresh, reuse int) { } // verifyAll checks EVERY reference hash resolves to a containing ledger -// (exactly, when the hash lives in one), plus absent-hash misses. +// (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 { @@ -122,7 +128,7 @@ func (hh *hotIndexHarness) settle() { } func testHotIndex(t *testing.T, dir string, m runset.Manifest) *HotIndex { - h, err := NewHotIndex(dir, m) + 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 @@ -167,17 +173,20 @@ func repeatedHash(b byte) [32]byte { // 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 merge'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 hashes are non-decreasing, so -// intra-run duplicate ORDER — the (hash, row) tie-break that emits equal -// hashes oldest-ledger-first — was otherwise protected solely by RNG draws in -// the freeze fixture, transitively and by accident. +// 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 written out by hand as (hash, seq) pairs in emission -// order, so it pins the order the format PROMISES rather than whatever the -// merge currently produces. +// 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) @@ -192,41 +201,50 @@ func TestWriteRun_CrossRowDuplicateGoldenBytes(t *testing.T) { {seq: 103, bytes: encode(b, a)}, } path := filepath.Join(t.TempDir(), "seal-000000.run") - _, err := writeRun(rows, path) + _, err := writeRun(rows, path, testBinSecret) require.NoError(t, err) got, err := os.ReadFile(path) require.NoError(t, err) - // Ascending hash; equal hashes in ASCENDING LEDGER. - emitted := []struct { - hash [32]byte - seq uint32 + // 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 }{ - {a, 100}, - {a, 102}, - {a, 103}, - {b, 100}, - {b, 103}, - {c, 102}, + {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.hash[:]...) + 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, "TXHRUN01") + 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, hash + // 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) @@ -234,7 +252,7 @@ func TestWriteRun_CrossRowDuplicateGoldenBytes(t *testing.T) { 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 "TXHRUN01" + // 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' @@ -244,6 +262,48 @@ func TestWriteRun_CrossRowDuplicateGoldenBytes(t *testing.T) { 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. @@ -272,7 +332,7 @@ func TestHotIndex_DenseChainViolationRejected(t *testing.T) { func TestHotIndex_SealingDisarmedUntilArmed(t *testing.T) { dir := t.TempDir() m := &fakeManifest{} - h, err := NewHotIndex(dir, m) + h, err := NewHotIndex(dir, m, testBinSecret) require.NoError(t, err) defer h.Close() h.sealEvery = 8 // NOT armed — the warmup-replay state @@ -325,7 +385,7 @@ func TestHotIndex_WarmupRebuild(t *testing.T) { tail := append([]windowRow(nil), h.view.Load().rows...) h.Close() - h2, lastSealed, err := OpenHotIndex(dir, 900, m) + h2, lastSealed, err := OpenHotIndex(dir, 900, m, testBinSecret) require.NoError(t, err) defer h2.Close() h2.sealEvery = 8 @@ -359,7 +419,7 @@ func TestHotIndex_WarmupSweepsOrphans(t *testing.T) { orphan := filepath.Join(dir, "seal-999999.run") require.NoError(t, os.WriteFile(orphan, []byte("junk"), 0o644)) - h2, _, err := OpenHotIndex(dir, 60, m) + h2, _, err := OpenHotIndex(dir, 60, m, testBinSecret) require.NoError(t, err) h2.Close() _, serr := os.Stat(orphan) @@ -369,7 +429,7 @@ func TestHotIndex_WarmupSweepsOrphans(t *testing.T) { names, _, _ := m.GetRuns() require.NotEmpty(t, names) require.NoError(t, os.Remove(filepath.Join(dir, names[0]))) - _, _, err = OpenHotIndex(dir, 60, m) + _, _, err = OpenHotIndex(dir, 60, m, testBinSecret) require.Error(t, err, "missing manifest-referenced run must fail open") } @@ -397,11 +457,11 @@ func TestHotIndex_WarmupRejectsCorruptRun(t *testing.T) { flipped := append([]byte(nil), raw...) flipped[runHeaderLen+5] ^= 0x01 require.NoError(t, os.WriteFile(path, flipped, 0o644)) - _, _, err = OpenHotIndex(dir, 300, m) + _, _, 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) + _, _, err = OpenHotIndex(dir, 300, m, testBinSecret) require.Error(t, err, "truncated payload must fail the open loudly") } @@ -427,15 +487,15 @@ func TestOpenHotIndex_RejectsBrokenRunChain(t *testing.T) { require.Len(t, names, 2) require.NoError(t, m.PutRuns([]string{names[1], names[0]}, lastSealed)) - _, _, err = OpenHotIndex(dir, 100, m) + _, _, 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) + _, _, 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) + h2, gotLast, err := OpenHotIndex(dir, 100, m, testBinSecret) require.NoError(t, err) assert.Equal(t, lastSealed, gotLast) h2.Close() @@ -461,7 +521,7 @@ func TestHotIndex_EmptyLedgersSealEmptyRun(t *testing.T) { require.ErrorIs(t, err, stores.ErrNotFound) h.Close() - h2, lastSealed2, err := OpenHotIndex(dir, 20, m) + h2, lastSealed2, err := OpenHotIndex(dir, 20, m, testBinSecret) require.NoError(t, err) defer h2.Close() assert.Equal(t, uint32(23), lastSealed2) @@ -470,33 +530,45 @@ func TestHotIndex_EmptyLedgersSealEmptyRun(t *testing.T) { } // prefixedHash builds a hash whose first 16 bytes repeat p and whose last 4 -// carry a counter — many DISTINCT hashes sharing one ladder prefix. +// 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 ladderKeyLen { + 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 16-byte ladder prefix: the probe must read -// the NEXT page too when the target prefix equals its ladder key, or every -// record past the boundary would be unfindable. +// 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 - // 100 hashes with prefix 0x11, then 50 with prefix 0x22: the page - // boundary at record pageRecords (113) falls inside the 0x22 range. - hashes := make([][32]byte, 0, 150) - for i := range uint32(100) { + // 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(50) { + for i := range uint32(100) { hashes = append(hashes, prefixedHash(0x22, i)) } row, err := EncodeRow(hashes) @@ -508,19 +580,29 @@ func TestHotIndex_LadderBoundaryTie(t *testing.T) { 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") - var tieKey [ladderKeyLen]byte - for i := range tieKey { - tieKey[i] = 0x22 + 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-prefix range") + 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) } - _, err = h.Get(prefixedHash(0x22, 200)) + // 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 @@ -528,21 +610,22 @@ func TestHotIndex_LadderBoundaryTie(t *testing.T) { // — 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-16-byte-prefix ranges across both page boundaries -// (the ladder's tie geometry), and spreads the hashes over two ledgers so -// the merge heap — not input order — dictates observation order. +// 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) { - // 120 hashes with prefix 0x11, 120 with 0x22, 60 with 0x33: 300 records - // = two full pages + a tail, with the page boundaries (records 113 and - // 226) inside the 0x11 and 0x22 ranges respectively. - all := make([][32]byte, 0, 300) - for i := range uint32(120) { + // 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(120) { + for i := range uint32(200) { all = append(all, prefixedHash(0x22, i)) } - for i := range uint32(60) { + for i := range uint32(100) { all = append(all, prefixedHash(0x33, i)) } var a, b [][32]byte @@ -559,7 +642,7 @@ func TestHotIndex_WritePassRoutingEqualsDrainRebuild(t *testing.T) { 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) + 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") @@ -686,10 +769,10 @@ func TestRocksdbManifest_PublishGoldenBytes(t *testing.T) { 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")) + 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")) + fresh, err := sealWindow([]windowRow{{seq: 5, bytes: row}}, filepath.Join(dir, "seal-000001.run"), testBinSecret) require.NoError(t, err) defer fresh.close() diff --git a/design-docs/full-history-campaign-handoff.md b/design-docs/full-history-campaign-handoff.md index 12ce18187..96fb0fdd5 100644 --- a/design-docs/full-history-campaign-handoff.md +++ b/design-docs/full-history-campaign-handoff.md @@ -26,7 +26,7 @@ rebuild window**, and the settled architecture (below) closes that too: 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 reinstated a whole-chunk blinded-sort accumulator in the txhash freeze arm, and the events freeze gained a blinded re-spill pass, so the freeze RSS/wall anchors need re-measurement). +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 — @@ -185,6 +185,19 @@ next pass does not re-derive them. 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. @@ -200,8 +213,8 @@ relocates a model rather than removing one fails even when it is free. 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 EVR2 records, fixed-cadence 16B - prefixes for TXHRUN01's fixed-width ones. + 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 diff --git a/design-docs/gettransaction-full-history-design.md b/design-docs/gettransaction-full-history-design.md index 3b29d27f1..ffe4f2f03 100644 --- a/design-docs/gettransaction-full-history-design.md +++ b/design-docs/gettransaction-full-history-design.md @@ -85,13 +85,19 @@ memtable keys per ledger dominated commit latency): 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, hash-sorted run files (`TXHRUN01`) 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`). - -Full 32-byte hashes are stored throughout, so the hot tier stays **exact**: -a lookup either finds the hash or it doesn't — bloom routing only prunes -which run is read, never what a probe verifies against. + 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 From 4e2e22fc65e31e345b20939ff621addb9ebc0338 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Thu, 27 Aug 2026 00:19:26 +0000 Subject: [PATCH 16/21] lint: annotate the two base-owned findings CI attributes to this PR Neither file is touched by this branch; CI's lint fails on them regardless. Both are deliberate shapes: the metrics interface is one cohesive sink, and the config-rejection table trades function length for row-per-rejection coverage. Annotated rather than restructured. Co-Authored-By: Claude Fable 5 --- cmd/stellar-rpc/internal/rpcv2/config_validate_test.go | 1 + cmd/stellar-rpc/internal/rpcv2/observability/observability.go | 2 ++ 2 files changed, 3 insertions(+) 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/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) From 5a41f6dc76c9e81ee139ee4352324dc7c8bdaa92 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Thu, 27 Aug 2026 06:15:36 +0000 Subject: [PATCH 17/21] txhash: name the collision alternative in the inconsistency error; record the cold-routing trust model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exact-index inconsistency error now says what a post-mortem needs: the overwhelmingly likely cause (corrupt index or store) and the ~2^-128 alternative (blinded-key collision), which reproduces deterministically on retry — and would in any case fail the chunk's next cold index build loudly (streamhash rejects duplicate keys), so read-path fallthrough was considered and rejected as buying nothing the build's own tripwire doesn't already guarantee. The handoff carries the full trust-model ruling. Co-Authored-By: Claude Fable 5 --- .../internal/rpcv2/stores/txhash/read_assembly.go | 4 +++- design-docs/full-history-campaign-handoff.md | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) 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/design-docs/full-history-campaign-handoff.md b/design-docs/full-history-campaign-handoff.md index 96fb0fdd5..b58736733 100644 --- a/design-docs/full-history-campaign-handoff.md +++ b/design-docs/full-history-campaign-handoff.md @@ -202,6 +202,20 @@ 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 From a068ae533614c381e4989bfe77a5963ebdc08df0 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Sat, 29 Aug 2026 08:47:30 +0000 Subject: [PATCH 18/21] events: restore the release index.pack layout; drop per-decode Validate index.pack records return to the release layout - fingerprint(4) followed by the RunOptimize'd roaring bytes, no codec byte - so the branch changes no cold artifact layout and indexPackFormat 0xFE1E000B describes exactly what is written (the delta layout had changed the record layout under the same format constant). The delta-postings on-disk half of the events work is withdrawn; #965, its pre-release cherry-pick vehicle, is closed after measurement showed the branch's ingest/backfill/freeze headlines are layout-independent and the layout's own wins did not justify freezing a new cold record format at release one. The reader drops per-decode bm.Validate(): byte integrity belongs to the packfile checksum layer (#910), and the proof is super-linear on run-dense terms - measured 15x on pubnet cold reads. A structurally hollow bitmap resolves as a present term matching nothing (pinned by test). encodeIndexBody keeps its single-encoder role (the byte-identity gate's anchor) and gains a caller-owned scratch bitmap so the streaming builder allocates no bitmap per term. inlineBodyMax is re-derived for roaring bodies (10 KiB, roaring's worst case for the ~1,024-posting population that always inlined); the inline/reference split's measured percentages predate the larger bodies - re-measure before retuning. The Postings engine is unchanged and layout-agnostic; DecodePostings loses its last caller and remains beside the hot tier's sibling codecs. Measured at this tip (sac stress, same-box A/B vs the pre-revert tip, one run per variant): backfill wall 6m52s -> 7m08s (+3.9%; events finalize arm +11.6%), freeze 3m55s -> 4m07s (+5.0%; events arm 2m55s -> 3m10s), hot ingest unchanged (commit p50 8.66 -> 8.37 ms; untouched code), peak RSS neutral-to-lower in every cell; index.pack 1.498 GB at stress density. index.hash is byte-identical to the pre-revert tip: the revert changes record bodies only. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y7opQUW9UZPKzE6B3tz9b5 --- .../rpcv2/stores/event/cold_format.go | 32 ---- .../internal/rpcv2/stores/event/cold_index.go | 52 ++++--- .../rpcv2/stores/event/cold_index_stream.go | 42 ++--- .../stores/event/cold_index_stream_test.go | 66 ++++---- .../rpcv2/stores/event/cold_index_test.go | 144 +++++++++--------- .../rpcv2/stores/event/cold_reader.go | 43 ++---- .../rpcv2/stores/event/cold_reader_test.go | 17 ++- .../internal/rpcv2/stores/event/packedrow.go | 4 +- 8 files changed, 183 insertions(+), 217 deletions(-) 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 dd457228c..f801031e0 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_format.go @@ -89,38 +89,6 @@ const ( // zstd frames. const indexPackChecksum = packfile.ChecksumCRC32C -// index.pack item codecs — the codec byte in the layout cold_index.go -// documents. -const ( - // itemCodecRoaring bodies are a serialized roaring bitmap, RunOptimize'd - // before Bitmap.WriteTo. - itemCodecRoaring byte = 0x00 - // itemCodecDelta bodies are AppendPostings output: a uvarint count - // then the event IDs, first absolute and the rest strictly-positive - // deltas. - itemCodecDelta byte = 0x01 -) - -// deltaPostingMaxCardinality is the term cardinality at or below which -// postings are stored as a delta-varint list instead of a roaring bitmap. -// -// Measured on a real pubnet chunk (10,000 ledgers, 8.65M events, 204,752 -// terms): below ~1024 postings roaring costs 1.7x to 3.8x a delta list, and -// above it roaring's run and bitmap containers pull ahead, reaching 12.5x on -// the nine terms holding more than a million postings. 99.2% of terms fall -// below the threshold and hold 71% of the byte saving. -// -// Cardinality is a proxy for whichever encoding is actually smaller, not a -// derivation of it: a contiguous 1024-posting term is far smaller as a run -// container than as deltas, and a scattered 262K-posting term is smaller as -// deltas than as roaring. The proxy holds on pubnet event postings because -// cardinality correlates with run structure there, fat terms being the -// clustered ones. That is a property of the data, so re-measure before -// retuning — and retuning also moves inlineBodyMax (cold_index_stream.go), -// which scales the reorder heap's corrupt-index inline ceiling -// (backstop × inlineBodyMax): redo that RAM math in the same change. -const deltaPostingMaxCardinality = 1024 - // IndexRecordFingerprintLen is the byte width of the leading // fingerprint in every index.pack record — the ROUTED (blinded) // key's first four bytes. The cold reader checks it against the 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 2280f7870..4e7d30c95 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index.go @@ -60,8 +60,7 @@ func blindTerm(secret [stores.SecretLen]byte, term TermKey) TermKey { // pack-without-index special case. // // bitmaps is the complete term index for the Chunk. It is read, not -// modified: encodeIndexBody builds its own bitmap for the terms that -// need one. +// modified: encodeIndexBody serializes through a bitmap of its own. // // 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 @@ -77,8 +76,7 @@ func blindTerm(secret [stores.SecretLen]byte, term TermKey) TermKey { // // offset size field // 0 4 fingerprint (first 4 bytes of the ROUTED/blinded key) -// 4 1 codec (itemCodecRoaring | itemCodecDelta) -// 5 N postings in that codec +// 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, @@ -140,7 +138,7 @@ func WriteColdIndex( // the reader compares against the routed query key). var fp [IndexRecordFingerprintLen]byte copy(fp[:], rk[:IndexRecordFingerprintLen]) - body, berr := encodeIndexBody(nil, bitmap.ToArray()) + body, berr := encodeIndexBody(nil, bitmap.ToArray(), nil) if berr != nil { return berr } @@ -206,42 +204,52 @@ func WriteColdIndex( } // indexEntry is one assembled index.pack record: the slot it lands at, -// the 4-byte fingerprint, and the encoded item body (codec ‖ postings). +// the 4-byte fingerprint, and the encoded item body (the serialized +// bitmap). type indexEntry struct { slot uint32 fp [IndexRecordFingerprintLen]byte body []byte } -// encodeIndexBody appends the index.pack item body for ids — the codec byte, -// then the postings in that codec — to dst. ids must be ascending and deduped. +// 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 delta codec needs a -// sorted slice and the streaming builder has one, while a bitmap is cheap to -// build from ids for the terms that need one. -func encodeIndexBody(dst []byte, ids []uint32) ([]byte, error) { +// 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, and the delta codec - // cannot represent one: DecodePostings rejects a zero count, so - // writing it would produce a record no reader can decode. + // 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 len(ids) <= deltaPostingMaxCardinality { - return AppendPostings(append(dst, itemCodecDelta), ids), nil + if scratch == nil { + scratch = roaring.New() + } else { + scratch.Clear() } + scratch.AddMany(ids) // RunOptimize re-encodes long runs of set bits as RUN containers, which - // MarshalBinary then serializes more compactly: ~14% on chunk 5999, + // the serialization then stores more compactly: ~14% on chunk 5999, // concentrated in dense clustered terms. It is also what makes the // serialization canonical. - bm := roaring.BitmapOf(ids...) - bm.RunOptimize() - b, err := bm.ToBytes() + scratch.RunOptimize() + b, err := scratch.ToBytes() if err != nil { return nil, fmt.Errorf("events: serialize bitmap: %w", err) } - return append(append(dst, itemCodecRoaring), b...), nil + return append(dst, b...), nil } // writeIndexPackEntries appends every assembled record to the index.pack 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 index 12c50efe1..77b84afc6 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_index_stream.go @@ -16,12 +16,11 @@ package event // 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 (every delta-coded body does, by -// construction), 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. +// 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" @@ -36,6 +35,8 @@ import ( "os" "path/filepath" + "github.com/RoaringBitmap/roaring/v2" + "github.com/stellar/streamhash" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/chunk" @@ -46,21 +47,25 @@ import ( // 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. It is derived so that EVERY -// delta-coded body inlines by construction — 1 codec byte, a count varint, -// and up to deltaPostingMaxCardinality posting varints — and moves with -// that constant if the codec split is ever retuned; only roaring bodies -// — multi-MiB at the extreme, and unbounded in aggregate per block — +// 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 = +29% on the freeze's events arm. Re-measure before retuning. +// 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 ≈ 17MB inline); the enforced ceiling — -// the derived backstop × inlineBodyMax ≈ 320MiB — is reachable only with -// a corrupt index.hash, which the backstop then turns into a loud abort. -const inlineBodyMax = 1 + binary.MaxVarintLen32 + deltaPostingMaxCardinality*binary.MaxVarintLen32 +// 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") @@ -224,10 +229,13 @@ func writeTermsRun(path string, runPaths []string) (uint64, error) { 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); berr != nil { + if bodyBuf, berr = encodeIndexBody(bodyBuf[:0], ids, scratch); berr != nil { return berr } recBuf = recBuf[:0] 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 index e0e27a428..281e37bf2 100644 --- 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 @@ -210,7 +210,7 @@ func TestStreamTermsRun_Offsets(t *testing.T) { fatIDs[j] = uint32(j * 7) } corpus[fat] = fatIDs - fatBody, err := encodeIndexBody(nil, 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") @@ -253,43 +253,32 @@ func TestWriteColdIndexFromRuns_ByteIdentical(t *testing.T) { const chunkID = chunk.ID(3) corpus := synthTerms(2000, 42) - // Add terms sitting either side of deltaPostingMaxCardinality. The two - // builders pick a codec from cardinality independently — one from - // len(ids), one from a bitmap built out of the same ids — so the threshold - // is where they could disagree, and synthTerms alone never lands near it. - for i, card := range []int{ - deltaPostingMaxCardinality - 1, - deltaPostingMaxCardinality, - deltaPostingMaxCardinality + 1, - deltaPostingMaxCardinality + 2, - } { - k := ComputeTermKey(fmt.Appendf(nil, "straddle-%d", i), FieldTopic1) - ids := make([]uint32, card) - for j := range ids { - ids[j] = uint32(j * 3) - } - corpus[k] = ids - } - - // Add roaring-coded 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. The - // straddle is premise-checked below, not assumed. + // 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, card := range []int{2400, 2500, 2600, 2700} { + 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, card) + ids := make([]uint32, spec.card) for j := range ids { - ids[j] = uint32(j*3 + 1) + ids[j] = uint32(j*spec.stride + 1) } corpus[k] = ids - body, berr := encodeIndexBody(nil, 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 roaring body") - require.Greater(t, maxBody, inlineBodyMax, "corpus must include a reference-path roaring 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() @@ -396,16 +385,18 @@ func TestWriteColdIndexFromRuns_ReadsBack(t *testing.T) { ) corpus := synthTerms(events, 9) - // Premise: the corpus must straddle the codec split, so this one read-back - // drives BOTH posting codecs through the reader's dispatch — the firehose - // term (every ID) is roaring, the singletons delta. + // 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.Greater(t, fattest, deltaPostingMaxCardinality, "corpus must include a roaring-coded term") - require.LessOrEqual(t, thinnest, deltaPostingMaxCardinality, "corpus must include a delta-coded term") + 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") @@ -426,8 +417,8 @@ func TestWriteColdIndexFromRuns_ReadsBack(t *testing.T) { 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 posting - // codecs all have to agree for the exact ID set to come back. + // 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) @@ -438,8 +429,7 @@ func TestWriteColdIndexFromRuns_ReadsBack(t *testing.T) { 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.Equal(t, len(corpus[k]) <= deltaPostingMaxCardinality, got[i].IDs() != nil, - "term %x form must follow its codec", 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. 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 64f7b65fe..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 @@ -126,8 +126,8 @@ func TestWriteIndex_RoundTripsBitmapsPerTerm(t *testing.T) { require.True(t, ok, "record missing at slot %d (term-%d)", slot, i) // Read it back the way the cold reader does, which also checks the - // fingerprint against the ROUTED key's prefix and dispatches on the - // codec byte. + // 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) @@ -309,9 +309,9 @@ func TestWriteIndex_LargeIndex(t *testing.T) { } func TestWriteIndex_RecordEncoding(t *testing.T) { - // Lock the on-disk record format: routed-key fingerprint || codec || postings. - // The cold reader relies 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) @@ -328,16 +328,18 @@ func TestWriteIndex_RecordEncoding(t *testing.T) { rk := TermKey(stores.BlindKey(testIndexSecret, term[:])) assert.Equal(t, rk[:IndexRecordFingerprintLen], record[:IndexRecordFingerprintLen]) - // A single-posting term takes the delta codec, and its body is the codec - // byte plus AppendPostings output — no roaring container framing. - assert.Equal(t, itemCodecDelta, record[IndexRecordFingerprintLen], - "a 1-posting term must use the delta codec") + // 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:]), + "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)) - assert.NotNil(t, post.IDs(), "a delta term must come back as an ID list") // Defensive: the fingerprint occupies bytes 0..3 in little-endian // the way TermKey itself encodes — read it back via binary helpers @@ -377,17 +379,12 @@ func TestWriteColdIndex_StampAndContentHash(t *testing.T) { require.NoError(t, r.Verify(context.Background())) } -// TestDeltaPostingThresholdPinned pins the threshold's value. Every other -// assertion names the constant symbolically, so they all hold at any value, -// including one that would silently cost 40% of the pack. -func TestDeltaPostingThresholdPinned(t *testing.T) { - require.Equal(t, 1024, deltaPostingMaxCardinality, - "retuning the threshold changes index.pack size materially; re-measure a real pubnet chunk first") -} - -// TestWriteIndex_CodecDispatch pins the cardinality dispatch at the threshold -// and round-trips both codecs through the real cold reader. -func TestWriteIndex_CodecDispatch(t *testing.T) { +// 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() @@ -401,8 +398,8 @@ func TestWriteIndex_CodecDispatch(t *testing.T) { return k } single := term("single", 1) - atThreshold := term("at", deltaPostingMaxCardinality) - overThreshold := term("over", deltaPostingMaxCardinality+1) + small := term("small", 1024) + fat := term("fat", 1025) require.NoError(t, WriteColdIndex(context.Background(), indexTestChunkID, idx, dir, testIndexSecret)) @@ -411,73 +408,82 @@ func TestWriteIndex_CodecDispatch(t *testing.T) { defer m.Close() records := loadIndexPack(t, filepath.Join(dir, IndexPackName(indexTestChunkID))) - codecOf := func(k TermKey) byte { - slot, _, lerr := m.Lookup(k) - require.NoError(t, lerr) - return records[int(slot)][IndexRecordFingerprintLen] - } - assert.Equal(t, itemCodecDelta, codecOf(atThreshold), - "exactly deltaPostingMaxCardinality postings must still be delta") - assert.Equal(t, itemCodecRoaring, codecOf(overThreshold), - "one posting past the threshold must be roaring") - - // Both codecs must come back through the reader's dispatch with the right - // postings. (The full ColdReader path over a complete artifact set is - // covered by TestWriteColdIndexFromRuns_ReadsBack, whose corpus straddles - // this same threshold and reads back through the production ColdReader.) - wantCard := []uint64{1, deltaPostingMaxCardinality, deltaPostingMaxCardinality + 1} - for i, k := range []TermKey{single, atThreshold, overThreshold} { + 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) - post, derr := verifyAndDecodePostings(records[int(slot)], rk, slot) + 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) - assert.Equal(t, want <= deltaPostingMaxCardinality, post.IDs() != nil, - "term %d form must follow its codec", i) } - - // An unknown codec byte must fail loudly rather than decode as something. - bad := append([]byte(nil), records[0]...) - bad[IndexRecordFingerprintLen] = 0x7f - var key TermKey - copy(key[:], bad[:IndexRecordFingerprintLen]) - _, derr := verifyAndDecodePostings(bad, key, 0) - require.ErrorContains(t, derr, "unknown codec") } // TestEncodeIndexBodyRejectsEmpty pins that the encoder refuses a term with no -// postings rather than writing a record the reader cannot decode: the delta -// codec's count is a uvarint and DecodePostings rejects zero. +// postings rather than writing a record for a term that cannot exist. func TestEncodeIndexBodyRejectsEmpty(t *testing.T) { - _, err := encodeIndexBody(nil, nil) + _, err := encodeIndexBody(nil, nil, nil) require.Error(t, err) - body, err := encodeIndexBody(nil, []uint32{7}) + body, err := encodeIndexBody(nil, []uint32{7}, nil) require.NoError(t, err) - ids, err := DecodePostings(body[1:]) - require.NoError(t, err, "one posting must round-trip") - require.Equal(t, []uint32{7}, ids) + bm := roaring.New() + require.NoError(t, bm.UnmarshalBinary(body), "one posting must round-trip") + require.Equal(t, []uint32{7}, bm.ToArray()) } -// TestVerifyAndDecodePostings_RejectsInvalidBitmap pins the trust boundary. -// roaring's UnmarshalBinary accepts a run container holding no intervals, which -// reads back as a bitmap with containers but no postings — a shape no producer -// makes, so it means the record is corrupt. It must be rejected here, naming -// the slot, rather than reaching a caller that assumes a present term holds at -// least one posting. -func TestVerifyAndDecodePostings_RejectsInvalidBitmap(t *testing.T) { +// 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]...), itemCodecRoaring) - record = append(record, body...) - - _, derr := verifyAndDecodePostings(record, key, 7) - require.ErrorContains(t, derr, "invalid bitmap at slot 7") + 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 e312880c3..6ee6d7cfa 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/cold_reader.go @@ -326,9 +326,9 @@ func (c *ColdReader) Offsets() (*LedgerOffsets, error) { // mismatch (residual MPHF collision on an unseen key) it returns the zero // Postings, which the caller treats as not-found. // -// A delta term is returned un-materialized (see Intersect). record is -// valid only inside ReadItems' callback, and neither codec aliases it, so the -// result outlives the callback safely. +// 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( @@ -338,32 +338,17 @@ func verifyAndDecodePostings(record []byte, key TermKey, slot uint32) (Postings, return Postings{}, nil } body := record[IndexRecordFingerprintLen:] - switch body[0] { - case itemCodecRoaring: - bm := roaring.New() - if err := bm.UnmarshalBinary(body[1:]); err != nil { - return Postings{}, fmt.Errorf("events: unmarshal bitmap at slot %d: %w", slot, err) - } - // UnmarshalBinary accepts shapes that are structurally invalid rather - // than merely unexpected, such as a run container holding no - // intervals, which reads back as a bitmap with containers but no - // postings. Reject them here, where the bytes are untrusted and the - // slot is known, rather than downstream where a caller assuming a - // present term holds at least one posting would fault. - if err := bm.Validate(); err != nil { - return Postings{}, fmt.Errorf("events: invalid bitmap at slot %d: %w", slot, err) - } - return BitmapPostings(bm), nil - case itemCodecDelta: - ids, err := DecodePostings(body[1:]) - if err != nil { - return Postings{}, fmt.Errorf("events: decode postings at slot %d: %w", slot, err) - } - return IDPostings(ids), nil - default: - return Postings{}, fmt.Errorf( - "events: index.pack record at slot %d has unknown codec 0x%02x", slot, body[0]) - } + bm := roaring.New() + 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 each key's postings, aligned positionally with the input 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 81003a941..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 @@ -8,7 +8,6 @@ import ( "hash/crc32" "os" "path/filepath" - "slices" "testing" "github.com/stretchr/testify/assert" @@ -546,9 +545,9 @@ func TestColdReader_LookupKeys(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. The fixture's term is small, so it comes back as an ID slice; -// mutating the slice is the mutation that could actually reach a shared buffer, -// whereas Bitmap() on an ID-backed term hands back a throwaway. +// 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) @@ -561,15 +560,17 @@ func TestColdReader_LookupKeysIsolatesCalls(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].IDs(), "a small term must come back as an ID slice") + require.True(t, first[0].Present(), "the fixture's contract term must hit") - want := slices.Clone(first[0].IDs()) - first[0].IDs()[0] = 999_999 + 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) - assert.Equal(t, want, second[0].IDs(), + assert.Equal(t, want, second[0].Bitmap().ToArray(), "a LookupKeys result mutation must not bleed into the next LookupKeys") } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go index 07c5e4699..5ddd73864 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go @@ -150,8 +150,8 @@ func AppendTermPostings(dst []byte, term TermKey, ids []uint32) []byte { 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, shared -// with the cold index's delta-codec records. +// 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 From 7c4f3c62108bbcefc7d8183e936be4e70ff5f001 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Sun, 30 Aug 2026 11:03:52 +0000 Subject: [PATCH 19/21] =?UTF-8?q?hotindex:=20one=20walk,=20one=20write=20?= =?UTF-8?q?=E2=80=94=20fuse=20the=20sealed-run=20record=20decode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sealed-run probe used to walk the found record twice with the same varint code — PackedRecordLen to size it, then DecodePackedRow to decode it — and then copy the ids out of a per-record buffer into the caller's list. The profile of the merged-pub hot cell puts that redundancy at 3.42s of PackedRecordLen plus a 5.65s decode subtree, out of a 10.31s HotIndex.Get (44.9% of all CPU). Deleted: - the sizing pre-walk: appendRecordIDs returns the record's byte length as a by-product of decoding it, so the found record is walked once. PackedRecordLen now runs ONLY on the skip branch (c < 0), never before the compare — the c > 0 absent exit used to compute a length and throw it away. - decodePostings' closure and its escaping cursor: the byte cursor is a local int index, not a captured slice header, so each id costs no funcval call and no heap slice-header write. -gcflags=-m confirms "rec does not escape" and no "moved to heap" for the cursor; binary.Uvarint stays inlined at both reads. - the per-record []uint32 buffer and the copy out of it: ids append straight into lookupSparse's accumulator, after one slices.Grow off the exact decoded count. - the per-probe make([]byte) of the fence window: a size-capped sync.Pool of scratch buffers, released before the probe returns. - the per-run fp64(term): lookupSparse holds the blinded key already, so lookup takes the fingerprint instead of recomputing it per run. The dst-not-nil contract. sealedRun.lookup now takes the accumulator, so every absent path — bloom reject, term-sorts-before-the-first-record, sorted-past-the-slot, window exhaustion — must return dst, never nil. Returning nil would silently drop the postings older runs already contributed: wrong answers, no error, no metric, and invisible to every existing lookup test, all of which probe a single run. TestSealedRunLookup_AbsentRunsKeepTheAccumulator covers all four exits (the two inside the walk reached via seeded bloom fingerprints), and TestHotIndex_MultiRunAccumulation drives the same thing end to end through lookupSparse: two sealed runs plus a live window, one term in all three tiers and one the newest run does not hold at all. On a decode error the accumulator holds a partial record's ids and lookupSparse returns nil, err — the partial is unreachable. Why a second decoder. appendRecordIDs duplicates DecodeAscendingIDs' validation character for character (raw-varint reject BEFORE accumulation, zero delta, uint32 overflow) and carries a third copy of the count-vs-remaining-bytes pre-allocation guard, against codec.go's one-definition-site rule. Extracting the core instead would rewrite the codec the seal merge, the warmup drain, the cold build and the freeze all share — the background-goroutine path — for zero measured gain, since after the double walk dies every other DecodeAscendingIDs caller profiles off the hot path. FuzzAppendRecordIDs is the pin in its place: a differential over arbitrary bytes asserting identical ids, identical accept/reject classification, and identical record length against the shared codec. Keep it green. The pool's size cap. putProbeBuf drops any buffer over maxProbeBufBytes (2*fenceSpanBytes) rather than retaining it: fenceBuilder.isolatePrev gives an oversized record a fence window of its OWN, of arbitrary size, and sync.Pool has no size classes, so one such probe would ratchet a multi-MB buffer into every P's private slot. 2*fenceSpanBytes is the structural bound on a multi-record window (its last record starts under fenceSpanBytes past the opening fence and is itself under fenceSpanBytes, or it would have been isolated), so ordinary windows still recycle. Unlike the two in-tree rejections of sync.Pool, this holds no state across ledger boundaries: it is byte scratch on a bounded size class, released before return and never aliased by the decoded ids. Also hardened while the loop was open: the walk requires len(b) >= 16, so a window cut short of a whole key is an absent probe instead of an over-read in the key compare and the length walk. Ingest is untouched: buildWindowRow keeps its own PackedRecordLen walk, runspill is byte-identical, and ApplyLedger's only contact with a run — the late-promotion lookupSparse — just gets cheaper. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Y7opQUW9UZPKzE6B3tz9b5 --- .../rpcv2/stores/event/hot_warmup_test.go | 4 +- .../internal/rpcv2/stores/event/hotindex.go | 30 ++- .../rpcv2/stores/event/hotindex_seal.go | 100 ++++++-- .../rpcv2/stores/event/hotindex_test.go | 216 +++++++++++++++++- .../rpcv2/stores/event/packed_index_test.go | 93 ++++++++ .../internal/rpcv2/stores/event/packedrow.go | 75 ++++++ 6 files changed, 482 insertions(+), 36 deletions(-) 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 521c8ccce..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 @@ -193,10 +193,10 @@ func TestHotStore_LookupKeysSpansBlindedRunsAndRawWindow(t *testing.T) { // 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(term) + 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(blinded) + ids, err = r.lookup(nil, blinded, fp64(blinded)) require.NoError(t, err) require.NotEmpty(t, ids, "a sealed run must answer the blinded term") } diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go index 4b3e407cc..0ee000e80 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex.go @@ -326,15 +326,6 @@ func (h *HotIndex) Get(term TermKey) (Postings, error) { return IDPostings(ids), nil } -// decodeRecordIDs decodes ONE packed record's ID list. -func decodeRecordIDs(rec []byte) ([]uint32, error) { - var out []uint32 - err := DecodePackedRow(rec[:PackedRecordLen(rec)], func(_ TermKey, ids []uint32) { - out = append(out, ids...) - }) - return out, err -} - // 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 @@ -364,17 +355,24 @@ func (h *HotIndex) Close() { // // 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. +// 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 { - ids, err := r.lookup(rk) - if err != nil { + if out, err = r.lookup(out, rk, rkfp); err != nil { return nil, err } - out = append(out, ids...) } for i := range v.rows { row := &v.rows[i] @@ -386,11 +384,9 @@ func (h *HotIndex) lookupSparse(v *hotIndexView, term TermKey) ([]uint32, error) if !bytes.Equal(rec[:16], term[:]) { continue } - ids, err := decodeRecordIDs(rec) - if err != nil { - return nil, err + if out, _, err = appendRecordIDs(out, rec); err != nil { + return nil, fmt.Errorf("events: hotindex window row: %w", err) } - out = append(out, ids...) } } // Compact is the whole dedup: out is ascending, and the only possible diff --git a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go index cdfcede76..6c7ba3214 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_seal.go @@ -15,6 +15,7 @@ import ( "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" @@ -406,38 +407,107 @@ func openSealedRun(path string) (*sealedRun, error) { return rt.open(path, end) // end = payload length } -// lookup probes the run for one term: bloom reject → fence window → pread → -// linear decode with full-term verification. Concurrent-safe (ReadAt). -func (r *sealedRun) lookup(term TermKey) ([]uint32, error) { - if !r.bloom.MayContain(fp64(term)) { - return nil, nil +// 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 nil, nil // term sorts before the first record + return dst, nil // term sorts before the first record } start, end := r.fences[i-1].off, r.fences[i].off - buf := make([]byte, end-start) + 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(buf, runspill.HeaderLen+start); err != nil { + 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) } - for len(buf) > 0 { - c := bytes.Compare(buf[:16], term[:]) - recLen := PackedRecordLen(buf) + // 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 { - return decodeRecordIDs(buf[:recLen]) + 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 nil, nil // sorted: passed the slot, absent + return dst, nil // sorted: passed the slot, absent } - buf = buf[recLen:] + // 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 nil, nil + return dst, nil } func (r *sealedRun) 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 index 8bc166af6..86f880766 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/hotindex_test.go @@ -455,11 +455,12 @@ func TestRunFences_ByteCapAndIsolation(t *testing.T) { mkTerm(0x10, 149), mkTerm(0x20, 50), mkTerm(0x90, 0), } for _, k := range probes { - ids, lerr := written.lookup(k) + ids, lerr := written.lookup(nil, k, fp64(k)) require.NoError(t, lerr) require.Equal(t, window[k], ids, "term %x", k) } - miss, err := written.lookup(mkTerm(0x70, 7)) + absent := mkTerm(0x70, 7) + miss, err := written.lookup(nil, absent, fp64(absent)) require.NoError(t, err) require.Nil(t, miss) } @@ -730,3 +731,214 @@ func TestHotIndex_ManifestFailureLeavesObsoleteRuns(t *testing.T) { 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/packed_index_test.go b/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go index 354c7b4ca..c913966db 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packed_index_test.go @@ -1,6 +1,9 @@ package event import ( + "encoding/binary" + "math" + "slices" "testing" "github.com/stretchr/testify/assert" @@ -44,3 +47,93 @@ func TestPackedIndexRow_RejectsCorruption(t *testing.T) { 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 index 5ddd73864..b2f646abd 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/event/packedrow.go @@ -5,6 +5,7 @@ import ( "encoding/binary" "errors" "fmt" + "math" "slices" "github.com/stellar/stellar-rpc/cmd/stellar-rpc/internal/rpcv2/stores/event/runspill" @@ -101,6 +102,80 @@ func decodePostings(b []byte, ids []uint32) ([]uint32, []byte, error) { 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 From ac54ec390984fc7523730cc9bbe98a81c7e52744 Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Tue, 22 Sep 2026 16:10:42 +0000 Subject: [PATCH 20/21] go.mod: pin go-stellar-sdk to the single-pass V4 walk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mainline pin (v0.7.4-0.20260918190431-7092f4b101ef) predates the SDK's single-pass V4 walk, so rebasing onto it gave this branch back the two-pass element decode and the unsized appends its ingest numbers were measured without. dec600e0 (branch ingest-single-pass-v4-walk) is that pin's descendant and restores them: - f4ee3708 routes the view read paths through ONE per-element assembly, so a V4 element is decoded once for every consumer instead of once per path; - dec600e0 sizes each V4 operation once and presizes the collected output, the allocation half of the same win; - 30ad7b27 adds the extractor benchmark the two are measured with. The range also carries 25c467a2, which records a transaction's byte span and rebuilds the transaction from it. This branch does not use that API — it comes along with the pin rather than for it, and nothing here depends on it. A real pin, not a replace: go get against the published commit with GOPROXY=direct, then go mod tidy; go.sum moves with it. No call site needed adapting — the tree builds, vets and tests unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y7opQUW9UZPKzE6B3tz9b5 --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c1dc911f4..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 ) 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= From a9db5ed8f0f8c3f3006584a7721cc0c196b8ddba Mon Sep 17 00:00:00 2001 From: Tamir Sen Date: Tue, 22 Sep 2026 16:37:10 +0000 Subject: [PATCH 21/21] docs: the freeze decodes for hashing; the extract contract; branch shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four small corrections a verification pass over the rewritten stack turned up. Nothing here changes behaviour; the one code edit is a test's column family list. - hotchunk.FreezeLedgersCold still advertised the ledger freeze as "no decompression". Frames are still copied verbatim and nothing is re-encoded, but the pack writer's hashing workers decode each frame once to feed the content hash over RAW ledger bytes. The doc says so. - ReaderOptions.ContentHashExtract said a reader must pass the writer's extract or Verify will mismatch. This branch is what makes that false: nothing in the base writes a pack with an extract at all, and the cold ledger pack the freeze introduces is written pre-compressed with a decompressing extract, then read by the base-owned cold_reader.go with a decompressing RecordDecoder and NO extract. Verify hashes items as the read path returns them, after decoding, so the decoder and the extract only have to reproduce the writer's hash input between them — a reader that obeyed the old sentence would hand already-decompressed items to a decompressing extract and fail Verify on an intact file. ledger/cold_freeze_test.go's content-hash gate is that configuration under test. Qualified on the field, where a caller meets the contract. design-docs/packfile-library.md carries the same claim in four places; correcting one of them here would leave that document contradicting itself, so the base-owned doc is left to a separate upstream fix. - ledger/hot_store_test.go opened its test store with a hardcoded []string{LedgersCF}; it now calls CFNames(), the list the production opener uses, so a new CF cannot appear in one and not the other. - The campaign handoff's "Branch:" paragraph described a stack that no longer exists (a views-walk port with a fused pin bump, a docs commit at the end). It now states the current shape: 21 commits, the commit that added the handoff document mid-stack, the go-stellar-sdk pin second from the tip with no call-site adaptation behind it because the views walk is in the base, and this docs commit at the tip. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Y7opQUW9UZPKzE6B3tz9b5 --- .../internal/rpcv2/packfile/reader.go | 10 +++++++--- .../rpcv2/stores/hotchunk/hotchunk.go | 7 +++++-- .../rpcv2/stores/ledger/hot_store_test.go | 2 +- design-docs/full-history-campaign-handoff.md | 19 ++++++++++++------- 4 files changed, 25 insertions(+), 13 deletions(-) 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/stores/hotchunk/hotchunk.go b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go index 986b5acab..735f88a57 100644 --- a/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go +++ b/cmd/stellar-rpc/internal/rpcv2/stores/hotchunk/hotchunk.go @@ -366,8 +366,11 @@ func (d *DB) FreezeEventsCold( // FreezeLedgersCold builds the chunk's cold ledger .pack at packPath by // copying THIS hot DB's ledgers-CF zstd frames verbatim (PreCompressed cold -// writer) — no decompression. Valid on a read-only view; the DB must be -// complete through the chunk's last ledger. Returns the ledgers written. +// 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) { 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 1cfc8f9cf..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 @@ -41,7 +41,7 @@ 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) diff --git a/design-docs/full-history-campaign-handoff.md b/design-docs/full-history-campaign-handoff.md index b58736733..9279dba47 100644 --- a/design-docs/full-history-campaign-handoff.md +++ b/design-docs/full-history-campaign-handoff.md @@ -72,13 +72,18 @@ one-thread-per-physical-core pinning (measured: total p90 37.8→29.2, p99 exclusive PHYSICAL cores; co-located validation owed at the 2-disk campaign. -**Branch:** the PR stack is one commit per concern (bench diagnostics → -hot-latency wave 1 → cold-memory redesign → Sorted-Run Tier → -zero-decompress freeze → window-scale bench + separation residue → -hot-latency wave 2 × 4 → views-walk port × 3, pin-bump fused with the -API swap since no intermediate state compiles → this docs commit). Each -boundary builds and -passes `-race` standalone; byte-identity gates (freeze-written vs +**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.