Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
33c6cde
fix(seidb): fail closed on live memiavl WAL reads
blindchaser Aug 21, 2026
e8d1778
fix(wal): classify vanished segments as retryable
blindchaser Aug 21, 2026
5879f4e
fix(wal): stabilize live read-only views
blindchaser Aug 21, 2026
9afce99
refactor(seidb): scope fail-loud WAL reads to digest
blindchaser Aug 21, 2026
dcdfa41
refactor(seidb): check the changelog instead of reading it read-only
blindchaser Aug 21, 2026
37c745f
refactor(wal): move VerifyIntact beside the repair it avoids
blindchaser Aug 21, 2026
9075ecb
fix(seidb): reject a digest replay that skipped pruned versions
blindchaser Aug 22, 2026
022d9df
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Aug 27, 2026
38feadd
test(seidb): adopt the CommitStore.Commit version cross-check
blindchaser Aug 27, 2026
10c4278
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Aug 27, 2026
d47e81b
fix(seidb): stop the gap check from rejecting seeded chains
blindchaser Aug 27, 2026
cc54a07
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Aug 27, 2026
f76dd3e
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Aug 28, 2026
efde104
refactor(seidb): drop the digest replay-coverage guard
blindchaser Aug 28, 2026
259276f
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Aug 28, 2026
5eb3d8e
fix(seidb): stop the digest replay open from repairing the changelog
blindchaser Aug 28, 2026
431de98
fix(seidb): refuse a digest replay of a directory a writer holds
blindchaser Aug 28, 2026
9c8aac8
refactor(seidb): drop the changelog no-repair option
blindchaser Aug 28, 2026
7e5af21
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion sei-db/state_db/sc/memiavl/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,15 @@ func OpenDB(targetVersion int64, opts Options) (database *DB, _err error) {
err error
fileLock FileLock
)
// A failed open hands back no DB, so nothing else can release the lock. The
// process usually exits either way, but a caller that recovers and retries
// would otherwise lock itself out.
defer func() {
if _err != nil && fileLock != nil {
_ = fileLock.Unlock()
_ = fileLock.Destroy()
}
}()
if err := opts.Validate(); err != nil {
return nil, fmt.Errorf("invalid commit store options: %w", err)
}
Expand All @@ -179,12 +188,14 @@ func OpenDB(targetVersion int64, opts Options) (database *DB, _err error) {
}
}

