fix(engine): settle tasks stranded under a terminal apply, and stop counting refused writes as success - #1222
Conversation
A terminal apply is never claimable, so a task still waiting for a driver under one has no way out on its own: start resumes only stopped work, the takeover path releases only stopped work, cleanup reaps only terminal work. The sweep that clears these enumerated a single task state, so a task that reached a terminal apply in any other state held its whole database with nothing able to act on it. Sweep on the invariant instead — a terminal apply's tasks must be settled — and settle each one to the state its apply already reached. A stopped apply settles its retryable work to stopped rather than cancelled, so the copy on the target stays resumable and the dispatch that meets it takes it over instead of being refused by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A stop holds the apply lease, but each task write is guarded by the operation lease, and driver churn can hand that one to a peer mid-stop. The task writes were refused, logged, and discarded, the count incremented anyway, and the apply-level write — guarded by the lease the stop did hold — landed. The result was an apply recorded as stopped over task rows that never moved, a durable log claiming "N tasks stopped", and tasks left holding the database. Count landed writes rather than attempts, and fail the stop or cancel instead of settling the apply on top of task rows that never moved. Every task is still attempted, so a retry has only the refused ones left. A refused write also restores the task's in-memory state, so nothing downstream reads a transition that did not happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens SchemaBot’s local control and dispatch logic to prevent tasks from becoming permanently “stranded” under terminal applies, and to ensure stop/cancel operations only report success when task state writes actually persist.
Changes:
- Expand orphan handling so tasks waiting under terminal applies are durably settled into an appropriate resting/terminal state, unblocking future dispatches.
- Make stop/cancel fail closed when lease-guarded task updates are refused, instead of counting refused writes as success and settling the apply anyway.
- Add targeted unit + integration coverage for stranded-task settlement and refused-write control-operation behavior.
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/tern/local_orphaned_task_integration_test.go | Adds an integration scenario asserting a stranded retryable task under a stopped apply is settled and no engine work is touched. |
| pkg/tern/local_control.go | Updates stop/cancel to treat refused task writes as errors and only count/publish landed task updates. |
| pkg/tern/local_control_test.go | Adds tests ensuring stop/cancel fail when task writes are refused, and that counts reflect only persisted updates. |
| pkg/tern/local_apply.go | Generalizes orphan settlement beyond pending and introduces shared log message + settlement policy for terminal-apply tasks. |
| pkg/tern/local_apply_conflict_test.go | Adds conflict-check tests covering stranded retryable tasks across stopped/ended/claimable applies and write-failure behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The settlement was covered from the dispatch side only. A stranded task closes both exits an operator has, and start is the one the refusal message names first, so assert that too: the stranded state refuses start, and settling the task into the stop it missed resumes the existing copy instead of starting the table over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… cannot Start compared task and apply states with raw string equality, so a state in proto form did not match and a change sitting ready was refused as though it had none. Compare through state.IsState, the way the rest of the state machine does. The seven-day window is a way of choosing among candidates when a start names no apply, not a rule about what may be resumed — but it was applied to named requests too, so a change resting longer than that was refused as if it did not exist. Bound only discovery by it, and resume a named apply however long it has been resting; the re-plan and the engine still decide whether its copy is usable. Report why nothing was resumable instead of collapsing every cause into "no stopped schema change to resume". Each cause asks something different of the operator: wait for the driver, reconcile the target, or name the apply. The skipped count now reports the tables that finished before the stop, which the previous unreachable branch always reported as zero. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rollback returns the task to the values it arrived with, which is the caller's own pre-call row rather than what storage holds — a caller that refreshed progress before the transition keeps it. Only the state gates control flow, so that is what the rollback exists to undo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
JashLal
left a comment
There was a problem hiding this comment.
Adversarial review — I went looking for ways this change is wrong rather than ways it is right. No blockers: the core fix (counting only landed writes, failing stop/cancel on refused task writes, settling stranded tasks under terminal applies) held up under the interleavings I traced. Findings, most substantive first.
1. IsTerminalTaskState doesn't normalize, and this PR's own premise makes that reachable
pkg/state/task.go:63; new call sites pkg/tern/local_control_resume.go:150, pkg/tern/local_apply.go:127
The PR asserts — with a test — that task states arrive in proto form (STATE_STOPPED) and fixes the raw comparisons for stopped. But IsTerminalTaskState is still a raw switch (unlike IsTerminalApplyState, which normalizes first), so a task stored as STATE_COMPLETED falls past the new terminal check at local_control_resume.go:150 into the unresumable bucket at line 160. Start then refuses with "tasks are in a state start cannot act on (STATE_COMPLETED)" instead of counting it skipped / "already reached a terminal state" — the same bug class this PR fixes, one branch over. The pre-existing loop head at local_apply.go:93 has the same latent shape (a STATE_CANCELLED row falls through to the engine-backed checks instead of being skipped as terminal). Suggest normalizing inside IsTerminalTaskState to mirror IsTerminalApplyState, or using state.IsState(task.State, state.TerminalTaskStates...) at the new call sites.
2. The stale > 0 branch of noResumableWorkError is unreachable
pkg/tern/local_control_resume.go:191
A named start sets ageBounded = false, so staleCount stays 0. An unnamed start only resolves an apply through an in-window stopped task in the discovery pass — and that same task increments startedCount in the second loop (same task slice, same window), so startedCount == 0 && stale > 0 can't occur. The carefully worded "re-issue it naming X" message can never be produced. TestLocalClient_StartOutsideTheDiscoveryWindowNamesTheWayForward quietly confirms this: despite its name, it asserts the generic discovery failure ("no stopped schema change to resume"), not this message. The place an operator actually hits the window is discovery returning nil, which still emits the bare message with no pointer to the out-of-window apply — the hint only lands in a server log they can't see. Consider having discovery remember the out-of-window candidate it passed over and surface the hint there, then delete the dead branch.
3. settleOrphanedTask settles without the guards its sibling treats as load-bearing
pkg/tern/local_apply.go:395 vs restingTaskReleasesDatabase at local_apply.go:467
restingTaskReleasesDatabase refuses to act on a terminal apply that still has a fresh lease ("a driver mid-settlement of this same apply") or a pending start/cancel. settleOrphanedTask checks only the apply state and then writes — and the conflict-check path holds no lease, so the write is unguarded. A driver mid-settlement (between its apply write and its task writes) can be writing the same row concurrently. Traced interleavings: stop-vs-settle converges on stopped, and a settled-to-stopped task still passes the resting guards before the database is released, which bounds the damage — but failApplyWithTasks-vs-settle means the last writer decides whether the row reads failed or cancelled. The old pending-only code had the same gap; extending settlement to failed_retryable widens it, since that's exactly a state a mid-settlement driver touches. Cheap mitigation: check apply.HasFreshLease(time.Now()) before settling, same as line 467. Was skipping it deliberate?
4. The settlement write drifted from persistTaskStateTransition
pkg/tern/local_apply.go:395 vs local_apply.go:767
persistTaskStateTransition clears ETASeconds/Throttled/ThrottleReason for at-rest states so "the stored row never carries a frozen estimate or renders as paused with no copy in flight." The hand-rolled write in settleOrphanedTask doesn't, so a task settled to stopped keeps whatever those fields held. Low severity for rows written by current code (the transition into failed_retryable already cleared them) — but the rows this sweep exists to repair are precisely the ones written by older or broken paths, so I wouldn't rely on that. The hand-roll is understandable (the rollback must also restore ErrorMessage/CompletedAt, which the shared helper's entry snapshot would keep), but the ETA/throttle clearing should match.
5. "settle 1 of 2 tasks to stopped" reads as a success count
pkg/tern/local_control.go:1641
len(refused) is the failed count, but "settle N of M tasks" scans as "N succeeded". Suggest "failed to settle 1 of 2 tasks to stopped: …". The doc comment at line 1589 also still describes three return values.
6. Question: the retry story after a partial stop failure and a restart
Post-PR, a refused task write leaves: engine stopped, apply handle cancelled, some tasks stopped, apply still running-family. A same-process retry works (Spirit's tracked state returns "Already stopped"). But if the process restarts first, Stop returns the permanent "no active schema change to stop" (pkg/engine/spirit/control.go:48) and the retried stop dies at engine stop failed before reaching the task writes; recovery then re-claims the running apply and resumes the copy the operator asked to stop, until a later stop lands. Strictly better than the old silent success + permanent wedge — just confirming recovery-resumes-then-stop-again is the intended story for that case.
None of these undermine the fix. Counting only landed writes, attempting every task so a retry shrinks, preserving ErrApplyLeaseLost through errors.Join, letting a named start bypass the discovery window, and settling a stranded task to stopped rather than cancelled so the copy stays resumable are all the right calls — and TestLocalClient_StartRecoversAfterAStrandedTaskIsSettled, reproducing the full wedge and its recovery, is the best kind of regression test. Approving.
Adversarial review by claude-fable-5-xhigh (Amp) 🤖
IsTerminalTaskState compared raw strings while its apply-side sibling normalizes first, so a task stored in proto form (STATE_COMPLETED) fell past every terminal check into whatever branch came next. Normalize inside the predicate so every call site skips finished work the same way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
"settle 1 of 2 tasks" scans as one success; the count is the refusals. Say "failed to settle" so the message reads the way an operator will, and fix the doc comment to match the four values the settler returns. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed over The "resting longer than the window, re-issue the start naming X" hint lived in a branch no request could reach: a named start is not age-bounded, and an unnamed one only resolves an apply through an in-window task that then counts as started. The place an operator actually hits the window is discovery resolving nothing — so discovery now remembers the most recent stopped task it passed over for age alone and the refusal names its apply and the command that resumes it anyway. The unreachable branch is gone, and the generic refusal now says how many tasks were seen and that none stopped inside the window. Tests pin the hint on the surface the operator sees, that a proto-formatted terminal task counts as skipped at the start path, and that the refused-settlement message reads as failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ting fields The settlement sweep holds no lease, so a terminal apply whose lease is still fresh may have a driver mid-settlement with task writes in flight — racing them leaves the row to whichever write lands last. The sweep now defers to a fresh lease, exactly as the resting-task release check does, and settles on a later check once the lease has aged out. The settlement write also now clears ETASeconds/Throttled/ThrottleReason the way persistTaskStateTransition does for at-rest states: the rows this sweep repairs are precisely the ones written by paths that never got to clear them, so a settled task no longer carries a frozen estimate or renders as paused with no copy in flight. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🤖 Addressing @JashLal's adversarial review. All six addressed — fixes in four new commits (no squash, per post-approval convention), each verified by mutation (fix reverted → new test fails → fix restored → green), plus a full
This reply was generated by Claude Code (Claude Fable 5). |
Why this matters
A stopped schema change could end up stuck: it could not be resumed, could not be replaced by a new one, and would not let go of its database. Clearing it took a hand-edited row.
Two things went wrong together. The stop marked the change as stopped, failed to write the state of the tables underneath it, and reported success anyway. That left a table in a state nothing else knew how to deal with.
What it does
A stop or cancel no longer claims to have done work it didn't do. SchemaBot holds two separate leases: one on the change, one on the work driving it. A stop can still hold the first after another server has taken the second, and when that happens its table writes are rejected. Those rejections were logged and then counted as successes. They are now counted as failures, and the command returns an error instead of marking the change stopped on top of tables that never moved. Every table is still attempted, so a retry only has the rejected ones left to do.
Nothing is left waiting for a driver that will never come. Once a change reaches a final state, no server will ever pick its tables up again. Two places already tried to clean this up, but each recognized only one specific table state, so a table in any other state stayed stuck. Both now handle any table sitting under a finished change. A table under a stopped change is settled to stopped rather than cancelled, which is what lets a later start pick its copy back up instead of throwing the work away.
startcan find the change again. It compared states as plain strings, so a state written in its protobuf form didn't match and a change that was ready to resume got refused. It also applied its seven-day search window to changes the operator named explicitly, so an older one couldn't be resumed even by name. And every reason for "nothing to resume" produced the same unhelpful message. All three are fixed, and the response now reports how many tables had already finished before the stop — that count was always zero, because the branch computing it couldn't be reached.What operators will notice: a stop or cancel that can't write its table states now returns an error naming the database and the reason. It used to return success along with a count of writes that never happened. And a
startrefused because the change stopped longer ago than the search window now names that change and the exact command that resumes it anyway, instead of claiming there is nothing to resume.Opened by Claude (Opus 5).