| index 238599cf9..1e52c760b 100644 |
| --- a/internal/provision/offline_move_test.go |
| +++ b/internal/provision/offline_move_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "context" |
| "encoding/json" |
| "errors" |
| + "math" |
| "reflect" |
| "strings" |
| "testing" |
| @@ -208,8 +209,13 @@ func TestOfflineRecordDiffersOnlyWhereTheMoveSaysSo(t *testing.T) { |
| t.Fatalf("record_seq %d -> %d", before.RecordSeq, after.RecordSeq) |
| } |
| |
| + // move_seq changes, by the three transitions that changed who |
| + // serves the host: the hold, then open or the abort, then finish. |
| + if after.MoveSeq != before.MoveSeq+3 { |
| + t.Errorf("move_seq %d -> %d, want three advances", before.MoveSeq, after.MoveSeq) |
| + } |
| afterMap := asMap(t, after) |
| - allowed := map[string]bool{"region": true, "move": true, "last_move": true, "record_seq": true} |
| + allowed := map[string]bool{"region": true, "move": true, "last_move": true, "record_seq": true, "move_seq": true} |
| keys := map[string]bool{} |
| for k := range beforeMap { |
| keys[k] = true |
| @@ -853,3 +859,202 @@ func TestOfflineRefusalsAreTheRecordedSentences(t *testing.T) { |
| s, rec = fresh(t, 1) |
| refuse(t, s, rec, MoveGuardedCut(MoveGuard{ID: offID}), refusedOfflineKind) |
| } |
| + |
| +// moveSeqAt sets the record's move_seq to n, through the record writer, as a |
| +// record that has already been moved n times carries it. The write puts |
| +// record_seq into presence, so every later write advances it. |
| +func moveSeqAt(t *testing.T, s release.Store, rec Record, n uint64) { |
| + t.Helper() |
| + if _, err := updateRecord(WithRecordSequence(context.Background()), s, rec.Account, rec.App, rec.Host, func(r *Record) error { |
| + r.MoveSeq = n |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// offlineStageSteps is the staged start: stage, one more round, then the |
| +// operator takes the instance offline. |
| +func offlineStageSteps(now time.Time) []offlineStep { |
| + st := StageTarget{Stager: offSealer, Bucket: "data-uk", Region: "eu-west-2"} |
| + return []offlineStep{ |
| + {"stage", func() func(*Record) error { return OfflineStage(offID, "", "uk", "ops@example.com", st, now) }, ""}, |
| + {"restage", func() func(*Record) error { return OfflineRestage(offID, now.Add(time.Minute)) }, OfflineStaging}, |
| + {"start staged", func() func(*Record) error { |
| + return OfflineStartStaged(offID, "ops@example.com", now.Add(2*time.Minute)) |
| + }, OfflineStaging}, |
| + } |
| +} |
| + |
| +// The transition table of self-serve moves plan 1 Task 7: the hold, the |
| +// target's open, the source's reopening at an abort and the finish of a move |
| +// that went offline each advance move_seq by one and stamp move.seq with the |
| +// new value; every other transition leaves both alone. The record starts at |
| +// move_seq 4, a host moved before. |
| +func TestOfflineTransitionsAdvanceMoveSeq(t *testing.T) { |
| + ctx := context.Background() |
| + now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| + steps, staged := offlineSteps(now), offlineStageSteps(now) |
| + abort := offlineStep{"abort", func() func(*Record) error { return OfflineAbort(offID) }, ""} |
| + finish := offlineStep{"finish", func() func(*Record) error { return OfflineFinish(offID, now.Add(time.Hour)) }, ""} |
| + cat := func(parts ...[]offlineStep) []offlineStep { |
| + var out []offlineStep |
| + for _, p := range parts { |
| + out = append(out, p...) |
| + } |
| + return out |
| + } |
| + one := func(st offlineStep) []offlineStep { return []offlineStep{st} } |
| + for _, c := range []struct { |
| + name string |
| + walk []offlineStep // from rest, before the transition under test |
| + step offlineStep |
| + advance bool |
| + }{ |
| + {"start", nil, steps[0], true}, |
| + {"stage", nil, staged[0], false}, |
| + {"restage", staged[:1], staged[1], false}, |
| + {"start staged", staged[:2], staged[2], true}, |
| + {"seal", steps[:1], steps[1], false}, |
| + {"sealed by", steps[:2], steps[2], false}, |
| + {"flip", steps[:3], steps[3], false}, |
| + {"landed by", steps[:4], steps[4], false}, |
| + {"open", steps[:5], steps[5], true}, |
| + {"finishing", steps[:6], steps[6], false}, |
| + {"abort from staging", staged[:2], abort, false}, |
| + {"abort from stopping", steps[:1], abort, true}, |
| + {"abort from sealing", steps[:2], abort, true}, |
| + {"abort from sealed", steps[:3], abort, true}, |
| + {"abort from landing", steps[:4], abort, true}, |
| + {"abort from landed", steps[:5], abort, true}, |
| + {"abort from a staged start", staged, abort, true}, |
| + {"finish done", steps[:7], steps[7], true}, |
| + {"finish after an abort from stopping", cat(steps[:1], one(abort)), finish, true}, |
| + {"finish after an abort from a staged start", cat(staged, one(abort)), finish, true}, |
| + {"finish after an abort from staging", cat(staged[:2], one(abort)), finish, false}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := offlineRecord(t, s) |
| + moveSeqAt(t, s, rec, 4) |
| + offlineWalk(t, s, rec, c.walk) |
| + before := offlineGet(t, s, rec) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, c.step.mut()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := before.MoveSeq |
| + if c.advance { |
| + want++ |
| + } |
| + stored := offlineGet(t, s, rec) |
| + if got.MoveSeq != want || stored.MoveSeq != want { |
| + t.Fatalf("move_seq %d -> returned %d, stored %d; want %d", before.MoveSeq, got.MoveSeq, stored.MoveSeq, want) |
| + } |
| + if c.advance && stored.RecordSeq <= before.RecordSeq { |
| + t.Fatalf("the advance did not write: record_seq %d -> %d", before.RecordSeq, stored.RecordSeq) |
| + } |
| + switch { |
| + case stored.Move == nil: |
| + // finish clears the move; the record keeps the count. |
| + case c.advance && stored.Move.Seq != want: |
| + t.Fatalf("move.seq %d after an advance to %d", stored.Move.Seq, want) |
| + case !c.advance && before.Move != nil && stored.Move.Seq != before.Move.Seq: |
| + t.Fatalf("move.seq %d -> %d on a transition that does not advance", before.Move.Seq, stored.Move.Seq) |
| + case !c.advance && before.Move == nil && stored.Move.Seq != 0: |
| + t.Fatalf("a move created without the hold carries move.seq %d", stored.Move.Seq) |
| + } |
| + }) |
| + } |
| + |
| + // The repeated abort is a success that writes nothing, move_seq |
| + // included (TestOfflineAbort pins the bytes). |
| + t.Run("abort from reverting", func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := offlineRecord(t, s) |
| + offlineWalk(t, s, rec, cat(steps[:5], one(abort))) |
| + before := offlineGet(t, s, rec) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if after := offlineGet(t, s, rec); got.MoveSeq != before.MoveSeq || !reflect.DeepEqual(after, before) { |
| + t.Fatalf("a second abort moved the record: move_seq %d -> %d", before.MoveSeq, after.MoveSeq) |
| + } |
| + }) |
| + |
| + // A whole move from a host's first: the hold 1, open 2, finish 3; an |
| + // abort from landed: the hold 1, the reopening 2, finish 3. |
| + for _, c := range []struct { |
| + name string |
| + walk []offlineStep |
| + seqs []uint64 |
| + }{ |
| + {"done", steps, []uint64{1, 1, 1, 1, 1, 2, 2, 3}}, |
| + {"aborted from landed", cat(steps[:5], one(abort), one(finish)), []uint64{1, 1, 1, 1, 1, 2, 3}}, |
| + } { |
| + t.Run("from a first move, "+c.name, func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := offlineRecord(t, s) |
| + for i, st := range c.walk { |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, st.mut()) |
| + if err != nil { |
| + t.Fatalf("%s: %v", st.name, err) |
| + } |
| + if got.MoveSeq != c.seqs[i] || (got.Move != nil && got.Move.Seq != c.seqs[i]) { |
| + t.Fatalf("%s: move_seq %d move %+v, want %d", st.name, got.MoveSeq, got.Move, c.seqs[i]) |
| + } |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// A move_seq already at the largest value the console's counters hold makes |
| +// every advancing transition refuse with ErrLifetimeExhausted, and nothing is |
| +// written. |
| +func TestMoveSeqExhaustionAtEveryTransition(t *testing.T) { |
| + ctx := context.Background() |
| + now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| + steps, staged := offlineSteps(now), offlineStageSteps(now) |
| + abort := offlineStep{"abort", func() func(*Record) error { return OfflineAbort(offID) }, ""} |
| + for _, c := range []struct { |
| + name string |
| + walk []offlineStep |
| + step offlineStep |
| + }{ |
| + {"start", nil, steps[0]}, |
| + {"start staged", staged[:2], staged[2]}, |
| + {"open", steps[:5], steps[5]}, |
| + {"abort from stopping", steps[:1], abort}, |
| + {"abort from landed", steps[:5], abort}, |
| + {"finish done", steps[:7], steps[7]}, |
| + {"finish aborted", append(append([]offlineStep(nil), steps[:3]...), abort), offlineStep{"finish", func() func(*Record) error { return OfflineFinish(offID, now.Add(time.Hour)) }, ""}}, |
| + } { |
| + t.Run(c.name, func(t *testing.T) { |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := offlineRecord(t, s) |
| + offlineWalk(t, s, rec, c.walk) |
| + moveSeqAt(t, s, rec, math.MaxInt64) |
| + key := RecordKey(rec.Account, rec.App, rec.Host) |
| + rawBefore, etagBefore, err := s.Get(ctx, key) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, c.step.mut()); !errors.Is(err, ErrLifetimeExhausted) { |
| + t.Fatalf("%s at move_seq MaxInt64 = %v, want ErrLifetimeExhausted", c.step.name, err) |
| + } |
| + rawAfter, etagAfter, err := s.Get(ctx, key) |
| + if err != nil || !bytes.Equal(rawBefore, rawAfter) || etagBefore != etagAfter { |
| + t.Fatalf("%s wrote the record on exhaustion: %v", c.step.name, err) |
| + } |
| + }) |
| + } |
| + // One below the ceiling still advances, to the ceiling. |
| + s := release.NewDirStore(t.TempDir()) |
| + rec := offlineRecord(t, s) |
| + moveSeqAt(t, s, rec, math.MaxInt64-1) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, steps[0].mut()) |
| + if err != nil || got.MoveSeq != math.MaxInt64 || got.Move.Seq != math.MaxInt64 { |
| + t.Fatalf("start one below the ceiling: %v %+v", err, got) |
| + } |
| +} |