Skip to content

fix(storage): discard pages from a WAL another process has truncated (#197) - #201

Open
CaffeinatedCoder wants to merge 8 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/197-mp-replay-after-truncation
Open

CaffeinatedCoder wants to merge 8 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/197-mp-replay-after-truncation

Conversation

@CaffeinatedCoder

@CaffeinatedCoder CaffeinatedCoder commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #197. Also covers #199.

In multi-process mode, committed data was lost once another process had checkpointed and truncated the shared WAL. Working on #197 turned up three more ways to the same loss (the third, through the backup path, is described under Changes), one of them much more common than the sequence in #197. A randomized two-engine stress run, described below, lost commits in 76 of 200 runs on #169's code and in none with this PR.

What went wrong

  1. Data loss in multi-process mode: pages replayed before another process truncated the WAL are written back over newer data #197: pages from the old WAL. Pages an engine had replayed, or read through the SHM page index, stayed in its _walIndex after another process checkpointed and truncated the WAL. Its reads returned them, and its next checkpoint wrote them over the newer images on disk. This does not even need the second BeginTransaction from Data loss in multi-process mode: pages replayed before another process truncated the WAL are written back over newer data #197: CheckpointAsync never caught up before writing.
  2. A WAL that grew past the old bookmark. Truncation was only detected as "SHM end < bookmark". Once the new WAL had grown past the old bookmark, the engine replayed it from that offset and skipped its first records. It then counted itself caught up, checkpointed, and truncated those records away.
  3. Abort records written at a stale position. RollbackTransactionAsync appended an abort record outside the commit path: no SHM writer lock, and at the engine's own write position, which another process's appends or truncation can make stale. After a truncation that left a gap of zeros in the shared WAL, and the replay of every other process stopped at the gap (the "end of written area" check). They skipped the commits after it, counted themselves caught up, and truncated those commits. Every transaction disposed without a commit writes such a record, read-only ones included, so this was the most frequent case in the stress run. It isn't on the Multi-process WAL: PrepareTransactionAsync and the direct commit paths bypass the commit and cross-process writer locks #188 list.

Changes

  • WAL generation in the SHM header.
    • Offset 48, in bytes that were reserved, so the version and size stay the same; existing files read 0. Every truncation increments it as its last step, under the writer lock, after resetting the end offset.
    • Processes running an older release don't increment it. Their truncations are still caught by the "end < bookmark" check, but not once the WAL has regrown.
  • Catch-up (TryCatchUpWithSharedWal).
    • If the generation differs from the one the engine's state belongs to, or the end is before the bookmark, it drops everything it holds from the old WAL and replays the new one from its start. This is safe because the truncating process had checkpointed every record, including this engine's own commits: a commit replays the WAL, and so notices a truncation, before it appends.
    • Without the writer lock (BeginTransaction), a truncation during the replay discards the result. That covers Multi-process WAL catch-up in BeginTransaction reads the WAL without the SHM writer lock #199.
  • Reads through the SHM page index. Such a page is cached in _walIndex only if neither the generation nor a local discard counter changed while it was read. Both are checked under a small lock, which only the read-from-WAL path takes. Otherwise a read that started before a discard could put an old image back.
  • CheckpointAsync in multi-process mode.
    • It catches up and writes its pages under _commitLock and the SHM writer lock, in the same order as the commit path. So it writes the newest image of every page, and no other process can commit or truncate while it does.
    • Commits in all processes wait for the page writes; the flush runs after the locks are released.
    • Backup already holds _commitLock, so it uses the same path without taking that lock again.
    • Single-process mode is unchanged.
  • Backup. In multi-process mode the backup truncated the WAL under _commitLock alone: no SHM writer lock, no reset of the shared end offset, and no check for records other processes hadn't replayed yet. That was another route to the Data loss in multi-process mode: pages replayed before another process truncated the WAL are written back over newer data #197 loss. It now truncates through the same path as a checkpoint, and so does the checkpoint the backup runs first.
  • Abort records. In multi-process mode the abort record is no longer written. Replay and recovery ignore it, since only transactions with a commit record are applied.
  • Docs.

Tests

MultiProcessWalTruncationTests, with two engines on the same files. All seven fail on b721fee (#169 as merged):

  • the Data loss in multi-process mode: pages replayed before another process truncated the WAL are written back over newer data #197 sequence;
  • the same without a second transaction in B, checkpointing straight away;
  • a WAL that regrew past B's bookmark: B begins without reading, then checkpoints, and a fresh engine must still find the new WAL's first record;
  • B's own commit after A truncated: it is kept, and A's newer page isn't overwritten;
  • a truncation by A's backup: B then drops its replayed page;
  • a rollback in A after B truncated, followed by a commit from A, which must survive B's checkpoint;
  • 25 seeded random interleavings of 300 steps (commits, empty and rolled-back transactions, reads checked against the last commit, checkpoints from either engine). A fresh engine must read the last committed value of every page.

I disabled each part in turn and checked what fails:

Part disabled Tests that fail
Generation comparison in the catch-up the regrown-WAL test
Discarding the old WAL's pages the #197 sequence, the variant without a second transaction, the own-commit test
Catching up before a checkpoint writes the variant without a second transaction
Incrementing the generation on truncation the regrown-WAL test
Skipping the abort record the rollback test, the random interleavings
Backup and its checkpoint truncating through the coordinated path the backup test

The read-through cache guard and the "truncated during an unlocked replay" check close races that need a truncation at an exact instruction, so no deterministic test covers them.

Stress run, 200 seeds × 300 steps, same harness as the last test:

Code Runs that lost commits Pages lost Stale reads inside a transaction
#169 (b721fee) 76 106 528
b721fee plus only skipping the abort record 26 31 314
this PR 0 0 0

The multi-process, free-page, SHM, checkpoint, backup and rollback tests (91) passed repeated runs with no hangs. Full suite: 2409 passed, 0 failed, 4 skipped; on current main (with #169 and #170): 2417 passed, 0 failed, 4 skipped. BLite.NetStandard21.Tests: 20/20. BLite.Core builds with no warnings. The branch merges with main and every open PR without conflicts.

Not changed here

🤖 Generated with Claude Code

CaffeinatedCoder and others added 6 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

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

Thanks. The generation counter in the reserved SHM bytes is a clean way to detect truncation without a version bump, and both old readers (ignore offset 48) and old SHM files (generation 0) are handled. Discarding all of _walIndex is safe for group commits, because truncation only happens when the truncating process has replayed and checkpointed everything published. #197 reproduces on main, and each part of the fix is covered by a test that fails when it's reverted. Lock order is consistent, BeginTransaction takes no new lock, and single-process mode is untouched. Full suite merged with main 2464 / 0 / 4 skipped. This also addresses #199.

One change: the discard turns commits from the direct paths into lost commits (StorageEngine.Transactions.cs:132, with CommitTransactionAsync(ulong) at :277 and MarkTransactionCommittedAsync at :451). Those paths don't catch up and don't publish the SHM end (#188), so "a commit replays the WAL before appending" only holds for group commit. Sequence: B commits P = 0x44 through CommitTransactionAsync(ulong); A commits Q, checkpoints and truncates without ever seeing P; B begins, discards, and reads the old P. The commit is gone. On main B kept P and its own checkpoint wrote it. I have a probe that passes on main and fails here. Please either land #188 first, or throw NotSupportedException from the direct and 2PC paths when AllowMultiProcessAccess is set.

Also:

  • A failed catch-up silently skips the checkpoint (StorageEngine.Recovery.cs:80): nothing is written, not even this engine's own pages, there's no log, and the Checkpoint metric still reports success. Please report it as unsuccessful and emit a warning or metric.
  • Mixed versions keep the old bugs: older processes don't bump the generation or discard, so #197 still happens in a mixed deployment. Please document in MULTI_PROCESS_WAL.md and the README that every process must run this version or later.
  • InitializeHeader (page size or max-readers mismatch) resets the generation under live processes, which they read as a truncation. Configuration error only, but worth a guard.
  • The backup's second TryTruncateWalCoreAsync call is redundant: with only it reverted, all 7 tests still pass.

CaffeinatedCoder and others added 2 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>
CaffeinatedCoder added a commit to CaffeinatedCoder/BLite that referenced this pull request Oct 13, 2026
- 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. Addressed in 829b471, after merging main in de840ae:

  • Direct and 2PC paths: I reproduced your sequence on 590174d: B reads 0x11 instead of 0x44.
    • In multi-process mode, CommitTransactionAsync(ulong), both PrepareTransactionAsync overloads and MarkTransactionCommittedAsync now throw NotSupportedException before writing anything. The transaction stays usable and can still commit through group commit.
    • Nothing in the library calls these paths (Transaction.CommitAsync uses the (ulong, CancellationToken) group-commit overload), so I went with the gate rather than routing them. 2PC can't be routed without redefining what a prepare guarantees, which stays with Multi-process WAL: PrepareTransactionAsync and the direct commit paths bypass the commit and cross-process writer locks #188.
    • Tests: DirectCommitPath_IsRejected_SoAnotherProcessesTruncationCannotLoseIt (your sequence), TwoPhaseCommitPaths_AreRejected and DirectAndTwoPhaseCommitPaths_StillWork_InSingleProcessMode.
  • Failed catch-up / checkpoint: a checkpoint that can't take its locks or catch up now publishes the Checkpoint metric with Success = false.
    • A new MetricsSnapshot.CheckpointsFailedTotal counts these; they no longer count in CheckpointsTotal or the average latency.
    • A failed catch-up is also traced as a warning (Trace.TraceWarning, as in the free-list fix): the first, then every power of two in a row.
    • Test: CheckpointThatCannotTakeTheWriterLock_IsReportedAsFailed.
  • Mixed versions: README and MULTI_PROCESS_WAL.md now say every process must run a version later than 5.2.3, and explain why.
  • InitializeHeader: a well-formed SHM with another page size or reader count is re-initialised only if it shows no sign of use (WAL end 0, no live writer or reader PID). Otherwise Open throws InvalidOperationException.
    • When it is re-initialised, the generation is incremented instead of reset, because a reset could later repeat a generation an attached process still holds. NextTransactionId is kept.
    • Known limit: an idle process attached to an empty WAL leaves no trace, so a reader-count mismatch with one can't be detected. StorageEngine always uses the default count.
    • Tests: ShmOpenedWithAnotherPageSize_WhileInUse_ThrowsAndKeepsTheHeader, ..._WhenIdle_IsReinitialisedWithoutGoingBack.
  • Backup: the second TryTruncateWalCoreAsync call is removed. The only case it covered (empty _walIndex, non-empty WAL) is safe, because backup copies the WAL.

Each new test fails with its part reverted. The multi-process filter passed 5 runs in a row with no hangs. Full suite: 2466 passed / 4 failed / 4 skipped. The 4 failures are lock-timeout TimeoutExceptions in single-process tests under parallel load, and they pass in isolation, except CrossCollectionWriteRaceTests.Concurrent_Inserts_Into_Different_Collections_LoseNothing, which is also flaky on main here (2 of 6 runs).

This branch was successfully deployed

1 active deployment
Production — 829b4712 Deployed Oct 13, 2026 by CaffeinatedCoder via build_and_publish #689
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.

Data loss in multi-process mode: pages replayed before another process truncated the WAL are written back over newer data

2 participants