State WAL replacement#3701
Conversation
|
The latest Buf updates on your PR. Results from workflow Buf / buf (pull_request).
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #3701 +/- ##
==========================================
- Coverage 59.85% 59.06% -0.79%
==========================================
Files 2278 2203 -75
Lines 189140 180599 -8541
==========================================
- Hits 113202 106665 -6537
+ Misses 65861 64541 -1320
+ Partials 10077 9393 -684
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
PR SummaryMedium Risk Overview The on-disk format uses sealed/mutable files, CRC-framed records, a directory Adds Reviewed by Cursor Bugbot for commit ed97f93. Bugbot is set up for automated code reviews on this repo. Configure here. |
| if err := w.enforceWriteOrdering(blockNumber); err != nil { | ||
| return fmt.Errorf("write rejected: %w", err) | ||
| } | ||
| if err := w.sendToSerializer(dataToBeSerialized{entry: NewEntry(blockNumber, cs)}); err != nil { |
There was a problem hiding this comment.
Write keeps caller-owned changesets and serializes them later. Because Write returns after enqueueing, callers can mutate or reuse cs before serializerLoop runs, causing the WAL to persist changed data or race with the caller
There was a problem hiding this comment.
This pattern is intentional for performance, but not well documented.
I updated all godocs to say that it is unsafe to modify slices passed into methods or slices received from methods.
| index: index, | ||
| serialize: func() ([]byte, error) { return s.serialize(data) }, | ||
| } | ||
| if err := s.submit(req); err != nil { |
There was a problem hiding this comment.
+1. two implementation notes if we add the gate here: (1) the gate needs to be seeded from inner.Bounds() at construction (same as newWAL does for lastAppendIndex), otherwise the first append after a reopen goes unchecked; (2) TestGenericWALAppendOrdering currently asserts the async-brick behavior as expected, so its assertions need to flip to a synchronous rejection.
| // from an interrupted rollback and seals any unsealed file left behind by a prior session. After it returns, | ||
| // every record lives in a sealed file whose name matches its content, with no orphans remaining. Shared by | ||
| // the WAL constructor and the offline GetRange/PruneAfter utilities, so all three run the same sanity pass. | ||
| func recoverDirectory(path string) error { |
There was a problem hiding this comment.
a second wal instance or an offline utility opens the same path while one is live may corrupts the log
can we add a FileLock to the dir?
There was a problem hiding this comment.
added file locks
| resp := w.startIterator(m.startIndex) | ||
| m.reply <- resp | ||
| if resp.err != nil { | ||
| w.fail(resp.err) |
There was a problem hiding this comment.
Iterator construction failure bricks the entire write path, and hard-link support becomes a hard filesystem requirement
should we distinguish the different failure cases? like for MkdirAll/os.Link error, just return error instead of bricking the entire write path
There was a problem hiding this comment.
Iterator failures are no longer fatal for failure modes that do not lead to corrupted state.
| } | ||
|
|
||
| // Write accumulates a set of changes for the given block number in memory. | ||
| func (w *stateWALImpl) Write(blockNumber uint64, cs []*proto.NamedChangeSet) error { |
There was a problem hiding this comment.
the nil changeset check is performed asynchronously in state_wal_serialization.go. As a result, Write() and potentially SignalEndOfBlock() can return successfully even when the input contains a nil changeset. The serialization goroutine discovers the error later and bricks the WAL, so the error surfaces at an unrelated subsequent operation.
can we validate nil entries synchronously in Write() ? so the invalid input is rejected at the call site without bricking the WAL.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit ed97f93. Configure here.
There was a problem hiding this comment.
A well-documented, thoroughly tested new WAL utility package (seiwal) and a statewal wrapper; the code is not yet integrated and is carefully engineered for crash durability and recovery. The only code finding is a low-severity uint64 overflow in the contiguous-index validation.
Findings: 0 blocking | 5 non-blocking | 1 posted inline
Blockers
- None at the file/PR level.
Non-blocking
- Cursor's review (cursor-review.md) is empty — that second-opinion pass produced no output.
- REVIEW_GUIDELINES.md (from the base branch) is empty, so no repo-specific review standards were applied.
- seiwal_iterator.go:290 acknowledges (in a comment) that locating the start index is a linear scan over a fully-loaded file; fine for now, but worth revisiting if startup replay from a deep offset becomes a hotspot, as the comment notes.
- Overall this is high-quality, self-contained utility code with strong test coverage; the main integration/correctness risk will land in the follow-up PRs that wire it into SS/SC.
- 1 suggestion(s)/nit(s) flagged inline on specific lines.
| "append rejected: index %d is not greater than last appended index %d", | ||
| index, w.lastAppendIndex) | ||
| } | ||
| } else if index != w.lastAppendIndex+1 { |
There was a problem hiding this comment.
[nit] uint64 overflow edge case in the contiguous check: when lastAppendIndex == math.MaxUint64, lastAppendIndex+1 wraps to 0, so an Append(0, ...) is incorrectly accepted as "contiguous", producing a record with an inverted index relative to the file's existing max. The writer-side backstop at appendRecord (line 489, m.index != w.lastWrittenIndex+1) has the identical wrap. Reaching it only requires the baseline (first) append to use a near-max index rather than 2^64 total records. Realistically block numbers won't approach 2^64, so severity is low, but a cheap explicit guard (reject once the max index is reached) in both the caller-side and writer-side checks would harden it. (Also raised by Codex.)
There was a problem hiding this comment.
Beyond the inline findings, I also checked two other candidate issues and ruled them out: walImpl.Close()'s per-call closeErr local means a second Close() call after a failed first one would return nil — but Close() is only ever expected to be called once, so this isn't reachable in practice. And walImpl.Append advancing lastAppendIndex/hasAppended before sendToWriter succeeds is fine because sendToWriter only fails once the WAL is already closed or bricked, at which point no retry could succeed anyway.
Extended reasoning...
This run's two inline findings (the serializing/StateWAL layers still bricking on non-fatal iterator snapshot errors that walImpl itself now tolerates, and a narrow EOF-vs-shutdown race in the iterator) are the substantive output of this pass. I additionally reviewed the two candidate issues surfaced by finder agents and confirmed they are not real bugs, as noted in the message.
| sei-db/ledger_db/block/blocksim/bin/ | ||
| sei-db/db_engine/litt/bin/ No newline at end of file | ||
| sei-db/db_engine/litt/bin/ | ||
| sei-db/seiwal/walsim/bin/ No newline at end of file |
There was a problem hiding this comment.
it seems there is no such folder
| w.hasAppended = true | ||
|
|
||
| record := frameRecord(index, data) | ||
| if err := w.sendToWriter(dataToBeWritten{record: record, index: index}); err != nil { |
There was a problem hiding this comment.
what happens if the w.sendToWriter() fails but lastAppendIndex and hasAppended already advances. should we move w.lastAppendIndex = index / w.hasAppended = true before w.sendToWriter?

Describe your changes and provide context
This WAL is intended to replace all existing WALs in both SS and SC. Instead of having multiple WALs for multiple different services, we can maintain just a single WAL which is used universally across all parts of the storage stack.
This PR does not integrate the changes, it just creates a WAL utility. Integration work will happen in future PRs. We've got a pre-existing WAL, but that implementation has some serious issues. Namely, the WAL library we currently use has very inefficient garbage collection (it rewrites garbage collected files, as opposed to dropping files as a whole when data in file is all stale).
Testing performed to validate your change
unit tests