schemachange: gate cutover on the ST-5 fidelity checklist and mint CutoverReady - #137
Conversation
16eaf22 to
4d39e3e
Compare
…toverReady (CO-1, ST-5) GateCutover takes a BuiltShadow and a checksum.VerifiedShadow for the same table and, in one read-only transaction under the per-table lock, re-reads both relations against what the build recorded. It refuses, fail-closed, when either proof is empty or they name different relations, when the verified watermark stops short of the key space, when either OID moved, when either table's fingerprint or metadata snapshot drifted, when a shadow index is invalid, or when a name the swap must assign is already taken. The checklist is one function so the swap can run it again inside its own transaction. CutoverReady is what the gate mints: the two proofs it held, every source index and extended statistics object paired with its shadow counterpart by catalog definition rather than by name (LIKE renames them), and the sequences the source's columns own, which the swap re-owns. The builder now records the shadow's own metadata snapshot alongside the source's, since the gated statement may change metadata on purpose, and carries each column's explicit statistics target onto the shadow, which LIKE … INCLUDING ALL does not copy.
48d61e1 to
b54bd32
Compare
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 1/2: adversarial correctness review of 1 blocking, 6 non-blocking. The gate refuses before connecting when there is no lock or a lock for another table. It runs read-only, under the lock's Blocking1. The gate accepts a
The caller cannot catch this either: the proof carries no OID to compare. The verifier already checks both OIDs on every pass (the replaced-relation test in CO-1). They are just not carried into the proof it mints. The fix is to put Test that fails on b54bd32 and passes with the fix// A verified-shadow proof minted for a shadow that was since dropped does
// not prove the shadow rebuilt under the same derived name, which no copy
// has filled (CO-1).
func TestGateCutoverRefusesAVerifiedProofForAnEarlierShadow(t *testing.T) {
f := newShadowFixture(t)
f.createOrders(t)
s := f.stage(t, "orders", `ALTER TABLE %s.orders DROP COLUMN note`)
require.NoError(t, schemachange.DropShadow(t.Context(), f.pool, s.lock, s.target, schemachange.Options{}))
rebuilt, err := schemachange.BuildShadow(t.Context(), f.pool, s.lock, s.target, f.alter(t, `ALTER TABLE %s.orders DROP COLUMN note`), schemachange.Options{})
require.NoError(t, err)
require.Equal(t, s.built.ShadowTable(), rebuilt.ShadowTable(), "the rebuild wears the same derived name")
require.NotEqual(t, s.built.ShadowOID(), rebuilt.ShadowOID(), "the rebuild is a new relation")
_, err = schemachange.GateCutover(t.Context(), f.pool, s.lock, rebuilt, s.verified, schemachange.Options{})
assert.Equal(t, schemachange.CauseCutoverUnverified, schemachange.RefusalCauseOf(err), "the rebuilt shadow holds no rows")
}
Non-blocking1. On the resume path, the gate holds each table only to itself, so a
Here is the sequence. This stays non-blocking because nothing calls the resume path yet. A caller can also catch it by comparing the whole
Test that fails on b54bd32 and passes with the grants cross-check// A privilege revoked on the source after the build is still granted on the
// shadow. Resuming through InspectShadow yields the same fingerprints the
// build recorded, and the gate must still refuse (ST-5).
func TestGateCutoverAfterInspectionRefusesASourceRevoke(t *testing.T) {
f := newShadowFixture(t)
f.createOrders(t)
f.exec(t, `GRANT SELECT ON %s.orders TO PUBLIC`)
s := f.stage(t, "orders", `ALTER TABLE %s.orders DROP COLUMN note`)
f.exec(t, `REVOKE SELECT ON %s.orders FROM PUBLIC`)
inspected, err := schemachange.InspectShadow(t.Context(), f.pool, s.lock, s.target, schemachange.Options{})
require.NoError(t, err)
require.Equal(t, s.built.SourceFingerprint(), inspected.SourceFingerprint())
require.Equal(t, s.built.TargetFingerprint(), inspected.TargetFingerprint())
_, err = schemachange.GateCutover(t.Context(), f.pool, s.lock, inspected, s.verified, schemachange.Options{})
assert.Equal(t, schemachange.CauseFidelityDrift, schemachange.RefusalCauseOf(err), "the shadow still grants SELECT to PUBLIC")
}
2. Index pairing ignores
The fix is to add Test that fails on b54bd32 and passes with the fix// Two unique indexes on one column that differ only in NULLS NOT DISTINCT
// are different indexes, and each pairs with the shadow copy that has the
// same setting (D8).
func TestGateCutoverPairsIndexesOnNullsNotDistinct(t *testing.T) {
f := newShadowFixture(t)
f.exec(t, `CREATE TABLE %s.items (id bigint PRIMARY KEY, code text, other integer)`)
f.exec(t, `CREATE UNIQUE INDEX z_nnd ON %s.items (code) NULLS NOT DISTINCT`)
f.exec(t, `CREATE UNIQUE INDEX a_plain ON %s.items (code)`)
f.exec(t, `INSERT INTO %s.items SELECT g, 'c' || g, g FROM generate_series(1, 50) g`)
s := f.stage(t, "items", `ALTER TABLE %s.items DROP COLUMN other`)
ready, err := f.gate(t, s)
require.NoError(t, err)
nullsNotDistinct := func(name string) bool {
var nnd bool
require.NoError(t, f.pool.QueryRow(t.Context(), `
SELECT i.indnullsnotdistinct FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = $1 AND c.relname = $2`, f.schema, name).Scan(&nnd))
return nnd
}
for _, pair := range ready.Indexes().Pairs {
assert.Equal(t, nullsNotDistinct(pair.SourceName), nullsNotDistinct(pair.ShadowName),
"%s pairs with %s", pair.SourceName, pair.ShadowName)
}
}
3. Extended statistics lose their target and their schema, and the pairing cannot see either. This PR carries column The pairing key is kinds plus columns, so these two still pair. After the swap, the live table's statistics object has the default target and has moved schemas. 4. The shadow half of the checklist is unpinned, and so is the identity-options check. Four mutants survive the PR's tests. Skip the shadow OID check (M10). Skip the shadow fingerprint (M13). Skip the shadow fidelity comparison (M15). Skip the identity sequence-options comparison (M17). Each of these is a refusal cause the PR documents. Nothing exercises a statement that changes table metadata on purpose either, which is the reason Test that passes on b54bd32 and kills M10, M13, M15, M17// The checklist holds the shadow to its own build-time record as well as the
// source: a shadow replaced under its name, reshaped, or given metadata after
// the build is refused, and so is a source identity whose sequence options
// changed (ST-5, ST-6).
func TestGateCutoverRefusesShadowSideAndIdentityDrift(t *testing.T) {
cases := []struct {
name string
after func(f shadowFixture, s staged)
want schemachange.RefusalCause
}{
{"shadow replaced", func(f shadowFixture, s staged) {
f.exec(t, "ALTER TABLE "+f.shadowName(s.built)+" RENAME TO shadow_was")
f.exec(t, "CREATE TABLE "+f.shadowName(s.built)+" (LIKE %s.shadow_was INCLUDING ALL)")
}, schemachange.CauseRelationReplaced},
{"shadow reshaped", func(f shadowFixture, s staged) {
f.exec(t, "ALTER TABLE "+f.shadowName(s.built)+" ADD COLUMN extra integer")
}, schemachange.CauseSchemaDrift},
{"shadow metadata", func(f shadowFixture, s staged) {
f.exec(t, "COMMENT ON TABLE "+f.shadowName(s.built)+" IS 'touched'")
}, schemachange.CauseFidelityDrift},
{"source identity options", func(f shadowFixture, s staged) {
f.exec(t, "ALTER TABLE %s.accounts ALTER COLUMN id SET INCREMENT BY 5")
}, schemachange.CauseFidelityDrift},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
f := newShadowFixture(t)
f.exec(t, `CREATE TABLE %s.accounts (id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, qty integer NOT NULL)`)
f.exec(t, `INSERT INTO %s.accounts (qty) SELECT g FROM generate_series(1, 2500) g`)
s := f.stage(t, "accounts", `ALTER TABLE %s.accounts ALTER COLUMN qty TYPE bigint`)
c.after(f, s)
_, err := f.gate(t, s)
assert.Equal(t, c.want, schemachange.RefusalCauseOf(err), "%v", err)
})
}
}It passes on 5. Smaller unpinned branches. No test pins these. The 6. CO-1's registry entry still lists cutover as planned. Verified
This review was generated by Claude Code (claude-opus-5-5). |
|
🤖 2/2: OSS adoption and integration ease, at The shape is easy to adopt. A proof type with a private constructor, which the swap will accept and nothing else, is the right contract for a third-party orchestrator. The six causes fit the closed 1. Say why a dependent is unpaired. An importer that renders the pairing before cutover cannot tell the two cases apart. One is an index the statement dropped on purpose, like 2. Route drift refusals on structure, not on 3. Let an orchestrator find fidelity drift while the copy runs, not at cutover. The remedy for 4. Give This review was generated by Claude Code (claude-opus-5-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving b54bd32 with 1 blocking finding: the gate matches the verified-shadow proof to the built shadow by name only. So a proof left over from a dropped shadow lets a rebuilt, never-copied shadow mint CutoverReady (CO-1). The fix is to carry both OIDs in VerifiedShadow and compare them in the gate. The 1/2 comment has the blocking finding with its test and six non-blocking findings. Three of the non-blocking findings come with tests: a source REVOKE survives the resume path, the pairing ignores NULLS NOT DISTINCT, and the shadow half of the checklist is unpinned. The 2/2 comment has four non-blocking integration notes.
This stamp was left by Claude Code (claude-opus-5-5).
|
🤖 Delta re-review of The only new commit merges Blocking1. The gate accepts a
I re-ran the test from the 1/2 comment at With the earlier fix applied, it passes, and so does every Non-blockingThe six non-blocking findings in 1/2 and the four in 2/2 are unchanged. The tests for the source This review was generated by Claude Code (claude-opus-5-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Stamping with comments: 1 blocking (see the delta re-review above).
This stamp was left by Claude Code (claude-opus-5-5).
… grants; carry extended statistics targets The verified-shadow proof carries the source and shadow OIDs the pass compared, and the cutover gate refuses a proof whose OIDs are not the built shadow's: a shadow dropped and rebuilt under the same derived name is a new relation no pass has read (CO-1). The gate compares the live source's table grants with the live shadow's as well as holding each table to its own build record, because the gated statement cannot change grants and a proof re-derived through InspectShadow records each table as it is (ST-5). The gate transaction runs at REPEATABLE READ so every catalog read sees one snapshot. The builder carries each extended-statistics object's explicit SET STATISTICS target to the LIKE-named shadow copy paired by definition, and the fidelity snapshot records the targets; the index pairing key includes NULLS NOT DISTINCT; the gate lists for re-owning only the sequences of columns the shadow kept; fidelityDrift derives its fact list from the snapshot's fields. Tests: a verified proof held across a rebuild is refused; a REVOKE on the source after the build is refused through InspectShadow; the shadow replaced, reshaped, or commented after the build and changed identity options are each refused while a gated SET (fillfactor) passes; indexes differing only in NULLS NOT DISTINCT or operator class and statistics objects differing only in kinds pair with their own copies; a taken _old name of a statistics object or identity sequence is refused; a dropped bigserial column's sequence is not re-owned; extended targets land on the shadow and a type change resets the rebuilt object's target as PostgreSQL does; every snapshot field is a named drift fact; every cause's Invariant() is pinned. Docs: CO-1 names GateCutover as the enforcement; ST-5 and the cutover-unverified / cutover-fidelity-drift rows cover the OID check, the grants cross-check, and extended statistics targets.
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/137, follow-up commit Verdict: the one blocking finding is fixed as proposed (the verified proof now carries both OIDs and the gate compares them); of the six correctness notes, five are taken in full and the sixth (extended statistics) is taken for the target and deferred for the namespace; the four integration-lens notes are tracked as plan rows, since each is an API shape the swap PR should settle. The internal review's REPEATABLE READ suggestion and its owned-sequence and doc findings are taken; its retyped-column pairing finding is already #138. Every surviving mutant the reviewer listed is now killed except M32, explained below.
Tests added or changed in the follow-up commit: Source: block/pg-sprite#137, review comments 5926992729 and 5926994796 at head |
Adds
schemachange.GateCutover: the ST-5 fidelity checklist that stands between a verified shadow and the swap, and theCutoverReadyproof the swap will accept nothing but.Why
The copier fills the shadow and the verifier proves the rows are equal (CO-1), but data equality is not the whole gate. Between the build and the swap the source may have gained a grant, an index, or a new OID; the shadow may carry an invalid index from a cancelled concurrent build; the
_oldname the swap assigns may still be worn by a leftover from an earlier run; and the swap needs to know which shadow index takes which source index's name — a questionLIKE … INCLUDING ALLanswers badly, since it names every shadow dependent after the shadow. ST-5 says the swap runs only when all of that holds. Landing the gate on its own keeps the checklist, its refusal causes, and the pairing rule in one review, apart from the rename sequence that consumes them.What
pkg/schemachange:GateCutover(ctx, pool, lock, built, verified, opts)refuses before connecting when either proof is empty, the two name different relations or different relation OIDs —VerifiedShadownow carries the source and shadow OIDs the pass compared, so a shadow dropped and rebuilt under the same derived name does not inherit an earlier pass's proof — or the verified watermark is not complete (cutover-unverified, CO-1), then runs the checklist in one bounded read-onlyREPEATABLE READtransaction underSET LOCAL ROLE owner, with the lock session'sBindcontext and the in-transaction lock confirmation every shadow operation makes (LK-1). The checklist refuses on a moved source or shadow OID (cutover-relation-replaced, ST-6), an invalid shadow index (cutover-index-invalid), a source or shadow fingerprint that no longer matches the build's (cutover-schema-drift), a source or shadow metadata snapshot that drifted — naming the drifted facts — source identity columns whose sequence options changed, or table grants that differ between the live source and the live shadow, a fact the gated statement cannot change and the one cross-check that holds on a proof re-derived byInspectShadow(cutover-fidelity-drift), and a derived_oldname already worn by a relation or statistics object, or a constraint-backed source index name already held by another constraint on the shadow (cutover-name-taken).gateCutoverTxis the checklist proper, so the swap re-runs it inside its own transaction before the first rename.CutoverReadycarries the two proofs it held,Indexes()andStatistics()asDependentPairing{Pairs, UnpairedSource, UnpairedShadow}— pairs matched by name-free catalog definition (access method, uniqueness,NULLS NOT DISTINCT, key and included columns by name, opclasses, collations,indoption, predicate, backing constraint; statistics kinds and columns), two source dependents with one definition each taking a distinct partner — andOwnedSequences()for the swap to re-own (D5, D8). An unpaired dependent is reported, not refused: the gated statement may have dropped an index's column or added an index.BuiltShadow.ShadowFidelity()/Proof.ShadowFidelityrecord the shadow's own metadata snapshot after the gated statement ran, apart from the source's, because the statement may change metadata on purpose; the gate holds the shadow to it.FidelitySnapshot.ColumnStatisticsTargetsandExtendedStatisticsTargetscarry each column's and each extended-statistics object's explicitSET STATISTICStarget, whichLIKE … INCLUDING ALLdoes not copy, and the builder applies them to the shadow — the extended ones to the LIKE-named copies paired by definition. The gate re-owns only the sequences of columns the shadow kept; a droppedbigserialcolumn's sequence goes with the old table.fidelityDriftderives the fact list from the snapshot's fields by reflection, so a field added later is compared without a hand-kept list changing.RefusalCausevalues with theirInvariant()mapping;requireTableLocktakes a schema and table so the gate can use the built shadow's.refusal-classes.mdclass rows for the six causes (pinned by the docs guard),invariants.mdST-5 "enforced" and CO-1 namingGateCutoveras the enforcement,copy-and-swap-design.mdpackage map,SAFETY.mdrow.orderstable passes the gate with its primary key, qty index, and extended statistics paired to the LIKE-named shadow objects, the index on the dropped column reported unpaired, and the serial key's sequence listed; a unique constraint the change adds is reported as an unpaired shadow index; zero built proof, zero verified proof, and a verified proof for another table are eachcutover-unverified; no lock and a lock for another table areshadow-lock-unproven, a lock whose backend is gone isshadow-lock-unconfirmed;GRANT … TO PUBLICon the source after the build iscutover-fidelity-drift;CREATE INDEXon the source after the build iscutover-schema-drift; a failedCREATE UNIQUE INDEX CONCURRENTLYon the shadow iscutover-index-invalid; the source renamed away and recreated under its name iscutover-relation-replaced; a table wearing the_oldname iscutover-name-taken; a gatedADD CONSTRAINT orders_pkey CHECKiscutover-name-takenwhile the same borrowed from a plain index passes;SET STATISTICStargets land on the shadow and in both snapshots, column and extended alike, and a type change resets the target of the statistics object it rebuilds exactly as PostgreSQL does on the source; the proof JSON round-trip covers everyshadow_fidelity,column_statistics_targets, andextended_statistics_targetskey path. The follow-up commit adds: a verified proof held acrossDropShadow+BuildShadowiscutover-unverified; aREVOKEon the source after the build iscutover-fidelity-drifteven throughInspectShadow; the shadow replaced, reshaped, or commented after the build and a source identity'sSET INCREMENT BYare each refused, whileSET (fillfactor = 50)as the gated statement passes; unique indexes differing only inNULLS NOT DISTINCT(PG 15+), indexes differing only in operator class, and statistics objects differing only in kinds each pair with their own copy; a leftover wearing a statistics object's or identity sequence's_oldname iscutover-name-taken; a droppedbigserialcolumn's sequence is not listed for re-owning. Unit tests pin pairing-by-definition, partner reuse, derived old names, drift naming over every snapshot field,keptSequences, and theInvariant()of every cause.Before / after
Known gap, carved as the next PR (not a review item here)
cs9-pair-type-change, lands before the swap consumes the pairing). PostgreSQL rebuilds an index on a column the gatedALTER COLUMN … TYPEchanges with the new type's default operator class —int4_ops → int8_opsonint → bigint, whilepg_get_constraintdefstill readsPRIMARY KEY (id)(checked on PG 16).indexDefinitionincludesOpclasses, so under this PR's definition-equality rule the shadow'spkeyand every secondary index on the widened column are reported unpaired, and a swap built on this pairing alone would leave them with their_pgsprite_<hash>_new_…names. The follow-up adds a second pairing pass for the leftovers: equal on access method, uniqueness, key and included columns,indoption, predicate, and constraint kind, with opclass and collation allowed to differ only on the columns the statement retyped. This PR keeps the strict rule on purpose: it is the exact-match base the relaxed pass refines, andUnpairedSource/UnpairedShadowalready make the gap observable.Follow-ups
cs9-swap: render the rename sequence fromCutoverReadyand run it inside oneACCESS EXCLUSIVEtransaction that re-runs this checklist first.cs9-fidelity-carry: carryattoptions(n_distinct) alongside the statistics target; re-synchronise additive drift (aGRANTafter the build) under the lock instead of refusing; an exported drift check an orchestrator can run during the copy.cs9-statistics-namespace: an extended-statistics object that lives outside its table's schema —LIKEcopies it into the table's schema andtakenNameslooks for its_oldname only there; the gate should refuse or followstxnamespace.cs9-cutover-ready-report: a plain encodable view ofCutoverReady(pairs, unpaired dependents with the reason, sequences, accepted proofs) and a typed side/facts onRefusalErrorso an importer routes drift without readingDetail.References
docs/invariants.mdST-5, ST-6, CO-1, LK-1;docs/copy-and-swap-design.mdD5, D8.checksum.CheckandVerifiedShadow) and checksum: chunk verifier comparing source and shadow in one snapshot (CO-1) #134 merged.🤖 Drafted with Amp (Claude Opus 4.6); reviewed and edited by the author.