Skip to content

Add crash recovery logic for GigaStorageManager - #4079

Open
yzang2019 wants to merge 4 commits into
mainfrom
yzang/crash-recovery
Open

Add crash recovery logic for GigaStorageManager#4079
yzang2019 wants to merge 4 commits into
mainfrom
yzang/crash-recovery

Conversation

@yzang2019

@yzang2019 yzang2019 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Describe your changes and provide context

Adds crash recovery to GigaStorageManager so that, after recovery:

  1. Every store is at or below the block store's height.
  2. Every store other than the block store is on the same height.

OpenDBWithRecovery opens the block store and receipt store, then computes a recovery target and brings the rest of the stores onto it:

  • findTargetRecoveryHeight — the target is the lowest head among the block store, the state WAL, and the receipt store (receipts skipped when disabled). It reads the WAL tail offline via statewal.GetRange without opening a live WAL. A target of 0 means a fresh node with nothing to converge on, and OpenDBWithoutRecovery just loads the commit store as it sits.
  • truncateStateWAL — drops every WAL block above the target offline via statewal.PruneAfter, so the first live write after startup is the block after the target.
  • recoverSC — loads the commit store from the truncated WAL, then rolls it back if a snapshot left it above the target.
  • recoverSS — brings the EVM state store onto the target, replaying the WAL when it is behind (CatchUpFrom) and restoring the newest snapshot <= target and replaying onto it when it is ahead (RollbackTo).
  • recoverReceipt — rolls the receipt store back to the target. The store must be closed for the rollback to rewrite it, so it closes, rolls back offline via receipt.Rollback, and reopens.

Supporting changes:

  • statewal.GetRange / PruneAfter / VerifyIntegrity now take a directory path instead of a *Config, so they can run offline against a WAL directory with no live instance open.
  • New offline rollback helpers: receipt.Rollback, EVMStateStore.CatchUpFrom / RollbackTo.
  • GigaStorageManager retains its config for the reopen/offline paths, and Close reports every store's failure rather than stopping at the first.

Testing performed to validate your change

  • New tests in sei-db/bootstrap/recovery_test.go covering each skew: SC behind the WAL, SC ahead (rollback), SS behind, SS ahead, receipts ahead, WAL truncation, target-height computation, and a fresh node where every height is zero.
  • go test ./sei-db/bootstrap/... and ./sei-db/state_db/sc/flatkv/... pass (via scripts/ramtest.sh).

@cursor

cursor Bot commented Sep 2, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Startup now mutates on-disk state (WAL prune, receipt rollback, EVM snapshot restore) based on cross-store height heuristics; bugs could truncate committed data or leave stores divergent.

Overview
Replaces the no-op CrashRecover path with OpenDBWithRecovery, which runs during NewGigaStorageManager so block store, state WAL, commit store (SC), EVM state store (SS), and receipts share one height after an unclean shutdown.

Recovery picks a target as the minimum head among the block store, offline state WAL tail, and receipt store (when enabled). It then rolls receipts back offline (receipt.Rollback), truncates the state WAL above the target, opens SC/SS in an order that avoids double-WAL ownership, replays or rolls back SC from the truncated WAL, and catches up or snapshot-restores SS via new CatchUpFrom / RollbackTo.

Supporting API changes: statewal.GetRange / PruneAfter / VerifyIntegrity take a directory path instead of *Config; flatkv.StateWALPath exposes the changelog layout; giga.NewStateDB now accepts an optional SS handle (SS commit on the live path is still a TODO). EVMStateStore refactors DB open/close so rollback can close and reopen Pebble dirs.

Adds recovery_test.go covering target-height logic, WAL truncation, SC/SS/receipt skew, and fresh-node startup.

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

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedSep 3, 2026, 5:20 AM

@codecov

codecov Bot commented Sep 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 31.45161% with 85 lines in your changes missing coverage. Please review.
✅ Project coverage is 60.44%. Comparing base (2e256b5) to head (e7f0fac).

Files with missing lines Patch % Lines
sei-db/state_db/ss/evm/recovery.go 0.00% 76 Missing ⚠️
sei-db/state_db/ss/evm/store.go 76.66% 5 Missing and 2 partials ⚠️
sei-db/state_db/sc/flatkv/snapshot.go 0.00% 0 Missing and 1 partial ⚠️
sei-db/state_db/statewal/state_wal_impl.go 83.33% 0 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #4079      +/-   ##
==========================================
- Coverage   61.30%   60.44%   -0.87%     
==========================================
  Files        2178     2101      -77     
  Lines      190826   182015    -8811     
==========================================
- Hits       116990   110021    -6969     
+ Misses      62807    61659    -1148     
+ Partials    11029    10335     -694     
Flag Coverage Δ
sei-db 69.80% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 73.10% <31.45%> (?)

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

