Repository navigation
fix(index): fill model-declared indexes from existing documents (#171) - #175
Conversation
…lDb#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>
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks. The fix follows the decimal-migration precedent and is correct: index name resolution matches the old EnsureIndex/CreateVectorIndexUntyped/CreateSpatialIndexUntyped paths, the thread-pool wait can't deadlock (no _collectionLock or manager lock held, CreateUnregistered takes none), metadata has no entry until Register, read-only opens are no worse than before, and the existing-index early return is actually fewer writes than before. Full suite green on macOS apart from the known .wal-shm failures; merges cleanly with main (5.2.3) and with #170. A few things before merging.
- Failing the open needs better guidance (
DocumentCollection.cs:1086-1094). The most likely real-world hit isHasIndex(..., unique: true)added over data that already has duplicates (BTreeIndex.cs:107), and the app then cannot start at all. The message says "it is built again on the next open", but it will fail on every open until the model or the data changes, and the only repair is removing theHasIndextemporarily. Please reword the message, add a CHANGELOG entry for the behaviour change (open can now throw and can take long on big collections), and consider an opt-in to skip unindexable documents or a public rebuild entry point. Please also add a test for the unique-over-duplicates case. - Overlap with #170. #170 adds
CreateUnregisteredCopy(name)/ReplaceIndex(name, fresh), this oneCreateUnregistered(definition)/Register(index). They merge without conflicts but leave four near-duplicate helpers. Whichever lands second should expressCreateUnregisteredCopyasCreateUnregistered(GetIndex(name).Definition)and shareRegister's validation.BuildDefinitionalso makesCreateVectorIndexUntyped/CreateSpatialIndexUntyped/EnsureIndexUntypeddead code; please remove them. FreeAllPagesis a no-op for R-Tree (CollectionSecondaryIndex.cs:639-648), so the failure path leaks spatial pages; the doc comment inCreateUnregisteredpromises a release that doesn't happen for spatial. Adjust the comment (pre-existing limitation).
Noted, not for this PR: an existing index with a changed definition (unique flag, vector dimension) is silently kept, before and after; and in multi-process mode two processes opening the same new model both backfill and the second SaveMetadata wins. Both pre-existing, worth separate issues.
Posted from the wrong account by mistake; the maintainer review from @mrdevrobot supersedes this.
) - 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>
|
Thanks. Addressed in 1c04a62; the branch also merges 1. Guidance when the open fails. The message now includes the underlying error, says that nothing was saved, and says that every open fails the same way until the documents can be indexed or the index is removed from On the cases you raised:
2. Overlap with #170. Whichever lands second, I'll express 3. Spatial The two pre-existing problems you noted (a changed definition silently kept, and two processes both backfilling) look worth separate issues; I can file them. Full suite on macOS: 2397 passed, 0 failed, 4 skipped. 🤖 Generated with Claude Code |
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks, all three points are addressed. I confirmed the #176 claim: a unique HasIndex over duplicates opens without error on this branch, so the test belongs on top of that fix. The whole solution builds and the full suite passes (2397 / 0 / 4 skipped). Approving, with two small asks.
- Keep the two public methods, marked obsolete.
CollectionIndexManager<TId,T>ispublic sealedwith a public constructor over public types, soCreateVectorIndexUntypedandCreateSpatialIndexUntypedare reachable, even if unlikely. Nothing in the repo uses them, but please keep them with[Obsolete("Use model HasVectorIndex/HasSpatialIndex or CreateIndex(definition); will be removed in 6.0")]. Removing the internalEnsureIndexUntypedis fine. - Message punctuation. The inner message has no trailing full stop, so it reads
...Expected 4, got 3 Nothing was saved, .... Use{ex.Message.TrimEnd('.')}.or move the inner message to the end.
On your questions:
- CHANGELOG: no
BREAKING CHANGEfooter here. Throwing only happens when the documents can't be indexed, which used to produce a silently wrong index, so this is a fix. versionize only puts the subject in the changelog, so I'll squash with a subject that states the behaviour:fix(index): build model-declared indexes over existing documents when the context opens (#171). The details (open can block while filling, throws if documents can't be indexed) go in the squash body and the release notes. - Opt-in skip / public rebuild: agreed on the direction (
OnIndexBuildFailure(Skip | Throw)plus a publicRebuildIndexAsync(name)). Please open the issue. - The two pre-existing problems: yes, please file both.
…lDb#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>
|
Thanks. Addressed in 1cb3a8e.
Squash subject and release notes: agreed. Issues filed:
|
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks, both asks are done: the two methods are back byte-for-byte with [Obsolete] and no CS0618 in the repo, and the message reads correctly. With #170 now on main, this PR is the second one to land, and two things need to change before merging.
1. The failure path leaks every page except the root. ApplyIndexBuilder calls RebuildIndexAsync(index, skipUnreadableDocuments: false), which rolls back internally, and then index.FreeAllPages(_storage) (DocumentCollection.cs ~1095), which walks the committed view, CollectAllPages(0). After the rollback only the root is reachable. This is the bug #170 fixed in RebuildIndexFromScratchAsync. Probe on the merged state, 3,000 documents with one wrong-dimension embedding at the end: the transaction view had 322 pages, the committed view 1, so 321 pages leak per failed open, and the file grew 192 → 320 → 384 → 448 → 512 pages over four opens. Since the message tells users every open fails the same way, this repeats on every start until the data is fixed. The existing test fails on the first document, before any split, so it can't see it. Please reuse #170's pattern: own transaction, FillIndexAsync, CollectAllPages(txnId) before the rollback, then FreeUnregisteredCopy. Ideally one shared "fill an unregistered index or free it" helper for both callers. Please add a test that fails late, after splits.
2. The helper unification you offered. On the merged state, CollectionIndexManager has:
CreateUnregistered(definition)andCreateUnregisteredCopy(name)doing the same construction; only the second checks_disposed.CreateUnregisteredCopycan becomeCreateUnregistered(GetIndex(name).Definition).RegisterandReplaceIndexrepeating the same steps, but onlyReplaceIndexrestores the old state whenSaveMetadatathrows. WithRegistera failed save leaves the index registered in memory but not on disk, andApplyIndexBuildercan't release it.
Merges cleanly with main; full suite on the merged state 2415 / 0 / 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>
|
Merged 1. Leak on the failure path.
New test 2. Helper unification.
Tests:
Each of the three new tests fails when only its own part of the fix is reverted. Full suite on 4a15f87: 2465 / 0 / 4 skipped. Not changed here:
|
… failure (EntglDb#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>
|
I also moved the decimal key-format migration onto the same helpers, in 758717b.
Test Full suite: 2466 / 0 / 4 skipped. Note for #177: this supersedes the |
…he next open (EntglDb#70) A full-text index whose format is current but whose tree holds keys it did not write (e.g. from an older BLite reading it as a plain B-Tree) is rebuilt on open. MigrateLegacyDecimalIndexes (on EntglDb#175's base) rebuilds it into an unregistered copy and swaps that in only after it committed, so a rebuild that fails leaves the old index registered as it was: still holding the foreign keys, still needing the migration. - MigrateLegacyDecimalIndexes doc comment covers full-text indexes. - Test: corrupt a document so the rebuild on open fails; over three failing opens the stored root and format are unchanged, the foreign key is still there and the file does not grow; after repairing the document the next open swaps in the rebuilt index and a further open keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…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>
…he next open (EntglDb#70) A full-text index whose format is current but whose tree holds keys it did not write (e.g. from an older BLite reading it as a plain B-Tree) is rebuilt on open. MigrateLegacyDecimalIndexes (on EntglDb#175's base) rebuilds it into an unregistered copy and swaps that in only after it committed, so a rebuild that fails leaves the old index registered as it was: still holding the foreign keys, still needing the migration. - MigrateLegacyDecimalIndexes doc comment covers full-text indexes. - Test: corrupt a document so the rebuild on open fails; over three failing opens the stored root and format are unchanged, the foreign key is still there and the file does not grow; after repairing the document the next open swaps in the rebuilt index and a further open keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
I merged
Tests: IndexBackfill 8/8, DecimalIndex 11/11, Vacuum 27/27; full suite 2532 / 0 / 4 skipped. |
Fixes #171.
Problem
When a
DocumentDbContextopens, it applies each model-declared index withDocumentCollection.ApplyIndexBuilder. For an index that didn't exist yet, that created and persisted it empty. Only documents written afterwards were indexed, so queries routed to the index silently missed every older document. This is the usual way of adding an index: a new app version that addsHasIndex,HasVectorIndexorHasSpatialIndexto an existing collection.Fix
ApplyIndexBuildernow:CollectionIndexManager.CreateUnregistered), so neither queries nor the collection metadata see it yet.RebuildIndexAsync(skipUnreadableDocuments: false), on the thread pool like the decimal key-format migration, so a capturedSynchronizationContextcannot deadlock the wait.CollectionIndexManager.Register).Building before registering makes the fill crash-safe. If the process dies mid-fill, the metadata has no entry for the index, and the next open builds it again. If the index were persisted first and filled afterwards, a crash would leave an empty index that later opens trust.
If a document cannot be loaded or indexed, the copy's pages are freed and the open fails with an
InvalidOperationExceptionnaming the index and collection. Nothing is persisted, so the next open retries, and a context without that index can still open the data. The test uses a vector index whose dimension doesn't match the stored embeddings.The B-Tree, vector and spatial cases share the same definition builder,
CollectionIndexManager.BuildDefinition. It produces the same definitions as the previousEnsureIndex/CreateVectorIndexUntyped/CreateSpatialIndexUntypedpaths.Tests
IndexBackfillTestsadds 5 tests, all failing onmain. They use new contexts intests/BLite.Shared/IndexEvolutionContexts.cs: v1 without indexes, v2 with them, and one with a mismatched vector dimension.QueryIndexAsync.Full suite on macOS: 2384 passed, 3 failed, 4 skipped. The 3 failures are the known
MultiProcessWalSharedMemoryTests.wal-shmlock timeouts.Behaviour change
Opening a context whose model declares a new index that cannot be built from the existing documents now throws. Before, it silently created an empty or partial index.
Note on
unique: true#171 also suggested that a unique index added this way would not check existing duplicates. While testing that, I found that secondary indexes don't enforce
unique: trueat all, even for documents inserted afterwards: secondary B-Tree keys include the document ID, and nothing else checks for duplicates. That is a separate issue, so I left it out of this PR.Merges cleanly with #169, #170, #172, #173 and #174.
🤖 Generated with Claude Code