droid
Slotted Pages
Flexible Storage · Stage 2 of 8

Slotted Pages

A page that holds records of any length, and names each one by a slot that never changes

Turn a page into a slotted page: a directory of slots growing from the front, records growing from the end, free space in between. Insert, read, delete and compact records of any length, keep every slot's number while its record moves, and refuse a record that does not fit.

Not run yet · 16 tests
Concepts in this stage
  • Indirection: a record is named by its slot, and the slot says where the record is, so the record can move without its name changing
  • Two ends: the slot directory grows forward from the header, the records grow backward from the end of the page, and the free space is whatever is left between them
  • Deleting leaves a hole: the slot is marked deleted and the record's bytes stay where they were
  • Compaction: moving the live records together to close the holes, rewriting only their slots' offsets
  • Free space is every unused byte, holes included, not only the gap in the middle
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

A Core Engine leaf finds cell i by multiplication: every cell is 64 bytes, so cell i starts at 14 + 64 × i. The previous stage made rows variable, and that multiplication stops working. Rows of 21, 39 and 15 bytes cannot be found by position, and a row that grows cannot stay where it was.

The answer every real database gives is the slotted page. The page keeps a small directory of fixed-size slots at its front. Each slot says where one record is and how long it is. The records themselves live at the other end of the page, packed wherever there was room. To find record 2, read slot 2: it is always at the same place, because slots all have the same size, and it tells you where the record is.

That one level of indirection buys a lot. A record can move, to close a hole or to make room, and nobody who knows it as "slot 2" needs to hear about it. Records of any length share a page with no padding. And the page knows exactly how much room is left.

This stage builds the slotted page on its own, with page commands on pages you allocate yourself, before any B+Tree uses it. The B+Tree still stores the Core Engine's leaves until varlen-btree-leaf, where each leaf becomes a slotted page, and every SQL statement behaves as before.

What changes: two ends and a gap

Here is page 1 after page init 1 and three inserts: alpha, bravo charlie and delta.

After three inserts
0–15
header
slots = 3
slot 0
@4091
5 B
slot 1
@4078
13 B
slot 2
@4073
5 B
new
28–4072
→ gap ←
4045 B
@4073
slot 2
'delta'
5 B · new
@4078
slot 1
'bravo charlie'
13 B
@4091
slot 0
'alpha'
5 B
4096
free_space = 4045
Slots grow from the header towards the middle, records from the end of the page towards the middle. The first record ends at byte 4096; each later one ends where the previous one starts.

The header is 16 bytes. Its first 14 are the Core Engine's node header, the same fields in the same places, and the last 2 are new: the record start, the offset of the lowest record. Slot i is the 4 bytes at 16 + 4 × i: the record's offset, then its length, 2 bytes each, little-endian like everything else in the file.

0
03
00
00
00
00
00
03
00
00
00
00
00
00
00
e9
0f
16
fb
0f
05
00
ee
0f
0d
00
e9
0f
05
00
  • page_typebytes 0–0 (1 bytes) · 3, a slotted page
  • is_rootbytes 1–1 (1 bytes) · 0; a B+Tree uses it from varlen-btree-leaf
  • parentbytes 2–5 (4 bytes) · 0; the same
  • slot_countbytes 6–9 (4 bytes) · 3
  • linkbytes 10–13 (4 bytes) · 0; a leaf's next leaf later
  • record_startbytes 14–15 (2 bytes) · 4073 (0x0fe9)
  • slot_0bytes 16–19 (4 bytes) · offset 4091, length 5
  • slot_1bytes 20–23 (4 bytes) · offset 4078, length 13
  • slot_2bytes 24–27 (4 bytes) · offset 4073, length 5
The first 28 bytes of page 1: the header, then the slot directory. Everything after byte 28 up to the record start is the gap.

The fields a slotted page made by page init does not use yet are zero. They are where they are so that a B+Tree leaf can be a slotted page without a second layout: in varlen-btree-leaf the type becomes 1, is_root, parent and link get their Core Engine meanings, and the slot count is the number of cells.

Free space is every byte no header, slot or live record uses: 4096 − 16 − 4 × slot_count − the lengths of the live records. Here that is 4096 − 16 − 12 − 23 = 4045.

Deleting leaves a hole

Delete slot 1 and the page looks like this:

After page delete 1 1
0–15
header
slots = 3
slot 0
@4091
5 B
slot 1
deleted
0 · 0
deleted
slot 2
@4073
5 B
28–4072
→ gap ←
4045 B
@4073
slot 2
'delta'
5 B
@4078
hole
13 B
@4091
slot 0
'alpha'
5 B
4096
free_space = 4058
The slot is marked deleted, offset 0 and length 0, and stays in the directory, so slot 2 is still slot 2. The 13 bytes of 'bravo charlie' are still on the page, but nothing points at them: they are a hole.

Nothing moved. That matters: anything that remembered "page 1, slot 2" can still find delta. Free space grew by 13 at once, to 4058, because free space counts holes. But the gap, the contiguous room between the directory and the record start, did not grow: a new 13-byte record would still go below delta, not into the hole.

