Repository navigation
Chain the B+Tree leaves and add range scans - #6
Merged
Merged
Conversation
Adds Scan(from, to), a cursor that descends once and then walks sideways along the leaf level. One line in splitLeaf does the structural work: right.next = n.next; n.next = right. Node visits at order 64 over 100,000 keys: a 10,000-key range costs 316 visits through the chain against 40,000 for Get in a loop, and the ratio grows with the range because the single descent is amortised. Once nodes are pages, a node visit is a page read. Bounds are half-open, [from, to), so ranges compose without gaps or double-counting, and they are positions rather than keys that must exist: Scan(25, 55) over 10..70 returns 30 40 50. The iterator holds a position, not rows: 80 bytes and one allocation whether the result is ten rows or ten million. Ten rows out of a hundred thousand costs 200ns because the other 99,990 are never touched, which is what LIMIT rides on. Next hides the leaf boundary behind one boolean, using a loop rather than an if so consecutive empty leaves cannot be mistaken for the end once deletion can merge them. btree now satisfies index.Iterator, asserted at compile time, which adds a second edge to the dependency graph. index.Index is still not claimed because Delete does not exist. Validate gained an invariant: the leaf chain must reach every leaf exactly once, in tree order, and terminate. Ordering and depth can all be correct while the chain is broken, and the symptom is a scan returning partial results with no error. TestBrokenChainIsDetected severs the chain and then makes it cyclic, asserting Validate rejects both. Review of this change found one defect: Scan retained the caller's 'to' slice, so a caller reusing that buffer could silently widen the range mid-iteration, turning a 10-row scan into 90 rows. The same aliasing class as codec.Bytes two steps ago, which is why it was probed for. Fixed by cloning the bound. Scan is verified against a brute-force filter over 300 random ranges and by a fuzz target at 6.3M executions. 89 tests, race clean.
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
Chains the B+Tree's leaves and adds
Scan(from, to)— a cursor that descendsonce and then walks sideways along one level.
One line in
splitLeafdoes the structural work:Everything else is the iterator, the bounds semantics, and proving both correct.
Background
The tree could find a single key but had no way to answer a range. Without a
chain,
WHERE id BETWEEN 200 AND 300means callingGetper key, paying a fullroot-to-leaf descent every time.
Node visits at order 64 over 100,000 keys:
Scanpays for one descent and amortises it, which is why the ratio grows withthe range rather than staying flat. Once nodes are pages, a node visit is a page
read — 316 reads against 40,000.
Design
Half-open bounds,
[from, to)fromincluded,toexcluded, anilbound unbounded. Half-open becauseranges then compose:
[0,10)followed by[10,20)covers everything once, withno gap and no double-count. Inclusive-inclusive would repeat the boundary on
every adjacent pair.
Bounds are positions, not keys that must exist.
Scan(25, 55)over10 20 30 40 50 60 70returns30 40 50— it starts at the first key>= 25and stops at the first key
>= 55. Neither bound is in the tree.The iterator holds a position, not rows
80 bytes and one allocation, whether the result is ten rows or ten million.
Scanreads no keys at all — it descends, then hands back{which leaf, which index}.Nexthides the leaf boundaryA loop rather than an
if, so several consecutive empty leaves are skippedrather than mistaken for the end. That cannot happen today — every non-root leaf
holds at least
(order-1)/2keys — but merging during deletion is exactly theoperation that could leave one briefly empty.
The caller writes
for it.Next()and never learns how many leaves it crossed.Lazy, which is what
LIMITrides onTen rows out of a hundred thousand costs 200 ns because the other 99,990 are
never touched. Nothing happens until
Nextis called.Streaming rather than materialising
Both walk the same 100,000 rows. This is the Volcano model: every operator
answers "give me the next row", operators stack, and rows are pulled from the
top — so a
LIMITat the top makes everything beneath it stop working, with nocoordination.
Why this is a B+Tree advantage specifically
A B-Tree stores values in internal nodes too, so an in-order scan climbs between
levels and its reads scatter across the file. A B+Tree keeps every value in a
leaf, so with the leaves chained a scan touches one level in order — random
reads become sequential ones, which is the difference that matters on a disk.
The decision to keep internal nodes as separators only was made in the previous
step; this is where it pays.
index.Iteratoris now satisfiedvar _ index.Iterator = (*Iterator)(nil)— the compiler refuses to build if theshapes diverge. This adds a second edge to the dependency graph:
btreenowimports
indexas well ascore.index.Indexis still deliberately not claimed.Deletedoes not exist, anddeclaring the interface early would be a lie the compiler cannot catch.
A new invariant
Validatenow checks the leaf chain reaches every leaf exactly once, in treeorder, and terminates. Node ordering and depth can all be correct while the
chain is broken, and the symptom is a scan that returns partial results with no
error — the worst failure mode available.
It has a negative test:
TestBrokenChainIsDetectedsevers the chain, thenmakes it cyclic, and asserts
Validaterejects both. An invariant nobody haswatched fail is not known to work. The cycle case is caught because the walk
counts leaves and errors once it exceeds the number the tree contains.
Defect found during review
Scanretained the caller'stoslice. A caller reusing a buffer for theupper bound could silently widen the range mid-iteration — a 10-row scan
returned 90 rows. Fixed by cloning the bound;
fromis only read during thedescent, and a test confirms it is not retained.
The same aliasing class as
codec.Bytestwo steps ago, which is why it wasprobed for deliberately.
Four other probes found nothing and were kept as regression tests:
Key()staying valid across later
Nextcalls, insert during iteration leaving thetree valid (you may miss keys inserted behind your position, which is ordinary
iterator semantics), and
Closebeing idempotent.Testing
89 tests.
go vet,gofmt -l,go test -race ./...clean.The two that carry the most weight:
TestScanMatchesBruteForce— 300 random ranges over 800 random inserts,each compared key-for-key against a linear filter over a sorted slice
FuzzScanMatchesBruteForce— the same property under the fuzzer;6.3M executions clean after the aliasing fix
Plus
TestScanBoundsThatMatchNothingcovering nine edge cases — entirely below,entirely above, empty, inverted, past the end, and each open-bound combination —
and
TestLeafChainIsAValidSequence, which validates after every one of 2,000inserts at three orders and confirms the chain holds exactly
Len()keys.A measurement I discarded
The first version of the node-visit comparison simulated "no sibling pointers"
by re-descending for each successive key. That simulation was itself broken: it
stopped silently at leaf boundaries, because re-descending for a key just past a
leaf's last entry lands back in the same leaf. Rather than publish a misleading
benchmark it was replaced with
Getin a loop, which is both correct and whatsomeone would actually write without a range API.
Not in scope
minimum-keys invariant and the chain loop are both already written in
anticipation.
prevpointer per leaf, doubling the linkmaintenance for an operation nothing needs yet.
tree valid but the iterator's view is undefined.
nextis still a pointer. On diskit becomes a
PageIDand the hop becomes a page read.