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.
- 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
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 stage | Now | |
|---|---|---|
| a commit ends | after its pages are in the database file | after its records are synced in the log |
| a committed page | clean in the pool | dirty until something writes it |
| the database file | holds every commit | holds every commit up to the last checkpoint, plus whatever eviction wrote |
| after a crash | recovery rewrites pages the file already had | recovery is the only way back for the recent commits |
| the log | emptied at a clean end | emptied by every checkpoint, of which a clean end is one |
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
Commit to the log only
The commit's last step goes away.
- 1.Append the page records and the commit record, sync the log, print the line. The pages stay in the pool, dirty.
- 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.
Checkpoint
Bring the database file up to date with the log, then drop the log.
- 1.Write every dirty page in the pool to the database file, in ascending page number.
- 2.Sync the database file. Only then cut the log back to its 16-byte header.
- 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.
Checkpoint without being asked
Two moments where the program does it on its own, silently.
- 1.At the clean end of a session: .exit, end of input, the end of -c.
- 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.
| Input | Output |
|---|---|
checkpoint; | Checkpoint statement executed successfully, then [WAL] Checkpoint: pages=<k> records=<r> |
checkpoint; with nothing to do | the 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
$droid --db notes.dbWelcome 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>.exitexiting.. 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
.exitmust 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, aswal statusdoes, 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
qsortit before writing;pwriteeach page,fsyncthe database file, thenftruncatethe log. - C++:
std::sortthe dirty page numbers, or keep frames in astd::mapkeyed by page so they come out in order. - Rust: collect the dirty page numbers into a
Vec<u32>andsort_unstable(); aBTreeMapof 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 onecommit-stays-in-log
- CHECKPOINT writes the dirty pages, empties the log and reports bothcheckpoint-statement
- After CHECKPOINT the rows live in the database filecheckpoint-data-in-db-file
- ROLLBACK puts back a commit the file does not have yetrollback-keeps-unwritten-commit
- CHECKPOINT inside a transaction is 01304checkpoint-inside-transaction
- A crash in the middle of a checkpoint is finished by recoverycheckpoint-crash-midway
- A committed page may leave a small pool before any checkpointcommitted-pages-evicted
- checkpoint is a keyword, with ast and explain linestokenize-ast-explain