droid
Durable Pages
Rows and Pages · Stage 7 of 16

Durable Pages

Put the pages in a file, and find them there the next time the program starts

Back the pager with a database file: open it before the session starts, restore the page count from its size, read each page from the file the first time it is needed, and write every changed page back to its place when the session ends.

Not run yet · 20 tests
Concepts in this stage
  • The database file as an array of pages: page n is the 4096 bytes at offset n × 4096, and nothing else is in the file
  • Opening is not creating: an existing file is read, never reset, and its size alone gives the page count
  • Pages on demand: a page is read from the file the first time it is used, a cache miss, and is a cache hit from then on
  • Saving at the end of the session: every page allocated or written in it goes back to its offset, and nothing else is touched
  • Refusing a file that cannot be a database, before the session starts, without changing it
Where this stage sits in the engine
Talking to the user
REPL
Sessions
Printer
Server
Wire protocol
Understanding SQL
LexerParser
Validator
Answering a query
Planner
Executor
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

Until now every page has lived in memory, and everything the program built was gone when it exited. A database is the thing that is still there the next time. This stage gives the pager a file, and it adds nothing else: no rows, no tree, no SQL. What the file holds is exactly the pages you already have, so the only new questions are where each page goes, when it is read, and when it is written.

It comes right after the pager, before the B+Tree, on purpose. Every tree stage after this one ends with a test that closes the program and opens the file again. Those tests can only mean something if pages already survive a restart, and if the file layer was tested on its own first, with pages whose bytes you chose yourself.

The classic mistake of this stage, stated up front: opening a file must never reset it. A program that treats every start as a new database, by truncating the file, zeroing the page count, or setting up page 0 again, passes every test in a single session and loses everything at the second one. The B+Tree will need a root in page 0, and it will create it only when the file has no pages at all. Everything in this stage is built so that the second session finds exactly what the first one left.

What changes: the file is the pages

The database file has no header and no index. It is the pages, one after the other, each at the offset its number gives:

PageBytes of the file
00 to 4095
14096 to 8191
28192 to 12287
nn × 4096 to n × 4096 + 4095

Two things follow. The file's size tells you how many pages there are, so opening needs no scan: 12288 bytes is 3 pages. And a size that is not a multiple of 4096, such as 5000 bytes, cannot be a droid database: it is error 00604. Refuse it before the session starts, and do not "fix" it by cutting it short or padding it. A file you cannot read is still somebody's data.

A page allocated and never written is 4096 zero bytes in the file, like any other page. It is not skipped.

Choosing the file

--db <path> names the file; without it the file is droid.db in the working directory. --db may come before or after -c. A missing file is created empty, and an empty file is a database with no pages. Two paths share nothing.

The database is opened before the welcome line. When the file cannot be used, the program prints one error line, nothing else, and exits with status 1. A path that cannot be opened as a file, such as a directory, is 00603.

Reading a page on first use

Opening a file reads no page. A fresh session on a file of three pages says total_pages=3 cached=0. A page is read the first time a command needs it:

Drawing the diagram…

pager get is the only command that says which of the two happened. pager read and pager write load a page the same way, silently, and after either one the page counts in cached.

A write changes only what it writes

In the pager stage a write already left the bytes after its zero byte as they were. With a file behind the pager, that rule has a consequence: the page you write into has to be the page from the file. These are the first 16 bytes of page 0 in the file after one session wrote hello world and the next one wrote hi, without reading the page first:

0
68
69
00
6c
6f
20
77
6f
72
6c
64
00
00
00
00
00
  • textbytes 0–1 (2 bytes) · 'hi', from the second session
  • endbytes 2–2 (1 bytes) · the zero byte after it
  • keptbytes 3–11 (9 bytes) · 'lo world' and its zero, from the first session
  • never writtenbytes 12–15 (4 bytes) · zero, like the 4080 bytes after them
The second session loaded page 0 from the file, changed three bytes, and saved it. A program that wrote 'hi' into a fresh page of zeros would save 68 69 00 followed by zeros, and a read would still say 'hi': only the file shows the loss.

This will matter as soon as a page holds more than one thing. A B+Tree leaf holds many rows, and inserting one must not wipe the others.

Saving when the session ends

A session ends in three ways: .exit, the end of input, or the end of the one command run by -c. All three save. Every page allocated or written during the session goes back to its offset; the rest of the file is not touched. A session that only reads, gets or asks for status leaves the file byte for byte as it was.

In this part nothing leaves memory before the session ends, so the end of the session is the one moment that must save. Writing a page back earlier, when memory runs short, comes with the Memory and Space part. What survives a crash in the middle of a session is the subject of the Transactions part.

The algorithm in words

1

Open the database file

Before anything is printed, the welcome line included.

  1. 1.The path is the one after --db, or droid.db in the working directory.
  2. 2.Open it for reading and writing, creating it if it does not exist. If that fails, print error 00603 and exit with status 1.
  3. 3.If its size is not a multiple of 4096, print error 00604 and exit with status 1, without writing to the file.
  4. 4.The page count is the size divided by 4096. No page is in memory yet.
  5. 5.Invariant: opening never changes the file. A file with pages keeps them all, exactly.
2

Get a page by its number

The same command as in the pager stage, with one more place a page can be.

  1. 1.A number that is not digits is 00104, and a page past the last one is 00601, as before.
  2. 2.If the page is in memory, it is a cache hit.
  3. 3.Otherwise it is in the file: read its 4096 bytes from offset number × 4096 and keep them in memory. That is a cache miss.
  4. 4.Read and write start by getting the page the same way, so a write changes a page that holds what the file held.
  5. 5.Invariant: a page is read from the file at most once per session.
3

Save the pages when the session ends

At .exit, at the end of input, and after the one command of -c.

  1. 1.For every page that was allocated or written in this session, write its 4096 bytes at offset number × 4096.
  2. 2.Leave every other byte of the file as it was.
  3. 3.Make sure the bytes reach the file before the program exits.
  4. 4.Invariants: the file's size is the page count × 4096; a session that only read leaves the file byte for byte unchanged.

Output contract for this stage

These are the exact lines the tests check. The full definition is on the output contract page, sections 2, 3, 7 and 8. The pager commands and their lines are the ones from the pager stage; this stage adds CACHE_MISS and three errors.

InputOutput
pager get <n>, page in the file and not yet in memory[PAGER] Get page <n>: CACHE_MISS (loaded from disk)
pager get <n>, page in memory[PAGER] Get page <n>: CACHE_HIT
pager status[PAGER] Status: total_pages=<n> cached=<m>, where n counts the pages in the file and m the ones read or allocated this session
--db with nothing after it[ERROR:00102] <message>, exit status 1
a path that cannot be opened or created as a file[ERROR:00603] <message> as the only output, exit status 1
a file whose size is not a multiple of 4096[ERROR:00604] <message> as the only output, exit status 1, the file unchanged

The file itself is part of the contract: page n at offset n × 4096, nothing else in it, and its size total_pages × 4096 after every session.

What you should see when done

Three sessions on the same file, the first two interactive, the last one a single command:

shell
$droid --db demo.db
Welcome to droid!
>pager alloc
[PAGER] Alloc: page 0 (4096 bytes, zeroed)
>pager alloc
[PAGER] Alloc: page 1 (4096 bytes, zeroed)
>pager write 1 still here
[PAGER] Write page 1: 10 bytes
>.exit
exiting.. good bye!
$droid --db demo.db
Welcome to droid!
>pager status
[PAGER] Status: total_pages=2 cached=0
>pager get 1
[PAGER] Get page 1: CACHE_MISS (loaded from disk)
>pager get 1
[PAGER] Get page 1: CACHE_HIT
>pager read 1
[PAGER] Read page 1: 'still here'
>pager get 2
[ERROR:00601] Page 2 was never allocated
>pager status
[PAGER] Status: total_pages=2 cached=1
>.exit
exiting.. good bye!
$droid --db demo.db -c "pager read 0"
[PAGER] Read page 0: ''

The second session starts with nothing in memory and two pages in the file. Page 1 is a miss once, then a hit. The file is 8192 bytes after all three sessions, and the last two changed none of them.

A file that is not a whole number of pages is refused before the session starts:

shell
$head -c 5000 /dev/zero > broken.db
$droid --db broken.db
[ERROR:00604] broken.db is 5000 bytes, not a multiple of 4096
$echo $?
1

The error messages are yours to word; the tests check only the codes.

Common bugs in this stage

  • Opening truncates the file. Many "open for writing" modes empty the file first. The second session then finds 0 pages. Open for reading and writing without truncating, and create the file only if it is missing.
  • Starting over on every run. Setting the page count to 0, or allocating page 0, whenever the program starts. The page count comes from the file's size, and a file with pages already has them.
  • Reading every page at open. It works for three pages and wastes time for a thousand, and cached shows it: a fresh session must say cached=0.
  • A write into a fresh buffer. Writing to a page of the file without reading it first saves zeros over the rest of the page. pager read still gives back the text, so only the file shows it.
  • The wrong offset. Page n goes at n × 4096 bytes, not at the end of the file and not at n × the size of whatever you wrote. Writing only the bytes up to the text, instead of the whole page, leaves the file too short.
  • Saving only on .exit. Input that ends without .exit, and every -c run, must save too.
  • Nothing reaches the file. Written data can sit in a buffer inside the program or the language's library. If the program exits without flushing or closing the file, the bytes are lost.
  • Writing back pages that did not change. Harmless to the bytes, but a session that only reads should not have to write at all. Keep track of which pages were allocated or written.
  • "Fixing" a broken file. Rounding a 5000-byte file down to one page, or padding it to two, destroys what was there. Refuse it with 00604 and leave it alone, and make sure nothing saves on the way out.
  • Printing the welcome line first. The file is opened, and checked, before anything is printed. A refused file prints its error and nothing else.

Language notes

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

  • C: fopen mode "w+" truncates and "r+" fails when the file is missing; the POSIX open with read-write and create flags, and no truncate flag, does both jobs. fstat gives the size. fwrite buffers, so the bytes reach the file at fflush or fclose. fopen of a directory for reading only can succeed on Linux; opening it for writing fails.
  • C++: an std::fstream opened with in | out fails when the file does not exist, and adding trunc empties it. Create a missing file first, then open it with in | out | binary. std::filesystem::file_size gives the size.
  • Rust: OpenOptions with read, write and create, and without truncate, opens or creates without emptying. metadata().len() gives the size; seek with read_exact and write_all move whole pages. A File is closed when it is dropped, but a BufWriter around it must be flushed.
  • Zig: createFile truncates unless its flags say .truncate = false, and it needs .read = true for reading. getEndPos gives the size; preadAll and pwriteAll read and write at an offset.
  • Every language: the end of input is a normal way to end the session. Make sure the path that handles it saves, the same as .exit.

Checklist

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

  • A reopened file reports its page count, with 0 pages in memory
    persist-page-count-restored
  • The first get of a page from the file is CACHE_MISS (loaded from disk), the second CACHE_HIT
    persist-cache-miss-on-reopen
  • What one session writes, the next one reads
    persist-page-roundtrip
  • pager read loads a page into memory too
    persist-read-caches-page
  • A second session allocates the next page number and keeps the pages of the first
    persist-sessions-accumulate
  • Pages in the file exist and the next one is 00601
    persist-bounds-after-reopen
  • An empty file is an empty database
    persist-empty-file
  • The 1000-page limit counts the pages in the file
    persist-limit-counts-file-pages
  • The file is exactly page count × 4096 bytes
    persist-file-size
  • Page n is at offset n × 4096, with no header and no gaps
    persist-file-layout
  • A short write to a page from the file keeps the rest of that page
    persist-write-keeps-rest-of-page
  • A session that only reads leaves the file unchanged
    persist-read-only-session
  • Pages are saved when input runs out without .exit
    persist-flush-at-end-of-input
  • Pages are saved after each -c run
    persist-flush-in-c-mode
  • Without --db the file is droid.db in the working directory
    persist-default-path
  • --db works before or after -c
    persist-db-flag-either-order
  • Two --db files share nothing
    persist-db-isolation
  • --db with no path is 00102 and exit 1
    persist-db-flag-missing-value
  • A file whose size is not a multiple of 4096 is 00604, exit 1, and stays as it was
    persist-file-not-page-multiple
  • A path that cannot be opened as a file is 00603 and exit 1
    persist-file-cannot-open