droid
B+Tree Leaves on Slotted Pages
Flexible Storage · Stage 3 of 8

B+Tree Leaves on Slotted Pages

Leaves that hold rows of any length, and are full when the bytes run out

Store the B+Tree's leaves as slotted pages holding variable-length rows: a cell is a key and a row in a record, its slot sits in key order, and a leaf is full when the next cell does not fit, not after a fixed count. Lift the string limit to 1000 bytes and refuse rows over 1000.

Not run yet · 15 tests
Concepts in this stage
  • A leaf is a slotted page: the slots are kept in key order, the records can sit anywhere in the record area
  • Capacity in bytes: how many rows a leaf holds depends on how long they are
  • Binary search over the slot directory, reading each key through its slot
  • A row limit that guarantees any four cells fit in one leaf, so a split always has somewhere to put both halves
  • Retiring a test-mode constant: the eight-cell leaf goes, the three-key internal node stays
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParser
Validator
Answering a query
PlannerExecutor
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

The two previous stages built a row that takes only the bytes it needs and a page that holds records of any length. Neither changed what the database stores: the B+Tree still keeps 64-byte cells, each a key and a 60-byte row, eight to a leaf. This stage puts the pieces together. A leaf becomes a slotted page, and its cells become records of whatever length their rows have.

Two things follow. A leaf no longer holds a fixed number of rows: it holds as many as fit, 140 short ones or four long ones. And the string limit of 27 bytes, which existed only because a field had to be 28 bytes wide, can go. From this stage name and email hold up to 1000 bytes each.

This is also the stage that retires the Core Engine's test-mode leaf. Eight cells per leaf kept the tests small while splitting was new; with capacity in bytes there is no count to shrink, and a leaf is exactly as big as a real one.

What changes: the leaf is a slotted page

A leaf's header is the slotted page's header with type 1. Every field keeps its Core Engine meaning: is-root, the parent, the count of cells, the link to the next leaf. The two bytes after them are the record start. Slot i is cell i, and a cell is the 4-byte key followed by the row:

0
01
00
00
00
15
00
00
00
03
00
00
01
00
13
00
00
03
00
64
61
6e
03
00
64
40
65
  • keybytes 0–3 (4 bytes) · 1, the row's id again, so the search reads it without decoding the row
  • rowbytes 4–24 (21 bytes) · the row of varlen-rows: (1, 'dan', 'd@e')
One cell: 25 bytes. Its slot says where it starts and that it is 25 bytes long; btree dump shows it as (21 bytes), the row's size, as it showed (60 bytes) before.

The slots are sorted; the records are not. Here is a leaf after inserting keys 2, 1 and 3, in that order, with one way the records can land:

Inserted 2, then 1, then 3
0–15
header
slots = 3
slot 0
@4028
25 B
slot 1
@4053
43 B
slot 2
@4009
19 B
28–4008
→ gap ←
3981 B
@4009
slot 2
'key 3'
19 B
@4028
slot 0
'key 1'
25 B
@4053
slot 1
'key 2'
43 B
4096
free_space = 3981
Slot 0 holds key 1, slot 1 key 2, slot 2 key 3: the directory is in key order, so a binary search over it works as over the Core Engine's cells. The records sit in the order they arrived. Where they sit is your choice; the tests read the leaf through its slots.

Inserting in the middle now moves slots, not rows. Key 2 arriving between 1 and 3:

Before: keys 1 and 3
0–15
header
slots = 2
slot 0
@4071
25 B
slot 1
@4052
19 B
24–4051
→ gap ←
4028 B
@4052
slot 1
'key 3'
19 B
@4071
slot 0
'key 1'
25 B
4096
free_space = 4028
After inserting key 2
0–15
header
slots = 3
slot 0
@4071
25 B
slot 1
@4009
43 B
new
slot 2
@4052
19 B
28–4008
→ gap ←
3981 B
@4009
slot 1
'key 2'
43 B · new
@4052
slot 2
'key 3'
19 B
@4071
slot 0
'key 1'
25 B
4096
free_space = 3981
The record of key 2 goes below the others; its slot goes between key 1's and key 3's. Key 3's slot moves one place, 4 bytes. Its record, 19 bytes, does not move at all. In the Core Engine the whole 64-byte cell moved.

Full is a number of bytes

A cell needs its record, 4 + row size bytes, and a slot of 4. It fits when the leaf's free space covers both, where free space is everything no header, slot or cell uses: 4080 − the sum of 8 + row size over the cells. That is the only test for full. Rows of 21 bytes take 29 bytes each, so a leaf holds 140 of them and has 20 bytes left, too few for a 141st. Rows of 1000 bytes take 1008, and a leaf holds four.

When the cell does not fit, the leaf splits. Everything about the split is what the Core Engine built: the left half keeps the page (or, for the root, both halves go to new pages), the right leaf takes over the link, the separator is the right leaf's first key. Only the division changes. In this stage any division that leaves both halves non-empty and each fitting its page is right, halving by count included. The next stage, varlen-btree-split, makes it a division by bytes and says why.

