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

9.8 KiB

Bounding a keys-resident row's delta chain

Design settled 2026-08-30. Fixes the limitation shipped with databasev2 2's delta updates and recorded in 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'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.