Skip to content

State WAL replacement#3701

Open
cody-littley wants to merge 20 commits into
mainfrom
cjl/flatkv-wal
Open

State WAL replacement#3701
cody-littley wants to merge 20 commits into
mainfrom
cjl/flatkv-wal

Conversation

@cody-littley

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

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

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed✅ passedJul 9, 2026, 5:56 PM

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.77778% with 234 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.04%. Comparing base (c6ab0be) to head (bc70257).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
sei-db/seiwal/seiwal_file.go 64.85% 44 Missing and 40 partials ⚠️
sei-db/seiwal/seiwal_impl.go 77.07% 46 Missing and 34 partials ⚠️
sei-db/seiwal/seiwal_serializing.go 81.38% 24 Missing and 11 partials ⚠️
sei-db/state_db/statewal/state_wal_impl.go 82.89% 7 Missing and 6 partials ⚠️
sei-db/seiwal/seiwal_iterator.go 89.18% 7 Missing and 5 partials ⚠️
...ei-db/state_db/statewal/state_wal_serialization.go 79.48% 4 Missing and 4 partials ⚠️
sei-db/seiwal/metrics.go 75.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #3701      +/-   ##
==========================================
- Coverage   59.85%   59.04%   -0.81%     
==========================================
  Files        2278     2200      -78     
  Lines      189140   180373    -8767     
==========================================
- Hits       113202   106497    -6705     
+ Misses      65861    64500    -1361     
+ Partials    10077     9376     -701     
Flag Coverage Δ
sei-chain-pr 76.72% <76.72%> (?)
sei-db 70.41% <ø> (ø)
sei-db-state-db ?
sei-db-state-db-pr 84.78% <84.78%> (?)

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

Files with missing lines Coverage Δ
sei-db/seiwal/seiwal_config.go 100.00% <100.00%> (ø)
sei-db/state_db/statewal/state_wal_config.go 100.00% <100.00%> (ø)
sei-db/seiwal/metrics.go 75.00% <75.00%> (ø)
...ei-db/state_db/statewal/state_wal_serialization.go 79.48% <79.48%> (ø)
sei-db/seiwal/seiwal_iterator.go 89.18% <89.18%> (ø)
sei-db/state_db/statewal/state_wal_impl.go 82.89% <82.89%> (ø)
sei-db/seiwal/seiwal_serializing.go 81.38% <81.38%> (ø)
sei-db/seiwal/seiwal_impl.go 77.07% <77.07%> (ø)
sei-db/seiwal/seiwal_file.go 64.85% <64.85%> (ø)

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

@cody-littley cody-littley marked this pull request as ready for review July 6, 2026 17:35
@cody-littley cody-littley requested a review from blindchaser July 6, 2026 17:35
@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Large new persistence layer with complex crash/rollback paths; risk is contained because nothing in the storage stack calls it yet, but correctness bugs would be critical once integrated.

Overview
Introduces a new seiwal generic WAL stack (plus statewal for block-keyed state changesets) intended to eventually replace existing WAL usage across SS/SC. No production wiring in this PR—only the libraries and tests.

seiwal exposes a typed WAL[T] with monotonic index keys, async append, Flush/Prune, snapshot iterators, and optional NewWALWithRollback. The byte engine uses a single writer goroutine, CRC-framed records, mutable .wal.u files that seal to {seq}-{first}-{last}.wal, rotation by target size, and whole-file pruning (vs rewriting files). Recovery handles torn tails, orphan sealing, rollback swap remnants, and crash-safe directory/fsync semantics via existing litt/util helpers. NewGenericWAL adds a serializer goroutine and OpenTelemetry metrics (seiwal_* counters/gauges/histograms).

statewal layers block semantics on top: Write / SignalEndOfBlock (one WAL record per completed block), strict block ordering, protobuf changeset encoding, and the same flush/prune/iterator/rollback surface mapped to block numbers.

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

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

