Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Surviving a restart

minidb keeps everything in a HashMap, so a restart loses the lot. The fix is the oldest idea in databases: before you change anything, write down what you are about to do, somewhere that outlives the process.

That is a write-ahead log, and the two words are the whole idea. Write it ahead of the change, because a log written afterwards is missing exactly the records you needed.

The format is already here

Every mutating request is a line of the wire protocol:

SET users alice hello
DEL users alice

So the log is a transcript of what clients asked for, and replaying it is running those requests again in order. Request has parse and Display and a round-trip test from chapter 3, so the log writer and the log reader were finished before this chapter started.

That is not a trick to save time in a workshop. Reusing the wire format as the log format is what gives you a log you can read with cat, and it means one round-trip test covers both.

The thing to be careful about is that the log records requests, not results. SET users alice hello replays to the same state every time. INCR users counter would not, and a log of non-deterministic operations replays into a different database than the one you had. If a command can produce a different result on a different day, log its effect rather than the command.

write_all is not durability

#![allow(unused)]
fn main() {
self.file.write_all(format!("{request}\n").as_bytes()).await
}

hands the bytes to tokio::fs, which hands them to the operating system, which puts them in a cache and says it is done. A process crash is survivable at that point. A power cut is not.

sync_all is the call that waits for the disk, and it is expensive, which is why the next two exercises are about when to call it rather than whether.

There are two buffers in the way, and both have to be emptied:

#![allow(unused)]
fn main() {
pub async fn sync(&mut self) -> io::Result<()> {
    self.file.flush().await?;      // tokio's own buffer
    self.file.sync_all().await     // the operating system's
}
}

Tokio’s file I/O is not async

There is no portable way to await a disk, so tokio::fs wraps the blocking calls in spawn_blocking. Chapter 2, in other words, with the trip to the blocking pool already written for you.

Two consequences follow. Every append costs a trip to that pool, which is another reason to do more per trip. And write_all returns before the write has been attempted, so a disk that refuses it says nothing until the buffer is flushed. The test for a log that cannot be written is where that shows up, and it is the reason the durability check in the next exercise has to look at what sync returned.

Where this chapter goes

Write the record before applying the change. Batch the syncs so a busy server does not make one trip to the disk per request. Replay the log on startup so the restart is invisible to whoever reconnects.

Exercise

The exercise for this section is located in 09_wal/00_intro