A deleted slot is not wasted. The next record takes the lowest deleted slot before the directory grows, so a page that sees many deletes and inserts does not grow a directory of dead slots.

Compaction closes the holes

Compaction moves every live record to the end of the page, in slot order, and rewrites their offsets:

Before page compact 1
0–15
header
slots = 3
slot 0
@4091
5 B
slot 1
deleted
0 · 0
slot 2
@4073
5 B
28–4072
→ gap ←
4045 B
@4073
slot 2
'delta'
5 B
@4078
hole
13 B
@4091
slot 0
'alpha'
5 B
4096
free_space = 4058
After page compact 1
0–15
header
slots = 3
slot 0
@4091
5 B
slot 1
deleted
0 · 0
slot 2
@4086
5 B
moved
28–4085
→ gap ←
4058 B
@4086
slot 2
'delta'
5 B · moved
@4091
slot 0
'alpha'
5 B
4096
free_space = 4058
After page insert 1 echo
0–15
header
slots = 3
slot 0
@4091
5 B
slot 1
@4082
4 B
new
slot 2
@4086
5 B
28–4081
→ gap ←
4054 B
@4082
slot 1
'echo'
4 B · new
@4086
slot 2
'delta'
5 B
@4091
slot 0
'alpha'
5 B
4096
free_space = 4054
delta moves from 4073 to 4086, next to alpha, and slot 2 now says so. Free space stays 4058; what changed is that all of it is gap again. The next record takes the deleted slot 1 and goes just below delta.

Slot numbers are what compaction protects. The record moved, the slot that names it did not.

An insert does not always wait for you to compact. When a record fits in the free space but not in the gap, because the room it needs is in holes, the page compacts itself first and then places the record. Only a record that does not fit in the free space at all is refused, with 00605.

Real engines do this too. PostgreSQL calls a slot a line pointer and names a row by its page and line pointer, which is why its rows can move within a page without their indexes changing. SQLite's pages have the same two ends, and defragment themselves when an insert needs the room.

The algorithm in words

1

Initialise a page

Any allocated page can become an empty slotted page.

  1. 1.Zero the whole page, then write the header: page type 3, a slot count of 0 and a record start of 4096.
  2. 2.Invariant: an empty slotted page has 4080 bytes free, all of them in the gap.
2

Insert a record

Choose the slot, check that the record fits, then find it room.

  1. 1.The record takes the lowest deleted slot. If no slot is deleted, it takes a new one at the end of the directory, which costs 4 more bytes.
  2. 2.It fits when its length, plus 4 if it needs a new slot, is at most the page's free space. If not, the answer is 00605 and nothing changes.
  3. 3.Its place is just below the record start. If that would reach into the slot directory, which can happen when the free space is in holes rather than in the gap, compact the page first.
  4. 4.Write the record, point the slot at it, and make its offset the new record start.
  5. 5.Invariants: the slot directory always ends at or before the record start; every live record lies between the record start and the end of the page; no two live records overlap.
3

Delete a record

Deleting changes one slot and nothing else.

  1. 1.The slot must exist and be live, or the answer is 00607.
  2. 2.Write offset 0 and length 0 into the slot. Leave the record's bytes, the record start and the slot count as they are.
  3. 3.Invariant: every other slot keeps its number, its offset and its length; free space grows by the record's length.
4

Compact a page

Move the live records to the end of the page, in slot order, and close the holes.

  1. 1.Take the live slots from the lowest number up. The first one's record ends at byte 4096, the next one's just below it, and so on.
  2. 2.Copy each record to its new place and write its new offset into its slot. Deleted slots stay deleted.
  3. 3.The record start becomes the offset of the lowest record, or 4096 if there is none.
  4. 4.Invariant: every slot reads the same bytes as before; free space is unchanged, and all of it is now the gap.

Output contract for this stage

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

InputOutput
page init <n>[PAGE] Init page <n>: free_space=4080
page insert <n> <text>[PAGE] Insert page <n>: slot=<i> offset=<o> length=<len>
page read <n> <slot>[PAGE] Read page <n> slot <i>: '<text>'
page delete <n> <slot>[PAGE] Delete page <n>: slot=<i>
page compact <n>[PAGE] Compact page <n>: free_space=<f>
page dump <n>[PAGE] Page <n>: type=SLOTTED slots=<k> free_space=<f>, then for every slot from 0, [PAGE] Slot <i>: offset=<o> length=<len> or [PAGE] Slot <i>: deleted
a page that was never allocated[ERROR:00601] <message>
insert, read, delete, compact or dump on a page page init did not make[ERROR:00606] <message>
read or delete of a slot past the last one, or deleted[ERROR:00607] <message>
a record that does not fit[ERROR:00605] <message>, and the page is unchanged
a page or slot number that is not digits, a missing argument, an empty record, an unknown subcommand[ERROR:00104] <message>

The record of page insert is the rest of the line after the page number and one space, spaces inside included, stored with no zero byte after it. page read prints it between single quotes. Slotted pages are pages of the pager, so they are saved when the session ends and read back from the file, as in the Core Engine.

