carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostlife
2
3import "testing"
4
5// The cases region HA spec 1 §9 L18 and claim rev 6.30 §11 require of the
6// joint model, each in a one-box and in a two-box region (bothShapes):
7// region 0 holds box 0 alone, kept at one box by its group, or boxes 0
8// and 3. Each is a direct test: it drives the case through the model's
9// own actors and asserts the outcome the spec names; the final oracle
10// (finish) checks every invariant. The witnesses (regionha_witness_test.go)
11// are the mutation evidence; these are the coverage.
12
13// serveAndPublish serves inc 1 of app A on box 0, runs it, and lets its
14// first files manifest land, so a later failover has the host's files
15// in S3 (finding F2 is the case where none landed).
16func (w *world) serveAndPublish(t *testing.T) *box {
17 from := w.boxes[0]
18 w.serve(from, 1, app0)
19 w.running(t, from)
20 w.published(t, from)
21 return from
22}
23
24// failedOver drives the failover of the terminated executor dead until
25// the host serves again elsewhere, and returns the box it serves on: the
26// peer in a two-box region, the box the group launched in a one-box
27// region.
28func (w *world) failedOver(t *testing.T, dead *box) *box {
29 if !w.reach(t, 3000, "serving after the failover", func() bool {
30 c := w.claim
31 return c.Op.Kind == "serving" && c.Op.Exec >= 0 && c.Op.Exec != dead.ID
32 }, w.actAll(), w.recoveryPass, w.operator, w.wakeExec) {
33 return nil
34 }
35 return w.execBox(w.claim)
36}
37
38// relocTarget drives until the relocation names a target with its pin,
39// and returns it (a one-box region's failover names the replacement the
40// group launched once it enrolls).
41func (w *world) relocTarget(t *testing.T) *box {
42 if !w.reach(t, 400, "a target named and pinned", func() bool {
43 c := w.claim
44 return c.Op.Kind == "relocating" && c.Op.Tgt >= 0 && c.Op.Pin != nil
45 }, w.actAll(), w.recoveryPass) {
46 return nil
47 }
48 return w.boxByID(w.claim.Op.Tgt)
49}
50
51func TestL18CreateSpread(t *testing.T) {
52 bothShapes(t, func(t *testing.T, w *world, boxes int) {
53 w.serve(w.boxes[0], 1, app0)
54 w.h2 = liteHost{}
55 w.console = append(w.console, &consolePlan{Kind: "create", Host: "h2", App: app0, OpID: w.newID("cr"), Inc: 9})
56 w.reach(t, 50, "the second host of app A placed", func() bool { return w.h2.Kind == "serving" }, w.runConsole)
57 want := 0
58 if boxes == 2 {
59 want = 3 // the box running no instance of A (H12)
60 }
61 if w.h2.Box != want {
62 t.Fatalf("the second instance of app A landed on box %d, want %d", w.h2.Box, want)
63 }
64 if boxes == 1 {
65 return
66 }
67 w.h2 = liteHost{}
68 w.console = append(w.console, &consolePlan{Kind: "create", Host: "h2", App: app1, OpID: w.newID("cr"), Inc: 10})
69 w.reach(t, 50, "the second host of app B placed", func() bool { return w.h2.Kind == "serving" }, w.runConsole)
70 tie := 0
71 if tieHash(3) > tieHash(0) {
72 tie = 3
73 }
74 if w.h2.Box != tie {
75 t.Fatalf("tie-break: app B landed on box %d, want the greater tieHash (%d)", w.h2.Box, tie)
76 }
77 })
78}
79
80func TestL18RelocationByDrain(t *testing.T) {
81 bothShapes(t, func(t *testing.T, w *world, boxes int) {
82 from := w.serveAndPublish(t)
83 wr := w.appWrite(from)
84 id := w.timerOn(from)
85 w.userRelocate()
86 if boxes == 1 {
87 // no eligible target: the drain never starts and the host keeps
88 // serving where it is (spec §4)
89 if c := w.claim; c.Op.Kind != "serving" || c.Op.Exec != from.ID {
90 t.Fatalf("a drain started in a one-box region: %s", w.state())
91 }
92 w.finish(t)
93 return
94 }
95 to := w.peer()
96 if c := w.claim; c.Op.Kind != "relocating" || c.Op.Tgt != to.ID {
97 t.Fatalf("the drain did not start: %s", w.state())
98 }
99 w.servingOn(t, to)
100 if w.stats["admissions covered: released"] != 1 || w.stats["writes_lost declared"] > 1 || w.stats["writes declared lost"] != 0 {
101 t.Errorf("released coverage: %d released, %d writes_lost covering %d writes (want 1, at most the first serving wake's, and 0)", w.stats["admissions covered: released"], w.stats["writes_lost declared"], w.stats["writes declared lost"])
102 }
103 w.finish(t)
104 if !contains(to.DB.Log, wr) || !contains(to.Timers, id) && !w.delivered[id] {
105 t.Errorf("the drain lost write %d or timer %d", wr, id)
106 }
107 })
108}
109
110func TestL18TerminationDuringServing(t *testing.T) {
111 bothShapes(t, func(t *testing.T, w *world, boxes int) {
112 from := w.serveAndPublish(t)
113 wr := w.appWrite(from)
114 w.replicate(from)
115 w.completeDue(true)
116 w.terminate(from)
117 to := w.failedOver(t, from)
118 if to == nil {
119 return
120 }
121 if boxes == 2 && to != w.peer() || boxes == 1 && to.ID < 100 {
122 t.Errorf("served on box %d after the failover (one-box regions wait for the group's replacement)", to.ID)
123 }
124 w.finish(t)
125 if to.DB == nil || !contains(to.DB.Log, wr) {
126 t.Errorf("the failover lost the replicated write %d", wr)
127 }
128 })
129}
130
131func TestL18NestedFailover(t *testing.T) {
132 bothShapes(t, func(t *testing.T, w *world, boxes int) {
133 from := w.serveAndPublish(t)
134 w.extraBox(0, spareID())
135 var to *box
136 if boxes == 2 {
137 w.drainTo(t, nil)
138 to = w.boxByID(w.claim.Op.Tgt)
139 } else {
140 w.terminate(from)
141 to = w.relocTarget(t)
142 }
143 if to == nil || !w.reach(t, 1500, "the probe runs on the target", func() bool { return preparedOpened(w.claim) && hasMain(to) }, w.actAll(), w.recoveryPass, func() { w.wake(to) }) {
144 return
145 }
146 wr := w.appWrite(to)
147 w.replicate(to)
148 w.completeDue(true) // the target's write is in P's remote before it dies
149 w.terminate(to)
150 next := w.failedOver(t, to)
151 w.finish(t)
152 if next == nil || next.DB == nil || !contains(next.DB.Log, wr) {
153 t.Fatalf("the nested failover lost the target's replicated write %d", wr)
154 }
155 if w.stats["nested failover after P was admitted"] != 1 {
156 t.Fatalf("no nested failover: %v", w.stats)
157 }
158 })
159}
160
161func TestL18StaleEdgesFile(t *testing.T) {
162 bothShapes(t, func(t *testing.T, w *world, boxes int) {
163 from := w.serveAndPublish(t)
164 if boxes == 1 {
165 // a one-box region's former executor is gone: a request through
166 // its stale edges file reaches nothing
167 w.terminate(from)
168 w.failedOver(t, from)
169 w.drive(30, never, func() { w.reconcile(from); w.wake(from) })
170 if hasMain(from) {
171 t.Fatalf("the terminated box ran the host")
172 }
173 w.finish(t)
174 return
175 }
176 to := w.peer()
177 w.drainTo(t, to)
178 w.servingOn(t, to)
179 if from.Row == nil || from.Row.Status == "ready" {
180 t.Fatalf("the former executor's row is not held: %+v", from.Row)
181 }
182 w.drive(30, never, func() { w.reconcile(from); w.wake(from) })
183 if hasMain(from) {
184 t.Fatalf("a request through the stale edges file started the host on the former executor")
185 }
186 w.finish(t)
187 })
188}
189
190func TestL18UnenrolledReference(t *testing.T) {
191 bothShapes(t, func(t *testing.T, w *world, boxes int) {
192 from := w.serveAndPublish(t)
193 if p := w.peer(); p != nil {
194 p.HasKey = false // the region's other box may work, never take the host (R9)
195 }
196 w.terminate(from)
197 w.now += replaceDelay
198 w.launchReplacements()
199 nb := w.boxes[len(w.boxes)-1]
200 if nb.Region != 0 || nb.Enrolled {
201 t.Fatalf("no unenrolled replacement in region 0: %+v", nb)
202 }
203 nb.EnrollAt = w.now + 10*enrollDelay // its confirmation is slow
204 w.drive(3*enrollDelay, never, w.actAll(), w.recoveryPass)
205 c := w.claim
206 if c.Op.Exec == nb.ID || c.Op.Tgt == nb.ID {
207 t.Fatalf("an unenrolled box was named: %s", w.state())
208 }
209 if boxes == 2 && (c.Op.Kind != "serving" || contains(c.Lost, from.ID)) {
210 // Worker.Start needs an eligible target (rev 7, Astra round 4
211 // finding 3): the failover does not start, and the claim still
212 // names the dead box, until the replacement enrolls
213 t.Fatalf("the failover started with no eligible target: %s", w.state())
214 }
215 if boxes == 1 && c.Op.Kind != "serving" {
216 // no enrolled worker of the region exists to write the fence: the
217 // claim still names the dead box until the replacement enrolls
218 t.Fatalf("a one-box region was fenced with no enrolled worker in it: %s", w.state())
219 }
220 nb.EnrollAt = w.now - enrollDelay
221 if to := w.failedOver(t, from); to != nb {
222 t.Fatalf("served on %v, want the replacement %d once enrolled", to, nb.ID)
223 }
224 w.finish(t)
225 })
226}
227
228// TestL18AdverseVolume (claim §11's adverse-volume case; H19; rev 5, Astra
229// round 2 finding 2): the current executor is terminated with a
230// volume that was not DeleteOnTermination. Execution recovery proceeds
231// to serving on the region's other box (two-box) or on the group's
232// replacement (one-box) with no operator, and the dead box's park-local
233// stays open throughout; only the operator's cleanup (the declared
234// destroyed-local loss) removes it.
235func TestL18AdverseVolume(t *testing.T) {
236 bothShapes(t, func(t *testing.T, w *world, boxes int) {
237 from := w.serveAndPublish(t)
238 from.DeleteOnTermination = false
239 w.terminate(from) // the current executor
240 if !w.reach(t, 3000, "serving after the failover, with no operator", func() bool {
241 c := w.claim
242 return c.Op.Kind == "serving" && c.Op.Exec >= 0 && c.Op.Exec != from.ID
243 }, w.actAll(), w.recoveryPass, w.wakeExec) {
244 return
245 }
246 to := w.execBox(w.claim)
247 if boxes == 2 && to != w.peer() {
248 t.Fatalf("served on %v, want the region's other box %d", to, w.peer().ID)
249 }
250 if !w.owesLocal(w.claim, from) {
251 t.Fatalf("the dead box's park-local is not open after the failover: %s", w.state())
252 }
253 if w.stats["fence: park-local chores left open (volume evidence)"] == 0 || w.stats["fence: park-local chores discharged"] != 0 {
254 t.Fatalf("the fence's discharge ignored the volume evidence: %v", w.stats)
255 }
256 w.drive(200, never, w.actAll(), w.recoveryPass, w.wakeExec)
257 if !w.owesLocal(w.claim, from) {
258 t.Fatalf("the dead box's park-local closed without the operator: %s", w.state())
259 }
260 w.finish(t) // the operator's destroyed-local declaration clears it
261 if w.owesLocal(w.claim, from) {
262 t.Fatalf("the park-local is still open after the operator's cleanup: %s", w.state())
263 }
264 })
265}
266
267func TestL18CrashDuringInstallation(t *testing.T) {
268 for _, boxes := range []int{1, 2} {
269 for _, at := range []string{"installing", "installed", "probing"} {
270 t.Run(map[int]string{1: "one-box region", 2: "two-box region"}[boxes]+"/"+at, func(t *testing.T) {
271 w := regionWorld(boxes)
272 from := w.serveAndPublish(t)
273 w.timerOn(from)
274 if boxes == 2 {
275 w.drainTo(t, nil)
276 } else {
277 w.terminate(from)
278 }
279 w.relocPhase(t, at, w.actAll(), w.recoveryPass)
280 to := w.boxByID(w.claim.Op.Tgt)
281 w.drive(2, never, w.act(to)) // a step into the phase: a PUT issued, a files directory extracted
282 w.crash(to)
283 if c := w.claim; c.Op.Kind == "relocating" && c.Op.Phase == "probed" && c.Op.ProbeStop == nil && to.probeRecord == nil {
284 // the probe passed and the crash came before the probe
285 // stop recorded its flush (ruling 87): no replicator, so
286 // the stop refuses until the deadline, and the operator's
287 // resolve serves the host elsewhere
288 w.servingOn(t, nil)
289 } else {
290 w.servingOn(t, to)
291 }
292 w.finish(t)
293 w.mustHold(t)
294 })
295 }
296 }
297}
298
299func TestL18LateFileUploads(t *testing.T) {
300 bothShapes(t, func(t *testing.T, w *world, boxes int) {
301 from := w.serveAndPublish(t)
302 late := w.timerOn(from)
303 loc := w.claim.Holder.Location
304 w.holdKey(loc + "files/")
305 w.publishFiles(from) // the manifest carrying the timer, held
306 w.hold = nil
307 w.terminate(from)
308 to := w.failedOver(t, from)
309 if to == nil {
310 return
311 }
312 if contains(to.Timers, late) {
313 t.Fatalf("the install applied a manifest that had not landed at the pin")
314 }
315 declared := false
316 for _, a := range w.acks {
317 if a.W == late && a.Exempt {
318 declared = true
319 }
320 }
321 if !declared {
322 t.Fatalf("the timer the late manifest carried was not declared with the fence")
323 }
324 if n := w.completeHeld(loc + "files/"); n != 1 {
325 t.Fatalf("held manifest PUTs completed: %d, want 1", n)
326 }
327 w.finish(t)
328 })
329}
330
331func TestL18LatePutAfterTermination(t *testing.T) {
332 bothShapes(t, func(t *testing.T, w *world, boxes int) {
333 from := w.serveAndPublish(t)
334 late := w.appWrite(from)
335 loc := w.claim.Holder.Location
336 w.holdKey(loc)
337 w.replicate(from) // the upload of the late write is held
338 w.hold = nil
339 w.terminate(from)
340 to := w.failedOver(t, from)
341 if to == nil {
342 return
343 }
344 if contains(to.DB.Log, late) {
345 t.Fatalf("the install restored a write whose upload had not landed at the pin")
346 }
347 if n := w.completeHeld(loc); n == 0 {
348 t.Fatalf("no held upload to complete")
349 }
350 w.finish(t) // the late object lands in generation_from, read by nothing, parked
351 })
352}
353
354// ---------- claim §11's region HA witnesses beyond L18 (two-box) ----------
355
356// TestRelocationStartRacesPurge: a worker read the serving claim, then
357// paused; a purge started meanwhile. A fresh start reads the purging
358// claim and refuses; no relocation runs over a purging host.
359func TestRelocationStartRacesPurge(t *testing.T) {
360 w := twoBoxWitness()
361 w.serve(w.boxes[0], 1, app0)
362 w.userPurge()
363 w.reach(t, 100, "the purge started", func() bool { return w.claim.Op.Kind == "purging" || w.admit[app0].Closed }, w.runConsole)
364 w.userRelocate()
365 if w.claim.Op.Kind == "relocating" {
366 t.Fatalf("a relocation started over a purging host: %s", w.state())
367 }
368 w.finish(t)
369 w.mustHold(t)
370}
371
372// TestEmptyDatabaseDrain: a created host whose database never committed
373// (nothing replicated) but has files and a timer is drained: the release
374// flushes at 0, the pin is an evidenced empty one, and the files and the
375// timer arrive through the manifests.
376func TestEmptyDatabaseDrain(t *testing.T) {
377 w := twoBoxWitness()
378 w.console = append(w.console, &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: 1, Region: 0})
379 w.reach(t, 100, "created", func() bool { c := w.claim; return c != nil && c.Holder != nil && c.Op.Kind == "serving" }, w.runConsole, w.recoveryPass)
380 from := w.execBox(w.claim)
381 w.reach(t, 100, "the host runs", func() bool { return hasMain(from) }, w.actAll(), func() { w.wake(from) })
382 id := w.timerOn(from)
383 key := from.Files.Key
384 w.published(t, from)
385 w.drainTo(t, nil)
386 to := w.boxByID(w.claim.Op.Tgt)
387 w.servingOn(t, to)
388 w.finish(t)
389 w.mustHold(t)
390 if !contains(to.Timers, id) && !w.delivered[id] {
391 t.Fatalf("the timer did not survive the empty-database drain")
392 }
393 if to.Files == nil || to.Files.Key != key {
394 t.Fatalf("the files did not survive the empty-database drain: %+v", to.Files)
395 }
396}
397
398// TestReleasedThenFailedProbe: the drain's first probe admission is
399// covered released; the target cannot run the app, fails its probe,
400// releases, and the nested relocation's admission is covered by the
401// failed target's own release: nothing is declared.
402func TestReleasedThenFailedProbe(t *testing.T) {
403 w := twoBoxWitness()
404 w.claimC1 = true // the failed target's release (claim ask C1, finding 4)
405 from, to := w.boxes[0], w.boxes[3]
406 to.FailApp = true
407 w.serve(from, 1, app0)
408 w.running(t, from)
409 w.drainTo(t, to)
410 third := w.extraBox(0, 30)
411 w.servingOn(t, third)
412 w.finish(t)
413 w.mustHold(t) // nothing acknowledged is lost: each later attempt declares its predecessor's session, which acknowledged nothing
414 if w.stats["admissions covered: released"] < 1 {
415 t.Fatalf("the drain's first probe admission was not covered released: %v", w.stats)
416 }
417}
418
419// TestStuckAfterTwoReplacements (reloc-replacement-cap, an operator
420// bound): three targets die at installing; the third death is the
421// exhausted fence, the operation is stuck, and the operator's resolve
422// replays it onto a spare.
423func TestStuckAfterTwoReplacements(t *testing.T) {
424 w := twoBoxWitness()
425 from := w.boxes[0]
426 w.serve(from, 1, app0)
427 w.running(t, from)
428 w.extraBox(0, 30)
429 w.extraBox(0, 31)
430 w.drainTo(t, nil)
431 for i := 0; i < 3; i++ {
432 if !w.reach(t, 1500, "a target installing", func() bool {
433 c := w.claim
434 return c.Op.Kind == "relocating" && c.Op.Phase == "installing" && c.Op.Stuck == ""
435 }, w.act(w.liveBoxes()...), w.recoveryPass) {
436 t.Fatalf("target %d never installed: %s", i, w.state())
437 }
438 x := w.boxByID(w.claim.Op.Exec)
439 w.terminate(x)
440 w.reach(t, 100, "fenced", func() bool { return contains(w.claim.Lost, x.ID) }, w.act(w.liveBoxes()...))
441 }
442 if w.claim.Op.Stuck == "" {
443 t.Fatalf("a third loss did not make the operation stuck: %s", w.state())
444 }
445 w.extraBox(0, spareID())
446 w.servingOn(t, nil) // the operator resolves it
447 w.finish(t)
448 w.mustHold(t)
449 if w.stats["operator resolved a stuck relocation"] != 1 {
450 t.Fatalf("the operator did not resolve it: %v", w.stats)
451 }
452}
453
454// TestReleaseStopsAnEscapedDescendant (L10): a process that escaped the
455// cgroup is stopped with the whole tree before release step 1 passes
456// (the model's evidence-whole-tree: the cleanup-only reopen kills the
457// cgroup and every identified stray); the source's release is journaled
458// only once nothing of the host runs on it.
459func TestReleaseStopsAnEscapedDescendant(t *testing.T) {
460 w := twoBoxWitness()
461 from, to := w.boxes[0], w.boxes[3]
462 w.serve(from, 1, app0)
463 w.running(t, from)
464 from.Procs = append(from.Procs, &proc{Main: false, Strict: false, Inc: 1, Box: from.ID, Session: w.claim.Holder.Session})
465 w.drainTo(t, to)
466 if !w.reach(t, 300, "the source's release journaled", func() bool { return w.claim.Op.Release != nil }, w.act(from, to), w.recoveryPass) {
467 w.mustHold(t)
468 }
469 if len(from.Procs) != 0 {
470 t.Fatalf("the release was journaled with %d processes of the host alive on the source", len(from.Procs))
471 }
472 w.servingOn(t, to)
473 w.finish(t)
474 w.mustHold(t)
475}
476
477// ---------- the bounds: direct evidence for the rules no invariant catches alone ----------
478
479// TestDrainRefusedForANeverWokenHost (reloc-drain-needs-writer, ruling
480// 11): a host with no writer session is never drained; its failover
481// relocates it.
482func TestDrainRefusedForANeverWokenHost(t *testing.T) {
483 w := twoBoxWitness()
484 w.serve(w.boxes[0], 1, app0)
485 w.cas(w.claim, func(n *claim) bool { n.Holder.Session = 0; return true })
486 w.userRelocate()
487 if w.claim.Op.Kind != "serving" || w.stats["drain refused: the host never had a writer"] != 1 {
488 t.Fatalf("a never-woken host was drained: %s", w.state())
489 }
490}
491
492// TestReplacedTargetAcknowledgesBeforePinning (reloc-ack-before-pinning):
493// the target is replaced in releasing; the operation leaves releasing
494// only once the replacement's execution lease is current.
495func TestReplacedTargetAcknowledgesBeforePinning(t *testing.T) {
496 w := twoBoxWitness()
497 from, to := w.boxes[0], w.boxes[3]
498 w.serve(from, 1, app0)
499 w.running(t, from)
500 w.published(t, from)
501 w.drainTo(t, to)
502 spare := w.extraBox(0, 30)
503 w.relocPhase(t, "releasing", w.act(from, to), w.recoveryPass)
504 w.terminate(to)
505 w.reach(t, 100, "the target replaced in releasing", func() bool { return w.claim.Op.Tgt == spare.ID }, w.act(from))
506 // the spare never acts: no lease, so the operation must stay in releasing
507 w.drive(40, never, w.act(from))
508 if c := w.claim; c.Op.Phase != "releasing" || w.stats["pin after release: waits for the acknowledgements"] == 0 {
509 t.Fatalf("left releasing without the replacement's acknowledgement: %s", w.state())
510 }
511 w.servingOn(t, spare)
512 w.finish(t)
513 w.mustHold(t)
514}
515
516// TestProbeAttemptsCountedBeforeTheyAreMade (reloc-probe-attempts,
517// ruling 19): an agent that crashes in every attempt never buys a
518// fourth; the target goes to probe-failed after three.
519func TestProbeAttemptsCountedBeforeTheyAreMade(t *testing.T) {
520 w := twoBoxWitness()
521 from, to := w.boxes[0], w.boxes[3]
522 w.serve(from, 1, app0)
523 w.running(t, from)
524 w.drainTo(t, to)
525 w.relocPhase(t, "probing", w.act(from, to), w.recoveryPass)
526 to.envUnreadyFor = 1000 // the launch environment never gets ready: each attempt is still starting when the agent dies
527 before := w.stats["admissions: cold"]
528 for i := 0; i < 8 && w.claim.Op.Phase == "probing"; i++ {
529 w.drive(3, func() bool { return to.open != nil }, w.act(to))
530 w.crash(to) // each attempt dies before its probe
531 }
532 if n := w.stats["admissions: cold"] - before; n > probeTries || w.claim.Op.Phase == "probing" {
533 t.Fatalf("%d attempts admitted, phase %s; want at most %d, then probe-failed", n, w.claim.Op.Phase, probeTries)
534 }
535}
536
537// TestCreateNamesItsExecutorOnce (create-choice-once, claim P5-D2,
538// P5-D6): a create names its executor in the intent; when that box is
539// gone before the create completes, recovery cancels the create and
540// never substitutes the region's other box.
541func TestCreateNamesItsExecutorOnce(t *testing.T) {
542 w := twoBoxWitness()
543 p := &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: 1, Region: 0}
544 w.admit[app0].Reg[p.OpID] = regEntry{Until: w.now + 1000}
545 w.console = append(w.console, p)
546 w.reach(t, 50, "the create claimed the host", func() bool { c := w.claim; return c != nil && c.Op.Kind == "creating" }, w.runConsole)
547 chosen := w.boxByID(w.claim.Op.NewBox)
548 w.terminate(chosen)
549 w.drive(200, func() bool { c := w.claim; return c == nil || c.Holder == nil || c.Op.Kind == "serving" }, w.runConsole, w.recoveryPass)
550 if c := w.claim; c != nil && c.Holder != nil && c.Op.Kind == "serving" {
551 t.Fatalf("the create substituted box %d for its chosen box %d", c.Op.Exec, chosen.ID)
552 }
553 if w.stats["create cancelled: its box is gone"] != 1 {
554 t.Fatalf("the create was not cancelled: %v", w.stats)
555 }
556}
557
558// TestStoppedBoxNeverFenced (fence-terminated-only): a stopped executor
559// is never fenced; when it starts again with its disk, it serves.
560func TestStoppedBoxNeverFenced(t *testing.T) {
561 w := twoBoxWitness()
562 from, to := w.boxes[0], w.boxes[3]
563 w.serve(from, 1, app0)
564 w.running(t, from)
565 from.Destroyed, from.Procs = true, nil // stopped, not terminated
566 w.drive(100, never, w.act(to), w.recoveryPass)
567 if contains(w.claim.Lost, from.ID) || w.claim.Op.Kind != "serving" {
568 t.Fatalf("a stopped box was fenced: %s", w.state())
569 }
570 from.Destroyed = false
571 w.running(t, from)
572 w.finish(t)
573 w.mustHold(t)
574}
575
576// TestRelocationStartGuardRefuses (reloc-start-guard): a whole-location
577// chore in the region refuses the start; a park-generation does not.
578func TestRelocationStartGuardRefuses(t *testing.T) {
579 w := twoBoxWitness()
580 w.serve(w.boxes[0], 1, app0)
581 w.claim.Obl = append(w.claim.Obl, obligation{ID: "pg", Op: "old", Kind: "park-generation", Region: 0, Box: -1, Prefix: w.claim.Holder.Location, Ret: "old"})
582 if !w.relocationStartAllowed(w.claim, w.boxes[0], w.boxes[3]) {
583 t.Fatalf("a park-generation refused the start (L11)")
584 }
585 w.claim.Obl = append(w.claim.Obl, obligation{ID: "pr", Op: "old", Kind: "park-replica", Region: 0, Box: -1, Prefix: w.claim.Holder.Location})
586 if w.relocationStartAllowed(w.claim, w.boxes[0], w.boxes[3]) {
587 t.Fatalf("a park-replica in the region did not refuse the start (CC11)")
588 }
589}
590
591// TestOnlyTheExecutorInstalls (reloc-exec-only-installs, HA2): through a
592// drain, no box but op.exec writes P or makes a local install.
593func TestOnlyTheExecutorInstalls(t *testing.T) {
594 w := twoBoxWitness()
595 from, to := w.boxes[0], w.boxes[3]
596 w.serve(from, 1, app0)
597 w.running(t, from)
598 w.drainTo(t, to)
599 w.servingOn(t, to)
600 if from.receipt != nil || from.Files != nil && from.Files.Gen != "" && from.Files.Gen == w.claim.Holder.Gen {
601 t.Fatalf("the source made a local install of P")
602 }
603}
604
605// ---------- findings F1 to F3, closed by 1b-next rev 17 to 19 ----------
606
607// TestFailoverBeforeTheFirstManifest (finding F2, closed by 1b-next
608// ruling 25 as rev 18 amended it): a host none of whose files manifests
609// has landed is terminated. The pin refuses files that carry no
610// instance.key for a nonempty database, the operation is stuck, and
611// nothing is installed: no fresh key over sealed data. The database is
612// unrecoverable by relocation (L21); the operator's replay never accepts
613// the keyless pin. Under the mutation of files-pin-needs-key the install
614// mints a fresh key (INV-4) and this test fails.
615func TestFailoverBeforeTheFirstManifest(t *testing.T) {
616 w := twoBoxWitness()
617 from := w.boxes[0]
618 w.serve(from, 1, app0)
619 loc := w.claim.Holder.Location
620 w.holdKey(loc + "files/") // the first manifest's PUT has not landed
621 w.running(t, from)
622 w.hold = nil
623 w.terminate(from)
624 w.drive(400, func() bool { return len(w.violations) > 0 }, w.actAll(), w.recoveryPass, w.wakeExec)
625 w.mustHold(t)
626 c := w.claim
627 if c.Op.Kind != "relocating" || c.Op.Stuck == "" || c.Op.Pin != nil || c.Op.Prepared != "" {
628 t.Fatalf("the failover did not stop at an unevidenced files pin: %s", w.state())
629 }
630 if w.stats["pin: files unevidenced, no instance.key"] == 0 {
631 t.Fatalf("the pin never refused: %v", w.stats)
632 }
633}
634
635// TestProbeFailedInATwoBoxRegion (finding F1, closed by 1b-next ruling
636// 26): a drain's target cannot run the app; its probe fails and it
637// releases. With no spare the worker waits, and ReleaseDeadline after
638// probe_failed_since the operation is stuck; nothing names the source
639// automatically. The operator's resolve returns the host to the source
640// (plan ruling 13). Under the mutation of reloc-no-spare-bound the
641// operation waits for ever (INV-3) and this test fails.
642func TestProbeFailedInATwoBoxRegion(t *testing.T) {
643 w := twoBoxWitness()
644 w.claimC1 = true // the failed target's release (claim ask C1, finding 4)
645 from, to := w.boxes[0], w.boxes[3]
646 to.FailApp = true
647 w.serve(from, 1, app0)
648 w.running(t, from)
649 w.drainTo(t, to)
650 w.reach(t, 1500, "probe-failed with the target released", func() bool {
651 c := w.claim
652 return c.Op.Phase == "probe-failed" && c.Op.Release != nil && c.Op.Release.Box == to.ID
653 }, w.actAll(), w.recoveryPass)
654 w.drive(releaseDeadline/2, never, w.actAll(), w.recoveryPass)
655 if c := w.claim; c.Op.Stuck != "" || c.Op.Tgt == from.ID {
656 t.Fatalf("before the deadline the operation is stuck or names the source: %s", w.state())
657 }
658 w.reach(t, 1500, "stuck with no spare", func() bool { return w.claim.Op.Stuck == "no eligible box for the nested relocation" }, w.actAll(), w.recoveryPass)
659 w.servingOn(t, from)
660 w.finish(t)
661 w.mustHold(t)
662}
663
664// TestAbandonedPreparedGenerationParked (finding F3, closed by 1b-next
665// ruling 27 and rev 18 finding 3): an unopened P is recorded on every
666// path that drops it and parked at completion. installing: the target
667// is replaced at installing and the replacement builds P'. probe-failed:
668// the probe deadline passes before any admission, the target is then
669// terminated, and the replacement table's nested branch drops P. Under
670// the mutation of reloc-abandoned-prepared INV-10 finds P's objects.
671func TestAbandonedPreparedGenerationParked(t *testing.T) {
672 for _, path := range []string{"installing", "probe-failed"} {
673 t.Run(path, func(t *testing.T) {
674 w := twoBoxWitness()
675 from, to := w.boxes[0], w.boxes[3]
676 w.serve(from, 1, app0)
677 w.running(t, from)
678 w.published(t, from)
679 w.drainTo(t, to)
680 third := w.extraBox(0, 30)
681 w.reach(t, 1500, "P's snapshot written", func() bool { c := w.claim; return c.Op.Phase == "installing" && c.Op.BaseKey != "" }, w.act(from, to), w.recoveryPass)
682 p := w.claim.Op.Prepared
683 if path == "probe-failed" {
684 w.dropPBeforeAdmission(t, from, to)
685 } else {
686 w.terminate(to)
687 }
688 w.reach(t, 300, "P dropped", func() bool { return w.claim.Op.Prepared != p }, w.actAll(), w.recoveryPass)
689 w.servingOn(t, third)
690 if w.stats["relocation: abandoned prepared generation parked"] == 0 {
691 t.Fatalf("the abandoned P %q was not parked at serving", p)
692 }
693 w.finish(t)
694 w.mustHold(t)
695 })
696 }
697}
698
699// dropPBeforeAdmission is rev 18 finding 3's trace: the target freezes
700// in probing until the probe deadline passes with P unopened, the worker
701// moves it to probe-failed, and it is terminated there.
702func (w *world) dropPBeforeAdmission(t *testing.T, from, to *box) {
703 w.reach(t, 1500, "probing", func() bool { return w.claim.Op.Phase == "probing" }, w.act(from, to), w.recoveryPass)
704 to.Frozen = true
705 w.reach(t, 3*probeDeadline, "probe-failed before any admission", func() bool { return w.claim.Op.Phase == "probe-failed" }, w.act(from), w.recoveryPass)
706 if preparedOpened(w.claim) {
707 t.Fatalf("P was opened")
708 }
709 w.terminate(to)
710}
711
712// invNumber is the n of the first "INV-n" in a violation, 0 if none.
713func invNumber(v string) int {
714 for i := 0; i+4 < len(v); i++ {
715 if v[i:i+4] == "INV-" {
716 n := 0
717 for j := i + 4; j < len(v) && v[j] >= '0' && v[j] <= '9'; j++ {
718 n = n*10 + int(v[j]-'0')
719 }
720 return n
721 }
722 }
723 return 0
724}
725
726// mustHold fails the test, with the trace, if any invariant was violated.
727func (w *world) mustHold(t *testing.T) {
728 t.Helper()
729 if len(w.violations) > 0 {
730 dump(t, w)
731 t.Fatalf("%s", w.violations[0])
732 }
733}
734