Skip to content

fix(storage): don't skip WAL records past a torn one in multi-process replay (#198) - #202

Open
CaffeinatedCoder wants to merge 11 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/198-torn-wal-record
Open

CaffeinatedCoder wants to merge 11 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/198-torn-wal-record

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #198.

Stacked on #201. Only the last commit (fix(storage): don't skip WAL records past a torn one in multi-process replay) belongs here. Both change TryCatchUpWithSharedWal, so basing this on main would conflict with #201. I'll merge main in once #201 lands.

Problem

WriteAheadLog.ReadCommittedPagesSince stops, without an error, at a record it can't read: a short read, an implausible size, a decryption failure, an unknown type or a zero byte. TryCatchUpWithSharedWal still reported success and moved the bookmark to the SHM end. So the engine skipped every record after the unreadable one, which could include other processes' commits. Its next checkpoint could then truncate the WAL that still held them, and with #201's discard on truncation those commits are gone for good.

How a torn record gets into the middle of the WAL: a writer dies part-way through a batch. Its bytes stay in the file beyond the published WAL end, and the next writer appended after them, because SeekToEnd went to the file's end. Every replay that started before the torn bytes then stopped there.

Changes

  • Reader: ReadCommittedPagesSince reports whether its records ended exactly at the requested end. If they didn't (it stopped early or ran past the end), TryCatchUpWithSharedWal returns false and the bookmark stays. The engine then neither counts the records as replayed nor truncates the WAL, until a replay gets through.
  • Writer:
    • The group commit appends at the published SHM WAL end and cuts off anything beyond it (SeekToSharedEnd, under the writer lock).
    • Those bytes belong to a batch that was never published. A commit is acknowledged only after the SHM end has moved past it and the locks are released, so nothing acknowledged is dropped.
    • Without the cut-off, the torn bytes would block every replay that reaches them, and no process could truncate the WAL again.
    • It falls back to the end of the file, as before, when the published end lies beyond it, or inside an encrypted WAL's crypto header.

Tests

