writeonce/docs/superpowers/specs/2026-08-28-wal-checkpoint-design.md
shoney.arickathil 69c34c9a89 docs(spec): WAL checkpoint — compact by rewrite + atomic rename
databasev2 3, chain 6. Brainstormed 2026-08-28 after databasev2 4 part A
landed.

Design: compact the log by rewriting it as one record per live row into a
temp file, fsync, rename over the live WAL, fsync the parent dir, reopen.
Recovery is COMPLETELY UNCHANGED — boot still opens one file and replays
it — and the crash criterion ("the same store as if the checkpoint had
never started") is satisfied by rename, not by code we must get right.

Read .dev/reference/postgresql for this. The finding is that PG's design
is UNAVAILABLE to us, which is what makes the simpler option legitimate:

- PG never compacts its WAL; segments before the redo point are recycled
  by rename or unlinked. Its records are page deltas, so a compacted redo
  log is not a store — hence heap files, a control file, a redo pointer,
  a second recovery source and a separate process
- ours are FULL ROW IMAGES (apply_record implements UPDATE as
  remove-then-recreate), so a compacted log IS a complete store. That one
  difference deletes all of the above from the design
- what IS worth porting is the ordering discipline: publish the new
  "recovery starts here" atomically and LAST, so a crash falls back. PG
  needs a start-of-checkpoint redo pointer plus an end-of-checkpoint
  control file update; we get the same property from one rename, because
  we can swap the whole data set atomically and PG cannot

Forks settled:

- no snapshot format — the compacted log is the snapshot, existing grammar,
  so no new encoder or decoder and the dump reuses wo_wal_append_insert
- one source, not two
- volume-only trigger, as a ratio against the LAST compaction's measured
  output (the denominator is known exactly; estimating the live set would
  mean estimating Text) with an absolute floor. NO TIMER — PG's exists to
  bound loss from unflushed buffers and we have none; an idle log does not
  grow. Copying the mechanism without the reason was the trap
- stop-the-world, with the pause measured against a stated budget rather
  than assumed acceptable; alternatives are bought against a number
- compaction may run ONLY where nothing is staged (right after a barrier),
  or a staged record lands in a file about to be replaced. Normative

Recorded before it can be found late: compaction invalidates every WAL
offset iteration 2's `resident: keys` stores, so the compactor rebuilds the
offset map as it writes. Nothing breaks today because that storage half is
unimplemented — it would break later, looking like corruption.

Also corrected exploration/postgresql/buffer-and-checkpoint.md, which was
wrong on two counts: PG does NOT update its control file by rename (in-place
full-block write + CRC32C), and its checkpoint sketch assumes writeonce has
segment files, which it does not and deliberately will not.

Grounding measured on master: seed 20000 leaves a 986614-byte log; 20000
updates take it to 2590262 bytes with the SAME live rows, and boot+verify on
that store is 155ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 17:39:38 +02:00

12 KiB

WAL checkpoint — design

databasev2 3, chain 6. Brainstormed and approved 2026-08-28, after databasev2 4 part A landed.

One sentence: compact the log by rewriting it as one record per live row into a temporary file, then rename it over the live WAL — so recovery is unchanged and crash safety comes from the filesystem.

Decisions taken (the brainstorm's forks, settled)

Fork Decision
Snapshot format None. The compacted log is the snapshot, in the existing record grammar
One source or two One. Rewrite + atomic rename; boot logic is untouched
Trigger Volume only, as a ratio against the last compaction's own size, with an absolute floor. No timer — see below
Write availability Stop-the-world, measured against a stated budget rather than assumed acceptable
Composition with group commit Compaction runs only where nothing is staged — immediately after a barrier
resident: keys (iteration 2) Compaction rebuilds the offset map as it writes. It cannot be left to discover this later

Why one file, and why Postgres cannot do it

Postgres was read for this (.dev/reference/postgresql), and the conclusion is that its design is unavailable to us — which is what makes the simpler option legitimate rather than lazy.

PostgreSQL writeonce
Where data lives heap/data files; the WAL is a redo tail the WAL is the only durable form, replayed into RAM
WAL contents page deltas and full-page images full row images — apply_record implements UPDATE as remove-then-recreate
Compaction never; segments before the redo point are recycled by rename or unlinked possible, because a log of row images is a complete store
Bounded replay recovery starts at the redo LSN in the control file recovery starts at byte 0 of a shorter log
Crash safety of the switch control file written in place, full block, torn writes caught by CRC32C (update_controlfile) one rename
Trigger CheckPointTimeout (300 s) or WAL volume (XLogCheckpointNeeded) volume only
Pause none; flush is spread over time in a separate process stop-the-world

Postgres cannot compact its WAL because a compacted redo log is not a store — its records describe changes to pages that live elsewhere. Ours describe whole rows, so the compacted log needs no companion. That single difference removes the control file, the redo pointer, the second recovery source, and the separate process from our design.

What is worth porting is not the architecture but the ordering discipline: publish the new "recovery starts here" atomically and last, so a crash at any instant falls back to the previous state with nothing to undo. Postgres achieves that with a redo pointer computed at checkpoint start and a control file updated at the end. We achieve the same property with rename, in one syscall, because we can swap the entire data set atomically and Postgres cannot.

Correction to a prior exploration doc. docs/plan/exploration/postgresql/buffer-and-checkpoint.md states that Postgres updates its control file by rename ("the same in BasicOpenFile + fsync_parent_path"). It does not — update_controlfile opens the existing file O_WRONLY, writes a zero-padded full block in place, and relies on CRC32C to detect a torn write. That doc also assumes writeonce has segment files ("records before that LSN are known to be in the segment files"), which it does not and, per databasev2 2, deliberately will not. The doc predates the databasev2 direction and should be annotated rather than followed.

The design

Compaction

Run on the owner shard, which owns the WAL. Walk each class's live rows — the bitmap-over-slabs walk that three call sites in db.c already perform — and append one INSERT record per live row to a new file, using the existing append path. No new encoder, no new decoder, no format.

Then: fsync the new file, rename it over the live path, fsync the parent directory (the rename's atomicity is in-kernel; the directory entry is not durable until the parent is synced — Postgres does the same, and the existing exploration doc is right about this part), and reopen the WAL descriptor, because the old one now refers to an unlinked inode.

Every crash point is safe without any recovery logic of ours. Before the rename, the live WAL is untouched and the temp file is garbage. After it, the new log is complete by construction. There is no window in which a reader could see a mixture, so the acceptance criterion — "recovery produces the same consistent store as if the checkpoint had never started" — is satisfied by rename, not by code we must get right.

Two obligations follow. Boot must unlink a stale temp file if one is present, because a crash mid-rewrite leaves one behind and it must never be mistaken for data. And the temp file must be zero-padded beyond its records exactly as the live WAL is, because the tail scan identifies the end of the log by a zero length field.

When it runs, and where in the sequence

The point matters more than the policy. The drain stages records into one buffer and commits them together; compaction rewrites the file those records would land in. So compaction may run only when nothing is staged — in practice, immediately after a barrier, before the next statement is served. Anywhere else and a staged record would either be written to a file about to be replaced, or be lost with it. This is the normative ordering rule that 04-db-binding.md must carry.

Trigger: volume, as a self-tuning ratio. Compact when the WAL's used bytes exceed a multiple of the bytes the last compaction wrote, with an absolute floor so a small store never bothers. The denominator is known exactly — the compactor wrote it — so this needs no estimate of the live set's size, which is not cheaply knowable when rows hold Text. The floor exists because a store whose whole log is a few hundred kilobytes has nothing to reclaim.

No timer, and that is a deliberate difference from Postgres. Postgres needs CheckPointTimeout because its dirty buffers are not durable until flushed — an idle-but-dirty system must still checkpoint or it loses data. Our records are already durable at commit; a checkpoint reclaims space and shortens boot and nothing else. An idle system's log does not grow, so a timer would fire with nothing to do. Adding one would be copying Postgres' mechanism without its reason.

A manual trigger exists for tests, because a policy that can only be observed by waiting is a policy that cannot be tested.

The pause, and how it is judged

Compaction is stop-the-world: the owner shard rewrites the log as one long operation while no statement is served. This is the simplest correct thing, and part A's own experience argues for measuring before buying complexity to avoid it. The dump is O(live rows) encodings plus one write and one barrier, so the expectation is that it is fast — but an expectation is not a measurement, and the proof plan below states the budget it must meet.

If the measured pause exceeds the budget, that is a finding and a follow-up, not something this iteration solves by adding concurrency. The alternative designs (incremental copy, fork-and-dump) cost exactly what Postgres pays, and should only be bought against a number.

The interaction that will otherwise be discovered late

Compaction invalidates every stored WAL offset. Rewriting the log moves every record, so any offset captured from the old file is meaningless afterwards — not stale-but-readable, but pointing at an arbitrary byte of a different file. Iteration 2's resident: keys stores exactly such offsets, one per row, and reads rows back through them.

The compactor therefore rebuilds the offset map as it writes: it is emitting the new records and knows each one's new position, so this is the cheap direction and the only one that keeps both features usable together. The alternative — forbidding compaction while any resident: keys table is live — would mean the feature that exists to handle huge tables is incompatible with the feature that stops their log growing forever.

This is recorded here because iteration 2's storage half is not yet implemented, so nothing will fail today. It will fail later, in a way that looks like data corruption rather than a design gap.

Proof plan

Claim How it is proven
Space is reclaimed An aged store shrinks. Measured today on master: seed 20000 gives a 986 614-byte log; 20 000 updates take it to 2 590 262 bytes with the same live rows. Compaction must return it to approximately the former
Replay is bounded Boot time on the aged store before and after, recorded. Measured today: boot+verify on that store is 155 ms
Crash safety kill -9 at many instants during compaction, then replay: the store must equal either the pre-compaction or post-compaction state, never a mixture, and no acked write may be missing. This is the criterion the whole design is shaped around, so it gets the crash battery's treatment rather than one case
A stale temp file is harmless Boot with one present, containing plausible records: it is removed and never read
The pause is known The stop-the-world pause measured on the largest store the harness builds, recorded as a number with a stated budget — not asserted to be acceptable
The ordering rule holds Compaction with records staged must be impossible by construction; a test that stages and then requests compaction must find it deferred, not executed
Nothing regressed The full battery, and specifically part A's wmix legs: compaction must not change the ack contract or the batching it introduced

Out of scope

  • A second file, a control file, or a redo pointer. The Postgres shape, priced above and not needed once the log is self-sufficient.
  • Avoiding the pause. Incremental or forked dumps are bought against a measurement, not in advance.
  • Per-shard compaction policy. One owner shard owns the WAL today; when that changes, this decision is revisited with it.
  • Compacting away tombstones across shards, or any cross-shard coordination. There is one log.
  • io_uring for the rewrite — part B of databasev2 4, whose premise is already under revision.
  • Changing the record grammar. The entire argument for this design is that the grammar already suffices.

Alternatives rejected

Snapshot + WAL tail (the Postgres shape). Rejected because it buys write availability at the cost of a second recovery source, a cutoff offset, a control file with its own torn-write detection, and a crash-safety guarantee that depends on our ordering rather than on rename. Postgres pays this because its log cannot stand alone; ours can.

Compacting in place. Rejected outright: there is no crash point at which a partially rewritten live log is recoverable, and it trades the one property that makes this design defensible for nothing.

A timer trigger. Rejected with a reason rather than on taste: Postgres' timer exists to bound data loss from unflushed buffers, and we have no unflushed buffers. An idle log does not grow.

A ratio against an estimated live-set size. Rejected in favour of the last compaction's measured output, because estimating the live size means estimating Text, and the compactor already knows the true number.