droid
Splitting by Bytes; DELETE and UPDATE with Variable Rows
Flexible Storage · Stage 4 of 8

Splitting by Bytes; DELETE and UPDATE with Variable Rows

A full leaf divides where the bytes balance, and a row that grows can split its leaf

Divide a full leaf where its two halves' bytes are closest, not in the middle of its cells, with the incoming cell weighed among them. Make UPDATE handle a row that no longer fits: it stays in its leaf when the room its old row leaves is enough, and otherwise splits the leaf as an insert of the new row would.

Not run yet · 9 tests
Concepts in this stage
  • Dividing by bytes: the cut goes where the halves' used bytes are closest, whatever the count on each side
  • The incoming cell is part of the division, in its place among the keys
  • An UPDATE that grows is a remove and a put: room is counted after the old cell is gone
  • Room freed by a DELETE is room: the next cell that fits takes it, with no split
  • A deterministic rule: from the rows and their order, which keys each leaf holds is fixed
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 formatSlotted 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

In the previous stage a leaf became full when its bytes ran out, but how it split was left open: any division that fit was right, halving the count included. With rows of one size that is a fine answer. With rows of very different sizes it is not.

Take a leaf that holds three rows of 1000 bytes and thirty-seven of 21. Halving by count puts the three long rows and seventeen short ones on the left, 3517 bytes, and twenty short rows on the right, 580. The left leaf is full again after a couple of inserts, and splits again; the right one sits nearly empty. A B+Tree splits so that both halves have room to grow. Room is measured in bytes now, so the split has to be too.

This stage also finishes what variable rows do to UPDATE and DELETE. A row that grows may no longer fit in its leaf; a row that is deleted leaves room another row can take. Both follow from one idea: a leaf's free space is the only thing that decides whether a cell fits.

What changes: where the leaf divides

A cell weighs its row, 4 bytes of key and 4 of slot. Line the cells up in key order, the new one in its place, and cut where the two halves weigh closest to the same:

By bytes: the cut after key 2
keys 1–2
2 × 1008 B
key 3
1008 B
keys 4–39
36 × 29 B
key 40
29 B
new
left: 2 cells, 2016 B · free_space = 2064
right: 38 cells, 2081 B · free_space = 1999
40 cells, 4097 B in all; the halves differ by 65 B
By count: the cut after key 20
keys 1–3
3 × 1008 B
keys 4–20
17 × 29 B
keys 21–39
19 × 29 B
key 40
29 B
new
left: 20 cells, 3517 B · free_space = 563
right: 20 cells, 580 B · free_space = 3500
40 cells, 4097 B in all; the halves differ by 2937 B
The leaf of split-by-bytes: rows 1 to 3 are 1000 bytes, the rest 21, and key 40 does not fit. By bytes the halves weigh 2016 and 2081: two cells and thirty-eight. By count they weigh 3517 and 580, and the left half has 563 bytes to grow in.

The rule has three parts.

  • The halves' bytes are as close as they can be. Walk the points between cells, from after the first to before the last, and keep the one where the difference is smallest. With the cut in the best place, moving one cell across can only make it worse, so the halves differ by at most one cell and its slot.
  • The new cell counts. It is weighed in its place among the keys, not added to a half afterwards. A 1000-byte row arriving at the front of a leaf of short rows pulls the cut towards itself.
  • A tie can go either way. When two points are equally close, both are right. Rows of one size and an odd count tie: 141 rows of 21 bytes divide 70 and 71 or 71 and 70. The tests never build a tie.
Key 1 arrives with 1000 bytes in front of 140 short rows
key 1
1008 B
new
keys 2–54
53 × 29 B
keys 55–141
87 × 29 B
left: 54 cells, 2545 B · free_space = 1535
right: 87 cells, 2523 B · free_space = 1557
141 cells, 5068 B in all; the halves differ by 22 B
The cut falls after key 54: 2545 bytes on the left, 2523 on the right. Leaving the new cell out of the division would halve the 140 short rows, 70 and 70, and then add key 1 to the left: 3038 bytes against 2030.

Nothing else about the split changes. The page, link, parent and allocation rules are the Core Engine's: the root's halves go to two new pages, any other leaf keeps its page for the left half and the right half goes to one new page, and the separator is the right leaf's first key. Only where the cut goes is new.

Before: page 2 has a 1000-byte row and 37 short ones; keys 41 and 42 arrive with 1000 bytes each
internal · page 0
[3]
leaf · page 1
[1,2]
next → page 2
leaf · page 2 · full
[3,4,…,40]
next → none
After: page 2 keeps keys 3 to 39, page 3 takes 40, 41 and 42
internal · page 0 · changed
[3,40]
leaf · page 1
[1,2]
next → page 2
leaf · page 2 · changed
[3,4,…,39]
next → page 3
leaf · page 3 · new
[40,41,42]
next → none
Key 41 fits in page 2; key 42 does not. The cells, 4097 bytes with 42 among them, divide after key 39: 2052 bytes and 2045. Page 2 keeps the left half, the new page 3 takes the right, and the root gains the separator 40. Dividing by count would have left the new page with two 1000-byte rows and eighteen short ones.

