writeonce/docs/superpowers/plans/2026-08-28-wal-group-commit.md
shoney.arickathil 026919762b docs(plan): WAL group commit — 6 tasks, databasev2 4 part A
Plan for the approved spec. Code-free per the repo convention
(docs/plan/discarded.md:54); the executor writes the code.

- T1 a failed barrier is detected and fatal — one entry point that names
  the operation, errno, WAL path and batch size, then exits. The abort
  path itself stays unexercised and the task says so rather than buying
  coverage with a fault-injection switch
- T2 the barrier moves to the drain point and replies are held; the
  request path stops committing per append. Riskiest task, and its risk
  is one place: the crash legs. Plan says STOP if they fail, do not
  adjust the test
- T3 the inline path takes the same fatal rule but keeps its own barrier,
  with a comment explaining the asymmetry so the next reader does not
  "fix" it. Looks like a no-op; without it the two paths disagree, which
  is the unevenness the spec exists to remove
- T4 prove batches actually form BEFORE measuring the payoff — otherwise
  a win gets attributed to the wrong cause. Also records peak staged
  bytes, settling the no-cap decision with a number
- T5 measure, gate, write it down. If the payoff is absent, say so and
  stop: part B must not start on an unproven premise
- T6 closeout, including the error catalogue — WO_T_IO leaving the write
  path is language-visible and must be written down

Spec corrected while planning: it pointed at durable.s1.seed as the
payoff. Wrong, structurally — worker shards hold no WAL, so a queue only
exists when other shards write, and a serial writer has nothing to batch
with. The real target is durable.sN.mixwrite: 480 ops/s at p99 5888us
against s1's 1023 at p99 664, so adding shards currently makes durable
writing WORSE. That inversion is a better argument for the iteration than
the one the story recorded.

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

13 KiB
Raw Permalink Blame History

