B+Tree Leaf Node and INSERT
Turn page 0 into a leaf node, and make INSERT put a row in it
Give a page the layout of a B+Tree leaf, a 14-byte header and then 64-byte cells of key and row; create the root leaf in page 0 the first time the tree is needed; make INSERT check the statement, serialize the row and store it as a new cell; and show the leaf with btree dump.
- The node page: a fixed header that says what the page is and how full, then an array of equal-sized cells
- The cell: the key the tree is ordered by, then the row it belongs to
- The root lives at a fixed page, page 0, so it can always be found without looking anything up
- Creating the root lazily, and only in an empty file: opening a database must never reset its tree
- INSERT end to end: parse, check names and values, serialize, store; a statement that fails changes nothing
- A node has a capacity, and a full node is an error until the tree learns to split
Why this stage exists
You can turn a row into 60 bytes, and you can keep pages in a file across restarts. Nothing connects the two yet: an INSERT has nowhere to go. This stage gives it a place. Page 0 becomes a leaf node, the bottom level of the B+Tree the next stages grow, and INSERT puts each row into it as a cell.
A leaf is the smallest tree there is: one node, no children. It is still worth building as a tree node from the start, because everything that comes later works on the same page layout. The next stage keeps the cells sorted and finds keys by binary search. The stage after it splits a full leaf in two and adds the first internal node above them. None of that changes how a leaf stores its cells, which is what you build now.
What changes: a page with a header and cells
Until now a page was 4096 bytes with no meaning. A node page has a structure: a header of 14 bytes that says what kind of node the page is and how full it is, then the cells, one after the other.
| Bytes | Field | In a leaf |
|---|---|---|
| 0 | node type | 1 for a leaf. (2 is the internal node of a later stage; 0 is never a node, so a page of zeros is not mistaken for an empty leaf.) |
| 1 | is root | 1 for page 0 |
| 2–5 | parent page | 0 for the root. The first leaf with a parent appears when a leaf splits |
| 6–9 | count | the number of cells |
| 10–13 | next leaf | 0: there is no other leaf yet |
| from 14 | cells | 64 bytes each: the 4-byte key, then the 60-byte row |
Every number is an unsigned integer with its least significant byte first, like id in the row format. The key is the row's id, stored once more in front of the row. That looks redundant, and it is what lets a tree compare keys without knowing anything about rows.
This is page 0 after inserting (2, 'bo', 'b@e') and then (5, 'dan', 'd@e'): the header, and the first of the two cells.
- typebytes 0–0 (1 bytes) · 1: a leaf
- is rootbytes 1–1 (1 bytes) · 1: this is page 0
- parentbytes 2–5 (4 bytes) · 0: the root has none
- countbytes 6–9 (4 bytes) · 2 cells
- next leafbytes 10–13 (4 bytes) · 0: none
- keybytes 14–17 (4 bytes) · cell 0: key 2
- row idbytes 18–21 (4 bytes) · the row's own id, 2
- row namebytes 22–49 (28 bytes) · 'bo', then zeros
- row emailbytes 50–77 (28 bytes) · 'b@e', then zeros
The root, and when it is created
The root of the tree is always page 0. That is a rule, not an accident: every search starts at the root, and a fixed page number means the program never has to look up where the root is.
Page 0 does not exist in a new file, and opening the file does not create it. The root is created the first time a command needs the tree: btree dump or an INSERT. Before that, pager status still reports total_pages=0, exactly as in the pager stage.
The rule that matters most is the other half: the root is created only when the file has no pages. A file with pages already has its root, with its cells in it. A program that writes an empty leaf into page 0 at every start works perfectly in one session and empties the table at every restart.
INSERT, from statement to cell
An INSERT goes through every layer you have built. The parser gives you the table, the columns and the values. Then come the checks this stage adds: the table must be users (00401), each column must be id, name or email (00402), and id must be there (00406). A column that is left out is an empty string. The row format's checks follow, as in the row-serialization stage: 00403 for a value of the wrong type, 00404 for an id out of range, 00405 for a string of more than 27 bytes. Last comes the leaf's own check, below: it must have room for one more cell.
Only then does anything change. The row is serialized, the cell goes in at the position the count gives, the count grows by one, and the program prints Insert statement executed successfully. Every check comes before the page changes, which is what makes "a failed statement changes nothing" easy to keep true.
In this stage a new cell simply goes after the last one, so the cells are in the order the rows arrived. The next stage keeps them sorted by key.
How many cells fit
A leaf holds at most 8 cells in this tutorial. That is a test-mode number, chosen so a full leaf takes eight inserts and a test that fills one runs in an instant.
The 9th INSERT into a full leaf is error 00702, and it changes nothing: the leaf still holds 8 cells and the row is not stored anywhere. That is a temporary answer. From btree-leaf-split on the leaf splits instead, and 00702 is no longer what a 9th row gets.
The algorithm in words
Find or create the root
Every btree command and every SQL statement starts here.
- 1.If the file has no pages, allocate page 0 and write the header of an empty root leaf: node type 1, is root 1, and zeros for parent, count and next leaf.
- 2.Otherwise page 0 already holds the root. Read it; do not write it.
- 3.Invariant: a file that has pages is never re-initialised. The root is created exactly once in the life of a database file.
Check an INSERT before it touches the page
Every check happens before the first byte of the page changes.
- 1.The table must be users (00401), every column one of id, name and email (00402), and id must be given (00406). Columns left out are empty strings.
- 2.The values must fit the row format: a number for id, in range (00403, 00404); strings of at most 27 bytes for name and email (00403, 00405).
- 3.The leaf must have room: with 8 cells already, the insert is 00702.
- 4.Invariant: an INSERT that reports an error leaves the leaf, its count and the file exactly as they were.
Store the row as a new cell
In this stage a new cell always goes after the last one.
- 1.Serialize the row into its 60 bytes, as in the row-serialization stage.
- 2.The new cell's position is the current count: cell 0 for the first row. Write the key, then the row, at that cell's place after the header.
- 3.Add one to the count in the header, and mark the page as changed so it is saved.
- 4.Print the success line.
- 5.Invariants: the count equals the number of cells; cells never overlap the header or each other; no earlier cell changed.
Output contract for this stage
These are the exact lines the tests check. The full definition is on the output contract page, sections 3, 6, 9 and 10, including the page layout above.
| Input | Output |
|---|---|
insert into users (...) values (...); | Insert statement executed successfully |
btree dump or btree dump <page>, a leaf | [BTREE] Page <page>: type=LEAF num_cells=<n>, then one line per cell, cell 0 first: [BTREE] Cell <i>: key=<k> (60 bytes) |
an INSERT into a table other than users | [ERROR:00401] <message> |
an INSERT naming a column users does not have | [ERROR:00402] <message> |
| a value of the wrong type, an id out of range, a string over 27 bytes | [ERROR:00403], [ERROR:00404], [ERROR:00405] |
an INSERT without id | [ERROR:00406] <message> |
| an INSERT into a leaf with 8 cells | [ERROR:00702] <message> |
btree dump of a page that was never allocated | [ERROR:00601] <message> |
btree dump with a page number that is not digits | [ERROR:00104] <message> |
There are three spaces between [BTREE] and Cell. The page in the file is part of the contract too: the header and cells laid out as in the table above, so the tests read the file to check a row really is where the layout says.
What you should see when done
$droid --db users.dbWelcome to droid!>pager status[PAGER] Status: total_pages=0 cached=0>insert into users (id, name, email) values (1, 'ada', '[email protected]');Insert statement executed successfully>insert into users (id) values (2);Insert statement executed successfully>insert into people (id) values (3);[ERROR:00401] Unknown table: people>insert into users (id, name, email) values (4, 'abcdefghijklmnopqrstuvwxyz01', 'x');[ERROR:00405] name is 28 bytes, at most 27 fit>btree dump[BTREE] Page 0: type=LEAF num_cells=2[BTREE] Cell 0: key=1 (60 bytes)[BTREE] Cell 1: key=2 (60 bytes)>pager status[PAGER] Status: total_pages=1 cached=1>.exitexiting.. good bye!$droid --db users.dbWelcome to droid!>pager status[PAGER] Status: total_pages=1 cached=0>btree dump 0[BTREE] Page 0: type=LEAF num_cells=2[BTREE] Cell 0: key=1 (60 bytes)[BTREE] Cell 1: key=2 (60 bytes)>.exitexiting.. good bye!
The first INSERT created page 0; before it, there were no pages. The two failed INSERTs left no trace in the leaf. The second session found one page in the file, read nothing until btree dump needed page 0, and found both rows still there. The error messages are yours to word; the tests check only the codes.
Common bugs in this stage
- Resetting the root at every start. Writing an empty leaf into page 0 whenever the program opens the file. Every single-session test passes and the second session shows
num_cells=0. Create the root only when the file has no pages. - Creating the root when the file is opened. Then a fresh database reports
total_pages=1before anything has used the tree. Create it when a command first needs it. - Changing the page before the checks. Writing the cell, or adding to the count, and then finding the name too long. Do every check first; an error leaves the page alone.
- Cutting a long string to fit. A 28-byte name is 00405. Storing its first 27 bytes quietly changes the user's data.
- Forgetting that the page changed. The cell is in memory and never reaches the file, because the page was not marked as changed. The persistence stage saves pages allocated or written in the session; an INSERT writes to page 0.
- Cells overlapping the header. Putting cell 0 at byte 0, or at the size of some structure in your language, which may include padding. Cell
istarts after the 14-byte header, 64 bytes after celli − 1. - A signed key. Id 4294967295 printed as -1. The key is unsigned, like
id. - Letting the 9th cell in. A leaf of 4096 bytes has room for 63 cells, so nothing breaks at the 9th unless you check. The limit is 8.
- Big-endian or native byte order. The counts and keys are least significant byte first on every machine, like
idin the row format.
Language notes
The page above is the same for every language. These are the only language-specific hints.
- C and C++: a struct laid over the page is tempting, but the compiler adds padding: a struct with a 1-byte field followed by a 4-byte field is usually 8 bytes, not 5. Read and write each field at its offset instead, with
memcpyinto and out of a fixed-width integer, and convert the byte order if your machine is not little-endian. - Rust:
u32::from_le_bytesandto_le_bytesover slices of the page read and write each field.u32::try_fromon the parsed id rejects values past 4294967295. - Zig:
std.mem.readInt(u32, page[6..10], .little)andstd.mem.writeIntread and write a field at its offset in the given byte order. Anextern structlaid over the page has C layout, padding included, so reading each field at its offset is the simpler path. - Every language: keep the offsets in named constants, one per field, and derive the cell offset from them. A layout written as numbers in several places drifts.
Checklist
One line per thing the tests check. The tags name the test cases.
- The root of a new database is an empty leafbtree-dump-empty
- The root is created by the first btree command, not by opening the file, and is a page of the filebtree-root-created-lazily
btree dumpwith no page dumps page 0btree-dump-default- An INSERT prints the success line and adds one cellbtree-insert-one-dump
- Three INSERTs make three cells, in the order they arrivedbtree-insert-three-dump
- The key is the row's id, up to 4294967295btree-insert-custom-key
- An INSERT of only the id stores empty stringsbtree-insert-id-only
- The leaf's bytes in the file follow the layout: header, then key and row per cellbtree-leaf-file-layout
- A leaf holds 8 cellsbtree-leaf-fill-to-capacity
- The 9th cell is 00702 and changes nothingbtree-leaf-overflow
- Unknown table, unknown column and a missing id are 00401, 00402 and 00406, and add no cellbtree-insert-name-errors
- Wrong type, id out of range and a string too long are 00403, 00404 and 00405, and add no cellbtree-insert-value-errors
- The leaf survives a restart, and the root is not created againbtree-leaf-persists
- An INSERT run with -c exits 0 and is savedbtree-insert-c-mode
btree dumpof a page that does not exist is 00601, of a page number that is not digits 00104btree-dump-out-of-bounds