schemachange: require the table lock and add DropShadow/InspectShadow - #121
Conversation
- BuildShadow takes the *dbconn.TableLockSession for the proven table: a nil session, an empty proof, a lock on another table, or a session that has reported loss is refused before any connection is opened (ErrInvariantViolation, LK-1). - Run the build under the session's Bind context so a lost lock cancels the statement in flight; report the loss as the cause instead of a bare context error. - Confirm the lock inside the build transaction, before the first write: pg_locks read on the working connection must show the lock granted to the session's own backend. - Add DropShadow: one bounded transaction under SET LOCAL ROLE owner that drops exactly the shadow, without CASCADE, refusing anything under the shadow's name that is not a plain table the source's owner owns (ST-5). A missing shadow is ErrShadowNotFound. - Add InspectShadow: re-derive the BuiltShadow proof from the catalog in one read-only transaction so a resume can compare it with its checkpoint; refuse a shadow with another owner or of another relkind, and a shadow whose identity default no longer depends on the source's sequence. - Extract newBuiltShadow so build and inspect assemble the proof the same way. - Tests: nil and wrong-table lock refusals for all three operations, a build against a lock session whose backend is already gone, a build parked on ACCESS EXCLUSIVE that loses the lock mid-flight, drop leaves the source and its sequence intact, drop and inspect refuse a view or a foreign-owned table under the shadow name, inspect reproduces the built proof exactly and refuses a stripped or re-pointed identity default and a source whose shape changed since the proof. - Docs: SAFETY.md, copy-and-swap design package table and D5, LK-1 and ST-5 enforcement in the invariant registry.
- Derive the identity handoff from the shadow's model: a source identity column the change dropped has no column on the shadow to carry the default, so it is not part of the proof and its sequence ends with the old table. InspectShadow verifies defaults only for the kept columns, so a correctly built shadow whose change dropped an identity column is no longer refused as ST-5. - Report a lock loss from the session's own Err rather than from the shape of the cancelled context, so a caller's cancellation with a cause of its own is returned as that cancellation, not as LK-1. - Tests: dropped identity column is excluded from the proof and the inspection reproduces it; caller cancellation is not a lock loss; drop and inspect refuse a lock session whose backend is gone (shared goneLock fixture); refusal tests create their extra role before the schema so cleanup drops the owned relation first. - Docs: D5 records the dropped-identity-column case.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
🤖 Adversarial correctness review (1/2) — 0 blocking, 5 non-blockingReviewed at Nothing here can damage a target database, and the in-transaction confirmation is the right shape. The lock and the work are deliberately on different sessions, so a proof that the session object believes it holds the lock proves nothing about the server; asking Invariant dispositions
Mutation testing. 22 mutants against
Non-blocking1 — the backend comparison is the one new LK-1 check nothing pins, and it is the one the two-instance case runs through
That is the collision LK-1 exists for: this instance's lock session dies, a second engine instance acquires the same table lock before this one's keepalive notices, and this one keeps working. Measured with the check deleted, the drop does not merely proceed — it removes the shadow out from under the instance that now holds the lock ( Test that passes on 60df87b and fails with the backend comparison deleted
// The in-transaction confirmation compares backends, not merely that some
// session holds the table. After this instance's lock session is gone, a
// second engine instance can take the same lock; the first must not keep
// working on the table under a lock the server now grants to someone else.
func TestShadowOperationsRefuseALockHeldByAnotherBackend(t *testing.T) {
f := newShadowFixture(t)
f.exec(t, `
CREATE TABLE %s.widgets (
id bigint PRIMARY KEY,
qty integer NOT NULL
)`)
target := f.prove(t, "widgets")
buildLock, err := dbconn.AcquireTableLock(t.Context(), f.cfg, f.schema, "widgets")
require.NoError(t, err)
_, err = schemachange.BuildShadow(t.Context(), f.pool, buildLock, target, f.alter(t, `ALTER TABLE %s.widgets ALTER COLUMN qty TYPE bigint`), schemachange.Options{})
require.NoError(t, err)
require.NoError(t, buildLock.Release(t.Context()))
shadow := schemachange.ShadowName(f.schema, "widgets")
stale := f.goneLock(t, "widgets")
rival, err := dbconn.AcquireTableLock(t.Context(), f.cfg, f.schema, "widgets")
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, rival.Release(context.WithoutCancel(t.Context()))) })
require.NotEqual(t, stale.BackendPID(), rival.BackendPID())
mismatch := fmt.Sprintf("not the lock session's backend %d", stale.BackendPID())
err = schemachange.DropShadow(t.Context(), f.pool, stale, target, schemachange.Options{})
assert.ErrorIs(t, err, schemachange.ErrInvariantViolation, "drop")
assert.ErrorContains(t, err, mismatch, "drop")
assert.True(t, f.relationExists(t, shadow), "a refused drop removes nothing")
_, err = schemachange.InspectShadow(t.Context(), f.pool, stale, target, schemachange.Options{})
assert.ErrorIs(t, err, schemachange.ErrInvariantViolation, "inspect")
assert.ErrorContains(t, err, mismatch, "inspect")
}
The third line is the interesting one: the drop succeeded, so by the time the inspection runs there is no shadow left. 2 — the free, pre-connection refusal is also unpinned
Test that passes on 60df87b and fails with the pre-connection refusal deleted
// A lock session that has already reported loss is refused before any
// connection is opened, and the refusal names the loss rather than whatever
// the cancelled work produced downstream.
func TestShadowOperationsRefuseALockSessionThatReportedLoss(t *testing.T) {
f := newShadowFixture(t)
f.exec(t, `
CREATE TABLE %s.widgets (
id bigint PRIMARY KEY,
qty integer NOT NULL
)`)
target := f.prove(t, "widgets")
lock := f.lock(t, "widgets", dbconn.WithTableLockKeepalive(time.Second))
f.terminateBackend(t, lock.BackendPID())
const lockLossDeadline = 30 * time.Second
select {
case <-lock.Done():
case <-time.After(lockLossDeadline):
t.Fatalf("lock session did not report loss within %s", lockLossDeadline)
}
_, err := schemachange.BuildShadow(t.Context(), f.pool, lock, target, f.alter(t, `ALTER TABLE %s.widgets ALTER COLUMN qty TYPE bigint`), schemachange.Options{})
assert.ErrorContains(t, err, "LK-1: table lock was lost", "build")
assert.False(t, f.relationExists(t, schemachange.ShadowName(f.schema, "widgets")), "a refused build creates nothing")
err = schemachange.DropShadow(t.Context(), f.pool, lock, target, schemachange.Options{})
assert.ErrorContains(t, err, "LK-1: table lock was lost", "drop")
_, err = schemachange.InspectShadow(t.Context(), f.pool, lock, target, schemachange.Options{})
assert.ErrorContains(t, err, "LK-1: table lock was lost", "inspect")
}
3 — the registry claims
|
🤖 Review (2/2) — adoption and integration lensesSame head, Lens 1 — OSS adoptionThe shadow lifecycle is now a complete, symmetric surface, and that is the headline. Before this PR an adopter could build a shadow and then had nothing to do with The new operational requirement is invisible from the doc comments. Every shadow operation now needs a The refusals are strings under one sentinel, and this package already has the better pattern next door. The new ST-5 and LK-1 refusals are all Lens 2 — integration ease for schemabot and other importersThe typed cause is the single highest-value change for schemabot specifically. schemabot renders engine outcomes into public pull-request comments, and its own rule is that an untrusted error string is never rendered raw into that markdown — dial failures, hostnames and driver internals leak infrastructure detail and break table layout. Every refusal added here carries exactly the material that rule is about: schema and role names, sequence names, relkind letters, and two server backend PIDs. With one sentinel, an importer's only options are to render nothing useful or to match on substrings. With a cause constant it can map each cause to fixed operator-facing text and log the detail server-side, which is what it already does for The resume contract itself is easy to consume, and the equality is the good part. Persisting the proof is the part an importer still has to hand-roll.
This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving: 0 blocking, 5 non-blocking — see the correctness review and the adoption/integration lenses. The in-transaction confirmation is the right shape for a lock that lives on a different session; 22 mutants, 14 killed, and the four survivors that matter are missing tests rather than missing checks — two of them ship with a test run both ways.
This stamp was left by Claude Code (claude-opus-5).
|
🤖 Reviewed at [P1, blocker] A blocked drop can commit after its table lock is lost —
Reproduced against PostgreSQL 16 with the default keepalive: build a shadow under instance A's lock; hold ACCESS EXCLUSIVE on the shadow from a separate transaction; start [P2, blocker] Lock confirmation can select a holder from another database — The newly enforced lookup calls Validation: the complete |
…ck checks - Run verifyIdentityDefaults at the end of the build, against the shadow the gated statement left, so the proof records exactly what an inspection of that shadow accepts; a statement that replaces an identity column under the same name is refused (ST-5) instead of recorded as a handoff. The helper moves to identity.go. - Say at each site why drop matches the shadow against the proof's owner (the shadow was created under it; the source may be gone) and inspect against the live owner (owner drift is shape drift). - Package doc: the lock session is a dedicated direct session apart from the pool; the resume order is inspect, compare, then drop only on disagreement or abandonment. Design doc says the same of the session. - Tests: a stale session refused when a rival backend holds the lock (build, drop, inspect); a session that already reported loss refused before any statement, wrapping its own error and no cancellation; a drop parked on the shadow's lock aborts on lock loss and leaves the shadow; the replaced-identity-column build refusal.
|
🤖 Adversarial review response — created by Kiran's code review agent (Amp, Claude Opus 4.6) — pull/121, follow-up commit 0 blocking. Of the correctness findings, 1, 2, 4 and 5 are fixed and 3 is fixed for drop and deferred for inspect; of the adoption/integration items, the direct-session doc and the resume ordering are fixed, and the typed cause set and the proof marshalling are deferred to follow-ups.
"What holds" (invariant dispositions; the 14 killed mutants; the in-transaction confirmation's shape; the Source: block/pg-sprite#121, review comments 5769205600 and 5769206335 and review 5272950893 at head |
pkg/schemachange's shadow operations now run only under the per-table lock:BuildShadowtakes the*dbconn.TableLockSessionfor the proven table, runs under itsBindcontext, and confirms from inside its own transaction that the session's backend holds the lock before the first write. The package gainsDropShadow(remove exactly the shadow, withoutCASCADE) andInspectShadow(re-derive theBuiltShadowproof from the catalog for a resume), both under the same lock discipline.Follows the shadow-builder PR (#117), which is on
main.Why
The shadow builder shipped with the lock deliberately left to a later leaf, so nothing yet stopped two engine instances from building, dropping, or inspecting the same table's shadow at once, and nothing removed or verified a shadow an aborted run left behind —
ErrShadowExistspointed at a resume path that did not exist. The lock session and the work run on different connections by design (the lock must survive pool recycling; the work must not), so the work has to prove, on its own connection, that the session it trusts is the one the server actually shows holding the table.What
requireTableLock(lock.go) runs before any connection is opened: a nil session, an emptyTableLockproof, a lock for another schema or table, or a session whoseErrreports loss isErrInvariantViolation(LK-1).confirmTableLockruns inside the working transaction, afterSET LOCAL ROLEand before the first write:dbconn.LookupTableLockHolderon the working connection must find the lock granted tolock.BackendPID(). A session whose backend is gone but whose keepalive has not yet noticed is refused here.lock.Bind(ctx);lockLossCauseturns a statement cancelled by lock loss intoErrInvariantViolation: LK-1: table lock lost …wrapping the session's reported cause, so a caller sees the loss rather than a barecontext.Canceled.DropShadow(ctx, pool, lock, target, opts)(drop.go): one bounded transaction underSET LOCAL ROLEowner;resolveShadowrefuses anything under the shadow's name that is not a plain table (relkind = 'r') owned by the proof's owner (ST-5);DROP TABLEwithoutCASCADE, since the shadow's identity defaults depend on the source's sequences and never the reverse (D5). No relation isErrShadowNotFound. The owner comes from the proof rather than the live source, so an orphaned shadow can still be dropped after its source is gone.InspectShadow(ctx, pool, lock, target, opts)(inspect.go): one read-only transaction; re-checks the source shape by OID (ST-6, same*preflight.UnsupportedCopySwapShapeErrorthe build raises), resolves the shadow with the same ST-5 gate, andverifyIdentityDefaultsproves each source identity column is a plain column on the shadow whosepg_attrdefdepends on the source sequence's OID — aCASCADE-stripped or re-pointed default is refused. Returns the sameBuiltShadowthe build did (newBuiltShadowis shared); the caller compares fingerprints with its checkpoint, because the gated statement is not recoverable from the catalog.lock_,drop_,inspect_integration_test.go): all three operations refuse a nil lock and a lock on another table, creating nothing; a build against a lock whose backend was terminated (default keepalive, so the session still believes it holds) is refused by the in-transaction check; a build parked on the source'sACCESS EXCLUSIVElock aborts within a named deadline when the lock session is terminated, reports the session's loss as the cause, and leaves no shadow; drop removes only the shadow and the source's identity sequence keeps issuing; drop and inspect refuse a foreign-owned table and a view under the shadow's name and leave them in place; inspect reproduces the built proof exactly and refuses a stripped default, a default pointing at another sequence, another owner, and a source that grew a trigger since the proof. The shadow fixture acquires one lock per build. Run locally against PostgreSQL 16; CI covers 14 → 18.SAFETY.mdrow,docs/copy-and-swap-design.mdpackage table and D5,docs/invariants.mdLK-1 (shadow build/drop/inspect move to Enforced today; copier and cutover remain planned) and ST-5.Before / after
References
docs/invariants.md§ LK-1, ST-5, ST-6;docs/copy-and-swap-design.md§ D5, D8, package table;pkg/dbconnTableLockSession.Bind,LookupTableLockHolder.Follow-up: identity handoff and lock-loss reporting
BuiltShadow.IdentityColumns()lists only the kept columns (before, the build listed the dropped column too),InspectShadowverifies defaults only for those, and a correctly built shadow whose change dropped an identity column is no longer refused as ST-5. Telling a dropped column apart from a tampered shadow is the caller's fingerprint comparison. D5 indocs/copy-and-swap-design.mdrecords the case.lockLossCausereports a loss from the session'sErr()rather than from the shape of the cancelled context, so a caller cancelling with its own cause gets that cancellation back, never a fabricated LK-1.goneLockfixture, so removing either in-transaction check fails the suite); refusal tests create their extra role before the schema so cleanup drops the owned relation first.Follow-up: build-time handoff proof and pinned lock checks
verifyIdentityDefaultsafter the gated statement, against the shadow it left, soBuiltShadowrecords exactly the handoffs an inspection of that shadow accepts; a statement that drops an identity column and adds a different column under the same name is refused (ST-5) and leaves no shadow.🤖 Drafted with Amp (Claude Opus 4.6); reviewed and edited by the author.