Skip to content

Commit 07eccbe

Browse files
aparajonclaude
andcommitted
fix(storage): terminal apply states are immutable
Applies().Update was last-writer-wins: a caller holding a stale in-memory snapshot could write an active state over a terminal row, resurrecting a completed, failed, or stopped apply. Update now refuses terminal-to-active transitions in the WHERE clause and surfaces the refusal as a distinct ErrApplyTerminalStateImmutable, resolved from the ambiguous zero-rows result by a read-committed re-read of the row. Terminal-to-terminal writes (including same-state refreshes) stay allowed; a settled apply re-enters the active lifecycle only through the dedicated guarded transition of claiming a stopped apply. The stop-before-start normalization in the API layer now reloads the row and proceeds from the newer verdict instead of overwriting it, and guard errors surface the apply identifier rather than the internal row ID. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 9607c90 commit 07eccbe

7 files changed

Lines changed: 288 additions & 14 deletions

File tree

pkg/api/control_handlers.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1388,14 +1388,39 @@ func (s *Service) completeResolvedStopBeforeStart(ctx context.Context, client te
13881388
return nil
13891389
}
13901390

1391+
// The remote progress check above is an arbitrary time window: a driver can
1392+
// advance the stored row while the RPC is in flight. Write stopped only if
1393+
// the row still holds the state this decision was made from, so a stale
1394+
// snapshot never overwrites a newer state.
13911395
now := time.Now()
13921396
oldState := apply.State
1397+
swapped, err := s.storage.Applies().UpdateDerivedState(ctx, apply.ID, oldState, state.Apply.Stopped, apply.ErrorMessage, nil, &now)
1398+
if err != nil {
1399+
return fmt.Errorf("sync remote stopped apply %s before start: %w", apply.ApplyIdentifier, err)
1400+
}
1401+
if !swapped {
1402+
fresh, err := s.storage.Applies().Get(ctx, apply.ID)
1403+
if err != nil {
1404+
return fmt.Errorf("reload apply %s after concurrent state change before start: %w", apply.ApplyIdentifier, err)
1405+
}
1406+
if fresh == nil {
1407+
return fmt.Errorf("reload apply %s after concurrent state change before start: %w", apply.ApplyIdentifier, storage.ErrApplyNotFound)
1408+
}
1409+
*apply = *fresh
1410+
s.logger.Info("stored apply advanced while checking remote state; leaving pending stop request for the current owner and starting from the reloaded state",
1411+
"apply_id", apply.ApplyIdentifier,
1412+
"external_apply_id", apply.ExternalID,
1413+
"database", apply.Database,
1414+
"environment", apply.Environment,
1415+
"requested_by", stopCaller,
1416+
"start_requested_by", caller,
1417+
"expected_state", oldState,
1418+
"state", apply.State)
1419+
return nil
1420+
}
13931421
apply.State = state.Apply.Stopped
13941422
apply.CompletedAt = &now
13951423
apply.UpdatedAt = now
1396-
if err := s.storage.Applies().Update(ctx, apply); err != nil {
1397-
return fmt.Errorf("sync remote stopped apply %s before start: %w", apply.ApplyIdentifier, err)
1398-
}
13991424
if err := controlStore.CompletePending(ctx, apply.ID, storage.ControlOperationStop); err != nil {
14001425
return fmt.Errorf("complete pending remote stop control request for apply %s before start: %w", apply.ApplyIdentifier, err)
14011426
}

