provision: the offline-queued state, the self-serve fields, receipts and caller-proposed stamps
MoveState gains self_serve, participants, queued_at, precheck_at and
return_of; last_move gains request. OfflineQueue creates a self-serve move
in offline-queued (move_seq unchanged, move.seq = move_seq, refused for a
recreated lifetime); OfflineQueuedToStaging, OfflinePrecheck,
OfflineSetEstimate and OfflineAbortAt are the queued steps. Every
transition the job writes takes a Receipt as its final parameter: a
non-empty nonce lands in self_serve.last_request (or last_move.request on a
clearing write) and is never a no-op; the operator doors pass Receipt{}.
OfflineStage and OfflineStart take returnOf.
Stamps are the caller's: the in-mutation clamp of OfflineStartStaged and
OfflineRestage is retired for ProposeStamp, max(now, the record's latest
stamp + 1 ns). A staged start not after staged_at is refused rather than
moved. The operator doors propose from the record their write reads until
Task 3 binds them to the record they observed; the abort door uses
OfflineAbortAt so a queued move clears. Every existing caller is updated
mechanically.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
15 files changed,
+611
−206
internal/console/apiinstancemove_offline.go+49 −13internal/console/apiinstancemove_offline_test.go+7 −7internal/console/claim_invariant_fixtures_test.go+4 −4internal/console/lifetimes_e2e_test.go+7 −7internal/host/movesoffline_e2e_test.go+1 −1internal/host/movesoffline_lifetime_test.go+7 −7internal/host/movesoffline_seal_test.go+1 −1internal/host/movesoffline_stage_test.go+15 −9internal/host/movesoffline_test.go+19 −7internal/provision/offline_move.go+333 −51internal/provision/offline_move_copy_test.go+4 −4internal/provision/offline_move_test.go+52 −52internal/provision/offline_stage_test.go+50 −34internal/provision/provision.go+53 −0internal/provision/reconcile_lifetime_test.go+9 −9
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index 103ac3927..3b2c34b09 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -628,11 +628,15 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re |
| writeRefusal(w, ref) |
| return |
| } |
| - s.offlineAdvance(w, r, d, provision.OfflineStartStaged(mv.ID, d.addr, time.Now())) |
| + s.offlineAdvance(w, r, d, offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStartStaged(mv.ID, d.addr, at, provision.Receipt{}) |
| + })) |
| return |
| } |
| s.offlineBegin(w, r, d, func(id string) func(*provision.Record) error { |
| - return provision.OfflineStart(id, d.rec.Region, to, d.addr, time.Now()) |
| + return offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStart(id, d.rec.Region, to, d.addr, at, "") |
| + }) |
| }) |
| } |
| @@ -660,7 +664,9 @@ func (s *Server) handleAPIInstanceOfflineStage(w http.ResponseWriter, r *http.Re |
| writeRefusal(w, ref) |
| return |
| } |
| - s.offlineAdvance(w, r, d, provision.OfflineRestage(mv.ID, time.Now())) |
| + s.offlineAdvance(w, r, d, offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineRestage(mv.ID, at, provision.Receipt{}) |
| + })) |
| return |
| } |
| for _, b := range append(fleet.boxesAt(d.rec.Region), fleet.boxesAt(to)...) { |
| @@ -691,7 +697,9 @@ func (s *Server) handleAPIInstanceOfflineStage(w http.ResponseWriter, r *http.Re |
| } |
| st := provision.StageTarget{Stager: moves.OfflineStager(names), Bucket: tgt.DataBucket, Region: tgt.DataBucketRegion} |
| s.offlineBegin(w, r, d, func(id string) func(*provision.Record) error { |
| - return provision.OfflineStage(id, d.rec.Region, to, d.addr, st, time.Now()) |
| + return offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStage(id, d.rec.Region, to, d.addr, st, at, "") |
| + }) |
| }) |
| } |
| @@ -879,6 +887,9 @@ const ( |
| // wait for a box, not for the operator. |
| func offlineVerbOf(state string) string { |
| switch state { |
| + case provision.OfflineQueued: |
| + // The self-serve job moves a queued move on; no operator verb does. |
| + return "" |
| case provision.OfflineStaging: |
| return offlineVerbStart |
| case provision.OfflineStopping: |
| @@ -900,7 +911,19 @@ type offlineStep struct { |
| // offlineOpenMut is the open transition. The one place the door names it. |
| func offlineOpenMut(id string, now time.Time) func(*provision.Record) error { |
| - return provision.OfflineOpenTransition(id, now) |
| + return offlineStamped(now, func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineOpenTransition(id, at, provision.Receipt{}) |
| + }) |
| +} |
| + |
| +// offlineStamped is a stamped operator transition whose stamp is proposed |
| +// from the record the write reads: provision.ProposeStamp, max(now, the |
| +// record's latest stamp + 1 ns), so a clock that went back never writes an |
| +// earlier stamp. The operator doors pass the empty receipt. |
| +func offlineStamped(now time.Time, mut func(at time.Time) func(*provision.Record) error) func(*provision.Record) error { |
| + return func(rec *provision.Record) error { |
| + return mut(provision.ProposeStamp(rec, now))(rec) |
| + } |
| } |
| // offlineStepFor runs the checks of the verb the move's state waits for, |
| @@ -925,7 +948,9 @@ func (s *Server) offlineStepFor(ctx context.Context, d offlineDoor, mv *provisio |
| if ref := s.offlineStageCoords(fleet, mv, host); ref != nil { |
| return offlineStep{}, ref |
| } |
| - return offlineStep{mut: provision.OfflineStartStaged(mv.ID, d.addr, now)}, nil |
| + return offlineStep{mut: offlineStamped(now, func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStartStaged(mv.ID, d.addr, at, provision.Receipt{}) |
| + })}, nil |
| case provision.OfflineStopping: |
| fleet, ok := s.readOfflineFleet(ctx) |
| if !ok { |
| @@ -935,12 +960,12 @@ func (s *Server) offlineStepFor(ctx context.Context, d offlineDoor, mv *provisio |
| if ref != nil { |
| return offlineStep{}, ref |
| } |
| - return offlineStep{mut: provision.OfflineSeal(mv.ID, sealer, boxes)}, nil |
| + return offlineStep{mut: provision.OfflineSeal(mv.ID, sealer, boxes, provision.Receipt{})}, nil |
| case provision.OfflineSealed: |
| if ref := s.offlineFlipCheck(ev, mv, host, from, now); ref != nil { |
| return offlineStep{}, ref |
| } |
| - return offlineStep{mut: provision.OfflineFlip(mv.ID)}, nil |
| + return offlineStep{mut: provision.OfflineFlip(mv.ID, provision.Receipt{})}, nil |
| case provision.OfflineLanded: |
| if ref := s.offlineOpenCheck(ev, mv, host, from, now); ref != nil { |
| return offlineStep{}, ref |
| @@ -950,12 +975,12 @@ func (s *Server) offlineStepFor(ctx context.Context, d offlineDoor, mv *provisio |
| if ref := offlineFinishCheck(ev, mv, host, moves.OfflinePhaseOpened, "", moves.OfflinePhaseParked, mv.Boxes); ref != nil { |
| return offlineStep{}, ref |
| } |
| - return offlineStep{mut: provision.OfflineFinishingTransition(mv.ID)}, nil |
| + return offlineStep{mut: provision.OfflineFinishingTransition(mv.ID, provision.Receipt{})}, nil |
| case provision.OfflineFinishing: |
| if ref := offlineFinishCheck(ev, mv, host, "", moves.OfflinePhaseLifted, moves.OfflinePhaseGone, mv.Boxes); ref != nil { |
| return offlineStep{}, ref |
| } |
| - return offlineStep{mut: provision.OfflineFinish(mv.ID, now)}, nil |
| + return offlineStep{mut: offlineFinishMut(mv.ID, now)}, nil |
| case provision.OfflineReverting: |
| // Every box the seal named, and the sealer. A move aborted before |
| // the seal named neither: the boxes are the source region's as the |
| @@ -983,11 +1008,18 @@ func (s *Server) offlineStepFor(ctx context.Context, d offlineDoor, mv *provisio |
| return offlineStep{}, offlineStateRefusal(mv.State, host) |
| } |
| } |
| - return offlineStep{mut: provision.OfflineFinish(mv.ID, now)}, nil |
| + return offlineStep{mut: offlineFinishMut(mv.ID, now)}, nil |
| } |
| return offlineStep{}, offlineStateRefusal(mv.State, host) |
| } |
| +// offlineFinishMut is the finish transition, its stamp proposed. |
| +func offlineFinishMut(id string, now time.Time) func(*provision.Record) error { |
| + return offlineStamped(now, func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineFinish(id, at, provision.Receipt{}) |
| + }) |
| +} |
| + |
| // offlineSealCheck: every source box reports this move held, fenced and |
| // quiesced, and its edges file no longer lists the host. It names the sealer |
| // and the boxes the later doors wait for. |
| @@ -1227,7 +1259,11 @@ func (s *Server) handleAPIInstanceOfflineAbort(w http.ResponseWriter, r *http.Re |
| writeJSON(w, http.StatusAccepted, s.offlineViewOf(r.Context(), d.rec)) |
| return |
| } |
| - s.offlineAdvance(w, r, d, provision.OfflineAbort(mv.ID)) |
| + // OfflineAbortAt: a queued move is cleared at once (it holds nothing), |
| + // every other state is OfflineAbort's. |
| + s.offlineAdvance(w, r, d, offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineAbortAt(mv.ID, at, provision.Receipt{}) |
| + })) |
| } |
| // ---- status ---- |
| @@ -1360,7 +1396,7 @@ func (s *Server) offlineViewOf(ctx context.Context, rec *provision.Record) offli |
| v.OpenedAt = mv.OpenedAt.Unix() |
| } |
| switch mv.State { |
| - case provision.OfflineStaging, provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded: |
| + case provision.OfflineQueued, provision.OfflineStaging, provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded: |
| v.CanAbort = true |
| } |
| ev, err := s.readOfflineEvidence(ctx, rec.Host, mv.ID) |
diff --git a/internal/console/apiinstancemove_offline_test.go b/internal/console/apiinstancemove_offline_test.go| index 7b6d0c144..ba7162c75 100644 |
| --- a/internal/console/apiinstancemove_offline_test.go |
| +++ b/internal/console/apiinstancemove_offline_test.go |
| @@ -1069,7 +1069,7 @@ func TestOfflineAbortDoor(t *testing.T) { |
| w3.toLanded() |
| w3.ok("/open", "", provision.OfflineOpen) |
| w3.refused(http.MethodPost, "/abort", "", http.StatusConflict, offlineHost+" is already served from uk. move it back with a new move") |
| - w3.edit(provision.OfflineFinishingTransition(w3.id)) |
| + w3.edit(provision.OfflineFinishingTransition(w3.id, provision.Receipt{})) |
| w3.refused(http.MethodPost, "/abort", "", http.StatusConflict, offlineHost+" is already served from uk. move it back with a new move") |
| } |
| @@ -1180,11 +1180,11 @@ func TestOfflineConflictMapsOntoRecordedSentences(t *testing.T) { |
| mut func(*provision.Record) error |
| want string |
| }{ |
| - {"wrong state", provision.OfflineFlip(w.id), "this offline move is offline-stopping. this step follows offline-sealed"}, |
| - {"another move's id", provision.OfflineSeal(other, "ie-box", []string{"ie-box"}), "the record carries a different offline move"}, |
| - {"a seal without its sealer among the boxes", provision.OfflineSeal(w.id, "ie-x", []string{"ie-box"}), |
| + {"wrong state", provision.OfflineFlip(w.id, provision.Receipt{}), "this offline move is offline-stopping. this step follows offline-sealed"}, |
| + {"another move's id", provision.OfflineSeal(other, "ie-box", []string{"ie-box"}, provision.Receipt{}), "the record carries a different offline move"}, |
| + {"a seal without its sealer among the boxes", provision.OfflineSeal(w.id, "ie-x", []string{"ie-box"}, provision.Receipt{}), |
| "a seal names the box that seals and the boxes that stopped the instance. the box that seals is one of them"}, |
| - {"an abort already past", provision.OfflineFinish(w.id, time.Now()), "this offline move is offline-stopping. it finishes from finishing or reverting"}, |
| + {"an abort already past", provision.OfflineFinish(w.id, time.Now(), provision.Receipt{}), "this offline move is offline-stopping. it finishes from finishing or reverting"}, |
| } |
| for _, c := range cases { |
| err := apply(c.mut) |
| @@ -1206,8 +1206,8 @@ func TestOfflineConflictMapsOntoRecordedSentences(t *testing.T) { |
| w.stopped(nil, "ie-box") |
| w.ok("/flip", "", provision.OfflineLanding) |
| w.landed("uk-box") |
| - w.edit(provision.OfflineOpenTransition(w.id, time.Now())) |
| - err := apply(provision.OfflineAbort(w.id)) |
| + w.edit(provision.OfflineOpenTransition(w.id, time.Now(), provision.Receipt{})) |
| + err := apply(provision.OfflineAbort(w.id, provision.Receipt{})) |
| if got := offlineConflict(err); got != "this offline move is offline-open. it cannot be aborted once it is open" { |
| t.Errorf("abort after open = %q", got) |
| } |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 02ea1539d..97d08a452 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -565,18 +565,18 @@ func (cx *claimCase) offlineMove(t *testing.T, state string) { |
| t.Helper() |
| cx.movableInstance(t) |
| now := time.Now() |
| - steps := []func(*provision.Record) error{provision.OfflineStart(claimOfflineMoveID, "ie", "uk", claimFxOwner, now)} |
| + steps := []func(*provision.Record) error{provision.OfflineStart(claimOfflineMoveID, "ie", "uk", claimFxOwner, now, "")} |
| switch state { |
| case provision.OfflineStopping: |
| case provision.OfflineSealed, provision.OfflineLanded, provision.OfflineOpen: |
| steps = append(steps, |
| - provision.OfflineSeal(claimOfflineMoveID, "ie-box", []string{"ie-box"}), |
| + provision.OfflineSeal(claimOfflineMoveID, "ie-box", []string{"ie-box"}, provision.Receipt{}), |
| provision.OfflineSealedBy(claimOfflineMoveID, "ie-box", provision.MoveSource{})) |
| if state != provision.OfflineSealed { |
| - steps = append(steps, provision.OfflineFlip(claimOfflineMoveID), provision.OfflineLandedBy(claimOfflineMoveID, "uk")) |
| + steps = append(steps, provision.OfflineFlip(claimOfflineMoveID, provision.Receipt{}), provision.OfflineLandedBy(claimOfflineMoveID, "uk")) |
| } |
| if state == provision.OfflineOpen { |
| - steps = append(steps, provision.OfflineOpenTransition(claimOfflineMoveID, now)) |
| + steps = append(steps, provision.OfflineOpenTransition(claimOfflineMoveID, now, provision.Receipt{})) |
| } |
| default: |
| t.Fatalf("offlineMove: no staging for %s", state) |
diff --git a/internal/console/lifetimes_e2e_test.go b/internal/console/lifetimes_e2e_test.go| index 37599927a..ce134e1d0 100644 |
| --- a/internal/console/lifetimes_e2e_test.go |
| +++ b/internal/console/lifetimes_e2e_test.go |
| @@ -41,14 +41,14 @@ func (l ltOn) moveThrough(t *testing.T, srv *Server, host, id, to string) provis |
| now := l.clock.Now() |
| var rec *provision.Record |
| for _, mut := range []func(*provision.Record) error{ |
| - provision.OfflineStart(id, from, to, "paul@keymail.dev", now), |
| - provision.OfflineSeal(id, "ie-a", []string{"ie-a"}), |
| + provision.OfflineStart(id, from, to, "paul@keymail.dev", now, ""), |
| + provision.OfflineSeal(id, "ie-a", []string{"ie-a"}, provision.Receipt{}), |
| provision.OfflineSealedBy(id, "ie-a", provision.MoveSource{}), |
| - provision.OfflineFlip(id), |
| + provision.OfflineFlip(id, provision.Receipt{}), |
| provision.OfflineLandedBy(id, to), |
| - provision.OfflineOpenTransition(id, now), |
| - provision.OfflineFinishingTransition(id), |
| - provision.OfflineFinish(id, now), |
| + provision.OfflineOpenTransition(id, now, provision.Receipt{}), |
| + provision.OfflineFinishingTransition(id, provision.Receipt{}), |
| + provision.OfflineFinish(id, now, provision.Receipt{}), |
| } { |
| var err error |
| if rec, err = srv.offlineApply(ctx, d, mut); err != nil { |
| @@ -239,7 +239,7 @@ func TestLateLegacyCreateIsInert(t *testing.T) { |
| t.Errorf("UpdateRecord = %v, want not found", err) |
| } |
| if _, err := srv.offlineApply(ctx, offlineDoor{acct: l.acct(t), app: ltApp, host: ltOne}, |
| - provision.OfflineStart("mv_"+strings.Repeat("b", 26), "", "uk", "paul@keymail.dev", l.clock.Now())); !errors.Is(err, os.ErrNotExist) { |
| + provision.OfflineStart("mv_"+strings.Repeat("b", 26), "", "uk", "paul@keymail.dev", l.clock.Now(), "")); !errors.Is(err, os.ErrNotExist) { |
| t.Errorf("the offline start's write = %v, want not found", err) |
| } |
| srv.cfg.LifetimesMigrate = true |
diff --git a/internal/host/movesoffline_e2e_test.go b/internal/host/movesoffline_e2e_test.go| index acd1dec79..10f2327ab 100644 |
| --- a/internal/host/movesoffline_e2e_test.go |
| +++ b/internal/host/movesoffline_e2e_test.go |
| @@ -1670,7 +1670,7 @@ func offlineReleaseSkipsAMovedRecord(t *testing.T) { |
| return |
| } |
| moved++ |
| - w.wd.move(provision.OfflineFinishingTransition(w.id)) |
| + w.wd.move(provision.OfflineFinishingTransition(w.id, provision.Receipt{})) |
| } |
| w.tick(w.uk) |
| if moved != 1 { |
diff --git a/internal/host/movesoffline_lifetime_test.go b/internal/host/movesoffline_lifetime_test.go| index 9aa37e7a5..25ff02a12 100644 |
| --- a/internal/host/movesoffline_lifetime_test.go |
| +++ b/internal/host/movesoffline_lifetime_test.go |
| @@ -252,14 +252,14 @@ func TestReconstructedSourceAfterAbortWins(t *testing.T) { |
| // was down throughout finds afterwards. Each move advances move_seq by 3. |
| func (wd *offWorld) moveOnTheRecord(id, from, to string) { |
| wd.t.Helper() |
| - wd.move(provision.OfflineStart(id, from, to, e2eAddr, wd.clock())) |
| - wd.move(provision.OfflineSeal(id, "sealer", []string{"sealer"})) |
| + wd.move(provision.OfflineStart(id, from, to, e2eAddr, wd.clock(), "")) |
| + wd.move(provision.OfflineSeal(id, "sealer", []string{"sealer"}, provision.Receipt{})) |
| wd.move(provision.OfflineSealedBy(id, "sealer", provision.MoveSource{})) |
| - wd.move(provision.OfflineFlip(id)) |
| + wd.move(provision.OfflineFlip(id, provision.Receipt{})) |
| wd.move(provision.OfflineLandedBy(id, to)) |
| - wd.move(provision.OfflineOpenTransition(id, wd.clock())) |
| - wd.move(provision.OfflineFinishingTransition(id)) |
| - wd.move(provision.OfflineFinish(id, wd.clock())) |
| + wd.move(provision.OfflineOpenTransition(id, wd.clock(), provision.Receipt{})) |
| + wd.move(provision.OfflineFinishingTransition(id, provision.Receipt{})) |
| + wd.move(provision.OfflineFinish(id, wd.clock(), provision.Receipt{})) |
| } |
| // moveID is a well-formed offline move id, one per letter. |
| @@ -424,7 +424,7 @@ func TestRecreatedHostWithAStartedMoveIsHeldAtOnce(t *testing.T) { |
| w.wd.retireThroughTombstone(offHost) |
| w.wd.recreateLifetime(offHost, 2, "uk") |
| id := moveID("e") |
| - w.wd.move(provision.OfflineStart(id, "uk", "", e2eAddr, w.wd.clock())) |
| + w.wd.move(provision.OfflineStart(id, "uk", "", e2eAddr, w.wd.clock(), "")) |
| if rec := w.wd.record(); rec.Incarnation != 2 || rec.MoveSeq != 1 || rec.Move == nil || rec.Move.Seq != 1 { |
| t.Fatalf("fixture: the record is at (%d, %d), move %+v", rec.Incarnation, rec.MoveSeq, rec.Move) |
| } |
diff --git a/internal/host/movesoffline_seal_test.go b/internal/host/movesoffline_seal_test.go| index 15e7d04ba..4868f8588 100644 |
| --- a/internal/host/movesoffline_seal_test.go |
| +++ b/internal/host/movesoffline_seal_test.go |
| @@ -270,7 +270,7 @@ func (sw *sealWorld) startAndSeal(sealer string, boxes ...*offBox) { |
| b.tick() |
| names = append(names, b.name) |
| } |
| - sw.move(provision.OfflineSeal(offID, sealer, names)) |
| + sw.move(provision.OfflineSeal(offID, sealer, names, provision.Receipt{})) |
| } |
| func (sw *sealWorld) mirror() lease.MirrorStore { |
diff --git a/internal/host/movesoffline_stage_test.go b/internal/host/movesoffline_stage_test.go| index 047efc554..e685ad4a5 100644 |
| --- a/internal/host/movesoffline_stage_test.go |
| +++ b/internal/host/movesoffline_stage_test.go |
| @@ -54,13 +54,13 @@ func (sw *sealWorld) stage() { sw.stageBy("ie-a") } |
| // stageBy stages with the stager the door pinned. |
| func (sw *sealWorld) stageBy(stager string) { |
| - sw.move(provision.OfflineStage(offID, "", "uk", "op@example.com", provision.StageTarget{Stager: stager, Bucket: sealUK, Region: sealUKRegion}, sw.clock())) |
| + sw.move(provision.OfflineStage(offID, "", "uk", "op@example.com", provision.StageTarget{Stager: stager, Bucket: sealUK, Region: sealUKRegion}, sw.clock(), "")) |
| } |
| // restage is a second carlos move offline stage: one more round. |
| func (sw *sealWorld) restage() { |
| sw.advance(time.Second) |
| - sw.move(provision.OfflineRestage(offID, sw.clock())) |
| + sw.move(provision.OfflineRestage(offID, sw.clock(), provision.Receipt{})) |
| } |
| // copySpy records the source key of every server-side copy the fake S3 |
| @@ -222,10 +222,12 @@ func TestOfflineStageThenStartThenSealSkipsThePreCopy(t *testing.T) { |
| // only what changed since the last stage round. |
| preStaged := sw.stagedRel() |
| preReplica := sw.replicaRel() |
| - sw.move(provision.OfflineStartStaged(offID, "op@example.com", sw.clock())) |
| + sw.move(sw.proposed(func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStartStaged(offID, "op@example.com", at, provision.Receipt{}) |
| + })) |
| ie.tick() |
| ieB.tick() |
| - sw.move(provision.OfflineSeal(offID, "ie-a", []string{"ie-a", "ie-b"})) |
| + sw.move(provision.OfflineSeal(offID, "ie-a", []string{"ie-a", "ie-b"}, provision.Receipt{})) |
| spy.take() |
| sw.mustPass(ie) |
| if st := sw.record().Move.State; st != provision.OfflineSealed { |
| @@ -320,7 +322,7 @@ func TestOfflineAbortFromStagingRemovesTheStagedCopy(t *testing.T) { |
| if sw.fake.Snapshot() != snap { |
| t.Fatal("a second revert pass wrote to the buckets") |
| } |
| - rec := sw.move(provision.OfflineFinish(offID, sw.clock())) |
| + rec := sw.move(provision.OfflineFinish(offID, sw.clock(), provision.Receipt{})) |
| if rec.Move != nil || rec.LastMove == nil || rec.LastMove.Outcome != provision.OfflineOutcomeAborted || rec.LastMove.OfflineS != 0 { |
| t.Fatalf("finish: %+v", rec.LastMove) |
| } |
| @@ -339,7 +341,7 @@ func TestOfflineStageRefusesTheSourcesOwnBucket(t *testing.T) { |
| ie := sw.ie |
| sw.instance(ie) |
| sw.publishTarget(sealIE, sealIERegion) |
| - sw.move(provision.OfflineStage(offID, "", "uk", "op@example.com", provision.StageTarget{Stager: "ie-a", Bucket: sealIE, Region: sealIERegion}, sw.clock())) |
| + sw.move(provision.OfflineStage(offID, "", "uk", "op@example.com", provision.StageTarget{Stager: "ie-a", Bucket: sealIE, Region: sealIERegion}, sw.clock(), "")) |
| if err := sw.stagePass(ie); err == nil { |
| t.Fatal("staged into the source's own bucket") |
| } |
| @@ -454,7 +456,9 @@ func TestOfflineStageNoRoundAfterTheMoveLeftStaging(t *testing.T) { |
| sw.mustStagePass(ie) |
| sw.restage() |
| stale := sw.record() // a pass's listing, read while still staging |
| - sw.move(provision.OfflineStartStaged(offID, "op@example.com", sw.clock())) |
| + sw.move(sw.proposed(func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStartStaged(offID, "op@example.com", at, provision.Receipt{}) |
| + })) |
| sw.fake.Put(sealIE, sealPrefix+"/"+offHost+"/scratch/new.bin", []byte("new")) |
| spy := sw.spyCopies() |
| if err := ie.w.offlineRound(context.Background(), sw.s, stale); err != nil { |
| @@ -491,7 +495,7 @@ func TestOfflineStageRoundDeletesNothingOnceTheMoveLeftStaging(t *testing.T) { |
| once.Do(func() { |
| go func() { // the operator's start, while the copy is in flight |
| _, _ = provision.SetRecordMove(context.Background(), sw.s, sw.acct, "app", offHost, |
| - provision.OfflineStartStaged(offID, "op@example.com", time.Now())) |
| + provision.OfflineStartStaged(offID, "op@example.com", time.Now(), provision.Receipt{})) |
| }() |
| time.Sleep(200 * time.Millisecond) |
| }) |
| @@ -516,7 +520,9 @@ func TestOfflineStagerAcknowledgesItHasStopped(t *testing.T) { |
| sw.instance(ie) |
| sw.stage() |
| sw.mustStagePass(ie) |
| - sw.move(provision.OfflineStartStaged(offID, "op@example.com", sw.clock())) |
| + sw.move(sw.proposed(func(at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStartStaged(offID, "op@example.com", at, provision.Receipt{}) |
| + })) |
| ie.tick() |
| r := sw.report(moves.OfflineRoleStager, "ie-a") |
| if r.Phase != moves.OfflinePhaseStopped || !r.At.Equal(sw.clock()) || r.StagedObjects == 0 { |
diff --git a/internal/host/movesoffline_test.go b/internal/host/movesoffline_test.go| index 45cbea708..f6c443814 100644 |
| --- a/internal/host/movesoffline_test.go |
| +++ b/internal/host/movesoffline_test.go |
| @@ -92,6 +92,14 @@ func (wd *offWorld) create(host string) { |
| } |
| } |
| +// proposed is a stamped transition whose stamp is proposed from the record |
| +// the write reads at the world's clock (provision.ProposeStamp), as every |
| +// writer proposes it: the transitions store the stamp they are given. |
| +func (wd *offWorld) proposed(mut func(at time.Time) func(*provision.Record) error) func(*provision.Record) error { |
| + now := wd.clock() |
| + return func(rec *provision.Record) error { return mut(provision.ProposeStamp(rec, now))(rec) } |
| +} |
| + |
| // move applies one offline transition to offHost's record. |
| func (wd *offWorld) move(mut func(*provision.Record) error) provision.Record { |
| wd.t.Helper() |
| @@ -118,19 +126,23 @@ func (wd *offWorld) record() provision.Record { |
| // The legal path to a state, from the record at rest. |
| func (wd *offWorld) start() { |
| - wd.move(provision.OfflineStart(offID, "", "uk", "op@example.com", wd.clock())) |
| + wd.move(provision.OfflineStart(offID, "", "uk", "op@example.com", wd.clock(), "")) |
| +} |
| +func (wd *offWorld) seal() { |
| + wd.move(provision.OfflineSeal(offID, "ie-a", []string{"ie-a", "ie-b"}, provision.Receipt{})) |
| } |
| -func (wd *offWorld) seal() { wd.move(provision.OfflineSeal(offID, "ie-a", []string{"ie-a", "ie-b"})) } |
| func (wd *offWorld) sealed() { |
| wd.move(provision.OfflineSealedBy(offID, "ie-a", provision.MoveSource{})) |
| } |
| -func (wd *offWorld) flip() { wd.move(provision.OfflineFlip(offID)) } |
| +func (wd *offWorld) flip() { wd.move(provision.OfflineFlip(offID, provision.Receipt{})) } |
| func (wd *offWorld) landed() { wd.move(provision.OfflineLandedBy(offID, "uk")) } |
| -func (wd *offWorld) open() { wd.move(provision.OfflineOpenTransition(offID, wd.clock())) } |
| +func (wd *offWorld) open() { |
| + wd.move(provision.OfflineOpenTransition(offID, wd.clock(), provision.Receipt{})) |
| +} |
| func (wd *offWorld) finishing() { |
| - wd.move(provision.OfflineFinishingTransition(offID)) |
| + wd.move(provision.OfflineFinishingTransition(offID, provision.Receipt{})) |
| } |
| -func (wd *offWorld) abort() { wd.move(provision.OfflineAbort(offID)) } |
| +func (wd *offWorld) abort() { wd.move(provision.OfflineAbort(offID, provision.Receipt{})) } |
| // report reads one box's report for offID, or fails. |
| func (wd *offWorld) report(role, box string) moves.OfflineReport { |
| @@ -548,7 +560,7 @@ func TestOfflineRevertReleasesEverySourceRow(t *testing.T) { |
| } |
| } |
| // The move finishes; nothing holds the rows again. |
| - wd.move(provision.OfflineFinish(offID, wd.clock())) |
| + wd.move(provision.OfflineFinish(offID, wd.clock(), provision.Receipt{})) |
| for _, bx := range []*offBox{a, b} { |
| bx.tick() |
| if rt := bx.row(offHost); rt == nil || rt.Status != "ready" || rt.HoldOp != "" { |
diff --git a/internal/provision/offline_move.go b/internal/provision/offline_move.go| index 2ddc0dcbe..5cc11fa99 100644 |
| --- a/internal/provision/offline_move.go |
| +++ b/internal/provision/offline_move.go |
| @@ -33,6 +33,24 @@ import ( |
| // finish of a move that went offline (OfflineFinish). Each sets move.seq to |
| // the new value in the same write, so a box writes the row's serving_seq from |
| // the transition it applies. Every other transition leaves both alone. |
| +// |
| +// The self-serve move (self-serve moves plan 2 Task 2) adds offline-queued |
| +// before staging: OfflineQueue creates it, OfflineQueuedToStaging begins the |
| +// pre-copy, OfflinePrecheck and OfflineSetEstimate move one stamp or one |
| +// number, and OfflineAbortAt clears a queued move. None of them advances |
| +// move_seq: a queued move holds nothing. |
| +// |
| +// Stamps are the caller's. Every transition that takes `at` stores it as |
| +// given (in UTC) and reads no clock; every caller proposes it with |
| +// ProposeStamp, max(now, the latest stamp on the record it observed + 1 ns), |
| +// so a clock that went back never writes an earlier stamp. |
| +// |
| +// Receipts. Every transition the self-serve job writes takes a Receipt as |
| +// its last parameter. A non-empty nonce is written to move.self_serve. |
| +// last_request, or to last_move.request by a write that clears the move, |
| +// and such a write is never a no-op, so the job can always confirm it. The |
| +// operator doors pass Receipt{}, which leaves the receipt fields alone, so |
| +// an operator transition never confirms a job request. |
| // MoveKindOffline is MoveState.Kind for an offline operator move. Every other |
| // move leaves Kind empty. |
| @@ -40,7 +58,7 @@ const MoveKindOffline = "offline" |
| // The offline move's states, in the order a move takes them: |
| // |
| -// [offline-staging ->] offline-stopping -> offline-sealing -> offline-sealed |
| +// [[offline-queued ->] offline-staging ->] offline-stopping -> offline-sealing -> offline-sealed |
| // -> offline-landing -> offline-landed -> offline-open -> offline-finishing |
| // -> (cleared) |
| // |
| @@ -51,7 +69,11 @@ const MoveKindOffline = "offline" |
| // in staging. OfflineStartStaged then takes it to stopping, and the seal's |
| // own round finds the pre-copied objects and skips them by their receipts. |
| // offline-reverting is abort's state, reached from staging through landed. |
| +// offline-queued is the self-serve move's first state (OfflineQueue): the |
| +// job has admitted it and nothing has been copied; OfflineQueuedToStaging |
| +// takes it to staging and OfflineAbortAt clears it. |
| const ( |
| + OfflineQueued = "offline-queued" |
| OfflineStaging = "offline-staging" |
| OfflineStopping = "offline-stopping" |
| OfflineSealing = "offline-sealing" |
| @@ -87,12 +109,19 @@ const ( |
| // refusedOfflineSeal: a seal that names no sealer, no boxes, or a sealer |
| // that is not one of them (MV-O37). |
| refusedOfflineSeal = "a seal names the box that seals and the boxes that stopped the instance. the box that seals is one of them" |
| + // refusedNotSelfServe: a self-serve step (the estimate, or a receipt) |
| + // met a move the self-serve job did not queue. |
| + refusedNotSelfServe = "not a self-serve move" |
| + // refusedRecreated: the queue met a record of another lifetime than |
| + // the one the move was requested for. |
| + refusedRecreated = "the instance was recreated since the move was requested" |
| ) |
| // errRecordUnchanged is what a transition returns to say "the record already |
| // says this": updateRecord then writes nothing and returns the record as read |
| -// (no record_seq advance, no updated_at stamp). Only OfflineAbort from |
| -// reverting uses it. Applied outside updateRecord the mut returns it as an |
| +// (no record_seq advance, no updated_at stamp). OfflineAbort from reverting |
| +// and OfflineSetEstimate with the estimate already set use it, and only |
| +// under an empty receipt. Applied outside updateRecord the mut returns it as an |
| // error, so a transition that can return it is called only through |
| // SetRecordMove. |
| var errRecordUnchanged = errors.New("provision: the record already says this") |
| @@ -113,6 +142,71 @@ func advanceMoveSeq(rec *Record) error { |
| return nil |
| } |
| +// Receipt is the request a self-serve job write carries: Nonce is persisted |
| +// with the request before the write, and the record carrying it is the |
| +// confirmation that the write landed. The zero Receipt is the operator's. |
| +type Receipt struct{ Nonce string } |
| + |
| +// onMove writes the receipt into the move the record still carries. An |
| +// empty nonce changes nothing; a non-empty one on a move the job did not |
| +// queue is refused. Called after the transition's own checks. |
| +func (r Receipt) onMove(rec *Record) error { |
| + if r.Nonce == "" { |
| + return nil |
| + } |
| + if err := r.check(rec); err != nil { |
| + return err |
| + } |
| + ss := *rec.Move.SelfServe |
| + ss.LastRequest = r.Nonce |
| + rec.Move.SelfServe = &ss |
| + return nil |
| +} |
| + |
| +// check refuses a non-empty receipt for a move without SelfServe. |
| +func (r Receipt) check(rec *Record) error { |
| + if r.Nonce != "" && (rec.Move == nil || rec.Move.SelfServe == nil) { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, refusedNotSelfServe) |
| + } |
| + return nil |
| +} |
| + |
| +// ProposeStamp is the stamp a writer proposes for a transition decided on |
| +// rec: now, or one nanosecond after the latest stamp rec carries when the |
| +// clock is not past it. The latest stamp is the largest of the move's |
| +// requested_at, queued_at, staged_at, precheck_at, opened_at and |
| +// self_serve.offline_at, and last_move.at. A nil record proposes now. The |
| +// result is in UTC. |
| +func ProposeStamp(rec *Record, now time.Time) time.Time { |
| + t := now.UTC() |
| + if rec == nil { |
| + return t |
| + } |
| + var latest time.Time |
| + later := func(p *time.Time) { |
| + if p != nil && p.After(latest) { |
| + latest = *p |
| + } |
| + } |
| + if m := rec.Move; m != nil { |
| + later(&m.RequestedAt) |
| + later(m.QueuedAt) |
| + later(m.StagedAt) |
| + later(m.PrecheckAt) |
| + later(m.OpenedAt) |
| + if m.SelfServe != nil { |
| + later(m.SelfServe.OfflineAt) |
| + } |
| + } |
| + if rec.LastMove != nil { |
| + later(&rec.LastMove.At) |
| + } |
| + if !latest.IsZero() && !t.After(latest) { |
| + t = latest.Add(time.Nanosecond).UTC() |
| + } |
| + return t |
| +} |
| + |
| // Offline reports whether this is an offline operator move. Nil-safe, so a |
| // record at rest answers false. |
| func (m *MoveState) Offline() bool { return m != nil && m.Kind == MoveKindOffline } |
| @@ -149,9 +243,10 @@ func offlineIs(rec *Record, id string) error { |
| // a seeded record, an open run, a move already in flight, the same region and |
| // a replica. It also refuses a `from` that is not the record's region, and a |
| // malformed id. It stamps kind, id, from, to, state, requested_by and |
| -// requested_at; it does not touch the region. |
| -func OfflineStart(id, from, to, by string, now time.Time) func(*Record) error { |
| - return offlineBegin(id, from, to, by, now, OfflineStopping) |
| +// requested_at; it does not touch the region. returnOf is the move this one |
| +// returns ("" for any other), recorded as move.return_of. |
| +func OfflineStart(id, from, to, by string, now time.Time, returnOf string) func(*Record) error { |
| + return offlineBegin(id, from, to, by, now, OfflineStopping, returnOf) |
| } |
| // StageTarget is what a stage fixes for the whole move: the one box that |
| @@ -162,9 +257,9 @@ type StageTarget struct{ Stager, Bucket, Region string } |
| // OfflineStage is OfflineStart into offline-staging: the same refusals and |
| // the same stamps, plus staged_at, the stager and the staging destination |
| // (a stage that names any of them empty is malformed). The instance keeps |
| -// serving. |
| -func OfflineStage(id, from, to, by string, st StageTarget, now time.Time) func(*Record) error { |
| - begin := offlineBegin(id, from, to, by, now, OfflineStaging) |
| +// serving. returnOf is as OfflineStart's. |
| +func OfflineStage(id, from, to, by string, st StageTarget, now time.Time, returnOf string) func(*Record) error { |
| + begin := offlineBegin(id, from, to, by, now, OfflineStaging, returnOf) |
| return func(rec *Record) error { |
| if st.Stager == "" || st.Bucket == "" { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart) |
| @@ -177,7 +272,7 @@ func OfflineStage(id, from, to, by string, st StageTarget, now time.Time) func(* |
| } |
| } |
| -func offlineBegin(id, from, to, by string, now time.Time, state string) func(*Record) error { |
| +func offlineBegin(id, from, to, by string, now time.Time, state, returnOf string) func(*Record) error { |
| return func(rec *Record) error { |
| if err := seedMoveRefusal(rec); err != nil { |
| return err |
| @@ -208,22 +303,150 @@ func offlineBegin(id, from, to, by string, now time.Time, state string) func(*Re |
| State: state, |
| RequestedBy: by, |
| RequestedAt: now.UTC(), |
| + ReturnOf: returnOf, |
| } |
| - if state == OfflineStaging { |
| + switch state { |
| + case OfflineStaging: |
| t := now.UTC() |
| rec.Move.StagedAt = &t |
| return nil // a stage holds nothing |
| + case OfflineQueued: |
| + t := now.UTC() |
| + q, st, pc := t, t, t |
| + rec.Move.QueuedAt, rec.Move.StagedAt, rec.Move.PrecheckAt = &q, &st, &pc |
| + rec.Move.Seq = rec.MoveSeq |
| + return nil // a queued move holds nothing |
| } |
| return advanceMoveSeq(rec) // the direct start is the hold |
| } |
| } |
| -// OfflineStartStaged is staging to stopping: the operator takes a staged |
| -// instance offline. The record's region must still be the move's source. |
| -// It stamps requested_by and requested_at anew, since the instance is |
| -// offline from now, and requested_at is always after staged_at, which is |
| -// how OfflineFinish tells a move that started from one that never did. |
| -func OfflineStartStaged(id, by string, now time.Time) func(*Record) error { |
| +// OfflineQueue creates a self-serve move in offline-queued. It carries |
| +// OfflineStage's refusals (a seeded record, an open run, a move in flight, |
| +// the same region, a from that is not the record's region, a replica, a |
| +// malformed id, stamp or stage target) and refuses a record whose |
| +// incarnation is not the one the move was requested for. The move is |
| +// offline from `from` to `to`, staged by the first source participant into |
| +// st's bucket and region, and carries p and ss. requested_at, queued_at, |
| +// staged_at and precheck_at are all `at`, the caller's proposed stamp, so |
| +// StartedOffline is false until the start; move.seq is the record's |
| +// move_seq, which the queue does not advance. A non-empty receipt is ss's |
| +// last_request. |
| +func OfflineQueue(id, from, to, by string, st StageTarget, p Participants, ss SelfServeMove, at time.Time, req Receipt) func(*Record) error { |
| + begin := offlineBegin(id, from, to, by, at, OfflineQueued, "") |
| + return func(rec *Record) error { |
| + if st.Bucket == "" || len(p.Source) == 0 || p.Source[0].Label == "" { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart) |
| + } |
| + if rec.Incarnation != ss.Incarnation { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, refusedRecreated) |
| + } |
| + if err := begin(rec); err != nil { |
| + return err |
| + } |
| + pin := Participants{Source: append([]Participant(nil), p.Source...), Target: p.Target} |
| + self := ss |
| + if ss.OfflineAt != nil { |
| + t := *ss.OfflineAt |
| + self.OfflineAt = &t |
| + } |
| + if req.Nonce != "" { |
| + self.LastRequest = req.Nonce |
| + } |
| + m := rec.Move |
| + m.Stager, m.StageBucket, m.StageRegion = p.Source[0].Label, st.Bucket, st.Region |
| + m.Participants, m.SelfServe = &pin, &self |
| + return nil |
| + } |
| +} |
| + |
| +// OfflineQueuedToStaging is queued to staging: the job begins the pre-copy. |
| +// staged_at becomes `at`, the stage request the stager answers with one |
| +// round; requested_at stays, so the move still reads as never started. The |
| +// record's region must still be the move's source. |
| +func OfflineQueuedToStaging(id string, at time.Time, req Receipt) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := offlineAt(rec, id, OfflineQueued); err != nil { |
| + return err |
| + } |
| + if rec.Region != rec.Move.From { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, offlineRegionMoved(rec.Region)) |
| + } |
| + if at.IsZero() { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart) |
| + } |
| + if err := req.onMove(rec); err != nil { |
| + return err |
| + } |
| + t := at.UTC() |
| + rec.Move.State, rec.Move.StagedAt = OfflineStaging, &t |
| + return nil |
| + } |
| +} |
| + |
| +// offlineBeforeStart checks what the steps a self-serve move takes before |
| +// its start check: the record carries this offline move, queued or staging. |
| +func offlineBeforeStart(rec *Record, id string) error { |
| + if err := offlineIs(rec, id); err != nil { |
| + return err |
| + } |
| + if s := rec.Move.State; s != OfflineQueued && s != OfflineStaging { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, offlineWrongState(s, OfflineStaging)) |
| + } |
| + return nil |
| +} |
| + |
| +// OfflinePrecheck asks the participants for a precheck: precheck_at |
| +// becomes `at`, in queued or staging, and nothing else of the move changes |
| +// (the receipt aside). |
| +func OfflinePrecheck(id string, at time.Time, req Receipt) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := offlineBeforeStart(rec, id); err != nil { |
| + return err |
| + } |
| + if at.IsZero() { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart) |
| + } |
| + if err := req.onMove(rec); err != nil { |
| + return err |
| + } |
| + t := at.UTC() |
| + rec.Move.PrecheckAt = &t |
| + return nil |
| + } |
| +} |
| + |
| +// OfflineSetEstimate records the estimate the owner is shown, in seconds, |
| +// on a self-serve move in queued or staging. A move without SelfServe is |
| +// refused. The estimate already set writes nothing under an empty receipt |
| +// (errRecordUnchanged); a non-empty receipt is written all the same. |
| +func OfflineSetEstimate(id string, seconds int, req Receipt) func(*Record) error { |
| + return func(rec *Record) error { |
| + if err := offlineBeforeStart(rec, id); err != nil { |
| + return err |
| + } |
| + if rec.Move.SelfServe == nil { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, refusedNotSelfServe) |
| + } |
| + if rec.Move.SelfServe.EstimateSeconds == seconds && req.Nonce == "" { |
| + return errRecordUnchanged |
| + } |
| + ss := *rec.Move.SelfServe |
| + ss.EstimateSeconds = seconds |
| + rec.Move.SelfServe = &ss |
| + return req.onMove(rec) |
| + } |
| +} |
| + |
| +// OfflineStartStaged is staging to stopping: the operator or the job takes |
| +// a staged instance offline. The record's region must still be the move's |
| +// source. It stamps requested_by and requested_at anew (and |
| +// self_serve.offline_at on a self-serve move), since the instance is |
| +// offline from `at`. requested_at must be after staged_at, which is how |
| +// OfflineFinish tells a move that started from one that never did: the |
| +// caller's ProposeStamp always is, and a stamp that is not is refused as |
| +// malformed rather than moved. |
| +func OfflineStartStaged(id, by string, at time.Time, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineAt(rec, id, OfflineStaging); err != nil { |
| return err |
| @@ -231,28 +454,35 @@ func OfflineStartStaged(id, by string, now time.Time) func(*Record) error { |
| if rec.Region != rec.Move.From { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, offlineRegionMoved(rec.Region)) |
| } |
| - if now.IsZero() { |
| + if at.IsZero() || (rec.Move.StagedAt != nil && !at.After(*rec.Move.StagedAt)) { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart) |
| } |
| + if err := req.check(rec); err != nil { |
| + return err |
| + } |
| // The staged start is the hold. |
| if err := advanceMoveSeq(rec); err != nil { |
| return err |
| } |
| - t := now.UTC() |
| - if s := rec.Move.StagedAt; s != nil && !t.After(*s) { |
| - t = s.Add(time.Nanosecond) |
| - } |
| + t := at.UTC() |
| rec.Move.State, rec.Move.RequestedBy, rec.Move.RequestedAt = OfflineStopping, by, t |
| - return nil |
| + if rec.Move.SelfServe != nil { |
| + ss := *rec.Move.SelfServe |
| + off := t |
| + ss.OfflineAt = &off |
| + rec.Move.SelfServe = &ss |
| + } |
| + return req.onMove(rec) |
| } |
| } |
| -// OfflineRestage is staging to staging: the operator asked for another |
| -// pre-copy round. It moves staged_at to now (always forward), which is the |
| -// request the stager answers with exactly one round (the stager report's |
| -// stage_request names the staged_at it served). requested_at stays, so the |
| -// move still reads as never started. |
| -func OfflineRestage(id string, now time.Time) func(*Record) error { |
| +// OfflineRestage is staging to staging: the operator or the job asked for |
| +// another pre-copy round. It moves staged_at to `at`, the caller's proposed |
| +// stamp (ProposeStamp keeps it moving forward), which is the request the |
| +// stager answers with exactly one round (the stager report's stage_request |
| +// names the staged_at it served). requested_at stays, so the move still |
| +// reads as never started. |
| +func OfflineRestage(id string, at time.Time, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineAt(rec, id, OfflineStaging); err != nil { |
| return err |
| @@ -260,13 +490,13 @@ func OfflineRestage(id string, now time.Time) func(*Record) error { |
| if rec.Region != rec.Move.From { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, offlineRegionMoved(rec.Region)) |
| } |
| - if now.IsZero() { |
| + if at.IsZero() { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart) |
| } |
| - t := now.UTC() |
| - if s := rec.Move.StagedAt; s != nil && !t.After(*s) { |
| - t = s.Add(time.Nanosecond) |
| + if err := req.onMove(rec); err != nil { |
| + return err |
| } |
| + t := at.UTC() |
| rec.Move.StagedAt = &t |
| return nil |
| } |
| @@ -290,7 +520,7 @@ func regionWord(r string) string { |
| // OfflineSeal is stopping to sealing: every source box has confirmed the stop |
| // and the operator asked for the copy. It names the sealer (the one box that |
| // copies) and the source boxes whose reports the later doors wait for. |
| -func OfflineSeal(id, sealer string, boxes []string) func(*Record) error { |
| +func OfflineSeal(id, sealer string, boxes []string, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineAt(rec, id, OfflineStopping); err != nil { |
| return err |
| @@ -298,6 +528,9 @@ func OfflineSeal(id, sealer string, boxes []string) func(*Record) error { |
| if sealer == "" || len(boxes) == 0 || !slices.Contains(boxes, sealer) { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, refusedOfflineSeal) |
| } |
| + if err := req.onMove(rec); err != nil { |
| + return err |
| + } |
| rec.Move.State = OfflineSealing |
| rec.Move.Sealer = sealer |
| rec.Move.Boxes = append([]string(nil), boxes...) |
| @@ -330,7 +563,7 @@ func OfflineSealedBy(id, sealer string, src MoveSource) func(*Record) error { |
| // OfflineFlip is sealed to landing, and the one record write that moves the |
| // region: region = to. The record's region must still be the move's source, |
| // so a record that was changed under the move is refused rather than flipped. |
| -func OfflineFlip(id string) func(*Record) error { |
| +func OfflineFlip(id string, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineAt(rec, id, OfflineSealed); err != nil { |
| return err |
| @@ -338,6 +571,9 @@ func OfflineFlip(id string) func(*Record) error { |
| if rec.Region != rec.Move.From { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, offlineRegionMoved(rec.Region)) |
| } |
| + if err := req.onMove(rec); err != nil { |
| + return err |
| + } |
| rec.Region = rec.Move.To |
| rec.Move.State = OfflineLanding |
| return nil |
| @@ -360,15 +596,15 @@ func OfflineLandedBy(id, to string) func(*Record) error { |
| } |
| } |
| -// OfflineOpenTransition is landed to open: the operator opens the target. The |
| -// region must be the target's. It stamps opened_at from now, which is when the |
| -// instance came back and so what the finished move's offline time is counted |
| -// to. |
| +// OfflineOpenTransition is landed to open: the operator or the job opens the |
| +// target. The region must be the target's. It stamps opened_at with `at`, the |
| +// caller's proposed stamp, which is when the instance came back and so what |
| +// the finished move's offline time is counted to. |
| // |
| // The brief named this OfflineOpen, which is also the name of the state |
| // constant; Go has one namespace, so the two transitions that clash with a |
| // state (this one and OfflineFinishingTransition) carry a suffix. |
| -func OfflineOpenTransition(id string, now time.Time) func(*Record) error { |
| +func OfflineOpenTransition(id string, at time.Time, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineAt(rec, id, OfflineLanded); err != nil { |
| return err |
| @@ -376,23 +612,29 @@ func OfflineOpenTransition(id string, now time.Time) func(*Record) error { |
| if rec.Region != rec.Move.To { |
| return fmt.Errorf("%w: %s", ErrMoveConflict, offlineRegionMoved(rec.Region)) |
| } |
| + if err := req.check(rec); err != nil { |
| + return err |
| + } |
| // The target serves. |
| if err := advanceMoveSeq(rec); err != nil { |
| return err |
| } |
| - t := now.UTC() |
| + t := at.UTC() |
| rec.Move.State, rec.Move.OpenedAt = OfflineOpen, &t |
| - return nil |
| + return req.onMove(rec) |
| } |
| } |
| // OfflineFinishingTransition is open to finishing: the operator asked the |
| // source to clean up. After open the move only goes forward. |
| -func OfflineFinishingTransition(id string) func(*Record) error { |
| +func OfflineFinishingTransition(id string, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineAt(rec, id, OfflineOpen); err != nil { |
| return err |
| } |
| + if err := req.onMove(rec); err != nil { |
| + return err |
| + } |
| rec.Move.State = OfflineFinishing |
| return nil |
| } |
| @@ -405,15 +647,25 @@ func OfflineFinishingTransition(id string) func(*Record) error { |
| // the target already serves. From reverting it changes nothing and writes |
| // nothing (updateRecord sees errRecordUnchanged and returns the record as |
| // read), so a second abort is a success that leaves record_seq and updated_at |
| -// alone. Call it through SetRecordMove. |
| -func OfflineAbort(id string) func(*Record) error { |
| +// alone; under a non-empty receipt it writes the receipt and nothing else. A |
| +// queued move is refused: OfflineAbortAt clears it. Call it through |
| +// SetRecordMove or SetRecordMoveAt. |
| +func OfflineAbort(id string, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineIs(rec, id); err != nil { |
| return err |
| } |
| + if err := req.check(rec); err != nil { |
| + return err |
| + } |
| switch rec.Move.State { |
| case OfflineReverting: |
| - return errRecordUnchanged |
| + if req.Nonce == "" { |
| + return errRecordUnchanged |
| + } |
| + return req.onMove(rec) |
| + case OfflineQueued: |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, offlineWrongState(OfflineQueued, OfflineStaging)) |
| case OfflineStaging: |
| // Never offline: nothing reopens, move_seq stays. |
| case OfflineStopping, OfflineSealing, OfflineSealed, OfflineLanding, OfflineLanded: |
| @@ -428,6 +680,32 @@ func OfflineAbort(id string) func(*Record) error { |
| rec.Region = rec.Move.From |
| } |
| rec.Move.State = OfflineReverting |
| + return req.onMove(rec) |
| + } |
| +} |
| + |
| +// OfflineAbortAt is the abort that also clears a queued move: from |
| +// offline-queued it clears the move and leaves last_move {id, aborted, at, |
| +// no offline time}, the receipt in last_move.request, move_seq unchanged |
| +// (nothing was held or copied). From every other state it is OfflineAbort, |
| +// a repeated abort on a reverting record included. |
| +func OfflineAbortAt(id string, at time.Time, req Receipt) func(*Record) error { |
| + abort := OfflineAbort(id, req) |
| + return func(rec *Record) error { |
| + if err := offlineIs(rec, id); err != nil { |
| + return err |
| + } |
| + if rec.Move.State != OfflineQueued { |
| + return abort(rec) |
| + } |
| + if err := req.check(rec); err != nil { |
| + return err |
| + } |
| + if at.IsZero() { |
| + return fmt.Errorf("%w: %s", ErrMoveConflict, refusedMalformedStart) |
| + } |
| + rec.LastMove = &MoveOutcome{ID: id, Outcome: OfflineOutcomeAborted, At: at.UTC(), Request: req.Nonce} |
| + rec.Move = nil |
| return nil |
| } |
| } |
| @@ -436,16 +714,20 @@ func OfflineAbort(id string) func(*Record) error { |
| // legal from finishing (outcome done, region must be the target's) and from |
| // reverting (outcome aborted, region must be the source's); anything else is |
| // refused. The outcome's offline time runs from the request to the open for a |
| -// done move and to now for an aborted one; a move aborted while it was still |
| -// staging was never offline, and records none. |
| -func OfflineFinish(id string, now time.Time) func(*Record) error { |
| +// done move and to `at` (the caller's proposed stamp) for an aborted one; a |
| +// move aborted while it was still staging was never offline, and records |
| +// none. The receipt goes to last_move.request. |
| +func OfflineFinish(id string, at time.Time, req Receipt) func(*Record) error { |
| return func(rec *Record) error { |
| if err := offlineIs(rec, id); err != nil { |
| return err |
| } |
| + if err := req.check(rec); err != nil { |
| + return err |
| + } |
| m := rec.Move |
| - out := &MoveOutcome{ID: id, At: now.UTC()} |
| - end := now |
| + out := &MoveOutcome{ID: id, At: at.UTC(), Request: req.Nonce} |
| + end := at |
| switch m.State { |
| case OfflineFinishing: |
| if rec.Region != m.To { |
diff --git a/internal/provision/offline_move_copy_test.go b/internal/provision/offline_move_copy_test.go| index a277457ee..e8473bf11 100644 |
| --- a/internal/provision/offline_move_copy_test.go |
| +++ b/internal/provision/offline_move_copy_test.go |
| @@ -30,11 +30,11 @@ func TestOfflineHelperRefusalsMatchTheCopyRecord(t *testing.T) { |
| mut func(*Record) error |
| fills []string // in the order the placeholders appear |
| }{ |
| - {"MV-O36", 1, OfflineFlip(offID), []string{OfflineStopping, OfflineSealed}}, |
| + {"MV-O36", 1, OfflineFlip(offID, Receipt{}), []string{OfflineStopping, OfflineSealed}}, |
| {"MV-O38", 2, OfflineSealedBy(offID, "box-b", MoveSource{}), []string{offSealer}}, |
| - {"MV-O40", 6, OfflineAbort(offID), []string{OfflineOpen}}, |
| - {"MV-O41", 1, OfflineFinish(offID, now), []string{OfflineStopping}}, |
| - {"MV-O43", 0, OfflineStart(offID, "us", "uk", "ops@example.com", now), []string{"home", "us"}}, |
| + {"MV-O40", 6, OfflineAbort(offID, Receipt{}), []string{OfflineOpen}}, |
| + {"MV-O41", 1, OfflineFinish(offID, now, Receipt{}), []string{OfflineStopping}}, |
| + {"MV-O43", 0, OfflineStart(offID, "us", "uk", "ops@example.com", now, ""), []string{"home", "us"}}, |
| } { |
| t.Run(c.id, func(t *testing.T) { |
| text, ok := approved[c.id] |
diff --git a/internal/provision/offline_move_test.go b/internal/provision/offline_move_test.go| index 1e52c760b..673c6c719 100644 |
| --- a/internal/provision/offline_move_test.go |
| +++ b/internal/provision/offline_move_test.go |
| @@ -52,14 +52,14 @@ type offlineStep struct { |
| func offlineSteps(now time.Time) []offlineStep { |
| src := MoveSource{Replication: "cold", NextDue: 99, FinalTXID: 42, Release: "v0.28.4", EnvDigest: "d1", Files: &MoveFilesDigest{Plain: "p", Sealed: "s", Bytes: 7, Entries: 2}} |
| return []offlineStep{ |
| - {"start", func() func(*Record) error { return OfflineStart(offID, "", "uk", "ops@example.com", now) }, ""}, |
| - {"seal", func() func(*Record) error { return OfflineSeal(offID, offSealer, offBoxes) }, OfflineStopping}, |
| + {"start", func() func(*Record) error { return OfflineStart(offID, "", "uk", "ops@example.com", now, "") }, ""}, |
| + {"seal", func() func(*Record) error { return OfflineSeal(offID, offSealer, offBoxes, Receipt{}) }, OfflineStopping}, |
| {"sealed", func() func(*Record) error { return OfflineSealedBy(offID, offSealer, src) }, OfflineSealing}, |
| - {"flip", func() func(*Record) error { return OfflineFlip(offID) }, OfflineSealed}, |
| + {"flip", func() func(*Record) error { return OfflineFlip(offID, Receipt{}) }, OfflineSealed}, |
| {"landed", func() func(*Record) error { return OfflineLandedBy(offID, "uk") }, OfflineLanding}, |
| - {"open", func() func(*Record) error { return OfflineOpenTransition(offID, now.Add(30*time.Minute)) }, OfflineLanded}, |
| - {"finishing", func() func(*Record) error { return OfflineFinishingTransition(offID) }, OfflineOpen}, |
| - {"finish", func() func(*Record) error { return OfflineFinish(offID, now.Add(time.Hour)) }, OfflineFinishing}, |
| + {"open", func() func(*Record) error { return OfflineOpenTransition(offID, now.Add(30*time.Minute), Receipt{}) }, OfflineLanded}, |
| + {"finishing", func() func(*Record) error { return OfflineFinishingTransition(offID, Receipt{}) }, OfflineOpen}, |
| + {"finish", func() func(*Record) error { return OfflineFinish(offID, now.Add(time.Hour), Receipt{}) }, OfflineFinishing}, |
| } |
| } |
| @@ -189,10 +189,10 @@ func TestOfflineRecordDiffersOnlyWhereTheMoveSaysSo(t *testing.T) { |
| seqs = append(seqs, got.RecordSeq) |
| } |
| if via != "done" { |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(time.Hour))); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(time.Hour), Receipt{})); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -275,7 +275,7 @@ func TestOfflineEveryIllegalPairIsRefused(t *testing.T) { |
| rec := offlineRecord(t, s) |
| offlineWalk(t, s, rec, at.walk) |
| if at.abort { |
| - if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)); err != nil { |
| + if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})); err != nil { |
| t.Fatal(err) |
| } |
| } |
| @@ -304,14 +304,14 @@ func TestOfflineWrongIDIsRefusedEverywhere(t *testing.T) { |
| now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| steps := offlineSteps(now) |
| wrong := map[string]func() func(*Record) error{ |
| - "seal": func() func(*Record) error { return OfflineSeal(otherOff, offSealer, offBoxes) }, |
| + "seal": func() func(*Record) error { return OfflineSeal(otherOff, offSealer, offBoxes, Receipt{}) }, |
| "sealed": func() func(*Record) error { return OfflineSealedBy(otherOff, offSealer, MoveSource{}) }, |
| - "flip": func() func(*Record) error { return OfflineFlip(otherOff) }, |
| + "flip": func() func(*Record) error { return OfflineFlip(otherOff, Receipt{}) }, |
| "landed": func() func(*Record) error { return OfflineLandedBy(otherOff, "uk") }, |
| - "open": func() func(*Record) error { return OfflineOpenTransition(otherOff, now) }, |
| - "finishing": func() func(*Record) error { return OfflineFinishingTransition(otherOff) }, |
| - "finish": func() func(*Record) error { return OfflineFinish(otherOff, now) }, |
| - "abort": func() func(*Record) error { return OfflineAbort(otherOff) }, |
| + "open": func() func(*Record) error { return OfflineOpenTransition(otherOff, now, Receipt{}) }, |
| + "finishing": func() func(*Record) error { return OfflineFinishingTransition(otherOff, Receipt{}) }, |
| + "finish": func() func(*Record) error { return OfflineFinish(otherOff, now, Receipt{}) }, |
| + "abort": func() func(*Record) error { return OfflineAbort(otherOff, Receipt{}) }, |
| } |
| // Each wrong-id step is tried in the state where its right-id twin is legal. |
| for i, st := range steps[1:] { |
| @@ -349,7 +349,7 @@ func TestOfflineStartRefusals(t *testing.T) { |
| start := func(t *testing.T, s release.Store, rec Record, id, from, to string) (*Record, error) { |
| t.Helper() |
| - return SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStart(id, from, to, "ops@example.com", now)) |
| + return SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStart(id, from, to, "ops@example.com", now, "")) |
| } |
| t.Run("stamps the head and leaves the region", func(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| @@ -429,7 +429,7 @@ func TestOfflineStartRefusals(t *testing.T) { |
| // before any journal exists, as MoveRequest does. |
| unplaced := rec |
| unplaced.Seed = &SeedSource{ID: "sd_1", Kind: seeds.KindReplica, State: seeds.StateReady, By: "ops@example.com", At: now} |
| - err := OfflineStart(offID, "", "uk", "ops@example.com", now)(&unplaced) |
| + err := OfflineStart(offID, "", "uk", "ops@example.com", now, "")(&unplaced) |
| var refusal *SeedMoveError |
| if !errors.Is(err, ErrMoveConflict) || !errors.As(err, &refusal) { |
| t.Fatalf("seed: %v", err) |
| @@ -443,7 +443,7 @@ func TestOfflineStartRefusals(t *testing.T) { |
| rec := offlineRecord(t, s) |
| running := rec |
| running.ActiveRun = &RunState{ID: "run_1", State: RunPicked, Deadline: now.Add(time.Hour)} |
| - err := OfflineStart(offID, "", "uk", "ops@example.com", now)(&running) |
| + err := OfflineStart(offID, "", "uk", "ops@example.com", now, "")(&running) |
| var open *MoveRunOpenError |
| if !errors.As(err, &open) { |
| t.Fatalf("open run: %v", err) |
| @@ -463,20 +463,20 @@ func TestOfflineSealAndSealedByChecks(t *testing.T) { |
| rec := offlineRecord(t, s) |
| offlineWalk(t, s, rec, steps[:1]) |
| for name, mut := range map[string]func(*Record) error{ |
| - "no sealer": OfflineSeal(offID, "", offBoxes), |
| - "no boxes": OfflineSeal(offID, offSealer, nil), |
| + "no sealer": OfflineSeal(offID, "", offBoxes, Receipt{}), |
| + "no boxes": OfflineSeal(offID, offSealer, nil, Receipt{}), |
| } { |
| if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut); !errors.Is(err, ErrMoveConflict) { |
| t.Errorf("%s: %v", name, err) |
| } |
| } |
| // The sealer is one of the boxes. |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineSeal(offID, "box-z", offBoxes)); !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), refusedOfflineSeal) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineSeal(offID, "box-z", offBoxes, Receipt{})); !errors.Is(err, ErrMoveConflict) || !strings.Contains(err.Error(), refusedOfflineSeal) { |
| t.Errorf("a sealer outside the boxes: %v", err) |
| } |
| // The seal copies the caller's slice. |
| boxes := []string{"box-a", "box-b"} |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineSeal(offID, offSealer, boxes)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineSeal(offID, offSealer, boxes, Receipt{})); err != nil { |
| t.Fatal(err) |
| } |
| boxes[0] = "changed" |
| @@ -513,7 +513,7 @@ func TestOfflineFlipAndLandedGuardTheRegion(t *testing.T) { |
| if _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Region = "us"; return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFlip(offID)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFlip(offID, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("flip over a changed region: %v", err) |
| } |
| @@ -527,7 +527,7 @@ func TestOfflineFlipAndLandedGuardTheRegion(t *testing.T) { |
| } |
| } |
| // An abort puts the region back; a landing that raced it is refused. |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineLandedBy(offID, "uk")); !errors.Is(err, ErrMoveConflict) { |
| @@ -555,7 +555,7 @@ func TestOfflineAbort(t *testing.T) { |
| if got.Move.State != c.state || got.Region != c.region { |
| t.Fatalf("setup %s: %s %q", c.state, got.Move.State, got.Region) |
| } |
| - got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})) |
| if err != nil { |
| t.Fatalf("abort from %s: %v", c.state, err) |
| } |
| @@ -569,7 +569,7 @@ func TestOfflineAbort(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - again, err := SetRecordMove(WithRecordSequence(ctx), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)) |
| + again, err := SetRecordMove(WithRecordSequence(ctx), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})) |
| rawAfter, etagAfter, gerr := s.Get(ctx, key) |
| if gerr != nil || !bytes.Equal(rawBefore, rawAfter) || etagBefore != etagAfter { |
| t.Fatalf("second abort from %s wrote the record: %v", c.state, gerr) |
| @@ -578,7 +578,7 @@ func TestOfflineAbort(t *testing.T) { |
| t.Fatalf("second abort from %s: %v %+v", c.state, err, again) |
| } |
| // The way back to rest is finish. |
| - done, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(time.Minute))) |
| + done, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(time.Minute), Receipt{})) |
| if err != nil || done.Move != nil || done.LastMove == nil || done.LastMove.Outcome != "aborted" || done.LastMove.ID != offID || done.Region != "" { |
| t.Fatalf("finish after abort from %s: %v %+v", c.state, err, done) |
| } |
| @@ -590,7 +590,7 @@ func TestOfflineAbort(t *testing.T) { |
| rec := offlineRecord(t, s) |
| offlineWalk(t, s, rec, steps[:walked]) |
| before := offlineGet(t, s, rec) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("abort from %s: %v", state, err) |
| } |
| if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) { |
| @@ -622,19 +622,19 @@ func TestOfflineFinishFromBothAndTheRegionGuard(t *testing.T) { |
| if _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Region = "us"; return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("done finish with the wrong region: %v", err) |
| } |
| s = release.NewDirStore(t.TempDir()) |
| rec = offlineRecord(t, s) |
| offlineWalk(t, s, rec, steps[:5]) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})); err != nil { |
| t.Fatal(err) |
| } |
| if _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Region = "uk"; return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("aborted finish with the target's region: %v", err) |
| } |
| } |
| @@ -753,7 +753,7 @@ func TestEveryLegacyAndPhase1TransitionRefusesAnOfflineMove(t *testing.T) { |
| s = release.NewDirStore(t.TempDir()) |
| rec = offlineRecord(t, s) |
| offlineWalk(t, s, rec, steps[:3]) |
| - if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)); err != nil { |
| + if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})); err != nil { |
| t.Fatal(err) |
| } |
| check("reverting") |
| @@ -777,7 +777,7 @@ func TestOfflineTransitionsRefuseOtherKinds(t *testing.T) { |
| t.Errorf("%s move: offline %s: %v", name, st.name, err) |
| } |
| } |
| - if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Errorf("%s move: offline abort: %v", name, err) |
| } |
| if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) { |
| @@ -824,28 +824,28 @@ func TestOfflineRefusalsAreTheRecordedSentences(t *testing.T) { |
| } |
| s, rec := fresh(t, 0) |
| - refuse(t, s, rec, OfflineSeal(offID, offSealer, offBoxes), refusedNoMove) |
| - refuse(t, s, rec, OfflineStart(offID, "us", "uk", "ops", now), "the instance is in home, not us") |
| - refuse(t, s, rec, OfflineStart(offID, "", "", "ops", now), "the instance is already in home") |
| - refuse(t, s, rec, OfflineStart("mv_short", "", "uk", "ops", now), refusedMalformedStart) |
| + refuse(t, s, rec, OfflineSeal(offID, offSealer, offBoxes, Receipt{}), refusedNoMove) |
| + refuse(t, s, rec, OfflineStart(offID, "us", "uk", "ops", now, ""), "the instance is in home, not us") |
| + refuse(t, s, rec, OfflineStart(offID, "", "", "ops", now, ""), "the instance is already in home") |
| + refuse(t, s, rec, OfflineStart("mv_short", "", "uk", "ops", now, ""), refusedMalformedStart) |
| s, rec = fresh(t, 1) |
| - refuse(t, s, rec, OfflineSeal(otherOff, offSealer, offBoxes), refusedOtherOfflineMove) |
| - refuse(t, s, rec, OfflineFlip(offID), "this offline move is offline-stopping. this step follows offline-sealed") |
| - refuse(t, s, rec, OfflineSeal(offID, "", offBoxes), refusedOfflineSeal) |
| - refuse(t, s, rec, OfflineFinish(offID, now), "this offline move is offline-stopping. it finishes from finishing or reverting") |
| + refuse(t, s, rec, OfflineSeal(otherOff, offSealer, offBoxes, Receipt{}), refusedOtherOfflineMove) |
| + refuse(t, s, rec, OfflineFlip(offID, Receipt{}), "this offline move is offline-stopping. this step follows offline-sealed") |
| + refuse(t, s, rec, OfflineSeal(offID, "", offBoxes, Receipt{}), refusedOfflineSeal) |
| + refuse(t, s, rec, OfflineFinish(offID, now, Receipt{}), "this offline move is offline-stopping. it finishes from finishing or reverting") |
| s, rec = fresh(t, 2) |
| refuse(t, s, rec, OfflineSealedBy(offID, "box-b", MoveSource{}), "this offline move is sealed by box-a") |
| s, rec = fresh(t, 6) |
| - refuse(t, s, rec, OfflineAbort(offID), "this offline move is offline-open. it cannot be aborted once it is open") |
| + refuse(t, s, rec, OfflineAbort(offID, Receipt{}), "this offline move is offline-open. it cannot be aborted once it is open") |
| s, rec = fresh(t, 3) |
| if _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Region = "us"; return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - refuse(t, s, rec, OfflineFlip(offID), "the record's region is us, which this step does not expect. the instance changed under the move") |
| + refuse(t, s, rec, OfflineFlip(offID, Receipt{}), "the record's region is us, which this step does not expect. the instance changed under the move") |
| // A legacy move meets an offline step. |
| s = release.NewDirStore(t.TempDir()) |
| @@ -853,7 +853,7 @@ func TestOfflineRefusalsAreTheRecordedSentences(t *testing.T) { |
| if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, MoveRequest("uk", now)); err != nil { |
| t.Fatal(err) |
| } |
| - refuse(t, s, rec, OfflineSeal(offID, offSealer, offBoxes), refusedNotOffline) |
| + refuse(t, s, rec, OfflineSeal(offID, offSealer, offBoxes, Receipt{}), refusedNotOffline) |
| // And the other way: the Phase 1 guard on an offline move. |
| s, rec = fresh(t, 1) |
| @@ -878,10 +878,10 @@ func moveSeqAt(t *testing.T, s release.Store, rec Record, n uint64) { |
| 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}, |
| + {"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), Receipt{}) }, OfflineStaging}, |
| {"start staged", func() func(*Record) error { |
| - return OfflineStartStaged(offID, "ops@example.com", now.Add(2*time.Minute)) |
| + return OfflineStartStaged(offID, "ops@example.com", now.Add(2*time.Minute), Receipt{}) |
| }, OfflineStaging}, |
| } |
| } |
| @@ -895,8 +895,8 @@ 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)) }, ""} |
| + abort := offlineStep{"abort", func() func(*Record) error { return OfflineAbort(offID, Receipt{}) }, ""} |
| + finish := offlineStep{"finish", func() func(*Record) error { return OfflineFinish(offID, now.Add(time.Hour), Receipt{}) }, ""} |
| cat := func(parts ...[]offlineStep) []offlineStep { |
| var out []offlineStep |
| for _, p := range parts { |
| @@ -974,7 +974,7 @@ func TestOfflineTransitionsAdvanceMoveSeq(t *testing.T) { |
| 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)) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -1016,7 +1016,7 @@ 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) }, ""} |
| + abort := offlineStep{"abort", func() func(*Record) error { return OfflineAbort(offID, Receipt{}) }, ""} |
| for _, c := range []struct { |
| name string |
| walk []offlineStep |
| @@ -1028,7 +1028,7 @@ func TestMoveSeqExhaustionAtEveryTransition(t *testing.T) { |
| {"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)) }, ""}}, |
| + {"finish aborted", append(append([]offlineStep(nil), steps[:3]...), abort), offlineStep{"finish", func() func(*Record) error { return OfflineFinish(offID, now.Add(time.Hour), Receipt{}) }, ""}}, |
| } { |
| t.Run(c.name, func(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
diff --git a/internal/provision/offline_stage_test.go b/internal/provision/offline_stage_test.go| index 9da62fd55..577d84afc 100644 |
| --- a/internal/provision/offline_stage_test.go |
| +++ b/internal/provision/offline_stage_test.go |
| @@ -21,7 +21,7 @@ func TestOfflineStageThenStartThenTheWholeMove(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| rec := offlineRecord(t, s) |
| - got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now, "")) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -31,15 +31,15 @@ func TestOfflineStageThenStartThenTheWholeMove(t *testing.T) { |
| t.Fatalf("staged record: %+v region %q", m, got.Region) |
| } |
| // Staging is a move in flight: no second move starts beside it. |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStart(otherOff, "", "uk", "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStart(otherOff, "", "uk", "ops@example.com", now, "")); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("a start beside a staged move: %v", err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(otherOff, "", "us", "ops@example.com", stageAt, now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(otherOff, "", "us", "ops@example.com", stageAt, now, "")); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("a stage beside a staged move: %v", err) |
| } |
| start := now.Add(10 * time.Minute) |
| - got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "other@example.com", start)) |
| + got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "other@example.com", start, Receipt{})) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -65,7 +65,7 @@ func TestOfflineStartStagedRefusals(t *testing.T) { |
| rec := offlineRecord(t, s) |
| offlineWalk(t, s, rec, steps[:walked]) |
| before := offlineGet(t, s, rec) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("start-staged from %s: %v", before.Move.State, err) |
| } |
| if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) { |
| @@ -74,24 +74,24 @@ func TestOfflineStartStagedRefusals(t *testing.T) { |
| } |
| s := release.NewDirStore(t.TempDir()) |
| rec := offlineRecord(t, s) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now, "")); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(otherOff, "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(otherOff, "ops@example.com", now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("start-staged with another id: %v", err) |
| } |
| // A region written under the staged move. |
| if _, err := updateRecord(ctx, s, rec.Account, rec.App, rec.Host, func(r *Record) error { r.Region = "us"; return nil }); err != nil { |
| t.Fatal(err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("start-staged over a moved region: %v", err) |
| } |
| // No other step leaves staging: seal through finish all refuse. |
| s = release.NewDirStore(t.TempDir()) |
| rec = offlineRecord(t, s) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now, "")); err != nil { |
| t.Fatal(err) |
| } |
| before := offlineGet(t, s, rec) |
| @@ -105,22 +105,34 @@ func TestOfflineStartStagedRefusals(t *testing.T) { |
| } |
| } |
| -// A start in the same instant as the stage still lands after it, so the |
| -// finish can tell the two apart. |
| +// A start proposed in the same instant as the stage, or with a clock behind |
| +// it, still lands after it (ProposeStamp), so the finish can tell the two |
| +// apart. The transition stores the caller's stamp and moves nothing: a stamp |
| +// that is not after staged_at is refused and nothing is written. |
| func TestOfflineStartStagedIsAlwaysAfterTheStage(t *testing.T) { |
| ctx := context.Background() |
| now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| s := release.NewDirStore(t.TempDir()) |
| rec := offlineRecord(t, s) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now, "")); err != nil { |
| t.Fatal(err) |
| } |
| - got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now.Add(-time.Second))) |
| + before := offlineGet(t, s, rec) |
| + for _, at := range []time.Time{now, now.Add(-time.Second)} { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", at, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("a start stamped %v, not after the stage: %v", at, err) |
| + } |
| + if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) { |
| + t.Fatal("a refused start changed the record") |
| + } |
| + } |
| + at := ProposeStamp(&before, now.Add(-time.Second)) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", at, Receipt{})) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if !got.Move.RequestedAt.After(*got.Move.StagedAt) || !got.Move.StartedOffline() { |
| - t.Fatalf("start %v is not after the stage %v", got.Move.RequestedAt, got.Move.StagedAt) |
| + if !got.Move.RequestedAt.After(*got.Move.StagedAt) || !got.Move.StartedOffline() || !got.Move.RequestedAt.Equal(at) { |
| + t.Fatalf("start %v is not after the stage %v, or not the proposed %v", got.Move.RequestedAt, got.Move.StagedAt, at) |
| } |
| } |
| @@ -132,14 +144,14 @@ func TestOfflineAbortFromStaging(t *testing.T) { |
| now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| s := release.NewDirStore(t.TempDir()) |
| rec := offlineRecord(t, s) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now, "")); err != nil { |
| t.Fatal(err) |
| } |
| - got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID)) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineAbort(offID, Receipt{})) |
| if err != nil || got.Move.State != OfflineReverting || got.Region != "" { |
| t.Fatalf("abort from staging: %v %+v", err, got) |
| } |
| - done, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(time.Hour))) |
| + done, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(time.Hour), Receipt{})) |
| if err != nil || done.Move != nil || done.LastMove.Outcome != OfflineOutcomeAborted || done.LastMove.OfflineS != 0 { |
| t.Fatalf("finish after abort from staging: %v %+v", err, done.LastMove) |
| } |
| @@ -147,15 +159,15 @@ func TestOfflineAbortFromStaging(t *testing.T) { |
| s = release.NewDirStore(t.TempDir()) |
| rec = offlineRecord(t, s) |
| for _, mut := range []func(*Record) error{ |
| - OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now), |
| - OfflineStartStaged(offID, "ops@example.com", now.Add(20*time.Minute)), |
| - OfflineAbort(offID), |
| + OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now, ""), |
| + OfflineStartStaged(offID, "ops@example.com", now.Add(20*time.Minute), Receipt{}), |
| + OfflineAbort(offID, Receipt{}), |
| } { |
| if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, mut); err != nil { |
| t.Fatal(err) |
| } |
| } |
| - done, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(30*time.Minute))) |
| + done, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineFinish(offID, now.Add(30*time.Minute), Receipt{})) |
| if err != nil || done.LastMove.Outcome != OfflineOutcomeAborted || done.LastMove.OfflineS != 600 { |
| t.Fatalf("finish after a started staged move: %v %+v", err, done.LastMove) |
| } |
| @@ -185,30 +197,34 @@ func TestOfflineRestage(t *testing.T) { |
| now := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| s := release.NewDirStore(t.TempDir()) |
| rec := offlineRecord(t, s) |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("restage at rest: %v", err) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now)); err != nil { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", stageAt, now, "")); err != nil { |
| t.Fatal(err) |
| } |
| - got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now.Add(5*time.Minute))) |
| + got, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now.Add(5*time.Minute), Receipt{})) |
| if err != nil || !got.Move.StagedAt.Equal(now.Add(5*time.Minute)) || !got.Move.RequestedAt.Equal(now) || got.Move.StartedOffline() || got.Move.State != OfflineStaging || got.Move.Stager != "box-a" { |
| t.Fatalf("restage: %v %+v", err, got.Move) |
| } |
| - // A clock behind the last request still moves it forward. |
| - got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now)) |
| - if err != nil || !got.Move.StagedAt.After(now.Add(5*time.Minute)) { |
| + // A clock behind the last request still moves it forward: the caller |
| + // proposes the stamp from the record it observed, and the transition |
| + // stores it as given. |
| + at := ProposeStamp(got, now) |
| + got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, at, Receipt{})) |
| + if err != nil || !got.Move.StagedAt.After(now.Add(5*time.Minute)) || !got.Move.StagedAt.Equal(at) { |
| t.Fatalf("restage with an earlier clock: %v %+v", err, got.Move) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(otherOff, now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(otherOff, now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("restage with another id: %v", err) |
| } |
| - // The start after a restage is still after the last request. |
| - got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", now.Add(time.Minute))) |
| + // The start after a restage is still after the last request, its |
| + // stamp proposed from the record with a clock behind it. |
| + got, err = SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineStartStaged(offID, "ops@example.com", ProposeStamp(got, now.Add(time.Minute)), Receipt{})) |
| if err != nil || !got.Move.StartedOffline() || got.Move.State != OfflineStopping { |
| t.Fatalf("start after restage: %v %+v", err, got.Move) |
| } |
| - if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now)); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, OfflineRestage(offID, now, Receipt{})); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("restage after the start: %v", err) |
| } |
| } |
| @@ -222,10 +238,10 @@ func TestOfflineStageNamesItsStager(t *testing.T) { |
| s := release.NewDirStore(t.TempDir()) |
| rec := offlineRecord(t, s) |
| before := offlineGet(t, s, rec) |
| - if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", StageTarget{Bucket: "data-uk"}, time.Now())); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", StageTarget{Bucket: "data-uk"}, time.Now(), "")); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("a stage with no stager: %v", err) |
| } |
| - if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", StageTarget{Stager: "box-a"}, time.Now())); !errors.Is(err, ErrMoveConflict) { |
| + if _, err := SetRecordMove(context.Background(), s, rec.Account, rec.App, rec.Host, OfflineStage(offID, "", "uk", "ops@example.com", StageTarget{Stager: "box-a"}, time.Now(), "")); !errors.Is(err, ErrMoveConflict) { |
| t.Fatalf("a stage with no staging bucket: %v", err) |
| } |
| if after := offlineGet(t, s, rec); !reflect.DeepEqual(after, before) { |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index 35b0faaf8..9f687a486 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1174,6 +1174,55 @@ type MoveState struct { |
| // row's serving_seq. Additive and omitempty; 0 on a move whose |
| // transitions so far change nobody's serving (a staged move). |
| Seq uint64 `json:"seq,omitempty"` |
| + |
| + // The self-serve move's fields (self-serve moves plan 2 Task 2, |
| + // offline_move.go). All are additive and omitempty: a journal written |
| + // before them parses and re-encodes byte for byte. SelfServe is set |
| + // only on a move the self-serve job queued (OfflineQueue); its |
| + // LastRequest is the receipt of the job's latest write. |
| + SelfServe *SelfServeMove `json:"self_serve,omitempty"` |
| + // Participants are the boxes the move admitted, pinned at the queue. |
| + Participants *Participants `json:"participants,omitempty"` |
| + // QueuedAt is when the move was queued; PrecheckAt is the latest |
| + // precheck request (OfflinePrecheck), the stamp the boxes' precheck |
| + // reports answer. |
| + QueuedAt *time.Time `json:"queued_at,omitempty"` |
| + PrecheckAt *time.Time `json:"precheck_at,omitempty"` |
| + // ReturnOf names the move this one returns, "" for any other move |
| + // (OfflineStage, OfflineStart). |
| + ReturnOf string `json:"return_of,omitempty"` |
| +} |
| + |
| +// SelfServeMove is the self-serve part of an offline move: the row the job |
| +// drives (ID), the lifetime it was requested for, who asked, the estimate |
| +// the owner was shown, when the instance went offline, and LastRequest, the |
| +// receipt: every self-serve mutation sets it to the request's nonce, and a |
| +// request is confirmed by the record carrying it. |
| +type SelfServeMove struct { |
| + ID string `json:"id"` |
| + Incarnation uint64 `json:"incarnation"` |
| + DisplayName string `json:"display_name,omitempty"` |
| + RequestedBy string `json:"requested_by,omitempty"` |
| + EstimateSeconds int `json:"estimate_seconds,omitempty"` |
| + OfflineAt *time.Time `json:"offline_at,omitempty"` |
| + LastRequest string `json:"last_request,omitempty"` |
| +} |
| + |
| +// Participant is one box a move admitted: its box id and label from the |
| +// edges file, the instance id its status report named (empty when no fresh |
| +// report did), and whether that id was verified. |
| +type Participant struct { |
| + BoxID string `json:"box_id"` |
| + Label string `json:"label"` |
| + InstanceID string `json:"instance_id,omitempty"` |
| + Verified bool `json:"verified,omitempty"` |
| +} |
| + |
| +// Participants are a move's pinned boxes: the source boxes (the first is |
| +// the stager) and the one target. |
| +type Participants struct { |
| + Source []Participant `json:"source"` |
| + Target Participant `json:"target"` |
| } |
| // MoveInputs (what the target was checked against, §7.3 T3) is NOT |
| @@ -1219,6 +1268,10 @@ type MoveOutcome struct { |
| Outcome string `json:"outcome"` // done | rolled_back | aborted |
| At time.Time `json:"at"` |
| OfflineS float64 `json:"offline_s,omitempty"` |
| + // Request is the receipt of the write that cleared the move: the |
| + // self-serve request's nonce (OfflineFinish, OfflineAbortAt), "" for |
| + // any other write. Additive and omitempty. |
| + Request string `json:"request,omitempty"` |
| } |
| // MoveRollback is §7.8's journal. |
diff --git a/internal/provision/reconcile_lifetime_test.go b/internal/provision/reconcile_lifetime_test.go| index 2dce21778..e33611d21 100644 |
| --- a/internal/provision/reconcile_lifetime_test.go |
| +++ b/internal/provision/reconcile_lifetime_test.go |
| @@ -759,11 +759,11 @@ func (x *lifeScene) move(t *testing.T, muts ...func(*Record) error) Record { |
| var pairNow = time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC) |
| func startAs(id string) func(*Record) error { |
| - return OfflineStart(id, "ie", "uk", "ops@example.com", pairNow) |
| + return OfflineStart(id, "ie", "uk", "ops@example.com", pairNow, "") |
| } |
| func throughLanded(id string) []func(*Record) error { |
| - return []func(*Record) error{OfflineSeal(id, "box-a", []string{"box-a"}), |
| - OfflineSealedBy(id, "box-a", MoveSource{Release: "v1"}), OfflineFlip(id), OfflineLandedBy(id, "uk")} |
| + return []func(*Record) error{OfflineSeal(id, "box-a", []string{"box-a"}, Receipt{}), |
| + OfflineSealedBy(id, "box-a", MoveSource{Release: "v1"}), OfflineFlip(id, Receipt{}), OfflineLandedBy(id, "uk")} |
| } |
| func (x *lifeScene) pairIs(t *testing.T, want registry.Pair, status, op string) { |
| @@ -857,8 +857,8 @@ func TestReconcileInstallsTheRecordsPairOnAMintedRow(t *testing.T) { |
| muts []func(*Record) error |
| }{ |
| {"after finish", "uk", append(append([]func(*Record) error{startAs(pairMove)}, throughLanded(pairMove)...), |
| - OfflineOpenTransition(pairMove, pairNow.Add(time.Hour)), OfflineFinishingTransition(pairMove), OfflineFinish(pairMove, pairNow.Add(2*time.Hour)))}, |
| - {"after abort then finish", "ie", []func(*Record) error{startAs(pairMove), OfflineAbort(pairMove), OfflineFinish(pairMove, pairNow.Add(time.Hour))}}, |
| + OfflineOpenTransition(pairMove, pairNow.Add(time.Hour), Receipt{}), OfflineFinishingTransition(pairMove, Receipt{}), OfflineFinish(pairMove, pairNow.Add(2*time.Hour), Receipt{}))}, |
| + {"after abort then finish", "ie", []func(*Record) error{startAs(pairMove), OfflineAbort(pairMove, Receipt{}), OfflineFinish(pairMove, pairNow.Add(time.Hour), Receipt{})}}, |
| } { |
| t.Run(c.name, func(t *testing.T) { |
| x := newLifeScene(t) |
| @@ -887,13 +887,13 @@ func TestAnExistingRowKeepsOpenSequenceThroughFinish(t *testing.T) { |
| x.pass(t) |
| x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 1}, "landing", pairMove) |
| x.move(t, OfflineLandedBy(pairMove, "uk")) |
| - rec := x.move(t, OfflineOpenTransition(pairMove, pairNow.Add(time.Hour))) |
| + rec := x.move(t, OfflineOpenTransition(pairMove, pairNow.Add(time.Hour), Receipt{})) |
| // The target's open releases with the pair of the record it observed. |
| if ok, err := x.reg.ReleaseHoldSeq(lifeHost, pairMove, movePair(rec)); err != nil || !ok { |
| t.Fatalf("open's release: %v %v", ok, err) |
| } |
| x.pairIs(t, registry.Pair{Incarnation: 1, ServingSeq: 2}, "ready", "") |
| - for _, mut := range []func(*Record) error{OfflineFinishingTransition(pairMove), OfflineFinish(pairMove, pairNow.Add(2*time.Hour))} { |
| + for _, mut := range []func(*Record) error{OfflineFinishingTransition(pairMove, Receipt{}), OfflineFinish(pairMove, pairNow.Add(2*time.Hour), Receipt{})} { |
| x.pass(t) |
| x.move(t, mut) |
| x.pass(t) |
| @@ -990,9 +990,9 @@ func TestExistingRowsAcrossLifetimes(t *testing.T) { |
| var rec Record |
| if end == "open" { |
| x.move(t, throughLanded(pairMove)...) |
| - rec = x.move(t, OfflineOpenTransition(pairMove, pairNow.Add(time.Hour))) |
| + rec = x.move(t, OfflineOpenTransition(pairMove, pairNow.Add(time.Hour), Receipt{})) |
| } else { |
| - rec = x.move(t, OfflineAbort(pairMove)) |
| + rec = x.move(t, OfflineAbort(pairMove, Receipt{})) |
| } |
| if ok, err := x.reg.ReleaseHoldSeq(lifeHost, pairMove, movePair(rec)); err != nil || !ok { |
| t.Fatalf("%s's release: %v %v", end, ok, err) |