A split that follows from the rows

Dividing by count left the Core Engine's tree fixed by the order of the inserts. Dividing by bytes does the same for variable rows: given the rows and the order they arrive in, which keys each leaf holds follows, whatever the internal nodes do above them. The tests use this. They replay hundreds of statements on a model of the leaves and expect btree structure to show exactly those leaves, left to right. A split that is almost by bytes, or by bytes without the new cell, drifts from the model after a few splits.

UPDATE when a row changes size

An UPDATE builds the new row and checks it before touching the tree, as before. What happens next depends only on bytes:

  • Take the old cell out, then see if the new one fits. The room that counts is the room the leaf has once the old cell is gone. A row of 21 bytes growing to 41 needs 20 more bytes; a leaf with 20 free has 49 once the old 29 are back, and the new cell needs exactly 49. It fits, and stays at the same page and cell.
  • If it does not fit, split as for an insert. The leaf's cells, with the new cell in the old one's place, divide by bytes. The row keeps its key, so it stays in key order; btree find may report a new page and cell.
Row 70 grows from 21 bytes to 1000 in a full leaf of 140 short rows
keys 1–69
69 × 29 B
key 70
1008 B
changed
keys 71–140
70 × 29 B
left: 70 cells, 3009 B · free_space = 1071
right: 70 cells, 2030 B · free_space = 2050
140 cells, 5039 B in all; the halves differ by 979 B
With the old cell gone the leaf has 49 bytes free, and the new cell needs 1008. The leaf splits, weighing the new row: the cut after key 70 leaves 3009 bytes and 2030, and row 70 ends as the last cell of page 1.

Checking whether the new row fits before taking the old one out asks for room the leaf already has, and splits a leaf that did not need it.

DELETE and the room it frees

DELETE is unchanged: the cell's slot goes, and its bytes are free at once. What this stage checks is that free really means usable. After deleting a row from a full leaf, a new row as large as the room freed goes in without a split, even though the bytes it takes back sit in the middle of the record area, behind other records. The slotted page compacts when the gap between the slots and the records is too small but the free space is not; the leaf does the same.

No leaf is merged and no page is freed. A leaf can end up empty, and a separator can point at keys that are gone: both are valid, as in the Core Engine.

The algorithm in words

1

Divide a full leaf by bytes

The split of the Core Engine, with a different answer to where.

  1. 1.Take the leaf's cells in key order with the new cell in its place. Each one weighs its row, 4 bytes of key and 4 of slot.
  2. 2.Walk the division points from after the first cell to before the last. At each, the left half weighs the cells before it and the right half the rest; keep the point where the two are closest. Neither half is ever empty.
  3. 3.When two points are equally close, either is right.
  4. 4.Everything else is the Core Engine's split: the root's halves go to two new pages, any other leaf keeps its page for the left half, the right leaf takes over the link, the separator is the right leaf's first key.
  5. 5.Invariant: the two halves differ by at most one cell and its slot, and both fit, because no cell is over 1008 bytes.
2

Update a row that changes size

Build the new row, check it, then put it back where its key belongs.

  1. 1.Build every new row and check it (00407 over 1000 bytes) before changing anything.
  2. 2.Take the old cell out of its leaf. If the new cell fits in the room the leaf has now, put it in the same place: same page, same cell.
  3. 3.If it does not fit, split the leaf as for an insert of the new cell, weighing the new cell, not the old one. btree find may then report another page and cell.
  4. 4.Invariant: after every statement, each leaf's free space is 4080 less 8 bytes and the row's size for each cell.
3

Delete, and use the room

Nothing new in DELETE; what is new is that the room it frees is used.

  1. 1.DELETE removes the cell's slot and frees its bytes at once. No leaf is merged, no page is freed, no separator changes.
  2. 2.The next cell that fits in the free space goes in, even when the free bytes are scattered in holes: compact the record area first, as the slotted page does.

Output contract for this stage

Nothing new is printed. The lines the tests check are those of the previous stages; the full definition is on the output contract page, sections 9, 10 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 [page], an internal node[BTREE] Page <p>: type=INTERNAL num_keys=<n> right_child=<page>, then per key [BTREE] Key <i>: key=<k> (child_page=<page>)
btree structure[BTREE] Tree depth: <d>, then one line per node: [BTREE] INTERNAL (page=<p> keys=[...]) and [BTREE] LEAF (page=<p> cells=<n> keys=[...])
btree find <key>[BTREE] Find key=<k>: FOUND (page=<p> cell=<i>), then the Row: line

