Skip to content

Chain the B+Tree leaves and add range scans - #6

Merged
suhailopensource merged 1 commit into
mainfrom
step/0x07-range-scans
Sep 8, 2026
Merged

suhailopensource merged 1 commit into
mainfrom
step/0x07-range-scans

Conversation

@suhailopensource

Copy link
Copy Markdown
Member

Summary

Chains the B+Tree's leaves and adds Scan(from, to) — a cursor that descends
once and then walks sideways along one level.

One line in splitLeaf does the structural work:

right.next = n.next
n.next = right

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 300 means calling Get per key, paying a full
root-to-leaf descent every time.

Node visits at order 64 over 100,000 keys:

range     Scan (chained)   Get in a loop   ratio
10        4                40              10x
100       7                400             57x
1,000     35               4,000           114x
10,000    316              40,000          127x

Scan pays for one descent and amortises it, which is why the ratio grows with
the 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)

from included, to excluded, a nil bound unbounded. Half-open because
ranges then compose: [0,10) followed by [10,20) covers everything once, with
no 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) over
10 20 30 40 50 60 70 returns 30 40 50 — it starts at the first key >= 25
and stops at the first key >= 55. Neither bound is in the tree.

The iterator holds a position, not rows

type Iterator struct {
	n    *node
	i    int
	to   []byte
	key  []byte
	rid  core.RowID
	done bool
}

80 bytes and one allocation, whether the result is ten rows or ten million.
Scan reads no keys at all — it descends, then hands back {which leaf, which index}.

Next hides the leaf boundary

for it.n != nil && it.i >= len(it.n.keys) {
	it.n = it.n.next
	it.i = 0
}

A loop rather than an if, so several consecutive empty leaves are skipped
rather than mistaken for the end. That cannot happen today — every non-root leaf
holds at least (order-1)/2 keys — but merging during deletion is exactly the
operation that could leave one briefly empty.

The caller writes for it.Next() and never learns how many leaves it crossed.

Lazy, which is what LIMIT rides on

asked for       rows      time
first 10        10        200 ns
first 1,000     1,000     7.4 µs
first 100,000   100,000   758.8 µs

Ten rows out of a hundred thousand costs 200 ns because the other 99,990 are
never touched. Nothing happens until Next is called.

Streaming rather than materialising

                          time      memory
streaming (iterator)      521 µs    80 B, 1 alloc
collecting into a slice   8.4 ms    18.5 MB, 100,030 allocs

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 LIMIT at the top makes everything beneath it stop working, with no
coordination.

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.Iterator is now satisfied

var _ index.Iterator = (*Iterator)(nil) — the compiler refuses to build if the
shapes diverge. This adds a second edge to the dependency graph: btree now
imports index as well as core.

index.Index is still deliberately not claimed. Delete does not exist, and
declaring the interface early would be a lie the compiler cannot catch.

A new invariant

Validate now checks the leaf chain reaches every leaf exactly once, in tree
order, 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: TestBrokenChainIsDetected severs the chain, then
makes it cyclic, and asserts Validate rejects both. An invariant nobody has
watched 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

Scan retained the caller's to slice. A caller reusing a buffer for the
upper bound could silently widen the range mid-iteration — a 10-row scan
returned 90 rows. Fixed by cloning the bound; from is only read during the
descent, and a test confirms it is not retained.

The same aliasing class as codec.Bytes two steps ago, which is why it was
probed for deliberately.

Four other probes found nothing and were kept as regression tests: Key()
staying valid across later Next calls, insert during iteration leaving the
tree valid (you may miss keys inserted behind your position, which is ordinary
iterator semantics), and Close being 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 TestScanBoundsThatMatchNothing covering 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,000
inserts 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 Get in a loop, which is both correct and what
someone would actually write without a range API.

Not in scope

  • Deletion. Borrowing and merging are harder than splitting. The
    minimum-keys invariant and the chain loop are both already written in
    anticipation.
  • Reverse scans. Would need a prev pointer per leaf, doubling the link
    maintenance for an operation nothing needs yet.
  • Concurrent iteration. No locking. Modifying during iteration keeps the
    tree valid but the iterator's view is undefined.
  • Pages. Nodes are still Go structs and next is still a pointer. On disk
    it becomes a PageID and the hop becomes a page read.

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.
@suhailopensource
suhailopensource merged commit 55722cf into main Sep 8, 2026
1 check passed
@suhailopensource
suhailopensource deleted the step/0x07-range-scans branch September 8, 2026 05:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant