writeonce/docs/superpowers/specs/2026-08-30-bounded-delta-chains-design.md
shoney.arickathil 68dd3d88b8 test(db2-chain): cover flattening, and drop a ceiling no input could reach
- flattened row image is WO_WAL_UPDATE, not WO_WAL_INSERT: the row's
  original INSERT is already in a live log, so a second one for the same
  id is a duplicate replay refuses as corruption. INSERT is right only
  for compaction, which builds a fresh log
- remove WO_CKPT_MAX_GARBAGE: with the absolute term at 64 MiB, garbage
  large enough to reach a 256 MiB ceiling has already tripped it, so the
  branch was unreachable. Postgres needs both constants because it
  thresholds on tuples with its pair at opposite ends; this thresholds
  on bytes, where one constant does both jobs
- test_delta_chain_flattens_at_k: chain depth stays <= WO_DELTA_MAX_HOPS
  across 2K+2 updates, and a reset is observed
- test_delta_chain_flatten_replays: a flattened chain replays correctly
- test_keys_resident_indexed_across_flatten: a delta on an indexed
  column composes with flattening, checked at every step across the
  bound and after restart. Found no product defect
- test_should_compact_absolute_and_ceiling: pins the absolute term, the
  boundary just under it, and the small-log case the ratio still governs
- test_wal 5700 pass / 0 fail; wovm-test and woc-test green

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

182 lines
9.8 KiB
Markdown

