add migrate.RunDesired, library-level desired-state execution - #53
add migrate.RunDesired, library-level desired-state execution#53Kiran01bm wants to merge 3 commits into
Conversation
PostgreSQL turns one blocking ALTER into a multi-statement online sequence inside the engine, so the convergence loop — per-statement gating on fresh live facts, ordered execution, committed-prefix reporting — belongs in pkg/migrate where every embedder shares it, not re-implemented around the binary. The plan is admitted as a whole (existence, destructive guard, dispositions, optional fingerprint pin) before anything runs; execution then drives each planned statement back through Run. Library-only: the migrate --desired CLI flag follows.
SAFETY.md now names the asymmetry: the desired-state destructive guard is the one admission check the core cannot backstop, so destructiveOp and the admission gate carry the core's review bar. DROP NOT NULL joins the destructive set (DROP DEFAULT deliberately does not); a reparse failure of engine-generated SQL is ErrInvariantViolation; a zero-verdict stop is no longer worded as a failed statement; index drops are pointed at DROP INDEX CONCURRENTLY instead of a door that refuses them. The refusal reason tokens are pinned by verdict.Reasons() plus an exact-token test and a doc-coverage test over the new refusal-reason table.
…-execution * origin/main: schemadiff: describability refusals — partitions, FKs, unlogged, collations, sequence ownership (#55) docs: position pg-sprite by problem class — online executor, peers not competitors (#54) schemadiff: render the canonical model back to a desired schema file (#52) # Conflicts: # docs/limitations.md
|
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 his agent. Reviewed at head Verdict: the loop's structure is right and the committed-prefix semantics are exactly as documented — a mid-plan refusal leaves the prefix committed, the verdict list is short by the statements never attempted, and nothing after the stop touches the database. Two things to fix. The pinned path is not idempotent: a successful pinned run makes the identical retry refuse with Findings1. A successful pinned run makes the identical retry refuse, and the refusal says the schema changed. Nothing changed between runs 1 and 2 except pg-sprite's own success, and the detail's two factual claims — the live table changed, the desired schema changed — are both false. Retry-after-lost-ack and webhook redelivery are the normal case for the automated caller this pin exists to serve, so the feature that makes the reviewed path safe is the one that turns its idempotent no-op into a drift alarm, and the message's own instruction ("re-review the new plan") sends an operator to re-review a table that is already correct. The unpinned path gets this exactly right, which is what makes the asymmetry worth fixing rather than documenting. The test suite proves both halves and never composes them: "converges the live table and re-runs as a no-op" re-runs but sets no pin, and "executes under a matching pinned fingerprint" pins but runs once. The fix is ordering — resolve already-converged before the pin, since a plan with no statements trivially satisfies "what executes is what was reviewed" (nothing executes). Worth doing that rather than special-casing the empty digest, because the empty digest is universal: I pinned table 2.
Both narrowing edits are refused today, but only because they route to copy-and-swap: "routes to an execution strategy this build does not implement." That is a router availability fact, not a safety decision, and it is scheduled to stop being true. When copy-and-swap lands, these plans become executable and the destructive gate — the one admission check SAFETY.md says has no core backstop and whose failure mode is data loss — will admit them, because a narrowing 3. The whole-plan destructive refusal blocks the additive statements beside it, and the remedy for an index redefinition leaves the tool. The unrelated Related, and currently masked: that planned 4. (nit) The committed-prefix detail claims a prefix committed when nothing ran. That is reassuring noise on exactly the case where the database is untouched, and it appears on the single-statement plan too. Action items
Verified (tried to break, couldn't)The committed-prefix contract is real, not aspirational: I forced execution-time stops at several positions and each time the preceding statements were committed and present, This review was generated by Claude Code (claude-opus-5). |
|
🤖 Second pass, same head ( Lens 1 — OSS adoptionThis is the PR that makes pg-sprite a declarative schema tool rather than a safer- The gap an adopter hits first is the one this PR should document while it is being written: how much of an ordinary desired-file edit the declarative door actually converges. Right now the answer is scattered —
That last row is the one I'd most want stated plainly, because it is the least discoverable and the most surprising. With default options, on a 5952 MB table, adding a nullable column is refused: "pg-sprite cannot yet prove this change is instant on a table this size." The policy is pre-existing and defensible — and the tests here are explicit that The second adoption note is smaller but compounding: three of those refusals are whole-plan. A desired file is a file — people edit several things in it at once, because that is what a declarative artifact invites. So the common shape isn't "one destructive statement," it's "one destructive statement sitting next to three harmless ones," and today the harmless ones don't run and aren't mentioned. The eventual answer is probably a partial-convergence mode or an explicit acknowledgement, but the cheap thing now is for the refusal to say what else it skipped, so the operator knows the size of what's blocked before they go read the plan. Lens 2 — the seam an orchestrator consumesThe PR's stated purpose is to stop an embedding orchestrator from iterating the plan itself, and for non-destructive plans it fully achieves that — but a destructive plan pushes the orchestrator right back into the loop it was meant to retire. The reasoning in the summary is exactly right: statement ordering, per-statement re-gating on fresh facts, budget-bounded execution, and committed-prefix reporting are engine concerns, and anything reimplementing them around the binary will get them subtly wrong. The problem is that a desired-state workflow generates destructive plans as a matter of course — that is what happens when the file is the source of truth and someone deletes a line — and Retry idempotency is the single most important property for a webhook-driven consumer, and it is the one the pin currently breaks (finding 1 above). Worth restating here in seam terms rather than as a bug: an orchestrator's execution path is at-least-once — deliveries get replayed, acks get lost, pods get rescheduled mid-call. So the question it asks of any engine entry point is "if I call this twice, is the second call safe and does it tell me the truth?" Two smaller seam notes. The no-op is only distinguishable from real work by One thing worth keeping exactly as it is: This review was generated by Claude Code (claude-opus-5). |
aparajon
left a comment
There was a problem hiding this comment.
🤖 Approving. The loop's structure is right and the committed-prefix semantics hold up under real mid-plan stops — prefix committed, verdict list short by the statements never attempted, nothing after the stop touching the database. The fingerprint also covers ExecSQL, so the expanded safer sequence is genuinely pinned, and a stale pin still gets destructive-gated on today's classifier rather than smuggling anything past it.
Two to land, per the comments above. The pinned path isn't idempotent: a successful pinned run makes the identical retry refuse with plan-fingerprint-mismatch claiming the schema changed, because an already-converged plan's fingerprint is the empty-string digest and the pin is checked before the converged short-circuit. Both halves are tested, the composition isn't. And destructiveOp is keyed on drop shapes, so a narrowing ALTER COLUMN TYPE is destructive=false — safe today only because copy-and-swap is unimplemented, which is the coupling SAFETY.md's new paragraph asks reviewers to catch.
Neither is unsafe execution (both fail closed), so no objection to merging on your judgment of ordering. The SAFETY.md paragraph naming the destructive guard as the one check without a core backstop is the best thing in the PR — it told me where to attack.
This review was generated by Claude Code (claude-opus-5).
Summary
Add
migrate.RunDesired, library-level desired-state execution: derive the convergence plan for one table's desired schema, admit the plan as a whole, then run every planned statement back through the existingmigrate.Runpipeline — per-statement verdicts, committed-prefix semantics.Why
On PostgreSQL a single blocking
ALTERcan become a multi-statement online sequence inside the engine, so the convergence loop — statement ordering, per-statement re-gating on fresh live facts, budget-bounded execution, committed-prefix reporting — is something only the engine can do correctly; anything executing more than one statement around the binary would have to re-implement it. Putting the loop inpkg/migrategives direct CLI users declarative apply with no orchestrator (once themigrate --desiredflag lands on top of this), and gives an embedding orchestrator's PostgreSQL adapter one tested loop to consume instead of iterating the plan itself — plan-vs-execute drift, per-statement safety re-checks, and partial-failure reporting all stay behind the engine seam.What
migrate.RunDesired(ctx, pool, DesiredRequest, Options): plan viadiffplan.Plan, all-or-nothing plan admission (table existence, destructive guard, routed dispositions, optionalExpectedFingerprintpin for reviewed plans), then sequential execution throughRunwith fresh introspection/classification per statement, stopping at the first refusal or failure. Result carries the plan, one verdict per attempted statement, aggregate outcome, and committed-prefix detail. The result-and-error contract mirrorsRun's three shapes.Options.Forceis rejected: the declarative front door never runs a submitted form blind; destructive or force-worthy changes stay on the imperative front door.destructive-changeandplan-fingerprint-mismatch.Library-only: the
migrate --desiredCLI flag and its rendering/exit codes follow in the next PR.Before / after