Repository navigation
fix(storage): discard pages from a WAL another process has truncated (#197) - #201
CaffeinatedCoder wants to merge 8 commits into
Conversation
…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
left a comment
There was a problem hiding this comment.
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.mdand 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
TryTruncateWalCoreAsynccall is redundant: with only it reverted, all 7 tests still pass.
…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>
- 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>
|
Thanks. Addressed in 829b471, after merging
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 |
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
_walIndexafter 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 secondBeginTransactionfrom Data loss in multi-process mode: pages replayed before another process truncated the WAL are written back over newer data #197:CheckpointAsyncnever caught up before writing.RollbackTransactionAsyncappended 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
TryCatchUpWithSharedWal).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._walIndexonly 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.CheckpointAsyncin multi-process mode._commitLockand 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._commitLock, so it uses the same path without taking that lock again._commitLockalone: 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.BeginTransactioncan wait up toReadTimeoutMs), plus that a checkpoint makes commits wait for its page writes.MULTI_PROCESS_WAL.mdlists the new field.Tests
MultiProcessWalTruncationTests, with two engines on the same files. All seven fail on b721fee (#169 as merged):I disabled each part in turn and checked what fails:
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:
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.Corebuilds with no warnings. The branch merges withmainand every open PR without conflicts.Not changed here
PrepareTransactionAsyncand the direct commit paths still append without the SHM writer lock.RecoverAsyncon open still truncates without it.🤖 Generated with Claude Code