Two new tests in MultiProcessWalTruncationTests. Both fail on the parent commit (#201), and each fails when its own half of the fix is removed:

  • TornBatchOfACrashedWriter_IsCutOff_SoTheNextCommitIsReplayed: unpublished partial bytes of a write record are appended to the WAL, then A commits. B must replay that commit, checkpoint and truncate the WAL, and every page must survive. Without the cut-off, B's replay stops at the torn bytes and B can't truncate.
  • UnreadableRecordInsideTheWal_StopsTheReplay_WithoutLettingTheReaderTruncate: the first of A's records is corrupted. B commits and checkpoints its own page, and the WAL must stay as it is, so A's pages and B's page all survive A's checkpoint. Without the check, B truncates the WAL and A's later records are lost.

The full suite passes (2419 passed, 0 failed, 4 skipped), and so do BLite.NetStandard21.Tests (20/20). The 196 WAL, multi-process and encryption tests passed three runs in a row. BLite.Core builds with no warnings.

Limits

🤖 Generated with Claude Code

CaffeinatedCoder and others added 7 commits October 12, 2026 12:55
…tglDb#168)

ReadPage prefers the committed WAL image over the page file, and recovery
replays it over the page file. FreePage put a page straight back on the free
list, so when it was reused and initialized with WritePageImmediate (e.g. a
new B-Tree root), reads kept returning the old page until the next
checkpoint. Dropping and recreating an index made range scans follow the old
tree's leaf chain and return entries several times.

A freed page that is in the WAL index, was checkpointed since the last WAL
truncation, or is written by an open transaction is now queued and returned
to the free list right after the next truncation. Every other page is freed
immediately, as before. Queued pages are skipped by GetCollectionPageIds,
discarded when their collection file is dropped, and released by a
checkpoint on Dispose so a clean close does not leak them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- OnWalTruncated keeps a queued page that an open transaction has written:
  its image reaches the WAL only at that commit, so the page is released
  after the truncation that follows. Before, a B-Tree merge followed by a
  checkpoint before the commit released the page early (same failure as
  EntglDb#168).
- Multi-process: TryTruncateWalAsync skips the truncation while the shared
  WAL holds records this process has not replayed, so an empty local
  _walIndex can no longer discard another process's committed records.
- Commit promotion writes _walIndex before removing the transaction from
  _walCache (group commit and both direct paths), so a page is never in
  neither while FreePage looks for its WAL image.
- OnWalTruncated releases page by page and keeps a page whose release
  throws queued for the next truncation, instead of losing the rest of the
  batch.

Tests for the open-transaction sequence (storage level and through a real
B-Tree merge) and for the multi-process truncation; all three fail without
these changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tglDb#168)

Address review of EntglDb#169: in multi-process mode, group commit advanced
_lastKnownWalEndOffset past records another process had appended since
this engine's last replay, so the truncation check passed and a
checkpoint could drop that process's committed records.

- Group commit catches up with the shared WAL under _commitLock and the
  SHM writer lock before appending: it replays [bookmark, SHM end) and
  only advances the bookmark past its batch if that replay succeeded.
- Group commit appends at the current end of the WAL file instead of at
  this engine's stale stream position, which overwrote records other
  processes had appended (WriteAheadLog.SeekToEnd).
- BeginTransaction replays through the same helper, under _commitLock,
  and the newest image of a page in the replayed range now replaces one
  this engine committed itself; it used to be skipped, leaving a stale
  image in _walIndex that a checkpoint wrote back before truncating.
- Restore ConfigureAwait(false) on both TryTruncateWalAsync call sites.
- Tests: begin B, commit A, commit B, checkpoint B; and a page committed
  by B then by A, replayed and checkpointed by B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ntglDb#197)

Multi-process mode lost committed data once another process had checkpointed
and truncated the shared WAL:

- Pages an engine had replayed (or read through the SHM page index) stayed in
  its _walIndex. Its reads returned them, and its next checkpoint wrote them
  over the newer images on disk (EntglDb#197). That needed no further transaction:
  CheckpointAsync never caught up first.
- Truncation was detected only as "SHM end < bookmark". Once the new WAL grew
  past the old bookmark, the engine replayed it from that offset, skipped its
  first records, believed itself caught up and truncated them away.
- RollbackTransactionAsync appended an advisory abort record outside the
  commit path, without the SHM writer lock and at the engine's own, possibly
  stale, write position. After another process's truncation that left a gap of
  zeros in the shared WAL, which ended other processes' replay early, so they
  skipped later commits and truncated them.

Changes:
- The SHM header gets a WAL generation (offset 48, previously reserved; files
  without it read 0), incremented as the last step of every truncation, under
  the writer lock.
- The catch-up compares it with the generation the engine's state belongs to.
  On a change (or an end before the bookmark) it drops everything it holds from
  the old WAL, which the truncating process had checkpointed, and replays the
  new WAL from its start. Without the writer lock (BeginTransaction), a
  truncation during the replay discards its result.
- A page read through the SHM index is cached only if no truncation or discard
  happened meanwhile (generation and a local epoch, under a small lock).
- In multi-process mode CheckpointAsync catches up and writes its pages under
  _commitLock and the SHM writer lock, so it holds the newest image of every
  page it writes and no other process can commit or truncate meanwhile. The
  flush runs after the locks are released. Backup, which already holds
  _commitLock, uses the same path without taking it again.
- Backup truncates through the same path as a checkpoint (SHM writer lock, no
  truncation over records other processes have not replayed, new generation)
  instead of truncating the WAL under _commitLock alone.
- In multi-process mode the abort record is no longer written. Replay and
  recovery ignore it; only transactions with a commit record are applied.
- README notes that BeginTransaction may wait up to ReadTimeoutMs and that a
  checkpoint makes commits wait for its page writes; the SHM layout in
  MULTI_PROCESS_WAL.md lists the new field.

Tests (MultiProcessWalTruncationTests): the EntglDb#197 sequence, the same without a
second transaction, a WAL regrown past the old bookmark, an own commit after
another process's truncation, a rollback after a truncation, and 25 seeded
random interleavings of commits, empty and rolled-back transactions, reads and
checkpoints on two engines, plus a truncation by a backup. All seven fail on
b721fee.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fter-truncation

# Conflicts:
#	src/BLite.Core/Storage/StorageEngine.Recovery.cs
#	src/BLite.Core/Storage/StorageEngine.Transactions.cs
… replay (EntglDb#198)

WriteAheadLog.ReadCommittedPagesSince stopped without an error at a record it
could not read (a short read, an implausible size, a decryption failure, an
unknown type or a zero byte). TryCatchUpWithSharedWal then still reported
success and moved the bookmark to the SHM end, so the engine skipped every
later record, possibly other processes' commits, and its next checkpoint
could truncate the WAL that held them.

Such a record ended up in the middle of the shared WAL whenever a writer died
part-way through a batch. Its bytes stay in the file beyond the published WAL
end, and the next writer appended after them (SeekToEnd went to the file's end).

- ReadCommittedPagesSince reports whether its records ended exactly at the
  requested end. If not, TryCatchUpWithSharedWal returns false: the bookmark
  stays, so the engine neither skips the records nor truncates the WAL until a
  replay gets through.
- The group commit appends at the published SHM WAL end and cuts off anything
  beyond it (SeekToSharedEnd, under the writer lock). Those bytes belong to a
  batch that was never published, and so never acknowledged. Otherwise the
  torn bytes would block every replay that reaches them, and no process could
  truncate the WAL again. It falls back to the end of the file if the
  published end lies beyond it, or inside an encrypted WAL's header.

Tests: a crashed writer's torn batch followed by a commit (B replays it and
truncates; fails without the cut-off), and an unreadable record inside the WAL
(B must neither skip A's records nor truncate; fails without the check). Both
fail on the parent commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mrdevrobot mrdevrobot left a comment

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.

Reviewed only f38949d. The reader side is right: ReadCommittedPagesSince now reports whether it reached toOffset, and on an incomplete scan the bookmark stays put. #198 reproduces on main, and both new tests fail with their part reverted. Filtered multi-process tests passed 5 runs in a row with no hangs; full suite merged with main 2466 / 0 / 4 skipped.

Two things:

1. The cut-off deletes acknowledged commits (WriteAheadLog.cs:486-502, SeekToSharedEnd / SetLength(sharedEnd), called from StorageEngine.GroupCommit.cs:171). It removes everything past the SHM end, which includes records written by CommitTransactionAsync(ulong) and MarkTransactionCommittedAsync (they never publish the end) and prepared records from PrepareTransactionAsync. "A commit is acknowledged only after the SHM end has moved past it" doesn't hold for those paths. Repro with a single engine and AllowMultiProcessAccess: commit P = 0x44 through the direct path, commit Q through group commit, close, reopen: P reads as 0. It passes on main and #201 and fails here. With PrepareTransactionAsync + MarkTransactionCommittedAsync, the commit record can survive while its write records are cut. Same fix as on #201: gate those paths in multi-process mode or route them through the locked append-and-publish sequence (#188).

2. A permanently unreadable record inside the published range stalls the WAL. Torn bytes from a crashed writer sit past the SHM end, so the cut-off handles them. A bad record before the end (disk corruption, or a #188 direct write at a stale offset) makes every process whose bookmark is behind it fail every catch-up, skip every checkpoint and never truncate; the WAL and _walIndex grow without bound and nothing reports it. You note the limit; please surface it through a log or metric and file an issue. Related: after #200's unlocked RecoverAsync truncation, the SHM end stays past the file end, so every replay is incomplete until the WAL grows past it (a stall, not a loss).

This needs to rebase onto main after #201 lands.

CaffeinatedCoder and others added 4 commits October 13, 2026 15:37
…glDb#201)

- Direct commit (CommitTransactionAsync(ulong)) and two-phase commit
  (both PrepareTransactionAsync overloads, MarkTransactionCommittedAsync)
  throw NotSupportedException in multi-process mode. They append without
  the SHM writer lock, without catching up and without publishing the WAL
  end (EntglDb#188), so another process could truncate past their records and
  this engine would then discard them. Nothing in the library calls them;
  Transaction.CommitAsync uses group commit.
- A checkpoint that cannot take its locks or catch up with the shared WAL
  publishes the Checkpoint metric with Success = false; the new
  MetricsSnapshot.CheckpointsFailedTotal counts these, and they no longer
  count towards CheckpointsTotal. A failed catch-up is traced as a warning
  (the first, then every power of two in a row).
- WalSharedMemory.Open no longer re-initialises a well-formed SHM with
  another page size or reader count while it shows signs of use (WAL end
  offset, live writer or reader PID); it throws instead. Otherwise the
  re-initialised header keeps NextTransactionId and starts a new WAL
  generation instead of resetting it to 0.
- Backup no longer calls TryTruncateWalCoreAsync a second time: the
  checkpoint truncates, and whatever stays in the WAL is copied with it.
- README and MULTI_PROCESS_WAL.md: every process must run a version later
  than 5.2.3; the gated commit paths; the configuration-mismatch rule.

Tests: the review's direct-commit sequence, the 2PC paths in multi-process
and single-process mode, a checkpoint blocked by the writer lock, and SHM
re-opening with another page size while in use and while idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al-record

# Conflicts:
#	tests/BLite.Tests/MultiProcessWalTruncationTests.cs
- The cut-off past the published WAL end cannot delete acknowledged
  commits any more: since the EntglDb#201 review fix, the direct commit path and
  two-phase commit, which never published the end, throw
  NotSupportedException in multi-process mode, so group commit is the only
  writer and publishes everything it acknowledges.
- A catch-up stopped by an unreadable record inside the published range is
  reported: ReadCommittedPagesSince also returns where it stopped, and the
  failure goes through ReportFailedCatchUp (trace warning naming the WAL
  and the record's offset; the first, then every power of two in a row;
  from CatchUpStallThreshold = 16 in a row the warning calls the replay
  stalled). The generation check now runs first, so a truncation racing an
  unlocked replay is not reported. Checkpoints that fail this way count in
  MetricsSnapshot.CheckpointsFailedTotal.
- MULTI_PROCESS_WAL.md: the cut-off and the reporting of unreadable records.

Tests: the review's repro (direct commit P, then group commit Q, close,
reopen: the direct path throws and nothing lies past the published end),
and a record corrupted inside the published range (warnings rate-limited,
one says stalled, checkpoint reported as failed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@CaffeinatedCoder

Copy link
Copy Markdown
Contributor Author

Thanks. #201's new head is merged in (dae4638); changes in 9596837:

  1. Cut-off vs. direct/2PC commits: I confirmed your repro (direct P, group Q, close, reopen → P reads 0).
  2. Stall: ReadCommittedPagesSince also returns where it stopped, and an incomplete catch-up is now reported like a failed one.

Both tests fail with their part reverted. The multi-process filter passed 11 runs with no hangs. Full suite: 2471 passed / 3 failed / 4 skipped; the 3 failures are lock timeouts under parallel load and pass in isolation. I'll rebase onto main once #201 lands.

This branch had an error being deployed

1 failed deployment
Production — 9596837a Deployed Oct 13, 2026 by CaffeinatedCoder via build_and_publish #690
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-process WAL catch-up: a replay that stops at a torn record still advances the bookmark past later commits

2 participants