fix(planetscale): hold the cutover when the operator defers it - #978
Conversation
…e create The SDK's create-deploy-request struct tags auto_cutover and auto_delete_branch omitempty on plain bools, so the false SchemaBot sets is dropped from the body and "off" is indistinguishable from "unspecified". An unspecified deploy request falls to the database's own default, and where that default is on, PlanetScale swaps the schema itself — the outcome SchemaBot's cutover ownership exists to prevent, on an apply whose operator may have asked to hold it. No later call can undo it: the API exposes no way to change auto_cutover after creation. The client now marshals the create body itself so both settings transmit at their actual value, over the same raw-HTTP path the throttle endpoint already uses. Without an API base URL the setting cannot be expressed at all, so the deploy request is refused rather than created ungoverned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The apply's log surface carries lifecycle events, not the engine's own log lines, so a decision left in an engine logger cannot be read from the schema change it governs. Cutover ownership is settled when the deploy request is created and can never be changed afterwards, and it is the first fact an operator needs when a schema change swaps without them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Instant DDL rewrites metadata only: the deploy executes and the schema is swapped in a single step, with no pending_cutover in between. That makes it the right way to run an eligible change — but it also means a deferred cutover has no gate to hold. The change swapped as soon as the deploy ran, and the operator who asked to decide when the schema moved was told afterwards. An eligible change whose cutover was deferred is now deployed with a row copy instead, trading its speed for the gate that was asked for, and the timeline states the trade. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rred change A deploy request's auto-cutover setting is settled when the deploy request is created and no later call can change it, so the create request is the only thing standing between a deferred cutover and a schema that swaps seconds after the deploy goes ready. A create request that did not arrive as sent leaves no trace on any surface the operator reads: the deploy request looks ordinary right up to the moment it cuts itself over. The setting is carried on the deployment and modelled by the SDK on neither response, so psclient reads it back over raw HTTP. When the cutover was deferred, the engine confirms the backend is holding it before starting the deploy, and fails closed — including when the setting cannot be read. The deploy has not started at that point, so refusing costs a re-run, while proceeding costs the decision the operator kept for themselves. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes PlanetScale/Vitess deferred-cutover behavior so the operator’s --defer-cutover intent is reliably transmitted, verified, and preserved across resumes—preventing unintended automatic cutovers.
Changes:
- Reworks PlanetScale deploy-request creation to use raw JSON so
auto_cutover=false/auto_delete_branch=falseare actually sent (not dropped byomitempty). - Adds a read-back verification step for
auto_cutoverto fail closed if the backend would auto-cutover (or if the value can’t be confirmed). - Disables instant DDL when cutover is deferred, and persists
defer_cutoveracross resume; adds unit tests for the new behaviors.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/psclient/client.go | Sends deploy-request creation via raw HTTP JSON and adds a raw read path to confirm auto_cutover from the backend. |
| pkg/psclient/client_test.go | Adds unit tests covering serialization (including false values), auth header, error surfacing, and read-back parsing rules. |
| pkg/engine/planetscale/branch.go | Adds timeline event for deploy-request creation, introduces cutover-held verification, and gates instant DDL when cutover is deferred. |
| pkg/engine/planetscale/branch_test.go | Tests the new event, cutover-held verification behavior, and instant-DDL gating logic. |
| pkg/engine/planetscale/apply.go | Integrates new event, instant-DDL gating decision, read-back verification, and persists defer_cutover on resume. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
LocalScale stored the cutover setting a deploy request was created with but never returned it, so the deployment object it served described only instant DDL eligibility. SchemaBot reads the setting back before deploying a deferred change and refuses when it cannot be read, since the setting is settled at creation and no later call can change it. Against an emulator that does not report it, every deferred apply refuses. Serve auto_cutover from the deployment object the way the API does, on both the single and list responses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Review findings - created by Kiran's code review agent - for pull/978, 75c58f2. Verdict: 9 findings — 2 blocking (crash-resume bypasses the new cutover-hold gate; empty Blocking
Non-blocking
General suggestions
The one thing that could have broken, verifiedReplacing the SDK's Verified correct
This review was generated by Claude Code (claude-fable-5). |
…loy too A deploy request is created, then deployed. A driver that dies in between leaves a request the next drive inherits without knowing how it was created, and the setting that holds the cutover is fixed at creation and invisible afterwards. That drive deployed it unverified, so a request the backend still owns the cutover of would swap the schema on its own — the outcome deferring the cutover exists to prevent, reachable exactly when a request created without the hold is most likely to exist. The recovered request is now verified before it is deployed, the same way a fresh one is. The deferral itself is read from the operator's request as well as from stored metadata, since the metadata flag is written only after the deploy request exists and a crash inside that window recovers a deferred apply whose stored metadata does not say so yet.
Addressing the API directly to set the cutover made the base URL required, and a database whose configuration omits the optional api_url passed an empty one — so every apply on it was refused, deferred or not. api_url names a private or emulated endpoint; absent, the database is a real PlanetScale one. Fall back to the public endpoint the way the inventory-resolved path already does.
A non-2xx response from an endpoint called directly returned an untyped error carrying the whole response body, which travels up as the apply's failure message and is rendered into a PR comment. The API's own refusal is worth reading there; anything else in the body is text from whatever answered, and it can carry infrastructure detail or break the markdown it lands in. Give those calls a typed APIError that renders the endpoint, the status, and the API's message field when the body is the API's error shape, flattened and clamped for markdown. The whole response stays on the error and is logged at the point of failure. The throttle call, which hand-rolled the same raw HTTP, now goes through the shared helper and gets the same treatment.
…utover The event on the operator's timeline read as a confirmed fact, but a created deploy request echoes back no cutover setting, so what the backend recorded is not knowable at that point. An operator reading it as settled would take a deploy request that quietly kept auto-cutover for one that holds it. State the request and name the metadata for what it is. Reading the setting back is what turns it into a confirmed fact.
The deferred-deploy log recorded the decision under the eligibility key, so a change that was eligible but declined for a deferred cutover read as ineligible. Cleanup callbacks in the cutover-setting test also ran on the test context, which is already cancelled by the time cleanup runs.
…etime The cleanup callbacks ran on the test's context, which is already cancelled by the time cleanup runs, so the deploy request they cancel was left active for whatever ran next.
…ploy A single read decided the cutover gate, so a deploy request that answered late or came back partial failed the apply outright — PlanetScale engine errors are never retryable, so the operator had to re-run a schema change whose cutover was held all along. A gate that refuses at random is one operators learn to re-run past. Read again, bounded, when the read does not answer. Auto-cutover on is an answer and still stops the deploy on the first read. Reading nothing and reading a deployment that omits the setting are now separate errors: the first is a read with nothing in it yet, the second is the API answering in a shape this no longer matches, and a refused deploy should say which.
A deploy request reports both what it could have done and what it did, and the two differ exactly when an eligible change is deployed with a row copy. LocalScale recorded the deploy's instant flag but only ever reported eligibility, so nothing reading the fake could tell the two apart. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Progress recovered the instant DDL flag from the deploy request's instant eligibility, but an eligible change whose cutover the operator deferred is deployed with a row copy so it has a gate to park at — and it stays eligible the whole time. The comment and the CLI therefore reported a change as applied instantly while it was still copying and holding for the operator, which is the one thing the deferral exists to prevent them being told. Read what the deployment ran as instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r is held A recovered deploy request carries the instant decision the drive that created it recorded, and that drive's deferral is not one this drive can confirm — the same reason the cutover setting is read back before the recovered request is deployed. Instant DDL swaps the schema as the deploy runs, so taking a recorded instant decision under a held cutover leaves the operator a gate with the swap already behind it. The stored decision is only ever narrowed here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing ever set it, so the nil guard was the only branch that ran and the one failure log it fed always went to the default logger anyway. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Replying on Armand's behalf, from his agent. Thank you — this was a genuinely useful pass, and the crash-resume finding was the real one. All nine are addressed on the branch; CI is green at 1 + 3 — crash-resume bypassed the gate (blocking). Fixed in 49aa1de. 2 — an env config without 4 — the real-API contract. Settled live rather than in CI, and now written into the PR body: 5 — the resume claim had no test. 49aa1de adds a resume test that drives the recovered deploy both ways: cutover held and it deploys, auto-cutover on and it refuses with the deploy never started. The instant half you asked for is in 8afc90a, below. 6 — a transiently missing 7 — the created event asserted an unverified fact. Fixed in b1cdee6: the message and the metadata key ( 8 — 9 — hygiene. (a) ea74b03 renames the attr to Two more turned up while acting on this, both on the same invariant. Progress recovered the instant flag from This reply was posted by Claude Code (claude-opus-5) on Armand's behalf. |
…nucleus * origin/main: refactor(storage): render joined UPDATEs through the dialect (#1009) fix(planetscale): hold the cutover when the operator defers it (#978) fix(observability): make telemetry resource schema-tolerant (#1014) # Conflicts: # pkg/storage/internal/sqlstore/dialect.go # pkg/storage/internal/sqlstore/dialect_test.go # pkg/storage/internal/sqlstore/storage.go
…-joined-dml-13l * origin/main: refactor(storage): render joined UPDATEs through the dialect (#1009) fix(planetscale): hold the cutover when the operator defers it (#978) fix(observability): make telemetry resource schema-tolerant (#1014) feat(postgres): implement declarative planning via pg-sprite diffplan (#1008) refactor(storage): make remaining sqlstore SQL dialect-portable (#1007) test(e2e): deflake multi-table stop/start resume and MySQL cold starts (#1005) ci: verify golangci config against a vendored schema (#997) feat(api): fail-closed verdict gating for postgres plans (#1004) feat(tern): route postgres targets to the postgres engine (#1003) feat(storage): stamp remaining sqlstore timestamps explicitly (#1006) # Conflicts: # pkg/storage/internal/sqlstore/apply_comments.go # pkg/storage/internal/sqlstore/dialect.go # pkg/storage/internal/sqlstore/dialect_test.go # pkg/storage/internal/sqlstore/settings.go # pkg/storage/internal/sqlstore/storage.go # pkg/storage/internal/sqlstore/updated_at_lint_test.go
…re-public-13n * origin/main: refactor(storage): portable lease-guarded joined DML for the operation store (#1011) fix(github): align lint warnings formatting with issues and fold long lists (#959) fix(github): lead with the database's operators on command-rejection comments (#960) docs: regenerate stale tables of contents (#968) fix(engine): heartbeat the row a local drive actually owns (#915) fix(github): scope auto-plan to the schema a pull request proposes (#1016) fix(vitess): dispatch task-less VSchema-only work operations over gRPC (#961) feat(storage): add PostgreSQL dialect nucleus to the shared store core (#1010) refactor(storage): render joined UPDATEs through the dialect (#1009) fix(planetscale): hold the cutover when the operator defers it (#978) fix(observability): make telemetry resource schema-tolerant (#1014) feat(postgres): implement declarative planning via pg-sprite diffplan (#1008) refactor(storage): make remaining sqlstore SQL dialect-portable (#1007) test(e2e): deflake multi-table stop/start resume and MySQL cold starts (#1005) ci: verify golangci config against a vendored schema (#997) feat(api): fail-closed verdict gating for postgres plans (#1004) feat(tern): route postgres targets to the postgres engine (#1003) feat(storage): stamp remaining sqlstore timestamps explicitly (#1006)
…lect-factory-14b * origin/main: feat(github): flag destructive changes to tables another open PR owns (#1017) feat(storage): add public postgresstore constructor (#1012) refactor(storage): portable lease-guarded joined DML for the operation store (#1011) fix(github): align lint warnings formatting with issues and fold long lists (#959) fix(github): lead with the database's operators on command-rejection comments (#960) docs: regenerate stale tables of contents (#968) fix(engine): heartbeat the row a local drive actually owns (#915) fix(github): scope auto-plan to the schema a pull request proposes (#1016) fix(vitess): dispatch task-less VSchema-only work operations over gRPC (#961) feat(storage): add PostgreSQL dialect nucleus to the shared store core (#1010) refactor(storage): render joined UPDATEs through the dialect (#1009) fix(planetscale): hold the cutover when the operator defers it (#978) fix(observability): make telemetry resource schema-tolerant (#1014) feat(postgres): implement declarative planning via pg-sprite diffplan (#1008) refactor(storage): make remaining sqlstore SQL dialect-portable (#1007) test(e2e): deflake multi-table stop/start resume and MySQL cold starts (#1005) ci: verify golangci config against a vendored schema (#997) feat(api): fail-closed verdict gating for postgres plans (#1004) feat(tern): route postgres targets to the postgres engine (#1003) feat(storage): stamp remaining sqlstore timestamps explicitly (#1006)
Why this matters
--defer-cutoveris a decision the operator keeps for themselves: run the schema change now, swap it later, under supervision. On PlanetScale/Vitess that decision was not reaching the backend. Deploy requests were created with auto-cutover on, and the schema swapped seconds after the deploy went ready — the operator found out afterwards.Nothing on any surface showed it. The deploy request looked ordinary right up to the moment it cut itself over.
What it does
Sends the setting.
auto_cutoveris abooltaggedjson:"auto_cutover,omitempty"on the API client's create request, sofalsemarshals away and "cutover deferred" is byte-identical to "cutover unspecified" — leaving the backend to apply its default. Deploy request creation now builds the JSON body directly rather than through that struct, sofalsetransmits.auto_delete_branchshares the path and carried the same hazard.Confirms it took.
auto_cutoveris settled when the deploy request is created, and no later call can change it — so asking is not the same as knowing. Before deploying a deferred change, the engine reads the setting back off the created deploy request and refuses if the backend holds auto-cutover on, or if the setting cannot be read at all. The deploy has not started at that point, so refusing costs a re-run while proceeding costs the gate. A read that does not answer is read again before it is treated as a refusal, so a moment's lag does not throw away a branch, a DDL apply and a validation run.Verifies a recovered deploy request too. A driver can crash between creating a deploy request and starting it, and the drive that recovers it cannot know how the request it inherited was created. So the same read-back runs before a recovered request is deployed, and a recorded instant decision is declined there when the cutover is held — the stored decision is only ever narrowed. Resume now reads both
defer_cutoveranddefer_deployfrom the request, so a restart no longer loses either choice.Declines instant DDL while a cutover is deferred. Instant DDL executes the deploy and swaps the schema in one step, leaving no pending cutover to park at — so an eligible change would pass straight through the gate even with the setting correct. The row-copy path parks, trading the speed of an eligible change for the gate that was asked for. The timeline states this, since otherwise an eligible change quietly takes the slow path.
Says so honestly on the way through. An eligible change stays eligible while it is being copied, so progress read the instant flag off eligibility and reported a deferred change as applied instantly while it was still copying rows and holding for the operator — the one thing the deferral exists to prevent them being told. Progress now reports what the deployment ran as.
Around the edges
api_urlfalls back to the public endpoint the SDK used before — the same fallback the inventory path already applies, so a working configuration stays working.The timeline also names the cutover ownership a deploy request was created with, phrased as the request rather than the outcome, since that is all that is knowable before the read-back.
Verified against the real API
The read-back was exercised against the real PlanetScale API, not only the emulator:
auto_cutoveris returned nested under the deployment object, which is where this decodes it. A deferred change deployed with this fix read backfalse— against a baseline oftrueon every prior deploy request — then held at the gate for a minute and a half instead of cutting itself over 4.5 seconds after going ready, and swapped only on the operator's command.This PR was written by Armand's AI agent (Claude Opus 5).