fix(operator): hand an apply back cleanly when a process shuts down - #1019
Conversation
A cancelled drive context and an operator stop both end the sequential drive early, but they mean opposite things. An operator stop parks the apply in the stopped state until someone starts it again; a cancelled drive is this process handing an apply that is still live back for another driver to claim and resume. Both arrived as the same outcome, so a drive that lost its context was on course to be recorded as an operator stop. Give the cancellation its own outcome, so the drive exits leaving the apply active and logs which of the two happened.
An engine that runs its schema change inside this process holds resources on the target for as long as that work lives, and the work outlives the drive that started it. Shutdown cancelled the drives and exited, so the process stopped renewing the applies' leases while the targets stayed held. Peer drivers then reclaimed applies they could not execute, and each refusal burned a recovery attempt. Stopping the operator is now phased: stop claiming, end the in-flight drives, then halt the engines and wait for them to release their targets. An engine whose work runs elsewhere declares no halt capability, so its schema change is left alone and the lease handover stays the only thing that happens. A halt that does not complete inside its bound is logged with the endpoint and counted, so a shutdown that leaves a target held is visible before the refusals it causes are.
A departing process left its claimed applies leased. Nothing renewed those leases, so the work sat idle for the full staleness window before any peer driver could pick it up — a restart deferred every in-flight schema change by a minute. Shutdown now hands the claims back once the engines are down and their targets released, so the applies are claimable on the next poll. A driver registers the claim it is driving under and deregisters it on the way out, guarded on the lease token so it never hands back a claim a peer has since rotated onto itself. An apply that settled while shutdown was in progress is left alone: it has nothing to hand over, and backdating its heartbeat would misreport when it finished.
There was a problem hiding this comment.
Pull request overview
This PR improves SchemaBot’s operator shutdown behavior so in-process schema-change engines (notably Spirit) are halted before the process exits, and so active applies are handed back immediately instead of waiting for lease staleness—preventing lock refusals that burn recovery attempts and reducing downtime after restarts.
Changes:
- Distinguish drive-context cancellation (shutdown handover) from an operator stop so shutdown doesn’t park applies in
stopped. - Add shutdown halting for in-process engines (router + local client + Spirit implementation) and record halt failures via a new metric.
- Add storage support to explicitly release apply claims (backdate heartbeat + clear lease fields) and wire shutdown to release held claims after engines halt.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| pkg/tern/target_router.go | Adds HaltForShutdown to halt cached routed clients that support shutdown halting. |
| pkg/tern/local_control_resume.go | Treats taskHandover as an early-return outcome during resume paths. |
| pkg/tern/local_client.go | Implements client-level HaltForShutdown by delegating to engine shutdown-halting capability. |
| pkg/tern/local_apply_sequential.go | Introduces taskHandover and updates sequential execution/polling to hand over on drive cancellation. |
| pkg/tern/local_apply_handover_test.go | Adds tests ensuring drive cancellation is not recorded as an operator stop and leaves applies active. |
| pkg/tern/local_apply_grouped.go | Logs and returns on drive cancellation while polling grouped/atomic applies (handover semantics). |
| pkg/tern/client.go | Defines optional tern.ShutdownHalter capability for clients with in-process engines. |
| pkg/storage/storage.go | Extends ApplyStore with ReleaseClaim for explicit lease handback. |
| pkg/storage/internal/sqlstore/applies.go | Implements ApplyStore.ReleaseClaim guarded on lease token with stale backdate. |
| pkg/storage/internal/sqlstore/applies_test.go | Adds tests for reclaimability, token mismatch no-op, and invalid-lease refusal. |
| pkg/metrics/metrics.go | Adds schemabot.operator.shutdown_halt_failures counter and recorder. |
| pkg/engine/spirit/spirit.go | Implements engine.ShutdownHalter to checkpoint, cancel, and wait for Spirit copy goroutine to exit. |
| pkg/engine/spirit/shutdown_halt_test.go | Adds unit tests for Spirit shutdown halting behavior and bounded failure on deadline. |
| pkg/engine/engine.go | Adds engine.ShutdownHalter interface and HaltEngineForShutdown helper. |
| pkg/engine/engine_test.go | Tests HaltEngineForShutdown behavior for engines with/without shutdown-halting capability. |
| pkg/api/service.go | Adds service tracking for held apply leases to support explicit shutdown handback. |
| pkg/api/operator.go | Implements phased shutdown: stop claiming, cancel drives, halt engines, then release held claims; tracks leases per drive. |
| pkg/api/operator_shutdown_halt_test.go | Adds tests for shutdown halt ordering, failure fanout, skipping remote clients, and claim handback semantics. |
| pkg/api/handlers_test.go | Extends capturingApplyStore test double to support Get/ReleaseClaim for shutdown tests. |
Suppressed comments (2)
pkg/tern/local_apply_sequential.go:138
- checkTaskReady can mis-handle a drive cancellation race: if ctx is canceled after the initial ctx.Err() check but before Tasks().Get returns, the context-canceled error currently maps to taskSkip. In the single-task case this can fall through to finalizeSequentialApply and incorrectly mark the apply completed even though no task ran. Treat storage.Get errors caused by ctx cancellation as taskHandover instead.
if err != nil {
logger.Error("failed to fetch task state",
"task_id", task.TaskIdentifier, "table", task.TableName, "state", task.State, "error", err)
return taskSkip
}
pkg/api/operator.go:208
- This debug log attribute includes the internal numeric apply row ID (apply_row_lookup_id), which the repo conventions discourage logging (AGENTS.md:219). Since apply is nil here, log the held lease token/owner instead.
if apply == nil {
s.logger.Debug("operator: a claimed apply no longer exists; nothing to hand back",
"apply_row_lookup_id", lease.ApplyID, "lease_owner", lease.Owner)
continue
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
A tracked claim now carries the apply's triage identity alongside its lease, captured while the apply is in hand. The reload during shutdown is exactly the step that can fail, so the failure logs name the apply, database and environment instead of a row number.
|
🤖 Review findings - created by Kiran's code review agent - for pull/1019, 188aea5. Verdict: 8 findings — 2 blocking (the operation lease is never handed back, and the ordering invariant is untested), 5 non-blocking, 1 suggestion. CI is fully green: 34/34 checks pass at head 188aea5 (state OPEN) — build, lint x5, unit, integration, all E2E matrices (MySQL, Vitess x3, K8s x3, gRPC x3, LocalScale x3), Semgrep, zizmor, DCO all green at verification time. Blocking
Non-blocking
General suggestions
The one thing that could have broken, verifiedThe riskiest mechanism is shutdown releasing an apply's claim while something still holds the work — an invitation for a peer to double-drive. Tracing Verified correct
This review was generated by Claude Code (claude-fable-5). |
A driver reaches work through FindNextApplyOperation, so shutdown releasing only the parent apply lease left the schema change idle for the whole staleness window anyway. Register the operation claim once, where every drive path passes through it, and release the apply claims before the operations so a peer that grabs an operation finds its parent claimable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three ways the handback could hand a peer driver work it cannot run: - A claim whose engine did not come down was released anyway, putting a driver onto a target this process still holds the lock on — the refusal loop the handback exists to avoid. The halt now reports which deployments came down, and the rest are left to go stale. - An operation-lease drive reads its parent apply without claiming it, so the row can carry a peer's lease. Only claims this process wrote are registered. - A claim taken after the stop signal deregistered on the way out and was in neither the map nor the snapshot. Shutdown now drains after the drives have quiesced. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
🤖 Thanks — findings 1 and 2 were both right, and 1 was the load-bearing one: releasing only the apply lease left the mainline drive's work unclaimable for the full staleness window, since the operation claim is what the next poll looks at first. Both leases are now handed back, registered once in
7 I have not done in this PR, and will follow up with a dedicated one. Reviewed and addressed by Claude Code (claude-opus-5). |
Why this matters
An engine that runs its schema change inside the SchemaBot process holds resources on the target — for Spirit, an advisory lock on the table it is copying — for as long as that work lives. That work outlives the drive that started it: the drive runs behind a detached context, so cancelling the drive stops the heartbeat, not the copy.
Shutdown cancelled the drives and exited. The process stopped renewing its applies' leases while the targets stayed held. A minute later the leases went stale, peer drivers reclaimed the applies, every one of them was refused the lock, and each refusal charged a recovery attempt against the apply's budget.
Before
After
What it does
Tells a drive's shutdown apart from an operator stop. Both ended the sequential drive early through the same outcome, so a drive that lost its context was on course to be recorded as an operator stop — parking every apply a restart interrupted until a human ran
start. Cancellation now has its own outcome: the drive exits leaving the apply active, and logs which of the two happened.Brings in-process engines down before shutdown ends. Stopping the operator is phased: stop claiming, end the in-flight drives, then halt the engines and wait for them to release their targets. Halting is checkpointed and resumable, and records no operator intent — it is a handover, not a stop. An engine whose work runs elsewhere declares no halt capability, so its schema change is left alone and the lease handover stays the only thing that happens. A halt that does not complete inside its bound is logged with the endpoint and counted (
schemabot.operator.shutdown_halt_failures), so a shutdown that leaves a target held is visible before the refusals it causes are.Hands claimed applies back. A departing process left its applies leased with nothing renewing them, so the work sat idle for the whole staleness window before any peer could pick it up — a restart deferred every in-flight schema change by a minute. Shutdown now releases the claims once the targets are free. A driver registers the claim it drives under and deregisters it on the way out, guarded on the lease token so it never hands back a claim a peer has since rotated onto itself. An apply that settled while shutdown was in progress is left alone: it has nothing to hand over, and backdating its heartbeat would misreport when it finished.
The ordering is the invariant: the claims are released only after the engines are down, so a peer driver is never invited onto a target this process still holds.
🤖 Generated with Claude Code