Skip to content

fix(seidb): refuse a digest replay that would repair the live changelog - #3983

Open
blindchaser wants to merge 19 commits into
mainfrom
fix/seidb-digest-readonly-wal
Open

fix(seidb): refuse a digest replay that would repair the live changelog#3983
blindchaser wants to merge 19 commits into
mainfrom
fix/seidb-digest-readonly-wal

Conversation

@blindchaser

@blindchaser blindchaser commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Summary

seidb evm-logical-digest --memiavl-open-mode replay opens the changelog of a live node through a writer's API, and that API repairs what it finds. It truncates a tail ending mid-record, and it completes an interrupted truncation by removing and renaming segments without reporting anything. Options.ReadOnly prevents neither, because it gates the DB API rather than the changelog open.

Both repairs damage a running node, because each condition also occurs transiently in normal operation, so the repair fires on a healthy log:

  • A tail ending mid-record is usually the writer mid-append. write is not atomic against a concurrent reader, so a reader sees a page-aligned prefix of a multi-page changeset. Truncating it discards a record seid has committed, and the writer's descriptor keeps its old offset, leaving a zero-filled hole the binary decoder reads as valid zero-length records. Nothing fails at the time; it surfaces when the node next reopens and replays.
  • A .START segment is present partway through every successful TruncateFront, between the write of that segment and its rename. Completing it underneath the writer makes the writer's own remove fail, and tidwall sets l.corrupt on any error past that point, so every later append returns ErrCorrupt until the process restarts.

No inspection of the directory can separate either case from real damage, and no check before the open can prevent it: both conditions appear and vanish faster than the gap between a check and the open, which spans LoadMultiTree. Refuse to replay a directory a writer holds.

  • sei-db/state_db/sc/memiavl/filelock.go, opts.go, db.go: add Options.RequireExclusive, which takes the directory's LOCK under ReadOnly too, and export ErrLocked so a caller can name the outcome. ReadOnly keeps skipping the lock, since a reader that cannot rerun is better served by proceeding. removeTmpDirs stays behind !ReadOnly, so this open still writes nothing itself. A failed open now releases the lock, which nothing else can do once no DB is returned.
  • sei-db/tools/cmd/seidb/operations/evm_logical_digest.go: set it for replay mode, and report a held lock as the node being up, with the two ways forward.

Holding the lock for the run also stops seid from starting underneath a digest already in progress.

The changelog opener is unchanged, so a directory no writer holds replays exactly as before, including the repair of damage a crash left behind. That repair is what seid itself performs at its next start, so performing it here does not change what the node would have replayed.

The cost is that replay mode now requires the node stopped. It is the slow fallback for a height with no snapshot, so the common path is unaffected, and --memiavl-open-mode snapshot does not read the changelog at all.

Test plan

  • sei-db/tools/cmd/seidb/operations/memiavl_open_test.go: refuse a directory whose writer is still open, naming ErrLocked and both remedies; then open the same directory once that writer releases it, so the refusal tracks the writer rather than something the first attempt left behind. Replay a stopped node to the requested height as the positive control.
  • go test -race ./sei-db/wal ./sei-db/state_db/sc/memiavl ./sei-db/tools/cmd/seidb/operations
  • make dblint

Use an immutable WAL view for read-only replay so digest tooling never repairs or copies the live changelog. Return actionable retry errors when a point-in-time view cannot safely reach the requested version.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 78.57143% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.56%. Comparing base (0ba222a) to head (7e5af21).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
sei-db/state_db/sc/memiavl/db.go 50.00% 2 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3983      +/-   ##
==========================================
- Coverage   61.30%   60.56%   -0.74%     
==========================================
  Files        2155     2074      -81     
  Lines      188507   179795    -8712     
==========================================
- Hits       115556   108891    -6665     
+ Misses      62205    60895    -1310     
+ Partials    10746    10009     -737     
Flag Coverage Δ
sei-chain-pr 29.45% <100.00%> (?)
sei-db 69.80% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 77.19% <50.00%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sei-db/state_db/sc/memiavl/filelock.go 60.00% <ø> (ø)
sei-db/state_db/sc/memiavl/opts.go 100.00% <ø> (ø)
...b/tools/cmd/seidb/operations/evm_logical_digest.go 24.22% <100.00%> (+1.31%) ⬆️
sei-db/state_db/sc/memiavl/db.go 70.06% <50.00%> (-0.21%) ⬇️