if !opts.ReadOnly {
if !opts.ReadOnly || opts.RequireExclusive {
fileLock, err = LockFile(filepath.Join(opts.Dir, LockFileName))
if err != nil {
return nil, fmt.Errorf("fail to lock db: %w", err)
}
}

if !opts.ReadOnly {
// cleanup any temporary directories left by interrupted snapshot rewrite
if err := removeTmpDirs(opts.Dir); err != nil {
return nil, fmt.Errorf("fail to cleanup tmp directories: %w", err)
Expand Down
4 changes: 4 additions & 0 deletions sei-db/state_db/sc/memiavl/filelock.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ type FileLock interface {
Destroy() error
}

// ErrLocked reports that another process holds the lock, which for a database
// directory means a writer has it open.
var ErrLocked = filelock.ErrLocked

func LockFile(fname string) (FileLock, error) {
path, err := filepath.Abs(fname)
if err != nil {
Expand Down
5 changes: 5 additions & 0 deletions sei-db/state_db/sc/memiavl/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ type Options struct {
InitialVersion uint32
// ReadOnly opens the database in read-only mode
ReadOnly bool
// RequireExclusive takes the directory's lock even under ReadOnly, so the
// open fails with ErrLocked while a writer has it. A read-only tool that
// must not disturb the directory sets it, because the changelog opener
// mutates the directory whether or not the DB API can write.
RequireExclusive bool
// InitialStores are the initial store names when initializing an empty instance
InitialStores []string
// ZeroCopy if true, get and iterator methods return slices pointing to mmaped blob files
Expand Down
20 changes: 14 additions & 6 deletions sei-db/tools/cmd/seidb/operations/evm_logical_digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,11 @@ const (
// - replay (SLOW): opens a read-only DB, replays the changelog up to
// --height, then walks the in-memory/mmap tree. Roughly an order of
// magnitude slower than snapshot (changelog replay + per-leaf tree walk
// instead of a sequential file read). Use it only when no snapshot exists
// at the target height — e.g. nodes whose snapshot rewrite lags the tip, so
// an arbitrary comparison height has no snapshot-<height> on disk.
// instead of a sequential file read). It requires the node stopped, because
// opening a changelog its writer still holds lets the opener repair, and
// therefore damage, that changelog. Use it only when no snapshot exists at
// the target height — e.g. nodes whose snapshot rewrite lags the tip, so an
// arbitrary comparison height has no snapshot-<height> on disk.
//
// The flatkv side is always a pebble WAL-replay-to-height and is fast
// regardless. So when comparing across nodes, pick a height that is an existing
Expand Down Expand Up @@ -1106,11 +1108,17 @@ func digestMemIAVL(dbDir string, height int64, findTarget []byte, normalization

func openMemiAVLReplayReadOnly(dbDir string, height int64) (*memiavl.DB, error) {
db, err := memiavl.OpenDB(height, memiavl.Options{
Dir: dbDir,
ReadOnly: true,
ZeroCopy: true,
Dir: dbDir,
ReadOnly: true,
ZeroCopy: true,
RequireExclusive: true,
})
if err != nil {
if errors.Is(err, memiavl.ErrLocked) {
return nil, fmt.Errorf("another process has %s open, and replaying the changelog of a "+
"directory being written lets the changelog opener truncate a record that writer has "+
"committed; stop seid and rerun, or use --memiavl-open-mode snapshot: %w", dbDir, err)
}
return nil, fmt.Errorf("open memiavl read-only replay: %w", err)
}
return db, nil
Expand Down
65 changes: 65 additions & 0 deletions sei-db/tools/cmd/seidb/operations/memiavl_open_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package operations

import (
"testing"

"github.com/stretchr/testify/require"

"github.com/sei-protocol/sei-chain/sei-db/common/keys"
"github.com/sei-protocol/sei-chain/sei-db/common/utils"
"github.com/sei-protocol/sei-chain/sei-db/proto"
"github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl"
)

// TestOpenMemiAVLReplayReadOnlyRefusesADirectoryAWriterHasOpen covers what no
// check of the directory can: the changelog opener repairs a torn tail and
// completes an interrupted truncation, and both conditions occur transiently
// while a writer appends or truncates. Replaying a directory a writer holds is
// refused instead.
func TestOpenMemiAVLReplayReadOnlyRefusesADirectoryAWriterHasOpen(t *testing.T) {
homeDir := t.TempDir()
store := newTestMemiavlStore(t, homeDir)
require.NoError(t, store.ApplyChangeSets([]*proto.NamedChangeSet{{
Name: keys.EVMStoreKey,
Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{noncePair(addrN(0xA1), 1)}},
}}))
_, err := store.Commit(store.Version() + 1)
require.NoError(t, err)

// The store is still open, holding the lock the way a running seid does.
dbDir := utils.GetCosmosSCStorePath(homeDir)
db, err := openMemiAVLReplayReadOnly(dbDir, 0)
require.Nil(t, db)
require.ErrorIs(t, err, memiavl.ErrLocked)
require.Contains(t, err.Error(), "stop seid and rerun")
require.Contains(t, err.Error(), "--memiavl-open-mode snapshot")

// The same directory opens once that writer releases it, so the refusal
// tracks the writer rather than something the first open left behind.
require.NoError(t, store.Close())
db, err = openMemiAVLReplayReadOnly(dbDir, 0)
require.NoError(t, err)
require.NoError(t, db.Close())
}

// TestOpenMemiAVLReplayReadOnlyReplaysAStoppedNode is the positive control: the
// lock is the only thing the refusal adds, so a released directory replays as
// it did before.
func TestOpenMemiAVLReplayReadOnlyReplaysAStoppedNode(t *testing.T) {
homeDir := t.TempDir()
store := newTestMemiavlStore(t, homeDir)
for nonce := uint64(1); nonce <= 3; nonce++ {
require.NoError(t, store.ApplyChangeSets([]*proto.NamedChangeSet{{
Name: keys.EVMStoreKey,
Changeset: proto.ChangeSet{Pairs: []*proto.KVPair{noncePair(addrN(0xA1), nonce)}},
}}))
_, err := store.Commit(store.Version() + 1)
require.NoError(t, err)
}
require.NoError(t, store.Close())

db, err := openMemiAVLReplayReadOnly(utils.GetCosmosSCStorePath(homeDir), 3)
require.NoError(t, err)
defer func() { _ = db.Close() }()
require.Equal(t, int64(3), db.Version())
}
Loading