droid
Checkpoints and Log Truncation
Transactions and Recovery · Stage 5 of 5

Checkpoints and Log Truncation

Commit to the log alone, and bring the database file up to date when it suits you

Stop writing a commit's pages to the database file: the commit ends once the log is synced, and its pages stay dirty in the pool. They reach the file by eviction, pager flush, or a checkpoint, which writes every dirty page in page order, syncs the file and empties the log. Add CHECKPOINT and its report, make the clean end of a session a checkpoint, start VACUUM with one, and let recovery finish a checkpoint a crash cut short.

Not run yet · 8 tests
Concepts in this stage
  • No-force: a commit does not wait for the database file, only for one sequential append to the log
  • The log is the truth for everything since the last checkpoint; the database file can lag behind it
  • A checkpoint moves that line: after it, the database file alone holds every commit
  • The log cannot grow forever, and a checkpoint is what lets it shrink
  • A committed page may be evicted at any time: its records are already in the log
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 formatSlotted pageB+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 commit so far does the work twice. It appends its pages to the log and syncs, then writes the same pages to the database file and syncs again. The second half buys nothing any more: if the program stops before it, recovery does it at the next start. And it is the slow half. The log is one file written from start to end; the database file takes pages scattered all over it, and a busy page is written again at every commit that touches it.

So this stage drops it. A commit is done when its log record is synced. Its pages stay in the pool, dirty, and the database file catches up later, all at once, in a checkpoint. Real engines commit this way: PostgreSQL writes its data files in the background and at checkpoints, never at commit.

That raises the question the stage's title answers. If the database file lags behind, the log must keep every commit since it last caught up, and it grows with every commit. A checkpoint is the moment the database file has caught up completely, so the log before it is no longer needed and can be cut back to its header.

What changes: where a commit lives

Before this stageNow
a commit endsafter its pages are in the database fileafter its records are synced in the log
a committed pageclean in the pooldirty until something writes it
the database fileholds every commitholds every commit up to the last checkpoint, plus whatever eviction wrote
after a crashrecovery rewrites pages the file already hadrecovery is the only way back for the recent commits
the logemptied at a clean endemptied by every checkpoint, of which a clean end is one
Drawing the diagram…

The rule from the start of this part still holds: a page the open transaction changed never reaches the file. What is new is that a committed page may, at any time. That is safe because of the rule from the log's stage: by the time the commit is done, its records are synced. The database file can never get ahead of the log.

What changes: CHECKPOINT

A checkpoint writes every dirty page in page order, syncs the database file, and only then empties the log. The order matters for the same reason it did in recovery: if the program stops in the middle, the log is still whole, and the next start replays it. A checkpoint cut short is just a recovery waiting to happen.

The program checkpoints at three moments: when asked with CHECKPOINT;, at the clean end of a session, and before VACUUM moves its first page, since VACUUM leaves the log empty and must not leave any commit behind in it.

A real engine also checkpoints on its own. SQLite does when its log reaches 1000 pages; PostgreSQL every few minutes or when the log reaches max_wal_size. Both write the pages gradually, alongside other work, so a checkpoint does not stall the commits behind it. This engine checkpoints only when asked, so a test always knows what the log holds.

The algorithm in words

1

Commit to the log only

The commit's last step goes away.

  1. 1.Append the page records and the commit record, sync the log, print the line. The pages stay in the pool, dirty.
  2. 2.A committed dirty page is evictable like any other: when it leaves, it is written to the database file. Only the open transaction's pages are held.
2

Checkpoint

Bring the database file up to date with the log, then drop the log.

  1. 1.Write every dirty page in the pool to the database file, in ascending page number.
  2. 2.Sync the database file. Only then cut the log back to its 16-byte header.
  3. 3.For CHECKPOINT, print its line and then the report: the pages written and the good records the log had. Inside a transaction it is 01304 and changes nothing.
3

Checkpoint without being asked

Two moments where the program does it on its own, silently.

  1. 1.At the clean end of a session: .exit, end of input, the end of -c.
  2. 2.At the start of VACUUM, before it moves any page.

Output contract for this stage

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