... and 83 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedAug 28, 2026, 7:35 PM

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 33c6cde16d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread sei-db/wal/readonly.go Outdated
Comment thread sei-db/wal/readonly.go Outdated
Treat a segment removed between directory listing and open as WAL churn so read-only callers return the actionable retry path.

Co-authored-by: Cursor <cursoragent@cursor.com>

@seidroid seidroid Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Solid change: the immutable read-only changelog reader correctly avoids tidwall's writable open (no file creation, no tail truncation, no .START/.END recovery completion), retains fds for a stable point-in-time view, and the new OpenDB cleanup defer fixes real mtree/WAL/file-lock leaks on failure. No blockers, but the fail-closed policy now also applies to production read-only callers (CommitStore.Exporter, LoadVersion(v, true)) that have no retry, and a few guards/classifications could be tightened.

Findings: 0 blocking | 6 non-blocking | 4 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • [suggestion] openReadOnlyWAL eagerly io.ReadAlls every segment in the changelog directory and keeps one fd open per segment for the lifetime of the view. With the default 20 MB tidwall segment size and a multi-GB changelog that is hundreds of fds plus a full sequential read at open, even though MultiTree.Catchup typically only replays a suffix starting at the selected snapshot version, and each entry is then pread again on ReadAt (so the bytes are read twice). Consider indexing segments lazily, or at least skipping segments entirely below the first needed offset.
  • [suggestion] Options.ReadOnly is not only the seidb replay tool: memiavl.CommitStore.Exporter (state-sync snapshot export, when OnlyAllowExportOnSnapshotVersion is false) and CommitStore.LoadVersion(v, true) / CompositeCommitStore.LoadVersionReadOnly also flow through it, and they now inherit both the fail-closed WAL open and the new strict mtree.Version() != targetVersion check. The test plan covers only the WAL package, db_test.go, and the CLI. A memiavl-level test that opens read-only while a writer is committing in the same process (the analogue of TestOpenReadOnlyChangelogWALConcurrentWriter) would pin the behavior those callers actually see.
  • 4 suggestion(s)/nit(s) flagged inline on specific lines.

Comment thread sei-db/wal/readonly.go Outdated
Comment thread sei-db/state_db/sc/memiavl/db.go Outdated
Comment thread sei-db/tools/cmd/seidb/operations/evm_logical_digest.go Outdated
Comment thread sei-db/state_db/sc/memiavl/db.go Outdated
Use the complete tail prefix during concurrent writes, treat a missing changelog as empty, and classify deferred read failures through the retryable WAL error path.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Aug 21, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes memiavl open/lock semantics for a narrow tool path; incorrect lock handling could affect recovery retries, but default read-only behavior is unchanged and replay against live data was actively dangerous.

Overview
Prevents seidb evm-logical-digest replay from opening a live memiavl directory, where read-only open still repairs/truncates the changelog and can corrupt a running seid.

Adds Options.RequireExclusive: read-only opens can take the directory LOCK and fail with exported memiavl.ErrLocked when another process (the node) already holds it. OpenDB releases that lock on failed open so retries are not stuck; tmp-dir cleanup stays writer-only.

Replay mode sets RequireExclusive and returns a clear error (stop the node or use --memiavl-open-mode snapshot). Docs state replay requires a stopped node. Tests cover refusal while a writer is open and successful replay after close.

Reviewed by Cursor Bugbot for commit 7e5af21. Bugbot is set up for automated code reviews on this repo. Configure here.

blindchaser and others added 2 commits August 21, 2026 17:04
Keep existing memiavl read-only callers on their prior WAL path and require digest replay to opt into immutable, fail-loud access.

Co-authored-by: Cursor <cursoragent@cursor.com>
The digest replay mode refused a torn changelog through a purpose-built
read-only WAL reader and an opt-in memIAVL mode. A check before the open
reaches the same outcome without either: the opener repairs only the tail
segment, so reading that one segment answers whether the open would repair
anything.

This leaves memIAVL and the changelog opener untouched.

