- test_oracle_all_vs_keys_same_update_sequence continues the shared
sequence 3×WO_DELTA_MAX_HOPS steps, alternating scalar and Text, and
asserts the `resident: all` and `resident: keys` rows equal after EVERY
step; the fold's hop count proves the chain terminated at least twice and
never exceeded K; then the keys log replays into a fresh store and is
compared against the oracle once more — the criterion as written, which
the story carried as ⚠ "an expected value, not an oracle table"
- wal.h: wo_wal_append_row_image's comment claimed the flattened image is
written as WO_WAL_INSERT; it is WO_WAL_UPDATE — an INSERT would replay as
a duplicate id; compaction alone writes INSERT, into a FRESH log — as 11
landed it and its story recorded
- story 11: the criterion flips to ✅ naming the test; the sequencing note
and out-of-scope bullet record task 7's 2026-08-30 measurement (16× vs
105× collapse under a cap, 1.53× faster than swapping) — the work stands
- test_wal 6295 → 6880 pass, 0 fail; 21 runtime suites 0 fail
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit d841390f3087a0c2542ddf23f25f15037a7d4d71)
177 lines
9.9 KiB
Markdown
177 lines
9.9 KiB
Markdown
---
|
||
track: databasev2
|
||
iteration: "11"
|
||
status: done
|
||
readiness: ready
|
||
---
|
||
|
||
# databasev2 11 — bounding a keys-resident row's delta chain
|
||
|
||
> Part of [Story — databasev2: the database beyond RAM](00-story.md).
|
||
> Spec: [`2026-08-30-bounded-delta-chains-design.md`](../../superpowers/specs/2026-08-30-bounded-delta-chains-design.md).
|
||
>
|
||
> **Why this exists.** [Iteration 2](02-table-storage-modes.md) shipped delta
|
||
> updates with a deliberate decision not to cap chain length, on the reasoning
|
||
> that compaction bounds it. A whole-branch review showed that reasoning does
|
||
> not hold for the one workload the feature is motivated by. This iteration
|
||
> closes it. `readiness: ready` — every fork is settled in the spec.
|
||
|
||
## The finding this iteration answers
|
||
|
||
A read of a keys-resident row costs `1 + chain length` preads, and replay costs
|
||
**O(N²)** per chain. The shipped mitigation is compaction, which flattens chains
|
||
to zero. But `wo_wal_should_compact` triggers on `used > last * ratio` — a byte
|
||
ratio over the whole log — and cannot see that one row has a very long chain.
|
||
|
||
One popular SKU whose stock moves on every order, in a catalogue that is
|
||
otherwise quiet, grows an unbounded chain without ever moving that ratio. The
|
||
guard that bounds replay in general is structurally blind to the single case
|
||
that makes replay quadratic.
|
||
|
||
## The design, in one sentence each
|
||
|
||
**Tier 1 — flatten on update.** The update path already folds the row, because
|
||
it needs the old values for index maintenance, and the fold already walks the
|
||
chain hop by hop; so it reports the depth for free, and when that depth reaches
|
||
**K** the update appends a full-row record instead of a delta. Read cost becomes
|
||
at most `K + 1` reads and replay `O(K²)` per row, independent of when a
|
||
checkpoint fires.
|
||
|
||
**Tier 2 — give the compaction policy an absolute term and a ceiling.** Our
|
||
current policy has a proportional term and a *suppressor* misleadingly named a
|
||
floor; it lacks the triggering floor and the ceiling that keep a size-based
|
||
policy honest.
|
||
|
||
## Where the design came from
|
||
|
||
Read from PostgreSQL's source at `.dev/reference/postgresql`, not recalled:
|
||
|
||
- **`heap_page_prune_opt`** collapses HOT chains opportunistically, on a page the
|
||
process already holds, gated by an O(1) on-page hint and then by page fullness
|
||
against `Max(fillfactor, BLCKSZ/10)`. Tier 1 is this shape: do the work while
|
||
you already hold the thing, using a signal you already computed.
|
||
- **autovacuum** thresholds on
|
||
`vac_base_thresh + vac_scale_factor * reltuples`, clamped by a maximum —
|
||
defaults 50, 0.2, 100 000 000. A count with a floor, a proportion and a
|
||
ceiling, per table. Tier 2 borrows the floor and the ceiling.
|
||
- **Postgres never thresholds on new-bytes-versus-old-bytes**, despite knowing
|
||
exactly what a chain costs. Its space test is "will the next version fit" — an
|
||
operational constraint, not an economic comparison. That ruled out the
|
||
byte-ratio shape here too.
|
||
|
||
## Progress
|
||
|
||
| Part | State |
|
||
| --- | --- |
|
||
| Tier 1 — the fold reports hop count | ✅ `wo_wal_fold_row_at` takes `hops_out`; the walk already visited each hop, so it costs nothing |
|
||
| Tier 1 — the update branches on depth | ✅ `row_apply_field_keys` writes a full-row image past `WO_DELTA_MAX_HOPS` (16) instead of a delta |
|
||
| Tier 1 — the chain-terminating write | ✅ `wo_wal_append_row_image`, encoded as `WO_WAL_UPDATE` — see the correction below |
|
||
| Tier 2 — absolute garbage term | ✅ `WO_CKPT_ABS_BYTES` (64 MiB) triggers regardless of proportion |
|
||
| Tier 2 — proportional ceiling | ✅ **removed as dead code** — the absolute term already does this job |
|
||
| **Tests** | ✅ four tests; `test_wal` 5700 pass / 0 fail, `just wovm-test` and `just woc-test` green |
|
||
|
||
**Two corrections the tests forced, both worth recording.**
|
||
|
||
*The flattened record is a `WO_WAL_UPDATE`, not an `INSERT`.* The reasoning for
|
||
INSERT was that a chain's base must be a full row, and INSERT is what compaction
|
||
writes. That holds for compaction, which builds a *fresh* log. It is wrong for an
|
||
update appending into a *live* one: the row's original INSERT is already in that
|
||
log, so a second INSERT for the same id is a duplicate, and replay correctly
|
||
refuses it as corruption. `test_delta_chain_flatten_replays` failed on exactly
|
||
that. UPDATE replays as remove-then-recreate and the fold terminates on either
|
||
full-row kind, so nothing else changed.
|
||
|
||
*The proportional ceiling was unreachable, and is gone.* With the absolute term
|
||
at 64 MiB and the ceiling at 256 MiB, any garbage large enough to reach the
|
||
ceiling had already tripped the absolute term — the branch could never execute.
|
||
Found by trying to write a test that exercised the ceiling and discovering no
|
||
input could. PostgreSQL needs both constants because it thresholds on *tuples*
|
||
with its pair at opposite ends (base 50, max 1e8); this thresholds on *bytes*,
|
||
where one constant does both jobs. Any ceiling above the absolute term is dead,
|
||
and any below it would simply be the trigger.
|
||
|
||
**Verified by construction where tests do not reach.** Both update entry points
|
||
converge on `row_apply_field_keys` (`table.c:1039` and `:1319`), so one branch
|
||
covers both. The re-point is transparent to flattening because `db.c` captures
|
||
`wo_wal_next_offset(w)` *before* calling into `table.c` — it targets wherever the
|
||
next record lands, delta or full row alike.
|
||
|
||
## Acceptance Criteria
|
||
|
||
All verified but one, which is narrowed rather than dropped. Tests live in
|
||
`runtime/test/test_wal.c`.
|
||
|
||
- ✅ **Given** a row updated K times, **when** updated once more, **then** the
|
||
record its offset names is a full row and its chain length is zero.
|
||
*`test_delta_chain_flattens_at_k`.*
|
||
- ✅ **Given** a row updated far more than K times, **when** it is read, **then**
|
||
it performs at most K + 1 record reads, asserted by counting rather than
|
||
timing. *Both chain tests assert `scratch_hops <= WO_DELTA_MAX_HOPS` on every
|
||
read, which is the count itself, not a proxy for it.*
|
||
- ✅ **Given** the same row, **when** the process restarts, **then** replay is
|
||
correct and its cost does not grow with the total updates ever applied.
|
||
*`test_delta_chain_flatten_replays`.*
|
||
- ✅ **Given** a flattening update, **when** replayed, **then** the row matches
|
||
the same row in a `resident: all` table under the same update sequence.
|
||
*`test_oracle_all_vs_keys_same_update_sequence` (closed 2026-09-09): the
|
||
same sequence runs 3×K further updates, alternating scalar and Text, against
|
||
a `resident: all` and a `resident: keys` table, asserting equal rows after
|
||
every step; the fold's hop count proves the chain was terminated at least
|
||
twice and never exceeded K; then the keys log is replayed into a fresh store
|
||
and compared against the oracle once more.*
|
||
- ✅ **Given** a flattening update to an indexed column, **when** queried through
|
||
that index, **then** the row is found by its new value and not its old, before
|
||
and after a restart. *`test_keys_resident_indexed_across_flatten`, checked at
|
||
every step across the bound, not only at the end.*
|
||
- ✅ **Given** reclaimable bytes past the absolute threshold but inside the
|
||
ratio, **when** the policy is evaluated, **then** compaction fires.
|
||
*`test_should_compact_absolute_and_ceiling`, which also pins the boundary just
|
||
under the term and the small-log case where the ratio still governs.*
|
||
- ✅ **Given** a `resident: all` table, **when** any of this runs, **then**
|
||
nothing about its behaviour or log records changes. *Regression only: the
|
||
existing 856 `test_table` and 5700 `test_wal` assertions pass, and a
|
||
`resident: all` table never reaches `row_apply_field_keys`.*
|
||
|
||
**Honest note on what the new tests found:** no product defect in the
|
||
indexed-column path. Both failures during that test's development were bugs in
|
||
the test itself — reading a folded row as a `wo_str` when the fold yields engine
|
||
`db_text`, and probing a database whose WAL had been closed. The result is still
|
||
worth having: it is the only coverage that the index and the flatten branch
|
||
compose, and it now pins that.
|
||
|
||
## Out Of Scope
|
||
|
||
- **Varying K by row width.** Hop count is what bounds read and replay cost;
|
||
width would optimise only write amplification. Revisit with a measurement, not
|
||
before.
|
||
- **A time-based compaction trigger.** Records are durable at commit, so an idle
|
||
log does not grow.
|
||
- **Whether `resident: keys` earns its place at all.** That was iteration 2's
|
||
task 7, measured 2026-08-30 after this landed — see below.
|
||
|
||
## Info — the forks, settled
|
||
|
||
1. **Where to fix it: the update path, not the checkpoint.** Making compaction
|
||
depth-aware would mean one hot row triggering a stop-the-world rewrite of the
|
||
entire log — a 2 651 µs pause that scales with total live rows, not with the
|
||
row that misbehaved. Postgres reaches for the local, opportunistic fix first
|
||
for the same reason.
|
||
2. **The metric is hop count, not bytes.** Each hop is one `pread` whose cost
|
||
barely varies with the bytes it carries, so hops are what our read cost is
|
||
made of. Bytes govern write amplification, which is the secondary concern.
|
||
3. **K is a fixed constant and does not scale with table size.** Postgres scales
|
||
by `reltuples` because it thresholds a table-level aggregate with
|
||
proportional harm. Ours is per-row with additive cost — reading one product
|
||
costs the same whether the catalogue holds a hundred rows or ten million, and
|
||
total replay is the sum across rows. Scaling K up with size would make the
|
||
largest databases boot worst.
|
||
|
||
## Sequencing note
|
||
|
||
This iteration was written as **ready but arguably not next**: iteration 2's
|
||
task 7 had not yet measured whether `resident: keys` beats the kernel's own
|
||
paging, and everything built on it — including this — assumes it does. The
|
||
recommended order (measure first) was not followed; task 7 measured on
|
||
2026-08-30 and the answer is qualified but positive — under a memory cap
|
||
`resident: keys` collapses 16× where `resident: all` collapses 105×, 1.53×
|
||
faster than swapping, with a 2.55× smaller resident set — so this work stands.
|