Skip to content

Commit 1f1542e

Browse files
feat(data): integrate LittDB-backed BlockDB into autobahn data layer (CON-272) (#3707)
## Summary - Replaces `DataWAL` (two WAL files) with a LittDB-backed `BlockDB` for block and QC persistence - `NewState(cfg, blockDB)` replays persisted state at construction time; `None` disables persistence - `GigaRouter` owns the `BlockDB` lifecycle: open in constructor, defer close in `Run` **Behavioral note:** On restart, blocks pruned before the previous shutdown may briefly reappear until BlockDB GC catches up. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 5892250 commit 1f1542e

43 files changed

Lines changed: 1971 additions & 2400 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

sei-db/ledger_db/block/block_db_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func TestBlockDB(t *testing.T) {
6969
t.Run("PruneQCOnlyThenWriteBlock", func(t *testing.T) {
7070
testPruneQCOnlyThenWriteBlock(t, impl.build)
7171
})
72+
t.Run("Status", func(t *testing.T) { testStatus(t, impl.build) })
7273
t.Run("WriteOrderRejected", func(t *testing.T) { testWriteOrderRejected(t, impl.build) })
7374
t.Run("WriteOrderRejectedAfterRestart", func(t *testing.T) {
7475
testWriteOrderRejectedAfterRestart(t, impl.build)
@@ -130,6 +131,74 @@ func testEmptyDB(t *testing.T, build builder) {
130131
require.NoError(t, err)
131132
require.False(t, ok, "empty db should yield no QCs")
132133
require.NoError(t, qcIt.Close())
134+
135+
tips := db.Status()
136+
require.Zero(t, tips.NextBlock, "empty db has no block write tip")
137+
require.Zero(t, tips.NextQC, "empty db has no QC write tip")
138+
}
139+
140+
// testStatus asserts Status matches the highest block/QC still present
141+
// (via reverse iterators), including after prune and restart, and that a QC
142+
// written ahead of its blocks advances only NextQC.
143+
func testStatus(t *testing.T, build builder) {
144+
committee, keys := buildCommittee()
145+
batches := generateBatches(committee, keys)
146+
db, o := openFresh(t, build)
147+
defer func() { _ = db.Close() }()
148+
149+
require.NoError(t, db.WriteQC(batches[0].first, batches[0].next, batches[0].qc))
150+
tips := db.Status()
151+
require.Equal(t, batches[0].next, tips.NextQC)
152+
require.Zero(t, tips.NextBlock, "QC-only store has no block tip")
153+
assertTipsMatchPresent(t, db)
154+
155+
for i, blk := range batches[0].blocks {
156+
require.NoError(t, db.WriteBlock(batches[0].first+gbn(i), blk))
157+
}
158+
writeAll(t, db, batches[1:])
159+
last := batches[len(batches)-1]
160+
tips = db.Status()
161+
require.Equal(t, last.next, tips.NextBlock)
162+
require.Equal(t, last.next, tips.NextQC)
163+
assertTipsMatchPresent(t, db)
164+
165+
// Prune away an early cohort; the write tip must still equal the highest
166+
// present records (newest cohort is never removed).
167+
require.Greater(t, len(batches), 1)
168+
require.NoError(t, db.PruneBefore(batches[1].first))
169+
assertTipsMatchPresent(t, db)
170+
tips = db.Status()
171+
require.Equal(t, last.next, tips.NextBlock, "prune must not move the block write tip")
172+
require.Equal(t, last.next, tips.NextQC, "prune must not move the QC write tip")
173+
174+
db = restart(t, o, db)
175+
assertTipsMatchPresent(t, db)
176+
tips = db.Status()
177+
require.Equal(t, last.next, tips.NextBlock, "block tip must survive restart")
178+
require.Equal(t, last.next, tips.NextQC, "QC tip must survive restart")
179+
}
180+
181+
// assertTipsMatchPresent checks Status against one reverse-iterator
182+
// step for blocks and QCs (the highest records the public read API still serves).
183+
func assertTipsMatchPresent(t *testing.T, db types.BlockDB) {
184+
t.Helper()
185+
tips := db.Status()
186+
187+
highest, hasBlock := recoverHighestBlock(t, db)
188+
if tips.NextBlock != 0 {
189+
require.True(t, hasBlock, "Status has a block tip but Blocks is empty")
190+
require.Equal(t, highest+1, tips.NextBlock, "NextBlock must be one past the highest present block")
191+
} else {
192+
require.False(t, hasBlock, "Blocks has data but Status has no block tip")
193+
}
194+
195+
lastQC, hasQC := recoverLastQC(t, db)
196+
if tips.NextQC != 0 {
197+
require.True(t, hasQC, "Status has a QC tip but QCs is empty")
198+
require.Equal(t, lastQC.GlobalRange().Next, tips.NextQC, "NextQC must be Next of the highest present QC")
199+
} else {
200+
require.False(t, hasQC, "QCs has data but Status has no QC tip")
201+
}
133202
}
134203

135204
func testReadRoundTrip(t *testing.T, build builder) {

sei-db/ledger_db/block/littblock/litt_block_db.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,19 @@ func (s *blockDB) Flush() error {
328328
return nil
329329
}
330330

331+
func (s *blockDB) Status() types.DBStatus {
332+
s.mu.Lock()
333+
defer s.mu.Unlock()
334+
var tips types.DBStatus
335+
if s.hasBlocks {
336+
tips.NextBlock = s.lastBlockNumber + 1
337+
}
338+
if s.hasQC {
339+
tips.NextQC = s.lastQCNext
340+
}
341+
return tips
342+
}
343+
331344
func (s *blockDB) Blocks(reverse bool) (types.BlockIterator, error) {
332345
it, err := s.table.Iterator(reverse)
333346
if err != nil {

sei-db/ledger_db/block/memblock/mem_block_db.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,19 @@ func (s *blockDB) PruneBefore(n types.GlobalBlockNumber) error {
147147

148148
func (s *blockDB) Flush() error { return nil }
149149

150+
func (s *blockDB) Status() types.DBStatus {
151+
s.mu.RLock()
152+
defer s.mu.RUnlock()
153+
var tips types.DBStatus
154+
if s.hasBlocks {
155+
tips.NextBlock = s.lastBlockNumber + 1
156+
}
157+
if s.hasQC {
158+
tips.NextQC = s.lastQCNext
159+
}
160+
return tips
161+
}
162+
150163
func (s *blockDB) Blocks(reverse bool) (types.BlockIterator, error) {
151164
s.mu.RLock()
152165
defer s.mu.RUnlock()

sei-tendermint/autobahn/types/block_db.go

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,9 @@
11
package types
22

33
import (
4-
"errors"
5-
64
"github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils"
75
)
86

9-
// ErrBlockOutOfOrder is returned by WriteBlock when the supplied
10-
// GlobalBlockNumber is not strictly greater than every previously written
11-
// block number. Blocks must be written in strictly ascending order.
12-
var ErrBlockOutOfOrder = errors.New("block: WriteBlock out of order")
13-
14-
// ErrQCNonContiguous is returned by WriteQC when the QC's GlobalRange().First
15-
// does not equal the previous QC's GlobalRange().Next. QCs must be written as
16-
// a contiguous, ascending sequence.
17-
var ErrQCNonContiguous = errors.New("block: WriteQC non-contiguous")
18-
19-
// ErrBlockMissingQC is returned by WriteBlock when no previously written QC
20-
// covers the block's GlobalBlockNumber. A QC covering a block must be written
21-
// before that block (see the BlockDB ordering contract).
22-
var ErrBlockMissingQC = errors.New("block: WriteBlock without covering QC")
23-
24-
// ErrPruned is returned by the by-number read methods (ReadBlockByNumber and
25-
// ReadQCByBlockNumber) when the requested GlobalBlockNumber is strictly below
26-
// the current retention watermark: the record is treated as pruned and is not
27-
// served while below the watermark. It is distinct from a utils.None result,
28-
// which means "not present at or above the watermark" and may still be filled
29-
// by a future write.
30-
//
31-
// ErrPruned reflects the watermark's current position, not a permanent verdict.
32-
// The watermark only advances while a store stays open, so within a single
33-
// session ErrPruned is terminal — retrying the same n keeps returning it. It is
34-
// not durable across restarts: the watermark is re-derived on open and
35-
// reclamation is asynchronous, so an n that returned ErrPruned before a restart
36-
// may afterward read as present (or as utils.None). Callers should treat
37-
// ErrPruned as "not currently served," not as a guarantee the record is gone.
38-
var ErrPruned = errors.New("block: below retention watermark")
39-
407
// BlockDB is the durable backing store for data.State. It persists the two
418
// kinds of finalized records the consensus state machine produces —
429
// finalized blocks (indexed by GlobalBlockNumber and by header hash) and
@@ -183,6 +150,9 @@ type BlockDB interface {
183150
// issuance).
184151
Flush() error
185152

153+
// Status returns a consistent snapshot of the in-memory write tips (no I/O).
154+
Status() DBStatus
155+
186156
// Blocks returns an iterator over every persisted block not yet
187157
// pruned, for startup replay. Intended to be called once at
188158
// construction by data.State.NewState.
@@ -269,6 +239,20 @@ type BlockDB interface {
269239
Close() error
270240
}
271241

242+
// DBStatus is the in-memory write tips returned by BlockDB.Status.
243+
// Both fields are exclusive "next to write" cursors (matching data.State's
244+
// nextQC / nextBlock). Zero means no write of that kind has occurred yet
245+
// (NextBlock/NextQC are never zero after a successful write: the first
246+
// written block number N yields NextBlock = N+1 ≥ 1).
247+
type DBStatus struct {
248+
// NextBlock is one past the highest GlobalBlockNumber accepted by WriteBlock
249+
// (the next block number that may be written). Zero if no block has been written.
250+
NextBlock GlobalBlockNumber
251+
// NextQC is the exclusive upper bound of the last WriteQC (the next QC
252+
// range must start here). Zero if no QC has been written.
253+
NextQC GlobalBlockNumber
254+
}
255+
272256
// BlockIterator iterates over persisted blocks in GlobalBlockNumber order —
273257
// ascending, or descending if the iterator was created with reverse=true. It
274258
// is created via BlockDB.Blocks and captures a snapshot of the blocks present
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package types
2+
3+
import "errors"
4+
5+
// ErrNotFound is returned when a requested record is not yet available —
6+
// a block, QC, or AppProposal ahead of what data.State currently has (e.g.
7+
// ahead of the contiguous block/QC prefix). Distinct from ErrPruned, which
8+
// means the height is below the retention / eviction floor.
9+
var ErrNotFound = errors.New("not found")
10+
11+
// ErrBlockGap is returned when BlockDB blocks are not contiguous (e.g. during
12+
// data.State recovery). That indicates store corruption or an incomplete write
13+
// that left a hole.
14+
var ErrBlockGap = errors.New("block gap in BlockDB")
15+
16+
// ErrBlockOutOfOrder is returned by WriteBlock when the supplied
17+
// GlobalBlockNumber is not strictly greater than every previously written
18+
// block number. Blocks must be written in strictly ascending order.
19+
var ErrBlockOutOfOrder = errors.New("block: WriteBlock out of order")
20+
21+
// ErrQCNonContiguous is returned by WriteQC when the QC's GlobalRange().First
22+
// does not equal the previous QC's GlobalRange().Next. QCs must be written as
23+
// a contiguous, ascending sequence.
24+
var ErrQCNonContiguous = errors.New("block: WriteQC non-contiguous")
25+
26+
// ErrBlockMissingQC is returned by WriteBlock when no previously written QC
27+
// covers the block's GlobalBlockNumber. A QC covering a block must be written
28+
// before that block (see the BlockDB ordering contract).
29+
var ErrBlockMissingQC = errors.New("block: WriteBlock without covering QC")
30+
31+
// ErrPruned is returned when a requested record is below the current retention
32+
// / eviction floor and is not served. Used for BlockDB by-number reads below
33+
// the store watermark, and for data.State lookups (blocks, QCs, AppProposals)
34+
// after in-memory eviction. Distinct from utils.None on BlockDB, which means
35+
// "not present at or above the watermark" and may still be filled by a future
36+
// write.
37+
//
38+
// ErrPruned reflects the watermark's current position, not a permanent verdict.
39+
// The watermark only advances while a store stays open, so within a single
40+
// session ErrPruned is terminal — retrying the same n keeps returning it. It is
41+
// not durable across restarts: the watermark is re-derived on open and
42+
// reclamation is asynchronous, so an n that returned ErrPruned before a restart
43+
// may afterward read as present (or as utils.None). Callers should treat
44+
// ErrPruned as "not currently served," not as a guarantee the record is gone.
45+
var ErrPruned = errors.New("pruned: below retention watermark")

sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Output is written to the file specified by --output.`,
3434
return fmt.Errorf("--output flag is required")
3535
}
3636
persistentStateDir, _ := cmd.Flags().GetString("persistent-state-dir")
37+
blockDBRetention, _ := cmd.Flags().GetString("blockdb-retention")
38+
blockDBGCPeriod, _ := cmd.Flags().GetString("blockdb-gc-period")
3739

3840
var validators []config.AutobahnValidator
3941
for _, dir := range args {
@@ -97,6 +99,11 @@ Output is written to the file specified by --output.`,
9799
if persistentStateDir != "" {
98100
cfg.PersistentStateDir = utils.Some(persistentStateDir)
99101
}
102+
blockDB, err := buildGenBlockDBConfig(blockDBRetention, blockDBGCPeriod)
103+
if err != nil {
104+
return err
105+
}
106+
cfg.BlockDB = blockDB
100107

101108
data, err := json.MarshalIndent(cfg, "", " ")
102109
if err != nil {
@@ -110,6 +117,35 @@ Output is written to the file specified by --output.`,
110117
},
111118
}
112119
cmd.Flags().StringP("output", "o", "", "output file path for the autobahn config")
113-
cmd.Flags().String("persistent-state-dir", "data/autobahn", "directory to persist autobahn consensus and data WALs across restarts; relative paths are resolved against the node's --home dir; pass --persistent-state-dir= (empty) to disable persistence and run in-memory only")
120+
cmd.Flags().String("persistent-state-dir", "data/autobahn", "directory to persist autobahn consensus state and BlockDB across restarts; relative paths are resolved against the node's --home dir; pass --persistent-state-dir= (empty) to disable persistence and run in-memory only (memblock)")
121+
// Default 30s: this helper is used by docker/local clusters, not production
122+
// node bring-up. Pass --blockdb-retention= (empty) to omit block_db and keep
123+
// littblock's production default (24h).
124+
cmd.Flags().String("blockdb-retention", "30s", "BlockDB retention TTL written into block_db (default 30s for local/docker); pass empty to omit and keep littblock's 24h default")
125+
cmd.Flags().String("blockdb-gc-period", "", "optional BlockDB GC period (e.g. 10s); omit to keep littblock default")
114126
return cmd
115127
}
128+
129+
// buildGenBlockDBConfig builds optional block_db overrides from gen-autobahn-config flags.
130+
// Empty duration strings leave BlockDB as the zero value (omitted from JSON).
131+
func buildGenBlockDBConfig(retention, gcPeriod string) (config.AutobahnBlockDBConfig, error) {
132+
var bdb config.AutobahnBlockDBConfig
133+
if retention != "" {
134+
d, err := time.ParseDuration(retention)
135+
if err != nil {
136+
return config.AutobahnBlockDBConfig{}, fmt.Errorf("--blockdb-retention: %w", err)
137+
}
138+
bdb.Retention = utils.Some(utils.Duration(d))
139+
}
140+
if gcPeriod != "" {
141+
d, err := time.ParseDuration(gcPeriod)
142+
if err != nil {
143+
return config.AutobahnBlockDBConfig{}, fmt.Errorf("--blockdb-gc-period: %w", err)
144+
}
145+
bdb.GCPeriod = utils.Some(utils.Duration(d))
146+
}
147+
if err := bdb.Validate(); err != nil {
148+
return config.AutobahnBlockDBConfig{}, fmt.Errorf("block_db: %w", err)
149+
}
150+
return bdb, nil
151+
}

sei-tendermint/config/autobahn.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/url"
77

8+
"github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock"
89
atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types"
910
"github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p"
1011
"github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils"
@@ -34,6 +35,26 @@ type AutobahnValidator struct {
3435
EVMRPC URL `json:"evmrpc"`
3536
}
3637

38+
// AutobahnBlockDBConfig holds optional overrides for the LittDB-backed BlockDB
39+
// opened under persistent_state_dir/blockdb. Paths are never taken from here —
40+
// Autobahn always roots BlockDB at <persistent_state_dir>/blockdb.
41+
//
42+
// Each field is independently optional. Absent fields keep whatever
43+
// littblock.DefaultConfig currently uses (do not duplicate those values here —
44+
// they live in the littblock / LittDB packages and may change).
45+
//
46+
// Disk impact (most → least useful for bounding usage): Retention, then
47+
// GCPeriod. Segment sizing is intentionally not exposed (engine-internal).
48+
type AutobahnBlockDBConfig struct {
49+
// Retention is the failsafe minimum age before pruned records may be
50+
// reclaimed. Primary knob for worst-case disk after PruneBefore advances.
51+
// Absent ⇒ littblock.DefaultConfig Retention.
52+
Retention utils.Option[utils.Duration] `json:"retention"`
53+
// GCPeriod is how often GC runs once data is eligible (reclaim latency).
54+
// Absent ⇒ littblock.DefaultConfig / LittDB GCPeriod.
55+
GCPeriod utils.Option[utils.Duration] `json:"gc_period"`
56+
}
57+
3758
// AutobahnFileConfig is the JSON structure of the autobahn config file.
3859
type AutobahnFileConfig struct {
3960
Validators []AutobahnValidator `json:"validators"`
@@ -49,6 +70,11 @@ type AutobahnFileConfig struct {
4970
// fullnodes serving downstream block-sync are subject to the same
5071
// cap). Absent ⇒ DefaultMaxInboundFullnodePeers. Some(0) ⇒ reject all.
5172
MaxInboundFullnodePeers utils.Option[uint64] `json:"max_inbound_fullnode_peers"`
73+
// BlockDB optionally overlays AutobahnBlockDBConfig onto littblock.DefaultConfig
74+
// when PersistentStateDir is set. Zero value ⇒ littblock.DefaultConfig unchanged
75+
// (see AutobahnBlockDBConfig for field semantics). Ignored when
76+
// PersistentStateDir is absent (memblock). Omitted from JSON when empty.
77+
BlockDB AutobahnBlockDBConfig `json:"block_db,omitzero"`
5278
}
5379

5480
// DefaultMaxInboundFullnodePeers is the built-in cap used when
@@ -80,5 +106,40 @@ func (fc *AutobahnFileConfig) Validate() error {
80106
if fc.DialInterval <= 0 {
81107
return errors.New("dial_interval must be > 0")
82108
}
109+
if err := fc.BlockDB.Validate(); err != nil {
110+
return fmt.Errorf("block_db: %w", err)
111+
}
83112
return nil
84113
}
114+
115+
// Validate checks optional BlockDB overrides. Absent fields are fine.
116+
func (c AutobahnBlockDBConfig) Validate() error {
117+
if r, ok := c.Retention.Get(); ok && r <= 0 {
118+
return errors.New("retention must be > 0 when set")
119+
}
120+
if p, ok := c.GCPeriod.Get(); ok && p <= 0 {
121+
return errors.New("gc_period must be > 0 when set")
122+
}
123+
return nil
124+
}
125+
126+
// LittBlockConfig returns littblock.DefaultConfig(dir) with this config's
127+
// optional overrides applied. Fsync is always forced on.
128+
func (c AutobahnBlockDBConfig) LittBlockConfig(dir string) (littblock.LittBlockConfig, error) {
129+
if err := c.Validate(); err != nil {
130+
return littblock.LittBlockConfig{}, err
131+
}
132+
cfg, err := littblock.DefaultConfig(dir)
133+
if err != nil {
134+
return littblock.LittBlockConfig{}, fmt.Errorf("littblock.DefaultConfig: %w", err)
135+
}
136+
if r, ok := c.Retention.Get(); ok {
137+
cfg.Retention = r.Duration()
138+
}
139+
if p, ok := c.GCPeriod.Get(); ok {
140+
cfg.Litt.GCPeriod = p.Duration()
141+
}
142+
// NOT SAFE to set false: crash can lose acknowledged BlockDB writes.
143+
cfg.Litt.Fsync = true
144+
return *cfg, nil
145+
}

0 commit comments

Comments
 (0)