Skip to content

Add eventstore: per-Chunk hot + cold event storage for full-history - #740

Merged
tamirms merged 8 commits into
stellar:feature/full-historyfrom
urvisavla:feat/fh-events-stores
May 19, 2026
Merged

Add eventstore: per-Chunk hot + cold event storage for full-history#740
tamirms merged 8 commits into
stellar:feature/full-historyfrom
urvisavla:feat/fh-events-stores

Conversation

@urvisavla

@urvisavla urvisavla commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces the eventstore package — the layer that serves one Chunk's events through its hot (active-ingest) and cold (frozen) lifetime — plus the cold codec, on-disk layout, validation contracts, and supporting refactors the freeze and backfill services will rely on. Part of the Full-History Event Store umbrella.

Hot side (HotStore, hot_store.go):

  • Wraps one Chunk's RocksDB DB + in-memory term mirror + ledger-offset cache.
  • IngestLedgerEvents validates ledger range and next-expected sequence before any RocksDB write (ErrLedgerOutOfRange / ErrLedgerOutOfOrder); per-ledger writes commit data + index + offsets in one atomic Batch.
  • Read surface: ChunkID, EventCount, Offsets, Lookup, FetchEvents, All, Index, NextEventID.
  • Warmup reconstructs the in-memory mirrors from the per-Chunk DB at open.

Cold side:

  • ColdWriter (cold_writer.go) streams events.Payload into events.pack (zstd at ItemsPerRecord=128) and embeds LedgerOffsets as app data. Deferred Close cleans up the partial file + workers on any error path.
  • WriteColdIndex (cold_index.go) batch-produces index.pack + index.hash. Takes context.Context; cancels through buildMPHF to streamhash; removes orphan index.hash on error so the bucket dir is clean for retry.
  • ColdReader (cold_reader.go) opens the three artifacts from a bucket directory; serves Reader interface. Documents the read-vs-Close concurrency contract; metadata accessors survive Close for logging/error context.

Codec & format (cold_format.go):

  • events.pack: zstd encoder per-worker + process-wide concurrent-safe decoder, format ID 0xFE1E000C.
  • index.pack: no codec, ItemsPerRecord=1 (roaring is already container-encoded), format ID 0xFE1E000B.
  • Filename helpers (EventsPackName(chunkID) etc.) match the backfill design doc layout: {bucketDir}/{chunkID:08d}-events.pack. Bucket-path composition (bucketID = chunkID / 1000, %05d) stays at the orchestrator.

Supporting changes:

  • internal/events: EventIndexBitmapIndex interface (AddTo / Get / All / Len / Close); NewMemBitmaps unexported.
  • chunk.LedgersPerChunk typed as uint32.
  • fullhistory/pkg/geometry/ removed (zero consumers; duplicated chunk.LedgersPerChunk).
  • design-docs/getevents-full-history-design.md §9.1 defers to the backfill workflow doc as authoritative for cold layout.

Issues closed

Part of the umbrella #665. Out of scope (future PRs): #658 backfill workflow, #661 freeze, #662 startup/recovery, #663 segment coordination, #664 ingestion wiring, #666 observability.

Test plan

  • CI is green (go test ./cmd/stellar-rpc/internal/fullhistory/... ./cmd/stellar-rpc/internal/events/...)
  • Round-trip coverage:
    • HotStore: warmup, reopen-after-close, ingest + Lookup + FetchEvents + iterate all
    • ColdReader: open, lookup known/unseen terms, fetch by IDs, stream all
  • On-disk contract pinned by trailer-pinning tests for both events.pack and index.pack (format + ItemsPerRecord against package constants)
  • Failure-path tests:
    • ColdWriter.Finish failure → Close removes partial events.pack
    • WriteColdIndex failure → orphan index.hash removed
    • Pre-cancelled context surfaces context.Canceled from WriteColdIndex
  • IngestLedgerEvents validation tests: duplicate ledger, ledger gap, out-of-chunk-range ledger — each rejects with the matching sentinel and leaves state (events, mirror, offsets) unchanged
  • ColdReader metadata accessors survive Close (asserted via TestColdReader_MetadataSurvivesClose)

🤖 Generated with Claude Code

@socket-security

socket-security Bot commented May 15, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Updatedgolang/​golang.org/​x/​sys@​v0.38.0 ⏵ v0.40.084 +1100100100100

View full report

@urvisavla
urvisavla force-pushed the feat/fh-events-stores branch 2 times, most recently from 5f4c2f3 to d343691 Compare May 15, 2026 07:08

@tamirms tamirms left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of eventstore PR — comments inline below.

}

