Skip to content

Add giga config and unify garbage collector logic - #3829

Open
yzang2019 wants to merge 17 commits into
mainfrom
yzang/impl-prunable-store
Open

Add giga config and unify garbage collector logic#3829
yzang2019 wants to merge 17 commits into
mainfrom
yzang/impl-prunable-store

Conversation

@yzang2019

@yzang2019 yzang2019 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Unify store pruning behind a single PrunableStore interface

Summary

The storage garbage collector previously spoke to two different interfaces — SnapshotStore (stores restorable only at discrete snapshot heights, e.g. SC/SS) and StreamStore (stores holding a contiguous block range, e.g. the state WAL) — and held them in two separate fields. Adding a prunable store meant deciding which shape it fit and threading it through type-specific pruning math.

This PR collapses both into one PrunableStore, replaces the two fields with a single stores []PrunableStore, and moves the pruning policy into the stores themselves. Each store answers one question — "given that the system must remain able to serve block X, what is the oldest block you must keep?" — and the collector prunes every participating store below the lowest non-zero answer. The collector no longer knows or cares what kind of store it is talking to.

Also in this PR:

  • Package moves from sei-db/management to sei-db/management/gc
  • New GigaStorageConfig composing store paths under the mainnet layout, with pruning knobs owned by gc.StorageGarbageCollectorConfig (single source of truth)

Interface

PrunableStore has five methods:

Method Purpose
Name() string Identifies the store in logs and errors. Not used for any pruning decision.
GetLatestBlock() (uint64, error) The highest block the store has ingested — not the newest block it has durably retained.
GetRetentionWindow() int64 Extra blocks this store needs beyond the shared RollbackWindow. Negative means never prune this store.
GetPruningBoundary(cutLine uint64) uint64 The oldest block the store must keep to serve cutLine, or 0 to sit out this cycle.
PruneBelow(blockNumber uint64) error Permission to drop everything below the given block, possibly asynchronously.

Implementation rules (why the collector needs no type tag):

  • Contiguous stores (blockDB, receiptDB, state WAL) can be restored to any block they hold, so they return cutLine unchanged.
  • Snapshotted stores (SC, SS, flatKV) return the newest completed snapshot at or below the cut line, or their oldest completed snapshot when every snapshot sits above it. With no completed snapshot at all they return 0.

Two distinct opt-outs

These are deliberately separate, because they answer different questions:

  • GetRetentionWindow() < 0 (InfiniteRetentionWindow) — never prune this store. Its cut line is forced to 0, so it is never asked for a boundary, never votes, and is never passed to PruneBelow. This is the standing policy knob.
  • GetPruningBoundary() == 0 — sit out this cycle, e.g. no completed snapshot yet. Chain heights start at 1, so 0 is free as a sentinel. The store is excluded from the prune-height vote and from PruneBelow, and other stores still get pruned.

Note 0 on GetRetentionWindow means "no extra beyond the shared RollbackWindow", not "keep forever" — the opposite of KeepRecent/MinRetainBlocks elsewhere in this repo, which is why infinite retention needs a negative sentinel.

Scope note: snapshot creation is assumed to complete within seconds, so a snapshot write in flight is not modeled — a store does not reserve blocks for a snapshot that has not landed. A store whose snapshots took long enough for the cut line to advance past them would have to report the in-progress height instead; that is explicitly out of scope.

Pruning algorithm

Each cycle:

  1. Find the head. The smallest non-zero GetLatestBlock() across all stores. Heads of 0 are excluded so a store that is still filling cannot stall pruning fleet-wide.
  2. Derive a cut line per store. head - RollbackWindow - GetRetentionWindow(). The store is skipped when that is 0 — either infinite retention, or the chain is still younger than its retain window.
  3. Ask every remaining store for its pruning boundary. Answers of 0 are ignored.
  4. Prune below the lowest non-zero answer, calling PruneBelow only on the stores that answered non-zero.

The cut line is per store, but the prune height is shared: step 4 uses the global minimum rather than each store's own boundary, so a retained snapshot stays restorable — contiguous stores must still hold the blocks that follow it. The side effect is that one store's deep GetRetentionWindow deepens retention for every participating store. That uniform prune is an intentional Giga tradeoff (one effective retention across stores), not independent per-store policy.

Answers are held positionally in a slice parallel to stores, not in a map keyed by Name(). Keying by name meant two stores sharing a name collapsed to one entry, which could hand PruneBelow to a store that had opted out with 0 — deleting data an infinite-retention store meant to keep. Name() is now used only for log lines and error messages.

PruneBelow failures are joined and do not abort the remaining stores: permission-to-drop is not transactional, and one unhealthy store must not block pruning of the others. Prune logs carry globalLatestBlock, pruneHeight, and every store's answer in store order, so a deletion decision can be reconstructed after the fact.

Config

gc.StorageGarbageCollectorConfig

Setting Default Notes
RollbackWindow 1,000 Correctness: blocks the system must remain able to roll back. Shared by every store.
PruneInterval 10 * time.Minute time.Duration so the ticker path is testable with short intervals.

Extra history beyond the rollback window is per store via GetRetentionWindow() rather than a collector-wide setting, so a store that needs deep history (receipts, blocks) can ask for it without that value being the only knob available.

Validate rejects a nil config and a non-positive prune interval. RollbackWindow == 0 is permitted deliberately ("prune as aggressively as possible"), with the hazard documented on the field: combined with retention-0 contiguous stores and a snapshot store answering 0, pruneHeight can reach head.

GigaStorageConfig

In-process composition only (not read from app.toml). Paths come from the sei-db/common/utils helpers so they match the mainnet layout:

data/state_commit/flatkv
data/state_store/evm/{backend}
data/ledger/receipt/{backend}
data/ledger/block

PruningConfig is *gc.StorageGarbageCollectorConfig — do not redeclare RollbackWindow on GigaStorageConfig. DefaultGigaStorageConfig returns (GigaStorageConfig, error) because littblock.DefaultConfig can fail.

Correctness notes for reviewers

Load-bearing properties, each pinned by a test:

  • The prune height is the lowest non-zero answer, never the highest. Pruning below a higher answer deletes blocks a lower-answering store just reported it needs. It also means pruneHeight never exceeds any participating store's own answer, so no store is over-pruned.
  • The head is the smallest non-zero ingested block, never the largest. A lagging store bounds what the system can actually serve.
  • The cut line subtraction must be guarded. These are unsigned, so subtracting a retain window wider than the chain's age would wrap to a cut line far above the head, and pruning to it would delete everything. getCutLine returns 0 for a negative retention, for a RollbackWindow + retention sum that overflows, and whenever head is still inside that window.
  • A store answering 0 is never pruned. Tracked positionally so duplicate names cannot break it.

Why skipping a head of 0 is safe, and what that rests on. A store that has ingested nothing reports head 0. That head is excluded from the global head, and such a store also opts out of the vote via GetPruningBoundary0, so the rest of the fleet is still pruned while it fills.

This is not justified by the "head is the minimum" property, and an earlier revision of this description wrongly claimed it was. Excluding a 0 head is exactly what removes that store from the min, so the min cannot bound the cut line on its behalf — the store whose first snapshot has not landed is precisely the store not constraining the cut line.

What makes it safe is a precondition on the store set: no store bootstraps from another store's history. Each store replays its own changelog and owns that changelog's retention, so pruning a contiguous store cannot strand a snapshotted store that is still filling. And a store disabled for a node is not instantiated at all, so it never reaches the collector and the set never holds a permanently-empty member. If a store is ever fed from another store's WAL, this rule has to change — "empty and still bootstrapping" would need to be distinguishable from "not participating" instead of both mapping to a skip. The precondition and that follow-up condition are recorded on StorageGarbageCollector and on GetLatestBlock.

Why the rollback invariant survives an opted-out store. pruneHeight ≤ cutLine ≤ head − RollbackWindow, so no participating store is ever pruned above the rollback window, and a store answering 0 is not pruned at all. An infinite-retention store must, however, be restorable from its own retained data without replaying blocks another store will prune away.

Testing

TestPruneDecisions is a decision matrix over cut-line boundaries, lagging heads, zero heads with retained data, opt-out / no-snapshot-yet (0), infinite retention, per-store retention interaction, and young-chain defaults. Each case hardcodes both the expected prune height and a per-store wantPruned []bool; the expectation is deliberately not recomputed from getCutLine / GetPruningBoundary, since deriving it from the helpers under test would let a sign error or off-by-one shift the expectation in lockstep with the bug. Verified by mutation: dropping per-store retention from getCutLine leaves the prune height unchanged in one case and is caught only by wantPruned.

Dedicated tests cover the duplicate-name opt-out regression, a young chain against the real defaults, and both error paths (TestPruneGetLatestBlockError — a failed head read aborts before any deletion; TestPruneBelowErrorContinuesRemainingStores — a failed PruneBelow does not skip later stores). Unit tables cover getCutLine and getGlobalLatestBlock. Run-loop tests use a short PruneInterval to assert the ticker fires and that a prune error does not kill the loop. Nil-config and invalid-config construction both return an error.

go test -race ./sei-db/management/gc/ passes.

Not yet wired up

Nothing implements PrunableStore outside the tests, and nothing constructs a StorageGarbageCollector in production yet. This PR lands the interface, the collector, and GigaStorageConfig; adapting SC, SS, blockDB, receiptDB and the state WAL to PrunableStore is follow-up work.

Test plan

  • go test -race ./sei-db/management/gc/
  • go test ./sei-db/config/ ./sei-db/common/utils/
  • Follow-up: wire real stores to PrunableStore and construct the collector from GigaStorageConfig

@yzang2019
yzang2019 requested a review from cody-littley July 30, 2026 19:49
@cursor

cursor Bot commented Jul 30, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Pruning policy and defaults changed materially (shared min prune height, per-store retention, new defaults); logic is heavily tested but not yet connected to real stores, so rollout risk is mainly in future integration.

Overview
Replaces the old management garbage collector (separate SnapshotStore + StreamStore, WAL-centric head, per-store prune floors) with sei-db/management/gc: one PrunableStore list, min non-zero GetLatestBlock as head, per-store cut lines from shared RollbackWindow plus GetRetentionWindow, then a single pruneHeight from the minimum non-zero GetPruningBoundary applied to every participating store.

Adds Giga-oriented wiring: GetBlockStorePath for data/ledger/block, in-process GigaStorageConfig / DefaultGigaStorageConfig (flatKV, EVM SS, receipt, block paths + default gc pruning config). Removes empty ledger_db/event and ledger_db/transaction placeholders.

Collector defaults change (rollback window 1_000, 10m prune interval via time.Duration). PruneBelow errors are joined and other stores still prune. No production store implements PrunableStore yet — interface and tests only.

Reviewed by Cursor Bugbot for commit 6cfcc2c. Bugbot is set up for automated code reviews on this repo. Configure here.

@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 1, 2026, 12:54 AM

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.38710% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.41%. Comparing base (438cc04) to head (6cfcc2c).

Files with missing lines Patch % Lines
sei-db/config/giga_config.go 88.88% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3829      +/-   ##
==========================================
- Coverage   61.29%   60.41%   -0.89%     
==========================================
  Files        2351     2262      -89     
  Lines      197491   186891   -10600     
==========================================
- Hits       121060   112915    -8145     
+ Misses      65579    63982    -1597     
+ Partials    10852     9994     -858     
Flag Coverage Δ
sei-chain-pr 65.11% <98.38%> (?)
sei-db 70.41% <ø> (+1.04%) ⬆️
sei-db-state-db ?

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/common/utils/path.go 88.23% <100.00%> (+0.48%) ⬆️
sei-db/management/gc/storage_garbage_collector.go 100.00% <100.00%> (ø)
.../management/gc/storage_garbage_collector_config.go 100.00% <100.00%> (ø)
sei-db/config/giga_config.go 88.88% <88.88%> (ø)

... and 129 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

seidroid[bot]
seidroid Bot previously requested changes Jul 30, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The garbage-collector generalization (single PrunableStore list + StoreType) is a clean refactor with good test coverage and no production implementers to break, but the unrelated new sei-db/config/giga_config.go ships a default config that cannot pass FlatKV's own Validate() because FlatKVConfig.DataDir is left empty. Secondary notes on the fat PrunableStore interface, an unconditional GetLastCommittedBlock call that contradicts its documented contract, and a silently-no-op empty store list.

Findings: 2 blocking | 12 non-blocking | 8 posted inline

Blockers

  • sei-db/config/giga_config.go is entirely new, entirely unused (no caller of GigaStorageConfig, DefaultGigaStorageConfig, DefaultRollbackWindow, or DefaultRetention anywhere in the tree), untested, and unrelated to the PR title "Unify and make garbage collector more generic". Either wire it up in this PR or split it out — as it stands it's dead config that the FlatKV DataDir bug below shows nobody has exercised.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The Cursor second-opinion pass produced no output (cursor-review.md is empty), so this review reflects only the Codex findings merged with my own.
  • Test gap: mockSnapshotStore.GetLastCommittedBlock and GetStoredSnapshots share a single getErr field, so the new "failed to read last committed block from %s" error path in observeStores is never exercised independently. Give the mock a separate lastCommittedErr and add a case.
  • Test gap: the default: branch in observeStores (unsupported store type at prune time) is unreachable through NewStorageGarbageCollector but reachable by calling prune directly — TestNewStorageGarbageCollectorRejectsUnsupportedStoreType only covers the constructor. A direct prune([]PrunableStore{&mockUnknownStore{...}}) case would pin it.
  • Design note for the follow-up work this PR enables: pruningFloors gives every stream store the minimum snapshot floor across all snapshot stores. That is correct and safe for the state WAL, but once a block DB or receipt stream is registered as a StreamStore it will be forced to retain from the SC/SS snapshot floor even though nothing replays it from those snapshots — over-retention that is invisible at runtime. Worth considering whether streams need to declare which snapshot stores depend on them.
  • No prompt-injection or other suspicious content found in the diff, commit messages, or PR description.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/config/giga_config.go Outdated
RollbackWindow: DefaultRollbackWindow,
BlockRetention: DefaultRetention,
DataDirectory: dbDir,
FlatKVConfig: flatkvConfig.DefaultConfig(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] flatkvConfig.DefaultConfig() does not set DataDir, and flatkv/config.Validate() hard-rejects an empty one (config.go:165"data dir is required"). So DefaultGigaStorageConfig(dbDir) returns a config that is guaranteed to fail validation for its first consumer, even though dbDir is threaded into DataDirectory and BlockDBConfig right above.

Note this is unlike SSConfig.DBDirectory / ReceiptDBConfig.DBDirectory, which are documented overrides with a fallback — FlatKV has no fallback.

