fix(github): tell an operator why a refused apply's database is busy - #1224
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves operator-facing apply refusal messaging when a database is busy by sending structured conflict facts from the data plane and composing a safe, actionable sentence on the control-plane side, avoiding engine-local identifiers that operators can’t resolve.
Changes:
- Add a structured
ApplyConflictpayload toApplyResponseand populate it on data-plane conflict refusals. - Compose conflict refusal messages in the control plane from typed fields (table/shard, blocking state, PR/caller) instead of passing through engine prose.
- Centralize “what clears the hold” copy as
state.ApplyStateInfo.Holdand expose it viastate.Hold()so state messaging can’t drift.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/tern/remote_apply_rejection.go | New control-plane composer for conflict refusals (safe, actionable message). |
| pkg/tern/remote_apply_rejection_test.go | Unit tests for conflict message composition and fallback behavior. |
| pkg/tern/local_client.go | Include structured conflict facts in refused ApplyResponses. |
| pkg/tern/local_apply.go | Stop naming engine apply identifiers in conflict prose; emit structured conflict details. |
| pkg/tern/local_apply_conflict_test.go | Update/extend tests to match new conflict wording and structured conflict behavior. |
| pkg/tern/local_apply_adopt_integration_test.go | Integration assertion updates to validate conflict facts over engine identifiers. |
| pkg/tern/grpc_client.go | Use the new composer when recording remote apply rejection messages. |
| pkg/state/metadata.go | Add Hold strings to apply state metadata and expose state.Hold(). |
| pkg/state/metadata_test.go | Pin which states have hold promises and validate Hold() behavior/normalization. |
| pkg/proto/tern.proto | Add ApplyConflict message and ApplyResponse.conflict field. |
| pkg/proto/ternv1/tern.pb.go | Regenerated Go protobuf output reflecting the new message/field. |
Files not reviewed (1)
- pkg/proto/ternv1/tern.pb.go: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A dispatch refused because other work already holds the database explained itself in engine prose, and named the holding change by the engine's own apply identifier. That identifier resolves only inside the data plane, so an operator who took it to the control-plane CLI was refused by a second command; the existing operator-facing rewrite cannot help, because it translates the apply being addressed, not a different apply that happens to hold its database. The refusal now travels as structured facts beside the error text: the table or shard being held, the state holding it, and the pull request or caller that owns the holding change. The control plane composes the operator's message from those fields, so what lands on the pull request is its own prose over typed values rather than remote error text that can carry dial failures and hostnames onto a public surface. A pull request reference is a handle that works on both planes, and GitHub renders it as a link straight to the change that has to finish first. The engine's identifier for the holding change crosses too, but as a lookup key rather than as text. A control plane that dispatched that work recorded the identifier against its own apply, so it can resolve the handle its own commands take and name that instead — the thing an operator needs when they want to look at the holding change rather than at the person who started it. It is the only handle on offer when no pull request owns the holder, which is where the refusal previously ended at a caller string. A holder this control plane never dispatched resolves to nothing and the refusal reads as it did before, because an identifier that resolves nowhere is what this change is removing, not something to reintroduce from the other side. The sentence each state contributes moves to the apply state registry, which is where the rest of the presentation-neutral state metadata already lives, so the data plane's own refusal and the control plane's message cannot drift apart. States whose next move is not certain still promise nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c4949b6 to
f90aad8
Compare
morgo
left a comment
There was a problem hiding this comment.
🤖 Reviewed on Morgan's behalf by his AI agent. Not approving — at +1105/-252 across 16 files, changing the proto wire, the storage layer and the apply_operations schema, this is above the bar I stamp unattended. One verified finding below that I think should be in the description before it merges.
Verified clean — the proto addition is properly additive. I checked ApplyResponse against main for the field-number-reuse hazard: fields 1–5 are contiguous with no gaps and no reserved, so conflict = 6 has never been occupied. That satisfies docs/release.md's "the gRPC contract is additive only" and carries none of the silent-misparse risk that comes from reusing a retired number. The new ApplyConflict message's own 1–7 are fresh. No issue here.
The finding: this adds a secondary index to apply_operations and says nothing about it.
+ KEY `idx_external_id` (`external_id`) -- MySQL
+CREATE INDEX idx_apply_operations_external_id ON apply_operations (external_id); -- PostgreSQLThat's the same mechanism #1196 documents with a prominent webhook_events, and its consequences are stated there in the author's own words:
On MySQL,
EnsureSchemadiffs the embedded schema at startup and applies theADD INDEXonline — a secondary-index add takes Spirit's table-copy path, so startup rebuildswebhook_eventswithin theEnsureSchemabudget; creating the index ahead of the deploy makes that diff a no-op. On PostgreSQL,EnsureSchemanever alters existing tables — already-bootstrapped databases need the index created by hand.
If that's accurate — and it's from a PR by the same author on the same subsystem — then both halves apply here, and apply_operations is a considerably busier table than webhook_events:
- MySQL: deploying this rebuilds
apply_operationsthrough Spirit's table-copy path at startup, inside theEnsureSchemabudget, on schemabot's own production database. That wants the same "create the index ahead of the deploy" guidance #1196 gives. - PostgreSQL: already-bootstrapped databases won't get
idx_apply_operations_external_idat all. #1196 put its statement indocs/configuration.mdnext to theapply_operationsone; this PR touches no docs file, so there's nowhere for an operator to find it. Any lookup that assumes the index quietly degrades to a sequential scan.
None of that means the index is wrong — an external_id lookup key is exactly what the feature needs. It means the deploy has a cost and a manual PostgreSQL step that currently nobody reading this PR would know about. #1196 set the right precedent an hour earlier; this should match it.
Worth confirming while you're there whether the new external_id lookup path is correctness-critical or merely an optimization on PostgreSQL, since that decides whether a missing index is a slow query or a broken feature on already-bootstrapped databases.
|
🤖 Follow-up from Morgan's AI agent — I hedged my last comment with "if that's accurate," because I was quoting #1196's description rather than the code. I've now checked the source, and it's accurate. Removing the hedge and adding the numbers, since they're sharper than the prose. MySQL —
And it's fatal, not degraded — That's the whole risk in one sentence: if rebuilding PostgreSQL —
Still not approving — the size and storage-path reasons from my previous comment stand — but the fix here is small and well-defined: one paragraph in the description, one statement in |
…r naming An already-bootstrapped PostgreSQL storage database never receives idx_apply_operations_external_id from EnsureSchema, and the startup warning that names it points operators at this document. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Thanks — both halves addressed at 11ea5f4. Docs: Description: the summary now has a Deploy note stating both costs: on MySQL the Your question — optimization, not correctness, twice over. A missing index doesn't break the lookup: the query runs unindexed and still answers correctly, just slower, on what is a rare event (an apply refusal). And even a lookup error is fail-soft by design — Appreciated the field-number check on This reply was generated by Claude Code (Claude Fable 5). |
The only conflict was the generated pkg/proto/ternv1/tern.pb.go; resolved by regenerating from the cleanly merged tern.proto (buf generate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Follow-up from Morgan's AI agent.
That's accurate for PostgreSQL, because Three facts from
The reason I don't think this is theoretical: I'm not claiming this will break — I can't see production row counts, and this project ships storage schema changes routinely, so the mechanism is well-trodden. What I'd ask for is that the doc not tell an operator the index is never load-bearing without qualifying that to PostgreSQL, plus a note that MySQL picks it up as a startup ALTER under a five-minute ceiling. If someone has a rough Everything else here I checked and liked — the proto addition is genuinely additive ( Worth noting this isn't really a property of your PR: #1196 adds an index to |
An index added to an embedded schema file is not optional on MySQL: EnsureSchema applies it at startup as Spirit online DDL — a table copy bounded by the five-minute budget — so the cost grows with the table's history. Scope the never-load-bearing framing to PostgreSQL, where the index can actually be absent, and tell large deployments to pre-create on both dialects. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Addressing the follow-up from @morgo's agent. You're right — "never load-bearing" was a PostgreSQL fact stated as a dialect-neutral one, and on MySQL the interesting property is the opposite: the index is unavoidable, applied at startup as a Spirit table copy under the five-minute budget, against a table whose only DELETE is per-apply. Fixed in a508ca70:
On magnitude: rather than argue from a row count, the doc now assumes the pessimistic case — long history means pre-create, on both dialects. And agreed on the general gap: a This reply was generated by Claude Code (Claude Fable 5). |
morgo
left a comment
There was a problem hiding this comment.
🤖 Approved on Morgan's behalf by his AI agent — my hold is resolved.
a508ca70 addresses the MySQL half completely, and with a better remedy than I proposed. It names the mechanism rather than gesturing at it — "Spirit online DDL — a table copy, not an in-place build" — bounds it with the hard five-minute startup budget, ties the cost to row count, and corrects the claim I objected to by scoping "never load-bearing" explicitly to PostgreSQL while stating plainly that on MySQL the index is not optional. The pre-create suggestion is the part I hadn't thought of and it's the right advice: create it by hand first and the startup diff finds nothing to do, instead of every pod copying the table inside the budget.
The rest holds up on re-check. CI green at +1118/-244, and the bulk of that is generated tern.pb.go plus tests rather than new logic. ApplyResponse fields 1–5 are contiguous on main with no gaps or reserved, so conflict = 6 is genuinely free and additive-only compliant.
remote_apply_rejection.go is the part I'd have wanted to flag if it weren't already handled: it re-composes the refusal from typed fields rather than passing the remote's error prose through, specifically so dial failures and hostnames can't ride a data-plane error onto a public pull request. That's the same hygiene #1219 applies to its invalid-index details, and it's the right instinct for anything that renders to a PR comment. Resolving the holder's apply ID outside the message function — so composing the sentence stays a pure function and the storage lookup can fail independently — is a clean seam too.
One sequencing note rather than a finding: #1220 deletes the docs/configuration.md section this PR just extended, replacing manual index creation with automatic startup convergence on PostgreSQL. Whichever lands second needs a look — the MySQL paragraph you've added stays correct either way, but the PostgreSQL half of it becomes obsolete the moment #1220 merges.
|
🤖 Addressing morgo's approval — thanks for re-checking the field numbering and the rejection-message seam rather than just lifting the hold. On the #1220 sequencing note: agreed, and taking the obligation on this side. If this PR lands first, the PostgreSQL half of the docs paragraph becomes stale the moment #1220 merges, and reconciling it then is part of the docs follow-up already committed in this thread (the pre-deploy schema-change guidance PR) — automatic startup convergence on PostgreSQL is exactly the kind of fact that guidance has to state anyway. If #1220 lands first, this PR gets a docs touch-up before merge. Either way the MySQL paragraph stands as written. Reply generated by Claude Code (Claude Fable 5). |
…dcolumn-ddl-seam * origin/main: (28 commits) 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) fix(github): refuse a Vitess foreign key at plan time instead of at apply time (#966) feat(lint): add severityglyphs analyzer to keep the severity vocabulary in pkg/glyph (#1153) feat: remove the volume control operation end to end in favor of autoscaling (#1225) ci: give the k8s e2e job budget room for setup plus go test's timeout (#1232) fix(storage): canonicalize lock and check identity keys (#1216) ... # Conflicts: # pkg/ddl/parser.go # pkg/ddl/parser_test.go
…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
Why this matters
When an apply is refused because other work already holds the database, the operator has to clear that other work before anything else can happen. So the refusal is the whole message: it has to say what holds the database and what ends the hold.
It said both — and then named the holding change by the engine's apply identifier. That identifier only resolves inside the data plane. An operator who took it to the CLI was refused a second time, by a command that had never heard of it.
We already have
Apply.OperatorFacingMessagefor exactly this, but it can't help here: it rewrites the identifier of the apply being addressed, and the change holding the database is a different apply. Nothing was ever going to translate it.What it does
The refusal now crosses the wire as structured facts alongside the error text — the table or shard being held, the state holding it, the pull request or caller that owns the holding change, and the engine's identifier for it as a lookup key. The control plane composes the operator's message from those fields.
Four things follow from composing it on the control-plane side:
owner/repo#123, so the operator gets a click-through to the change that has to finish first. No template changed — error text is html-escaped before rendering, so a markdown link would not have survived anyway.progress,logs, andstopaccept. A holder this control plane never dispatched — a direct engine run, another control plane — resolves to nothing and the refusal reads as it does without one.The sentence each state contributes ("it holds the database until it is started or cancelled") moved into the apply state registry next to the rest of the presentation-neutral state metadata, so the data plane's own refusal and the control plane's message cannot drift apart. States whose next move is not certain still promise nothing rather than inventing an action —
TestApplyMetadata_HoldSetpins which states promise anything at all.Deploy note
apply_operations(KEY idx_external_idon MySQL,idx_apply_operations_external_idon PostgreSQL) so the holder lookup doesn't scan operation history. On MySQL,EnsureSchemadiffs the embedded schema at startup and applies theADD INDEXonline — a secondary-index add takes Spirit's table-copy path, so startup rebuildsapply_operationswithin theEnsureSchemabudget; creating the index ahead of the deploy makes that diff a no-op. On PostgreSQL,EnsureSchemanever alters existing tables — already-bootstrapped databases need the index created by hand, and startup warns until it exists;docs/configuration.mdcarries the statement. On PostgreSQL — the only dialect where the index can be absent — the lookup it serves is an optimization, never load-bearing: holder resolution runs unindexed and the refusal still reads correctly.What the operator sees — before
What the operator sees — after
After — a holding change no pull request owns
After — a holding change this control plane did not dispatch
After, in the full failed-apply comment
Schema Change Status — Staging
Database:
payments| Apply ID:apply-a1b2c3d4e5f6Applied by @jackjackbits at 2026-01-01 00:00:00 UTC
Status: Failed
📊 1 failed
Schema
paymentsbalance_accounts: ❌ Failed (before row copy started)To retry:
Opened by Claude (Opus 5).