You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Cold-storage ledger store at cmd/stellar-rpc/internal/fullhistory/pkg/stores/ledger/, sibling to the existing hot_store.go (RocksDB-backed). Reader + writer ship together so round-trip tests are authoritative.
Backed by the in-repo packfile library at cmd/stellar-rpc/internal/packfile/ with the native CGo zstd codec at cmd/stellar-rpc/internal/zstd/. No github.com/klauspost/compress/zstd anywhere.
Reuses the Entry type already exported from hot_store.go (type Entry struct { Seq uint32; Bytes []byte }).
No LedgerStore interface spanning hot + cold — sibling concrete types in one package; federation deferred to a separate slice.
firstSeq encoding.NewColdWriter takes firstSeq at construction. The writer encodes firstSeq as a 4-byte big-endian uint32 into the packfile's AppData trailer field (WriterOptions.AppData). OpenColdStore reads + decodes AppData to recover firstSeq. lastSeq is derived as firstSeq + TotalItems() - 1 from the packfile reader's metadata accessors.
Contiguous-append invariant.AppendLedger(seq, bytes) accepts only seq == firstSeq + n where n is the count of prior successful appends. A gap or out-of-order seq returns an error and does NOT advance internal state.
Bytes-verbatim seam. Caller passes uncompressed XDR-encoded LedgerCloseMeta bytes to AppendLedger; GetLedgerRaw returns the same uncompressed bytes. Zstd encode/decode is internal — handled by the packfile primitive's RecordEncoder / RecordDecoder plumbing. Caller never sees zstd-framed bytes.
Encoder factory.newEncoder returns a fresh packfile.RecordEncoder per call; the packfile writer calls it once per worker. Production wiring: newEncoder = func() packfile.RecordEncoder { return zstd.NewCompressor(...) }.
Decoder ownership.OpenColdStore takes a single concurrent-safe packfile.RecordDecoder (production: shared *zstd.Decompressor per process). ColdStore.Close closes the packfile reader but does NOT close the decoder — the decoder's lifecycle is the caller's.
One .pack per ColdStore. Cross-chunk federation (a reader that spans multiple .pack files keyed on seq) is deferred to a separate slice.
Close-before-Finalize.ColdWriter.Close called before Finalize removes the partial .pack file (matches packfile.Writer.Close behavior).
Close-after-Finalize.ColdWriter.Close called after Finalize is a no-op (or close-only on the underlying packfile writer if not already closed).
Closed-fence. Every public method on ColdWriter and ColdStore checks a closed atomic flag at entry and returns stores.ErrStoreClosed (or yields it and returns, for iteration) on closed.
Error translation at the L2 boundary.packfile.ErrPositionOutOfRange (and any other miss-sentinel from the packfile primitive) translates to stores.ErrNotFound. Callers depend only on pkg/stores sentinels; they never see packfile.* errors directly.
Logger.*supportlog.Entry (not a custom interface). Tests use bytes.Buffer + (*supportlog.Entry).SetOutput — no fixture types.
Implementation notes — IterateLedgers wrapper
ColdStore.IterateLedgers(start, end) is a thin wrapper over the packfile reader's iterator-shaped API. Signature from PR #712:
Clamp before subtracting.seq - firstSeq underflows if seq < firstSeq since both are uint32. Clamp start and end to [firstSeq, lastSeq] before the subtraction.
Copy each yielded slice.ReadRange's []byte becomes invalid after the iterator exits. HotStore.IterateLedgers does the same copy at hot_store.go:114 (append([]byte(nil), e.Value...)) — match that pattern.
Out-of-window is a no-op, not an error. Iteration semantics: empty result + nil error. packfile.ErrPositionOutOfRange matters only for GetLedgerRaw point lookups (where we translate it to stores.ErrNotFound); iteration has already clamped to bounds before calling ReadRange.
Test fixtures + sizing (pinned)
Reuse the in-package helpers from hot_store_test.go — same ledger package, unexported helpers are accessible from cold tests:
silentLogger() (hot_store_test.go:24) for the logger argument.
makeRandomLedgerCloseMeta(ledgerSeq, txCount, networkPassphrase) (hot_store_test.go:302) to build realistic XDR-encoded ledgers. Call .MarshalBinary() on the returned xdr.LedgerCloseMeta to get the raw bytes that ColdWriter.AppendLedger consumes.
Default test pack size: 10 ledgers. Use 10 (not 100, not 1000) in every round-trip / iteration test — keeps the suite fast and the assertions readable. Use txCount = 2 or 3 in makeRandomLedgerCloseMeta for the same reason — these are storage tests, not transaction-volume tests.
Build a small openTestColdWriter(t) / openTestColdStore(t) pair (≤5 lines each, mirroring openTestHotStore) for cleanup-on-failure ergonomics. Keep them in cold_*_test.go.
Test plan (this is the spec — implement in this order)
NewColdWriter validation: path / newEncoder / logger required; missing any returns rocksdb.ErrInvalidConfig (or the package's own sentinel — match hot_store.go's style).
AppendLedger happy path: append one ledger (built via makeRandomLedgerCloseMeta, MarshalBinary()), round-trip via OpenColdStore + GetLedgerRaw. Assert byte-equality.
AppendLedger contiguous-append: a gap (seq != expected next) returns an error and the writer's internal counter does NOT advance.
AppendLedger out-of-order: seq < expected next returns an error.
Finalize emits a complete .pack file: verify the trailer + AppData round-trip via packfile.Open.
Close before Finalize removes the partial .pack file.
Close after Finalize is a no-op; calling Close twice after Finalize is also a no-op.
AppendLedger after Close returns stores.ErrStoreClosed.
OpenColdStore reads firstSeq from AppData and derives lastSeq from packfile.Reader.TotalItems().
GetLedgerRaw on closed store returns stores.ErrStoreClosed.
IterateLedgers(start, end) happy path: covers [start, end] inclusive in ascending order, over a 10-ledger fixture.
IterateLedgers(start, end) with start > end is a no-op (no yields, no error).
IterateLedgers(start, end) clamps to store bounds when start < firstSeq or end > lastSeq.
IterateLedgers on closed store yields stores.ErrStoreClosed once and returns.
End-to-end round-trip: build 10 ledgers via makeRandomLedgerCloseMeta, marshal each to bytes, write via ColdWriter, read each back via ColdStore.GetLedgerRaw, assert byte-equality.
packfile.Reader.Verify(ctx) content-hash check passes on a finalized file (with content-hash enabled in writer options).
Prior art (commit 19a9179)
Read via git show 19a9179:<path>; do NOT check out, do NOT cherry-pick. For EACH file, make an explicit port / adapt / rewrite / discard call before writing:
full-history/backfill/lfs_writer.go
full-history/backfill/lfs_writer_test.go
full-history/pkg/lfs/chunk.go
full-history/pkg/lfs/discovery.go
full-history/pkg/lfs/iterator.go
Expect rewrite on the codec + file-format wiring (the reference predated the packfile library and used klauspost zstd). Sequence-bound discovery patterns may adapt.
Out of scope
Cross-chunk federation (a reader that spans multiple .pack files based on seq).
A LedgerStore interface unifying hot + cold — siblings only, no spanning interface.
Retention pruning at the cold tier — deletion of old .pack files is the orchestrator's concern.
Summary
cmd/stellar-rpc/internal/fullhistory/pkg/stores/ledger/, sibling to the existinghot_store.go(RocksDB-backed). Reader + writer ship together so round-trip tests are authoritative.cmd/stellar-rpc/internal/packfile/with the native CGo zstd codec atcmd/stellar-rpc/internal/zstd/. Nogithub.com/klauspost/compress/zstdanywhere.Entrytype already exported fromhot_store.go(type Entry struct { Seq uint32; Bytes []byte }).LedgerStoreinterface spanning hot + cold — sibling concrete types in one package; federation deferred to a separate slice.Files to create
cmd/stellar-rpc/internal/fullhistory/pkg/stores/ledger/cold_writer.gocmd/stellar-rpc/internal/fullhistory/pkg/stores/ledger/cold_writer_test.gocmd/stellar-rpc/internal/fullhistory/pkg/stores/ledger/cold_store.gocmd/stellar-rpc/internal/fullhistory/pkg/stores/ledger/cold_store_test.goPublic API (pinned)
Behavior contracts (pinned)
NewColdWritertakesfirstSeqat construction. The writer encodesfirstSeqas a 4-byte big-endianuint32into the packfile'sAppDatatrailer field (WriterOptions.AppData).OpenColdStorereads + decodesAppDatato recoverfirstSeq.lastSeqis derived asfirstSeq + TotalItems() - 1from the packfile reader's metadata accessors.AppendLedger(seq, bytes)accepts onlyseq == firstSeq + nwherenis the count of prior successful appends. A gap or out-of-order seq returns an error and does NOT advance internal state.LedgerCloseMetabytes toAppendLedger;GetLedgerRawreturns the same uncompressed bytes. Zstd encode/decode is internal — handled by the packfile primitive'sRecordEncoder/RecordDecoderplumbing. Caller never sees zstd-framed bytes.newEncoderreturns a freshpackfile.RecordEncoderper call; the packfile writer calls it once per worker. Production wiring:newEncoder = func() packfile.RecordEncoder { return zstd.NewCompressor(...) }.OpenColdStoretakes a single concurrent-safepackfile.RecordDecoder(production: shared*zstd.Decompressorper process).ColdStore.Closecloses the packfile reader but does NOT close the decoder — the decoder's lifecycle is the caller's..packperColdStore. Cross-chunk federation (a reader that spans multiple.packfiles keyed on seq) is deferred to a separate slice.ColdWriter.Closecalled beforeFinalizeremoves the partial.packfile (matchespackfile.Writer.Closebehavior).ColdWriter.Closecalled afterFinalizeis a no-op (or close-only on the underlying packfile writer if not already closed).ColdWriterandColdStorechecks aclosedatomic flag at entry and returnsstores.ErrStoreClosed(or yields it and returns, for iteration) on closed.packfile.ErrPositionOutOfRange(and any other miss-sentinel from the packfile primitive) translates tostores.ErrNotFound. Callers depend only onpkg/storessentinels; they never seepackfile.*errors directly.*supportlog.Entry(not a custom interface). Tests usebytes.Buffer + (*supportlog.Entry).SetOutput— no fixture types.Implementation notes — IterateLedgers wrapper
ColdStore.IterateLedgers(start, end)is a thin wrapper over the packfile reader's iterator-shaped API. Signature from PR #712:ReadRangeis designed for sequential scans — it coalesces consecutive records into batchedReadAtcalls via a pooled 1 MiB buffer.Sketch of the wrapper:
Three things to get right:
seq - firstSequnderflows ifseq < firstSeqsince both areuint32. Clampstartandendto[firstSeq, lastSeq]before the subtraction.ReadRange's[]bytebecomes invalid after the iterator exits.HotStore.IterateLedgersdoes the same copy athot_store.go:114(append([]byte(nil), e.Value...)) — match that pattern.packfile.ErrPositionOutOfRangematters only forGetLedgerRawpoint lookups (where we translate it tostores.ErrNotFound); iteration has already clamped to bounds before callingReadRange.Test fixtures + sizing (pinned)
hot_store_test.go— sameledgerpackage, unexported helpers are accessible from cold tests:silentLogger()(hot_store_test.go:24) for the logger argument.makeRandomLedgerCloseMeta(ledgerSeq, txCount, networkPassphrase)(hot_store_test.go:302) to build realistic XDR-encoded ledgers. Call.MarshalBinary()on the returnedxdr.LedgerCloseMetato get the raw bytes thatColdWriter.AppendLedgerconsumes.txCount = 2or3inmakeRandomLedgerCloseMetafor the same reason — these are storage tests, not transaction-volume tests.openTestColdWriter(t)/openTestColdStore(t)pair (≤5 lines each, mirroringopenTestHotStore) for cleanup-on-failure ergonomics. Keep them incold_*_test.go.Test plan (this is the spec — implement in this order)
NewColdWritervalidation: path /newEncoder/ logger required; missing any returnsrocksdb.ErrInvalidConfig(or the package's own sentinel — matchhot_store.go's style).AppendLedgerhappy path: append one ledger (built viamakeRandomLedgerCloseMeta,MarshalBinary()), round-trip viaOpenColdStore+GetLedgerRaw. Assert byte-equality.AppendLedgercontiguous-append: a gap (seq != expected next) returns an error and the writer's internal counter does NOT advance.AppendLedgerout-of-order: seq < expected next returns an error.Finalizeemits a complete.packfile: verify the trailer + AppData round-trip viapackfile.Open.ClosebeforeFinalizeremoves the partial.packfile.CloseafterFinalizeis a no-op; callingClosetwice afterFinalizeis also a no-op.AppendLedgerafterClosereturnsstores.ErrStoreClosed.OpenColdStorereadsfirstSeqfromAppDataand deriveslastSeqfrompackfile.Reader.TotalItems().GetLedgerRaw(seq)out-of-range (seq < firstSeq or seq > lastSeq) returnsstores.ErrNotFound.GetLedgerRawon closed store returnsstores.ErrStoreClosed.IterateLedgers(start, end)happy path: covers[start, end]inclusive in ascending order, over a 10-ledger fixture.IterateLedgers(start, end)withstart > endis a no-op (no yields, no error).IterateLedgers(start, end)clamps to store bounds whenstart < firstSeqorend > lastSeq.IterateLedgerson closed store yieldsstores.ErrStoreClosedonce and returns.makeRandomLedgerCloseMeta, marshal each to bytes, write viaColdWriter, read each back viaColdStore.GetLedgerRaw, assert byte-equality.packfile.Reader.Verify(ctx)content-hash check passes on a finalized file (with content-hash enabled in writer options).Prior art (commit
19a9179)Read via
git show 19a9179:<path>; do NOT check out, do NOT cherry-pick. For EACH file, make an explicit port / adapt / rewrite / discard call before writing:full-history/backfill/lfs_writer.gofull-history/backfill/lfs_writer_test.gofull-history/pkg/lfs/chunk.gofull-history/pkg/lfs/discovery.gofull-history/pkg/lfs/iterator.goExpect rewrite on the codec + file-format wiring (the reference predated the packfile library and used klauspost zstd). Sequence-bound discovery patterns may adapt.
Out of scope
.packfiles based on seq).LedgerStoreinterface unifying hot + cold — siblings only, no spanning interface..packfiles is the orchestrator's concern.pathis passed in by the caller; this slice does not derive paths.Dependencies
NewRecordEncoderfactory.RecordDecodersingle instance;ReadRange(start, count int) iter.Seq2[[]byte, error]for iteration.*zstd.Compressorand*zstd.Decompressorsatisfy the packfile codec interfaces directly.full-history/design-docs/packfile-library.mdis stale.pkg/stores/ledger/hot_store.go+hot_store_test.go— forEntrytype, closed-fence convention,iter.Seq2[Entry, error]shape, bytes-verbatim contract, and thesilentLogger()/makeRandomLedgerCloseMeta(...)test helpers.Supersedes #720 (closed); see the close comment on #720 for context.
Acceptance
go test -race.$(go env GOPATH)/bin/golangci-lint run ./cmd/stellar-rpc/internal/fullhistory/...clean.gofmt -l cmd/stellar-rpc/internal/fullhistory/prints nothing.packfile.*orzstd.*types in any public method signature except codec interfaces in constructors.github.com/klauspost/compress/zstdimport anywhere in the new code (grep before submitting).LedgerStoreinterface spanning hot + cold.silentLogger()+makeRandomLedgerCloseMeta()fromhot_store_test.go; do not duplicate fixture code.