Skip to content

fix(index): split B-Tree nodes by size and find duplicates across splits (#217, #213) - #232

Merged
mrdevrobot merged 4 commits into
mainfrom
fix/213-217-btree-splits
Oct 15, 2026
Merged

mrdevrobot merged 4 commits into
mainfrom
fix/213-217-btree-splits

Conversation

@mrdevrobot

@mrdevrobot mrdevrobot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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

Fix

Size-aware splits. A leaf or internal node splits when the new entry doesn't fit by count (64) or by bytes.

  • The split point is computed over the existing entries plus the new one, so both halves are known to fit before anything is written. Among the points where both halves fit, it takes the best byte balance; with equal-size keys that is the old count midpoint.
  • The leaf split writes the new entry straight into its half.
  • An internal split sends its middle entry up to the parent, which splits recursively if needed.
  • The count-only SplitNode, SplitLeafNode, SplitInternalNode and InsertIntoInternal are removed.

Key size limit. MaxKeySize is 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.

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:

  • merging is skipped under a parent with no separators;
  • the cursor (Seek, MoveNext, MovePrev, MoveToFirst, MoveToLast) skips empty leaves, as range scans already did.

Descent. FindLeafNode and FindChildNode take a leftmost flag.

  • Forward scans, TryFind/TryFindAsync and cursor Seek go 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.
  • Backward scans and inserts still go right on equality, and the unique-index check on insert is unchanged.
  • Delete searches 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

BTreeSplitTests has 19 test cases:

  • the B-Tree nodes split by entry count, not size: long keys overflow a node page mid-insert #217 repro on DynamicCollection (padding N = 100/400/2000) and on a typed collection (400/2000, with a reopen);
  • an oversized key rejected through the collection and directly on the B-Tree;
  • maximum-size keys mixed with small ones, so the split has to go by bytes;
  • the DynamicCollection non-unique index: forward scan misses duplicate keys that straddle a leaf split #213 p70 repro forward and backward, a sweep over every duplicated key, and the delete of duplicates across a split, at collection and B-Tree level;
  • 400 copies of one key spread over many leaves, all found and then deleted;
  • a randomized test (6 seeds, 8 KB pages, keys up to near the limit, about half deleted, then more inserts) checked against a sorted in-memory multiset. It covers Range in 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 in ExplicitTransactionFailureTests used 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.Tests suite, after merging the current main into the branch: 3023 passed, 4 skipped, 0 failed. BLite.Core builds 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:

  • If the document stays in its slot, the entry is left alone.
  • If the document moves, the entry is re-pointed with the new BTreeIndex.Relocate(key, oldLocation, newLocation), which rewrites its 6-byte location in place.

To make the first case the common one, DynamicCollection now 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 DynamicCollection index 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

  • ⚠️ Breaking: smaller key limit. MaxKeySize drops 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 a BLiteValidationException naming 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.
  • Underfull nodes. When two nodes don't fit in one page, they are left underfull rather than redistributed by bytes, so space reuse after deleting large keys can be poor.
  • Deleting a heavily duplicated key in a 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

…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>
mrdevrobot and others added 3 commits October 15, 2026 21:58
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>
@mrdevrobot

Copy link
Copy Markdown
Contributor Author

Review (independent agent, two passes)

First pass on 02c2896: no correctness bug in the split, descent or rebalance logic.

  • A randomized probe ran 480 times: page sizes 4/8/16 KB, four key mixes (mixed sizes, tiny vs maximum-size keys, a pool of 3 heavily duplicated keys, 40 keys), and 40 seeds each. Each run did 2500 interleaved inserts and deletes, then a rolled-back batch of inserts, then a reopen. All 480 passed.
  • After each run it checked:
    • structure: every node fits its page and holds at most 64 entries; separators are sorted; every child's keys lie within its separators; all leaves are at the same depth; the leaf chain matches the in-order leaves;
    • lookups, per key against a model: Range in both directions (sync and async), CountRange, TryFind, TryFindFirst, cursor walks both ways, and Seek.
  • Byte accounting is correct, including the bound that guarantees a full node can always be split at MaxKeySize. Recursive and root splits, the separator placement and the modified ExplicitTransactionFailureTests were checked too.

It raised two points, both handled in facc6d2 as decided by the maintainer:

  1. Deleting copies of a heavily duplicated key in a DynamicCollection is linear per delete. Mitigated: unchanged keys are no longer deleted and re-inserted on update. The structural fix, unique dynamic keys, is DynamicCollection secondary index keys are not unique: deleting entries of a heavily duplicated value is quadratic #234 (v6).
  2. The lower MaxKeySize blocked updates of documents holding an older, larger key. Fixed: unchanged keys are exempt from the size check and keep their entry. New or changed oversized keys are still rejected.

Second pass on facc6d2: nothing wrong.

  • TryUpdateInPlace matches the typed in-place update. Free-space accounting stays consistent, the write goes through the transaction, CDC still emits a single Update event, and the freed tail is zeroed.
  • Relocate matches the exact (key, location) entry.
  • No changed key can slip past the size or unique checks.

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 main: a page whose documents only shrank keeps its gaps until a delete happens on it, as with typed collections.

Found along the way, already on main, not part of this PR: a B-Tree merge frees the emptied page outside the transaction, so rolling back deletes corrupts the index. Tracked as #233 (P0).

@mrdevrobot
mrdevrobot merged commit 70eb3c3 into main Oct 15, 2026
6 checks passed
@mrdevrobot
mrdevrobot deleted the fix/213-217-btree-splits branch October 15, 2026 20:07

This branch was successfully deployed

1 active deployment
Production — 4441f182 Deployed Oct 15, 2026 by mrdevrobot via build_and_publish #727
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/indexing B-Tree and secondary indexes, index lifecycle bug Something isn't working

Projects

None yet

1 participant