Skip to content

fix(vacuum): rebuild typed-collection indexes into fresh trees (#167) - #170

Merged
mrdevrobot merged 10 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/167-vacuum-rebuild-duplicates
Oct 12, 2026
Merged

mrdevrobot merged 10 commits into
EntglDb:mainfrom
CaffeinatedCoder:fix/167-vacuum-rebuild-duplicates

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Contributor

Fixes #167.

Stacked on #169 (fix for #168). Until that merges, this PR also shows its commit. Only the last commit, fix(vacuum): rebuild typed-collection indexes into fresh trees, belongs here. Without #169, the second rebuild in a session reuses the freed pages of the first and duplicates entries again.

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.VacuumAsync already builds into fresh indexes.

Fix

  • CollectionIndexManager.RecreateIndex is split into two steps. CreateUnregisteredCopy creates an empty index with the same definition that queries don't see yet. ReplaceIndex swaps 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.
  • VacuumAsync rebuilds each index with RebuildIndexFromScratchAsync: 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.
  • Unreadable documents fail the rebuild. If a document can't be loaded or indexed, vacuum throws, the copy's pages are freed, and the existing index stays untouched. Before, skipUnreadableDocuments silently dropped those documents from the rebuilt index. Index creation still skips unreadable documents as before.

ICollectionIndex handles obtained before the vacuum point at the replaced instance afterwards. DropIndexAsync and the decimal migration already behave this way.

Tests

New in VacuumTests, all failing on main:

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-shm lock timeouts.

Not in scope

  • Spatial (R-Tree) indexes still have no CollectAllPages, so their old pages leak on each rebuild, as on DropIndex today.

🤖 Generated with Claude Code

CaffeinatedCoder and others added 2 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>
…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>
app-reviewer

This comment was marked as outdated.

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

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-Range on the old index keeps walking pages that ReplaceIndex just freed (pages without a WAL image are freed immediately). Same exposure as DropIndex/RecreateIndex today, but vacuum newly exposes it. User-held ICollectionIndex handles become silently dead after a vacuum. Please document that.
  • Crash between the rebuild commit and SaveMetadata leaks the new tree (the other window is documented in the comment).
  • DynamicCollection.VacuumAsync does 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.

@mrdevrobot
mrdevrobot dismissed app-reviewer’s stale review October 12, 2026 13:20

Posted from the wrong account by mistake; the maintainer review from @mrdevrobot supersedes this.

CaffeinatedCoder and others added 3 commits October 12, 2026 15:26
- 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>
@CaffeinatedCoder

Copy link
Copy Markdown
Contributor Author

Thanks. Both points are addressed in 50ef143. The branch also picks up the updated #169 (review fixes and main 5.2.3).

1. Failure-path leak. RebuildIndexFromScratchAsync now runs its own transaction. On failure it collects the copy's pages through that transaction before RollbackAsync, then frees them; the free is wrapped so it never masks the original exception. For this, BTreeIndex and VectorSearchIndex gain an internal CollectAllPages(ulong transactionId) overload; the public parameterless one is unchanged. New test Vacuum_RebuildIndexes_DocumentCollection_FailedRebuild_KeepsIndexAndFreesCopy:

  • It corrupts the last document of a 3,000-entry collection so the rebuild fails late.
  • It checks that the old index still answers queries.
  • It counts the pages that come back to the free list. On the previous commit that count is 1, matching your analysis; now it is the whole copy.

2. Spatial. The rebuild now skips spatial indexes until RTreeIndex.CollectAllPages exists. The old spatial test now checks that the R-Tree root is unchanged after a vacuum and still answers queries. I dropped the claim about stale spatial entries; with #172 those are removed at update/delete time anyway.

Documentation. VacuumOptions.RebuildIndexes now states three things:

  • earlier ICollectionIndex handles refer to the replaced tree and must be fetched again;
  • a query running during the swap may read pages being freed (same exposure as DropIndex / RecreateIndex);
  • spatial indexes are not rebuilt.

The crash window between the rebuild commit and SaveMetadata (it leaks the new tree) is noted here. DynamicCollection.VacuumAsync freeing old trees and reporting unreadable documents is a follow-up.

Nits:

  • The exception-masking nit is covered under point 1.
  • CreateUnregisteredCopy and ReplaceIndex now check _disposed.
  • The commit message mentions the ForceCheckpoint() → CheckpointAsync() test change.

On the helper overlap with #175: agreed. Whichever of the two lands second, I'll express CreateUnregisteredCopy through CreateUnregistered(GetIndex(name).Definition) and share Register's checks with ReplaceIndex.

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
mrdevrobot previously approved these changes Oct 12, 2026

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

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 and fresh.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>
CaffeinatedCoder and others added 2 commits October 12, 2026 18:56
…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>
@CaffeinatedCoder

Copy link
Copy Markdown
Contributor Author

Thanks. Both done in 6c4df5d.

  • Rollback in the catch: RollbackAsync is now wrapped, so a failing abort record no longer replaces the rebuild's exception, and freeing and disposing the copy always run. Test: Vacuum_RebuildIndexes_DocumentCollection_FailedRollback_KeepsOriginalErrorAndFreesCopy (a WAL whose abort-record write throws).
  • ReplaceIndex after the commit: if it throws and the copy is not the registered index, the copy's committed pages are freed and the copy is disposed. ReplaceIndex also puts the old index back when SaveMetadata fails. Before, the copy stayed registered in memory while the metadata on disk still pointed at the old tree. If only freeing the old tree fails (after the new root is persisted), the copy is in use and isn't touched. Test: Vacuum_RebuildIndexes_DocumentCollection_FailedSwap_KeepsIndexAndFreesCopy. It holds the metadata lock so the save times out, then checks the root in memory and on disk, queries, freed pages and a reopen.

Both tests fail with the fix reverted. VacuumTests passed 26/26 on four runs.

I also merged #169's latest commit (b721fee) into this branch (77052e8). Full suite on the merge: 2410 passed, 0 failed, 4 skipped.

RTreeIndex.CollectAllPages: filed as #189.

@mrdevrobot
mrdevrobot marked this pull request as ready for review October 12, 2026 18:59
@mrdevrobot
mrdevrobot merged commit 870c729 into EntglDb:main Oct 12, 2026
1 check passed
CaffeinatedCoder added a commit to CaffeinatedCoder/BLite that referenced this pull request Oct 13, 2026
… 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>
mrdevrobot pushed a commit that referenced this pull request Oct 14, 2026
#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>
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.

VacuumAsync(RebuildIndexes = true) duplicates every secondary-index entry on typed collections

3 participants