writeonce/docs/superpowers/plans/2026-08-28-wal-checkpoint.md
shoney.arickathil 74399ffc68 docs(plan): WAL checkpoint — 6 tasks, databasev2 3
Plan for the approved spec. Code-free per the repo convention
(docs/plan/discarded.md:54); the executor writes the code.

- T1 wo_wal_compact: walk live rows via the bitmap, append one INSERT
  each through the EXISTING append path, fsync, rename over the live log,
  fsync the parent dir, reopen the descriptor. Test asserts BOTH that the
  log shrank AND that a replay reproduces the same rows/ids/values —
  shorter alone is worthless, a truncating bug also passes that
- T2 a stale temp file is removed at open and never read. The test uses
  PLAUSIBLE records, not garbage: garbage would be rejected anyway and
  would prove nothing
- T3 the trigger as a PURE decision (used bytes, last compaction's
  measured output, floor) so it is unit-testable without a store; env
  knobs for floor and ratio, which is what makes the policy testable at
  all. No timer, with the reason. The check is called only where nothing
  is staged, asserted by a test that stages and expects deferral
- T4 kill -9 DURING compaction, extending the existing fork-based crash
  battery. Asserts the PROPERTY — the store equals the pre- or the
  post-compaction content, never a mixture, and every acked id survives.
  Run repeatedly and state the count: it is a race, one green run proves
  little
- T5 measure space reclaimed, boot before/after, and the stop-the-world
  PAUSE against a stated budget. If the pause exceeds it, stop and report
  — the alternatives are bought against that number, not before it
- T6 closeout, including the normative ordering rule in 04-db-binding.md

Constraints carried from the spec into every task:

- recovery must NOT change; a task editing the replay path should stop
- the dump must FLUSH PERIODICALLY. stage() grows the staging buffer by
  doubling, so dumping a whole store through one buffer would hold the
  entire store in RAM — the unbounded growth databasev2 1 identified as
  how this engine dies
- a FAILED compaction is a missed optimisation, not a durability event,
  so it must not take databasev2 4's fatal path
- gate tolerances must not be waived wholesale (part A's T4 made that
  mistake), and the baseline is full-mode — writing a quick-mode baseline
  over it is a regression part A also made

Deliberately NOT a task: rebuilding the `resident: keys` offset map. It
cannot be implemented against a feature that does not exist yet, so T6
records it as an obligation at the compactor and in the story instead of
a stub nobody can test.

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

