console: an operator's start of a self-serve staged move takes it: the job yields (aborted, another move is in flight) and leaves the reservations to the operator's clearing door
Fix round 1, Important 4: reconciliation detects a record started without the job's start receipt (selfServeTakenOver) and finishes the row with YieldSelfServeMove (release_pending = 0); selfServeDoorCleared releases a yielded row's reservations when the operator clears the record.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+122
−4
internal/console/apiinstancemove_offline.go+6 −1internal/console/selfserve_job.go+35 −0internal/console/selfserve_store.go+14 −2internal/e2e/selfserve/cut_test.go+35 −0internal/e2e/selfserve/job_reconcile_test.go+32 −1
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index 160d84085..be6a7db0b 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -1135,7 +1135,12 @@ func (s *Server) selfServeDoorCleared(id string, rec *provision.Record) bool { |
| if row == nil { |
| return false |
| } |
| - if row.Outcome != "" || rec.LastMove == nil || rec.LastMove.ID != id { |
| + if row.Outcome != "" { |
| + // Finished already: the finaliser releases a pending release; a |
| + // move the job yielded to an operator is released by this door. |
| + return row.ReleasePending |
| + } |
| + if rec.LastMove == nil || rec.LastMove.ID != id { |
| return true |
| } |
| if out, conf := selfServeOutstanding(row); out && !conf && rec.RecordSeq > row.Request.ExpectSeq { |
diff --git a/internal/console/selfserve_job.go b/internal/console/selfserve_job.go| index f0a0d803d..7f2323a90 100644 |
| --- a/internal/console/selfserve_job.go |
| +++ b/internal/console/selfserve_job.go |
| @@ -377,6 +377,20 @@ func (s *Server) selfServeReconcile(ctx context.Context, t *leaseToken, row *Sel |
| } |
| return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeAnotherMove, "") |
| } |
| + if selfServeTakenOver(row, mv) { |
| + // An operator's start took the move offline: the move is the |
| + // operator's from here, and the job yields it (its reservations |
| + // stay with the move; the operator's clearing door releases them). |
| + if unresolved() { |
| + return true, s.selfServeResolveFenced(ctx, t, row) |
| + } |
| + TestHooks.point("finalise:yield") |
| + if err := s.store.YieldSelfServeMove(row.ID, msgSelfServeAnotherMove, now); err != nil { |
| + return true, err |
| + } |
| + log.Printf("console: self-serve move %s (%s): an operator started the move; the job yields it", row.ID, row.Host) |
| + return true, s.selfServeFinaliseRest(ctx, t, row.ID) |
| + } |
| set, ok := selfServeSets[mv.State] |
| if !ok || containsPhase(set.phases, row.Phase) { |
| return false, nil |
| @@ -423,6 +437,27 @@ func (s *Server) selfServeReconcile(ctx context.Context, t *leaseToken, row *Sel |
| return true, nil |
| } |
| +// selfServeTakenOver reports whether an operator took the move offline: the |
| +// record is past its start (stopping through finishing) and the row never |
| +// started it (the row is before stopping, or at the start substep without |
| +// the start the record's receipt names). |
| +func selfServeTakenOver(row *SelfServeMove, mv *provision.MoveState) bool { |
| + switch mv.State { |
| + case provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding, |
| + provision.OfflineLanded, provision.OfflineOpen, provision.OfflineFinishing: |
| + default: |
| + return false |
| + } |
| + if selfServeRank[row.Phase] > selfServeRank[ssStopping] || row.Phase == ssReverting { |
| + return false |
| + } |
| + if row.Phase == ssStopping && row.PhaseStep != ssStepStart { |
| + return false |
| + } |
| + ours := row.Request.Kind == "start" && mv.SelfServe != nil && mv.SelfServe.LastRequest == row.Request.Nonce |
| + return !ours |
| +} |
| + |
| // selfServeOutcomeReason is a finished move's reason: none for done; for an |
| // abort, the reason the job recorded when it decided it, else a |
| // cancellation's. |
diff --git a/internal/console/selfserve_store.go b/internal/console/selfserve_store.go| index e65abde9e..f308161d4 100644 |
| --- a/internal/console/selfserve_store.go |
| +++ b/internal/console/selfserve_store.go |
| @@ -951,6 +951,18 @@ func (s *Store) SetSelfServeAbortFinished(id string, at time.Time) error { |
| // resolved first). Matching nothing is not an error: the row is finished |
| // already, or a request is outstanding (the caller reads the row again). |
| func (s *Store) FinishSelfServeMove(id, outcome, reason, warning string, now time.Time) error { |
| + return s.finishSelfServeMove(id, outcome, reason, warning, true, now) |
| +} |
| + |
| +// YieldSelfServeMove is FinishSelfServeMove for a move an operator took |
| +// over (an operator's start of a self-serve staged move): outcome aborted |
| +// with reason, and release_pending = 0, since the reservations are the |
| +// operator's move's now and its own clearing door releases them. |
| +func (s *Store) YieldSelfServeMove(id, reason string, now time.Time) error { |
| + return s.finishSelfServeMove(id, provision.OfflineOutcomeAborted, reason, "", false, now) |
| +} |
| + |
| +func (s *Store) finishSelfServeMove(id, outcome, reason, warning string, release bool, now time.Time) error { |
| if outcome == "" { |
| return fmt.Errorf("console: self-serve move %s: an outcome is required", id) |
| } |
| @@ -961,11 +973,11 @@ func (s *Store) FinishSelfServeMove(id, outcome, reason, warning string, now tim |
| defer tx.Rollback() |
| res, err := tx.Exec(`UPDATE self_serve_moves SET |
| outcome = ?, outcome_reason = ?, warning = CASE WHEN ? <> '' THEN ? ELSE warning END, |
| - phase = 'done', phase_started_at = ?, step_deadline = 0, release_pending = 1, effects_done = 0, open_record_pending = 0, |
| + phase = 'done', phase_started_at = ?, step_deadline = 0, release_pending = ?, effects_done = 0, open_record_pending = 0, |
| request_consumed = CASE WHEN request_confirmed <> 0 THEN 1 ELSE request_consumed END, |
| updated_at = ? |
| WHERE id = ? AND outcome = '' AND NOT (request_issued <> 0 AND request_consumed = 0 AND request_confirmed = 0)`, |
| - outcome, reason, warning, warning, ssNanos(now), ssNanos(now), id) |
| + outcome, reason, warning, warning, ssNanos(now), ssBool(release), ssNanos(now), id) |
| won, err := ssWon(res, err) |
| if err != nil { |
| return err |
diff --git a/internal/e2e/selfserve/cut_test.go b/internal/e2e/selfserve/cut_test.go| index 56be85c0f..b795db25b 100644 |
| --- a/internal/e2e/selfserve/cut_test.go |
| +++ b/internal/e2e/selfserve/cut_test.go |
| @@ -616,6 +616,41 @@ func TestOperatorDoorsFinaliseASelfServeMove(t *testing.T) { |
| t.Fatalf("finished %s, reservations %v", describe(r), w.reservations()) |
| } |
| }) |
| + t.Run("an operator's start takes the move", func(t *testing.T) { |
| + // The fix round's pin (Controller ruling 5): an operator starts a |
| + // self-serve staged move; the job yields it ("another move is in |
| + // flight") without releasing the reservations, and writes nothing |
| + // more; the operator's own clearing door releases them. |
| + w := newJobWorld(t) |
| + row := w.admit() |
| + w.phase(row.ID, "ready", 60) |
| + if code, out := w.door("POST", "", `{"region":"uk"}`); code != 202 { |
| + t.Fatalf("start = %d %s", code, out) |
| + } |
| + w.consoleUntil(3, "yielded", func() bool { return w.row(row.ID).Outcome != "" }) |
| + r := w.row(row.ID) |
| + if r.Outcome != "aborted" || r.OutcomeReason != "another move is in flight" || r.ReleasePending || !w.reservedBy(row.ID) { |
| + t.Fatalf("yielded %s, release %v, reservations %v", describe(r), r.ReleasePending, w.reservations()) |
| + } |
| + seq := w.record().RecordSeq |
| + for i := 0; i < 3; i++ { |
| + w.tick() |
| + } |
| + if got := w.record().RecordSeq; got != seq { |
| + t.Fatalf("the job wrote the operator's record after yielding (seq %d, was %d)", got, seq) |
| + } |
| + // The operator aborts and finishes: the door releases. |
| + if code, out := w.door("POST", "/abort", ""); code != 202 { |
| + t.Fatalf("abort = %d %s", code, out) |
| + } |
| + for i := 0; i < 40 && w.record().Move != nil; i++ { |
| + w.boxes() |
| + w.door("POST", "/finish", "") |
| + } |
| + if rec := w.record(); rec.Move != nil || len(w.reservations()) != 0 { |
| + t.Fatalf("after the operator's finish: %+v, reservations %v", rec.Move, w.reservations()) |
| + } |
| + }) |
| t.Run("the operator's abort of a queued move", func(t *testing.T) { |
| w := newJobWorld(t) |
| row := w.admit() |
diff --git a/internal/e2e/selfserve/job_reconcile_test.go b/internal/e2e/selfserve/job_reconcile_test.go| index 4006c2f13..8970ebc23 100644 |
| --- a/internal/e2e/selfserve/job_reconcile_test.go |
| +++ b/internal/e2e/selfserve/job_reconcile_test.go |
| @@ -61,11 +61,16 @@ func TestReconcileForwardNeverRegresses(t *testing.T) { |
| {provision.OfflineFinishing, "finishing", "gate2"}, |
| {provision.OfflineReverting, "reverting", "finish"}, |
| } { |
| - t.Run("ready row, record "+c.state, func(t *testing.T) { |
| + t.Run("started row, record "+c.state, func(t *testing.T) { |
| + // The job started the move (Task 8: a row that never started it |
| + // yields to the operator who did, below). |
| w := newJobWorld(t) |
| row := w.admit() |
| w.phase(row.ID, "ready", 60) |
| w.down() |
| + if c.state != provision.OfflineReverting { |
| + w.setPhase(row.ID, "stopping", "seal") |
| + } |
| w.operatorTo(row.ID, c.state) |
| w.consoleUntil(3, "reconciled to "+c.phase, func() bool { return w.row(row.ID).Phase == c.phase }) |
| if r := w.row(row.ID); r.PhaseStep != c.step || r.Outcome != "" { |
| @@ -83,6 +88,29 @@ func TestReconcileForwardNeverRegresses(t *testing.T) { |
| }) |
| } |
| + // Task 8: an operator's start of a ready row's move takes it: the job |
| + // yields (aborted "another move is in flight", the reservations left to |
| + // the operator's move), whatever state the operator took it to. |
| + for _, state := range []string{provision.OfflineStopping, provision.OfflineSealed, provision.OfflineLanded, provision.OfflineOpen, provision.OfflineFinishing} { |
| + t.Run("ready row, an operator's move at "+state, func(t *testing.T) { |
| + w := newJobWorld(t) |
| + row := w.admit() |
| + w.phase(row.ID, "ready", 60) |
| + w.down() |
| + before := w.record().RecordSeq |
| + w.operatorTo(row.ID, state) |
| + after := w.record().RecordSeq |
| + w.consoleUntil(3, "yielded", func() bool { return w.row(row.ID).Outcome != "" }) |
| + r := w.row(row.ID) |
| + if r.Outcome != "aborted" || r.OutcomeReason != "another move is in flight" || r.ReleasePending || !w.reservedBy(row.ID) { |
| + t.Fatalf("yielded %s release %v reservations %v", describe(r), r.ReleasePending, w.reservations()) |
| + } |
| + if rec := w.record(); rec.RecordSeq != after || before == after { |
| + t.Fatalf("the job wrote the operator's record: seq %d, the operator left %d", rec.RecordSeq, after) |
| + } |
| + }) |
| + } |
| + |
| // Rows already in the record's set stay. |
| stays := func(t *testing.T, w *jobWorld, id, phase string) { |
| t.Helper() |
| @@ -124,6 +152,7 @@ WHERE id = ?`, time.Now().UnixNano(), rec.Incarnation, rec.RecordSeq, time.Now() |
| row := w.admit() |
| w.phase(row.ID, "ready", 60) |
| w.down() |
| + w.setPhase(row.ID, "stopping", "seal") |
| w.operatorTo(row.ID, provision.OfflineSealed) |
| w.consoleUntil(3, "sealing", func() bool { return w.row(row.ID).Phase == "sealing" }) |
| w.setPhase(row.ID, "landing", "") |
| @@ -135,6 +164,7 @@ WHERE id = ?`, time.Now().UnixNano(), rec.Incarnation, rec.RecordSeq, time.Now() |
| row := w.admit() |
| w.phase(row.ID, "ready", 60) |
| w.down() |
| + w.setPhase(row.ID, "stopping", "seal") |
| w.operatorTo(row.ID, provision.OfflineOpen) |
| w.consoleUntil(3, "opened", func() bool { return w.row(row.ID).Phase == "opened" }) |
| step := "" |
| @@ -275,6 +305,7 @@ WHERE id = ?`, time.Now().UnixNano(), rec.Incarnation, rec.RecordSeq, time.Now() |
| row := w.admit() |
| w.phase(row.ID, "ready", 60) |
| w.down() |
| + w.setPhase(row.ID, "stopping", "seal") |
| w.operatorTo(row.ID, provision.OfflineLanded) |
| w.consoleUntil(3, "landed", func() bool { return w.row(row.ID).Phase == "landed" }) |
| w.setPhase(row.ID, "open-uncertain", "") |