Add eventstore: per-Chunk hot + cold event storage for full-history - #740
Conversation
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
5f4c2f3 to
d343691
Compare
tamirms
left a comment
There was a problem hiding this comment.
Review of eventstore PR — comments inline below.
| } | ||
|
|
||
| // Batch durable — apply to the in-memory mirrors. | ||
| for i, keys := range termKeys { |
There was a problem hiding this comment.
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 nilUpdate 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.
There was a problem hiding this comment.
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:
IngestLedgerEventsbecomes fully retry-safe regardless of where phase 3 fails.- Phase 3 keeps
errorreturns (no panic). - The atomicity docstring (lines 98-101) becomes honest — disk and mirror converge to the same state regardless of partial failure.
- Document
BitmapIndex.AddTocontract: "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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.AddTopanic comment says it's reachable only "if the freeze orchestrator closed the mirror while ingest was still running" — butBitmapIndex.Closewas deleted in commit331c341f.AddTono longer has any failure mode, so itserrorreturn is vestigial and the panic guards a path that no longer exists. - The
offsets.Appendpanic 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.
AddTocan drop itserrorreturn entirely.- The atomicity contract becomes honest end-to-end —
IngestLedgerEventsis fully retry-safe regardless of where partial failure happens, no orchestrator-coordination caveats needed. BitmapIndex.AddTodocstring 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.
| // 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] |
There was a problem hiding this comment.
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:
-
Change the
Reader.FetchEventsinterface 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." -
ColdReader.FetchEventsusesc.events.ReadItems(ctx, positions, func(idx int, data []byte) error { return results[idx].Unmarshal(data) }). The packfile already supports parallel scattered reads viaReadItems; we just need to use it. -
HotStore.FetchEventsmatches the signature. The actual parallelism on the hot side comes viaBatchedMultiGetCF— see the separate comment onpkg/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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| return key[:] | ||
| } | ||
|
|
||
| func encodeOffsetValue(cumulative uint32) []byte { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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 cumulative → eventCount so the parameter matches what it represents.
Could also rename encodeOffsetValue → encodeLedgerEventCount 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.
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]>
d343691 to
289f26a
Compare
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. |
There was a problem hiding this comment.
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:
- MPHF-resolve all keys to slots in one in-memory pass.
- Sort by slot, dedupe (multiple keys may collide to the same MPHF slot in pathological cases).
- Single
c.index.ReadItems(ctx, sortedSlots, fn)call — same primitive we use forFetchEvents. The packfile layer coalesces adjacent slot reads into singleReadAtcalls and fans out across the worker count configured viaColdReaderOptions.Concurrency. - 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
ReadItemssyscall path instead of NReadItemcalls — same shape we already chose forFetchEvents. - 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)
}…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), |
There was a problem hiding this comment.
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]>
5d91c1b to
aed0770
Compare
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]>
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.
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.
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.
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.
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.
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.
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.
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.
Summary
Introduces the
eventstorepackage — 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):IngestLedgerEventsvalidates ledger range and next-expected sequence before any RocksDB write (ErrLedgerOutOfRange/ErrLedgerOutOfOrder); per-ledger writes commit data + index + offsets in one atomic Batch.ChunkID,EventCount,Offsets,Lookup,FetchEvents,All,Index,NextEventID.Warmupreconstructs the in-memory mirrors from the per-Chunk DB at open.Cold side:
ColdWriter(cold_writer.go) streamsevents.Payloadintoevents.pack(zstd at ItemsPerRecord=128) and embedsLedgerOffsetsas app data. DeferredClosecleans up the partial file + workers on any error path.WriteColdIndex(cold_index.go) batch-producesindex.pack+index.hash. Takescontext.Context; cancels throughbuildMPHFto streamhash; removes orphanindex.hashon error so the bucket dir is clean for retry.ColdReader(cold_reader.go) opens the three artifacts from a bucket directory; servesReaderinterface. 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 ID0xFE1E000C.index.pack: no codec, ItemsPerRecord=1 (roaring is already container-encoded), format ID0xFE1E000B.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:EventIndex→BitmapIndexinterface (AddTo/Get/All/Len/Close);NewMemBitmapsunexported.chunk.LedgersPerChunktyped asuint32.fullhistory/pkg/geometry/removed (zero consumers; duplicatedchunk.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
go test ./cmd/stellar-rpc/internal/fullhistory/... ./cmd/stellar-rpc/internal/events/...)events.packandindex.pack(format + ItemsPerRecord against package constants)ColdWriter.Finishfailure →Closeremoves partialevents.packWriteColdIndexfailure → orphanindex.hashremovedcontext.CanceledfromWriteColdIndexIngestLedgerEventsvalidation tests: duplicate ledger, ledger gap, out-of-chunk-range ledger — each rejects with the matching sentinel and leaves state (events, mirror, offsets) unchangedColdReadermetadata accessors surviveClose(asserted viaTestColdReader_MetadataSurvivesClose)🤖 Generated with Claude Code