pkg/api/control_handlers_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import (
77
"log/slog"
88
"net/http"
99
"net/http/httptest"
10+
"os"
1011
"testing"
12+
"time"
1113

1214
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
1316

17+
ternv1 "github.com/block/schemabot/pkg/proto/ternv1"
1418
"github.com/block/schemabot/pkg/state"
1519
"github.com/block/schemabot/pkg/storage"
1620
)
@@ -78,3 +82,80 @@ func TestWriteControlError_LogsCarryFullApplyAttrs(t *testing.T) {
7882
assert.Equal(t, "remote-apply-7", line["external_id"])
7983
assert.Contains(t, line, "error")
8084
}
85+
86+
// TestCompleteResolvedStopBeforeStart verifies the stop-request normalization
87+
// that runs before a start of a remote apply: when the data plane reports the
88+
// apply stopped, the stored row is written to stopped only if it still holds
89+
// the state the handler read before the remote check. If a driver advanced the
90+
// row while the check was in flight, the stale write is skipped, the pending
91+
// stop request stays with the current owner, and the handler proceeds from the
92+
// reloaded state instead of overwriting a newer verdict.
93+
func TestCompleteResolvedStopBeforeStart(t *testing.T) {
94+
newSnapshot := func() *storage.Apply {
95+
return &storage.Apply{
96+
ID: 7,
97+
ApplyIdentifier: "apply_stop_sync",
98+
Database: "testdb",
99+
DatabaseType: storage.DatabaseTypeMySQL,
100+
Environment: "staging",
101+
ExternalID: "remote-apply-7",
102+
State: state.Apply.Running,
103+
}
104+
}
105+
newService := func(t *testing.T, applies *staticApplyStore) (*Service, *memoryControlRequestStore) {
106+
t.Helper()
107+
controls := &memoryControlRequestStore{}
108+
_, _, err := controls.RequestPending(t.Context(), &storage.ApplyControlRequest{
109+
ApplyID: 7,
110+
Operation: storage.ControlOperationStop,
111+
RequestedBy: "alice",
112+
Status: storage.ControlRequestPending,
113+
})
114+
require.NoError(t, err)
115+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelError}))
116+
return New(&mockStorageWithApplyStores{
117+
applies: applies,
118+
controls: controls,
119+
applyLogs: &noopApplyLogStore{},
120+
}, testServerConfig(), nil, logger), controls
121+
}
122+
remoteStopped := &mockTernClient{
123+
isRemote: true,
124+
progressResp: &ternv1.ProgressResponse{State: ternv1.State_STATE_STOPPED},
125+
}
126+
127+
t.Run("stored row still matches the handler's read", func(t *testing.T) {
128+
snapshot := newSnapshot()
129+
stored := *snapshot
130+
applies := &staticApplyStore{apply: &stored}
131+
svc, controls := newService(t, applies)
132+
133+
require.NoError(t, svc.completeResolvedStopBeforeStart(t.Context(), remoteStopped, snapshot, "bob"))
134+
135+
assert.Equal(t, state.Apply.Stopped, stored.State)
136+
assert.Equal(t, state.Apply.Stopped, snapshot.State)
137+
assert.NotNil(t, snapshot.CompletedAt)
138+
pending, err := controls.GetPending(t.Context(), 7, storage.ControlOperationStop)
139+
require.NoError(t, err)
140+
assert.Nil(t, pending, "resolved stop request must be completed")
141+
})
142+
143+
t.Run("stored row advanced while the remote check was in flight", func(t *testing.T) {
144+
snapshot := newSnapshot()
145+
completedAt := time.Now().Add(-time.Minute)
146+
stored := *snapshot
147+
stored.State = state.Apply.Completed
148+
stored.CompletedAt = &completedAt
149+
applies := &staticApplyStore{apply: &stored}
150+
svc, controls := newService(t, applies)
151+
152+
require.NoError(t, svc.completeResolvedStopBeforeStart(t.Context(), remoteStopped, snapshot, "bob"))
153+
154+
assert.Equal(t, state.Apply.Completed, stored.State, "a stale stop write must not overwrite the newer verdict")
155+
assert.Equal(t, state.Apply.Completed, snapshot.State, "handler must proceed from the reloaded state")
156+
pending, err := controls.GetPending(t.Context(), 7, storage.ControlOperationStop)
157+
require.NoError(t, err)
158+
require.NotNil(t, pending, "pending stop request must stay with the current owner")
159+
assert.Equal(t, "alice", pending.RequestedBy)
160+
})
161+
}

pkg/api/handlers_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,32 @@ func (s *staticApplyStore) Update(_ context.Context, apply *storage.Apply) error
255255
return s.err
256256
}
257257

