carlos / platform Public

sim: region HA scenarios: every L18 case in a one-box and a two-box region, claim §11's extras, twenty witnesses for the relocation, fence and files guards, direct evidence for every bound, and reproductions of findings F1 to F3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3cb7f53c87a894e9dac9bdb4c493af487ecc5830 parent 25982b0
9 files changed, +1389 −16
  • internal/sim/hostlife/background_test.go +1 −1
  • internal/sim/hostlife/console_test.go +11 −1
  • internal/sim/hostlife/protocol_test.go +1 −1
  • internal/sim/hostlife/regionha_cases_test.go +717 −0
  • internal/sim/hostlife/regionha_test.go +10 −1
  • internal/sim/hostlife/regionha_witness_test.go +590 −5
  • internal/sim/hostlife/twobox_test.go +8 −4
  • internal/sim/hostlife/witness_test.go +44 −0
  • internal/sim/hostlife/world_test.go +7 −3
diff --git a/internal/sim/hostlife/background_test.go b/internal/sim/hostlife/background_test.go
index 19c587478..91b0da4ae 100644
--- a/internal/sim/hostlife/background_test.go
+++ b/internal/sim/hostlife/background_test.go
@@ -476,7 +476,7 @@ func (w *world) fault() {
func (w *world) replaceDestroyed() {
for r := 0; r < nRegions; r++ {
- for len(w.regionBoxes(r)) < w.perRegion { // the region's minimum (H5): the group launches replacements
+ for len(w.regionBoxes(r)) < w.regionMin(r) { // the region's minimum (H5): the group launches replacements
w.nextID++
nb := &box{ID: 100 + w.nextID, Region: r, HasKey: true, Enrolled: w.perRegion == 1, EnrollAt: w.now, DeleteOnTermination: true}
nb.forget()
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go
index 57c09ad44..cf98bfc3a 100644
--- a/internal/sim/hostlife/console_test.go
+++ b/internal/sim/hostlife/console_test.go
@@ -201,7 +201,7 @@ func (w *world) createSteps(p *consolePlan) bool {
if on("fence-etag") && w.h2Epoch != p.SeenVer {
return restart() // If-Match the epoch read at registration (If-None-Match: * when it was 0)
}
- w.h2 = liteHost{Inc: p.Inc, App: p.App, Kind: "creating", OpID: p.OpID, At: w.now}
+ w.h2 = liteHost{Inc: p.Inc, App: p.App, Kind: "creating", OpID: p.OpID, At: w.now, Box: w.h2Box(p.App)}
w.h2Epoch++ // every write changes the claim's ETag
w.logf("create %s: second host claimed for app %s", p.OpID, p.App)
p.Step = 2
@@ -383,6 +383,16 @@ func (w *world) userMove() {
w.moveTo(w.rng.Intn(nRegions)) // drawn only when a move is possible, as before rev 7.3
}
+// h2Box is the executor the second host's create names (region HA §5.2):
+// choose() in region 0, where the model keeps the control-plane-only
+// host. With one box per region it is box 0, as the model always implied.
+func (w *world) h2Box(app string) int {
+ if b := w.chooseBox(0, app); b != nil {
+ return b.ID
+ }
+ return 0
+}
+
// createExecutor is the box a create's step 4 hands op.exec to: the one
// its intent named, never another (region HA §5.2, claim P5-D2: recovery
// never substitutes a newly available box); nil while that box is gone.
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go
index a0d45bd97..3ad1cc317 100644
--- a/internal/sim/hostlife/protocol_test.go
+++ b/internal/sim/hostlife/protocol_test.go
@@ -592,7 +592,7 @@ func (w *world) appStart(b *box, c *claim, want string, probe bool, mode string)
return false // not an app failure: the agent waits for its config pass
}
// FailApp is the injected "the target cannot run it" fault: landing probes only
- healthy := !(probe && b.FailApp && c.Op.Kind == "landing")
+ healthy := !(probe && b.FailApp && (c.Op.Kind == "landing" || c.Op.Kind == "relocating")) // and relocation probes (region HA, Task 5)
// §7.7: the probe checks the launch environment at spawn. The forward
// path requires the inputs pinned at admission. A recovery probe (the
// source's revert or rollback) runs on the inputs it rendered now and
diff --git a/internal/sim/hostlife/regionha_cases_test.go b/internal/sim/hostlife/regionha_cases_test.go
new file mode 100644
index 000000000..9a52eea4b
--- /dev/null
+++ b/internal/sim/hostlife/regionha_cases_test.go
@@ -0,0 +1,717 @@
+package hostlife
+
+import "testing"
+
+// The cases region HA spec 1 §9 L18 and claim rev 6.30 §11 require of the
+// joint model, each in a one-box and in a two-box region (bothShapes):
+// region 0 holds box 0 alone, kept at one box by its group, or boxes 0
+// and 3. Each is a direct test: it drives the case through the model's
+// own actors and asserts the outcome the spec names; the final oracle
+// (finish) checks every invariant. The witnesses (regionha_witness_test.go)
+// are the mutation evidence; these are the coverage.
+
+// serveAndPublish serves inc 1 of app A on box 0, runs it, and lets its
+// first files manifest land, so a later failover has the host's files
+// in S3 (finding F2 is the case where none landed).
+func (w *world) serveAndPublish(t *testing.T) *box {
+ from := w.boxes[0]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.published(t, from)
+ return from
+}
+
+// failedOver drives the failover of the terminated executor dead until
+// the host serves again elsewhere, and returns the box it serves on: the
+// peer in a two-box region, the box the group launched in a one-box
+// region.
+func (w *world) failedOver(t *testing.T, dead *box) *box {
+ if !w.reach(t, 3000, "serving after the failover", func() bool {
+ c := w.claim
+ return c.Op.Kind == "serving" && c.Op.Exec >= 0 && c.Op.Exec != dead.ID
+ }, w.actAll(), w.recoveryPass, w.operator, w.wakeExec) {
+ return nil
+ }
+ return w.execBox(w.claim)
+}
+
+// relocTarget drives until the relocation names a target with its pin,
+// and returns it (a one-box region's failover names the replacement the
+// group launched once it enrolls).
+func (w *world) relocTarget(t *testing.T) *box {
+ if !w.reach(t, 400, "a target named and pinned", func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && c.Op.Tgt >= 0 && c.Op.Pin != nil
+ }, w.actAll(), w.recoveryPass) {
+ return nil
+ }
+ return w.boxByID(w.claim.Op.Tgt)
+}
+
+func TestL18CreateSpread(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ w.serve(w.boxes[0], 1, app0)
+ w.h2 = liteHost{}
+ w.console = append(w.console, &consolePlan{Kind: "create", Host: "h2", App: app0, OpID: w.newID("cr"), Inc: 9})
+ w.reach(t, 50, "the second host of app A placed", func() bool { return w.h2.Kind == "serving" }, w.runConsole)
+ want := 0
+ if boxes == 2 {
+ want = 3 // the box running no instance of A (H12)
+ }
+ if w.h2.Box != want {
+ t.Fatalf("the second instance of app A landed on box %d, want %d", w.h2.Box, want)
+ }
+ if boxes == 1 {
+ return
+ }
+ w.h2 = liteHost{}
+ w.console = append(w.console, &consolePlan{Kind: "create", Host: "h2", App: app1, OpID: w.newID("cr"), Inc: 10})
+ w.reach(t, 50, "the second host of app B placed", func() bool { return w.h2.Kind == "serving" }, w.runConsole)
+ tie := 0
+ if tieHash(3) > tieHash(0) {
+ tie = 3
+ }
+ if w.h2.Box != tie {
+ t.Fatalf("tie-break: app B landed on box %d, want the greater tieHash (%d)", w.h2.Box, tie)
+ }
+ })
+}
+
+func TestL18RelocationByDrain(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ wr := w.appWrite(from)
+ id := w.timerOn(from)
+ w.userRelocate()
+ if boxes == 1 {
+ // no eligible target: the drain never starts and the host keeps
+ // serving where it is (spec §4)
+ if c := w.claim; c.Op.Kind != "serving" || c.Op.Exec != from.ID {
+ t.Fatalf("a drain started in a one-box region: %s", w.state())
+ }
+ w.finish(t)
+ return
+ }
+ to := w.peer()
+ if c := w.claim; c.Op.Kind != "relocating" || c.Op.Tgt != to.ID {
+ t.Fatalf("the drain did not start: %s", w.state())
+ }
+ w.servingOn(t, to)
+ if w.stats["admissions covered: released"] != 1 || w.stats["writes_lost declared"] != 0 {
+ t.Errorf("released coverage: %d released, %d writes_lost (want 1 and 0)", w.stats["admissions covered: released"], w.stats["writes_lost declared"])
+ }
+ w.finish(t)
+ if !contains(to.DB.Log, wr) || !contains(to.Timers, id) && !w.delivered[id] {
+ t.Errorf("the drain lost write %d or timer %d", wr, id)
+ }
+ })
+}
+
+func TestL18TerminationDuringServing(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ wr := w.appWrite(from)
+ w.replicate(from)
+ w.completeDue(true)
+ w.terminate(from)
+ to := w.failedOver(t, from)
+ if to == nil {
+ return
+ }
+ if boxes == 2 && to != w.peer() || boxes == 1 && to.ID < 100 {
+ t.Errorf("served on box %d after the failover (one-box regions wait for the group's replacement)", to.ID)
+ }
+ w.finish(t)
+ if to.DB == nil || !contains(to.DB.Log, wr) {
+ t.Errorf("the failover lost the replicated write %d", wr)
+ }
+ })
+}
+
+func TestL18TerminationInEveryRelocationPhase(t *testing.T) {
+ phases := []string{"starting", "releasing", "pinning", "installing", "installed", "probing", "probed", "opened"}
+ for _, boxes := range []int{1, 2} {
+ for _, who := range []string{"source", "target"} {
+ for _, phase := range phases {
+ if boxes == 1 && (who == "source" || phase == "starting" || phase == "releasing") {
+ continue // a one-box region relocates only by failover, which starts at pinning with the source fenced
+ }
+ name := map[int]string{1: "one-box region", 2: "two-box region"}[boxes] + "/" + who + "@" + phase
+ t.Run(name, func(t *testing.T) {
+ w := regionWorld(boxes)
+ from := w.serveAndPublish(t)
+ w.appWrite(from)
+ w.timerOn(from)
+ w.extraBox(0, spareID()) // a spare for the replacement table
+ if boxes == 2 {
+ w.drainTo(t, nil)
+ } else {
+ w.terminate(from)
+ if w.relocTarget(t) == nil {
+ t.Fatalf("%s", w.violations[0])
+ }
+ }
+ if !w.relocPhase(t, phase, w.actAll(), w.recoveryPass) {
+ t.Fatalf("%s", w.violations[0])
+ }
+ if who == "source" {
+ w.terminate(from)
+ } else if x := w.boxByID(w.claim.Op.Tgt); x != nil && w.claim.Op.Kind == "relocating" {
+ w.terminate(x)
+ }
+ w.servingOn(t, nil)
+ w.finish(t)
+ w.mustHold(t)
+ })
+ }
+ }
+ }
+}
+
+func TestL18NestedFailover(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ w.extraBox(0, spareID())
+ var to *box
+ if boxes == 2 {
+ w.drainTo(t, nil)
+ to = w.boxByID(w.claim.Op.Tgt)
+ } else {
+ w.terminate(from)
+ to = w.relocTarget(t)
+ }
+ 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) }) {
+ return
+ }
+ wr := w.appWrite(to)
+ w.replicate(to)
+ w.completeDue(true) // the target's write is in P's remote before it dies
+ w.terminate(to)
+ next := w.failedOver(t, to)
+ w.finish(t)
+ if next == nil || next.DB == nil || !contains(next.DB.Log, wr) {
+ t.Fatalf("the nested failover lost the target's replicated write %d", wr)
+ }
+ if w.stats["nested failover after P was admitted"] != 1 {
+ t.Fatalf("no nested failover: %v", w.stats)
+ }
+ })
+}
+
+func TestL18StaleEdgesFile(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ if boxes == 1 {
+ // a one-box region's former executor is gone: a request through
+ // its stale edges file reaches nothing
+ w.terminate(from)
+ w.failedOver(t, from)
+ w.drive(30, never, func() { w.reconcile(from); w.wake(from) })
+ if hasMain(from) {
+ t.Fatalf("the terminated box ran the host")
+ }
+ w.finish(t)
+ return
+ }
+ to := w.peer()
+ w.drainTo(t, to)
+ w.servingOn(t, to)
+ if from.Row == nil || from.Row.Status == "ready" {
+ t.Fatalf("the former executor's row is not held: %+v", from.Row)
+ }
+ w.drive(30, never, func() { w.reconcile(from); w.wake(from) })
+ if hasMain(from) {
+ t.Fatalf("a request through the stale edges file started the host on the former executor")
+ }
+ w.finish(t)
+ })
+}
+
+func TestL18UnenrolledReference(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ if p := w.peer(); p != nil {
+ p.HasKey = false // the region's other box may work, never take the host (R9)
+ }
+ w.terminate(from)
+ w.now += replaceDelay
+ w.launchReplacements()
+ nb := w.boxes[len(w.boxes)-1]
+ if nb.Region != 0 || nb.Enrolled {
+ t.Fatalf("no unenrolled replacement in region 0: %+v", nb)
+ }
+ nb.EnrollAt = w.now + 10*enrollDelay // its confirmation is slow
+ w.drive(3*enrollDelay, never, w.actAll(), w.recoveryPass)
+ c := w.claim
+ if c.Op.Exec == nb.ID || c.Op.Tgt == nb.ID {
+ t.Fatalf("an unenrolled box was named: %s", w.state())
+ }
+ if boxes == 2 && (c.Op.Kind != "relocating" || c.Op.Tgt != -1 || c.Op.Exec != -1) {
+ // the peer fenced the executor; the failover waits at pinning
+ t.Fatalf("the relocation did not wait at pinning with no target: %s", w.state())
+ }
+ if boxes == 1 && c.Op.Kind != "serving" {
+ // no enrolled worker of the region exists to write the fence: the
+ // claim still names the dead box until the replacement enrolls
+ t.Fatalf("a one-box region was fenced with no enrolled worker in it: %s", w.state())
+ }
+ nb.EnrollAt = w.now - enrollDelay
+ if to := w.failedOver(t, from); to != nb {
+ t.Fatalf("served on %v, want the replacement %d once enrolled", to, nb.ID)
+ }
+ w.finish(t)
+ })
+}
+
+func TestL18AdverseVolume(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ from.DeleteOnTermination = false
+ if boxes == 2 {
+ // a former executor that still owes its park-local
+ w.drainTo(t, w.peer())
+ w.servingOn(t, w.peer())
+ if !w.owesLocal(w.claim, from) {
+ t.Fatalf("the former executor owes no park-local: %s", w.state())
+ }
+ }
+ w.terminate(from)
+ if boxes == 2 {
+ w.reach(t, 60, "the former executor fenced", func() bool { return contains(w.claim.Lost, from.ID) }, w.act(w.peer()))
+ if !w.owesLocal(w.claim, from) {
+ t.Fatalf("the fence discharged the park-local without qualifying evidence: %s", w.state())
+ }
+ } else {
+ w.failedOver(t, from) // execution recovery proceeds (H19)
+ }
+ if w.stats["fence: park-local chores left open (volume evidence)"] == 0 || w.stats["fence: park-local chores discharged"] != 0 {
+ t.Fatalf("the fence's discharge ignored the volume evidence: %v", w.stats)
+ }
+ w.finish(t) // the operator's destroyed-local declaration clears it
+ })
+}
+
+func TestL18CrashDuringInstallation(t *testing.T) {
+ for _, boxes := range []int{1, 2} {
+ for _, at := range []string{"installing", "installed", "probing"} {
+ t.Run(map[int]string{1: "one-box region", 2: "two-box region"}[boxes]+"/"+at, func(t *testing.T) {
+ w := regionWorld(boxes)
+ from := w.serveAndPublish(t)
+ w.timerOn(from)
+ if boxes == 2 {
+ w.drainTo(t, nil)
+ } else {
+ w.terminate(from)
+ }
+ w.relocPhase(t, at, w.actAll(), w.recoveryPass)
+ to := w.boxByID(w.claim.Op.Tgt)
+ w.drive(2, never, w.act(to)) // a step into the phase: a PUT issued, a files directory extracted
+ w.crash(to)
+ w.servingOn(t, to)
+ w.finish(t)
+ w.mustHold(t)
+ })
+ }
+ }
+}
+
+func TestL18LateFileUploads(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ late := w.timerOn(from)
+ loc := w.claim.Holder.Location
+ w.holdKey(loc + "files/")
+ w.publishFiles(from) // the manifest carrying the timer, held
+ w.hold = nil
+ w.terminate(from)
+ to := w.failedOver(t, from)
+ if to == nil {
+ return
+ }
+ if contains(to.Timers, late) {
+ t.Fatalf("the install applied a manifest that had not landed at the pin")
+ }
+ declared := false
+ for _, a := range w.acks {
+ if a.W == late && a.Exempt {
+ declared = true
+ }
+ }
+ if !declared {
+ t.Fatalf("the timer the late manifest carried was not declared with the fence")
+ }
+ if n := w.completeHeld(loc + "files/"); n != 1 {
+ t.Fatalf("held manifest PUTs completed: %d, want 1", n)
+ }
+ w.finish(t)
+ })
+}
+
+func TestL18LatePutAfterTermination(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ late := w.appWrite(from)
+ loc := w.claim.Holder.Location
+ w.holdKey(loc)
+ w.replicate(from) // the upload of the late write is held
+ w.hold = nil
+ w.terminate(from)
+ to := w.failedOver(t, from)
+ if to == nil {
+ return
+ }
+ if contains(to.DB.Log, late) {
+ t.Fatalf("the install restored a write whose upload had not landed at the pin")
+ }
+ if n := w.completeHeld(loc); n == 0 {
+ t.Fatalf("no held upload to complete")
+ }
+ w.finish(t) // the late object lands in generation_from, read by nothing, parked
+ })
+}
+
+// ---------- claim §11's region HA witnesses beyond L18 (two-box) ----------
+
+// TestRelocationStartRacesPurge: a worker read the serving claim, then
+// paused; a purge started meanwhile. A fresh start reads the purging
+// claim and refuses; no relocation runs over a purging host.
+func TestRelocationStartRacesPurge(t *testing.T) {
+ w := twoBoxWitness()
+ w.serve(w.boxes[0], 1, app0)
+ w.userPurge()
+ w.reach(t, 100, "the purge started", func() bool { return w.claim.Op.Kind == "purging" || w.admit[app0].Closed }, w.runConsole)
+ w.userRelocate()
+ if w.claim.Op.Kind == "relocating" {
+ t.Fatalf("a relocation started over a purging host: %s", w.state())
+ }
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// TestEmptyDatabaseDrain: a created host whose database never committed
+// (nothing replicated) but has files and a timer is drained: the release
+// flushes at 0, the pin is an evidenced empty one, and the files and the
+// timer arrive through the manifests.
+func TestEmptyDatabaseDrain(t *testing.T) {
+ w := twoBoxWitness()
+ w.console = append(w.console, &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: 1, Region: 0})
+ w.reach(t, 100, "created", func() bool { c := w.claim; return c != nil && c.Holder != nil && c.Op.Kind == "serving" }, w.runConsole, w.recoveryPass)
+ from := w.execBox(w.claim)
+ w.reach(t, 100, "the host runs", func() bool { return hasMain(from) }, w.actAll(), func() { w.wake(from) })
+ id := w.timerOn(from)
+ key := from.Files.Key
+ w.published(t, from)
+ w.drainTo(t, nil)
+ to := w.boxByID(w.claim.Op.Tgt)
+ w.servingOn(t, to)
+ w.finish(t)
+ w.mustHold(t)
+ if !contains(to.Timers, id) && !w.delivered[id] {
+ t.Fatalf("the timer did not survive the empty-database drain")
+ }
+ if to.Files == nil || to.Files.Key != key {
+ t.Fatalf("the files did not survive the empty-database drain: %+v", to.Files)
+ }
+}
+
+// TestReleasedThenFailedProbe: the drain's first probe admission is
+// covered released; the target cannot run the app, fails its probe,
+// releases, and the nested relocation's admission is covered by the
+// failed target's own release: nothing is declared.
+func TestReleasedThenFailedProbe(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ to.FailApp = true
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ third := w.extraBox(0, 30)
+ w.servingOn(t, third)
+ w.finish(t)
+ w.mustHold(t) // nothing acknowledged is lost: each later attempt declares its predecessor's session, which acknowledged nothing
+ if w.stats["admissions covered: released"] < 1 {
+ t.Fatalf("the drain's first probe admission was not covered released: %v", w.stats)
+ }
+}
+
+// TestStuckAfterTwoReplacements (reloc-replacement-cap, an operator
+// bound): three targets die at installing; the third death is the
+// exhausted fence, the operation is stuck, and the operator's resolve
+// replays it onto a spare.
+func TestStuckAfterTwoReplacements(t *testing.T) {
+ w := twoBoxWitness()
+ from := w.boxes[0]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.extraBox(0, 30)
+ w.extraBox(0, 31)
+ w.drainTo(t, nil)
+ for i := 0; i < 3; i++ {
+ if !w.reach(t, 1500, "a target installing", func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && c.Op.Phase == "installing" && c.Op.Stuck == ""
+ }, w.act(w.liveBoxes()...), w.recoveryPass) {
+ t.Fatalf("target %d never installed: %s", i, w.state())
+ }
+ x := w.boxByID(w.claim.Op.Exec)
+ w.terminate(x)
+ w.reach(t, 100, "fenced", func() bool { return contains(w.claim.Lost, x.ID) }, w.act(w.liveBoxes()...))
+ }
+ if w.claim.Op.Stuck == "" {
+ t.Fatalf("a third loss did not make the operation stuck: %s", w.state())
+ }
+ w.extraBox(0, spareID())
+ w.servingOn(t, nil) // the operator resolves it
+ w.finish(t)
+ w.mustHold(t)
+ if w.stats["operator resolved a stuck relocation"] != 1 {
+ t.Fatalf("the operator did not resolve it: %v", w.stats)
+ }
+}
+
+// TestReleaseStopsAnEscapedDescendant (L10): a process that escaped the
+// cgroup is stopped with the whole tree before release step 1 passes
+// (the model's evidence-whole-tree: the cleanup-only reopen kills the
+// cgroup and every identified stray); the source's release is journaled
+// only once nothing of the host runs on it.
+func TestReleaseStopsAnEscapedDescendant(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ from.Procs = append(from.Procs, &proc{Main: false, Strict: false, Inc: 1, Box: from.ID, Session: w.claim.Holder.Session})
+ w.drainTo(t, to)
+ if !w.reach(t, 300, "the source's release journaled", func() bool { return w.claim.Op.Release != nil }, w.act(from, to), w.recoveryPass) {
+ w.mustHold(t)
+ }
+ if len(from.Procs) != 0 {
+ t.Fatalf("the release was journaled with %d processes of the host alive on the source", len(from.Procs))
+ }
+ w.servingOn(t, to)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// ---------- the bounds: direct evidence for the rules no invariant catches alone ----------
+
+// TestDrainRefusedForANeverWokenHost (reloc-drain-needs-writer, ruling
+// 11): a host with no writer session is never drained; its failover
+// relocates it.
+func TestDrainRefusedForANeverWokenHost(t *testing.T) {
+ w := twoBoxWitness()
+ w.serve(w.boxes[0], 1, app0)
+ w.cas(w.claim, func(n *claim) bool { n.Holder.Session = 0; return true })
+ w.userRelocate()
+ if w.claim.Op.Kind != "serving" || w.stats["drain refused: the host never had a writer"] != 1 {
+ t.Fatalf("a never-woken host was drained: %s", w.state())
+ }
+}
+
+// TestReplacedTargetAcknowledgesBeforePinning (reloc-ack-before-pinning):
+// the target is replaced in releasing; the operation leaves releasing
+// only once the replacement's execution lease is current.
+func TestReplacedTargetAcknowledgesBeforePinning(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.published(t, from)
+ w.drainTo(t, to)
+ spare := w.extraBox(0, 30)
+ w.relocPhase(t, "releasing", w.act(from, to), w.recoveryPass)
+ w.terminate(to)
+ w.reach(t, 100, "the target replaced in releasing", func() bool { return w.claim.Op.Tgt == spare.ID }, w.act(from))
+ // the spare never acts: no lease, so the operation must stay in releasing
+ w.drive(40, never, w.act(from))
+ if c := w.claim; c.Op.Phase != "releasing" || w.stats["pin after release: waits for the acknowledgements"] == 0 {
+ t.Fatalf("left releasing without the replacement's acknowledgement: %s", w.state())
+ }
+ w.servingOn(t, spare)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// TestProbeAttemptsCountedBeforeTheyAreMade (reloc-probe-attempts,
+// ruling 19): an agent that crashes in every attempt never buys a
+// fourth; the target goes to probe-failed after three.
+func TestProbeAttemptsCountedBeforeTheyAreMade(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ w.relocPhase(t, "probing", w.act(from, to), w.recoveryPass)
+ to.envUnreadyFor = 1000 // the launch environment never gets ready: each attempt is still starting when the agent dies
+ before := w.stats["admissions: cold"]
+ for i := 0; i < 8 && w.claim.Op.Phase == "probing"; i++ {
+ w.drive(3, func() bool { return to.open != nil }, w.act(to))
+ w.crash(to) // each attempt dies before its probe
+ }
+ if n := w.stats["admissions: cold"] - before; n > probeTries || w.claim.Op.Phase == "probing" {
+ t.Fatalf("%d attempts admitted, phase %s; want at most %d, then probe-failed", n, w.claim.Op.Phase, probeTries)
+ }
+}
+
+// TestCreateNamesItsExecutorOnce (create-choice-once, claim P5-D2,
+// P5-D6): a create names its executor in the intent; when that box is
+// gone before the create completes, recovery cancels the create and
+// never substitutes the region's other box.
+func TestCreateNamesItsExecutorOnce(t *testing.T) {
+ w := twoBoxWitness()
+ p := &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: 1, Region: 0}
+ w.admit[app0].Reg[p.OpID] = regEntry{Until: w.now + 1000}
+ w.console = append(w.console, p)
+ w.reach(t, 50, "the create claimed the host", func() bool { c := w.claim; return c != nil && c.Op.Kind == "creating" }, w.runConsole)
+ chosen := w.boxByID(w.claim.Op.NewBox)
+ w.terminate(chosen)
+ w.drive(200, func() bool { c := w.claim; return c == nil || c.Holder == nil || c.Op.Kind == "serving" }, w.runConsole, w.recoveryPass)
+ if c := w.claim; c != nil && c.Holder != nil && c.Op.Kind == "serving" {
+ t.Fatalf("the create substituted box %d for its chosen box %d", c.Op.Exec, chosen.ID)
+ }
+ if w.stats["create cancelled: its box is gone"] != 1 {
+ t.Fatalf("the create was not cancelled: %v", w.stats)
+ }
+}
+
+// TestStoppedBoxNeverFenced (fence-terminated-only): a stopped executor
+// is never fenced; when it starts again with its disk, it serves.
+func TestStoppedBoxNeverFenced(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ from.Destroyed, from.Procs = true, nil // stopped, not terminated
+ w.drive(100, never, w.act(to), w.recoveryPass)
+ if contains(w.claim.Lost, from.ID) || w.claim.Op.Kind != "serving" {
+ t.Fatalf("a stopped box was fenced: %s", w.state())
+ }
+ from.Destroyed = false
+ w.running(t, from)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// TestRelocationStartGuardRefuses (reloc-start-guard): a whole-location
+// chore in the region refuses the start; a park-generation does not.
+func TestRelocationStartGuardRefuses(t *testing.T) {
+ w := twoBoxWitness()
+ w.serve(w.boxes[0], 1, app0)
+ 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"})
+ if !w.relocationStartAllowed(w.claim, w.boxes[0], w.boxes[3]) {
+ t.Fatalf("a park-generation refused the start (L11)")
+ }
+ w.claim.Obl = append(w.claim.Obl, obligation{ID: "pr", Op: "old", Kind: "park-replica", Region: 0, Box: -1, Prefix: w.claim.Holder.Location})
+ if w.relocationStartAllowed(w.claim, w.boxes[0], w.boxes[3]) {
+ t.Fatalf("a park-replica in the region did not refuse the start (CC11)")
+ }
+}
+
+// TestOnlyTheExecutorInstalls (reloc-exec-only-installs, HA2): through a
+// drain, no box but op.exec writes P or makes a local install.
+func TestOnlyTheExecutorInstalls(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ w.servingOn(t, to)
+ if from.receipt != nil || from.Files != nil && from.Files.Gen != "" && from.Files.Gen == w.claim.Holder.Gen {
+ t.Fatalf("the source made a local install of P")
+ }
+}
+
+// ---------- findings: hazards the model shows in 1b-next rev 16 ----------
+
+// TestFailoverBeforeTheFirstManifest (finding F2): a host none of whose
+// files manifests has landed is terminated; 1b-next pins its files as
+// empty (StorePinner: "Empty for a host that never published files"), so
+// the install mints a fresh instance.key over data sealed with the old
+// one. The model reproduces it (INV-4); the sweep never terminates such
+// an executor (model ruling 17).
+func TestFailoverBeforeTheFirstManifest(t *testing.T) {
+ w := twoBoxWitness()
+ from := w.boxes[0]
+ w.serve(from, 1, app0)
+ loc := w.claim.Holder.Location
+ w.holdKey(loc + "files/") // the first manifest's PUT has not landed
+ w.running(t, from)
+ w.hold = nil
+ w.terminate(from)
+ w.drive(400, func() bool {
+ return len(w.violations) > 0 || w.claim.Op.Kind == "serving" && w.claim.Op.Exec != from.ID
+ }, w.actAll(), w.recoveryPass, w.operator, w.wakeExec)
+ if len(w.violations) == 0 || !contains([]int{4}, invNumber(w.violations[0])) {
+ t.Fatalf("finding F2 no longer reproduces (want INV-4): %v", w.violations)
+ }
+}
+
+// TestProbeFailedInATwoBoxRegion (finding F1): a drain's target cannot
+// run the app; its probe fails and it releases. 1b-next's worker looks
+// for a spare with no bound (Worker.Step: "no eligible box yet: a later
+// pass may find one"), and replaceTargetSlot refuses the source, so in a
+// two-box region the host serves nowhere until an operator acts. The
+// model marks it stuck after the release deadline (model ruling 14) and
+// the operator's resolve returns it to the source (model ruling 13).
+func TestProbeFailedInATwoBoxRegion(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ to.FailApp = true
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ 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)
+ w.servingOn(t, from)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// TestAbandonedPreparedGenerationParked (finding F3): a target replaced
+// at installing leaves its partial build of P behind (the replacement
+// builds a fresh P'); 1b-next's CompleteRelocation queues park-local
+// chores only. The model parks every unopened P in the serving CAS
+// (model ruling 16); without it INV-10 finds P's objects at the release.
+func TestAbandonedPreparedGenerationParked(t *testing.T) {
+ w := twoBoxWitness()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.published(t, from)
+ w.drainTo(t, to)
+ third := w.extraBox(0, 30)
+ 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)
+ p := w.claim.Op.Prepared
+ w.terminate(to)
+ w.reach(t, 100, "replaced", func() bool { return w.claim.Op.Exec == third.ID }, w.act(third))
+ if w.claim.Op.Prepared == p {
+ t.Fatalf("the replacement at installing kept P")
+ }
+ w.servingOn(t, third)
+ if w.stats["relocation: abandoned prepared generation parked"] == 0 {
+ t.Fatalf("the abandoned P %q was not parked at serving", p)
+ }
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// invNumber is the n of the first "INV-n" in a violation, 0 if none.
+func invNumber(v string) int {
+ for i := 0; i+4 < len(v); i++ {
+ if v[i:i+4] == "INV-" {
+ n := 0
+ for j := i + 4; j < len(v) && v[j] >= '0' && v[j] <= '9'; j++ {
+ n = n*10 + int(v[j]-'0')
+ }
+ return n
+ }
+ }
+ return 0
+}
+
+// mustHold fails the test, with the trace, if any invariant was violated.
+func (w *world) mustHold(t *testing.T) {
+ t.Helper()
+ if len(w.violations) > 0 {
+ dump(t, w)
+ t.Fatalf("%s", w.violations[0])
+ }
+}
diff --git a/internal/sim/hostlife/regionha_test.go b/internal/sim/hostlife/regionha_test.go
index 2884f0524..47effbedf 100644
--- a/internal/sim/hostlife/regionha_test.go
+++ b/internal/sim/hostlife/regionha_test.go
@@ -1100,7 +1100,7 @@ func (w *world) terminate(b *box) {
// sweeps only: a witness world's quiet source draws nothing).
func (w *world) launchReplacements() {
for r := 0; r < nRegions; r++ {
- if len(w.regionBoxes(r)) >= w.perRegion {
+ if len(w.regionBoxes(r)) >= w.regionMin(r) {
continue
}
last := 0
@@ -1691,3 +1691,12 @@ func (w *world) pinnedManifest(c *claim) *blob {
}
return nil
}
+
+// regionMin is the region's minimum box count (H5): the shape's, or one
+// for a witness's one-box region.
+func (w *world) regionMin(r int) int {
+ if w.oneBoxRegion[r] {
+ return 1
+ }
+ return w.perRegion
+}
diff --git a/internal/sim/hostlife/regionha_witness_test.go b/internal/sim/hostlife/regionha_witness_test.go
index 790220b5d..2b9079246 100644
--- a/internal/sim/hostlife/regionha_witness_test.go
+++ b/internal/sim/hostlife/regionha_witness_test.go
@@ -62,8 +62,8 @@ func TestUnenrolledReferenceNeverChosen(t *testing.T) {
w := twoBoxWitness()
from, peer := w.boxes[0], w.boxes[3]
w.serve(from, 1, app0)
- w.publishFiles(from) // its files are in S3 before it dies (Task 4: a dead box's disk is never read)
- w.completeDue(true)
+ w.reach(t, 50, "the host runs on box 0", func() bool { return hasMain(from) }, w.actAll(), func() { w.wake(from) })
+ w.published(t, from)
peer.HasKey = false
w.terminate(from)
w.now += replaceDelay
@@ -72,16 +72,17 @@ func TestUnenrolledReferenceNeverChosen(t *testing.T) {
if nb.Region != 0 || nb.Enrolled {
t.Fatalf("no unenrolled replacement in region 0: %+v", nb)
}
+ nb.EnrollAt = w.now + 10*enrollDelay // its confirmation is slow
w.reach(t, 100, "the executor fenced by the peer", func() bool {
return contains(w.claim.Lost, from.ID) && w.claim.Op.Kind == "relocating"
}, w.act(peer), w.recoveryPass)
- // the witness schedule never runs enrollStep: nb stays unenrolled, and
- // the relocation must wait at pinning with no target
+ // nb stays unenrolled, and the relocation must wait at pinning with no
+ // target
w.drive(enrollDelay, func() bool { return false }, w.actAll(), w.recoveryPass)
if c := w.claim; c.Op.Kind != "relocating" || c.Op.Tgt != -1 || c.Op.Exec != -1 {
t.Fatalf("an unenrolled box was named, or the relocation moved on: %s", w.state())
}
- w.now += enrollDelay
+ nb.EnrollAt = w.now - enrollDelay
w.enrollStep()
if !nb.Enrolled {
t.Fatalf("the replacement did not enroll")
@@ -166,3 +167,587 @@ func TestLateFileUploads(t *testing.T) {
t.Fatalf("the late manifest was never parked: %v", r)
}
}
+
+// ---------- Task 5: the scenarios ----------
+
+// twoBox turns a witness world into the two-box shape.
+func (w *world) twoBox() {
+ w.perRegion = 2
+ for r := 0; r < nRegions; r++ {
+ w.addBox(r)
+ }
+}
+
+// regionWorld is a witness world with region HA on (the two-box shape's
+// rules, its publisher and its group) whose region 0 holds boxes boxes:
+// 2 is a two-box region (boxes 0 and 3); 1 is a one-box region (box 0
+// alone, kept at one box by its group, H5's minimum being per region).
+// Regions 1 and 2 have two boxes either way.
+func regionWorld(boxes int) *world {
+ w := newWitness()
+ w.perRegion = 2
+ for r := 0; r < nRegions; r++ {
+ if r == 0 && boxes == 1 {
+ w.oneBoxRegion[0] = true
+ continue
+ }
+ w.addBox(r)
+ }
+ return w
+}
+
+// bothShapes runs an L18 case in a one-box and in a two-box region.
+func bothShapes(t *testing.T, f func(t *testing.T, w *world, boxes int)) {
+ for _, n := range []int{1, 2} {
+ n := n
+ t.Run(map[int]string{1: "one-box region", 2: "two-box region"}[n], func(t *testing.T) {
+ w := regionWorld(n)
+ f(t, w, n)
+ if len(w.violations) > 0 {
+ dump(t, w)
+ t.Fatalf("%s", w.violations[0])
+ }
+ })
+ }
+}
+
+// peer is region 0's second box, nil in a one-box region.
+func (w *world) peer() *box {
+ for _, b := range w.boxes {
+ if b.ID == nRegions && !b.Destroyed {
+ return b
+ }
+ }
+ return nil
+}
+
+// extraBox enrolls another box in region r with the given id (a spare
+// for the nested scenarios).
+func (w *world) extraBox(r, id int) *box {
+ b := &box{ID: id, Region: r, HasKey: true, Enrolled: true, DeleteOnTermination: true}
+ b.forget()
+ w.boxes = append(w.boxes, b)
+ return b
+}
+
+// running drives the serving host until it runs on b.
+func (w *world) running(t *testing.T, b *box) {
+ w.reach(t, 60, "the host runs", func() bool { return hasMain(b) }, w.actAll(), func() { w.wake(b) })
+}
+
+// drainTo starts a drain of the serving host and fails the test unless
+// it named to (any target when to is nil).
+func (w *world) drainTo(t *testing.T, to *box) {
+ w.userRelocate()
+ if c := w.claim; c.Op.Kind != "relocating" || to != nil && c.Op.Tgt != to.ID {
+ t.Fatalf("witness: the drain did not start (to %v): %s", to, w.state())
+ }
+}
+
+// relocPhase drives until the relocation is in phase p (or beyond it, by
+// the table's order), with the actors given.
+func (w *world) relocPhase(t *testing.T, p string, actors ...func()) bool {
+ order := []string{"starting", "releasing", "pinning", "installing", "installed", "probing", "probed", "opened"}
+ at := func(x string) int {
+ for i, o := range order {
+ if o == x {
+ return i
+ }
+ }
+ return -1
+ }
+ return w.reach(t, 1500, "relocation at "+p, func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && at(c.Op.Phase) >= at(p) || c.Op.Kind == "serving" && p == "serving"
+ }, actors...)
+}
+
+// servingOn drives until the host serves on b (on any box, when b is
+// nil), with every actor the at-rest model has.
+func (w *world) servingOn(t *testing.T, b *box) bool {
+ return w.reach(t, 3000, "serving on the new executor", func() bool {
+ c := w.claim
+ return c.Op.Kind == "serving" && (b == nil && c.Op.Exec >= 0 || b != nil && c.Op.Exec == b.ID)
+ }, w.actAll(), w.recoveryPass, w.operator, w.wakeExec)
+}
+
+// wakeExec wakes the executor, whichever box it is.
+func (w *world) wakeExec() {
+ if e := w.execBox(w.claim); e != nil {
+ w.wake(e)
+ }
+}
+
+// timerOn registers an acknowledged timer on b's running host.
+func (w *world) timerOn(b *box) int {
+ w.nextW++
+ b.Timers = append(b.Timers, w.nextW)
+ sess, gen := w.sessionOf(b)
+ w.acks = append(w.acks, ack{W: w.nextW, Inc: w.claim.Holder.Inc, Box: b.ID, Session: sess, Gen: gen, At: w.now, Kind: "timer"})
+ return w.nextW
+}
+
+// spareID is an id for an extra box of region 0 whose tieHash beats box
+// 0's, so choose() prefers it to the source.
+func spareID() int {
+ for id := 40; ; id++ {
+ if tieHash(id) > tieHash(0) {
+ return id
+ }
+ }
+}
+
+func never() bool { return false }
+
+// published drives until a manifest of b's host has landed (the files
+// publisher runs in every two-box tick): a failover of a host none of
+// whose manifests landed is finding F2's, not the scenario's.
+func (w *world) published(t *testing.T, b *box) bool {
+ return w.reach(t, 60, "the host's first manifest landed", func() bool {
+ _, _, _, _, m := w.newestManifest(b.Region, w.claim.Holder.Location, w.claim.Holder.Gen)
+ return m != nil
+ }, w.act(b), func() { w.completeDue(true) })
+}
+
+// ---------- the witnesses (one per safety guard) ----------
+
+func wRelocReleaseFirst(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ // only the target's worker runs while the source should be releasing:
+ // with the rule it waits for the release; without it, it pins and
+ // installs while the source still runs the host
+ w.drive(12, never, w.act(to))
+ w.servingOn(t, to)
+ w.finish(t)
+}
+
+func wRelocInstallReceipt(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ third := w.extraBox(0, 30)
+ if !w.reach(t, 1500, "the target's receipt", func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && c.Op.Phase == "installed" && c.Op.Receipt != nil && c.Op.Receipt.Box == to.ID
+ }, w.act(from, to), w.recoveryPass) {
+ return
+ }
+ w.terminate(to)
+ w.servingOn(t, third)
+ w.finish(t)
+}
+
+func wRelocNoReinstall(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ if !w.reach(t, 1500, "the probe runs on the target", func() bool {
+ return preparedOpened(w.claim) && hasMain(to)
+ }, w.act(from, to), w.recoveryPass) {
+ return
+ }
+ w.timerOn(to)
+ w.crash(to)
+ w.servingOn(t, to)
+ w.finish(t)
+}
+
+func wRelocReleasedCoverage(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.published(t, from)
+ w.appWrite(from)
+ loc := w.claim.Holder.Location
+ w.holdKey(loc) // every upload of the executor is held
+ w.replicate(from)
+ w.hold = nil
+ w.terminate(from)
+ w.servingOn(t, to)
+ w.completeHeld(loc) // the dead box's upload lands late, after the pin
+ w.finish(t)
+}
+
+func wRelocSourceLoss(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.appWrite(from)
+ loc := w.claim.Holder.Location
+ w.holdKey(loc) // the flush and the final manifest are held: no release
+ w.drainTo(t, to)
+ w.relocPhase(t, "releasing", w.act(from, to), w.recoveryPass)
+ w.drive(20, never, w.act(from, to))
+ if w.claim.Op.Release != nil {
+ t.Fatalf("witness: the release was journaled while its uploads were held")
+ }
+ w.hold = nil
+ w.terminate(from)
+ w.servingOn(t, to)
+ w.completeHeld(loc)
+ w.finish(t)
+}
+
+func wRelocRowCases(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ if !w.reach(t, 1500, "the source released", func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && c.Op.Release != nil && c.Op.Release.Box == from.ID && !hasMain(from)
+ }, w.act(from, to), w.recoveryPass) {
+ return
+ }
+ // a paused duplicate of the source's earlier open lands now: its row
+ // is ready again while its admission fence is closed. Reconcile
+ // decides the row; a request reaches the box through its stale edges
+ // file
+ from.Row.Status, from.Row.HoldOp = "ready", ""
+ w.drive(40, never, func() {
+ w.reconcile(from)
+ w.wake(from)
+ }, w.act(to), w.recoveryPass, func() { w.wake(to) })
+ w.servingOn(t, to)
+ w.finish(t)
+}
+
+func wFenceDischargeQualified(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ from.DeleteOnTermination = false
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ w.servingOn(t, to)
+ if !w.owesLocal(w.claim, from) {
+ t.Fatalf("witness: the former executor owes no park-local: %s", w.state())
+ }
+ w.terminate(from)
+ w.reach(t, 60, "the former executor fenced", func() bool { return contains(w.claim.Lost, from.ID) }, w.act(to))
+ w.finish(t)
+}
+
+func wFenceDeclaresTimers(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.publishFiles(from)
+ w.completeDue(true)
+ w.holdKey(w.claim.Holder.Location + "files/") // the manifest that would carry the timer never lands
+ w.timerOn(from)
+ w.publishFiles(from)
+ w.hold = nil
+ w.terminate(from)
+ w.servingOn(t, to)
+ w.completeHeld(w.claim.Holder.Location + "files/") // it lands after the pin: a version nothing reads
+ w.finish(t)
+}
+
+func wRelocEnrolledOnly(t *testing.T, w *world) {
+ w.twoBox()
+ from, peer := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.published(t, from)
+ peer.HasKey = false
+ w.terminate(from)
+ w.reach(t, 100, "the executor fenced", func() bool { return contains(w.claim.Lost, from.ID) }, w.act(peer), w.recoveryPass)
+ // the replacement launches unenrolled; under the mutation it is named
+ // before it enrolls (INV-27)
+ w.servingOn(t, nil)
+ w.finish(t)
+}
+
+func wFilesLiveSet(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.timerOn(from)
+ if !w.reach(t, 30, "the origin's manifest landed", func() bool {
+ _, _, _, _, m := w.newestManifest(0, w.claim.Holder.Location, w.claim.Holder.Gen)
+ return m != nil
+ }, w.actAll(), func() { w.completeDue(true) }) {
+ return
+ }
+ // the agent restarts: the next wake is a cold admission into a fresh
+ // generation, which publishes nothing (nothing changed), and the
+ // origin's park-generation runs once the new marker lands
+ w.crash(from)
+ if !w.reach(t, 600, "a fresh generation serving and the origin parked", func() bool {
+ c := w.claim
+ if c.Holder.Gen == "" || !hasMain(from) || from.open != nil {
+ return false
+ }
+ for _, o := range c.Obl {
+ if o.Kind == "park-generation" {
+ return false
+ }
+ }
+ return true
+ }, w.actAll(), w.recoveryPass, func() { w.wake(from) }) {
+ return
+ }
+ w.terminate(from)
+ w.servingOn(t, to)
+ w.finish(t)
+}
+
+// rev 2: 1b-next rev 16's guards
+
+func wRelocPinClaimGen(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ third := w.extraBox(0, 30)
+ // the first round, observed by a pinner after the source's release and
+ // before any pin; it stalls before its journal CAS
+ if !w.reach(t, 1500, "the source's release journaled", func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && c.Op.Release != nil && c.Op.Pin == nil
+ }, w.act(from, to)) {
+ return
+ }
+ stale := w.claim.clone()
+ if !w.reach(t, 1500, "the target's probe writes into P", func() bool { return preparedOpened(w.claim) && hasMain(to) }, w.act(from, to), w.recoveryPass, func() { w.wake(to) }) {
+ return
+ }
+ w.appWrite(to)
+ w.replicate(to)
+ w.completeDue(true) // the target's write is in P's remote before it dies
+ w.terminate(to)
+ if !w.reach(t, 100, "the nested pinning", func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && c.Op.Phase == "pinning" && c.Op.Pin == nil && contains(c.Lost, to.ID)
+ }, w.act(third)) {
+ return
+ }
+ w.now += failoverPinDelay
+ w.relocPinning(third, stale) // the stalled pinner resumes with the first round's selection
+ w.servingOn(t, third)
+ w.finish(t)
+}
+
+func wRelocReleaseDeadline(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.appWrite(from)
+ loc := w.claim.Holder.Location
+ w.holdKey(loc) // the source's flush never completes
+ w.drainTo(t, to)
+ w.reach(t, 3*releaseDeadline+200, "the host serving again", func() bool {
+ c := w.claim
+ return c.Op.Kind == "serving"
+ }, w.actAll(), w.recoveryPass, w.operator)
+ w.hold = nil
+ w.completeHeld(loc)
+ w.finish(t)
+}
+
+func wRelocEnsureLocal(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ if !w.reach(t, 1500, "the target's receipt journaled", func() bool {
+ c := w.claim
+ return c.Op.Kind == "relocating" && c.Op.Receipt != nil && c.Op.Receipt.Box == to.ID
+ }, w.act(from, to), w.recoveryPass) {
+ return
+ }
+ // the target freezes past its execution lease: its relocation
+ // controller is lost, and the scoped cleanup retires the unopened
+ // local install when it runs again
+ to.Frozen = true
+ w.drive(execTTL+2, never, w.act(from))
+ to.Frozen = false
+ w.servingOn(t, to)
+ w.finish(t)
+}
+
+func wRelocVerifyReceipt(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ third := w.extraBox(0, 30)
+ if !w.reach(t, 1500, "the probe runs on the target", func() bool { return preparedOpened(w.claim) && hasMain(to) }, w.act(from, to), w.recoveryPass, func() { w.wake(to) }) {
+ return
+ }
+ // the volume holding the host's directory is replaced and the agent
+ // restarts: the files, the database and the install journal are gone
+ w.crash(to)
+ to.Files, to.DB, to.receipt = nil, nil, nil
+ w.servingOn(t, nil)
+ _ = third
+ w.finish(t)
+}
+
+func wRelocPreparedOpenedJournal(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ if !w.reach(t, 1500, "the probe runs on the target", func() bool { return preparedOpened(w.claim) && hasMain(to) }, w.act(from, to), w.recoveryPass, func() { w.wake(to) }) {
+ return
+ }
+ w.timerOn(to)
+ w.crash(to) // the next attempt admits a fresh generation from P's
+ if !w.reach(t, 400, "the second attempt runs", func() bool { return hasMain(to) && w.claim.Holder.Gen != w.claim.Op.Prepared }, w.act(from, to), w.recoveryPass) {
+ return
+ }
+ // holder.admissions is trimmed as AdmitWriter trims it past eight:
+ // P's admission is no longer listed
+ w.cas(w.claim, func(n *claim) bool {
+ var keep []admissionRecord
+ for _, e := range n.Holder.Admissions {
+ if e.Gen != n.Op.Prepared {
+ keep = append(keep, e)
+ }
+ }
+ n.Holder.Admissions = keep
+ return true
+ })
+ w.crash(to)
+ w.servingOn(t, to)
+ w.finish(t)
+}
+
+func wFenceReassignsRegional(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ // a dead generation of the location, with an object in region 0, whose
+ // park-generation chore carries the legacy owner form box:0
+ loc := w.claim.Holder.Location
+ k := ltxKey(loc, "old", 0, 1, 1)
+ w.buckets[0].objs[k] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Level: 0, Min: 1, Max: 1, Inc: 1}}}
+ id := w.newID("ob")
+ w.cas(w.claim, func(n *claim) bool {
+ n.Obl = append(n.Obl, w.scoped(obligation{ID: id, Op: "old-op", Kind: "park-generation", Region: 0, Box: -1, Prefix: loc, Ret: "old", Quiet: w.now, Owner: boxOwner(from.ID)}, w.now, 1))
+ return true
+ })
+ w.terminate(from)
+ w.servingOn(t, to)
+ w.reach(t, 600, "the dead box's regional chore done", func() bool { return !hasObl(w.claim.Obl, id) }, w.actAll(), w.recoveryPass)
+ w.finish(t)
+}
+
+func wRelocReplaceNotOwing(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.drainTo(t, to)
+ // box 30 owes a park-local for the host: stale artifacts of an earlier
+ // stay under stamp 7
+ spare := w.extraBox(0, 30)
+ spare.DB = &localDB{Inc: 1, Loc: w.claim.Holder.Location, State: map[string]int{}, Stamp: 7, Epoch: spare.Epoch}
+ spare.install(1, 7)
+ w.cas(w.claim, func(n *claim) bool {
+ n.Obl = append(n.Obl, obligation{ID: w.newID("ob"), Op: "stay", Kind: "park-local", Box: spare.ID, Region: -1, Ret: "stay"})
+ return true
+ })
+ if !w.relocPhase(t, "installing", w.act(from, to), w.recoveryPass) {
+ return
+ }
+ w.terminate(to)
+ w.servingOn(t, nil)
+ w.finish(t)
+}
+
+func wRelocExhaustedFence(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ b30, b31 := w.extraBox(0, 30), w.extraBox(0, 31)
+ w.userRelocate()
+ if w.claim.Op.Kind != "relocating" {
+ t.Fatalf("witness: no drain")
+ }
+ // three targets die in starting, the live source op.exec throughout
+ for _, x := range []*box{w.boxByID(w.claim.Op.Tgt), b30, b31, to} {
+ if x == nil || x.Destroyed || x.ID != w.claim.Op.Tgt {
+ continue
+ }
+ w.terminate(x)
+ w.reach(t, 60, "the dead target fenced", func() bool { return contains(w.claim.Lost, x.ID) }, w.act(from))
+ }
+ if w.claim.Op.Stuck == "" {
+ t.Fatalf("witness: the exhausted target did not make the operation stuck: %s", w.state())
+ }
+ w.extraBox(0, spareID()) // a spare the operator's replay may choose
+ w.servingOn(t, nil)
+ w.finish(t)
+}
+
+func wFilesPreparedSeqZero(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ for i := 0; i < 3; i++ { // the source publishes three manifests: its sequence reaches 3
+ w.timerOn(from)
+ w.publishFiles(from)
+ w.completeDue(true)
+ }
+ w.drainTo(t, to)
+ third := w.extraBox(0, 30)
+ if !w.reach(t, 1500, "the probe runs on the target", func() bool { return preparedOpened(w.claim) && hasMain(to) }, w.act(from, to), w.recoveryPass, func() { w.wake(to) }) {
+ return
+ }
+ w.timerOn(to) // the probe session's timer, published at P's next sequence
+ w.publishFiles(to)
+ w.completeDue(true)
+ w.terminate(to)
+ w.servingOn(t, third)
+ w.finish(t)
+}
+
+func wRelocPinEvidencedEmpty(t *testing.T, w *world) {
+ w.twoBox()
+ from, to := w.boxes[0], w.boxes[3]
+ _ = to
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.published(t, from)
+ // the listing finds nothing although the writes are durable (ruling
+ // 15: a listing that finds nothing is no evidence); the model hides
+ // the objects for a while, then the listing shows them again
+ bk := w.buckets[0]
+ hidden := map[string][]*version{}
+ for _, k := range bk.keys(w.claim.Holder.Location) {
+ if isDataKey(k) {
+ hidden[k] = bk.objs[k]
+ delete(bk.objs, k)
+ }
+ }
+ w.terminate(from)
+ w.drive(60, never, w.act(to), w.recoveryPass)
+ for k, vs := range hidden {
+ bk.objs[k] = vs
+ }
+ w.servingOn(t, nil)
+ w.finish(t)
+}
diff --git a/internal/sim/hostlife/twobox_test.go b/internal/sim/hostlife/twobox_test.go
index d837ae00f..0d6c828e6 100644
--- a/internal/sim/hostlife/twobox_test.go
+++ b/internal/sim/hostlife/twobox_test.go
@@ -60,17 +60,21 @@ func tieHash(id int) uint32 {
// app): the claimed host on its executor, or on the box its create or
// move named.
func (w *world) hostsOn(b *box, app string) int {
+ n := 0
+ if w.h2.Kind != "" && w.h2.Box == b.ID && (app == "" || w.h2.App == app) {
+ n = 1 // the second host, placed by its create (Task 5)
+ }
c := w.claim
if c == nil || c.Holder == nil || (app != "" && c.Holder.App != app) {
- return 0
+ return n
}
switch {
case c.Op.Kind == "creating" && c.Op.NewBox == b.ID:
- return 1
+ return n + 1
case c.Op.Exec == b.ID:
- return 1
+ return n + 1
}
- return 0
+ return n
}
// owesLocal: an open park-local chore names b.
diff --git a/internal/sim/hostlife/witness_test.go b/internal/sim/hostlife/witness_test.go
index 88394c375..34544e7ba 100644
--- a/internal/sim/hostlife/witness_test.go
+++ b/internal/sim/hostlife/witness_test.go
@@ -199,6 +199,47 @@ var witnesses = []witness{
"an upload times out and its retry succeeds while the original stays unresolved; the writer's clean close must write no certificate, because a request of its lifetime is unaccounted for; looking at the level-0 queue alone it certifies (claim rev 6.8 §6.4a, R8-5)", wRetryUnresolved},
{"build-completion", "build-completion", "INV-2",
"a push's build with its snapshot, then its anchor, held, the agent crashing in between each; since rev 7.11 the marker is the admission's, so a build completed from its intent gives the probe nothing to restore from its pinned snapshot, and the next session restores an empty generation (INV-2); rev 7.10 caught it at the marker (INV-11)", wBuildCrashes},
+ // region HA, two boxes per region (L18; plan 2026-10-07-region-ha-hostlife-two-box rev 2)
+ {"reloc-release-first", "reloc-release-first", "INV-1",
+ "a drain: the target's worker pins while the source still runs the host, and the target's probe starts beside it", wRelocReleaseFirst},
+ {"reloc-install-receipt", "reloc-install-receipt,reloc-ensure-local", "INV-4",
+ "the target is terminated at installed, after its receipt; its replacement inherits the receipt and skips its local install, and with EnsureLocal gone too (it restores the install otherwise: 1b-next rev 14's 'each covers the other') it starts over data sealed with the old key", wRelocInstallReceipt},
+ {"reloc-no-reinstall", "reloc-no-reinstall", "acknowledged timer",
+ "the first probe admitted P and registered a timer; the agent restarts and re-applies the pinned wake state over it", wRelocNoReinstall},
+ {"reloc-released-coverage", "reloc-released-coverage", "INV-2",
+ "a failover: the executor is terminated with an acknowledged write whose upload is still in flight; the first admission of P takes released coverage and the write is neither restored nor declared", wRelocReleasedCoverage},
+ {"reloc-source-loss", "reloc-source-loss", "INV-2",
+ "a drain whose source is terminated in releasing before its release is journaled, with an unuploaded write; taken as released at what the remote holds, the write goes undeclared", wRelocSourceLoss},
+ {"reloc-row-cases", "reloc-row-cases,exec-admission,exec-writer", "INV-6",
+ "a stale edges file: after the source's release a paused duplicate open makes its row ready again; reconcile holds it, and with the row case, the exec check and AdmitWriter's op.exec check gone a request admits the released source as a writer, and the stamp its admission issued is activated under the target (INV-6)", wRelocRowCases},
+ {"fence-discharge-qualified", "fence-discharge-qualified", "INV-26",
+ "a terminated box with a volume that was not DeleteOnTermination has its park-local chores discharged automatically", wFenceDischargeQualified},
+ {"fence-declares-timers", "fence-declares-timers", "acknowledged timer",
+ "the executor registers a timer whose manifest never lands and is terminated; the fence drops the timer silently", wFenceDeclaresTimers},
+ {"reloc-enrolled-only", "reloc-enrolled-only", "INV-27",
+ "the only other box of the region holds no key and a replacement has launched unenrolled; the failover names the unenrolled reference", wRelocEnrolledOnly},
+ {"files-live-set", "files-live-set", "INV-4",
+ "the chain's live manifest is the origin generation's, which a cold admission superseded without publishing (nothing changed: 1b-next rev 12's unchanged file); the origin's park-generation archives it, and the failover's install starts over data sealed with a key no manifest carries", wFilesLiveSet},
+ {"reloc-pin-claim-gen", "reloc-pin-claim-gen", "INV-3",
+ "a pinner observed the first round after the source's release and stalled; after the target wrote into P and died, its stale selection of generation_from is journaled for the nested pinning, and the build waits forever for objects generation_from's own park retired", wRelocPinClaimGen},
+ {"reloc-release-deadline", "reloc-release-deadline", "INV-3",
+ "a drain whose source can never flush: with the deadline the operation is stuck and the operator's resolve returns the host to the source; without it the drain waits forever", wRelocReleaseDeadline},
+ {"reloc-ensure-local", "reloc-ensure-local", "INV-4",
+ "the target freezes past its lease after its receipt; the scoped cleanup retires the unopened local install, and without EnsureLocal P's first admission starts over data sealed with a key no files directory carries", wRelocEnsureLocal},
+ {"reloc-verify-receipt", "reloc-verify-receipt", "INV-4",
+ "after P opened the target's volume is replaced and its agent restarts; without VerifyReceipt the next attempt is admitted with no install and starts over data sealed with the old key", wRelocVerifyReceipt},
+ {"reloc-prepared-opened-journal", "reloc-prepared-opened-journal", "acknowledged timer",
+ "P opened, a timer registered, a second attempt admitted, and P's admission trimmed from holder.admissions; read from the list alone, P looks unopened after the next restart and the local install is remade over the timer", wRelocPreparedOpenedJournal},
+ {"fence-reassigns-regional", "fence-reassigns-regional", "INV-3",
+ "the terminated executor owns a park-generation chore in the legacy box:<b> form; without the reassignment nothing ever runs it", wFenceReassignsRegional},
+ {"reloc-replace-not-owing", "reloc-replace-not-owing", "INV-3",
+ "the only spare owes a park-local for the host when the target dies at installing; named anyway, its pending retirement retires the new installation's stamp under it, and the operation never converges", wRelocReplaceNotOwing},
+ {"reloc-exhausted-fence", "reloc-exhausted-fence", "INV-1",
+ "three targets die while the live source is still op.exec in starting; with rev 8's exhausted fence op.exec goes to none, the operator's replay installs a spare, and it runs beside the source", wRelocExhaustedFence},
+ {"reloc-pin-evidenced-empty", "reloc-pin-evidenced-empty", "INV-2",
+ "a failover whose listing finds nothing although the writes are durable: with the rule the operation is stuck until an operator resolves it on a listing that shows them; without it the pin is empty and the install loses the durable writes", wRelocPinEvidencedEmpty},
+ {"files-prepared-seq-zero", "files-prepared-seq-zero", "INV-28",
+ "the source's manifests reach sequence 3; P's prepared manifest keeps 3, the probe session publishes its timer at 1, and the nested failover pins and declares from the stale prepared manifest", wFilesPreparedSeqZero},
}
// TestWitnesses is the deterministic half of the mutation evidence.
@@ -457,6 +498,9 @@ func (w *world) tick(actors ...func()) {
}
}
w.runStalled()
+ if w.perRegion > 1 {
+ w.twoBoxStep() // replacements launch and enroll, the publisher runs; the quiet source terminates and drains nothing
+ }
for _, a := range actors {
a()
}
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go
index 7426cd5fc..19ce89d71 100644
--- a/internal/sim/hostlife/world_test.go
+++ b/internal/sim/hostlife/world_test.go
@@ -991,6 +991,7 @@ type cancelledCreate struct {
type liteHost struct {
Inc int
App string
+ Box int // the box its create named (region HA §5.2); control-plane only
Kind string // "", "creating", "serving", "purging", "fenced"
OpID string
Rec bool // its record is published
@@ -1052,9 +1053,12 @@ type intner interface{ Intn(n int) int }
type world struct {
perRegion int // boxes per region at the start (region HA L18): 1, today's model, or 2
issued int // files manifests issued, in order: INV-28's ground truth (blob.Base)
- t testingT
- seed int64
- rng intner
+ // oneBoxRegion: a witness's region kept at one box by its group in the
+ // two-box shape (H5's minimum is per region)
+ oneBoxRegion [nRegions]bool
+ t testingT
+ seed int64
+ rng intner
// hold, when set, is a witness's hand on S3: a request it answers true
// for is accepted and completes only when the witness says so
// (witness_test.go). The random sweep never sets it.