carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package moves
2
3import (
4 "reflect"
5 "strconv"
6 "testing"
7 "time"
8
9 "github.com/carlosframework/platform/internal/provision"
10)
11
12// The fixture journals of Plan 6 Task 4 (spec §13): one move per outcome,
13// with every stamp a known distance from the cut.
14const (
15 tmID = "mv_abcdefghijklmnopqrstuvwxyz"
16 tmInc = "inc-1"
17 tmRB = "rb_abcdefghijklmnopqrstuvwxyz"
18)
19
20var tmT0 = time.Date(2026, 10, 11, 9, 0, 0, 0, time.UTC)
21
22func tmAt(d time.Duration) *time.Time { t := tmT0.Add(d); return &t }
23
24// tmCut is 10 minutes after the request; every later stamp is from it.
25func tmCut(d time.Duration) *time.Time { return tmAt(10*time.Minute + d) }
26
27// doneJournal: requested, ready 5 minutes later, cut 5 minutes after that
28// with a 95-second budget (cut_deadline 90 s after the cut, the open budget
29// off it), flipped 40 s in, a landing_deadline later than the cut's deadline
30// (so the cut's binds: 50 s after the flip), opened 70.5 s after the cut.
31func doneJournal() *provision.MoveState {
32 return &provision.MoveState{ID: tmID, Incarnation: tmInc, From: "ie", To: "uk", State: provision.MoveCommitted,
33 RequestedAt: tmT0, ReadyAt: tmAt(5 * time.Minute), CutAt: tmCut(0), CutBudgetS: 95, CutDeadline: tmCut(90 * time.Second),
34 FlippedAt: tmCut(40 * time.Second), LandingDeadline: tmCut(160 * time.Second),
35 CommittedAt: tmCut(65 * time.Second), OpenedAt: tmCut(70500 * time.Millisecond), SourceParkedAt: tmCut(5 * time.Minute)}
36}
37
38// rolledBackJournal: the same cut and flip, a landing_deadline 30 s after
39// the flip (earlier than the cut's, so it binds), the rollback decided at 75
40// s with its 15-minute release deadline, the source recovered at 200 s and
41// reopened at 210 s, the target parked at 300 s.
42func rolledBackJournal() *provision.MoveState {
43 m := doneJournal()
44 m.State, m.CommittedAt, m.OpenedAt, m.SourceParkedAt = provision.MoveRollingBack, nil, nil, nil
45 m.LandingDeadline = tmCut(70 * time.Second)
46 m.Rollback = &provision.MoveRollback{ID: tmRB, Reason: provision.RollbackLandingDeadline, RequestedAt: tmCut(75 * time.Second),
47 ReleaseDeadline: tmCut(75*time.Second + 15*time.Minute), TargetPhase: provision.RbTargetCleaned, SourcePhase: provision.RbSourceOpened,
48 RecoveredAt: tmCut(200 * time.Second), OpenedAt: tmCut(210 * time.Second), TargetParkedAt: tmCut(300 * time.Second)}
49 return m
50}
51
52// revertJournal: cut, never flipped, the source reopened 50 s after the cut.
53func revertJournal() *provision.MoveState {
54 m := doneJournal()
55 m.State, m.FlippedAt, m.LandingDeadline, m.CommittedAt, m.OpenedAt, m.SourceParkedAt = provision.MoveReverting, nil, nil, nil, nil, nil
56 m.ReopenedAt = tmCut(50 * time.Second)
57 return m
58}
59
60// Plan 6 Task 4: a fixture journal yields the expected seconds, each beside
61// its budget: prepare (requested to ready), cut (cut_at to the flip), land
62// (the flip to the open) against the landing deadline (the earlier of the
63// cut's absolute deadline and landing_deadline, provision.LandingDeadlineOf),
64// offline (cut_at to the open, or to the source's reopen) against the cut's
65// admitted budget, and a rollback (its decision to the source's reopen)
66// against its release deadline.
67func TestTimingsOfAFixtureJournal(t *testing.T) {
68 for name, c := range map[string]struct {
69 m *provision.MoveState
70 want map[string]float64
71 }{
72 "done": {doneJournal(), map[string]float64{
73 "prepare_s": 300, "cut_s": 40, "cut_budget_s": 95, "land_s": 30.5, "land_budget_s": 50, "offline_s": 70.5}},
74 "rolled back after the flip": {rolledBackJournal(), map[string]float64{
75 "prepare_s": 300, "cut_s": 40, "cut_budget_s": 95, "land_budget_s": 30, "offline_s": 210,
76 "rollback_s": 135, "release_budget_s": 900}},
77 "reverted before the flip": {revertJournal(), map[string]float64{
78 "prepare_s": 300, "cut_budget_s": 95, "offline_s": 50}},
79 "nothing stamped but the request": {&provision.MoveState{ID: tmID, State: provision.MovePreparing, RequestedAt: tmT0}, map[string]float64{}},
80 "no move": {nil, map[string]float64{}},
81 } {
82 if got := Timings(c.m); !reflect.DeepEqual(got, c.want) {
83 t.Errorf("%s: Timings = %v, want %v", name, got, c.want)
84 }
85 }
86 // The landing deadline is provision's one rule (preflight L20): the
87 // earlier of the two, named.
88 for name, c := range map[string]struct {
89 m *provision.MoveState
90 want *time.Time
91 which string
92 }{
93 "the cut's binds": {doneJournal(), doneJournal().CutDeadline, "cut_deadline"},
94 "the landing's binds": {rolledBackJournal(), rolledBackJournal().LandingDeadline, "landing_deadline"},
95 "neither": {&provision.MoveState{}, nil, ""},
96 } {
97 d, which := provision.LandingDeadlineOf(c.m)
98 if which != c.which || (d == nil) != (c.want == nil) || (d != nil && !d.Equal(*c.want)) {
99 t.Errorf("%s: LandingDeadlineOf = %v %q, want %v %q", name, d, which, c.want, c.which)
100 }
101 }
102}
103
104// Preflight L20: Timings' offline is the closes' own (provision.MoveOfflineS),
105// so it equals the LastMove.OfflineS each of the three closes records.
106func TestTimingsOfflineIsTheClosesOffline(t *testing.T) {
107 g := provision.MoveGuard{ID: tmID, Incarnation: tmInc}
108 now := *tmCut(time.Hour)
109 for name, c := range map[string]struct {
110 m *provision.MoveState
111 region string
112 close func(*provision.Record) error
113 }{
114 "MoveClose": {doneJournal(), "uk", provision.MoveClose(g, now)},
115 "MoveRolledBackClose": {rolledBackJournal(), "ie", provision.MoveRolledBackClose(g, now)},
116 "MoveRevertClose": {revertJournal(), "ie", provision.MoveRevertClose(g, now)},
117 } {
118 want := Timings(c.m)["offline_s"]
119 rec := provision.Record{Host: "a.go.example.com", Region: c.region, Move: c.m}
120 if err := c.close(&rec); err != nil {
121 t.Fatalf("%s: %v", name, err)
122 }
123 if rec.LastMove == nil || rec.LastMove.OfflineS != want || want == 0 {
124 t.Errorf("%s: the close recorded %+v, Timings says offline %v", name, rec.LastMove, want)
125 }
126 }
127}
128
129// Plan 6 Task 4: the done and rolled_back log events carry the timings in
130// their Fields, from the record's own journal times (the close's before),
131// whoever writes them; an abort carries none.
132func TestTerminalTransitionsCarryTheTimings(t *testing.T) {
133 g := provision.MoveGuard{ID: tmID, Incarnation: tmInc}
134 now := *tmCut(time.Hour)
135 str := func(v float64) string { return strconv.FormatFloat(v, 'f', -1, 64) }
136 for name, c := range map[string]struct {
137 m *provision.MoveState
138 region string
139 close func(*provision.Record) error
140 want map[string]string
141 }{
142 "done": {doneJournal(), "uk", provision.MoveClose(g, now), map[string]string{
143 "prepare_s": "300", "cut_s": "40", "cut_budget_s": "95", "land_s": str(30.5), "land_budget_s": "50", "offline_s": str(70.5)}},
144 "rolled back": {rolledBackJournal(), "ie", provision.MoveRolledBackClose(g, now), map[string]string{
145 "prepare_s": "300", "cut_s": "40", "cut_budget_s": "95", "land_budget_s": "30", "offline_s": "210",
146 "rollback_s": "135", "release_budget_s": "900"}},
147 "reverted": {revertJournal(), "ie", provision.MoveRevertClose(g, now), map[string]string{
148 "prepare_s": "300", "cut_budget_s": "95", "offline_s": "50"}},
149 } {
150 before := provision.Record{Host: "a.go.example.com", Region: c.region, Move: c.m}
151 after := before
152 after.Move = c.m
153 if err := c.close(&after); err != nil {
154 t.Fatalf("%s: %v", name, err)
155 }
156 e, ok := TransitionOf(before, after)
157 if !ok || e.Identity != after.LastMove.Outcome {
158 t.Fatalf("%s: TransitionOf = %+v %v", name, e, ok)
159 }
160 for k, v := range c.want {
161 if e.Fields[k] != v {
162 t.Errorf("%s: Fields[%s] = %q, want %q (fields %v)", name, k, e.Fields[k], v, e.Fields)
163 }
164 }
165 if got := TimingsOf(e.Fields); !reflect.DeepEqual(got, Timings(c.m)) {
166 t.Errorf("%s: TimingsOf(Fields) = %v, want Timings %v", name, got, Timings(c.m))
167 }
168 }
169 // An abort's close names no timing, only the offline_s every close
170 // carries (Task 3: LastMove's).
171 m := &provision.MoveState{ID: tmID, Incarnation: tmInc, From: "ie", To: "uk", State: provision.MoveAborting, RequestedAt: tmT0, ReadyAt: tmAt(5 * time.Minute)}
172 before := provision.Record{Host: "a.go.example.com", Region: "ie", Move: m}
173 after := provision.Record{Host: "a.go.example.com", Region: "ie", LastMove: &provision.MoveOutcome{ID: tmID, Outcome: "aborted", At: now}}
174 e, ok := TransitionOf(before, after)
175 if !ok || e.Event != "aborted" {
176 t.Fatalf("the abort: %+v %v", e, ok)
177 }
178 if got := TimingsOf(e.Fields); !reflect.DeepEqual(got, map[string]float64{"offline_s": 0}) {
179 t.Errorf("an abort carries timings %v", got)
180 }
181}
182
183// Preflight L29: a preparing move has one start, PreparingSince, which both
184// the six-hour abort (AbortDue) and the console's 60-minute page measure from.
185func TestPreparingSinceIsTheLaterOfRequestAndReady(t *testing.T) {
186 later := tmT0.Add(2 * time.Hour)
187 earlier := tmT0.Add(-time.Hour)
188 for name, c := range map[string]struct {
189 ready *time.Time
190 want time.Time
191 }{
192 "no ready": {nil, tmT0},
193 "a later ready": {&later, later},
194 "an earlier one": {&earlier, tmT0},
195 } {
196 m := &provision.MoveState{ID: tmID, State: provision.MovePreparing, RequestedAt: tmT0, ReadyAt: c.ready}
197 if got := PreparingSince(m); !got.Equal(c.want) {
198 t.Errorf("%s: PreparingSince = %v, want %v", name, got, c.want)
199 }
200 rec := provision.Record{Move: m}
201 if AbortDue(rec, c.want.Add(AbortAfter-time.Second)) || !AbortDue(rec, c.want.Add(AbortAfter)) {
202 t.Errorf("%s: AbortDue does not run from PreparingSince", name)
203 }
204 }
205}
206