// Batch durable — apply to the in-memory mirrors.
for i, keys := range termKeys {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the RocksDB batch commits durably (line 348), mirror.AddTo and offsets.Append are called with error returns that don't surface meaningfully. Both errors are unreachable under the function's preconditions (AddTo only fails with ErrClosed which the leading check rules out; Append only fails on sequence mismatch which the validation at line 307 already enforced). But typed-fallible code that can't actually fail forces every reader to reason about a recovery path that doesn't exist — and if either ever did fail post-batch, we'd silently violate the atomicity the type docstring at lines 98-101 advertises.

Replace the post-batch error returns with panic + rationale comment:

// Phase 3: apply the committed batch to the in-memory mirrors.
//
// mirror.AddTo and offsets.Append are typed as fallible but cannot
// fail here:
//   - mirror.AddTo errors only with ErrClosed; we hold h.mu and
//     closed is gated by the leading check.
//   - offsets.Append errors only on out-of-order ledger sequence,
//     which the validation at the top of this function already
//     enforced.
//
// Phase 2 has already committed to disk, so a non-nil return here
// would leave on-disk and in-memory state diverged with no clean
// recovery. Panic so a future regression surfaces loudly instead
// of silently corrupting the hot store.
for i, keys := range termKeys {
    eventID := startID + uint32(i)
    for _, key := range keys {
        if err := h.mirror.AddTo(key, eventID); err != nil {
            panic(fmt.Sprintf("events: mirror.AddTo invariant violation, chunk %s ledger %d: %v",
                h.chunkID, ledgerSeq, err))
        }
    }
}
if err := h.offsets.Append(ledgerSeq, uint32(len(payloads))); err != nil {
    panic(fmt.Sprintf("events: offsets.Append invariant violation, chunk %s ledger %d: %v",
        h.chunkID, ledgerSeq, err))
}
return nil

Update the type-level docstring to reflect the panic contract. BitmapIndex.AddTo and LedgerOffsets.Append stay typed-fallible (they may be called from contexts where errors are recoverable); this is a localized policy at the IngestLedgerEvents call site.

@tamirms tamirms May 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walked through this with @tamirms — the panic recommendation here is overstated. The actual fix is to make the operation idempotent.

The RocksDB batch (phase 2) is already idempotent (same keys, same values, overwrite-on-Put). offsets.Append can't actually fail at this call site (its only failure mode is sequence mismatch, which the leading validation rules out). The only thing that prevents IngestLedgerEvents from being fully retry-safe is memBitmaps.AddTo in list mode — the te.ids = append(te.ids, eventIDs...) at membitmaps.go:109 accumulates duplicates if the eventID was already added.

Key observation: eventIDs are added in monotonically increasing order per term. IngestLedgerEvents assigns eventID = startID + i and iterates i = 0, 1, 2...; warmup iterates events_index in byte-lex order which is numeric order for the BE eventID suffix. So te.ids is always sorted, and any retry duplicate is <= the current last entry (not necessarily equal — it could be from the successful prefix of a partially-failed attempt).

O(1) dedupe:

if te.bm != nil {
    te.bm.AddMany(eventIDs) // already set-semantics; idempotent
} else {
    for _, id := range eventIDs {
        if len(te.ids) > 0 && te.ids[len(te.ids)-1] >= id {
            continue // already in the sorted prefix — retry duplicate
        }
        te.ids = append(te.ids, id)
    }
    if len(te.ids) >= promotionThreshold {
        te.bm = roaring.New()
        te.bm.AddMany(te.ids)
        te.ids = nil
    }
}

The >= is important — not just ==. Trace: if a previous IngestLedgerEvents attempt failed at i=3 after term T had already been added for i=0 and i=1, the retry comes in at i=0 with id = X+0 while te_T.ids = [X+0, X+1] and last = X+1. The == check would treat X+0 as new (because X+0 != X+1) and append, corrupting the list. >= correctly recognizes it as part of the already-added prefix and skips.

With that change:

  • IngestLedgerEvents becomes fully retry-safe regardless of where phase 3 fails.
  • Phase 3 keeps error returns (no panic).
  • The atomicity docstring (lines 98-101) becomes honest — disk and mirror converge to the same state regardless of partial failure.
  • Document BitmapIndex.AddTo contract: "AddTo is idempotent. Callers must add eventIDs in monotonically increasing order per term; the same (key, eventID) pair has the same effect added once or many times."

Withdrawing the panic recommendation above in favor of this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. Split the rationale across the two panics — mirror.AddTo is reachable only via orchestrator-coordination bug (mirror closed mid-ingest); offsets.Append is reachable only via a future refactor breaking the up-front validation. Function docstring grew a "Post-batch atomicity" paragraph.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry — re-reading this thread, the dedup-in-AddTo follow-up was buried as a self-reply on the original panic recommendation, which made it easy to miss. The "Applied" tag here covered splitting the panic rationale (taking the original panic recommendation), but the dedup-in-AddTo proposal that I posted as a follow-up didn't make it across. Re-raising it now because it's the structural fix, and the current rationale comments are already stale:

  • The mirror.AddTo panic comment says it's reachable only "if the freeze orchestrator closed the mirror while ingest was still running" — but BitmapIndex.Close was deleted in commit 331c341f. AddTo no longer has any failure mode, so its error return is vestigial and the panic guards a path that no longer exists.
  • The offsets.Append panic is "defense in depth" for a hypothetical future refactor breaking phase 1.

Both panics exist as workarounds for AddTo not being idempotent. Idempotent AddTo removes the need for either:

if te.bm != nil {
    te.bm.AddMany(eventIDs)  // already set-semantics
} else {
    for _, id := range eventIDs {
        if len(te.ids) > 0 && te.ids[len(te.ids)-1] >= id {
            continue  // already in the sorted prefix — retry duplicate
        }
        te.ids = append(te.ids, id)
    }
    if len(te.ids) >= promotionThreshold {
        te.bm = roaring.New()
        te.bm.AddMany(te.ids)
        te.ids = nil
    }
}

With that:

  • Phase 3 has no failure modes. Both panics go away.
  • AddTo can drop its error return entirely.
  • The atomicity contract becomes honest end-to-end — IngestLedgerEvents is fully retry-safe regardless of where partial failure happens, no orchestrator-coordination caveats needed.
  • BitmapIndex.AddTo docstring states the contract: "AddTo is idempotent. Callers must add eventIDs in monotonically increasing order per term; the same (key, eventID) pair has the same effect added once or many times."

The structural fix is at the AddTo layer, not at the IngestLedgerEvents layer or the panic-vs-error question.

Comment thread cmd/stellar-rpc/internal/events/index.go Outdated
// intersection. A missing row is an error: the only way to
// obtain an eventID is via Lookup, so a miss signals corruption
// or a writer/reader mismatch, not a normal not-found case.
FetchEvents(eventIDs []uint32) iter.Seq2[events.Payload, error]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FetchEvents iterates eventIDs serially (ReadItem per ID on cold, Get per ID on hot). For typical queries (coordinator computes bitmap intersection, hundreds-to-thousands of IDs per chunk), this pays per-call overhead per ID.

Three coupled changes:

  1. Change the Reader.FetchEvents interface signature to materialize a slice, accept context, and document a sorted-input requirement:

    FetchEvents(ctx context.Context, eventIDs []uint32) ([]events.Payload, error)

    Slice instead of iter.Seq2: we materialize internally anyway. ctx: lets the parallel I/O honor cancellation. Add to the docstring: "eventIDs must be sorted ascending with no duplicates. Coordinators iterating a bitmap intersection naturally satisfy this. Behavior is undefined otherwise."

  2. ColdReader.FetchEvents uses c.events.ReadItems(ctx, positions, func(idx int, data []byte) error { return results[idx].Unmarshal(data) }). The packfile already supports parallel scattered reads via ReadItems; we just need to use it.

  3. HotStore.FetchEvents matches the signature. The actual parallelism on the hot side comes via BatchedMultiGetCF — see the separate comment on pkg/rocksdb/rocksdb.go.

All keeps its iter.Seq2 shape — naturally streaming from a scan, no benefit to materializing.

Note this fix only delivers parallel reads if packfile.ReaderOptions.Concurrency is also set — see the comment on cold_reader.go below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied (commit cec8ff82).

Interface change:

FetchEvents(ctx context.Context, eventIDs []uint32) ([]events.Payload, error)

Docstring states the sorted-ascending-no-duplicates precondition, calls out that roaring.Bitmap iteration satisfies it for free, and documents the asymmetric enforcement (cold returns wrapped packfile.ErrPositionsUnsorted; hot reads in given order — neither relied upon).

Cold side: delegates to packfile.ReadItems(ctx, positions, fn). The fn(idx, data) callback writes into results[idx], so concurrent workers produce ordered output by construction. Parallel fan-out kicks in when ColdReaderOptions.Concurrency > 1 (knob landed in batch 3).

Hot side: signature matches but the batching part (your separate comment #13) is in this commit too — details in the reply there.

All() shape: kept as iter.Seq2 per your note.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to add: the applied docstring acknowledges the asymmetry as "hot reads in given order — neither relied upon", but BatchMultiGet is called with sortedInput=true (rocksdb.go:204-210), which tells RocksDB's BatchedMultiGetCF that the input is sorted. With unsorted input + sortedInput=true, BatchedMultiGetCF's behavior is implementation-defined — the hot side does silently rely on the precondition, just without the cold side's explicit error.

A cheap O(N) check before the encode loop makes both sides enforce the same contract:

for i := 1; i < len(eventIDs); i++ {
    if eventIDs[i] <= eventIDs[i-1] {
        return nil, fmt.Errorf("events: FetchEvents requires sorted ascending no-dup eventIDs (chunk %s)", h.chunkID)
    }
}

For symmetry with the cold side's wrapped packfile.ErrPositionsUnsorted, we could also define a package-level ErrUnsortedEventIDs and return that wrapped so callers can errors.Is against either path.

Also worth revising the implementation comment at hot_store.go line ~237 — "builds a sorted [][]byte of encoded eventID keys" is misleading since the slice isn't actively sorted; it's sorted post-validation by the caller's precondition.

Comment thread cmd/stellar-rpc/internal/fullhistory/pkg/chunk/chunk.go
Comment thread cmd/stellar-rpc/internal/fullhistory/pkg/chunk/chunk.go
return key[:]
}

func encodeOffsetValue(cumulative uint32) []byte {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

events_offsets stores cumulative counts on disk and warmupOffsets deltas them back at startup. The on-disk format and the in-memory API (LedgerOffsets.Append(ledger, eventCount)) disagree on shape, requiring the delta arithmetic at warmup time.

Switch to per-ledger deltas on disk. Warmup becomes a direct offsets.Append(ledger, eventCount) — no delta arithmetic, the on-disk and in-memory shapes match. No correctness impact.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied. On-disk shape is now per-ledger event count (not cumulative). Warmup drops the delta arithmetic; the on-disk and in-memory shapes match. endID local var dropped (unused).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One leftover from this refactor: encodeOffsetValue(cumulative uint32) at hot_store.go:558 — the parameter name is stale from the pre-applied cumulative-on-disk shape. The caller now passes eventCount := uint32(len(payloads)) per-ledger, and the comment at line 407 explicitly says "per-ledger event count, not cumulative." The function name and parameter name still encode the old shape.

Minimum: rename cumulativeeventCount so the parameter matches what it represents.

Could also rename encodeOffsetValueencodeLedgerEventCount for symmetry with the post-applied semantics (and the same on the decode side at warmup). The current encodeOffsetKey / encodeOffsetValue pair reads as "key/value of the offsets CF" but the value isn't an offset anymore.

Comment thread cmd/stellar-rpc/internal/fullhistory/pkg/chunk/chunk_test.go Outdated
Comment thread cmd/stellar-rpc/internal/events/ingest.go Outdated
Introduces the eventstore package — the layer that serves one
chunk's events through its hot (active-ingest) and cold (frozen)
lifetime — plus the cold codec, on-disk layout, validation
contracts, and supporting refactors the freeze and backfill
services will rely on.

Package contents
================

HotStore (hot_store.go).
- Wraps one Chunk's RocksDB DB plus the in-memory term mirror and
  ledger-offset cache that feed the query path.
- Read methods: ChunkID, EventCount, Offsets, Lookup, FetchEvents,
  All, Index, NextEventID. Lookup misses return ErrTermNotFound.
- IngestLedgerEvents validates the ledger sequence up front
  (in-range AND next-expected) before any RocksDB write, with
  ErrLedgerOutOfRange / ErrLedgerOutOfOrder sentinels. Atomicity:
  per-ledger writes commit data + index + offsets in one Batch.
- Warmup reconstructs the in-memory mirrors from the per-Chunk DB
  at open.

ColdWriter (cold_writer.go).
- Streams a Chunk's events.Payload sequence into events.pack
  (zstd at ItemsPerRecord=128). Finish commits atomically and
  embeds the events.LedgerOffsets as packfile app data. A
  deferred Close on any error path cleans up the partial file
  and worker goroutines.
- ErrColdWriterClosed sentinel mirrors ErrColdReaderClosed.

WriteColdIndex (cold_index.go).
- Batch-produces index.pack + index.hash for one Chunk.
  index.pack is one record per MPHF slot (ItemsPerRecord=1; raw,
  since roaring's MarshalBinary is already container-encoded).
- Takes context.Context; cancellation propagates through
  buildMPHF to streamhash. On error, removes orphaned index.hash
  so the bucket directory is clean for retry.

ColdReader (cold_reader.go).
- Opens events.pack + index.pack + index.hash from a bucket
  directory; serves the events.Reader interface.
- Concurrency: read methods (Lookup, FetchEvents, All) are safe
  to call concurrently with each other but NOT with Close;
  callers drain in-flight reads (including iterator consumption)
  before closing.
- Metadata accessors (ChunkID, EventCount, Offsets) are
  populated at Open and survive Close so callers can use them
  for logging or error context. Offsets is read-only.

Cold codec and format (cold_format.go).
- events.pack: zstd encoder (per-worker) + shared concurrent-safe
  decoder, ItemsPerRecord=128. Format ID 0xFE1E000C.
- index.pack: no codec, ItemsPerRecord=1. Format ID 0xFE1E000B.
- LedgerOffsets app-data wire format (versioned, BE).
- MPHF wrapper (unexported mphf / buildMPHF / openMPHF) around
  github.com/tamirms/streamhash.

On-disk layout (matches full-history/design-docs/03-backfill-workflow.md).
- Cold artifacts live as flat siblings inside a bucket directory:
  {bucketDir}/{chunkID:08d}-events.pack, {chunkID:08d}-index.pack,
  {chunkID:08d}-index.hash.
- Bucket-path composition (bucketID = chunkID / 1000, %05d) is the
  orchestrator's job; the eventstore composes per-chunk filenames
  via EventsPackName / IndexPackName / IndexHashName.
- design-docs/getevents-full-history-design.md §9.1 defers to the
  backfill doc as authoritative.

Supporting changes outside eventstore
=====================================

- internal/events: EventIndex -> BitmapIndex interface (AddTo /
  Get / All / Len / Close); NewMemBitmaps unexported.
- chunk.LedgersPerChunk typed as uint32 so it composes with
  ledger sequences without per-call casts; package doc refreshed
  to refer to fullhistory/pkg/stores/ as the home for subsystem-
  specific concerns.
- fullhistory/pkg/geometry/ removed (zero consumers; duplicated
  chunk.LedgersPerChunk).

Tests
=====

- Round-trips for HotStore (warmup, reopen, ingest + lookup +
  fetch + iterate) and ColdReader (open, lookup known/unseen,
  fetch by IDs, stream all).
- Trailer-pinning: events.pack and index.pack format +
  ItemsPerRecord asserted against the package constants.
- Failure paths: ColdWriter cleanup on a failed Finish, orphan
  index.hash cleanup on a failed WriteColdIndex,
  context.Canceled surfaced from a pre-cancelled ctx.
- IngestLedgerEvents validation: duplicate ledger, ledger gap,
  out-of-chunk-range ledger — each rejects with the matching
  sentinel and leaves state (events, mirror, offsets) unchanged.
- ColdReader metadata accessors survive Close.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@urvisavla
urvisavla force-pushed the feat/fh-events-stores branch from d343691 to 289f26a Compare May 15, 2026 22:40
urvisavla and others added 4 commits May 15, 2026 16:03
Adds ColdWriterOptions{Concurrency, BytesPerSync} threaded through to
packfile.Create. Zero-value preserves current behavior; batch workloads
(freeze, backfill) opt in to parallel zstd + background writeback.

Per the reviewer's measurements, Concurrency=8 takes EBS write
throughput from ~449 MB/s to ~821 MB/s and NVMe to ~2.8 GB/s.
BytesPerSync=1MB cuts the final fdatasync from ~2.3s to ~55ms on EBS.

API change: NewColdWriter(chunkID, bucketDir, opts) — added a third
parameter. Tests pass ColdWriterOptions{} to preserve today's serial
behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…-to-bytes

ColdReaderOptions.Concurrency forwards to packfile.ReaderOptions.Concurrency
on both events.pack and index.pack, so ReadItems on cold artifacts can fan out
when the caller opts in. Zero stays serial (packfile normalizes to 1).

openMPHF swaps streamhash.Open (mmap) for os.ReadFile + streamhash.OpenBytes.
MPHF files are small (~hundreds of KB at production term counts); on storage
with expensive random IOPS (EBS, ~1 ms each), mmap page-faults on cold Lookups
cost more than one up-front sequential read amortized over the index lifetime.

Regression tests pin:
  - MPHF stays usable after the on-disk index.hash is unlinked.
  - Open with Concurrency=4 produces identical Lookup/FetchEvents results.
  - Negative Concurrency surfaces as an Open error.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
CI's golangci-lint gci formatter was failing on the trailing blank line
introduced upthread. Fix-forward to unblock the eventstore review-fix
commits stacked on top.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Reader.FetchEvents materializes a sorted-aligned []events.Payload, accepts
ctx, and documents the sorted-input precondition. Cold side uses
packfile.ReadItems (parallel when ColdReaderOptions.Concurrency > 1); hot
side issues one batched RocksDB call via the new Store.BatchMultiGet wrapper
(BatchedMultiGetCF + async_io). Single CGO crossing replaces N per-event
crossings; on EBS the kernel can overlap SST page reads.

The "order is caller-controlled, unsorted input works" contract is
abolished. Coordinators iterating a roaring.Bitmap intersection satisfy
the new sorted-ascending precondition for free; behavior is undefined
otherwise (cold returns wrapped ErrPositionsUnsorted, hot reads in
given order — neither relied upon).

Tests: TestHotStore_FetchEventsPreservesOrder deleted (pinned the
abolished contract). New regression tests pin sorted-input rejection
on cold, ctx-cancel observance on both impls, and a 256-key batched
round-trip on hot. Five wrapper-level tests cover BatchMultiGet
directly (round-trip, missing keys, empty input, closed store,
unknown CF).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
// eventID order. The freeze loop uses this to dump a hot Chunk
// into a Writer without intermediate buffering.
// Each events.Payload carries its LedgerSequence, so consumers can
// track ledger boundaries without separate signaling.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Single-key Lookup forces multi-term queries to either serialize lookups or have the caller wrap each in its own goroutine. For a query that intersects N terms in a chunk (the standard pattern — contractID + topic filters), this means N MPHF queries + N separate packfile.ReadItem calls per chunk.

A batch API can do better by exploiting the existing primitives:

LookupKeys(ctx context.Context, keys []events.TermKey) ([]*roaring.Bitmap, error)

Implementation sketch for ColdReader:

  1. MPHF-resolve all keys to slots in one in-memory pass.
  2. Sort by slot, dedupe (multiple keys may collide to the same MPHF slot in pathological cases).
  3. Single c.index.ReadItems(ctx, sortedSlots, fn) call — same primitive we use for FetchEvents. The packfile layer coalesces adjacent slot reads into single ReadAt calls and fans out across the worker count configured via ColdReaderOptions.Concurrency.
  4. Scatter the resulting bitmaps back to caller-input positions.

HotStore's implementation is trivially N in-memory mirror.Get calls (no I/O to batch).

Wins:

  • One ReadItems syscall path instead of N ReadItem calls — same shape we already chose for FetchEvents.
  • Sort-by-slot enables coalescing of adjacent records into a single read.
  • Caller doesn't have to write goroutine fan-out at every multi-term call site.
  • Result positions align with input — caller's intersection pipeline doesn't need to track ordering.

Suggested signature on Reader:

type Reader interface {
    // ... existing methods ...

    // LookupKeys returns bitmaps for each key, aligned positionally with the
    // input slice. result[i] is nil if keys[i] has no matching events in this
    // chunk; the function does not return ErrTermNotFound for individual misses.
    LookupKeys(ctx context.Context, keys []events.TermKey) ([]*roaring.Bitmap, error)
}

urvisavla and others added 2 commits May 16, 2026 00:24
…ne Get

Closed lifecycle on memBitmaps was the source of a live-pointer
escape hazard: between mirror.Close() and HotStore.Close(), Get
returned the live bitmap to the caller, who could mutate it via
bm.And(other) (the mutating method form of intersection) and
corrupt a concurrent WriteColdIndex iteration.

Fix: Lookup always returns a clone. With reads always cloning, the
closed lifecycle is no longer needed and is removed.

- BitmapIndex.Close removed from interface.
- memBitmaps.Close and the closed field removed.
- memBitmaps.Get always clones under RLock.
- memBitmaps.All holds RLock for the iteration body, yields live
  pointers valid only inside the body.
- HotStore.Close drops the mirror reference; no mirror.Close() call.
- WriteColdIndex calls MarshalBinary inside the All body and
  accumulates (slot, fp, []byte); sort + write happen after with no
  live pointers in flight.

Race detector clean on a new concurrent Get + All test (8
goroutines x 50 rounds mixing Get-and-mutate / All-iterate against
a 200-term store with sparse and dense terms).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Batch 128 bitmaps per index.pack record to shrink the resident
offset array. At ~600K unique terms per production chunk, batch=1
produces a ~2.4 MB offset array per ColdReader; batch=128 reduces
that to ~19 KB (~130x smaller), and the cost scales linearly with
concurrent reader count.

Lookup latency is unchanged in measurement: per-record I/O reads
128 bitmaps' worth of bytes but only decodes one, and the bitmaps
themselves are small enough that the wasted read is dominated by
the bitmap deserialization the caller does anyway.

Constants live in cold_format.go alongside eventsPackItemsPerRecord.
TestIndexPack_TrailerPinsFormatAndRecordSize asserts the new value
via the constant rather than a hardcoded literal so a future tweak
flips both sides together.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
// before the store is usable).
func openHotChunk(dataDir string, chunkID chunk.ID, logger *supportlog.Entry) (*rocksdb.Store, error) {
store, err := rocksdb.New(rocksdb.Config{
Path: HotChunkDir(dataDir, chunkID),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

openHotChunk opens the per-chunk RocksDB without Tuning, so the three CFs inherit applyPinnedCFOptions — including SetCompression(NoCompression) at rocksdb.go:530 — and the default BBTO (16 KiB block) via applySharedTableOptions. Two related gaps:

(1) Compression on DataCF

DataCF holds XDR-encoded event payloads — significant compression headroom (repeated field tags, padding, similar structures across events). IndexCF (20-byte hash keys, empty values) and OffsetsCF (4-byte rows) have nothing to compress; leave them as NoCompression.

Recommendation: enable ZSTDCompression on DataCF. Per-block decompression CPU on cache-miss reads is real but well below the per-IOP latency on EBS-class storage.

(2) Block size per CF

The shared BBTO uses RocksDB's default 16 KiB block for all CFs. Two adjustments fit the eventstore CFs:

  • DataCF: bump to 32 KiB. Larger blocks give zstd more context per compression unit and amortize per-block headers; batch-fetch shapes (FetchEvents) read multiple events per block.
  • IndexCF: drop to 4 KiB. Random Lookups touch dense small-key blocks one at a time; smaller blocks let RocksDB's block cache hold more distinct blocks, improving hit ratio for the random-access pattern.
  • OffsetsCF: same as IndexCF.

Structural cause: the wrapper pins compression in applyPinnedCFOptions and uses a shared BBTO with no per-CF override. To enable the per-CF policies above, Tuning would need per-CF override hooks for Compression and BlockSize. The existing per-CF knobs (write buffer, level0 thresholds) suggest the same shape would fit.

…dTo, batched LookupKeys, per-CF rocksdb tuning, sort-input validation, encodeOffsetValue rename

- ColdReader fully-async open: packfile.Open stays lazy, events.pack
  metadata loads on first call via sync.OnceValues, MPHF reads in a
  background goroutine awaited via a second sync.OnceValues. Open
  does no I/O — file-content errors (missing files, format mismatch,
  chunkID/path disagreement) surface from the first metadata access
  or first Lookup, not from Open. Reader.EventCount and Offsets
  return errors; ChunkID stays infallible. closeUnderlying + named-
  return pattern dropped; Close drains the MPHF goroutine before
  tearing down. TestColdReader_MetadataSurvivesClose replaced with
  TestColdReader_MetadataErrorsAfterClose; OpenMissing* tests
  rewritten for lazy semantics.

- AddTo idempotency: memBitmaps.AddTo's list-mode path skips IDs
  already in the sorted prefix (>= check — catches retries that
  replay multiple already-committed IDs). bitmap-mode path is
  already set-semantic via roaring.AddMany. BitmapIndex.AddTo drops
  its error return; IngestLedgerEvents' phase-3 AddTo panic goes
  away (it guarded a closed-state that no longer exists) and the
  offsets.Append panic becomes an error return.

- LookupKeys batch API: added to the Reader interface. HotStore does
  N in-memory mirror clones. ColdReader MPHF-resolves all keys,
  sorts the surviving (outIdx, slot) pairs, dedupes adjacent
  duplicates (residual MPHF collisions can map two distinct keys to
  the same rank), and runs one packfile.ReadItems pass over the
  unique slot list. New verifyAndDeserializeBitmap helper shared
  between Lookup and LookupKeys.

- Per-CF rocksdb tuning: rocksdb.CFOptions{Compression, BlockSize}
  added; rocksdb.Config.PerCFOptions map plumbs them through.
  Pinned NoCompression default and default 16 KiB block preserved
  for facades that don't opt in. HotStore opts DataCF into
  ZSTDCompression with a 32 KiB block (XDR payloads, batch-fetch
  shape) and IndexCF / OffsetsCF into 4 KiB blocks (sparse-value
  random-lookup shape).

- Sort-input validation: HotStore.FetchEvents now does the O(N)
  strict-ascending check that the cold path already does (via
  packfile.ReadItems). New ErrUnsortedEventIDs sentinel; both paths
  return it wrapped so callers can errors.Is uniformly.

- encodeOffsetValue rename: parameter "cumulative uint32" was stale
  after the on-disk format switched to per-ledger event counts.
  Renamed to encodeLedgerEventCount(eventCount uint32); related
  docstrings refreshed.

Tests pass under -race. go vet clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@tamirms
tamirms force-pushed the feat/fh-events-stores branch from 5d91c1b to aed0770 Compare May 19, 2026 14:04
@tamirms
tamirms merged commit 50d5128 into stellar:feature/full-history May 19, 2026
15 checks passed
chowbao pushed a commit to chowbao/stellar-rpc that referenced this pull request May 19, 2026
Brings in the upstream version of PR stellar#740 (eventstore) and PR stellar#743
(full-history-backfill), which superseded our local cherry-pick.

Conflict resolution:
- All eventstore + events files: took upstream (canonical post-PR-stellar#740).
- go.mod: kept both indirect deps (streamhash from stellar#740 + xxh3 local).
- go.sum: took upstream; reconciled via go mod tidy.

Post-merge build fixes:
- scripts/full-history-backfill/main.go: pkg/geometry was removed by
  upstream PR stellar#740 but the script still imported it. Switched to
  pkg/chunk.LedgersPerChunk (same value, new home).
- scripts/bench-fullhistory/bench_events.go: eventstore.Reader.Offsets()
  now returns (Offsets, error) — added error handling.
- scripts/bench-fullhistory/seed_events.go: eventstore.HotStore.EventCount()
  now returns (uint32, error) — added error handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@sydneynotthecity sydneynotthecity added this to the platform sprint 72 milestone May 26, 2026
chowbao added a commit that referenced this pull request Jun 23, 2026
Vertical slice 1 of the unified ingestion workflow (design #722), split out of
the all-three-types implementation (#795) so the skeleton can be reviewed on its
own. This slice is the streaming daemon carved to the LEDGERS data type only,
plus the full type-agnostic skeleton. Events (slice 2) and tx-hash (slice 3)
follow as separate PRs.

Contains (carved to ledgers-only):
- streaming/: the catalog (meta-store) + one-write protocol + key-driven sweeps;
  per-chunk hot RocksDB (ledgers CF) via pkg/stores/hotchunk; catch-up backfill,
  live ingestion (one atomic synced WriteBatch per ledger), and the
  freeze -> discard -> prune lifecycle; derived progress (resume recomputed from
  durable state, never stored); startup orchestration; config + single-process
  locking; surgical recovery; Prometheus observability; and the audit command
  (INV-2/3/4 for ledgers).
- ingest/{driver,service}: the single multi-CF hot-DB driving, reduced to the
  ledger column family.
- Tests: crash-injection/convergence suite (every injected state converges to
  INV-2/3/4 via audit) and an in-process lifecycle E2E (first-start -> ingest ->
  freeze -> discard -> restart-resume re-derivation -> prune -> audit).

Composes unchanged base dependencies (already on feature/full-history via
#765/#728/#740/#695): pkg/stores/{ledger,eventstore,txhash,metastore},
internal/{packfile,events}. The events/tx-hash stores remain present as composed
deps but are not wired by the ledger daemon.

Deferred to later slices (intentionally NOT here):
- Events column families + cold segments (slice 2).
- The tx-hash CF, .bin/.idx, and the per-window rolling-index subsystem
  (slice 3) -- the design's hardest-to-review component.
- Read-path dispatch + v1 SQLite retirement (#770/#772/#774).

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
chowbao added a commit that referenced this pull request Jun 23, 2026
Vertical slice 1 of the unified ingestion workflow (design #722), split out of
the all-three-types implementation (#795) so the skeleton can be reviewed on its
own. This slice is the streaming daemon carved to the LEDGERS data type only,
plus the full type-agnostic skeleton. Events (slice 2) and tx-hash (slice 3)
follow as separate PRs.

Contains (carved to ledgers-only):
- streaming/: the catalog (meta-store) + one-write protocol + key-driven sweeps;
  per-chunk hot RocksDB (ledgers CF) via pkg/stores/hotchunk; catch-up backfill,
  live ingestion (one atomic synced WriteBatch per ledger), and the
  freeze -> discard -> prune lifecycle; derived progress (resume recomputed from
  durable state, never stored); startup orchestration; config + single-process
  locking; surgical recovery; Prometheus observability; and the audit command
  (INV-2/3/4 for ledgers).
- ingest/{driver,service}: the single multi-CF hot-DB driving, reduced to the
  ledger column family.
- Tests: crash-injection/convergence suite (every injected state converges to
  INV-2/3/4 via audit) and an in-process lifecycle E2E (first-start -> ingest ->
  freeze -> discard -> restart-resume re-derivation -> prune -> audit).

Composes unchanged base dependencies (already on feature/full-history via
#765/#728/#740/#695): pkg/stores/{ledger,eventstore,txhash,metastore},
internal/{packfile,events}. The events/tx-hash stores remain present as composed
deps but are not wired by the ledger daemon.

Deferred to later slices (intentionally NOT here):
- Events column families + cold segments (slice 2).
- The tx-hash CF, .bin/.idx, and the per-window rolling-index subsystem
  (slice 3) -- the design's hardest-to-review component.
- Read-path dispatch + v1 SQLite retirement (#770/#772/#774).

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
chowbao added a commit that referenced this pull request Jun 23, 2026
Stacked on slice 1 (the ledgers skeleton); this commit's diff is only the
events additions on top of it. Adds the EVENTS data type to the streaming
daemon:
- events column families in the per-chunk hot RocksDB (hotchunk), so one
  atomic synced WriteBatch per ledger now carries ledgers + events;
- the events cold-segment writer in processChunk;
- the chunk:{c}:events catalog key + its sweeps;
- events coverage in the audit (INV-3 disk<->catalog) and in the
  crash-injection/convergence suite and lifecycle E2E.

Events is a per-chunk artifact, like ledgers — no window/index subsystem
(that is tx-hash, deferred to slice 3).

Composes the events store (pkg/stores/eventstore, #740/#756) and the events
design (getevents-full-history-design.md, #635), already on
feature/full-history.

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
chowbao added a commit that referenced this pull request Jun 23, 2026
Stacked on slice 1 (the ledgers skeleton); this commit's diff is only the
events additions on top of it. Adds the EVENTS data type to the streaming
daemon:
- events column families in the per-chunk hot RocksDB (hotchunk), so one
  atomic synced WriteBatch per ledger now carries ledgers + events;
- the events cold-segment writer in processChunk;
- the chunk:{c}:events catalog key + its sweeps;
- events coverage in the audit (INV-3 disk<->catalog) and in the
  crash-injection/convergence suite and lifecycle E2E.

Events is a per-chunk artifact, like ledgers — no window/index subsystem
(that is tx-hash, deferred to slice 3).

Composes the events store (pkg/stores/eventstore, #740/#756) and the events
design (getevents-full-history-design.md, #635), already on
feature/full-history.

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
chowbao added a commit that referenced this pull request Jun 23, 2026
Stacked on slice 1 (the ledgers skeleton); this commit's diff is only the
events additions on top of it. Adds the EVENTS data type to the streaming
daemon:
- events column families in the per-chunk hot RocksDB (hotchunk), so one
  atomic synced WriteBatch per ledger now carries ledgers + events;
- the events cold-segment writer in processChunk;
- the chunk:{c}:events catalog key + its sweeps;
- events coverage in the audit (INV-3 disk<->catalog) and in the
  crash-injection/convergence suite and lifecycle E2E.

Events is a per-chunk artifact, like ledgers — no window/index subsystem
(that is tx-hash, deferred to slice 3).

Composes the events store (pkg/stores/eventstore, #740/#756) and the events
design (getevents-full-history-design.md, #635), already on
feature/full-history.

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
chowbao added a commit that referenced this pull request Jun 23, 2026
Stacked on slice 1 (the ledgers skeleton); this commit's diff is only the
events additions on top of it. Adds the EVENTS data type to the streaming
daemon:
- events column families in the per-chunk hot RocksDB (hotchunk), so one
  atomic synced WriteBatch per ledger now carries ledgers + events;
- the events cold-segment writer in processChunk;
- the chunk:{c}:events catalog key + its sweeps;
- events coverage in the audit (INV-3 disk<->catalog) and in the
  crash-injection/convergence suite and lifecycle E2E.

Events is a per-chunk artifact, like ledgers — no window/index subsystem
(that is tx-hash, deferred to slice 3).

Composes the events store (pkg/stores/eventstore, #740/#756) and the events
design (getevents-full-history-design.md, #635), already on
feature/full-history.

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
chowbao added a commit that referenced this pull request Jun 24, 2026
Stacked on slice 1 (the ledgers skeleton); this commit's diff is only the
events additions on top of it. Adds the EVENTS data type to the streaming
daemon:
- events column families in the per-chunk hot RocksDB (hotchunk), so one
  atomic synced WriteBatch per ledger now carries ledgers + events;
- the events cold-segment writer in processChunk;
- the chunk:{c}:events catalog key + its sweeps;
- events coverage in the audit (INV-3 disk<->catalog) and in the
  crash-injection/convergence suite and lifecycle E2E.

Events is a per-chunk artifact, like ledgers — no window/index subsystem
(that is tx-hash, deferred to slice 3).

Composes the events store (pkg/stores/eventstore, #740/#756) and the events
design (getevents-full-history-design.md, #635), already on
feature/full-history.

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
chowbao added a commit that referenced this pull request Jun 24, 2026
Stacked on slice 1 (the ledgers skeleton); this commit's diff is only the
events additions on top of it. Adds the EVENTS data type to the streaming
daemon:
- events column families in the per-chunk hot RocksDB (hotchunk), so one
  atomic synced WriteBatch per ledger now carries ledgers + events;
- the events cold-segment writer in processChunk;
- the chunk:{c}:events catalog key + its sweeps;
- events coverage in the audit (INV-3 disk<->catalog) and in the
  crash-injection/convergence suite and lifecycle E2E.

Events is a per-chunk artifact, like ledgers — no window/index subsystem
(that is tx-hash, deferred to slice 3).

Composes the events store (pkg/stores/eventstore, #740/#756) and the events
design (getevents-full-history-design.md, #635), already on
feature/full-history.

Built against RocksDB 10.9.1 (grocksdb 1.10.7); fullhistory tree green on the
non-short suite incl. the lifecycle E2E.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants