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.
- 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
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:
| Page | Bytes of the file |
|---|---|
| 0 | 0 to 4095 |
| 1 | 4096 to 8191 |
| 2 | 8192 to 12287 |
| n | n × 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:
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:
- 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
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
Open the database file
Before anything is printed, the welcome line included.
- 1.The path is the one after --db, or droid.db in the working directory.
- 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.If its size is not a multiple of 4096, print error 00604 and exit with status 1, without writing to the file.
- 4.The page count is the size divided by 4096. No page is in memory yet.
- 5.Invariant: opening never changes the file. A file with pages keeps them all, exactly.
Get a page by its number
The same command as in the pager stage, with one more place a page can be.
- 1.A number that is not digits is 00104, and a page past the last one is 00601, as before.
- 2.If the page is in memory, it is a cache hit.
- 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.Read and write start by getting the page the same way, so a write changes a page that holds what the file held.
- 5.Invariant: a page is read from the file at most once per session.
Save the pages when the session ends
At .exit, at the end of input, and after the one command of -c.
- 1.For every page that was allocated or written in this session, write its 4096 bytes at offset number × 4096.
- 2.Leave every other byte of the file as it was.
- 3.Make sure the bytes reach the file before the program exits.
- 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.
| Input | Output |
|---|---|
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:
$droid --db demo.dbWelcome 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>.exitexiting.. good bye!$droid --db demo.dbWelcome 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>.exitexiting.. 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:
$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
cachedshows it: a fresh session must saycached=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 readstill 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-crun, 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:
fopenmode"w+"truncates and"r+"fails when the file is missing; the POSIXopenwith read-write and create flags, and no truncate flag, does both jobs.fstatgives the size.fwritebuffers, so the bytes reach the file atfflushorfclose.fopenof a directory for reading only can succeed on Linux; opening it for writing fails. - C++: an
std::fstreamopened within | outfails when the file does not exist, and addingtruncempties it. Create a missing file first, then open it within | out | binary.std::filesystem::file_sizegives the size. - Rust:
OpenOptionswithread,writeandcreate, and withouttruncate, opens or creates without emptying.metadata().len()gives the size;seekwithread_exactandwrite_allmove whole pages. AFileis closed when it is dropped, but aBufWriteraround it must be flushed. - Zig:
createFiletruncates unless its flags say.truncate = false, and it needs.read = truefor reading.getEndPosgives the size;preadAllandpwriteAllread 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 memorypersist-page-count-restored
- The first get of a page from the file is CACHE_MISS (loaded from disk), the second CACHE_HITpersist-cache-miss-on-reopen
- What one session writes, the next one readspersist-page-roundtrip
- pager read loads a page into memory toopersist-read-caches-page
- A second session allocates the next page number and keeps the pages of the firstpersist-sessions-accumulate
- Pages in the file exist and the next one is 00601persist-bounds-after-reopen
- An empty file is an empty databasepersist-empty-file
- The 1000-page limit counts the pages in the filepersist-limit-counts-file-pages
- The file is exactly page count × 4096 bytespersist-file-size
- Page n is at offset n × 4096, with no header and no gapspersist-file-layout
- A short write to a page from the file keeps the rest of that pagepersist-write-keeps-rest-of-page
- A session that only reads leaves the file unchangedpersist-read-only-session
- Pages are saved when input runs out without .exitpersist-flush-at-end-of-input
- Pages are saved after each -c runpersist-flush-in-c-mode
- Without --db the file is droid.db in the working directorypersist-default-path
- --db works before or after -cpersist-db-flag-either-order
- Two --db files share nothingpersist-db-isolation
- --db with no path is 00102 and exit 1persist-db-flag-missing-value
- A file whose size is not a multiple of 4096 is 00604, exit 1, and stays as it waspersist-file-not-page-multiple
- A path that cannot be opened as a file is 00603 and exit 1persist-file-cannot-open