265 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# databasev2 3 — WAL checkpoint (implementation plan)
> **For agentic workers:** REQUIRED SUB-SKILL: Use
> superpowers:subagent-driven-development (recommended) or
> superpowers:executing-plans to implement this plan task-by-task. Steps
> use checkbox (`- [ ]`) syntax for tracking.
>
> **Style rule (user convention):** concept, reason, and required
> behaviour in words plus verification commands only — no implementation
> or test code blocks; the executor writes the code.
**Goal:** reclaim disk and bound replay by rewriting the log as one record per
live row and swapping it in with `rename`, so boot replays a short log instead
of all history.
**Architecture:** compaction writes the live store into a temporary file using
the existing record grammar and the existing append path, fsyncs it, renames it
over the live WAL, fsyncs the parent directory, and reopens the descriptor.
Recovery is untouched — boot still opens one file and replays it — and every
crash point is safe because `rename` is atomic.
**Tech Stack:** C11, libc only. `pwrite`, `fdatasync`, `rename`, `open`,
`unlink`. No new dependency and no new file format.
**Spec:** [`../specs/2026-08-28-wal-checkpoint-design.md`](../specs/2026-08-28-wal-checkpoint-design.md)
## Global Constraints
- **Recovery must not change.** No second source, no cutoff offset, no control
file. If a task finds itself editing the replay path, something has gone
wrong with the design and it should stop rather than proceed.
- **Every crash point falls back.** Before the rename the live log is untouched;
after it the new log is complete. There must be no window in which a reader
could observe a mixture.
- **The record grammar is frozen.** The whole argument for this design is that
it already suffices. A compacted log is INSERT records for live rows, ids
preserved exactly.
- **Bounded memory.** `stage()` grows the staging buffer by doubling and never
shrinks it, so dumping a whole store through one buffer would hold the entire
store in RAM — the unbounded growth databasev2 1 identified as how this engine
dies. The dump must flush periodically.
- **Compaction may run only where nothing is staged** — in practice immediately
after a barrier. Anywhere else, a staged record lands in a file about to be
replaced.
- **libc only**, no new syscall interface. Gates run through `just`. Never
commit on `master`; branch first.
---
## Task 1 — `wo_wal_compact`: rewrite, fsync, rename, reopen
**Files:**
- Modify: `database/src/wal.c`, `database/src/wal.h`.
- Test: `runtime/test/test_wal.c`.
**Interfaces:**
- Produces: a compaction entry point taking the live WAL and the store, which
replaces the log with one INSERT record per live row and leaves the WAL usable
(descriptor reopened, offset correct). Returns success or failure; a failure
must leave the ORIGINAL log intact and usable, because a failed checkpoint is
not a durability event.
- Consumes: the existing append path and commit routine, and the bitmap walk
that `db.c` already performs in three places.
- [ ] Read three things first and confirm them, because the design rests on
them: `apply_record` implements UPDATE as remove-then-recreate (so records are
full row images), `wo_wal_append_insert` takes an id and reads the row from
the store (so ids are preserved), and the tail scan treats a zero length field
as end-of-log (so the new file must be zero beyond its records).
- [ ] Test first, RED: build a store, age it (insert rows, then update the same
rows repeatedly so history exceeds live data), compact, then assert **both**
that the log got materially shorter AND that a fresh replay of it produces the
same rows with the same ids and the same values. Shorter alone is worthless —
a truncating bug also passes that.
- [ ] Verify RED for the right reason: the entry point does not exist yet.
- [ ] Implement the walk: for each class, iterate slots via the bitmap and
append one INSERT per live row. Reuse the append path; do not write a second
encoder.
- [ ] **Flush every K records rather than staging the whole store.** Point a
scratch WAL at the temp descriptor and commit periodically. State the chosen K
and why in a comment. Without this the dump holds the entire store in RAM.
- [ ] Sequence the switch exactly: fsync the temp file, `rename` over the live
path, **fsync the parent directory** (the rename is atomic in-kernel but the
directory entry is not durable until the parent is synced), then reopen the
descriptor — the old one refers to an unlinked inode — and reset the offset to
the new end of log.
- [ ] Handle failure without losing data: any error before the rename must
unlink the temp file and leave the live log untouched. A failed compaction is
a missed optimisation, **not** a durability failure, so it must NOT take the
fatal path databasev2 4 introduced.
- [ ] GREEN: `just wovm-test`.
- [ ] Commit.
## Task 2 — a stale temp file is removed, never read
**Files:**
- Modify: `database/src/wal.c` (the open path).
- Test: `runtime/test/test_wal.c`.
**Interfaces:**
- Consumes: Task 1's temp-file naming.
- Produces: the guarantee that a crash mid-rewrite leaves nothing that can be
mistaken for data.
- [ ] Test first, RED: place a temp file next to the log containing *plausible,
well-formed records* (not garbage — garbage would be rejected anyway and would
prove nothing), open the store, and assert the temp file is gone and the
replayed store is exactly what the live log said.
- [ ] Verify RED for the right reason.
- [ ] Remove any stale temp file when the WAL is opened. Note in a comment why
this is safe: the only way one exists is a crash before a rename, and its
contents are by definition not yet authoritative.
- [ ] GREEN: `just wovm-test`.
- [ ] Commit.
## Task 3 — the trigger, and the ordering guard
**Files:**
- Modify: `database/src/wal.c`, `database/src/wal.h` (remember the last
compaction's size; the policy decision), `runtime/src/vm.c` (call the check
after the barrier).
- Test: `runtime/test/test_wal.c`.
**Interfaces:**
- Consumes: Task 1's compaction entry point.
- Produces: automatic compaction, and the invariant that it never runs with
records staged.
- [ ] Extract the policy as a **pure decision** — given the log's used bytes,
the bytes the last compaction wrote, and a floor, should we compact? Pure
because it is then unit-testable without a store, which is the only way this
policy gets tested at all.
- [ ] Test the decision directly, RED then GREEN: below the floor it never
fires however bad the ratio; above the floor it fires exactly when used bytes
exceed the multiple; with no prior compaction it uses the floor alone.
- [ ] Record the bytes each compaction wrote, so the denominator is measured
rather than estimated. Estimating the live size would mean estimating Text,
and the compactor already knows the true number.
- [ ] Expose the floor and the ratio as env knobs, matching the existing idiom
(`WO_MAILBOX`, `WO_HEAP_MB`, `WO_SHARDS`, `WO_WAL_STATS`). **This is what
makes the policy testable** — a test sets a tiny floor and forces compaction
in a few writes instead of waiting for megabytes. Document them beside the
others. **Deviation from the spec, disclosed:** the spec spoke of a "manual
trigger for tests"; env-tunable thresholds serve that purpose without adding
language surface, which is the cheaper way to buy the same testability.
- [ ] **No timer.** If the implementer is tempted, the reason is in the spec:
Postgres' `CheckPointTimeout` bounds loss from unflushed buffers, our records
are durable at commit, and an idle log does not grow.
- [ ] Call the check from the one place that is safe — immediately after the
drain's barrier, where nothing is staged. Comment that this is a correctness
requirement and not a scheduling preference.
- [ ] Verify the guard: a test that stages records and then makes the policy
say yes must find compaction deferred, not executed. This is the assertion
that keeps the ordering rule true as the code moves.
- [ ] Verify durability is unaffected: `just db-bench --quick` — the crash and
restart legs must be unchanged, and part A's `wmix` legs must still batch.
- [ ] Commit.
## Task 4 — kill -9 *during* compaction
**Files:**
- Test: `runtime/test/test_wal.c` (extend the existing fork-based crash
battery).
**Interfaces:**
- Consumes: Tasks 1–3.
- Produces: the evidence for the criterion the whole design is shaped around.
- [ ] Read the existing crash battery first: a forked child inserts and acks
each committed id over a pipe while the parent SIGKILLs it mid-stream, then
the parent verifies every acked id survived. Extend that shape rather than
inventing a second harness.
- [ ] Drive compaction repeatedly in the child (a tiny floor makes it fire
often) while it inserts and acks, and kill at many instants so the kill lands
inside a rewrite, at the rename, and after it.
- [ ] Assert the property, not a state: after replay the store must equal
**either** the pre-compaction **or** the post-compaction content — never a
mixture — and **every acked id must be present**. A test that only checks "it
replayed without error" would pass on a silently truncated log.
- [ ] Assert no temp file survives a kill in a way that affects the next boot.
- [ ] Run the battery repeatedly, not once: this is a race, and one green run
proves very little. State how many repetitions were run in the commit message.
- [ ] GREEN: `just wovm-test` plus the repetitions.
- [ ] Commit.
## Task 5 — measure: space, boot, and the pause
**Files:**
- Modify: `scripts/db-bench.py` (a checkpoint leg), `docs/plan/perf-targets.md`,
`bench/baseline.json` (refresh, with the reason in the commit message).
**Interfaces:**
- Consumes: Tasks 1–3.
- Produces: the before/after record, and the pause number the spec deliberately
refused to assume.
- [ ] Capture the before numbers already measured on master, rather than
re-deriving them: `seed 20000` leaves a 986 614-byte log; 20 000 updates take
it to 2 590 262 bytes **with the same live rows**; boot+verify on that aged
store is 155 ms.
- [ ] Add a leg that ages a store, compacts it, and records: bytes before and
after, the ratio reclaimed, and boot time before and after. Age it by
updating the same rows — history must grow while the live set does not, or the
leg is measuring insert throughput instead of compaction.
- [ ] Measure the **stop-the-world pause** on the largest store the harness
builds and record it as a number. State the budget it must meet.
- [ ] **If the pause exceeds the budget, stop and report it.** That is the
finding the spec asked for, and the alternatives (incremental copy,
fork-and-dump) are bought against this number — not before it.
- [ ] Give the new metrics tolerances that match what they are: bytes reclaimed
is structural and can be gated tightly; the pause is wall-clock on a shared
box and cannot. Do not waive them all, which is the mistake part A's task 4
made and had to undo.
- [ ] Verify the gate bites: doctor the reclaimed-bytes metric and confirm the
suite fails on exactly that metric.
- [ ] Refresh the baseline and confirm the **full** campaign passes against it.
The committed baseline is full-mode (`N=20000`, `crash_reps=3`) — writing a
quick-mode baseline over it is a regression, and part A made exactly that
mistake.
- [ ] Commit.
## Task 6 — closeout
**Files:**
- Modify: `docs/stories/databasev2/03-wal-checkpoint.md`,
`docs/stories/00-status.md`, `docs/plan/oop-vm/04-db-binding.md`,
`database/src/CODE-LOGIC.md`, `docs/examples/db-bench/README.md`.
- [ ] `04-db-binding.md`: the normative ordering rule — compaction runs only
where nothing is staged, and what recovery does (unchanged: one file, replayed
from byte 0). This is the doc the spec named for it.
- [ ] `CODE-LOGIC.md`: why one file rather than snapshot-plus-tail, why
`rename` is the crash-safety primitive, why the dump flushes periodically, and
why a failed compaction is not a durability event. Reasoning, not call graph.
- [ ] README: the new env knobs beside the existing ones, and the checkpoint
leg.
- [ ] Story: progress, criteria split met/outstanding, and the measured
before/after.
- [ ] Board: standup entry in the six-question shape, and the chain note —
chain 6 was the last link, so say what the chain's completion means and what
is next.
- [ ] **Record the `resident: keys` obligation prominently, in the story and at
the compactor.** Compaction moves every record, so it invalidates every WAL
offset iteration 2 stores; the compactor must rebuild that map as it writes.
There is nothing to implement today because iteration 2's storage half does
not exist — which is exactly why this must be written where the next
implementer will hit it, not left in a spec they may not read.
- [ ] Full battery: `just wovm-test`, `just woc-test`, `just oop-e2e`,
`just db-bench`, `python3 scripts/linkcheck.py .`
- [ ] Commit.
## Self-review notes
- **Spec coverage.** Compaction and the switch → Task 1. Stale temp → Task 2.
Trigger, no timer, ordering rule → Task 3. Crash safety → Task 4. Space, boot,
pause → Task 5. Normative doc, `resident: keys` obligation → Task 6.
- **The riskiest task is 4**, not 1: Task 1's correctness is a single replay
comparison, while Task 4 is a race and can pass by luck. Hence the explicit
instruction to run it repeatedly and to state the count.
- **Task 2 looks trivial and is not.** A stale temp file containing well-formed
records is the one input that could be mistaken for data, so the test uses
plausible records rather than garbage.
- **One thing deliberately NOT a task:** rebuilding the `resident: keys` offset
map. It cannot be implemented against a feature that does not exist yet.
Recorded as an obligation in Task 6 instead of a stub nobody can test.