writeonce/docs/00-databasev2-chain-review.md
shoney.arickathil 7ad52937b2 fix(db2-keys): delete on a keys-resident table was memory corruption
- wo_row_remove read the id map's value as a slot, but on a keys table
  that value is a LOG OFFSET (hput(t, id, wal_off + 1)). slot_row does
  no bounds check, so a delete indexed t->slabs[] with a byte offset and
  then called db_val_free on whatever it landed on — arbitrary frees,
  not a wrong answer
- keys tables now take their own arm: no slab slot, no bitmap bit, no
  free-list entry to return. The index hook needs the row's values, so
  the row is borrowed from the log for exactly that long
- wo_row_ptr carried the same trap and is public. It cannot refuse keys
  tables outright (insert legitimately calls it while the map still
  holds a slot), so it now detects the offset case — index past the
  slabs, or bitmap bit clear — and returns NULL. Callers all handle NULL
- test_keys_resident_delete pins it; it SEGVs against the old code,
  verified by reverting the fix rather than assumed
- found while auditing every hget() reader before narrowing the loader
  refusal to allow benchmarking. The refusal was justified in the docs
  by "updates are unimplemented" while actually standing in front of
  this too: a guard whose stated reason is narrower than its real one
  gets removed by someone who believes the stated reason

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 76b8fd944af9ed062467bdf9ab93c2e96dd198cf)
2026-08-30 20:37:27 +02:00

122 lines
6 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 — chain and dependency review
Reviewed 2026-08-29 against story frontmatter, the track index's sequence
table, and the code as it stands on `dev`. Six findings, ordered by how much
damage each could do if acted on.
## The recorded picture
`chain` is a **cross-track** field (positions 1–6, defined in
`docs/stories/board-views.md`), not a databasev2 one. Only two databasev2
iterations carry it:
| chain | Story | status |
| --- | --- | --- |
| 1 | language 08 shard-actor runtime, 11 fibers | done |
| 2 | language 22 durability/throughput/scale | done |
| 3 | language 31 actor lifecycle, 40 shutdown drain | done |
| 4 | language 24 chat/websocket workload | done |
| 5 | **databasev2 4** io_uring group commit | in-progress |
| 6 | **databasev2 3** WAL checkpoint | done |
The track's own sequence lives in `00-story.md` as a Needs column plus an
ASCII graph. The two disagree with each other, with the chain field, and with
what happened.
## Finding 1 — the graph contradicts the chain field and the history
`00-story.md` draws `3 ──▶ 4`: iteration 3 before iteration 4. The chain field
says the opposite — iteration 4 is chain 5, iteration 3 is chain 6, so 4 comes
first. History settles it: **4's part A landed 2026-08-28, 3 landed
2026-08-29.** The chain field and the history agree; the graph is wrong.
Worth fixing rather than shrugging at, because the graph is the artefact
someone reads when choosing what to start.
## Finding 2 — the graph contradicts its own prose about direction
The order rationale states "**3 and 4 matter to 2**" — that is, 2 depends on 3
and 4. The graph draws an edge *from* 2 *to* 3, which reads as the reverse.
One of the two is backwards, and the prose is the one that matches the code:
`resident: keys` needed the checkpoint, not the other way round.
## Finding 3 — a retired path is still drawn
The graph still shows `2 ──▶ 5 ──▶ 6`. The 2026-08-27 amendment directly below
it says iteration 6 is largely superseded by 2, and that **5 is no longer a
prerequisite for anything on the critical path**. The prose retired the path;
the picture kept it.
## Finding 4 — the chain metadata omits the iteration doing the work
Iteration 2 is on the critical path, is `in-progress`, and is where 5c/5d just
landed — and it carries **no `chain` field**. The board-views query "the
concurrency chain, in execution order" filters `WHERE chain`, so iteration 2 is
invisible to it. Either 2 belongs on the chain and should say so, or the chain
is genuinely a concurrency artefact that databasev2 2 sits outside — in which
case 3 and 4 carrying it while 2 does not deserves a one-line explanation.
## Finding 5 — iteration 3's hazard section is stale, and was incomplete
This is the one with teeth.
`03-wal-checkpoint.md` carries a "Hazard: compaction invalidates every
`resident: keys` offset" section and a matching Outstanding entry. Both are now
**stale**: the Outstanding entry says "**Nothing fails today** because
iteration 2's storage half is unimplemented", which stopped being true when
5c/5d landed (`125bd09`, `08abd09`, `0c97fa4`, `f606fc9`). The hazard also
offers two shapes and says "the first is almost certainly right" — the first
*was* implemented, and the section should now record that as settled rather
than as an open fork.
More importantly, **the recorded hazard named only half the danger.** It
described stored offsets becoming wrong: a pointer into a rewritten file.
Implementation found a second, worse failure it did not anticipate — compaction
walked the slab **bitmap**, and a keys-resident row has no bitmap bit, because
its slot is returned to the free list when the payload is dropped. Every such
row would therefore have been **omitted from the new log entirely**. That is
silent data loss, not a bad pointer, and no amount of offset-rebuilding would
have caught it.
Both failure modes are now pinned by `test_keys_resident_survives_compaction`,
which rewrites rows in hash order so offsets genuinely move and a missing
re-point cannot pass by luck.
## Finding 6 — the coupling is now bidirectional, and undocumented in that direction
The docs record 2 depending on 3. After 5d, **3's own deliverable depends on
2's API**: `wo_wal_compact` in `database/src/wal.c` now calls
`wo_row_next_id`, `wo_row_offset1`, `wo_row_set_offset` and
`wo_table_is_keys_resident` — all iteration 2 surface. Compaction can no longer
be described as a pure file operation, which is exactly what the hazard section
predicted and no dependency table records.
Minor, same family: iteration 6 is "largely superseded" and to be revisited
"only with a measurement showing the page cache insufficient" — a hold
condition — yet its status is `pending` while genuinely parked iterations 8, 9
and 10 are `hold`.
## Addendum 2026-08-29 — the recommendation this review implied was wrong
This review argued the next step was to measure `resident: keys` before
investing further, and that measuring required narrowing the loader refusal so
a benchmark could declare such a table. **Auditing the code before narrowing it
found that `delete` on a keys-resident table was memory corruption**, not a
missing feature: `wo_row_remove` read the id map's value as a slot when on such
a table it is a log offset, and `slot_row` bounds-checks nothing.
The refusal was therefore load-bearing in a way nobody had written down. It was
justified in the docs by "updates are unimplemented" — one honest gap — while
actually standing in front of two, one of which frees arbitrary pointers.
Both are now closed or contained (`wo_row_remove` fixed, `wo_row_ptr` returns
NULL rather than a wild pointer), but the lesson generalises: **a guard whose
stated reason is narrower than its real one will eventually be removed by
someone who believes the stated reason.**
## What is actually blocked
Nothing in databasev2 is blocked on anything else in databasev2. Iteration 2's
remaining tasks 6 and 7 depend only on iteration 2. Iteration 4's part B is not
blocked either — it is unstarted with an invalidated premise, which is a
re-brainstorm, not a dependency.