Repository navigation
feat(storage): per-collection required features, refused by versions that lack them (#187) - #194
Conversation
mrdevrobot
left a comment
There was a problem hiding this comment.
Thanks, this is the right design. I checked that 5.2.x reads the collection record with a stream bounded by the slot length and stops after the key formats, so the trailing section is ignored; and I reproduced the compatibility table against NuGet 5.2.3. Refusal is per collection, before anything is written, and read-only opens accept Write-level features. With each check disabled in turn, 8, 1 and 1 of the 11 tests fail. Merged with main: BLite.Core builds with no warnings on both targets, full suite 2468 / 0 / 4 skipped, NetStandard21 20/20.
Approving. Three things to handle, none blocking:
- Conflict with #196. Both rewrite
SaveCollectionMetadata. Git places this PR's guard after #196's new in-place write, where it no longer covers that path; with the guard there,Storage_RefusesToRewriteDeleteOrFree_ACollectionWithAnUnknownFeaturefails. Whichever lands second should put the check right afterFindMetadataRecord, before the in-place step, e.g.if (foundPage >= 0 && TryReadRecord(chain[foundPage].Buffer, ReadSlot(...)) is {} stored) EnsureCanWrite(stored);. Resolved that way,main+ #194 + #196 passes the full suite (2474 / 0 / 4 skipped). I'd merge #196 first: its up-front size check also fixes the pre-existing case where an oversized record fails after the old one was marked deleted. - The description overstates the index-type rule. 5.2.3's
DynamicCollection.PersistIndexMetadataclearsIndexesand rebuilds the list from the indexes it loaded, so a dynamicCreateIndexAsyncorDropIndexin 5.2.3 drops both the feature section and the type-6 entry. I reproduced it: after a 5.2.3 dynamicCreateIndexAsync,features = 0and this branch opens the collection without refusing. The rule only helps when the old writer keeps the index entry. Please reword the description and theCollectionFeaturesremarks, and keep the "don't open with BLite < 5.3 for writing" release note from #177. - Future index types. If a later index type adds per-index bytes inline in the index list (as Vector does with dims/metric), this version misreads the record,
GetCollectionMetadatareturns null, and the dynamic open creates a fresh record, so the refusal never runs. Please add the rule "new per-index data goes only in trailing sections" to theReadCollectionMetadataandCollectionFeaturescomments.
Nits: ListCollections now returns names that GetOrCreateCollection throws on, worth a release-note line; TryReadRecord swallows every exception, so an unparsable record skips the guard (consider refusing); check that the release pipeline stamps the assembly version used in the message (5.3.0, not 1.0.0).
|
#196 is now merged (4250e29), so this branch conflicts in |
…that lack them (EntglDb#187) A collection can now record features it depends on that older versions cannot maintain. A version that finds one it does not know refuses the collection instead of silently damaging it. Format: a new trailing section of the collection record, after the index key formats: a count, then per feature its id, a requirement byte and a description. Releases that stop reading at the key formats load the record unchanged; records without the section load with no features. Requirement levels: - Open: needed to open the collection at all. Any unknown requirement value is treated this way. - Write: needed only to modify it; the collection still opens when the database is opened read-only (PageFileConfig.Access = Read). Checks: - Opening a typed collection (CollectionIndexManager) or a dynamic one (DynamicCollection) throws NotSupportedException naming the collection, this version and each missing feature's description. - SaveCollectionMetadata and DeleteCollectionMetadata refuse to rewrite or drop a stored record with unknown features; FreeCollectionPages refuses to free a collection whose pages it cannot find. - An index whose type is newer than any this version knows counts as an unknown Open feature, so a new index type stays protected even after a release without this check rewrote the record and dropped the section. - BLiteEngine.ListCollections lists such a collection without opening it, so one collection no longer makes the listing fail. VacuumAsync over all collections refuses before vacuuming anything (it could not erase that collection's deleted documents); VacuumAsync for that collection throws instead of returning silently. - DocumentDbContext no longer drops an unmapped collection with unknown features at open; it leaves it for the version that created it. No feature is defined yet; full-text indexes (EntglDb#177) will be the first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
a245437 to
46918ee
Compare
|
I rebased this onto |
Brings in EntglDb#196 (squash-merged) and EntglDb#194 (per-collection required features). StorageEngine.Collections.cs is main's version with the follow-up re-applied: lock-free GetCollectionMetadata with a locked re-walk on a miss, GetAllCollectionsMetadata under the lock, compaction planned from slot lengths, AppendRecord(ReadOnlySpan<byte>). DeleteCollectionMetadata (EntglDb#194's feature guard) now looks the record up with FindCollectionMetadata: it already holds _metadataLock, which is not reentrant, so the locked re-walk on a miss would wait for itself until the lock timeout. Test added. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The review follow-ups are in #219. A stored record that doesn't parse is now refused instead of overwritten. The |
…ection (EntglDb#70) Uses the per-collection required features from EntglDb#194, so BLite versions that have that check but not full-text indexes refuse the collection instead of misreading it. - CollectionFeatures.FullTextIndexes: id "fulltext-index", description "full-text indexes (BLite 5.3 or later)", requirement Open. A version without full-text indexes reads the index as a B-Tree: its planner answers equality, range and IN lookups on the property from the postings, so even read-only results are wrong. It is a known feature in this version. - SyncIndexFeatures records the feature while the collection has a full-text index and removes it with the last one, leaving every other feature alone. Called whenever the index list is written: typed UpdateMetadata (CreateFullTextIndexAsync, model-declared indexes via ApplyIndexBuilder, rebuilds, DropIndex) and dynamic PersistIndexMetadata. - A read-write open records the feature again if a release that predates it rewrote the record and dropped the section (5.2.x does); a read-only open leaves the file alone. - CollectionFeatures and ReadCollectionMetadata document that the index-type rule only helps while an old writer keeps the index entry (a 5.2.x dynamic collection drops both), and that new per-index data goes only in trailing sections. - CreateFullTextIndexAsync (typed and dynamic) notes: do not open a database with full-text indexes for writing with BLite < 5.3. Tests: the feature is recorded for model-declared, typed and dynamic indexes, survives reopen, stays while another full-text index is left and is cleared with the last one; it is recorded again on a read-write open after being dropped, not on a read-only one; a version without full-text indexes (known features and index types passed in) refuses to open the collection even read-only, from the feature alone and from the index type alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nnot parse (#187) (#219) Follow-up to the #194 review: - SaveCollectionMetadata and DeleteCollectionMetadata now refuse a stored record that does not parse, with a NotSupportedException naming the collection, instead of skipping the feature check and overwriting it. An unparsable record made GetCollectionMetadata return null, so a dynamic open would save a fresh, empty record over it. EnsureCanReplaceRecord replaces TryReadRecord; the Save guard stays right after FindMetadataRecord, before the in-place step. The Delete guard uses LoadMetadataChain/FindMetadataRecord instead of GetCollectionMetadata, which skips unparsable records. - CollectionFeatures remarks: the index-type rule protects a new index type only while the older release keeps the index entry; 5.2.3's dynamic PersistIndexMetadata (index create/drop, vacuum with RebuildIndexes) drops the entry together with the section. - CollectionFeatures and ReadCollectionMetadata: new per-index data goes only in trailing sections, never inline in the index list, and why. - Test: a record cut after its name is neither overwritten nor dropped, the dynamic open refuses, and other collections stay usable. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* 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> * feat(index): full-text search with BM25 ranking (#70) Adds a full-text index type for string properties, stored as a companion inverted index in one B-Tree per index: - postings [0x01][term][0x00][location][tf][doc length] -> location, so BM25 scores come from the postings alone and a delete recomputes the exact keys from the old text; - one statistics entry [0x02][documents][terms], kept in the same transaction as the postings. Text is analyzed with TextNormalizer (accents removed, lowercased, punctuation as separators) and split into terms of at most 32 UTF-8 bytes. API: - HasFullTextIndex(x => x.Text) and CreateFullTextIndexAsync(x => x.Text) (default name idx_fts_{property}), filled from existing documents before the index is registered; - LINQ x.Text.FullTextMatch("terms"): documents containing every term, served from the index and evaluated with the same rules in memory; - FullTextSearchAsync(index, query, k, matchAllTerms) returning the top k documents with their BM25 score; - DynamicCollection.CreateFullTextIndexAsync / FullTextSearchAsync. Equality, range and IN lookups no longer pick a vector, spatial or full-text index just because it covers the property. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fts): address review of full-text search (#70) - Tag postings 0x10 and statistics 0x20, outside the range of the generic [0x00..0x03] keys an older BLite writes when it reads the tree as a plain B-Tree; validate the statistics key length. - Record FullTextIndex.FormatVersion as the index KeyFormat and rebuild the index on open (typed and dynamic) when it differs or the tree holds keys the index did not write, since deletes rely on reproducing the keys. - Re-check every hit against the document text, for LINQ FullTextMatch and FullTextSearchAsync (typed and dynamic), so a stale posting never surfaces the document that now holds its location. - Search with matchAllTerms intersects rarest-first before scoring, and falls back to statistics estimated from the postings when the entry is missing (e.g. lost to a concurrent transaction's update). - CreateFullTextIndexAsync throws when the name belongs to a non-full-text index, passes its token to the rebuild, and is declared on IDocumentCollection; DynamicCollection.FullTextSearchAsync takes a token. - Document the concurrent-transaction statistics caveat, batching inserts and the CJK / supplementary-plane limitation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(fts): batching advice and name-collision exception on CreateFullTextIndexAsync (#70) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(fts): a failed rebuild triggered by foreign keys is retried on the next open (#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 #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> * fix(fts): return k re-checked hits and each location once (#70) - FullTextSearchAsync (typed and dynamic) keeps taking candidates past the first k, in widening windows, until k hits survive the re-check against the document; the first window costs one Search as before. - Postings yields one posting per location, so a stale posting sharing its term and location with the current document's no longer makes Match return the location twice or Search score it twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(fts): rarest-first saves decoding and allocation, not I/O (#70) CountRange walks every leaf entry of each query term, so looking up the few candidates in a common term's postings avoids decoding them, not reading their pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(fts): a stale posting at a document's location can be the one scored (#70) - Postings documents that the first posting of a location in key order (lowest frequency, then shortest length) is returned, which can be a stale one, so its frequency and length can be used for BM25 scoring, and CountRange counts both towards the document frequency. Matches and once-per-location results are unaffected. - The test for a stale posting at a document's location notes that it checks membership and order, not scores. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fts): free a failed dynamic full-text rebuild and keep the old index (#70) DynamicCollection rebuilt a stale full-text index on open into a fresh tree and, if a document could not be read or indexed, rolled back and threw without freeing that tree. Pages added by splits are allocated outside the transaction, so every failing open leaked the partial tree and grew the file. CreateFullTextIndexAsync leaked the same way. - FillUnregisteredFullTextIndexAsync fills the new index in its own transaction; on failure it collects the index's pages through the transaction before the rollback and frees them. Used by the rebuild on open and by CreateFullTextIndexAsync. - PublishFullTextIndex registers the filled index and persists the metadata; if that fails it restores the previous registration and frees the new index. - Each full-text index keeps its own KeyFormat and PersistIndexMetadata writes it, so persisting after one index's rebuild no longer marks another index that still awaits its rebuild as current (e.g. a crash between two rebuilds). Test: a dynamic full-text index over 3000 documents with a foreign key and an unreadable last document; four failing opens do not grow the file and keep the old index (same root, still searchable, still flagged for rebuild), and the rebuild succeeds once the document is repaired. Fails without this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(fts): record full-text indexes as a required feature of the collection (#70) Uses the per-collection required features from #194, so BLite versions that have that check but not full-text indexes refuse the collection instead of misreading it. - CollectionFeatures.FullTextIndexes: id "fulltext-index", description "full-text indexes (BLite 5.3 or later)", requirement Open. A version without full-text indexes reads the index as a B-Tree: its planner answers equality, range and IN lookups on the property from the postings, so even read-only results are wrong. It is a known feature in this version. - SyncIndexFeatures records the feature while the collection has a full-text index and removes it with the last one, leaving every other feature alone. Called whenever the index list is written: typed UpdateMetadata (CreateFullTextIndexAsync, model-declared indexes via ApplyIndexBuilder, rebuilds, DropIndex) and dynamic PersistIndexMetadata. - A read-write open records the feature again if a release that predates it rewrote the record and dropped the section (5.2.x does); a read-only open leaves the file alone. - CollectionFeatures and ReadCollectionMetadata document that the index-type rule only helps while an old writer keeps the index entry (a 5.2.x dynamic collection drops both), and that new per-index data goes only in trailing sections. - CreateFullTextIndexAsync (typed and dynamic) notes: do not open a database with full-text indexes for writing with BLite < 5.3. Tests: the feature is recorded for model-declared, typed and dynamic indexes, survives reopen, stays while another full-text index is left and is cleared with the last one; it is recorded again on a read-write open after being dropped, not on a read-only one; a version without full-text indexes (known features and index types passed in) refuses to open the collection even read-only, from the feature alone and from the index type alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(storage): drop the format-rule note duplicated by #219's remarks Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: MrDevRobot <mrdevrobot@entgldb.com>
Closes #187.
This adds the per-collection "required features" value discussed in #177. A collection records the features it depends on that older versions cannot maintain. A version that finds a feature it does not know refuses the collection instead of silently damaging it. The page format version stays unchanged, as agreed, so older versions still open the database. The PR defines no feature yet; full-text indexes (#177) will be the first. It is meant to be merged before any release that ships #177, so that every release from then on can refuse what it does not know.
Format
The record gets a new section after the index key formats, the last section today:
idis a stable identifier that versions implementing the feature recognise, e.g.fulltext-index.descriptionis stored with the feature, e.g.full-text indexes (BLite 5.3 or later), so that a version that has never heard of the feature can still say what is missing and which version to use.Requirement levels
requirementOpen(1)Write(2)PageFileConfig.Access = Read), then opens it for reading.Open.I added both levels now rather than starting with one. A second level added later would be ignored by every version released in between: those versions would write to a collection that only needs to be protected from writers. So the distinction has to exist in the first version that checks.
Where it is enforced
Opening a collection. Both the typed path (
CollectionIndexManager, so beforeDocumentCollectionwrites anything) and the dynamic path (DynamicCollection) throwNotSupportedException. The message names the collection, this version and every missing feature:A
DocumentDbContextthat fails this way disposes its storage, as with any other open failure.Writing metadata outside an open collection.
SaveCollectionMetadataandDeleteCollectionMetadatacheck the record that is already stored, not the object passed in, and refuse to overwrite or drop it.FreeCollectionPagesrefuses too, because it can't find the pages of an index type it doesn't know. Nothing is written before these checks.DocumentDbContextorphan cleanup. An unmapped collection that has unknown features is no longer dropped at open. It belongs to the newer version that created it, and dropping it would also leak the pages this version can't find.BLiteEngine:ListCollectionslists such a collection without opening it. Before, one such collection would have made the whole listing throw.VacuumAsync()over all collections refuses before vacuuming anything, since it could not erase that collection's deleted documents.VacuumAsyncwith thatCollectionNamethrows instead of returning silently.TruncateCollectionAsync, migration (BLiteMigration) and GDPR subject export throw when they open it, rather than skipping a collection they can't read.Index types. An index whose type is above the highest
IndexTypethis version defines counts as an unknownOpenfeature (type 0 stays accepted). This keeps a new index type protected even after a release without this check has rewritten the record and dropped the section. When feat(index): full-text search with BM25 ranking (#70) #177 addsFullText = 6, the limit moves with the enum.Limitations
BLite.Core5.2.3 from NuGet (details below). The index-type rule covers index features in that case. The "do not open with BLite < 5.3 for writing" note from feat(index): full-text search with BM25 ranking (#70) #177 stays.Plan for #177
Once this is merged, #177 would:
fulltext-indexto the known features;new RequiredFeature("fulltext-index", Open, "full-text indexes (BLite 5.3 or later)")whenever a collection has a full-text index, and remove it when the last one is dropped (typedUpdateMetadata, dynamicPersistIndexMetadata);The two cases you listed in #177 (an older
DynamicCollectionthat drops the type-6 index from the metadata, and an older typed version that leaves stale postings on delete) are then refused by any release that has this PR. The branches merge without conflicts. On the merge, #177's full-text tests and these pass, and the index-type rule accepts type 6 as known.Tests
RequiredFeaturesTests, 11 tests. A newer version is simulated by adding the feature to the stored metadata.TruncateCollectionAsyncand named vacuum throw;ListCollectionslists it;BLiteMigration.ToMultiFileAsyncthrow instead of skipping the collection, and the source database is left unchanged.SaveCollectionMetadata,FreeCollectionPagesandDeleteCollectionMetadatathrow and leave the record unchanged.Writefeature is refused read-write, with the message pointing to read-only, but opens and reads read-only;Each check was disabled in turn: the open checks, the listing, orphan cleanup, the save/delete/free guards, named vacuum, the read-only flag, the unknown-level handling and the index-type rule. Each time, the matching tests fail.
Full suite on macOS: 2403 passed, 0 failed, 4 skipped.
BLite.NetStandard21.Tests: 20/20.BLite.Corebuilds for net10.0 and netstandard2.1 with no warnings.Checked against BLite.Core 5.2.3 (NuGet)
I used two scratch console apps, not part of the PR, one on this branch and one on the published 5.2.3 package:
Cost
The only per-operation cost is in
SaveCollectionMetadata, which now parses the one stored record it has already located. 20,000GetNextSequencecalls (each a metadata read and save) took 7.38 s on this branch and 7.21 s onmain, and later rounds were within noise. Both grow with every round because each save adds a slot to the metadata chain; that is independent of this PR.Release notes (suggestion)
🤖 Generated with Claude Code