converge additive postgresql storage schema drift at startup - #1220
Conversation
Pure seam for startup schema convergence: lift the ColumnDef from the desired CREATE TABLE into a deparsed ALTER TABLE ... ADD COLUMN, so the converger never hand-maintains a second copy of column DDL. Nothing calls it yet; the tripwire-to-convergence flip follows separately.
Missing columns and standalone indexes on an already-bootstrapped store previously failed startup (or warned) and required manual DDL. The bootstrapper now converges them under the advisory lock: ADD COLUMN synthesized from the embedded desired CREATE TABLE, index statements executed verbatim, per-table transactions, additive-only. NOT NULL without DEFAULT still fails closed since it may need a deliberate backfill.
…m the parse tree The manual-remediation gate now scans the whole drift set before any table's transaction runs, so a NOT NULL-without-DEFAULT column can no longer leave earlier tables half-converged. The gate itself reads the column's parsed constraints instead of scanning SQL text: generated and identity columns converge automatically, quoted identifiers cannot mask a missing DEFAULT, and function-call defaults fail closed because their volatility cannot be proven from the statement alone. Convergence DDL now bounds its lock wait with lock_timeout, and index builds run in their own transactions so the ALTER TABLE's exclusive lock is never held across a build. Drift and verify share one expectations parser that fails closed on schema-file statements the convergence cannot track.
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
🤖 Adversarial correctness review, requested by @aparajon and performed by their agent. Reviewed at head Verdict: the convergence machinery is right — the lock/re-check/gate/apply/re-verify shape is careful, and gating the whole drift set before any DDL is the correct call — but the safety classifier has its generated/identity arm backwards, and I can prove it. That arm is the one place where the reasoning inverts: it green-lights, as needing no remediation, exactly the two column shapes that force a full-table rewrite under Findings1. 2. The same enumeration treats every constraint type it does not name as automatically safe, and 3. A converged 4. (nit) The non-unique-index warning is now effectively unreachable, and its only test was removed. Action items
Verified (tried to break, couldn't)The concurrency shape is genuinely careful and I could not find a hole in it: drift is discovered without the lock, re-discovered under it because another pod may have converged in the meantime, the manual-remediation gate scans the entire drift set before any table's DDL runs so an operator sees every problem at once rather than one per crashloop, and unresolved drift still fails the final verification — the fail-closed exits all survive. This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving on @aparajon's behalf after the adversarial correctness review above — trusting you with the fix rather than gating on it. To be plain about what the stamp is not saying: findings 1 and 2 are a reproduced defect in the safety classifier, not style notes, and I'd want them addressed before this converges anything on a populated store. The convergence machinery itself held up under everything I threw at it.
This stamp was left by Claude Code (claude-opus-5).
|
🤖 Review from Morgan's AI agent. Not stamping this one — +660/-172 rewriting what a pod does to an existing storage database at startup is the "large core change" case in his standing bar, so it stays human-reviewed. Green CI, and the craftsmanship is high; two findings below that I think are worth resolving first, plus a cross-PR collision. The column path is better-guarded than I expected. 1. That same reasoning isn't applied to the index path. So the threat model that rejected a volatile column default because it takes an exclusive lock accepts an index build that takes the same lock, for a duration proportional to table size, with no size guard. Under the bootstrap advisory lock, with trailing pods waiting behind it and the whole thing bounded by 2. It composes badly with two PRs in flight right now. #1196 adds an index to That's an argument for sequencing rather than against the design: land this, then let those two indexes ride the convergence path knowingly, with someone having looked at real row counts first. 3. Cross-PR docs collision, concrete. This PR deletes the Nit, non-blocking. |
Generated and identity columns are refused because they rewrite the whole table under an exclusive lock, not because of a NOT NULL detail. Only NULL, UNIQUE, and FOREIGN KEY column constraints are allowlisted; anything else is sent to manual remediation. Drop the unreachable missing-index warning and describe the SHARE lock precisely.
|
🤖 Review response — created by Kiran's code review agent (Amp, Claude Opus 4.5) — pull/1220, follow-up commit The classifier's generated/identity arm is inverted to refuse (both shapes rewrite the table), unrecognized constraints now fail closed, the index-build lock is described precisely, and the cross-PR docs collision is resolved when
|
|
🤖 Automated review on Morgan's behalf — findings, holding the stamp for now (reason at the end). The shape of this is right, and I checked it against source rather than the description. The concurrency dance is correct: fast-path discovery unlocked, advisory lock only on drift, re-discovery under the lock, and the lock held on a separate session (
Three findings, the first two worth acting on. 1. The index presence check ignores SELECT index_class.relname, index_info.indisunique
FROM pg_index AS index_info ...
WHERE table_namespace.nspname = current_schema()
AND table_class.relname = $1Nothing filters What makes this more than theoretical is that this PR creates the incentive. Its own doc comment warns that plain One predicate closes it — 2. case pgproto.ConstrType_CONSTR_NULL, pgproto.ConstrType_CONSTR_UNIQUE, pgproto.ConstrType_CONSTR_FOREIGN:
// These constraints are safe on a nullable new column.Safe in the sense the comment means — an all-NULL new column can't violate uniqueness, so the statement won't be rejected. But every other branch here is judged on cost, not on acceptance: generated/identity and volatile defaults are refused because they "rewrite the whole table under an exclusive lock." The doc comment one level up leans on this too — "column changes share one transaction — each is metadata-only after the manual-remediation gate" — which holds for the other allowed shapes but not for
3. The unique/non-unique mismatch short-circuits, defeating the gate's stated purpose. if present {
return nil, fmt.Errorf("storage table %q has non-unique index %q where the embedded schema requires a unique index; replace it manually", table, index.name)
}Failing closed is right. Returning early from discovery is what I'd change: Nit, non-blocking: On the stamp: holding it, and the reason is CI rather than the findings above. This PR ran three checks — Nothing is blocked by this: the PR can't merge to |
…ditive-convergence * origin/main: (33 commits) feat(postgres): add ADD COLUMN synthesis to the statement parser seam (#1212) feat(cli): add storage canonicalize-identity-keys admin subcommand (#1231) fix(storage): canonicalize remaining identity keys (#1218) fix(storage): canonicalize apply and task identity keys (#1217) fix(webhook): canonicalize repository identity at ingress (#1213) docs: document the PostgreSQL support envelope (#1144) fix(engine): report why a Vitess schema change failed (#1242) feat(ddl): detect statements whose cost scales with table size (#1237) fix(operator): keep a multi-table apply running while tables are queued behind a cutover (#1241) fix(storage): index the webhook inbox claim ordering (#1196) fix(github): drop the cutover duration promise from progress surfaces (#1240) fix(github): render row-copy progress percentages at their true precision (#1239) fix(observability): do not report a shutdown as a claim failure (#1233) fix(github): tell an operator why a refused apply's database is busy (#1224) fix(engine): do not mark an apply failed when its driver shuts down (#1234) feat(github): render live row-copy progress on sharded table lines (#1191) feat(ui): add approximate row and byte formatters (#1236) fix(planetscale): delete the branch an apply created when it fails before its deploy request (#963) feat(api): app grouping field on database config (#1226) feat(cli): filter pulled tables with --table (#1235) ... # Conflicts: # docs/configuration.md # pkg/ddl/postgres_parser.go # pkg/ddl/postgres_parser_test.go
There was a problem hiding this comment.
🟡 Changes recommended
The new ADD COLUMN “safe” constraint classification appears inconsistent with existing in-repo cost/lock semantics and could allow unexpectedly expensive DDL at startup without an explicit policy decision.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR updates SchemaBot’s PostgreSQL storage bootstrapper to additively converge schema drift at startup (missing tables, columns, and standalone indexes) rather than failing startup or requiring manual DDL for those additive cases, closing a gap vs. the existing MySQL Spirit-based convergence.
Changes:
- Add PostgreSQL “ADD COLUMN safety” classification (
PostgresAddColumnManualReason) and a shared CREATE TABLE column extraction helper in the Postgres DDL parser. - Replace PostgreSQL startup schema verification-only behavior with drift discovery + advisory-lock-protected convergence + post-apply re-verification.
- Expand unit/integration tests and update operator docs/AGENTS.md to reflect additive convergence and manual-remediation bounds.
File summaries
| File | Description |
|---|---|
| pkg/ddl/postgres_parser.go | Refactors CREATE TABLE column extraction and adds manual-remediation classification for ADD COLUMN safety. |
| pkg/ddl/postgres_parser_test.go | Adds coverage for the new ADD COLUMN manual-remediation classifier and edge cases. |
| pkg/api/ensure_schema_postgres.go | Implements drift discovery, manual remediation gate, lock-protected additive convergence, and updated shape verification. |
| pkg/api/ensure_schema_postgres_test.go | Adds unit tests for drift expectation parsing and manual remediation aggregation behavior. |
| pkg/api/ensure_schema_postgres_integration_test.go | Updates/extends real-PostgreSQL integration tests to assert convergence of missing columns/indexes and fail-closed cases. |
| docs/configuration.md | Updates storage dialect documentation to describe additive PostgreSQL convergence and operational implications of index builds. |
| AGENTS.md | Updates repository guidance to reflect additive PostgreSQL convergence behavior and its fail-closed bounds. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| var notNull, hasDefault, constantDefault bool | ||
| for _, node := range column.GetConstraints() { | ||
| constraint := node.GetConstraint() | ||
| switch constraint.GetContype() { | ||
| case pgproto.ConstrType_CONSTR_NOTNULL: | ||
| notNull = true | ||
| case pgproto.ConstrType_CONSTR_DEFAULT: | ||
| hasDefault = true | ||
| constantDefault = postgresNonVolatileExpression(constraint.GetRawExpr()) | ||
| case pgproto.ConstrType_CONSTR_GENERATED, pgproto.ConstrType_CONSTR_IDENTITY: | ||
| return "definition is generated or identity, which rewrites the whole table under an exclusive lock; add it manually", nil | ||
| case pgproto.ConstrType_CONSTR_NULL, pgproto.ConstrType_CONSTR_UNIQUE, pgproto.ConstrType_CONSTR_FOREIGN: | ||
| // These constraints are safe on a nullable new column. | ||
| default: | ||
| return fmt.Sprintf("definition has constraint %s, which is not safe for automatic convergence; add it manually", constraint.GetContype().String()), nil | ||
| } |
| for _, column := range expected.columns { | ||
| if existingColumns[column] { | ||
| continue | ||
| } | ||
| statement, err := parser.SynthesizeAddColumn(expected.createTable, column) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("synthesize ADD COLUMN for %q.%q: %w", table, column, err) | ||
| } | ||
| manualReason, err := ddl.PostgresAddColumnManualReason(expected.createTable, column) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("classify ADD COLUMN safety for %q.%q: %w", table, column, err) | ||
| } |
Flip the PostgreSQL startup schema-drift tripwire into additive convergence: missing columns and standalone indexes are now created automatically under the bootstrap advisory lock instead of failing startup or warning for manual DDL.
Why
On an already-bootstrapped PostgreSQL store, a schema file gaining a column failed startup and a new non-unique index only warned — both required an operator to run DDL by hand before deploying. MySQL already converges via Spirit; this closes the gap for the additive cases PostgreSQL can apply safely, using the ADD COLUMN synthesis seam from the previous PR so the DDL always derives from the embedded desired CREATE TABLE.
What
postgresSchemaDriftFor): missing table → full schema file; missing column →ddl.SynthesizePostgresAddColumn; missing index → the schema file's own CREATE INDEX verbatim.Before / after