Skip to content

fix(spatial): enumerate R-tree pages so spatial indexes are freed and rebuilt (#189) - #205

Merged
mrdevrobot merged 1 commit into
EntglDb:mainfrom
CaffeinatedCoder:fix/189-rtree-collect-pages
Oct 13, 2026
Merged

mrdevrobot merged 1 commit into
EntglDb:mainfrom
CaffeinatedCoder:fix/189-rtree-collect-pages

Conversation

@CaffeinatedCoder

Copy link
Copy Markdown
Collaborator

Fixes #189.

What was wrong

RTreeIndex could not enumerate its pages. CollectionSecondaryIndex.CollectAllPages returned an empty list for spatial indexes and FreeAllPages did nothing, so:

  • VacuumAsync(RebuildIndexes = true) skipped spatial indexes (if (index.Definition.Type == IndexType.Spatial) continue;).
  • Dropping a spatial index or its collection leaked the whole tree (DropIndex → FreeAllPages no-op; FreeCollectionPagesCore skipped IndexType.Spatial with a comment that a later VACUUM would reclaim the pages, which nothing does).
  • A failed rebuild of a spatial index would have leaked its half-built copy.

Change

  • RTreeIndex.CollectAllPages() / CollectAllPages(ulong transactionId): the same walk as Search without the bounding-box filter. Starts at _rootPageId, reads each node as the given transaction sees it (transactionId == 0 → committed state), pushes pointer.PageId for every entry of a non-leaf node. A visited set guards against a corrupt parent pointer looping the walk. The transaction overload is needed because SplitNode allocates its pages outside the transaction and links them only from nodes written in it, so after a rollback they are unreachable from the committed tree. RebuildIndexFromScratchAsync already collects the copy's pages through the transaction before rolling back; it now works for spatial copies too.
  • CollectionSecondaryIndex.CollectAllPages: returns the R-tree's pages for a spatial index, so FreeAllPages, DropIndex and ReplaceIndex free them.
  • StorageEngine.FreeCollectionPagesCore: the IndexType.Spatial case builds an RTreeIndex on the stored root and collects its pages, like the B-tree and vector cases.
  • DocumentCollection.VacuumAsync: the spatial skip is removed. Spatial indexes go through the same CreateUnregisteredCopy → fill → commit → ReplaceIndex path as the other kinds (fix(vacuum): rebuild typed-collection indexes into fresh trees (#167) #170).

Tests

RTreeIndexPagesTests (new):

  • empty tree → root only;
  • 2000 points without a transaction → more pages than the minimum leaf count, no duplicates, root included, and the leaves of the collected pages hold all 2000 entries;
  • 2000 points inside a transaction → the transaction overload sees the split pages, the committed view is root-only, and stays root-only after the rollback;
  • same, committed → committed view equals the transaction view;
  • FreeCollectionPagesCore("geo_items") on a 3000-point collection returns every page of idx_spatial.

VacuumTests: Vacuum_RebuildIndexes_DocumentCollection_Spatial_IsKept is replaced by

  • ..._Spatial_RebuildsTreeAndFreesOldPages: 3000 points, rebuild → new root persisted and in use, Within still returns the expected 100 points, and every page of the old tree (collected before the vacuum) is back on the free list or queued for the next WAL truncation (AssertPagesFreedAsync, which checks the exact page set rather than a count);
  • ..._Spatial_FailedRebuild_KeepsIndexAndFreesCopy: 6000 points, last document corrupted → the vacuum throws, the stored and in-memory root are unchanged, Within still works, and the copy's pages are freed (AssertCopyWasFreedAsync). CorruptLastDocumentAsync was generalised over the collection type for this.

Mutation check: with RTreeIndex.CollectAllPages present but the three wiring edits reverted, the rebuild, failed-rebuild and drop-collection tests fail and the four tree-level tests pass. Full suite: 2416 passed, 4 skipped.

Not covered here

  • DynamicCollection.VacuumAsync(RebuildIndexes) builds fresh trees for every index kind but frees none of the old ones, and DynamicCollection.DropIndex removes the entry without freeing any pages, for B-tree and vector indexes as well as spatial. That is a separate change on the dynamic side; this PR gives it the R-tree walk it would need.

🤖 Generated with Claude Code

… rebuilt (EntglDb#189)

RTreeIndex had no CollectAllPages, so CollectionSecondaryIndex.CollectAllPages
returned nothing for a spatial index and FreeAllPages was a no-op. As a result
VacuumAsync(RebuildIndexes) skipped spatial indexes, dropping a spatial index or
its collection leaked every page of the tree, and a failed rebuild would have
leaked its half-built copy.

Add RTreeIndex.CollectAllPages() and CollectAllPages(ulong transactionId): walk
from the root, read each node as the given transaction sees it, and follow the
child pointers of non-leaf entries. The transaction overload matters because
SplitNode allocates pages outside the transaction and links them only from
nodes written in it, so after a rollback they cannot be found from the
committed tree.

Wire it into CollectionSecondaryIndex.CollectAllPages, the drop-collection
path in StorageEngine.FreeCollectionPagesCore, and remove the spatial skip in
DocumentCollection.VacuumAsync.

Tests: RTreeIndexPagesTests (empty tree, split tree, transaction view before
and after rollback/commit, drop-collection frees spatial pages) and two
VacuumTests replacing Spatial_IsKept (rebuild frees every old page; failed
rebuild keeps the old tree and frees the copy).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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

Thanks. CollectAllPages walks every non-leaf entry from the fixed root, including nodes emptied by #172's Delete, the seen set prevents double counting, and the transactional overload is what RebuildIndexFromScratchAsync's failure path needs. Probe on the merged state: 2,000 points, half deleted, 5 rounds of VacuumAsync(RebuildIndexes = true) with a checkpoint after each: the file stayed at 239 pages, the spatial tree went from 16 to 5 pages and stayed there, and Within/Near returned identical results every round. Dropping the spatial index and creating a B-Tree reused 5 of its pages with no corruption, also after reopening. Both new VacuumTests fail on main; full suite merged 2463 / 0 / 4 skipped.

Approving. One fix: src/BLite.Core/VacuumOptions.cs:44 still says "Spatial indexes are not rebuilt." Please remove or update it.

Nits: a remark in the CollectAllPages doc that callers hold the collection lock; a debug assert when seen hides a cycle. DynamicCollection not freeing old index pages on rebuild and drop is now #211.

@mrdevrobot
mrdevrobot merged commit 82deef3 into EntglDb:main Oct 13, 2026
2 checks passed
@CaffeinatedCoder

Copy link
Copy Markdown
Collaborator Author

The review follow-ups are in #218: the VacuumOptions sentence is corrected, the CollectAllPages doc now says which lock each caller holds, and a page reached twice now hits Debug.Fail. Noted #211.

CaffeinatedCoder added a commit to CaffeinatedCoder/BLite that referenced this pull request Oct 13, 2026
…ckfill

Conflict in DocumentCollection.VacuumAsync: main (EntglDb#205) removed the
spatial skip now that R-trees enumerate their pages; this branch passes
the index object to RebuildIndexFromScratchAsync. Kept both: every index,
spatial included, is rebuilt through RebuildIndexFromScratchAsync(index).

Also drops the CreateUnregistered remark that a spatial index cannot
enumerate its pages, which EntglDb#205 made obsolete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Production — b0eb49ab Deployed Oct 12, 2026 by CaffeinatedCoder via build_and_publish #665
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.

RTreeIndex: implement CollectAllPages so spatial indexes can be freed and rebuilt

2 participants