Add pause and cancel to a running migration
A live run could only be stopped one account at a time, and stopping it at all meant losing the queue: the accounts that had not started yet stayed idle with no record that they were meant to run. A run now carries a handle holding the context that stops every account under it plus the reason it was stopped. Pause and cancel take the same path and differ only in the status left behind — paused accounts are what Resume re-runs, and the migration journal makes each one continue where it stopped instead of re-copying. Accounts still queued when the stop lands get the same status as the interrupted ones, so the whole remainder is resumable after a pause and cancelled after a cancel. Database writes keep using the uncancellable context, so statuses and counters survive the stop. The scheduler skips paused tasks: auto-starting a full run would defeat the pause. An operator stopping a run no longer trips the schedule breaker either — that is for failures, not for intent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
package orchestrator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStopReasonAccountStatus(t *testing.T) {
|
||||
if got := stopPaused.accountStatus(); got != "paused" {
|
||||
t.Fatalf("stopPaused = %q want paused", got)
|
||||
}
|
||||
if got := stopCancelled.accountStatus(); got != "cancelled" {
|
||||
t.Fatalf("stopCancelled = %q want cancelled", got)
|
||||
}
|
||||
// A run stopped without an operator reason (per-account cancel, stall
|
||||
// watchdog) must not look like a pause, or Resume would pick it up.
|
||||
if got := stopNone.accountStatus(); got != "cancelled" {
|
||||
t.Fatalf("stopNone = %q want cancelled", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The first stop wins: a cancel arriving after a pause must not downgrade the
|
||||
// accounts a pause already promised to keep resumable, and vice versa.
|
||||
func TestRunHandleFirstStopWins(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
first, later stopReason
|
||||
}{
|
||||
{"pause then cancel", stopPaused, stopCancelled},
|
||||
{"cancel then pause", stopCancelled, stopPaused},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
h := &runHandle{cancel: cancel}
|
||||
h.stopWith(tc.first)
|
||||
h.stopWith(tc.later)
|
||||
if got := h.stopReason(); got != tc.first {
|
||||
t.Fatalf("reason = %v want %v", got, tc.first)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHandleStopCancelsContext(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
h := &runHandle{cancel: cancel}
|
||||
if ctx.Err() != nil {
|
||||
t.Fatal("context cancelled before stop")
|
||||
}
|
||||
h.stopWith(stopPaused)
|
||||
if ctx.Err() == nil {
|
||||
t.Fatal("stop must cancel the run context")
|
||||
}
|
||||
}
|
||||
|
||||
// Pause/Cancel report false for a task with no live run, which the HTTP layer
|
||||
// turns into 409 instead of pretending it stopped something.
|
||||
func TestStopRunWithoutLiveRun(t *testing.T) {
|
||||
o := &Orchestrator{runs: map[int64]*runHandle{}}
|
||||
if o.PauseTask(1) {
|
||||
t.Fatal("PauseTask must report false with no live run")
|
||||
}
|
||||
if o.CancelTask(1) {
|
||||
t.Fatal("CancelTask must report false with no live run")
|
||||
}
|
||||
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
h := &runHandle{cancel: cancel}
|
||||
o.registerRun(1, h)
|
||||
if !o.PauseTask(1) {
|
||||
t.Fatal("PauseTask must report true for a live run")
|
||||
}
|
||||
if got := h.stopReason(); got != stopPaused {
|
||||
t.Fatalf("reason = %v want stopPaused", got)
|
||||
}
|
||||
o.unregisterRun(1)
|
||||
if o.CancelTask(1) {
|
||||
t.Fatal("unregistered run must not be stoppable")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user