The row limit makes splits safe

A split has to put every cell, the new one included, into two pages. That is only always possible if no cell is too large. With rows of at most 1000 bytes, a cell is at most 1004 bytes and needs 1008 with its slot, so any four cells fit in an empty leaf's 4080 bytes. A full leaf plus one new cell always divides into two halves that fit.

So a row over 1000 bytes is refused, with 00407, before the tree is touched. That can happen with both strings inside their own limit of 1000: a 600-byte name and a 400-byte email make a row of 1015 bytes. The first check that fails decides the code: a string over 1000 bytes is 00405, as it always was, and only a row whose strings are each fine but too long together is 00407. An UPDATE that would make a row too large is 00407 too, and changes nothing.

Real engines don't refuse such rows. They store what does not fit on overflow pages and keep only a part in the leaf. That comes later, in Memory and Space. Until then 1000 bytes is the limit.

Delete and update

DELETE removes the cell's slot; the slots after it move up one place, exactly as the cells did in the Core Engine. The record's bytes are free at once. Whether they stay as a hole or the leaf compacts is up to you: free_space counts them either way.

UPDATE can change a row's size now. A row that still fits in its leaf, with the old one gone, stays in the same slot, so btree find reports the same page and cell. A row that no longer fits needs room the leaf does not have, and the leaf splits as it would for an insert of it. The tests of this stage only grow rows that fit; the next stage tests the split.

What stays the same

The pager, the file, SELECT, WHERE, explain, the counters and every error code but one are unchanged. Internal nodes keep their 14-byte header and 8-byte entries, and their test-mode limit of 3 keys: a real internal node would hold 510, and a tree of depth 3 would need tens of thousands of rows. The root is still page 0.

The algorithm in words

1

Read a leaf

A leaf is the slotted page of slotted-page with type 1. Its header fields keep their Core Engine meanings.

  1. 1.The count at bytes 6–9 is the number of cells; the link at 10–13 is the next leaf; bytes 14–15 are the record start.
  2. 2.Cell i is found through slot i: its offset and length point at a record that holds the 4-byte key, then the row of the previous stages.
  3. 3.Invariants: slot i holds the i-th smallest key; a leaf has no deleted slots; every slot points inside the record area and no two records overlap.
2

Search a leaf

The search of btree-search, over slots instead of cells.

  1. 1.Binary search the slots by the key each one points at. The result is the same as before: the position where the key is, or where it belongs.
  2. 2.Reading a key costs a slot read and a record read; nothing about the search depends on how long the rows are.
3

Insert into a leaf

Check first, then place the cell, then split only if it does not fit.

  1. 1.Check the row as before; then its size. A row over 1000 bytes is 00407, before the tree is touched. A key the table has is 00701, as before.
  2. 2.The cell needs 4 + row size bytes of record and 4 of slot. If the leaf's free space covers both, write the record into the record area (compacting first if the room is only in holes) and insert its slot at the key's position, moving the later slots one place. The records do not move.
  3. 3.If it does not fit, the leaf splits as in the Core Engine: same pages, same links, same parents, the separator the right half's first key. Divide the cells, the new one among them, anywhere that leaves both halves non-empty and each fitting its page. The next stage decides where exactly.
  4. 4.Invariant: after every statement, each leaf's free space is 4080 less 8 bytes and the row's size for each cell.
4

Delete and update

Rows change size now, and the leaf keeps count in bytes.

  1. 1.DELETE removes the cell's slot; the later slots move one place towards the start. Its bytes are free at once, as a hole or compacted away, your choice: free space counts them either way.
  2. 2.UPDATE builds the new row and checks it (00407 over 1000 bytes) before changing anything. If the new cell fits in the leaf once the old one is gone, it replaces it in the same slot. If not, the leaf splits as for an insert of the new cell.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, sections 14, 15 and 17.

InputOutput
btree dump [page], a leaf[BTREE] Page <p>: type=LEAF num_cells=<n> free_space=<f>, then per cell [BTREE] Cell <i>: key=<k> (<row size> bytes)
btree dump, an internal node; btree find; btree structureunchanged
a row over 1000 bytes, in INSERT, UPDATE or serialize[ERROR:00407] <message>
a string over 1000 bytes[ERROR:00405] <message>

free_space is 4080 less 8 + row size for every cell. The limits of the users columns are now 1000 bytes, and a row, its header and length prefixes included, is at most 1000 bytes. A leaf never refuses a row with 00702: from this stage a full leaf always splits.

The Core Engine cases that check the old leaf (its bytes in the file, btree dump lines without free_space, splits after eight cells and the depths that follow from them, the 27-byte limit, here and in varlen-rows) are retired here, by name, in the manifest. Everything they checked about SQL is checked again by this stage's cases on the new leaves.

