docs: record copy-and-swap v1 design decisions - #88
Conversation
Amp-Thread-ID: https://ampcode.com/threads/T-01a07b66-537a-7764-b662-d64ff26273f5 Co-authored-by: Amp <amp@ampcode.com>
…he cutover lock retry Amp-Thread-ID: https://ampcode.com/threads/T-01a07b66-537a-7764-b662-d64ff26273f5 Co-authored-by: Amp <amp@ampcode.com>
Per-key delete-then-insert retry cannot converge on a cyclic unique-value exchange, so D13 becomes batch-wide delete-all-then-insert-all over the per-key buffer and CO-6 carries the refuting vector as its test obligation. LIKE derives the shadow's index and constraint names from the shadow's own name, so the cutover gains the step that restores the user's names; a shared serial sequence keeps its name and is re-owned rather than renamed. RESTART WITH is undefined on a never-advanced sequence, so identity handoff copies (last_value, is_called) with setval. The unchanged-TOAST marker appears under REPLICA IDENTITY FULL as well as DEFAULT, and the column is present with no value rather than omitted. The v1 scope table now names dependent views and publication membership, the two RF-2 refusals it left out, and INCLUDING ALL is described by what it does and does not carry. Also registers CO-8 and CopySwapTarget everywhere the other invariants and proof types are listed, cites ST-7 at D1's retarget check, and sweeps the sentences elsewhere in the docs that still described a scratch database, composite chunk keys, replica-lag throttling, deferred cutover, or a trigger-capture fallback as v1 behaviour.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Review 1/2 — the decided design ( Recording the fifteen decisions as one authoritative page, each with its rejected alternative and its enforcement site, is the right move at this point in the build — most of these are choices a reader would otherwise have to reconstruct from three narrative documents, and several (D1's execute-and-introspect against the empty shadow, D5's A docs PR has no code to mutate, so I did the equivalent: I took the load-bearing PostgreSQL claims and tried to falsify them against a live PostgreSQL 16 (the repo's own The swap silently reverts a user's
|
|
🤖 Review 2/2 — the doc set and the guards that pin it The other half of this PR is a sweep: fifteen decisions land, and every narrative page that promised something else has to stop promising it. That is the part most likely to be left half-done, so I checked it mechanically rather than by reading. Two results first, because both are verifiable:
Merging this with the contracts PR produces two conflicting registry rows, with no conflict marker (med)
Nothing catches this. CI runs no docs guard on a docs-only PR (med)The repo has seven
I ran them locally against this branch and they pass, so nothing is broken here — but that is a fact about this PR, not a property CI holds. AGENTS.md's coverage invariant ("no behavior lands without a test that would fail without it") applies to the guards themselves: the cheapest fix is a docs-only leg that runs the The sweep leaves standing the two claims that most directly contradict the new decisions (med)The stale claims that remain are not evenly distributed — the ones left behind are the ones about replica identity and unchanged TOAST, which is the subject of the new CO-8:
The first two matter most.
CO-8 reaches the applier's rows but not the decode ones (low)CO-8's own Enforced: line names two sites: "
|
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. The decisions page is the right artifact and the fifteen entries are well-reasoned; my findings are all about the composition of two decisions or about a page the sweep did not reach, and none of them change what this PR decides.
Highest-value items, all verified against a live PostgreSQL 16 or with git merge-tree: LIKE … INCLUDING ALL does not carry relreplident, so the swap silently reverts a user's REPLICA IDENTITY FULL against D6's promise (ST-5's fidelity list does not cover it); a source CHECK … NOT VALID is copied as validated and the copier's insert then fails on rows the source legally holds (ST-6's own failure mode, knowable from pg_constraint.convalidated); D13's delete-all-then-insert-all recovery cannot reconstruct the TOAST-omitted column CO-8 requires be preserved, and neither test vector can catch it; and merging this with #87 auto-merges docs/tcb-model.md into two conflicting CopySwapTarget rows with no conflict marker.
Details in the two review comments above.
This stamp was left by Claude Code (claude-opus-5).
…inish v1-scope sweep The delete-all-then-insert-all fallback inserts whole rows, but pgoutput UPDATE images omit unchanged TOASTed values. D13 and CO-6 now state how the fallback obtains complete images: the change buffer merges a newer image's present columns onto the buffered image (CO-5), and any marker that survives dedup is completed from the current shadow row inside the flush transaction; an absent shadow row for such an image is an invariant violation. CO-6 gains a TOAST-bearing test vector. Narrow CO-4 and CO-5 to the v1 integer-family key: above-watermark discard is stated as sound because of D4, and the map/FIFO mode toggle is recorded as having no v1 counterpart. Align the remaining prose with the recorded decisions: trigger capture is documented but deferred (D15) everywhere it was still offered as an available fallback; replica-lag throttling is deferred (D12); deferred cutover is not in v1 (D10); REPLICA IDENTITY FULL is not a TOAST remedy (D6); the slot is durable, not temporary (D11); the ST-1 heading matches its one-row-per-target body; AGENTS.md records the single-relation retarget as the one permitted AST edit (D1). Add CopySwapTarget to the review checklist's proof-type list, add CO-8 to pkg/decode in the three package maps, note the database-level CREATE requirement of the scratch schema as an open follow-up, and fix a stale comment in pkg/statement.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — block/pg-sprite pull/88, follow-up commit
|
|
🤖 Re-review 1/2 — the completion rule ( The D6/D13 collision from the last round is answered properly, and the answer is the right shape: the fallback inserts whole rows, so the two new rules say where a complete row comes from — CO-5 merges instead of replacing, and anything still carrying a marker is completed from the shadow before the batch-wide delete. Extending the Why to say why the alternative (restrict the fallback to already-complete batches) is worse — a cyclic exchange on a TOASTed row would then have no converging path at all — is the part that makes this a decision rather than a patch. One of the two rules rests on a claim about pgoutput that is not true, and it is the claim that turns a live case into an abort. An UPDATE that moves the primary key produces exactly the image the doc calls impossible (blocking)
The enumeration is complete for a row whose key does not change. It omits the case where the row pre-exists under a different key. I probed it on a real PostgreSQL 16 with CREATE TABLE seats (id int PRIMARY KEY, slot text UNIQUE, payload text);
ALTER TABLE seats ALTER COLUMN payload SET STORAGE EXTERNAL;
INSERT INTO seats VALUES (5,'A',repeat('x',20000)), (2,'B',repeat('y',20000));
UPDATE seats SET id = 6 WHERE id = 5; -- moves the PK, payload untouched
UPDATE seats SET slot = 'C' WHERE id = 2; -- plain update, payload untoucheddecodes to The first record is a marker-bearing image keyed 6. Key 6 has never existed on the source, so the shadow has no row 6, and nothing about that is a protocol error — it is a single legal statement. Walk it through the new rules:
Fail-closed is the right default and this is not a corruption path, so it is not a safety hole. It is a correctness hole in the decision: a workload that moves a primary key on a row with a TOASTed column will abort the change, and the doc forecloses handling it by name, so whoever implements Phase 6 will implement the abort deliberately. The value is not missing — it is in the shadow row for the old key, and #87 landed the field that addresses it: This is the same blind spot as the CO-4 finding from the last round, which is still open: The test vector's threshold is not the property it needs (low)
Only after
This review was generated by Claude Code (claude-opus-5). |
|
🤖 Re-review 2/2 — the merge, the links, and the sweep The duplicate registry row predicted last round is now real (med)#87 merged at 03:28Z; this head is
Two validating passages and two different invariant sets for one proof type, in the table whose whole job is to be the single answer to "what proves this input was validated." Worth noting Renaming the ST-1 heading orphans an inbound link (low)
That is the only broken link in the tree: I resolved every relative markdown link and fragment across all 42 Smaller things
The guards ran this time, but the hazard behind them did not moveLast round the whole But it is coverage by accident: a comment edit unrelated to any of the prose flipped the filter. The next docs-only PR gets the same skip, and the guards that exist specifically to pin prose against code are the ones it switches off. Not this PR's job to fix — worth a follow-up, since the value of seven What the sweep got rightEvery residual from the last round except
Approving on the same basis as last round — this is a docs PR and every finding above is a doc claim to correct, none of them a reason to hold the set. The pgoutput one in 1/2 is worth doing before Phase 6 reads this page as its spec, and the rebase is worth doing before merge. This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Re-approving 315e897a. The D6/D13 collision from the last round is resolved properly, and the sweep closes every residual but one. Findings are in the two comments above — the pgoutput completion rule needs the key-move case (a PK-moving UPDATE emits exactly the marker-bearing image D13 calls a protocol error; OldKey, landed by #87, addresses it), and the branch wants a rebase before merge so docs/tcb-model.md does not end up with two CopySwapTarget rows.
This stamp was left by Claude Code (claude-opus-5).
… anchor
Three follow-ups from the re-review of the copy-and-swap design decisions.
- change-capture-tradeoff: the capture-completeness row said unchanged
TOAST is omitted "unless REPLICA IDENTITY FULL". That contradicts D6 and
CO-8 — FULL enlarges only the old tuple and the marker still arrives —
so the row now describes the marker and the column-wise skip, and
points at D6.
- copy-and-swap D13: the fail-closed rule ("marker-bearing image with no
shadow row is a protocol error") assumed every in-flight-chunk flush is
deferred, but CO-4 also admits tombstone retention, under which that
interleaving is permitted. D13 now records that v1 takes the
mutual-exclusion (deferral) form for every buffered change and states
why the tombstone form is unavailable; low-level-design's CO-4 bullet
cross-references the choice.
- design-principles: the ST-1 link still used the pre-rename anchor;
updated to the "one row per target" slug.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — block/pg-sprite pull/88, follow-up commit Re-review of 315e897: three blocking findings.
Also in this push: merge of |
|
🤖 Re-review 1/2 — Re-reviewing only what moved past my last approval at The merge was hand-resolved, and it did not cost anything. All three follow-ups land, and I checked each claim rather than the commit message. The capture-completeness row is now right, and agrees with the invariant it has to agree with. The old row said unchanged TOAST is omitted "unless D13's fail-closed rule is now conditional on a discipline the design actually commits to. The previous text asserted that a marker-bearing image with no shadow row is a protocol error because such a key "is either above the copier watermark … or inside an in-flight chunk (whose flush CO-4 already defers until the chunk lands)" — which read CO-4 as mandating deferral when it admits two forms. The new passage ( The ST-1 anchor resolves, and I checked the heading rather than the link text. One finding, in 2/2. It is about the registry entry these three fixes now lean on, not about the fixes. This review was generated by Claude Code (claude-opus-5). |
|
🤖 Re-review 2/2 — the invariant the fix now leans on (low-med) D13 now says the shadow read "holds only under one of the two chunk-overlap disciplines CO-4 admits", and fixes v1 on the deferral one. CO-4's own body admits only the other one, and only for deletes: — Two consequences, both of the shape the registry is meant to prevent. A future applier checked against CO-4 as written passes by implementing tombstone retention, which D13 has just ruled out — and it would pass the entry's And CO-4 is scoped to deletes, while the rule that now governs is broader. D13's motivating case is a marker-bearing UPDATE: the fallback's The fix belongs in this PR rather than a later one, because this PR is what makes the entry stale: before Worth pairing with the test obligation. CO-8's obligation ( Re-approving at This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Re-approving at 9e9e3394. All three follow-ups from the last round land, and I verified the claims rather than the commit message: the capture-completeness row now agrees with CO-8 under both replica identities and points at the remedy that exists, D13's fail-closed rule is now derived from a recorded v1 choice (deferral for every buffered change) instead of from a misreading of CO-4, and the three anchors resolve against their actual headings. The hand-resolved merge of main is additive on every hunk over the three conflicted files and loses nothing from #87 or #91.
One low-med finding in 2/2: CO-4's registry text still states only the tombstone-retention form, and only for deletes, so it is now the least specific of the three places describing this rule — and the one a reviewer is told to check against. Worth aligning in this PR, since this PR is what creates the gap.
This stamp was left by Claude Code (claude-opus-5).
morgo
left a comment
There was a problem hiding this comment.
🤖 Review posted by Morgan's AI agent.
Approving at 9e9e3394. I checked the "docs only; no code or behaviour change" claim first, since there's a .go file in the diff: pkg/statement/desired.go changes one line of a doc comment — "that is the scratch database's job" → "that is execute-and-introspect's job" — which is stale prose being brought in line with D1's decision not to have a durable scratch database. The claim holds.
The invariant amendments are the substantive part, and they tighten rather than relax. Two are worth calling out because they close hazards that would otherwise have been found the expensive way:
CO-5's new merge-never-replace rule. Dedup overlaying only the columns a newer image carries is not a detail — with pgoutput's partial images, a replace-on-dedup would let a later UPDATE's unchanged-TOAST marker erase a buffered image that actually held the value. Stating that the marker survives dedup only when no buffered image for that key ever held the column is the precise form of that rule, and it's honestly labelled as this doc set's addition rather than inherited from Spirit.
CO-6's TOAST completion before the fallback delete. This is the one I'd have gone looking for. The batch-wide delete-all-then-insert-all recovery reinserts whole rows, so an image still carrying an unchanged-TOAST marker would reinsert a fabricated value over live shadow data — silent corruption that the checksum would catch only later, if at all. Requiring the fallback to complete such an image from the current shadow row under SELECT … FOR UPDATE in the same transaction, and to treat a missing shadow row as an invariant violation rather than a reason to guess, is exactly right: fail closed on the case that shouldn't happen.
The D13 reasoning behind that fallback is also sound. A cyclic exchange {1→'B', 2→'A'} on a UNIQUE column collides in both orders, so per-key delete-then-insert retry cannot converge — no ordering heuristic rescues it, and the batch-wide form is the smallest thing that does. Making that a named test obligation with the concrete seats vector, plus a second vector adding a ≥8 KiB untouched TOASTed column, means the two hazards are pinned together.
CO-8's underlying claim about pgoutput is correct: the unchanged-TOAST marker is a property of how the new tuple is encoded, so it appears under REPLICA IDENTITY FULL just as under DEFAULT — FULL gives you a fuller old tuple, not a resolved TOAST value. Admitting FULL while stating plainly that it is not a way around the marker is the right pair of decisions.
One coupling worth being explicit about, since it's now load-bearing. CO-5's watermark rule changed from "discarded only for a monotonic integer PK, and must be queued for composite/non-comparable PKs" to discarded outright, sound because D4 restricts v1 to one integer-family PK. That's strictly safer than queueing — but the safety now lives in a preflight refusal (copy-and-swap-pk-unsupported) rather than in the buffer. If that refusal is ever loosened, or a route reaches the buffer without passing it, the discard becomes unsound with nothing local to catch it. Worth a pointer in CO-5 naming the preflight proof type as its precondition, so the dependency is visible from the invariant that depends on it.
ST-1's re-key from a fixed id=1 to (schema_name, table_name) is consistent — the ON CONFLICT matches, and the guarantee is correctly narrowed to "never a partial pair for one target" rather than continuing to claim there is always exactly one row, which per-target keying can't support before a target's first write. Good that it was narrowed rather than restated.
Docs-only, 12/12 checks green, mergeStateStatus CLEAN.
The copy-and-swap design decisions left several fidelity gaps that the adversarial review traced to real PostgreSQL behaviour rather than to wording. The shadow table inherited the wrong replica identity and lost NOT VALID constraint state; the watermark rule discarded above-watermark changes wholesale, which drops a PK-moving UPDATE whose old key sits below the watermark; identity sequence state was read from a view that hides is_called and the new sequence kept a shadow-derived name; index pairing was by name, which breaks on renamed indexes and ignores extended statistics; the reaper matched abandoned slots by table hash alone, so a same-named table in another database on the shared server could be reaped; and the completion rule read only the buffered row's Key, missing the OldKey tombstone. Each of these is a correctness decision the engine will be built against, so the design and the CO-*/ ST-* invariants now state the per-key OldKey rule, the sequence-relation read, pairing by definition, the database-scoped reaper with a slot-collision refusal, and the replica-identity and convalidated fidelity checks, with matching test obligations. Also folds the planned pkg/table into pkg/copier, fixes the 63-byte identifier limit and the composite-PK stance in the comparison page, and corrects the capabilities page's scratch-schema prerequisite so the sweep leaves no page promising a retired mechanism.
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/88, follow-up commit All findings across the three review rounds are fixed in the follow-up commit or in an earlier commit on this branch, except the docs-only CI guard, which is tracked as an internal follow-up (roadmap R22); the 13:17 round-3 first comment raised no findings, so no action.
Source: #88, review comments 5594080811, 5594081296, 5596228680, 5596229228, 5602493022, 5602494086 and review 5155516816 at head |
Records the v1 design decisions for the copy-and-swap strategy in a new
docs/copy-and-swap-design.mdand amends the existing docs that said something different, so the implementation PRs that follow build against one agreed shape. Docs only; no code or behaviour change.Why
The low-level design left several copy-and-swap questions open (durable scratch database or not, when to build shadow indexes, where checkpoints live, which primary-key shapes are admitted, how sequences and identity columns are handed off, how unchanged TOAST values are preserved, how divergence is handled). Building the strategy as parallel leaf packages only works if those are answered once, up front, and written down where the code reviews can cite them. This PR is that ratification. Where a decision contradicts existing prose (AGENTS.md, the LLD, engine-role.md, the change-capture trade-off), the prose is amended in the same change so the docs do not disagree with each other.
Every PostgreSQL-specific claim in the decisions was checked against a live server; the ones the server refuted were corrected rather than softened.
What
New
docs/copy-and-swap-design.md:copy-and-swap-pk-unsupported,-replica-identity,-foreign-keys,-triggers,-partitioned,-dependent-views,-publication-member,-name-length,-logical-decoding-unavailable,-slot-headroom,-disk-headroom,-grants. Dependent views and publication membership are the two RF-2 dependents a rename swap strands; the table names both so the vocabulary and RF-2 agree.CREATE TABLE … (LIKE <source> INCLUDING ALL EXCLUDING IDENTITY)in the source schema underSET ROLE <owner>, then the gatedALTER TABLEis executed against the empty shadow. The statement reaches the shadow by one edit at the parse boundary:pkg/statementretargets the relation to the shadow name and deparses (the same single-field-and-deparse shape theCONCURRENTLYrewrite uses), and the executor re-verifies the result against the gated statement before running it — ST-7 with the shadow as the sole permitted target. Asearch_pathapproach was considered and rejected because it cannot handle schema-qualified statements and would turn the swap into aSET SCHEMA.INCLUDING ALLcarries (defaults, constraints, indexes, per-column storage, column/constraint/index comments) and what the shadow builder must replicate explicitly under ST-5 (owner, ACLs, row-level security,reloptions, table comment).serial/nextvalsequence keeps its name and is re-owned; identity is re-added on the live table with the source sequence's options and its exact(last_value, is_called)copied withsetval, which is defined on a never-advanced sequence whereRESTART WITH last_value + 1is not.REPLICA IDENTITY FULLas well asDEFAULT(the column is present with type byteuand no value, not omitted), soFULLis admitted but is not a way around the marker.LIKEderived the shadow's from the shadow's name — soON CONFLICT ON CONSTRAINT u_slotkeeps working and a second change of the same table derives the same names.23505a savepoint rollback and delete-all-then-insert-all. Per-key delete-then-insert pairs are rejected because a cyclic exchange ({1→'B', 2→'A'}on aUNIQUEcolumn) collides in both orders; that vector is now CO-6's test obligation.--keep-oldopts out); D10 cut over as soon as the gate passes; D11 bound and reap logical-decoding state; D12 throttle by chunk time and slot lag; D14 divergence policy is explicit; D15 capture withpgoutput.dbconn,preflight(CopySwapTarget, the route's proof),copier,checksum,decode,applier,checkpoint,schemachange, with invariant columns that agree with SAFETY.md and tcb-model.md.lock_timeoutretry/backoff forACCESS EXCLUSIVE, the rule that no checksum runs under the lock, the dependent-name restoration, the sequence handoff, and the catalog recheck before commit.Amendments to existing docs:
.agents/checks/review.md: the "shadow-table DDL … derived by execute-and-introspect" rule now names the empty shadow and the transaction-scoped scratch schema and links D1; the review check names the single relation retarget as the only permitted AST edit.docs/low-level-design.md: scratch-database section and decisions §1/§2 rewritten to match D1; the throttler row of the package diagram matches D12.docs/engine-role.md: the tier-4 "planner scratch database" row is replaced by a note that copy-and-swap adds no privilege tier and needs noCREATEDB.docs/change-capture-tradeoff.mdanddocs/high-level-design.md: v1 implements logical decoding and refuses clusters without it; triggers stay the documented alternative with implementation deferred (D15).docs/invariants.md: new CO-8 "A TOAST-omitted column is never overwritten" (both replica identities; test obligation runs under both); CO-6 records the decided semantics and its refuting test vector; ST-1 reworded to one checkpoint row per(schema, table); ST-6 no longer lists a durable scratch database; ST-7 lists the planned shadow-retarget enforcement site.docs/tcb-model.md:pkg/appliergains CO-8;CopySwapTargetjoins the proof-type registries; the core dependency list records the decision to admitjackc/pglogrepl(pinned) forpkg/decode— streaming-replication protocol andpgoutputdecoding are load-bearing wire-protocol expertise under the same rubric as the parser. It is confined topkg/decodeand is not added togo.moduntil that package's implementation lands.docs/architecture.mdanddocs/design-principles.md: package rows and principles that still described composite chunk keys, replica-lag throttling, or deferred cutover as v1 behaviour now match D4, D10, and D12.docs/README.md: index row for the new document.Verification
make test-unitgreen (the docs tests that pin proof-type and invariant lists still pass).#anchoracross the tree resolves after the D13 retitle.{1→'B', 2→'A'}exchange raises23505for a delete-then-insert pair in either order and converges under batch-wide delete-then-insert;LIKE … INCLUDING ALLnames the shadow's indexes<shadow>_pkey/<shadow>_<col>_keyandALTER INDEX … RENAMErestores the constraint name with the index;ALTER SEQUENCE … RESTART WITH NULLis a syntax error whilesetval(seq, last_value, is_called)reproduces a never-advanced sequence exactly; renaming an identity sequence is permitted.Authored with Amp (Claude Opus 4.5).