Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 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
d3aab48
fix(seidb): refuse a torn changelog in digest replay instead of repai…
blindchaser Aug 30, 2026
bad7368
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Aug 31, 2026
d03ad01
Merge remote-tracking branch 'origin/main' into fix/seidb-digest-read…
blindchaser Sep 2, 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
1 change: 1 addition & 0 deletions sei-db/state_db/sc/memiavl/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ func OpenDB(targetVersion int64, opts Options) (database *DB, _err error) {
// Even in read-only mode we may need WAL replay to reconstruct non-snapshot versions.
streamHandler, err := wal.NewChangelogWAL(utils.GetChangelogPath(opts.Dir), wal.Config{
WriteBufferSize: opts.AsyncCommitBuffer,
NoRepairOnOpen: opts.NoChangelogRepair,
})
if err != nil {
return nil, fmt.Errorf("failed to open changelog WAL: %w", err)
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
// NoChangelogRepair fails the open with wal.ErrCorrupt instead of repairing a
// torn changelog tail. ReadOnly alone does not make an open non-mutating: the
// changelog opener repairs the tail whether or not writes through the DB API
// are allowed, so a tool that must leave a live directory alone sets both.
NoChangelogRepair 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
23 changes: 19 additions & 4 deletions sei-db/tools/cmd/seidb/operations/evm_logical_digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/sei-protocol/sei-chain/sei-db/state_db/sc/flatkv/vtype"
"github.com/sei-protocol/sei-chain/sei-db/state_db/sc/memiavl"
"github.com/sei-protocol/sei-chain/sei-db/state_db/sc/migration"
"github.com/sei-protocol/sei-chain/sei-db/wal"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -86,7 +87,10 @@ const (
// 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.
// an arbitrary comparison height has no snapshot-<height> on disk. On a live
// node this mode can read a changelog record the node is midway through
// writing; it then reports that and asks for a rerun rather than repairing
// the changelog under its writer.
//
// 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 @@ -1104,13 +1108,24 @@ func digestMemIAVL(dbDir string, height int64, findTarget []byte, normalization
}
}

// openMemiAVLReplayReadOnly opens dbDir for changelog replay without repairing
// its changelog. The repair is a truncation, and a record a running node is
// midway through writing is indistinguishable from a corrupt one, so repairing
// here can discard a block that node has committed.
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,
NoChangelogRepair: true,
})
if err != nil {
if errors.Is(err, wal.ErrCorrupt) {
return nil, fmt.Errorf("the changelog under %s ends mid-record, which is what a node "+
"writing a block looks like; %s was left as it was found, so rerun this command, and if "+
"it keeps failing the changelog is corrupt and the node needs attention: %w",
dbDir, dbDir, err)
}
return nil, fmt.Errorf("open memiavl read-only replay: %w", err)
}
return db, nil
Expand Down
95 changes: 95 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,95 @@
package operations

import (
"os"
"path/filepath"
"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/wal"
)

// TestOpenMemiAVLReplayReadOnlyRefusesATornChangelogWithoutTruncatingIt pins the
// reason replay does not repair: a record that ends mid-write looks the same
// whether the node crashed or is committing right now, and truncating it in the
// second case discards a committed block. The refusal is only worth anything if
// the tail survives it, so the segment's bytes are compared too.
func TestOpenMemiAVLReplayReadOnlyRefusesATornChangelogWithoutTruncatingIt(t *testing.T) {
homeDir := t.TempDir()
writeMemiavlNonces(t, homeDir, 3)

dbDir := utils.GetCosmosSCStorePath(homeDir)
segment := lastChangelogSegment(t, dbDir)
torn := tearFileTail(t, segment)

db, err := openMemiAVLReplayReadOnly(dbDir, 3)
require.Nil(t, db)
require.ErrorIs(t, err, wal.ErrCorrupt)
require.Contains(t, err.Error(), "rerun this command")

after, err := os.ReadFile(segment) //nolint:gosec // test-controlled path
require.NoError(t, err)
require.Equal(t, torn, after, "the open must leave the changelog segment as it found it")
}

// TestOpenMemiAVLReplayReadOnlyReplaysAnIntactChangelog is the positive control:
// refusing a torn tail is only the intended change if an intact one still
// replays.
func TestOpenMemiAVLReplayReadOnlyReplaysAnIntactChangelog(t *testing.T) {
homeDir := t.TempDir()
writeMemiavlNonces(t, homeDir, 3)

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

// writeMemiavlNonces commits count blocks to a fresh memiavl store under homeDir
// and closes it, leaving a changelog with one entry per block.
func writeMemiavlNonces(t *testing.T, homeDir string, count uint64) {
t.Helper()
store := newTestMemiavlStore(t, homeDir)
for nonce := uint64(1); nonce <= count; 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())
}

// lastChangelogSegment returns the path of the segment the changelog appends to.
// Segment names are zero-padded indices, so the highest name sorts last.
func lastChangelogSegment(t *testing.T, dbDir string) string {
t.Helper()
changelogDir := utils.GetChangelogPath(dbDir)
entries, err := os.ReadDir(changelogDir)
require.NoError(t, err)
var name string
for _, entry := range entries {
if !entry.IsDir() && len(entry.Name()) >= 20 {
name = entry.Name()
}
}
require.NotEmpty(t, name, "no changelog segment under %s", changelogDir)
return filepath.Join(changelogDir, name)
}

