droid
Page Checksums
Flexible Storage · Stage 8 of 8

Page Checksums

Every page carries a checksum of itself, so a damaged page is an error instead of a wrong answer

Give every page a 4-byte trailer: the CRC32 of the other 4092 bytes. Write it whenever a page goes to the file, check it whenever a page comes back, and refuse to use a page that does not match. Move the end of every slotted page from byte 4096 to 4092 to make room, and add db verify, which checks the whole file.

Not run yet · 11 tests
Concepts in this stage
  • Silent corruption: a disk, a cable or a bug can change a byte, and nothing notices unless something checks
  • A checksum is a short summary of the bytes: change any one of them and the summary changes
  • Written on the way out, checked on the way in: the checksum belongs to the file's copy of a page, not to the page in memory
  • Detection, not repair: a bad page becomes a loud error, never a wrong row
  • A format change has a cost: every layout that ran to the end of the page moves four bytes
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParserValidator
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
Pager
Database file
this stagebuilt alreadystill to comeA statement enters at the top; rows come back up.

Why this stage exists

Every stage so far has trusted the file. The program writes a page, reads it back later, and uses whatever bytes come back. Usually they are the bytes it wrote. Not always.

Disks return the wrong sector. A cable flips a bit. A power cut leaves half a page new and half old. A bug in some other program, or in this one, writes over the wrong offset. None of these announce themselves. The program reads the damaged page, finds a slot count that is a little off or a key that is not the one it stored, and does what those bytes say: returns a row that was never inserted, skips one that was, or follows a child pointer into the middle of nowhere. The worst outcome is not a crash. It is an answer that looks right and is not.

This stage makes damage loud. Every page carries a checksum of itself, written each time the page goes to the file and checked each time it comes back. A page that does not match is an error the moment it is read, before any of its bytes are believed.

It comes last in Flexible Storage because it changes a page layout, the subject of this part, and it comes before the write-ahead log because recovering from a crash starts with knowing which pages a crash has torn.

What changes: every page ends with a trailer

The last four bytes of every page, 4092 to 4095, are its trailer: the CRC32 of the page's other 4092 bytes, stored little-endian like every integer in the file.

0
00
00
00
00
a3
74
47
df
  • …bytes 0–3 (4 bytes) · the last bytes of the header page: zeros, bytes 4088 to 4091
  • trailerbytes 4–7 (4 bytes) · CRC32 of bytes 0 to 4091: 0xdf4774a3, little-endian
The end of page 0 of a file whose catalog is at page 1. Change any one of the 4092 bytes before the trailer, a zero in the middle of the page included, and the CRC32 no longer matches.

Every page has one, whatever it holds: the header, the catalog and table leaves, internal nodes, slotted pages made by page init, and a page that only pager write ever touched. The program does not need to know what a page is to check it, which is the point: a damaged header must be caught before the program reads the header to find out what everything else is.

When it is written. Whenever a page goes to the file, the trailer is computed from the bytes the page holds at that moment and written with it. In this part that is the end of the session. The copy of a page in memory is never checked and its last four bytes mean nothing; only the file's copy has to be right.

When it is checked. Whenever a page comes from the file, the first time a session needs it, its trailer is checked before any byte of it is used. A page that does not match is 00609. The command that needed it prints that one line and nothing else: no rows, no partial result, and nothing changes. The bad page is not kept in memory, so the next command that needs it reads it and fails again, and a command that does not need it works as before. Opening checks page 0 the same way, after its magic, version and page size: a damaged header is 00609, the only line, with exit status 1.

CRC32 is not cryptography. It catches accidents, every change of a single byte and every burst of changes up to 32 bits long, but anyone who wants to change a page on purpose can recompute it. PostgreSQL uses a 16-bit checksum in each page header; InnoDB keeps its checksum in a trailer at the end of each page, as this tutorial does, and SQLite can with its checksum extension.

What changes: pages end at 4092

A layout that ran to the last byte of the page has to stop four bytes earlier. Internal nodes, the header's fields and the text pager write stores never come near the end, so they do not change. Slotted pages do: their records grew from byte 4096 down, and now they grow from 4092.

