Repository navigation
fix(index): split B-Tree nodes by size and find duplicates across splits (#217, #213) - #232
Merged
Merged
Conversation
…its (#217, #213) #217: BTreeIndex split a node only when it reached MaxEntriesPerNode (64) entries and never checked the bytes left in the page, so with variable-length keys a node filled its page first and the next insert threw ArgumentOutOfRangeException from the span writes. - A leaf or internal node now splits when the new entry does not fit by count or by bytes. The split point is chosen over the existing entries plus the new one, by bytes (best byte balance among the points where both halves fit and hold at most 64 entries), so uneven key sizes cannot leave a half larger than a page; the leaf split inserts the new entry directly into its half. - MaxKeySize is now half of a node's entry space (PageSize / 2 - 36: 8156 bytes on 16 KB pages, 4060 on 8 KB), the bound that guarantees a two-way split always fits. The collections already reject larger keys up front with a BLiteValidationException naming the limit; BTreeIndex.Insert now throws an ArgumentException too. - Borrowing from a sibling and merging check that the moved entries and the new separator fit; when they don't, the node stays underfull (still a valid tree). An internal node can be left with P0 only and a leaf can be left empty, so merges skip a parent without separators and the cursor skips empty leaves. #213: a leaf split promotes the first key of the right half, so in a non-unique index copies of that key can stay in the left leaf, while descent went right on a key equal to a separator. A forward range scan (and TryFind, Delete, the cursor) started past those copies. - Forward scans, lookups and the cursor descend to the leftmost leaf that can hold the key (left on equality) and walk the leaf chain; backward scans and inserts keep descending right. - Delete searches every child that can hold the key, leftmost first, so it finds the exact (key, location) entry and its path. - A promoted separator is inserted in the parent right after the entry pointing at the split child, not by key order, which put it after equal separators of other children. ExplicitTransactionFailureTests used the #217 overflow as their mid-write failure; they now corrupt the index root inside the transaction instead, which the rollback discards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 15, 2026
Two follow-ups to the size-based B-Tree splits: - Deleting one copy of a heavily duplicated key in a DynamicCollection index searches every leaf holding that key (its keys carry no document id), and every update deleted and re-inserted the entry of every B-Tree index even when the key had not changed. DynamicCollection now rewrites a document that is not larger than before in its own slot, as typed collections already do; an unchanged B-Tree key is then left alone, and re-pointed with the new BTreeIndex.Relocate (location rewritten in place, no split or merge) when the document moves. The primary entry is re-pointed the same way, in typed and dynamic collections, and typed secondary entries with an unchanged key use Relocate when the document moves. - MaxKeySize went from PageSize - 64 to PageSize / 2 - 36, so documents whose _id or indexed value lies between the two could no longer be updated at all. On update, a key equal to the stored one is no longer size-checked (dynamic ValidateWrite, typed ValidateKeysInAll / ValidateKey) and, with the above, is never inserted again. A new or changed oversized key is still rejected up front. BTreeIndex.MaxKeySizeOverride is an internal test hook that stands in for an index written by an older version. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ves (#217) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Author
Review (independent agent, two passes)First pass on 02c2896: no correctness bug in the split, descent or rebalance logic.
It raised two points, both handled in facc6d2 as decided by the maintainer:
Second pass on facc6d2: nothing wrong.
The one missing test case it pointed out, a legacy oversized typed secondary key whose document moves, was added in 2c0851c. Known limit, also on Found along the way, already on |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #217. Fixes #213.
One PR for both issues, because the fixes touch the same split and descent code in
BTreeIndex. The on-disk node format is unchanged.Root causes
ArgumentOutOfRangeException). Borrow and merge on delete had no byte checks either.Range,ScanRange,TryFindFirst,TryFind,Deleteand the cursor'sSeekall started past the left copies.InsertIntoParentplaced the promoted separator by key order. When several separators were equal, it landed after the wrong child, so the tree's order no longer matched the leaf chain. It is now placed right after the child that split.Fix
Size-aware splits. A leaf or internal node splits when the new entry doesn't fit by count (64) or by bytes.
SplitNode,SplitLeafNode,SplitInternalNodeandInsertIntoInternalare removed.Key size limit.
MaxKeySizeis now half of a node's entry space, PageSize/2 − 36: 8156 bytes on 16 KB pages, 4060 on 8 KB. This bound guarantees that any full node can be split in two halves that each fit a page.BLiteValidationExceptionnaming the limit, andBTreeIndex.Insertnow also throws anArgumentExceptionthat names it.InvalidOperationExceptionwhen such a node can't be split. Per InsertAsync / InsertBulkAsync / UpsertAsync roll back the caller's explicit transaction on any exception #185/fix(collections): don't roll back a caller-owned transaction on a failed write (#185) #204, the transaction then rolls back.Delete. Borrow and merge now check that the moved entries and the new separator fit; if they don't, the node is left underfull, which is still a valid tree. This means an internal node can end up with only its leftmost child, and a leaf can end up empty:
Seek,MoveNext,MovePrev,MoveToFirst,MoveToLast) skips empty leaves, as range scans already did.Descent.
FindLeafNodeandFindChildNodetake aleftmostflag.TryFind/TryFindAsyncand cursorSeekgo left on equality and then walk the leaf chain. This costs one extra page read only when the key equals a separator or falls past a leaf's last key.Deletesearches every child that can hold the key, leftmost first, and returns the leaf with the exact (key, location) and its path for rebalancing.Page writes still go through the transaction, page reads stay WAL-aware, and a failed insert still rolls the transaction back.
Tests
BTreeSplitTestshas 19 test cases:DynamicCollection(padding N = 100/400/2000) and on a typed collection (400/2000, with a reopen);Rangein both directions, per-key ranges,TryFind,CountRange, equality and full cursor walks, before and after a reopen. It was also run with 80 extra seeds during development.Before the fix, 14 of the first 16 cases failed.
The two
Failure_After_The_First_Page_Write...tests inExplicitTransactionFailureTestsused the #217 page overflow as their mid-write failure, which no longer happens. They now corrupt the index root page inside the caller's transaction, so the insert fails after the data page and the primary index were written, and the rollback discards the corruption.Full
BLite.Testssuite, after merging the currentmaininto the branch: 3023 passed, 4 skipped, 0 failed.BLite.Corebuilds with no warnings.Unchanged keys on update (follow-up, facc6d2)
An update whose index key does not change no longer deletes and re-inserts the entry:
BTreeIndex.Relocate(key, oldLocation, newLocation), which rewrites its 6-byte location in place.To make the first case the common one,
DynamicCollectionnow overwrites a document that is not larger than before in its own slot (TryUpdateInPlace), as typed collections already do, and zeroes the freed tail. Typed collections re-point their secondary and primary entries the same way when a document moves.Without this, the multi-leaf delete search that #213 needs made every update of a document with a heavily duplicated value in a
DynamicCollectionindex read every leaf holding that value. The review measured 20,000 copies of one key deleted in random order at 5.9 s, against 0.12 s with distinct keys. Updates that grow and move a document, and deletes, still pay that search. The structural fix, unique dynamic keys with a key-format change, is tracked in #234 for v6.Tests:
UnchangedIndexKeyUpdateTests(8 tests). One checks that 5000 same-size updates of documents sharing one indexed value leave every page of that index byte-identical.Behaviour changes
MaxKeySizedrops from PageSize − 64 to PageSize/2 − 36 (16 KB pages: 16320 → 8156 bytes; 8 KB pages: 8128 → 4060). That bound is what guarantees any full node can be split. Index keys and ids between the two sizes were accepted before, but broke the first time their node split; they are now rejected up front with aBLiteValidationExceptionnaming the limit. Documents already stored with such a key stay readable and deletable, and stay updatable as long as that key is unchanged: the size check is skipped for a key equal to the stored one, and its entry is kept or re-pointed. A new or changed key over the limit is rejected.DynamicCollection. The delete visits every leaf holding that key until it finds the exact entry, which is linear in the number of copies. Before, it often failed silently. Typed indexes are unaffected, because their keys are unique.🤖 Generated with Claude Code