258+
// UpdateDerivedState compare-and-swaps against the stored row like the real
259+
// store: the write lands only when the row still holds expectedState, so tests
260+
// can advance the row independently of the snapshot a handler is working from.
261+
func (s *staticApplyStore) UpdateDerivedState(_ context.Context, applyID int64, expectedState, newState, errorMessage string, startedAt, completedAt *time.Time) (bool, error) {
262+
if s.err != nil {
263+
return false, s.err
264+
}
265+
apply := s.apply
266+
for _, candidate := range s.applies {
267+
if candidate.ID == applyID {
268+
apply = candidate
269+
break
270+
}
271+
}
272+
if apply == nil || !state.IsState(apply.State, expectedState) {
273+
return false, nil
274+
}
275+
apply.State = newState
276+
apply.ErrorMessage = errorMessage
277+
if apply.StartedAt == nil {
278+
apply.StartedAt = startedAt
279+
}
280+
apply.CompletedAt = completedAt
281+
return true, nil
282+
}
283+
258284
type recentApplyStore struct {
259285
storage.ApplyStore
260286
filters []storage.RecentAppliesFilter

pkg/storage/errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,13 @@ var (
3737
// matches the apply lease token stored by the latest operator claimant.
3838
ErrApplyLeaseLost = errors.New("apply lease lost")
3939

40+
// ErrApplyTerminalStateImmutable is returned when a general update would
41+
// move an apply from a terminal state back to an active state. A settled
42+
// apply re-enters the active lifecycle only through the dedicated guarded
43+
// transition of claiming a stopped apply, never through an update written
44+
// from a caller's in-memory snapshot.
45+
ErrApplyTerminalStateImmutable = errors.New("terminal apply state is immutable")
46+
4047
// ErrPlanNotFound is returned when a plan does not exist.
4148
ErrPlanNotFound = errors.New("plan not found")
4249

pkg/storage/internal/sqlstore/applies.go

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,33 @@ func ensureApplyLeaseStillOwned(ctx context.Context, db queryRower, lease storag
462462
return nil
463463
}
464464

465+
// ensureApplyStillActiveOnZeroRows resolves a zero-rows result from an update
466+
// that carried the terminal-state guard (new state is active). Zero rows is
467+
// ambiguous: the write can be an idempotent no-op (every column already held
468+
// its target value), the row can be gone, or the guard refused because the row
469+
// is already terminal. It re-reads the row to distinguish the three so callers
470+
// can log the right cause: a missing row returns ErrApplyNotFound, a terminal
471+
// row returns ErrApplyTerminalStateImmutable, and a still-active row is a
472+
// benign no-op.
473+
func ensureApplyStillActiveOnZeroRows(ctx context.Context, db queryRower, apply *storage.Apply) error {
474+
// The re-read must observe the latest committed row, not the transaction's
475+
// repeatable-read snapshot: a concurrent terminal write committed after the
476+
// snapshot is exactly what the guard refused. FOR UPDATE reads current data
477+
// on the row lock the guarded UPDATE already examined.
478+
var currentState string
479+
err := db.QueryRowContext(ctx, `SELECT state FROM applies WHERE id = ? FOR UPDATE`, apply.ID).Scan(&currentState)
480+
if errors.Is(err, sql.ErrNoRows) {
481+
return fmt.Errorf("apply %s no longer exists for update to state %s: %w", apply.ApplyIdentifier, apply.State, storage.ErrApplyNotFound)
482+
}
483+
if err != nil {
484+
return fmt.Errorf("re-read apply %s after guarded update to state %s: %w", apply.ApplyIdentifier, apply.State, err)
485+
}
486+
if state.IsTerminalApplyState(currentState) {
487+
return fmt.Errorf("apply %s is %s; update to active state %s refused: %w", apply.ApplyIdentifier, currentState, apply.State, storage.ErrApplyTerminalStateImmutable)
488+
}
489+
return nil
490+
}
491+
465492
// confirmLeaseOnZeroRows fails closed when a lease-scoped write changed no rows.
466493
// Zero rows is ambiguous: either a legitimate idempotent no-op (the lease is
467494
// still valid) or the lease token no longer matches because ownership was lost.
@@ -951,24 +978,44 @@ func (s *applyStore) Update(ctx context.Context, apply *storage.Apply) error {
951978
leasePredicate = " AND lease_token = ?"
952979
args = append(args, lease.Token)
953980
}
981+
// Terminal states are immutable through a general update: a caller writing
982+
// from a stale snapshot must not resurrect a settled apply back into the
983+
// active lifecycle. When the new state is active, match only rows that are
984+
// still active; terminal rows re-enter the lifecycle solely through the
985+
// dedicated guarded transition of claiming a stopped apply.
986+
// Terminal-to-terminal writes (including same-state refreshes) stay allowed.
987+
terminalGuard := isActiveApplyState(apply.State)
988+
terminalGuardPredicate := ""
989+
if terminalGuard {
990+
predicate, guardArgs := nonTerminalApplyStatePredicate("state")
991+
terminalGuardPredicate = " AND " + predicate
992+
args = append(args, guardArgs...)
993+
}
954994

955995
result, err := writeTx.tx.ExecContext(ctx, fmt.Sprintf(`
956996
UPDATE applies
957997
SET state = ?, error_message = ?, attempt = ?,
958998
external_id = ?%s, started_at = ?, completed_at = ?, updated_at = NOW()
959-
WHERE id = ?%s
960-
`, optionsUpdate, leasePredicate), args...)
999+
WHERE id = ?%s%s
1000+
`, optionsUpdate, leasePredicate, terminalGuardPredicate), args...)
9611001
if err != nil {
9621002
return fmt.Errorf("update apply %d: %w", apply.ID, err)
9631003
}
964-
if hasLease {
1004+
if hasLease || terminalGuard {
9651005
rows, err := result.RowsAffected()
9661006
if err != nil {
9671007
return fmt.Errorf("read apply update rows affected for apply %d: %w", apply.ID, err)
9681008
}
9691009
if rows == 0 {
970-
if err := ensureApplyLeaseStillOwned(ctx, writeTx.tx, lease); err != nil {
971-
return err
1010+
if hasLease {
1011+
if err := ensureApplyLeaseStillOwned(ctx, writeTx.tx, lease); err != nil {
1012+
return err
1013+
}
1014+
}
1015+
if terminalGuard {
1016+
if err := ensureApplyStillActiveOnZeroRows(ctx, writeTx.tx, apply); err != nil {
1017+
return err
1018+
}
9721019
}
9731020
}
9741021
}

pkg/storage/internal/sqlstore/applies_test.go

Lines changed: 87 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,7 +1881,9 @@ func TestApplyStore_UpdateBlocksActiveApplyForSameTarget(t *testing.T) {
18811881

18821882
active.State = state.Apply.Completed
18831883
require.NoError(t, store.Applies().Update(ctx, active))
1884-
require.NoError(t, store.Applies().Update(ctx, completed))
1884+
// With the target free, the terminal row is still not resurrectable through
1885+
// a general update: only the dedicated transitions reopen a settled apply.
1886+
require.ErrorIs(t, store.Applies().Update(ctx, completed), storage.ErrApplyTerminalStateImmutable)
18851887
}
18861888

18871889
func TestApplyStore_UpdateNonExistent(t *testing.T) {
@@ -1894,9 +1896,88 @@ func TestApplyStore_UpdateNonExistent(t *testing.T) {
18941896
State: state.Apply.Running,
18951897
}
18961898

1897-
// Update on a non-existent row is a no-op (0 rows affected), not an error.
1898-
// MySQL UPDATE with WHERE id=? succeeds even when no row matches.
1899-
require.NoError(t, store.Applies().Update(ctx, apply))
1899+
// An update to an active state re-reads the row when nothing matched, so a
1900+
// missing row surfaces as ErrApplyNotFound instead of a silent no-op.
1901+
require.ErrorIs(t, store.Applies().Update(ctx, apply), storage.ErrApplyNotFound)
1902+
}
1903+
1904+
// TestApplyStore_UpdateRefusesTerminalToActive verifies that a settled apply is
1905+
// immutable through a general update: a caller writing an active state from a
1906+
// stale in-memory snapshot must not resurrect a completed, failed, or stopped
1907+
// apply. A settled apply re-enters the active lifecycle only through the
1908+
// dedicated guarded transition of claiming a stopped apply.
1909+
func TestApplyStore_UpdateRefusesTerminalToActive(t *testing.T) {
1910+
clearTables(t)
1911+
ctx := t.Context()
1912+
store := New(testDB)
1913+
1914+
lock := createTestLock(t, store, "testdb", "mysql", "staging")
1915+
1916+
cases := []struct {
1917+
name string
1918+
applyID string
1919+
planID int64
1920+
terminalState string
1921+
activeState string
1922+
}{
1923+
{"completed to running", "apply_guard_completed", 401, state.Apply.Completed, state.Apply.Running},
1924+
{"failed to failed_retryable", "apply_guard_failed", 402, state.Apply.Failed, state.Apply.FailedRetryable},
1925+
{"stopped to resuming", "apply_guard_stopped", 403, state.Apply.Stopped, state.Apply.Resuming},
1926+
{"cancelled to pending", "apply_guard_cancelled", 404, state.Apply.Cancelled, state.Apply.Pending},
1927+
}
1928+
for _, tc := range cases {
1929+
t.Run(tc.name, func(t *testing.T) {
1930+
// A driver's stale snapshot: taken while the apply was active, used
1931+
// for a write after another writer settled the row.
1932+
apply := createTestApply(t, store, lock, tc.applyID, tc.planID)
1933+
snapshot := *apply
1934+
1935+
now := time.Now()
1936+
apply.State = tc.terminalState
1937+
apply.CompletedAt = &now
1938+
require.NoError(t, store.Applies().Update(ctx, apply))
1939+
1940+
snapshot.State = tc.activeState
1941+
require.ErrorIs(t, store.Applies().Update(ctx, &snapshot), storage.ErrApplyTerminalStateImmutable)
1942+
1943+
stored, err := store.Applies().Get(ctx, apply.ID)
1944+
require.NoError(t, err)
1945+
assert.Equal(t, tc.terminalState, stored.State)
1946+
assert.NotNil(t, stored.CompletedAt)
1947+
})
1948+
}
1949+
}
1950+
1951+
// TestApplyStore_UpdateTerminalToTerminalAllowed verifies that terminal rows
1952+
// stay writable for terminal outcomes: a failed apply can refresh its error
1953+
// message, and a stopped apply can be corrected to another terminal verdict,
1954+
// without reopening the apply.
1955+
func TestApplyStore_UpdateTerminalToTerminalAllowed(t *testing.T) {
1956+
clearTables(t)
1957+
ctx := t.Context()
1958+
store := New(testDB)
1959+
1960+
lock := createTestLock(t, store, "testdb", "mysql", "staging")
1961+
1962+
failed := createTestApplyWithStateAndEnv(t, store, lock, "apply_terminal_refresh", 405, state.Apply.Failed, "staging")
1963+
failed.ErrorMessage = "engine failure: table copy interrupted"
1964+
require.NoError(t, store.Applies().Update(ctx, failed))
1965+
1966+
stored, err := store.Applies().Get(ctx, failed.ID)
1967+
require.NoError(t, err)
1968+
assert.Equal(t, state.Apply.Failed, stored.State)
1969+
assert.Equal(t, "engine failure: table copy interrupted", stored.ErrorMessage)
1970+
1971+
stopped := createTestApplyWithStateAndEnv(t, store, lock, "apply_terminal_correct", 406, state.Apply.Stopped, "staging")
1972+
now := time.Now()
1973+
stopped.State = state.Apply.Cancelled
1974+
stopped.CompletedAt = &now
1975+
require.NoError(t, store.Applies().Update(ctx, stopped))
1976+
1977+
stored, err = store.Applies().Get(ctx, stopped.ID)
1978+
require.NoError(t, err)
1979+
assert.Equal(t, state.Apply.Cancelled, stored.State)
1980+
assert.NotNil(t, stored.CompletedAt)
19001981
}
19011982

19021983
// TestApplyStore_UpdateDerivedState verifies the rollout-projection compare-and-
@@ -3746,7 +3827,7 @@ func TestApplyStore_UpdateOptions(t *testing.T) {
37463827
PullRequest: 123,
37473828
Environment: "staging",
37483829
Engine: "spirit",
3749-
State: state.Apply.Stopped,
3830+
State: state.Apply.Pending,
37503831
}
37513832
apply.SetOptions(storage.ApplyOptions{Target: "testdb"})
37523833

@@ -3755,7 +3836,7 @@ func TestApplyStore_UpdateOptions(t *testing.T) {
37553836

37563837
retrieved, err := store.Applies().Get(ctx, id)
37573838
require.NoError(t, err)
3758-
retrieved.State = state.Apply.Pending
3839+
retrieved.State = state.Apply.Running
37593840

37603841
require.NoError(t, store.Applies().Update(ctx, retrieved))
37613842

0 commit comments

Comments
 (0)