Fix memiavl snapshot race condition - #4042
Conversation
PR SummaryHigh Risk Overview
Adds regression tests for the freeze invariant, stability under live commits, on-disk snapshot correctness, background-write channel isolation, and Reviewed by Cursor Bugbot for commit 3f67c52. Bugbot is set up for automated code reviews on this repo. Configure here. |
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
There was a problem hiding this comment.
The fix is sound: taking the write lock in Copy/SaveVersion, hashing the whole reachable set before handing nodes to a reader, raising the COW floor to version+1 when unsaved writes exist, and detaching the copy from the live tree's background-write channel together close the real races (including the pre-existing t.cowVersion write under a read lock and cloned.Close() closing the live tree's pendingChanges). Only two non-blocking notes, both on the new regression test.
Findings: 0 blocking | 4 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- None at the file/PR level.
- 2 suggestion(s)/nit(s) flagged inline on specific lines.
- 2 non-blocking pre-existing issue(s) listed below under pre-existing issues.
Pre-existing issues
- [suggestion]
Tree.Version(),Tree.IsEmpty(),Tree.SetZeroCopy()andTree.Export()(sei-db/state_db/sc/memiavl/tree.go) readt.version/t.root/t.snapshotwithout takingt.mtx, whileSaveVersion/Set/Remove/ReplaceWithnow write them under the write lock.MultiTree.buildCommitInfocallsentry.Version()on this unlocked path. It is currently serialized bydb.mtxin practice, but it leaves the last unsynchronized reads of the fields this PR otherwise locks down. - [suggestion]
Tree.StartBackgroundWrite/WaitToCompleteAsyncWrite(sei-db/state_db/sc/memiavl/tree.go:187,199) assign and nilt.pendingChangeswithout holdingt.mtx, whileCloseand nowcopyNoLockread that field under the lock — an unsynchronized field access that the race detector can flag if catchup ever overlaps a copy.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #4042 +/- ##
==========================================
- Coverage 61.25% 60.51% -0.74%
==========================================
Files 2153 2073 -80
Lines 188426 179766 -8660
==========================================
- Hits 115414 108783 -6631
+ Misses 62274 60971 -1303
+ Partials 10738 10012 -726
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
| // Hash every reachable node now, while we hold the write lock. Otherwise the | ||
| // copy's reader fills MemNode.hash in place on nodes the live tree also | ||
| // holds, and the two trees have separate mutexes to serialize with. | ||
| _ = t.rootHashNoLock() |
There was a problem hiding this comment.
could this increase EndBlock latency? Trace snapshots call Copy() before commit, so this now hashes all dirty nodes while holding the write lock
There was a problem hiding this comment.
Not a concern. Trace capture runs in EVM EndBlock, and that is before the current block is flushed into memiavl. So SnapshotSCStore → Copy() is hashing last-committed trees, not this block’s dirty nodes. rootHashNoLock() therefore stops at the root: one nil-check per tree, not a walk. MultiTree.Copy does that for each of the ~20 stores, under that tree’s write lock. The added EndBlock cost is lock acquire + an O(1) hash hit + the same shallow struct copy as before.
|
@seidroid review |
There was a problem hiding this comment.
The fix is sound: hashing the reachable set and raising the COW floor to the highest node version under the write lock, making SaveVersion one locked section, reading root+version as a pair in the snapshot writer, and detaching the copy from the live tree's background-write plumbing together close the cross-copy hole. Both previously unresolved review threads are addressed; the only remaining notes are documentation accuracy around the new hashing cost and the maxNodeVersion contract.
Findings: 0 blocking | 4 non-blocking | 2 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- None at the file/PR level.
- 2 suggestion(s)/nit(s) flagged inline on specific lines.
- 2 non-blocking pre-existing issue(s) listed below under pre-existing issues.
Pre-existing issues
- [suggestion]
Tree.Version(),IsEmpty(),SetZeroCopy()andExport()(sei-db/state_db/sc/memiavl/tree.go:97-103, 250, 380) readt.version/t.root/t.snapshotwithout takingt.mtx, whileSaveVersion/Set/Remove/ReplaceWith/Copynow write them under the write lock.MultiTree.buildCommitInforeachesentry.Version()on this unlocked path; it is serialized bydb.mtxin practice, but these are the last unsynchronized reads of the fields this PR otherwise locks down. - [suggestion]
Tree.StartBackgroundWrite/WaitToCompleteAsyncWrite(sei-db/state_db/sc/memiavl/tree.go:187, 199) assign and nilt.pendingChangesand callt.pendingWg.Addwithout holdingt.mtx, whileCloseandcopyNoLockread those fields under the lock — an unsynchronized field access the race detector can flag if catchup ever overlaps a copy.
| // Hash every reachable node now, while we hold the write lock. Otherwise the | ||
| // copy's reader fills MemNode.hash in place on nodes the live tree also | ||
| // holds, and the two trees have separate mutexes to serialize with. | ||
| _ = t.rootHashNoLock() |
There was a problem hiding this comment.
[suggestion] Copy is no longer O(1), and two callers still advertise that it is. memiavl.CommitStore.Copy (store.go:117, "returns an O(1) memiavl snapshot") and rootmulti.Store.SnapshotSCStore (sei-cosmos/storev2/rootmulti/store.go:483, "returns an O(1) SC snapshot") both reach here.
The mid-version case is the live one: rootmulti.Store.Commit runs rs.flush() (→ ApplyChangeSets) before taking rs.mtx, and SnapshotSCStore only takes rs.mtx.RLock(), so the per-block traceSnapshotCapture in EVM EndBlock can land on a tree with the whole block's dirty set unhashed. Total CPU is unchanged — GetWorkingHash/Commit would hash the same nodes moments later — but it now happens under the tree's write lock, so concurrent Get/Iterator readers block on it. Worth correcting those two doc comments so the cost isn't misread as free at the call site.
| return t.version + 1 | ||
| } | ||
|
|
||
| // maxNodeVersion returns the highest version carried by any node in the tree. |
There was a problem hiding this comment.
[suggestion] The godoc claims equality ("the highest version carried by any node") but the function returns an upper bound. With an initial version, nextVersionU32(0, initialVersion) jumps t.version from 0 to initialVersion while the nodes written in that first block carry version 1, so maxNodeVersion() returns initialVersion and no node is anywhere near it.
That's harmless here — over-freezing is the safe direction, and the property Copy actually needs is "≥ every node's version". But stating it as an exact maximum invites a later reader to reuse it where equality matters (e.g. deriving the version snapshotSource records) or to "tighten" it back down. Documenting the bound rather than the maximum keeps the safety argument the one the code relies on.
|
I will land the corruption check PR after this one lands, thanks @yzang2019 |
Summary
Tree.Copy()shares nodes with the live tree but did not freeze them. A reader of the copy and a writer on the live tree therefore shareMemNodememory with no lock in common, and a torn read ofMemNode.hashis enough to produce a leaves file that is 32 bytes short of a whole number of records. This PR makesCopy()establish the freeze before handing the tree over.The production crash that motivated the work is:
The byte pattern matches that torn-hash write. This PR does not claim to have found the production trigger, and should not be treated as a confirmed fix for that incident. On the affected node's config, every concrete path we could enumerate is closed; see "Scope" below.
The problem
Snapshots are written in the background so they don't block the chain. To do that safely,
Tree.Copy()hands the writer its own view of the tree. The two views share the same underlying nodes in memory, and the deal is: the copy is frozen, so the live chain will never touch a node the writer is still reading.Copy()was not keeping that deal. Three separate reasons:version + 1, butCopy()set its protection floor atversion, leaving any node written during the current block unprotected.DB.Copy/CommitStore.Copyreach this state:ApplyChangeSetsreleasesdb.mtxon return, so a copy taken before the followingCommitis mid-version.SaveVersion()updated the version with no lock held, so a concurrent copy could read the new version and compute a floor that protected nothing.Each leaf is written as a 16-byte header followed by its 32-byte hash.
writeLeafsizes that copy withlen(hash). A torn read of the slice header — pointer set, length still zero — yields a zero-length hash, so the writer emits the header and nothing else. That is a leaves file with remainder 16, which is the observed63201088 % 48 == 16.Why one mutex per copy is the underlying hole
RootHashand the proof builders take the write lock becauseMemNode.Hashfills the hash cache in place — that was the fix for Immunefi 83246. ButCopy()gives each tree its own mutex, so that lock only serializes hash fills within a single tree instance. Two trees sharing nodes serialize against nothing, which puts the same unsynchronized mutation right back. Freezing the nodes before handing them over is what actually closes it.Changes
Copy()now takes the write lock, hashes everything reachable before handing the tree over, and sets the protection floor to the highest version actually present in the tree instead of the last saved version.SaveVersion()is now a single locked section, so the version bump and the hashing can't be observed apart.Close()on any copy shut down the live tree's background writer and made the next async write panic on a closed channel. Copies now get their own.There is no behavior change outside the race — no existing test needed editing.
Notes for reviewers
Copy()is free on the rewrite path, since the commit already hashed everything and the call short-circuits. Only a mid-block copy does real work, and that's work the nextSaveVersionwould have done moments later anyway. The tighter freeze line avoids extra node copies rather than adding them.node.hashat the write site → remainder 16) is exact. A production path that writesnode.hashon a node the rewrite is traversing has not been found on this node's config.