fix(storage): terminal apply states are immutable outside explicit reapply#644
Draft
aparajon wants to merge 2 commits into
Draft
fix(storage): terminal apply states are immutable outside explicit reapply#644aparajon wants to merge 2 commits into
aparajon wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR enforces “terminal apply state immutability” as a storage-layer safety invariant, preventing stale writers from moving an apply from a terminal verdict back into the active lifecycle, and hardens the stop-before-start normalization path with a CAS write to avoid clobbering concurrent state advances.
Changes:
- Add a storage guard in
Applies().Updatethat refuses terminal → active transitions with a distinctstorage.ErrApplyTerminalStateImmutable. - Disambiguate “0 rows affected” outcomes for guarded active-state updates by re-reading the row to distinguish missing vs terminal vs idempotent no-op.
- Update the stop-before-start handler to use
UpdateDerivedState(CAS) and add coverage for the concurrent-advance scenario.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| pkg/storage/storage.go | Documents new Update() behavior around terminal immutability. |
| pkg/storage/mysqlstore/applies.go | Implements terminal→active guard + zero-rows disambiguation helper. |
| pkg/storage/mysqlstore/applies_test.go | Adds/updates tests for terminal immutability and Update() missing-row behavior. |
| pkg/storage/errors.go | Introduces ErrApplyTerminalStateImmutable. |
| pkg/api/handlers_test.go | Extends staticApplyStore test double with UpdateDerivedState CAS semantics. |
| pkg/api/control_handlers.go | Uses UpdateDerivedState to CAS the stop-before-start normalization write. |
| pkg/api/control_handlers_test.go | Adds tests covering the CAS stop-before-start behavior and concurrent advance handling. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…apply A general Applies().Update was a full-row last-writer-wins overwrite: with no lease on the context, nothing stopped a caller holding a stale in-memory snapshot from moving a completed, failed, or stopped apply back into an active state. The update path now refuses a terminal-to-active transition in the WHERE clause and surfaces the refusal as a distinct ErrApplyTerminalStateImmutable so callers can log the right cause. A zero-rows result is re-read to distinguish the guard refusal from a missing row (ErrApplyNotFound) and from a benign no-op write. Terminal-to-terminal writes, including same-state refreshes, remain allowed, and the dedicated guarded transitions that reopen a settled apply (ReapplyFailed, claiming a stopped apply) are unchanged. The stop-before-start normalization in the control plane read the stored row, made a remote Progress RPC, and then wrote stopped unconditionally -- an arbitrary window for a driver to advance the row. The final write is now a compare-and-swap on the state the handler decided from; on a miss it reloads the row, logs definitively, and leaves the pending stop request for the current owner instead of overwriting a newer verdict. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…errors Document ErrApplyNotFound on ApplyStore.Update and drop the internal numeric row ID from the terminal-guard re-read errors in favor of the user-facing apply identifier. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aparajon
force-pushed
the
armand/terminal-state-guard
branch
from
July 18, 2026 17:25
739c1cd to
68a6f3d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this matters
Once an apply settles — completed, failed, stopped, cancelled — that final verdict is what operators and the PR merge gate trust. But the general update method wrote whatever state the caller had in memory, last writer wins. A caller holding a stale snapshot (a slow goroutine, a raced handler) could overwrite a settled apply back to "running": the verdict silently disappears, the merge gate reasons from a state that no longer exists, and nobody can tell what actually happened.
What it does
1. The database now refuses to move a settled apply back to an active state. The update SQL only matches rows that are still active, so writing "running" over "completed" is rejected with a distinct error (
ErrApplyTerminalStateImmutable) instead of silently succeeding. Settled verdicts stay settled.2. When a write is refused, the caller learns exactly why. A guarded write that matched no rows is re-read to distinguish three cases: the row is gone (not-found), the row is settled (the immutability refusal), or nothing needed to change (a harmless no-op). The re-read is a locking read, so it sees the latest committed state rather than the transaction's stale snapshot.
3. Legitimate reopenings still work. Restarting a stopped apply and reapplying a failed one go through their own dedicated, compare-and-swap transitions — those are unchanged. Rewriting one terminal state to another (a same-state refresh or verdict correction) also stays allowed.
4. The stop-before-start cleanup no longer clobbers a driver's progress. When a start request finds an earlier stop that already took effect on the data plane, the handler records "stopped" only if the row still holds the exact state that decision was based on. If a driver advanced the apply while the handler was checking, the handler backs off, reloads the row, and leaves the stop request for the current owner — the start is then rejected by the pending-stop gate instead of proceeding over live work.
Before / after
🤖 Generated with Claude Code