Skip to content

fix(storage): terminal apply states are immutable outside explicit reapply#644

Draft
aparajon wants to merge 2 commits into
mainfrom
armand/terminal-state-guard
Draft

fix(storage): terminal apply states are immutable outside explicit reapply#644
aparajon wants to merge 2 commits into
mainfrom
armand/terminal-state-guard

Conversation

@aparajon

@aparajon aparajon commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

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

before: apply settles as completed → stale caller writes "running"
        → verdict silently erased, apply resurrected ❌

after:  apply settles as completed → stale caller writes "running"
        → write refused (ErrApplyTerminalStateImmutable), verdict intact ✅

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings July 2, 2026 04:11

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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().Update that refuses terminal → active transitions with a distinct storage.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.

Comment thread pkg/storage/storage.go
Comment thread pkg/storage/mysqlstore/applies.go Outdated
Comment thread pkg/storage/mysqlstore/applies.go Outdated
Comment thread pkg/storage/mysqlstore/applies.go Outdated
aparajon and others added 2 commits July 18, 2026 13:14
…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
aparajon force-pushed the armand/terminal-state-guard branch from 739c1cd to 68a6f3d Compare July 18, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants