Repository navigation
fix(storage): don't reuse freed pages that still have a WAL image (#168) - #169
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>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks for this, the deferred-free approach is the right one and the tests show it fixes #168 (3 of the 5 new tests fail on main). Full suite is green locally on macOS apart from the 3 known .wal-shm failures. Two things need to change before merging, plus two hardening items.
1. A page freed by a still-open transaction is released before that transaction's image reaches the WAL (StorageEngine.Memory.cs:92-139).
FreePage correctly queues a page that an open transaction has in _walCache, but OnWalTruncated releases everything queued at the next truncation, whether or not that transaction has committed yet. Sequence:
txn.WritePage(P);FreePage(P)→ queued.CheckpointAsync()→_walIndexempty,HasPendingFreePages→TryTruncateWalAsync→OnWalTruncated→PageFile.FreePage(P).txn.CommitAsync()→ the old image of P is now in the WAL and_walIndex.AllocatePage()returns P,WritePageImmediate(P, fresh),ReadPage(P)returns the old image. Same bug as #168. The next checkpoint also writes the old image over the Free header while P sits on the free list, breaking the free-list chain.
This is reachable from an ordinary B-Tree delete: BTreeIndex.Delete writes the leaf through the transaction and then MergeWithSibling calls _storage.FreePage(rightNodeId) (BTreeIndex.cs:1335-1341, :1661). Any checkpoint (size-triggered, explicit, backup) between the merge and the commit hits it. I reproduced it with a small test on this branch.
Fix: in OnWalTruncated (runs under _commitLock, so no promotion can race) keep pages that are still in any _walCache queued, and release them after the truncation that follows their commit. Please add a test for this sequence.
2. Multi-process: truncating with an empty local _walIndex can discard other processes' un-replayed WAL records (StorageEngine.Recovery.cs:41-50, :178-224).
Before, CheckpointAsync returned at once when _walIndex was empty, so a process that never replayed anything never truncated. Now every FreePage queues in multi-process mode, so any process with a queued free and an empty _walIndex, including on Dispose, takes the SHM writer lock and truncates the shared WAL. TryTruncateWalAsync never checks that this process has replayed up to _shm.ReadWalEndOffset(). If process B committed and A disposes before its next BeginTransaction, B's records are gone from the WAL, and if B crashes before its own checkpoint, committed data is lost.
Fix: skip the truncation unless _shm.ReadWalEndOffset() <= _lastKnownWalEndOffset (or require checkpointed offset >= WAL end).
3. (hardening) HasWalImage can observe neither cache nor index during promotion. GroupCommit.cs:196-198, Transactions.cs:223-227, :372-377 do _walCache.TryRemove(txn) and then _walIndex[page] = .... Promote into _walIndex first, then remove from _walCache.
4. (hardening) OnWalTruncated aborts on the first failing PageFile.FreePage and loses the rest of the batch (Memory.cs:137-138); the exception escapes after the WAL is already truncated. Note that after #178 on main, PageFile.FreePage throws InvalidDataException for an id past EOF, so this matters more after a rebase. Catch per page and re-queue.
Merge status: no conflicts with main (5.2.3). #178 is complementary, it does not address #168.
Nits: Recovery.cs:46 uses ConfigureAwait(false) while :127 does not; the early-return path skips the Checkpoint metric event (fine, worth a comment); SnapshotPendingFreePages copies the set on every GetCollectionPageIds.
Posted from the wrong account by mistake; the maintainer review from @mrdevrobot supersedes this.
- 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>
|
Thanks for the careful review and the repro. All four points are addressed in 7ae1bbd. I also merged 1. Open transaction released early.
Both fail on the previous commit. 2. Multi-process truncation. 3. Promotion order. Group commit and both direct commit paths now write 4. Per-page release. Each Nits:
Full suite on macOS: 2400 passed, 0 failed, 4 skipped. 🤖 Generated with Claude Code |
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks, points 1, 3 and 4 are fixed and I checked that each new test fails with its fix reverted. Full suite passes here too (2400 / 0 / 4 skipped). One gap left in point 2.
_lastKnownWalEndOffset can be ahead of what this process has actually replayed. Group commit sets it to the new WAL end under the SHM lock (GroupCommit.cs ~L222-228), even when another process appended records after this process's last replay. Sequence:
- B begins a transaction; its bookmark is X.
- A commits; the SHM WAL end is now Y.
- B commits; group commit sets B's bookmark to Z, past A's records, which B never replayed.
- B's
_walIndexdrains; at the next checkpointReadWalEndOffset() == _lastKnownWalEndOffset, so the new check passes and B truncates the WAL holding A's committed record.
I reproduced it with a scratch test: after b.CheckpointAsync() the WAL went from 16,435 bytes to 0, and B read A's page as zeros. A's commit then lives only in A's memory until A checkpoints.
The bookmark overwrite predates this PR, but it defeats the new check. Fix options: in ProcessBatchAsync, after taking the SHM lock, replay [lastKnown, shmEnd) before appending; or only advance the bookmark when the SHM end at lock time equals lastKnown. Please add a test for begin B, commit A, commit B, checkpoint B.
Not blocking, for awareness: PrepareTransactionAsync writes WAL records without _commitLock, so a truncation can drop records of a prepared 2PC transaction. Worth an issue. Nit: the empty-index path at Recovery.cs ~L47 dropped ConfigureAwait(false). Library code should keep it; I'd add it to the other call site rather than remove it here.
…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>
|
Thanks. Fixed in b721fee. Bookmark gap (point 2): I went with replaying before appending. Both replay paths now share one helper,
Tests, in
ConfigureAwait: restored on the empty-index call site and added on the other 2PC: filed as #188. Full suite: 2402 passed, 0 failed, 4 skipped. |
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks, and good catch on the stale append position. I verified b721fee:
- The begin B / commit A / commit B / checkpoint B scenario now keeps A's commit (WAL 16,435 → 32,870 → 0, both pages read back correctly). The same probe fails on 7ae1bbd and on
main. - Each new test fails with its fix reverted: the catch-up before appending,
SeekToEnd, and the old_walOffsetsskip in the replay helper. - Lock order is
_commitLockthen the SHM writer lock everywhere,BeginTransactionnever takes the SHM lock, and nothing in Core calls it while holding_commitLock. Single-process mode is unchanged, with no new lock on the hot path. - Full suite 2402 / 0 / 4 skipped; the MultiProcess tests passed 5 runs in a row with no hangs; a 2-engine stress probe with interleaved checkpoints kept every commit (7ae1bbd lost one). netstandard2.1 builds clean, no conflicts with
main.
Approving. One line in the docs would help: in multi-process mode BeginTransaction can now wait up to ReadTimeoutMs when another process has committed and a local group commit holds _commitLock.
While testing I found four multi-process defects. None comes from this PR; all exist on main too. Could you file them, alongside #188?
- Data loss, please file it as such. After another process truncates the WAL, entries this engine replayed earlier stay in
_walIndex. Sequence: A commits P = 0x11; B begins and replays it; A commits P = 0x22, checkpoints and truncates; B begins again, seesshmEnd < lastKnownand resets the bookmark but keeps the stale entry; B checkpoints and writes 0x11 over 0x22 on disk. A fresh engine then reads 0x11.TryCatchUpWithSharedWal(Transactions.cs:79-80) should drop the replayed entries when it detects a truncation, or better, compare a WAL generation counter kept in the SHM. - A replay that stops early still advances the bookmark.
ReadCommittedPagesSince(WriteAheadLog.cs:786-831) breaks without error on a torn record, and the helper still returns true and sets the bookmark toshmEnd. With a torn record left by a crashed writer, a later commit's record is skipped and then truncated by the next checkpoint. Reproduced with a hand-truncated record. Fix: have the parser report whether it reachedtoOffsetand treat a short read as failure. - Plausible: the catch-up in
BeginTransactionreads the WAL without the SHM writer lock, so a concurrent truncate-and-append can make it parse new bytes at old offsets and skip records. The generation counter from 1 would cover this too. - Plausible: opening a database with a non-empty WAL runs
RecoverAsync, which truncates the shared WAL without the SHM lock and without resetting the SHM end. SinceAdvanceWalEndOffsetis monotonic, other processes then stop seeing new records.
|
Thanks for the thorough verification. Filed the four multi-process defects:
#197 and #199 both point at the SHM generation counter you suggested. On the docs line about |
…#170) * fix(storage): don't reuse freed pages that still have a WAL image (#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> * fix(vacuum): rebuild typed-collection indexes into fresh trees (#167) VacuumAsync(RebuildIndexes = true) re-inserted every document into the existing index without clearing it, so each rebuild duplicated every B-Tree entry and kept the stale spatial and vector entries from #164. Each index is now rebuilt into an empty copy that queries don't see. Only after the rebuild committed is it swapped in, its root persisted, and the old tree freed. A document that cannot be loaded or indexed fails the rebuild and leaves the existing index untouched, instead of being silently skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(storage): address review of deferred page frees (#168) - 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 #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> * fix(vacuum): address review of index rebuild (#167) - A failed rebuild now frees the whole half-built copy: the rebuild runs its own transaction and collects the copy's pages through it before the rollback, so pages added by splits (allocated outside the transaction, linked only from nodes written in it) are found. Freeing never masks the original exception. - Spatial indexes are skipped by the rebuild: RTreeIndex cannot enumerate its pages, so replacing the tree would leak the old one on every vacuum. - CreateUnregisteredCopy and ReplaceIndex check for disposal. - VacuumOptions.RebuildIndexes documents that earlier index handles refer to the replaced tree, that a query running during the swap may read pages being freed, and that spatial indexes are not rebuilt. BTreeIndex and VectorSearchIndex gain an internal CollectAllPages overload that walks the tree as a transaction sees it. The spatial vacuum test now checks that the R-Tree is kept; a new test covers the failure path (one page freed before, the whole copy now). The previous commit message also switched two tests from ForceCheckpoint() to CheckpointAsync(). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(vacuum): make the failed-rebuild leak test deterministic (#167) The test corrupted the document with the largest ObjectId, which is not always the last entry in primary-key byte order, so the rebuild sometimes failed early with a small copy. It also read the free-list high-water mark from GetCollectionPageIds (which leaves out queued frees) and could query the corrupted document's age. It now corrupts the last primary-index entry, reads NextPageId from the page file, queries a different age, and counts pages still queued for the next WAL truncation as freed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(vacuum): keep the original error and free the copy when a rebuild cannot finish (#167) - RebuildIndexFromScratchAsync: a failing RollbackAsync no longer replaces the rebuild's exception or skips freeing and disposing the copy. - If ReplaceIndex fails after the copy committed, the copy's pages are freed unless the swap already went through. - ReplaceIndex restores the old index when the metadata cannot be saved, so memory and disk keep agreeing and the caller still owns the copy. - Tests for both paths (abort record write fails; metadata save times out). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(storage): replay other processes' WAL records before a commit (#168) Address review of #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> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #168.
Problem
ReadPageprefers a page's committed image in the WAL over the page file, and recovery replays that image over the page file.FreePageput a page straight back on the free list. When it was reused and initialized withWritePageImmediate(a new B-Tree root, for example), reads kept returning the old page until the next checkpoint. Dropping and recreating an index made forward range scans follow the old tree's leaf chain and return entries several times (263, 264, 248, 242 entries for 200 documents in the #168 repro).Fix
StorageEngine.FreePagenow checks whether the page has a WAL image: it is in the WAL index, was checkpointed since the last WAL truncation (the record is still in the WAL file and would be replayed), or is written by an open transaction.Supporting changes:
GetCollectionPageIdsskips queued pages. In single-file modeRebuildFreeSpaceIndexdecides which pages are free by scanning forPageType.Data, and a queued page still carries its old contents on disk.SchemaEvolutionTests.InsertAfterOrphanCollectionIsDropped_DoesNotRouteToFreedPagesfails without this filter.DropCollectionFilediscards queued frees for that collection's file. The slot keeps its number, so releasing them later would free pages of a recreated file.Disposecheckpoints only when frees are queued, so a clean close doesn't leak them. It runs on the thread pool so a capturedSynchronizationContextcannot deadlock. A crash still leaks queued pages, the same trade-offRecreateIndexalready documents.CheckpointAsyncreturned immediately when the WAL index was empty, so it never truncated. When frees are queued it now runs the truncation step. That step moved unchanged intoTryTruncateWalAsync.FreeCollectionPagesCoreno longer removes freed pages from the WAL index. This was the partial in-memory fix for the same problem; it had no effect on replay.Tests
FreePageReuseTestsadds five tests:Disposeis reused after reopening.The first two fail on
main. The other three cover the queue's edge cases.Full suite on macOS: 2384 passed, 3 failed, 4 skipped. The 3 failures are the known
MultiProcessWalSharedMemoryTests.wal-shmlock timeouts, which also fail without this change.BLite.NetStandard21.Testscould not run locally because the .NET 6 runtime isn't installed;BLite.Corebuilds for netstandard2.1 without warnings.Not in scope
FreePageis still not transactional. B-Tree merges and overflow-chain frees release the page even if the transaction rolls back.WritePageImmediateoutside the free-and-reuse case can still be shadowed by a stale image.This unblocks #167: rebuilding into a fresh index frees the old tree and allocates the new one in the same session.
🤖 Generated with Claude Code