Co-authored-by: Cursor <cursoragent@cursor.com>
@blindchaser blindchaser changed the title fix(seidb): fail closed on live memiavl WAL reads fix(seidb): refuse a digest replay that would repair the live changelog Aug 21, 2026
_ = db.Close()
return nil, fmt.Errorf("memiavl replay reached version %d, not the requested height %d; "+
"the changelog does not cover that height", reached, height)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pruned WAL gap check removed

High Severity

Removing FailOnWALRepair also dropped the immutable-view check that the changelog still covers every version after the selected snapshot. Replay now only compares the final Version() to --height, so a pruned gap can replay a contiguous suffix, reach the requested height, and return a digest that silently omitted intermediate versions.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit dcdfa41. Configure here.

blindchaser and others added 2 commits August 21, 2026 17:48
VerifyIntact sat in its own file, away from truncateCorruptedTail and
loadNextBinaryEntry, which are the repair it guards against and the framing
it reuses.

Co-authored-by: Cursor <cursoragent@cursor.com>
Catchup starts at the changelog's first offset whenever the snapshot ends
before it, so a changelog pruned past the snapshot replays a contiguous
suffix, reaches the requested height, and omits the versions in between.
Comparing the final version to --height does not catch that, and a digest
missing intermediate versions reads as a state mismatch between nodes.

Co-authored-by: Cursor <cursoragent@cursor.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

There are 2 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9075ecb. Configure here.

Comment thread sei-db/tools/cmd/seidb/operations/evm_logical_digest.go Outdated
blindchaser and others added 12 commits August 27, 2026 09:35
main gave CommitStore.Commit a version argument. The merge was textually
clean because it touched no file this branch changed, so the break only
showed up at compile time.

Co-authored-by: Cursor <cursoragent@cursor.com>
The check flagged any distance between the snapshot and the changelog's first
entry. A chain seeded above height 1 has that distance legitimately: its
changelog starts at the initial version while initEmptyDB leaves the snapshot
at version 0, and the versions in between never existed.

Ask instead whether the offset the replay needs existed and was pruned, which
is the branch of Catchup's clamp that loses data.

Co-authored-by: Cursor <cursoragent@cursor.com>
tryTruncateWAL anchors its changelog cut at the earliest retained snapshot
and seekSnapshot refuses a height below every snapshot, so the pruned-gap the
guard reported cannot arise unless the data directory was assembled or pruned
by hand. It also had to special-case a chain seeded above height 1, where the
distance it measured is legitimate.

Keep the pre-flight check the change exists for, and keep one positive control
for it. Guarding a silent memIAVL replay gap belongs in memIAVL rather than in
one caller.

Co-authored-by: Cursor <cursoragent@cursor.com>
Checking the changelog and then opening it anyway left the repair reachable.
The gap between the two spans LoadMultiTree, which is orders of magnitude
longer than a torn tail survives, so a tail torn after the check was truncated
by the open exactly as before. The check only ever covered the conditions that
persist.

Refuse inside the open instead. Config.NoRepairOnOpen makes open return
ErrCorrupt for a torn tail rather than truncating it, and refuse a directory
holding a .START/.END marker before wal.Open completes that truncation.
memiavl passes it through as Options.NoChangelogRepair, and the digest sets it
for replay mode. Default is off, so every existing caller keeps the repair.

Co-authored-by: Cursor <cursoragent@cursor.com>
The marker check could not hold. tidwall writes a .START segment partway
through every successful TruncateFront, not only an interrupted one, so it
appears after the check exactly as a torn tail does. Completing that truncation
underneath the writer makes its own remove fail, and tidwall sets l.corrupt on
any error past that point, so the node's appends fail until it restarts.

No pre-open check fixes this, so exclude the writer instead.
Options.RequireExclusive takes the LOCK under ReadOnly too, and the digest
refuses a directory seid has open, offering snapshot mode. Under that exclusion
a torn tail is damage rather than an append in flight, so the message asks for
an offline repair rather than a rerun, and reading the directory before the open
is sound because nothing can change it.

Co-authored-by: Cursor <cursoragent@cursor.com>
RequireExclusive made it redundant. With no writer on the directory, a torn
tail or a leftover truncation marker is damage rather than a race, and the
repair for it is the one seid performs at its next start, so refusing it here
changes nothing about what the node replays. It only withheld a diagnostic.

Restores sei-db/wal to its state on main, so the change no longer touches that
package: the refusal is the lock, and the lock alone.

Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant