droid
B+Tree Search and SELECT
The B+Tree · Stage 9 of 16

B+Tree Search and SELECT

Keep the leaf sorted, find a key without looking at every cell, and read the table back

Keep the cells of the leaf in key order, find any key with binary search, reject duplicate keys, and answer SELECT by walking the table with a cursor.

Not run yet · 20 tests
Concepts in this stage
  • A sorted leaf: every insert lands in its ordered position, not at the end
  • Binary search: halve the candidates at each step, and get the insertion position for free when the key is absent
  • Primary key uniqueness: the same search that finds a key is the one that rejects a duplicate
  • The cursor: one small object that knows where you are in the table, so SELECT never touches page layout
  • Keys are unsigned: 4294967295 is the largest id, not -1
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParser
Validator
Answering a query
Planner
Executor
Keeping changes safe
Transactions
Write-ahead log
Undo log
Lock manager
Read views
Storing rows
Row format
Slotted page
B+Tree
Reaching the disk
PagerDatabase file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

At the end of the previous stage, INSERT appended each new cell after the last one. The rows are stored, but the table cannot answer the two questions a table exists to answer: is this key here? and what is in the table? Answering the first by reading every cell works on eight rows and fails on eight million. And the next stage, splitting a full leaf in two, only makes sense if the cells are in order: "the lower half goes left, the upper half goes right" means nothing on an unsorted page.

So this stage makes one promise and builds two features on it. The promise: the keys in a leaf are always in ascending order. The features: finding a key quickly, and reading the whole table back in order.

What changes in the leaf

Nothing changes in the page layout from the previous stage. What changes is where a new cell goes. Insert key 2 into a leaf that holds keys 1 and 3:

Before: the leaf holds keys 1 and 3
header
cells = 2
cell 0
key = 1
cell 1
key = 3
will move
free space
After inserting key 2
header
cells = 3
cell 0
key = 1
cell 1
key = 2
new
cell 2
key = 3
moved
free space
Each cell is 64 bytes. A cell that moves one position moves by 64 bytes.
Key 2 belongs between 1 and 3. The cell holding key 3 moves one position to the right, and key 2 is written into the position it left. Cell 0 does not move.

Each cell is a 4-byte key followed by the 60-byte row, and the row moves with its key: btree dump shows only the keys, so a leaf whose keys moved without their rows looks right there and is wrong everywhere else. The tests read the file to catch it. The source and the destination of that move overlap, so use whatever your language offers for moving overlapping memory safely; a plain forward copy will overwrite cells before it has moved them.

Why binary search, and why it returns a position

In a sorted leaf, one comparison with the middle key rules out half of the cells. Eight cells need at most four comparisons; a real leaf of 63 cells needs at most six. A linear scan would need up to 63.

Compare keys as unsigned 32-bit numbers, the way they are stored. Read as signed, 4294967295 is −1 and sorts before 0.

The detail that makes the rest of the stage easy: when the key is not there, the search does not just say "no". The place where it gave up is the place where the key belongs. That single result is what btree find reports, what INSERT uses to reject a duplicate, and what INSERT uses to pick the slot. Write the search once and have all three call it.

The position is also what btree find reports as cell: where the key is at the moment you ask, not where it was first written. Key 3 inserted first and followed by keys 1 and 2 is in cell 2.

Why a cursor

SELECT could loop over the cells of page 0 directly, and today that would work. It stops working in btree-leaf-split, when the table spans many leaves. A cursor puts the question "what is the next row?" in one place. Today its answer is "the next cell". Later it becomes "the next cell, or the first cell of the next leaf", and SELECT will not change by a single line.

SELECT, from statement to lines

The parser has been giving you the select list since the parser stage. SELECT is where it first reaches the output. * means all three columns in table order; select email, id from users; means those two, in that order, in the header and in every row. Check the names before reading any row: a table other than users is 00401 and a column users does not have is 00402, the same codes an INSERT gets, and the error is the only line printed.

In this stage SELECT has no WHERE: it returns every row. Filtering comes in its own stage later in this part.

The algorithm in words

1

Find a key, or the place where it belongs

One search serves three callers: btree find, the duplicate check in INSERT, and the choice of slot for a new cell. It must answer two things at once: is the key here, and at which position.

  1. 1.Consider all the cells of the leaf as candidates.
  2. 2.Compare the wanted key with the key of the middle candidate. If they are equal, the key is found at that position.
  3. 3.If the wanted key is smaller, only the candidates before the middle remain. If it is larger, only those after it remain.
  4. 4.Repeat until a key matches or no candidates remain.
  5. 5.When nothing remains, the position where the search stopped is exactly where the key would have to be inserted to keep the leaf sorted. Return it together with 'not found'.
  6. 6.Invariant to check yourself against: with n cells the search makes at most about log2(n) + 1 comparisons, and the returned position is always between 0 and n inclusive.
2

Insert in order

Appending was enough in the previous stage. Now every insert must leave the leaf sorted.

  1. 1.Search for the key. If it is found, stop: report the duplicate key error and change nothing.
  2. 2.Otherwise the search gave you the position for the new cell.
  3. 3.Make room: every cell from that position onward moves one cell to the right. Cells before it do not move.
  4. 4.Write the key and the serialized row into the freed position, and increase the cell count.
  5. 5.Invariants: after every insert the keys read in strictly increasing order; the cell count grew by exactly one; no existing row changed.
3

Walk the table with a cursor

SELECT should not know how a page is laid out. A cursor hides it behind three operations.

  1. 1.Start: position the cursor at the first cell of the table. On an empty table the cursor starts already at the end.
  2. 2.Read: give back the row under the cursor, decoded with the row format from Row Serialization.
  3. 3.Advance: move to the next cell. When there is no next cell, the cursor is at the end.
  4. 4.Before reading anything, check the statement: the table must be users (00401) and every column in the select list one of its columns (00402). * stands for id, name and email, in that order.
  5. 5.SELECT prints the header, the selected column names joined by ' | ', then reads and advances until the cursor is at the end, printing the selected values of each row in the same order and counting rows, then prints the footer.
  6. 6.Invariant: because the leaf is sorted, the rows come out in ascending id order without any sorting step.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, sections 3, 9 and 10.

InputOutput
btree find <key>, key present[BTREE] Find key=<k>: FOUND (page=<p> cell=<i>) then [BTREE] Row: id=<id> name='<name>' email='<email>'
btree find <key>, key absent[BTREE] Find key=<k>: NOT_FOUND
btree find with no key, a key that is not digits, or past 4294967295[ERROR:00104] <message>
select * from users;the header id | name | email, one line per row as <id> | <name> | <email>, then (<n> rows)
select email, id from users;the header email | id, one line per row as <email> | <id>, then (<n> rows)
SELECT from a table that is not users[ERROR:00401] <message>
SELECT of a column users does not have[ERROR:00402] <message>
INSERT of an existing key[ERROR:00701] <message>, one line, and nothing else

The footer always says rows, also for (0 rows) and (1 rows). An empty table still prints the header. Strings print as stored, without the zeros that pad them: an empty name is name='' in btree find. Cells are numbered from 0. In this stage the page is always 0.

What you should see when done

shell
$droid --db users.db
Welcome to droid!
>select * from users;
id | name | email
(0 rows)
>insert into users (id, name, email) values (3, 'carol', 'carol@example.com');
Insert statement executed successfully
>insert into users (id, name, email) values (1, 'alice', 'alice@example.com');
Insert statement executed successfully
>insert into users (id, name, email) values (2, 'bob', 'bob@example.com');
Insert statement executed successfully
>btree dump
[BTREE] Page 0: type=LEAF num_cells=3
[BTREE] Cell 0: key=1 (60 bytes)
[BTREE] Cell 1: key=2 (60 bytes)
[BTREE] Cell 2: key=3 (60 bytes)
>btree find 3
[BTREE] Find key=3: FOUND (page=0 cell=2)
[BTREE] Row: id=3 name='carol' email='carol@example.com'
>btree find 9
[BTREE] Find key=9: NOT_FOUND
>insert into users (id, name, email) values (1, 'mallory', 'm@example.com');
[ERROR:00701] Key 1 already exists
>select * from users;
id | name | email
1 | alice | alice@example.com
2 | bob | bob@example.com
3 | carol | carol@example.com
(3 rows)
>select name, id from users;
name | id
alice | 1
bob | 2
carol | 3
(3 rows)
>.exit
exiting.. good bye!

Three things to notice. The rows were inserted as 3, 1, 2 and come back as 1, 2, 3. Key 3 was inserted first and is reported in cell 2, because the two later inserts moved it. And after the rejected insert, row 1 is still alice. The error messages are yours to word; the tests check only the codes.

Common bugs in this stage

  • The duplicate check runs after the insert. The error is printed, but the second row is already in the leaf, or the success message is printed as well. Search first, and return before touching the page.
  • Searching an empty leaf. With zero cells there is no middle cell to read. The search must answer "not found, position 0" without reading anything.
  • Off by one at the ends. Test inserting a key smaller than every key in the leaf and a key larger than every key. The positions must be 0 and n.
  • The move overwrites cells. A forward, cell-by-cell copy to the right destroys the cells it has not moved yet. Move from the last cell backwards, or use an overlap-safe move.
  • Moving the wrong amount. The cells to move are those from the insertion position to the end; the distance is one cell, 64 bytes, not 60 and not 4.
  • btree find prints the row from the wrong cell. If FOUND reports cell=2 but the row printed is another one, the search result and the read are using different positions.
  • SELECT forgets the empty table. A cursor on an empty table must start at the end, so the loop body never runs, and the header and (0 rows) are still printed.
  • Comparing keys as signed numbers. 4294967295 lands in cell 0, before key 0. Keys are unsigned.
  • Moving the keys but not the rows. The dump looks right, and btree find prints the wrong row for a key. A cell is 64 bytes, key and row, and moves as one.
  • btree find reports where the key was written. The cell is the key's position now. Inserting a smaller key moves it.
  • Ignoring the select list. Every SELECT prints all three columns. The header and each row follow the list, in its order.
  • Sorting inside SELECT. If SELECT sorts, the leaf is not sorted and btree dump will show it. Fix the insert, not the output.

Language notes

The page above is the same for every language. These are the only language-specific hints.

  • C and C++: memmove is the overlap-safe move; memcpy is not.
  • Rust: slice::copy_within moves a range inside one buffer and handles overlap.
  • Zig: std.mem.copyBackwards is the one to use when the destination is to the right of the source.

Checklist

One line per thing the tests check. The tags name the test cases.

  • Keys inserted out of order are stored in order, each row moving with its key
    btree-sorted-insert
  • A leaf filled in any order holds its 8 keys in order, and SELECT returns them in that order
    btree-sorted-full-leaf
  • Keys compare as unsigned numbers: 4294967295 goes last
    btree-sorted-unsigned
  • btree find <key> reports FOUND with the page and cell, then the row
    btree-find-existing · btree-find-after-multiple
  • The cell btree find reports is where the key is now, after the inserts that moved it
    find-reports-cell
  • An empty string prints as '' in the Row line
    btree-find-id-only
  • btree find <key> reports NOT_FOUND on an empty tree, and below, between and above the keys of a populated one
    btree-find-missing · find-missing-populated
  • btree find with no key, a key that is not digits or a key past 4294967295 is 00104
    btree-find-arguments
  • After a restart the keys are found in their cells, and a new key still goes in order
    find-after-reopen
  • Inserting an existing key prints 00701 and no success line, and adds no cell
    btree-duplicate-key-error
  • A refused duplicate, in the same session or the next, leaves the original row untouched
    duplicate-preserves-original
  • SELECT on an empty table prints the header and (0 rows)
    select-empty-table
  • SELECT prints the header, one line per row and the (n rows) footer
    select-after-insert · select-row-count
  • SELECT returns rows in ascending id order whatever the insertion order
    select-sorted-after-unordered-insert
  • The header and the rows follow the select list
    select-column-headers
  • SELECT from an unknown table is 00401, of an unknown column 00402
    select-name-errors
  • A SELECT run with -c prints the table and exits 0
    select-c-mode