A new, well-tested, not-yet-integrated state WAL package with careful crash-recovery/durability handling; no blocking correctness issues found, but the asynchronous ownership contract for caller-supplied changesets should be documented (or copies taken), and a couple of design/efficiency notes are worth tracking before integration.

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

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor produced no second-opinion review (cursor-review.md is empty); only Codex's pass was available to merge with.
  • Iterator() unconditionally seals and rotates the mutable file whenever it holds a complete block (sealForIterator -> rotate). If the integrated read path creates iterators frequently during normal operation, this will fragment the WAL into many tiny single-block sealed files. Worth confirming iterators are only created rarely (e.g. at startup replay) or reconsidering the forced-seal-on-iterate design before wiring this in.
  • metrics.go initializes OTel counters at package-init time via must(...), which panics the whole process if meter/counter creation ever fails. Acceptable for a metrics setup, but note it is a hard init-time dependency.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

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 {

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] Write() stores the caller-owned cs []*proto.NamedChangeSet slice/pointers directly (via NewEntry) and returns before serialization happens — Serialize()/Marshal() runs later on the serializer goroutine. If the caller mutates or reuses the slice or the underlying NamedChangeSet/KVPair buffers after Write() returns, the WAL can persist data different from what was passed at the call boundary. The StateWAL.Write interface doc says the write is only "scheduled" but never states this ownership contract. Please either document that the caller must not mutate cs (or its contents) after Write() returns, or deep-copy/serialize synchronously before enqueueing. (Matches Codex's finding.)

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.

updated godoc to reflect that modification is unsafe after method call

Comment thread sei-db/state_db/statewal/state_wal_impl.go
Comment thread sei-db/state_db/statewal/state_wal_entry.go Outdated

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

This PR adds a standalone, un-integrated state WAL utility (files, framing, recovery/rollback, iterator, metrics) with strong documentation and broad unit tests. No blocking bugs in the intended single-writer path, but two edge-case robustness concerns are worth addressing before integration.

Findings: 0 blocking | 5 non-blocking | 2 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Cursor's second-opinion review (cursor-review.md) and REVIEW_GUIDELINES.md were both empty; only Codex's pass produced output, and its two findings are incorporated below.
  • Overall the package is well-structured and unusually well-commented; durability ordering (fsync-before-rename, directory syncs, contiguous-prefix/suffix guarantees on prune/rollback) and the torn-tail recovery model are carefully reasoned and covered by tests.
  • metrics.go: the package-level must(...) helper panics at init if OpenTelemetry counter construction ever fails, taking down the process at import time. This is a common and low-risk pattern for otel counters, but worth a brief acknowledgement.
  • 2 suggestion(s)/nit(s) flagged inline on specific lines.

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 {

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] The write-ordering state is validated under w.mu in enforceWriteOrdering, but the lock is released before the record is enqueued via sendToSerializer (and SignalEndOfBlock does the same at line 255). Serialization order is determined by the order of the channel sends, not by the ordering check. With concurrent callers, goroutine A can pass the check for a changeset on block N, then goroutine B can take the lock, mark the block ended, and enqueue the end-of-block marker before A enqueues its changeset — landing EOB(N) ahead of changeset(N) on disk. On replay the iterator coalesces this into a duplicated/empty-then-populated block N (mis-coalescing). Since the mu mutex exists solely to guard these single-sequence ordering fields (it implies concurrency-safety intent), either hold the lock across the enqueue or explicitly document that callers must serialize Write/SignalEndOfBlock. Not a bug for a single-writer caller, which is how the tests exercise it. (Also raised by Codex.)

payload := data[payloadStart : payloadStart+payloadLen]
recordEnd := payloadStart + payloadLen + 4
gotCRC := binary.BigEndian.Uint32(data[payloadStart+payloadLen : recordEnd])
if gotCRC != crc32.ChecksumIEEE(payload) {

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] A CRC32 mismatch always breaks and treats everything from that point on as a torn/incomplete tail that is silently discarded. That is the right behavior for the mutable file after a crash, but parseWalFileData applies it uniformly to sealed files too (via the iterator's readWalFileFromHandle). A sealed file's name encodes first-last and is trusted by scanSealedFiles/GetStoredRange, so mid-stream bit-rot in a sealed file would leave GetStoredRange reporting blocks that iteration silently skips (contents.lastCompleteBlock drops below the name's lastBlock), diverging state without surfacing corruption. Consider surfacing a checksum mismatch in a sealed file as a hard error rather than a torn tail. Rare (requires post-fsync disk corruption), hence non-blocking. (Also raised by Codex.)

Comment on lines +498 to +516
func (w *stateWALImpl) rotate() error {
index := w.mutableFile.index
first := w.mutableFile.firstBlock
last := w.mutableFile.lastCompleteBlock
sealedName, err := w.mutableFile.seal()
if err != nil {
return fmt.Errorf("failed to seal WAL file during rotation: %w", err)
}
w.sealedFiles.PushBack(&sealedFileInfo{index: index, name: sealedName, firstBlock: first, lastBlock: last})
walFilesSealed.Add(w.ctx, 1)

mutable, err := newWalFile(w.config.Path, w.nextIndex)
if err != nil {
return fmt.Errorf("failed to open new mutable WAL file during rotation: %w", err)
}
w.mutableFile = mutable
w.nextIndex++
return 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.

🟡 🟡 In rotate() (state_wal_impl.go:498), sealedFiles.PushBack happens before newWalFile succeeds, and the iteratorStartRequest branch in the writer loop (line 465) does not call w.fail(err) on rotate failure — unlike appendRecord/pruneRequest. If a rotate triggered by sealForIterator fails at newWalFile (ENOSPC/EPERM/EMFILE), w.mutableFile still points at the now-sealed file, the WAL keeps limping, and the next Iterator() call calls rotate()seal() again. seal() idempotently returns ("", nil) for the already-sealed file, so PushBack registers {index: dup, name: "", ...}. Iterators built afterward compute filepath.Join(dir, "") == dir, open the directory, and io.ReadAll on it returns EISDIR; a subsequent prune of that bogus entry runs os.Remove(dir) which fails and triggers fail(). One-line fix: call w.fail(err) on rotate failure in startIterator/sealForIterator so the WAL shuts down cleanly rather than staging bogus entries.

Extended reasoning...

What the bug is. rotate() at state_wal_impl.go:498-516 is a two-step operation: (1) seal the current mutable file and PushBack a sealedFileInfo into w.sealedFiles, (2) open a fresh mutable file via newWalFile. On step (2) failure (out of disk, permission error, EMFILE, etc.), rotate returns an error but has already committed step (1). w.mutableFile is never updated, so it keeps pointing at the just-sealed, closed walFile whose sealed=true and hasCompleteBlock=true.

Why it survives. The writer loop's iteratorStartRequest branch (state_wal_impl.go:465-466) forwards the error back to the caller via the reply channel but does not call w.fail(err) — contrast with the dataToBeWritten and pruneRequest branches, which both do. The WAL therefore keeps running with an internally-inconsistent mutableFile. Any subsequent Write would hit writeRecord's f.sealed guard (state_wal_file.go:165-167) and eventually call fail(), but between the failed rotate and the next Write, additional Iterator() calls can pile in.

How the bogus entry appears. On the next Iterator() call, sealForIterator (state_wal_impl.go:589) sees hasCompleteBlock=true, does not short-circuit, and — since size==completeSize (the common case where the caller just did SignalEndOfBlock then Iterator) — readIncompleteTail returns (nil, nil). Then rotate() is re-entered: walFile.seal() at state_wal_file.go:255-258 returns ("", nil) idempotently because f.sealed is already true, and rotate() at line 506 calls sealedFiles.PushBack(&sealedFileInfo{index: X, name: "", firstBlock: Y, lastBlock: Z}) — an entry with a duplicate index and an empty name. newWalFile may now succeed, so the WAL "recovers" while carrying this in-memory phantom.

Downstream damage. Two paths:

  1. IteratorstartIterator snapshots the sealed-files list (state_wal_impl.go:570-577), including the bogus entry. In walIterator.loadNextFile, path = filepath.Join(dir, "") evaluates to dir itself. os.Open(dir) succeeds on Linux (opens the directory), readWalFileFromHandle calls io.ReadAll on the directory fd, which returns EISDIR — surfacing as a confusing iterator error.
  2. PrunepruneSealedFiles (state_wal_impl.go:537-553) walks entries from the front. The bogus entry and the correct one share the same lastBlock; if the correct one is drained first, the bogus one becomes the front and os.Remove(filepath.Join(dir, "")) runs on the directory, fails with !os.IsNotExist(err), and triggers w.fail(err) — shutting down the WAL from a prune.

Step-by-step proof.

  1. Suppose blocks 1,2 have been written and ended; mutableFile has index=0, firstBlock=1, lastCompleteBlock=2, size==completeSize.
  2. Caller invokes Iterator(0). Writer runs sealForIteratorrotate().
  3. rotate captures index=0, first=1, last=2. seal() succeeds: renames 0.swal.u0-1-2.swal, sets f.sealed=true. Line 506 pushes {0, "0-1-2.swal", 1, 2} into sealedFiles.
  4. newWalFile(dir, 1) fails (e.g., ENOSPC). rotate returns error. startIterator returns {err: ...}. Writer loop moves on without fail().
  5. w.mutableFile still references the sealed walFile object (sealed=true, hasCompleteBlock=true). No Write has occurred.
  6. Caller invokes Iterator(0) a second time. sealForIterator proceeds. readIncompleteTail returns (nil,nil) because size==completeSize.
  7. rotate re-enters. Captures the same index=0, first=1, last=2. seal() returns ("", nil) idempotently at state_wal_file.go:256-258. Line 506 pushes {0, "", 1, 2} — bogus.
  8. newWalFile(dir, 1) succeeds this time (transient error cleared). w.mutableFile updates. startIterator returns success.
  9. The iterator's file snapshot contains both {0, "0-1-2.swal", ...} and {0, "", ...}. When loadNextFile reaches the bogus one, os.Open(dir) succeeds and io.ReadAll on the directory fd returns EISDIR — the iterator's Next() surfaces this error.

Scope / impact. Requires an initial newWalFile failure (rare — disk-related). No on-disk corruption; the bogus entry lives only in memory and does not survive restart (scanSealedFiles rebuilds from directory entries). Failure mode is loud (iterator errors, prune-triggered fail()) rather than silent data loss. The next Write on the sealed mutable file also self-heals via writeRecord's f.sealed guard, so the buggy window is between the failed rotate and the next write.

Fix. The straightforward one-liner is to call w.fail(err) when sealForIterator/rotate returns an error in the iteratorStartRequest branch, matching the appendRecord/pruneRequest branches. Alternatively, rotate() can compute the sealed name deterministically from (index, firstBlock, lastCompleteBlock) instead of trusting seal()'s return, and/or seal() can remember and return the sealed name on idempotent re-entry.

Severity: nit. Real bug, but the triggering conditions are narrow (newWalFile failure plus at least one Iterator() call before the next Write), the failure mode is loud rather than silent, no data is at risk, nothing survives restart, and the module isn't wired into SS/SC yet. Worth a robustness cleanup, not a merge blocker.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

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

A new, self-contained generic WAL package (seiwal) plus a statewal wrapper, not yet integrated. The code is unusually well-documented and well-tested, with careful crash-safety reasoning (framing/CRC, seal/rotate/prune/rollback recovery, fsync ordering, iterator read-leases). No blocking defects found; a few non-blocking notes, chiefly a latent append-ordering hazard in the state WAL and the two empty second-opinion review files.

Findings: 0 blocking | 5 non-blocking | 1 posted inline

Blockers

  • None at the file/PR level.

Non-blocking

  • Second-opinion passes produced no output: both codex-review.md and cursor-review.md are empty, so this synthesis reflects only this review.
  • seiwal.WAL is explicitly documented as single-consumer/not-safe-for-concurrent-use, but the StateWAL interface (state_wal.go) does not document any threading contract while stateWALImpl uses a mutex — implying concurrency-safety. Consider documenting StateWAL as single-threaded (matching the inner WAL) to avoid the append-reordering hazard noted inline.
  • metrics.go creates all OTel instruments at package-init via must(...), which panics (crashing the process) if instrument construction ever fails. This is a common pattern and low-risk, but worth being aware of since it runs before any error handling is possible.
  • Consider a test that reopens after an interrupted rollback swap (both same-sequence sealed files present) to directly exercise reconcileRollbackRemnants; the crash-window reasoning is only covered indirectly today.
  • 1 suggestion(s)/nit(s) flagged inline on specific lines.

w.buf = nil // hand ownership to the WAL; the next block starts a fresh buffer
w.mu.Unlock()

if err := w.wal.Append(blockNumber, changeset); 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.

[suggestion] w.wal.Append(...) runs after w.mu.Unlock() (line 104), so the inner append is not serialized by the mutex. Under concurrent callers this can reorder appends: goroutine A can finalize block N (set currentBlockEnded=true, release lock) and be preempted before its Append(N), letting goroutine B write+end block N+1 and call Append(N+1) first. The inner seiwal.WAL enforces strictly-increasing indices and would reject the late Append(N), bricking the WAL. In practice block processing is single-threaded so this is a latent hazard rather than a live bug, but since StateWAL's contract doesn't state single-threaded use (and the struct uses a mutex, implying it's safe), either hold w.mu across the Append (it only schedules, so it's cheap) or document StateWAL as single-consumer like the underlying seiwal.WAL.

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

Fix All in Cursor

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

Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit bc70257. Configure here.

Comment thread sei-db/seiwal/metrics.go
metric.WithDescription("Time spent serializing a payload in the generic WAL"),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(commonmetrics.LatencyBuckets...),
))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Metric names duplicate unit auto-suffix

Low Severity

seiwal_serialize_duration_seconds with WithUnit("s") duplicates the _seconds suffix (the exporter auto-appends it from the unit). Same issue for seiwal_serialized_bytes with WithUnit("By") duplicating _bytes. Additionally, seiwal_bytes_written with WithUnit("By") produces the awkward exported name seiwal_bytes_written_bytes since it doesn't end with _bytes and the exporter appends it. Either drop WithUnit or remove the suffix from the metric name.

Additional Locations (2)
Fix in Cursor Fix in Web

Triggered by learned rule: OTel Prometheus export: namespace prefix and unit auto-suffix naming

Reviewed by Cursor Bugbot for commit bc70257. Configure here.

Comment on lines +575 to +582
func (w *walImpl) pinLowestReadableIndex(startIndex uint64) uint64 {
pinned := startIndex
if r := w.bounds(); r.ok && r.first > pinned {
pinned = r.first
}
w.indexRefs[pinned]++
return pinned
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 In pinLowestReadableIndex (seiwal_impl.go:575-582), the clamp if r := w.bounds(); r.ok && r.first > pinned never fires when the WAL is empty at iterator creation time, so Iterator(startIndex) on a fresh WAL pins the raw startIndex (e.g. 0) into indexRefs, violating the invariant documented at lines 570-574 that pruning relies on. If the caller then appends records at higher indices and leaks the iterator (never calls Close), pruneSealedFiles breaks immediately on front.lastIndex >= reservation(0) for every file, silently wedging all future pruning and causing unbounded disk growth. One-line fix: skip w.indexRefs[pinned]++ when r.ok is false (an iterator with an empty file snapshot has nothing to protect from pruning), or track a separate hasPin flag.

Extended reasoning...

What the bug is. pinLowestReadableIndex at seiwal_impl.go:575-582 records the iterator's read lease at startIndex but clamps up to the oldest stored index only when bounds().ok is true. When the WAL is empty at iterator creation time, bounds() returns {ok: false} (no mutable records + empty sealedFiles), so the clamp branch is skipped and the raw startIndex — 0 in the common Iterator(0) case — is inserted into indexRefs. The docstring on lines 570-574 explicitly claims this clamping establishes the invariant pruneSealedFiles depends on: "a reservation never falls below the lowest stored index, so a file entirely below the lowest reservation is one every iterator has moved past." That invariant is violated for exactly this empty-WAL path.

Why existing code doesn'''t prevent it. The clamp only fires when the WAL already has data. There is no fallback for the empty case, and indexRefs is unconditionally incremented on line 580. The iterator itself is useless (its file snapshot is empty, so nextRecord yields nothing), but its pin at startIndex persists in indexRefs until Close() releases it.

Impact. In pruneSealedFiles (line 537 onward), lowestReservation() returns the smallest key of indexRefs. When a leaked iterator has pinned 0, every front file satisfies front.lastIndex >= 0 trivially (uint64), so the loop breaks immediately and nothing is ever pruned. Silent unbounded disk growth with no error surfaced. Recovery requires closing the iterator or restarting the process. This is a real defect against a documented invariant.

Step-by-step proof.

  1. Open a fresh empty WAL. sealedFiles empty, mutableFile.hasRecords false → bounds() returns storedRange{} with ok=false.
  2. Call Iterator(0). startIteratorsealForIterator is a no-op (mutable has no records), file snapshot is empty, pinLowestReadableIndex(0): pinned=0, r.ok=false → clamp skipped, w.indexRefs[0]++ sets indexRefs[0]=1. Iterator returned with empty file snapshot.
  3. Set TargetFileSize=1 and append records at indices 100, 101, 102. Each rotates into its own sealed file [100,100], [101,101], [102,102].
  4. Call Prune(1000). lowestReservation() returns (0, true). Loop iteration for front [100,100]: front.lastIndex(100) >= pruneThrough(1000)? No. hasReservation && front.lastIndex(100) >= reservation(0)? Yes — trivially, since uint64 >= 0 always holds. Loop breaks. Nothing pruned.
  5. Continue appending and pruning: same result forever, as long as the iterator is not closed. Disk grows without bound.

Fix. The one-line fix is to skip the pin entirely when the file snapshot is empty:

func (w *walImpl) pinLowestReadableIndex(startIndex uint64) uint64 {
    pinned := startIndex
    r := w.bounds()
    if !r.ok {
        return pinned  // empty snapshot has nothing to protect
    }
    if r.first > pinned {
        pinned = r.first
    }
    w.indexRefs[pinned]++
    return pinned
}

The corresponding releaseIndex call from walIterator.Close remains safe: releaseIndex on an absent key is a no-op (w.indexRefs[index] <= 1 is 0, which triggers the delete branch harmlessly).

Severity. Marking this nit: the trigger requires an atypical caller pattern (open an iterator on a fresh empty WAL, hold it across subsequent appends+prunes without closing), the failure mode is silent unbounded disk growth rather than data corruption, no test in the current suite exercises this shape, and this module isn'''t yet wired into SS/SC. Real bug against a documented load-bearing invariant, worth the one-liner before integration — but not a merge blocker.

Comment on lines +38 to +40
// Flush the WAL to disk. All data previously passed to Write() before this call will be crash durable
// after this call returns.
Flush() error

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 godoc for Flush() at state_wal.go:38-40 says "All data previously passed to Write() before this call will be crash durable after this call returns," but data buffered by Write() is only handed to the underlying WAL by SignalEndOfBlock() — a bare Write() + Flush() without an intervening end-of-block leaves the changeset in memory and lost on crash. Fix is doc-only: reword to "All data for blocks that were ended with SignalEndOfBlock before this call will be crash durable after this call returns," matching the already-correct Iterator godoc.

Extended reasoning...

What the bug is. The Flush() contract at sei-db/state_db/statewal/state_wal.go:38-40 promises: "All data previously passed to Write() before this call will be crash durable after this call returns." The implementation contradicts this.

Code path that triggers it. Three code sites in state_wal_impl.go:

  1. Write() at line 85 only appends the caller's changesets to the in-memory w.buf. It never touches the underlying WAL.
  2. SignalEndOfBlock() at line 106 is the only site that hands w.buf to the underlying WAL via w.wal.Append(blockNumber, changeset).
  3. Flush() at lines 142-144 simply delegates to w.wal.Flush() on the underlying WAL — the underlying WAL has no knowledge of w.buf.

So a Write(N, cs) followed immediately by Flush() (with no SignalEndOfBlock in between) leaves cs sitting in an in-memory slice; Flush returns nil, but nothing for block N is on disk.

Why existing code doesn't prevent it. The design intentionally excludes incomplete (unended) blocks from durability — this is what makes "only complete blocks survive a crash" a clean invariant. The Iterator godoc at the same interface already correctly states "Blocks that were never ended with SignalEndOfBlock are not yielded." The Flush godoc is simply out of sync with that design.

Step-by-step proof. Walk through the sequence in TestIncompleteBlockDiscardedOnReopen (state_wal_impl_test.go):

  1. Write(1, cs1)SignalEndOfBlock() → block 1 is Appended to the underlying WAL.
  2. Write(2, cs2)SignalEndOfBlock() → block 2 is Appended.
  3. Write(3, cs3)SignalEndOfBlock() → block 3 is Appended.
  4. Write(4, cs4)w.buf = [cs4], no Append.
  5. Flush() → underlying WAL flushes records 1, 2, 3 to disk. w.buf is untouched.
  6. Close(), reopen.
  7. GetStoredRange() reports (true, 1, 3) — block 4's data is gone, even though it was "previously passed to Write() before" the Flush call.

The same behavior is directly observable pre-crash in TestIteratorStopsBeforeIncompleteBlock (state_wal_iterator_test.go): Write(4, ...) followed by Flush() yields an iterator that does not include block 4.

Impact. Documentation-only inaccuracy. No data is at risk in the tested flow — the actual behavior is safer than the doc promises, because incomplete blocks are excluded from durability by design. However, a future caller reading the current wording could reasonably assume Write + Flush alone is sufficient for durability, then be surprised when in-progress block data disappears on crash. Worth fixing before the module is integrated into SS/SC so callers do not build atop a false guarantee.

How to fix. One-line doc change on Flush() at state_wal.go:38-40:

// Flush the WAL to disk. All data for blocks that were ended with SignalEndOfBlock before this
// call will be crash durable after this call returns.

This matches the design and the already-correct Iterator godoc.

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.

2 participants