Files with missing lines Coverage Δ
sei-db/bootstrap/recovery.go 100.00% <ø> (ø)
sei-db/bootstrap/storage_manager.go 72.41% <ø> (+3.78%) ⬆️
sei-db/state_db/giga/state_db_impl.go 100.00% <100.00%> (ø)
sei-db/state_db/sc/flatkv/store_meta.go 79.64% <100.00%> (ø)
sei-db/state_db/sc/flatkv/wal_glue.go 100.00% <100.00%> (ø)
sei-db/tools/cmd/seidb/operations/flatkv_open.go 63.93% <ø> (-0.20%) ⬇️
sei-db/state_db/sc/flatkv/snapshot.go 67.97% <0.00%> (ø)
sei-db/state_db/statewal/state_wal_impl.go 83.20% <83.33%> (ø)
sei-db/state_db/ss/evm/store.go 60.79% <76.66%> (+0.44%) ⬆️
sei-db/state_db/ss/evm/recovery.go 0.00% <0.00%> (ø)

... and 124 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.

@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.

The crash-recovery implementation has several correctness problems: the commit store is now constructed with the same live state WAL that giga.stateDB already writes (which the WAL's own ordering check rejects), a computed target of 0 runs the destructive recovery steps instead of short-circuiting, and CatchUpFrom stamps the EVM store at the target even when the WAL cannot cover the replay range. The refactors around statewal path arguments and EVMStateStore.openDBs/closeDBs are clean.

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

Blockers

  • None at the file/PR level.
  • 4 blocking issue(s) flagged inline on specific lines.

Non-blocking

  • [suggestion] No test exercises OpenDBWithRecovery against a home directory whose stores actually disagree. Every new test drives the private helpers directly on a freshly opened manager, and TestOpenDBWithoutRecoveryOnAFreshHome only covers the fresh case. A reopen test (commit N blocks, close the manager, desynchronize one store on disk, reopen and assert every head converged) is what would have caught the SC/WAL ownership change and the target-0 path.
  • [suggestion] giga/state_db_impl.go now holds an ss field that CommitStateChanges never writes (// TODO: Commit changes to SS). The EVM state store therefore only ever advances during startup recovery, so the state WAL must retain every block back to the previous recovery point for the next restart to be correct — a constraint nothing in the prune cycle enforces. Worth stating in the recovery godoc while the TODO stands.
  • [suggestion] flatkvStateWALName in sei-db/tools/cmd/seidb/operations/flatkv_open.go is now dead after the GetRange signature change — its only remaining reference is its own declaration.
  • 3 suggestion(s)/nit(s) flagged inline on specific lines.

if err := m.openStateWal(); err != nil {
return err
}
if err := m.openSC(ctx, m.stateWAL); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] Passing the live m.stateWAL into NewCommitStore hands FlatKV the WAL write path, but giga.stateDB still writes the same instance itself, so every block is written twice.

CommitStore.Commit does wal.Write / SignalEndOfBlock / Flush whenever its wal field is non-nil (store_write.go:87, and the field doc at store.go:148 — "non-nil ⇒ FlatKV writes/replays/prunes it"). stateDB.CommitStateChanges already does wal.Write(blockNum, …) + SignalEndOfBlock() before delegating to sc.CommitStateChanges. The second Write for the same height reaches enforceWriteOrdering with currentBlockEnded == true and returns block number N has already ended; cannot write more changes to it, so the very first commit fails.

This is exactly the invariant NewStateDB's godoc states ("one holding a WAL would record every block twice"), and storage_manager.go:34 still documents SC as "opened with no WAL of its own; StateDB writes it" — both are now stale.

There is a second hazard even if the write path is fixed: CommitStore.Close closes whatever WAL instance it holds and reopenWAL may replace it, while GigaStorageManager.Close also closes m.stateWAL and giga.stateDB keeps writing through its own copy of the pointer. Two owners of one WAL handle.

SC only needs the WAL so recoverSC's LoadLatest can replay; consider injecting it for the recovery window only, or moving the WAL write out of giga.stateDB so FlatKV is the single writer.

if err := m.recoverReceipt(targetHeight); err != nil {
return err
}
if err := m.truncateStateWAL(targetHeight); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] The godoc on line 19 says "A target of 0 means a fresh node, and nothing is moved", but nothing short-circuits on 0 — every destructive step below still runs.

With a target of 0 and populated stores (see the receipt-head case on line 145, or a block store at 0 while the state WAL is not):

  1. truncateStateWAL(0) calls statewal.PruneAfter(path, 0) and drops the entire state WAL;
  2. recoverSC(0) then calls sc.Rollback(0), which rollbackBaseVersion rejects (snapshot.go:611: "rollback target 0 is invalid: version 0 means no state");
  3. recoverSS(0) would likewise hit RollbackTo's target <= 0 guard.

So the node destroys its WAL and then refuses to start. Return early from OpenDBWithRecovery when the target is 0, or treat a 0 target as an error when any store is non-empty, before anything is written.

if err != nil {
return 0, fmt.Errorf("read receipt store head: %w", err)
}
target = min(target, receiptHeight)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] blockHeight and stateHeight each get a == 0 → fresh node guard on line 136, but receiptHeight does not, so an empty receipt store silently collapses target to 0 and triggers the destructive path described on line 34.

