Repository navigation
Add internal/btree: in-memory B+Tree with search, insert and split - #5
Merged
Merged
Conversation
The first structure that makes key lookup fast: 55-60ns against ~60,000ns for a linear scan over 100,000 keys. A hash map is roughly 8x faster still but cannot answer a range query at all, which is why databases index with trees rather than hash tables. Internal nodes hold only separators and every value lives in a leaf, so a range becomes a walk along the bottom row once leaves are chained. Leaf splits copy the middle key upward because it still has a value; internal splits move it, because it is only a separator. The tree grows only at the root, which is why all leaves stay at the same depth with no rebalancing pass. Validate() asserts seven invariants including subtree key bounds, and runs after every insert across five orders plus two fuzz targets. Adversarial probing of this change found one defect, now covered by TestZeroValueTreeIsUsable: a zero-value Tree dereferenced a nil root and panicked on Get, Height, Validate and Dump. It now reads as empty and initialises at DefaultOrder on first write. TestLeavesFormOneSortedSequence walks the leaves across four orders after 2,000 random inserts and asserts one complete sorted sequence, which catches splits that promote the wrong key while leaving every node locally sorted.
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.
Summary
Adds
internal/btree: an in-memory B+Tree with search, insert and nodesplitting. No disk involvement, so the algorithm is the only thing under test.
This is the first structure in the project that makes "find this key" fast.
Why a tree
Measured over 100,000 keys:
Ranges across three runs, not single figures. A hash map is roughly 8x faster
than the B+Tree at finding a single key. It is also
completely useless for
WHERE id BETWEEN 200 AND 300, because hashing destroysorder. That one column is why databases index with trees rather than hash
tables, and it is worth stating plainly: the B+Tree is not the fastest way to
look up one key, it is the fastest way to look up one key while keeping the
ability to walk in order.
A plain binary tree keeps order too, but holds one key per node. At 100,000 keys
that is ~17 levels; a B+Tree at order 64 is 4. On disk each level is a page
read, so the fanout is the entire point.
Design
Internal nodes hold separators, leaves hold values
This is the difference between a B-Tree and a B+Tree. A B-Tree stores values in
internal nodes too; a B+Tree keeps every value in a leaf and uses internal nodes
purely as signposts.
The payoff arrives with range scans: once leaves are chained, a range is a walk
along the bottom row rather than a repeated descent.
The two splits behave differently
Easy to get wrong, and the tests pin both:
splitLeafcopies its middle key upward. The key still has a valueattached, so it must remain in the leaf as well as appearing as a separator.
splitInternalmoves its middle key upward. It is only a separator, sokeeping a copy would be duplication.
When the root splits, a new root is created above it. That is the only way the
tree gets taller, which is why all leaves stay at the same depth with no
rebalancing pass — growth happens at the root, not at the leaves.
Two different binary searches
childIndexandleafIndexare not the same function, which is a classicsource of off-by-one bugs:
key < keys[i]keys[i] >= keyThey are separate methods rather than one with a flag, so a caller cannot pick
the wrong one silently.
Keys are copied on insert
Putclones the key. A caller reusing a buffer across inserts would otherwisesee every stored key mutate together. This is the same defect class found in
codec.Bytesduring the0x04review, so it is guarded by a test here:TestKeysAreCopiedNotAliasedwrites a key, mutates the caller's buffer, andchecks the tree is unaffected.
Height maths
At order 512 a four-level tree addresses over 68 billion keys. Height grows like
log(n), so multiplying the data by 500 adds one level. Measured: 100,000 keys at
order 64 built in 18ms, height 4, lookups ~55 ns and inserts ~110 ns — insertion
costing roughly twice a lookup is the price of shifting keys within a node.
The zero value is usable
An unconfigured
Treereads as empty —Getmisses,Heightis 0,Validatepasses,
Dumpis empty — and initialises itself atDefaultOrder(64) on firstwrite. Before this it dereferenced a nil root and panicked.
Getpays one nil check for this; the benchmark is unchanged. This is the samedefect class as the zero-value
Layoutin the previous step, so it was probedfor deliberately rather than found by accident.
Testing
Validate()is a first-class method rather than a test helper, because astorage engine benefits from being able to check its own structure. It asserts:
len(children) == len(keys) + 1in internal nodesorder-1keys; no non-root node under(order-1)/2The bounds check is the one that catches real bugs: a split that promotes the
wrong key produces a tree that still looks locally sorted but sends searches to
the wrong leaf.
TestInvariantsHoldAfterEveryInsertrunsValidate()after each of 500random inserts, across orders 3, 4, 5, 8 and 32.
TestSortedAndReverseInsertioncovers the two worst cases for a naive split, since ascending inserts always
split the rightmost node.
Two fuzz targets, 20s each, clean:
FuzzInsertKeepsInvariants— arbitrary order and key sequence; validates afterevery insert and checks every inserted key is still findable
FuzzGetNeverPanics— arbitrary probe keys against a populated treeDefect found by adversarial probing of this change, now covered by
TestZeroValueTreeIsUsable: a zero-valueTreepanicked with a nil pointerdereference on
Get,Height,ValidateandDump.Five further probes found nothing, and are kept as regression tests:
TestLeavesFormOneSortedSequence— the strongest check here. Walks the leavesleft to right across orders 3, 4, 7 and 16 after 2,000 random inserts each,
and asserts the result is one complete, strictly sorted sequence. The
per-node invariants can all hold while the tree as a whole is wrong; this
cannot.
TestPrefixKeys— keys where one is a prefix of another (a,aa,aaa,ab,""), the case most likely to break a comparisonTestNoPhantomKeys— inserting evens must not make odds findableTestOverwriteAcrossSplits— overwrite all 300 keys after the tree is deepTestNilAndEmptyKeysAreTheSameKey—niland[]byte{}must not become twodifferent keys
72 tests across the module.
go vet,gofmt -l,go test -race ./...clean.The trap this does not solve
The tree compares keys with
bytes.Compareand nothing else. It has no idea akey is a number, so a wrong encoding produces wrong answers and never errors:
The demo shows this deliberately, because a correct tree over a bad key encoding
is exactly the kind of failure that survives a full test suite.
Not in scope
to prefer a B+Tree, but it is a separate change and touches
splitLeaf.minimum-keys invariant is already asserted in anticipation.
index.Index.btreeprovidesGetandPutbut does not claim theinterface until
ScanandDeleteexist. Declaring it early would be a liethe compiler cannot catch.
reads is a later step, and doing it now would mean debugging two things at
once.
Putoverwrites. Non-unique indexes are their own step.