Repository navigation
fix(vacuum): rebuild typed-collection indexes into fresh trees (#167) - #170
mrdevrobot merged 10 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>
…Db#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 EntglDb#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>
mrdevrobot
left a comment
There was a problem hiding this comment.
Reviewed only 56e1651. The fix for #167 is correct and the 5 new tests cover it (23/23 in VacuumTests, full suite green apart from the known .wal-shm failures). Merges cleanly with main (5.2.3). One thing to fix, one to decide.
1. The failure path leaks every page of the half-built tree except the root (DocumentCollection.cs:1603-1617).
RebuildIndexFromScratchAsync calls RebuildIndexAsync, which rolls the transaction back on exception (:1641), and only then calls fresh.FreeAllPages(_storage). BTreeIndex.CollectAllPages (BTreeIndex.cs:1970-1996) walks the committed view, which after the rollback is just the empty root written by WritePageImmediate in the constructor. Split pages allocated via _storage.AllocateIndexPage() (BTreeIndex.cs:921, :984, non-transactional) are never freed. Same for VectorSearchIndex.CollectAllPages. So "the copy's pages are freed" holds only for the root. Collect the page ids before the rollback with the rebuild transaction id (e.g. a transactionId parameter on CollectAllPages/FreeAllPages, called in the catch before RollbackAsync), and add a test for the failure path; none of the new tests exercise it.
2. Spatial rebuild now leaks a full R-Tree per vacuum. You mention it under "not in scope", but before this PR the tree was reused, so for spatial users this is a new unbounded leak with no reclaim path. Either skip spatial indexes in the rebuild until RTreeIndex.CollectAllPages exists, or document it on VacuumOptions.RebuildIndexes and in the CHANGELOG. I'd prefer the former.
Worth noting for the description, not blocking:
- Readers never take
_collectionLock; a reader mid-Rangeon the old index keeps walking pages thatReplaceIndexjust freed (pages without a WAL image are freed immediately). Same exposure asDropIndex/RecreateIndextoday, but vacuum newly exposes it. User-heldICollectionIndexhandles become silently dead after a vacuum. Please document that. - Crash between the rebuild commit and
SaveMetadataleaks the new tree (the other window is documented in the comment). DynamicCollection.VacuumAsyncdoes not free old trees either and skips unreadable documents silently; a follow-up to align both would be good.
Nits: if fresh.FreeAllPages throws in the catch it masks the original exception; ReplaceIndex/CreateUnregisteredCopy don't check _disposed like CreateIndex; the two ForceCheckpoint() → CheckpointAsync() test edits aren't mentioned in the commit message; the message says the stale spatial entries from #164 are fixed but the old R-Tree leaks.
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>
- 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>
|
Thanks. Both points are addressed in 50ef143. The branch also picks up the updated #169 (review fixes and 1. Failure-path leak.
2. Spatial. The rebuild now skips spatial indexes until Documentation.
The crash window between the rebuild commit and Nits:
On the helper overlap with #175: agreed. Whichever of the two lands second, I'll express Full suite on macOS: 2406 passed, 0 failed, 4 skipped. 🤖 Generated with Claude Code |
…b#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>
mrdevrobot
left a comment
There was a problem hiding this comment.
Both points are fixed. I checked the failure path against #169's deferred-free queue: after the rollback the copy's pages have no WAL image, so they go straight back to the free list, and nothing shared with the old tree is walked. The leak test fails with the fix reverted, and it isn't flaky: over 8 runs it got 66 to 77 reused pages against a threshold of 20. 28/28 in Vacuum, full suite 2406 / 0 / 4 skipped.
Approving. It still has to land after #169.
Two small things in RebuildIndexFromScratchAsync, fine here or in a follow-up:
await transaction.RollbackAsync()in the catch isn't wrapped. If writing the abort record throws, that exception replaces the original one and the free andfresh.Dispose()are skipped._indexManager.ReplaceIndex(name, fresh)runs after the commit and outside the try. If it throws, the committed copy's pages leak.
Please also open an issue for RTreeIndex.CollectAllPages so spatial rebuild can come back.
… cannot finish (EntglDb#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>
…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. Both done in 6c4df5d.
Both tests fail with the fix reverted. I also merged #169's latest commit (b721fee) into this branch (77052e8). Full suite on the merge: 2410 passed, 0 failed, 4 skipped.
|
… registration (EntglDb#171) Address review of EntglDb#175 on top of EntglDb#170: - ApplyIndexBuilder fills the new index through the same helper as the vacuum rebuild (FillUnregisteredIndexAsync): its own transaction, pages collected through it before the rollback, then freed. Before, a fill that failed after splits freed only the root, so every failing open leaked the rest of the partial tree and grew the file. - PublishFilledIndex hands a filled index to Register or ReplaceIndex and frees it if it did not get registered; used by both callers. - CollectionIndexManager: Register and ReplaceIndex share PublishLocked, which restores the previous registration (or none) when the metadata cannot be saved, and refreshes the unique-index flag. Register skipped that refresh, so a unique index added by the model was not enforced until the next open. - CreateUnregisteredCopy is gone; callers use CreateUnregistered with the existing definition, which now checks for disposal. Tests: a fill that fails on the last of 3000 documents does not grow the file over repeated opens; a failed metadata save leaves the index unregistered and frees its pages; a unique model index is enforced in the session that adds it. All three fail without these changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#175) * fix(index): fill model-declared indexes from existing documents (#171) An index declared in OnModelCreating was created empty when the collection already held documents, so queries routed to it missed every older document. This hit the usual way of adding an index: a new app version that adds HasIndex, HasVectorIndex or HasSpatialIndex to an existing collection. ApplyIndexBuilder now builds a missing index unregistered, fills it from the primary index, and only then registers and persists it. A crash before that leaves no empty index in the metadata, and the next open builds it again. If a document cannot be loaded or indexed, the open fails with an InvalidOperationException naming the index, and nothing is persisted. Existing indexes are still a cheap name lookup on every open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(index): address review of model-declared index backfill (#171) - The error when an existing document cannot be indexed now says that nothing was saved and that every open fails the same way until the documents can be indexed or the index is removed from OnModelCreating, and includes the underlying message. - Removes CollectionIndexManager.EnsureIndexUntyped and the public CreateVectorIndexUntyped / CreateSpatialIndexUntyped, which BuildDefinition made dead code. - The CreateUnregistered doc comment notes that a spatial index cannot release its pages yet. Opening a context whose model declares a new index over documents it cannot index now throws; before, the index was created empty. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(index): address follow-ups on model-declared index backfill (#171) - Restore the public CreateVectorIndexUntyped and CreateSpatialIndexUntyped on CollectionIndexManager, unchanged but marked [Obsolete]; the internal EnsureIndexUntyped stays removed. - The backfill failure message ends the inner message with exactly one full stop before "Nothing was saved". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(index): free a partially built model index and roll back a failed registration (#171) Address review of #175 on top of #170: - ApplyIndexBuilder fills the new index through the same helper as the vacuum rebuild (FillUnregisteredIndexAsync): its own transaction, pages collected through it before the rollback, then freed. Before, a fill that failed after splits freed only the root, so every failing open leaked the rest of the partial tree and grew the file. - PublishFilledIndex hands a filled index to Register or ReplaceIndex and frees it if it did not get registered; used by both callers. - CollectionIndexManager: Register and ReplaceIndex share PublishLocked, which restores the previous registration (or none) when the metadata cannot be saved, and refreshes the unique-index flag. Register skipped that refresh, so a unique index added by the model was not enforced until the next open. - CreateUnregisteredCopy is gone; callers use CreateUnregistered with the existing definition, which now checks for disposal. Tests: a fill that fails on the last of 3000 documents does not grow the file over repeated opens; a failed metadata save leaves the index unregistered and frees its pages; a unique model index is enforced in the session that adds it. All three fail without these changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(index): migrate legacy decimal indexes into a copy and free it on failure (#171) MigrateLegacyDecimalIndexes replaced the index with an empty tree (RecreateIndex) and then filled it in place. A rebuild that failed after splits rolled back and left those pages linked from nowhere, and the next open freed only the root of the emptied tree, so every failing open leaked the partial tree and grew the file. The legacy tree was also gone after the first failure. The migration now uses the same helpers as model indexes and the vacuum rebuild: it fills an unregistered copy (FillUnregisteredIndexAsync) set to the current key format and swaps it in with ReplaceIndex through PublishFilledIndex, so the new format and root are one metadata save. On failure the copy is freed and the old index stays registered in its old format with its data, and the next open retries. RecreateIndex has no callers left and is removed. Test: a legacy-format decimal index over 3000 documents whose last one cannot be read; four failing opens do not grow the file, the stored index keeps its root and legacy format, and the migration succeeds once the document is repaired. Fails without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Fixes #167.
Problem
VacuumAsync(RebuildIndexes = true)on a typed collection re-inserted every document into the existing index without clearing it. Each rebuild duplicated every B-Tree entry and kept the stale spatial and vector entries from #164 next to the current ones.DynamicCollection.VacuumAsyncalready builds into fresh indexes.Fix
CollectionIndexManager.RecreateIndexis split into two steps.CreateUnregisteredCopycreates an empty index with the same definition that queries don't see yet.ReplaceIndexswaps it in, persists its root page, then frees the old tree.RecreateIndex, used by the decimal key-format migration, now calls both and behaves as before.VacuumAsyncrebuilds each index withRebuildIndexFromScratchAsync: fill the copy, commit, and only then swap it in. Queries keep using the old index until the swap, and the collection write lock blocks writers for the whole rebuild.skipUnreadableDocumentssilently dropped those documents from the rebuilt index. Index creation still skips unreadable documents as before.ICollectionIndexhandles obtained before the vacuum point at the replaced instance afterwards.DropIndexAsyncand the decimal migration already behave this way.Tests
New in
VacuumTests, all failing onmain:VacuumAsync(RebuildIndexes = true)duplicates every secondary-index entry on typed collections #167 repro).The last two make a rebuild a workaround for #164 until that is fixed.
Full suite on macOS: 2389 passed, 3 failed, 4 skipped. The 3 failures are the known
MultiProcessWalSharedMemoryTests.wal-shmlock timeouts.Not in scope
CollectAllPages, so their old pages leak on each rebuild, as onDropIndextoday.🤖 Generated with Claude Code