What the tests pin down is which keys land where:

  • a full leaf's cells, in key order with the new one among them, divide where the halves' used bytes, each cell's row plus 8, are closest; either point on a tie;
  • an UPDATE keeps its page and cell when the new cell fits once the old one is gone, and otherwise splits the leaf as an insert of the new cell would;
  • a DELETE frees row size + 8 at once, and the next cell that fits uses it without a split.

No earlier case is retired: the previous stage allowed any division that fit, and none of its cases depended on which.

What you should see when done

The file holds the rows of split-by-bytes: three rows of 1000 bytes, keys 1 to 3, then keys 4 to 39 with 21 bytes each. The leaf has 12 bytes left. Open it again, look, and insert key 40:

droid session
Welcome to droid!
>btree structure
[BTREE] Tree depth: 1
[BTREE] LEAF (page=0 cells=39 keys=[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39])
>insert into users (id, name, email) values (40, 'dan', 'd@e');
Insert statement executed successfully (1 rows)
>btree structure
[BTREE] Tree depth: 2
[BTREE] INTERNAL (page=0 keys=[3])
[BTREE] LEAF (page=1 cells=2 keys=[1,2])
[BTREE] LEAF (page=2 cells=38 keys=[3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40])
>btree dump 0
[BTREE] Page 0: type=INTERNAL num_keys=1 right_child=2
[BTREE] Key 0: key=3 (child_page=1)
>btree dump 1
[BTREE] Page 1: type=LEAF num_cells=2 free_space=2064
[BTREE] Cell 0: key=1 (1000 bytes)
[BTREE] Cell 1: key=2 (1000 bytes)
>btree find 40
[BTREE] Find key=40: FOUND (page=2 cell=37)
[BTREE] Row: id=40 name='dan' email='d@e'
>delete from users where id = 2;
Delete statement executed successfully (1 rows)
[EXEC] rows_examined=1
>btree dump 1
[BTREE] Page 1: type=LEAF num_cells=1 free_space=3072
[BTREE] Cell 0: key=1 (1000 bytes)
>.exit
exiting.. good bye!

Things to notice. Key 40 is 21 bytes, and it moved 38 cells to a new page: the split weighs every cell, and two 1000-byte rows balance thirty-eight short ones. Page 1 holds two cells and has 2064 bytes free; page 2, with 38, has 1999. Deleting row 2 gave back its 1000 bytes and 8 more, and left the separator 3 where it was.

Common bugs in this stage

  • Cutting in the middle of the count. It passes every test with rows of one size and fails the first one with long and short rows mixed.
  • Leaving the new cell out. Dividing the old cells by bytes and then adding the new one to whichever half it belongs in puts the cut in the wrong place whenever the new cell is large.
  • Weighing rows without their key and slot. A cell takes row size + 8. Weighing only the rows moves the cut when the halves have different numbers of cells.
  • An empty half. The first division point is after the first cell and the last is before the last cell. A leaf of one huge cell and many small ones still leaves a cell on each side.
  • Checking room before removing the old row. An UPDATE that grows a row in a full leaf asks for the extra bytes only. Take the old cell out first, then check.
  • Weighing the old row in an UPDATE's split. The split divides the cells as they will be: with the new row, not the one it replaces.
  • Freed bytes that cannot be used. Free space says 49 and a 49-byte cell is refused, or splits the leaf, because the free bytes are in holes. Compact, then write.
  • Moving a separator on DELETE. Separators change only when a leaf splits.

Language notes

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

  • C and C++: gather the cells to divide, the new one included, into a buffer the size of two pages before writing either half; dividing in place while reading the same page overwrites cells you have not copied yet.
  • Rust: collect the cells as owned byte vectors before writing either half; borrowing them from the page you are about to rewrite will not pass the borrow checker, and should not.
  • Zig: a fixed array of slices over a scratch buffer holds the cells; std.mem.copyForwards writes them into the halves.

Checklist

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

  • A full leaf divides where the halves' bytes are closest
    split-by-bytes
  • The new cell is weighed with the others, in its place
    split-counts-the-new-cell
  • A leaf under the root divides by bytes too and keeps its page
    split-below-the-root
  • An UPDATE whose row no longer fits splits the leaf
    update-grows-into-a-split
  • An UPDATE that fits once its old row is gone does not split
    update-fits-once-the-old-row-is-gone
  • The room a DELETE frees takes the next row without a split
    delete-makes-room
  • 400 rows of every size: each leaf holds the keys the byte rule gives
    split-many-by-bytes
  • Rows that grow, shrink and go: the leaves still follow the rule
    updates-and-deletes-by-bytes
  • A tree of depth 4 with rows of every size survives a restart
    varlen-deep-tree-persists