From 2ed09bf3d5b3fd01da62e4c6c97795a7ce602ab5 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 15:05:29 -0400 Subject: [PATCH 01/46] =?UTF-8?q?feat(data):=20wire=20BlockDB=20into=20Sta?= =?UTF-8?q?te=20=E2=80=94=20persist=20QCs=20and=20blocks=20across=20restar?= =?UTF-8?q?ts=20(CON-272)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the old per-package persist/ layer (consensus/persist/fullcommitqcs.go, globalblocks.go) with a single LittDB-backed BlockDB. State.NewState now recovers QCs and blocks from BlockDB on restart: a QC-first pass advances nextQC, then a block pass restores the in-memory maps and sets nextBlock to the first gap (gap detection guards against DB corruption). PersistentStateDir is promoted from utils.Option[string] to plain string — the empty-string guard in buildDataState makes missing config explicit. Tests migrate from memblock to littblock via newTestLittDB / newTestNode helpers. Co-Authored-By: Claude Sonnet 4.6 --- .../commands/gen_autobahn_config.go | 11 +- sei-tendermint/config/autobahn.go | 2 +- .../internal/autobahn/avail/inner_test.go | 12 +- .../internal/autobahn/avail/state_test.go | 30 +- .../internal/autobahn/consensus/inner_test.go | 11 +- .../consensus/persist/fullcommitqcs.go | 166 ---- .../consensus/persist/fullcommitqcs_test.go | 293 ------- .../consensus/persist/globalblocks.go | 231 ------ .../consensus/persist/globalblocks_test.go | 296 ------- .../autobahn/consensus/persist/testonly.go | 8 - .../internal/autobahn/consensus/state_test.go | 17 +- .../internal/autobahn/data/state.go | 402 +++++---- .../internal/autobahn/data/state_test.go | 764 +++++++----------- .../autobahn/producer/mempool_test.go | 26 +- .../internal/p2p/giga/avail_test.go | 2 +- .../internal/p2p/giga/consensus_test.go | 2 +- sei-tendermint/internal/p2p/giga/data_test.go | 17 +- sei-tendermint/internal/p2p/giga_router.go | 6 +- .../internal/p2p/giga_router_common.go | 58 +- .../internal/p2p/giga_router_fullnode_test.go | 8 +- .../internal/p2p/giga_router_validator.go | 2 +- .../p2p/giga_router_validator_test.go | 4 +- sei-tendermint/node/setup.go | 6 +- sei-tendermint/node/setup_test.go | 37 +- 24 files changed, 578 insertions(+), 1833 deletions(-) delete mode 100644 sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go delete mode 100644 sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go delete mode 100644 sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go delete mode 100644 sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go delete mode 100644 sei-tendermint/internal/autobahn/consensus/persist/testonly.go diff --git a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go index 13736e876d..f770d2a00b 100644 --- a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go +++ b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go @@ -89,14 +89,9 @@ Output is written to the file specified by --output.`, ViewTimeout: utils.Duration(1500 * time.Millisecond), DialInterval: utils.Duration(10 * time.Second), } - // The flag defaults to "data/autobahn" so persistence is on without - // operator action. node/setup.go rootifies the relative path against - // cfg.RootDir at load time. Passing --persistent-state-dir= (empty) - // disables persistence and runs both consensus and data layers - // in-memory only. - if persistentStateDir != "" { - cfg.PersistentStateDir = utils.Some(persistentStateDir) - } + // The flag defaults to "data/autobahn"; node/setup.go rootifies + // the relative path against cfg.RootDir at load time. + cfg.PersistentStateDir = persistentStateDir data, err := json.MarshalIndent(cfg, "", " ") if err != nil { diff --git a/sei-tendermint/config/autobahn.go b/sei-tendermint/config/autobahn.go index 6c08e0629c..ac38f4c050 100644 --- a/sei-tendermint/config/autobahn.go +++ b/sei-tendermint/config/autobahn.go @@ -42,7 +42,7 @@ type AutobahnFileConfig struct { AllowEmptyBlocks bool `json:"allow_empty_blocks"` BlockInterval utils.Duration `json:"block_interval"` ViewTimeout utils.Duration `json:"view_timeout"` - PersistentStateDir utils.Option[string] `json:"persistent_state_dir"` + PersistentStateDir string `json:"persistent_state_dir"` DialInterval utils.Duration `json:"dial_interval"` // MaxInboundFullnodePeers caps concurrent inbound block-sync from // non-committee peers, applied on both validators and fullnodes (relay diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 257641de63..30bd7c9212 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -4,6 +4,7 @@ import ( pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "testing" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -11,6 +12,13 @@ import ( "github.com/stretchr/testify/require" ) +func newTestBlockDB(t testing.TB) types.BlockDB { + cfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) + db := utils.OrPanic1(littblock.NewBlockDB(cfg)) + t.Cleanup(func() { _ = db.Close() }) + return db +} + func TestPruneMismatchedIndices(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) @@ -35,7 +43,7 @@ func TestPruneMismatchedIndices(t *testing.T) { qc1 := makeCommitQC(utils.Some(qc0)) t.Logf("test State.PushAppQC") - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) require.Error(t, state.PushAppQC(makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") @@ -44,7 +52,7 @@ func TestPruneMismatchedIndices(t *testing.T) { require.NoError(t, state.PushAppQC(makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") t.Logf("test inner.prune") - ds = utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds = utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) state, err = NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) for inner := range state.inner.Lock() { diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 4bb2d3e2fa..a35bb46993 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -107,7 +107,7 @@ func testState(t *testing.T, stateDir utils.Option[string]) { if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { ds := utils.OrPanic1(data.NewState(&data.Config{ Committee: committee, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + }, newTestBlockDB(t))) s.SpawnBgNamed("data.State.Run()", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) @@ -236,7 +236,7 @@ func TestStateRestartFromPersisted(t *testing.T) { var wantNextBlocks map[types.LaneID]types.BlockNumber require.NoError(t, scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) @@ -311,7 +311,7 @@ func TestStateRestartFromPersisted(t *testing.T) { })) // Phase 2: Restart from the same directory. - ds2 := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds2 := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) state2, err := NewState(keys[0], ds2, utils.Some(dir)) require.NoError(t, err) @@ -337,7 +337,7 @@ func TestStateMismatchedQCs(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{ Committee: committee, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + }, newTestBlockDB(t))) state, err := NewState(keys[0], ds, utils.None[string]()) require.NoError(t, err) @@ -395,7 +395,7 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{ Committee: committee, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + }, newTestBlockDB(t))) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) // Produce a valid first block on our lane. @@ -420,7 +420,7 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{ Committee: committee, - }, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + }, newTestBlockDB(t))) state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) // Create a block on keys[0]'s lane but sign it with keys[1]. @@ -439,7 +439,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("empty dir loads fresh state", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) @@ -452,7 +452,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted AppQC", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) roadIdx := types.RoadIndex(7) globalNum := types.GlobalBlockNumber(50) @@ -493,7 +493,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted blocks", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) lane := keys[0].Public() // Persist blocks using BlockPersister. @@ -517,7 +517,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted AppQC and blocks together", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) lane := keys[0].Public() roadIdx := types.RoadIndex(2) @@ -570,7 +570,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted commitQCs", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) // Persist CommitQCs to disk. cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) @@ -595,7 +595,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted commitQCs with AppQC", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) // Persist AppQC at road index 1. roadIdx := types.RoadIndex(1) @@ -668,7 +668,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("anchor past all persisted commitQCs truncates WAL", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) // Build a chain of 10 CommitQCs (indices 0-9). qcs := make([]*types.CommitQC, 10) @@ -711,7 +711,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("anchor past all persisted blocks truncates lane WAL", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) lane := keys[0].Public() // Persist commitQCs 0-9 and blocks 0-2 for one lane. @@ -759,7 +759,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("corrupt AppQC data returns error", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) // Create a throwaway persister to discover the A/B filenames, // then corrupt them so NewState fails on load. diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 761e528d36..23f0e1f358 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -16,6 +17,14 @@ import ( ) // seedPersistedInner is a test helper that persists a persistedInner using the public API. + +func newTestBlockDB(t testing.TB) types.BlockDB { + cfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) + db := utils.OrPanic1(littblock.NewBlockDB(cfg)) + t.Cleanup(func() { _ = db.Close() }) + return db +} + func seedPersistedInner(dir string, state *persistedInner) { p, _, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) if err != nil { @@ -925,7 +934,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // and terminates the consensus component (instead of panicking). rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) + ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) wantErr := errors.New("disk on fire") pers := utils.Some[persist.Persister[*pb.PersistedInner]](failPersister[*pb.PersistedInner]{err: wantErr}) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go deleted file mode 100644 index f4ff1df34e..0000000000 --- a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs.go +++ /dev/null @@ -1,166 +0,0 @@ -package persist - -import ( - "fmt" - "path/filepath" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -const fullCommitQCsDir = "fullcommitqcs" - -// fullCommitQCState is the mutable state protected by FullCommitQCPersister's mutex. -type fullCommitQCState struct { - iw utils.Option[*indexedWAL[*types.FullCommitQC]] - committee *types.Committee - next types.GlobalBlockNumber // next expected GlobalRange().First == last QC's GlobalRange().Next - loaded []*types.FullCommitQC -} - -func (s *fullCommitQCState) persistQC(qc *types.FullCommitQC) error { - gr := qc.QC().GlobalRange(s.committee) - if gr.First < s.next { - return nil - } - if gr.First > s.next { - return fmt.Errorf("full commitqc %d out of sequence (next=%d)", gr.First, s.next) - } - if iw, ok := s.iw.Get(); ok { - if err := iw.Write(qc); err != nil { - return fmt.Errorf("persist full commitqc at %d: %w", gr.First, err) - } - } - s.next = gr.Next - return nil -} - -func (s *fullCommitQCState) truncateBefore(n types.GlobalBlockNumber) error { - if n == 0 { - return nil - } - if n > s.next { - s.next = n - } - iw, ok := s.iw.Get() - if !ok || iw.Count() == 0 { - return nil - } - // Remove QCs whose range is fully before n. A QC is stale when - // GlobalRange().Next <= n. In practice the scan visits 0–1 entries - // per prune call because pruning advances one block at a time while - // each QC covers many blocks. - if err := iw.TruncateWhile(func(entry *types.FullCommitQC) bool { - return entry.QC().GlobalRange(s.committee).Next <= n - }); err != nil { - return fmt.Errorf("truncate full commitqc WAL: %w", err) - } - return nil -} - -// FullCommitQCPersister manages persistence of FullCommitQCs using a WAL. -// Each entry is one FullCommitQC covering a range of global block numbers. -// When stateDir is None, all disk I/O is skipped (no-op mode). -// All public methods are safe for concurrent use. -type FullCommitQCPersister struct { - state utils.Mutex[*fullCommitQCState] -} - -// NewFullCommitQCPersister opens (or creates) a WAL in the fullcommitqcs/ -// subdir and replays all persisted entries. Loaded QCs are available via -// ConsumeLoaded. When stateDir is None, returns a no-op persister. -func NewFullCommitQCPersister(stateDir utils.Option[string], committee *types.Committee) (*FullCommitQCPersister, error) { - sd, ok := stateDir.Get() - if !ok { - return &FullCommitQCPersister{state: utils.NewMutex(&fullCommitQCState{committee: committee, next: committee.FirstBlock()})}, nil - } - dir := filepath.Join(sd, fullCommitQCsDir) - iw, err := openIndexedWAL(dir, types.FullCommitQCConv) - if err != nil { - return nil, fmt.Errorf("open full commitqc WAL in %s: %w", dir, err) - } - - s := &fullCommitQCState{iw: utils.Some(iw), committee: committee, next: committee.FirstBlock()} - loaded, err := s.loadAll() - if err != nil { - _ = iw.Close() - return nil, err - } - if len(loaded) > 0 { - s.next = loaded[len(loaded)-1].QC().GlobalRange(s.committee).Next - } - s.loaded = loaded - return &FullCommitQCPersister{ - state: utils.NewMutex(s), - }, nil -} - -// Next returns the next GlobalBlockNumber expected by the persister -// (i.e., the next QC's GlobalRange().First). -func (gp *FullCommitQCPersister) Next() types.GlobalBlockNumber { - for s := range gp.state.Lock() { - return s.next - } - panic("unreachable") -} - -// LoadedFirst returns the first global block number of the first loaded QC, -// or committee.FirstBlock() if empty. -func (gp *FullCommitQCPersister) LoadedFirst() types.GlobalBlockNumber { - for s := range gp.state.Lock() { - if len(s.loaded) > 0 { - return s.loaded[0].QC().GlobalRange(s.committee).First - } - return s.committee.FirstBlock() - } - panic("unreachable") -} - -// ConsumeLoaded returns QCs loaded from the WAL during construction -// and nils the internal slice so the data is not retained. -func (gp *FullCommitQCPersister) ConsumeLoaded() []*types.FullCommitQC { - for s := range gp.state.Lock() { - loaded := s.loaded - s.loaded = nil - return loaded - } - panic("unreachable") -} - -// PersistQC appends a FullCommitQC to the WAL. Duplicates are silently ignored. -// Gaps return an error. -func (gp *FullCommitQCPersister) PersistQC(qc *types.FullCommitQC) error { - for s := range gp.state.Lock() { - return s.persistQC(qc) - } - panic("unreachable") -} - -// TruncateBefore removes all QC entries whose range is fully before n. -func (gp *FullCommitQCPersister) TruncateBefore(n types.GlobalBlockNumber) error { - for s := range gp.state.Lock() { - return s.truncateBefore(n) - } - panic("unreachable") -} - -// Close shuts down the WAL. -func (gp *FullCommitQCPersister) Close() error { - for s := range gp.state.Lock() { - iw, ok := s.iw.Get() - if !ok { - return nil - } - s.iw = utils.None[*indexedWAL[*types.FullCommitQC]]() - return iw.Close() - } - panic("unreachable") -} - -func (s *fullCommitQCState) loadAll() ([]*types.FullCommitQC, error) { - iw, ok := s.iw.Get() - if !ok { - return nil, nil - } - return iw.ReadAll() -} diff --git a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go deleted file mode 100644 index 8932bcab76..0000000000 --- a/sei-tendermint/internal/autobahn/consensus/persist/fullcommitqcs_test.go +++ /dev/null @@ -1,293 +0,0 @@ -package persist - -import ( - "testing" - "time" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" -) - -func makeSequentialFullCommitQCs( - rng utils.Rng, - committee *types.Committee, - keys []types.SecretKey, - n int, -) []*types.FullCommitQC { - qcs := make([]*types.FullCommitQC, n) - prev := utils.None[*types.CommitQC]() - for i := range n { - blocks := map[types.LaneID][]*types.Block{} - for range 3 { - lane := committee.Lanes().At(rng.Intn(committee.Lanes().Len())) - var b *types.Block - if bs := blocks[lane]; len(bs) > 0 { - parent := bs[len(bs)-1] - b = types.NewBlock(lane, parent.Header().Next(), parent.Header().Hash(), types.GenPayload(rng)) - } else { - b = types.NewBlock( - lane, - types.LaneRangeOpt(prev, lane).Next(), - types.GenBlockHeaderHash(rng), - types.GenPayload(rng), - ) - } - blocks[lane] = append(blocks[lane], b) - } - laneQCs := map[types.LaneID]*types.LaneQC{} - var headers []*types.BlockHeader - for lane := range committee.Lanes().All() { - if bs := blocks[lane]; len(bs) > 0 { - votes := make([]*types.Signed[*types.LaneVote], 0, len(keys)) - for _, k := range keys { - votes = append(votes, types.Sign(k, types.NewLaneVote(bs[len(bs)-1].Header()))) - } - laneQCs[lane] = types.NewLaneQC(votes) - for _, b := range bs { - headers = append(headers, b.Header()) - } - } - } - viewSpec := types.ViewSpec{CommitQC: prev} - leader := committee.Leader(viewSpec.View()) - var leaderKey types.SecretKey - for _, k := range keys { - if k.Public() == leader { - leaderKey = k - break - } - } - proposal := utils.OrPanic1(types.NewProposal( - leaderKey, - committee, - viewSpec, - time.Now(), - laneQCs, - utils.None[*types.AppQC](), - )) - votes := make([]*types.Signed[*types.CommitVote], 0, len(keys)) - for _, k := range keys { - votes = append(votes, types.Sign(k, types.NewCommitVote(proposal.Proposal().Msg()))) - } - cqc := types.NewCommitQC(votes) - qcs[i] = types.NewFullCommitQC(cqc, headers) - prev = utils.Some(cqc) - } - return qcs -} - -func TestNewFullCommitQCPersisterEmptyDir(t *testing.T) { - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - dir := t.TempDir() - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.NotNil(t, gp) - require.Equal(t, 0, len(gp.ConsumeLoaded())) - require.Equal(t, committee.FirstBlock(), gp.Next()) - require.NoError(t, gp.Close()) -} - -func TestNewFullCommitQCPersisterNoop(t *testing.T) { - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - - gp, err := NewFullCommitQCPersister(utils.None[string](), committee) - require.NoError(t, err) - require.NotNil(t, gp) - require.Equal(t, 0, len(gp.ConsumeLoaded())) - - for _, qc := range qcs { - require.NoError(t, gp.PersistQC(qc)) - } - lastNext := qcs[len(qcs)-1].QC().GlobalRange(committee).Next - require.Equal(t, lastNext, gp.Next()) - - // Truncate past everything in no-op mode advances cursor. - futureN := lastNext + 100 - require.NoError(t, gp.TruncateBefore(futureN)) - require.Equal(t, futureN, gp.Next()) - require.NoError(t, gp.Close()) -} - -func TestFullCommitQCPersistAndReload(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - for _, qc := range qcs { - require.NoError(t, gp.PersistQC(qc)) - } - lastNext := qcs[len(qcs)-1].QC().GlobalRange(committee).Next - require.Equal(t, lastNext, gp.Next()) - require.NoError(t, gp.Close()) - - gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - loaded := gp2.ConsumeLoaded() - require.Equal(t, len(qcs), len(loaded)) - for i, lqc := range loaded { - require.Equal(t, qcs[i].QC().GlobalRange(committee).First, lqc.QC().GlobalRange(committee).First) - } - require.Equal(t, lastNext, gp2.Next()) - require.NoError(t, gp2.Close()) -} - -func TestFullCommitQCTruncateAndReload(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - for _, qc := range qcs { - require.NoError(t, gp.PersistQC(qc)) - } - // Truncate before the third QC's range start, which should remove - // all QCs whose range is fully below that point. - truncPoint := qcs[2].QC().GlobalRange(committee).First - require.NoError(t, gp.TruncateBefore(truncPoint)) - require.NoError(t, gp.Close()) - - gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - loaded := gp2.ConsumeLoaded() - // QCs 0 and 1 should be gone (their ranges are fully before truncPoint). - // QC 2 should be the first one remaining. - require.GreaterOrEqual(t, len(loaded), 1) - require.Equal(t, qcs[2].QC().GlobalRange(committee).First, loaded[0].QC().GlobalRange(committee).First) - require.NoError(t, gp2.Close()) -} - -func TestFullCommitQCTruncateAll(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 3) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - for _, qc := range qcs { - require.NoError(t, gp.PersistQC(qc)) - } - lastNext := qcs[len(qcs)-1].QC().GlobalRange(committee).Next - require.NoError(t, gp.TruncateBefore(lastNext+100)) - require.NoError(t, gp.Close()) - - gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 0, len(gp2.ConsumeLoaded())) - require.NoError(t, gp2.Close()) -} - -func TestFullCommitQCDuplicateIgnored(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 2) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.NoError(t, gp.PersistQC(qcs[0])) - require.NoError(t, gp.PersistQC(qcs[0])) // duplicate - require.Equal(t, qcs[0].QC().GlobalRange(committee).Next, gp.Next()) - require.NoError(t, gp.Close()) -} - -func TestFullCommitQCGapError(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 3) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - // Skip qcs[0] and try to persist qcs[1] directly. - err = gp.PersistQC(qcs[1]) - require.Error(t, err) - require.Contains(t, err.Error(), "out of sequence") - require.NoError(t, gp.Close()) -} - -func TestFullCommitQCTruncateBeforeNoop(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 3) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - for _, qc := range qcs { - require.NoError(t, gp.PersistQC(qc)) - } - // TruncateBefore(0) is a no-op. - require.NoError(t, gp.TruncateBefore(0)) - require.NoError(t, gp.Close()) - - gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 3, len(gp2.ConsumeLoaded())) - require.NoError(t, gp2.Close()) -} - -func TestFullCommitQCContinueAfterReload(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 6) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - for _, qc := range qcs[:3] { - require.NoError(t, gp.PersistQC(qc)) - } - require.NoError(t, gp.Close()) - - gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 3, len(gp2.ConsumeLoaded())) - for _, qc := range qcs[3:] { - require.NoError(t, gp2.PersistQC(qc)) - } - require.Equal(t, qcs[5].QC().GlobalRange(committee).Next, gp2.Next()) - require.NoError(t, gp2.Close()) - - gp3, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 6, len(gp3.ConsumeLoaded())) - require.NoError(t, gp3.Close()) -} - -func TestFullCommitQCTruncateMidRange(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - qcs := makeSequentialFullCommitQCs(rng, committee, keys, 5) - - gp, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - for _, qc := range qcs { - require.NoError(t, gp.PersistQC(qc)) - } - // Truncate at a point inside the first QC's range. - // The first QC should be kept because its range extends past truncPoint. - gr0 := qcs[0].QC().GlobalRange(committee) - if gr0.Len() > 1 { - midPoint := gr0.First + types.GlobalBlockNumber(gr0.Len()/2) - require.NoError(t, gp.TruncateBefore(midPoint)) - require.NoError(t, gp.Close()) - - gp2, err := NewFullCommitQCPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 5, len(gp2.ConsumeLoaded())) - require.NoError(t, gp2.Close()) - } else { - require.NoError(t, gp.Close()) - } -} diff --git a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go deleted file mode 100644 index 9471994e3c..0000000000 --- a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks.go +++ /dev/null @@ -1,231 +0,0 @@ -package persist - -import ( - "encoding/binary" - "fmt" - "path/filepath" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" -) - -const globalBlocksDir = "globalblocks" - -// LoadedGlobalBlock is a block loaded from disk during state restoration. -// Also used as the internal WAL entry type. Block doesn't carry its -// GlobalBlockNumber (that's assigned by the ordering layer), so we embed -// it in each WAL entry to make entries self-describing. -type LoadedGlobalBlock struct { - Number types.GlobalBlockNumber - Block *types.Block -} - -// loadedGlobalBlockCodec serializes LoadedGlobalBlock as [8-byte LE number][proto block]. -type loadedGlobalBlockCodec struct{} - -func (loadedGlobalBlockCodec) Marshal(e LoadedGlobalBlock) []byte { - proto := types.BlockConv.Marshal(e.Block) - out := make([]byte, 8+len(proto)) - binary.LittleEndian.PutUint64(out, uint64(e.Number)) - copy(out[8:], proto) - return out -} - -func (loadedGlobalBlockCodec) Unmarshal(raw []byte) (LoadedGlobalBlock, error) { - if len(raw) < 8 { - return LoadedGlobalBlock{}, fmt.Errorf("global block entry too short: %d bytes", len(raw)) - } - n := types.GlobalBlockNumber(binary.LittleEndian.Uint64(raw[:8])) - block, err := types.BlockConv.Unmarshal(raw[8:]) - if err != nil { - return LoadedGlobalBlock{}, fmt.Errorf("unmarshal block %d: %w", n, err) - } - return LoadedGlobalBlock{Number: n, Block: block}, nil -} - -// globalBlockState is the mutable state protected by GlobalBlockPersister's mutex. -type globalBlockState struct { - iw utils.Option[*indexedWAL[LoadedGlobalBlock]] - committee *types.Committee - next types.GlobalBlockNumber - loaded []LoadedGlobalBlock -} - -func (s *globalBlockState) persistBlock(n types.GlobalBlockNumber, block *types.Block) error { - if n < s.next { - return nil - } - if n > s.next { - return fmt.Errorf("global block %d out of sequence (next=%d)", n, s.next) - } - if iw, ok := s.iw.Get(); ok { - if err := iw.Write(LoadedGlobalBlock{Number: n, Block: block}); err != nil { - return fmt.Errorf("persist global block %d: %w", n, err) - } - } - s.next = n + 1 - return nil -} - -func (s *globalBlockState) truncateBefore(n types.GlobalBlockNumber) error { - if n == 0 { - return nil - } - if n > s.next { - s.next = n - } - iw, ok := s.iw.Get() - if !ok || iw.Count() == 0 { - return nil - } - firstGlobal := s.next - types.GlobalBlockNumber(iw.Count()) - if n <= firstGlobal { - return nil - } - walIdx := iw.FirstIdx() + uint64(n-firstGlobal) - if err := iw.TruncateBefore(walIdx, func(entry LoadedGlobalBlock) error { - if entry.Number != n { - return fmt.Errorf("global block at WAL index %d has number %d, expected %d (index mapping broken)", walIdx, entry.Number, n) - } - return nil - }); err != nil { - return fmt.Errorf("truncate global block WAL before %d: %w", n, err) - } - return nil -} - -// GlobalBlockPersister manages persistence of globally-ordered blocks using a WAL. -// Each entry embeds its GlobalBlockNumber since Block doesn't carry it. -// When stateDir is None, all disk I/O is skipped (no-op mode). -// All public methods are safe for concurrent use. -type GlobalBlockPersister struct { - state utils.Mutex[*globalBlockState] -} - -// NewGlobalBlockPersister opens (or creates) a WAL in the globalblocks/ subdir -// and replays all persisted entries. Loaded blocks are available via -// ConsumeLoaded. When stateDir is None, returns a no-op persister. -func NewGlobalBlockPersister(stateDir utils.Option[string], committee *types.Committee) (*GlobalBlockPersister, error) { - sd, ok := stateDir.Get() - if !ok { - return &GlobalBlockPersister{state: utils.NewMutex(&globalBlockState{committee: committee, next: committee.FirstBlock()})}, nil - } - dir := filepath.Join(sd, globalBlocksDir) - iw, err := openIndexedWAL(dir, loadedGlobalBlockCodec{}) - if err != nil { - return nil, fmt.Errorf("open global block WAL in %s: %w", dir, err) - } - - s := &globalBlockState{iw: utils.Some(iw), committee: committee, next: committee.FirstBlock()} - // TODO: avoid loading all blocks on startup; cache only the last N blocks - // (e.g. 1000) in memory instead. - loaded, err := s.loadAll() - if err != nil { - _ = iw.Close() - return nil, err - } - if len(loaded) > 0 { - s.next = loaded[len(loaded)-1].Number + 1 - } - s.loaded = loaded - return &GlobalBlockPersister{ - state: utils.NewMutex(s), - }, nil -} - -// Next returns the next GlobalBlockNumber expected by the persister. -func (gp *GlobalBlockPersister) Next() types.GlobalBlockNumber { - for s := range gp.state.Lock() { - return s.next - } - panic("unreachable") -} - -// LoadedFirst returns the first loaded block number, or committee.FirstBlock() if empty. -func (gp *GlobalBlockPersister) LoadedFirst() types.GlobalBlockNumber { - for s := range gp.state.Lock() { - if len(s.loaded) > 0 { - return s.loaded[0].Number - } - return s.committee.FirstBlock() - } - panic("unreachable") -} - -// ConsumeLoaded returns blocks loaded from the WAL during construction -// and nils the internal slice so the data is not retained. -func (gp *GlobalBlockPersister) ConsumeLoaded() []LoadedGlobalBlock { - for s := range gp.state.Lock() { - loaded := s.loaded - s.loaded = nil - return loaded - } - panic("unreachable") -} - -// PersistBlock appends a block to the WAL. Duplicates are silently ignored. -// Gaps return an error. -func (gp *GlobalBlockPersister) PersistBlock(n types.GlobalBlockNumber, block *types.Block) error { - for s := range gp.state.Lock() { - return s.persistBlock(n, block) - } - panic("unreachable") -} - -// TruncateBefore removes all entries before n from the WAL. -func (gp *GlobalBlockPersister) TruncateBefore(n types.GlobalBlockNumber) error { - for s := range gp.state.Lock() { - return s.truncateBefore(n) - } - panic("unreachable") -} - -// TruncateAfter removes block entries >= n from the WAL, updates the -// persister cursor, and trims loaded data. Called by DataWAL.reconcile -// to remove blocks persisted without corresponding QCs. -func (gp *GlobalBlockPersister) TruncateAfter(n types.GlobalBlockNumber) error { - for s := range gp.state.Lock() { - iw, ok := s.iw.Get() - if ok && iw.Count() > 0 && n < s.next { - firstGlobal := s.next - types.GlobalBlockNumber(iw.Count()) - walIdx := iw.FirstIdx() - if n > firstGlobal { - walIdx += uint64(n - firstGlobal) - } - if err := iw.TruncateAfter(walIdx); err != nil { - return fmt.Errorf("truncate global block WAL after %d: %w", n, err) - } - s.next = firstGlobal + types.GlobalBlockNumber(iw.Count()) - } - trimmed := s.loaded[:0] - for _, lb := range s.loaded { - if lb.Number < n { - trimmed = append(trimmed, lb) - } - } - s.loaded = trimmed - return nil - } - panic("unreachable") -} - -// Close shuts down the WAL. -func (gp *GlobalBlockPersister) Close() error { - for s := range gp.state.Lock() { - iw, ok := s.iw.Get() - if !ok { - return nil - } - s.iw = utils.None[*indexedWAL[LoadedGlobalBlock]]() - return iw.Close() - } - panic("unreachable") -} - -func (s *globalBlockState) loadAll() ([]LoadedGlobalBlock, error) { - iw, ok := s.iw.Get() - if !ok { - return nil, nil - } - return iw.ReadAll() -} diff --git a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go deleted file mode 100644 index 85e50feb7c..0000000000 --- a/sei-tendermint/internal/autobahn/consensus/persist/globalblocks_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package persist - -import ( - "testing" - - "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" -) - -func makeGlobalBlocks(rng utils.Rng, n int) []*types.Block { - blocks := make([]*types.Block, n) - for i := range blocks { - blocks[i] = types.GenBlock(rng) - } - return blocks -} - -func TestNewGlobalBlockPersisterEmptyDir(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.NotNil(t, gp) - require.Equal(t, 0, len(gp.ConsumeLoaded())) - require.Equal(t, fb, gp.Next()) - require.NoError(t, gp.Close()) -} - -func TestNewGlobalBlockPersisterNoop(t *testing.T) { - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 5) - - gp, err := NewGlobalBlockPersister(utils.None[string](), committee) - require.NoError(t, err) - require.NotNil(t, gp) - require.Equal(t, 0, len(gp.ConsumeLoaded())) - - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - require.Equal(t, fb+5, gp.Next()) - - // Truncate in no-op mode. Before the fix, truncateBefore wouldn't - // advance s.next, so subsequent PersistBlock calls at higher numbers - // would fail with "out of sequence". - require.NoError(t, gp.TruncateBefore(fb+10)) - require.Equal(t, fb+10, gp.Next()) - - newBlock := types.GenBlock(rng) - require.NoError(t, gp.PersistBlock(fb+10, newBlock)) - require.Equal(t, fb+11, gp.Next()) - require.NoError(t, gp.Close()) -} - -func TestGlobalBlockPersistAndReload(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 5) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - require.Equal(t, fb+5, gp.Next()) - require.NoError(t, gp.Close()) - - gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - loaded := gp2.ConsumeLoaded() - require.Equal(t, 5, len(loaded)) - for i, lb := range loaded { - require.Equal(t, fb+types.GlobalBlockNumber(i), lb.Number) - } - require.Equal(t, fb+5, gp2.Next()) - require.NoError(t, gp2.Close()) -} - -func TestGlobalBlockTruncateAndReload(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 10) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - require.NoError(t, gp.TruncateBefore(fb+5)) - require.NoError(t, gp.Close()) - - gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - loaded := gp2.ConsumeLoaded() - require.Equal(t, 5, len(loaded)) - require.Equal(t, fb+5, loaded[0].Number) - require.Equal(t, fb+9, loaded[4].Number) - require.Equal(t, fb+10, gp2.Next()) - require.NoError(t, gp2.Close()) -} - -func TestGlobalBlockTruncateAll(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 5) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - require.NoError(t, gp.TruncateBefore(fb+10)) - require.Equal(t, fb+10, gp.Next()) - require.NoError(t, gp.Close()) - - gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 0, len(gp2.ConsumeLoaded())) - require.Equal(t, fb, gp2.Next()) - require.NoError(t, gp2.Close()) -} - -func TestGlobalBlockDuplicateIgnored(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - block := types.GenBlock(rng) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.NoError(t, gp.PersistBlock(fb, block)) - require.NoError(t, gp.PersistBlock(fb, block)) - require.Equal(t, fb+1, gp.Next()) - require.NoError(t, gp.Close()) -} - -func TestGlobalBlockGapError(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - block := types.GenBlock(rng) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - err = gp.PersistBlock(fb+2, block) - require.Error(t, err) - require.Contains(t, err.Error(), "out of sequence") - require.NoError(t, gp.Close()) -} - -func TestGlobalBlockTruncateBeforeNoop(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 5) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - require.NoError(t, gp.TruncateBefore(0)) - require.Equal(t, fb+5, gp.Next()) - require.NoError(t, gp.Close()) - - gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 5, len(gp2.ConsumeLoaded())) - require.NoError(t, gp2.Close()) -} - -func TestGlobalBlockContinueAfterReload(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 10) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i := range 5 { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), blocks[i])) - } - require.NoError(t, gp.Close()) - - gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 5, len(gp2.ConsumeLoaded())) - for i := 5; i < 10; i++ { - require.NoError(t, gp2.PersistBlock(fb+types.GlobalBlockNumber(i), blocks[i])) - } - require.Equal(t, fb+10, gp2.Next()) - require.NoError(t, gp2.Close()) - - gp3, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 10, len(gp3.ConsumeLoaded())) - require.NoError(t, gp3.Close()) -} - -func TestGlobalBlockTruncateAfterMiddle(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 5) - - // First session: persist 5 blocks. - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - require.NoError(t, gp.Close()) - - // Second session: reload, then TruncateAfter trims WAL and loaded. - gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.NoError(t, gp2.TruncateAfter(fb+3)) - require.Equal(t, fb+3, gp2.Next()) - loaded := gp2.ConsumeLoaded() - require.Equal(t, 3, len(loaded)) - require.Equal(t, fb, loaded[0].Number) - require.Equal(t, fb+2, loaded[2].Number) - require.NoError(t, gp2.Close()) - - // Third session: verify WAL persistence. - gp3, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - loaded = gp3.ConsumeLoaded() - require.Equal(t, 3, len(loaded)) - require.Equal(t, fb, loaded[0].Number) - require.Equal(t, fb+2, loaded[2].Number) - require.NoError(t, gp3.Close()) -} - -func TestGlobalBlockTruncateAfterNoop(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 3) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - // TruncateAfter at or past the last block is a no-op. - require.NoError(t, gp.TruncateAfter(fb+11)) - require.Equal(t, fb+3, gp.Next()) - require.NoError(t, gp.Close()) -} - -func TestGlobalBlockTruncateAfterBeforeFirst(t *testing.T) { - dir := t.TempDir() - rng := utils.TestRng() - committee, _ := types.GenCommittee(rng, 3) - fb := committee.FirstBlock() - blocks := makeGlobalBlocks(rng, 5) - - gp, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - for i, b := range blocks { - require.NoError(t, gp.PersistBlock(fb+types.GlobalBlockNumber(i), b)) - } - // Truncate first 2 blocks, leaving [fb+2, fb+5). - require.NoError(t, gp.TruncateBefore(fb+2)) - require.Equal(t, fb+5, gp.Next()) - - // TruncateAfter at or before the first remaining block removes everything. - require.NoError(t, gp.TruncateAfter(fb+2)) - require.Equal(t, fb+2, gp.Next()) // cursor stays at firstGlobal - require.NoError(t, gp.Close()) - - // Reload — should be empty. - gp2, err := NewGlobalBlockPersister(utils.Some(dir), committee) - require.NoError(t, err) - require.Equal(t, 0, len(gp2.ConsumeLoaded())) - require.NoError(t, gp2.Close()) -} diff --git a/sei-tendermint/internal/autobahn/consensus/persist/testonly.go b/sei-tendermint/internal/autobahn/consensus/persist/testonly.go deleted file mode 100644 index 31f852897d..0000000000 --- a/sei-tendermint/internal/autobahn/consensus/persist/testonly.go +++ /dev/null @@ -1,8 +0,0 @@ -package persist - -// SetLoadedForTest overrides loaded block data. Test-only. -func (gp *GlobalBlockPersister) SetLoadedForTest(loaded []LoadedGlobalBlock) { - for s := range gp.state.Lock() { - s.loaded = loaded - } -} diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 43cdb6ef61..6e832e74d0 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -16,11 +16,12 @@ import ( // newTestState creates a State for testing with no persistence and a long // view timeout (so voteTimeout is only triggered explicitly). // keys[0] is used as the node's signing key. -func newTestState(rng utils.Rng) (*State, []types.SecretKey) { + +func newTestState(t testing.TB, rng utils.Rng) (*State, []types.SecretKey) { committee, keys := types.GenCommittee(rng, 3) dataState := utils.OrPanic1(data.NewState( &data.Config{Committee: committee}, - utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)), + newTestBlockDB(t), )) s := utils.OrPanic1(NewState(&Config{ Key: keys[0], @@ -58,7 +59,7 @@ func testTimeoutVotePrepareQC(tv *types.FullTimeoutVote) utils.Option[*types.Pre func TestVoteTimeoutPrepareQC_BothNone(t *testing.T) { rng := utils.TestRng() - s, _ := newTestState(rng) + s, _ := newTestState(t, rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -80,7 +81,7 @@ func TestVoteTimeoutPrepareQC_BothNone(t *testing.T) { func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(rng) + s, keys := newTestState(t, rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -108,7 +109,7 @@ func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { // consecutive timeouts with an offline leader must not lose the PrepareQC. func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(rng) + s, keys := newTestState(t, rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -165,7 +166,7 @@ func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { // view's PrepareQC is preferred over the older inherited one. func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(rng) + s, keys := newTestState(t, rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -215,7 +216,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // the current view's PrepareQC is used. func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(rng) + s, keys := newTestState(t, rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -271,7 +272,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { makeDataState := func() *data.State { return utils.OrPanic1(data.NewState( &data.Config{Committee: committee}, - utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)), + newTestBlockDB(t), )) } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index c4521c919b..b500fb5d66 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -7,7 +7,6 @@ import ( "time" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data/metrics" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -40,101 +39,10 @@ type StateAPI interface { var _ StateAPI = (*State)(nil) -// DataWAL groups the WALs used by State for crash recovery. -// Both fields are always non-nil; when stateDir is None they are no-op persisters. -// This is temporary and will go away once we switch to a proper storage solution. -type DataWAL struct { - Blocks *persist.GlobalBlockPersister - CommitQCs *persist.FullCommitQCPersister -} - -// Close shuts down both WALs. -func (dw *DataWAL) Close() error { - return errors.Join(dw.Blocks.Close(), dw.CommitQCs.Close()) -} - -// TruncateBefore removes entries fully before n from both WALs in parallel. -// A crash between the two calls just leaves stale entries in one WAL, -// which are harmless on reload. -func (dw *DataWAL) TruncateBefore(n types.GlobalBlockNumber) error { - return scope.Parallel(func(ps scope.ParallelScope) error { - ps.Spawn(func() error { - if err := dw.Blocks.TruncateBefore(n); err != nil { - return fmt.Errorf("truncate global block WAL: %w", err) - } - return nil - }) - ps.Spawn(func() error { - if err := dw.CommitQCs.TruncateBefore(n); err != nil { - return fmt.Errorf("truncate full commitqc WAL: %w", err) - } - return nil - }) - return nil - }) -} - -// reconcile fixes cursor inconsistencies between the two WALs that can -// result from crashes during parallel persistence or pruning. -// -// The possible WAL states on startup and how they are handled: -// -// Case Blocks QCs Scenario Action -// 1 empty empty Fresh start no-op -// 2 [a,b] empty QCs lost (corruption) error (statesync needed) -// 3 empty [X,Y) Blocks lost (crash) Prefix: fast-forward blocks.next to X -// 4 [a,b] [X,Y) Normal (a=X, bX) Prefix: truncate QCs to a -// 6 [a,b] [X,Y) Prune crash: QCs ahead (a=Y) Tail: truncate blocks to Y -// 8 [a,b] [X,Y) QCs ahead (normal, b fb { - // Blocks exist but QCs WAL is empty — data is corrupted. - return fmt.Errorf("corrupted WAL: blocks exist but QCs WAL is empty; statesync required to recover") - } - if err := dw.Blocks.TruncateAfter(qcNext); err != nil { - return fmt.Errorf("truncate blocks tail: %w", err) - } - // Fix prefix: align both WALs to the later start. - blocksFirst := dw.Blocks.LoadedFirst() - qcsFirst := dw.CommitQCs.LoadedFirst() - reconciled := max(blocksFirst, qcsFirst) - if reconciled > fb { - if err := dw.TruncateBefore(reconciled); err != nil { - return err - } - } - return nil -} - -// NewDataWAL constructs both global-block and global-commitqc WALs. -// When stateDir is None, the returned persisters are no-ops. -func NewDataWAL(stateDir utils.Option[string], committee *types.Committee) (*DataWAL, error) { - blocks, err := persist.NewGlobalBlockPersister(stateDir, committee) - if err != nil { - return nil, fmt.Errorf("global block WAL: %w", err) - } - commitQCs, err := persist.NewFullCommitQCPersister(stateDir, committee) - if err != nil { - _ = blocks.Close() - return nil, fmt.Errorf("full commitqc WAL: %w", err) - } - dw := &DataWAL{ - Blocks: blocks, - CommitQCs: commitQCs, - } - // Reconcile cursor inconsistency: a crash between the two parallel - // TruncateBefore calls can leave one WAL truncated while the other - // still has stale entries. Advance both to the max starting point. - if err := dw.reconcile(committee); err != nil { - _ = dw.Close() - return nil, fmt.Errorf("reconcile WALs: %w", err) - } - return dw, nil +// blockEntry is a (number, block) pair collected in runPersist batches. +type blockEntry struct { + n types.GlobalBlockNumber + blk *types.Block } type appProposalWithTimestamp struct { @@ -148,15 +56,12 @@ type inner struct { // appProposal[n] contains appProposal block >=n. appProposals map[types.GlobalBlockNumber]appProposalWithTimestamp // [first,nextAppProposal) - // blockHashes is a hash → height index mirroring blocks. Maintained - // in lockstep with blocks via insertBlock / pruneFirst, so it covers - // exactly the same retain window without a separate prune cursor or - // startup warmup. Powers BlockByHash. + // blockHashes is a hash → height index for GlobalBlockByHash. Maintained + // in lockstep with blocks via insertBlock / pruneFirst, covering the same + // retain window. // - // TODO(autobahn): remove once a writer is wired into block execution - // that populates sei-db/ledger_db/block.BlockDB. BlockDB has a built-in - // hash index that survives process restart and lives outside Autobahn's - // RetainHeight pruning, making this in-memory index obsolete. + // TODO: replace with blockDB.ReadBlockByHash once BlockDB exposes the + // primary GlobalBlockNumber alongside the block on a secondary-key lookup. blockHashes map[types.BlockHeaderHash]types.GlobalBlockNumber // first <= nextAppProposal <= nextBlockToPersist <= nextBlock <= nextQC @@ -278,65 +183,150 @@ type State struct { cfg *Config metrics *metrics.Metrics inner utils.Watch[*inner] - dataWAL *DataWAL -} + blockDB types.BlockDB + + // blockDBPersistedQC and blockDBPersistedBlock record where BlockDB's + // write cursors stood at construction time. runPersist initialises from + // these to avoid re-writing data already durably stored in BlockDB. + blockDBPersistedQC types.GlobalBlockNumber + blockDBPersistedBlock types.GlobalBlockNumber +} + +// Close releases resources held by the BlockDB backing this State. +func (s *State) Close() error { + return s.blockDB.Close() +} + +// NewState constructs a new data State backed by blockDB for crash recovery. +// Pass littblock.NewBlockDB(cfg) for a durable store. +func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { + in := newInner(cfg.Committee) + // Restore QCs from BlockDB. On the first QC, skipTo its GlobalRange.First + // to advance past any pruned prefix (a straddling QC starts before the + // prune boundary). Subsequent QCs must be consecutive — insertQC errors + // on any gap. + { + it, err := blockDB.QCs(false) + if err != nil { + return nil, fmt.Errorf("open QC iterator: %w", err) + } + firstQC := true + var iterErr error + for { + var ok bool + ok, iterErr = it.Next() + if iterErr != nil || !ok { + break + } + var qc *types.FullCommitQC + qc, iterErr = it.QC() + if iterErr != nil { + break + } + if firstQC { + firstQC = false + gr := qc.QC().GlobalRange(cfg.Committee) + if gr.First < in.nextQC { + iterErr = fmt.Errorf("QC in BlockDB predates committee genesis %d: got %d", in.nextQC, gr.First) + break + } + in.skipTo(gr.First) + } + if iterErr = in.insertQC(cfg.Committee, qc); iterErr != nil { + iterErr = fmt.Errorf("load QC from BlockDB: %w", iterErr) + break + } + } + closeErr := it.Close() + if iterErr != nil { + return nil, iterErr + } + if closeErr != nil { + return nil, fmt.Errorf("close QC iterator: %w", closeErr) + } + } -// NewState constructs a new data State. -// dataWAL persists blocks and QCs to WALs for crash recovery and provides -// preloaded data from the previous run. Use NewDataWAL to construct it. -func NewState(cfg *Config, dataWAL *DataWAL) (*State, error) { - inner := newInner(cfg.Committee) - // Fast-forward cursors to where data starts. Use blocks as golden: - // per-block pruning may split a QC range, so blocks determine where - // useful data starts. QCs before that are kept for verification but - // don't set inner.first. - blocksFirst := dataWAL.Blocks.LoadedFirst() - qcFirst := dataWAL.CommitQCs.LoadedFirst() - dataFirst := max(blocksFirst, qcFirst) - if dataFirst > cfg.Committee.FirstBlock() { - inner.skipTo(dataFirst) - } - // Restore QCs. insertQC handles partially pruned QCs (range starts - // before inner.first) by skipping the pruned prefix. - for _, qc := range dataWAL.CommitQCs.ConsumeLoaded() { - if err := inner.insertQC(cfg.Committee, qc); err != nil { - return nil, fmt.Errorf("load QC from WAL: %w", err) - } - } - // Restore blocks. Verify contiguity as defense in depth. - expectedBlock := inner.first - for _, lb := range dataWAL.Blocks.ConsumeLoaded() { - if lb.Number < inner.first || lb.Number >= inner.nextQC { - continue // outside QC range (stale from reconcile) - } - if lb.Number != expectedBlock { - return nil, fmt.Errorf("block gap in WAL: expected %d, got %d", expectedBlock, lb.Number) - } - expectedBlock = lb.Number + 1 - if err := lb.Block.Verify(cfg.Committee); err != nil { - return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) - } - if err := inner.insertBlock(cfg.Committee, lb.Number, lb.Block); err != nil { - return nil, fmt.Errorf("load block %d from WAL: %w", lb.Number, err) - } - } - // Advance nextBlock through contiguous blocks. Don't use - // updateNextBlock: stale timestamps would skew metrics. - for ; inner.blocks[inner.nextBlock] != nil; inner.nextBlock++ { + // Restore blocks from BlockDB. The first retained block drives inner.first — + // a straddling QC may start before the prune boundary, so QC data alone + // cannot determine where blocks begin. insertBlock's n >= nextQC guard + // enforces the no-block-without-QC invariant. + { + it, err := blockDB.Blocks(false) + if err != nil { + return nil, fmt.Errorf("open block iterator: %w", err) + } + var ( + iterErr error + firstBlock = true + nextExpect types.GlobalBlockNumber + ) + for { + var ok bool + ok, iterErr = it.Next() + if iterErr != nil || !ok { + break + } + n := it.Number() + if n >= in.nextQC { + iterErr = fmt.Errorf("block %d in BlockDB has no QC coverage (nextQC=%d)", n, in.nextQC) + break + } + if firstBlock { + firstBlock = false + if n < in.first { + iterErr = fmt.Errorf("block %d in BlockDB predates first QC start %d", n, in.first) + break + } + in.first = n + in.nextBlock = n + in.nextAppProposal = n + nextExpect = n + } + // updateNextBlock only advances nextBlock through contiguous present + // entries, so runPersist always writes [persistedBlock, nextBlock) + // fully populated. A gap here means BlockDB corruption. + if n != nextExpect { + iterErr = fmt.Errorf("block gap in BlockDB: expected %d, got %d", nextExpect, n) + break + } + nextExpect++ + var blk *types.Block + blk, iterErr = it.Block() + if iterErr != nil { + break + } + if iterErr = blk.Verify(cfg.Committee); iterErr != nil { + iterErr = fmt.Errorf("verify block %d from BlockDB: %w", n, iterErr) + break + } + if iterErr = in.insertBlock(cfg.Committee, n, blk); iterErr != nil { + iterErr = fmt.Errorf("insert block %d from BlockDB: %w", n, iterErr) + break + } + } + closeErr := it.Close() + if iterErr != nil { + return nil, iterErr + } + if closeErr != nil { + return nil, fmt.Errorf("close block iterator: %w", closeErr) + } } - // Data loaded from WALs was already persisted in the previous run. - inner.nextBlockToPersist = inner.nextBlock - // WAL cursor consistency was resolved by DataWAL.reconcile at construction. - // Verify the blocks persister cursor is not behind inner.nextBlock. - if dataWAL.Blocks.Next() < inner.nextBlock { - return nil, fmt.Errorf("blocks WAL cursor %d behind inner.nextBlock %d after reconciliation", - dataWAL.Blocks.Next(), inner.nextBlock) + + // Advance nextBlock through contiguous loaded blocks. Don't use + // updateNextBlock: stale timestamps would skew metrics. + for ; in.blocks[in.nextBlock] != nil; in.nextBlock++ { } + // Data loaded from BlockDB was already durably persisted. + in.nextBlockToPersist = in.nextBlock + return &State{ - cfg: cfg, - metrics: metrics.Get(), - inner: utils.NewWatch(inner), - dataWAL: dataWAL, + cfg: cfg, + metrics: metrics.Get(), + inner: utils.NewWatch(in), + blockDB: blockDB, + blockDBPersistedQC: in.nextQC, + blockDBPersistedBlock: in.nextBlock, }, nil } @@ -450,20 +440,7 @@ func (s *State) NextBlock() types.GlobalBlockNumber { // GlobalBlockByHash returns the finalized GlobalBlock whose stored header // hashes to the given value, or None if no such block is currently in the -// retained range. The lookup-and-construct happens under a single lock so -// the returned block matches the looked-up hash atomically — pruning can't -// change which height a hash maps to between the index check and the block -// construction. Tracks the same retain window as Block / GlobalBlock since -// the hash index is maintained in lockstep by insertBlock / pruneFirst. -// -// Returns an error in the signature for forward-compat with the eventual -// switch to sei-db/ledger_db/block.BlockDB.GetBlockByHash. Today's -// in-memory implementation never errors. -// -// TODO(autobahn): when BlockDB is wired, take a ctx parameter and narrow -// the error contract — db-internal errors should surface by shutting down -// the persistence background task (matching how persistence handles errors -// today), so the query path's error stays bounded to context.Canceled. +// retained range. Non-blocking. func (s *State) GlobalBlockByHash(hash types.BlockHeaderHash) (utils.Option[*types.GlobalBlock], error) { for inner := range s.inner.Lock() { n, ok := inner.blockHashes[hash] @@ -645,41 +622,40 @@ func (s *State) PruneBefore(retainFrom types.GlobalBlockNumber) error { if firstToKeep <= inner.first { return nil } - // Keep at least one entry so WALs are never empty on restart. + // Keep at least one entry so BlockDB is never empty on restart. for inner.first+1 < firstToKeep { inner.pruneFirst(pruningTime, s.metrics) } ctrl.Updated() truncateTo = utils.Some(inner.first) } - // Truncate WALs outside the lock to avoid holding it during disk I/O. + // Prune BlockDB outside the lock to avoid holding it during disk I/O. if n, ok := truncateTo.Get(); ok { - return s.dataWAL.TruncateBefore(n) + return s.blockDB.PruneBefore(n) } return nil } -// runPersist is a background goroutine that persists blocks and QCs to WALs. +// runPersist is a background goroutine that persists blocks and QCs to BlockDB. // It waits for in-memory data to advance past the persistence cursors, then -// writes QCs and blocks in parallel. QCs are persisted up to nextQC (eagerly), -// blocks up to nextBlock. nextBlockToPersist advances to min(persistedQC, persistedBlock) +// writes QCs (first, per the BlockDB contract) and blocks, then flushes once +// per batch. nextBlockToPersist advances to min(persistedQC, persistedBlock) // to unblock PushAppHash only when both are durable. // Errors propagate vertically (kill the component). func (s *State) runPersist(ctx context.Context) error { - // Initialize from nextBlockToPersist, not nextQC/nextBlock. PushQC may - // have been called before Run() (race between p2p startup and Run), - // advancing nextQC/nextBlock beyond what's been persisted. Starting - // from nextBlockToPersist ensures we don't skip unpersisted data. - var persistedQC, persistedBlock types.GlobalBlockNumber - for inner := range s.inner.Lock() { - persistedQC = inner.nextBlockToPersist - persistedBlock = inner.nextBlockToPersist - } + // Initialise from BlockDB's cursors at construction time, not from + // inner.nextQC/nextBlock. PushQC may have been called before Run() + // (race between p2p startup and Run), advancing inner cursors beyond + // what's durably in BlockDB. Starting from blockDBPersistedQC/Block + // ensures we don't skip unpersisted data. + persistedQC := s.blockDBPersistedQC + persistedBlock := s.blockDBPersistedBlock + for { // Wait for unpersisted data and snapshot what needs writing. type batch struct { qcs []*types.FullCommitQC - blocks []persist.LoadedGlobalBlock + blocks []blockEntry qcEnd types.GlobalBlockNumber blockEnd types.GlobalBlockNumber } @@ -704,33 +680,25 @@ func (s *State) runPersist(ctx context.Context) error { } // Collect blocks for [persistedBlock, nextBlock). for n := persistedBlock; n < inner.nextBlock; n++ { - b.blocks = append(b.blocks, persist.LoadedGlobalBlock{ - Number: n, - Block: inner.blocks[n], - }) + b.blocks = append(b.blocks, blockEntry{n: n, blk: inner.blocks[n]}) } } - // Persist QCs and blocks in parallel. - if err := scope.Parallel(func(ps scope.ParallelScope) error { - ps.Spawn(func() error { - for _, qc := range b.qcs { - if err := s.dataWAL.CommitQCs.PersistQC(qc); err != nil { - return fmt.Errorf("persist full commitqc: %w", err) - } - } - return nil - }) - ps.Spawn(func() error { - for _, lb := range b.blocks { - if err := s.dataWAL.Blocks.PersistBlock(lb.Number, lb.Block); err != nil { - return fmt.Errorf("persist global block %d: %w", lb.Number, err) - } - } - return nil - }) - return nil - }); err != nil { - return err + // Write QCs first (BlockDB contract: QC must precede covered blocks). + for _, qc := range b.qcs { + gr := qc.QC().GlobalRange(s.cfg.Committee) + if err := s.blockDB.WriteQC(gr.First, gr.Next, qc); err != nil { + return fmt.Errorf("write QC [%d,%d): %w", gr.First, gr.Next, err) + } + } + for _, lb := range b.blocks { + if err := s.blockDB.WriteBlock(lb.n, lb.blk); err != nil { + return fmt.Errorf("write block %d: %w", lb.n, err) + } + } + // Flush once per batch before advancing nextBlockToPersist, so that + // PushAppHash only unblocks after data is crash-durable. + if err := s.blockDB.Flush(); err != nil { + return fmt.Errorf("flush BlockDB: %w", err) } persistedQC = b.qcEnd persistedBlock = b.blockEnd @@ -770,10 +738,10 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { } pruningTime = inner.appProposals[inner.first].timestamp.Add(after) } - // Truncate WALs outside the lock to avoid holding it during disk I/O. + // Prune BlockDB outside the lock to avoid holding it during disk I/O. if n, ok := truncateTo.Get(); ok { - if err := s.dataWAL.TruncateBefore(n); err != nil { - return err + if err := s.blockDB.PruneBefore(n); err != nil { + return fmt.Errorf("prune BlockDB before %d: %w", n, err) } } // Wait until the next pruning time. diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 7f5c57b477..299d1bbaae 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -5,14 +5,12 @@ import ( "errors" "fmt" "maps" - "os" - "path/filepath" "testing" "testing/synctest" "time" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" - "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" @@ -47,6 +45,38 @@ func snapshot(s *State) Snapshot { panic("unreachable") } +// newTestLittDB opens (or creates) a LittDB-backed BlockDB at dir. +// Retention is set to 1ns so ForceGC reclaims pruned data immediately in tests. +func newTestLittDB(t *testing.T, dir string) types.BlockDB { + t.Helper() + cfg, err := littblock.DefaultConfig(dir) + if err != nil { + t.Fatalf("littblock.DefaultConfig: %v", err) + } + cfg.Retention = time.Nanosecond + db, err := littblock.NewBlockDB(cfg) + if err != nil { + t.Fatalf("littblock.NewBlockDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return db +} + +// writeToBlockDB writes QC+block pairs sequentially to db and flushes once. +// qcs[i] and blockss[i] must correspond; QCs must be in ascending order. +func writeToBlockDB(t *testing.T, db types.BlockDB, committee *types.Committee, qcs []*types.FullCommitQC, blockss [][]*types.Block) { + t.Helper() + for i, qc := range qcs { + gr := qc.QC().GlobalRange(committee) + require.NoError(t, db.WriteQC(gr.First, gr.Next, qc)) + for j, n := 0, gr.First; n < gr.Next; n++ { + require.NoError(t, db.WriteBlock(n, blockss[i][j])) + j++ + } + } + require.NoError(t, db.Flush()) +} + func TestState(t *testing.T) { ctx := t.Context() rng := utils.TestRng() @@ -54,7 +84,7 @@ func TestState(t *testing.T) { if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ Committee: committee, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -127,7 +157,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { committee, keys := types.GenCommittee(rng, 3) state := utils.OrPanic1(NewState(&Config{ Committee: committee, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) // Push a valid QC to advance inner.nextQC. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -237,7 +267,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { committee, keys := types.GenCommittee(rng, 3) state := utils.OrPanic1(NewState(&Config{ Committee: committee, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) // Push qc1 with NO blocks — only the QC is stored. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -283,7 +313,7 @@ func TestExecution(t *testing.T) { if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { state := utils.OrPanic1(NewState(&Config{ Committee: committee, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -327,7 +357,7 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { state := utils.OrPanic1(NewState(&Config{ Committee: committee, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) // Push QC without blocks. qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -341,18 +371,6 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { require.Equal(t, blocks[0], got) } -// TestGlobalBlockByHash isolates the hash-keyed lookup from the -// consensus-driven harness. We push a single QC + block via the same code -// path the network would (insertBlock writes to inner.blockHashes), then: -// -// - the block's own header hash resolves to Some(*GlobalBlock) with the -// expected height/header/payload — the index points at the right -// block, atomically with the block construction -// - a zero hash and a random hash both resolve to None — distinct -// unknown-hash inputs all read as "not found", no panics -// - err is nil throughout — today's in-memory implementation has no -// failure mode; the error return on GlobalBlockByHash is reserved -// for the future BlockDB-backed path func TestGlobalBlockByHash(t *testing.T) { ctx := t.Context() rng := utils.TestRng() @@ -360,7 +378,7 @@ func TestGlobalBlockByHash(t *testing.T) { state := utils.OrPanic1(NewState(&Config{ Committee: committee, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, blocks)) @@ -393,471 +411,189 @@ func TestGlobalBlockByHash(t *testing.T) { require.False(t, ok, "GlobalBlockByHash(random) returned Some") } -// ── Reconcile tests (grouped by case number) ────────────────────────── +// ── Recovery tests ──────────────────────────────────────────────────── -// TestStateRecoveryBlocksOnly simulates a crash after blocks are written -// TestReconcileCase1Empty verifies that reconcile is a no-op on a fresh -// WAL directory with no data. -func TestReconcileCase1Empty(t *testing.T) { - t.Log("Reconcile case 1: Fresh start (empty/empty)") +// TestRecoveryEmpty verifies that NewState is a no-op on a fresh BlockDB. +func TestRecoveryEmpty(t *testing.T) { rng := utils.TestRng() committee, _ := types.GenCommittee(rng, 3) dir := t.TempDir() fb := committee.FirstBlock() - dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw)) - + db := newTestLittDB(t, dir) + state := utils.OrPanic1(NewState(&Config{Committee: committee}, db)) for inner := range state.inner.Lock() { require.Equal(t, fb, inner.first) require.Equal(t, fb, inner.nextQC) require.Equal(t, fb, inner.nextBlock) } - require.NoError(t, dw.Close()) + require.NoError(t, state.Close()) } -// TestReconcileCase2Corrupted verifies that when blocks are persisted but -// QCs WAL is deleted (corruption), NewDataWAL returns a corruption error. -func TestReconcileCase2Corrupted(t *testing.T) { - t.Log("Reconcile case 2: QCs lost (corruption), returns error") +// TestRecoveryNormal verifies that NewState fully restores QCs and blocks +// from BlockDB on restart. +func TestRecoveryNormal(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() - // Persist blocks and QCs normally. - qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) - gr1 := qc1.QC().GlobalRange(committee) - - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) - for i, n := 0, gr1.First; n < gr1.Next; n++ { - require.NoError(t, dw1.Blocks.PersistBlock(n, blocks1[i])) - i++ - } - require.NoError(t, dw1.Close()) - - // Simulate corruption: delete QCs WAL. - require.NoError(t, os.RemoveAll(filepath.Join(dir, "fullcommitqcs"))) - - // Reopen should fail — blocks exist but QCs are gone. - _, err := NewDataWAL(utils.Some(dir), committee) - require.Error(t, err) - require.Contains(t, err.Error(), "corrupted") -} - -// TestStateRecoveryQCsOnly simulates a crash after QCs are written to the -// WAL but before blocks are written (or after blocks WAL is truncated but -// QCs WAL is not). On recovery, QCs are loaded but no blocks exist. -// The cursor sync in NewState must advance the blocks persister so that -// subsequent PersistBlock calls don't fail with "out of sequence". -func TestReconcileCase3BlocksLost(t *testing.T) { - t.Log("Reconcile case 3: Blocks lost (crash), QCs survive") - ctx := t.Context() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - dir := t.TempDir() - - // First run: populate both WALs. - qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) - gr1 := qc1.QC().GlobalRange(committee) - - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state1 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw1)) - require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) - require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) - for i, n := 0, gr1.First; n < gr1.Next; n++ { - require.NoError(t, dw1.Blocks.PersistBlock(n, blocks1[i])) - i++ - } - require.NoError(t, dw1.Close()) - - // Simulate crash: delete blocks WAL directory, keep QCs WAL. - require.NoError(t, os.RemoveAll(filepath.Join(dir, "globalblocks"))) - - // Second run: only QCs WAL survives. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) - - // QCs loaded, blocks empty. The state needs blocks re-pushed. - // Without the cursor sync fix, PushBlock here would fail with - // "out of sequence" because the blocks persister cursor is 0 - // but inner.nextBlock was advanced by QC data. - for i, n := 0, gr1.First; n < gr1.Next; n++ { - require.NoError(t, state2.PushBlock(ctx, n, blocks1[i])) - i++ - } - - for n := gr1.First; n < gr1.Next; n++ { - got, err := state2.TryBlock(n) - require.NoError(t, err) - require.NotNil(t, got) - } - - // State should accept the next QC normally. - qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) - require.NoError(t, state2.PushQC(ctx, qc2, blocks2)) - require.Equal(t, qc2.QC().GlobalRange(committee).Next, state2.NextBlock()) - require.NoError(t, dw2.Close()) -} - -func TestReconcileCase4Normal(t *testing.T) { - t.Log("Reconcile case 4: Normal (a=X, bX). -// Blocks WAL was truncated further than QCs during a crash between -// the two parallel TruncateBefore calls. -func TestReconcileCase5BlocksAhead(t *testing.T) { - t.Log("Reconcile case 5: Prune crash, blocks ahead (a>X)") - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - dir := t.TempDir() - - qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) - gr1 := qc1.QC().GlobalRange(committee) - gr2 := qc2.QC().GlobalRange(committee) + state := utils.OrPanic1(NewState(&Config{Committee: committee}, newTestLittDB(t, t.TempDir()))) + require.NoError(t, state.PushQC(ctx, qc1, blocks1)) + require.NoError(t, state.PushQC(ctx, qc2, blocks2)) + require.NoError(t, state.PushQC(ctx, qc3, blocks3)) - // Persist both QCs and all blocks. - dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - require.NoError(t, dw.CommitQCs.PersistQC(qc1)) - require.NoError(t, dw.CommitQCs.PersistQC(qc2)) - allBlocks := append(blocks1, blocks2...) - for i, n := 0, gr1.First; n < gr2.Next; n++ { - require.NoError(t, dw.Blocks.PersistBlock(n, allBlocks[i])) - i++ + // Execute all blocks so they are eligible for pruning. + runCtx, cancel := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- state.Run(runCtx) }() + for n := gr1.First; n < gr3.Next; n++ { + require.NoError(t, state.PushAppHash(ctx, n, types.GenAppHash(rng))) } - // Simulate crash: blocks truncated to gr2.First but QCs not. - require.NoError(t, dw.Blocks.TruncateBefore(gr2.First)) - require.NoError(t, dw.Close()) + cancel() + <-done - // Reopen: blocks start at gr2.First, QCs start at gr1.First. - // Reconcile should truncate QCs to match blocks. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) + // Prune qc1 entirely (keep from qc2 onward). + require.NoError(t, state.PruneBefore(gr2.First)) + // PruneBefore keeps one sentinel block at firstToKeep-1; capture it. + var survivingBlock types.GlobalBlockNumber for inner := range state.inner.Lock() { - require.Equal(t, gr2.First, inner.first) + survivingBlock = inner.first } - // Blocks in qc2's range should be available. - for n := gr2.First; n < gr2.Next; n++ { + for n := gr1.First; n < survivingBlock; n++ { + _, err := state.TryBlock(n) + require.ErrorIs(t, err, ErrPruned) + } + // survivingBlock itself and everything from gr2.First onward must be readable. + for n := survivingBlock; n < gr3.Next; n++ { got, err := state.TryBlock(n) require.NoError(t, err) require.NotNil(t, got) } - require.NoError(t, dw2.Close()) } -// TestStateRecoverySkipsStaleBlocks verifies that blocks loaded from the WAL -// that fall before the first QC range are not inserted into the state map. -// This can happen when the QCs WAL is pruned but the blocks WAL still has -// older entries (e.g., a crash between the two TruncateBefore calls). -func TestReconcileCase6QCsAhead(t *testing.T) { - t.Log("Reconcile case 6: Prune crash, QCs ahead (a= nextQC are ignored. This can happen when blocks are -// persisted in parallel with QCs and we crash before QCs catch up. -func TestReconcileCase7BlocksPastQCs(t *testing.T) { - t.Log("Reconcile case 7: Persist crash, blocks past QCs (b>=Y)") - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - dir := t.TempDir() - - // Build 2 sequential QCs. - qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) - gr1 := qc1.QC().GlobalRange(committee) - gr2 := qc2.QC().GlobalRange(committee) - - // Persist only qc1 to QCs WAL but persist ALL blocks (qc1 + qc2) to blocks WAL. - // This simulates blocks being persisted ahead of QCs. - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) - allBlocks := append(blocks1, blocks2...) - for i, n := 0, gr1.First; n < gr2.Next; n++ { - require.NoError(t, dw1.Blocks.PersistBlock(n, allBlocks[i])) - i++ - } - require.NoError(t, dw1.Close()) - - // On recovery, only blocks within qc1's range should be loaded. - // Blocks in qc2's range have no QC and should be ignored. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) - - // Blocks in qc1's range should be available. - for n := gr1.First; n < gr1.Next; n++ { + for n := gr2.First; n < gr3.Next; n++ { got, err := state2.TryBlock(n) require.NoError(t, err) require.NotNil(t, got) } - - // Blocks in qc2's range should NOT be in the state (no QC for them). - for n := gr2.First; n < gr2.Next; n++ { - _, err := state2.TryBlock(n) - require.ErrorIs(t, err, ErrNotFound) - } - - // State should still accept qc2 normally. - require.NoError(t, state2.PushQC(t.Context(), qc2, blocks2)) - require.Equal(t, gr2.Next, state2.NextBlock()) - require.NoError(t, dw2.Close()) -} - -// TestReconcileTruncatesBlocksTail verifies that blocks persisted without -// corresponding QCs are removed during WAL reconciliation. This prevents -// stale blocks from blocking new (different) blocks at the same positions. -func TestReconcileCase7BlocksTail(t *testing.T) { - t.Log("Reconcile case 7: Persist crash, tail truncation with re-push") - ctx := t.Context() - rng := utils.TestRng() - committee, keys := types.GenCommittee(rng, 3) - dir := t.TempDir() - - // Build 2 sequential QCs. - qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) - qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) - gr1 := qc1.QC().GlobalRange(committee) - gr2 := qc2.QC().GlobalRange(committee) - - // Persist qc1 to both WALs, but only blocks (not QC) for qc2. - // This simulates a crash during parallel persistence in runPersist. - dw1 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - require.NoError(t, dw1.CommitQCs.PersistQC(qc1)) - allBlocks := append(blocks1, blocks2...) - for i, n := 0, gr1.First; n < gr2.Next; n++ { - require.NoError(t, dw1.Blocks.PersistBlock(n, allBlocks[i])) - i++ - } - require.NoError(t, dw1.Close()) - - // Reopen: reconcile should truncate the blocks tail (qc2's blocks). - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - - // Blocks persister cursor should now match QCs range. - require.Equal(t, dw2.CommitQCs.Next(), dw2.Blocks.Next()) - - state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) - - // qc1's blocks should be available. - for n := gr1.First; n < gr1.Next; n++ { - got, err := state.TryBlock(n) - require.NoError(t, err) - require.NotNil(t, got) - } - - // Now push qc2 with its blocks — should succeed because stale blocks - // were removed and the persister cursor was reset. - require.NoError(t, state.PushQC(ctx, qc2, blocks2)) - require.Equal(t, gr2.Next, state.NextBlock()) - require.NoError(t, dw2.Close()) + require.NoError(t, state2.Close()) } -// TestStateRecoveryBlocksBehindQCs verifies recovery when QCs cover a wider -// range than blocks (e.g. crash during block persistence). Blocks up to -// blocksEnd are available; the rest must be re-fetched via PushBlock. -func TestReconcileCase8BlocksBehind(t *testing.T) { - t.Log("Reconcile case 8: QCs ahead normal (b= mid { + require.NoError(t, db1.WriteBlock(n, blocks1[i])) + } i++ } - // Simulate per-block pruning: truncate blocks prefix, keep QC intact. - // This creates the split: QC covers [gr1.First, gr1.Next) but blocks - // start at mid. - mid := gr1.First + (gr1.Next-gr1.First)/2 - require.NoError(t, dw.Blocks.TruncateBefore(mid)) - require.NoError(t, dw.Close()) + require.NoError(t, db1.Flush()) + require.NoError(t, db1.Close()) - // Recovery should use blocks as golden, not try to re-fetch pruned prefix. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) + // Recovery should use blocks as golden: first == mid, not gr1.First. + db2 := newTestLittDB(t, dir) + state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) - // first should be at mid (where blocks start), not gr1.First (where QC starts). for inner := range state2.inner.Lock() { require.Equal(t, mid, inner.first, "first should be where blocks start, not where QC starts") require.Equal(t, gr1.Next, inner.nextQC, "QC should still cover the full range") } - - // Blocks before mid should be pruned, not ErrNotFound. for n := gr1.First; n < mid; n++ { _, err := state2.TryBlock(n) require.ErrorIs(t, err, ErrPruned) } - - // Blocks at mid and above should be available. for n := mid; n < gr1.Next; n++ { got, err := state2.TryBlock(n) require.NoError(t, err) require.NotNil(t, got) } - require.NoError(t, dw2.Close()) + require.NoError(t, state2.Close()) } -// TestStateRejectsBlockGapInWAL verifies that NewState returns an error -// if loaded blocks have a gap (defense in depth for future storage backends). -func TestReconcileBlockGap(t *testing.T) { - // Reconcile: block gap in WAL detected (defense in depth) - t.Log("Reconcile: block gap in WAL detected (defense in depth)") +// TestRecoveryQCsNoBlocks verifies that NewState succeeds when the DB contains +// QCs but no blocks (crash between QC flush and block writes). The state +// cursor sits at the QC start with nextBlock == first and no block data. +func TestRecoveryQCsNoBlocks(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) dir := t.TempDir() - qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + qc1, _ := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange(committee) - if gr1.Next-gr1.First < 3 { - t.Skip("need at least 3 blocks in QC range to test gap") + + db1 := newTestLittDB(t, dir) + require.NoError(t, db1.WriteQC(gr1.First, gr1.Next, qc1)) + require.NoError(t, db1.Flush()) + require.NoError(t, db1.Close()) + + db2 := newTestLittDB(t, dir) + state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) + defer func() { require.NoError(t, state2.Close()) }() + + for inner := range state2.inner.Lock() { + require.Equal(t, gr1.First, inner.first) + require.Equal(t, gr1.Next, inner.nextQC) + require.Equal(t, gr1.First, inner.nextBlock) } + for n := gr1.First; n < gr1.Next; n++ { + _, err := state2.TryBlock(n) + require.ErrorIs(t, err, ErrNotFound) + } +} - // Persist QC to real WAL so it loads on restart. - dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - require.NoError(t, dw.CommitQCs.PersistQC(qc1)) - require.NoError(t, dw.Close()) - - // Reopen and inject blocks with a gap via test helper. - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - dw2.Blocks.SetLoadedForTest([]persist.LoadedGlobalBlock{ - {Number: gr1.First, Block: blocks1[0]}, - // skip gr1.First+1 - {Number: gr1.First + 2, Block: blocks1[2]}, - }) +// TestRecoveryBlockGap verifies that NewState returns an error when blocks in +// BlockDB are not contiguous. WriteBlock only enforces strictly-ascending and +// QC coverage, not continuity, so a gap can arise from corruption. +func TestRecoveryBlockGap(t *testing.T) { + rng := utils.TestRng() + committee, keys := types.GenCommittee(rng, 3) + dir := t.TempDir() + + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + gr1 := qc1.QC().GlobalRange(committee) - _, err := NewState(&Config{Committee: committee}, dw2) + // TestCommitQC generates 10 global blocks, so the range is always wide + // enough to skip one block in the middle. + mid := gr1.First + (gr1.Next-gr1.First)/2 + + db1 := newTestLittDB(t, dir) + require.NoError(t, db1.WriteQC(gr1.First, gr1.Next, qc1)) + for i, n := 0, gr1.First; n < gr1.Next; n++ { + if n != mid { + require.NoError(t, db1.WriteBlock(n, blocks1[i])) + } + i++ + } + require.NoError(t, db1.Flush()) + require.NoError(t, db1.Close()) + + db2 := newTestLittDB(t, dir) + _, err := NewState(&Config{Committee: committee}, db2) require.Error(t, err) - require.Contains(t, err.Error(), "block gap") - require.NoError(t, dw2.Close()) + require.Contains(t, err.Error(), "block gap in BlockDB") + require.NoError(t, db2.Close()) } -// ── Non-reconcile tests ─────────────────────────────────────────────── +// ── Non-recovery tests ──────────────────────────────────────────────── // TestPruningKeepsLastQCRange verifies that pruning never removes the last -// QC range, ensuring WALs are never empty and inner.first is recoverable -// on restart. +// retained block, and that a restart from a BlockDB with only that block +// recovers correctly. func TestPruningKeepsLastQCRange(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - dir := t.TempDir() - - dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw)) - // Push one QC with blocks and execute all of them. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange(committee) - require.NoError(t, state.PushQC(ctx, qc1, blocks1)) - // Persist and execute. + // In-memory: push QC, execute all blocks, then prune everything. + state1 := utils.OrPanic1(NewState(&Config{Committee: committee}, newTestLittDB(t, t.TempDir()))) + require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) + runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) - go func() { done <- state.Run(runCtx) }() + go func() { done <- state1.Run(runCtx) }() for n := gr1.First; n < gr1.Next; n++ { - require.NoError(t, state.PushAppHash(ctx, n, types.GenAppHash(rng))) + require.NoError(t, state1.PushAppHash(ctx, n, types.GenAppHash(rng))) } cancel() <-done - // Try to prune everything — at least one block should survive. - state.PruneBefore(gr1.Next) - for inner := range state.inner.Lock() { - require.Less(t, inner.first, gr1.Next, - "pruning should keep at least one block") + require.NoError(t, state1.PruneBefore(gr1.Next)) + var survivingBlock types.GlobalBlockNumber + for inner := range state1.inner.Lock() { + survivingBlock = inner.first + require.Less(t, survivingBlock, gr1.Next, "pruning should keep at least one block") + } + for n := gr1.First; n < survivingBlock; n++ { + _, err := state1.TryBlock(n) + require.ErrorIs(t, err, ErrPruned) } - // Restart from WALs — inner.first should be recoverable. - require.NoError(t, dw.Close()) - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) + // Restart: build a fresh BlockDB containing only the surviving block (as GC + // would leave it) and verify NewState correctly sets first = survivingBlock. + dir := t.TempDir() + db := newTestLittDB(t, dir) + require.NoError(t, db.WriteQC(gr1.First, gr1.Next, qc1)) + require.NoError(t, db.WriteBlock(survivingBlock, blocks1[survivingBlock-gr1.First])) + require.NoError(t, db.Flush()) + require.NoError(t, db.Close()) + + db2 := newTestLittDB(t, dir) + state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) for inner := range state2.inner.Lock() { - require.GreaterOrEqual(t, inner.first, gr1.First, - "after restart, first should be recoverable from WAL") + require.Equal(t, survivingBlock, inner.first, + "after restart, first should match the surviving block") } - require.NoError(t, dw2.Close()) + require.NoError(t, state2.Close()) } -// TestPruningWithPartialQCRange verifies that per-block pruning can split -// a QC range, and recovery handles it correctly by using blocks as the -// golden source for inner.first. +// TestPruningWithPartialQCRange verifies per-block pruning across QC ranges: +// pruning advances first in-memory, and a restart from a post-GC BlockDB +// (with only the last surviving block) recovers correctly. func TestPruningWithPartialQCRange(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - dir := t.TempDir() - dw := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state := utils.OrPanic1(NewState(&Config{Committee: committee}, dw)) - - // Push two QCs so we have two distinct QC ranges. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) gr1 := qc1.QC().GlobalRange(committee) gr2 := qc2.QC().GlobalRange(committee) - require.NoError(t, state.PushQC(ctx, qc1, blocks1)) - require.NoError(t, state.PushQC(ctx, qc2, blocks2)) - // Run to persist, then execute all blocks. + // In-memory: push 2 QCs, execute, then prune. + state1 := utils.OrPanic1(NewState(&Config{Committee: committee}, newTestLittDB(t, t.TempDir()))) + require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) + require.NoError(t, state1.PushQC(ctx, qc2, blocks2)) + runCtx, cancel := context.WithCancel(ctx) done := make(chan error, 1) - go func() { done <- state.Run(runCtx) }() + go func() { done <- state1.Run(runCtx) }() for n := gr1.First; n < gr2.Next; n++ { - require.NoError(t, state.PushAppHash(ctx, n, types.GenAppHash(rng))) + require.NoError(t, state1.PushAppHash(ctx, n, types.GenAppHash(rng))) } cancel() <-done - // Prune into the middle of qc1's range. Per-block pruning allows this. + // Per-block prune into middle of qc1's range. midQC1 := gr1.First + (gr1.Next-gr1.First)/2 if midQC1 > gr1.First+1 { - state.PruneBefore(midQC1) - for inner := range state.inner.Lock() { + require.NoError(t, state1.PruneBefore(midQC1)) + for inner := range state1.inner.Lock() { require.Greater(t, inner.first, gr1.First, "per-block pruning should advance past gr1.First") } } // Prune past qc1 entirely. - state.PruneBefore(gr2.Next) - - // Restart — recovery uses blocks as golden, skips partial QC prefix. - require.NoError(t, dw.Close()) - dw2 := utils.OrPanic1(NewDataWAL(utils.Some(dir), committee)) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, dw2)) + require.NoError(t, state1.PruneBefore(gr2.Next)) + var survivingBlock types.GlobalBlockNumber + for inner := range state1.inner.Lock() { + survivingBlock = inner.first + require.GreaterOrEqual(t, survivingBlock, gr2.First) + require.Less(t, survivingBlock, gr2.Next, "at least one block should survive") + } + for n := gr1.First; n < survivingBlock; n++ { + _, err := state1.TryBlock(n) + require.ErrorIs(t, err, ErrPruned) + } - // first should be recoverable and blocks should be available. + // Restart: build a fresh BlockDB containing only the surviving block from qc2 + // (qc1 is pruned entirely) and verify NewState recovers correctly. + dir := t.TempDir() + db := newTestLittDB(t, dir) + require.NoError(t, db.WriteQC(gr2.First, gr2.Next, qc2)) + require.NoError(t, db.WriteBlock(survivingBlock, blocks2[survivingBlock-gr2.First])) + require.NoError(t, db.Flush()) + require.NoError(t, db.Close()) + + db2 := newTestLittDB(t, dir) + state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) for inner := range state2.inner.Lock() { - require.GreaterOrEqual(t, inner.first, gr2.First, - "after restart, first should be at or past gr2.First") - require.Less(t, inner.first, gr2.Next, - "at least one block should survive") + require.Equal(t, survivingBlock, inner.first, + "after restart, first should match the surviving block") } - require.NoError(t, dw2.Close()) + require.NoError(t, state2.Close()) } // TestRunPruningEmptyState verifies that runPruning does not panic when @@ -1078,7 +856,7 @@ func TestRunPruningEmptyState(t *testing.T) { state := utils.OrPanic1(NewState(&Config{ Committee: committee, PruneAfter: utils.Some(time.Duration(0)), - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) // Run briefly — runPruning should not panic on empty state. ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) @@ -1094,7 +872,7 @@ func TestPushBlockWaitsForQC(t *testing.T) { state := utils.OrPanic1(NewState(&Config{ Committee: committee, - }, utils.OrPanic1(NewDataWAL(utils.None[string](), committee)))) + }, newTestLittDB(t, t.TempDir()))) // Push first QC covering [0, N). qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index b0d7987797..f5b49a99bf 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -12,6 +12,7 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/holiman/uint256" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" @@ -33,6 +34,13 @@ type txSpec struct { Payload [32]byte } +func newTestBlockDB(t testing.TB) types.BlockDB { + cfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) + db := utils.OrPanic1(littblock.NewBlockDB(cfg)) + t.Cleanup(func() { _ = db.Close() }) + return db +} + func (tx *txSpec) encode() []byte { return utils.OrPanic1(binary.Append(nil, binary.BigEndian, tx)) } @@ -227,11 +235,11 @@ func (env *testEnv) Run(ctx context.Context) error { })) } -func newTestEnv(rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { +func newTestEnv(t testing.TB, rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { committee, keys := types.GenCommittee(rng, 1) dataState := utils.OrPanic1(data.NewState( &data.Config{Committee: committee}, - utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)), + newTestBlockDB(t), )) consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], @@ -253,7 +261,7 @@ func TestInsertTx_TooLargeTx(t *testing.T) { ctx := t.Context() rng := utils.TestRng() app := newTestApp() - env := newTestEnv(rng, app.Cfg(), app.Proxy()) + env := newTestEnv(t, rng, app.Cfg(), app.Proxy()) // Tx with size exceeding block limit. tx := utils.GenBytes(rng, int(types.MaxTxsBytesPerBlock+1)) // Should be rejected by mempool. @@ -267,7 +275,7 @@ func TestInsertTx_GasWantedExceeded(t *testing.T) { rng := utils.TestRng() app := newTestApp() cfg := app.Cfg() - env := newTestEnv(rng, cfg, app.Proxy()) + env := newTestEnv(t, rng, cfg, app.Proxy()) // Tx with gas wanted exceeding block limit addr, nonce := app.NewAccount(rng) tx := env.genTx(rng, addr, nonce) @@ -285,7 +293,7 @@ func TestInsertTx_GasEstimatedExceeded(t *testing.T) { cfg := app.Cfg() cfg.MaxGasEstimatedPerBlock = 10000 cfg.MaxGasWantedPerBlock = cfg.MaxGasEstimatedPerBlock * 2 - env := newTestEnv(rng, cfg, app.Proxy()) + env := newTestEnv(t, rng, cfg, app.Proxy()) // Tx with gas wanted exceeding block limit addr, nonce := app.NewAccount(rng) tx := env.genTx(rng, addr, nonce) @@ -301,7 +309,7 @@ func TestInsertTx_AppRejectsTx(t *testing.T) { ctx := t.Context() rng := utils.TestRng() app := newTestApp() - env := newTestEnv(rng, app.Cfg(), app.Proxy()) + env := newTestEnv(t, rng, app.Cfg(), app.Proxy()) // Construct tx with invalid encoding. tx := utils.GenBytes(rng, 1) _, err := decodeTxSpec(tx) @@ -317,7 +325,7 @@ func TestMempool_BadNonce(t *testing.T) { ctx := t.Context() rng := utils.TestRng() app := newTestApp() - env := newTestEnv(rng, app.Cfg(), app.Proxy()) + env := newTestEnv(t, rng, app.Cfg(), app.Proxy()) // Initialize nonce for random account. addr := common.Address(utils.GenBytes(rng, len(common.Address{}))) nonce := uint64(rng.Intn(10000)) @@ -363,7 +371,7 @@ func TestMempool_HappyPath(t *testing.T) { cfg.MaxTxsPerBlock = 20 cfg.MaxGasWantedPerBlock = 100 cfg.MaxGasEstimatedPerBlock = 100 - env := newTestEnv(rng, cfg, app.Proxy()) + env := newTestEnv(t, rng, cfg, app.Proxy()) want := utils.NewMutex(map[common.Address][]*txSpec{}) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("env", func() error { return env.Run(ctx) }) @@ -445,7 +453,7 @@ func TestMempool_EvmTxByHash(t *testing.T) { app := newTestApp() cfg := app.Cfg() cfg.BlockInterval = time.Millisecond - env := newTestEnv(rng, cfg, app.Proxy()) + env := newTestEnv(t, rng, cfg, app.Proxy()) addr, nonce := app.NewAccount(rng) txs := utils.Slice( diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 2406af6350..34a8570e9d 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -17,7 +17,7 @@ func TestAvailClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) - env := newTestEnv(committee) + env := newTestEnv(t, committee) var nodes []*testNode activeKeys := keys[:3] // keys are sorted by weight, so that's ok. totalWeight := uint64(0) diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index 08eb75f77d..b81fa46232 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -15,7 +15,7 @@ func TestConsensusClientServer(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 7) firstBlock := committee.FirstBlock() - env := newTestEnv(committee) + env := newTestEnv(t, committee) // Run only a subset of replicas, to enforce timeouts. var nodes []*testNode for _, key := range types.TestKeysWithWeight(committee, keys, committee.CommitQuorum()) { diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index f5249517b9..47ac9b2911 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -23,8 +24,11 @@ type testNode struct { func defaultViewTimeout(view types.View) time.Duration { return time.Hour } -func newTestNode(committee *types.Committee, cfg *consensus.Config) *testNode { - dataState := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, utils.OrPanic1(data.NewDataWAL(utils.None[string](), committee)))) +func newTestNode(t testing.TB, committee *types.Committee, cfg *consensus.Config) *testNode { + blockCfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) + db := utils.OrPanic1(littblock.NewBlockDB(blockCfg)) + t.Cleanup(func() { _ = db.Close() }) + dataState := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, db)) consensusState, err := consensus.NewState(cfg, dataState) if err != nil { panic(fmt.Sprintf("consensus.NewState(): %v", err)) @@ -46,17 +50,18 @@ func (n *testNode) Run(ctx context.Context) error { } type testEnv struct { + t testing.TB committee *types.Committee nodes map[types.PublicKey]*testNode } -func newTestEnv(committee *types.Committee) *testEnv { - return &testEnv{committee, map[types.PublicKey]*testNode{}} +func newTestEnv(t testing.TB, committee *types.Committee) *testEnv { + return &testEnv{t, committee, map[types.PublicKey]*testNode{}} } // Call AddNode BEFORE Run. func (e *testEnv) AddNode(key types.SecretKey) *testNode { - n := newTestNode(e.committee, &consensus.Config{ + n := newTestNode(e.t, e.committee, &consensus.Config{ Key: key, ViewTimeout: func(view types.View) time.Duration { if _, ok := e.nodes[e.committee.Leader(view)]; ok { @@ -92,7 +97,7 @@ func TestDataClientServer(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 2) firstBlock := committee.FirstBlock() - env := newTestEnv(committee) + env := newTestEnv(t, committee) server := env.AddNode(keys[0]) client := env.AddNode(keys[1]) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { diff --git a/sei-tendermint/internal/p2p/giga_router.go b/sei-tendermint/internal/p2p/giga_router.go index 8ec60a977d..669b3863a3 100644 --- a/sei-tendermint/internal/p2p/giga_router.go +++ b/sei-tendermint/internal/p2p/giga_router.go @@ -32,9 +32,9 @@ type GigaRouterCommonConfig struct { DialInterval time.Duration ValidatorAddrs map[atypes.PublicKey]GigaNodeAddr GenDoc *types.GenesisDoc - // PersistentStateDir is the on-disk root for the data WAL (and the - // validator's consensus persister in a sibling subdir). None ⇒ in-memory. - PersistentStateDir utils.Option[string] + // PersistentStateDir is the on-disk root for durable state (BlockDB, + // hashvault, and the validator's consensus persister in sibling subdirs). + PersistentStateDir string // App is the ABCI proxy executeBlock drives. NewGigaValidatorRouter // also passes it to producer.NewState so the producer's internal // mempool drives the same proxy. diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 4ee2c58d11..7ef5477bb9 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -11,6 +11,7 @@ import ( "sync/atomic" "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-db/state_db/sc/hashvault" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" @@ -49,17 +50,13 @@ type gigaRouterCommon struct { inboundFullnodeCount atomic.Int64 inboundFullnodeCap int64 - // hashVault is the app-hash equivocation guard. runExecute owns its lifecycle: it builds the - // vault on entry (durable under PersistentStateDir, or a no-op when disabled / in-memory) and - // closes it on exit. + // hashVault is the app-hash equivocation guard. runExecute owns its lifecycle: + // it builds the vault on entry (durable under PersistentStateDir) and closes it on exit. hashVault hashvault.HashVault } // buildDataState validates the common config and constructs the data -// layer (committee, WAL, State) shared by both giga constructors. -// -// TODO(autobahn): once sei-db/ledger_db/block.BlockDB has a writer wired -// (see BlockByNumber's TODO), the data WAL is redundant. +// layer (committee, BlockDB, State) shared by both giga constructors. func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { if cfg.GenDoc.InitialHeight < 1 { return nil, fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) @@ -70,6 +67,9 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { if cfg.MaxInboundFullnodePeers < 0 || cfg.MaxInboundFullnodePeers > maxInboundFullnodePeers { return nil, fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) } + if cfg.PersistentStateDir == "" { + return nil, fmt.Errorf("GigaRouterCommonConfig.PersistentStateDir must not be empty") + } committee, err := atypes.NewRoundRobinElection( slices.Collect(maps.Keys(cfg.ValidatorAddrs)), atypes.GlobalBlockNumber(cfg.GenDoc.InitialHeight), // nolint:gosec // verified to be positive. @@ -78,12 +78,17 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { if err != nil { return nil, fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) } - dataWAL, err := data.NewDataWAL(cfg.PersistentStateDir, committee) + blockCfg, err := littblock.DefaultConfig(filepath.Join(cfg.PersistentStateDir, "blockdb")) if err != nil { - return nil, fmt.Errorf("data.NewDataWAL(): %w", err) + return nil, fmt.Errorf("littblock.DefaultConfig: %w", err) } - dataState, err := data.NewState(&data.Config{Committee: committee}, dataWAL) + blockDB, err := littblock.NewBlockDB(blockCfg) if err != nil { + return nil, fmt.Errorf("littblock.NewBlockDB: %w", err) + } + dataState, err := data.NewState(&data.Config{Committee: committee}, blockDB) + if err != nil { + _ = blockDB.Close() return nil, fmt.Errorf("data.NewState(): %w", err) } return dataState, nil @@ -114,14 +119,6 @@ func (r *gigaRouterCommon) MaxGasEstimatedPerBlock() uint64 { // evmrpc does not read them on the receipt path. If gb.Header is nil // BlockID.Hash also stays empty; if gb.Payload is nil Block.Data.Txs // stays empty (see the malformed-block handling below). -// -// TODO(autobahn): switch this to read from sei-db/ledger_db/block.BlockDB -// once a writer is wired (e.g. from app.FinalizeBlocker or executeBlock). -// Today no production code calls BlockDB.WriteBlock, so Autobahn's in-memory -// data.State is the only place a full block lives — but it's pruned per -// Sei's RetainHeight and exposes only a height index (no GetBlockByHash). -// BlockDB has the right shape (height + hash indexes, async pruning) and -// is the long-term home for this read path. func (r *gigaRouterCommon) BlockByNumber(ctx context.Context, n atypes.GlobalBlockNumber) (*coretypes.ResultBlock, error) { gb, err := r.data.GlobalBlock(ctx, n) if err != nil { @@ -143,15 +140,11 @@ func (r *gigaRouterCommon) BlockByNumber(ctx context.Context, n atypes.GlobalBlo // (same translation as BlockByNumber). Matches CometBFT semantics for // unknown hashes: returns &ResultBlock{Block: nil} with no error. // -// Lookup-and-construct happens under a single data.State lock acquire, so -// the returned block matches the requested hash atomically. Hashes below -// the pruning watermark are not indexed and read as "unknown". Wrong-size -// inputs are rejected at the call site (env.BlockByHash) so this method -// can stay strongly typed on atypes.BlockHeaderHash. -// -// TODO(autobahn): replace this with a direct read from -// sei-db/ledger_db/block.BlockDB.GetBlockByHash once a writer is wired into -// block execution. The data.State-side index can also go away at that point. +// The lookup delegates to data.State.GlobalBlockByHash, which reads from +// BlockDB (durable, survives restarts). Hashes not yet persisted or below +// the prune watermark are read as "unknown". Wrong-size inputs are rejected +// at the call site (env.BlockByHash) so this method can stay strongly typed +// on atypes.BlockHeaderHash. func (r *gigaRouterCommon) BlockByHash(ctx context.Context, hash atypes.BlockHeaderHash) (*coretypes.ResultBlock, error) { opt, err := r.data.GlobalBlockByHash(hash) if err != nil { @@ -269,8 +262,6 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo // returns a durable Pebble-backed vault rooted at /hashvault, alongside the // other Autobahn on-disk state. It returns a no-op vault (no protection) in two cases: the operator // explicitly set HashVaultDisabledUnsafe — logged loudly as unsafe — or there is no persistent -// state directory (in-memory mode, e.g. tests), where a durable vault would be pointless because -// the data WAL is itself a no-op (see data.NewDataWAL). func buildHashVault(ctx context.Context, cfg *GigaRouterCommonConfig) (hashvault.HashVault, error) { if cfg.HashVaultDisabledUnsafe { logger.Error("################################################################") @@ -281,15 +272,8 @@ func buildHashVault(ctx context.Context, cfg *GigaRouterCommonConfig) (hashvault logger.Error("################################################################") return hashvault.NewNoopHashVault(), nil } - dir, ok := cfg.PersistentStateDir.Get() - if !ok { - logger.Error("HASHVAULT DISABLED: no persistent state dir (in-memory mode); using no-op vault. " + - "This node has NO app-hash equivocation protection. This is expected only for in-memory " + - "runs such as tests; a production node reaching this path is misconfigured.") - return hashvault.NewNoopHashVault(), nil - } hvCfg := hashvault.DefaultHashVaultConfig() - hvCfg.DataDir = filepath.Join(dir, "hashvault") + hvCfg.DataDir = filepath.Join(cfg.PersistentStateDir, "hashvault") return hashvault.NewPebbleHashVault(ctx, hvCfg) } diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go index bcd191785b..830471a964 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go @@ -57,14 +57,12 @@ func TestGigaRouter_Fullnode(t *testing.T) { app := newTestApp() proxyApp := proxy.New(app) - // Fullnodes have no validator key and no Producer config. The data WAL - // reads PersistentStateDir = None (in-memory) for this construction- - // shape check; App is required for executeBlock but isn't exercised by - // this test. + // Fullnodes have no validator key and no Producer config. + // App is required for executeBlock but isn't exercised by this test. router, err := NewGigaFullnodeRouter(&GigaRouterCommonConfig{ DialInterval: time.Second, ValidatorAddrs: addrs, - PersistentStateDir: utils.None[string](), + PersistentStateDir: t.TempDir(), App: proxyApp, GenDoc: genDoc, }, makeKey(rng)) diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 4c65cd1538..628b8575cb 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -33,7 +33,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV consensusState, err := consensus.NewState(&consensus.Config{ Key: cfg.ValidatorKey, ViewTimeout: cfg.ViewTimeout, - PersistentStateDir: cfg.PersistentStateDir, + PersistentStateDir: utils.Some(cfg.PersistentStateDir), }, dataState) if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 78a5f6cc66..65702d7db5 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -67,7 +67,7 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { // Aggressive dialing rate to speed up startup. DialInterval: 100 * time.Millisecond, ValidatorAddrs: addrs, - PersistentStateDir: utils.None[string](), + PersistentStateDir: t.TempDir(), App: proxyApp, GenDoc: genDoc, }, @@ -224,7 +224,7 @@ func TestGigaRouter_EvmProxy(t *testing.T) { GigaRouterCommonConfig: GigaRouterCommonConfig{ DialInterval: time.Second, ValidatorAddrs: addrs, - PersistentStateDir: utils.None[string](), + PersistentStateDir: utils.Some(t.TempDir()), App: proxy.New(newTestApp()), GenDoc: genDoc, }, diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 0b81dbe4db..362ee38c57 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -331,10 +331,10 @@ func buildGigaRouter( // rootifyPersistentStateDir resolves a relative PersistentStateDir // against the node's --home dir (mirrors config.go's rootify). Absolute -// paths pass through; None stays None. +// paths pass through unchanged. func rootifyPersistentStateDir(rootDir string, c *p2p.GigaRouterCommonConfig) { - if dir, ok := c.PersistentStateDir.Get(); ok && !filepath.IsAbs(dir) { - c.PersistentStateDir = utils.Some(filepath.Join(rootDir, dir)) + if !filepath.IsAbs(c.PersistentStateDir) { + c.PersistentStateDir = filepath.Join(rootDir, c.PersistentStateDir) } } diff --git a/sei-tendermint/node/setup_test.go b/sei-tendermint/node/setup_test.go index 375a0098fa..150545705f 100644 --- a/sei-tendermint/node/setup_test.go +++ b/sei-tendermint/node/setup_test.go @@ -54,7 +54,7 @@ func writeAutobahnConfig(t *testing.T, fc *config.AutobahnFileConfig) string { return path } -func defaultFileConfig(validators []config.AutobahnValidator) *config.AutobahnFileConfig { +func defaultFileConfig(t testing.TB, validators []config.AutobahnValidator) *config.AutobahnFileConfig { return &config.AutobahnFileConfig{ Validators: validators, MaxTxsPerBlock: 5_000, @@ -62,7 +62,7 @@ func defaultFileConfig(validators []config.AutobahnValidator) *config.AutobahnFi AllowEmptyBlocks: false, BlockInterval: utils.Duration(400 * time.Millisecond), ViewTimeout: utils.Duration(1500 * time.Millisecond), - PersistentStateDir: utils.None[string](), + PersistentStateDir: t.TempDir(), DialInterval: utils.Duration(10 * time.Second), } } @@ -105,7 +105,7 @@ func TestBuildGigaConfig_EnabledWithValidators(t *testing.T) { AllowEmptyBlocks: true, BlockInterval: utils.Duration(200 * time.Millisecond), ViewTimeout: utils.Duration(3 * time.Second), - PersistentStateDir: utils.Some("/tmp/autobahn-state"), + PersistentStateDir: "/tmp/autobahn-state", DialInterval: utils.Duration(5 * time.Second), } cfgFile := writeAutobahnConfig(t, fc) @@ -122,9 +122,7 @@ func TestBuildGigaConfig_EnabledWithValidators(t *testing.T) { assert.Equal(t, 5*time.Second, result.DialInterval) assert.Equal(t, 3*time.Second, result.ViewTimeout(atypes.View{})) - persistDir, ok := result.PersistentStateDir.Get() - require.True(t, ok) - assert.Equal(t, "/tmp/autobahn-state", persistDir) + assert.Equal(t, "/tmp/autobahn-state", result.PersistentStateDir) // Verify the validator key is derived from the validator-key seed, not the node key. expectedValPub := makeTestValidatorKey([]byte("val1-seed")).Public() @@ -145,7 +143,7 @@ func TestBuildGigaConfig_EnabledWithValidators(t *testing.T) { func TestBuildGigaConfig_NoneMaxTxsPerSecond(t *testing.T) { v1 := makeValidator([]byte("val-seed"), []byte("node-seed"), "localhost:26660") - fc := defaultFileConfig([]config.AutobahnValidator{v1}) + fc := defaultFileConfig(t, []config.AutobahnValidator{v1}) cfgFile := writeAutobahnConfig(t, fc) nodeKey := makeTestNodeKey([]byte("node-seed")) valKey := makeTestValidatorKey([]byte("val-seed")) @@ -157,19 +155,6 @@ func TestBuildGigaConfig_NoneMaxTxsPerSecond(t *testing.T) { assert.False(t, result.Producer.MaxTxsPerSecond.IsPresent()) } -func TestBuildGigaConfig_NonePersistentStateDir(t *testing.T) { - v1 := makeValidator([]byte("val-seed"), []byte("node-seed"), "localhost:26660") - fc := defaultFileConfig([]config.AutobahnValidator{v1}) - cfgFile := writeAutobahnConfig(t, fc) - nodeKey := makeTestNodeKey([]byte("node-seed")) - valKey := makeTestValidatorKey([]byte("val-seed")) - txMempool, genDoc := makeTestGigaDeps() - - result, err := buildValidatorGigaConfig(cfgFile, nodeKey, valKey, txMempool, genDoc) - require.NoError(t, err) - assert.False(t, result.PersistentStateDir.IsPresent()) -} - func TestBuildGigaConfig_InvalidConfigFile(t *testing.T) { nodeKey := makeTestNodeKey([]byte("node-seed")) valKey := makeTestValidatorKey([]byte("val-seed")) @@ -188,7 +173,7 @@ func TestBuildGigaConfig_InvalidConfigFile(t *testing.T) { }) t.Run("empty validators", func(t *testing.T) { - fc := defaultFileConfig([]config.AutobahnValidator{}) + fc := defaultFileConfig(t, []config.AutobahnValidator{}) cfgFile := writeAutobahnConfig(t, fc) _, err := buildValidatorGigaConfig(cfgFile, nodeKey, valKey, txMempool, genDoc) assert.Error(t, err) @@ -202,7 +187,7 @@ func TestBuildGigaConfig_GenesisMaxGas(t *testing.T) { nodeKey := makeTestNodeKey([]byte("node-seed")) valKey := makeTestValidatorKey([]byte("val-seed")) v := makeValidator([]byte("val-seed"), []byte("node-seed"), "localhost:26660") - cfgFile := writeAutobahnConfig(t, defaultFileConfig([]config.AutobahnValidator{v})) + cfgFile := writeAutobahnConfig(t, defaultFileConfig(t, []config.AutobahnValidator{v})) t.Run("nil ConsensusParams", func(t *testing.T) { txMempool, genDoc := makeTestGigaDeps() @@ -229,7 +214,7 @@ func TestBuildGigaConfig_GenesisMaxGas(t *testing.T) { func TestBuildGigaConfig_DuplicateValidatorKey(t *testing.T) { v1 := makeValidator([]byte("val-seed"), []byte("node1"), "localhost:26660") v1dup := makeValidator([]byte("val-seed"), []byte("node2"), "localhost:26661") - fc := defaultFileConfig([]config.AutobahnValidator{v1, v1dup}) + fc := defaultFileConfig(t, []config.AutobahnValidator{v1, v1dup}) data, _ := json.Marshal(fc) path := filepath.Join(t.TempDir(), "autobahn.json") os.WriteFile(path, data, 0644) @@ -245,7 +230,7 @@ func TestBuildGigaConfig_DuplicateValidatorKey(t *testing.T) { func TestBuildGigaConfig_DuplicateNodeKey(t *testing.T) { v1 := makeValidator([]byte("val1"), []byte("same-node"), "localhost:26660") v2 := makeValidator([]byte("val2"), []byte("same-node"), "localhost:26661") - fc := defaultFileConfig([]config.AutobahnValidator{v1, v2}) + fc := defaultFileConfig(t, []config.AutobahnValidator{v1, v2}) data, _ := json.Marshal(fc) path := filepath.Join(t.TempDir(), "autobahn.json") os.WriteFile(path, data, 0644) @@ -260,7 +245,7 @@ func TestBuildGigaConfig_DuplicateNodeKey(t *testing.T) { func TestBuildGigaConfig_SelfNotInValidators(t *testing.T) { v1 := makeValidator([]byte("other-val"), []byte("other-node"), "localhost:26660") - cfgFile := writeAutobahnConfig(t, defaultFileConfig([]config.AutobahnValidator{v1})) + cfgFile := writeAutobahnConfig(t, defaultFileConfig(t, []config.AutobahnValidator{v1})) nodeKey := makeTestNodeKey([]byte("my-node")) valKey := makeTestValidatorKey([]byte("my-val")) txMempool, genDoc := makeTestGigaDeps() @@ -273,7 +258,7 @@ func TestBuildGigaConfig_SelfNotInValidators(t *testing.T) { func TestBuildGigaConfig_NodeKeyMismatch(t *testing.T) { // Validator entry has the right val key but wrong node key. v1 := makeValidator([]byte("my-val"), []byte("wrong-node"), "localhost:26660") - cfgFile := writeAutobahnConfig(t, defaultFileConfig([]config.AutobahnValidator{v1})) + cfgFile := writeAutobahnConfig(t, defaultFileConfig(t, []config.AutobahnValidator{v1})) nodeKey := makeTestNodeKey([]byte("my-node")) valKey := makeTestValidatorKey([]byte("my-val")) txMempool, genDoc := makeTestGigaDeps() From 49cb596999b68d827cd22e598ac9211d4e361879 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 16:08:13 -0400 Subject: [PATCH 02/46] fix: close data.State on router shutdown; guard rootify against empty PersistentStateDir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data.State.Close() (which closes the LittDB backing store) was never called on graceful shutdown, leaving file locks and mmaps unreleased — mirroring how hashVault is explicitly closed in runExecute. Wire it into both validator and fullnode router Run() by capturing the scope.Run error and calling Close() after all goroutines have exited. rootifyPersistentStateDir called filepath.Join(rootDir, "") = rootDir for an empty PersistentStateDir, turning the empty-string guard in buildDataState into a dead code path. Skip rootify when the path is already empty so the guard fires as intended. Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_fullnode.go | 4 +++- sei-tendermint/internal/p2p/giga_router_validator.go | 4 +++- sei-tendermint/node/setup.go | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index afd0e1fa3f..67d0fa27e5 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -42,7 +42,7 @@ func (r *gigaFullnodeRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaFullnodeRouter) Run(ctx context.Context) error { - return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Single-active subscriber: walk the committee in a stable order, // move to the next on disconnect. Avoids the N× QC duplication of // fanning out to every committee member. @@ -56,6 +56,8 @@ func (r *gigaFullnodeRouter) Run(ctx context.Context) error { s.SpawnNamed("service", func() error { return r.service.Run(ctx) }) return nil }) + _ = r.data.Close() + return err } // runFullnodeSubscriber: pick a committee member, dial + block-sync, diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 628b8575cb..3fcfefc05f 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -62,7 +62,7 @@ func (r *gigaValidatorRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaValidatorRouter) Run(ctx context.Context) error { - return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Validators dial every committee member in parallel — consensus // voting needs fan-out, not stickiness. Same connections also // serve block sync between committee peers. @@ -84,6 +84,8 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { s.SpawnNamed("service", func() error { return r.service.Run(ctx) }) return nil }) + _ = r.data.Close() + return err } // EvmProxy on the validator returns None when the sender's shard owner is diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 362ee38c57..e20ce44842 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -333,7 +333,7 @@ func buildGigaRouter( // against the node's --home dir (mirrors config.go's rootify). Absolute // paths pass through unchanged. func rootifyPersistentStateDir(rootDir string, c *p2p.GigaRouterCommonConfig) { - if !filepath.IsAbs(c.PersistentStateDir) { + if c.PersistentStateDir != "" && !filepath.IsAbs(c.PersistentStateDir) { c.PersistentStateDir = filepath.Join(rootDir, c.PersistentStateDir) } } From 4887f74a396c5fc64cc72cd521585daed0099c9e Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 16:20:58 -0400 Subject: [PATCH 03/46] =?UTF-8?q?docs:=20update=20--persistent-state-dir?= =?UTF-8?q?=20flag=20help=20=E2=80=94=20persistence=20is=20now=20required?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old help text said pass empty to disable persistence and run in-memory; that mode was removed when PersistentStateDir became a required plain string. Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go index f770d2a00b..d46031c989 100644 --- a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go +++ b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go @@ -105,6 +105,6 @@ Output is written to the file specified by --output.`, }, } cmd.Flags().StringP("output", "o", "", "output file path for the autobahn config") - 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") + cmd.Flags().String("persistent-state-dir", "data/autobahn", "directory to persist autobahn state across restarts; relative paths are resolved against the node's --home dir; must not be empty") return cmd } From 5f57fe5e121e0358247572c1461c7ab255947d56 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 16:41:39 -0400 Subject: [PATCH 04/46] refactor: PersistentStateDir required string in consensus/avail; fix validator constructor leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - consensus/, avail/, persist/: change PersistentStateDir from utils.Option[string] to plain string; remove all None fast-paths (no-op persisters, loadPersistedState skip). The single empty-string guard now lives exclusively in buildDataState. - giga_router_validator: close dataState when consensus.NewState fails to avoid leaking the open LittDB. - Update all test call sites: utils.None[string]() → t.TempDir(), utils.Some(dir) → dir. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/inner_test.go | 4 +- .../internal/autobahn/avail/state.go | 18 ++--- .../internal/autobahn/avail/state_test.go | 68 +++++++++---------- .../internal/autobahn/consensus/inner.go | 9 --- .../internal/autobahn/consensus/inner_test.go | 9 +-- .../autobahn/consensus/persist/blocks.go | 32 ++------- .../autobahn/consensus/persist/blocks_test.go | 54 +++++++-------- .../autobahn/consensus/persist/commitqcs.go | 8 +-- .../consensus/persist/commitqcs_test.go | 50 +++++++------- .../autobahn/consensus/persist/persist.go | 34 ++++------ .../consensus/persist/persist_test.go | 49 +++++++------ .../internal/autobahn/consensus/state.go | 18 ++--- .../internal/autobahn/consensus/state_test.go | 4 +- .../internal/p2p/giga_router_validator.go | 3 +- 14 files changed, 152 insertions(+), 208 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 30bd7c9212..d491d2defe 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -44,7 +44,7 @@ func TestPruneMismatchedIndices(t *testing.T) { t.Logf("test State.PushAppQC") ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) - state, err := NewState(keys[0], ds, utils.None[string]()) + state, err := NewState(keys[0], ds, t.TempDir()) require.NoError(t, err) require.Error(t, state.PushAppQC(makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") require.Error(t, state.PushAppQC(makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") @@ -53,7 +53,7 @@ func TestPruneMismatchedIndices(t *testing.T) { t.Logf("test inner.prune") ds = utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) - state, err = NewState(keys[0], ds, utils.None[string]()) + state, err = NewState(keys[0], ds, t.TempDir()) require.NoError(t, err) for inner := range state.inner.Lock() { _, err := inner.prune(committee, makeAppQC(qc1, qc0), qc1) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index d6e49ebb18..888a24611d 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -88,11 +88,10 @@ var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAncho }, } -// loadPersistedState creates persisters for the given directory option and loads -// any existing state from disk. When dir is None, all persisters are no-op -// and no state is loaded. When a prune anchor is present, stale commitQCs and -// blocks below the anchor are filtered out before returning. -func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailState], persisters, error) { +// loadPersistedState creates persisters for the given directory and loads +// any existing state from disk. When a prune anchor is present, stale commitQCs +// and blocks below the anchor are filtered out before returning. +func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters, error) { prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) if err != nil { return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) @@ -109,11 +108,6 @@ func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailStat } pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} - - if _, ok := dir.Get(); !ok { - return utils.None[*loadedAvailState](), pers, nil - } - loaded := &loadedAvailState{commitQCs: commitQCs, blocks: blocks} if raw, ok := persistedPruneAnchor.Get(); ok { @@ -148,9 +142,7 @@ func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailStat } // NewState constructs a new availability state. -// stateDir is None when persistence is disabled (testing only); a no-op -// persist goroutine still runs to bump cursors without disk I/O. -func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[string]) (*State, error) { +func NewState(key types.SecretKey, data *data.State, stateDir string) (*State, error) { loaded, pers, err := loadPersistedState(stateDir) if err != nil { return nil, err diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index a35bb46993..74f3768b20 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -85,7 +85,7 @@ func qcPayloadHashes(qc *types.FullCommitQC) byLane[types.PayloadHash] { } func TestState(t *testing.T) { - testState(t, utils.None[string]()) + testState(t, t.TempDir()) } // TestStateWithPersistence runs the same flow as TestState but with disk @@ -94,11 +94,11 @@ func TestState(t *testing.T) { // pruned map entries. func TestStateWithPersistence(t *testing.T) { for range 5 { - testState(t, utils.Some(t.TempDir())) + testState(t, t.TempDir()) } } -func testState(t *testing.T, stateDir utils.Option[string]) { +func testState(t *testing.T, stateDir string) { t.Helper() ctx := t.Context() rng := utils.TestRng() @@ -240,7 +240,7 @@ func TestStateRestartFromPersisted(t *testing.T) { s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) if err != nil { return err } @@ -312,7 +312,7 @@ func TestStateRestartFromPersisted(t *testing.T) { // Phase 2: Restart from the same directory. ds2 := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) - state2, err := NewState(keys[0], ds2, utils.Some(dir)) + state2, err := NewState(keys[0], ds2, dir) require.NoError(t, err) got, ok := state2.LastAppQC().Get() @@ -338,7 +338,7 @@ func TestStateMismatchedQCs(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{ Committee: committee, }, newTestBlockDB(t))) - state, err := NewState(keys[0], ds, utils.None[string]()) + state, err := NewState(keys[0], ds, t.TempDir()) require.NoError(t, err) // Helper to create a CommitQC for a specific index @@ -396,7 +396,7 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{ Committee: committee, }, newTestBlockDB(t))) - state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + state := utils.OrPanic1(NewState(keys[0], ds, t.TempDir())) // Produce a valid first block on our lane. _, err := state.ProduceLocalBlock(state.NextBlock(keys[0].Public()), types.GenPayload(rng)) @@ -421,7 +421,7 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{ Committee: committee, }, newTestBlockDB(t))) - state := utils.OrPanic1(NewState(keys[0], ds, utils.None[string]())) + state := utils.OrPanic1(NewState(keys[0], ds, t.TempDir())) // Create a block on keys[0]'s lane but sign it with keys[1]. lane := keys[0].Public() @@ -441,7 +441,7 @@ func TestNewStateWithPersistence(t *testing.T) { dir := t.TempDir() ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) // No persisted AppQC → None. @@ -460,7 +460,7 @@ func TestNewStateWithPersistence(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist commitQCs 0-7 so the matching one at roadIdx exists. - cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) + cp, _, err := persist.NewCommitQCPersister(dir) require.NoError(t, err) prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC @@ -472,14 +472,14 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist prune anchor (AppQC + CommitQC pair). - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), CommitQc: types.CommitQCConv.Encode(pruneQC), })) - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) aq := state.LastAppQC() @@ -497,7 +497,7 @@ func TestNewStateWithPersistence(t *testing.T) { lane := keys[0].Public() // Persist blocks using BlockPersister. - bp, _, err := persist.NewBlockPersister(utils.Some(dir)) + bp, _, err := persist.NewBlockPersister(dir) require.NoError(t, err) var parent types.BlockHeaderHash @@ -509,7 +509,7 @@ func TestNewStateWithPersistence(t *testing.T) { } // Now construct state — it should load the blocks. - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) require.Equal(t, types.BlockNumber(3), state.NextBlock(lane)) @@ -526,7 +526,7 @@ func TestNewStateWithPersistence(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist commitQCs 0-2 so the matching one at roadIdx exists. - cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) + cp, _, err := persist.NewCommitQCPersister(dir) require.NoError(t, err) prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC @@ -538,7 +538,7 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist prune anchor (AppQC + CommitQC pair). - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -546,7 +546,7 @@ func TestNewStateWithPersistence(t *testing.T) { })) // Persist blocks starting at 0 (nil laneQCs → lr.First()=0 after prune). - bp, _, err := persist.NewBlockPersister(utils.Some(dir)) + bp, _, err := persist.NewBlockPersister(dir) require.NoError(t, err) var parent types.BlockHeaderHash @@ -557,7 +557,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, bp.MaybePruneAndPersistLane(lane, utils.None[*types.CommitQC](), []*types.Signed[*types.LaneProposal]{signed}, noBlockCB)) } - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) got, ok := state.LastAppQC().Get() @@ -573,7 +573,7 @@ func TestNewStateWithPersistence(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) // Persist CommitQCs to disk. - cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) + cp, _, err := persist.NewCommitQCPersister(dir) require.NoError(t, err) qcs := make([]*types.CommitQC, 3) @@ -584,7 +584,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) // All 3 commitQCs should be loaded (no AppQC to skip past). @@ -604,7 +604,7 @@ func TestNewStateWithPersistence(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist CommitQCs 0-4. - cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) + cp, _, err := persist.NewCommitQCPersister(dir) require.NoError(t, err) qcs := make([]*types.CommitQC, 5) @@ -616,14 +616,14 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist prune anchor (AppQC + CommitQC pair at roadIdx). - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), CommitQc: types.CommitQCConv.Encode(qcs[roadIdx]), })) - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) // inner.prune(appQC@1, commitQC@1) sets commitQCs.first = 1. @@ -645,7 +645,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist prune anchor (AppQC + CommitQC pair at road index 0). appProposal := types.NewAppProposal(initialBlock, 0, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -655,7 +655,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist QCs 0, 1, 2 contiguously, then try to skip to 5. // MaybePruneAndPersist enforces strict sequential order, so the gap // is caught at write time rather than at load time. - cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) + cp, _, err := persist.NewCommitQCPersister(dir) require.NoError(t, err) for i := range 3 { require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{allQCs[i]}, noCommitQCCB)) @@ -679,7 +679,7 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist only indices 0-4 to the CommitQC WAL. - cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) + cp, _, err := persist.NewCommitQCPersister(dir) require.NoError(t, err) for i := range 5 { require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) @@ -689,7 +689,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist a prune anchor at index 9 — well past the persisted range. appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -698,7 +698,7 @@ func TestNewStateWithPersistence(t *testing.T) { // NewState should succeed: MaybePruneAndPersist truncates the stale WAL // and internally re-persists the anchor's CommitQC for crash recovery. - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) require.Equal(t, types.RoadIndex(9), state.FirstCommitQC()) @@ -717,7 +717,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist commitQCs 0-9 and blocks 0-2 for one lane. qcs := make([]*types.CommitQC, 10) prev := utils.None[*types.CommitQC]() - cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) + cp, _, err := persist.NewCommitQCPersister(dir) require.NoError(t, err) for i := range qcs { qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) @@ -726,7 +726,7 @@ func TestNewStateWithPersistence(t *testing.T) { } require.NoError(t, cp.Close()) - bp, _, err := persist.NewBlockPersister(utils.Some(dir)) + bp, _, err := persist.NewBlockPersister(dir) require.NoError(t, err) var parent types.BlockHeaderHash for n := range types.BlockNumber(3) { @@ -740,7 +740,7 @@ func TestNewStateWithPersistence(t *testing.T) { // all persisted blocks — MaybePruneAndPersistLane will TruncateAll the block WAL. appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -748,7 +748,7 @@ func TestNewStateWithPersistence(t *testing.T) { })) // NewState should succeed: block WAL gets truncated, lane starts clean. - state, err := NewState(keys[0], ds, utils.Some(dir)) + state, err := NewState(keys[0], ds, dir) require.NoError(t, err) require.Equal(t, types.RoadIndex(9), state.FirstCommitQC()) @@ -763,7 +763,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Create a throwaway persister to discover the A/B filenames, // then corrupt them so NewState fails on load. - _, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) + _, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) require.NoError(t, err) entries, err := os.ReadDir(dir) require.NoError(t, err) @@ -771,7 +771,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, e.Name()), []byte("corrupt"), 0600)) } - _, err = NewState(keys[0], ds, utils.Some(dir)) + _, err = NewState(keys[0], ds, dir) require.Error(t, err) }) } diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 6cc197cbbd..2956a53db4 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -37,15 +37,6 @@ // - D, E receive TimeoutQC(5, 2), can now advance to (5, 3) // - Everyone converges to the highest view // -// # PersistentStateDir Configuration -// -// At config level, PersistentStateDir is an Option[string]. NewState creates a persister when -// it is Some(path). When None, no persister is created (persistence -// disabled - DANGEROUS, may lead to SLASHING if the node restarts and double-votes; -// only use for testing). When Some(path), the path must already exist and be -// writable (verified by writing a temp file at startup); returns error otherwise. -// TODO: surface the None warning in CLI --help (e.g. stream command or config docs). -// // # Recovery Behavior // // - Fresh start (files don't exist): Logged at INFO level, node starts clean diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 23f0e1f358..7a9d853736 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -26,7 +26,7 @@ func newTestBlockDB(t testing.TB) types.BlockDB { } func seedPersistedInner(dir string, state *persistedInner) { - p, _, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) + p, _, err := persist.NewPersister[*pb.PersistedInner](dir, innerFile) if err != nil { panic(err) } @@ -38,7 +38,7 @@ func seedPersistedInner(dir string, state *persistedInner) { // loadInner is a test helper that loads persisted data and creates inner. // Mirrors what NewState does: NewPersister → newInner. func loadInner(dir string, committee *types.Committee) (inner, error) { - _, data, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) + _, data, err := persist.NewPersister[*pb.PersistedInner](dir, innerFile) if err != nil { return inner{}, err } @@ -939,8 +939,9 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { wantErr := errors.New("disk on fire") pers := utils.Some[persist.Persister[*pb.PersistedInner]](failPersister[*pb.PersistedInner]{err: wantErr}) cs, err := newState(&Config{ - Key: keys[0], - ViewTimeout: func(types.View) time.Duration { return time.Hour }, + Key: keys[0], + ViewTimeout: func(types.View) time.Duration { return time.Hour }, + PersistentStateDir: t.TempDir(), }, ds, pers, utils.None[*pb.PersistedInner]()) require.NoError(t, err) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go index 585eb1d74b..3aeef0f4d4 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go @@ -170,7 +170,7 @@ func (lw *laneWAL) close() error { // removed. If lane deletion is added in the future, the map RLock must be // held through the WAL write. type BlockPersister struct { - dir utils.Option[string] // immutable after construction + dir string // immutable after construction lanes utils.RWMutex[map[types.LaneID]*laneWAL] } @@ -190,19 +190,14 @@ func newLaneWALState(dir string) (*laneWALState, error) { // blocks/ and replays all persisted entries. Returns the persister and loaded // blocks grouped by lane (sorted by block number). Corrupt tail entries are // auto-truncated by the WAL library. -// When stateDir is None, returns a no-op persister. -func NewBlockPersister(stateDir utils.Option[string]) (*BlockPersister, map[types.LaneID][]LoadedBlock, error) { - sd, ok := stateDir.Get() - if !ok { - return &BlockPersister{lanes: utils.NewRWMutex(map[types.LaneID]*laneWAL{})}, nil, nil - } - dir := filepath.Join(sd, blocksDir) +func NewBlockPersister(stateDir string) (*BlockPersister, map[types.LaneID][]LoadedBlock, error) { + dir := filepath.Join(stateDir, blocksDir) if err := os.MkdirAll(dir, 0700); err != nil { return nil, nil, fmt.Errorf("create blocks dir %s: %w", dir, err) } lanes := map[types.LaneID]*laneWAL{} - bp := &BlockPersister{dir: utils.Some(dir), lanes: utils.NewRWMutex(lanes)} + bp := &BlockPersister{dir: dir, lanes: utils.NewRWMutex(lanes)} entries, err := os.ReadDir(dir) if err != nil { @@ -250,12 +245,7 @@ func NewBlockPersister(stateDir utils.Option[string]) (*BlockPersister, map[type // slow path (lane creation) promotes to a write Lock. // The returned pointer is safe to use after the lock is released because // lanes are only ever added, never removed (see BlockPersister doc). -// Returns an error if called on a no-op persister (caller should check first). func (bp *BlockPersister) getOrCreateLane(lane types.LaneID) (*laneWAL, error) { - dir, ok := bp.dir.Get() - if !ok { - return nil, fmt.Errorf("getOrCreateLane called on no-op persister") - } // Fast path: read-only check. for lanes := range bp.lanes.RLock() { if lw, ok := lanes[lane]; ok { @@ -267,7 +257,7 @@ func (bp *BlockPersister) getOrCreateLane(lane types.LaneID) (*laneWAL, error) { if lw, ok := lanes[lane]; ok { return lw, nil } - s, err := newLaneWALState(filepath.Join(dir, laneDir(lane))) + s, err := newLaneWALState(filepath.Join(bp.dir, laneDir(lane))) if err != nil { return nil, fmt.Errorf("create lane WAL for %s: %w", lane, err) } @@ -300,15 +290,6 @@ func (bp *BlockPersister) MaybePruneAndPersistLane( proposals []*types.Signed[*types.LaneProposal], afterEach utils.Option[func(*types.Signed[*types.LaneProposal])], ) error { - if _, ok := bp.dir.Get(); !ok { - if fn, ok := afterEach.Get(); ok { - for _, p := range proposals { - fn(p) - } - } - return nil - } - lw, err := bp.getOrCreateLane(lane) if err != nil { return err @@ -321,9 +302,6 @@ func (bp *BlockPersister) MaybePruneAndPersistLane( // at shutdown — the OS reclaims resources on process exit. // Safe for concurrent use. func (bp *BlockPersister) close() error { - if _, ok := bp.dir.Get(); !ok { - return nil // no-op persister (persistence disabled) - } for lanes := range bp.lanes.Lock() { var errs []error for _, lw := range lanes { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go index e799dcf363..8d700f23a1 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go @@ -54,7 +54,7 @@ func testDeleteBefore(bp *BlockPersister, laneFirsts map[types.LaneID]types.Bloc func TestNewBlockPersisterEmptyDir(t *testing.T) { dir := t.TempDir() - bp, blocks, err := NewBlockPersister(utils.Some(dir)) + bp, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.NotNil(t, bp) require.Equal(t, 0, len(blocks)) @@ -70,7 +70,7 @@ func TestPersistBlockAndLoad(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) b0 := testSignedProposal(rng, key, 0) @@ -79,7 +79,7 @@ func TestPersistBlockAndLoad(t *testing.T) { testPersistBlock(t, bp, b1) require.NoError(t, bp.close()) - bp2, blocks, err := NewBlockPersister(utils.Some(dir)) + bp2, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.NotNil(t, bp2) require.Equal(t, 1, len(blocks), "should have 1 lane") @@ -99,7 +99,7 @@ func TestPersistBlockMultipleLanes(t *testing.T) { key2 := types.GenSecretKey(rng) lane1 := key1.Public() lane2 := key2.Public() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) b1 := testSignedProposal(rng, key1, 0) @@ -108,7 +108,7 @@ func TestPersistBlockMultipleLanes(t *testing.T) { testPersistBlock(t, bp, b2) require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(utils.Some(dir)) + _, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(blocks), "should have 2 lanes") require.Equal(t, 1, len(blocks[lane1])) @@ -123,7 +123,7 @@ func TestDeleteBeforeRemovesOldKeepsNew(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) for i := range types.BlockNumber(5) { @@ -133,7 +133,7 @@ func TestDeleteBeforeRemovesOldKeepsNew(t *testing.T) { require.NoError(t, testDeleteBefore(bp, map[types.LaneID]types.BlockNumber{lane: 3})) require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(utils.Some(dir)) + _, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(blocks[lane]), "should have blocks 3 and 4") require.Equal(t, types.BlockNumber(3), blocks[lane][0].Number) @@ -150,7 +150,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { lane1 := key1.Public() lane2 := key2.Public() lane3 := key3.Public() // never persisted — exercises the "no WAL yet" path - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -163,7 +163,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { require.NoError(t, bp.close()) // Restart — verify varied lane states load correctly. - bp2, blocks, err := NewBlockPersister(utils.Some(dir)) + bp2, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 1, len(blocks[lane1]), "lane1 should have block 2") require.Equal(t, types.BlockNumber(2), blocks[lane1][0].Number) @@ -175,7 +175,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { testPersistBlock(t, bp2, testSignedProposal(rng, key2, 3)) require.NoError(t, bp2.close()) - _, blocks2, err := NewBlockPersister(utils.Some(dir)) + _, blocks2, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(blocks2[lane1]), "lane1 should have blocks 2,3") require.Equal(t, types.BlockNumber(3), blocks2[lane1][1].Number) @@ -184,7 +184,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { } func TestNoOpBlockPersister(t *testing.T) { - bp, blocks, err := NewBlockPersister(utils.None[string]()) + bp, blocks, err := NewBlockPersister(t.TempDir()) require.NoError(t, err) require.NotNil(t, bp) require.Equal(t, 0, len(blocks)) @@ -218,7 +218,7 @@ func TestDeleteBeforeThenPersistMore(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) // Persist 0..4, delete before 3, then persist 5. @@ -229,7 +229,7 @@ func TestDeleteBeforeThenPersistMore(t *testing.T) { testPersistBlock(t, bp, testSignedProposal(rng, key, 5)) require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(utils.Some(dir)) + _, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 3, len(blocks[lane]), "should have blocks 3, 4, 5") require.Equal(t, types.BlockNumber(3), blocks[lane][0].Number) @@ -243,7 +243,7 @@ func TestDeleteBeforePastAllBlocks(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -259,7 +259,7 @@ func TestDeleteBeforePastAllBlocks(t *testing.T) { require.NoError(t, bp.close()) // Reopen — should see only the post-TruncateAll blocks. - _, blocks, err := NewBlockPersister(utils.Some(dir)) + _, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(blocks[lane])) require.Equal(t, types.BlockNumber(10), blocks[lane][0].Number) @@ -272,7 +272,7 @@ func TestDeleteBeforePastAllRejectsStaleBlock(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -299,7 +299,7 @@ func TestTruncateOnEmptyWALAdvancesCursor(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -331,7 +331,7 @@ func TestEmptyLaneWALSurvivesReopen(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(bd, laneDir(lane)), 0700)) // Reopen — empty lane WAL should be loaded and usable. - bp, blocks, err := NewBlockPersister(utils.Some(dir)) + bp, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 0, len(blocks[lane]), "no blocks loaded") @@ -340,7 +340,7 @@ func TestEmptyLaneWALSurvivesReopen(t *testing.T) { require.NoError(t, bp.close()) // Reopen — should see the new block. - _, blocks2, err := NewBlockPersister(utils.Some(dir)) + _, blocks2, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 1, len(blocks2[lane])) require.Equal(t, types.BlockNumber(0), blocks2[lane][0].Number) @@ -355,7 +355,7 @@ func TestNewBlockPersisterSkipsNonHexDir(t *testing.T) { require.NoError(t, os.Mkdir(filepath.Join(bd, "not-valid-hex"), 0700)) require.NoError(t, os.WriteFile(filepath.Join(bd, "stray-file.txt"), []byte("hi"), 0600)) - bp, blocks, err := NewBlockPersister(utils.Some(dir)) + bp, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 0, len(blocks)) require.NoError(t, bp.close()) @@ -369,7 +369,7 @@ func TestNewBlockPersisterSkipsInvalidKeyDir(t *testing.T) { // Valid hex but too short to be a valid ed25519 public key. require.NoError(t, os.Mkdir(filepath.Join(bd, "abcd"), 0700)) - bp, blocks, err := NewBlockPersister(utils.Some(dir)) + bp, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 0, len(blocks)) require.NoError(t, bp.close()) @@ -380,7 +380,7 @@ func TestPersistBlockOutOfSequence(t *testing.T) { dir := t.TempDir() key := types.GenSecretKey(rng) - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) lane := key.Public() @@ -417,7 +417,7 @@ func TestLoadAllDetectsBlockGap(t *testing.T) { require.NoError(t, s.Write(testSignedProposal(rng, key, 2))) require.NoError(t, s.Close()) - _, _, err = NewBlockPersister(utils.Some(dir)) + _, _, err = NewBlockPersister(dir) require.Error(t, err) require.Contains(t, err.Error(), "gap") } @@ -426,7 +426,7 @@ func TestPersistBlockAutoCreatesLane(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) entries, _ := os.ReadDir(filepath.Join(dir, blocksDir)) @@ -441,7 +441,7 @@ func TestPersistBlockAutoCreatesLane(t *testing.T) { require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(utils.Some(dir)) + _, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, 1, len(blocks[lane])) require.Equal(t, types.BlockNumber(0), blocks[lane][0].Number) @@ -453,7 +453,7 @@ func TestPersistBlockConcurrentDistinctLanes(t *testing.T) { const numLanes = 8 const blocksPerLane = 20 - bp, _, err := NewBlockPersister(utils.Some(dir)) + bp, _, err := NewBlockPersister(dir) require.NoError(t, err) keys := make([]types.SecretKey, numLanes) @@ -482,7 +482,7 @@ func TestPersistBlockConcurrentDistinctLanes(t *testing.T) { require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(utils.Some(dir)) + _, blocks, err := NewBlockPersister(dir) require.NoError(t, err) require.Equal(t, numLanes, len(blocks)) for i := range numLanes { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go index d0291410b7..511508b628 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go @@ -90,12 +90,8 @@ type CommitQCPersister struct { // new write followed), LoadNext() returns 0. The caller MUST use // MaybePruneAndPersist with the prune CommitQC in Anchor to re-establish the // cursor and re-persist the anchor's CommitQC before appending more QCs. -func NewCommitQCPersister(stateDir utils.Option[string]) (*CommitQCPersister, []LoadedCommitQC, error) { - sd, ok := stateDir.Get() - if !ok { - return &CommitQCPersister{state: utils.NewMutex(&commitQCState{})}, nil, nil - } - dir := filepath.Join(sd, commitqcsDir) +func NewCommitQCPersister(stateDir string) (*CommitQCPersister, []LoadedCommitQC, error) { + dir := filepath.Join(stateDir, commitqcsDir) iw, err := openIndexedWAL(dir, types.CommitQCConv) if err != nil { return nil, nil, fmt.Errorf("open commitqc WAL in %s: %w", dir, err) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index 1bbcadeb4a..e67ae0ce5a 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -92,7 +92,7 @@ func clearCommitQCWAL(t *testing.T, dir string) { func TestNewCommitQCPersisterEmptyDir(t *testing.T) { dir := t.TempDir() - cp, loaded, err := NewCommitQCPersister(utils.Some(dir)) + cp, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.NotNil(t, cp) require.Equal(t, 0, len(loaded)) @@ -111,7 +111,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { qcs := makeSequentialCommitQCs(committee, keys, 3) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for _, qc := range qcs { @@ -120,7 +120,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { require.Equal(t, types.RoadIndex(3), cp.LoadNext()) require.NoError(t, cp.Close()) - cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) + cp2, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.NotNil(t, cp2) require.Equal(t, 3, len(loaded)) @@ -138,7 +138,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -147,7 +147,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { testDeleteCommitQCsBefore(t, cp, qcs[3]) require.NoError(t, cp.Close()) - _, loaded, err := NewCommitQCPersister(utils.Some(dir)) + _, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(loaded), "should have indices 3 and 4") require.Equal(t, types.RoadIndex(3), loaded[0].Index) @@ -161,7 +161,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { qcs := makeSequentialCommitQCs(committee, keys, 3) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for _, qc := range qcs[:2] { testPersistCommitQC(t, cp, qc) @@ -172,7 +172,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { testDeleteCommitQCsBefore(t, cp, qcs[0]) require.NoError(t, cp.Close()) - cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) + cp2, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(loaded)) @@ -187,7 +187,7 @@ func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) testPersistCommitQC(t, cp, qcs[0]) @@ -204,7 +204,7 @@ func TestCommitQCPersistGapRejected(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) testPersistCommitQC(t, cp, qcs[0]) @@ -234,7 +234,7 @@ func TestLoadAllDetectsCommitQCGap(t *testing.T) { require.NoError(t, iw.Write(qcs[2])) require.NoError(t, iw.Close()) - _, _, err = NewCommitQCPersister(utils.Some(dir)) + _, _, err = NewCommitQCPersister(dir) require.Error(t, err) require.Contains(t, err.Error(), "gap") } @@ -246,7 +246,7 @@ func TestNoOpCommitQCPersister(t *testing.T) { // Fresh no-op persister: prune with anchor at index 0 (idx==0, // s.next==0). Should persist the anchor and advance to 1. - cp, loaded, err := NewCommitQCPersister(utils.None[string]()) + cp, loaded, err := NewCommitQCPersister(t.TempDir()) require.NoError(t, err) require.NotNil(t, cp) require.Equal(t, 0, len(loaded)) @@ -278,7 +278,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for i := range 3 { testPersistCommitQC(t, cp, qcs[i]) @@ -293,7 +293,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { require.NoError(t, cp.Close()) // Reopen — should see only the post-TruncateAll entries. - _, loaded, err := NewCommitQCPersister(utils.Some(dir)) + _, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(loaded)) require.Equal(t, types.RoadIndex(10), loaded[0].Index) @@ -309,7 +309,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for i := range 3 { testPersistCommitQC(t, cp, qcs[i]) @@ -321,7 +321,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { clearCommitQCWAL(t, dir) // Restart: WAL is empty, next will be 0. - cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) + cp2, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Empty(t, loaded) require.Equal(t, types.RoadIndex(0), cp2.LoadNext()) @@ -336,7 +336,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { require.Equal(t, types.RoadIndex(12), cp2.LoadNext()) require.NoError(t, cp2.Close()) - _, loaded, err = NewCommitQCPersister(utils.Some(dir)) + _, loaded, err = NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(loaded)) require.Equal(t, types.RoadIndex(10), loaded[0].Index) @@ -352,7 +352,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -363,7 +363,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { clearCommitQCWAL(t, dir) // Restart: WAL is empty. Pass the anchor QC (index 4) through deleteBefore. - cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) + cp2, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Empty(t, loaded) @@ -377,7 +377,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { require.NoError(t, cp2.Close()) // Reopen — anchor QC should be on disk. - _, loaded, err = NewCommitQCPersister(utils.Some(dir)) + _, loaded, err = NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 1, len(loaded)) require.Equal(t, types.RoadIndex(4), loaded[0].Index) @@ -390,7 +390,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 6) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) // Persist 0..4, delete before 3, then persist 5. @@ -401,7 +401,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { testPersistCommitQC(t, cp, qcs[5]) require.NoError(t, cp.Close()) - _, loaded, err := NewCommitQCPersister(utils.Some(dir)) + _, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 3, len(loaded), "should have indices 3, 4, 5") require.Equal(t, types.RoadIndex(3), loaded[0].Index) @@ -415,7 +415,7 @@ func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -430,7 +430,7 @@ func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { require.NoError(t, cp.Close()) // Verify nothing extra was pruned. - _, loaded, err := NewCommitQCPersister(utils.Some(dir)) + _, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 2, len(loaded), "should still have indices 3 and 4") require.Equal(t, types.RoadIndex(3), loaded[0].Index) @@ -443,7 +443,7 @@ func TestCommitQCProgressiveDeleteBefore(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 8) - cp, _, err := NewCommitQCPersister(utils.Some(dir)) + cp, _, err := NewCommitQCPersister(dir) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -458,7 +458,7 @@ func TestCommitQCProgressiveDeleteBefore(t *testing.T) { require.NoError(t, cp.Close()) // Verify indices 5, 6, 7 survive. - _, loaded, err := NewCommitQCPersister(utils.Some(dir)) + _, loaded, err := NewCommitQCPersister(dir) require.NoError(t, err) require.Equal(t, 3, len(loaded)) require.Equal(t, types.RoadIndex(5), loaded[0].Index) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/persist.go b/sei-tendermint/internal/autobahn/consensus/persist/persist.go index 4a2dc6ca6e..83ff1b8315 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/persist.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/persist.go @@ -99,36 +99,30 @@ type abPersister[T protoutils.Message] struct { } // NewPersister creates a crash-safe persister for the given directory and prefix. -// When dir is None, returns a no-op persister that discards all writes. -// When dir is Some, it must already exist and be a directory; returns error otherwise. -// Also returns the loaded message (None on fresh start or no-op) for the caller to use. +// dir must already exist and be a directory; returns error otherwise. +// Also returns the loaded message (None on fresh start) for the caller to use. // This encapsulates all on-disk format details (A/B files, seq wrapper, proto marshal) in one place. -func NewPersister[T protoutils.Message](dir utils.Option[string], prefix string) (Persister[T], utils.Option[T], error) { +func NewPersister[T protoutils.Message](dir string, prefix string) (Persister[T], utils.Option[T], error) { none := utils.None[T]() - d, ok := dir.Get() - if !ok { - return newNoOpPersister[T](), none, nil - } - - fi, err := os.Stat(d) + fi, err := os.Stat(dir) if err != nil { - return nil, none, fmt.Errorf("invalid state dir %q: %w", d, err) + return nil, none, fmt.Errorf("invalid state dir %q: %w", dir, err) } if !fi.IsDir() { - return nil, none, fmt.Errorf("invalid state dir %q: not a directory", d) + return nil, none, fmt.Errorf("invalid state dir %q: not a directory", dir) } // Probe writability by creating and removing a temp file. Checking permission // bits is not reliable: Windows emulates Unix bits from ACLs, root bypasses // permission checks on Unix, and group/other write bits are easy to miss. - probe, err := os.CreateTemp(d, ".writable-probe-*") + probe, err := os.CreateTemp(dir, ".writable-probe-*") if err != nil { - return nil, none, fmt.Errorf("state dir %q is not writable: %w", d, err) + return nil, none, fmt.Errorf("state dir %q is not writable: %w", dir, err) } _ = probe.Close() _ = os.Remove(probe.Name()) - ds, err := loadPersisted(d, prefix) + ds, err := loadPersisted(dir, prefix) if err != nil && !errors.Is(err, ErrNoData) { return nil, none, err } @@ -137,7 +131,7 @@ func NewPersister[T protoutils.Message](dir utils.Option[string], prefix string) // directory entries. Empty files are treated as non-existent by loadFile, // so they won't interfere with loading on restart. for _, suffix := range []string{suffixA, suffixB} { - path := filepath.Join(d, prefix+suffix) + path := filepath.Join(dir, prefix+suffix) f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec // path is stateDir + hardcoded suffix; not user-controlled if err != nil { return nil, none, fmt.Errorf("state file %q is not writable: %w", path, err) @@ -145,11 +139,11 @@ func NewPersister[T protoutils.Message](dir utils.Option[string], prefix string) _ = f.Close() } // Sync directory to make file entries durable (harmless if files already existed). - if df, err := os.Open(d); err != nil { //nolint:gosec // d is operator-configured stateDir; not user-controlled - return nil, none, fmt.Errorf("open directory %s: %w", d, err) + if df, err := os.Open(dir); err != nil { //nolint:gosec // dir is operator-configured stateDir; not user-controlled + return nil, none, fmt.Errorf("open directory %s: %w", dir, err) } else if err := df.Sync(); err != nil { _ = df.Close() - return nil, none, fmt.Errorf("sync directory %s: %w", d, err) + return nil, none, fmt.Errorf("sync directory %s: %w", dir, err) } else { _ = df.Close() } @@ -163,7 +157,7 @@ func NewPersister[T protoutils.Message](dir utils.Option[string], prefix string) loaded = utils.Some(msg) } return &abPersister[T]{ - dir: d, + dir: dir, prefix: prefix, seq: ds.seq, }, loaded, nil diff --git a/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go b/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go index c5f1d5ab71..a4c2983665 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go @@ -12,14 +12,13 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/wrapperspb" - "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func TestPersisterAlternates(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) // Both files should be pre-created (empty) by NewPersister for dir-sync optimization. @@ -48,7 +47,7 @@ func TestPersisterAlternates(t *testing.T) { func TestPersisterPicksHigherSeq(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) // Write three times: A(seq=1), B(seq=2), A(seq=3) @@ -57,7 +56,7 @@ func TestPersisterPicksHigherSeq(t *testing.T) { require.NoError(t, w.Persist(wrapperspb.String("third"))) // Should load "third" (seq=3 > seq=2) - w2, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w2, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -69,7 +68,7 @@ func TestLoadPersistedOneCorruptFileSucceeds(t *testing.T) { dir := t.TempDir() // Write to both files: A(seq=1), B(seq=2) - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("first"))) // seq=1, A require.NoError(t, w.Persist(wrapperspb.String("second"))) // seq=2, B @@ -78,7 +77,7 @@ func TestLoadPersistedOneCorruptFileSucceeds(t *testing.T) { err = os.WriteFile(filepath.Join(dir, "test"+suffixB), []byte("corrupt"), 0600) require.NoError(t, err) - _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -89,7 +88,7 @@ func TestLoadPersistedBothCorruptError(t *testing.T) { dir := t.TempDir() // Write valid data to A only - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("valid"))) // seq=1, A @@ -97,7 +96,7 @@ func TestLoadPersistedBothCorruptError(t *testing.T) { err = os.WriteFile(filepath.Join(dir, "test"+suffixA), []byte("corrupt"), 0600) require.NoError(t, err) - _, _, err = NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + _, _, err = NewPersister[*wrapperspb.StringValue](dir, "test") require.Error(t, err) } @@ -105,7 +104,7 @@ func TestNewPersisterOneCorruptFileSucceeds(t *testing.T) { dir := t.TempDir() // Write to both files: A(seq=1), B(seq=2) - w1, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w1, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w1.Persist(wrapperspb.String("first"))) // seq=1, A require.NoError(t, w1.Persist(wrapperspb.String("second"))) // seq=2, B @@ -114,7 +113,7 @@ func TestNewPersisterOneCorruptFileSucceeds(t *testing.T) { err = os.WriteFile(filepath.Join(dir, "test"+suffixB), []byte("corrupt"), 0600) require.NoError(t, err) - w2, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w2, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -123,7 +122,7 @@ func TestNewPersisterOneCorruptFileSucceeds(t *testing.T) { // A won (seq=1), so next write goes to B (the corrupt/loser slot) require.NoError(t, w2.Persist(wrapperspb.String("recovered"))) // seq=2, B - _, loaded2, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + _, loaded2, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) msg2, ok := loaded2.Get() require.True(t, ok) @@ -140,7 +139,7 @@ func TestLoadPersistedEmptyDir(t *testing.T) { func TestNewPersisterInvalidDirError(t *testing.T) { // State dir must already exist; invalid (nonexistent or not a directory) returns error - _, _, err := NewPersister[*wrapperspb.StringValue](utils.Some("/nonexistent/path/that/does/not/exist"), "test") + _, _, err := NewPersister[*wrapperspb.StringValue]("/nonexistent/path/that/does/not/exist", "test") require.Error(t, err) require.Contains(t, err.Error(), "invalid state dir") } @@ -152,7 +151,7 @@ func TestPersistWriteErrorReturnsError(t *testing.T) { t.Skip("chmod 000 on directory not reliable on Windows") } dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("data1"))) // Remove all permissions from dir so OpenFile fails with EACCES @@ -170,7 +169,7 @@ func TestPersistWriteErrorDoesNotAdvanceSeq(t *testing.T) { t.Skip("chmod 000 on directory not reliable on Windows") } dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) // Successful write: seq=1 → A @@ -188,7 +187,7 @@ func TestPersistWriteErrorDoesNotAdvanceSeq(t *testing.T) { require.NoError(t, w.Persist(wrapperspb.String("recovered"))) // Verify: "recovered" is latest. - _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -205,7 +204,7 @@ func TestLoadPersistedOSErrorPropagates(t *testing.T) { dir := t.TempDir() // Write to both files: A(seq=1), B(seq=2) - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("first"))) // seq=1, A require.NoError(t, w.Persist(wrapperspb.String("second"))) // seq=2, B @@ -226,7 +225,7 @@ func TestNewPersisterResumeSeq(t *testing.T) { dir := t.TempDir() // Create persister and write some data - w1, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w1, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w1.Persist(wrapperspb.String("data1"))) // seq=1, A require.NoError(t, w1.Persist(wrapperspb.String("data2"))) // seq=2, B @@ -234,12 +233,12 @@ func TestNewPersisterResumeSeq(t *testing.T) { // Create new persister (simulates restart) // A has seq=3 (winner), so new persister should write to B first to preserve A - w2, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w2, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w2.Persist(wrapperspb.String("data4"))) // seq=4, B (preserves A) // Verify data4 is the latest (seq=4) - _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -250,18 +249,18 @@ func TestNewPersisterPreservesWinner(t *testing.T) { dir := t.TempDir() // Write to both files: A=seq1, B=seq2 (B wins) - w1, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w1, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w1.Persist(wrapperspb.String("old"))) // seq=1, A require.NoError(t, w1.Persist(wrapperspb.String("winner"))) // seq=2, B (winner) // New persister should write to A first (preserve B) - w2, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w2, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w2.Persist(wrapperspb.String("new"))) // seq=3, A (preserves B) // Verify "new" is the latest (seq=3) - _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -273,7 +272,7 @@ func TestNewPersisterPreservesWinner(t *testing.T) { func TestLoadFileDataCorruption(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("data"))) // seq=1, A @@ -323,7 +322,7 @@ func TestLoadFileZeroSeq(t *testing.T) { func TestLoadFileSeqCorruption(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("data"))) @@ -342,7 +341,7 @@ func TestLoadFileSeqCorruption(t *testing.T) { func TestPersistFileFormat(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") + w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("hello"))) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 75374a5ba4..842270ee30 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -24,8 +24,7 @@ type Config struct { Key types.SecretKey ViewTimeout ViewTimeoutFunc // PersistentStateDir is the directory where the consensus state is persisted. - // If None, persistence is disabled - DANGEROUS, may lead to SLASHING on restart. - PersistentStateDir utils.Option[string] + PersistentStateDir string } // State represents the high-level Consensus Control Plane. @@ -43,7 +42,6 @@ type State struct { inner utils.Mutex[*utils.AtomicSend[inner]] innerRecv utils.AtomicRecv[inner] - // persister writes inner's persistedInner to disk when PersistentStateDir is set; None when disabled. persister utils.Option[persist.Persister[*pb.PersistedInner]] timeoutVotes utils.Mutex[*timeoutVotes] @@ -79,17 +77,11 @@ func (s *State) SubscribeTimeoutQC() utils.AtomicRecv[utils.Option[*types.Timeou func NewState(cfg *Config, data *data.State) (*State, error) { // Create persister first so newInner can receive the loaded data // instead of reading the files directly. - var pers utils.Option[persist.Persister[*pb.PersistedInner]] - var persistedData utils.Option[*pb.PersistedInner] - if dir, ok := cfg.PersistentStateDir.Get(); ok { - p, d, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) - if err != nil { - return nil, fmt.Errorf("NewPersister: %w", err) - } - pers = utils.Some(p) - persistedData = d + p, persistedData, err := persist.NewPersister[*pb.PersistedInner](cfg.PersistentStateDir, innerFile) + if err != nil { + return nil, fmt.Errorf("NewPersister: %w", err) } - return newState(cfg, data, pers, persistedData) + return newState(cfg, data, utils.Some(p), persistedData) } // newState is the internal constructor exposed for tests that need to inject diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 6e832e74d0..d000317909 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -26,7 +26,7 @@ func newTestState(t testing.TB, rng utils.Rng) (*State, []types.SecretKey) { s := utils.OrPanic1(NewState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: utils.None[string](), + PersistentStateDir: t.TempDir(), }, dataState)) return s, keys } @@ -266,7 +266,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { return &Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: utils.Some(dir), + PersistentStateDir: dir, } } makeDataState := func() *data.State { diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 3fcfefc05f..1eb4e5d464 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -33,9 +33,10 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV consensusState, err := consensus.NewState(&consensus.Config{ Key: cfg.ValidatorKey, ViewTimeout: cfg.ViewTimeout, - PersistentStateDir: utils.Some(cfg.PersistentStateDir), + PersistentStateDir: cfg.PersistentStateDir, }, dataState) if err != nil { + _ = dataState.Close() return nil, fmt.Errorf("consensus.NewState(): %w", err) } producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) From 60099782c7216c82bd74d1c746a0a75ae7dd72a5 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 17:32:39 -0400 Subject: [PATCH 05/46] fix: missed Option[string] call site in producer test; fix stale comments - producer/mempool_test.go: replace remaining utils.None[string]() with t.TempDir() (linter caught this) - giga_router_common.go: fix BlockByHash doc (in-memory hash index, not direct BlockDB read); fix buildHashVault doc (truncated sentence and stale two-cases claim) Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/producer/mempool_test.go | 2 +- sei-tendermint/internal/p2p/giga_router_common.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index f5b49a99bf..9d75cbb997 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -244,7 +244,7 @@ func newTestEnv(t testing.TB, rng utils.Rng, cfg *Config, app *proxy.Proxy) *tes consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: utils.None[string](), + PersistentStateDir: t.TempDir(), }, dataState)) return &testEnv{ data: dataState, diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 7ef5477bb9..1630981593 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -140,9 +140,9 @@ func (r *gigaRouterCommon) BlockByNumber(ctx context.Context, n atypes.GlobalBlo // (same translation as BlockByNumber). Matches CometBFT semantics for // unknown hashes: returns &ResultBlock{Block: nil} with no error. // -// The lookup delegates to data.State.GlobalBlockByHash, which reads from -// BlockDB (durable, survives restarts). Hashes not yet persisted or below -// the prune watermark are read as "unknown". Wrong-size inputs are rejected +// The lookup delegates to data.State.GlobalBlockByHash, which reads from an +// in-memory hash index rebuilt from BlockDB at construction time. Hashes not +// yet seen or below the prune watermark are read as "unknown". Wrong-size inputs are rejected // at the call site (env.BlockByHash) so this method can stay strongly typed // on atypes.BlockHeaderHash. func (r *gigaRouterCommon) BlockByHash(ctx context.Context, hash atypes.BlockHeaderHash) (*coretypes.ResultBlock, error) { @@ -260,8 +260,8 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo // buildHashVault constructs the app-hash equivocation guard runExecute owns. By default it // returns a durable Pebble-backed vault rooted at /hashvault, alongside the -// other Autobahn on-disk state. It returns a no-op vault (no protection) in two cases: the operator -// explicitly set HashVaultDisabledUnsafe — logged loudly as unsafe — or there is no persistent +// other Autobahn on-disk state. It returns a no-op vault (no protection) only when the operator +// explicitly sets HashVaultDisabledUnsafe — logged loudly as unsafe. func buildHashVault(ctx context.Context, cfg *GigaRouterCommonConfig) (hashvault.HashVault, error) { if cfg.HashVaultDisabledUnsafe { logger.Error("################################################################") From a828ae3503f73e5bc834ca12423208d1ab0cea4f Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 17:49:38 -0400 Subject: [PATCH 06/46] fix: add PersistentStateDir to giga test helpers consensus.NewState now requires a non-empty PersistentStateDir; wire t.TempDir() into newTestNode (covers all AddNode callers) and into the fakeNode0 construction in avail_test.go. Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga/avail_test.go | 5 +++-- sei-tendermint/internal/p2p/giga/data_test.go | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 34a8570e9d..2900fbe43b 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -37,8 +37,9 @@ func TestAvailClientServer(t *testing.T) { s.SpawnBg(func() error { return env.Run(ctx) }) t.Log("Spawn a fake unconnected node0 to generate some conflicting blocks and push them to node2.") fakeNode0, err := consensus.NewState(&consensus.Config{ - Key: keys[0], - ViewTimeout: defaultViewTimeout, + Key: keys[0], + ViewTimeout: defaultViewTimeout, + PersistentStateDir: t.TempDir(), }, nodes[0].data) if err != nil { return fmt.Errorf("consensus.NewState(): %w", err) diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 47ac9b2911..e0a9518741 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -25,6 +25,9 @@ type testNode struct { func defaultViewTimeout(view types.View) time.Duration { return time.Hour } func newTestNode(t testing.TB, committee *types.Committee, cfg *consensus.Config) *testNode { + if cfg.PersistentStateDir == "" { + cfg.PersistentStateDir = t.TempDir() + } blockCfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) db := utils.OrPanic1(littblock.NewBlockDB(blockCfg)) t.Cleanup(func() { _ = db.Close() }) From ebe6cb8fcbfa6dfe00ceb3101e9bd4e289615947 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 19:29:43 -0400 Subject: [PATCH 07/46] docs: document PruneBefore async-GC behavior; validate empty persistent-state-dir early - data/state.go: add comment explaining that PruneBefore advances an in-memory watermark only; prune durability comes from the BlockDB retention TTL, not from PruneBefore itself - gen_autobahn_config.go: reject empty --persistent-state-dir at config generation time instead of deferring to node startup Co-Authored-By: Claude Sonnet 4.6 --- .../cmd/tendermint/commands/gen_autobahn_config.go | 3 +++ sei-tendermint/internal/autobahn/data/state.go | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go index d46031c989..099dfbc488 100644 --- a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go +++ b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go @@ -89,6 +89,9 @@ Output is written to the file specified by --output.`, ViewTimeout: utils.Duration(1500 * time.Millisecond), DialInterval: utils.Duration(10 * time.Second), } + if persistentStateDir == "" { + return fmt.Errorf("--persistent-state-dir must not be empty") + } // The flag defaults to "data/autobahn"; node/setup.go rootifies // the relative path against cfg.RootDir at load time. cfg.PersistentStateDir = persistentStateDir diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index b500fb5d66..b424adf4d3 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -630,6 +630,12 @@ func (s *State) PruneBefore(retainFrom types.GlobalBlockNumber) error { truncateTo = utils.Some(inner.first) } // Prune BlockDB outside the lock to avoid holding it during disk I/O. + // PruneBefore advances an in-memory watermark; GC is asynchronous and not + // persisted. On restart before GC reclaims entries, NewState may see + // below-watermark blocks/QCs and set inner.first lower than the pre-crash + // watermark. This is safe (resurrected data is QC-covered committed data) + // and self-heals on the next runPruning cycle. Durable prune bounds come + // from the BlockDB retention TTL, not from PruneBefore itself. if n, ok := truncateTo.Get(); ok { return s.blockDB.PruneBefore(n) } From 13bc4ec956a73793f983c3bf39820cf310187bd8 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 19:37:41 -0400 Subject: [PATCH 08/46] test(data): add PruneBefore-without-GC recovery test; validate empty persistent-state-dir early MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestRecoveryAfterPruneNoGC: exercises the real PruneBefore → close → reopen-without-ForceGC path; proves NewState succeeds because blocks and QCs share the same GC filter in littblock (below-watermark blocks never outlive their QCs, so the block-predates-first-QC guard never fires) - gen_autobahn_config.go: reject empty --persistent-state-dir at config generation time instead of deferring the error to node startup Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/data/state_test.go | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 299d1bbaae..17c8897e8f 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -660,6 +660,58 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { require.NoError(t, state2.Close()) } +// TestRecoveryAfterPruneNoGC verifies that restarting before async GC reclaims +// pruned entries does not cause NewState to fail. Blocks and QCs share the same +// GC filter in littblock, so below-watermark blocks never survive past their +// corresponding QCs — the first block iterator entry is always >= inner.first +// set by the QC pass, so the "block predates first QC start" guard never fires. +func TestRecoveryAfterPruneNoGC(t *testing.T) { + rng := utils.TestRng() + committee, keys := types.GenCommittee(rng, 3) + dir := t.TempDir() + + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + qc2, blocks2 := TestCommitQC(rng, committee, keys, utils.Some(qc1.QC())) + gr1 := qc1.QC().GlobalRange(committee) + gr2 := qc2.QC().GlobalRange(committee) + + // Write both QCs and all their blocks to the DB. + cfg1, err := littblock.DefaultConfig(dir) + require.NoError(t, err) + cfg1.Retention = time.Nanosecond + db1, err := littblock.NewBlockDB(cfg1) + require.NoError(t, err) + writeToBlockDB(t, db1, committee, []*types.FullCommitQC{qc1, qc2}, [][]*types.Block{blocks1, blocks2}) + + // Prune qc1's range. GC is NOT called — pruned entries remain on disk. + require.NoError(t, db1.PruneBefore(gr2.First)) + require.NoError(t, db1.Close()) + + // Reopen the same dir without ForceGC — pruned entries may still be present. + cfg2, err := littblock.DefaultConfig(dir) + require.NoError(t, err) + cfg2.Retention = time.Nanosecond + db2, err := littblock.NewBlockDB(cfg2) + require.NoError(t, err) + t.Cleanup(func() { _ = db2.Close() }) + + // NewState must succeed — below-watermark blocks never outlive their QCs + // because blocks and QCs share the same GC filter in littblock. Without GC, + // all entries are still present and recovery treats the DB as unpruned. + // This is the PruneBefore-without-GC path: the watermark advanced but + // physical reclamation has not happened yet, so the DB looks like a + // fresh DB containing all data from qc1 and qc2. + state, err := NewState(&Config{Committee: committee}, db2) + require.NoError(t, err) + t.Cleanup(func() { _ = state.Close() }) + + // Without GC all data is still present; qc1 and qc2 blocks are accessible. + for n := gr1.First; n < gr2.Next; n++ { + _, err := state.TryBlock(n) + require.NoError(t, err) + } +} + // TestRecoveryQCsNoBlocks verifies that NewState succeeds when the DB contains // QCs but no blocks (crash between QC flush and block writes). The state // cursor sits at the QC start with nextBlock == first and no block data. From 1331181d335d5d572b23dd936ae42336fb722e36 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 19:41:44 -0400 Subject: [PATCH 09/46] chore(persist): remove dead noopPersister and stale None-mode comments Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/persist/blocks.go | 3 --- .../internal/autobahn/consensus/persist/commitqcs.go | 7 ++----- .../internal/autobahn/consensus/persist/persist.go | 9 --------- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go index 3aeef0f4d4..6f6d350324 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go @@ -157,8 +157,6 @@ func (lw *laneWAL) close() error { // but a lane whose blocks are never included in a committed block (e.g. the // validator is removed from the committee) would prevent truncation of all // other lanes' entries that follow it. -// When dir is None, all disk I/O is skipped (no-op mode). -// // All public methods are safe for concurrent use. The lanes map is protected // by an RWMutex; each laneWAL has its own Mutex for write serialization. // MaybePruneAndPersistLane holds the per-lane lock for the entire @@ -279,7 +277,6 @@ func (bp *BlockPersister) getOrCreateLane(lane types.LaneID) (*laneWAL, error) { // afterEach, when present, is called after each successful append. It is // invoked while the per-lane lock is held, so it must not re-enter the // persister. -// No-op persister (dir=None): skips disk I/O but still invokes afterEach. // Does not spawn goroutines — the caller schedules parallelism per lane. // // The per-lane lock is held for the entire truncate-then-append sequence, diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go index 511508b628..0a258048f1 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go @@ -76,7 +76,6 @@ func (s *commitQCState) deleteBefore(anchor *types.CommitQC) error { // Entries are appended in order; each entry is self-describing (the serialized // CommitQC contains its RoadIndex). The WAL index is append order, not // RoadIndex — the indexedWAL tracks first/next indices to enable truncation. -// When iw is None, all disk I/O is skipped but cursor tracking still works. type CommitQCPersister struct { state utils.Mutex[*commitQCState] } @@ -84,8 +83,6 @@ type CommitQCPersister struct { // NewCommitQCPersister opens (or creates) a WAL in the commitqcs/ subdirectory // and replays all persisted entries. Returns the persister and a sorted slice of // loaded CommitQCs. Corrupt tail entries are auto-truncated by the WAL library. -// When stateDir is None, returns a no-op persister. -// // After crash recovery with an empty WAL (e.g. TruncateAll completed but no // new write followed), LoadNext() returns 0. The caller MUST use // MaybePruneAndPersist with the prune CommitQC in Anchor to re-establish the @@ -162,7 +159,7 @@ func (cp *CommitQCPersister) Close() error { for s := range cp.state.Lock() { iw, ok := s.iw.Get() if !ok { - return nil // no-op persister or already closed + return nil // already closed } s.iw = utils.None[*indexedWAL[*types.CommitQC]]() return iw.Close() @@ -173,7 +170,7 @@ func (cp *CommitQCPersister) Close() error { func loadAllCommitQCs(s *commitQCState) ([]LoadedCommitQC, error) { iw, ok := s.iw.Get() if !ok { - return nil, nil // no-op persister (persistence disabled) + return nil, nil } entries, err := iw.ReadAll() if err != nil { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/persist.go b/sei-tendermint/internal/autobahn/consensus/persist/persist.go index 83ff1b8315..6a4715f9ae 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/persist.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/persist.go @@ -79,15 +79,6 @@ type Persister[T protoutils.Message] interface { Persist(T) error } -type noopPersister[T protoutils.Message] struct{} - -func (noopPersister[T]) Persist(T) error { return nil } - -// newNoOpPersister returns a Persister that silently discards all writes. -func newNoOpPersister[T protoutils.Message]() Persister[T] { - return noopPersister[T]{} -} - // abPersister writes data to A/B files with automatic seq management. // File format: [4-byte CRC32-C BE] [8-byte seq LE] [proto-marshalled message]. // Only created when config has a state dir; dir is always a valid path. From 2fa77ed8d48b3f42fa361d51bfa9ce6bcc8175aa Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 19:53:00 -0400 Subject: [PATCH 10/46] chore(persist): rename stale TestNoOp* tests; fix misplaced comment in inner_test.go Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/consensus/inner_test.go | 3 +-- .../internal/autobahn/consensus/persist/blocks_test.go | 2 +- .../internal/autobahn/consensus/persist/commitqcs_test.go | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 7a9d853736..c77bed4726 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -16,8 +16,6 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) -// seedPersistedInner is a test helper that persists a persistedInner using the public API. - func newTestBlockDB(t testing.TB) types.BlockDB { cfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) db := utils.OrPanic1(littblock.NewBlockDB(cfg)) @@ -25,6 +23,7 @@ func newTestBlockDB(t testing.TB) types.BlockDB { return db } +// seedPersistedInner is a test helper that persists a persistedInner using the public API. func seedPersistedInner(dir string, state *persistedInner) { p, _, err := persist.NewPersister[*pb.PersistedInner](dir, innerFile) if err != nil { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go index 8d700f23a1..69a020d682 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go @@ -183,7 +183,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { require.Equal(t, types.BlockNumber(3), blocks2[lane2][3].Number) } -func TestNoOpBlockPersister(t *testing.T) { +func TestBlockPersisterAfterEachCallback(t *testing.T) { bp, blocks, err := NewBlockPersister(t.TempDir()) require.NoError(t, err) require.NotNil(t, bp) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index e67ae0ce5a..c5896b3a21 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -239,7 +239,7 @@ func TestLoadAllDetectsCommitQCGap(t *testing.T) { require.Contains(t, err.Error(), "gap") } -func TestNoOpCommitQCPersister(t *testing.T) { +func TestCommitQCPersisterFreshStart(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) qcs := makeSequentialCommitQCs(committee, keys, 11) From b5a7d61e0b7dd54470f176417c4eff9e26ba67aa Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 19:54:39 -0400 Subject: [PATCH 11/46] chore: remove remaining stale no-op persister references from comments Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/avail/state.go | 5 ++--- .../internal/autobahn/consensus/persist/commitqcs_test.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 888a24611d..88ba3a6b28 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -263,9 +263,8 @@ func (s *State) PushCommitQC(ctx context.Context, qc *types.CommitQC) error { } inner.commitQCs.pushBack(qc) metrics.ObserveCommitQC(committee, qc) - // The persist goroutine publishes latestCommitQC after writing to disk - // (or immediately for no-op persisters), so consensus won't advance - // until the CommitQC is durable. + // The persist goroutine publishes latestCommitQC after writing to disk, + // so consensus won't advance until the CommitQC is durable. ctrl.Updated() return nil } diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index c5896b3a21..19bca45992 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -244,7 +244,7 @@ func TestCommitQCPersisterFreshStart(t *testing.T) { committee, keys := types.GenCommittee(rng, 4) qcs := makeSequentialCommitQCs(committee, keys, 11) - // Fresh no-op persister: prune with anchor at index 0 (idx==0, + // Fresh persister: prune with anchor at index 0 (idx==0, // s.next==0). Should persist the anchor and advance to 1. cp, loaded, err := NewCommitQCPersister(t.TempDir()) require.NoError(t, err) From 6e66336d781bfb5fee835d80719afc837c4975e0 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 20:17:18 -0400 Subject: [PATCH 12/46] fix: close BlockDB in router tests; simplify loadPersistedState return type - giga_router_{fullnode,validator}_test.go: add t.Cleanup to close the LittDB-backed BlockDB opened by the router constructor - avail/state.go: loadPersistedState now returns *loadedAvailState directly (always non-nil on success); callers wrap with utils.Some at the newInner call site Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/state.go | 30 +++++++++---------- .../internal/p2p/giga_router_fullnode_test.go | 1 + .../p2p/giga_router_validator_test.go | 1 + 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 88ba3a6b28..1cf0e33219 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -91,20 +91,20 @@ var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAncho // loadPersistedState creates persisters for the given directory and loads // any existing state from disk. When a prune anchor is present, stale commitQCs // and blocks below the anchor are filtered out before returning. -func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters, error) { +func loadPersistedState(dir string) (*loadedAvailState, persisters, error) { prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) + return nil, persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) } bp, blocks, err := persist.NewBlockPersister(dir) if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewBlockPersister: %w", err) + return nil, persisters{}, fmt.Errorf("NewBlockPersister: %w", err) } cp, commitQCs, err := persist.NewCommitQCPersister(dir) if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) + return nil, persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) } pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} @@ -113,7 +113,7 @@ func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters if raw, ok := persistedPruneAnchor.Get(); ok { anchor, err := PruneAnchorConv.Decode(raw) if err != nil { - return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode prune anchor: %w", err) + return nil, persisters{}, fmt.Errorf("decode prune anchor: %w", err) } loaded.pruneAnchor = utils.Some(anchor) @@ -138,7 +138,7 @@ func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters } } - return utils.Some(loaded), pers, nil + return loaded, pers, nil } // NewState constructs a new availability state. @@ -148,24 +148,22 @@ func NewState(key types.SecretKey, data *data.State, stateDir string) (*State, e return nil, err } - inner, err := newInner(data.Committee(), loaded) + inner, err := newInner(data.Committee(), utils.Some(loaded)) if err != nil { return nil, err } // Truncate WAL entries below the prune anchor that were filtered out by // loadPersistedState. - if ls, ok := loaded.Get(); ok { - if anchor, ok := ls.pruneAnchor.Get(); ok { - for lane := range data.Committee().Lanes().All() { - if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { - return nil, fmt.Errorf("prune stale block WAL entries: %w", err) - } - } - if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchor.CommitQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { - return nil, fmt.Errorf("prune stale commitQC WAL entries: %w", err) + if anchor, ok := loaded.pruneAnchor.Get(); ok { + for lane := range data.Committee().Lanes().All() { + if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { + return nil, fmt.Errorf("prune stale block WAL entries: %w", err) } } + if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchor.CommitQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { + return nil, fmt.Errorf("prune stale commitQC WAL entries: %w", err) + } } return &State{ diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go index 830471a964..34bc8fa3c8 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go @@ -67,6 +67,7 @@ func TestGigaRouter_Fullnode(t *testing.T) { GenDoc: genDoc, }, makeKey(rng)) require.NoError(t, err) + t.Cleanup(func() { _ = router.data.Close() }) // EvmProxy: for every sender, the fullnode router resolves to the // shard owner's URL. NewGigaRouter rejects configs where any diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 65702d7db5..558920d0d6 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -239,6 +239,7 @@ func TestGigaRouter_EvmProxy(t *testing.T) { }, }, nodeKeys[0]) require.NoError(t, err) + t.Cleanup(func() { _ = router.data.Close() }) localValidator := validatorKeys[0].Public() localURL, ok := urlByValidator[localValidator] From 0cf1aea57b1c44a83fd79bc45f25d3e9f76843f5 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 20:19:53 -0400 Subject: [PATCH 13/46] chore: remove last stale no-op-mode references from test comments Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/persist/blocks_test.go | 2 +- .../internal/autobahn/consensus/persist/commitqcs_test.go | 5 ++--- sei-tendermint/internal/autobahn/consensus/state_test.go | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go index 69a020d682..fd6e1f8802 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go @@ -198,7 +198,7 @@ func TestBlockPersisterAfterEachCallback(t *testing.T) { proposals[i] = testSignedProposal(rng, key, types.BlockNumber(i)) } - // Persist and prune with anchor + new proposals in no-op mode. + // Persist and prune with anchor + new proposals. // Verify afterEach is still invoked for every proposal. var called int cb := utils.Some(func(_ *types.Signed[*types.LaneProposal]) { called++ }) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index 19bca45992..dfcac15497 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -260,9 +260,8 @@ func TestCommitQCPersisterFreshStart(t *testing.T) { // Prune with a future anchor (index 8 > s.next=5). deleteBefore // advances s.next to 8, then persistCommitQC persists the anchor // and advances s.next to 9. The remaining QCs (9,10) follow. - // Before the fix, deleteBefore in no-op mode wouldn't advance - // s.next, so re-persisting the anchor QC would fail with "out of - // sequence". + // deleteBefore must advance s.next before persistCommitQC runs, + // otherwise re-persisting the anchor QC fails with "out of sequence". require.NoError(t, cp.MaybePruneAndPersist( utils.Some(qcs[8]), qcs[9:], diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index d000317909..983452d475 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -13,8 +13,8 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) -// newTestState creates a State for testing with no persistence and a long -// view timeout (so voteTimeout is only triggered explicitly). +// newTestState creates a State for testing with a long view timeout +// (so voteTimeout is only triggered explicitly). // keys[0] is used as the node's signing key. func newTestState(t testing.TB, rng utils.Rng) (*State, []types.SecretKey) { From 3ecc27cb0e81f801c86da06ad90aa0d32e1ad50a Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 20:28:09 -0400 Subject: [PATCH 14/46] fix: propagate data.Close() error on router shutdown Use errors.Join to surface BlockDB close failures instead of discarding them with _ = r.data.Close(). Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_fullnode.go | 4 ++-- sei-tendermint/internal/p2p/giga_router_validator.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index 67d0fa27e5..ecb0996d67 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -2,6 +2,7 @@ package p2p import ( "context" + "errors" "maps" "math/rand/v2" "slices" @@ -56,8 +57,7 @@ func (r *gigaFullnodeRouter) Run(ctx context.Context) error { s.SpawnNamed("service", func() error { return r.service.Run(ctx) }) return nil }) - _ = r.data.Close() - return err + return errors.Join(err, r.data.Close()) } // runFullnodeSubscriber: pick a committee member, dial + block-sync, diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 1eb4e5d464..968b41b823 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -2,6 +2,7 @@ package p2p import ( "context" + "errors" "fmt" "net/url" @@ -85,8 +86,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { s.SpawnNamed("service", func() error { return r.service.Run(ctx) }) return nil }) - _ = r.data.Close() - return err + return errors.Join(err, r.data.Close()) } // EvmProxy on the validator returns None when the sender's shard owner is From db32fcb39918af640b375949b4f95fb82e131216 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 20:52:23 -0400 Subject: [PATCH 15/46] fix(data): trim orphaned QC entries when first block is beyond QC start When PruneBefore advances the watermark past the start of a straddling QC, the QC pass populates in.qcs for [qcFirst, blockN) but in.first is set to blockN. Those entries below blockN are unreachable via pruneFirst (which starts at first=blockN), so they leak for the lifetime of the process. Fix: on the first retained block, delete in.qcs[k] for k in [in.first, n) before updating in.first = n. Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/data/state.go | 7 +++++++ sei-tendermint/internal/autobahn/data/state_test.go | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index b424adf4d3..abb8d68ad5 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -277,6 +277,13 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { iterErr = fmt.Errorf("block %d in BlockDB predates first QC start %d", n, in.first) break } + // Drop QC entries below the first retained block (straddling-QC + // case: QC started before the prune boundary so the QC pass + // populated in.qcs for [qcFirst, n); those entries are unreachable + // via pruneFirst which starts at first=n). + for k := in.first; k < n; k++ { + delete(in.qcs, k) + } in.first = n in.nextBlock = n in.nextAppProposal = n diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 17c8897e8f..cb372d25da 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -647,6 +647,12 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { "first should be where blocks start, not where QC starts") require.Equal(t, gr1.Next, inner.nextQC, "QC should still cover the full range") + // QC entries below first must be absent — they are unreachable via + // pruneFirst and should have been trimmed during recovery. + for n := gr1.First; n < mid; n++ { + _, ok := inner.qcs[n] + require.False(t, ok, "orphaned QC entry at %d should have been trimmed", n) + } } for n := gr1.First; n < mid; n++ { _, err := state2.TryBlock(n) From 8b332dcb665337aba99ef18694bc3d537cfa69bc Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 21:15:46 -0400 Subject: [PATCH 16/46] revert: restore Option[*loadedAvailState] return from loadPersistedState The pointer return collapsed the None/Some distinction that newInner relies on: None means fresh start (skip restore loop), Some means disk state was found and should be applied. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/state.go | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 1cf0e33219..dbbdf70d45 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -91,20 +91,21 @@ var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAncho // loadPersistedState creates persisters for the given directory and loads // any existing state from disk. When a prune anchor is present, stale commitQCs // and blocks below the anchor are filtered out before returning. -func loadPersistedState(dir string) (*loadedAvailState, persisters, error) { +// Returns None when the directory contains no persisted state (fresh start). +func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters, error) { prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) if err != nil { - return nil, persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) } bp, blocks, err := persist.NewBlockPersister(dir) if err != nil { - return nil, persisters{}, fmt.Errorf("NewBlockPersister: %w", err) + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewBlockPersister: %w", err) } cp, commitQCs, err := persist.NewCommitQCPersister(dir) if err != nil { - return nil, persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewCommitQCPersister: %w", err) } pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} @@ -113,7 +114,7 @@ func loadPersistedState(dir string) (*loadedAvailState, persisters, error) { if raw, ok := persistedPruneAnchor.Get(); ok { anchor, err := PruneAnchorConv.Decode(raw) if err != nil { - return nil, persisters{}, fmt.Errorf("decode prune anchor: %w", err) + return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("decode prune anchor: %w", err) } loaded.pruneAnchor = utils.Some(anchor) @@ -138,7 +139,7 @@ func loadPersistedState(dir string) (*loadedAvailState, persisters, error) { } } - return loaded, pers, nil + return utils.Some(loaded), pers, nil } // NewState constructs a new availability state. @@ -148,21 +149,23 @@ func NewState(key types.SecretKey, data *data.State, stateDir string) (*State, e return nil, err } - inner, err := newInner(data.Committee(), utils.Some(loaded)) + inner, err := newInner(data.Committee(), loaded) if err != nil { return nil, err } // Truncate WAL entries below the prune anchor that were filtered out by // loadPersistedState. - if anchor, ok := loaded.pruneAnchor.Get(); ok { - for lane := range data.Committee().Lanes().All() { - if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { - return nil, fmt.Errorf("prune stale block WAL entries: %w", err) + if ls, ok := loaded.Get(); ok { + if anchor, ok := ls.pruneAnchor.Get(); ok { + for lane := range data.Committee().Lanes().All() { + if err := pers.blocks.MaybePruneAndPersistLane(lane, utils.Some(anchor.CommitQC), nil, utils.None[func(*types.Signed[*types.LaneProposal])]()); err != nil { + return nil, fmt.Errorf("prune stale block WAL entries: %w", err) + } + } + if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchor.CommitQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { + return nil, fmt.Errorf("prune stale commitQC WAL entries: %w", err) } - } - if err := pers.commitQCs.MaybePruneAndPersist(utils.Some(anchor.CommitQC), nil, utils.None[func(*types.CommitQC)]()); err != nil { - return nil, fmt.Errorf("prune stale commitQC WAL entries: %w", err) } } From 345d268ccdbd08f6407e8b0e21757709b3a3f7cc Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 21:41:20 -0400 Subject: [PATCH 17/46] refactor(persist): drop Option wrapper from commitQCState.iw iw is always initialized now that no-op mode is gone. Replace Option[*indexedWAL] with a plain pointer and a closed bool sentinel for idempotent Close. Co-Authored-By: Claude Sonnet 4.6 --- .../autobahn/consensus/persist/commitqcs.go | 41 ++++++++----------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go index 0a258048f1..ac69fe6b7e 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go @@ -19,8 +19,9 @@ type LoadedCommitQC struct { // commitQCState is the mutable state protected by CommitQCPersister's mutex. type commitQCState struct { - iw utils.Option[*indexedWAL[*types.CommitQC]] - next types.RoadIndex + iw *indexedWAL[*types.CommitQC] + closed bool + next types.RoadIndex } // persistCommitQC writes a CommitQC to the WAL. Caller must hold the lock. @@ -34,10 +35,8 @@ func (s *commitQCState) persistCommitQC(qc *types.CommitQC) error { if idx > s.next { return fmt.Errorf("commitqc %d out of sequence (next=%d)", idx, s.next) } - if iw, ok := s.iw.Get(); ok { - if err := iw.Write(qc); err != nil { - return fmt.Errorf("persist commitqc %d: %w", idx, err) - } + if err := s.iw.Write(qc); err != nil { + return fmt.Errorf("persist commitqc %d: %w", idx, err) } s.next = idx + 1 return nil @@ -47,19 +46,18 @@ func (s *commitQCState) persistCommitQC(qc *types.CommitQC) error { // re-persists the anchor for crash recovery. Caller must hold the lock. func (s *commitQCState) deleteBefore(anchor *types.CommitQC) error { idx := anchor.Index() - iw, ok := s.iw.Get() if idx >= s.next { s.next = idx - if ok && iw.Count() > 0 { - if err := iw.TruncateAll(); err != nil { + if s.iw.Count() > 0 { + if err := s.iw.TruncateAll(); err != nil { return err } } - } else if ok && iw.Count() > 0 { - firstRoadIndex := s.next - types.RoadIndex(iw.Count()) + } else if s.iw.Count() > 0 { + firstRoadIndex := s.next - types.RoadIndex(s.iw.Count()) if idx > firstRoadIndex { - walIdx := iw.FirstIdx() + uint64(idx-firstRoadIndex) - if err := iw.TruncateBefore(walIdx, func(entry *types.CommitQC) error { + walIdx := s.iw.FirstIdx() + uint64(idx-firstRoadIndex) + if err := s.iw.TruncateBefore(walIdx, func(entry *types.CommitQC) error { if entry.Index() != idx { return fmt.Errorf("commitqc at WAL index %d has road index %d, expected %d (index mapping broken)", walIdx, entry.Index(), idx) } @@ -94,7 +92,7 @@ func NewCommitQCPersister(stateDir string) (*CommitQCPersister, []LoadedCommitQC return nil, nil, fmt.Errorf("open commitqc WAL in %s: %w", dir, err) } - s := &commitQCState{iw: utils.Some(iw)} + s := &commitQCState{iw: iw} loaded, err := loadAllCommitQCs(s) if err != nil { _ = iw.Close() @@ -157,22 +155,17 @@ func (cp *CommitQCPersister) MaybePruneAndPersist( // Close shuts down the WAL. Safe to call multiple times (idempotent). func (cp *CommitQCPersister) Close() error { for s := range cp.state.Lock() { - iw, ok := s.iw.Get() - if !ok { - return nil // already closed + if s.closed { + return nil } - s.iw = utils.None[*indexedWAL[*types.CommitQC]]() - return iw.Close() + s.closed = true + return s.iw.Close() } panic("unreachable") } func loadAllCommitQCs(s *commitQCState) ([]LoadedCommitQC, error) { - iw, ok := s.iw.Get() - if !ok { - return nil, nil - } - entries, err := iw.ReadAll() + entries, err := s.iw.ReadAll() if err != nil { return nil, err } From 29d6ba46daf913dda643e1fe792dfbeeba90db95 Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 21:53:03 -0400 Subject: [PATCH 18/46] refactor(consensus): drop Option wrapper from State.persister persister is always initialized now that no-op mode is gone. Replace Option[Persister] with a plain Persister interface value. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/inner_test.go | 2 +- sei-tendermint/internal/autobahn/consensus/state.go | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index c77bed4726..c1b172da24 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -936,7 +936,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) wantErr := errors.New("disk on fire") - pers := utils.Some[persist.Persister[*pb.PersistedInner]](failPersister[*pb.PersistedInner]{err: wantErr}) + pers := persist.Persister[*pb.PersistedInner](failPersister[*pb.PersistedInner]{err: wantErr}) cs, err := newState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 842270ee30..33e102c4b0 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -42,7 +42,7 @@ type State struct { inner utils.Mutex[*utils.AtomicSend[inner]] innerRecv utils.AtomicRecv[inner] - persister utils.Option[persist.Persister[*pb.PersistedInner]] + persister persist.Persister[*pb.PersistedInner] timeoutVotes utils.Mutex[*timeoutVotes] prepareVotes utils.Mutex[*prepareVotes] @@ -81,7 +81,7 @@ func NewState(cfg *Config, data *data.State) (*State, error) { if err != nil { return nil, fmt.Errorf("NewPersister: %w", err) } - return newState(cfg, data, utils.Some(p), persistedData) + return newState(cfg, data, p, persistedData) } // newState is the internal constructor exposed for tests that need to inject @@ -89,7 +89,7 @@ func NewState(cfg *Config, data *data.State) (*State, error) { func newState( cfg *Config, data *data.State, - pers utils.Option[persist.Persister[*pb.PersistedInner]], + pers persist.Persister[*pb.PersistedInner], persistedData utils.Option[*pb.PersistedInner], ) (*State, error) { initialInner, err := newInner(persistedData, data.Committee()) @@ -247,10 +247,8 @@ func (s *State) runOutputs(ctx context.Context) error { s.myView.Store(vs) } // Persist to disk before broadcasting votes to the network. - if p, ok := s.persister.Get(); ok { - if err := p.Persist(innerProtoConv.Encode(&i.persistedInner)); err != nil { - return fmt.Errorf("persist inner: %w", err) - } + if err := s.persister.Persist(innerProtoConv.Encode(&i.persistedInner)); err != nil { + return fmt.Errorf("persist inner: %w", err) } if v, ok := i.PrepareVote.Get(); ok { updateOutput(&s.myPrepareVote, &types.ConsensusReqPrepareVote{Signed: v}) From 355838bc6234d12b16e4d010d788702a30e547df Mon Sep 17 00:00:00 2001 From: Wen Date: Mon, 6 Jul 2026 22:04:10 -0400 Subject: [PATCH 19/46] =?UTF-8?q?doc(avail):=20fix=20loadPersistedState=20?= =?UTF-8?q?comment=20=E2=80=94=20always=20returns=20Some?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/avail/state.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index dbbdf70d45..91b3822347 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -91,7 +91,7 @@ var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAncho // loadPersistedState creates persisters for the given directory and loads // any existing state from disk. When a prune anchor is present, stale commitQCs // and blocks below the anchor are filtered out before returning. -// Returns None when the directory contains no persisted state (fresh start). +// Returns Some with empty commitQCs/blocks on a fresh start (no prior state). func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters, error) { prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) if err != nil { From 53ea2b76199515eade28df55d805a0743d4a6911 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 7 Jul 2026 16:51:33 -0700 Subject: [PATCH 20/46] refactor(data): move BlockDB open/close into GigaRouter.Run State.NewState(cfg) is now infallible and takes no BlockDB. Call State.Init(blockDB) at the start of Run to replay persisted state; defer blockDB.Close() so the lifetime is scoped to the Run call. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/inner_test.go | 10 +- .../internal/autobahn/consensus/state_test.go | 12 +- .../internal/autobahn/data/state.go | 121 +++++++++--------- .../internal/autobahn/data/state_test.go | 84 +++++------- .../autobahn/producer/mempool_test.go | 14 +- sei-tendermint/internal/p2p/giga/data_test.go | 5 +- .../internal/p2p/giga_router_common.go | 31 ++--- .../internal/p2p/giga_router_fullnode.go | 19 ++- .../internal/p2p/giga_router_fullnode_test.go | 1 - .../internal/p2p/giga_router_validator.go | 22 +++- .../p2p/giga_router_validator_test.go | 1 - 11 files changed, 165 insertions(+), 155 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index c1b172da24..63db3e160a 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -23,6 +23,14 @@ func newTestBlockDB(t testing.TB) types.BlockDB { return db } +func newTestDataState(t testing.TB, committee *types.Committee) *data.State { + s := data.NewState(&data.Config{Committee: committee}) + if err := s.Init(newTestBlockDB(t)); err != nil { + t.Fatalf("data.Init: %v", err) + } + return s +} + // seedPersistedInner is a test helper that persists a persistedInner using the public API. func seedPersistedInner(dir string, state *persistedInner) { p, _, err := persist.NewPersister[*pb.PersistedInner](dir, innerFile) @@ -933,7 +941,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // and terminates the consensus component (instead of panicking). rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, committee) wantErr := errors.New("disk on fire") pers := persist.Persister[*pb.PersistedInner](failPersister[*pb.PersistedInner]{err: wantErr}) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 983452d475..1be6de0afa 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -19,10 +19,7 @@ import ( func newTestState(t testing.TB, rng utils.Rng) (*State, []types.SecretKey) { committee, keys := types.GenCommittee(rng, 3) - dataState := utils.OrPanic1(data.NewState( - &data.Config{Committee: committee}, - newTestBlockDB(t), - )) + dataState := newTestDataState(t, committee) s := utils.OrPanic1(NewState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, @@ -269,12 +266,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { PersistentStateDir: dir, } } - makeDataState := func() *data.State { - return utils.OrPanic1(data.NewState( - &data.Config{Committee: committee}, - newTestBlockDB(t), - )) - } + makeDataState := func() *data.State { return newTestDataState(t, committee) } view0 := types.View{Index: 0, Number: 0} pqc0 := makePrepareQC(keys, types.GenProposalAt(rng, view0)) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index abb8d68ad5..102821d956 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -192,23 +192,28 @@ type State struct { blockDBPersistedBlock types.GlobalBlockNumber } -// Close releases resources held by the BlockDB backing this State. -func (s *State) Close() error { - return s.blockDB.Close() -} - -// NewState constructs a new data State backed by blockDB for crash recovery. -// Pass littblock.NewBlockDB(cfg) for a durable store. -func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { - in := newInner(cfg.Committee) - // Restore QCs from BlockDB. On the first QC, skipTo its GlobalRange.First - // to advance past any pruned prefix (a straddling QC starts before the - // prune boundary). Subsequent QCs must be consecutive — insertQC errors - // on any gap. - { +// NewState constructs a new data State. The caller must call Run to open +// the BlockDB, replay persisted state, and start background goroutines. +func NewState(cfg *Config) *State { + return &State{ + cfg: cfg, + metrics: metrics.Get(), + inner: utils.NewWatch(newInner(cfg.Committee)), + } +} + +// loadFromBlockDB replays QCs and blocks from blockDB into s.inner and +// initialises the write-cursor fields. Called at the start of Run before +// any goroutines are spawned, so no locking is needed. +func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { + for in := range s.inner.Lock() { + // Restore QCs from BlockDB. On the first QC, skipTo its GlobalRange.First + // to advance past any pruned prefix (a straddling QC starts before the + // prune boundary). Subsequent QCs must be consecutive — insertQC errors + // on any gap. it, err := blockDB.QCs(false) if err != nil { - return nil, fmt.Errorf("open QC iterator: %w", err) + return fmt.Errorf("open QC iterator: %w", err) } firstQC := true var iterErr error @@ -225,48 +230,44 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { } if firstQC { firstQC = false - gr := qc.QC().GlobalRange(cfg.Committee) + gr := qc.QC().GlobalRange(s.cfg.Committee) if gr.First < in.nextQC { iterErr = fmt.Errorf("QC in BlockDB predates committee genesis %d: got %d", in.nextQC, gr.First) break } in.skipTo(gr.First) } - if iterErr = in.insertQC(cfg.Committee, qc); iterErr != nil { + if iterErr = in.insertQC(s.cfg.Committee, qc); iterErr != nil { iterErr = fmt.Errorf("load QC from BlockDB: %w", iterErr) break } } - closeErr := it.Close() - if iterErr != nil { - return nil, iterErr + if closeErr := it.Close(); iterErr == nil && closeErr != nil { + iterErr = fmt.Errorf("close QC iterator: %w", closeErr) } - if closeErr != nil { - return nil, fmt.Errorf("close QC iterator: %w", closeErr) + if iterErr != nil { + return iterErr } - } - // Restore blocks from BlockDB. The first retained block drives inner.first — - // a straddling QC may start before the prune boundary, so QC data alone - // cannot determine where blocks begin. insertBlock's n >= nextQC guard - // enforces the no-block-without-QC invariant. - { - it, err := blockDB.Blocks(false) + // Restore blocks from BlockDB. The first retained block drives inner.first — + // a straddling QC may start before the prune boundary, so QC data alone + // cannot determine where blocks begin. insertBlock's n >= nextQC guard + // enforces the no-block-without-QC invariant. + it2, err := blockDB.Blocks(false) if err != nil { - return nil, fmt.Errorf("open block iterator: %w", err) + return fmt.Errorf("open block iterator: %w", err) } var ( - iterErr error firstBlock = true nextExpect types.GlobalBlockNumber ) for { var ok bool - ok, iterErr = it.Next() + ok, iterErr = it2.Next() if iterErr != nil || !ok { break } - n := it.Number() + n := it2.Number() if n >= in.nextQC { iterErr = fmt.Errorf("block %d in BlockDB has no QC coverage (nextQC=%d)", n, in.nextQC) break @@ -298,43 +299,36 @@ func NewState(cfg *Config, blockDB types.BlockDB) (*State, error) { } nextExpect++ var blk *types.Block - blk, iterErr = it.Block() + blk, iterErr = it2.Block() if iterErr != nil { break } - if iterErr = blk.Verify(cfg.Committee); iterErr != nil { + if iterErr = blk.Verify(s.cfg.Committee); iterErr != nil { iterErr = fmt.Errorf("verify block %d from BlockDB: %w", n, iterErr) break } - if iterErr = in.insertBlock(cfg.Committee, n, blk); iterErr != nil { + if iterErr = in.insertBlock(s.cfg.Committee, n, blk); iterErr != nil { iterErr = fmt.Errorf("insert block %d from BlockDB: %w", n, iterErr) break } } - closeErr := it.Close() - if iterErr != nil { - return nil, iterErr + if closeErr := it2.Close(); iterErr == nil && closeErr != nil { + iterErr = fmt.Errorf("close block iterator: %w", closeErr) } - if closeErr != nil { - return nil, fmt.Errorf("close block iterator: %w", closeErr) + if iterErr != nil { + return iterErr } - } - // Advance nextBlock through contiguous loaded blocks. Don't use - // updateNextBlock: stale timestamps would skew metrics. - for ; in.blocks[in.nextBlock] != nil; in.nextBlock++ { + // Advance nextBlock through contiguous loaded blocks. Don't use + // updateNextBlock: stale timestamps would skew metrics. + for ; in.blocks[in.nextBlock] != nil; in.nextBlock++ { + } + // Data loaded from BlockDB was already durably persisted. + in.nextBlockToPersist = in.nextBlock + s.blockDBPersistedQC = in.nextQC + s.blockDBPersistedBlock = in.nextBlock } - // Data loaded from BlockDB was already durably persisted. - in.nextBlockToPersist = in.nextBlock - - return &State{ - cfg: cfg, - metrics: metrics.Get(), - inner: utils.NewWatch(in), - blockDB: blockDB, - blockDBPersistedQC: in.nextQC, - blockDBPersistedBlock: in.nextBlock, - }, nil + return nil } // Committee returns the committee. @@ -764,8 +758,21 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { } } -// Run runs the background tasks of the data State. +// Init replays persisted state from blockDB and stores the reference for use +// by Run's background goroutines. Must be called once before Run, from the +// same goroutine that will call Run (no concurrent callers). The caller owns +// blockDB and is responsible for closing it after Run returns. // TODO(gprusak): add support for starting execution from non-zero commit QC. +func (s *State) Init(blockDB types.BlockDB) error { + if err := s.loadFromBlockDB(blockDB); err != nil { + return fmt.Errorf("loadFromBlockDB: %w", err) + } + s.blockDB = blockDB + return nil +} + +// Run starts the background goroutines (persistence and pruning). Init must +// be called before Run. func (s *State) Run(ctx context.Context) error { return scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { scope.SpawnNamed("runPersist", func() error { diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index cb372d25da..26321e2052 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -62,6 +62,14 @@ func newTestLittDB(t *testing.T, dir string) types.BlockDB { return db } +// newTestState constructs a State, replays db, and returns it ready to Run. +func newTestState(t testing.TB, cfg *Config, db types.BlockDB) *State { + t.Helper() + s := NewState(cfg) + require.NoError(t, s.Init(db)) + return s +} + // writeToBlockDB writes QC+block pairs sequentially to db and flushes once. // qcs[i] and blockss[i] must correspond; QCs must be in ascending order. func writeToBlockDB(t *testing.T, db types.BlockDB, committee *types.Committee, qcs []*types.FullCommitQC, blockss [][]*types.Block) { @@ -82,9 +90,7 @@ func TestState(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - state := utils.OrPanic1(NewState(&Config{ - Committee: committee, - }, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -155,9 +161,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := utils.OrPanic1(NewState(&Config{ - Committee: committee, - }, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) // Push a valid QC to advance inner.nextQC. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -265,9 +269,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := utils.OrPanic1(NewState(&Config{ - Committee: committee, - }, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) // Push qc1 with NO blocks — only the QC is stored. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -311,9 +313,7 @@ func TestExecution(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - state := utils.OrPanic1(NewState(&Config{ - Committee: committee, - }, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -355,9 +355,7 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := utils.OrPanic1(NewState(&Config{ - Committee: committee, - }, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) // Push QC without blocks. qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -376,9 +374,7 @@ func TestGlobalBlockByHash(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := utils.OrPanic1(NewState(&Config{ - Committee: committee, - }, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, blocks)) @@ -421,13 +417,12 @@ func TestRecoveryEmpty(t *testing.T) { fb := committee.FirstBlock() db := newTestLittDB(t, dir) - state := utils.OrPanic1(NewState(&Config{Committee: committee}, db)) + state := newTestState(t, &Config{Committee: committee}, db) for inner := range state.inner.Lock() { require.Equal(t, fb, inner.first) require.Equal(t, fb, inner.nextQC) require.Equal(t, fb, inner.nextBlock) } - require.NoError(t, state.Close()) } // TestRecoveryNormal verifies that NewState fully restores QCs and blocks @@ -451,7 +446,7 @@ func TestRecoveryNormal(t *testing.T) { // Session 2: NewState should recover blocks and QCs. db2 := newTestLittDB(t, dir) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) + state2 := newTestState(t, &Config{Committee: committee}, db2) require.Equal(t, gr2.Next, state2.NextBlock()) for n := gr1.First; n < gr2.Next; n++ { @@ -464,13 +459,12 @@ func TestRecoveryNormal(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) } - require.NoError(t, state2.Close()) + require.NoError(t, db2.Close()) // Session 3: verify session 2 did not corrupt BlockDB. db3 := newTestLittDB(t, dir) - state3 := utils.OrPanic1(NewState(&Config{Committee: committee}, db3)) + state3 := newTestState(t, &Config{Committee: committee}, db3) require.Equal(t, gr2.Next, state3.NextBlock()) - require.NoError(t, state3.Close()) } // TestPruningDiscards verifies that PruneBefore advances inner.first and causes @@ -488,7 +482,7 @@ func TestPruningDiscards(t *testing.T) { gr2 := qc2.QC().GlobalRange(committee) gr3 := qc3.QC().GlobalRange(committee) - state := utils.OrPanic1(NewState(&Config{Committee: committee}, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) require.NoError(t, state.PushQC(ctx, qc2, blocks2)) require.NoError(t, state.PushQC(ctx, qc3, blocks3)) @@ -545,7 +539,7 @@ func TestRecoveryAfterPruning(t *testing.T) { // Recovery: first = gr2.First; qc1's range is before first, so ErrPruned. db2 := newTestLittDB(t, dir) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) + state2 := newTestState(t, &Config{Committee: committee}, db2) require.Equal(t, gr3.Next, state2.NextBlock()) for n := qc1.QC().GlobalRange(committee).First; n < gr2.First; n++ { @@ -557,7 +551,6 @@ func TestRecoveryAfterPruning(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) } - require.NoError(t, state2.Close()) } // TestRecoveryBlocksBehind verifies recovery when QCs cover more range than @@ -587,7 +580,7 @@ func TestRecoveryBlocksBehind(t *testing.T) { // Recovery: both QCs loaded, but only qc1's blocks. db2 := newTestLittDB(t, dir) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) + state2 := newTestState(t, &Config{Committee: committee}, db2) for n := gr1.First; n < gr1.Next; n++ { got, err := state2.TryBlock(n) @@ -605,7 +598,6 @@ func TestRecoveryBlocksBehind(t *testing.T) { i++ } require.Equal(t, gr2.Next, state2.NextBlock()) - require.NoError(t, state2.Close()) } // TestRecoveryPartialQCPrefix verifies that when the QC spans a wider range @@ -640,7 +632,7 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { // Recovery should use blocks as golden: first == mid, not gr1.First. db2 := newTestLittDB(t, dir) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) + state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { require.Equal(t, mid, inner.first, @@ -663,7 +655,6 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { require.NoError(t, err) require.NotNil(t, got) } - require.NoError(t, state2.Close()) } // TestRecoveryAfterPruneNoGC verifies that restarting before async GC reclaims @@ -701,15 +692,13 @@ func TestRecoveryAfterPruneNoGC(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = db2.Close() }) - // NewState must succeed — below-watermark blocks never outlive their QCs + // Init must succeed — below-watermark blocks never outlive their QCs // because blocks and QCs share the same GC filter in littblock. Without GC, // all entries are still present and recovery treats the DB as unpruned. // This is the PruneBefore-without-GC path: the watermark advanced but // physical reclamation has not happened yet, so the DB looks like a // fresh DB containing all data from qc1 and qc2. - state, err := NewState(&Config{Committee: committee}, db2) - require.NoError(t, err) - t.Cleanup(func() { _ = state.Close() }) + state := newTestState(t, &Config{Committee: committee}, db2) // Without GC all data is still present; qc1 and qc2 blocks are accessible. for n := gr1.First; n < gr2.Next; n++ { @@ -735,8 +724,7 @@ func TestRecoveryQCsNoBlocks(t *testing.T) { require.NoError(t, db1.Close()) db2 := newTestLittDB(t, dir) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) - defer func() { require.NoError(t, state2.Close()) }() + state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { require.Equal(t, gr1.First, inner.first) @@ -776,10 +764,10 @@ func TestRecoveryBlockGap(t *testing.T) { require.NoError(t, db1.Close()) db2 := newTestLittDB(t, dir) - _, err := NewState(&Config{Committee: committee}, db2) + s2 := NewState(&Config{Committee: committee}) + err := s2.Init(db2) require.Error(t, err) require.Contains(t, err.Error(), "block gap in BlockDB") - require.NoError(t, db2.Close()) } // ── Non-recovery tests ──────────────────────────────────────────────── @@ -796,7 +784,7 @@ func TestPruningKeepsLastQCRange(t *testing.T) { gr1 := qc1.QC().GlobalRange(committee) // In-memory: push QC, execute all blocks, then prune everything. - state1 := utils.OrPanic1(NewState(&Config{Committee: committee}, newTestLittDB(t, t.TempDir()))) + state1 := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) runCtx, cancel := context.WithCancel(ctx) @@ -829,12 +817,11 @@ func TestPruningKeepsLastQCRange(t *testing.T) { require.NoError(t, db.Close()) db2 := newTestLittDB(t, dir) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) + state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { require.Equal(t, survivingBlock, inner.first, "after restart, first should match the surviving block") } - require.NoError(t, state2.Close()) } // TestPruningWithPartialQCRange verifies per-block pruning across QC ranges: @@ -851,7 +838,7 @@ func TestPruningWithPartialQCRange(t *testing.T) { gr2 := qc2.QC().GlobalRange(committee) // In-memory: push 2 QCs, execute, then prune. - state1 := utils.OrPanic1(NewState(&Config{Committee: committee}, newTestLittDB(t, t.TempDir()))) + state1 := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) require.NoError(t, state1.PushQC(ctx, qc2, blocks2)) @@ -897,12 +884,11 @@ func TestPruningWithPartialQCRange(t *testing.T) { require.NoError(t, db.Close()) db2 := newTestLittDB(t, dir) - state2 := utils.OrPanic1(NewState(&Config{Committee: committee}, db2)) + state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { require.Equal(t, survivingBlock, inner.first, "after restart, first should match the surviving block") } - require.NoError(t, state2.Close()) } // TestRunPruningEmptyState verifies that runPruning does not panic when @@ -911,10 +897,10 @@ func TestRunPruningEmptyState(t *testing.T) { rng := utils.TestRng() committee, _ := types.GenCommittee(rng, 3) - state := utils.OrPanic1(NewState(&Config{ + state := newTestState(t, &Config{ Committee: committee, PruneAfter: utils.Some(time.Duration(0)), - }, newTestLittDB(t, t.TempDir()))) + }, newTestLittDB(t, t.TempDir())) // Run briefly — runPruning should not panic on empty state. ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) @@ -928,9 +914,7 @@ func TestPushBlockWaitsForQC(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := utils.OrPanic1(NewState(&Config{ - Committee: committee, - }, newTestLittDB(t, t.TempDir()))) + state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) // Push first QC covering [0, N). qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 9d75cbb997..5a6ba029b4 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -41,6 +41,15 @@ func newTestBlockDB(t testing.TB) types.BlockDB { return db } +func newTestDataState(t testing.TB, committee *types.Committee) *data.State { + t.Helper() + s := data.NewState(&data.Config{Committee: committee}) + if err := s.Init(newTestBlockDB(t)); err != nil { + t.Fatalf("data.Init: %v", err) + } + return s +} + func (tx *txSpec) encode() []byte { return utils.OrPanic1(binary.Append(nil, binary.BigEndian, tx)) } @@ -237,10 +246,7 @@ func (env *testEnv) Run(ctx context.Context) error { func newTestEnv(t testing.TB, rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { committee, keys := types.GenCommittee(rng, 1) - dataState := utils.OrPanic1(data.NewState( - &data.Config{Committee: committee}, - newTestBlockDB(t), - )) + dataState := newTestDataState(t, committee) consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index e0a9518741..ed3be4e8c8 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -31,7 +31,10 @@ func newTestNode(t testing.TB, committee *types.Committee, cfg *consensus.Config blockCfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) db := utils.OrPanic1(littblock.NewBlockDB(blockCfg)) t.Cleanup(func() { _ = db.Close() }) - dataState := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, db)) + dataState := data.NewState(&data.Config{Committee: committee}) + if err := dataState.Init(db); err != nil { + panic(fmt.Sprintf("data.Init: %v", err)) + } consensusState, err := consensus.NewState(cfg, dataState) if err != nil { panic(fmt.Sprintf("consensus.NewState(): %v", err)) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 1630981593..e76433ca9d 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -55,20 +55,22 @@ type gigaRouterCommon struct { hashVault hashvault.HashVault } -// buildDataState validates the common config and constructs the data -// layer (committee, BlockDB, State) shared by both giga constructors. -func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { +// buildDataState validates the common config, constructs the committee and +// BlockDB config, and returns an uninitialised data.State. The caller's Run +// method opens BlockDB, replays persisted state, and closes it on exit by +// calling data.State.Run(ctx, blockDB). +func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, *littblock.LittBlockConfig, error) { if cfg.GenDoc.InitialHeight < 1 { - return nil, fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) + return nil, nil, fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) } if cfg.DialInterval <= 0 { - return nil, fmt.Errorf("GigaRouterCommonConfig.DialInterval = %v, want > 0", cfg.DialInterval) + return nil, nil, fmt.Errorf("GigaRouterCommonConfig.DialInterval = %v, want > 0", cfg.DialInterval) } if cfg.MaxInboundFullnodePeers < 0 || cfg.MaxInboundFullnodePeers > maxInboundFullnodePeers { - return nil, fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) + return nil, nil, fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) } if cfg.PersistentStateDir == "" { - return nil, fmt.Errorf("GigaRouterCommonConfig.PersistentStateDir must not be empty") + return nil, nil, fmt.Errorf("GigaRouterCommonConfig.PersistentStateDir must not be empty") } committee, err := atypes.NewRoundRobinElection( slices.Collect(maps.Keys(cfg.ValidatorAddrs)), @@ -76,22 +78,13 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, error) { cfg.GenDoc.GenesisTime, ) if err != nil { - return nil, fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) + return nil, nil, fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) } blockCfg, err := littblock.DefaultConfig(filepath.Join(cfg.PersistentStateDir, "blockdb")) if err != nil { - return nil, fmt.Errorf("littblock.DefaultConfig: %w", err) + return nil, nil, fmt.Errorf("littblock.DefaultConfig: %w", err) } - blockDB, err := littblock.NewBlockDB(blockCfg) - if err != nil { - return nil, fmt.Errorf("littblock.NewBlockDB: %w", err) - } - dataState, err := data.NewState(&data.Config{Committee: committee}, blockDB) - if err != nil { - _ = blockDB.Close() - return nil, fmt.Errorf("data.NewState(): %w", err) - } - return dataState, nil + return data.NewState(&data.Config{Committee: committee}), blockCfg, nil } func (r *gigaRouterCommon) LastCommittedBlockNumber() int64 { diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index ecb0996d67..1017bcf8e5 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -2,11 +2,12 @@ package p2p import ( "context" - "errors" + "fmt" "maps" "math/rand/v2" "slices" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" @@ -16,10 +17,12 @@ import ( type gigaFullnodeRouter struct { *gigaRouterCommon + + blockDBCfg *littblock.LittBlockConfig } func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gigaFullnodeRouter, error) { - dataState, err := buildDataState(cfg) + dataState, blockDBCfg, err := buildDataState(cfg) if err != nil { return nil, err } @@ -35,6 +38,7 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gig app: cfg.App, inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), }, + blockDBCfg: blockDBCfg, }, nil } @@ -43,7 +47,15 @@ func (r *gigaFullnodeRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaFullnodeRouter) Run(ctx context.Context) error { - err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + blockDB, err := littblock.NewBlockDB(r.blockDBCfg) + if err != nil { + return fmt.Errorf("open BlockDB: %w", err) + } + defer blockDB.Close() + if err := r.data.Init(blockDB); err != nil { + return fmt.Errorf("data.Init: %w", err) + } + return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Single-active subscriber: walk the committee in a stable order, // move to the next on disconnect. Avoids the N× QC duplication of // fanning out to every committee member. @@ -57,7 +69,6 @@ func (r *gigaFullnodeRouter) Run(ctx context.Context) error { s.SpawnNamed("service", func() error { return r.service.Run(ctx) }) return nil }) - return errors.Join(err, r.data.Close()) } // runFullnodeSubscriber: pick a committee member, dial + block-sync, diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go index 34bc8fa3c8..830471a964 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go @@ -67,7 +67,6 @@ func TestGigaRouter_Fullnode(t *testing.T) { GenDoc: genDoc, }, makeKey(rng)) require.NoError(t, err) - t.Cleanup(func() { _ = router.data.Close() }) // EvmProxy: for every sender, the fullnode router resolves to the // shard owner's URL. NewGigaRouter rejects configs where any diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 968b41b823..20138d62b2 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -2,11 +2,11 @@ package p2p import ( "context" - "errors" "fmt" "net/url" "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" @@ -19,15 +19,16 @@ import ( type gigaValidatorRouter struct { *gigaRouterCommon - consensus *consensus.State - producer *producer.State + blockDBCfg *littblock.LittBlockConfig + consensus *consensus.State + producer *producer.State // validatorKey is the cached public form of cfg.ValidatorKey, used by // EvmProxy to short-circuit self-shard sends to the local mempool. validatorKey atypes.PublicKey } func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaValidatorRouter, error) { - dataState, err := buildDataState(&cfg.GigaRouterCommonConfig) + dataState, blockDBCfg, err := buildDataState(&cfg.GigaRouterCommonConfig) if err != nil { return nil, err } @@ -37,7 +38,6 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV PersistentStateDir: cfg.PersistentStateDir, }, dataState) if err != nil { - _ = dataState.Close() return nil, fmt.Errorf("consensus.NewState(): %w", err) } producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) @@ -53,6 +53,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV app: cfg.App, inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), }, + blockDBCfg: blockDBCfg, consensus: consensusState, producer: producerState, validatorKey: cfg.ValidatorKey.Public(), @@ -64,7 +65,15 @@ func (r *gigaValidatorRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaValidatorRouter) Run(ctx context.Context) error { - err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + blockDB, err := littblock.NewBlockDB(r.blockDBCfg) + if err != nil { + return fmt.Errorf("open BlockDB: %w", err) + } + defer blockDB.Close() + if err := r.data.Init(blockDB); err != nil { + return fmt.Errorf("data.Init: %w", err) + } + return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Validators dial every committee member in parallel — consensus // voting needs fan-out, not stickiness. Same connections also // serve block sync between committee peers. @@ -86,7 +95,6 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { s.SpawnNamed("service", func() error { return r.service.Run(ctx) }) return nil }) - return errors.Join(err, r.data.Close()) } // EvmProxy on the validator returns None when the sender's shard owner is diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 558920d0d6..65702d7db5 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -239,7 +239,6 @@ func TestGigaRouter_EvmProxy(t *testing.T) { }, }, nodeKeys[0]) require.NoError(t, err) - t.Cleanup(func() { _ = router.data.Close() }) localValidator := validatorKeys[0].Public() localURL, ok := urlByValidator[localValidator] From 416743a753b3ae750ddf29b074748ac86a614b92 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 7 Jul 2026 19:00:38 -0700 Subject: [PATCH 21/46] refactor(data): change blockDB field to utils.Option[BlockDB]; restore persist/avail Option[string] pattern Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/inner_test.go | 16 ++- .../internal/autobahn/avail/state.go | 19 +++- .../internal/autobahn/avail/state_test.go | 106 ++++++++---------- .../internal/autobahn/consensus/inner_test.go | 8 +- .../autobahn/consensus/persist/blocks.go | 35 +++++- .../autobahn/consensus/persist/blocks_test.go | 54 ++++----- .../autobahn/consensus/persist/commitqcs.go | 52 +++++---- .../consensus/persist/commitqcs_test.go | 50 ++++----- .../autobahn/consensus/persist/persist.go | 43 ++++--- .../consensus/persist/persist_test.go | 49 ++++---- .../internal/autobahn/consensus/state.go | 20 +++- .../internal/autobahn/consensus/state_test.go | 4 +- .../internal/autobahn/data/state.go | 55 +++++---- .../autobahn/producer/mempool_test.go | 2 +- .../internal/p2p/giga/avail_test.go | 2 +- sei-tendermint/internal/p2p/giga/data_test.go | 4 +- .../internal/p2p/giga_router_validator.go | 2 +- 17 files changed, 301 insertions(+), 220 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index d491d2defe..284732e6c1 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -19,6 +19,14 @@ func newTestBlockDB(t testing.TB) types.BlockDB { return db } +func newTestDataState(t testing.TB, cfg *data.Config) *data.State { + s := data.NewState(cfg) + if err := s.Init(newTestBlockDB(t)); err != nil { + t.Fatalf("data.Init: %v", err) + } + return s +} + func TestPruneMismatchedIndices(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) @@ -43,8 +51,8 @@ func TestPruneMismatchedIndices(t *testing.T) { qc1 := makeCommitQC(utils.Some(qc0)) t.Logf("test State.PushAppQC") - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) - state, err := NewState(keys[0], ds, t.TempDir()) + ds := newTestDataState(t, &data.Config{Committee: committee}) + state, err := NewState(keys[0], ds, utils.Some(t.TempDir())) require.NoError(t, err) require.Error(t, state.PushAppQC(makeAppQC(qc0, qc0), qc1), "bad range, bad index should fail") require.Error(t, state.PushAppQC(makeAppQC(qc1, qc0), qc1), "good range, bad index should fail") @@ -52,8 +60,8 @@ func TestPruneMismatchedIndices(t *testing.T) { require.NoError(t, state.PushAppQC(makeAppQC(qc1, qc1), qc1), "good range, good index should succeed") t.Logf("test inner.prune") - ds = utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) - state, err = NewState(keys[0], ds, t.TempDir()) + ds = newTestDataState(t, &data.Config{Committee: committee}) + state, err = NewState(keys[0], ds, utils.Some(t.TempDir())) require.NoError(t, err) for inner := range state.inner.Lock() { _, err := inner.prune(committee, makeAppQC(qc1, qc0), qc1) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 91b3822347..a2a9b08345 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -88,11 +88,11 @@ var PruneAnchorConv = protoutils.Conv[*PruneAnchor, *pb.PersistedAvailPruneAncho }, } -// loadPersistedState creates persisters for the given directory and loads -// any existing state from disk. When a prune anchor is present, stale commitQCs -// and blocks below the anchor are filtered out before returning. -// Returns Some with empty commitQCs/blocks on a fresh start (no prior state). -func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters, error) { +// loadPersistedState creates persisters for the given directory option and loads +// any existing state from disk. When dir is None, all persisters are no-op +// and no state is loaded. When a prune anchor is present, stale commitQCs and +// blocks below the anchor are filtered out before returning. +func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailState], persisters, error) { prunePersister, persistedPruneAnchor, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) if err != nil { return utils.None[*loadedAvailState](), persisters{}, fmt.Errorf("NewPersister %s: %w", innerFile, err) @@ -109,6 +109,11 @@ func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters } pers := persisters{pruneAnchor: prunePersister, blocks: bp, commitQCs: cp} + + if _, ok := dir.Get(); !ok { + return utils.None[*loadedAvailState](), pers, nil + } + loaded := &loadedAvailState{commitQCs: commitQCs, blocks: blocks} if raw, ok := persistedPruneAnchor.Get(); ok { @@ -143,7 +148,9 @@ func loadPersistedState(dir string) (utils.Option[*loadedAvailState], persisters } // NewState constructs a new availability state. -func NewState(key types.SecretKey, data *data.State, stateDir string) (*State, error) { +// stateDir is None when persistence is disabled (testing/loadtest); a no-op +// persist goroutine still runs to bump cursors without disk I/O. +func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[string]) (*State, error) { loaded, pers, err := loadPersistedState(stateDir) if err != nil { return nil, err diff --git a/sei-tendermint/internal/autobahn/avail/state_test.go b/sei-tendermint/internal/autobahn/avail/state_test.go index 74f3768b20..cfe880d21a 100644 --- a/sei-tendermint/internal/autobahn/avail/state_test.go +++ b/sei-tendermint/internal/autobahn/avail/state_test.go @@ -85,7 +85,7 @@ func qcPayloadHashes(qc *types.FullCommitQC) byLane[types.PayloadHash] { } func TestState(t *testing.T) { - testState(t, t.TempDir()) + testState(t, utils.None[string]()) } // TestStateWithPersistence runs the same flow as TestState but with disk @@ -94,20 +94,18 @@ func TestState(t *testing.T) { // pruned map entries. func TestStateWithPersistence(t *testing.T) { for range 5 { - testState(t, t.TempDir()) + testState(t, utils.Some(t.TempDir())) } } -func testState(t *testing.T, stateDir string) { +func testState(t *testing.T, stateDir utils.Option[string]) { t.Helper() ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - ds := utils.OrPanic1(data.NewState(&data.Config{ - Committee: committee, - }, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) s.SpawnBgNamed("data.State.Run()", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) @@ -236,11 +234,11 @@ func TestStateRestartFromPersisted(t *testing.T) { var wantNextBlocks map[types.LaneID]types.BlockNumber require.NoError(t, scope.Run(t.Context(), func(ctx context.Context, s scope.Scope) error { - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) s.SpawnBgNamed("data.Run", func() error { return utils.IgnoreCancel(ds.Run(ctx)) }) - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) if err != nil { return err } @@ -311,8 +309,8 @@ func TestStateRestartFromPersisted(t *testing.T) { })) // Phase 2: Restart from the same directory. - ds2 := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) - state2, err := NewState(keys[0], ds2, dir) + ds2 := newTestDataState(t, &data.Config{Committee: committee}) + state2, err := NewState(keys[0], ds2, utils.Some(dir)) require.NoError(t, err) got, ok := state2.LastAppQC().Get() @@ -335,10 +333,8 @@ func TestStateMismatchedQCs(t *testing.T) { committee, keys := types.GenCommittee(rng, 4) initialBlock := committee.FirstBlock() - ds := utils.OrPanic1(data.NewState(&data.Config{ - Committee: committee, - }, newTestBlockDB(t))) - state, err := NewState(keys[0], ds, t.TempDir()) + ds := newTestDataState(t, &data.Config{Committee: committee}) + state, err := NewState(keys[0], ds, utils.Some(t.TempDir())) require.NoError(t, err) // Helper to create a CommitQC for a specific index @@ -393,10 +389,8 @@ func TestPushBlockRejectsBadParentHash(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - ds := utils.OrPanic1(data.NewState(&data.Config{ - Committee: committee, - }, newTestBlockDB(t))) - state := utils.OrPanic1(NewState(keys[0], ds, t.TempDir())) + ds := newTestDataState(t, &data.Config{Committee: committee}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) // Produce a valid first block on our lane. _, err := state.ProduceLocalBlock(state.NextBlock(keys[0].Public()), types.GenPayload(rng)) @@ -418,10 +412,8 @@ func TestPushBlockRejectsWrongSigner(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - ds := utils.OrPanic1(data.NewState(&data.Config{ - Committee: committee, - }, newTestBlockDB(t))) - state := utils.OrPanic1(NewState(keys[0], ds, t.TempDir())) + ds := newTestDataState(t, &data.Config{Committee: committee}) + state := utils.OrPanic1(NewState(keys[0], ds, utils.Some(t.TempDir()))) // Create a block on keys[0]'s lane but sign it with keys[1]. lane := keys[0].Public() @@ -439,9 +431,9 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("empty dir loads fresh state", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) // No persisted AppQC → None. @@ -452,7 +444,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted AppQC", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) roadIdx := types.RoadIndex(7) globalNum := types.GlobalBlockNumber(50) @@ -460,7 +452,7 @@ func TestNewStateWithPersistence(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist commitQCs 0-7 so the matching one at roadIdx exists. - cp, _, err := persist.NewCommitQCPersister(dir) + cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC @@ -472,14 +464,14 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist prune anchor (AppQC + CommitQC pair). - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), CommitQc: types.CommitQCConv.Encode(pruneQC), })) - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) aq := state.LastAppQC() @@ -493,11 +485,11 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted blocks", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) lane := keys[0].Public() // Persist blocks using BlockPersister. - bp, _, err := persist.NewBlockPersister(dir) + bp, _, err := persist.NewBlockPersister(utils.Some(dir)) require.NoError(t, err) var parent types.BlockHeaderHash @@ -509,7 +501,7 @@ func TestNewStateWithPersistence(t *testing.T) { } // Now construct state — it should load the blocks. - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) require.Equal(t, types.BlockNumber(3), state.NextBlock(lane)) @@ -517,7 +509,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted AppQC and blocks together", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) lane := keys[0].Public() roadIdx := types.RoadIndex(2) @@ -526,7 +518,7 @@ func TestNewStateWithPersistence(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist commitQCs 0-2 so the matching one at roadIdx exists. - cp, _, err := persist.NewCommitQCPersister(dir) + cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) prev := utils.None[*types.CommitQC]() var pruneQC *types.CommitQC @@ -538,7 +530,7 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist prune anchor (AppQC + CommitQC pair). - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -546,7 +538,7 @@ func TestNewStateWithPersistence(t *testing.T) { })) // Persist blocks starting at 0 (nil laneQCs → lr.First()=0 after prune). - bp, _, err := persist.NewBlockPersister(dir) + bp, _, err := persist.NewBlockPersister(utils.Some(dir)) require.NoError(t, err) var parent types.BlockHeaderHash @@ -557,7 +549,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, bp.MaybePruneAndPersistLane(lane, utils.None[*types.CommitQC](), []*types.Signed[*types.LaneProposal]{signed}, noBlockCB)) } - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) got, ok := state.LastAppQC().Get() @@ -570,10 +562,10 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted commitQCs", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) // Persist CommitQCs to disk. - cp, _, err := persist.NewCommitQCPersister(dir) + cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) qcs := make([]*types.CommitQC, 3) @@ -584,7 +576,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) } - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) // All 3 commitQCs should be loaded (no AppQC to skip past). @@ -595,7 +587,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("loads persisted commitQCs with AppQC", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) // Persist AppQC at road index 1. roadIdx := types.RoadIndex(1) @@ -604,7 +596,7 @@ func TestNewStateWithPersistence(t *testing.T) { appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) // Persist CommitQCs 0-4. - cp, _, err := persist.NewCommitQCPersister(dir) + cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) qcs := make([]*types.CommitQC, 5) @@ -616,14 +608,14 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist prune anchor (AppQC + CommitQC pair at roadIdx). - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), CommitQc: types.CommitQCConv.Encode(qcs[roadIdx]), })) - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) // inner.prune(appQC@1, commitQC@1) sets commitQCs.first = 1. @@ -645,7 +637,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist prune anchor (AppQC + CommitQC pair at road index 0). appProposal := types.NewAppProposal(initialBlock, 0, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -655,7 +647,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist QCs 0, 1, 2 contiguously, then try to skip to 5. // MaybePruneAndPersist enforces strict sequential order, so the gap // is caught at write time rather than at load time. - cp, _, err := persist.NewCommitQCPersister(dir) + cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range 3 { require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{allQCs[i]}, noCommitQCCB)) @@ -668,7 +660,7 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("anchor past all persisted commitQCs truncates WAL", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) // Build a chain of 10 CommitQCs (indices 0-9). qcs := make([]*types.CommitQC, 10) @@ -679,7 +671,7 @@ func TestNewStateWithPersistence(t *testing.T) { } // Persist only indices 0-4 to the CommitQC WAL. - cp, _, err := persist.NewCommitQCPersister(dir) + cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range 5 { require.NoError(t, cp.MaybePruneAndPersist(utils.None[*types.CommitQC](), []*types.CommitQC{qcs[i]}, noCommitQCCB)) @@ -689,7 +681,7 @@ func TestNewStateWithPersistence(t *testing.T) { // Persist a prune anchor at index 9 — well past the persisted range. appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -698,7 +690,7 @@ func TestNewStateWithPersistence(t *testing.T) { // NewState should succeed: MaybePruneAndPersist truncates the stale WAL // and internally re-persists the anchor's CommitQC for crash recovery. - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) require.Equal(t, types.RoadIndex(9), state.FirstCommitQC()) @@ -711,13 +703,13 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("anchor past all persisted blocks truncates lane WAL", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) lane := keys[0].Public() // Persist commitQCs 0-9 and blocks 0-2 for one lane. qcs := make([]*types.CommitQC, 10) prev := utils.None[*types.CommitQC]() - cp, _, err := persist.NewCommitQCPersister(dir) + cp, _, err := persist.NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range qcs { qcs[i] = makeCommitQC(committee, keys, prev, nil, utils.None[*types.AppQC]()) @@ -726,7 +718,7 @@ func TestNewStateWithPersistence(t *testing.T) { } require.NoError(t, cp.Close()) - bp, _, err := persist.NewBlockPersister(dir) + bp, _, err := persist.NewBlockPersister(utils.Some(dir)) require.NoError(t, err) var parent types.BlockHeaderHash for n := range types.BlockNumber(3) { @@ -740,7 +732,7 @@ func TestNewStateWithPersistence(t *testing.T) { // all persisted blocks — MaybePruneAndPersistLane will TruncateAll the block WAL. appProposal := types.NewAppProposal(50, 9, types.GenAppHash(rng)) appQC := types.NewAppQC(makeAppVotes(keys, appProposal)) - prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + prunePers, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) require.NoError(t, prunePers.Persist(&pb.PersistedAvailPruneAnchor{ AppQc: types.AppQCConv.Encode(appQC), @@ -748,7 +740,7 @@ func TestNewStateWithPersistence(t *testing.T) { })) // NewState should succeed: block WAL gets truncated, lane starts clean. - state, err := NewState(keys[0], ds, dir) + state, err := NewState(keys[0], ds, utils.Some(dir)) require.NoError(t, err) require.Equal(t, types.RoadIndex(9), state.FirstCommitQC()) @@ -759,11 +751,11 @@ func TestNewStateWithPersistence(t *testing.T) { t.Run("corrupt AppQC data returns error", func(t *testing.T) { dir := t.TempDir() - ds := utils.OrPanic1(data.NewState(&data.Config{Committee: committee}, newTestBlockDB(t))) + ds := newTestDataState(t, &data.Config{Committee: committee}) // Create a throwaway persister to discover the A/B filenames, // then corrupt them so NewState fails on load. - _, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](dir, innerFile) + _, _, err := persist.NewPersister[*pb.PersistedAvailPruneAnchor](utils.Some(dir), innerFile) require.NoError(t, err) entries, err := os.ReadDir(dir) require.NoError(t, err) @@ -771,7 +763,7 @@ func TestNewStateWithPersistence(t *testing.T) { require.NoError(t, os.WriteFile(filepath.Join(dir, e.Name()), []byte("corrupt"), 0600)) } - _, err = NewState(keys[0], ds, dir) + _, err = NewState(keys[0], ds, utils.Some(dir)) require.Error(t, err) }) } diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 63db3e160a..a705f9173b 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -33,7 +33,7 @@ func newTestDataState(t testing.TB, committee *types.Committee) *data.State { // seedPersistedInner is a test helper that persists a persistedInner using the public API. func seedPersistedInner(dir string, state *persistedInner) { - p, _, err := persist.NewPersister[*pb.PersistedInner](dir, innerFile) + p, _, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) if err != nil { panic(err) } @@ -45,7 +45,7 @@ func seedPersistedInner(dir string, state *persistedInner) { // loadInner is a test helper that loads persisted data and creates inner. // Mirrors what NewState does: NewPersister → newInner. func loadInner(dir string, committee *types.Committee) (inner, error) { - _, data, err := persist.NewPersister[*pb.PersistedInner](dir, innerFile) + _, data, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) if err != nil { return inner{}, err } @@ -944,11 +944,11 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { ds := newTestDataState(t, committee) wantErr := errors.New("disk on fire") - pers := persist.Persister[*pb.PersistedInner](failPersister[*pb.PersistedInner]{err: wantErr}) + pers := utils.Some[persist.Persister[*pb.PersistedInner]](failPersister[*pb.PersistedInner]{err: wantErr}) cs, err := newState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: t.TempDir(), + PersistentStateDir: utils.Some(t.TempDir()), }, ds, pers, utils.None[*pb.PersistedInner]()) require.NoError(t, err) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go index 6f6d350324..585eb1d74b 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks.go @@ -157,6 +157,8 @@ func (lw *laneWAL) close() error { // but a lane whose blocks are never included in a committed block (e.g. the // validator is removed from the committee) would prevent truncation of all // other lanes' entries that follow it. +// When dir is None, all disk I/O is skipped (no-op mode). +// // All public methods are safe for concurrent use. The lanes map is protected // by an RWMutex; each laneWAL has its own Mutex for write serialization. // MaybePruneAndPersistLane holds the per-lane lock for the entire @@ -168,7 +170,7 @@ func (lw *laneWAL) close() error { // removed. If lane deletion is added in the future, the map RLock must be // held through the WAL write. type BlockPersister struct { - dir string // immutable after construction + dir utils.Option[string] // immutable after construction lanes utils.RWMutex[map[types.LaneID]*laneWAL] } @@ -188,14 +190,19 @@ func newLaneWALState(dir string) (*laneWALState, error) { // blocks/ and replays all persisted entries. Returns the persister and loaded // blocks grouped by lane (sorted by block number). Corrupt tail entries are // auto-truncated by the WAL library. -func NewBlockPersister(stateDir string) (*BlockPersister, map[types.LaneID][]LoadedBlock, error) { - dir := filepath.Join(stateDir, blocksDir) +// When stateDir is None, returns a no-op persister. +func NewBlockPersister(stateDir utils.Option[string]) (*BlockPersister, map[types.LaneID][]LoadedBlock, error) { + sd, ok := stateDir.Get() + if !ok { + return &BlockPersister{lanes: utils.NewRWMutex(map[types.LaneID]*laneWAL{})}, nil, nil + } + dir := filepath.Join(sd, blocksDir) if err := os.MkdirAll(dir, 0700); err != nil { return nil, nil, fmt.Errorf("create blocks dir %s: %w", dir, err) } lanes := map[types.LaneID]*laneWAL{} - bp := &BlockPersister{dir: dir, lanes: utils.NewRWMutex(lanes)} + bp := &BlockPersister{dir: utils.Some(dir), lanes: utils.NewRWMutex(lanes)} entries, err := os.ReadDir(dir) if err != nil { @@ -243,7 +250,12 @@ func NewBlockPersister(stateDir string) (*BlockPersister, map[types.LaneID][]Loa // slow path (lane creation) promotes to a write Lock. // The returned pointer is safe to use after the lock is released because // lanes are only ever added, never removed (see BlockPersister doc). +// Returns an error if called on a no-op persister (caller should check first). func (bp *BlockPersister) getOrCreateLane(lane types.LaneID) (*laneWAL, error) { + dir, ok := bp.dir.Get() + if !ok { + return nil, fmt.Errorf("getOrCreateLane called on no-op persister") + } // Fast path: read-only check. for lanes := range bp.lanes.RLock() { if lw, ok := lanes[lane]; ok { @@ -255,7 +267,7 @@ func (bp *BlockPersister) getOrCreateLane(lane types.LaneID) (*laneWAL, error) { if lw, ok := lanes[lane]; ok { return lw, nil } - s, err := newLaneWALState(filepath.Join(bp.dir, laneDir(lane))) + s, err := newLaneWALState(filepath.Join(dir, laneDir(lane))) if err != nil { return nil, fmt.Errorf("create lane WAL for %s: %w", lane, err) } @@ -277,6 +289,7 @@ func (bp *BlockPersister) getOrCreateLane(lane types.LaneID) (*laneWAL, error) { // afterEach, when present, is called after each successful append. It is // invoked while the per-lane lock is held, so it must not re-enter the // persister. +// No-op persister (dir=None): skips disk I/O but still invokes afterEach. // Does not spawn goroutines — the caller schedules parallelism per lane. // // The per-lane lock is held for the entire truncate-then-append sequence, @@ -287,6 +300,15 @@ func (bp *BlockPersister) MaybePruneAndPersistLane( proposals []*types.Signed[*types.LaneProposal], afterEach utils.Option[func(*types.Signed[*types.LaneProposal])], ) error { + if _, ok := bp.dir.Get(); !ok { + if fn, ok := afterEach.Get(); ok { + for _, p := range proposals { + fn(p) + } + } + return nil + } + lw, err := bp.getOrCreateLane(lane) if err != nil { return err @@ -299,6 +321,9 @@ func (bp *BlockPersister) MaybePruneAndPersistLane( // at shutdown — the OS reclaims resources on process exit. // Safe for concurrent use. func (bp *BlockPersister) close() error { + if _, ok := bp.dir.Get(); !ok { + return nil // no-op persister (persistence disabled) + } for lanes := range bp.lanes.Lock() { var errs []error for _, lw := range lanes { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go index fd6e1f8802..72c352327a 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go @@ -54,7 +54,7 @@ func testDeleteBefore(bp *BlockPersister, laneFirsts map[types.LaneID]types.Bloc func TestNewBlockPersisterEmptyDir(t *testing.T) { dir := t.TempDir() - bp, blocks, err := NewBlockPersister(dir) + bp, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.NotNil(t, bp) require.Equal(t, 0, len(blocks)) @@ -70,7 +70,7 @@ func TestPersistBlockAndLoad(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) b0 := testSignedProposal(rng, key, 0) @@ -79,7 +79,7 @@ func TestPersistBlockAndLoad(t *testing.T) { testPersistBlock(t, bp, b1) require.NoError(t, bp.close()) - bp2, blocks, err := NewBlockPersister(dir) + bp2, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.NotNil(t, bp2) require.Equal(t, 1, len(blocks), "should have 1 lane") @@ -99,7 +99,7 @@ func TestPersistBlockMultipleLanes(t *testing.T) { key2 := types.GenSecretKey(rng) lane1 := key1.Public() lane2 := key2.Public() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) b1 := testSignedProposal(rng, key1, 0) @@ -108,7 +108,7 @@ func TestPersistBlockMultipleLanes(t *testing.T) { testPersistBlock(t, bp, b2) require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(dir) + _, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(blocks), "should have 2 lanes") require.Equal(t, 1, len(blocks[lane1])) @@ -123,7 +123,7 @@ func TestDeleteBeforeRemovesOldKeepsNew(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) for i := range types.BlockNumber(5) { @@ -133,7 +133,7 @@ func TestDeleteBeforeRemovesOldKeepsNew(t *testing.T) { require.NoError(t, testDeleteBefore(bp, map[types.LaneID]types.BlockNumber{lane: 3})) require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(dir) + _, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(blocks[lane]), "should have blocks 3 and 4") require.Equal(t, types.BlockNumber(3), blocks[lane][0].Number) @@ -150,7 +150,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { lane1 := key1.Public() lane2 := key2.Public() lane3 := key3.Public() // never persisted — exercises the "no WAL yet" path - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -163,7 +163,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { require.NoError(t, bp.close()) // Restart — verify varied lane states load correctly. - bp2, blocks, err := NewBlockPersister(dir) + bp2, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 1, len(blocks[lane1]), "lane1 should have block 2") require.Equal(t, types.BlockNumber(2), blocks[lane1][0].Number) @@ -175,7 +175,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { testPersistBlock(t, bp2, testSignedProposal(rng, key2, 3)) require.NoError(t, bp2.close()) - _, blocks2, err := NewBlockPersister(dir) + _, blocks2, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(blocks2[lane1]), "lane1 should have blocks 2,3") require.Equal(t, types.BlockNumber(3), blocks2[lane1][1].Number) @@ -184,7 +184,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { } func TestBlockPersisterAfterEachCallback(t *testing.T) { - bp, blocks, err := NewBlockPersister(t.TempDir()) + bp, blocks, err := NewBlockPersister(utils.Some(t.TempDir())) require.NoError(t, err) require.NotNil(t, bp) require.Equal(t, 0, len(blocks)) @@ -218,7 +218,7 @@ func TestDeleteBeforeThenPersistMore(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) // Persist 0..4, delete before 3, then persist 5. @@ -229,7 +229,7 @@ func TestDeleteBeforeThenPersistMore(t *testing.T) { testPersistBlock(t, bp, testSignedProposal(rng, key, 5)) require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(dir) + _, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 3, len(blocks[lane]), "should have blocks 3, 4, 5") require.Equal(t, types.BlockNumber(3), blocks[lane][0].Number) @@ -243,7 +243,7 @@ func TestDeleteBeforePastAllBlocks(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -259,7 +259,7 @@ func TestDeleteBeforePastAllBlocks(t *testing.T) { require.NoError(t, bp.close()) // Reopen — should see only the post-TruncateAll blocks. - _, blocks, err := NewBlockPersister(dir) + _, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(blocks[lane])) require.Equal(t, types.BlockNumber(10), blocks[lane][0].Number) @@ -272,7 +272,7 @@ func TestDeleteBeforePastAllRejectsStaleBlock(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -299,7 +299,7 @@ func TestTruncateOnEmptyWALAdvancesCursor(t *testing.T) { key := types.GenSecretKey(rng) lane := key.Public() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) for i := range types.BlockNumber(3) { @@ -331,7 +331,7 @@ func TestEmptyLaneWALSurvivesReopen(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(bd, laneDir(lane)), 0700)) // Reopen — empty lane WAL should be loaded and usable. - bp, blocks, err := NewBlockPersister(dir) + bp, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 0, len(blocks[lane]), "no blocks loaded") @@ -340,7 +340,7 @@ func TestEmptyLaneWALSurvivesReopen(t *testing.T) { require.NoError(t, bp.close()) // Reopen — should see the new block. - _, blocks2, err := NewBlockPersister(dir) + _, blocks2, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 1, len(blocks2[lane])) require.Equal(t, types.BlockNumber(0), blocks2[lane][0].Number) @@ -355,7 +355,7 @@ func TestNewBlockPersisterSkipsNonHexDir(t *testing.T) { require.NoError(t, os.Mkdir(filepath.Join(bd, "not-valid-hex"), 0700)) require.NoError(t, os.WriteFile(filepath.Join(bd, "stray-file.txt"), []byte("hi"), 0600)) - bp, blocks, err := NewBlockPersister(dir) + bp, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 0, len(blocks)) require.NoError(t, bp.close()) @@ -369,7 +369,7 @@ func TestNewBlockPersisterSkipsInvalidKeyDir(t *testing.T) { // Valid hex but too short to be a valid ed25519 public key. require.NoError(t, os.Mkdir(filepath.Join(bd, "abcd"), 0700)) - bp, blocks, err := NewBlockPersister(dir) + bp, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 0, len(blocks)) require.NoError(t, bp.close()) @@ -380,7 +380,7 @@ func TestPersistBlockOutOfSequence(t *testing.T) { dir := t.TempDir() key := types.GenSecretKey(rng) - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) lane := key.Public() @@ -417,7 +417,7 @@ func TestLoadAllDetectsBlockGap(t *testing.T) { require.NoError(t, s.Write(testSignedProposal(rng, key, 2))) require.NoError(t, s.Close()) - _, _, err = NewBlockPersister(dir) + _, _, err = NewBlockPersister(utils.Some(dir)) require.Error(t, err) require.Contains(t, err.Error(), "gap") } @@ -426,7 +426,7 @@ func TestPersistBlockAutoCreatesLane(t *testing.T) { rng := utils.TestRng() dir := t.TempDir() - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) entries, _ := os.ReadDir(filepath.Join(dir, blocksDir)) @@ -441,7 +441,7 @@ func TestPersistBlockAutoCreatesLane(t *testing.T) { require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(dir) + _, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 1, len(blocks[lane])) require.Equal(t, types.BlockNumber(0), blocks[lane][0].Number) @@ -453,7 +453,7 @@ func TestPersistBlockConcurrentDistinctLanes(t *testing.T) { const numLanes = 8 const blocksPerLane = 20 - bp, _, err := NewBlockPersister(dir) + bp, _, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) keys := make([]types.SecretKey, numLanes) @@ -482,7 +482,7 @@ func TestPersistBlockConcurrentDistinctLanes(t *testing.T) { require.NoError(t, bp.close()) - _, blocks, err := NewBlockPersister(dir) + _, blocks, err := NewBlockPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, numLanes, len(blocks)) for i := range numLanes { diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go index ac69fe6b7e..d0291410b7 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs.go @@ -19,9 +19,8 @@ type LoadedCommitQC struct { // commitQCState is the mutable state protected by CommitQCPersister's mutex. type commitQCState struct { - iw *indexedWAL[*types.CommitQC] - closed bool - next types.RoadIndex + iw utils.Option[*indexedWAL[*types.CommitQC]] + next types.RoadIndex } // persistCommitQC writes a CommitQC to the WAL. Caller must hold the lock. @@ -35,8 +34,10 @@ func (s *commitQCState) persistCommitQC(qc *types.CommitQC) error { if idx > s.next { return fmt.Errorf("commitqc %d out of sequence (next=%d)", idx, s.next) } - if err := s.iw.Write(qc); err != nil { - return fmt.Errorf("persist commitqc %d: %w", idx, err) + if iw, ok := s.iw.Get(); ok { + if err := iw.Write(qc); err != nil { + return fmt.Errorf("persist commitqc %d: %w", idx, err) + } } s.next = idx + 1 return nil @@ -46,18 +47,19 @@ func (s *commitQCState) persistCommitQC(qc *types.CommitQC) error { // re-persists the anchor for crash recovery. Caller must hold the lock. func (s *commitQCState) deleteBefore(anchor *types.CommitQC) error { idx := anchor.Index() + iw, ok := s.iw.Get() if idx >= s.next { s.next = idx - if s.iw.Count() > 0 { - if err := s.iw.TruncateAll(); err != nil { + if ok && iw.Count() > 0 { + if err := iw.TruncateAll(); err != nil { return err } } - } else if s.iw.Count() > 0 { - firstRoadIndex := s.next - types.RoadIndex(s.iw.Count()) + } else if ok && iw.Count() > 0 { + firstRoadIndex := s.next - types.RoadIndex(iw.Count()) if idx > firstRoadIndex { - walIdx := s.iw.FirstIdx() + uint64(idx-firstRoadIndex) - if err := s.iw.TruncateBefore(walIdx, func(entry *types.CommitQC) error { + walIdx := iw.FirstIdx() + uint64(idx-firstRoadIndex) + if err := iw.TruncateBefore(walIdx, func(entry *types.CommitQC) error { if entry.Index() != idx { return fmt.Errorf("commitqc at WAL index %d has road index %d, expected %d (index mapping broken)", walIdx, entry.Index(), idx) } @@ -74,6 +76,7 @@ func (s *commitQCState) deleteBefore(anchor *types.CommitQC) error { // Entries are appended in order; each entry is self-describing (the serialized // CommitQC contains its RoadIndex). The WAL index is append order, not // RoadIndex — the indexedWAL tracks first/next indices to enable truncation. +// When iw is None, all disk I/O is skipped but cursor tracking still works. type CommitQCPersister struct { state utils.Mutex[*commitQCState] } @@ -81,18 +84,24 @@ type CommitQCPersister struct { // NewCommitQCPersister opens (or creates) a WAL in the commitqcs/ subdirectory // and replays all persisted entries. Returns the persister and a sorted slice of // loaded CommitQCs. Corrupt tail entries are auto-truncated by the WAL library. +// When stateDir is None, returns a no-op persister. +// // After crash recovery with an empty WAL (e.g. TruncateAll completed but no // new write followed), LoadNext() returns 0. The caller MUST use // MaybePruneAndPersist with the prune CommitQC in Anchor to re-establish the // cursor and re-persist the anchor's CommitQC before appending more QCs. -func NewCommitQCPersister(stateDir string) (*CommitQCPersister, []LoadedCommitQC, error) { - dir := filepath.Join(stateDir, commitqcsDir) +func NewCommitQCPersister(stateDir utils.Option[string]) (*CommitQCPersister, []LoadedCommitQC, error) { + sd, ok := stateDir.Get() + if !ok { + return &CommitQCPersister{state: utils.NewMutex(&commitQCState{})}, nil, nil + } + dir := filepath.Join(sd, commitqcsDir) iw, err := openIndexedWAL(dir, types.CommitQCConv) if err != nil { return nil, nil, fmt.Errorf("open commitqc WAL in %s: %w", dir, err) } - s := &commitQCState{iw: iw} + s := &commitQCState{iw: utils.Some(iw)} loaded, err := loadAllCommitQCs(s) if err != nil { _ = iw.Close() @@ -155,17 +164,22 @@ func (cp *CommitQCPersister) MaybePruneAndPersist( // Close shuts down the WAL. Safe to call multiple times (idempotent). func (cp *CommitQCPersister) Close() error { for s := range cp.state.Lock() { - if s.closed { - return nil + iw, ok := s.iw.Get() + if !ok { + return nil // no-op persister or already closed } - s.closed = true - return s.iw.Close() + s.iw = utils.None[*indexedWAL[*types.CommitQC]]() + return iw.Close() } panic("unreachable") } func loadAllCommitQCs(s *commitQCState) ([]LoadedCommitQC, error) { - entries, err := s.iw.ReadAll() + iw, ok := s.iw.Get() + if !ok { + return nil, nil // no-op persister (persistence disabled) + } + entries, err := iw.ReadAll() if err != nil { return nil, err } diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index dfcac15497..abe1a57f1f 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -92,7 +92,7 @@ func clearCommitQCWAL(t *testing.T, dir string) { func TestNewCommitQCPersisterEmptyDir(t *testing.T) { dir := t.TempDir() - cp, loaded, err := NewCommitQCPersister(dir) + cp, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.NotNil(t, cp) require.Equal(t, 0, len(loaded)) @@ -111,7 +111,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { qcs := makeSequentialCommitQCs(committee, keys, 3) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for _, qc := range qcs { @@ -120,7 +120,7 @@ func TestPersistCommitQCAndLoad(t *testing.T) { require.Equal(t, types.RoadIndex(3), cp.LoadNext()) require.NoError(t, cp.Close()) - cp2, loaded, err := NewCommitQCPersister(dir) + cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.NotNil(t, cp2) require.Equal(t, 3, len(loaded)) @@ -138,7 +138,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -147,7 +147,7 @@ func TestCommitQCDeleteBeforeRemovesOldKeepsNew(t *testing.T) { testDeleteCommitQCsBefore(t, cp, qcs[3]) require.NoError(t, cp.Close()) - _, loaded, err := NewCommitQCPersister(dir) + _, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(loaded), "should have indices 3 and 4") require.Equal(t, types.RoadIndex(3), loaded[0].Index) @@ -161,7 +161,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { qcs := makeSequentialCommitQCs(committee, keys, 3) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for _, qc := range qcs[:2] { testPersistCommitQC(t, cp, qc) @@ -172,7 +172,7 @@ func TestCommitQCDeleteBeforeZero(t *testing.T) { testDeleteCommitQCsBefore(t, cp, qcs[0]) require.NoError(t, cp.Close()) - cp2, loaded, err := NewCommitQCPersister(dir) + cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(loaded)) @@ -187,7 +187,7 @@ func TestCommitQCPersistDuplicateIsNoOp(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 3) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) testPersistCommitQC(t, cp, qcs[0]) @@ -204,7 +204,7 @@ func TestCommitQCPersistGapRejected(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) testPersistCommitQC(t, cp, qcs[0]) @@ -234,7 +234,7 @@ func TestLoadAllDetectsCommitQCGap(t *testing.T) { require.NoError(t, iw.Write(qcs[2])) require.NoError(t, iw.Close()) - _, _, err = NewCommitQCPersister(dir) + _, _, err = NewCommitQCPersister(utils.Some(dir)) require.Error(t, err) require.Contains(t, err.Error(), "gap") } @@ -246,7 +246,7 @@ func TestCommitQCPersisterFreshStart(t *testing.T) { // Fresh persister: prune with anchor at index 0 (idx==0, // s.next==0). Should persist the anchor and advance to 1. - cp, loaded, err := NewCommitQCPersister(t.TempDir()) + cp, loaded, err := NewCommitQCPersister(utils.Some(t.TempDir())) require.NoError(t, err) require.NotNil(t, cp) require.Equal(t, 0, len(loaded)) @@ -277,7 +277,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range 3 { testPersistCommitQC(t, cp, qcs[i]) @@ -292,7 +292,7 @@ func TestCommitQCDeleteBeforePastAll(t *testing.T) { require.NoError(t, cp.Close()) // Reopen — should see only the post-TruncateAll entries. - _, loaded, err := NewCommitQCPersister(dir) + _, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(loaded)) require.Equal(t, types.RoadIndex(10), loaded[0].Index) @@ -308,7 +308,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 12) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for i := range 3 { testPersistCommitQC(t, cp, qcs[i]) @@ -320,7 +320,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { clearCommitQCWAL(t, dir) // Restart: WAL is empty, next will be 0. - cp2, loaded, err := NewCommitQCPersister(dir) + cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Empty(t, loaded) require.Equal(t, types.RoadIndex(0), cp2.LoadNext()) @@ -335,7 +335,7 @@ func TestCommitQCDeleteBeforePastAllCrashRecovery(t *testing.T) { require.Equal(t, types.RoadIndex(12), cp2.LoadNext()) require.NoError(t, cp2.Close()) - _, loaded, err = NewCommitQCPersister(dir) + _, loaded, err = NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(loaded)) require.Equal(t, types.RoadIndex(10), loaded[0].Index) @@ -351,7 +351,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -362,7 +362,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { clearCommitQCWAL(t, dir) // Restart: WAL is empty. Pass the anchor QC (index 4) through deleteBefore. - cp2, loaded, err := NewCommitQCPersister(dir) + cp2, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Empty(t, loaded) @@ -376,7 +376,7 @@ func TestCommitQCDeleteBeforeWithAnchorRecovers(t *testing.T) { require.NoError(t, cp2.Close()) // Reopen — anchor QC should be on disk. - _, loaded, err = NewCommitQCPersister(dir) + _, loaded, err = NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 1, len(loaded)) require.Equal(t, types.RoadIndex(4), loaded[0].Index) @@ -389,7 +389,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 6) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) // Persist 0..4, delete before 3, then persist 5. @@ -400,7 +400,7 @@ func TestCommitQCDeleteBeforeThenPersistMore(t *testing.T) { testPersistCommitQC(t, cp, qcs[5]) require.NoError(t, cp.Close()) - _, loaded, err := NewCommitQCPersister(dir) + _, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 3, len(loaded), "should have indices 3, 4, 5") require.Equal(t, types.RoadIndex(3), loaded[0].Index) @@ -414,7 +414,7 @@ func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 5) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -429,7 +429,7 @@ func TestCommitQCDeleteBeforeAlreadyPruned(t *testing.T) { require.NoError(t, cp.Close()) // Verify nothing extra was pruned. - _, loaded, err := NewCommitQCPersister(dir) + _, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 2, len(loaded), "should still have indices 3 and 4") require.Equal(t, types.RoadIndex(3), loaded[0].Index) @@ -442,7 +442,7 @@ func TestCommitQCProgressiveDeleteBefore(t *testing.T) { dir := t.TempDir() qcs := makeSequentialCommitQCs(committee, keys, 8) - cp, _, err := NewCommitQCPersister(dir) + cp, _, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) for _, qc := range qcs { testPersistCommitQC(t, cp, qc) @@ -457,7 +457,7 @@ func TestCommitQCProgressiveDeleteBefore(t *testing.T) { require.NoError(t, cp.Close()) // Verify indices 5, 6, 7 survive. - _, loaded, err := NewCommitQCPersister(dir) + _, loaded, err := NewCommitQCPersister(utils.Some(dir)) require.NoError(t, err) require.Equal(t, 3, len(loaded)) require.Equal(t, types.RoadIndex(5), loaded[0].Index) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/persist.go b/sei-tendermint/internal/autobahn/consensus/persist/persist.go index 6a4715f9ae..4a2dc6ca6e 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/persist.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/persist.go @@ -79,6 +79,15 @@ type Persister[T protoutils.Message] interface { Persist(T) error } +type noopPersister[T protoutils.Message] struct{} + +func (noopPersister[T]) Persist(T) error { return nil } + +// newNoOpPersister returns a Persister that silently discards all writes. +func newNoOpPersister[T protoutils.Message]() Persister[T] { + return noopPersister[T]{} +} + // abPersister writes data to A/B files with automatic seq management. // File format: [4-byte CRC32-C BE] [8-byte seq LE] [proto-marshalled message]. // Only created when config has a state dir; dir is always a valid path. @@ -90,30 +99,36 @@ type abPersister[T protoutils.Message] struct { } // NewPersister creates a crash-safe persister for the given directory and prefix. -// dir must already exist and be a directory; returns error otherwise. -// Also returns the loaded message (None on fresh start) for the caller to use. +// When dir is None, returns a no-op persister that discards all writes. +// When dir is Some, it must already exist and be a directory; returns error otherwise. +// Also returns the loaded message (None on fresh start or no-op) for the caller to use. // This encapsulates all on-disk format details (A/B files, seq wrapper, proto marshal) in one place. -func NewPersister[T protoutils.Message](dir string, prefix string) (Persister[T], utils.Option[T], error) { +func NewPersister[T protoutils.Message](dir utils.Option[string], prefix string) (Persister[T], utils.Option[T], error) { none := utils.None[T]() - fi, err := os.Stat(dir) + d, ok := dir.Get() + if !ok { + return newNoOpPersister[T](), none, nil + } + + fi, err := os.Stat(d) if err != nil { - return nil, none, fmt.Errorf("invalid state dir %q: %w", dir, err) + return nil, none, fmt.Errorf("invalid state dir %q: %w", d, err) } if !fi.IsDir() { - return nil, none, fmt.Errorf("invalid state dir %q: not a directory", dir) + return nil, none, fmt.Errorf("invalid state dir %q: not a directory", d) } // Probe writability by creating and removing a temp file. Checking permission // bits is not reliable: Windows emulates Unix bits from ACLs, root bypasses // permission checks on Unix, and group/other write bits are easy to miss. - probe, err := os.CreateTemp(dir, ".writable-probe-*") + probe, err := os.CreateTemp(d, ".writable-probe-*") if err != nil { - return nil, none, fmt.Errorf("state dir %q is not writable: %w", dir, err) + return nil, none, fmt.Errorf("state dir %q is not writable: %w", d, err) } _ = probe.Close() _ = os.Remove(probe.Name()) - ds, err := loadPersisted(dir, prefix) + ds, err := loadPersisted(d, prefix) if err != nil && !errors.Is(err, ErrNoData) { return nil, none, err } @@ -122,7 +137,7 @@ func NewPersister[T protoutils.Message](dir string, prefix string) (Persister[T] // directory entries. Empty files are treated as non-existent by loadFile, // so they won't interfere with loading on restart. for _, suffix := range []string{suffixA, suffixB} { - path := filepath.Join(dir, prefix+suffix) + path := filepath.Join(d, prefix+suffix) f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0600) //nolint:gosec // path is stateDir + hardcoded suffix; not user-controlled if err != nil { return nil, none, fmt.Errorf("state file %q is not writable: %w", path, err) @@ -130,11 +145,11 @@ func NewPersister[T protoutils.Message](dir string, prefix string) (Persister[T] _ = f.Close() } // Sync directory to make file entries durable (harmless if files already existed). - if df, err := os.Open(dir); err != nil { //nolint:gosec // dir is operator-configured stateDir; not user-controlled - return nil, none, fmt.Errorf("open directory %s: %w", dir, err) + if df, err := os.Open(d); err != nil { //nolint:gosec // d is operator-configured stateDir; not user-controlled + return nil, none, fmt.Errorf("open directory %s: %w", d, err) } else if err := df.Sync(); err != nil { _ = df.Close() - return nil, none, fmt.Errorf("sync directory %s: %w", dir, err) + return nil, none, fmt.Errorf("sync directory %s: %w", d, err) } else { _ = df.Close() } @@ -148,7 +163,7 @@ func NewPersister[T protoutils.Message](dir string, prefix string) (Persister[T] loaded = utils.Some(msg) } return &abPersister[T]{ - dir: dir, + dir: d, prefix: prefix, seq: ds.seq, }, loaded, nil diff --git a/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go b/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go index a4c2983665..c5f1d5ab71 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/persist_test.go @@ -12,13 +12,14 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/wrapperspb" + "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils" "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) func TestPersisterAlternates(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) // Both files should be pre-created (empty) by NewPersister for dir-sync optimization. @@ -47,7 +48,7 @@ func TestPersisterAlternates(t *testing.T) { func TestPersisterPicksHigherSeq(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) // Write three times: A(seq=1), B(seq=2), A(seq=3) @@ -56,7 +57,7 @@ func TestPersisterPicksHigherSeq(t *testing.T) { require.NoError(t, w.Persist(wrapperspb.String("third"))) // Should load "third" (seq=3 > seq=2) - w2, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w2, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -68,7 +69,7 @@ func TestLoadPersistedOneCorruptFileSucceeds(t *testing.T) { dir := t.TempDir() // Write to both files: A(seq=1), B(seq=2) - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("first"))) // seq=1, A require.NoError(t, w.Persist(wrapperspb.String("second"))) // seq=2, B @@ -77,7 +78,7 @@ func TestLoadPersistedOneCorruptFileSucceeds(t *testing.T) { err = os.WriteFile(filepath.Join(dir, "test"+suffixB), []byte("corrupt"), 0600) require.NoError(t, err) - _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -88,7 +89,7 @@ func TestLoadPersistedBothCorruptError(t *testing.T) { dir := t.TempDir() // Write valid data to A only - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("valid"))) // seq=1, A @@ -96,7 +97,7 @@ func TestLoadPersistedBothCorruptError(t *testing.T) { err = os.WriteFile(filepath.Join(dir, "test"+suffixA), []byte("corrupt"), 0600) require.NoError(t, err) - _, _, err = NewPersister[*wrapperspb.StringValue](dir, "test") + _, _, err = NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.Error(t, err) } @@ -104,7 +105,7 @@ func TestNewPersisterOneCorruptFileSucceeds(t *testing.T) { dir := t.TempDir() // Write to both files: A(seq=1), B(seq=2) - w1, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w1, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w1.Persist(wrapperspb.String("first"))) // seq=1, A require.NoError(t, w1.Persist(wrapperspb.String("second"))) // seq=2, B @@ -113,7 +114,7 @@ func TestNewPersisterOneCorruptFileSucceeds(t *testing.T) { err = os.WriteFile(filepath.Join(dir, "test"+suffixB), []byte("corrupt"), 0600) require.NoError(t, err) - w2, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w2, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -122,7 +123,7 @@ func TestNewPersisterOneCorruptFileSucceeds(t *testing.T) { // A won (seq=1), so next write goes to B (the corrupt/loser slot) require.NoError(t, w2.Persist(wrapperspb.String("recovered"))) // seq=2, B - _, loaded2, err := NewPersister[*wrapperspb.StringValue](dir, "test") + _, loaded2, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) msg2, ok := loaded2.Get() require.True(t, ok) @@ -139,7 +140,7 @@ func TestLoadPersistedEmptyDir(t *testing.T) { func TestNewPersisterInvalidDirError(t *testing.T) { // State dir must already exist; invalid (nonexistent or not a directory) returns error - _, _, err := NewPersister[*wrapperspb.StringValue]("/nonexistent/path/that/does/not/exist", "test") + _, _, err := NewPersister[*wrapperspb.StringValue](utils.Some("/nonexistent/path/that/does/not/exist"), "test") require.Error(t, err) require.Contains(t, err.Error(), "invalid state dir") } @@ -151,7 +152,7 @@ func TestPersistWriteErrorReturnsError(t *testing.T) { t.Skip("chmod 000 on directory not reliable on Windows") } dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("data1"))) // Remove all permissions from dir so OpenFile fails with EACCES @@ -169,7 +170,7 @@ func TestPersistWriteErrorDoesNotAdvanceSeq(t *testing.T) { t.Skip("chmod 000 on directory not reliable on Windows") } dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) // Successful write: seq=1 → A @@ -187,7 +188,7 @@ func TestPersistWriteErrorDoesNotAdvanceSeq(t *testing.T) { require.NoError(t, w.Persist(wrapperspb.String("recovered"))) // Verify: "recovered" is latest. - _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -204,7 +205,7 @@ func TestLoadPersistedOSErrorPropagates(t *testing.T) { dir := t.TempDir() // Write to both files: A(seq=1), B(seq=2) - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("first"))) // seq=1, A require.NoError(t, w.Persist(wrapperspb.String("second"))) // seq=2, B @@ -225,7 +226,7 @@ func TestNewPersisterResumeSeq(t *testing.T) { dir := t.TempDir() // Create persister and write some data - w1, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w1, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w1.Persist(wrapperspb.String("data1"))) // seq=1, A require.NoError(t, w1.Persist(wrapperspb.String("data2"))) // seq=2, B @@ -233,12 +234,12 @@ func TestNewPersisterResumeSeq(t *testing.T) { // Create new persister (simulates restart) // A has seq=3 (winner), so new persister should write to B first to preserve A - w2, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w2, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w2.Persist(wrapperspb.String("data4"))) // seq=4, B (preserves A) // Verify data4 is the latest (seq=4) - _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -249,18 +250,18 @@ func TestNewPersisterPreservesWinner(t *testing.T) { dir := t.TempDir() // Write to both files: A=seq1, B=seq2 (B wins) - w1, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w1, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w1.Persist(wrapperspb.String("old"))) // seq=1, A require.NoError(t, w1.Persist(wrapperspb.String("winner"))) // seq=2, B (winner) // New persister should write to A first (preserve B) - w2, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w2, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w2.Persist(wrapperspb.String("new"))) // seq=3, A (preserves B) // Verify "new" is the latest (seq=3) - _, loaded, err := NewPersister[*wrapperspb.StringValue](dir, "test") + _, loaded, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) msg, ok := loaded.Get() require.True(t, ok) @@ -272,7 +273,7 @@ func TestNewPersisterPreservesWinner(t *testing.T) { func TestLoadFileDataCorruption(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("data"))) // seq=1, A @@ -322,7 +323,7 @@ func TestLoadFileZeroSeq(t *testing.T) { func TestLoadFileSeqCorruption(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("data"))) @@ -341,7 +342,7 @@ func TestLoadFileSeqCorruption(t *testing.T) { func TestPersistFileFormat(t *testing.T) { dir := t.TempDir() - w, _, err := NewPersister[*wrapperspb.StringValue](dir, "test") + w, _, err := NewPersister[*wrapperspb.StringValue](utils.Some(dir), "test") require.NoError(t, err) require.NoError(t, w.Persist(wrapperspb.String("hello"))) diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index 33e102c4b0..e05848ee70 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -24,7 +24,8 @@ type Config struct { Key types.SecretKey ViewTimeout ViewTimeoutFunc // PersistentStateDir is the directory where the consensus state is persisted. - PersistentStateDir string + // If None, persistence is disabled - DANGEROUS, may lead to SLASHING on restart. + PersistentStateDir utils.Option[string] } // State represents the high-level Consensus Control Plane. @@ -42,7 +43,8 @@ type State struct { inner utils.Mutex[*utils.AtomicSend[inner]] innerRecv utils.AtomicRecv[inner] - persister persist.Persister[*pb.PersistedInner] + // persister writes inner's persistedInner to disk when PersistentStateDir is set; None when disabled. + persister utils.Option[persist.Persister[*pb.PersistedInner]] timeoutVotes utils.Mutex[*timeoutVotes] prepareVotes utils.Mutex[*prepareVotes] @@ -81,7 +83,11 @@ func NewState(cfg *Config, data *data.State) (*State, error) { if err != nil { return nil, fmt.Errorf("NewPersister: %w", err) } - return newState(cfg, data, p, persistedData) + var pers utils.Option[persist.Persister[*pb.PersistedInner]] + if _, ok := cfg.PersistentStateDir.Get(); ok { + pers = utils.Some(p) + } + return newState(cfg, data, pers, persistedData) } // newState is the internal constructor exposed for tests that need to inject @@ -89,7 +95,7 @@ func NewState(cfg *Config, data *data.State) (*State, error) { func newState( cfg *Config, data *data.State, - pers persist.Persister[*pb.PersistedInner], + pers utils.Option[persist.Persister[*pb.PersistedInner]], persistedData utils.Option[*pb.PersistedInner], ) (*State, error) { initialInner, err := newInner(persistedData, data.Committee()) @@ -247,8 +253,10 @@ func (s *State) runOutputs(ctx context.Context) error { s.myView.Store(vs) } // Persist to disk before broadcasting votes to the network. - if err := s.persister.Persist(innerProtoConv.Encode(&i.persistedInner)); err != nil { - return fmt.Errorf("persist inner: %w", err) + if p, ok := s.persister.Get(); ok { + if err := p.Persist(innerProtoConv.Encode(&i.persistedInner)); err != nil { + return fmt.Errorf("persist inner: %w", err) + } } if v, ok := i.PrepareVote.Get(); ok { updateOutput(&s.myPrepareVote, &types.ConsensusReqPrepareVote{Signed: v}) diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 1be6de0afa..90b84d182c 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -23,7 +23,7 @@ func newTestState(t testing.TB, rng utils.Rng) (*State, []types.SecretKey) { s := utils.OrPanic1(NewState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: t.TempDir(), + PersistentStateDir: utils.Some(t.TempDir()), }, dataState)) return s, keys } @@ -263,7 +263,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { return &Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: dir, + PersistentStateDir: utils.Some(dir), } } makeDataState := func() *data.State { return newTestDataState(t, committee) } diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 102821d956..d95c5e4ad7 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -183,7 +183,7 @@ type State struct { cfg *Config metrics *metrics.Metrics inner utils.Watch[*inner] - blockDB types.BlockDB + blockDB utils.Option[types.BlockDB] // blockDBPersistedQC and blockDBPersistedBlock record where BlockDB's // write cursors stood at construction time. runPersist initialises from @@ -637,8 +637,10 @@ func (s *State) PruneBefore(retainFrom types.GlobalBlockNumber) error { // watermark. This is safe (resurrected data is QC-covered committed data) // and self-heals on the next runPruning cycle. Durable prune bounds come // from the BlockDB retention TTL, not from PruneBefore itself. - if n, ok := truncateTo.Get(); ok { - return s.blockDB.PruneBefore(n) + if db, ok := s.blockDB.Get(); ok { + if n, ok := truncateTo.Get(); ok { + return db.PruneBefore(n) + } } return nil } @@ -650,7 +652,7 @@ func (s *State) PruneBefore(retainFrom types.GlobalBlockNumber) error { // to unblock PushAppHash only when both are durable. // Errors propagate vertically (kill the component). func (s *State) runPersist(ctx context.Context) error { - // Initialise from BlockDB's cursors at construction time, not from + // Initialize from BlockDB's cursors at construction time, not from // inner.nextQC/nextBlock. PushQC may have been called before Run() // (race between p2p startup and Run), advancing inner cursors beyond // what's durably in BlockDB. Starting from blockDBPersistedQC/Block @@ -690,22 +692,24 @@ func (s *State) runPersist(ctx context.Context) error { b.blocks = append(b.blocks, blockEntry{n: n, blk: inner.blocks[n]}) } } - // Write QCs first (BlockDB contract: QC must precede covered blocks). - for _, qc := range b.qcs { - gr := qc.QC().GlobalRange(s.cfg.Committee) - if err := s.blockDB.WriteQC(gr.First, gr.Next, qc); err != nil { - return fmt.Errorf("write QC [%d,%d): %w", gr.First, gr.Next, err) + if db, ok := s.blockDB.Get(); ok { + // Write QCs first (BlockDB contract: QC must precede covered blocks). + for _, qc := range b.qcs { + gr := qc.QC().GlobalRange(s.cfg.Committee) + if err := db.WriteQC(gr.First, gr.Next, qc); err != nil { + return fmt.Errorf("write QC [%d,%d): %w", gr.First, gr.Next, err) + } } - } - for _, lb := range b.blocks { - if err := s.blockDB.WriteBlock(lb.n, lb.blk); err != nil { - return fmt.Errorf("write block %d: %w", lb.n, err) + for _, lb := range b.blocks { + if err := db.WriteBlock(lb.n, lb.blk); err != nil { + return fmt.Errorf("write block %d: %w", lb.n, err) + } + } + // Flush once per batch before advancing nextBlockToPersist, so that + // PushAppHash only unblocks after data is crash-durable. + if err := db.Flush(); err != nil { + return fmt.Errorf("flush BlockDB: %w", err) } - } - // Flush once per batch before advancing nextBlockToPersist, so that - // PushAppHash only unblocks after data is crash-durable. - if err := s.blockDB.Flush(); err != nil { - return fmt.Errorf("flush BlockDB: %w", err) } persistedQC = b.qcEnd persistedBlock = b.blockEnd @@ -746,9 +750,11 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { pruningTime = inner.appProposals[inner.first].timestamp.Add(after) } // Prune BlockDB outside the lock to avoid holding it during disk I/O. - if n, ok := truncateTo.Get(); ok { - if err := s.blockDB.PruneBefore(n); err != nil { - return fmt.Errorf("prune BlockDB before %d: %w", n, err) + if db, ok := s.blockDB.Get(); ok { + if n, ok := truncateTo.Get(); ok { + if err := db.PruneBefore(n); err != nil { + return fmt.Errorf("prune BlockDB before %d: %w", n, err) + } } } // Wait until the next pruning time. @@ -762,12 +768,17 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { // by Run's background goroutines. Must be called once before Run, from the // same goroutine that will call Run (no concurrent callers). The caller owns // blockDB and is responsible for closing it after Run returns. +// Passing nil disables persistence: Run advances nextBlockToPersist without +// any disk I/O, which is suitable for load tests and in-memory-only nodes. // TODO(gprusak): add support for starting execution from non-zero commit QC. func (s *State) Init(blockDB types.BlockDB) error { + if blockDB == nil { + return nil + } if err := s.loadFromBlockDB(blockDB); err != nil { return fmt.Errorf("loadFromBlockDB: %w", err) } - s.blockDB = blockDB + s.blockDB = utils.Some(blockDB) return nil } diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 5a6ba029b4..95e1cb2a56 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -250,7 +250,7 @@ func newTestEnv(t testing.TB, rng utils.Rng, cfg *Config, app *proxy.Proxy) *tes consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: t.TempDir(), + PersistentStateDir: utils.Some(t.TempDir()), }, dataState)) return &testEnv{ data: dataState, diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index 2900fbe43b..a8288b16b0 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -39,7 +39,7 @@ func TestAvailClientServer(t *testing.T) { fakeNode0, err := consensus.NewState(&consensus.Config{ Key: keys[0], ViewTimeout: defaultViewTimeout, - PersistentStateDir: t.TempDir(), + PersistentStateDir: utils.Some(t.TempDir()), }, nodes[0].data) if err != nil { return fmt.Errorf("consensus.NewState(): %w", err) diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index ed3be4e8c8..785af1c3dd 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -25,8 +25,8 @@ type testNode struct { func defaultViewTimeout(view types.View) time.Duration { return time.Hour } func newTestNode(t testing.TB, committee *types.Committee, cfg *consensus.Config) *testNode { - if cfg.PersistentStateDir == "" { - cfg.PersistentStateDir = t.TempDir() + if _, ok := cfg.PersistentStateDir.Get(); !ok { + cfg.PersistentStateDir = utils.Some(t.TempDir()) } blockCfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) db := utils.OrPanic1(littblock.NewBlockDB(blockCfg)) diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 20138d62b2..92b4987137 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -35,7 +35,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV consensusState, err := consensus.NewState(&consensus.Config{ Key: cfg.ValidatorKey, ViewTimeout: cfg.ViewTimeout, - PersistentStateDir: cfg.PersistentStateDir, + PersistentStateDir: utils.Some(cfg.PersistentStateDir), }, dataState) if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) From 288c776a9c0b876cfb2d82913a3a8077bc76cb8b Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 7 Jul 2026 19:12:35 -0700 Subject: [PATCH 22/46] fix(lint): check blockDB.Close() error return in GigaRouter.Run Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_fullnode.go | 2 +- sei-tendermint/internal/p2p/giga_router_validator.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index 1017bcf8e5..d3b08f3b7f 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -51,7 +51,7 @@ func (r *gigaFullnodeRouter) Run(ctx context.Context) error { if err != nil { return fmt.Errorf("open BlockDB: %w", err) } - defer blockDB.Close() + defer func() { _ = blockDB.Close() }() if err := r.data.Init(blockDB); err != nil { return fmt.Errorf("data.Init: %w", err) } diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 92b4987137..8885e9c137 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -69,7 +69,7 @@ func (r *gigaValidatorRouter) Run(ctx context.Context) error { if err != nil { return fmt.Errorf("open BlockDB: %w", err) } - defer blockDB.Close() + defer func() { _ = blockDB.Close() }() if err := r.data.Init(blockDB); err != nil { return fmt.Errorf("data.Init: %w", err) } From 044c7ee2df2f1e450104f904712bdc346a15c332 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 7 Jul 2026 19:54:11 -0700 Subject: [PATCH 23/46] fix(config): restore PersistentStateDir as Option[string]; allow empty to disable persistence GigaRouterCommonConfig.PersistentStateDir is now utils.Option[string] so None flows through cleanly when the operator passes --persistent-state-dir= (empty). buildDataState returns Option[LittBlockConfig] (None when no dir); buildHashVault falls back to NoopHashVault; Run methods skip BlockDB when blockDBCfg is None. Co-Authored-By: Claude Sonnet 4.6 --- .../commands/gen_autobahn_config.go | 14 +++++---- sei-tendermint/config/autobahn.go | 2 +- sei-tendermint/internal/p2p/giga_router.go | 3 +- .../internal/p2p/giga_router_common.go | 31 +++++++++++-------- .../internal/p2p/giga_router_fullnode.go | 18 ++++++----- .../internal/p2p/giga_router_validator.go | 20 ++++++------ sei-tendermint/node/setup.go | 4 +-- 7 files changed, 52 insertions(+), 40 deletions(-) diff --git a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go index 099dfbc488..13736e876d 100644 --- a/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go +++ b/sei-tendermint/cmd/tendermint/commands/gen_autobahn_config.go @@ -89,12 +89,14 @@ Output is written to the file specified by --output.`, ViewTimeout: utils.Duration(1500 * time.Millisecond), DialInterval: utils.Duration(10 * time.Second), } - if persistentStateDir == "" { - return fmt.Errorf("--persistent-state-dir must not be empty") + // The flag defaults to "data/autobahn" so persistence is on without + // operator action. node/setup.go rootifies the relative path against + // cfg.RootDir at load time. Passing --persistent-state-dir= (empty) + // disables persistence and runs both consensus and data layers + // in-memory only. + if persistentStateDir != "" { + cfg.PersistentStateDir = utils.Some(persistentStateDir) } - // The flag defaults to "data/autobahn"; node/setup.go rootifies - // the relative path against cfg.RootDir at load time. - cfg.PersistentStateDir = persistentStateDir data, err := json.MarshalIndent(cfg, "", " ") if err != nil { @@ -108,6 +110,6 @@ Output is written to the file specified by --output.`, }, } cmd.Flags().StringP("output", "o", "", "output file path for the autobahn config") - cmd.Flags().String("persistent-state-dir", "data/autobahn", "directory to persist autobahn state across restarts; relative paths are resolved against the node's --home dir; must not be empty") + 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") return cmd } diff --git a/sei-tendermint/config/autobahn.go b/sei-tendermint/config/autobahn.go index ac38f4c050..6c08e0629c 100644 --- a/sei-tendermint/config/autobahn.go +++ b/sei-tendermint/config/autobahn.go @@ -42,7 +42,7 @@ type AutobahnFileConfig struct { AllowEmptyBlocks bool `json:"allow_empty_blocks"` BlockInterval utils.Duration `json:"block_interval"` ViewTimeout utils.Duration `json:"view_timeout"` - PersistentStateDir string `json:"persistent_state_dir"` + PersistentStateDir utils.Option[string] `json:"persistent_state_dir"` DialInterval utils.Duration `json:"dial_interval"` // MaxInboundFullnodePeers caps concurrent inbound block-sync from // non-committee peers, applied on both validators and fullnodes (relay diff --git a/sei-tendermint/internal/p2p/giga_router.go b/sei-tendermint/internal/p2p/giga_router.go index 669b3863a3..1cc77d14d8 100644 --- a/sei-tendermint/internal/p2p/giga_router.go +++ b/sei-tendermint/internal/p2p/giga_router.go @@ -34,7 +34,8 @@ type GigaRouterCommonConfig struct { GenDoc *types.GenesisDoc // PersistentStateDir is the on-disk root for durable state (BlockDB, // hashvault, and the validator's consensus persister in sibling subdirs). - PersistentStateDir string + // If None, persistence is disabled and the node runs fully in-memory. + PersistentStateDir utils.Option[string] // App is the ABCI proxy executeBlock drives. NewGigaValidatorRouter // also passes it to producer.NewState so the producer's internal // mempool drives the same proxy. diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index e76433ca9d..d1403fc29a 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -59,18 +59,15 @@ type gigaRouterCommon struct { // BlockDB config, and returns an uninitialised data.State. The caller's Run // method opens BlockDB, replays persisted state, and closes it on exit by // calling data.State.Run(ctx, blockDB). -func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, *littblock.LittBlockConfig, error) { +func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, utils.Option[*littblock.LittBlockConfig], error) { if cfg.GenDoc.InitialHeight < 1 { - return nil, nil, fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) + return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) } if cfg.DialInterval <= 0 { - return nil, nil, fmt.Errorf("GigaRouterCommonConfig.DialInterval = %v, want > 0", cfg.DialInterval) + return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("GigaRouterCommonConfig.DialInterval = %v, want > 0", cfg.DialInterval) } if cfg.MaxInboundFullnodePeers < 0 || cfg.MaxInboundFullnodePeers > maxInboundFullnodePeers { - return nil, nil, fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) - } - if cfg.PersistentStateDir == "" { - return nil, nil, fmt.Errorf("GigaRouterCommonConfig.PersistentStateDir must not be empty") + return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) } committee, err := atypes.NewRoundRobinElection( slices.Collect(maps.Keys(cfg.ValidatorAddrs)), @@ -78,13 +75,17 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, *littblock.LittBl cfg.GenDoc.GenesisTime, ) if err != nil { - return nil, nil, fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) + return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) } - blockCfg, err := littblock.DefaultConfig(filepath.Join(cfg.PersistentStateDir, "blockdb")) - if err != nil { - return nil, nil, fmt.Errorf("littblock.DefaultConfig: %w", err) + var blockCfgOpt utils.Option[*littblock.LittBlockConfig] + if dir, ok := cfg.PersistentStateDir.Get(); ok { + blockCfg, err := littblock.DefaultConfig(filepath.Join(dir, "blockdb")) + if err != nil { + return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("littblock.DefaultConfig: %w", err) + } + blockCfgOpt = utils.Some(blockCfg) } - return data.NewState(&data.Config{Committee: committee}), blockCfg, nil + return data.NewState(&data.Config{Committee: committee}), blockCfgOpt, nil } func (r *gigaRouterCommon) LastCommittedBlockNumber() int64 { @@ -265,8 +266,12 @@ func buildHashVault(ctx context.Context, cfg *GigaRouterCommonConfig) (hashvault logger.Error("################################################################") return hashvault.NewNoopHashVault(), nil } + dir, ok := cfg.PersistentStateDir.Get() + if !ok { + return hashvault.NewNoopHashVault(), nil + } hvCfg := hashvault.DefaultHashVaultConfig() - hvCfg.DataDir = filepath.Join(cfg.PersistentStateDir, "hashvault") + hvCfg.DataDir = filepath.Join(dir, "hashvault") return hashvault.NewPebbleHashVault(ctx, hvCfg) } diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index d3b08f3b7f..4ce688bd7a 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -18,7 +18,7 @@ import ( type gigaFullnodeRouter struct { *gigaRouterCommon - blockDBCfg *littblock.LittBlockConfig + blockDBCfg utils.Option[*littblock.LittBlockConfig] } func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gigaFullnodeRouter, error) { @@ -47,13 +47,15 @@ func (r *gigaFullnodeRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaFullnodeRouter) Run(ctx context.Context) error { - blockDB, err := littblock.NewBlockDB(r.blockDBCfg) - if err != nil { - return fmt.Errorf("open BlockDB: %w", err) - } - defer func() { _ = blockDB.Close() }() - if err := r.data.Init(blockDB); err != nil { - return fmt.Errorf("data.Init: %w", err) + if blockDBCfg, ok := r.blockDBCfg.Get(); ok { + blockDB, err := littblock.NewBlockDB(blockDBCfg) + if err != nil { + return fmt.Errorf("open BlockDB: %w", err) + } + defer func() { _ = blockDB.Close() }() + if err := r.data.Init(blockDB); err != nil { + return fmt.Errorf("data.Init: %w", err) + } } return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Single-active subscriber: walk the committee in a stable order, diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 8885e9c137..3ba5f4bdad 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -19,7 +19,7 @@ import ( type gigaValidatorRouter struct { *gigaRouterCommon - blockDBCfg *littblock.LittBlockConfig + blockDBCfg utils.Option[*littblock.LittBlockConfig] consensus *consensus.State producer *producer.State // validatorKey is the cached public form of cfg.ValidatorKey, used by @@ -35,7 +35,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV consensusState, err := consensus.NewState(&consensus.Config{ Key: cfg.ValidatorKey, ViewTimeout: cfg.ViewTimeout, - PersistentStateDir: utils.Some(cfg.PersistentStateDir), + PersistentStateDir: cfg.PersistentStateDir, }, dataState) if err != nil { return nil, fmt.Errorf("consensus.NewState(): %w", err) @@ -65,13 +65,15 @@ func (r *gigaValidatorRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaValidatorRouter) Run(ctx context.Context) error { - blockDB, err := littblock.NewBlockDB(r.blockDBCfg) - if err != nil { - return fmt.Errorf("open BlockDB: %w", err) - } - defer func() { _ = blockDB.Close() }() - if err := r.data.Init(blockDB); err != nil { - return fmt.Errorf("data.Init: %w", err) + if blockDBCfg, ok := r.blockDBCfg.Get(); ok { + blockDB, err := littblock.NewBlockDB(blockDBCfg) + if err != nil { + return fmt.Errorf("open BlockDB: %w", err) + } + defer func() { _ = blockDB.Close() }() + if err := r.data.Init(blockDB); err != nil { + return fmt.Errorf("data.Init: %w", err) + } } return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Validators dial every committee member in parallel — consensus diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index e20ce44842..edbb466f77 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -333,8 +333,8 @@ func buildGigaRouter( // against the node's --home dir (mirrors config.go's rootify). Absolute // paths pass through unchanged. func rootifyPersistentStateDir(rootDir string, c *p2p.GigaRouterCommonConfig) { - if c.PersistentStateDir != "" && !filepath.IsAbs(c.PersistentStateDir) { - c.PersistentStateDir = filepath.Join(rootDir, c.PersistentStateDir) + if dir, ok := c.PersistentStateDir.Get(); ok && !filepath.IsAbs(dir) { + c.PersistentStateDir = utils.Some(filepath.Join(rootDir, dir)) } } From 2ea7b9413d596ddb28feaf976be6e66068236fd3 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 7 Jul 2026 20:10:55 -0700 Subject: [PATCH 24/46] fix(tests): wrap PersistentStateDir literals in utils.Some after Option[string] change Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_fullnode_test.go | 2 +- sei-tendermint/internal/p2p/giga_router_validator_test.go | 2 +- sei-tendermint/node/setup_test.go | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go index 830471a964..ff58611d14 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode_test.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode_test.go @@ -62,7 +62,7 @@ func TestGigaRouter_Fullnode(t *testing.T) { router, err := NewGigaFullnodeRouter(&GigaRouterCommonConfig{ DialInterval: time.Second, ValidatorAddrs: addrs, - PersistentStateDir: t.TempDir(), + PersistentStateDir: utils.Some(t.TempDir()), App: proxyApp, GenDoc: genDoc, }, makeKey(rng)) diff --git a/sei-tendermint/internal/p2p/giga_router_validator_test.go b/sei-tendermint/internal/p2p/giga_router_validator_test.go index 65702d7db5..cdec864e13 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator_test.go +++ b/sei-tendermint/internal/p2p/giga_router_validator_test.go @@ -67,7 +67,7 @@ func TestGigaRouter_FinalizeBlocks(t *testing.T) { // Aggressive dialing rate to speed up startup. DialInterval: 100 * time.Millisecond, ValidatorAddrs: addrs, - PersistentStateDir: t.TempDir(), + PersistentStateDir: utils.Some(t.TempDir()), App: proxyApp, GenDoc: genDoc, }, diff --git a/sei-tendermint/node/setup_test.go b/sei-tendermint/node/setup_test.go index 150545705f..606acc4f60 100644 --- a/sei-tendermint/node/setup_test.go +++ b/sei-tendermint/node/setup_test.go @@ -62,7 +62,7 @@ func defaultFileConfig(t testing.TB, validators []config.AutobahnValidator) *con AllowEmptyBlocks: false, BlockInterval: utils.Duration(400 * time.Millisecond), ViewTimeout: utils.Duration(1500 * time.Millisecond), - PersistentStateDir: t.TempDir(), + PersistentStateDir: utils.Some(t.TempDir()), DialInterval: utils.Duration(10 * time.Second), } } @@ -105,7 +105,7 @@ func TestBuildGigaConfig_EnabledWithValidators(t *testing.T) { AllowEmptyBlocks: true, BlockInterval: utils.Duration(200 * time.Millisecond), ViewTimeout: utils.Duration(3 * time.Second), - PersistentStateDir: "/tmp/autobahn-state", + PersistentStateDir: utils.Some("/tmp/autobahn-state"), DialInterval: utils.Duration(5 * time.Second), } cfgFile := writeAutobahnConfig(t, fc) @@ -122,7 +122,7 @@ func TestBuildGigaConfig_EnabledWithValidators(t *testing.T) { assert.Equal(t, 5*time.Second, result.DialInterval) assert.Equal(t, 3*time.Second, result.ViewTimeout(atypes.View{})) - assert.Equal(t, "/tmp/autobahn-state", result.PersistentStateDir) + assert.Equal(t, utils.Some("/tmp/autobahn-state"), result.PersistentStateDir) // Verify the validator key is derived from the validator-key seed, not the node key. expectedValPub := makeTestValidatorKey([]byte("val1-seed")).Public() From c4ec2e9c273c1a4d689c103f70dc8a43da0aa286 Mon Sep 17 00:00:00 2001 From: Wen Date: Tue, 7 Jul 2026 20:30:40 -0700 Subject: [PATCH 25/46] fix(node): mkdir persistent state dir before constructing GigaRouter NewPersister requires the directory to already exist. setup.go now calls os.MkdirAll on the rootified PersistentStateDir before passing it to NewGigaValidatorRouter / NewGigaFullnodeRouter so the node doesn't fail on a fresh data directory. Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/node/setup.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index edbb466f77..45b403bf5c 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -311,6 +311,9 @@ func buildGigaRouter( return nil, fmt.Errorf("buildValidatorGigaConfig: %w", err) } rootifyPersistentStateDir(cfg.RootDir, &valCfg.GigaRouterCommonConfig) + if err := mkdirPersistentStateDir(valCfg.PersistentStateDir); err != nil { + return nil, err + } // The GigaRouter builds and owns the equivocation guard itself; just pass the operator's // enable/disable decision through as plain config. valCfg.HashVaultDisabledUnsafe = cfg.HashVaultDisabledUnsafe @@ -322,6 +325,9 @@ func buildGigaRouter( return nil, fmt.Errorf("buildFullnodeGigaConfig: %w", err) } rootifyPersistentStateDir(cfg.RootDir, fnCfg) + if err := mkdirPersistentStateDir(fnCfg.PersistentStateDir); err != nil { + return nil, err + } // The GigaRouter builds and owns the equivocation guard itself; just pass the operator's // enable/disable decision through as plain config. fnCfg.HashVaultDisabledUnsafe = cfg.HashVaultDisabledUnsafe @@ -338,6 +344,17 @@ func rootifyPersistentStateDir(rootDir string, c *p2p.GigaRouterCommonConfig) { } } +func mkdirPersistentStateDir(dir utils.Option[string]) error { + d, ok := dir.Get() + if !ok { + return nil + } + if err := os.MkdirAll(d, 0700); err != nil { + return fmt.Errorf("creating persistent state dir %q: %w", d, err) + } + return nil +} + // resolveMaxInboundFullnodePeers: None ⇒ default, Some(0) ⇒ reject all, // Some(n) ⇒ n. The default lives in the config package so giga_router // doesn't carry an operator-facing knob. From 5e22de8d34aa9da987e57a6757e4f715f46a2efd Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 10:32:40 -0700 Subject: [PATCH 26/46] fix(data): always call Init; seed persisted cursors from FirstBlock in in-memory mode In-memory mode (blockDB == nil) left blockDBPersistedQC and blockDBPersistedBlock at zero, causing runPersist to iterate from block 0 instead of the committee's first block. Init(nil) now seeds both cursors from Committee.FirstBlock(). Both routers always call Init (nil when no BlockDB, real handle otherwise), so the persisted-cursor invariant holds regardless of mode. Co-Authored-By: Claude Sonnet 4.6 --- .../autobahn/consensus/persist/blocks_test.go | 2 +- .../consensus/persist/commitqcs_test.go | 2 +- .../internal/autobahn/data/state.go | 3 +++ .../internal/autobahn/data/state_test.go | 27 +++++++++++++++++++ .../internal/p2p/giga_router_fullnode.go | 10 ++++--- .../internal/p2p/giga_router_validator.go | 10 ++++--- sei-tendermint/node/setup_test.go | 14 ++++++++++ 7 files changed, 60 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go index 72c352327a..de2b320fae 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go @@ -183,7 +183,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { require.Equal(t, types.BlockNumber(3), blocks2[lane2][3].Number) } -func TestBlockPersisterAfterEachCallback(t *testing.T) { +func TestNoOpBlockPersister(t *testing.T) { bp, blocks, err := NewBlockPersister(utils.Some(t.TempDir())) require.NoError(t, err) require.NotNil(t, bp) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index abe1a57f1f..89e06cab30 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -239,7 +239,7 @@ func TestLoadAllDetectsCommitQCGap(t *testing.T) { require.Contains(t, err.Error(), "gap") } -func TestCommitQCPersisterFreshStart(t *testing.T) { +func TestNoOpCommitQCPersister(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) qcs := makeSequentialCommitQCs(committee, keys, 11) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index d95c5e4ad7..9248da8a44 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -772,7 +772,10 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { // any disk I/O, which is suitable for load tests and in-memory-only nodes. // TODO(gprusak): add support for starting execution from non-zero commit QC. func (s *State) Init(blockDB types.BlockDB) error { + fb := s.cfg.Committee.FirstBlock() if blockDB == nil { + s.blockDBPersistedQC = fb + s.blockDBPersistedBlock = fb return nil } if err := s.loadFromBlockDB(blockDB); err != nil { diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 26321e2052..066303f3ec 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -425,6 +425,33 @@ func TestRecoveryEmpty(t *testing.T) { } } +// TestInitNilInMemoryMode verifies that Init(nil) followed by Run works +// end-to-end: QCs and blocks are accessible in memory with no BlockDB backing. +func TestInitNilInMemoryMode(t *testing.T) { + ctx := t.Context() + rng := utils.TestRng() + committee, keys := types.GenCommittee(rng, 3) + qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) + + state := NewState(&Config{Committee: committee}) + require.NoError(t, state.Init(nil)) + + require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + s.SpawnBgNamed("state", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) + if err := state.PushQC(ctx, qc1, blocks1); err != nil { + return err + } + // Verify data is accessible (no panic, no error). + gr := qc1.QC().GlobalRange(committee) + for n := gr.First; n < gr.Next; n++ { + if _, err := state.Block(ctx, n); err != nil { + return err + } + } + return nil + })) +} + // TestRecoveryNormal verifies that NewState fully restores QCs and blocks // from BlockDB on restart. func TestRecoveryNormal(t *testing.T) { diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index 4ce688bd7a..5be0076f3c 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -47,15 +47,19 @@ func (r *gigaFullnodeRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaFullnodeRouter) Run(ctx context.Context) error { + var initErr error if blockDBCfg, ok := r.blockDBCfg.Get(); ok { blockDB, err := littblock.NewBlockDB(blockDBCfg) if err != nil { return fmt.Errorf("open BlockDB: %w", err) } defer func() { _ = blockDB.Close() }() - if err := r.data.Init(blockDB); err != nil { - return fmt.Errorf("data.Init: %w", err) - } + initErr = r.data.Init(blockDB) + } else { + initErr = r.data.Init(nil) + } + if initErr != nil { + return fmt.Errorf("data.Init: %w", initErr) } return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Single-active subscriber: walk the committee in a stable order, diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 3ba5f4bdad..1cd6040b54 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -65,15 +65,19 @@ func (r *gigaValidatorRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaValidatorRouter) Run(ctx context.Context) error { + var initErr error if blockDBCfg, ok := r.blockDBCfg.Get(); ok { blockDB, err := littblock.NewBlockDB(blockDBCfg) if err != nil { return fmt.Errorf("open BlockDB: %w", err) } defer func() { _ = blockDB.Close() }() - if err := r.data.Init(blockDB); err != nil { - return fmt.Errorf("data.Init: %w", err) - } + initErr = r.data.Init(blockDB) + } else { + initErr = r.data.Init(nil) + } + if initErr != nil { + return fmt.Errorf("data.Init: %w", initErr) } return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Validators dial every committee member in parallel — consensus diff --git a/sei-tendermint/node/setup_test.go b/sei-tendermint/node/setup_test.go index 606acc4f60..8dd0dc953a 100644 --- a/sei-tendermint/node/setup_test.go +++ b/sei-tendermint/node/setup_test.go @@ -84,6 +84,20 @@ func makeTestGigaDeps() (*proxy.Proxy, *types.GenesisDoc) { return app, genDoc } +func TestBuildGigaConfig_NonePersistentStateDir(t *testing.T) { + v1 := makeValidator([]byte("val-seed"), []byte("node-seed"), "localhost:26660") + fc := defaultFileConfig(t, []config.AutobahnValidator{v1}) + fc.PersistentStateDir = utils.None[string]() + cfgFile := writeAutobahnConfig(t, fc) + nodeKey := makeTestNodeKey([]byte("node-seed")) + valKey := makeTestValidatorKey([]byte("val-seed")) + txMempool, genDoc := makeTestGigaDeps() + + result, err := buildValidatorGigaConfig(cfgFile, nodeKey, valKey, txMempool, genDoc) + require.NoError(t, err) + assert.False(t, result.PersistentStateDir.IsPresent()) +} + func TestBuildGigaConfig_EmptyPathErrors(t *testing.T) { nodeKey := makeTestNodeKey([]byte("test-node-key")) valKey := makeTestValidatorKey([]byte("val-seed")) From 264804b84a40cf74b30b5f4751aedf0951b11934 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 10:44:31 -0700 Subject: [PATCH 27/46] refactor(avail): use Init(nil) in inner_test; revert state.go to main inner_test.go tests exercise in-memory inner logic only; they don't need a real BlockDB. Switch newTestDataState to Init(nil). Revert avail/state.go to main (BlocksPerLanePerCommit constant and comment wording restored). Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/avail/inner_test.go | 10 +--------- sei-tendermint/internal/autobahn/avail/state.go | 7 ++++--- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 284732e6c1..452f0f67e9 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -4,7 +4,6 @@ import ( pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "testing" - "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -12,16 +11,9 @@ import ( "github.com/stretchr/testify/require" ) -func newTestBlockDB(t testing.TB) types.BlockDB { - cfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) - db := utils.OrPanic1(littblock.NewBlockDB(cfg)) - t.Cleanup(func() { _ = db.Close() }) - return db -} - func newTestDataState(t testing.TB, cfg *data.Config) *data.State { s := data.NewState(cfg) - if err := s.Init(newTestBlockDB(t)); err != nil { + if err := s.Init(nil); err != nil { t.Fatalf("data.Init: %v", err) } return s diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index a2a9b08345..5b0e9bb9b7 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -19,7 +19,8 @@ import ( // ErrBadLane . var ErrBadLane = errors.New("bad lane") -const BlocksPerLane = 3 * types.MaxLaneRangeInProposal +const BlocksPerLane = 3 * BlocksPerLanePerCommit +const BlocksPerLanePerCommit = 10 // State represents the Data Availability Plane and Ordered Event Log. // Although it resides in a sub-package, it serves as the "source of truth" for: @@ -148,7 +149,7 @@ func loadPersistedState(dir utils.Option[string]) (utils.Option[*loadedAvailStat } // NewState constructs a new availability state. -// stateDir is None when persistence is disabled (testing/loadtest); a no-op +// stateDir is None when persistence is disabled (testing only); a no-op // persist goroutine still runs to bump cursors without disk I/O. func NewState(key types.SecretKey, data *data.State, stateDir utils.Option[string]) (*State, error) { loaded, pers, err := loadPersistedState(stateDir) @@ -548,7 +549,7 @@ func (s *State) WaitForLaneQCs( for { for lane := range c.Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() - for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { + for i := range types.BlockNumber(BlocksPerLanePerCommit) { if qc, ok := inner.laneQC(c, lane, first+i); ok { laneQCs[lane] = qc } else { From aab405d4274db53608998d2ff12b698346f8e335 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 10:56:53 -0700 Subject: [PATCH 28/46] refactor(consensus): revert state.go/inner.go to main; use Init(nil) in tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert consensus/state.go and inner.go to main — changes there were not part of this PR's scope. Revert persist/blocks_test.go and persist/commitqcs_test.go to main. Switch newTestDataState and newTestState to use Init(nil) with no persistence — these tests exercise in-memory logic only. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/inner.go | 9 ++++++++ .../internal/autobahn/consensus/inner_test.go | 17 ++++---------- .../autobahn/consensus/persist/blocks_test.go | 4 ++-- .../consensus/persist/commitqcs_test.go | 9 ++++---- .../internal/autobahn/consensus/state.go | 12 ++++++---- .../internal/autobahn/consensus/state_test.go | 23 +++++++++---------- 6 files changed, 39 insertions(+), 35 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner.go b/sei-tendermint/internal/autobahn/consensus/inner.go index 2956a53db4..6cc197cbbd 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner.go +++ b/sei-tendermint/internal/autobahn/consensus/inner.go @@ -37,6 +37,15 @@ // - D, E receive TimeoutQC(5, 2), can now advance to (5, 3) // - Everyone converges to the highest view // +// # PersistentStateDir Configuration +// +// At config level, PersistentStateDir is an Option[string]. NewState creates a persister when +// it is Some(path). When None, no persister is created (persistence +// disabled - DANGEROUS, may lead to SLASHING if the node restarts and double-votes; +// only use for testing). When Some(path), the path must already exist and be +// writable (verified by writing a temp file at startup); returns error otherwise. +// TODO: surface the None warning in CLI --help (e.g. stream command or config docs). +// // # Recovery Behavior // // - Fresh start (files don't exist): Logged at INFO level, node starts clean diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index a705f9173b..1ba8a91ddf 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -3,10 +3,10 @@ package consensus import ( "context" "errors" + "fmt" "testing" "time" - "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -16,17 +16,10 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) -func newTestBlockDB(t testing.TB) types.BlockDB { - cfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) - db := utils.OrPanic1(littblock.NewBlockDB(cfg)) - t.Cleanup(func() { _ = db.Close() }) - return db -} - -func newTestDataState(t testing.TB, committee *types.Committee) *data.State { +func newTestDataState(committee *types.Committee) *data.State { s := data.NewState(&data.Config{Committee: committee}) - if err := s.Init(newTestBlockDB(t)); err != nil { - t.Fatalf("data.Init: %v", err) + if err := s.Init(nil); err != nil { + panic(fmt.Sprintf("data.Init: %v", err)) } return s } @@ -941,7 +934,7 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { // and terminates the consensus component (instead of panicking). rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) - ds := newTestDataState(t, committee) + ds := newTestDataState(committee) wantErr := errors.New("disk on fire") pers := utils.Some[persist.Persister[*pb.PersistedInner]](failPersister[*pb.PersistedInner]{err: wantErr}) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go index de2b320fae..e799dcf363 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/blocks_test.go @@ -184,7 +184,7 @@ func TestDeleteBeforeAndRestart(t *testing.T) { } func TestNoOpBlockPersister(t *testing.T) { - bp, blocks, err := NewBlockPersister(utils.Some(t.TempDir())) + bp, blocks, err := NewBlockPersister(utils.None[string]()) require.NoError(t, err) require.NotNil(t, bp) require.Equal(t, 0, len(blocks)) @@ -198,7 +198,7 @@ func TestNoOpBlockPersister(t *testing.T) { proposals[i] = testSignedProposal(rng, key, types.BlockNumber(i)) } - // Persist and prune with anchor + new proposals. + // Persist and prune with anchor + new proposals in no-op mode. // Verify afterEach is still invoked for every proposal. var called int cb := utils.Some(func(_ *types.Signed[*types.LaneProposal]) { called++ }) diff --git a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go index 89e06cab30..1bbcadeb4a 100644 --- a/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go +++ b/sei-tendermint/internal/autobahn/consensus/persist/commitqcs_test.go @@ -244,9 +244,9 @@ func TestNoOpCommitQCPersister(t *testing.T) { committee, keys := types.GenCommittee(rng, 4) qcs := makeSequentialCommitQCs(committee, keys, 11) - // Fresh persister: prune with anchor at index 0 (idx==0, + // Fresh no-op persister: prune with anchor at index 0 (idx==0, // s.next==0). Should persist the anchor and advance to 1. - cp, loaded, err := NewCommitQCPersister(utils.Some(t.TempDir())) + cp, loaded, err := NewCommitQCPersister(utils.None[string]()) require.NoError(t, err) require.NotNil(t, cp) require.Equal(t, 0, len(loaded)) @@ -260,8 +260,9 @@ func TestNoOpCommitQCPersister(t *testing.T) { // Prune with a future anchor (index 8 > s.next=5). deleteBefore // advances s.next to 8, then persistCommitQC persists the anchor // and advances s.next to 9. The remaining QCs (9,10) follow. - // deleteBefore must advance s.next before persistCommitQC runs, - // otherwise re-persisting the anchor QC fails with "out of sequence". + // Before the fix, deleteBefore in no-op mode wouldn't advance + // s.next, so re-persisting the anchor QC would fail with "out of + // sequence". require.NoError(t, cp.MaybePruneAndPersist( utils.Some(qcs[8]), qcs[9:], diff --git a/sei-tendermint/internal/autobahn/consensus/state.go b/sei-tendermint/internal/autobahn/consensus/state.go index e05848ee70..75374a5ba4 100644 --- a/sei-tendermint/internal/autobahn/consensus/state.go +++ b/sei-tendermint/internal/autobahn/consensus/state.go @@ -79,13 +79,15 @@ func (s *State) SubscribeTimeoutQC() utils.AtomicRecv[utils.Option[*types.Timeou func NewState(cfg *Config, data *data.State) (*State, error) { // Create persister first so newInner can receive the loaded data // instead of reading the files directly. - p, persistedData, err := persist.NewPersister[*pb.PersistedInner](cfg.PersistentStateDir, innerFile) - if err != nil { - return nil, fmt.Errorf("NewPersister: %w", err) - } var pers utils.Option[persist.Persister[*pb.PersistedInner]] - if _, ok := cfg.PersistentStateDir.Get(); ok { + var persistedData utils.Option[*pb.PersistedInner] + if dir, ok := cfg.PersistentStateDir.Get(); ok { + p, d, err := persist.NewPersister[*pb.PersistedInner](utils.Some(dir), innerFile) + if err != nil { + return nil, fmt.Errorf("NewPersister: %w", err) + } pers = utils.Some(p) + persistedData = d } return newState(cfg, data, pers, persistedData) } diff --git a/sei-tendermint/internal/autobahn/consensus/state_test.go b/sei-tendermint/internal/autobahn/consensus/state_test.go index 90b84d182c..8d2df5ede9 100644 --- a/sei-tendermint/internal/autobahn/consensus/state_test.go +++ b/sei-tendermint/internal/autobahn/consensus/state_test.go @@ -13,17 +13,16 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/scope" ) -// newTestState creates a State for testing with a long view timeout -// (so voteTimeout is only triggered explicitly). +// newTestState creates a State for testing with no persistence and a long +// view timeout (so voteTimeout is only triggered explicitly). // keys[0] is used as the node's signing key. - -func newTestState(t testing.TB, rng utils.Rng) (*State, []types.SecretKey) { +func newTestState(rng utils.Rng) (*State, []types.SecretKey) { committee, keys := types.GenCommittee(rng, 3) - dataState := newTestDataState(t, committee) + dataState := newTestDataState(committee) s := utils.OrPanic1(NewState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: utils.Some(t.TempDir()), + PersistentStateDir: utils.None[string](), }, dataState)) return s, keys } @@ -56,7 +55,7 @@ func testTimeoutVotePrepareQC(tv *types.FullTimeoutVote) utils.Option[*types.Pre func TestVoteTimeoutPrepareQC_BothNone(t *testing.T) { rng := utils.TestRng() - s, _ := newTestState(t, rng) + s, _ := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -78,7 +77,7 @@ func TestVoteTimeoutPrepareQC_BothNone(t *testing.T) { func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(t, rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -106,7 +105,7 @@ func TestVoteTimeoutPrepareQC_OnlyCurrentView(t *testing.T) { // consecutive timeouts with an offline leader must not lose the PrepareQC. func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(t, rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -163,7 +162,7 @@ func TestVoteTimeoutPrepareQC_InheritedFromTimeoutQC(t *testing.T) { // view's PrepareQC is preferred over the older inherited one. func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(t, rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -213,7 +212,7 @@ func TestVoteTimeoutPrepareQC_CurrentViewHigherThanInherited(t *testing.T) { // the current view's PrepareQC is used. func TestVoteTimeoutPrepareQC_CurrentViewPresentInheritedNone(t *testing.T) { rng := utils.TestRng() - s, keys := newTestState(t, rng) + s, keys := newTestState(rng) err := scope.Run(t.Context(), func(ctx context.Context, sc scope.Scope) error { sc.SpawnBg(func() error { return utils.IgnoreCancel(s.Run(ctx)) }) @@ -266,7 +265,7 @@ func TestVoteTimeoutPrepareQC_PersistedRestart(t *testing.T) { PersistentStateDir: utils.Some(dir), } } - makeDataState := func() *data.State { return newTestDataState(t, committee) } + makeDataState := func() *data.State { return newTestDataState(committee) } view0 := types.View{Index: 0, Number: 0} pqc0 := makePrepareQC(keys, types.GenProposalAt(rng, view0)) From d3e8f7763e412f015b3bfc0b19e4c093856e3f13 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 10:58:26 -0700 Subject: [PATCH 29/46] refactor(producer): use Init(nil) in mempool_test; drop BlockDB dependency mempool_test exercises in-memory logic only; switch newTestDataState to Init(nil) and consensus PersistentStateDir to None. Co-Authored-By: Claude Sonnet 4.6 --- .../autobahn/producer/mempool_test.go | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 95e1cb2a56..28761e3620 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -12,7 +12,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/holiman/uint256" - "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" @@ -34,18 +33,10 @@ type txSpec struct { Payload [32]byte } -func newTestBlockDB(t testing.TB) types.BlockDB { - cfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) - db := utils.OrPanic1(littblock.NewBlockDB(cfg)) - t.Cleanup(func() { _ = db.Close() }) - return db -} - -func newTestDataState(t testing.TB, committee *types.Committee) *data.State { - t.Helper() +func newTestDataState(committee *types.Committee) *data.State { s := data.NewState(&data.Config{Committee: committee}) - if err := s.Init(newTestBlockDB(t)); err != nil { - t.Fatalf("data.Init: %v", err) + if err := s.Init(nil); err != nil { + panic(fmt.Sprintf("data.Init: %v", err)) } return s } @@ -246,11 +237,11 @@ func (env *testEnv) Run(ctx context.Context) error { func newTestEnv(t testing.TB, rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { committee, keys := types.GenCommittee(rng, 1) - dataState := newTestDataState(t, committee) + dataState := newTestDataState(committee) consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: utils.Some(t.TempDir()), + PersistentStateDir: utils.None[string](), }, dataState)) return &testEnv{ data: dataState, From 979da631eb57953133e6ee95d937436e818e15a4 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 10:59:06 -0700 Subject: [PATCH 30/46] refactor(giga): use Init(nil) in data_test; drop BlockDB and persistence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data_test exercises p2p block sync only — no restart or recovery. Switch to Init(nil) and consensus PersistentStateDir None. Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga/data_test.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 785af1c3dd..947748d898 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -6,7 +6,6 @@ import ( "testing" "time" - "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -25,14 +24,9 @@ type testNode struct { func defaultViewTimeout(view types.View) time.Duration { return time.Hour } func newTestNode(t testing.TB, committee *types.Committee, cfg *consensus.Config) *testNode { - if _, ok := cfg.PersistentStateDir.Get(); !ok { - cfg.PersistentStateDir = utils.Some(t.TempDir()) - } - blockCfg := utils.OrPanic1(littblock.DefaultConfig(t.TempDir())) - db := utils.OrPanic1(littblock.NewBlockDB(blockCfg)) - t.Cleanup(func() { _ = db.Close() }) + cfg.PersistentStateDir = utils.None[string]() dataState := data.NewState(&data.Config{Committee: committee}) - if err := dataState.Init(db); err != nil { + if err := dataState.Init(nil); err != nil { panic(fmt.Sprintf("data.Init: %v", err)) } consensusState, err := consensus.NewState(cfg, dataState) From 6ab21ded47272c2936632f16725119880cae2eb4 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 11:04:03 -0700 Subject: [PATCH 31/46] refactor(producer): drop unused t from newTestEnv Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/producer/mempool_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 28761e3620..72a0588b19 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -235,7 +235,7 @@ func (env *testEnv) Run(ctx context.Context) error { })) } -func newTestEnv(t testing.TB, rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { +func newTestEnv(rng utils.Rng, cfg *Config, app *proxy.Proxy) *testEnv { committee, keys := types.GenCommittee(rng, 1) dataState := newTestDataState(committee) consensusState := utils.OrPanic1(consensus.NewState(&consensus.Config{ @@ -258,7 +258,7 @@ func TestInsertTx_TooLargeTx(t *testing.T) { ctx := t.Context() rng := utils.TestRng() app := newTestApp() - env := newTestEnv(t, rng, app.Cfg(), app.Proxy()) + env := newTestEnv(rng, app.Cfg(), app.Proxy()) // Tx with size exceeding block limit. tx := utils.GenBytes(rng, int(types.MaxTxsBytesPerBlock+1)) // Should be rejected by mempool. @@ -272,7 +272,7 @@ func TestInsertTx_GasWantedExceeded(t *testing.T) { rng := utils.TestRng() app := newTestApp() cfg := app.Cfg() - env := newTestEnv(t, rng, cfg, app.Proxy()) + env := newTestEnv(rng, cfg, app.Proxy()) // Tx with gas wanted exceeding block limit addr, nonce := app.NewAccount(rng) tx := env.genTx(rng, addr, nonce) @@ -290,7 +290,7 @@ func TestInsertTx_GasEstimatedExceeded(t *testing.T) { cfg := app.Cfg() cfg.MaxGasEstimatedPerBlock = 10000 cfg.MaxGasWantedPerBlock = cfg.MaxGasEstimatedPerBlock * 2 - env := newTestEnv(t, rng, cfg, app.Proxy()) + env := newTestEnv(rng, cfg, app.Proxy()) // Tx with gas wanted exceeding block limit addr, nonce := app.NewAccount(rng) tx := env.genTx(rng, addr, nonce) @@ -306,7 +306,7 @@ func TestInsertTx_AppRejectsTx(t *testing.T) { ctx := t.Context() rng := utils.TestRng() app := newTestApp() - env := newTestEnv(t, rng, app.Cfg(), app.Proxy()) + env := newTestEnv(rng, app.Cfg(), app.Proxy()) // Construct tx with invalid encoding. tx := utils.GenBytes(rng, 1) _, err := decodeTxSpec(tx) @@ -322,7 +322,7 @@ func TestMempool_BadNonce(t *testing.T) { ctx := t.Context() rng := utils.TestRng() app := newTestApp() - env := newTestEnv(t, rng, app.Cfg(), app.Proxy()) + env := newTestEnv(rng, app.Cfg(), app.Proxy()) // Initialize nonce for random account. addr := common.Address(utils.GenBytes(rng, len(common.Address{}))) nonce := uint64(rng.Intn(10000)) @@ -368,7 +368,7 @@ func TestMempool_HappyPath(t *testing.T) { cfg.MaxTxsPerBlock = 20 cfg.MaxGasWantedPerBlock = 100 cfg.MaxGasEstimatedPerBlock = 100 - env := newTestEnv(t, rng, cfg, app.Proxy()) + env := newTestEnv(rng, cfg, app.Proxy()) want := utils.NewMutex(map[common.Address][]*txSpec{}) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("env", func() error { return env.Run(ctx) }) @@ -450,7 +450,7 @@ func TestMempool_EvmTxByHash(t *testing.T) { app := newTestApp() cfg := app.Cfg() cfg.BlockInterval = time.Millisecond - env := newTestEnv(t, rng, cfg, app.Proxy()) + env := newTestEnv(rng, cfg, app.Proxy()) addr, nonce := app.NewAccount(rng) txs := utils.Slice( From 408dd239f3da3bae17d9fafb365c6f77ced3f50b Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 11:04:52 -0700 Subject: [PATCH 32/46] refactor(giga): drop unused t from newTestNode and newTestEnv Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga/data_test.go | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 947748d898..4b24da749c 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -23,7 +23,7 @@ type testNode struct { func defaultViewTimeout(view types.View) time.Duration { return time.Hour } -func newTestNode(t testing.TB, committee *types.Committee, cfg *consensus.Config) *testNode { +func newTestNode(committee *types.Committee, cfg *consensus.Config) *testNode { cfg.PersistentStateDir = utils.None[string]() dataState := data.NewState(&data.Config{Committee: committee}) if err := dataState.Init(nil); err != nil { @@ -50,18 +50,17 @@ func (n *testNode) Run(ctx context.Context) error { } type testEnv struct { - t testing.TB committee *types.Committee nodes map[types.PublicKey]*testNode } -func newTestEnv(t testing.TB, committee *types.Committee) *testEnv { - return &testEnv{t, committee, map[types.PublicKey]*testNode{}} +func newTestEnv(committee *types.Committee) *testEnv { + return &testEnv{committee, map[types.PublicKey]*testNode{}} } // Call AddNode BEFORE Run. func (e *testEnv) AddNode(key types.SecretKey) *testNode { - n := newTestNode(e.t, e.committee, &consensus.Config{ + n := newTestNode(e.committee, &consensus.Config{ Key: key, ViewTimeout: func(view types.View) time.Duration { if _, ok := e.nodes[e.committee.Leader(view)]; ok { @@ -97,7 +96,7 @@ func TestDataClientServer(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 2) firstBlock := committee.FirstBlock() - env := newTestEnv(t, committee) + env := newTestEnv(committee) server := env.AddNode(keys[0]) client := env.AddNode(keys[1]) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { From 8d7552d6406adebb90b409beb0a4ecddf58de33e Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 11:05:43 -0700 Subject: [PATCH 33/46] revert(avail): restore state.go to origin/main (MaxLaneRangeInProposal) Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/avail/state.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/state.go b/sei-tendermint/internal/autobahn/avail/state.go index 5b0e9bb9b7..c751a13eaa 100644 --- a/sei-tendermint/internal/autobahn/avail/state.go +++ b/sei-tendermint/internal/autobahn/avail/state.go @@ -19,8 +19,7 @@ import ( // ErrBadLane . var ErrBadLane = errors.New("bad lane") -const BlocksPerLane = 3 * BlocksPerLanePerCommit -const BlocksPerLanePerCommit = 10 +const BlocksPerLane = 3 * types.MaxLaneRangeInProposal // State represents the Data Availability Plane and Ordered Event Log. // Although it resides in a sub-package, it serves as the "source of truth" for: @@ -549,7 +548,7 @@ func (s *State) WaitForLaneQCs( for { for lane := range c.Lanes().All() { first := types.LaneRangeOpt(prev, lane).Next() - for i := range types.BlockNumber(BlocksPerLanePerCommit) { + for i := range types.BlockNumber(types.MaxLaneRangeInProposal) { if qc, ok := inner.laneQC(c, lane, first+i); ok { laneQCs[lane] = qc } else { From b70e97e9593b3fe4add9956a4b38f70731db4866 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 11:16:44 -0700 Subject: [PATCH 34/46] refactor(data): replace blockDBPersistedQC/Block fields with local vars in Run Capture persist cursors from inner.nextQC/nextBlock at the start of Run (before spawning goroutines) and pass them directly to runPersist. Removes the two State fields and the Init(nil) seeding logic. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/data/state.go | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 9248da8a44..e6a1d78723 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -184,12 +184,6 @@ type State struct { metrics *metrics.Metrics inner utils.Watch[*inner] blockDB utils.Option[types.BlockDB] - - // blockDBPersistedQC and blockDBPersistedBlock record where BlockDB's - // write cursors stood at construction time. runPersist initialises from - // these to avoid re-writing data already durably stored in BlockDB. - blockDBPersistedQC types.GlobalBlockNumber - blockDBPersistedBlock types.GlobalBlockNumber } // NewState constructs a new data State. The caller must call Run to open @@ -651,15 +645,7 @@ func (s *State) PruneBefore(retainFrom types.GlobalBlockNumber) error { // per batch. nextBlockToPersist advances to min(persistedQC, persistedBlock) // to unblock PushAppHash only when both are durable. // Errors propagate vertically (kill the component). -func (s *State) runPersist(ctx context.Context) error { - // Initialize from BlockDB's cursors at construction time, not from - // inner.nextQC/nextBlock. PushQC may have been called before Run() - // (race between p2p startup and Run), advancing inner cursors beyond - // what's durably in BlockDB. Starting from blockDBPersistedQC/Block - // ensures we don't skip unpersisted data. - persistedQC := s.blockDBPersistedQC - persistedBlock := s.blockDBPersistedBlock - +func (s *State) runPersist(ctx context.Context, persistedQC, persistedBlock types.GlobalBlockNumber) error { for { // Wait for unpersisted data and snapshot what needs writing. type batch struct { @@ -772,10 +758,7 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { // any disk I/O, which is suitable for load tests and in-memory-only nodes. // TODO(gprusak): add support for starting execution from non-zero commit QC. func (s *State) Init(blockDB types.BlockDB) error { - fb := s.cfg.Committee.FirstBlock() if blockDB == nil { - s.blockDBPersistedQC = fb - s.blockDBPersistedBlock = fb return nil } if err := s.loadFromBlockDB(blockDB); err != nil { @@ -788,9 +771,14 @@ func (s *State) Init(blockDB types.BlockDB) error { // Run starts the background goroutines (persistence and pruning). Init must // be called before Run. func (s *State) Run(ctx context.Context) error { + var persistedQC, persistedBlock types.GlobalBlockNumber + for inner := range s.inner.Lock() { + persistedQC = inner.nextQC + persistedBlock = inner.nextBlock + } return scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { scope.SpawnNamed("runPersist", func() error { - return s.runPersist(ctx) + return s.runPersist(ctx, persistedQC, persistedBlock) }) if pruneAfter, ok := s.cfg.PruneAfter.Get(); ok { scope.SpawnNamed("runPruning", func() error { From b3c62429159047b94ccc7487d44ccb9eb5b9e87e Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 11:19:18 -0700 Subject: [PATCH 35/46] fix(data): remove leftover blockDBPersistedQC/Block assignments in loadFromBlockDB Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/data/state.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index e6a1d78723..ea4e4125be 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -319,8 +319,6 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { } // Data loaded from BlockDB was already durably persisted. in.nextBlockToPersist = in.nextBlock - s.blockDBPersistedQC = in.nextQC - s.blockDBPersistedBlock = in.nextBlock } return nil } From 290a76ed13f782454eb352500eff0581f9c250bd Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 13:04:34 -0700 Subject: [PATCH 36/46] fix(giga): update avail_test.go call site for newTestEnv signature change Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga/avail_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/internal/p2p/giga/avail_test.go b/sei-tendermint/internal/p2p/giga/avail_test.go index a8288b16b0..5027c3a237 100644 --- a/sei-tendermint/internal/p2p/giga/avail_test.go +++ b/sei-tendermint/internal/p2p/giga/avail_test.go @@ -17,7 +17,7 @@ func TestAvailClientServer(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) - env := newTestEnv(t, committee) + env := newTestEnv(committee) var nodes []*testNode activeKeys := keys[:3] // keys are sorted by weight, so that's ok. totalWeight := uint64(0) From c46ee9d432be237a83d1a8c62d6a9a8227314ec4 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 13:05:34 -0700 Subject: [PATCH 37/46] fix(giga): update consensus_test.go call site for newTestEnv signature change Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga/consensus_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sei-tendermint/internal/p2p/giga/consensus_test.go b/sei-tendermint/internal/p2p/giga/consensus_test.go index b81fa46232..08eb75f77d 100644 --- a/sei-tendermint/internal/p2p/giga/consensus_test.go +++ b/sei-tendermint/internal/p2p/giga/consensus_test.go @@ -15,7 +15,7 @@ func TestConsensusClientServer(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 7) firstBlock := committee.FirstBlock() - env := newTestEnv(t, committee) + env := newTestEnv(committee) // Run only a subset of replicas, to enforce timeouts. var nodes []*testNode for _, key := range types.TestKeysWithWeight(committee, keys, committee.CommitQuorum()) { From 87d6bfbcb39636f3a3431c208c4e85e8fe791f70 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 13:19:37 -0700 Subject: [PATCH 38/46] fix(data): capture persist cursors in Init to prevent PushQC startup race Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/data/state.go | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index ea4e4125be..4c7775bb88 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -184,6 +184,13 @@ type State struct { metrics *metrics.Metrics inner utils.Watch[*inner] blockDB utils.Option[types.BlockDB] + // Captured in Init (single-threaded, before any sibling goroutines start) + // and consumed once at the top of Run to seed runPersist. If Run read these + // from inner.nextQC/nextBlock instead, a concurrent PushQC could win the + // inner lock first, advance the cursors past unpersisted data, and cause + // runPersist to skip writing those entries to BlockDB. + blockDBPersistedQC types.GlobalBlockNumber + blockDBPersistedBlock types.GlobalBlockNumber } // NewState constructs a new data State. The caller must call Run to open @@ -756,27 +763,25 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { // any disk I/O, which is suitable for load tests and in-memory-only nodes. // TODO(gprusak): add support for starting execution from non-zero commit QC. func (s *State) Init(blockDB types.BlockDB) error { - if blockDB == nil { - return nil + if blockDB != nil { + if err := s.loadFromBlockDB(blockDB); err != nil { + return fmt.Errorf("loadFromBlockDB: %w", err) + } + s.blockDB = utils.Some(blockDB) } - if err := s.loadFromBlockDB(blockDB); err != nil { - return fmt.Errorf("loadFromBlockDB: %w", err) + for inner := range s.inner.Lock() { + s.blockDBPersistedQC = inner.nextQC + s.blockDBPersistedBlock = inner.nextBlock } - s.blockDB = utils.Some(blockDB) return nil } // Run starts the background goroutines (persistence and pruning). Init must // be called before Run. func (s *State) Run(ctx context.Context) error { - var persistedQC, persistedBlock types.GlobalBlockNumber - for inner := range s.inner.Lock() { - persistedQC = inner.nextQC - persistedBlock = inner.nextBlock - } return scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { scope.SpawnNamed("runPersist", func() error { - return s.runPersist(ctx, persistedQC, persistedBlock) + return s.runPersist(ctx, s.blockDBPersistedQC, s.blockDBPersistedBlock) }) if pruneAfter, ok := s.cfg.PruneAfter.Get(); ok { scope.SpawnNamed("runPruning", func() error { From 93116286ff6e8f36112fc0e2f349111a3be43e35 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 13:29:46 -0700 Subject: [PATCH 39/46] fix(giga): log loud warning when HashVault disabled due to missing PersistentStateDir Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_common.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index d1403fc29a..08d982d8f0 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -254,8 +254,8 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo // buildHashVault constructs the app-hash equivocation guard runExecute owns. By default it // returns a durable Pebble-backed vault rooted at /hashvault, alongside the -// other Autobahn on-disk state. It returns a no-op vault (no protection) only when the operator -// explicitly sets HashVaultDisabledUnsafe — logged loudly as unsafe. +// other Autobahn on-disk state. It returns a no-op vault (no protection) when PersistentStateDir +// is unset or when the operator explicitly sets HashVaultDisabledUnsafe — both logged loudly. func buildHashVault(ctx context.Context, cfg *GigaRouterCommonConfig) (hashvault.HashVault, error) { if cfg.HashVaultDisabledUnsafe { logger.Error("################################################################") @@ -268,6 +268,12 @@ func buildHashVault(ctx context.Context, cfg *GigaRouterCommonConfig) (hashvault } dir, ok := cfg.PersistentStateDir.Get() if !ok { + logger.Error("################################################################") + logger.Error("# HASHVAULT DISABLED (PersistentStateDir not set). #") + logger.Error("# This node has NO app-hash equivocation protection and is #") + logger.Error("# running in an UNSAFE configuration. Set persistent_state_dir #") + logger.Error("# in the node config to enable protection. #") + logger.Error("################################################################") return hashvault.NewNoopHashVault(), nil } hvCfg := hashvault.DefaultHashVaultConfig() From e12bf9cb876f8343c084ed0b91cddad8c21e5939 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 13:48:45 -0700 Subject: [PATCH 40/46] =?UTF-8?q?test(data,consensus):=20rename=20newTestL?= =?UTF-8?q?ittDB=E2=86=92newTestBlockDB,=20use=20real=20BlockDB=20in=20per?= =?UTF-8?q?sist-error=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/consensus/inner_test.go | 25 +++++++- .../internal/autobahn/data/state_test.go | 62 +++++++++---------- 2 files changed, 54 insertions(+), 33 deletions(-) diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index 1ba8a91ddf..cebfe9d5fd 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -4,9 +4,11 @@ import ( "context" "errors" "fmt" + "path/filepath" "testing" "time" + "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -16,6 +18,19 @@ import ( "github.com/sei-protocol/sei-chain/sei-tendermint/libs/utils/require" ) +func newTestBlockDB(t *testing.T, dir string) types.BlockDB { + t.Helper() + cfg, err := littblock.DefaultConfig(dir) + if err != nil { + t.Fatalf("littblock.DefaultConfig: %v", err) + } + db, err := littblock.NewBlockDB(cfg) + if err != nil { + t.Fatalf("littblock.NewBlockDB: %v", err) + } + return db +} + func newTestDataState(committee *types.Committee) *data.State { s := data.NewState(&data.Config{Committee: committee}) if err := s.Init(nil); err != nil { @@ -932,16 +947,22 @@ func (f failPersister[T]) Persist(T) error { return f.err } func TestRunOutputsPersistErrorPropagates(t *testing.T) { // Verify that a persist error in runOutputs propagates // and terminates the consensus component (instead of panicking). + dir := t.TempDir() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) - ds := newTestDataState(committee) + db := newTestBlockDB(t, filepath.Join(dir, "blockdb")) + t.Cleanup(func() { _ = db.Close() }) + ds := data.NewState(&data.Config{Committee: committee}) + if err := ds.Init(db); err != nil { + t.Fatalf("data.Init: %v", err) + } wantErr := errors.New("disk on fire") pers := utils.Some[persist.Persister[*pb.PersistedInner]](failPersister[*pb.PersistedInner]{err: wantErr}) cs, err := newState(&Config{ Key: keys[0], ViewTimeout: func(types.View) time.Duration { return time.Hour }, - PersistentStateDir: utils.Some(t.TempDir()), + PersistentStateDir: utils.Some(dir), }, ds, pers, utils.None[*pb.PersistedInner]()) require.NoError(t, err) diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index 066303f3ec..a016f74c71 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -45,9 +45,9 @@ func snapshot(s *State) Snapshot { panic("unreachable") } -// newTestLittDB opens (or creates) a LittDB-backed BlockDB at dir. +// newTestBlockDB opens (or creates) a LittDB-backed BlockDB at dir. // Retention is set to 1ns so ForceGC reclaims pruned data immediately in tests. -func newTestLittDB(t *testing.T, dir string) types.BlockDB { +func newTestBlockDB(t *testing.T, dir string) types.BlockDB { t.Helper() cfg, err := littblock.DefaultConfig(dir) if err != nil { @@ -90,7 +90,7 @@ func TestState(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -161,7 +161,7 @@ func TestPushConflictingBadCommitQC(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) // Push a valid QC to advance inner.nextQC. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -269,7 +269,7 @@ func TestPushQCIgnoresBlocksMatchingUnverifiedHeaders(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) // Push qc1 with NO blocks — only the QC is stored. qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -313,7 +313,7 @@ func TestExecution(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) if err := scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) s.SpawnBgNamed("state.Run()", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -355,7 +355,7 @@ func TestPushBlockAcceptsBlockWithQC(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) // Push QC without blocks. qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) @@ -374,7 +374,7 @@ func TestGlobalBlockByHash(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) qc, blocks := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) require.NoError(t, state.PushQC(ctx, qc, blocks)) @@ -416,7 +416,7 @@ func TestRecoveryEmpty(t *testing.T) { dir := t.TempDir() fb := committee.FirstBlock() - db := newTestLittDB(t, dir) + db := newTestBlockDB(t, dir) state := newTestState(t, &Config{Committee: committee}, db) for inner := range state.inner.Lock() { require.Equal(t, fb, inner.first) @@ -465,14 +465,14 @@ func TestRecoveryNormal(t *testing.T) { gr2 := qc2.QC().GlobalRange(committee) // Session 1: write both QCs and all blocks. - db1 := newTestLittDB(t, dir) + db1 := newTestBlockDB(t, dir) writeToBlockDB(t, db1, committee, []*types.FullCommitQC{qc1, qc2}, [][]*types.Block{blocks1, blocks2}) require.NoError(t, db1.Close()) // Session 2: NewState should recover blocks and QCs. - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) state2 := newTestState(t, &Config{Committee: committee}, db2) require.Equal(t, gr2.Next, state2.NextBlock()) @@ -489,7 +489,7 @@ func TestRecoveryNormal(t *testing.T) { require.NoError(t, db2.Close()) // Session 3: verify session 2 did not corrupt BlockDB. - db3 := newTestLittDB(t, dir) + db3 := newTestBlockDB(t, dir) state3 := newTestState(t, &Config{Committee: committee}, db3) require.Equal(t, gr2.Next, state3.NextBlock()) } @@ -509,7 +509,7 @@ func TestPruningDiscards(t *testing.T) { gr2 := qc2.QC().GlobalRange(committee) gr3 := qc3.QC().GlobalRange(committee) - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) require.NoError(t, state.PushQC(ctx, qc1, blocks1)) require.NoError(t, state.PushQC(ctx, qc2, blocks2)) require.NoError(t, state.PushQC(ctx, qc3, blocks3)) @@ -558,14 +558,14 @@ func TestRecoveryAfterPruning(t *testing.T) { gr3 := qc3.QC().GlobalRange(committee) // Write only qc2 and qc3 — simulating a DB where qc1 was pruned and GC'd. - db1 := newTestLittDB(t, dir) + db1 := newTestBlockDB(t, dir) writeToBlockDB(t, db1, committee, []*types.FullCommitQC{qc2, qc3}, [][]*types.Block{blocks2, blocks3}) require.NoError(t, db1.Close()) // Recovery: first = gr2.First; qc1's range is before first, so ErrPruned. - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) state2 := newTestState(t, &Config{Committee: committee}, db2) require.Equal(t, gr3.Next, state2.NextBlock()) @@ -595,7 +595,7 @@ func TestRecoveryBlocksBehind(t *testing.T) { gr2 := qc2.QC().GlobalRange(committee) // Write both QCs but only qc1's blocks (simulate crash before qc2 blocks). - db1 := newTestLittDB(t, dir) + db1 := newTestBlockDB(t, dir) require.NoError(t, db1.WriteQC(gr1.First, gr1.Next, qc1)) require.NoError(t, db1.WriteQC(gr2.First, gr2.Next, qc2)) for i, n := 0, gr1.First; n < gr1.Next; n++ { @@ -606,7 +606,7 @@ func TestRecoveryBlocksBehind(t *testing.T) { require.NoError(t, db1.Close()) // Recovery: both QCs loaded, but only qc1's blocks. - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) state2 := newTestState(t, &Config{Committee: committee}, db2) for n := gr1.First; n < gr1.Next; n++ { @@ -646,7 +646,7 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { // starting from mid. It simulates a DB where the block prefix [gr1.First, mid) // was pruned and physically removed. mid := gr1.First + (gr1.Next-gr1.First)/2 - db1 := newTestLittDB(t, dir) + db1 := newTestBlockDB(t, dir) require.NoError(t, db1.WriteQC(gr1.First, gr1.Next, qc1)) for i, n := 0, gr1.First; n < gr1.Next; n++ { if n >= mid { @@ -658,7 +658,7 @@ func TestRecoveryPartialQCPrefix(t *testing.T) { require.NoError(t, db1.Close()) // Recovery should use blocks as golden: first == mid, not gr1.First. - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { @@ -745,12 +745,12 @@ func TestRecoveryQCsNoBlocks(t *testing.T) { qc1, _ := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) gr1 := qc1.QC().GlobalRange(committee) - db1 := newTestLittDB(t, dir) + db1 := newTestBlockDB(t, dir) require.NoError(t, db1.WriteQC(gr1.First, gr1.Next, qc1)) require.NoError(t, db1.Flush()) require.NoError(t, db1.Close()) - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { @@ -779,7 +779,7 @@ func TestRecoveryBlockGap(t *testing.T) { // enough to skip one block in the middle. mid := gr1.First + (gr1.Next-gr1.First)/2 - db1 := newTestLittDB(t, dir) + db1 := newTestBlockDB(t, dir) require.NoError(t, db1.WriteQC(gr1.First, gr1.Next, qc1)) for i, n := 0, gr1.First; n < gr1.Next; n++ { if n != mid { @@ -790,7 +790,7 @@ func TestRecoveryBlockGap(t *testing.T) { require.NoError(t, db1.Flush()) require.NoError(t, db1.Close()) - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) s2 := NewState(&Config{Committee: committee}) err := s2.Init(db2) require.Error(t, err) @@ -811,7 +811,7 @@ func TestPruningKeepsLastQCRange(t *testing.T) { gr1 := qc1.QC().GlobalRange(committee) // In-memory: push QC, execute all blocks, then prune everything. - state1 := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state1 := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) runCtx, cancel := context.WithCancel(ctx) @@ -837,13 +837,13 @@ func TestPruningKeepsLastQCRange(t *testing.T) { // Restart: build a fresh BlockDB containing only the surviving block (as GC // would leave it) and verify NewState correctly sets first = survivingBlock. dir := t.TempDir() - db := newTestLittDB(t, dir) + db := newTestBlockDB(t, dir) require.NoError(t, db.WriteQC(gr1.First, gr1.Next, qc1)) require.NoError(t, db.WriteBlock(survivingBlock, blocks1[survivingBlock-gr1.First])) require.NoError(t, db.Flush()) require.NoError(t, db.Close()) - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { require.Equal(t, survivingBlock, inner.first, @@ -865,7 +865,7 @@ func TestPruningWithPartialQCRange(t *testing.T) { gr2 := qc2.QC().GlobalRange(committee) // In-memory: push 2 QCs, execute, then prune. - state1 := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state1 := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) require.NoError(t, state1.PushQC(ctx, qc1, blocks1)) require.NoError(t, state1.PushQC(ctx, qc2, blocks2)) @@ -904,13 +904,13 @@ func TestPruningWithPartialQCRange(t *testing.T) { // Restart: build a fresh BlockDB containing only the surviving block from qc2 // (qc1 is pruned entirely) and verify NewState recovers correctly. dir := t.TempDir() - db := newTestLittDB(t, dir) + db := newTestBlockDB(t, dir) require.NoError(t, db.WriteQC(gr2.First, gr2.Next, qc2)) require.NoError(t, db.WriteBlock(survivingBlock, blocks2[survivingBlock-gr2.First])) require.NoError(t, db.Flush()) require.NoError(t, db.Close()) - db2 := newTestLittDB(t, dir) + db2 := newTestBlockDB(t, dir) state2 := newTestState(t, &Config{Committee: committee}, db2) for inner := range state2.inner.Lock() { require.Equal(t, survivingBlock, inner.first, @@ -927,7 +927,7 @@ func TestRunPruningEmptyState(t *testing.T) { state := newTestState(t, &Config{ Committee: committee, PruneAfter: utils.Some(time.Duration(0)), - }, newTestLittDB(t, t.TempDir())) + }, newTestBlockDB(t, t.TempDir())) // Run briefly — runPruning should not panic on empty state. ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) @@ -941,7 +941,7 @@ func TestPushBlockWaitsForQC(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) - state := newTestState(t, &Config{Committee: committee}, newTestLittDB(t, t.TempDir())) + state := newTestState(t, &Config{Committee: committee}, newTestBlockDB(t, t.TempDir())) // Push first QC covering [0, N). qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) From 6f0795f1aec64632659f8689feb72f7ff0b7776c Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 14:06:12 -0700 Subject: [PATCH 41/46] fix(data): clarify loadFromBlockDB comment re lock usage Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/autobahn/data/state.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 4c7775bb88..4fc934c6e1 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -204,8 +204,8 @@ func NewState(cfg *Config) *State { } // loadFromBlockDB replays QCs and blocks from blockDB into s.inner and -// initialises the write-cursor fields. Called at the start of Run before -// any goroutines are spawned, so no locking is needed. +// initialises the write-cursor fields. Called from Init before any goroutines +// are spawned; the lock is acquired only to satisfy the Watch API. func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { for in := range s.inner.Lock() { // Restore QCs from BlockDB. On the first QC, skipTo its GlobalRange.First From 46e6a31aed53d1a508f84ee200273c1bde003e56 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 14:16:00 -0700 Subject: [PATCH 42/46] refactor(data): replace iterErr pattern with IIFE, drop Close() errors in loadFromBlockDB Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/data/state.go | 160 ++++++++---------- 1 file changed, 73 insertions(+), 87 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 4fc934c6e1..c126445287 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -216,38 +216,33 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { if err != nil { return fmt.Errorf("open QC iterator: %w", err) } - firstQC := true - var iterErr error - for { - var ok bool - ok, iterErr = it.Next() - if iterErr != nil || !ok { - break - } - var qc *types.FullCommitQC - qc, iterErr = it.QC() - if iterErr != nil { - break - } - if firstQC { - firstQC = false - gr := qc.QC().GlobalRange(s.cfg.Committee) - if gr.First < in.nextQC { - iterErr = fmt.Errorf("QC in BlockDB predates committee genesis %d: got %d", in.nextQC, gr.First) - break + err = func() error { + firstQC := true + for { + ok, err := it.Next() + if err != nil || !ok { + return err + } + qc, err := it.QC() + if err != nil { + return err + } + if firstQC { + firstQC = false + gr := qc.QC().GlobalRange(s.cfg.Committee) + if gr.First < in.nextQC { + return fmt.Errorf("QC in BlockDB predates committee genesis %d: got %d", in.nextQC, gr.First) + } + in.skipTo(gr.First) + } + if err := in.insertQC(s.cfg.Committee, qc); err != nil { + return fmt.Errorf("load QC from BlockDB: %w", err) } - in.skipTo(gr.First) - } - if iterErr = in.insertQC(s.cfg.Committee, qc); iterErr != nil { - iterErr = fmt.Errorf("load QC from BlockDB: %w", iterErr) - break } - } - if closeErr := it.Close(); iterErr == nil && closeErr != nil { - iterErr = fmt.Errorf("close QC iterator: %w", closeErr) - } - if iterErr != nil { - return iterErr + }() + it.Close() // nolint:errcheck + if err != nil { + return err } // Restore blocks from BlockDB. The first retained block drives inner.first — @@ -258,66 +253,57 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { if err != nil { return fmt.Errorf("open block iterator: %w", err) } - var ( - firstBlock = true - nextExpect types.GlobalBlockNumber - ) - for { - var ok bool - ok, iterErr = it2.Next() - if iterErr != nil || !ok { - break - } - n := it2.Number() - if n >= in.nextQC { - iterErr = fmt.Errorf("block %d in BlockDB has no QC coverage (nextQC=%d)", n, in.nextQC) - break - } - if firstBlock { - firstBlock = false - if n < in.first { - iterErr = fmt.Errorf("block %d in BlockDB predates first QC start %d", n, in.first) - break + err = func() error { + firstBlock := true + var nextExpect types.GlobalBlockNumber + for { + ok, err := it2.Next() + if err != nil || !ok { + return err } - // Drop QC entries below the first retained block (straddling-QC - // case: QC started before the prune boundary so the QC pass - // populated in.qcs for [qcFirst, n); those entries are unreachable - // via pruneFirst which starts at first=n). - for k := in.first; k < n; k++ { - delete(in.qcs, k) + n := it2.Number() + if n >= in.nextQC { + return fmt.Errorf("block %d in BlockDB has no QC coverage (nextQC=%d)", n, in.nextQC) + } + if firstBlock { + firstBlock = false + if n < in.first { + return fmt.Errorf("block %d in BlockDB predates first QC start %d", n, in.first) + } + // Drop QC entries below the first retained block (straddling-QC + // case: QC started before the prune boundary so the QC pass + // populated in.qcs for [qcFirst, n); those entries are unreachable + // via pruneFirst which starts at first=n). + for k := in.first; k < n; k++ { + delete(in.qcs, k) + } + in.first = n + in.nextBlock = n + in.nextAppProposal = n + nextExpect = n + } + // updateNextBlock only advances nextBlock through contiguous present + // entries, so runPersist always writes [persistedBlock, nextBlock) + // fully populated. A gap here means BlockDB corruption. + if n != nextExpect { + return fmt.Errorf("block gap in BlockDB: expected %d, got %d", nextExpect, n) + } + nextExpect++ + blk, err := it2.Block() + if err != nil { + return err + } + if err := blk.Verify(s.cfg.Committee); err != nil { + return fmt.Errorf("verify block %d from BlockDB: %w", n, err) + } + if err := in.insertBlock(s.cfg.Committee, n, blk); err != nil { + return fmt.Errorf("insert block %d from BlockDB: %w", n, err) } - in.first = n - in.nextBlock = n - in.nextAppProposal = n - nextExpect = n - } - // updateNextBlock only advances nextBlock through contiguous present - // entries, so runPersist always writes [persistedBlock, nextBlock) - // fully populated. A gap here means BlockDB corruption. - if n != nextExpect { - iterErr = fmt.Errorf("block gap in BlockDB: expected %d, got %d", nextExpect, n) - break - } - nextExpect++ - var blk *types.Block - blk, iterErr = it2.Block() - if iterErr != nil { - break - } - if iterErr = blk.Verify(s.cfg.Committee); iterErr != nil { - iterErr = fmt.Errorf("verify block %d from BlockDB: %w", n, iterErr) - break - } - if iterErr = in.insertBlock(s.cfg.Committee, n, blk); iterErr != nil { - iterErr = fmt.Errorf("insert block %d from BlockDB: %w", n, iterErr) - break } - } - if closeErr := it2.Close(); iterErr == nil && closeErr != nil { - iterErr = fmt.Errorf("close block iterator: %w", closeErr) - } - if iterErr != nil { - return iterErr + }() + it2.Close() // nolint:errcheck + if err != nil { + return err } // Advance nextBlock through contiguous loaded blocks. Don't use From 3aead2c11f2018ff6856a30c61416e9d0ffaf6d4 Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 14:24:44 -0700 Subject: [PATCH 43/46] fix(giga): update stale buildDataState comment to reflect Init+Run split Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_common.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 08d982d8f0..f6a4018bca 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -57,8 +57,8 @@ type gigaRouterCommon struct { // buildDataState validates the common config, constructs the committee and // BlockDB config, and returns an uninitialised data.State. The caller's Run -// method opens BlockDB, replays persisted state, and closes it on exit by -// calling data.State.Run(ctx, blockDB). +// method opens BlockDB, calls data.State.Init(blockDB) to replay persisted +// state, then data.State.Run(ctx) to start background goroutines. func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, utils.Option[*littblock.LittBlockConfig], error) { if cfg.GenDoc.InitialHeight < 1 { return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) From 53951f57b2cb9499f099d8436c3737ccb4c2f7db Mon Sep 17 00:00:00 2001 From: Wen Date: Wed, 8 Jul 2026 14:58:47 -0700 Subject: [PATCH 44/46] refactor(data): move BlockDB open+replay into NewState constructor NewState(cfg, blockDB) replaces the two-step NewState()+Init(blockDB) pattern. Callers pass a BlockDB (or nil) at construction time; the constructor replays persisted state and captures the persist cursors before any goroutines start, eliminating the startup race where a concurrent PushQC could advance inner.nextQC/nextBlock past unpersisted entries before Run's runPersist loop read the watermarks. The BlockDB lifecycle is now owned by the caller (Run defers Close). preparePersistentStateDir merges the former rootify+mkdir helpers. buildDataState returns the raw BlockDB handle alongside *data.State so the router structs can defer-close it in Run. Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/avail/inner_test.go | 9 +-- .../internal/autobahn/consensus/inner_test.go | 14 ++-- .../internal/autobahn/data/state.go | 66 ++++++++----------- .../internal/autobahn/data/state_test.go | 19 +++--- .../autobahn/producer/mempool_test.go | 6 +- sei-tendermint/internal/p2p/giga/data_test.go | 6 +- .../internal/p2p/giga_router_common.go | 40 +++++++---- .../internal/p2p/giga_router_fullnode.go | 24 ++----- .../internal/p2p/giga_router_validator.go | 29 +++----- sei-tendermint/node/setup.go | 29 ++++---- 10 files changed, 111 insertions(+), 131 deletions(-) diff --git a/sei-tendermint/internal/autobahn/avail/inner_test.go b/sei-tendermint/internal/autobahn/avail/inner_test.go index 452f0f67e9..9b52cc6e88 100644 --- a/sei-tendermint/internal/autobahn/avail/inner_test.go +++ b/sei-tendermint/internal/autobahn/avail/inner_test.go @@ -1,9 +1,10 @@ package avail import ( - pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" "testing" + pb "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/pb" + "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus/persist" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/data" @@ -12,9 +13,9 @@ import ( ) func newTestDataState(t testing.TB, cfg *data.Config) *data.State { - s := data.NewState(cfg) - if err := s.Init(nil); err != nil { - t.Fatalf("data.Init: %v", err) + s, err := data.NewState(cfg, utils.None[types.BlockDB]()) + if err != nil { + t.Fatalf("data.NewState: %v", err) } return s } diff --git a/sei-tendermint/internal/autobahn/consensus/inner_test.go b/sei-tendermint/internal/autobahn/consensus/inner_test.go index cebfe9d5fd..dec517c1c4 100644 --- a/sei-tendermint/internal/autobahn/consensus/inner_test.go +++ b/sei-tendermint/internal/autobahn/consensus/inner_test.go @@ -28,13 +28,14 @@ func newTestBlockDB(t *testing.T, dir string) types.BlockDB { if err != nil { t.Fatalf("littblock.NewBlockDB: %v", err) } + t.Cleanup(func() { _ = db.Close() }) return db } func newTestDataState(committee *types.Committee) *data.State { - s := data.NewState(&data.Config{Committee: committee}) - if err := s.Init(nil); err != nil { - panic(fmt.Sprintf("data.Init: %v", err)) + s, err := data.NewState(&data.Config{Committee: committee}, utils.None[types.BlockDB]()) + if err != nil { + panic(fmt.Sprintf("data.NewState: %v", err)) } return s } @@ -951,10 +952,9 @@ func TestRunOutputsPersistErrorPropagates(t *testing.T) { rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 4) db := newTestBlockDB(t, filepath.Join(dir, "blockdb")) - t.Cleanup(func() { _ = db.Close() }) - ds := data.NewState(&data.Config{Committee: committee}) - if err := ds.Init(db); err != nil { - t.Fatalf("data.Init: %v", err) + ds, err := data.NewState(&data.Config{Committee: committee}, utils.Some[types.BlockDB](db)) + if err != nil { + t.Fatalf("data.NewState: %v", err) } wantErr := errors.New("disk on fire") diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index c126445287..3adc0a3ada 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -184,28 +184,42 @@ type State struct { metrics *metrics.Metrics inner utils.Watch[*inner] blockDB utils.Option[types.BlockDB] - // Captured in Init (single-threaded, before any sibling goroutines start) - // and consumed once at the top of Run to seed runPersist. If Run read these - // from inner.nextQC/nextBlock instead, a concurrent PushQC could win the - // inner lock first, advance the cursors past unpersisted data, and cause - // runPersist to skip writing those entries to BlockDB. + // Captured in NewState (before any goroutines start) and consumed once at + // the top of Run to seed runPersist. Capturing here avoids a race where a + // concurrent PushQC (called via an inbound connection after the transport + // starts but before Run's scope begins) could advance inner.nextQC/nextBlock + // past unpersisted data, causing runPersist to skip writing those entries. blockDBPersistedQC types.GlobalBlockNumber blockDBPersistedBlock types.GlobalBlockNumber } -// NewState constructs a new data State. The caller must call Run to open -// the BlockDB, replay persisted state, and start background goroutines. -func NewState(cfg *Config) *State { - return &State{ +// NewState constructs a data State, replaying persisted state from blockDB if +// set. Passing None disables persistence: Run advances nextBlockToPersist +// without any disk I/O. The caller owns blockDB and is responsible for closing +// it after Run returns; State never closes it. +// TODO(gprusak): add support for starting execution from non-zero commit QC. +func NewState(cfg *Config, blockDB utils.Option[types.BlockDB]) (*State, error) { + s := &State{ cfg: cfg, metrics: metrics.Get(), inner: utils.NewWatch(newInner(cfg.Committee)), } + if db, ok := blockDB.Get(); ok { + if err := s.loadFromBlockDB(db); err != nil { + return nil, fmt.Errorf("loadFromBlockDB: %w", err) + } + s.blockDB = blockDB + } + for inner := range s.inner.Lock() { + s.blockDBPersistedQC = inner.nextQC + s.blockDBPersistedBlock = inner.nextBlock + } + return s, nil } -// loadFromBlockDB replays QCs and blocks from blockDB into s.inner and -// initialises the write-cursor fields. Called from Init before any goroutines -// are spawned; the lock is acquired only to satisfy the Watch API. +// loadFromBlockDB replays QCs and blocks from blockDB into s.inner. +// Called from NewState before any goroutines are spawned; the lock is acquired +// only to satisfy the Watch API. func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { for in := range s.inner.Lock() { // Restore QCs from BlockDB. On the first QC, skipTo its GlobalRange.First @@ -240,7 +254,7 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { } } }() - it.Close() // nolint:errcheck + _ = it.Close() if err != nil { return err } @@ -301,7 +315,7 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { } } }() - it2.Close() // nolint:errcheck + _ = it2.Close() if err != nil { return err } @@ -741,29 +755,7 @@ func (s *State) runPruning(ctx context.Context, after time.Duration) error { } } -// Init replays persisted state from blockDB and stores the reference for use -// by Run's background goroutines. Must be called once before Run, from the -// same goroutine that will call Run (no concurrent callers). The caller owns -// blockDB and is responsible for closing it after Run returns. -// Passing nil disables persistence: Run advances nextBlockToPersist without -// any disk I/O, which is suitable for load tests and in-memory-only nodes. -// TODO(gprusak): add support for starting execution from non-zero commit QC. -func (s *State) Init(blockDB types.BlockDB) error { - if blockDB != nil { - if err := s.loadFromBlockDB(blockDB); err != nil { - return fmt.Errorf("loadFromBlockDB: %w", err) - } - s.blockDB = utils.Some(blockDB) - } - for inner := range s.inner.Lock() { - s.blockDBPersistedQC = inner.nextQC - s.blockDBPersistedBlock = inner.nextBlock - } - return nil -} - -// Run starts the background goroutines (persistence and pruning). Init must -// be called before Run. +// Run starts the background goroutines (persistence and pruning). func (s *State) Run(ctx context.Context) error { return scope.Run(ctx, func(ctx context.Context, scope scope.Scope) error { scope.SpawnNamed("runPersist", func() error { diff --git a/sei-tendermint/internal/autobahn/data/state_test.go b/sei-tendermint/internal/autobahn/data/state_test.go index a016f74c71..ad99bc3b99 100644 --- a/sei-tendermint/internal/autobahn/data/state_test.go +++ b/sei-tendermint/internal/autobahn/data/state_test.go @@ -65,8 +65,8 @@ func newTestBlockDB(t *testing.T, dir string) types.BlockDB { // newTestState constructs a State, replays db, and returns it ready to Run. func newTestState(t testing.TB, cfg *Config, db types.BlockDB) *State { t.Helper() - s := NewState(cfg) - require.NoError(t, s.Init(db)) + s, err := NewState(cfg, utils.Some(db)) + require.NoError(t, err) return s } @@ -425,16 +425,16 @@ func TestRecoveryEmpty(t *testing.T) { } } -// TestInitNilInMemoryMode verifies that Init(nil) followed by Run works -// end-to-end: QCs and blocks are accessible in memory with no BlockDB backing. -func TestInitNilInMemoryMode(t *testing.T) { +// TestNewStateNilInMemoryMode verifies that NewState(cfg, None) followed by Run +// works end-to-end: QCs and blocks are accessible in memory with no BlockDB backing. +func TestNewStateNilInMemoryMode(t *testing.T) { ctx := t.Context() rng := utils.TestRng() committee, keys := types.GenCommittee(rng, 3) qc1, blocks1 := TestCommitQC(rng, committee, keys, utils.None[*types.CommitQC]()) - state := NewState(&Config{Committee: committee}) - require.NoError(t, state.Init(nil)) + state, err := NewState(&Config{Committee: committee}, utils.None[types.BlockDB]()) + require.NoError(t, err) require.NoError(t, scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { s.SpawnBgNamed("state", func() error { return utils.IgnoreCancel(state.Run(ctx)) }) @@ -719,7 +719,7 @@ func TestRecoveryAfterPruneNoGC(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { _ = db2.Close() }) - // Init must succeed — below-watermark blocks never outlive their QCs + // NewState must succeed — below-watermark blocks never outlive their QCs // because blocks and QCs share the same GC filter in littblock. Without GC, // all entries are still present and recovery treats the DB as unpruned. // This is the PruneBefore-without-GC path: the watermark advanced but @@ -791,8 +791,7 @@ func TestRecoveryBlockGap(t *testing.T) { require.NoError(t, db1.Close()) db2 := newTestBlockDB(t, dir) - s2 := NewState(&Config{Committee: committee}) - err := s2.Init(db2) + _, err := NewState(&Config{Committee: committee}, utils.Some[types.BlockDB](db2)) require.Error(t, err) require.Contains(t, err.Error(), "block gap in BlockDB") } diff --git a/sei-tendermint/internal/autobahn/producer/mempool_test.go b/sei-tendermint/internal/autobahn/producer/mempool_test.go index 72a0588b19..6d8665ea00 100644 --- a/sei-tendermint/internal/autobahn/producer/mempool_test.go +++ b/sei-tendermint/internal/autobahn/producer/mempool_test.go @@ -34,9 +34,9 @@ type txSpec struct { } func newTestDataState(committee *types.Committee) *data.State { - s := data.NewState(&data.Config{Committee: committee}) - if err := s.Init(nil); err != nil { - panic(fmt.Sprintf("data.Init: %v", err)) + s, err := data.NewState(&data.Config{Committee: committee}, utils.None[types.BlockDB]()) + if err != nil { + panic(fmt.Sprintf("data.NewState: %v", err)) } return s } diff --git a/sei-tendermint/internal/p2p/giga/data_test.go b/sei-tendermint/internal/p2p/giga/data_test.go index 4b24da749c..ab90cb44a6 100644 --- a/sei-tendermint/internal/p2p/giga/data_test.go +++ b/sei-tendermint/internal/p2p/giga/data_test.go @@ -25,9 +25,9 @@ func defaultViewTimeout(view types.View) time.Duration { return time.Hour } func newTestNode(committee *types.Committee, cfg *consensus.Config) *testNode { cfg.PersistentStateDir = utils.None[string]() - dataState := data.NewState(&data.Config{Committee: committee}) - if err := dataState.Init(nil); err != nil { - panic(fmt.Sprintf("data.Init: %v", err)) + dataState, err := data.NewState(&data.Config{Committee: committee}, utils.None[types.BlockDB]()) + if err != nil { + panic(fmt.Sprintf("data.NewState: %v", err)) } consensusState, err := consensus.NewState(cfg, dataState) if err != nil { diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index f6a4018bca..1d1c269622 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -55,19 +55,19 @@ type gigaRouterCommon struct { hashVault hashvault.HashVault } -// buildDataState validates the common config, constructs the committee and -// BlockDB config, and returns an uninitialised data.State. The caller's Run -// method opens BlockDB, calls data.State.Init(blockDB) to replay persisted -// state, then data.State.Run(ctx) to start background goroutines. -func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, utils.Option[*littblock.LittBlockConfig], error) { +// buildDataState validates the common config, constructs the committee, opens +// BlockDB (if PersistentStateDir is set), and returns an initialised data.State +// alongside the raw BlockDB handle. The caller owns blockDB and must close it +// after Run returns; data.State never closes it. +func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, atypes.BlockDB, error) { if cfg.GenDoc.InitialHeight < 1 { - return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) + return nil, nil, fmt.Errorf("GenDoc.InitialHeight = %v, want >=1", cfg.GenDoc.InitialHeight) } if cfg.DialInterval <= 0 { - return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("GigaRouterCommonConfig.DialInterval = %v, want > 0", cfg.DialInterval) + return nil, nil, fmt.Errorf("GigaRouterCommonConfig.DialInterval = %v, want > 0", cfg.DialInterval) } if cfg.MaxInboundFullnodePeers < 0 || cfg.MaxInboundFullnodePeers > maxInboundFullnodePeers { - return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) + return nil, nil, fmt.Errorf("GigaRouterCommonConfig.MaxInboundFullnodePeers = %v, want 0..%v", cfg.MaxInboundFullnodePeers, maxInboundFullnodePeers) } committee, err := atypes.NewRoundRobinElection( slices.Collect(maps.Keys(cfg.ValidatorAddrs)), @@ -75,17 +75,31 @@ func buildDataState(cfg *GigaRouterCommonConfig) (*data.State, utils.Option[*lit cfg.GenDoc.GenesisTime, ) if err != nil { - return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) + return nil, nil, fmt.Errorf("atypes.NewRoundRobinElection(): %w", err) } - var blockCfgOpt utils.Option[*littblock.LittBlockConfig] + var blockDB atypes.BlockDB if dir, ok := cfg.PersistentStateDir.Get(); ok { blockCfg, err := littblock.DefaultConfig(filepath.Join(dir, "blockdb")) if err != nil { - return nil, utils.None[*littblock.LittBlockConfig](), fmt.Errorf("littblock.DefaultConfig: %w", err) + return nil, nil, fmt.Errorf("littblock.DefaultConfig: %w", err) } - blockCfgOpt = utils.Some(blockCfg) + blockDB, err = littblock.NewBlockDB(blockCfg) + if err != nil { + return nil, nil, fmt.Errorf("open BlockDB: %w", err) + } + } + blockDBOpt := utils.None[atypes.BlockDB]() + if blockDB != nil { + blockDBOpt = utils.Some(blockDB) + } + ds, err := data.NewState(&data.Config{Committee: committee}, blockDBOpt) + if err != nil { + if blockDB != nil { + _ = blockDB.Close() + } + return nil, nil, fmt.Errorf("data.NewState: %w", err) } - return data.NewState(&data.Config{Committee: committee}), blockCfgOpt, nil + return ds, blockDB, nil } func (r *gigaRouterCommon) LastCommittedBlockNumber() int64 { diff --git a/sei-tendermint/internal/p2p/giga_router_fullnode.go b/sei-tendermint/internal/p2p/giga_router_fullnode.go index 5be0076f3c..1c3a7c43fa 100644 --- a/sei-tendermint/internal/p2p/giga_router_fullnode.go +++ b/sei-tendermint/internal/p2p/giga_router_fullnode.go @@ -2,12 +2,11 @@ package p2p import ( "context" - "fmt" "maps" "math/rand/v2" "slices" - "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" + atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/giga" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/p2p/rpc" @@ -18,11 +17,11 @@ import ( type gigaFullnodeRouter struct { *gigaRouterCommon - blockDBCfg utils.Option[*littblock.LittBlockConfig] + blockDB atypes.BlockDB // nil if persistence is disabled } func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gigaFullnodeRouter, error) { - dataState, blockDBCfg, err := buildDataState(cfg) + dataState, blockDB, err := buildDataState(cfg) if err != nil { return nil, err } @@ -38,7 +37,7 @@ func NewGigaFullnodeRouter(cfg *GigaRouterCommonConfig, key NodeSecretKey) (*gig app: cfg.App, inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), }, - blockDBCfg: blockDBCfg, + blockDB: blockDB, }, nil } @@ -47,19 +46,8 @@ func (r *gigaFullnodeRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaFullnodeRouter) Run(ctx context.Context) error { - var initErr error - if blockDBCfg, ok := r.blockDBCfg.Get(); ok { - blockDB, err := littblock.NewBlockDB(blockDBCfg) - if err != nil { - return fmt.Errorf("open BlockDB: %w", err) - } - defer func() { _ = blockDB.Close() }() - initErr = r.data.Init(blockDB) - } else { - initErr = r.data.Init(nil) - } - if initErr != nil { - return fmt.Errorf("data.Init: %w", initErr) + if r.blockDB != nil { + defer func() { _ = r.blockDB.Close() }() } return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Single-active subscriber: walk the committee in a stable order, diff --git a/sei-tendermint/internal/p2p/giga_router_validator.go b/sei-tendermint/internal/p2p/giga_router_validator.go index 1cd6040b54..a126b38ca8 100644 --- a/sei-tendermint/internal/p2p/giga_router_validator.go +++ b/sei-tendermint/internal/p2p/giga_router_validator.go @@ -6,7 +6,6 @@ import ( "net/url" "github.com/ethereum/go-ethereum/common" - "github.com/sei-protocol/sei-chain/sei-db/ledger_db/block/littblock" atypes "github.com/sei-protocol/sei-chain/sei-tendermint/autobahn/types" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/consensus" "github.com/sei-protocol/sei-chain/sei-tendermint/internal/autobahn/producer" @@ -19,16 +18,16 @@ import ( type gigaValidatorRouter struct { *gigaRouterCommon - blockDBCfg utils.Option[*littblock.LittBlockConfig] - consensus *consensus.State - producer *producer.State + blockDB atypes.BlockDB // nil if persistence is disabled + consensus *consensus.State + producer *producer.State // validatorKey is the cached public form of cfg.ValidatorKey, used by // EvmProxy to short-circuit self-shard sends to the local mempool. validatorKey atypes.PublicKey } func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaValidatorRouter, error) { - dataState, blockDBCfg, err := buildDataState(&cfg.GigaRouterCommonConfig) + dataState, blockDB, err := buildDataState(&cfg.GigaRouterCommonConfig) if err != nil { return nil, err } @@ -38,6 +37,9 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV PersistentStateDir: cfg.PersistentStateDir, }, dataState) if err != nil { + if blockDB != nil { + _ = blockDB.Close() + } return nil, fmt.Errorf("consensus.NewState(): %w", err) } producerState := producer.NewState(cfg.Producer, consensusState, cfg.App) @@ -53,7 +55,7 @@ func NewGigaValidatorRouter(cfg *GigaValidatorConfig, key NodeSecretKey) (*gigaV app: cfg.App, inboundFullnodeCap: int64(cfg.MaxInboundFullnodePeers), }, - blockDBCfg: blockDBCfg, + blockDB: blockDB, consensus: consensusState, producer: producerState, validatorKey: cfg.ValidatorKey.Public(), @@ -65,19 +67,8 @@ func (r *gigaValidatorRouter) Mempool() utils.Option[*producer.State] { } func (r *gigaValidatorRouter) Run(ctx context.Context) error { - var initErr error - if blockDBCfg, ok := r.blockDBCfg.Get(); ok { - blockDB, err := littblock.NewBlockDB(blockDBCfg) - if err != nil { - return fmt.Errorf("open BlockDB: %w", err) - } - defer func() { _ = blockDB.Close() }() - initErr = r.data.Init(blockDB) - } else { - initErr = r.data.Init(nil) - } - if initErr != nil { - return fmt.Errorf("data.Init: %w", initErr) + if r.blockDB != nil { + defer func() { _ = r.blockDB.Close() }() } return scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { // Validators dial every committee member in parallel — consensus diff --git a/sei-tendermint/node/setup.go b/sei-tendermint/node/setup.go index 45b403bf5c..24316dedd4 100644 --- a/sei-tendermint/node/setup.go +++ b/sei-tendermint/node/setup.go @@ -310,8 +310,7 @@ func buildGigaRouter( if err != nil { return nil, fmt.Errorf("buildValidatorGigaConfig: %w", err) } - rootifyPersistentStateDir(cfg.RootDir, &valCfg.GigaRouterCommonConfig) - if err := mkdirPersistentStateDir(valCfg.PersistentStateDir); err != nil { + if err := preparePersistentStateDir(cfg.RootDir, &valCfg.GigaRouterCommonConfig); err != nil { return nil, err } // The GigaRouter builds and owns the equivocation guard itself; just pass the operator's @@ -324,8 +323,7 @@ func buildGigaRouter( if err != nil { return nil, fmt.Errorf("buildFullnodeGigaConfig: %w", err) } - rootifyPersistentStateDir(cfg.RootDir, fnCfg) - if err := mkdirPersistentStateDir(fnCfg.PersistentStateDir); err != nil { + if err := preparePersistentStateDir(cfg.RootDir, fnCfg); err != nil { return nil, err } // The GigaRouter builds and owns the equivocation guard itself; just pass the operator's @@ -335,22 +333,19 @@ func buildGigaRouter( return p2p.NewGigaFullnodeRouter(fnCfg, p2p.NodeSecretKey(nodeKey)) } -// rootifyPersistentStateDir resolves a relative PersistentStateDir -// against the node's --home dir (mirrors config.go's rootify). Absolute -// paths pass through unchanged. -func rootifyPersistentStateDir(rootDir string, c *p2p.GigaRouterCommonConfig) { - if dir, ok := c.PersistentStateDir.Get(); ok && !filepath.IsAbs(dir) { - c.PersistentStateDir = utils.Some(filepath.Join(rootDir, dir)) - } -} - -func mkdirPersistentStateDir(dir utils.Option[string]) error { - d, ok := dir.Get() +// preparePersistentStateDir resolves a relative PersistentStateDir against +// the node's --home dir (mirrors config.go's rootify) and creates it if absent. +func preparePersistentStateDir(rootDir string, c *p2p.GigaRouterCommonConfig) error { + dir, ok := c.PersistentStateDir.Get() if !ok { return nil } - if err := os.MkdirAll(d, 0700); err != nil { - return fmt.Errorf("creating persistent state dir %q: %w", d, err) + if !filepath.IsAbs(dir) { + dir = filepath.Join(rootDir, dir) + c.PersistentStateDir = utils.Some(dir) + } + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("creating persistent state dir %q: %w", dir, err) } return nil } From df8ddcf221b2988aeebc83dbe4c28603c39c74f2 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 9 Jul 2026 06:18:30 -0700 Subject: [PATCH 45/46] refactor(p2p): pass hashVault as param to executeBlock, drop struct field Co-Authored-By: Claude Sonnet 4.6 --- sei-tendermint/internal/p2p/giga_router_common.go | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/sei-tendermint/internal/p2p/giga_router_common.go b/sei-tendermint/internal/p2p/giga_router_common.go index 1d1c269622..f35bf5fed4 100644 --- a/sei-tendermint/internal/p2p/giga_router_common.go +++ b/sei-tendermint/internal/p2p/giga_router_common.go @@ -49,10 +49,6 @@ type gigaRouterCommon struct { // by one or two under contention but never over-accepts. inboundFullnodeCount atomic.Int64 inboundFullnodeCap int64 - - // hashVault is the app-hash equivocation guard. runExecute owns its lifecycle: - // it builds the vault on entry (durable under PersistentStateDir) and closes it on exit. - hashVault hashvault.HashVault } // buildDataState validates the common config, constructs the committee, opens @@ -210,7 +206,7 @@ func (r *gigaRouterCommon) translateGlobalBlock(gb *atypes.GlobalBlock) *coretyp } } -func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlock) (*abci.ResponseCommit, error) { +func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlock, hashVault hashvault.HashVault) (*abci.ResponseCommit, error) { app := r.app hash := b.Header.Hash() var proposerAddress types.Address @@ -252,7 +248,7 @@ func (r *gigaRouterCommon) executeBlock(ctx context.Context, b *atypes.GlobalBlo // before the hash is proposed for AppQC voting via PushAppHash below). On restart the block is // re-executed and the identical hash is re-committed idempotently. A returned error is a benign // shutdown cancellation; genuine faults panic inside the call. See commitAppHashToVault. - if err := commitAppHashToVault(ctx, r.hashVault, b.GlobalNumber, resp.AppHash); err != nil { + if err := commitAppHashToVault(ctx, hashVault, b.GlobalNumber, resp.AppHash); err != nil { return nil, err } @@ -339,7 +335,6 @@ func (r *gigaRouterCommon) runExecute(ctx context.Context) error { if err != nil { return fmt.Errorf("buildHashVault(): %w", err) } - r.hashVault = hashVault defer func() { if err := hashVault.Close(context.Background()); err != nil { logger.Error("failed to close hashvault", "err", err) @@ -381,7 +376,7 @@ func (r *gigaRouterCommon) runExecute(ctx context.Context) error { // first executed; if the committed app state has diverged from what the vault recorded (e.g. an // out-of-band rollback/restore), this halts the node instead of externalizing a conflicting // hash. A returned error is a benign shutdown cancellation; genuine faults panic inside. - if err := commitAppHashToVault(ctx, r.hashVault, last, info.LastBlockAppHash); err != nil { + if err := commitAppHashToVault(ctx, hashVault, last, info.LastBlockAppHash); err != nil { return err } // Losing a prefix of appHashes on crash is fine: AppQC is reached @@ -396,7 +391,7 @@ func (r *gigaRouterCommon) runExecute(ctx context.Context) error { if err != nil { return fmt.Errorf("r.data.GlobalBlock(%v): %w", n, err) } - commitResp, err := r.executeBlock(ctx, b) + commitResp, err := r.executeBlock(ctx, b, hashVault) if err != nil { return fmt.Errorf("r.executeBlock(%v): %w", n, err) } @@ -408,7 +403,7 @@ func (r *gigaRouterCommon) runExecute(ctx context.Context) error { return fmt.Errorf("r.data.PruneBefore(%v): %w", pruneBefore, err) } // Align the vault's retention with the data layer's prune boundary. - if err := r.hashVault.Prune(ctx, uint64(pruneBefore)); err != nil { + if err := hashVault.Prune(ctx, uint64(pruneBefore)); err != nil { // A canceled context just means we're shutting down between a successful executeBlock // and this prune; that's benign, not a prune failure, so don't alarm operators. if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { From 344eb21da9a26461bb77333bb68573a0b1e28425 Mon Sep 17 00:00:00 2001 From: Wen Date: Thu, 9 Jul 2026 07:42:30 -0700 Subject: [PATCH 46/46] refactor(data): IIFE+defer for iterators, drop bool flags in loadFromBlockDB Co-Authored-By: Claude Sonnet 4.6 --- .../internal/autobahn/data/state.go | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/sei-tendermint/internal/autobahn/data/state.go b/sei-tendermint/internal/autobahn/data/state.go index 3adc0a3ada..dd3ae8d8ce 100644 --- a/sei-tendermint/internal/autobahn/data/state.go +++ b/sei-tendermint/internal/autobahn/data/state.go @@ -189,6 +189,8 @@ type State struct { // concurrent PushQC (called via an inbound connection after the transport // starts but before Run's scope begins) could advance inner.nextQC/nextBlock // past unpersisted data, causing runPersist to skip writing those entries. + // + // TODO: expose persisted watermarks from BlockDB directly once runPersist no longer needs to cache all blocks/QCs in-memory. blockDBPersistedQC types.GlobalBlockNumber blockDBPersistedBlock types.GlobalBlockNumber } @@ -226,12 +228,13 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { // to advance past any pruned prefix (a straddling QC starts before the // prune boundary). Subsequent QCs must be consecutive — insertQC errors // on any gap. - it, err := blockDB.QCs(false) - if err != nil { - return fmt.Errorf("open QC iterator: %w", err) - } + var err error err = func() error { - firstQC := true + it, err := blockDB.QCs(false) + if err != nil { + return fmt.Errorf("open QC iterator: %w", err) + } + defer it.Close() for { ok, err := it.Next() if err != nil || !ok { @@ -241,8 +244,7 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { if err != nil { return err } - if firstQC { - firstQC = false + if in.first == in.nextQC { gr := qc.QC().GlobalRange(s.cfg.Committee) if gr.First < in.nextQC { return fmt.Errorf("QC in BlockDB predates committee genesis %d: got %d", in.nextQC, gr.First) @@ -254,7 +256,6 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { } } }() - _ = it.Close() if err != nil { return err } @@ -263,11 +264,12 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { // a straddling QC may start before the prune boundary, so QC data alone // cannot determine where blocks begin. insertBlock's n >= nextQC guard // enforces the no-block-without-QC invariant. - it2, err := blockDB.Blocks(false) - if err != nil { - return fmt.Errorf("open block iterator: %w", err) - } err = func() error { + it2, err := blockDB.Blocks(false) + if err != nil { + return fmt.Errorf("open block iterator: %w", err) + } + defer it2.Close() firstBlock := true var nextExpect types.GlobalBlockNumber for { @@ -315,7 +317,6 @@ func (s *State) loadFromBlockDB(blockDB types.BlockDB) error { } } }() - _ = it2.Close() if err != nil { return err }