Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sei-db/config/giga_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type GigaStorageConfig struct {
FlatKVConfig *flatkvConfig.Config
SSConfig StateStoreConfig
ReceiptDBConfig ReceiptStoreConfig
BlockDBConfig *littblock.LittBlockConfig
BlockDBConfig *littblock.BlockDBConfig
PruningConfig *gc.StorageGarbageCollectorConfig
}

Expand Down
18 changes: 18 additions & 0 deletions sei-db/config/receipt_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,24 @@ type ReceiptStoreConfig struct {
// default to every 600 seconds
PruneIntervalSeconds int `mapstructure:"prune-interval-seconds"`

// ExternalPruning hands retention to the StorageGarbageCollector: the littidx backend
// stops running its own KeepRecent pruner and answers gc.PrunableStore.ExternalPruning
// with this value, so the collector prunes it instead.
//
// Like KeepRecent this is not read from the receipt-store config. It is set by whatever
// constructs the collector, because it is only correct when this store is actually
// registered with a running one — nothing here can check that, and the failure is silent
// and unbounded: the retention floor simply stops advancing.
//
// It is one field rather than two so the KeepRecent pruner and the collector can never
// both be enforcing retention. They would disagree, and the local pruner would win by
// deleting the rollback headroom the collector exists to preserve.
//
// Only the littidx backend honors this. The pebbledb backend is not a gc.PrunableStore,
// so the collector would not prune it and setting this would leave it with no pruner at
// all; newReceiptBackend rejects that combination rather than growing without bound.
ExternalPruning bool `mapstructure:"-"`

// EnableReadWriteMetrics emits simple estimated read/write counters for Pebble-backed receipt storage.
// defaults to false
EnableReadWriteMetrics bool `mapstructure:"enable-read-write-metrics"`
Expand Down
6 changes: 6 additions & 0 deletions sei-db/config/receipt_config_fuzz_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,5 +202,11 @@ func TestManifestNamesEveryField(t *testing.T) {
// sitting in a config struct that configuration cannot address is exactly the kind of
// thing a replacement manager would otherwise try to map a key onto.
"KeepRecent",
// ExternalPruning is tagged mapstructure:"-" for a sharper reason than KeepRecent: it is

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 states as fact that ExternalPruning is tagged mapstructure:"-", but receipt_config.go:74 tags it mapstructure:"external-pruning". Per testutil/configtest/AGENTS.md these manifest exclusions are the recorded contract a replacement implementation reads, so a comment that misdescribes the tag is the specific drift the suite is meant to prevent. Fix the tag (preferred) or the comment.

// only correct when this store is registered with a running StorageGarbageCollector, which
// is a property of how the process was wired and not something an operator can assert from
// app.toml. Exposing a key for it would let a node stand its pruner down with nothing to
// replace it, and the resulting unbounded growth is silent.
"ExternalPruning",
)
}
1 change: 1 addition & 0 deletions sei-db/config/testdata/receipt_store.golden
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ Backend = string("pebbledb")
AsyncWriteBuffer = int(100)
KeepRecent = int(0)
PruneIntervalSeconds = int(600)
ExternalPruning = bool(false)
EnableReadWriteMetrics = bool(false)
LogFilterParallelism = int(16)
1 change: 1 addition & 0 deletions sei-db/config/toml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func TestStateCommitConfigTemplate(t *testing.T) {
require.NotContains(t, flatKVSection, "snapshot-interval", "FlatKV snapshot-interval should not be exposed in app.toml")
require.NotContains(t, flatKVSection, "snapshot-keep-recent", "FlatKV snapshot-keep-recent should not be exposed in app.toml")
require.NotContains(t, flatKVSection, "enable-read-write-metrics", "FlatKV read/write metrics flag should not be exposed in app.toml")
require.NotContains(t, flatKVSection, "external-pruning", "FlatKV external-pruning should not be exposed in app.toml")

// sc-snapshot-writer-limit is intentionally removed from template (hardcoded to 4)
// but old configs with this field still parse fine via mapstructure
Expand Down
2 changes: 1 addition & 1 deletion sei-db/ledger_db/block/block_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1402,7 +1402,7 @@ func TestMemblockPruneIntoCohortRoundsDown(t *testing.T) {

// littConfig builds a littblock config rooted at dir with a tiny retention so
// the prune watermark is the sole observable reclamation gate in tests.
func littConfig(t *testing.T, dir string) *littblock.LittBlockConfig {
func littConfig(t *testing.T, dir string) *littblock.BlockDBConfig {
cfg, err := littblock.DefaultConfig(dir)
require.NoError(t, err)
cfg.Retention = time.Nanosecond
Expand Down
43 changes: 35 additions & 8 deletions sei-db/ledger_db/block/littblock/litt_block_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import (
"time"

littdb "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt"
"github.com/sei-protocol/sei-chain/sei-db/management/gc"
)

// LittBlockConfig configures a LittDB-backed types.BlockDB.
type LittBlockConfig struct {
// BlockDBConfig configures a LittDB-backed types.BlockDB.
type BlockDBConfig struct {
// Litt is the underlying LittDB configuration, including the data directory
// paths. The block store builds its two tables (blocks, qcs) on top of this
// DB. Required; use DefaultConfig to obtain one with sane defaults, then
Expand All @@ -20,23 +21,45 @@ type LittBlockConfig struct {
// watermark to advance past the record, so even an over-eager watermark
// cannot delete data younger than Retention. Must be positive.
Retention time.Duration

// RetentionWindow is how much history this store keeps beyond the shared rollback
// window of the StorageGarbageCollector that manages it, in blocks. It is what
// gc.PrunableStore.GetRetentionWindow answers:
//
// > 0 → that many blocks of history beyond the rollback window
// 0 → keep history to serve rollback window only
// -1 → never prune this store (gc.InfiniteRetentionWindow)
//
// Zero does NOT mean "keep everything" here, unlike the KeepRecent fields on
// StateStoreConfig and ReceiptStoreConfig, where 0 disables pruning. It is the most
// aggressive setting this field has; "keep everything" is -1. Assigning a KeepRecent
// value to this field inverts the retention it asks for.
//
// This is an input to a minimum shared across every managed store, not a policy applied
// to this store alone: a deep window here also holds back receiptDB and the SC/SS
// snapshots. Must be >= gc.InfiniteRetentionWindow.
//
// Independent of Retention, which is a wall-clock TTL failsafe underneath the watermark.
// Both must permit reclamation before any record is dropped.
RetentionWindow int64

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.

Might be useful to call out the following invariants:

Storage garbage collection guarantees the following invariants:

1. Garbage collection will not delete any data that is necessary to roll back to any block 
   between LatestBlock and (LatestBlock - RollbackWindow), inclusive.
2. Garbage collection will not delete block DB data that is before 
   (LatestBlock - RollbackWindow - RetentionWindow). This ensures that even if the 
   system rolls back to block (LatestBlock - RollbackWindow), it is still possible to read any
   block from the last RetentionWindow blocks.
3. Garbage collection will eventually delete block data older than 
   (LatestBlock - RollbackWindow - RetentionWindow).

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.

I suggest we paste this block of invariants at each RetentionWindow or RollbackWindow config (with wording adjusted a little, the above is specific to block storage).

}

// DefaultConfig returns a LittBlockConfig preloaded with all defaults, rooted at
// DefaultConfig returns a BlockDBConfig preloaded with all defaults, rooted at
// dir. Override fields as needed, then pass it to NewBlockDB (which validates).
func DefaultConfig(dir string) (*LittBlockConfig, error) {
func DefaultConfig(dir string) (*BlockDBConfig, error) {
littConfig, err := littdb.DefaultConfig(dir)
if err != nil {
return nil, fmt.Errorf("failed to build litt config: %w", err)
}
return &LittBlockConfig{
Litt: littConfig,
Retention: 24 * time.Hour,
return &BlockDBConfig{
Litt: littConfig,
Retention: 24 * time.Hour,
RetentionWindow: 10000,
Comment thread
yzang2019 marked this conversation as resolved.
Outdated

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 default is a fleet-wide policy in a per-store field. As the RetentionWindow doc two dozen lines up says, the value is an input to a shared minimum — so 10000 here pushes pruneHeight 10k blocks deeper for ReceiptDB, the state WAL and the SC snapshots too, not just for BlockDB. Two things worth reconsidering: (a) whether the default should be 0 and the extra depth expressed once as RollbackWindow in StorageGarbageCollectorConfig, and (b) that AutobahnBlockDBConfig.LittBlockConfig exposes Retention but not RetentionWindow, so once wired there is no way to tune this from tendermint config.

}, nil
}

// Validate performs a sanity check on the configuration.
func (c *LittBlockConfig) Validate() error {
func (c *BlockDBConfig) Validate() error {
if c == nil {
return fmt.Errorf("config is required")
}
Expand All @@ -46,5 +69,9 @@ func (c *LittBlockConfig) Validate() error {
if c.Retention <= 0 {
return fmt.Errorf("config.Retention must be positive (got %s)", c.Retention)
}
if c.RetentionWindow < gc.InfiniteRetentionWindow {
return fmt.Errorf("config.RetentionWindow must be >= %d (got %d)",
gc.InfiniteRetentionWindow, c.RetentionWindow)
}
return nil
}
23 changes: 14 additions & 9 deletions sei-db/ledger_db/block/littblock/litt_block_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,22 @@ import (
"github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils"
)

// ledgerTableName is the single table holding both blocks and QCs. They share
// one table so a crash leaves a contiguous write-order prefix spanning both
// record kinds (see NewBlockDB), which is what guarantees a persisted block is
// always covered by a persisted QC.
const ledgerTableName = "ledger"
// tableName is the single table holding blocks and QCs both, despite the name. They share one
// table so a crash leaves a contiguous write-order prefix spanning both record kinds (see
// NewBlockDB), which is what guarantees a persisted block is always covered by a persisted QC.
//
// This value is persisted layout, not just an identifier: littdb puts a table's data at
// <root>/<tableName>/segments, so changing it makes NewBlockDB open a fresh empty table while the
// old data sits untouched under the previous name — neither served nor reclaimed.
const tableName = "blocks"

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 comment you added here states the hazard precisely — a rename makes NewBlockDB open a fresh empty table while the old data sits under <root>/ledger/ "neither served nor reclaimed" — and then the rename is performed anyway, relying on "BlockDB is not deployed on any network yet" from the PR description. That holds for mainnet, but any dev/CI/devnet home directory carrying a ledger/ table silently comes up empty rather than failing, which is the worst shape for the one class of environment where it can happen.

Since the check is cheap and the comment already argues for it: os.Stat(<root>/ledger) at open and refuse to start (or log loudly) if it exists. That turns a silent empty store into a one-line operator action, and can be deleted once no such directories remain.

Comment thread
yzang2019 marked this conversation as resolved.

var _ types.BlockDB = (*blockDB)(nil)

// blockDB is a durable types.BlockDB backed by LittDB
type blockDB struct {
db littdb.DB
table littdb.Table
db littdb.DB
table littdb.Table
config *BlockDBConfig

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 field is assigned once (s.config = config, line 126) and never read anywhere in the package — grep '\.config' across littblock/*.go (non-test) returns only the assignment. The doc comments in litt_block_gc.go refer to config.RetentionTime, but the value that actually gates reclamation is the TTL already handed to littdb.DefaultTableConfig. unused isn't enabled in .golangci.yml, so nothing catches it. Drop the field and the assignment, or read it where the docs imply it is read.

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] config is stored (s.config = config at line 126) but never read anywhere in the package — grep -rn '\.config' sei-db/ledger_db/block/littblock/ returns only the assignment. config.RetentionTime is consumed at construction time via tableConfig.TTL, so the retained pointer does no work.

Struct fields aren't caught by the unused linter, so this will sit here indefinitely. Either drop the field, or if it's groundwork for a follow-up, say so in a comment. Note the doc comments in litt_block_gc.go refer to "config.RetentionTime" as though this field were the source (PruneHistory, ExternalPruning), which reads as if it's live.


// watermark is a retention floor, always a QC boundary (a GlobalRange().First):
// PruneBefore rounds a requested prune point down to the start of the cohort
Expand Down Expand Up @@ -77,7 +81,7 @@ type blockDB struct {
// underlying LittDB is built from config.Litt, and the two tables apply
// config.Retention as a TTL failsafe (pruning never reclaims data younger than
// that even once the watermark has advanced past it).
func NewBlockDB(config *LittBlockConfig) (types.BlockDB, error) {
func NewBlockDB(config *BlockDBConfig) (types.BlockDB, error) {
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid block db config: %w", err)
}
Expand All @@ -96,7 +100,7 @@ func NewBlockDB(config *LittBlockConfig) (types.BlockDB, error) {
// guarantees a persisted block is always covered by a persisted QC. It also
// backs the write-order cursors and contiguous-QC recovery. ShardingFactor
// > 1, or splitting blocks and QCs across two tables, would void this.
tableConfig := littdb.DefaultTableConfig(ledgerTableName)
tableConfig := littdb.DefaultTableConfig(tableName)
tableConfig.TTL = config.Retention
tableConfig.GCFilter = s.gcFilter
tableConfig.ShardingFactor = 1 // DO NOT CHANGE!!
Expand All @@ -107,6 +111,7 @@ func NewBlockDB(config *LittBlockConfig) (types.BlockDB, error) {
}

s.table = table
s.config = config

if err := s.recoverCursors(); err != nil {
_ = db.Close()
Expand Down
77 changes: 77 additions & 0 deletions sei-db/ledger_db/block/littblock/litt_block_gc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package littblock

import (
"github.com/sei-protocol/sei-chain/sei-db/management/gc"
"github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types"
)

// blockDB joins the shared prune cycle as a contiguous store: everything from its retention
// floor up to its head is retained, so it can serve a rollback to any height in between. The
// collector owns the decision of how deep to prune; PruneBefore stays the direct entry point
// for callers holding a types.BlockDB.
var _ gc.PrunableStore = (*blockDB)(nil)

func (s *blockDB) Name() string {
return "BlockDB"
}

// ExternalPruning is unconditionally true: this store has no pruner of its own for the collector to
// collide with. LittDB's GC reclaims what PruneBefore has already released, on a config.Retention
// timer, so it enforces no retention policy — it only carries out one this store has recorded.
func (s *blockDB) ExternalPruning() bool {
return true
}

// PruneBelow advances the retention watermark to blockNumber. It only records the watermark;
// reclamation happens on LittDB's own GC schedule and no earlier than config.Retention (see
// PruneBefore).
//
// blockNumber is a minimum shared across every managed store, so it may sit above this store's
// own head — a store that ingests ahead of blockDB pulls the head up, and a QC boundary can
// leave the newest retained cohort below it. PruneBefore caps the request at the newest
// retained (block, QC) pair, which is what keeps the store from emptying itself here.
func (s *blockDB) PruneBelow(blockNumber uint64) error {
return s.PruneBefore(types.GlobalBlockNumber(blockNumber))
}

// GetRetentionWindow reports the configured history beyond the collector's shared rollback
// window. See BlockDBConfig.RetentionWindow for the meaning of each value, and note that it is
// an input to a fleet-wide minimum rather than a policy applied to this store alone.
func (s *blockDB) GetRetentionWindow() int64 {
if s.config.RetentionWindow < 0 {
return gc.InfiniteRetentionWindow
}
return s.config.RetentionWindow
}

// GetPruningBoundary returns cutLine, the contract's answer for a contiguous store: every block
// at or above the watermark is retained, so cutLine itself is restorable and nothing below it
// has to be held back.
//
// Unconditional on purpose. A store whose floor already sits above cutLine — bootstrapped
// mid-chain, or pruned there by an earlier cycle — still answers cutLine, because the
// PruneBefore that follows is a no-op on it while a lower answer would hold back every other
// store. CannotServeRollback is never right here: this store fills from its own ingest path,
// so it has no replay range for another store's data to protect.
func (s *blockDB) GetPruningBoundary(cutLine uint64) uint64 {
return cutLine
}

// GetLatestBlock returns the newest block number written, or 0 when none has been.
//
// Global block numbers start at genesis block 0, so a store holding only that block is
// indistinguishable from an empty one and is excluded from the collector's head. That is the
// safe direction: it drops out of the head minimum rather than dragging every store's cut line
// to 0, and the prune it then receives is capped by PruneBefore to a no-op.
//
// Reports the written cursor, not the flushed one. A block that a crash would lose still counts
// as ingested — recovery re-derives this cursor from what survived, so the head can only move
// back, never past a prune that was already issued.
func (s *blockDB) GetLatestBlock() (uint64, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.hasBlocks {
return 0, nil
}
return uint64(s.lastBlockNumber), nil
}
Loading