What you should see when done

droid session
Welcome to droid!
>btree dump
[BTREE] Page 0: type=LEAF num_cells=0 free_space=4080
>insert into users (id, name, email) values (2, 'danimar', 'danimar@email.com');
Insert statement executed successfully (1 rows)
>insert into users (id, name, email) values (1, 'dan', 'd@e');
Insert statement executed successfully (1 rows)
>insert into users (id, name, email) values (3, '', '');
Insert statement executed successfully (1 rows)
>btree dump
[BTREE] Page 0: type=LEAF num_cells=3 free_space=3981
[BTREE] Cell 0: key=1 (21 bytes)
[BTREE] Cell 1: key=2 (39 bytes)
[BTREE] Cell 2: key=3 (15 bytes)
>btree find 2
[BTREE] Find key=2: FOUND (page=0 cell=1)
[BTREE] Row: id=2 name='danimar' email='danimar@email.com'
>update users set name = 'dani' where id = 2;
Update statement executed successfully (1 rows)
[EXEC] rows_examined=1
>btree dump
[BTREE] Page 0: type=LEAF num_cells=3 free_space=3984
[BTREE] Cell 0: key=1 (21 bytes)
[BTREE] Cell 1: key=2 (36 bytes)
[BTREE] Cell 2: key=3 (15 bytes)
>delete from users where id = 1;
Delete statement executed successfully (1 rows)
[EXEC] rows_examined=1
>btree dump
[BTREE] Page 0: type=LEAF num_cells=2 free_space=4013
[BTREE] Cell 0: key=2 (36 bytes)
[BTREE] Cell 1: key=3 (15 bytes)
>.exit
exiting.. good bye!

Things to notice. The first dump shows an empty leaf with 4080 bytes free. The rows went in as 2, 1, 3 and the cells list them as 1, 2, 3. Shortening danimar to dani gave 3 bytes back, and deleting row 1 gave back its 21 bytes and 8 more.

Common bugs in this stage

  • Sorting the records instead of the slots. Moving whole records on every insert works, and it is the thing slotted pages exist to avoid. Keep the slots in order and let the records sit where they landed.
  • Free space that forgets the key or the slot. A cell costs row size + 8, not row size.
  • Splitting on a count. A leaf of short rows that splits at 8 cells, or at any fixed number, is full too early. A leaf is full when the next cell does not fit.
  • A split that ignores sizes. Halving by count is allowed in this stage, but check that each half fits its page; three long rows and many short ones can put too much in one half.
  • 00407 after the tree has changed. Check the row's size before the duplicate check and before touching any page, and build every new row of an UPDATE before writing the first.
  • 00407 for a string over 1000 bytes. That is still 00405, checked first.
  • Reading the key out of the row. It works for users, where id is at a known offset, but the cell starts with the key so that the search never decodes a row. Read the key the cell gives you.
  • A root leaf without its record start. An empty leaf has a record start of 4096, 00 10. A zero there makes the first cell land at a negative offset.
  • The old leaf left behind in a code path. btree find, the cursor, DELETE and UPDATE all read leaves. Change them all, or one of them reads 64-byte cells out of a slotted page.

Language notes

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

  • C and C++: the slotted-page code of the previous stage can serve the leaf as it is, if it takes the page type as an argument. Inserting a slot in the middle is one memmove of the slots after it.
  • Rust: a leaf view over &mut [u8; 4096] with methods for slot i keeps the offsets in one place. copy_within shifts the slot directory.
  • Zig: a struct holding a *[4096]u8 with inline functions for the header fields and slots does the same; std.mem.copyBackwards shifts slots towards the end.

Checklist

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

  • A new root leaf is an empty slotted page: 01 01, zeros, 00 10
    leaf-root-bytes
  • btree dump gives free_space and each row's size
    leaf-cell-sizes
  • Each insert takes its row plus 8 bytes
    leaf-free-space-tracks
  • In the file, slots are in key order and each points at its key and row
    leaf-file-layout
  • Fifty short rows fit in one leaf
    leaf-holds-more-rows
  • 140 rows of 21 bytes fill a leaf to 20 bytes; the 141st splits it
    leaf-capacity-in-bytes
  • Strings far past 27 bytes are stored and read back
    long-strings-stored
  • A row over 1000 bytes is 00407; a string over 1000 is 00405
    row-too-large
  • An UPDATE that makes a row too large is 00407 and changes nothing
    update-too-large
  • Cells stay in key order and find reports each key's rank
    leaf-sorted-and-find
  • A duplicate id is 00701 and changes nothing
    duplicate-key
  • Deleting gives the bytes back at once
    leaf-delete-frees
  • An UPDATE that changes the row's size keeps its place
    leaf-update-resizes
  • 600 shuffled rows keep every tree invariant and come back in order
    many-rows-split
  • Variable rows survive a restart
    varlen-leaf-persists