The worked page, after three inserts
0–15
header
slots = 3
slot 0
@4087
5 B
slot 1
@4074
13 B
slot 2
@4069
5 B
new
28–4068
→ gap ←
4041 B
@4069
slot 2
'delta'
5 B · new
@4074
slot 1
'bravo charlie'
13 B
@4087
slot 0
'alpha'
5 B
4092–4095
trailer
CRC32
4096
free_space = 4041
The page of the slotted-page stage, with the trailer taking the last four bytes. Every offset is four lower than before, and an empty page has 4076 bytes free instead of 4080.

So:

  • An empty slotted page has a record start of 4092 and free_space=4076. The first record ends at byte 4092.
  • A new root leaf is 01 01, zeros up to byte 14, then fc 0f: a record start of 4092.
  • A leaf holds 4076 bytes of cells and slots. Rows of 21 bytes still fit 140 to a leaf, since 4076 / 29 rounds down to 140 as 4080 / 29 did, but rows whose cells and slots take 510 bytes now fit seven, not eight.
  • Any four cells still fit in an empty leaf: four of the largest take 4032 bytes, well within 4076.

Earlier cases that pinned 4096 or 4080, or compared a whole page of the file byte for byte, are retired here by name in the manifest. What they checked about SQL still has to pass.

What changes: db verify

db verify checks the whole file, and is how you find out which pages are damaged without tripping over them one statement at a time. For each page, in order, a page already in memory counts as good: it was checked when it was read, or made in this session and not written yet. Every other page is read and checked as any read is, so a good page stays in memory afterwards and a bad one does not. It changes nothing, and on a file with no pages it reports none and creates nothing.

The algorithm in words

1

Compute a CRC32

The same checksum zlib, PNG and Ethernet use, so you can test yours against any of them.

  1. 1.Start from 0xFFFFFFFF. For each byte, combine it into the low 8 bits, then eight times: shift right one bit, and when the bit shifted out was 1, combine the result with 0xEDB88320.
  2. 2.Invert every bit at the end. The CRC32 of the nine ASCII bytes 123456789 is 0xCBF43926: check yours against it before using it anywhere else.
  3. 3.A table of the 256 possible results for one byte makes it eight times faster; the answers are the same.
2

Seal a page on its way to the file

The trailer describes the bytes as they are written, never as they were.

  1. 1.Whenever a page is written to the file, compute the CRC32 of its bytes 0 to 4091 and store it in bytes 4092 to 4095, little-endian, in the copy being written.
  2. 2.Every page, whatever it holds: the header, catalog and table pages, slotted pages, and pages that only pager write touched.
  3. 3.Nothing checks the copy in memory. Only the file's copy has to be right.
3

Check a page on its way back

No byte of a page from the file is used before its trailer says it is whole.

  1. 1.Whenever a page is read from the file, compute the CRC32 of its bytes 0 to 4091 and compare it with its trailer.
  2. 2.A mismatch is 00609 for the command that needed the page: it prints that one line, prints no rows and changes nothing. The page is not kept in memory, so the next command that needs it reads it and checks it again.
  3. 3.On open, check page 0 after its magic, version and page size: a mismatch there is 00609, the only line, exit status 1, the file untouched.
4

End every layout at 4092

The last four bytes of a page belong to the trailer.

  1. 1.A slotted page's records end at byte 4092. Its record start is 4092 when it has no records, and its free space is counted from 4092.
  2. 2.An empty page has 4076 bytes free, and a new root leaf records a start of 4092, the bytes fc 0f.
  3. 3.The header's fields, internal nodes and the text of pager write never come near byte 4092, so they do not change.
5

Verify the file

db verify checks every page and names the bad ones.

  1. 1.For each page of the file, in order: a page already in memory counts as good; any other page is read and checked as any read is.
  2. 2.Print the number of pages and of bad ones, then one line per bad page. Change nothing.

Output contract for this stage

The full definition is on the output contract page, section 20.

InputOutput
reading a page whose trailer does not match[ERROR:00609] <message>, and nothing else from that command
opening a file whose page 0 does not match[ERROR:00609] <message>, the only line, exit status 1
db verify[DB] Verify: pages=<total_pages> bad=<k>, then [DB] Page <p>: checksum mismatch for each bad page, ascending
page init <n>[PAGE] Init page <n>: free_space=4076
btree dump of an empty leaf[BTREE] Page <p>: type=LEAF num_cells=0 free_space=4076