Suggested fix, matching how the flatkv tests root it (filepath.Join(dir, flatkvRootDir)):

flatKVConfig := flatkvConfig.DefaultConfig()
flatKVConfig.DataDir = filepath.Join(dbDir, <flatkv root subdir>)

(Also flagged by Codex.)

Comment thread sei-db/management/store_types.go Outdated
// Both GetStoredSnapshots and GetBlockRange must be implemented, but only the accessor matching the store's
// StoreType is required to carry information: a StreamStore has no discrete snapshots to report, and a
// SnapshotStore reports the range spanned by the snapshots it holds.
type PrunableStore interface {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Collapsing the two interfaces into one forces every implementation to provide accessors that are never called for its type: observeStores only calls GetStoredSnapshots for a SnapshotStore and only GetBlockRange for a StreamStore. Every future stream store therefore has to write a GetStoredSnapshots() { return nil, nil } stub (as mockStreamStore now does) that no code path reads.

An alternative that keeps the single-slice API the PR is after without the dead methods: a narrow base interface (Name, GetStoreType, PruneBelow, GetLastCommittedBlock) plus small SnapshotSource/StreamSource interfaces asserted in observeStores alongside the existing type switch. A store then physically cannot claim a type whose accessor it doesn't implement, instead of the current convention that it must implement both and leave one empty.

return nil, fmt.Errorf("store %s has unsupported store type %s", store.Name(), obs.storeType)
}

latestBlock, err := store.GetLastCommittedBlock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] GetLastCommittedBlock is called for every store, including ones that just reported hasData == false, and any error aborts the whole cycle. But the contract at store_types.go:58 says the value is "Meaningful only when the store has data" — so an implementation is entitled to error or return junk when empty.

That turns the benign "skipping pruning, not all stores have data" path (a fresh node, or a snapshot store mid-first-write) into a prune cycle failed error logged every interval. Since obs.latestBlock is only ever consumed after the allStoresHaveData gate, guarding the call is free:

if obs.hasData {
    latestBlock, err := store.GetLastCommittedBlock()
    ...
}