databasev2 4 part A — WAL group commit (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: one durability barrier per drain instead of one per statement, so a writer is acknowledged after the barrier that carried its record rather than after a barrier of its own.

Architecture: the barrier moves up, not out. Applying to RAM and staging the record stay exactly where they are in db.c; the request path stops committing after each append and instead holds its reply envelope, and shard 0 issues one commit when it runs out of queued requests, then releases every held reply. Any failure between "RAM mutated" and "record durable" ends the process with a diagnostic.

Tech Stack: C11, libc only. pwrite + fdatasync (unchanged — io_uring is part B). The existing per-shard envelope inbox carries the requests.

Spec: ../specs/2026-08-28-wal-group-commit-design.md

Global Constraints

  • Durability is unchanged. Every guarantee iterations 9 and 22 proved holds identically: replay-whole-or-not-at-all, torn-tail drop, no acknowledged write ever lost. This changes when the barrier runs, never what the log holds.
  • A writer is released only after the barrier carrying its record. Never before, and never on the strength of a different batch's barrier.
  • libc only. No new dependency, no new syscall interface in part A.
  • The payoff metric is durable.sN.mixwrite (today 480 ops/s, p99 5888 µs). durable.s1.* and both seed legs are regression guards, not targets — a serial writer and an all-inline shard have nothing to batch with.
  • WO_T_IO leaves the write path. A commit or staging failure is fatal, not catchable. Exit 1 is a trap and exit 2 is a refusal, so this takes a third status of its own.
  • Gates run through just. Never commit on master; branch first.

Task 1 — a failed barrier is detected, and fatal

Files:

  • Modify: database/src/wal.c (the commit routine's failure returns; a new fatal-commit entry point beside it), database/src/wal.h (declare it).
  • Test: runtime/test/test_wal.c (a new case in the existing suite).

Interfaces:

  • Produces: a commit entry point that takes the WAL and the number of records in the batch, commits, and on failure writes one stderr line naming the failing operation, the errno text, the WAL path and the record count, then exits with the durability-failure status. Tasks 2 and 3 call only this.

  • Consumes: the existing staging buffer and commit routine.

  • Read the commit routine first and confirm what it already reports: it loops pwrite until the staged buffer is written, then fdatasync, and returns non-zero on either failing. Confirm the WAL struct carries its path, or add it — the diagnostic is worthless without it.

  • Test first, RED: assert the commit routine reports failure when the descriptor is unusable (a closed descriptor gives EBADF). This proves the error is detected; it does not exercise the exit.

  • Verify RED for the right reason — the case must fail because the assertion is unmet, not because the suite does not compile.

  • Add the fatal entry point. It must distinguish the two operations in its message: a pwrite failure and an fdatasync failure are different operational problems and the operator needs to know which.

  • GREEN: just wovm-test. The new case passes and no existing case moves.

  • Disclosed gap, record it in the commit message: the exit path itself is not exercised. Forcing a real fdatasync failure needs a full or read-only filesystem, which the gate cannot arrange without mount privileges. Do NOT add a fault-injection switch to buy coverage — shipping a binary that can be told to kill itself is the worse trade, and the spec rejected it.

  • Commit.

Task 2 — the barrier moves to the drain point; replies are held

Files:

  • Modify: database/src/db.c (the request-path arms only — the three commit calls inside the marshaled-statement executor), runtime/src/vm.c (the envelope drain loop's DB-statement branch and the end of that loop).
  • Test: no new fixture; the existing durability battery is the test. It already covers exactly what could break.

Interfaces:

  • Consumes: Task 1's fatal commit entry point.

  • Produces: the invariant later tasks measure — at most one barrier per drain, and every held reply released only after it.

  • Read the drain loop's DB-statement branch first. Today it executes the request, marks it done, then immediately pushes a reply envelope that unparks the requester. Note that it runs on shard 0's thread, serialized — that is why no locking is needed anywhere in this task.

  • Remove the three commit calls from the request-path executor in db.c. Leave applying to RAM and staging untouched, and leave the inline path's three commit calls alone — Task 3 owns that path and conflating them is how this change breaks the single-shard configuration.

  • In the drain loop, collect reply envelopes in a local list instead of pushing them as each request finishes. A local is correct and deliberate: nothing needs to survive the loop, and per-shard state would outlive the batch it describes.

  • At the end of the drain loop, if anything was staged, call Task 1's fatal commit once, then push every held reply.

  • Handle the empty case: a drain that executed no DB statements must not commit and must not touch the staging buffer.

  • Verify the ack contract has not moved: just wovm-test — the WAL and table suites must be unchanged, since neither knows about batching.

  • Verify durability end to end: just db-bench --quick. The restart-replay and kill -9 crash legs are the ones that matter — a kill between staging and the barrier must lose only unacknowledged writes. If a crash leg fails here, stop; do not adjust the test. That leg failing means the ack contract broke, which is the one thing this task may not do.

  • Commit.

Task 3 — the inline path keeps its own barrier, and says why

Files:

  • Modify: database/src/db.c (the inline path's three commit calls — replace with Task 1's fatal entry point), plus the comment above them.

Interfaces:

  • Consumes: Task 1's fatal commit entry point.

  • Produces: nothing new. This task exists to make the asymmetry deliberate and legible rather than accidental.

  • Replace the inline path's three commit calls with Task 1's fatal entry point, batch size one. Behaviour is unchanged — this is the fatal-failure rule reaching the second path, not batching.

  • Write the comment that explains the asymmetry, because the next reader will otherwise "fix" it: the inline path cannot hold a reply, because it returns into its own fiber rather than unparking a requester. Batching it would require parking that fiber on the barrier, which is part B's machinery and deliberately out of part A.

  • Confirm the ordering assumption holds: because the drain loop always commits before it ends, nothing uncommitted is ever left staged when an inline statement runs. If that stops being true the inline path would commit another statement's record early — say so in the comment as the reason the drain must commit unconditionally.

  • Verify: just wovm-test and just db-bench --quick both green, and WO_SHARDS=1 in particular — the single-shard configuration takes this path exclusively.

  • Commit.

Task 4 — prove batches actually form

Files:

  • Modify: scripts/db-bench.py (new metrics and their tolerances), docs/examples/db-bench/main.wo only if the batch figures cannot be observed without the sample reporting them.
  • Test: the driver's own gate-bites check.

Interfaces:

  • Consumes: the batching from Task 2.

  • Produces: mean batch size, peak batch size and peak staged bytes as recorded metrics, so Task 5 measures a mechanism that is known to engage.

  • Decide where the counters live and prefer the smallest surface: the runtime can report them at exit, or the driver can derive them. Do not add a builtin for this — the numbers are diagnostic, not part of the language.

  • Record mean and peak batch size under the concurrent multi-shard write workload. This is the task's real point: if batches are always one, the feature is inert and any throughput change came from somewhere else, so the measurement in Task 5 would be attributing a win to the wrong cause.

  • Record peak staged bytes. This settles whether the batch needs a cap with a number instead of a guess — the spec deliberately shipped no cap because the request queue is already bounded upstream by iteration 24's mailbox caps.

  • Give the new metrics wide tolerances. Batch size is a function of arrival timing, so gating it tightly would gate the scheduler; what must be gated is that it is greater than one under contention.

  • Verify the gate bites: doctor the recorded mean batch size to one and confirm the suite fails on exactly that metric.

  • Commit.

Task 5 — measure the payoff, gate it, write it down

Files:

  • Modify: bench/baseline.json (refresh, with the reason in the commit message), docs/plan/perf-targets.md (a new section).

Interfaces:

  • Consumes: Tasks 2 and 4.

  • Produces: the before/after record every later optimization argues against.

  • Capture the before numbers from the committed baseline rather than re-measuring them: durable.sN.mixwrite 480 ops/s, p50 538 µs, p99 5888 µs; durable.s1.mixwrite 1023 ops/s, p99 664 µs; seed ~4460 ops/s on both.

  • Run the full campaign, not the quick one, and record after numbers for the same metrics on the same machine. A payoff measured across machines is not a payoff.

  • Assert the scoped criterion: durable.sN.mixwrite throughput up and p99 down, with durable.s1.* and both seed legs not regressed. Do not report the s1 seed number as a disappointment — a serial writer has nothing to batch with, and the spec says so.

  • Write the perf-targets.md section: the before/after table, the mean and peak batch size that produced it, and the peak staged bytes. State the inversion that motivated the work — multi-shard concurrent writes were 2× slower than single-shard with a 9× worse p99 — and whether it is now gone.

  • If the payoff is absent or small, say so and stop. That is a finding, not a failure: it would mean the barrier was not the bottleneck the baseline implied, and part B must not be started on an unproven premise.

  • Refresh the baseline and confirm just db-bench passes against it, then re-confirm the gate bites on a doctored write metric.

  • Commit.

Task 6 — closeout

Files:

  • Modify: docs/stories/databasev2/04-io-uring-commit.md (progress, criteria split met/outstanding, the landing banner), docs/stories/00-status.md (standup entry, chain note), docs/plan/oop-vm/01-error-catalog.md (the WO_T_IO removal and the new exit status), database/src/CODE-LOGIC.md (a group-commit section).

  • Story: record what landed and what did not. The outstanding items are single-shard concurrent batching (needs the inline park) and part B itself. Keep the corrected premise visible — this iteration was written as "fsync-per-commit" and the engine was fsync-per-statement.

  • Error catalogue: WO_T_IO no longer reachable from a write, and the new durability-failure exit status documented beside the trap and refusal codes. A language-visible removal that is not written down is a trap for the next reader.

  • CODE-LOGIC.md: the commit path as built — where the barrier runs, why replies are held, why the inline path is asymmetric, and the one rule for failure. Explain the reasoning, not the call graph.

  • Board: the standup entry in the six-question shape, and the chain note — part B's go/no-go now rests on Task 5's number.

  • Full battery after the doc edits: just wovm-test, just woc-test, just oop-e2e, just db-bench, python3 scripts/linkcheck.py .

  • Commit.

Self-review notes

  • Spec coverage. Queue-drain boundary → Task 2. Fatal failure rule → Tasks 1 and 3. Held replies and the ack contract → Task 2. No batch cap, settled by measurement → Task 4. Payoff and its scoping → Task 5. WO_T_IO removal → Task 6. The disclosed abort-coverage gap → Task 1's last step.
  • The riskiest task is 2, and its risk is concentrated in one place: the crash legs of the durability battery. That is why the plan says stop rather than adjust if they fail.
  • Task 3 looks like a no-op and is not. Without it the inline path keeps a catchable WO_T_IO while the request path aborts, which is precisely the per-path unevenness this spec exists to remove.
  • Task 4 before Task 5 is deliberate. Measuring a payoff before proving the mechanism engages is how a win gets attributed to the wrong cause.