Repository navigation
fix(index): enforce unique secondary indexes on typed collections (#176) - #182
Conversation
…tglDb#176) CollectionSecondaryIndex stores [user key][document id], so two documents with the same value never produced equal B-Tree keys and the tree's unique check never fired. Typed collections accepted duplicates in indexes declared unique; DynamicCollection, which stores the raw value, already rejected them. Before writing any page, inserts and updates now range-seek each unique index over [user key][min id] .. [user key][max id] and throw InvalidOperationException if an entry belongs to another document. The document's own entry is skipped, so updates that keep their value still work. Entries of longer values sharing the prefix (e.g. "abc" when checking "ab") are told apart by the id prefix byte and, for fixed-size ids, the id length. The key format is unchanged and only new writes are checked, so existing databases with duplicates still 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 is correct and minimal. The check runs before any write on every insert, bulk, upsert and update path. It sees the transaction's own uncommitted entries and skips the document's own entry. The id-length filter is exact for fixed-size ids, and the cost in the normal case is within noise (20k inserts: 1,045 to 1,070 ms on main, 1,050 to 1,350 ms here). 4 of the 9 new tests fail on main, full suite 2401 / 0 / 4 skipped. One change before merging.
1. A document that already shares a value can no longer be updated at all (DocumentCollection.cs:2789). UpdateAtLocationCore runs EnsureUniqueInAll(entity) on every unique index even when that index's key hasn't changed. If a database already holds duplicates, from before this fix or from CreateIndexAsync(unique: true) backfilling over them, then any update to either document throws, including a no-op UpdateAsync(e1) or a change to another field. That contradicts "only new writes are checked". Pass the old entity and check an index only when its key changed, like the short-circuit in CollectionSecondaryIndex.Update. That also saves the seek on most updates. Please add a test for it.
Worth knowing, not for this PR:
InsertAsync,InsertBulkAsyncandUpsertAsyncroll back the caller's explicit transaction on any exception (~L2026, ~L2084, ~L2960);UpdateAsynconly does that when it owns the transaction. A duplicate inside an explicit transaction therefore aborts the whole transaction, and the nextCommitAsyncfails with "state Aborted". Please open an issue.- Two concurrent explicit transactions inserting the same value both commit, and one insert is lost. The same happens without any index, so it's an isolation problem, not a uniqueness one.
- The seek range
[key][]..[key][FF×20]also walks every longer value starting with the key. With 11,110 values starting with "1", inserting "1" took about 20 ms. Starting the range at[userKey][id prefix byte]would skip them. - Typed collections allow any number of nulls;
DynamicCollectionindexes an explicit BSON null and rejects a second one. Worth a sentence in the docs.
Nits: cache whether the collection has any unique B-Tree index so EnsureUniqueInAll can skip the loop; the ownIdKey.Data.Length > 0 guard is dead code; the XML doc says "before writing anything", but on update it runs after reading the old document.
- Update checks a unique index only when that index's key changed versus
the stored document, mirroring the short-circuit in
CollectionSecondaryIndex.Update. Documents that already share a value
(written before enforcement, or backfilled by CreateIndexAsync(unique:
true)) can be updated again; moving a key onto a taken value still throws.
- The uniqueness seek is bounded to [user key][id prefix] ..
[user key][id prefix + 1], so it no longer walks entries of longer values
starting with the checked one ("10", "11", ... when checking "1").
- CollectionIndexManager caches whether any unique BTree index exists and
skips the loop otherwise.
- Removed the dead ownIdKey length guard; corrected the XML docs (the
check runs before any page is written, after the old document is read).
- README: typed collections allow any number of null keys in a unique
index, DynamicCollection rejects a second explicit BSON null.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks. Addressed in e369cb1. 1. Updates of documents that already share a value.
It fails when Seek range. Done in the same commit. Every id key starts with its prefix byte (0x02, or 0x03 for decimal), so the scan now runs from There is one remaining false positive, and the old range had it too. With string ids, an existing value equal to the checked value followed by U+0002 is reported as a duplicate, because Nulls. I added a sentence under "Index Management" in the README, and Nits.
Transaction abort: filed as #185. I left the concurrent-transactions lost insert out; as you say, it's isolation rather than uniqueness. Full suite: 2404 passed, 0 failed, 4 skipped. |
mrdevrobot
left a comment
There was a problem hiding this comment.
Verified e369cb1. With a unique index created over existing duplicates, a no-op update, an update of another field, and updates of the other duplicate all succeed; changing the key onto a taken value throws, onto a fresh value succeeds; the same holds in an explicit transaction and for upsert. int, decimal (1.5m vs 1.500m unchanged, 3.00m conflicts with 3m), byte[] and compound keys behave correctly, and string ids starting with 0x01–0x03 give no false negatives. The new range also fixes the prefix cost: inserting "1" next to 11,110 values starting with "1" went from 8–18 ms to about 0.1 ms. Full suite on main with #169/#170 merged: 2422 / 0 / 4 skipped.
The string-id false positive you disclosed is a bit broader than "ab\u0002": any longer value whose next byte is 0x02 blocks the shorter one, e.g. an existing "ab\u0002xyz" blocks inserting "ab". It needs a key-format change; fine to track separately.
Approving and merging.
…te failure (EntglDb#185) Address review of EntglDb#204: with the unconditional rollback gone, a write that failed after its first page write stayed in the caller's transaction, and committing it left the indexes out of step with the data. - New BLiteValidationException (an InvalidOperationException). Typed and dynamic collections now check, before writing any page: duplicate primary key (no more orphaned data slot), unique secondary indexes (dynamic now checks like EntglDb#182 does for typed), vector dimensions, document size and B-Tree keys larger than a node page. Dynamic UpdateBulkAsync reuses UpdateAtLocationCore instead of its own copy, which checked the size only after deleting the old slot. - WriteFailure.HandleAsync: every write method rolls back on failure, except for a BLiteValidationException in a caller-owned transaction before the call wrote anything (for bulk calls: rejected on the first document). Typed Update/UpdateBulk/Delete/DeleteBulk/Truncate never rolled back a caller's transaction before; they now do on a non-validation failure. - A committed or aborted transaction is never rolled back by the catch block, so an OnInsert retention failure after an auto-commit surfaces instead of "Cannot rollback committed transaction". - Write methods reject a caller transaction that is no longer Active instead of writing pages into a cache entry that is never committed or released. - Documented the contract on ITransaction and on every ITransaction overload. Tests: ExplicitTransactionFailureTests rewritten, 17 tests; each checks after commit that primary count, every index (entries and lookups) and live data slots agree. Exact-type assertions in UniqueIndexTests, DocumentCollectionTests, BLiteEngineTests and VectorSearchTests now expect BLiteValidationException. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…led write (#185) (#204) * fix(collections): don't roll back a caller-owned transaction on a failed write (#185) InsertAsync, InsertBulkAsync, UpsertAsync and UpsertBulkAsync on DocumentCollection, and every write method on DynamicCollection, rolled the transaction back in their catch block even when the caller had passed it in. Any exception (duplicate key, vector dimension mismatch, serialization error) therefore aborted the whole explicit transaction: the caller could not catch the error and carry on, and the next CommitAsync failed with "state Aborted". Only roll back a transaction the method opened itself, as UpdateAsync already did. A failed operation inside a caller-owned transaction now rethrows and leaves the transaction to the caller. Tests: ExplicitTransactionFailureTests, 9 tests (8 fail on main). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(collections): validate writes up front, roll back after a mid-write failure (#185) Address review of #204: with the unconditional rollback gone, a write that failed after its first page write stayed in the caller's transaction, and committing it left the indexes out of step with the data. - New BLiteValidationException (an InvalidOperationException). Typed and dynamic collections now check, before writing any page: duplicate primary key (no more orphaned data slot), unique secondary indexes (dynamic now checks like #182 does for typed), vector dimensions, document size and B-Tree keys larger than a node page. Dynamic UpdateBulkAsync reuses UpdateAtLocationCore instead of its own copy, which checked the size only after deleting the old slot. - WriteFailure.HandleAsync: every write method rolls back on failure, except for a BLiteValidationException in a caller-owned transaction before the call wrote anything (for bulk calls: rejected on the first document). Typed Update/UpdateBulk/Delete/DeleteBulk/Truncate never rolled back a caller's transaction before; they now do on a non-validation failure. - A committed or aborted transaction is never rolled back by the catch block, so an OnInsert retention failure after an auto-commit surfaces instead of "Cannot rollback committed transaction". - Write methods reject a caller transaction that is no longer Active instead of writing pages into a cache entry that is never committed or released. - Documented the contract on ITransaction and on every ITransaction overload. Tests: ExplicitTransactionFailureTests rewritten, 17 tests; each checks after commit that primary count, every index (entries and lookups) and live data slots agree. Exact-type assertions in UniqueIndexTests, DocumentCollectionTests, BLiteEngineTests and VectorSearchTests now expect BLiteValidationException. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(index): expect BLiteValidationException for unique violations in the #206 tests (#185) #214 landed after this branch last merged main; its unique-index test asserted the exact InvalidOperationException type, which validation failures now subclass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Fixes #176.
Problem
CollectionSecondaryIndexstores every B-Tree entry as[user key][document id]. Two documents with the same value therefore never produce equal keys, soBTreeIndex's unique check never fires. Typed collections accepted duplicates in indexes declaredunique: true, whileDynamicCollection, which stores the raw value, rejected them.Fix
This uses the range-seek approach agreed in the issue. The composite key format stays the same.
CollectionSecondaryIndex.EnsureUniquerange-seeks[user key][min id] .. [user key][max id]on a unique B-Tree index. It throwsInvalidOperationException("Duplicate key violation for unique index '<name>'")if an entry belongs to a different document. The document's own entry is skipped, so an update that keeps its value, or changes only other fields, still passes.CollectionIndexManager.EnsureUniqueInAllruns that check across all indexes.DocumentCollectioncalls it inInsertDataCoreandUpdateAtLocationCore. Each call comes before any data page, primary-index or secondary-index write, so a rejected insert or update leaves nothing behind. Every insert, bulk insert, update and upsert goes through these two methods.Insert.Prefix handling. The seek range also contains entries of longer values that begin with the checked value. For example,
"abc"lies inside the range for"ab", because the composite key has no separator. A hit counts as a duplicate only if the bytes after the user key look like an id key. They must begin with the id key's prefix byte, and for fixed-size id types (ObjectId,Guid,int,long,double,decimal) they must also have the id's exact length. That check is exact for fixed-size ids. Withstringids, a value that continues with a0x02byte (a control character) could in theory be mistaken for a duplicate. Ruling that out would need a change to the key format, which the issue asked to keep.Existing data. Only new writes are checked. A database that already contains duplicates in a unique index still opens and reads normally. Whether a rebuild should report existing duplicates is left for a follow-up, as agreed.
Concurrency. Within a process, the collection write lock already serializes inserts and updates. The check reads through the writing transaction, so it also sees that transaction's own uncommitted entries, including earlier documents in the same bulk insert. I haven't changed anything for multi-process access.
Tests
UniqueIndexTestsadds 9 tests. The 4 rejection tests fail onmain. The other 5 pass onmainand guard against false positives.UpdateAsyncmoving a document onto an existing value is rejected, and the document and its index entry are unchanged.stringids, a duplicate is rejected, while"ab"next to"abc"is allowed.nullvalues.Full suite on macOS (net10.0): 2401 passed, 4 skipped, 0 failed.
BLite.Corealso builds fornetstandard2.1. I couldn't runBLite.NetStandard21.Testslocally because it needs a .NET 6 runtime.Behaviour change
Inserts and updates that would duplicate a value in a unique secondary index on a typed collection now throw
InvalidOperationException. Before, they succeeded silently.Merges cleanly with #169, #170, #172, #173, #174, #175, #177 and #180.
🤖 Generated with Claude Code