Repository navigation
fix(storage): don't skip WAL records past a torn one in multi-process replay (#198) - #202
CaffeinatedCoder wants to merge 11 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
… 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
left a comment
There was a problem hiding this comment.
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.
…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>
|
Thanks. #201's new head is merged in (dae4638); changes in 9596837:
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 |
Fixes #198.
Problem
WriteAheadLog.ReadCommittedPagesSincestops, 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.TryCatchUpWithSharedWalstill 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
SeekToEndwent to the file's end. Every replay that started before the torn bytes then stopped there.Changes
ReadCommittedPagesSincereports whether its records ended exactly at the requested end. If they didn't (it stopped early or ran past the end),TryCatchUpWithSharedWalreturnsfalseand the bookmark stays. The engine then neither counts the records as replayed nor truncates the WAL, until a replay gets through.SeekToSharedEnd, under the writer lock).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.Corebuilds with no warnings.Limits
PrepareTransactionAsyncand the direct commit paths still write without the writer lock. Recovery on open still truncates without it.🤖 Generated with Claude Code