carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostclaim
2
3import (
4 "encoding/json"
5 "errors"
6 "strconv"
7 "strings"
8 "testing"
9 "time"
10)
11
12// The console's release of a declared-lost move target (moves spec §7.10,
13// J16): after the declaration's claim CAS took the target out of execution
14// and participation, one claim write rolls a landing back, steps the
15// rollback to released, queues the target region's parks and covers its
16// predecessors, as TargetReleased does for a target that released itself.
17
18const (
19 lostOp = "mv_abcdefghijklmnopqrstuvwxyz"
20 lostRB = "rb_abcdefghijklmnopqrstuvwxyz"
21 lostID = "loss_1"
22 lostHost = "shop.example"
23)
24
25func lostTargetClaim(t *testing.T, kind, phase string) *Claim {
26 t.Helper()
27 at := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
28 inc, err := Incarnation("bab", "jam", lostHost, at)
29 if err != nil {
30 t.Fatal(err)
31 }
32 c := &Claim{V: 2, Host: lostHost, Gen: 30,
33 Holder: &Holder{Family: "instance", Key: "apps/bab/jam/instances/" + lostHost + ".json", Account: "bab", App: "jam", CreatedAt: at, Incarnation: inc,
34 Location: "instances/" + lostHost, Generation: "mv", Stamp: 21, Session: 20, WriterBox: "uk-box",
35 Admissions: []AdmissionRecord{
36 {Session: 15, Box: "ie-box", Generation: "src", Predecessor: "older", Region: "ie", PredecessorRegion: "ie", Mode: "cold"},
37 {Session: 20, Box: "uk-box", Generation: "mv", Predecessor: "src", Region: "uk", PredecessorRegion: "ie", Mode: "cold", Coverage: "sealed"},
38 {Session: 25, Box: "uk-box", Generation: "mv2", Predecessor: "mv", Region: "uk", PredecessorRegion: "uk", Mode: "cold"},
39 }},
40 Op: Op{Kind: kind, ID: lostOp, Phase: phase, Exec: "none", Participants: []string{"ie-box"},
41 Intent: map[string]string{"to": "uk", "source": "ie-box", "target": "uk-box"},
42 Journal: map[string]string{JournalSourceStamp: "15", JournalSrcGen: "src", JournalCutSession: "15", "loss." + lostID: `{"kind":"move-target-lost"}`}},
43 Lost: []string{"uk-box"}, LostRevisions: map[string]uint64{"uk-box": 2}, LossRefs: []string{lostID},
44 }
45 if kind == KindRestoring {
46 c.Op.Intent["rollback"] = lostRB
47 c.Op.Journal[JournalTargetStamp] = "21"
48 }
49 if err := c.Validate(); err != nil {
50 t.Fatal(err)
51 }
52 return c
53}
54
55func lostRelease(c *Claim, returnLane bool) LostTargetRelease {
56 at := time.Date(2026, 10, 1, 1, 0, 0, 0, time.UTC)
57 n := 0
58 r := LostTargetRelease{OpID: lostOp, RollbackID: lostRB, LossID: lostID, Target: "uk-box", TargetRegion: "uk", SourceRegion: "ie",
59 Location: ParkScope{Location: "instances/" + lostHost, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: 2 * time.Minute},
60 Lane: ParkScope{Location: "moves/" + lostHost + "/" + lostOp, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: 2 * time.Minute},
61 NewID: func() string { n++; return "id-" + strconv.Itoa(n) }}
62 if returnLane {
63 r.ReturnLane = &ParkScope{Location: "moves/" + lostHost + "/" + lostRB, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: 2 * time.Minute}
64 }
65 return r
66}
67
68func claimJSON(t *testing.T, c *Claim) string {
69 t.Helper()
70 b, err := json.Marshal(c)
71 if err != nil {
72 t.Fatal(err)
73 }
74 return string(b)
75}
76
77func parksOf(c *Claim) map[string]Obligation {
78 out := map[string]Obligation{}
79 for _, o := range c.Obligations {
80 key := o.Kind + "|" + o.Region + "|" + o.Box
81 if o.Park != nil {
82 key += "|" + o.Park.Location
83 }
84 out[key] = o
85 }
86 return out
87}
88
89func TestReleaseLostTargetFromLanding(t *testing.T) {
90 c := lostTargetClaim(t, KindLanding, "probing")
91 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
92 t.Fatal(err)
93 }
94 if c.Op.Kind != KindRestoring || c.Op.Phase != PhaseReleased || c.Op.Intent["rollback"] != lostRB || c.Op.Exec != "none" {
95 t.Fatalf("op = %+v", c.Op)
96 }
97 if c.Op.Journal[JournalTargetStamp] != "21" || c.Op.Journal[journalLostTargetReleased] != lostID {
98 t.Fatalf("journal = %v", c.Op.Journal)
99 }
100 parks := parksOf(c)
101 loc, ok := parks["park-replica|uk||instances/"+lostHost]
102 if !ok || loc.Owner != "region:uk" {
103 t.Fatalf("location park = %+v (%v)", loc, parks)
104 }
105 if _, ok := parks["park-replica|uk||moves/"+lostHost+"/"+lostOp]; !ok {
106 t.Fatalf("target lane park missing: %v", parks)
107 }
108 if len(parks) != 2 {
109 t.Fatalf("parks = %v", parks)
110 }
111 for _, o := range c.Obligations {
112 if o.Kind == "park-local" || o.Box != "" {
113 t.Fatalf("a target with no installation in inventory acquired local cleanup: %+v", o)
114 }
115 }
116 // The target's own predecessor is covered by the park; the source's
117 // return admission later is born covered the same way.
118 for _, a := range c.Holder.Admissions {
119 if a.PredecessorRegion == "uk" && !a.Covered {
120 t.Fatalf("uk predecessor not covered: %+v", a)
121 }
122 if a.PredecessorRegion == "ie" && a.Covered && a.Session != 15 {
123 t.Fatalf("ie predecessor covered: %+v", a)
124 }
125 }
126 if err := c.Validate(); err != nil {
127 t.Fatal(err)
128 }
129 // The source's claimed CAS takes it from here: the declaration is the
130 // target's authority (targetGaveUp).
131 if err := ReturnToSource(lostOp, "ie-box", true, ReturnFence{})(c); err != nil {
132 t.Fatalf("return to source after the release: %v", err)
133 }
134}
135
136func TestReleaseLostTargetIsIdempotent(t *testing.T) {
137 c := lostTargetClaim(t, KindLanding, "probing")
138 r := lostRelease(c, false)
139 if err := ReleaseLostTarget(r)(c); err != nil {
140 t.Fatal(err)
141 }
142 once := claimJSON(t, c)
143 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
144 t.Fatal(err)
145 }
146 if claimJSON(t, c) != once {
147 t.Fatal("a replay changed the claim")
148 }
149 // A replay after the source has claimed, or after a chore was acked,
150 // is still a no-op.
151 if err := ReturnToSource(lostOp, "ie-box", true, ReturnFence{})(c); err != nil {
152 t.Fatal(err)
153 }
154 c.Obligations = c.Obligations[:1]
155 claimed := claimJSON(t, c)
156 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
157 t.Fatal(err)
158 }
159 if claimJSON(t, c) != claimed {
160 t.Fatal("a replay after the source claimed changed the claim")
161 }
162}
163
164func TestReleaseLostTargetFromReleasing(t *testing.T) {
165 c := lostTargetClaim(t, KindRestoring, PhaseReleasing)
166 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
167 t.Fatal(err)
168 }
169 if c.Op.Phase != PhaseReleased || len(parksOf(c)) != 2 {
170 t.Fatalf("op %+v parks %v", c.Op, parksOf(c))
171 }
172}
173
174// A target that released dirty and then died before its return: its own
175// release queued the location park already; the declaration adds the
176// partial return lane's park in the source region and the move's lane.
177func TestReleaseLostTargetAfterDirtyRelease(t *testing.T) {
178 c := lostTargetClaim(t, KindRestoring, PhaseReleased)
179 c.Op.Exec = ""
180 c.Op.Journal[JournalEvidenceTarget] = "moves/x/stopped-target.json"
181 at := time.Date(2026, 10, 1, 0, 30, 0, 0, time.UTC)
182 c.Obligations = []Obligation{{ID: "loc", Op: lostOp, Kind: "park-replica", Region: "uk", Retirement: "r-loc", Owner: "region:uk",
183 Park: &ParkScope{Location: "instances/" + lostHost, Incarnation: c.Holder.Incarnation, EvidenceAt: at, RequestLimit: time.Minute}}}
184 if err := ReleaseLostTarget(lostRelease(c, true))(c); err != nil {
185 t.Fatal(err)
186 }
187 parks := parksOf(c)
188 if len(parks) != 3 || parks["park-replica|uk||instances/"+lostHost].ID != "loc" {
189 t.Fatalf("parks = %v", parks)
190 }
191 lane, ok := parks["park-replica|ie||moves/"+lostHost+"/"+lostRB]
192 if !ok || lane.Owner != "region:ie" {
193 t.Fatalf("return lane park = %+v (%v)", lane, parks)
194 }
195 if c.Op.Journal[journalReturnLane] != "moves/"+lostHost+"/"+lostRB {
196 t.Fatalf("journal = %v", c.Op.Journal)
197 }
198 // The return lane's own probed-time queue is then a no-op.
199 c.Op.Phase = PhaseProbed
200 before := len(c.Obligations)
201 if err := QueueReturnLanePark(lostOp, "ie", *lostRelease(c, true).ReturnLane, func() string { return "x" })(c); err != nil {
202 t.Fatal(err)
203 }
204 if len(c.Obligations) != before {
205 t.Fatal("return lane queued twice")
206 }
207}
208
209func TestReleaseLostTargetRefusals(t *testing.T) {
210 for name, tc := range map[string]struct {
211 mutate func(*Claim, *LostTargetRelease)
212 refusal error
213 }{
214 "no loss ref": {func(c *Claim, _ *LostTargetRelease) { c.LossRefs = nil }, ErrWrongState},
215 "no loss journal": {func(c *Claim, _ *LostTargetRelease) { delete(c.Op.Journal, "loss."+lostID) }, ErrWrongState},
216 "target participates": {func(c *Claim, _ *LostTargetRelease) { c.Op.Participants = []string{"ie-box", "uk-box"} }, ErrWrongState},
217 "target executes": {func(c *Claim, _ *LostTargetRelease) { c.Op.Exec = "uk-box" }, ErrWrongState},
218 "target not lost": {func(c *Claim, _ *LostTargetRelease) { c.Lost, c.LostRevisions = nil, nil }, ErrWrongState},
219 "other target": {func(_ *Claim, r *LostTargetRelease) { r.Target = "ie-box" }, ErrWrongState},
220 "other region": {func(_ *Claim, r *LostTargetRelease) { r.TargetRegion = "de" }, ErrWrongState},
221 "source region is to": {func(_ *Claim, r *LostTargetRelease) { r.SourceRegion = "uk" }, ErrMalformed},
222 "other op": {func(_ *Claim, r *LostTargetRelease) { r.OpID = "mv_bbbbbbbbbbbbbbbbbbbbbbbbbb" }, ErrStaleOp},
223 "committed": {func(c *Claim, _ *LostTargetRelease) { c.Op.Phase = PhaseCommitted }, ErrWrongState},
224 "bad rollback": {func(_ *Claim, r *LostTargetRelease) { r.RollbackID = "rb_x" }, ErrMalformed},
225 "wrong lane": {func(_ *Claim, r *LostTargetRelease) { r.Lane.Location = "moves/" + lostHost }, ErrMalformed},
226 "wrong return lane": {func(c *Claim, r *LostTargetRelease) { s := r.Lane; r.ReturnLane = &s }, ErrMalformed},
227 "wrong location": {func(_ *Claim, r *LostTargetRelease) { r.Location.Location = "instances/" + lostHost + "/x" }, ErrMalformed},
228 "no ids": {func(_ *Claim, r *LostTargetRelease) { r.NewID = nil }, ErrMalformed},
229 "moving (before flip)": {func(c *Claim, _ *LostTargetRelease) { c.Op.Kind, c.Op.Phase = KindMoving, PhaseSealed }, ErrWrongState},
230 } {
231 t.Run(name, func(t *testing.T) {
232 c := lostTargetClaim(t, KindLanding, "probing")
233 r := lostRelease(c, false)
234 tc.mutate(c, &r)
235 before := claimJSON(t, c)
236 err := ReleaseLostTarget(r)(c)
237 if !errors.Is(err, tc.refusal) {
238 t.Fatalf("err = %v, want %v", err, tc.refusal)
239 }
240 if claimJSON(t, c) != before {
241 t.Fatal("a refused release changed the claim")
242 }
243 })
244 }
245 // Another rollback already on the claim.
246 c := lostTargetClaim(t, KindRestoring, PhaseReleasing)
247 r := lostRelease(c, false)
248 r.RollbackID = "rb_bbbbbbbbbbbbbbbbbbbbbbbbbb"
249 if err := ReleaseLostTarget(r)(c); !errors.Is(err, ErrWrongState) {
250 t.Fatalf("other rollback = %v", err)
251 }
252 if !strings.HasPrefix(lostRB, "rb_") {
253 t.Fatal("fixture")
254 }
255}
256
257// D1: the lanes are checked against the holder's incarnation, as every
258// other writer of a move lane does, and a trailing slash is normalized.
259func TestReleaseLostTargetLaneIncarnation(t *testing.T) {
260 other, err := Incarnation("bab", "jam", lostHost, time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC))
261 if err != nil {
262 t.Fatal(err)
263 }
264 for name, mutate := range map[string]func(*Claim, *LostTargetRelease){
265 "lane of another incarnation": func(_ *Claim, r *LostTargetRelease) { r.Lane.Incarnation = other },
266 "return lane of another incarnation": func(c *Claim, r *LostTargetRelease) {
267 s := lostRelease(c, true).ReturnLane
268 s.Incarnation = other
269 r.ReturnLane = s
270 },
271 } {
272 t.Run(name, func(t *testing.T) {
273 c := lostTargetClaim(t, KindLanding, "probing")
274 r := lostRelease(c, false)
275 mutate(c, &r)
276 before := claimJSON(t, c)
277 if err := ReleaseLostTarget(r)(c); !errors.Is(err, ErrMalformed) {
278 t.Fatalf("err = %v, want %v", err, ErrMalformed)
279 }
280 if claimJSON(t, c) != before {
281 t.Fatal("a refused release changed the claim")
282 }
283 })
284 }
285 c := lostTargetClaim(t, KindLanding, "probing")
286 r := lostRelease(c, true)
287 r.Lane.Location += "/"
288 r.ReturnLane.Location += "/"
289 if err := ReleaseLostTarget(r)(c); err != nil {
290 t.Fatal(err)
291 }
292 parks := parksOf(c)
293 if _, ok := parks["park-replica|uk||moves/"+lostHost+"/"+lostOp]; !ok {
294 t.Fatalf("normalized lane park missing: %v", parks)
295 }
296 if _, ok := parks["park-replica|ie||moves/"+lostHost+"/"+lostRB]; !ok || c.Op.Journal[journalReturnLane] != "moves/"+lostHost+"/"+lostRB {
297 t.Fatalf("normalized return lane park missing: %v %v", parks, c.Op.Journal)
298 }
299}
300
301// D2: a target that released dirty and then died owes its return lane's
302// park; a release without it is refused and leaves the claim as found.
303func TestReleaseLostTargetReleasedNeedsReturnLane(t *testing.T) {
304 c := lostTargetClaim(t, KindRestoring, PhaseReleased)
305 c.Op.Exec = ""
306 c.Op.Journal[JournalEvidenceTarget] = "moves/x/stopped-target.json"
307 before := claimJSON(t, c)
308 if err := ReleaseLostTarget(lostRelease(c, false))(c); !errors.Is(err, ErrMalformed) {
309 t.Fatalf("err = %v, want %v", err, ErrMalformed)
310 }
311 if claimJSON(t, c) != before {
312 t.Fatal("a refused release changed the claim")
313 }
314}
315
316// D3: Readmit removes the box from lost without looking at the op, so a
317// retried release whose CAS landed is still a no-op afterwards.
318func TestReleaseLostTargetReplayAfterReadmit(t *testing.T) {
319 c := lostTargetClaim(t, KindLanding, "probing")
320 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
321 t.Fatal(err)
322 }
323 if err := Readmit("uk-box", 2)(c); err != nil {
324 t.Fatal(err)
325 }
326 once := claimJSON(t, c)
327 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
328 t.Fatalf("replay after readmit: %v", err)
329 }
330 if claimJSON(t, c) != once {
331 t.Fatal("a replay changed the claim")
332 }
333}
334
335func TestReleaseLostTargetRetainsInventoryBoundLocal(t *testing.T) {
336 for _, kind := range []string{KindLanding, KindRestoring} {
337 t.Run(kind, func(t *testing.T) {
338 phase := PhaseReleasing
339 if kind == KindLanding {
340 phase = PhaseProbing
341 }
342 c := lostTargetClaim(t, kind, phase)
343 c.Inventory = map[string]InventoryEntry{"uk-box": {Paths: map[string]uint64{"root": 21}}}
344 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
345 t.Fatal(err)
346 }
347 var local Obligation
348 for _, o := range c.Obligations {
349 if o.Kind == "park-local" && o.Box == "uk-box" {
350 local = o
351 }
352 }
353 if local.ID == "" || local.Op != lostOp || local.Retirement == "" {
354 t.Fatal("target inventory's retirement obligation missing")
355 }
356 before := claimJSON(t, c)
357 if err := ReleaseLostTarget(lostRelease(c, false))(c); err != nil {
358 t.Fatal(err)
359 }
360 if claimJSON(t, c) != before {
361 t.Fatal("retry lost or replaced local chore")
362 }
363 if err := ReturnToSource(lostOp, "ie-box", true, ReturnFence{})(c); err != nil {
364 t.Fatal("pending target cleanup blocked source recovery", err)
365 }
366 c.Op.Phase, c.Op.Exec = PhaseOpened, "ie-box"
367 c.Obligations = []Obligation{local}
368 if err := ReleaseMove(lostOp, "ie-box")(c); err == nil {
369 t.Fatal("move released with target-local retirement pending")
370 }
371 if err := CompleteObligation(local.Op, local.ID)(c); err != nil {
372 t.Fatal(err)
373 }
374 if err := ReleaseMove(lostOp, "ie-box")(c); err != nil {
375 t.Fatal("retired local still blocks release", err)
376 }
377 })
378 }
379}
380