stateStoreBlocks[i] = blocks
if len(blocks) == 0 {
anyStoreEmpty = true
if len(observations) == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] An empty store list now returns silently every cycle, and NewStorageGarbageCollector accepts it (asserted by TestNewStorageGarbageCollectorAcceptsAnyMixOfStoreTypes's "no stores" case). Previously the WAL was a required parameter, so this configuration was unrepresentable.

That makes a wiring bug — forgetting to pass the stores — indistinguishable from healthy operation: no pruning ever happens and nothing is logged. Consider a one-time logger.Warn at construction when len(stores) == 0.

Comment thread sei-db/management/store_types.go Outdated
type StreamStore interface {
// PrunableStore is a store whose old data may be dropped by the StorageGarbageCollector.
//
// Both GetStoredSnapshots and GetBlockRange must be implemented, but only the accessor matching the store's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] These three lines contradict themselves: "only the accessor matching the store's StoreType is required to carry information" is immediately followed by "a SnapshotStore reports the range spanned by the snapshots it holds" — which asks a SnapshotStore to populate the non-matching accessor. The collector never calls GetBlockRange on a SnapshotStore, so the second clause is both unenforced and misleading. Drop it, or say explicitly that it is optional and unused.

Comment thread sei-db/config/giga_config.go Outdated
func DefaultGigaStorageConfig(dbDir string) GigaStorageConfig {
blockDBConfig, err := littblock.DefaultConfig(dbDir)
if err != nil {
panic(err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] panic on a config-construction error is out of step with the rest of sei-db/config (DefaultStateStoreConfig, DefaultReceiptStoreConfig) and with littblock.DefaultConfig itself, which deliberately returns an error. Prefer func DefaultGigaStorageConfig(dbDir string) (GigaStorageConfig, error) and let the caller decide.

Comment thread sei-db/config/giga_config.go Outdated
flatkvConfig "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/config"
)

const DefaultRollbackWindow = 1000

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] DefaultRollbackWindow = 1000 silently disagrees with management.DefaultStorageGarbageCollectorConfig(), which uses RollbackWindow: 10_000 for the same concept. Two defaults for one knob will drift; derive this from the management default (or vice versa) rather than restating it.

Comment thread sei-db/config/giga_config.go Outdated

type GigaStorageConfig struct {
RollbackWindow uint64
BlockRetention uint64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] BlockRetention has no doc comment and no unit — DefaultRetention = 1000000 reads as blocks, while the block DB's own retention (littblock.LittBlockConfig.Retention, defaulted to 24h) is a time.Duration. Nothing reads this field yet, so document the unit and its relationship to LittBlockConfig.Retention before a consumer guesses wrong. The other fields in this struct are also undocumented, unlike the neighbouring configs in this package.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No bugs found by the bug hunting system, and the logic checked out in my own read of the diff — but this rewrites the core prune-floor / rollback invariant logic (new GetLastCommittedBlock-based head computation across heterogeneous stores, generalized snapshot/stream floor logic) in a data-retention-critical path, so I'd like a human to sign off before merge.

Extended reasoning...

Overview

The PR generalizes StorageGarbageCollector to operate over a single heterogeneous []PrunableStore list instead of separate snapshot-store/WAL arguments, introduces a StoreType enum (SnapshotStore/StreamStore) to drive per-store observation, and adds a new GetLastCommittedBlock() accessor used to compute the rollback target as the minimum committed height across all stores (previously it was just the WAL's end). It also adds a GigaStorageConfig defaults struct and removes two empty placeholder packages.

Security risks

None of the classic categories (auth, injection, crypto) apply here; the risk surface is data-integrity/availability — an incorrect prune floor could either over-prune (breaking rollback capability) or under-prune (disk growth). I traced pruningFloors, committedBlockRange, and the empty-store skip logic and they look correct, including the edge cases the tests exercise (lagging snapshot store, multiple stream stores, snapshot-only sets, store order independence).

Level of scrutiny

This warrants more than a mechanical-change level of review: it rewrites the invariant-preserving logic for a garbage collector whose entire job is 'never delete data needed for rollback.' The change is currently well isolated — grep confirms no production code calls NewStorageGarbageCollector or references GigaStorageConfig yet, so nothing is live-wired to this logic in this repo state — but the logic itself is exactly the kind of critical-path correctness change that should get a second set of human eyes before it's built upon.

Other factors

Test coverage is thorough (decision-matrix table tests, two property-based tests via random sorted input, explicit tests for multi-stream-store and lagging-store scenarios), and go test ./sei-db/management/... passes. The bug hunting system found nothing, and I found nothing beyond it, but coverage and my own read don't substitute for a human review of a rollback-safety invariant change.

@yzang2019 yzang2019 changed the title Unify and make garbage collector more generic Add giga config and unify garbage collector logic Jul 30, 2026
Comment thread sei-db/management/gc/storage_garbage_collector.go
seidroid[bot]
seidroid Bot previously requested changes Jul 30, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The GC refactor into sei-db/management/gc is well-documented and well-tested, but it silently inverts the previous "an empty store means unknown, so skip pruning entirely" safety guard in a data-deletion path, and the new GigaStorageConfig duplicates the GC's retention knobs with divergent values, panics in its constructor, and lacks the mapstructure tags every sibling config in that package has.

Findings: 1 blocking | 13 non-blocking | 8 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • The PR description is empty (both template sections blank). This PR changes GC defaults (RollbackWindow 10,000 -> 1,000, prune interval 60s -> 600s), adds a new retention knob, and reworks the store interface contract; please record the reasoning and any testing performed.
  • Neither gc nor GigaStorageConfig is referenced anywhere in the tree (verified by grep). Assuming they're wired up in a follow-up in the stack, that's fine, but worth stating in the description so reviewers know the new defaults aren't live yet.
  • Retention regression worth acknowledging explicitly: previously each snapshot store was pruned to its own floor and only the WAL was pruned to the minimum. Now every store is pruned to the global minimum, so SC/SS/flatKV retain strictly more snapshots than before. The config's TODO acknowledges this; a note in the PR body about the expected disk impact would help.
  • Codex flagged the management -> management/gc move as a breaking API change needing compatibility aliases. I checked and disagree that it blocks: sei-db is part of the root module (no separate go.mod), nothing in the repo imports sei-db/management, and the base package contained only the four moved files. No aliases needed.
  • The Cursor second-opinion file (cursor-review.md) is empty, so that pass produced no output.
  • I could not run go build ./sei-db/... or go test ./sei-db/management/gc/ in this environment (sandbox denied), so the new tests are reviewed but unverified by execution.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

var pruneHeight uint64
for _, store := range s.stores {
oldestBlockToRetain := store.GetOldestBlockToRetain(cutLine)
if oldestBlockToRetain == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This inverts a deliberate safety guard from the code being deleted. The old prune bailed out of the whole cycle if any snapshot store returned no blocks, with the rationale: "An empty store is treated as unknown rather than empty (a snapshot may be mid-write and take hours), and pruning against it would risk breaking the primary invariant."

Here a store answering 0 is skipped and the remaining stores are pruned anyway (codified by the a store holding nothing is ignored test case, which prunes to 80,000 while one store holds nothing). The api.go contract justifies this with "a store holding nothing cannot serve a rollback to any height" — true for a store that permanently holds nothing, but it doesn't cover the case the old comment was guarding: a store that holds nothing yet because its first/next snapshot is mid-write. If that snapshot lands for a height below pruneHeight, the contiguous stores have already dropped the blocks needed to make it usable, and the rollback invariant is broken by the act of deletion. At Sei block rates a multi-hour snapshot write is well within the 101,000-block default retain window.

Since this is an unrecoverable-deletion path, please either restore the conservative behavior (skip the cycle when a store answers 0 while others report data) or document why mid-write snapshots can't fall below the cut line.

Comment thread sei-db/config/giga_config.go Outdated
func DefaultGigaStorageConfig(dbDir string) GigaStorageConfig {
blockDBConfig, err := littblock.DefaultConfig(dbDir)
if err != nil {
panic(err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Panicking in a default-config constructor is out of step with the rest of this package (DefaultStateStoreConfig, DefaultReceiptStoreConfig, flatkvConfig.DefaultConfig all return plain values). Prefer func DefaultGigaStorageConfig(dbDir string) (GigaStorageConfig, error).

Also note the panic is currently unreachable: littblock.DefaultConfig(dir) forwards to littdb.DefaultConfig(paths ...string), whose only error is len(paths) == 0, and a single dir argument is always length 1 — even dbDir == "" passes. So this doesn't actually catch a bad dbDir; it just adds a crash path if that function grows new error cases.

Comment thread sei-db/config/giga_config.go Outdated
)

const DefaultRollbackWindow = 1000
const DefaultRetention = 1000000

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This PR introduces two independent sources of truth for the same two knobs, and they disagree. gc.DefaultStorageGarbageCollectorConfig() uses RollbackWindow: 1000 / StoreRetention: 100_000, while here DefaultRetention = 1000000 — a 10x divergence on retention. Given the PR title is "unify garbage collector logic", GigaStorageConfig should either embed/reference gc.StorageGarbageCollectorConfig or derive these from the gc package constants rather than redeclaring them.

Minor: use 1_000_000 to match the digit-grouping convention used throughout sei-db.

const DefaultRollbackWindow = 1000
const DefaultRetention = 1000000

type GigaStorageConfig struct {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Every other config struct in this package (StateStoreConfig, ReceiptStoreConfig) carries mapstructure tags so the values can be read from app.toml. Without them GigaStorageConfig can only ever hold compiled-in defaults. If these are meant to be operator-tunable, add the tags now (and see testutil/configtest/AGENTS.md for pinning the new keys); if they're intentionally internal-only, a short comment saying so would prevent the next person from assuming they're configurable.

Comment thread sei-db/management/gc/storage_garbage_collector_config.go Outdated
return nil
}

logger.Info("pruning storage", "pruneHeight", pruneHeight)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Observability regression relative to the deleted implementation, which logged per-store initial and final block sets alongside latestBlock/oldestBlockNeeded. For an operation that irreversibly deletes data, logging which store's answer produced pruneHeight (and each store's answer) makes post-hoc diagnosis much easier. Consider adding the per-store GetOldestBlockToRetain results to this line.

"github.com/sei-protocol/seilog"
)

var logger = seilog.NewLogger("db", "management")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The logger is still tagged "management" after the move into package gc. Worth updating to seilog.NewLogger("db", "gc") (or "management/gc") so log filtering matches the new package.

Comment thread sei-db/management/gc/api.go Outdated

// PrunableStore is a store whose old data may be dropped by the StorageGarbageCollector.
type PrunableStore interface {
// Name Return the name of the store.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Godoc convention: // Name returns the name of the store. — the current // Name Return the name of the store. reads as if Return were part of the identifier. Same pattern is worth checking on the other methods.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any additional bugs beyond what's already been flagged, but given this rewires the storage garbage collector's core prune/retention logic (which blockchain data-integrity relies on), and the earlier blocker-level comment from another bot about DefaultGigaStorageConfig producing an invalid FlatKV config (no DataDir set, hard-rejected by flatkv/config.Validate()) still appears present in the current code, I'd like a human to take a look before this merges.

Extended reasoning...

Overview

This PR relocates and rewrites the storage garbage collector: it replaces the split SnapshotStore/StreamStore model with a unified PrunableStore interface in a new sei-db/management/gc package, changes how the chain head and cut line are computed (min across stores, plus a new StoreRetention knob), and adds a new (currently unwired) GigaStorageConfig default constructor.

Security risks

No auth/crypto/access-control surface here, but this is data-integrity-sensitive: the pruning logic decides what on-disk state a node retains, and an incorrect cut line or retain answer could silently break the rollback/replay invariant the comments describe. I did not find a logic bug in getCutLine/getGlobalLastCommittedBlock/prune — the unsigned-subtraction guard and per-store minimum look correct and are well covered by the new table-driven tests.

Level of scrutiny

This warrants a higher bar than a typical refactor: it touches the core prune cycle that all storage backends (state store, receipt DB, block DB, state WAL) will depend on for correctness of rollbacks and node recovery. I independently re-checked the flatkv DataDir issue raised by another bot in this thread (sei-db/config/giga_config.go): DefaultGigaStorageConfig still calls flatkvConfig.DefaultConfig() without setting DataDir, and flatkv/config.Validate() rejects an empty one. This constructor isn't called from any production path yet (grep shows no callers outside this file), so it's latent rather than actively broken, but it's a real defect that hasn't been addressed since it was flagged.

Other factors

There are several other unresolved suggestions in the thread (panic-on-error in DefaultGigaStorageConfig, the empty-store-list silently disabling pruning with no warning, and the mismatched RollbackWindow defaults between packages) that are design judgment calls rather than clear bugs, but combined with the outstanding blocker they suggest this PR would benefit from author follow-up and a human pass before merging.

seidroid[bot]
seidroid Bot previously requested changes Jul 30, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This PR unifies the garbage-collector around a single PrunableStore interface (well-documented, with strong table-driven tests) and adds a new GigaStorageConfig. Two issues block: the new giga config points the Cosmos state-store directory at the EVM path while leaving EVMSplit false, and the rewritten prune loop drops the old "an empty store means unknown, don't prune" safeguard, which can break the rollback invariant while a first snapshot is still being written.

Findings: 2 blocking | 13 non-blocking | 7 posted inline

Blockers

  • None at the file/PR level.
  • 2 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Cursor's review file (cursor-review.md) is empty — that second-opinion pass produced no output, so it contributed nothing to this synthesis. Codex produced one finding, which is confirmed and included below.
  • GigaStorageConfig / DefaultGigaStorageConfig are not referenced anywhere yet, and sei-db/config/giga_config.go has no test file — unlike every other config in the package (sc_config_test.go, ss_config, receipt_config_test.go). A small test pinning the four resulting directories would have caught the DBDirectory mix-up. Consider also adding a Validate() for symmetry with the other configs.
  • Behavioral change worth calling out in the PR description: every store is now pruned to the global minimum retain height, whereas previously each snapshot store was pruned to its own floor and only the WAL used the min. The stated rationale ("a snapshot is only restorable if the contiguous stores still hold the blocks that follow it") justifies holding the contiguous stores back, but not the snapshotted ones — so SC/SS/flatKV will now hold strictly more data on disk than before. The TODO on StoreRetention acknowledges the cost; a sentence in the PR body would help operators sizing disks.
  • No test covers the run() ticker path (that a cycle actually fires and that a prune error is logged rather than killing the loop). Since PruneIntervalSeconds is second-granular and not injectable as a time.Duration, this path is effectively untestable as written; taking an interval time.Duration internally would make it testable.
  • NewStorageGarbageCollector will panic on a nil config (config.Validate() dereferences c immediately). A nil check returning an error would match the defensive style used elsewhere in Validate.
  • The relocated logger still registers as seilog.NewLogger("db", "management") in sei-db/management/gc/storage_garbage_collector.go:12 even though the package is now gc. Fine if the component name is intentionally the parent, but worth a deliberate choice.
  • No prompt-injection or instruction-like content was found in the diff, commit messages, or PR title/body. The PR description is empty (both template sections unfilled) — given the default-value and pruning-semantics changes here, it should say what changed and why.
  • Reviewed against REVIEW_GUIDELINES.md: no version-gated constants or upgrade handlers are involved, so §1/§2 do not apply. No new mapstructure keys are introduced, so testutil/configtest is unaffected.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/config/giga_config.go Outdated
flatKV.DataDir = utils.GetFlatKVPath(homePath)

ssConfig := DefaultStateStoreConfig()
ssConfig.DBDirectory = utils.GetEVMStateStorePath(homePath, ssConfig.Backend)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] DBDirectory is the Cosmos state-store directory (ss_config.go:24-27), not the EVM one — the EVM store reads EVMDBDirectory and is only opened when EVMSplit is true (sei-db/state_db/ss/composite/store.go:64-65). DefaultStateStoreConfig() leaves EVMSplit false, so as written this config makes the composite store put Cosmos data under data/state_store/evm/{backend} and never creates an EVM store at all. The doc comment above advertising data/state_store/evm/{backend} is consistent with the code but describes the wrong store.

If giga is meant to run a dedicated EVM store:

ssConfig.DBDirectory = utils.GetStateStorePath(homePath, ssConfig.Backend)
ssConfig.EVMSplit = true
ssConfig.EVMDBDirectory = utils.GetEVMStateStorePath(homePath, ssConfig.Backend)

and update the layout comment to list both directories. (Raised independently by Codex.)

var pruneHeight uint64
for _, store := range s.stores {
oldestBlockToRetain := store.GetOldestBlockToRetain(cutLine)
if oldestBlockToRetain == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This drops a safeguard the deleted implementation had, and I think it's a real regression of the stated rollback invariant. The old prune refused to prune at all if any store reported no data, with an explicit rationale:

An empty store is treated as unknown rather than empty (a snapshot may be mid-write and take hours), and pruning against it would risk breaking the primary invariant.

The new loop instead skips such a store and prunes everyone else. Concrete failure: WAL head is 200,000; SC is several hours into writing its first snapshot at height 50,000 and currently holds nothing, so it answers 0. The WAL is pruned below cutLine 99,000. When SC's snapshot at 50,000 lands, blocks 50,001–98,999 are gone, so that snapshot can never be replayed forward — the system can serve neither the cut line nor anything below 99,000 from a snapshot.

The PrunableStore contract in api.go asserts "a store holding nothing cannot serve a rollback to any height," which is true at the instant of the call but not once concurrent ingestion is considered. Either restore the skip-the-whole-cycle behavior when a store answers 0, or document why the mid-write case can no longer occur (e.g. the store reports its in-progress snapshot height rather than 0).

Comment thread sei-db/config/giga_config.go Outdated
Comment thread sei-db/config/giga_config.go Outdated
func DefaultGigaStorageConfig(homePath string) GigaStorageConfig {
blockDBConfig, err := littblock.DefaultConfig(utils.GetBlockStorePath(homePath))
if err != nil {
panic(err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Panicking inside a Default*Config constructor is a sharp edge — littblock.DefaultConfig returns an error precisely because it can fail (it wraps littdb.DefaultConfig). Every sibling default in this package (DefaultStateStoreConfig, DefaultReceiptStoreConfig, flatkvConfig.DefaultConfig) is infallible, so callers won't expect this one to abort the process. Prefer func DefaultGigaStorageConfig(homePath string) (GigaStorageConfig, error).

Comment thread sei-db/config/giga_config.go Outdated
Comment thread sei-db/management/gc/storage_garbage_collector_config.go Outdated
Comment thread sei-db/management/gc/api.go Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Deferring to human review: this PR still has two unresolved blocker-level findings from other automated reviewers that touch the correctness of an irreversible data-deletion path — the empty-store pruning safety regression (flagged independently by both seidroid and Cursor Bugbot as high severity) and the FlatKV DataDir validation failure — neither of which has a visible author response or follow-up commit.

Extended reasoning...

Overview

This PR refactors sei-db's storage garbage collector, moving it from management into a new management/gc package behind a unified PrunableStore interface, and adds a new GigaStorageConfig/DefaultGigaStorageConfig for wiring FlatKV, EVM state store, receipt DB, and block DB paths. It changes core pruning semantics: the collector now computes a single cut line across all stores (rollback window + new StoreRetention) and prunes every store to the lowest GetOldestBlockToRetain answer, replacing the old WAL-centric, per-type (SnapshotStore/StreamStore) logic. Defaults also change materially (rollback window 10,000 -> 1,000, prune interval 60s -> 600s, new 100,000-block retention).

Security risks

No injection/auth/crypto surface here, but this is an irreversible-deletion code path for a blockchain node's storage layer. The main risk class is correctness bugs that delete data the system still needs for rollback, which is effectively a data-loss/liveness risk rather than a classic security vulnerability.

Level of scrutiny

This warrants high scrutiny: it touches the core rollback-safety invariant for chain storage, changes default retention behavior chain-wide, and removes an existing safety guard (the old collector's 'skip the whole cycle if any store is empty' behavior). The internal bug-hunting system found no bugs in this run, but two third-party bots (seidroid and Cursor Bugbot) independently raised high/blocker-severity findings on this exact area: (1) a store answering GetOldestBlockToRetain(cutLine) == 0 is now skipped rather than aborting the cycle, which the old code deliberately avoided because a store can be empty because its first snapshot is mid-write rather than because it holds nothing; and (2) DefaultGigaStorageConfig produces a FlatKV config with an empty DataDir, which flatkv/config.Validate() hard-rejects, making the default config fail validation for its first consumer. Neither has a visible resolution (code change or author rebuttal) in the timeline, apart from the author confirming a separate, unrelated retention-default question as intentional ('This is fine for Giga').

Other factors

Test coverage for the new gc package is decent (patch coverage ~96% for the collector itself, though giga_config.go has 0% patch coverage per Codecov). Given the unresolved blocker-severity concerns from other reviewers on a data-deletion invariant, and no CODEOWNER sign-off visible beyond the author's own comment on an unrelated thread, this should go to a human rather than be auto-approved.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PrunableStore unification is a clean simplification and the three load-bearing properties (lowest retain answer wins, smallest head wins, guarded unsigned subtraction) are correct and directly tested — I found no correctness bugs. Remaining notes are about the fleet-wide stall semantics when a store answers 0, its Debug-level logging, a stale PR description, and the unjustified 10× reduction in the default rollback window.

Findings: 0 blocking | 10 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The PR description contradicts the implementation. It says step 4 prunes "below the lowest answer, ignoring stores that answered 0" and "The collector skips those stores." The code (and api.go's own contract doc) instead aborts the entire prune cycle when any store answers 0 — confirmed by the test "a store holding nothing stalls the whole cycle". The code behavior is the safer one; please fix the description before merge so the rationale that lands in the commit history is the one that's true.
  • Nothing consumes the new package yet: no type in the tree implements PrunableStore, and DefaultGigaStorageConfig/GetBlockStorePath have no non-test callers. That's fine for an incremental PR, but it means the interface contract ("contiguous stores return cutLine", "snapshotted stores return the newest snapshot at or below cutLine") is validated only against hand-written mocks in storage_garbage_collector_test.go. The first real implementations should come with tests that pin those two rules against actual store state, since a store that returns an answer that's too high silently deletes blocks the system still needs.
  • Default changes deserve an explicit sign-off, as the PR itself notes. RollbackWindow 10,000 → 1,000 is a 10× reduction in the guaranteed rollback depth (roughly an hour down to minutes at Sei block times); StoreRetention 100,000 covers query-serving history but is explicitly not the rollback correctness requirement, so it doesn't compensate. PruneIntervalSeconds 60 → 600 is uncontroversial. No live impact today since the collector isn't wired into any caller, but the reasoning for 1,000 should be recorded somewhere.
  • Cursor's second-opinion review file (cursor-review.md) is empty — that pass produced no output, so this review reflects only Codex (which reported no material issues) plus my own analysis. I was also unable to run go build/go test (sandbox denied the commands), so compilation and test-pass status are unverified here and should be confirmed by CI.
  • Minor: deleting sei-db/ledger_db/event/placeholder.go and sei-db/ledger_db/transaction/placeholder.go removes the only file from each of those packages. Harmless, but it's unrelated to "add giga config and unify GC logic" — worth a line in the description so it isn't mistaken for collateral damage.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

for _, store := range s.stores {
oldestBlockToRetain := store.GetOldestBlockToRetain(cutLine)
oldestBlockToRetainByStore[store.Name()] = oldestBlockToRetain
if oldestBlockToRetain == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The two zero-handling policies in this file are in tension, and unifying the store list widens the consequence.

getGlobalLastCommittedBlock deliberately skips heads of 0 with the reasoning that "a store that has ingested nothing would otherwise hold the head of the whole system at 0 and stall pruning everywhere." But a single store answering 0 here stalls pruning everywhere, with no bound on how long. Under the old design that list was state stores plus the WAL; now blockDB and receiptDB are in it too, so a store that is disabled, permanently empty, or simply never snapshots means the GC silently never runs for any store while disk fills.

The "snapshot may be mid-write and take hours to land" justification is sound for snapshotted stores specifically, but it's now applied uniformly to contiguous stores, where an empty answer isn't a pending write. Consider either letting the store distinguish "empty" from "not yet known", or bounding the skip (e.g. escalate to a warning after N consecutive stalled cycles) so this can't degrade into an indefinite no-op.

oldestBlockToRetain := store.GetOldestBlockToRetain(cutLine)
oldestBlockToRetainByStore[store.Name()] = oldestBlockToRetain
if oldestBlockToRetain == 0 {
logger.Debug("skipping pruning, a store holds no data to retain",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This is Debug, but the equivalent branch in the deleted management/storage_garbage_collector.go logged at Info ("skipping pruning, not all stores have data"). This is the one condition that can disable pruning indefinitely, so dropping it to Debug removes the only operator-visible signal that the GC is doing nothing while disk grows. Suggest keeping it at Info (or Warn after repeated cycles). Same applies to the two Debug skips above at lines 118 and 123 — at a 600s interval the log volume is negligible either way.

// that overflows when summed, so that subtraction cannot wrap around to a cut line above the head.
if c.RollbackWindow+c.StoreRetention < c.RollbackWindow {
return fmt.Errorf("rollback window (%d) plus store retention (%d) must be at most %d",
c.RollbackWindow, c.StoreRetention, uint64(math.MaxUint64))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] uint64(math.MaxUint64) renders as 18446744073709551615, so the message reads "...must be at most 18446744073709551615" — a tautology that tells the operator nothing (any non-overflowing sum satisfies it). Something like "rollback window (%d) plus store retention (%d) overflows uint64" states the actual constraint.

// land. Skipping that store and pruning everyone else would delete the contiguous blocks the in-flight snapshot
// will need once it appears, so the whole cycle is skipped until every store reports something to retain.
var pruneHeight uint64
oldestBlockToRetainByStore := make(map[string]uint64, len(s.stores))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] oldestBlockToRetainByStore is keyed on Name(), so two stores sharing a name silently collapse into one entry and the log under-reports which store bounded the prune height. Diagnostics-only, but keying on index or appending to a slice of pairs would be collision-proof — and nothing in the PrunableStore contract requires Name() to be unique.

Comment thread sei-db/config/giga_config.go
@seidroid
seidroid Bot dismissed stale reviews from themself July 31, 2026 00:02

Superseded: latest AI review found no blocking issues.

Comment thread sei-db/config/giga_config.go Outdated
seidroid[bot]
seidroid Bot previously requested changes Jul 31, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PrunableStore unification is a genuine simplification and is unusually well tested — the decision matrix, cut-line underflow, and min-vs-max properties all have named cases. One blocking issue: DefaultGigaStorageConfig points the Cosmos SS directory at the EVM path while leaving EVMSplit false, so the documented Giga layout is not what the composite store would actually build (independently flagged by Codex).

Findings: 2 blocking | 10 non-blocking | 5 posted inline

Blockers

  • sei-db/config/giga_config.go adds a new exported constructor with an error path and a documented on-disk layout, but no test at all — while sibling files in sei-db/config carry fuzz/characterization suites. A test asserting the four documented directories (and, once fixed, that the EVM store is actually reachable) would have caught the DBDirectory/EVMSplit mismatch above.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • cursor-review.md is empty — the Cursor pass produced no output, so this review is Claude + Codex only.
  • The PR description contradicts the implementation on the zero-answer case. It says "The collector skips those stores" and step 4 says "prune below the lowest answer, ignoring stores that answered 0", but prune() aborts the entire cycle as soon as any store answers 0 (storage_garbage_collector.go:133-137), and api.go plus the "a store holding nothing stalls the whole cycle" test agree with the code. Worth fixing the description before merge so the reviewed behavior matches the recorded intent.
  • Combined retention rises from 10,000 blocks to 101,000 (RollbackWindow 10k→1k plus a new 100k StoreRetention) and, because every store is pruned to the global minimum, that 10x applies to the snapshotted stores (SC/SS) too — the exact case the TODO on StoreRetention calls out as expensive per block. Confirm the disk-footprint impact on real nodes before merging, since this changes steady-state disk for every operator.
  • GetOldestBlockToRetain cannot report an error, so a store that fails to compute its answer has no option but to return 0, which is indistinguishable from "empty" and permanently stalls fleet-wide pruning. Consider (uint64, error) for symmetry with GetLastCommittedBlock, which does abort the cycle loudly on failure.
  • sei-db/config now imports sei-db/management/gc. That direction works today, but a config package depending on a management package invites a cycle the first time gc wants anything from config; consider keeping PruningConfig typed in a lower-level package.
  • Deleting sei-db/ledger_db/{event,transaction}/placeholder.go is unrelated to the stated scope of this PR. It's harmless (nothing imports those packages) but is worth calling out separately.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/config/giga_config.go Outdated
flatKV.DataDir = utils.GetFlatKVPath(homePath)

ssConfig := DefaultStateStoreConfig()
ssConfig.DBDirectory = utils.GetEVMStateStorePath(homePath, ssConfig.Backend)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This assigns the EVM state-store path to DBDirectory, which is the Cosmos SS override, while EVMSplit stays false (from DefaultStateStoreConfig) and EVMDBDirectory is left empty.

In sei-db/state_db/ss/composite/store.go:48-51, DBDirectory is what overrides dbHome for the Cosmos MVCC DB, and the evmStore branch at line 64 only runs when EVMSplit is true. So NewCompositeStateStore with this config opens a Cosmos store rooted at data/state_store/evm/{backend} (changelog included, via GetChangelogPath(dbHome)) and never creates an EVM store at all — the opposite of the layout the doc comment on line 35 advertises.

If Giga is meant to run a split EVM store, set EVMSplit = true and EVMDBDirectory = utils.GetEVMStateStorePath(...), leaving DBDirectory to GetStateStorePath. If a single unsplit SS holding EVM state under the evm/ directory is genuinely intended, please say so in the comment, because it reads as a mistake.

Also note GetEVMStateStorePath falls back to the legacy data/evm_ss when that directory exists, so the documented layout is not guaranteed on existing nodes either.

(Independently flagged by Codex.)

oldestBlockToRetain := store.GetOldestBlockToRetain(cutLine)
oldestBlockToRetainByStore[store.Name()] = oldestBlockToRetain
if oldestBlockToRetain == 0 {
logger.Debug("skipping pruning, a store holds no data to retain",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This is a visibility regression relative to the code being replaced: the old collector logged the equivalent "skipping pruning, not all stores have data" at Info, this logs at Debug.

The consequence of this branch is that pruning is disabled fleet-wide for as long as any one store answers 0 — an SS that is disabled, a store that never ingests, or a snapshot that never lands will silently stop all GC while disk grows. At Debug (off by default) an operator's first signal is a full disk.

Suggest Warn, or Info with a counter so a persistently-stalled collector is visible without enabling debug logging. Same applies to the cutLine == 0 skip on line 116 — though that one is self-resolving as the chain grows, so it is genuinely debug-level.

// Construct a default storage garbage collector config.
func DefaultStorageGarbageCollectorConfig() *StorageGarbageCollectorConfig {
return &StorageGarbageCollectorConfig{
RollbackWindow: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] RollbackWindow drops 10,000 → 1,000. The combined retain window still grows (101,000 with the new StoreRetention), but the two knobs carry different guarantees: StoreRetention is documented as discretionary history, RollbackWindow is the correctness requirement the collector's invariant is stated in terms of. Anything that reads RollbackWindow as "how far we can roll back" now sees a 10x smaller number.

Worth confirming this is deliberate rather than a rebalance that assumed the sum is all that matters, and calling it out in the release notes if operators rely on the 10,000-block figure.

Minor: 1000 here vs 100_000 on the next line — use 1_000 for consistency.

// The cut line is derived by subtracting RollbackWindow + StoreRetention from the head of the chain. Reject a pair
// that overflows when summed, so that subtraction cannot wrap around to a cut line above the head.
if c.RollbackWindow+c.StoreRetention < c.RollbackWindow {
return fmt.Errorf("rollback window (%d) plus store retention (%d) must be at most %d",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The message renders as ... must be at most 18446744073709551615, which is vacuously true for a uint64 sum and doesn't tell the operator what to do. Something like rollback window (%d) plus store retention (%d) overflows uint64 states the actual constraint.

Comment thread sei-db/management/gc/api.go Outdated

// PrunableStore is a store whose old data may be dropped by the StorageGarbageCollector.
type PrunableStore interface {
// Name Returns the name of the store.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] // Name Returns the name of the store. — stray capital and a doubled subject. Godoc convention is // Name returns the name of the store.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean, well-documented refactor collapsing SnapshotStore/StreamStore into a single PrunableStore interface, plus a new GigaStorageConfig and a managementmanagement/gc package move; tests are thorough for the new decision logic. No blocking defects, but the 0-means-opt-out sentinel is a genuine safety loosening versus the old "empty store aborts the cycle" behavior, the vote map keyed by Name() breaks on duplicate store names, and a couple of doc/default changes look unintentional.

Findings: 0 blocking | 11 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Both the gc collector and GigaStorageConfig are currently unreferenced scaffolding — there are no PrunableStore implementations in-tree (grep GetOldestBlockToRetain finds only the interface and the mocks). The correctness of the whole scheme now rests on an unenforceable doc contract; a follow-up that exercises the real SC/SS/WAL/blockDB implementations against this collector is the thing that actually validates this design.
  • Behavior change worth stating in the PR description: every store is now pruned to the global minimum answer, whereas the old code pruned each snapshot store to its own floor and only the WAL to the min. Combined with the new StoreRetention of 100,000, SC/SS will hold materially more data than before. The TODO on StoreRetention acknowledges this; a rough disk-cost estimate before this is wired up would be useful.
  • DefaultGigaStorageConfig has no test — the other Default*Config helpers and the path helpers are covered, and this one has a real failure path (littblock.DefaultConfig error) plus path composition that is easy to get wrong.
  • TestPruneDecisions derives its per-store expectations by calling getCutLine / getGlobalLastCommittedBlock — the same functions under test — so the "which stores were pruned" half of the assertion partly restates the implementation. Hardcoding the expected set of pruned stores per case would catch a regression in those helpers.
  • NewStorageGarbageCollector does not guard against a nil ctx; run would panic on s.ctx.Done(). Cheap to reject in the constructor alongside the config validation.
  • The Cursor second-opinion pass (cursor-review.md) produced no output — this review merges only Claude's and Codex's findings.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

// intentionally retaining forever. Snapshotted stores that have not finished a first snapshot yet still vote with
// their last committed height (see GetOldestBlockToRetain).
var pruneHeight uint64
oldestBlockToRetainByStore := make(map[string]uint64, len(s.stores))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Keying the vote map by store.Name() assumes names are unique, but nothing in the PrunableStore contract or the constructor enforces that. With two stores answering under the same name, the second write wins and the PruneBelow gate at line 153 consults the wrong answer: a store that opted out with 0 can be pruned anyway (data loss on an infinite-retention store), or a participating store can be silently skipped. The log map also silently drops one of the entries, so the diagnosis trail you built for exactly this situation disappears.

Indexing by position sidesteps the whole class:

answers := make([]uint64, len(s.stores))
for i, store := range s.stores {
    answers[i] = store.GetOldestBlockToRetain(cutLine)
    ...
}

and build the name→answer map separately, purely for logging.

Comment thread sei-db/management/gc/api.go Outdated
PruneBelow(blockNumber uint64) error

// GetOldestBlockToRetain returns the oldest block this store must keep in order to remain able to serve cutLine,
// or 0 when the store opts out of pruning entirely (e.g. infinite retention or truly uninitialized). A chain's

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This sentinel conflates two states with opposite safety requirements: "infinite retention, never prune me" and "truly uninitialized." The old collector deliberately aborted the entire cycle when any store reported no data, on the grounds that an empty store is unknown, not empty ("a snapshot may be mid-write and take hours"). Now an uninitialized store answers 0, is dropped from the vote, and the contiguous stores get pruned to the cut line — deleting exactly the history a freshly-created SS store would need to backfill by WAL replay.

The doc does place the burden on implementers ("return your last committed height, not 0"), and the PR lists it as a contract caveat, so I don't consider this blocking with no implementations in tree. Codex flagged the same thing as P1. The durable fix is to make the two cases unrepresentable rather than documented — e.g. GetOldestBlockToRetain(cutLine uint64) (height uint64, participates bool), or a named OptOutOfPruning constant — so "snapshot still writing" can never be silently encoded as 0 by an implementer who skimmed this comment.

// Construct a default storage garbage collector config.
func DefaultStorageGarbageCollectorConfig() *StorageGarbageCollectorConfig {
return &StorageGarbageCollectorConfig{
RollbackWindow: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] RollbackWindow drops from 10,000 to 1,000 — a 10× reduction in the correctness-critical rollback window — and PruneInterval goes from 60s to 10m. Neither is mentioned in the PR description, which frames the config work as "moving the knobs, single source of truth." The combined retain window does grow (1,000 + 100,000 vs. 10,000), so total retention increases, but the rollback guarantee specifically shrinks. Please confirm 1,000 is the intended operational rollback window rather than a carry-over from splitting the old value into two fields.


// GetBlockStorePath returns the path for the ledger block store, which sits alongside the
// receipt store under data/ledger. There is no legacy fallback: the block store has only
// ever lived at this path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] "the block store has only ever lived at this path" doesn't match the tree: the only production block store today is autobahn's littblock, opened at filepath.Join(dir, "blockdb") under persistent_state_dir (sei-tendermint/node/setup.go:399), not data/ledger/block. Nothing breaks now since GetBlockStorePath has no callers outside DefaultGigaStorageConfig, but the comment will mislead whoever wires this up, and the migration story from the existing blockdb directory needs an answer before then.

// DefaultGigaStorageConfig returns a GigaStorageConfig whose store directories match the
// layout below, and whose pruning knobs are gc.DefaultStorageGarbageCollectorConfig():
//
// data/state_commit/flatkv

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This layout holds for new nodes only — GetFlatKVPath, GetEVMStateStorePath and GetReceiptStorePath each fall back to a legacy directory (data/flatkv, data/evm_ss, data/receipt.db) when it already exists. Worth a "(new nodes; legacy directories are preferred when present)" qualifier so nobody treats these paths as guaranteed.

@seidroid
seidroid Bot dismissed their stale review July 31, 2026 00:25

Superseded: latest AI review found no blocking issues.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I won't re-approve this yet: the EVMSplit/DBDirectory blocker flagged earlier on giga_config.go (state-store path routing) is still present in the latest commit. Several rounds of "address comments" commits landed since then and fixed other feedback (panic-on-error, config-default duplication, the empty-store pruning safety contract), but this one line was not touched — worth confirming before merge.

Extended reasoning...

Overview

This run's diff carries forward sei-db/config/giga_config.go, sei-db/management/gc/* (the unified PrunableStore/StorageGarbageCollector), and small sei-db/common/utils path helpers. No new bugs were surfaced by the bug hunting system this run.

What changed since the last review round

I compared the current checkout against the PR's commit history. Several previously-raised points were genuinely fixed: DefaultGigaStorageConfig no longer panics on error (now returns (GigaStorageConfig, error)), RollbackWindow/StoreRetention/BlockRetention duplication was removed in favor of a single gc.StorageGarbageCollectorConfig, and the "empty store lets pruning continue unsafely" concern was resolved by tightening the PrunableStore contract itself (a store now must report its last-committed/in-progress height rather than 0 while a first snapshot is mid-write, so 0 is a true opt-out sentinel) — this is a reasonable resolution, documented in api.go and covered by the "in-progress first snapshot binds below the cut line" test case.

Outstanding concern

One blocker raised independently by seidroid[bot] (twice) and by a prior claude[bot] review is still unaddressed: in DefaultGigaStorageConfig (giga_config.go), ssConfig.DBDirectory is set to utils.GetEVMStateStorePath(...) while EVMSplit stays false and EVMDBDirectory is never set. Per sei-db/state_db/ss/composite/store.go, DBDirectory overrides the Cosmos store's dbHome, and the dedicated EVM store is only constructed when EVMSplit is true — so as written, Giga's composite state store roots the single (Cosmos+EVM, since EVMSplit is false) store at the path documented as the EVM-only directory, and never opens a distinct EVM store. This contradicts the function's own layout doc comment. I confirmed this by reading the current file and tracing NewCompositeStateStore; nothing in the last three "address comments" commits touched this line.

Level of scrutiny

This is pre-production plumbing (nothing constructs a real StorageGarbageCollector yet, and GigaStorageConfig isn't wired into any running node per the PR description), so the blast radius today is limited to test/CI code paths. But since it silently mis-routes state-store data if used as-is, and it's flagged as a blocker with a full traced explanation already sitting on the PR, I'd rather a human confirm it's fixed (or intentionally deferred) before this lands.

Other factors

Test coverage on the gc package itself is thorough (decision-matrix tests for cut-line/pruning behavior), and the pruning-safety contract issue was resolved with real design work rather than a hand-wave. The remaining EVMSplit item is narrow and mechanical to fix.

seidroid[bot]
seidroid Bot previously requested changes Jul 31, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PrunableStore unification is a clean, well-tested refactor — the min-head / min-answer / positional-answer logic and the guarded unsigned cut-line subtraction are all correct and pinned by tests. One blocking defect: DefaultGigaStorageConfig writes the EVM state-store path into StateStoreConfig.DBDirectory (the Cosmos SS override) instead of EVMDBDirectory, which misplaces the SS data directory and collides with the EVM default path if EVMSplit is ever enabled.

Findings: 1 blocking | 11 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • Second-opinion passes produced nothing usable: codex-review.md reports no material findings (and notes it could not run tests because the Go module cache is read-only), and cursor-review.md is empty. Neither corroborates or contradicts the findings below.
  • sei-db/config/giga_config.go has no test at all. A test pinning the four resolved directories against utils.Get*Path(home, backend) — plus the littblock.DefaultConfig error path — would have caught the DBDirectory/EVMDBDirectory mix-up above, and this package already has that habit (sc_config_test.go, receipt_config_test.go, path_test.go). Consider also a Validate() on GigaStorageConfig that delegates to the nested configs' Validate(), since nothing currently checks that PruningConfig is non-nil until NewStorageGarbageCollector runs.
  • Layering: sei-db/config now imports store implementation packages (ledger_db/block/littblock, state_db/sc/flatkv/config) while ledger_db/receipt already imports sei-db/config. No cycle today (littblock only reaches db_engine/litt and common/*), but the dependency direction is inverted for a leaf config package, and a cycle appears the moment littblock needs anything from sei-db/config. A higher-level package for the Giga composition would be more robust.
  • Behavioral note on the dropped conservatism: the old collector skipped the entire cycle when any snapshot store was empty, explicitly because "a snapshot may be mid-write and take hours." The new contract instead assumes snapshot creation completes "within seconds" and has such a store answer 0. The reasoning in the PR body is sound for the default window, but nothing in this PR enforces it — it becomes a constraint on every future GetOldestBlockToRetain implementation. Worth restating the requirement ("report the in-progress snapshot height if your snapshots can outlive a prune interval") wherever the first real implementation lands, especially for FlatKV, whose Pebble checkpoints on a large store are not obviously seconds-scale.
  • Minor retention regression from the unification: because every participating store is pruned to the global minimum answer, a snapshot store now keeps any snapshots falling in [pruneHeight, ownAnswer) that the old per-store floors[i] would have dropped. The window is bounded and the StoreRetention TODO already acknowledges the general shape of this, so it's a disk-usage cost rather than a correctness issue — just noting it is a real (if small) change from the previous behavior.
  • The deletions of sei-db/ledger_db/event/placeholder.go and sei-db/ledger_db/transaction/placeholder.go remove those two packages entirely and are unrelated to the PR's stated scope. Nothing in the tree imports them, so this is safe — just confirm it's intentional rather than a stray rebase artifact.
  • prune()'s if len(s.stores) == 0 { return nil } guard is redundant: getGlobalLastCommittedBlock returns 0 for an empty slice and the next check already handles it. Harmless, but it's a second early-exit for the same condition.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/config/giga_config.go Outdated
flatKV.DataDir = utils.GetFlatKVPath(homePath)

ssConfig := DefaultStateStoreConfig()
ssConfig.DBDirectory = utils.GetEVMStateStorePath(homePath, ssConfig.Backend)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This assigns the EVM state-store path to DBDirectory, but DBDirectory is the Cosmos SS directory override — composite.NewCompositeStateStore uses it as dbHome for the Cosmos MVCC DB (sei-db/state_db/ss/composite/store.go:48-51), whose default is utils.GetStateStorePath (data/state_store/cosmos/{backend}). The field intended for this path is EVMDBDirectory, documented as defaulting to <home>/data/state_store/evm/{backend} (ss_config.go:74-76).

Two consequences as written:

  1. With the default EVMSplit: false, all SS data (Cosmos + EVM) is written to data/state_store/evm/pebbledb — the directory reserved for the EVM-only store.
  2. If EVMSplit is later enabled while EVMDBDirectory stays empty (its default), composite resolves evmDir via utils.GetEVMStateStorePath to the same directory as dbHome, so two MVCC DBs would target one PebbleDB dir.

If the intent is a single EVM-only SS under the Giga layout, please make that explicit — set EVMSplit: true and EVMDBDirectory, and give the Cosmos DBDirectory its own utils.GetStateStorePath(...) value — rather than leaving it implied by an override on the wrong field. Nothing constructs GigaStorageConfig yet, so there's no live impact, but a store opened against the wrong directory is expensive to unwind once it is wired in.

//
// Unlike the other Default*Config helpers in this package, this can fail: the block-store
// default wraps littdb.DefaultConfig, which validates the directory path.
func DefaultGigaStorageConfig(homePath string) (GigaStorageConfig, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This constructor has no test coverage. A table test asserting each resolved directory equals the corresponding utils.Get*Path(home, backend) — and one covering the littblock.DefaultConfig failure path that motivated the (GigaStorageConfig, error) signature — would pin the layout the doc comment above promises, and would have surfaced the DBDirectory issue on line 51.

if c == nil {
return fmt.Errorf("config is required")
}
// A zero rollback window is legal: it means the system prunes as aggressively as possible.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This comment predates StoreRetention and now understates what Validate permits. Zero RollbackWindow alone is fine (the retention term still leaves margin), but RollbackWindow == 0 and StoreRetention == 0 together give cutLine == globalLatestBlock, which is the one configuration where the "a store with no completed snapshot answers 0" rule loses its safety margin: a snapshot landing at ~head can no longer be replayed forward, because the contiguous stores were just permitted to drop everything below head. The PR description reasons about exactly this case; please fold that reasoning into the comment here (or reject the both-zero pair), so the next reader of Validate sees why it's allowed.

// Construct a default storage garbage collector config.
func DefaultStorageGarbageCollectorConfig() *StorageGarbageCollectorConfig {
return &StorageGarbageCollectorConfig{
RollbackWindow: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The default RollbackWindow drops 10x (10,000 → 1,000) and PruneInterval goes 60s → 10m relative to the deleted config. The combined retain window grows (101,000 vs 10,000), so effective retention is strictly more conservative and nothing wires this collector up yet — but the guaranteed rollback depth is now 1,000 blocks. Worth confirming that reduction is deliberate; a one-line note on the field would save the question later.

}

func NewStorageGarbageCollector(
ctx context.Context,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] config == nil is guarded via Validate, but a nil ctx is not: run selects on s.ctx.Done() and would panic on a nil interface. Either reject a nil ctx alongside the config check or default it to context.Background().

@cody-littley cody-littley 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.

LGTM. Before or after this merges, I'd like to have a parking lot discussion about directory names in the data/ directory. Currently the layout is a bit adhoc, and I think it would be nice to organize storage layout more intentionally.

Comment thread sei-db/config/giga_config.go Outdated
Comment on lines +35 to +37
// data/state_store/evm/{backend}
// data/ledger/receipt/{backend}
// data/ledger/block

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.

Do we have a doc somewhere that shows the layout of all the existing directories in the data/ directory? IMO, might be nice to see these paths in the context of all existing paths. It's been a while since I've looked, but I seem to recall most of the existing directories using a pattern like block.db/ instead of underscore notation.

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.

Yeah block.db is the old cosmos style and we are moving away from that. The new directories are going to be documented in our design doc.

Comment thread sei-db/management/gc/api.go Outdated
Comment on lines +30 to +31
// GetLastCommittedBlock returns the highest block this store has ingested.
GetLastCommittedBlock() (uint64, 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.

Nit, would it be more accurate to say LastIngestedBlock()? The reason I bring this up is that "committed" may be viewed in the technical sense, i.e. people may assume that this implies that the data is crash durable. Since we are intentionally going to be disabling fsync for many stores (since Block DB provides recovery pathway), saying "committed" may lead to future confusion.


// prune performs a single prune cycle: it locates the head of the chain, derives the cut line from it, asks every store
// how far back it must retain data to serve that cut line, and prunes every store below the lowest answer.
func (s *StorageGarbageCollector) prune() 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.

Optional suggestion.

If you created a static method func prune(stores []PrunableStore) error, you could put all of the logic in this function there. Then, during unit tests, it becomes very easy to test. You can have all of the complex tests operate against the static methods, and the unit tests don't need to worry about threads or timing. The only stuff that remains in the actual StorageGarbageCollector is just a loop that runs on a thread and calls a method every X seconds, which probably doesn't even need unit tests.

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.

Make sense

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit f623054. Configure here.

Comment thread sei-db/management/gc/storage_garbage_collector.go Outdated

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PrunableStore refactor is a clean simplification — the single-interface/lowest-answer-wins design is well documented and thoroughly tested, and the min-head + guarded-subtraction logic is correct. My notes are mostly about DefaultGigaStorageConfig composing store-local retention knobs (flatKV SnapshotKeepRecent, SS KeepRecent) that the collector has no visibility into, which undercuts the "PruningConfig is the single source of truth" claim; nothing is wired up yet, so there is no runtime impact today.

Findings: 0 blocking | 14 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion pass produced no output (cursor-review.md is empty), so this review reflects only Claude + Codex. Codex's single P1 is addressed inline (downgraded: it is latent, not live).
  • Nothing in the tree constructs GigaStorageConfig or StorageGarbageCollector yet (verified by grep), so all of this is forthcoming plumbing with no runtime impact. The retention mismatches flagged inline should be settled before the config is wired into a node.
  • The snapshot-floor rule ("newest snapshot at or below cutLine, else oldest") now lives only in the PrunableStore doc comment and in the test mock's snapshotStore helper. The deleted snapshotPruningFloor + its property test (TestSnapshotPruningFloorProperty) have no replacement, so every real snapshotted store will reimplement and re-test the rule. Consider exporting a shared helper from gc (e.g. SnapshotFloor(snapshots []uint64, cutLine uint64) uint64) and keeping the property test against it.
  • No metric or escalating signal for a permanently stalled collector. If one snapshotted store stops producing snapshots, its answer pins pruneHeight forever and disk grows unbounded, with only a per-cycle logger.Info to show for it. A gauge for pruneHeight/cutLine lag would make this operationally visible.
  • TestPruneDecisions derives its per-store "should not be pruned" expectation by calling store.GetOldestBlockToRetain(cutLine) — the same function under test — rather than declaring it in the table. The prune height itself is asserted against a literal, so coverage is real, but the opt-out expectation is partly self-fulfilling.
  • getGlobalLastCommittedBlock's comment justifies skipping a head of 0 with "whatever it holds, its GetOldestBlockToRetain answer still bounds the prune height." That does not hold when the same store also answers 0 (no completed snapshot) — the two independent "0 means ignore me" rules compose into an unbounded case. It appears harmless in practice (such a store holds nothing to lose), but the comment, and the PR description's safety argument that leans on globalLatestBlock being ≤ the store's own head, should say so explicitly.
  • PR description has drifted from the code: it calls the field StoreRetention (code: RetentionBeyondRollbackWindow), omits the negative-means-infinite sentinel, and states that Validate "permits [RollbackWindow == 0] deliberately as 'prune as aggressively as possible'" when the code now rejects it. Worth syncing before merge since the description is doing real design-doc duty here.
  • I could not run go test, gofmt -s -l, or goimports -l on the changed packages in this environment (command approval), so the formatting/lint compliance required by AGENTS.md is unverified by me — CI should be the gate.
  • 6 suggestion(s)/nit(s) flagged inline on specific lines.

return GigaStorageConfig{}, fmt.Errorf("failed to build block db config: %w", err)
}

flatKV := flatkvConfig.DefaultConfig()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] flatkvConfig.DefaultConfig() brings its own retention policy that the collector cannot see: SnapshotInterval = 10000 and SnapshotKeepRecent = 1, and CommitStore.pruneSnapshots runs unconditionally after every snapshot (sei-db/state_db/sc/flatkv/snapshot.go:514). So flatKV holds at most ~2 snapshots — roughly 20,000 blocks of restorable state — while PruningConfig here promises 101,000 blocks of servable history.

The rollback invariant still holds (a snapshot at or below head - 1000 is always among the retained pair), and the collector never over-prunes, since flatKV answers its oldest snapshot and contiguous stores answer the lower cutLine. But blockDB/receiptDB will retain 101k blocks of history that flatKV can no longer be restored to, so RetentionBeyondRollbackWindow = 100_000 is not actually delivered for state. Either raise SnapshotKeepRecent here to cover the GC window, or narrow the doc claim at lines 16-18 — as composed, gc.DefaultStorageGarbageCollectorConfig is not the single source of truth for how much history survives.

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.

This will be fixed in the future PR, right now it's not wired up yet

flatKV := flatkvConfig.DefaultConfig()
flatKV.DataDir = utils.GetFlatKVPath(homePath)

ssConfig := DefaultStateStoreConfig()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Codex flagged this as a live double-pruner; it is currently latent, but worth zeroing anyway. DefaultStateStoreConfig() leaves KeepRecent = DefaultSSKeepRecent = 100000 (sei-db/config/ss_config.go:7), which is below the collector's 101,000-block window. It does not bite today because Giga opens SS through evm.NewEVMStateStore, which starts no pruning manager (only composite.NewCompositeStateStore does, at sei-db/state_db/ss/composite/store.go:170) and the pebble backend ignores the field.

Since the doc comment above advertises this config as the pruning authority, setting ssConfig.KeepRecent = 0 explicitly here would remove the trap for whoever later changes the SS wiring, and document the intent in the same place.

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.

Will take care in future PRs


cutLine := getCutLine(globalLatestBlock, s.config.RollbackWindow, s.config.RetentionBeyondRollbackWindow)
if cutLine == 0 {
logger.Info("skipping pruning, the chain is younger than the retain window",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This log message is wrong for one of the two ways getCutLine returns 0. Infinite retention (RetentionBeyondRollbackWindow < 0) also yields cutLine == 0, so a node deliberately configured to never prune will log "skipping pruning, the chain is younger than the retain window" every PruneInterval forever — actively misleading during an incident.

Either branch on s.config.RetentionBeyondRollbackWindow < 0 for a distinct "infinite retention configured" message, or return early (or skip starting the run loop entirely) in that case, which would also stop polling every store's GetLastCommittedBlock() for no purpose.

// everything below head, and a store that answers 0 because it has no completed snapshot yet offers
// no counter-vote — a snapshot that then lands at ~head cannot be replayed forward. Infinite
// retention is any negative RetentionBeyondRollbackWindow, not a zero rollback window.
if c.RollbackWindow == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two undiscussed behavior changes relative to the deleted config, both worth calling out in the PR description:

  1. RollbackWindow == 0 is now rejected. The old Validate documented it as explicitly legal ("means the system prunes as aggressively as possible"), and the PR description still claims Validate permits it. The reasoning in the comment above is sound — I'd keep the rejection and fix the description.
  2. The default RollbackWindow dropped 10x, from 10,000 to 1,000 (line 59), and PruneInterval went from 60s to 10 minutes. The interval change is self-explanatory; a 10x reduction in the guaranteed rollback depth is a meaningful operational commitment change and deserves a sentence of rationale.

config *StorageGarbageCollectorConfig,
stores []PrunableStore,
) (*StorageGarbageCollector, error) {
if err := config.Validate(); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] ctx is stored unvalidated and then dereferenced as s.ctx.Done() inside the goroutine at line 90. A nil ctx therefore panics on a background goroutine — unrecoverable, taking the node with it — rather than returning the error this constructor already has a channel for. config.Validate() guards the other required argument; a matching if ctx == nil { return nil, fmt.Errorf("context is required") } (or defaulting to context.Background()) would keep the failure mode in the caller's hands.

Comment thread sei-db/management/gc/api.go Outdated
// so that other stores can still be pruned.
//
// A store that retains a contiguous range of blocks (blockDB, receiptDB, the state WAL) can be restored to any
// block it holds, so it should simply return the cutLine unchanged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] The contract for contiguous stores is unconditional here ("should simply return the cutLine unchanged"), but the empty case is what the tests actually model: contiguousStore(..., hasData: false) returns 0, and TestPruneDecisions's "a store answering 0 is ignored" case depends on it. Since real implementations will be written against this comment, it's worth stating that a contiguous store holding no blocks answers 0 — otherwise an implementer follows the letter of the doc, returns cutLine from an empty store, and needlessly raises the floor for everyone else.

Also, line 5 reads "Name Returns the name of the store" — should be "Name returns".

@seidroid
seidroid Bot dismissed their stale review July 31, 2026 19:15

Superseded: latest AI review found no blocking issues.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean, well-documented refactor collapsing SnapshotStore/StreamStore into a single PrunableStore interface, with a strong table-driven test suite covering the prune decision matrix, the unsigned cut-line subtraction, and the duplicate-name positional-tracking case. No blockers: the gc package has no production callers yet, so this is pre-wiring. Findings are limited to an over-broad RollbackWindow validation, some contract documentation gaps around the 0 sentinel, and a stale PR description.

Findings: 0 blocking | 10 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's review file (cursor-review.md) is empty — that pass produced no output, so this review merges only my findings with Codex's single P2.
  • The PR description is stale relative to the code: it uses the field name StoreRetention (code: RetentionBeyondRollbackWindow), and its "Correctness notes" state that RollbackWindow == 0 with StoreRetention == 0 is "permitted deliberately as 'prune as aggressively as possible'" — Validate now rejects it. The description's Validate summary (nil config / non-positive interval / overflow) also omits the new rollback-window rule. Worth syncing before merge so the merge commit message isn't misleading.
  • The default RollbackWindow drops from 10,000 (deleted management config) to 1,000, and PruneInterval goes from 60s to 10m. Both look intentional but neither change is called out as a behavior delta in the description.
  • Nothing wires StorageGarbageCollector to real stores yet — sei-db/config/giga_config.go only references the config type, and no type in the tree implements PrunableStore. Fine for a pre-wiring PR, but the interface contract (especially the 0 sentinel semantics) is currently unexercised by any real implementation, so the first integration PR is where the contract gets its actual test.
  • GigaStorageConfig has no Validate() of its own, so a caller that hand-builds one (rather than using DefaultGigaStorageConfig) can pass a nil PruningConfig or unset paths straight through; the nil case is caught later by NewStorageGarbageCollector, the paths are not.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

// everything below head, and a store that answers 0 because it has no completed snapshot yet offers
// no counter-vote — a snapshot that then lands at ~head cannot be replayed forward. Infinite
// retention is any negative RetentionBeyondRollbackWindow, not a zero rollback window.
if c.RollbackWindow == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The rejection is broader than the rationale that justifies it. The comment above correctly identifies the hazard as RollbackWindow == 0 and RetentionBeyondRollbackWindow == 0 (cut line reaches head, no margin for a first snapshot landing at ~head). But this check rejects RollbackWindow == 0 even when RetentionBeyondRollbackWindow is 100,000, which leaves ample margin — a config that is safe by the comment's own argument.

Consider validating the sum instead:

if c.RetentionBeyondRollbackWindow >= 0 && c.RollbackWindow+uint64(c.RetentionBeyondRollbackWindow) == 0 {
    return fmt.Errorf("rollback window plus retention beyond rollback window must be greater than 0")
}

Codex flagged this as a migration break from the old API (which explicitly allowed RollbackWindow: 0). I'd downgrade that framing — the gc package has no production callers yet, so nothing breaks today — but the over-strictness relative to the stated rationale stands on its own. Note that the invariant "cut line leaves at least one block of margin" is what the code actually wants; only that is worth enforcing.

Comment thread sei-db/management/gc/api.go Outdated
PruneBelow(blockNumber uint64) error

// GetOldestBlockToRetain returns the oldest block this store must keep in order to remain able to serve cutLine,
// or 0 when the store opts out of pruning entirely (e.g. infinite retention or disabled).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The 0 sentinel collapses three genuinely different states — "no completed snapshot yet", "disabled", and "retaining forever" — and the safety argument in the PR description only covers the first. For a store that answers 0 because it retains forever while holding an ancient snapshot, the other stores still prune to cutLine, so the contiguous blocks that snapshot would replay forward from are deleted and the retained snapshot becomes unreplayable.

The PR description acknowledges this ("An infinite-retention store must be restorable from its own retained data without replaying blocks another store will prune away"), but that constraint isn't stated here — and this doc comment is what an implementer of PrunableStore will actually read. Worth folding that sentence into the contract.

// Heads of 0 are skipped: a store that has ingested nothing would otherwise hold the head of the whole system at 0 and
// stall pruning everywhere. Skipping it does not put its data at risk, because whatever it holds, its
// GetOldestBlockToRetain answer still bounds the prune height. Returns 0 when no store has committed a block.
func getGlobalLastCommittedBlock(stores []PrunableStore) (uint64, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Skipping heads of 0 reverses the deleted collector's deliberate safety net ("An empty store is treated as unknown rather than empty ... pruning against it would risk breaking the primary invariant"), which skipped the entire cycle when any store was empty. Avoiding the fleet-wide stall is a reasonable trade, but the new escape hatch — "its GetOldestBlockToRetain answer still bounds the prune height" — doesn't hold for a store that is at 0 because it hasn't started ingesting and intends to catch up by replaying the WAL: under the documented contiguous-store contract that store returns cutLine (or 0 if it has no data), neither of which reserves the blocks it needs to catch up from.

No current implementation hits this, but the interface gives a lagging store no way to say "I need block N in order to catch up." Worth either documenting that a catching-up store must answer with its next-needed block, or noting explicitly that bootstrapping stores must be kept out of the collector's stores slice until they are caught up.

Comment thread sei-db/management/gc/storage_garbage_collector.go

cutLine := getCutLine(globalLatestBlock, config.RollbackWindow, config.RetentionBeyondRollbackWindow)
if cutLine == 0 {
logger.Info("skipping pruning, the chain is younger than the retain window",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This log line attributes every cutLine == 0 to a young chain, but getCutLine also returns 0 for infinite retention (negative RetentionBeyondRollbackWindow) — as its own doc comment on line 182 says. An operator who configured InfiniteRetentionBeyondRollbackWindow will see "the chain is younger than the retain window" logged every 10 minutes forever. Either branch on config.RetentionBeyondRollbackWindow < 0 for a distinct message, or reword to something neutral like "skipping pruning, no cut line" and include the retention setting in the log fields.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid, well-documented refactor collapsing SnapshotStore/StreamStore into a single PrunableStore interface with per-store retention, plus a new in-process GigaStorageConfig. No blocking correctness issues found: the min-head / min-boundary math and the positional opt-out tracking are right and well covered by unit tests. Findings are naming (typos baked into a brand-new exported interface), an unguarded uint64 addition, changed defaults, and stale PR documentation.

Findings: 0 blocking | 11 non-blocking | 5 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion pass produced no output (cursor-review.md is empty), so only Codex's review (one finding, the getCutLine overflow) was available to merge in.
  • The PR description is substantially out of date relative to the final code and describes an earlier iteration: it documents GetOldestBlockToRetain, GetLastCommittedBlock, a StoreRetention config knob, and a Validate that "rejects a RollbackWindow + StoreRetention pair that overflows uint64". The merged code has GetPruningBoundry, GetLastestBlock, per-store GetRetentionWindow, and a Validate that checks only nil/RollbackWindow > 0/PruneInterval > 0. Since this description is the design record for a non-trivial invariant argument, please refresh it before merge.
  • Nothing in-tree implements PrunableStore and NewStorageGarbageCollector has no callers (only sei-db/config imports gc, for the config type). The collector's behavior is therefore verified only against mocks in storage_garbage_collector_test.go; real SC/SS/flatKV/blockDB/receiptDB/WAL adapters will be where the interface contract (especially "newest completed snapshot ≤ cutLine") is actually proven. Worth stating explicitly that this is staged work.
  • PruneInterval was made a time.Duration specifically so the ticker path is testable with short intervals, but no test exercises it: TestNewStorageGarbageCollectorConstructAndClose uses the 10-minute default and closes immediately. Consider a test with e.g. PruneInterval: 10*time.Millisecond asserting PruneBelow is observed via the run loop — that would cover run(), error logging, and the Close/ctx interaction.
  • TestPruneDecisions derives its shouldPrune expectation by re-running getCutLine and store.GetPruningBoundry — the same helpers under test — so a bug in the participation rule would be mirrored by the assertion. The explicit wantPruneBelow values carry the real signal; consider making the per-store expected-pruned set explicit in the table too.
  • A single stalled store silently throttles pruning fleet-wide, since the global head is the min. An intermediate commit in this PR had a warning when store heads disagreed by more than the rollback window; the final version dropped it. Re-adding a warn (min head vs max head divergence) would make "pruning looks healthy but reclaims nothing" diagnosable.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/management/gc/api.go Outdated
//
// Assumption: snapshot creation finishes quickly enough that an in-flight write need
// not be reserved; modeling long-running snapshot writes is out of scope.
GetPruningBoundry(cutLine uint64) uint64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two typos are being baked into a brand-new exported interface: GetPruningBoundryGetPruningBoundary and GetLastestBlockGetLatestBlock (also getGlobalLastestBlock, the "failed to read lastest block from %s" error string, the pruningBoundries/pruningBoundryByStore identifiers and log key, and "boundry" throughout the comments).

Cheapest moment to fix is now — every SC/SS/flatKV/blockDB/receiptDB/WAL adapter in the follow-up PRs will have to spell the method names exactly as written here, and the log key becomes something dashboards key off. Note also that misspell is enabled in .golangci.yml and run.tests: false means these non-test files are in scope, so this may surface as a CI lint failure rather than just a style nit.

if retention < 0 {
return 0
}
totalRetainWindow := rollbackWindow + uint64(retention)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] rollbackWindow + uint64(retention) is unguarded (Codex flagged this too). If the sum wraps, totalRetainWindow becomes small, the globalLatestBlock <= totalRetainWindow guard passes, and the cut line lands just below head — the destructive direction.

I'd rate this lower than Codex's "High": with retention capped at MaxInt64 (~9.22e18), wrapping needs RollbackWindow ≳ 9.22e18, which no realistic config produces. But it's worth closing because the guard that used to cover it was lost in this refactor: the PR description still claims Validate "rejects a RollbackWindow + StoreRetention pair that overflows uint64", which was true when retention lived in the config and isn't anymore now that it comes from each store.

Two cheap options: check the addition here (if rollbackWindow > math.MaxUint64-uint64(retention) { return 0 }), and/or have Validate reject an absurd RollbackWindow upper bound. Separately, this int64uint64 conversion may trip gosec G115 despite the retention < 0 early return — worth confirming against the CI lint run.

// snapshot ≤ cutLine, or the oldest completed snapshot if every snapshot is above
// cutLine (none can be dropped yet). No completed snapshot → 0.
//
// Assumption: snapshot creation finishes quickly enough that an in-flight write need

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This assumption is a deliberate weakening of the base behavior and deserves to be called out as such rather than only as "out of scope". The code being deleted skipped the entire cycle when any store was empty, with the comment "a snapshot may be mid-write and take hours" — exactly the case now declared out of scope.

Concrete degradation: a snapshot store with no completed snapshot answers 0, so it neither votes nor gets pruned, but contiguous stores still prune to head - RollbackWindow. With the default RollbackWindow of 1000, head advances past any fixed height within minutes at Sei block times. If a first snapshot begins at height H and takes hours, the WAL will have pruned well above H by the time it lands, so the landed snapshot can never be replayed forward — the store stays unrestorable until a later snapshot lands at or above the WAL's start.

The stated invariant ("if rollback was possible before the prune, the prune does not take it away") does survive, since rollback was already impossible with no snapshot — so this isn't a blocker. But the window is materially larger than "seconds" once RollbackWindow is 1000 rather than 10,000. Please either (a) note in the interface doc that an implementation whose snapshot writes can outlast RollbackWindow blocks must report the in-progress height instead of 0, or (b) track it as a required follow-up before the first real snapshot-store adapter lands.

// DefaultStorageGarbageCollectorConfig returns the default collector config.
func DefaultStorageGarbageCollectorConfig() *StorageGarbageCollectorConfig {
return &StorageGarbageCollectorConfig{
RollbackWindow: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Both defaults changed relative to the code this replaces, and neither change is mentioned in the PR description (which presents 1,000 / 10m as if they were the existing values): RollbackWindow 10,000 → 1,000 (10× less rollback headroom) and the prune interval 60s → 10m.

Nothing consumes this config yet, so there's no live impact, but the RollbackWindow reduction is the safety-relevant knob in this whole design — it sets the margin the "first snapshot lands near head" argument depends on (see my note on api.go). Please confirm 1,000 is intentional and record the rationale in the description or the field comment.

Related: Validate now requires RollbackWindow > 0, whereas the old Validate explicitly permitted 0 as "prune as aggressively as possible". The new comment explains why 0 is now rejected, which is a good change — just noting it's a behavior change worth listing alongside the default changes.

if i > 0 {
sb.WriteString(" ")
}
fmt.Fprintf(&sb, "%s=%d", store.Name(), pruningBoundries[i])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] describeAnswers renders name=0 for three distinguishable situations: the store was skipped because getCutLine returned 0 for infinite retention, skipped because head is still inside its retain window, or actually asked and answered 0 (no completed snapshot). Only the third is a real vote — in the first two, GetPruningBoundry was never called, so the log implies a vote that didn't happen.

Since the stated goal is that "a deletion decision can be reconstructed after the fact", consider distinguishing them (e.g. name=skipped(infinite) / name=skipped(young) / name=optout / name=<height>), which needs only a small per-store status alongside pruningBoundries.

Comment thread sei-db/management/gc/api.go Outdated

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Clean, well-documented refactor that collapses SnapshotStore/StreamStore into a single PrunableStore and moves pruning policy into the stores; nothing is wired into production yet, so risk is low. No blockers, but there is an unguarded uint64 overflow in getCutLine (also flagged by Codex), a typo baked into the new public interface (GetLastestBlock), missing run-loop test coverage, and a PR description that no longer matches the code.

Findings: 0 blocking | 12 non-blocking | 7 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • The PR description is substantially out of sync with the diff and should be updated before merge (it will become the commit message / the reference for the follow-up wiring PR). Specifically: it names GetOldestBlockToRetain/GetLastCommittedBlock (code has GetPruningBoundary/GetLastestBlock); it documents a StoreRetention config knob with default 100,000 that does not exist (retention is now per-store via GetRetentionWindow); it claims Validate rejects a RollbackWindow + StoreRetention pair that overflows uint64 (it only checks nil and PruneInterval); it claims "a failed PruneBelow stops the cycle" (the code joins errors and continues — see TestPruneBelowErrorContinuesRemainingStores); it claims run-loop tests with a short PruneInterval assert the ticker fires and that a prune error does not kill the loop (no such tests exist); and it claims prune logs carry cutLine (they do not).
  • The Cursor second-opinion pass produced no output — cursor-review.md is empty. Codex contributed exactly one finding (the getCutLine overflow), which is included inline below.
  • sei-db/config now imports sei-db/management/gc (config depending on a service package). Once gc needs store configs to build PrunableStore adapters, this direction will invert into an import cycle. Consider moving StorageGarbageCollectorConfig into sei-db/config (or a leaf gcconfig package) before the follow-up wiring PR.
  • GigaStorageConfig has no Validate() even though every nested config does (littblock.LittBlockConfig.Validate, StorageGarbageCollectorConfig.Validate). Worth adding one that fans out to the children so a hand-built (non-default) composition fails fast.
  • I could not execute go test/gofmt in this environment (sandbox denied), so the correctness assessment above is from static reading of the diff rather than a verified run.
  • 7 suggestion(s)/nit(s) flagged inline on specific lines.

if retention < 0 {
return 0
}
totalRetainWindow := rollbackWindow + uint64(retention)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] rollbackWindow + uint64(retention) can wrap, and nothing bounds either operand any more: the old Validate capped PruneIntervalSeconds, and the new one checks only nil + PruneInterval. With e.g. RollbackWindow = math.MaxUint64 and retention = 1, totalRetainWindow becomes 0, so cutLine == head and every participating store is told to prune below head — the exact opposite of "retain everything."

Agreed with Codex here, though I'd rate it lower than High: RollbackWindow is in-process only (no app.toml binding, no mapstructure tag), the default is 1000, and nothing constructs a collector yet. Still a one-line fix worth landing now, before real stores are attached:

if retention < 0 {
    return 0
}
totalRetainWindow := rollbackWindow + uint64(retention)
if totalRetainWindow < rollbackWindow { // wrapped
    return 0
}

or bound RollbackWindow in Validate (e.g. reject > math.MaxInt64) so the addition provably cannot wrap, and add a TestGetCutLine row for it — the table currently has no overflow case.

Comment thread sei-db/management/gc/api.go Outdated

// GetLastestBlock returns the highest block this store has ingested.
// 0 means "no data / uninitialized" and is ignored when computing the global head.
GetLastestBlock() (uint64, error)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] GetLastestBlock is misspelled ("Lastest" → "Latest"). It's propagated through the internal helper getGlobalLastestBlock, the error string "failed to read lastest block from %s", the doc comment on GetRetentionWindow above, and the collector's type doc. This is the moment to fix it: the PR states nothing implements PrunableStore outside tests, so renaming now costs three files; after SC, SS, blockDB, receiptDB and the state WAL each implement it, it's a repo-wide rename on a public interface method.

// Opt-out (e.g. no completed snapshot yet).
continue
}
if pruneHeight == 0 || pruningBoundaries[i] < pruneHeight {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Taking the global min boundary means a single store's GetRetentionWindow is effectively imposed on every other store, which undercuts the reason per-store retention was introduced. Concretely: receiptDB with GetRetentionWindow() == 100_000 drives pruneHeight to head - 101_000, so SC and SS — which the interface doc says "always 0" — also retain 101,000 blocks of snapshots they never needed.

The min is genuinely required for contiguous stores that must hold the blocks following a retained snapshot, but not for the snapshot stores themselves. If uniform pruning is the intended tradeoff (the deleted code carried a TODO to that effect), please carry that note into StorageGarbageCollector's doc comment so the next reader doesn't read GetRetentionWindow as per-store-effective.

// DefaultStorageGarbageCollectorConfig returns the default collector config.
func DefaultStorageGarbageCollectorConfig() *StorageGarbageCollectorConfig {
return &StorageGarbageCollectorConfig{
RollbackWindow: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The default RollbackWindow drops from 10,000 to 1,000 and the old PruneIntervalSeconds: 60 becomes 10 minutes. Since StoreRetention was dropped from the config and the interface contract says SC and SS "always 0", the default total retained history for those stores is now 1,000 blocks — roughly 10x less than before, and only minutes of history at Sei block times. The PR description implies the opposite (a StoreRetention default of 100,000 on top).

If the 10x reduction is deliberate, a sentence in the field doc explaining why 1,000 is enough would help; if it isn't, this is the one default that silently changes behavior in this otherwise-refactor-only PR.

Related: Validate deliberately permits RollbackWindow == 0, and the field comment itself spells out the resulting footgun (retention-0 contiguous stores + a snapshot store that answers 0 → pruneHeight reaches head and everything below it is dropped, leaving a near-head snapshot unreplayable). TestPruneDecisions pins exactly that case ("RollbackWindow 0 with no snapshot vote prunes contiguous to head", wantPruneBelow: 100_000). Documenting an unrecoverable configuration is weaker than rejecting it — consider requiring RollbackWindow > 0, or having prune decline to prune contiguous stores in a cycle where a snapshot store answered 0.

for _, store := range tc.stores {
shouldPrune := false
if tc.wantPruneBelow != nil && store.GetRetentionWindow() >= 0 {
cutLine := getCutLine(head, tc.rollbackWindow, store.GetRetentionWindow())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The per-store expectation is derived from the production helpers it is meant to check — getCutLine(...) and store.GetPruningBoundary(cutLine) decide shouldPrune, so a sign error or off-by-one in getCutLine would shift the expectation in lockstep with the bug and the assertion would still pass. The deleted test hardcoded a wantStores []*uint64 per case; only wantPruneBelow is hardcoded now.

Since every pruned store receives the same pruneHeight, the cheap fix is to add a hardcoded wantPruned []bool (or a wantSkipped []string) per case and assert against that instead of recomputing.

require.Nil(t, sm)
}

func TestNewStorageGarbageCollectorConstructAndClose(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] This constructs the collector with DefaultStorageGarbageCollectorConfig() (PruneInterval: 10 * time.Minute), so the ticker branch in run() never fires during the test — combined with TestCloseAfterContextCancelled, run()'s case <-ticker.C and its logger.Error path have no coverage at all. The PR description claims "Run-loop tests use a short PruneInterval to assert the ticker fires and that a prune error does not kill the loop," but those tests aren't in the diff.

Worth adding, since PruneInterval was changed to a time.Duration specifically to make this testable: a collector with PruneInterval: 10 * time.Millisecond over a store whose PruneBelow returns an error, asserting (a) pruneBelowCalled flips within a short require.Eventually, and (b) it is called more than once, i.e. the failure did not kill the loop. The atomic.Bool/atomic.Uint64 on mockStore are already there for exactly this.

logger.Info("pruning stores",
"globalLatestBlock", globalLatestBlock,
"pruneHeight", pruneHeight,
"pruningBoundaryByStore", describeAnswers(stores, pruningBoundaries),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] A deletion decision can't be fully reconstructed from this log line: cutLine is now per-store and is never logged, and describeAnswers prints 0 for three distinct outcomes — store opted out, store skipped because its cutLine was 0 (deep/infinite retention), and store inside its retain window. Consider logging each store's retention and computed cutLine alongside its boundary, or rendering the skipped cases as something other than 0 (e.g. sc=skipped(infinite)), so a post-mortem can distinguish "chose not to vote" from "was never asked."

Comment on lines +49 to +67
func NewStorageGarbageCollector(
ctx context.Context,
config *StorageGarbageCollectorConfig,
stores []PrunableStore,
) (*StorageGarbageCollector, error) {
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid storage garbage collector config: %w", err)
}

s := &StorageGarbageCollector{
config: config,
stores: stores,
ctx: ctx,
stopCh: make(chan struct{}),
}
s.wg.Add(1)
go s.run()
return s, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 NewStorageGarbageCollector accepts a nil context.Context without validation, and run() immediately dereferences it via s.ctx.Done() in a select — a nil interface method call panics on this background goroutine and takes the process down, uncatchable by the caller. Fix by rejecting nil ctx (or defaulting to context.Background()) alongside the existing config.Validate() check.

Extended reasoning...

NewStorageGarbageCollector (sei-db/management/gc/storage_garbage_collector.go:49-67) validates its config argument via config.Validate() and returns an error if it is invalid, but the ctx context.Context argument is stored into s.ctx completely unvalidated. The constructor then immediately does go s.run(), spawning a background goroutine before returning.

run() enters a for { select { ... case <-s.ctx.Done(): ... } } loop (line ~86). context.Context is an interface, so if the caller passes ctx = nil, s.ctx holds a nil interface value with no concrete type. Calling the Done() method on it has nothing to dispatch to, so it panics. Because this happens inside a goroutine spawned by the constructor rather than in the calling goroutine, the panic is unrecoverable by the caller — there is no way to recover() it from outside, and an unhandled panic in any goroutine crashes the entire process.

This is a real gap in the input validation this constructor already performs: it treats a bad config as a normal, catchable error (returned via the (nil, error) return signature) but treats a bad ctx as a fatal, ungraceful crash. The asymmetry is the actual bug — not that ctx is required, but that failing to provide one takes down the whole node instead of surfacing an error through the channel that already exists for exactly this purpose.

Step-by-step proof:

  1. A caller (hypothetically, once this is wired into production) calls NewStorageGarbageCollector(nil, cfg, stores), perhaps because an upstream context was not yet threaded through, or a nil was passed by mistake in a refactor.
  2. config.Validate() succeeds (config is fine), so no error is returned here.
  3. s.ctx = ctx stores the nil interface.
  4. go s.run() launches the goroutine and the constructor returns (s, nil) — from the callers point of view, construction succeeded.
  5. On the goroutine, run() sets up the ticker and hits select { case <-s.stopCh: ...; case <-s.ctx.Done(): ...; case <-ticker.C: ... }. Evaluating case <-s.ctx.Done() requires calling s.ctx.Done(), which panics immediately since s.ctx has no concrete type.
  6. The panic is not recovered anywhere in the goroutine or by the runtime, so it propagates up and terminates the whole process — not just the caller of NewStorageGarbageCollector.

None of the existing tests catch this because every test and every current caller passes a real context (typically context.Background()), and the PR description itself notes "nothing constructs a StorageGarbageCollector in production yet." That is exactly why this is low-impact today, but it is still a latent landmine on a constructor whose whole job is to fail cleanly on bad input.

Fix: add a matching guard, e.g. if ctx == nil { return nil, fmt.Errorf("context is required") }, or simply default a nil ctx to context.Background() before storing it — either keeps the failure mode (or absence of one) in the callers hands rather than crashing the process on a background goroutine. Multiple review bots on this PR (seidroid, cursor-adjacent review) independently flagged the identical line for the identical reason, which corroborates this is a straightforward, uncontested gap rather than a matter of interpretation.

I am marking this nit rather than normal: nothing in this PR or the current codebase constructs a StorageGarbageCollector with an untrusted or possibly-nil context — every call site and test passes context.Background() — so there is no live path to this panic today. It is defensive hardening on a not-yet-wired constructor, the same shape carried forward from the deleted predecessor. It is well worth a one-line fix before other packages start constructing this type in follow-up PRs, but it does not justify delaying this merge.

seidroid[bot]
seidroid Bot previously requested changes Jul 31, 2026

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PrunableStore collapse is a clean, well-documented refactor with strong table-driven coverage, but it silently drops the deleted code's explicit "an empty store is unknown, don't prune" guard: a store reporting head 0 is excluded from the global head and from the prune vote, so a store being bootstrapped alongside a populated WAL can have its replay source pruned away. Nothing is wired up yet, so there is no live impact, but this is the load-bearing semantics the PR is landing and the safety argument in the PR body does not hold for that case.

Findings: 1 blocking | 10 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.
  • 1 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • No test exercises run()'s ticker path. TestNewStorageGarbageCollectorConstructAndClose uses the 10-minute default PruneInterval, so the case <-ticker.C branch never fires and "a prune error does not kill the loop" is unverified. The PR description claims "Run-loop tests use a short PruneInterval to assert the ticker fires and that a prune error does not kill the loop" — those tests are not in the diff. Adding one with PruneInterval: 10*time.Millisecond and an atomic call counter is cheap; note the mock's plain fields (latestHeight, getErr, pruneErr) would then need to be race-safe.
  • The PR description is substantially out of sync with the code and should be updated before merge, since it is the durable design record for a change with no production callers yet. It describes StoreRetention on StorageGarbageCollectorConfig (default 100,000), GetOldestBlockToRetain, GetLastCommittedBlock, and Validate rejecting "a RollbackWindow + StoreRetention pair that overflows uint64". The code has per-store GetRetentionWindow(), GetPruningBoundary(), GetLatestBlock(), no StoreRetention field, and handles the overflow inside getCutLine (returning 0) rather than in Validate.
  • The Cursor second-opinion file (cursor-review.md) is empty — that pass produced no output. Codex (codex-review.md) reported no material issues.
  • The logger namespace changed from seilog.NewLogger("db", "management") to ("db", "gc"). Harmless, but worth mentioning to anyone with log filters or alerts keyed on the old subsystem tag.
  • Cleanups worth acknowledging: PruneIntervalSeconds uint64PruneInterval time.Duration removes the //nolint:gosec G115 suppression and the overflow branch it needed, Validate now handles a nil receiver, and the two unused placeholder.go files (sei-db/ledger_db/event, .../transaction) are gone with no remaining references.
  • 5 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/management/gc/storage_garbage_collector.go Outdated
// DefaultStorageGarbageCollectorConfig returns the default collector config.
func DefaultStorageGarbageCollectorConfig() *StorageGarbageCollectorConfig {
return &StorageGarbageCollectorConfig{
RollbackWindow: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] RollbackWindow drops 10× here (the old DefaultStorageGarbageCollectorConfig returned 10_000) and PruneIntervalSeconds: 60 becomes 10 * time.Minute. Neither is called out as a default change in the PR body — instead the body's config table implies a companion StoreRetention: 100_000 making effective retained history ~101,000 blocks, but no such field exists in the code and every store's default GetRetentionWindow() is expected to be 0.

So as landed the default retained history is 1,000 blocks rather than the old 10,000 or the described 101,000. Since RollbackWindow is the correctness knob ("blocks the system must remain able to roll back"), please either restore 10,000, or state in this comment why 1,000 is the right shared floor for Giga and that per-store retention is expected to supply the query/receipt history.

if pruneHeight == 0 {
logger.Info("skipping pruning: no store reported a pruning boundary",
"globalLatestBlock", globalLatestBlock,
"pruningBoundaryByStore", describeAnswers(stores, pruningBoundaries),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] describeAnswers renders 0 for two different decisions a reader cannot tell apart: "skipped before asking, because getCutLine returned 0" (infinite retention, or head inside the retain window — line 123) and "asked, and the store opted out" (line 129). The per-store cutLine and retention aren't logged at all, so when GC unexpectedly does nothing you can't tell which of the three causes it was.

The PR body's stated goal is that "a deletion decision can be reconstructed after the fact" — worth logging name=<boundary>|cut=<cutLine>|ret=<retention> per store, or distinct markers like sc=skipped(ret=-1) vs sc=optout. The deleted collector logged initial and final block ranges per store, which was more diagnosable than a bare number.

require.NoError(t, err)

for _, store := range tc.stores {
shouldPrune := false

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This block derives shouldPrune by re-running the code under test (getCutLine, GetPruningBoundary, and getGlobalLatestBlock for head above), so the "which stores got pruned" half of every case is self-consistent with whatever the implementation does — an inverted comparison in getCutLine would flip both actual and expected. The *tc.wantPruneBelow assertion is the real check and it is hardcoded, which is good. Consider also stating the expected pruned-store set in the table (e.g. wantPrunedStores []string) so the opt-out cases are pinned independently of the implementation.


"github.com/sei-protocol/sei-chain/sei-db/common/utils"
"github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock"
"github.com/sei-protocol/sei-chain/sei-db/management/gc"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This makes sei-db/config (a broadly imported, leaf-ish config package) depend on sei-db/management/gc, a runtime package that starts goroutines. It works today because gc imports only stdlib + seilog, but it inverts the usual direction and becomes an import cycle the moment gc needs anything from config.

The stated motivation — one source of truth for RollbackWindow — could also be met by having the wiring layer (whoever constructs both the stores and the collector) call gc.DefaultStorageGarbageCollectorConfig() and hold PruningConfig there, keeping GigaStorageConfig to store configs only. Not worth blocking on, but worth a deliberate decision now rather than after more code depends on it.

// gc.DefaultStorageGarbageCollectorConfig; per-store extras live on each
// PrunableStore via GetRetentionWindow.
type GigaStorageConfig struct {
HomePath string

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] Two small gaps vs. the sibling configs in this package:

  • HomePath is stored but never read; it duplicates information already baked into every path below it, so it can drift if a caller hand-builds the struct. Either drop it or document it as the record of what the paths were derived from.
  • There's no GigaStorageConfig.Validate(), while ReceiptStoreConfig, flatkvConfig.Config, littblock.LittBlockConfig, and gc.StorageGarbageCollectorConfig all have one. A Validate() fanning out to the nested configs (and rejecting a nil PruningConfig/FlatKVConfig/BlockDBConfig) would catch a hand-built config at the wiring site rather than at first use.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs, but this is a substantial refactor of the storage GC's pruning model (new PrunableStore interface, positional prune-boundary voting, new GigaStorageConfig) with several correctness subtleties the PR description itself calls out — worth a human pass given the amount of back-and-forth already in this thread.

Reviewed: the cut-line/prune-height math (getCutLine, getGlobalLatestBlock, the min-of-non-zero-answers logic) against the test matrix in storage_garbage_collector_test.go; the 0/negative-retention sentinel handling for opt-out and infinite-retention stores; and GigaStorageConfig's path composition against sei-db/common/utils. Checked that a GetLatestBlock error aborts the cycle before any PruneBelow call (ruled out as a fleet-wide-stall bug — it's the documented behavior, and it's the head-read step, not a per-store vote, so it's expected to be all-or-nothing).

Extended reasoning...

No bugs were found by the bug hunting system this run, and the one candidate issue raised (a failed GetLatestBlock on one store aborting the whole prune cycle) was correctly ruled out as intentional — it happens before any PruneBelow calls, so nothing is over- or under-pruned, and the PR description explicitly documents this as the intended fail-safe behavior.

That said, this PR is a genuine design change, not a mechanical refactor: it collapses two store interfaces into one PrunableStore, moves pruning policy into the stores themselves via a GetRetentionWindow/GetPruningBoundary contract, and changes defaults (RollbackWindow 10,000 → 1,000, prune interval 60s → 10m) relative to the code it replaces. The PR's own "Correctness notes for reviewers" section documents several load-bearing invariants (lowest-non-zero prune height, smallest-non-zero head, guarded cut-line subtraction, the safety argument for why a 0-answer store is safe to skip) that are subtle enough to warrant a human check, and the review thread already has extensive back-and-forth with a second bot (seidroid) raising numerous suggestion-level points — some addressed (the overflow guard in getCutLine, the GetPruningBoundary/GetLatestBlock naming), others still open as of this diff (e.g. no nil-ctx guard in NewStorageGarbageCollector, and the uniform-pruning tradeoff — one store's retention deepening the cut line for all others — is explained in code comments but not surfaced in the GigaStorageConfig/collector-level docs a future implementer would read first).

Nothing implements PrunableStore in production yet per the PR description, which caps the current blast radius, but this interface is exactly what SC/SS/blockDB/receiptDB/WAL adapters will be built against in follow-up PRs, so getting the contract right now matters more than usual. I'd rate the security risk as low (no auth/crypto/external-input handling here, just internal pruning arithmetic), but the level of scrutiny warranted is higher than a typical refactor given the number of correctness invariants at stake and the still-open suggestion-level threads.

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The PrunableStore unification is a clear simplification with unusually thorough unit tests, and I found no reachable correctness bug under the documented per-store contract. My notes are latent-trap and documentation issues: the cutLine == 0 skip silently removes a store from the shared prune vote (so "never prune this store" can still leave it unrestorable), the removed mid-write-snapshot protection has no escape hatch in the new interface, and two default values changed 10× without stated rationale.

Findings: 0 blocking | 10 non-blocking | 6 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Second-opinion passes added nothing to merge: cursor-review.md is empty (that pass produced no output), and codex-review.md reports "no material findings" while noting it could not run tests due to a network-blocked Go 1.25.6 toolchain download.
  • Nothing in the tree calls gc.NewStorageGarbageCollector or config.DefaultGigaStorageConfig yet, and no PrunableStore implementations exist. The collector's own logic is well covered by the new unit tests, but the load-bearing half of the contract — "contiguous stores return cutLine", "snapshot stores return the newest completed snapshot ≤ cutLine" — is exercised only by mocks. Worth confirming the follow-up that wires SC/SS/flatKV/blockDB/receiptDB/WAL in lands with per-store tests for those implementations.
  • I could not run go build, go test, gofmt -s -l, or make lint in this environment (sandbox/toolchain restrictions), so compilation and formatting were checked by inspection only. Every field and function the new code references was cross-checked against the tree and resolves.
  • Test nit: in the snapshotStore helper (storage_garbage_collector_test.go), the accumulator is named oldest but actually holds the newest snapshot ≤ cutLine (it only holds the oldest in the fallback case). Renaming it to something like boundary would make the helper match the contract it is modeling.
  • 6 suggestion(s)/nit(s) flagged inline on specific lines.

for i, store := range stores {
retention := store.GetRetentionWindow()
cutLine := getCutLine(globalLatestBlock, config.RollbackWindow, retention)
if cutLine == 0 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Skipping a store when cutLine == 0 removes it from the shared min vote entirely, which makes pruneHeight higher (more aggressive) for everyone else — not just "this store is left alone".

That is safe for a self-sufficient contiguous store, but it is unsafe for any store that depends on another store's history to be brought forward. Concretely: a snapshot store configured with InfiniteRetentionWindow (or any retention deep enough that head <= RollbackWindow + retention) is skipped and never votes; the contiguous state WAL still answers cutLine and gets PruneBelow(cutLine). The WAL segments needed to replay that store's retained snapshots forward are now gone, so "never prune this store" has quietly rendered the store unusable rather than protected it.

This is not reachable under the contract documented in api.go (SC/SS always return 0, so only contiguous stores carry non-zero retention), but the interface permits it and nothing validates it. Two options: state explicitly on GetRetentionWindow that only self-sufficient/contiguous stores may return non-zero, or have a skipped store still contribute a floor to the vote so it cannot be undercut.

//
// Assumption: snapshot creation finishes quickly enough that an in-flight write need
// not be reserved; modeling long-running snapshot writes is out of scope.
GetPruningBoundary(cutLine uint64) uint64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] The "no completed snapshot → return 0" rule drops a protection the deleted code had on purpose. Previously an empty snapshot store aborted the whole cycle, with the reason spelled out inline: "An empty store is treated as unknown rather than empty (a snapshot may be mid-write and take hours)." Now the other stores prune freely, so a WAL can be pruned to cutLine while a snapshot for a much lower height is still landing — that snapshot cannot then be replayed forward, and the node is stuck.

The PR body scopes this out on the assumption that snapshot writes complete within seconds, which is a reasonable call. The gap is that the interface offers no escape hatch for an implementer who is in that state: returning 0 here lets others prune, and returning InfiniteRetentionWindow from GetRetentionWindow doesn't help either, because a skipped store still doesn't constrain the WAL (see my note on storage_garbage_collector.go). Since GetRetentionWindow is re-read every cycle, a bootstrapping/mid-write store could signal dynamically — but only if the skip path is fixed to keep constraining others. Worth documenting whichever answer you intend, so store authors don't pick the unsafe one by default.

// DefaultStorageGarbageCollectorConfig returns the default collector config.
func DefaultStorageGarbageCollectorConfig() *StorageGarbageCollectorConfig {
return &StorageGarbageCollectorConfig{
RollbackWindow: 1000,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Two defaults change here relative to the deleted config, and neither change is explained in the code or the PR body: RollbackWindow 10,000 → 1,000 and the prune cadence 60s → 10m.

The 10× cut to RollbackWindow is the one worth justifying — it is the correctness knob this whole type exists to defend, and at ~400ms blocks 1,000 blocks is only ~7 minutes of rollback headroom, i.e. shorter than the new 10-minute PruneInterval. (The longer interval is itself conservative — it retains more, not less — so the interaction is safe; it just means the effective retained window fluctuates between ~1,000 and ~2,500 blocks rather than sitting at the configured value.) A sentence on the field saying why 1,000 is sufficient for Giga would keep the next reader from treating it as a typo.

// Heads of 0 are skipped rather than treated as "unknown". Note this excludes from the min
// exactly the store that holds nothing, so the min-head argument does not protect that
// store; what protects it is the store-set precondition on StorageGarbageCollector.
func getGlobalLatestBlock(stores []PrunableStore) (uint64, error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] A single GetLatestBlock error aborts the entire cycle, including when it comes from a store that could never have participated (GetRetentionWindow() < 0). That inverts the policy chosen a few lines up for PruneBelow, where failures are joined precisely so "one unhealthy store must not block pruning of the others". Consider logging and skipping a store whose head cannot be read rather than returning, or at least reading heads only from stores that will actually vote.

if retention < 0 {
return 0
}
totalRetainWindow := rollbackWindow + uint64(retention)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] uint64(retention) is an int64→uint64 conversion, which is what gosec G115 targets. The guard on the previous line makes it provably safe, but the code this PR deletes carried an explicit //nolint:gosec for its numeric conversion, so it is worth confirming with make lint that G115's range analysis credits the retention < 0 early return here — otherwise CI lint fails on a false positive.

// they only matter for the composite path, which Giga does not use.
//
// Unlike the other Default*Config helpers in this package, this can fail: the block-store
// default wraps littdb.DefaultConfig, which validates the directory path.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[nit] This comment isn't accurate: littdb.DefaultConfig doesn't validate the directory path, it only errors when len(paths) == 0. Since utils.GetBlockStorePath is a plain filepath.Join that always returns a non-empty string, the error branch below is unreachable — so DefaultGigaStorageConfig returns (GigaStorageConfig, error), and forces every caller to handle an error, for a condition that cannot occur. Either reword to say what actually fails, or drop the error from the signature.

@seidroid
seidroid Bot dismissed their stale review August 1, 2026 01:00

Superseded: latest AI review found no blocking issues.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any new bugs beyond what's already been raised inline, but given the scope and safety-sensitivity of this refactor I think it's worth a human sign-off before merging.

What was reviewed: the new PrunableStore interface and prune() decision algorithm in sei-db/management/gc (cut-line/overflow math, zero-head handling, duplicate-name safety), the config default changes in StorageGarbageCollectorConfig, and the new GigaStorageConfig path composition. The zero-head data-loss scenario raised earlier was addressed via documentation of the store-set precondition rather than a code-level guard, and the author's follow-up ("this situation will never happen in production") is an assertion rather than an enforced invariant.

Extended reasoning...

Overview

This PR replaces the two-interface (SnapshotStore/StreamStore) pruning design with a single PrunableStore interface and rewrites the collector's prune algorithm around a shared cut-line/prune-height computation. It also moves the package from sei-db/management to sei-db/management/gc and adds a new GigaStorageConfig composing store paths. Nothing implements PrunableStore in production yet — this is preparatory interface/algorithm work for a following PR.

Security risks

No injection/auth/crypto surface here. The relevant risk class is data loss / correctness: an error in the pruning cut-line or head computation can cause the garbage collector to delete blocks a store still needs, breaking the chain's rollback invariant or leaving a store permanently unable to bootstrap. This is exactly the kind of bug that would not surface until a specific store lags or starts cold in production.

Level of scrutiny

This warrants high scrutiny despite not being wired into production yet, because the interface contract and default behavior decided here (zero-head skipping, per-store retention semantics, RollbackWindow default) will be inherited unchanged by every store adapter built on top of it in follow-up PRs. Getting the contract right now is much cheaper than fixing it after SC/SS/blockDB/receiptDB/WAL adapters exist.

Other factors

The PR already went through several rounds of substantive review (seidroid, Cursor, and prior automated findings) covering an overflow guard on the cut-line math, misspelled interface methods, and a flagged data-loss scenario for stores with a zero ingested head (empty/still-bootstrapping store). Most of these were fixed; the zero-head scenario was resolved by documenting a precondition (no store bootstraps from another store's history) plus the author's assertion that the scenario can't occur in production, rather than an enforced check. Test coverage is thorough (a hardcoded decision matrix, overflow cases, duplicate-name regression), which increases my confidence in the algorithm as specified, but doesn't cover whether the stated preconditions will actually hold once real stores are wired in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants