moves: timings against budgets in the terminal log events (§13)
moves-phase1-6 Task 4. moves.Timings works out prepare, cut, land, offline and a rollback's span, each beside its budget (cut_budget_s, the landing deadline, the release deadline), from the record's own journal times. TransitionOf adds them to the done and rolled_back entries' Fields, so the box's outbox and the console's move_log_outbox carry the same seconds whoever writes the entry. detail=1 shows them from the logged entry (moves.TimingsOf), never worked out again. Preflight L20: the landing deadline rule moves to provision.LandingDeadlineOf (host's landingDeadlineOf and the console's moveDeadlinePassed call it), and the three closes share provision.MoveOfflineS with the timings. L29: moves.PreparingSince is the one start of AbortDue and the console's 60-minute page. No censused unit changes. No new sentence.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
10 files changed,
+508
−46
internal/console/apiinstancemove.go+34 −0internal/console/apiinstancemove_test.go+58 −4internal/console/movesrecovery.go+4 −16internal/console/movesrecovery_test.go+31 −0internal/host/movesland.go+4 −12internal/moves/abort.go+10 −1internal/moves/log.go+95 −0internal/moves/timings_test.go+205 −0internal/moves/transition.go+10 −2internal/provision/provision.go+57 −11
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index f2eecc0d2..90cf4ec3a 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -1290,6 +1290,12 @@ type moveDetailBody struct { |
| // Log is every key under the move's log/ prefix, sorted (preflight L26: |
| // a move writes a few dozen; the CLI reads bodies only on demand). |
| Log []string `json:"log"` |
| + // Timings are a closed move's timings against their budgets (Plan 6 |
| + // Task 4, moves.Timings), in seconds: the timing fields of its done or |
| + // rolled_back log entry and nothing else of it, as the entry's writer |
| + // worked them out from the record's journal. Absent until that entry |
| + // is logged. |
| + Timings map[string]float64 `json:"timings,omitempty"` |
| // WaitingForBox is the region a relocating move waits on for a live |
| // enrolled box (M-C22), empty otherwise and when it cannot be told. |
| WaitingForBox string `json:"waiting_for_box,omitempty"` |
| @@ -1433,6 +1439,15 @@ func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *A |
| } |
| slices.Sort(keys) |
| out.Detail.Log = append(out.Detail.Log, keys...) |
| + if rec.Move == nil && rec.LastMove != nil { |
| + timings, err := s.readMoveTimings(ctx, prefix, rec.LastMove.Outcome) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: detail: timings of %s: %v", acct.Sqid, app, host, opID, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the move's reports. try again") |
| + return |
| + } |
| + out.Detail.Timings = timings |
| + } |
| } |
| if (rec.Move != nil && rec.Move.State == provision.MoveRelocating) || moveConvertedAfterCommit(rec, obs) { |
| out.State = provision.MoveRelocating |
| @@ -1448,6 +1463,25 @@ func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *A |
| writeJSON(w, http.StatusOK, out) |
| } |
| +// readMoveTimings is the timings a closed move's terminal log entry carries |
| +// (moves.TimingsOf over its Fields): nil for an outcome that carries none |
| +// (aborted), for an entry not logged yet, and for one that does not parse |
| +// (logged); an error when the read failed. |
| +func (s *Server) readMoveTimings(ctx context.Context, prefix, outcome string) (map[string]float64, error) { |
| + if outcome != "done" && outcome != "rolled_back" { |
| + return nil, nil |
| + } |
| + var ev moves.LogEvent |
| + ok, err := s.readMoveReport(ctx, moves.LogKeyFor(prefix, outcome), &ev) |
| + if !ok || err != nil { |
| + return nil, err |
| + } |
| + if t := moves.TimingsOf(ev.Fields); len(t) > 0 { |
| + return t, nil |
| + } |
| + return nil, nil |
| +} |
| + |
| // readMoveReport reads one report: (false, nil) when it is absent or does |
| // not parse (logged), an error when the read failed. |
| func (s *Server) readMoveReport(ctx context.Context, key string, into any) (bool, error) { |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index dec908463..af45ff58c 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1699,10 +1699,11 @@ type moveAnswer struct { |
| Source string `json:"source"` |
| Target string `json:"target"` |
| } `json:"evidence"` |
| - Log []string `json:"log"` |
| - WaitingForBox string `json:"waiting_for_box"` |
| - From string `json:"from"` |
| - Region string `json:"region"` |
| + Log []string `json:"log"` |
| + Timings map[string]float64 `json:"timings"` |
| + WaitingForBox string `json:"waiting_for_box"` |
| + From string `json:"from"` |
| + Region string `json:"region"` |
| } `json:"detail"` |
| } |
| @@ -1964,6 +1965,59 @@ func TestMoveDetailLeaksNothing(t *testing.T) { |
| } |
| } |
| +// Plan 6 Task 4: once a move has closed done or rolled_back, detail=1 shows |
| +// its timings, read from the terminal log event's Fields: the box or the |
| +// console wrote them from the record's own journal times when it built the |
| +// entry (moves.Timings), and the door never works them out a second way. |
| +// Only the timing fields are shown, never the rest of the event; a move with |
| +// no terminal event logged yet, or one still in flight, shows none; a log |
| +// read that fails answers the reports' fixed sentence. |
| +func TestMoveDetailShowsTheTimings(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-timings") |
| + ctx := context.Background() |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + mv := recordMove(t, fx) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.Detail == nil || a.Detail.Timings != nil { |
| + t.Fatalf("a move in flight shows timings: %s", out) |
| + } |
| + prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", mv.ID) |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Move, rec.LastMove = nil, &provision.MoveOutcome{ID: mv.ID, Outcome: "done", At: time.Now().UTC(), OfflineS: 70.5} |
| + }) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.Detail == nil || a.Detail.Timings != nil { |
| + t.Fatalf("timings before the terminal event is logged: %s", out) |
| + } |
| + const digest = "c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00c0ffee00" |
| + ev := moves.LogEvent{V: 1, Op: mv.ID, Host: "acme.go.tito.io", Event: "done", Identity: "done", Actor: "uk-box", At: time.Now().UTC(), |
| + Fields: map[string]string{"from": "ie", "to": "uk", "db_sha256": digest, "prepare_s": "300", "cut_s": "40", "cut_budget_s": "95", |
| + "land_s": "30.5", "land_budget_s": "50", "offline_s": "70.5"}} |
| + if _, err := moves.WriteLog(ctx, fx.rel, prefix, ev); err != nil { |
| + t.Fatal(err) |
| + } |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| + want := map[string]float64{"prepare_s": 300, "cut_s": 40, "cut_budget_s": 95, "land_s": 30.5, "land_budget_s": 50, "offline_s": 70.5} |
| + if a.Detail == nil || !reflect.DeepEqual(a.Detail.Timings, want) { |
| + t.Fatalf("detail timings = %s, want %v", out, want) |
| + } |
| + if strings.Contains(out, digest) { |
| + t.Fatalf("the detail shows the event's other fields: %s", out) |
| + } |
| + // A rolled_back close reads its own event, not the done one. |
| + setMove(t, fx, func(rec *provision.Record) { rec.LastMove.Outcome = "rolled_back" }) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.Detail == nil || a.Detail.Timings != nil { |
| + t.Fatalf("a rolled_back close shows the done event's timings: %s", out) |
| + } |
| + rb := ev |
| + rb.Event, rb.Identity, rb.Fields = "rolled_back", "rolled_back", map[string]string{"offline_s": "210", "rollback_s": "135", "release_budget_s": "900"} |
| + if _, err := moves.WriteLog(ctx, fx.rel, prefix, rb); err != nil { |
| + t.Fatal(err) |
| + } |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.Detail == nil || |
| + !reflect.DeepEqual(a.Detail.Timings, map[string]float64{"offline_s": 210, "rollback_s": 135, "release_budget_s": 900}) { |
| + t.Fatalf("rolled_back timings = %s", out) |
| + } |
| +} |
| + |
| // Relocating (spec rev 7.20 §7.10, D16) and Paul's J3 (preflight L18): a |
| // move whose claim is relocating/pinning with exec "none" in a region with |
| // no live enrolled box reports waiting_for_box = that region: the source's |
diff --git a/internal/console/movesrecovery.go b/internal/console/movesrecovery.go| index 0e6583b91..074e04689 100644 |
| --- a/internal/console/movesrecovery.go |
| +++ b/internal/console/movesrecovery.go |
| @@ -178,7 +178,7 @@ func decideMoveRecovery(in moveRecoveryInput) moveRecoveryDecision { |
| if m.State == provision.MoveRollingBack && rb != nil && rb.ReleaseDeadline != nil && in.Now.After(*rb.ReleaseDeadline) && rb.TargetPhase == provision.RbTargetReleasing { |
| d.Pages = append(d.Pages, movePageReleaseDeadline) |
| } |
| - if m.State == provision.MovePreparing && !m.RequestedAt.IsZero() && !in.Now.Before(preparingSince(m).Add(movePreparingPage)) { |
| + if m.State == provision.MovePreparing && !m.RequestedAt.IsZero() && !in.Now.Before(moves.PreparingSince(m).Add(movePreparingPage)) { |
| d.Pages = append(d.Pages, movePagePreparing) |
| } |
| owed := owedUnderOp(c, op.ID, nil) |
| @@ -216,23 +216,11 @@ func decideMoveRecovery(in moveRecoveryInput) moveRecoveryDecision { |
| return d |
| } |
| -// preparingSince is where a preparing move's clocks run from: the later of |
| -// its request and its readiness (moves.AbortDue's base, K18). |
| -func preparingSince(m *provision.MoveState) time.Time { |
| - from := m.RequestedAt |
| - if m.ReadyAt != nil && m.ReadyAt.After(from) { |
| - from = *m.ReadyAt |
| - } |
| - return from |
| -} |
| - |
| // moveDeadlinePassed is the grant's deadline rule: the earlier of the cut and |
| -// landing deadlines (J11) is behind now. |
| +// landing deadlines (J11, provision.LandingDeadlineOf, the box's own rule) is |
| +// behind now. |
| func moveDeadlinePassed(m *provision.MoveState, now time.Time) bool { |
| - deadline := m.CutDeadline |
| - if l := m.LandingDeadline; l != nil && (deadline == nil || l.Before(*deadline)) { |
| - deadline = l |
| - } |
| + deadline, _ := provision.LandingDeadlineOf(m) |
| return deadline != nil && now.After(*deadline) |
| } |
diff --git a/internal/console/movesrecovery_test.go b/internal/console/movesrecovery_test.go| index 38468fc0a..2a08ec5db 100644 |
| --- a/internal/console/movesrecovery_test.go |
| +++ b/internal/console/movesrecovery_test.go |
| @@ -618,6 +618,37 @@ func TestRecordFirstCloseCrashIsFinishedByThePass(t *testing.T) { |
| // TestRecoveryPassAbortsAfterSixHours (K18): a move still preparing |
| // moves.AbortAfter after its request is aborted through ConsoleMove; before |
| // that the pass pages once at an hour and writes nothing. |
| +// Preflight L29: the 60-minute preparing page and the six-hour abort run |
| +// from one start, moves.PreparingSince (the later of the request and the |
| +// readiness), so the two can never disagree about how long a move prepared. |
| +func TestPreparingPageRunsFromPreparingSince(t *testing.T) { |
| + ready := mvAt(30 * time.Minute) |
| + for name, c := range map[string]struct { |
| + ready *time.Time |
| + at time.Duration |
| + want bool |
| + }{ |
| + "an hour after the request": {nil, time.Hour, true}, |
| + "short of it": {nil, time.Hour - time.Second, false}, |
| + "an hour after the request, ready 30": {ready, time.Hour, false}, |
| + "an hour after the readiness": {ready, 90 * time.Minute, true}, |
| + } { |
| + rec := mvRecord(provision.MovePreparing, func(r *provision.Record) { r.Move.ReadyAt = c.ready }) |
| + now := mvT0.Add(c.at) |
| + d := decideMoveRecovery(moveRecoveryInput{Now: now, Claim: mvClaim(hostclaim.KindMoving, hostclaim.PhaseMoving), Rec: rec}) |
| + paged := false |
| + for _, k := range d.Pages { |
| + paged = paged || k == movePagePreparing |
| + } |
| + if paged != c.want { |
| + t.Errorf("%s: preparing page %v, want %v (pages %v)", name, paged, c.want, d.Pages) |
| + } |
| + if since := moves.PreparingSince(rec.Move); paged != !now.Before(since.Add(movePreparingPage)) { |
| + t.Errorf("%s: the page does not run from moves.PreparingSince (%v)", name, since) |
| + } |
| + } |
| +} |
| + |
| func TestRecoveryPassAbortsAfterSixHours(t *testing.T) { |
| w := startedMovePass(t, "code-mvpass-abort") |
| claim := claimBytes(t, w.fx, "acme.go.tito.io") |
diff --git a/internal/host/movesland.go b/internal/host/movesland.go| index 702c274f8..089364023 100644 |
| --- a/internal/host/movesland.go |
| +++ b/internal/host/movesland.go |
| @@ -251,19 +251,11 @@ func (l *landingRun) step(ctx context.Context) (bool, error) { |
| } |
| // landingDeadlineOf is the deadline a landing before restored works to: |
| -// the earlier of the cut's absolute deadline and landing_deadline. Spec |
| -// §7.4 has every pre-commit phase on both participants check the absolute |
| -// deadline; the flip's landing_deadline is a second, separate bound, never |
| -// an extension of it (final review, finding 3). which names the one that |
| -// applies, for the log. |
| +// provision.LandingDeadlineOf, the earlier of the cut's absolute deadline |
| +// and landing_deadline, which the move's timings share (Plan 6 preflight |
| +// L20). which names the one that applies, for the log. |
| func landingDeadlineOf(m *provision.MoveState) (d *time.Time, which string) { |
| - if m.CutDeadline != nil { |
| - d, which = m.CutDeadline, "cut_deadline" |
| - } |
| - if m.LandingDeadline != nil && (d == nil || m.LandingDeadline.Before(*d)) { |
| - d, which = m.LandingDeadline, "landing_deadline" |
| - } |
| - return d, which |
| + return provision.LandingDeadlineOf(m) |
| } |
| // pastDeadline reports whether the landing's deadline (landingDeadlineOf) |
diff --git a/internal/moves/abort.go b/internal/moves/abort.go| index 60c136e20..7ba8d4c91 100644 |
| --- a/internal/moves/abort.go |
| +++ b/internal/moves/abort.go |
| @@ -31,9 +31,18 @@ func AbortDue(rec provision.Record, now time.Time) bool { |
| if m == nil || m.Legacy() || m.State != provision.MovePreparing || m.RequestedAt.IsZero() { |
| return false |
| } |
| + return !now.Before(PreparingSince(m).Add(AbortAfter)) |
| +} |
| + |
| +// PreparingSince is where a preparing move's clocks run from: the later of |
| +// its RequestedAt and its ReadyAt (ruling K18). The six-hour abort (AbortDue) |
| +// and the console's 60-minute page (§13) both measure from it, so the two |
| +// can never disagree about how long a move has prepared (Plan 6 preflight |
| +// L29). |
| +func PreparingSince(m *provision.MoveState) time.Time { |
| from := m.RequestedAt |
| if m.ReadyAt != nil && m.ReadyAt.After(from) { |
| from = *m.ReadyAt |
| } |
| - return !now.Before(from.Add(AbortAfter)) |
| + return from |
| } |
diff --git a/internal/moves/log.go b/internal/moves/log.go| index 955306272..53cb3996e 100644 |
| --- a/internal/moves/log.go |
| +++ b/internal/moves/log.go |
| @@ -7,9 +7,11 @@ import ( |
| "fmt" |
| "os" |
| "sort" |
| + "strconv" |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -97,3 +99,96 @@ func ReadLog(ctx context.Context, s release.Store, prefix string) ([]LogEvent, e |
| }) |
| return out, nil |
| } |
| + |
| +// Timings (spec §13, Plan 6 Task 4): how long a move took, phase by phase, |
| +// each beside its budget, in seconds. They are worked out once, from the |
| +// record's own journal times, when the done or rolled_back entry is built |
| +// (TransitionOf, whoever writes it), never from a clock at delivery, and |
| +// carried in that entry's Fields; detail=1 shows them from there (TimingsOf). |
| +// |
| +// - prepare_s: requested_at to ready_at. |
| +// - cut_s: cut_at to flipped_at. |
| +// - land_s: flipped_at to opened_at, against land_budget_s, the landing |
| +// deadline (provision.LandingDeadlineOf: the earlier of cut_deadline and |
| +// landing_deadline) less flipped_at. |
| +// - offline_s: cut_at to the open, or to the source's reopen after a |
| +// rollback or a revert (provision.MoveOfflineS, the closes' own), |
| +// against cut_budget_s, the admitted stop-to-open estimate (T11). |
| +// - rollback_s: a rollback's decision (rollback.requested_at) to the |
| +// source's reopen (rollback.opened_at), against release_budget_s, its |
| +// release deadline less its decision. The record stamps no "released", |
| +// so the rollback is measured to the reopen, which comes only after the |
| +// target's release. |
| +// |
| +// A timing whose ends are not both stamped is left out, and so is a budget |
| +// the record does not carry. |
| +const ( |
| + TimingPrepare = "prepare_s" |
| + TimingCut = "cut_s" |
| + TimingCutBudget = "cut_budget_s" |
| + TimingLand = "land_s" |
| + TimingLandBudget = "land_budget_s" |
| + TimingOffline = "offline_s" |
| + TimingRollback = "rollback_s" |
| + TimingReleaseBudget = "release_budget_s" |
| +) |
| + |
| +// timingKeys are every key Timings may set. |
| +var timingKeys = []string{TimingPrepare, TimingCut, TimingCutBudget, TimingLand, TimingLandBudget, TimingOffline, TimingRollback, TimingReleaseBudget} |
| + |
| +// Timings is m's timings (see above); empty for no move. |
| +func Timings(m *provision.MoveState) map[string]float64 { |
| + out := map[string]float64{} |
| + if m == nil { |
| + return out |
| + } |
| + span := func(key string, from, to *time.Time) { |
| + if from != nil && to != nil { |
| + out[key] = to.Sub(*from).Seconds() |
| + } |
| + } |
| + if !m.RequestedAt.IsZero() { |
| + span(TimingPrepare, &m.RequestedAt, m.ReadyAt) |
| + } |
| + span(TimingCut, m.CutAt, m.FlippedAt) |
| + if m.CutBudgetS > 0 { |
| + out[TimingCutBudget] = float64(m.CutBudgetS) |
| + } |
| + if m.State == provision.MoveCommitted { |
| + span(TimingLand, m.FlippedAt, m.OpenedAt) |
| + } |
| + if d, _ := provision.LandingDeadlineOf(m); m.FlippedAt != nil { |
| + span(TimingLandBudget, m.FlippedAt, d) |
| + } |
| + if s, ok := provision.MoveOfflineS(m); ok { |
| + out[TimingOffline] = s |
| + } |
| + if rb := m.Rollback; rb != nil { |
| + span(TimingRollback, rb.RequestedAt, rb.OpenedAt) |
| + span(TimingReleaseBudget, rb.RequestedAt, rb.ReleaseDeadline) |
| + } |
| + return out |
| +} |
| + |
| +// TimingsOf is the timings a logged entry's Fields carry: the timing keys |
| +// alone, each parsed back to seconds; anything else in the fields, and a |
| +// value that does not parse, is left out. |
| +func TimingsOf(fields map[string]string) map[string]float64 { |
| + out := map[string]float64{} |
| + for _, k := range timingKeys { |
| + v, ok := fields[k] |
| + if !ok { |
| + continue |
| + } |
| + if f, err := strconv.ParseFloat(v, 64); err == nil { |
| + out[k] = f |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// timingText is a timing as a field's text: the shortest decimal that |
| +// parses back to it. |
| +func timingText(s float64) string { |
| + return strconv.FormatFloat(s, 'f', -1, 64) |
| +} |
diff --git a/internal/moves/timings_test.go b/internal/moves/timings_test.go| new file mode 100644 |
| index 000000000..1992f44ec |
| --- /dev/null |
| +++ b/internal/moves/timings_test.go |
| @@ -0,0 +1,205 @@ |
| +package moves |
| + |
| +import ( |
| + "reflect" |
| + "strconv" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The fixture journals of Plan 6 Task 4 (spec §13): one move per outcome, |
| +// with every stamp a known distance from the cut. |
| +const ( |
| + tmID = "mv_abcdefghijklmnopqrstuvwxyz" |
| + tmInc = "inc-1" |
| + tmRB = "rb_abcdefghijklmnopqrstuvwxyz" |
| +) |
| + |
| +var tmT0 = time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC) |
| + |
| +func tmAt(d time.Duration) *time.Time { t := tmT0.Add(d); return &t } |
| + |
| +// tmCut is 10 minutes after the request; every later stamp is from it. |
| +func tmCut(d time.Duration) *time.Time { return tmAt(10*time.Minute + d) } |
| + |
| +// doneJournal: requested, ready 5 minutes later, cut 5 minutes after that |
| +// with a 95-second budget (cut_deadline 90 s after the cut, the open budget |
| +// off it), flipped 40 s in, a landing_deadline later than the cut's deadline |
| +// (so the cut's binds: 50 s after the flip), opened 70.5 s after the cut. |
| +func doneJournal() *provision.MoveState { |
| + return &provision.MoveState{ID: tmID, Incarnation: tmInc, From: "ie", To: "uk", State: provision.MoveCommitted, |
| + RequestedAt: tmT0, ReadyAt: tmAt(5 * time.Minute), CutAt: tmCut(0), CutBudgetS: 95, CutDeadline: tmCut(90 * time.Second), |
| + FlippedAt: tmCut(40 * time.Second), LandingDeadline: tmCut(160 * time.Second), |
| + CommittedAt: tmCut(65 * time.Second), OpenedAt: tmCut(70500 * time.Millisecond), SourceParkedAt: tmCut(5 * time.Minute)} |
| +} |
| + |
| +// rolledBackJournal: the same cut and flip, a landing_deadline 30 s after |
| +// the flip (earlier than the cut's, so it binds), the rollback decided at 75 |
| +// s with its 15-minute release deadline, the source recovered at 200 s and |
| +// reopened at 210 s, the target parked at 300 s. |
| +func rolledBackJournal() *provision.MoveState { |
| + m := doneJournal() |
| + m.State, m.CommittedAt, m.OpenedAt, m.SourceParkedAt = provision.MoveRollingBack, nil, nil, nil |
| + m.LandingDeadline = tmCut(70 * time.Second) |
| + m.Rollback = &provision.MoveRollback{ID: tmRB, Reason: provision.RollbackLandingDeadline, RequestedAt: tmCut(75 * time.Second), |
| + ReleaseDeadline: tmCut(75*time.Second + 15*time.Minute), TargetPhase: provision.RbTargetCleaned, SourcePhase: provision.RbSourceOpened, |
| + RecoveredAt: tmCut(200 * time.Second), OpenedAt: tmCut(210 * time.Second), TargetParkedAt: tmCut(300 * time.Second)} |
| + return m |
| +} |
| + |
| +// revertJournal: cut, never flipped, the source reopened 50 s after the cut. |
| +func revertJournal() *provision.MoveState { |
| + m := doneJournal() |
| + m.State, m.FlippedAt, m.LandingDeadline, m.CommittedAt, m.OpenedAt, m.SourceParkedAt = provision.MoveReverting, nil, nil, nil, nil, nil |
| + m.ReopenedAt = tmCut(50 * time.Second) |
| + return m |
| +} |
| + |
| +// Plan 6 Task 4: a fixture journal yields the expected seconds, each beside |
| +// its budget: prepare (requested to ready), cut (cut_at to the flip), land |
| +// (the flip to the open) against the landing deadline (the earlier of the |
| +// cut's absolute deadline and landing_deadline, provision.LandingDeadlineOf), |
| +// offline (cut_at to the open, or to the source's reopen) against the cut's |
| +// admitted budget, and a rollback (its decision to the source's reopen) |
| +// against its release deadline. |
| +func TestTimingsOfAFixtureJournal(t *testing.T) { |
| + for name, c := range map[string]struct { |
| + m *provision.MoveState |
| + want map[string]float64 |
| + }{ |
| + "done": {doneJournal(), map[string]float64{ |
| + "prepare_s": 300, "cut_s": 40, "cut_budget_s": 95, "land_s": 30.5, "land_budget_s": 50, "offline_s": 70.5}}, |
| + "rolled back after the flip": {rolledBackJournal(), map[string]float64{ |
| + "prepare_s": 300, "cut_s": 40, "cut_budget_s": 95, "land_budget_s": 30, "offline_s": 210, |
| + "rollback_s": 135, "release_budget_s": 900}}, |
| + "reverted before the flip": {revertJournal(), map[string]float64{ |
| + "prepare_s": 300, "cut_budget_s": 95, "offline_s": 50}}, |
| + "nothing stamped but the request": {&provision.MoveState{ID: tmID, State: provision.MovePreparing, RequestedAt: tmT0}, map[string]float64{}}, |
| + "no move": {nil, map[string]float64{}}, |
| + } { |
| + if got := Timings(c.m); !reflect.DeepEqual(got, c.want) { |
| + t.Errorf("%s: Timings = %v, want %v", name, got, c.want) |
| + } |
| + } |
| + // The landing deadline is provision's one rule (preflight L20): the |
| + // earlier of the two, named. |
| + for name, c := range map[string]struct { |
| + m *provision.MoveState |
| + want *time.Time |
| + which string |
| + }{ |
| + "the cut's binds": {doneJournal(), doneJournal().CutDeadline, "cut_deadline"}, |
| + "the landing's binds": {rolledBackJournal(), rolledBackJournal().LandingDeadline, "landing_deadline"}, |
| + "neither": {&provision.MoveState{}, nil, ""}, |
| + } { |
| + d, which := provision.LandingDeadlineOf(c.m) |
| + if which != c.which || (d == nil) != (c.want == nil) || (d != nil && !d.Equal(*c.want)) { |
| + t.Errorf("%s: LandingDeadlineOf = %v %q, want %v %q", name, d, which, c.want, c.which) |
| + } |
| + } |
| +} |
| + |
| +// Preflight L20: Timings' offline is the closes' own (provision.MoveOfflineS), |
| +// so it equals the LastMove.OfflineS each of the three closes records. |
| +func TestTimingsOfflineIsTheClosesOffline(t *testing.T) { |
| + g := provision.MoveGuard{ID: tmID, Incarnation: tmInc} |
| + now := *tmCut(time.Hour) |
| + for name, c := range map[string]struct { |
| + m *provision.MoveState |
| + region string |
| + close func(*provision.Record) error |
| + }{ |
| + "MoveClose": {doneJournal(), "uk", provision.MoveClose(g, now)}, |
| + "MoveRolledBackClose": {rolledBackJournal(), "ie", provision.MoveRolledBackClose(g, now)}, |
| + "MoveRevertClose": {revertJournal(), "ie", provision.MoveRevertClose(g, now)}, |
| + } { |
| + want := Timings(c.m)["offline_s"] |
| + rec := provision.Record{Host: "a.go.example.com", Region: c.region, Move: c.m} |
| + if err := c.close(&rec); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if rec.LastMove == nil || rec.LastMove.OfflineS != want || want == 0 { |
| + t.Errorf("%s: the close recorded %+v, Timings says offline %v", name, rec.LastMove, want) |
| + } |
| + } |
| +} |
| + |
| +// Plan 6 Task 4: the done and rolled_back log events carry the timings in |
| +// their Fields, from the record's own journal times (the close's before), |
| +// whoever writes them; an abort carries none. |
| +func TestTerminalTransitionsCarryTheTimings(t *testing.T) { |
| + g := provision.MoveGuard{ID: tmID, Incarnation: tmInc} |
| + now := *tmCut(time.Hour) |
| + str := func(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) } |
| + for name, c := range map[string]struct { |
| + m *provision.MoveState |
| + region string |
| + close func(*provision.Record) error |
| + want map[string]string |
| + }{ |
| + "done": {doneJournal(), "uk", provision.MoveClose(g, now), map[string]string{ |
| + "prepare_s": "300", "cut_s": "40", "cut_budget_s": "95", "land_s": str(30.5), "land_budget_s": "50", "offline_s": str(70.5)}}, |
| + "rolled back": {rolledBackJournal(), "ie", provision.MoveRolledBackClose(g, now), map[string]string{ |
| + "prepare_s": "300", "cut_s": "40", "cut_budget_s": "95", "land_budget_s": "30", "offline_s": "210", |
| + "rollback_s": "135", "release_budget_s": "900"}}, |
| + "reverted": {revertJournal(), "ie", provision.MoveRevertClose(g, now), map[string]string{ |
| + "prepare_s": "300", "cut_budget_s": "95", "offline_s": "50"}}, |
| + } { |
| + before := provision.Record{Host: "a.go.example.com", Region: c.region, Move: c.m} |
| + after := before |
| + after.Move = c.m |
| + if err := c.close(&after); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + e, ok := TransitionOf(before, after) |
| + if !ok || e.Identity != after.LastMove.Outcome { |
| + t.Fatalf("%s: TransitionOf = %+v %v", name, e, ok) |
| + } |
| + for k, v := range c.want { |
| + if e.Fields[k] != v { |
| + t.Errorf("%s: Fields[%s] = %q, want %q (fields %v)", name, k, e.Fields[k], v, e.Fields) |
| + } |
| + } |
| + if got := TimingsOf(e.Fields); !reflect.DeepEqual(got, Timings(c.m)) { |
| + t.Errorf("%s: TimingsOf(Fields) = %v, want Timings %v", name, got, Timings(c.m)) |
| + } |
| + } |
| + // An abort's close names no timing, only the offline_s every close |
| + // carries (Task 3: LastMove's). |
| + m := &provision.MoveState{ID: tmID, Incarnation: tmInc, From: "ie", To: "uk", State: provision.MoveAborting, RequestedAt: tmT0, ReadyAt: tmAt(5 * time.Minute)} |
| + before := provision.Record{Host: "a.go.example.com", Region: "ie", Move: m} |
| + after := provision.Record{Host: "a.go.example.com", Region: "ie", LastMove: &provision.MoveOutcome{ID: tmID, Outcome: "aborted", At: now}} |
| + e, ok := TransitionOf(before, after) |
| + if !ok || e.Event != "aborted" { |
| + t.Fatalf("the abort: %+v %v", e, ok) |
| + } |
| + if got := TimingsOf(e.Fields); !reflect.DeepEqual(got, map[string]float64{"offline_s": 0}) { |
| + t.Errorf("an abort carries timings %v", got) |
| + } |
| +} |
| + |
| +// Preflight L29: a preparing move has one start, PreparingSince, which both |
| +// the six-hour abort (AbortDue) and the console's 60-minute page measure from. |
| +func TestPreparingSinceIsTheLaterOfRequestAndReady(t *testing.T) { |
| + later := tmT0.Add(2 * time.Hour) |
| + earlier := tmT0.Add(-time.Hour) |
| + for name, c := range map[string]struct { |
| + ready *time.Time |
| + want time.Time |
| + }{ |
| + "no ready": {nil, tmT0}, |
| + "a later ready": {&later, later}, |
| + "an earlier one": {&earlier, tmT0}, |
| + } { |
| + m := &provision.MoveState{ID: tmID, State: provision.MovePreparing, RequestedAt: tmT0, ReadyAt: c.ready} |
| + if got := PreparingSince(m); !got.Equal(c.want) { |
| + t.Errorf("%s: PreparingSince = %v, want %v", name, got, c.want) |
| + } |
| + rec := provision.Record{Move: m} |
| + if AbortDue(rec, c.want.Add(AbortAfter-time.Second)) || !AbortDue(rec, c.want.Add(AbortAfter)) { |
| + t.Errorf("%s: AbortDue does not run from PreparingSince", name) |
| + } |
| + } |
| +} |
diff --git a/internal/moves/transition.go b/internal/moves/transition.go| index 94f9b7785..a682af150 100644 |
| --- a/internal/moves/transition.go |
| +++ b/internal/moves/transition.go |
| @@ -86,7 +86,8 @@ func seconds(a, b interface{ Unix() int64 }) string { |
| // stuck-<epoch> keyed by the record's recovery retry epoch, the new state |
| // itself (requested for preparing, cutting-requested, ready, aborting, |
| // reverting, relocating, rolling_back), and the terminal outcome (done, |
| -// rolled_back, aborted). |
| +// rolled_back, aborted). A done or rolled_back outcome carries the move's |
| +// timings (Timings) over the journal as the close found it. |
| func TransitionOf(before, after provision.Record) (Transition, bool) { |
| bm, am := before.Move, after.Move |
| if am == nil { |
| @@ -95,7 +96,14 @@ func TransitionOf(before, after provision.Record) (Transition, bool) { |
| return Transition{}, false |
| } |
| f := transitionFields(bm) |
| - f["offline_s"] = strconv.FormatFloat(after.LastMove.OfflineS, 'f', -1, 64) |
| + if out := after.LastMove.Outcome; out == "done" || out == "rolled_back" { |
| + // The timings (Plan 6 Task 4), from the journal the close |
| + // found: the same for every writer of the entry. |
| + for k, v := range Timings(bm) { |
| + f[k] = timingText(v) |
| + } |
| + } |
| + f["offline_s"] = timingText(after.LastMove.OfflineS) |
| return Transition{Op: bm.ID, Event: after.LastMove.Outcome, Identity: after.LastMove.Outcome, Incarnation: bm.Incarnation, Fields: f}, true |
| } |
| if am.ID == "" || (bm != nil && bm.ID != am.ID) { |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index fdbe6cd23..b9615a1d1 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -1786,12 +1786,58 @@ func MoveSourceParked(g MoveGuard, now time.Time) func(*Record) error { |
| return committedStamp(g, now, func(m *MoveState) **time.Time { return &m.SourceParkedAt }) |
| } |
| +// LandingDeadlineOf is the deadline a landing before restored works to: |
| +// the earlier of the cut's absolute deadline and landing_deadline. Spec |
| +// §7.4 has every pre-commit phase on both participants check the absolute |
| +// deadline; the flip's landing_deadline is a second, separate bound, never |
| +// an extension of it (final review, finding 3). which names the one that |
| +// applies, for the log. The box's landing and rollback decisions (host's |
| +// landingDeadlineOf) and the move's timings (moves.Timings) share it |
| +// (Plan 6 preflight L20). |
| +func LandingDeadlineOf(m *MoveState) (d *time.Time, which string) { |
| + if m.CutDeadline != nil { |
| + d, which = m.CutDeadline, "cut_deadline" |
| + } |
| + if m.LandingDeadline != nil && (d == nil || m.LandingDeadline.Before(*d)) { |
| + d, which = m.LandingDeadline, "landing_deadline" |
| + } |
| + return d, which |
| +} |
| + |
| +// MoveOfflineS is a move's offline time as its close records it (index D11): |
| +// from cut_at to the open that ends the move. That is a committed move's |
| +// opened_at (MoveClose), a rollback's source reopen, rollback.opened_at |
| +// (MoveRolledBackClose), or a revert's reopened_at (MoveRevertClose). ok is |
| +// false while either end is unstamped. The three closes and the move's |
| +// timings (moves.Timings) share it (Plan 6 preflight L20). |
| +func MoveOfflineS(m *MoveState) (seconds float64, ok bool) { |
| + if m == nil || m.CutAt == nil { |
| + return 0, false |
| + } |
| + var end *time.Time |
| + switch m.State { |
| + case MoveCommitted: |
| + end = m.OpenedAt |
| + case MoveRollingBack: |
| + if m.Rollback != nil { |
| + end = m.Rollback.OpenedAt |
| + } |
| + case MoveReverting: |
| + end = m.ReopenedAt |
| + } |
| + if end == nil { |
| + return 0, false |
| + } |
| + return end.Sub(*m.CutAt).Seconds(), true |
| +} |
| + |
| // MoveClose is the close (§7.9 step 5, index D11): a committed move with |
| // both obligations stamped clears Move and leaves the outcome in LastMove, |
| // in the same write. The region is already the target's from the flip; a |
| // record whose region is not is refused rather than closed on a lie. The |
| -// offline time is opened_at minus cut_at, zero when cut_at was never |
| -// stamped. Plan 5 calls it once no obligation under the move remains. |
| +// offline time is opened_at minus cut_at (MoveOfflineS), zero when cut_at |
| +// was never stamped. Plan 5 calls it once no obligation under the move |
| +// remains. |
| func MoveClose(g MoveGuard, now time.Time) func(*Record) error { |
| return func(rec *Record) error { |
| if err := g.check(rec); err != nil { |
| @@ -1805,8 +1851,8 @@ func MoveClose(g MoveGuard, now time.Time) func(*Record) error { |
| return fmt.Errorf("%w: the record's region is not the target's", ErrMoveConflict) |
| } |
| out := &MoveOutcome{ID: g.ID, Outcome: "done", At: now.UTC()} |
| - if m.CutAt != nil { |
| - out.OfflineS = m.OpenedAt.Sub(*m.CutAt).Seconds() |
| + if s, ok := MoveOfflineS(m); ok { |
| + out.OfflineS = s |
| } |
| rec.LastMove, rec.Move = out, nil |
| return nil |
| @@ -2301,7 +2347,7 @@ func MoveRollbackLossCommitted(g MoveGuard) func(*Record) error { |
| // target has parked clears Move and leaves the outcome in LastMove, in the |
| // same write. The region is already From (MoveRollBack); a record whose |
| // region is not is refused. The offline time is the rollback's OpenedAt |
| -// minus CutAt, zero when CutAt was never stamped. |
| +// minus CutAt (MoveOfflineS), zero when CutAt was never stamped. |
| func MoveRolledBackClose(g MoveGuard, now time.Time) func(*Record) error { |
| return func(rec *Record) error { |
| rb, err := rollingBack(g, rec) |
| @@ -2316,8 +2362,8 @@ func MoveRolledBackClose(g MoveGuard, now time.Time) func(*Record) error { |
| return fmt.Errorf("%w: the record's region is not the source's", ErrMoveConflict) |
| } |
| out := &MoveOutcome{ID: g.ID, Outcome: "rolled_back", At: now.UTC()} |
| - if m.CutAt != nil { |
| - out.OfflineS = rb.OpenedAt.Sub(*m.CutAt).Seconds() |
| + if s, ok := MoveOfflineS(m); ok { |
| + out.OfflineS = s |
| } |
| rec.LastMove, rec.Move = out, nil |
| return nil |
| @@ -2375,8 +2421,8 @@ func MoveRecordRevertInputs(g MoveGuard, in MoveInputs) func(*Record) error { |
| // MoveRevertClose is a revert's close (§7.4 reverting, index D11): the |
| // caller has seen the claim's rev_phase at opened; the record needs its |
| // ReopenedAt. Move is cleared and LastMove says rolled_back, the offline |
| -// time counted from CutAt to the reopen. A revert never flipped, so the |
| -// region is From; a record whose region is not is refused. |
| +// time counted from CutAt to the reopen (MoveOfflineS). A revert never |
| +// flipped, so the region is From; a record whose region is not is refused. |
| func MoveRevertClose(g MoveGuard, now time.Time) func(*Record) error { |
| return func(rec *Record) error { |
| if err := g.check(rec); err != nil { |
| @@ -2390,8 +2436,8 @@ func MoveRevertClose(g MoveGuard, now time.Time) func(*Record) error { |
| return fmt.Errorf("%w: the record's region is not the source's", ErrMoveConflict) |
| } |
| out := &MoveOutcome{ID: g.ID, Outcome: "rolled_back", At: now.UTC()} |
| - if m.CutAt != nil { |
| - out.OfflineS = m.ReopenedAt.Sub(*m.CutAt).Seconds() |
| + if s, ok := MoveOfflineS(m); ok { |
| + out.OfflineS = s |
| } |
| rec.LastMove, rec.Move = out, nil |
| return nil |