What you should see when done

droid session
Welcome to droid!
>pager alloc
[PAGER] Alloc: page 0 (4096 bytes, zeroed)
>pager alloc
[PAGER] Alloc: page 1 (4096 bytes, zeroed)
>page init 1
[PAGE] Init page 1: free_space=4080
>page insert 1 alpha
[PAGE] Insert page 1: slot=0 offset=4091 length=5
>page insert 1 bravo charlie
[PAGE] Insert page 1: slot=1 offset=4078 length=13
>page insert 1 delta
[PAGE] Insert page 1: slot=2 offset=4073 length=5
>page delete 1 1
[PAGE] Delete page 1: slot=1
>page dump 1
[PAGE] Page 1: type=SLOTTED slots=3 free_space=4058
[PAGE] Slot 0: offset=4091 length=5
[PAGE] Slot 1: deleted
[PAGE] Slot 2: offset=4073 length=5
>page compact 1
[PAGE] Compact page 1: free_space=4058
>page insert 1 echo
[PAGE] Insert page 1: slot=1 offset=4082 length=4
>page dump 1
[PAGE] Page 1: type=SLOTTED slots=3 free_space=4054
[PAGE] Slot 0: offset=4091 length=5
[PAGE] Slot 1: offset=4082 length=4
[PAGE] Slot 2: offset=4086 length=5
>page read 1 1
[PAGE] Read page 1 slot 1: 'echo'
>page dump 0
[ERROR:00606] page 0 is not a slotted page
>.exit
exiting.. good bye!

Things to notice. The delete changed no offset. After compaction slot 2 is at 4086, and echo took the deleted slot 1 rather than a new slot 3. The last dump lists the slots in slot order, not in the order of their offsets: slot 1's record is now the lowest on the page. Page 0 was allocated but never initialised, so it is not a slotted page.

Common bugs in this stage

  • Free space that forgets the slot. A new slot costs 4 bytes. A record of exactly free_space bytes that needs a new slot does not fit.
  • Free space that ignores holes, or counts only holes. Free space is every unused byte: the gap and the holes together. The gap alone decides where a record goes, not whether it fits.
  • Moving records on delete. A delete that compacts at once still keeps every slot's number, but it moves records the contract says stay put, and the file's bytes differ from what the tests expect.
  • Shifting the directory on delete. Removing the slot and moving the later ones down renumbers every record after it. A deleted slot stays, as offset 0 and length 0.
  • Taking a new slot while a deleted one is free. The lowest deleted slot comes first.
  • Compacting in offset order. Compaction packs the records in slot order, lowest slot at the end of the page. In offset order, a record that was taken again into a low slot ends up somewhere else.
  • An overlapping copy during compaction. Records move towards the end of the page and may overlap their old place. Copy each record out before writing it, or use a move that handles overlap.
  • Forgetting the record start. After compaction it is the lowest record's offset. A stale record start puts the next record in the wrong place, or on top of another one.
  • Trimming the record. page insert 1 bravo charlie stores 13 bytes, the space included.
  • Storing 4096 in 2 bytes. The record start of an empty page is 4096, 00 10 little-endian. It fits, but only because 2 bytes go up to 65535; don't store it as an offset from somewhere else.

Language notes

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

  • C and C++: the page is a uint8_t[4096]; read and write the 2-byte and 4-byte fields byte by byte, little-endian, as with the row. memmove, not memcpy, for compaction.
  • Rust: u16::from_le_bytes(page[o..o + 2].try_into().unwrap()) reads a slot field; copy_within moves a record inside the page and handles overlap.
  • Zig: std.mem.readInt(u16, page[o..][0..2], .little) reads a slot field, and std.mem.copyBackwards or std.mem.copyForwards moves a record depending on the direction.

Checklist

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

  • A first record ends at byte 4096 and costs its length plus 4
    slot-offset-math
  • Records grow down, each just below the one before
    slot-offsets-decrease
  • free_space counts the header, every slot and every live record exactly
    slot-free-space-exact
  • Each slot reads back its own record
    slot-read-back
  • Deleting marks the slot and moves nothing
    slot-delete-tombstone
  • A new record takes the lowest deleted slot
    slot-reuse-deleted
  • Compaction moves records into slot order and keeps every slot's number
    slot-compact
  • An insert that fits only after compaction compacts first
    slot-insert-compacts
  • A record that does not fit is 00605 and changes nothing; one that fits exactly is accepted
    slot-page-full
  • page init empties any allocated page
    slot-init-resets
  • 00601 for a page that does not exist, 00606 for one that is not slotted
    slot-dump-invalid-page
  • 00607 for a slot that does not exist or is deleted
    slot-bad-slot
  • 00104 for arguments that are missing or not numbers
    slot-bad-arguments
  • The page in the file is the contract's layout, byte for byte
    slot-page-bytes
  • Slots survive a restart
    slot-persists
  • page commands work in -c mode
    slot-cli-mode