This is reachable whenever receipts are enabled on a node that already has block/state history — receipts newly turned on, or the receipt directory recreated after corruption. Either give receiptHeight == 0 the same treatment as the other two, or make "one store is at 0 while others are not" an explicit error rather than a target.

return err
}
}
if got := s.GetLatestVersion(); got < target {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[blocker] This unconditional tail bump marks the store as caught up even when the WAL never supplied the blocks.

wal.Iterator(head+1, target) only rejects endIndex > last (seiwal_impl.go:588); a startIndex below the WAL's first stored block is accepted, and the iterator just yields records from the WAL's actual floor. So when the WAL has been pruned past head+1, the loop applies [walFirst, target], leaves [head+1, walFirst) unapplied, and then SetLatestVersion(target) stamps the EVM store as being at target — silent state divergence instead of a startup failure.

The FlatKV side already does the check this is missing: CommitStore.rollbackBaseVersion reads GetStoredRange() and errors with "blocks %d-%d are needed, but the WAL only holds %d-%d" when the range is short (snapshot.go:639). Mirror that here — verify the stored range covers [head+1, target] before replaying, and drop the unconditional SetLatestVersion (or gate it on having actually consumed every block in the range).

This is load-bearing because stateDB.CommitStateChanges still has // TODO: Commit changes to SS, so the store's head only ever moves at startup and the distance it must replay grows with uptime.

if err := utils.ClonePebbleDir(src, tmp); err != nil {
return err
}
if err := os.Rename(dst, bak); err != nil && !os.IsNotExist(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.

[suggestion] An interrupted restore is indistinguishable from a fresh store, and nothing ever consumes the leftovers.

A crash between this rename and the next one leaves no dst: the next startup's openDBs creates an empty Pebble directory, GetLatestVersion() reports 0, and recoverSS classifies the store as merely behind and calls CatchUpFrom from block 1 — which, per the range issue above, silently stamps target over a store that holds almost nothing. .restore-bak and .restore-tmp are only removed by the next replacePebbleDir call, so they are never inspected on open.

Add a step on open (or at the top of restoreSnapshot) that promotes a leftover bak when dst is missing, or write a marker recovery can read. The separateDBs loop in restoreSnapshot has the same problem one level up: a failure after the first sub-DB leaves the sub-DBs at mixed versions with no record of it.

if normalizeReceiptBackend(cfg.Backend) != receiptBackendLittIdx {
return fmt.Errorf("receipt store rollback is not supported for backend %q", cfg.Backend)
}
if err := rollbackLittBodies(cfg, uint64(target)); err != nil { //nolint:gosec // target >= 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[suggestion] Bodies are dropped before the index and head are rewound, so a crash in between leaves m:latest above the newest surviving body plus tag entries pointing at receipts that no longer exist. Recovery re-derives the same target from the block/state heads on the next boot, so the common case self-heals, but this is the crash-recovery path — rewinding the head first and deleting bodies second makes the intermediate state under-promise rather than over-promise, which is the safe direction.

Comment thread sei-db/state_db/giga/state_db_impl.go Outdated
}
if err := m.openSC(ctx, m.stateWAL); err != nil {
return 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.

SC shares manager-owned WAL

High Severity

openSC now injects the manager-owned WAL into CommitStore, but StateDB still writes that same WAL and Close still closes it. Every live commit therefore writes the block twice, Close closes the WAL twice, and recoverSC rollback replaces s.wal while leaving m.stateWAL pointing at the closed instance.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78e8216. Configure here.

}
if err := m.truncateStateWAL(targetHeight); err != nil {
return 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.

Zero target still wipes stores

High Severity

A recovery target of 0 is documented as a fresh node that should not move data, but OpenDBWithRecovery still truncates the WAL and attempts rollbacks. findTargetRecoveryHeight also returns 0 when only the block store, WAL, or receipt head is empty, including genesis (GetLatestBlock reports 0).

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 78e8216. Configure here.

* main:
  Add Giga checkpoint mechanism to EVM SS (#4073)
  fix(seidb): refuse a corrupted changelog in digest replay instead of repairing it (#3983)

@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 2 potential issues.

There are 4 total unresolved issues (including 2 from previous reviews).

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 e7f0fac. Configure here.

return 0, fmt.Errorf("read receipt store head: %w", err)
}
target = min(target, receiptHeight)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Empty receipts wipe recovery target

High Severity

findTargetRecoveryHeight treats a missing block or WAL head as "nothing to converge on", but still folds a receipt head of 0 into min(). Receipts are enabled by default and can be unwritten while the block store and WAL already have height, so the target becomes 0. OpenDBWithRecovery then always calls truncateStateWAL(0), which drops every WAL block, and recoverSS cannot roll back to 0 because RollbackTo rejects that target.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e7f0fac. Configure here.

}
if err := os.Rename(tmp, dst); err != nil {
return 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.

SS restore is not crash-safe

Medium Severity

replacePebbleDir renames the live directory to .restore-bak before swapping in the clone. If that second rename fails or the process crashes between the two, the live path is gone and openDBs cannot reopen the store. RollbackTo also calls closeDBs without stopCheckpoints, so an in-flight snapshot can still be using the directories being replaced.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e7f0fac. Configure here.

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