Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions app/testdata/state-commit.golden
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ FlatKVConfig.Fsync = bool(false)
FlatKVConfig.AsyncWriteBuffer = int(0)
FlatKVConfig.SnapshotInterval = uint32(10000)
FlatKVConfig.SnapshotKeepRecent = uint32(1)
FlatKVConfig.ExternalPruning = bool(false)
FlatKVConfig.EnablePebbleMetrics = bool(true)
FlatKVConfig.EnableReadWriteMetrics = bool(false)
FlatKVConfig.AccountDBConfig.DataDir = string("")
Expand Down
1 change: 1 addition & 0 deletions sei-cosmos/server/config/testdata/server_config.golden
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ StateCommit.FlatKVConfig.Fsync = bool(false)
StateCommit.FlatKVConfig.AsyncWriteBuffer = int(0)
StateCommit.FlatKVConfig.SnapshotInterval = uint32(10000)
StateCommit.FlatKVConfig.SnapshotKeepRecent = uint32(1)
StateCommit.FlatKVConfig.ExternalPruning = bool(false)
StateCommit.FlatKVConfig.EnablePebbleMetrics = bool(true)
StateCommit.FlatKVConfig.EnableReadWriteMetrics = bool(false)
StateCommit.FlatKVConfig.AccountDBConfig.DataDir = string("")
Expand Down
7 changes: 3 additions & 4 deletions sei-db/config/giga_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,14 @@ import (
// callers build it via DefaultGigaStorageConfig. Nested knobs live on the store and
// collector configs they already own (StateStoreConfig, ReceiptStoreConfig,
// gc.StorageGarbageCollectorConfig) rather than being redeclared here — in particular
// RollbackWindow has a single source of truth in
// gc.DefaultStorageGarbageCollectorConfig; per-store extras live on each
// PrunableStore via GetRetentionWindow.
// RollbackWindow and LookbackWindow have a single source of truth in
// gc.DefaultStorageGarbageCollectorConfig, and cover every managed store at once.
type GigaStorageConfig struct {
HomePath string
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 @@ -206,6 +206,12 @@ 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",
)
}

Expand Down
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
4 changes: 2 additions & 2 deletions sei-db/ledger_db/block/block_db_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1402,10 +1402,10 @@ 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
cfg.RetentionTime = time.Nanosecond
return cfg
}

Expand Down
2 changes: 1 addition & 1 deletion sei-db/ledger_db/block/blocksim/blocksim.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,7 @@ func openBlockDB(config *BlocksimConfig) (types.BlockDB, error) {
if err != nil {
return nil, fmt.Errorf("failed to build litt block db config: %w", err)
}
littConfig.Retention = time.Duration(config.LittRetentionSeconds) * time.Second
littConfig.RetentionTime = time.Duration(config.LittRetentionSeconds) * time.Second
// Record litt_* metrics into blocksim's already-configured global OTel MeterProvider (set up in
// main before the DB is opened). MetricsServeEndpoint stays false so LittDB does not stand up its
// own registry/server; the metrics surface on blocksim's single /metrics endpoint.
Expand Down
39 changes: 23 additions & 16 deletions sei-db/ledger_db/block/littblock/litt_block_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,44 +7,51 @@ import (
littdb "github.com/sei-protocol/sei-chain/sei-db/db_engine/litt"
)

// 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
// override fields as needed (e.g. Litt.Fsync, Litt.GCPeriod).
// paths. The block store builds its single table (see tableName, which holds
// blocks and QCs both) on top of this DB. Required; use DefaultConfig to obtain
// one with sane defaults, then override fields as needed (e.g. Litt.Fsync,
// Litt.GCPeriod).
Litt *littdb.Config

// Retention is the failsafe minimum age before any pruned record may be
// RetentionTime is the failsafe minimum age before any pruned record may be
// reclaimed. Reclamation requires BOTH this age to elapse AND the prune
// watermark to advance past the record, so even an over-eager watermark
// cannot delete data younger than Retention. Must be positive.
Retention time.Duration
// cannot delete data younger than RetentionTime. Must be positive.
//
// It is an age floor, not a retention policy: how much history this store keeps
// is the RollbackWindow and LookbackWindow on
// gc.StorageGarbageCollectorConfig, which cover every managed store at once.
// Raising this only delays reclaiming what the watermark has already released,
// which costs disk and buys nothing those windows do not already express.
RetentionTime time.Duration
}

// 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,
RetentionTime: time.Hour,

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 drops 24h → 1h, shrinking by 24× the failsafe the field doc describes as protecting against an over-eager watermark ("even an over-eager watermark cannot delete data younger than RetentionTime"). The PR description explains at length why the receipt TTL became a flat duration and why littTTLPerBlock had to go, but says only that both "default to 1 hour" — it never addresses why BlockDB's existing margin was cut.

Note this also flows into sei-tendermint: AutobahnBlockDBConfig.LittBlockConfig starts from DefaultConfig, so any autobahn node that does not set Retention explicitly picks up the new value (node/setup_test.go re-pins it). Given BlockDB is not deployed on a network the blast radius is dev/CI/devnet, but the reasoning belongs in the field doc alongside the invariant it weakens.

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 BlockDB default TTL failsafe drops from 24 * time.Hour to time.Hour — a 24× reduction — and sei-tendermint/node/setup_test.go was updated to match rather than the change being gated behind the collector.

BlockDB is not wired to a StorageGarbageCollector in this PR, so on the autobahn path today the only effect is that records the watermark has already released get reclaimed up to 23 hours sooner. That's defensible given the watermark is authoritative and this is only a failsafe, but it is a live behavior change on a shipping path, and it shrinks the window in which an operator could notice and recover from an over-eager watermark from a day to an hour.

The PR body mentions "defaulting to 1 hour" only in the context of the flat-duration rework; worth calling out explicitly as a default change (and confirming 1h is intended for the pre-collector world, not just the collector-managed one).

}, 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")
}
if c.Litt == nil {
return fmt.Errorf("config.Litt is required")
}
if c.Retention <= 0 {
return fmt.Errorf("config.Retention must be positive (got %s)", c.Retention)
if c.RetentionTime <= 0 {
return fmt.Errorf("config.RetentionTime must be positive (got %s)", c.RetentionTime)
}
return nil
}
74 changes: 61 additions & 13 deletions sei-db/ledger_db/block/littblock/litt_block_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package littblock

import (
"fmt"
"os"
"path/filepath"
"sync"
"sync/atomic"

Expand All @@ -12,18 +14,27 @@ 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. refuseLegacyTable
// turns that into a startup error rather than a store that looks healthy and empty.
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.


// legacyTableName is what tableName was called before the rename. Nothing opens it; it exists only
// so refuseLegacyTable can recognize a directory written before the rename.
const legacyTableName = "ledger"

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 @@ -74,13 +85,18 @@ type blockDB struct {
}

// NewBlockDB opens (or creates) a LittDB-backed types.BlockDB from config. The
// 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) {
// underlying LittDB is built from config.Litt, and the table applies
// config.RetentionTime as a TTL failsafe (pruning never reclaims data younger
// than that even once the watermark has advanced past it).
func NewBlockDB(config *BlockDBConfig) (types.BlockDB, error) {
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid block db config: %w", err)
}
// Before littbuilder.NewDB, so a refused open leaves the directory exactly as it found it
// rather than adding an empty table beside the one it is complaining about.
if err := refuseLegacyTable(config.Litt.Paths); err != nil {
return nil, err
}
db, err := littbuilder.NewDB(config.Litt)
if err != nil {
return nil, fmt.Errorf("failed to open litt db: %w", err)
Expand All @@ -96,8 +112,8 @@ 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.TTL = config.Retention
tableConfig := littdb.DefaultTableConfig(tableName)
tableConfig.TTL = config.RetentionTime
tableConfig.GCFilter = s.gcFilter
tableConfig.ShardingFactor = 1 // DO NOT CHANGE!!
table, err := db.BuildTable(tableConfig)
Expand All @@ -107,6 +123,7 @@ func NewBlockDB(config *LittBlockConfig) (types.BlockDB, error) {
}

s.table = table
s.config = config

if err := s.recoverCursors(); err != nil {
_ = db.Close()
Expand All @@ -119,6 +136,37 @@ func NewBlockDB(config *LittBlockConfig) (types.BlockDB, error) {
return s, nil
}

// refuseLegacyTable fails the open when any root path holds a table directory under
// legacyTableName. Such a directory is blocks and QCs that this process cannot reach: littdb
// resolves a table to <root>/<tableName>/segments, so the data is neither served nor reclaimed, and
// the store would otherwise present itself as healthy and empty. An empty store is indistinguishable
// from a correct one until something asks for history that is no longer there, which makes it the
// worst shape this failure could take.
//
// No deployment can reach this — nothing has ever run littblock with the old name persisted — so it
// exists for dev, CI, and devnet homes written before the rename. The operator action is to delete
// the directory (or move it aside); this check can be deleted once no such directory remains.
//
// A root that cannot be stat'd is refused for the same reason it is refused when the directory is
// present: an unreadable root cannot rule out data hiding under the old name.
func refuseLegacyTable(paths []string) error {
for _, root := range paths {
legacy := filepath.Join(root, legacyTableName)
switch _, err := os.Stat(legacy); {
case err == nil:
return fmt.Errorf(
"block db: found a pre-rename %q table at %s; the table is now named %q, so those "+
"blocks and QCs would be neither served nor reclaimed. Delete or move the "+
"directory aside to start from an empty store",
legacyTableName, legacy, tableName)
case !os.IsNotExist(err):
return fmt.Errorf("block db: check for a pre-rename %q table at %s: %w",
legacyTableName, legacy, err)
}
}
return nil
}

// recoverCursors reloads the write-order cursors (lastBlockNumber, lastQCNext,
// and their presence flags) from on-disk state. Without this, a reopened DB
// would treat itself as empty and let WriteBlock/WriteQC silently accept
Expand Down
91 changes: 91 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,91 @@
package littblock

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

// In terms of the collector's RollbackWindow and LookbackWindow, garbage collection guarantees:
//
// 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 at or after
// (LatestBlock - RollbackWindow - LookbackWindow). This ensures that however far the system
// rolls back inside the rollback window, it is still possible to read at least LookbackWindow
// blocks of history below wherever it landed.
// 3. Garbage collection will eventually delete block data older than
// (LatestBlock - RollbackWindow - LookbackWindow).
//
// Eventually, in guarantee 3, because PruneHistory only records a watermark: reclamation also
// waits for BlockDBConfig.RetentionTime and for LittDB's own GC to come round.
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.RetentionTime timer, so it enforces no retention policy — it only carries out one this
// store has recorded.
func (s *blockDB) ExternalPruning() bool {
return true
}

// PruneHistory advances the retention watermark to blockNumber. It only records the watermark;
// reclamation happens on LittDB's own GC schedule and no earlier than config.RetentionTime (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) PruneHistory(blockNumber uint64) error {
return s.PruneBefore(types.GlobalBlockNumber(blockNumber))
}

// PruneSnapshots does nothing: blockDB keeps no snapshots. It restores by reading the blocks it
// holds, so its whole retention story is the watermark PruneHistory moves.
func (s *blockDB) PruneSnapshots(uint64) error {
return nil
}

// GetRollbackFloor returns head - rollbackWindow, the contract's answer for a contiguous store:
// every block from its floor to its head is retained, so that height is restorable directly and
// nothing below it has to be held back. It keeps no snapshots, so there is nothing to resolve the
// window against beyond its own head.
//
// It reports against its own head even when that runs ahead of the fleet's, because the collector
// takes a minimum across stores. A lagging store therefore sets the depth, and answering high here
// cannot prune anything out from under it.
//
// 0 when the window is deeper than the whole history — including the empty store, whose head is 0.
// Nothing here is eligible for pruning yet: the rollback owed reaches past genesis, so no part of the
// history can be given up until the head clears the window. Nothing is logged from here; the
// collector logs every store's answer each cycle.
func (s *blockDB) GetRollbackFloor(rollbackWindow uint64) uint64 {
head, err := s.GetLatestBlock()
if err != nil || head <= rollbackWindow {
return 0 // cannot say what it holds, so nothing may be dropped anywhere
}
return head - rollbackWindow
}

// 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. That is the safe direction: GetRollbackFloor then answers 0,
// which holds the fleet's history where it is, and the prune it 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
Loading