// tearFileTail drops the last byte of path, which is what a reader sees partway
// through the writer's append, and returns the resulting contents.
func tearFileTail(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path) //nolint:gosec // test-controlled path
require.NoError(t, err)
require.NotEmpty(t, data)
torn := data[:len(data)-1]
require.NoError(t, os.WriteFile(path, torn, 0o600))
return torn
}
2 changes: 1 addition & 1 deletion sei-db/wal/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func GetLastIndex(dir string) (index uint64, err error) {
rlog, err := open(dir, &wal.Options{
NoSync: true,
NoCopy: true,
})
}, false)
if err != nil {
return 0, err
}
Expand Down
21 changes: 18 additions & 3 deletions sei-db/wal/wal.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ const defaultBufferSize = 1024
// The size of write batches if the provided write batch size is less than 1.
const defaultWriteBatchSize = 64

// ErrCorrupt reports that the log ends mid-record. An open returns it only under
// Config.NoRepairOnOpen; otherwise the tail is truncated and the open succeeds.
var ErrCorrupt = wal.ErrCorrupt

// WAL is a generic write-ahead log implementation.
type WAL[T any] struct {
ctx context.Context
Expand Down Expand Up @@ -94,6 +98,12 @@ type Config struct {
// AllowEmpty permits removing all entries via TruncateAll.
// When false (default), at least one entry must remain after truncation.
AllowEmpty bool

// NoRepairOnOpen returns wal.ErrCorrupt from the open instead of truncating a
// corrupted tail to recover the log. A reader that must leave the log as it
// found it sets it. It does not cover the segment cleanup the open performs for
// an interrupted TruncateFront, which reports no error to gate on.
NoRepairOnOpen bool
}

// NewWAL creates a new generic write-ahead log that persists entries.
Expand All @@ -120,7 +130,7 @@ func NewWAL[T any](
NoSync: !config.FsyncEnabled,
NoCopy: !config.DeepCopyEnabled,
AllowEmpty: config.AllowEmpty,
})
}, config.NoRepairOnOpen)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -542,13 +552,18 @@ func (walLog *WAL[T]) Close() error {
return nil
}

// open opens the replay log, try to truncate the corrupted tail if there's any
func open(dir string, opts *wal.Options) (*wal.Log, error) {
// open opens the replay log, truncating a corrupted tail to recover the log.
// When noRepair is set it returns wal.ErrCorrupt instead, leaving the tail in
// place.
func open(dir string, opts *wal.Options, noRepair bool) (*wal.Log, error) {
if opts == nil {
opts = wal.DefaultOptions
}
rlog, err := wal.Open(dir, opts)
if errors.Is(err, wal.ErrCorrupt) {
if noRepair {
return nil, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replay still repairs live TruncateFront

High Severity

Replay no longer refuses a directory a writer holds. NoRepairOnOpen only skips torn-tail truncation after wal.Open; that open still finishes an in-flight TruncateFront whenever a .START segment exists, which is a normal prune window on every successful tryTruncateWAL. Completing that rename under seid makes the writer's next remove fail and sets l.corrupt, so later appends return ErrCorrupt until restart.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d3aab48. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, and deliberate — the PR body has a "What this does not cover" section for exactly this. One correction to the severity, though.

tidwall's own comment at the top of that cleanup says what the consequence is:

The log was truncated but still needs some file cleanup. Any errors following this message will not cause an on-disk data corruption, but may cause an inconsistency with the current program, so we'll return ErrCorrupt so the user can attempt a recover by calling Close() followed by Open().

Both parties perform the same sequence toward the same target — remove the segments preceding .START, then rename .START to the final segment name — so the directory converges on the state the writer was already moving it to. What breaks is the writer's process: its own os.Remove hits ENOENT, the deferred handler sets l.corrupt, and appends fail until restart. That is an availability event a restart clears, not data loss, and it is loud on the node rather than silent.

The torn-tail path this PR closes was the one that lost data: it truncated a record seid had committed and left a zero-filled hole the decoder accepts, surfacing only at the node's next replay.

Both closures were considered and cost more than the residual:

  • A check before the open is racy in the direction that matters — the writer can create .START in the gap, and the completion that follows reports no error, so there is nothing to retry on.
  • Taking the directory's LOCK closes it completely, but requires the node stopped, and replay exists for heights with no snapshot on a live migrating node, where the two backends rarely retain a common snapshot height.

Window sizes differ by about an order of magnitude too: a mid-record tail recurs every block, while .START exists only during a TruncateFront that follows a snapshot rewrite, roughly hourly at the default interval.

// try to truncate corrupted tail
var fis []os.DirEntry
fis, err = os.ReadDir(dir)
Expand Down
4 changes: 2 additions & 2 deletions sei-db/wal/wal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func TestOpenAndCorruptedTail(t *testing.T) {
_, err = wal.Open(dir, opts)
require.Equal(t, wal.ErrCorrupt, err)

log, err := open(dir, opts)
log, err := open(dir, opts, false)
require.NoError(t, err)

lastIndex, err := log.LastIndex()
Expand Down Expand Up @@ -171,7 +171,7 @@ func TestOpenWithNilOptions(t *testing.T) {
dir := t.TempDir()

// Test that open function handles nil options correctly
log, err := open(dir, nil)
log, err := open(dir, nil, false)
require.NoError(t, err)
require.NotNil(t, log)

Expand Down
Loading