InputOutput
checkpoint;Checkpoint statement executed successfully, then [WAL] Checkpoint: pages=<k> records=<r>
checkpoint; with nothing to dothe same line, then [WAL] Checkpoint: pages=0 records=0
checkpoint; inside a transaction[ERROR:01304] <message>, and the transaction carries on
tokenize CHECKPOINT;[KEYWORD_CHECKPOINT - CHECKPOINT], [SYMBOL - ;]
ast checkpoint;Statement: CHECKPOINT
explain checkpoint;[PLAN] checkpoint, [PLAN] └── Checkpoint

k is the pages written to the database file, r the good records the log had. mid_page_write now stops a checkpoint after its first page, the silent ones included.

What you should see when done

shell
$droid --db notes.db
Welcome to droid!
>create table notes (id int, text varchar(100));
Create table statement executed successfully
>checkpoint;
Checkpoint statement executed successfully
[WAL] Checkpoint: pages=3 records=4
>insert into notes (id, text) values (1, 'buy milk');
Insert statement executed successfully (1 rows)
>insert into notes (id, text) values (2, 'call mom');
Insert statement executed successfully (1 rows)
>wal status
[WAL] Status: size=8288 records=4
[WAL] Record 1: lsn=16 tx=1 type=PAGE page=2
[WAL] Record 2: lsn=4132 tx=1 type=COMMIT
[WAL] Record 3: lsn=4152 tx=2 type=PAGE page=2
[WAL] Record 4: lsn=8268 tx=2 type=COMMIT
>checkpoint;
Checkpoint statement executed successfully
[WAL] Checkpoint: pages=1 records=4
>checkpoint;
Checkpoint statement executed successfully
[WAL] Checkpoint: pages=0 records=0
>.exit
exiting.. good bye!

Two commits changed the same page, and the checkpoint wrote it once. That is the saving: a hot page committed a hundred times between checkpoints is written to the database file once.

Try a commit followed by .crash, then delete notes.db.wal before restarting. The row is gone: the database file never had it. With the log kept, recovery brings it back. With a checkpoint; before the crash, deleting the log loses nothing.

Common bugs in this stage

  • Emptying the log before the file is synced. A crash between the two loses every commit since the last checkpoint. Write, sync, then cut the log.
  • Still holding committed pages in the pool. Only the open transaction's pages are held. A committed page that cannot be evicted brings back the transaction-too-large error for work that is long committed.
  • ROLLBACK reading pages back from the file. The file may not have the last commit of a page yet. Before-images hold it; the file does not. A page restored by ROLLBACK that held an unwritten commit stays dirty.
  • Forgetting the clean end. A session that ends with .exit must leave the database file holding everything: the next start should find an empty log and print no recovery line.
  • Writing pages in the order you find them. Write in ascending page number. The tests' fail-point stops after the first page, and the order is what makes that page known.
  • VACUUM throwing commits away. VACUUM empties the log; without a checkpoint first, every commit since the last one goes with it.
  • Counting records the log no longer trusts. records= counts the good records, as wal status does, not the bytes after a torn tail.

Language notes

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

  • C: collect the dirty frames' page numbers into an array and qsort it before writing; pwrite each page, fsync the database file, then ftruncate the log.
  • C++: std::sort the dirty page numbers, or keep frames in a std::map keyed by page so they come out in order.
  • Rust: collect the dirty page numbers into a Vec<u32> and sort_unstable(); a BTreeMap of frames gives the order for free.
  • Zig: std.mem.sort(u32, pages, {}, std.sort.asc(u32)) on the dirty page numbers, then write, sync, setEndPos(16).

Checklist

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

  • A commit reaches the log, not the database file, until a checkpoint; a clean end is one
    commit-stays-in-log
  • CHECKPOINT writes the dirty pages, empties the log and reports both
    checkpoint-statement
  • After CHECKPOINT the rows live in the database file
    checkpoint-data-in-db-file
  • ROLLBACK puts back a commit the file does not have yet
    rollback-keeps-unwritten-commit
  • CHECKPOINT inside a transaction is 01304
    checkpoint-inside-transaction
  • A crash in the middle of a checkpoint is finished by recovery
    checkpoint-crash-midway
  • A committed page may leave a small pool before any checkpoint
    committed-pages-evicted
  • checkpoint is a keyword, with ast and explain lines
    tokenize-ast-explain