fix(tern): a refused stop or cancel no longer settles the apply stopped - #1330
fix(tern): a refused stop or cancel no longer settles the apply stopped#1330aparajon wants to merge 3 commits into
Conversation
A durable stop or cancel that reaches an apply already past cutover is refused for the whole revert phase: the operator has to choose revert or skip-revert, and nothing else can act. Both revert-phase gates resolved that request correctly and then reported it to the drive as though the stop had taken effect, so the drive stood down, marked the cut-over task stopped in memory, and settled the apply stopped in storage while the change was applied and the engine was still working underneath it. The gates now report that nothing took effect, leaving the drive exactly as it found it: the poll keeps polling and the revert phase drives its own outcome. The bool the processors return conflated two independent facts about one request, which is why a gate that resolves a request could look right while returning the opposite of what the drive needed. It is now a named return on both clients, documented as reporting whether the apply is really stopped rather than whether the request was resolved. Two tests asserted the old value to express "the request is resolved"; they now assert that fact against the stored request, which is where it lives. Upholds CO-5 (the revert phase owns the outcome) and ST-7 (a stop snapshot is taken only after the engine's own stop returns).
There was a problem hiding this comment.
🟡 Changes recommended
The new “tookEffect” contract is documented as always resolving requests, but the implementation can intentionally leave requests pending, and there are still error paths in the changed refusal branches that return tookEffect=true, which is inconsistent with the new semantics.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes control-request handling in the tern drive loop so that a refused stop/cancel during the revert window no longer causes the driver to stand down and settle the apply as stopped, preserving CO-5 (“the revert phase owns the outcome”) and ST-7 (“stop checkpoints conservatively”).
Before (bug) After (fixed)
refused stop/cancel in revert window refused stop/cancel in revert window
│ │
▼ ▼
drive treated as “took effect” drive treats as “no effect”
│ │
▼ ▼
driver stands down + apply settles stopped driver keeps polling; revert window settles
Changes:
- Change stop/cancel processing to return “took effect” = false for revert-phase refusals, so the driver keeps polling and the revert phase settles its own outcome.
- Update sequential/grouped/resume drive loops to use the renamed
tookEffectsignal. - Add/adjust tests covering revert-window refusals and ensuring durable requests are resolved without stopping the drive.
File summaries
| File | Description |
|---|---|
| pkg/tern/local_control.go | Adjusts local stop/cancel control-request consumption so refusals in revert phases don’t signal a stop/cancel took effect. |
| pkg/tern/local_control_test.go | Refactors client construction to inject control-request stores; adds targeted revert-window refusal tests. |
| pkg/tern/local_control_resume.go | Updates resume loop to use the new tookEffect semantics when deciding to stand down. |
| pkg/tern/local_client_test.go | Updates stop-revert-window rejection test expectations to match “no effect” semantics. |
| pkg/tern/local_apply_sequential.go | Updates sequential apply loop and polling loop to use tookEffect when deciding to stop driving. |
| pkg/tern/local_apply_sequential_progress_test.go | Adds a progress/poll test asserting a refused cancel in revert window keeps polling until engine completion. |
| pkg/tern/local_apply_grouped.go | Updates grouped apply flow to use tookEffect when deciding whether to return early. |
| pkg/tern/grpc_client.go | Mirrors the stop/cancel “tookEffect” contract changes in the gRPC client call sites and documentation. |
Review details
Suppressed comments (1)
pkg/tern/local_control.go:1267
- Same issue as the stop refusal path: if failing the pending cancel request write returns an error, the cancel did not take effect and the request is still unresolved, so the error path should return tookEffect=false rather than true.
if err := failPendingControlRequests(ctx, c.storage, apply, storage.ControlOperationCancel, message); err != nil {
return true, err
}
- Files reviewed: 8/8 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The refusal decides that nothing was stopped before the drive tries to record it, so a storage failure recording it changes what the operator can read, not what happened to their schema change. Both revert-phase gates were returning that a stop took effect on that error path, which is the value this change exists to remove, and the odd one out among the sibling known-no-effect paths that already return false with an error. The doc comments on the processors overstated the contract in the other direction: they said the request is resolved either way, and it is deliberately left pending in two shapes that still report a stop took effect, an accepted stop whose apply row has not settled and (on the gRPC client) an operation-only drive that finds its parent terminal. They now say what the return value means without claiming anything about where the request ended up.
|
🤖 Review findings - created by Kiran's code review agent - for schemabot/pull/1330, 1f086ef. Verdict: 7 findings — 0 blocking, 3 non-blocking, 4 suggestions. The fix itself is right and now well pinned: reverting all four flipped returns kills six tests, and before this PR nothing in Non-blocking1. The new gRPC contract comment is wrong about the code it sits above, in three places. 2. The gRPC refusal still reports 3. General suggestions4. One of the four flipped returns is unpinned. Flipping only the cancel side's 5. The drive that actually consumes the new 6. 7. Two smaller ones. CO-5's text names only The one thing that could have broken, verified
Verified correct
This review was generated by Claude Code (claude-opus-5). |
The stop and cancel processors return one bool, and the honest reading of it is what the drive must do, not what happened to the request or to the change. A completed stop with a queued start reports false so the same claim resumes from the start, even though that stop took effect, so `tookEffect` was wrong about its own code. Rename it `standDown` on both clients and state the contract as the one question it answers, instead of enumerating the shapes that reach it — the enumerations were the part that went stale, and two of them described the local client's branches while sitting above the gRPC one. The gRPC refusal path still reported a stand-down when the write resolving the request failed, the last copy of the value this change removes. It now reports what the refusal already decided, and both clients' refusal writes wrap their error with the apply the operator would look up. CO-5 named two of the three states its own gate refuses in, leaving out the revert window that a reviewer following the citation would arrive at, and named only the control path where half the rule now lives in the drive loops. Tests: the reachable drive site for a revert-window refusal is the grouped progress tick (only PlanetScale reports a revert window, and it always groups), so that is where the drive-level proof belongs. Both refusal writes and the gRPC one are pinned at the function boundary, where a failing store is the only way in.
|
🤖 Replying to the review at 1, 3 — the contract comments, and the name. Both accepted, and they turned out to be one problem. 2 — the gRPC refusal's write-error return. Fixed, and pinned: 4 — the unpinned cancel twin. Added, and it dies on the mutant. 5 — the drive-level test was on the wrong path. This is the one I had wrong in the PR body, not just in the tests. Only PlanetScale reports a revert window and PlanetScale always groups, so the sequential poll I described as the reachable site cannot see one from any built-in engine. There is now a 7 — CO-5 and the bare errors. Both fixed. CO-5 now names the phase rather than two of its three states, and its 6 — the sequential fail-closed guard: declining. The distinction I'd draw is that a resume adopts whatever task state storage holds, so its guard can be reached by state it did not write; a fresh sequential dispatch creates its tasks, and per finding 5 no built-in engine can put a revert window under it. A refusal there would be a branch no test can reach, which is what I'd rather not add. If you think the resume guard's reachability argument extends further than that, I'll take it. Also worth recording: your census found that before this PR the flip was completely unpinned in This reply was generated by Claude Code (claude-opus-5). |
Why this matters
An operator can cancel a schema change that has already cut over and is holding its revert window. Nothing gates the command, and the drive refuses it there, because at that point only revert (undo) or skip-revert (finalize) can act. That refusal is correct and it resolves the durable request with the reason.
Then it told the drive the stop had taken effect. The drive stood down, marked the cut-over task stopped in memory, and settled the apply
stoppedin storage. The change was applied. The engine was still working underneath. And the revert window was left with no driver watching it expire.Reachable with no operator error and no unusual timing. A task enters its revert window, the progress tick persists that state, and the next tick reads a pending cancel: the gate fires, and the tick that had been watching the change walks away from it reporting a pause that never happened. The engine that reports a revert window always runs its tables as a group, so the grouped progress tick is where this lands.
What it does
Both revert-phase gates now report that nothing took effect. The drive is left exactly as the gate found it, which is the whole point of a refusal: the poll keeps polling, and the revert phase drives its own outcome. The durable request is still resolved, so no later claim re-sends it.
The value the processors return conflated independent facts about one request. "I resolved this request" and "the drive should stand down" are not the same claim, and a gate that resolves a request could look right while returning the opposite of what the drive needed. Nor is standing down the same as the command taking effect: a completed stop with a start already queued keeps the drive going so the same claim resumes from the start. So the return is now a named
standDownon both clients, documented by the one question it answers rather than by the branches that reach it.Two existing tests asserted the old value in order to express "the request is resolved" — one of them says so in its own assertion message. That fact is asserted against the stored request now, which is where it lives.
Invariants
Upholds CO-5 ("the revert phase owns the outcome"). Its rule already decides this case, and its stated reason is the defect exactly: storage must never settle on a state that contradicts what the engine is still doing to the database underneath. The gate that exists to enforce CO-5 was contradicting it one line later.
The entry itself needed two corrections, neither of them a change to the rule. It named
revertingandskipping_revertbut not the revert window, which is the phase every one of these refusals is actually reached in, so a reviewer following the citation arrived at an entry about different states. And its*Enforced:*line named only the control path, while the half this change fixes lives in the drive loops that act on the gate's answer.Upholds ST-7 ("stop checkpoints conservatively"): the task snapshot is taken only after the engine's own stop returns, and here no engine stop was ever attempted. Its text and
*Enforced:*line are unchanged.Opened by Claude (Opus 5).