Errors are checked by code, never by message. A wrong magic, version or page size is still 00608, checked before the trailer.

What you should see when done

A table, a restart, a check of the whole file:

droid session
Welcome to droid!
>create table users (id int, name varchar(1000), email varchar(1000));
Create table statement executed successfully
>insert into users (id, name, email) values (1, 'dan', 'd@e');
Insert statement executed successfully (1 rows)
>btree dump 2
[BTREE] Page 2: type=LEAF num_cells=1 free_space=4047
[BTREE] Cell 0: key=1 (21 bytes)
>.exit
exiting.. good bye!
droid session
Welcome to droid!
>db verify
[DB] Verify: pages=3 bad=0
>.exit
exiting.. good bye!

Now damage one byte of page 2 in the file, anywhere, even in the empty middle of the page, and open it again:

droid session
Welcome to droid!
>select * from users;
[ERROR:00609] Page 2 failed its checksum
>select * from users;
[ERROR:00609] Page 2 failed its checksum
>db verify
[DB] Verify: pages=3 bad=1
[DB] Page 2: checksum mismatch
>.exit
exiting.. good bye!

Things to notice. The SELECT printed no header and no rows, not even (0 rows): it did not get far enough to have a result. The second SELECT failed again, because the bad page was never kept. And db verify found page 2 without being told where to look.

Damage page 0 instead, and the program never gets as far as the welcome line:

shell
$droid --db notes.db
[ERROR:00609] Page 0 failed its checksum
$echo $?
1

Common bugs in this stage

  • Checking the page in memory. The trailer is written when the page goes to the file. A page you changed a moment ago has a stale trailer in memory, and that is fine; checking it there raises alarms on every write.
  • Computing the checksum over 4096 bytes. The trailer cannot cover itself. It is the CRC32 of bytes 0 to 4091.
  • Forgetting a kind of page. The header, the catalog, raw pages from pager write: every page the file holds gets a trailer, or the first db verify after a restart finds it bad.
  • Writing the trailer big-endian. Every integer in the file is little-endian, this one too.
  • A slotted page that still ends at 4096. The first record goes over the trailer, and the page fails its checksum after the next restart, or the record is cut when the trailer is written. Every place that knew the page ended at 4096 now needs 4092: the empty record start, free space, compaction and the capacity of a leaf.
  • Keeping the bad page. A page that failed its check must not stay in memory, or the second statement reads it without complaint.
  • Checking the header before its magic. A file that is not a droid database at all is still 00608; only a droid header with a wrong trailer is 00609.

Language notes

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

  • C: there is no CRC32 in the standard library. Write the bitwise version first and check it against 0xCBF43926, then add a 256-entry table if you like. Use uint32_t throughout so the shifts are unsigned.
  • C++: the same as C; std::array<uint32_t, 256> built once holds the table.
  • Rust: write it yourself with u32 and >>, or compare yours with the crc32fast crate in a test. u32::to_le_bytes gives the trailer's four bytes.
  • Zig: std.hash.Crc32 computes exactly this checksum; writing your own is still worth doing once. std.mem.writeInt with .little stores the trailer.

Checklist

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

  • Every page in the file ends with the CRC32 of its first 4092 bytes
    trailer-on-every-page
  • A new root leaf records a start of 4092 and has 4076 bytes free
    empty-leaf-ends-at-4092
  • A leaf holds 4076 bytes: eight 502-byte rows no longer fit
    leaf-holds-4076-bytes
  • The worked slotted page, four bytes lower
    worked-page-ends-at-4092
  • db verify counts the pages and finds none bad, in the session and after a restart
    verify-clean
  • db verify on a file with no pages creates nothing
    verify-empty-file
  • db verify names each bad page, in order, and keeps none of them
    verify-lists-bad-pages
  • A page that fails its checksum is 00609 every time, and the rest of the file still works
    flipped-byte-detected
  • A statement that needs a bad page changes nothing
    bad-page-changes-nothing
  • A damaged page 0 is 00609 at open; a wrong magic is still 00608
    header-corruption-detected
  • Pages rewritten later carry their new checksums: no false alarm
    checksum-updated-on-write