# Bounding a keys-resident row's delta chain
Design settled 2026-08-30. Fixes the limitation shipped with
[databasev2 2](../../stories/databasev2/02-table-storage-modes.md)'s delta
updates and recorded in
[`2026-08-30-keys-resident-delta-updates-design.md`](2026-08-30-keys-resident-delta-updates-design.md).
## The problem, and why the shipped mitigation does not fire
An update to a `resident: keys` row appends a delta — one field's new value plus
a back-pointer. Reading the row folds the chain backward, so a read costs
`1 + chain length` preads, and replay costs **O(N²)** per chain because it folds
once per delta and each fold walks back to the base.
The shipped design chose not to cap chain length, on the reasoning that
compaction flattens every chain and that deltas grow the log, pulling the next
checkpoint forward. **That reasoning is wrong for the case that matters.**
`wo_wal_should_compact` decides on `used > last * ratio` — a byte ratio over the
whole log. It cannot see that one row has a five-thousand-delta chain. A single
hot row taking many small updates barely moves that ratio in a large database,
so the checkpoint never fires, that row's chain grows without bound, and its
replay cost grows as the square.
The motivating workload is precisely this shape: one popular SKU whose stock
moves on every order while the rest of the catalogue sits still.
## What PostgreSQL does, read from source
Verified against `.dev/reference/postgresql`, not recalled. Postgres solves the
same class of problem — chains of row versions that must be collapsed — with
**two tiers**, and neither is a size ratio.
**Tier 1, `heap_page_prune_opt` in `pruneheap.c`** — opportunistic and local.
Three gates, cheapest first: an O(1) `pd_prune_xid` hint stored on the page; a
visibility test; then `PageIsFull(page) || PageGetHeapFreeSpace(page) < minfree`
where `minfree = Max(fillfactor target, BLCKSZ / 10)`. The work happens on a
page the process **already holds** because it is reading or updating it anyway.
The source is explicit that the check is deliberately approximate — it reads
free space without taking a lock, because "avoiding taking a lock seems more
important than sometimes getting a wrong answer in what is after all just a
heuristic estimate."
**Tier 2, autovacuum** — background and per-table:
`vacthresh = vac_base_thresh + vac_scale_factor * reltuples`, clamped by
`autovacuum_vacuum_max_threshold`. Shipped defaults are 50, 0.2 and 100 000 000.
A **count** with a floor, a proportional term and a ceiling — computed per
table, never per database.
Three lessons, and one correction to our own vocabulary:
- **Do the work while you already hold the thing.** That is the whole of tier 1.
- **The floor exists to catch what the proportion hides.** `base_thresh = 50`
fires on a small table where 20% would not. `Max(…, BLCKSZ/10)` does the same
for space.
- **The ceiling exists so scale does not defer forever.**
- **Our "floor" is the opposite of theirs, despite the name.**
`wo_wal_should_compact` reads `if (used < floor) return 0` — ours *suppresses*
compaction on a small log. Postgres's floor *triggers* cleanup on a small
absolute problem. We have the proportional term and the suppressor; we have
neither the triggering floor nor the ceiling.
Postgres also, notably, does **not** threshold on "new bytes versus old bytes",
despite knowing exactly what every chain costs. Its space check is "will the
next version physically fit", a hard operational constraint, not an economic
comparison. That rules out the byte-ratio shape for us as well — and our cost is
worse suited to it still, since each hop is one `pread` whose cost barely varies
with the bytes it carries.
## Tier 1 — flatten on update
**The update path already folds the row.** It must: it needs the old values to
maintain indexes. And the fold already walks the chain hop by hop. So it can
report how many hops it took, and the update path learns the chain's depth for
free — no new record field, no extra read, no per-row RAM. That reported hop
count is our `pd_prune_xid`: the cheap signal that says whether work is worth
doing, obtained from something we were doing anyway.
The rule is one branch. When the fold reports a depth at or beyond **K**, the
update appends a **full-row record** instead of a delta, and the chain resets to
zero. Otherwise it appends a delta as today.
Consequences:
- A read costs at most **K + 1** preads, always, independent of when a
checkpoint fires.
- Replay costs **O(K²) per row**, bounded rather than unbounded.
- Write cost rises by one row-sized record per K updates — amortised, under
`1/K` extra bytes against today.
- Compaction, replay and the fold are untouched. A full-row record is a shape
all three already handle, because it is what an insert writes.
**K is a fixed constant, not a per-table knob.** Postgres ships `fillfactor` and
`base_thresh` as documented constants that are rarely tuned, and that is the
right precedent: K is a *bound*, not a dial. Anything from 8 to 64 caps the
pathology, and being wrong by a factor of two costs one extra row-write per K
updates.
**K does NOT scale with table size, and that is deliberate.** Postgres scales its
threshold by `reltuples` because it thresholds a table-level aggregate whose harm
is proportional. Ours is a per-row property with additive cost: reading one
product costs `1 + depth` preads whether the catalogue holds a hundred rows or
ten million, and total replay is the sum over every row's chain. Scaling K up
with table size would make the largest databases boot worst — exactly backwards.
**Row width is the one thing that might justify varying K**, since flattening
writes a whole row while a delta writes one field, so the write-amplification
break-even genuinely depends on row size. Deliberately **not** done now: hop
count is what bounds read and replay cost, which are the costs actually hurting,
and width would optimise only the write side. Revisit if measurement shows write
amplification matters.
## Tier 2 — give the checkpoint the trigger shape it is missing
Smaller, and separable from tier 1. Tier 1 bounds one row; tier 2 corrects the
whole-log policy's shape so it stops being blind to absolute garbage.
`wo_wal_should_compact` gains, alongside its existing ratio:
- **An absolute garbage term** — compact when reclaimable bytes exceed an
absolute threshold regardless of ratio. This is postgres's `base_thresh`, and
it is what our current "floor" is not.
- **A ceiling** — cap the proportional term so a very large live set does not
defer compaction indefinitely. This is `autovacuum_vacuum_max_threshold`.
> **Implementation outcome (2026-08-30): the ceiling was built and then removed
> as dead code — the absolute term above already does its job.** Borrowing both
> constants from postgres was the wrong inference. Postgres needs two because it
> thresholds on *tuples*, with its pair at opposite ends of the range (base 50,
> max 1e8). This design thresholds on *bytes*, and "compact once garbage exceeds
> X bytes" is itself a cap on deferral: with the absolute term at 64 MiB, any
> garbage large enough to reach a 256 MiB ceiling has already tripped it, so the
> branch is unreachable. Any ceiling above the absolute term is dead; any below
> it would simply be the trigger. Caught by trying to write a test for the
> ceiling and finding no input could reach it. Do not reintroduce it without
> also changing what the absolute term means.
The existing floor keeps its current meaning — do not bother with a tiny log —
but the doc comment must stop calling it a floor in postgres's sense, because it
does the opposite thing.
## Acceptance criteria
- **Given** a keys-resident row updated K times, **when** it is updated once
more, **then** the record its offset names is a full row, not a delta, and its
chain length is zero.
- **Given** a row updated many times more than K, **when** it is read, **then**
the read performs at most K + 1 record reads — asserted by counting, not by
timing.
- **Given** a row updated many times more than K, **when** the process restarts,
**then** replay reconstructs it correctly and its cost does not grow with the
total number of updates ever applied to it.
- **Given** a flattening update, **when** it is replayed, **then** the row is
identical to the same row in a `resident: all` table subjected to the same
update sequence. The resident table is the oracle.
- **Given** a flattening update that changes an indexed column, **when** the row
is queried through that index, **then** it is found by the new value and not
the old — before and after a restart.
- **Given** reclaimable bytes past the absolute threshold but within the ratio,
**when** the policy is evaluated, **then** compaction fires. *(Tier 2.)*
- **Given** a `resident: all` table, **when** anything here runs, **then**
nothing about its behaviour or its log records changes.
## Out of scope
- Varying K by row width. Reasoned above; revisit only with a measurement.
- A time-based compaction trigger. Records are durable at commit, so an idle log
does not grow — the existing design's reasoning still holds.
- The mid-drain stale-read limitation, which a separate fix already closed.
- Whether `resident: keys` is worth having at all. That is
[databasev2 2](../../stories/databasev2/02-table-storage-modes.md)'s task 7,
and this design does not answer it.
## Risks
- **K is a constant chosen without measurement.** The bound is right in shape;
its value is a judgement. The mitigation is that being wrong is cheap and
symmetric — too small costs write amplification, too large costs read latency,
and neither is a correctness failure.
- **Flattening makes one update in K expensive.** A burst of updates to one row
pays a row-sized write on every Kth. Acceptable, and the alternative is an
unbounded read path, but it should be visible in the measurement rather than
discovered in production.