carlos / platform Public

sim: Astra's round 1 on the two-box model: stuck refuses admission, region HA checks a sole box, durable acknowledgements gate every transition, deadlines from the fence record, the exact phase matrix, a discriminating test per guard, files objects with origins and region binding, moves' parks keep the files live set, committed-landing and record-race fences, the controller record that blocks updates, the forced open boundaries, and the tie-break over the real host

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 8b673c998d9e23a67d809fe425112346ecf7d176 parent b652b8f
12 files changed, +1175 −67
  • internal/sim/hostlife/console_test.go +13 −1
  • internal/sim/hostlife/moves_test.go +20 −5
  • internal/sim/hostlife/protocol_test.go +10 −3
  • internal/sim/hostlife/regionha_cases_test.go +0 −40
  • internal/sim/hostlife/regionha_round1_test.go +572 −0
  • internal/sim/hostlife/regionha_test.go +347 −9
  • internal/sim/hostlife/regionha_witness_test.go +145 −0
  • internal/sim/hostlife/rules_test.go +11 −0
  • internal/sim/hostlife/sim_test.go +2 −1
  • internal/sim/hostlife/twobox_test.go +26 −6
  • internal/sim/hostlife/witness_test.go +11 −0
  • internal/sim/hostlife/world_test.go +18 −2
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go
index 88980b190..1e7f99d23 100644
--- a/internal/sim/hostlife/console_test.go
+++ b/internal/sim/hostlife/console_test.go
@@ -387,7 +387,7 @@ func (w *world) userMove() {
// 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 {
+ if b := w.chooseBoxFor("h2", 0, app); b != nil {
return b.ID
}
return 0
@@ -1274,6 +1274,9 @@ func (w *world) operator() {
default:
w.abandonPrepared(n)
n.Op.Stuck, n.Op.Replacements, n.Op.Excluded, n.Op.Resolved = "", 0, nil, true
+ if released && op.Exec >= 0 {
+ n.Op.Excluded = []int{op.Exec} // the target that just failed its probe is not the operator's fresh choice (rev 4)
+ }
n.Op.Phase, n.Op.Exec, n.Op.Tgt = "pinning", -1, -1
n.Op.Pin, n.Op.Prepared, n.Op.Receipt = nil, "", nil
n.Op.BaseKey, n.Op.BaseVer, n.Op.BaseTX = "", 0, 0
@@ -1326,6 +1329,15 @@ func (w *world) operator() {
return
}
}
+ w.declareTargetLost(c)
+}
+
+// declareTargetLost is the declared loss of a landing's or a rollback's
+// target that is gone (claim D2, moves §7.10): the operator's path, and,
+// for a terminated target, the regional worker's automatic fence (claim
+// D2 "Automatic termination", rev 4). It proceeds record first, so a loss
+// racing the record's commit CAS follows whichever the record holds.
+func (w *world) declareTargetLost(c *claim) {
if c.Holder == nil {
return
}
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go
index a12678ab1..6150cf544 100644
--- a/internal/sim/hostlife/moves_test.go
+++ b/internal/sim/hostlife/moves_test.go
@@ -152,6 +152,7 @@ func (w *world) resumeStale(b *box, old *claim) {
// acting on the claim c it read.
func (w *world) role(b *box, c *claim) {
w.initRecord(b, c)
+ w.finishParticipation(b, c) // a relocation controller whose participation ended (rev 4)
// exec lease (claim §7.1)
if w.isParticipant(c, b) && !contains(c.Lost, b.ID) {
if b.ExecUntil > 0 && w.localNow(b) > b.ExecUntil {
@@ -161,6 +162,14 @@ func (w *world) role(b *box, c *claim) {
b.ExecUntil = w.localNow(b) + execTTL
w.execLease[b.ID] = execLease{Op: c.Op.ID, Expires: w.now + execTTL}
}
+ if c.Op.Kind == "relocating" {
+ w.recordAck(b, c) // exec_ack.<box>, durable (rev 4)
+ if cur := w.claim; cur != nil && cur.Ver != c.Ver {
+ nc := cur.clone()
+ nc.readStamp = c.readStamp
+ c = nc
+ }
+ }
} else if c == nil || !w.isParticipant(c, b) {
b.ExecUntil = 0
}
@@ -1022,7 +1031,7 @@ func (w *world) landTarget(b *box, c *claim) {
w.violate("INV-9: the cut of %s waits on quiet_after[%d] until %d", op.ID, b.Region, c.Quiet[b.Region])
}
// an earlier stay of this incarnation, in its location here
- if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/install/", op.ID, host), c.Quiet[b.Region], w.opScope(c, loc), confine(loc)) {
+ if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/install/", op.ID, host), c.Quiet[b.Region], w.opScope(c, loc), w.keepFilesLive(confine(loc))) {
w.cas(c, func(n *claim) bool { n.Op.LandPhase = "install-copy"; return sameOp(n.Op.ID, op.ID) })
}
case "install-copy":
@@ -1217,6 +1226,9 @@ func (w *world) landTarget(b *box, c *claim) {
// written last (R2-6). seal names the files archive version.
func (w *world) installLocal(b *box, c *claim, gen string, seal ref, _ string, next func(n *claim)) {
if b.Files != nil && b.Files.Gen == gen && b.Files.Done && b.Files.Inc == c.Holder.Inc {
+ if w.perRegion > 1 && !w.preparedManifestHere(b, c, gen) {
+ return // moves §7.6 (L17, R18-1) in the two-box shape: the generation's prepared manifest first
+ }
// the marker vouches for everything before it
w.cas(c, func(n *claim) bool { next(n); return true })
return
@@ -1281,6 +1293,9 @@ func (w *world) installLocal(b *box, c *claim, gen string, seal ref, _ string, n
b.Row.Repl, b.Row.Next = c.Op.Repl, c.Op.Next
}
b.Files.Done = true
+ if w.perRegion > 1 && !w.preparedManifestHere(b, c, gen) {
+ return // the first branch finishes once the prepared manifest is acknowledged
+ }
w.cas(c, func(n *claim) bool { next(n); return true })
}
@@ -1503,7 +1518,7 @@ func (w *world) recoverSource(b *box, c *claim) {
if !w.evidence(b, op.RollbackID) {
return
}
- if w.parkStep(b, c.Holder.Location, fmt.Sprintf("superseded/%s/%s/return/", op.ID, host), op.StopAt+horizonH, w.opScope(c, c.Holder.Location), confine(c.Holder.Location)) {
+ if w.parkStep(b, c.Holder.Location, fmt.Sprintf("superseded/%s/%s/return/", op.ID, host), op.StopAt+horizonH, w.opScope(c, c.Holder.Location), w.keepFilesLive(confine(c.Holder.Location))) {
w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "install-copy"; return sameOp(n.Op.ID, op.ID) })
}
case "install-copy":
@@ -1635,7 +1650,7 @@ func (w *world) pushStep(b *box, c *claim) {
return true
})
case "park":
- if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/push/", op.ID, host), op.StopAt+horizonH, w.opScope(c, loc), confine(loc)) {
+ if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/push/", op.ID, host), op.StopAt+horizonH, w.opScope(c, loc), w.keepFilesLive(confine(loc))) {
w.cas(c, func(n *claim) bool { n.Op.Phase = "install"; return sameOp(n.Op.ID, op.ID) })
}
case "install":
@@ -1947,8 +1962,8 @@ func (w *world) deadGeneration(c *claim, r int, k string) bool {
}
return true // a dead generation's certificates
}
- if isManifestKey(k) {
- // a files manifest outside the files live set (region HA §5.6, L13):
+ if isFilesKey(k) {
+ // a files manifest or object outside the files live set (region HA §5.6, L13):
// its generation's, as the data keys are
if genOf(h.Location, k) == h.Gen {
return !authoritative
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go
index 3ad1cc317..219c9e2c5 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" || c.Op.Kind == "relocating")) // and relocation probes (region HA, Task 5)
+ healthy := !(probe && b.FailApp && (c.Op.Kind == "landing" || c.Op.Kind == "relocating" && b.ID != c.Op.Src)) // 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
@@ -1146,7 +1146,7 @@ func (w *world) coverParkedPredecessors(n *claim, region int, gens []string) {
// that never had anything (INV-17).
func (w *world) hadFilesIn(r int, loc, gen string) bool {
for _, k := range w.buckets[r].keys(dataKey(loc, gen, "")) {
- if genOf(loc, k) == gen && !isManifestKey(k) { // a files manifest is no database object (region HA §5.6)
+ if genOf(loc, k) == gen && !isFilesKey(k) { // a files manifest is no database object (region HA §5.6)
return true
}
}
@@ -1158,7 +1158,7 @@ func (w *world) hadFilesIn(r int, loc, gen string) bool {
func (w *world) hasFilesIn(r int, loc, gen string) bool {
bk := w.buckets[r]
for _, k := range bk.keys(dataKey(loc, gen, "")) {
- if genOf(loc, k) == gen && bk.current(k) != nil && !isManifestKey(k) { // a files manifest is no database object (region HA §5.6)
+ if genOf(loc, k) == gen && bk.current(k) != nil && !isFilesKey(k) { // a files manifest is no database object (region HA §5.6)
return true
}
}
@@ -1432,6 +1432,9 @@ func (w *world) admits(b *box, c *claim, probe string) bool {
case "pushing":
return probe == op.ID && op.Phase == "probing"
case "relocating":
+ if op.Stuck != "" && on("reloc-stuck-refuses-admission") {
+ return false // relocationAdmission: a stuck operation admits nobody, the source's wake included (ruling 9, R1-16; rev 4, Astra R1-2)
+ }
if probe != "" {
return probe == op.ID && role(op.Tgt) && op.Phase == "probing"
}
@@ -1607,6 +1610,10 @@ func (w *world) admitWriter(b *box, c *claim, want string, probe bool) (admitInp
w.stats["admission refused: op kind"]++
return a, false
}
+ if c.Op.Kind == "relocating" && c.Op.Stuck != "" && on("reloc-stuck-refuses-admission") {
+ w.stats["admission refused: relocation stuck"]++
+ return a, false
+ }
a.KeepStamp = h.Stamp > h.Session
if !on("admit-everywhere") && c.Op.Kind != "serving" {
a.Mode, a.NewGeneration = "unguarded", want // the mutation: writer sessions for ordinary wakes only (rev 7.8)
diff --git a/internal/sim/hostlife/regionha_cases_test.go b/internal/sim/hostlife/regionha_cases_test.go
index 6086dcba6..36efb1e61 100644
--- a/internal/sim/hostlife/regionha_cases_test.go
+++ b/internal/sim/hostlife/regionha_cases_test.go
@@ -128,46 +128,6 @@ func TestL18TerminationDuringServing(t *testing.T) {
})
}
-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)
diff --git a/internal/sim/hostlife/regionha_round1_test.go b/internal/sim/hostlife/regionha_round1_test.go
new file mode 100644
index 000000000..ad0f45d10
--- /dev/null
+++ b/internal/sim/hostlife/regionha_round1_test.go
@@ -0,0 +1,572 @@
+package hostlife
+
+import (
+ "crypto/sha256"
+ "fmt"
+ "testing"
+)
+
+// Astra's round 1 on the two-box plan (rev 4): a direct test for every
+// guard the round found unproven, the phase matrix stopped between single
+// actor transitions, and claim §11's additional witnesses that rev 3 left
+// outside the model. Each test names the rule whose mutation makes it
+// fail.
+
+// stepUntil advances the world one actor at a time and stops the moment
+// done holds: the tick's own work (completions, stalls, the two-box
+// step), then each live box's worker, obligation runner, reconcile and
+// passes, the executor's wake, then the recovery pass, with done checked
+// after every single one. A fault injected after it returns lands
+// exactly between two transitions.
+func (w *world) stepUntil(t *testing.T, max int, what string, done func() bool) bool {
+ t.Helper()
+ if done() {
+ return true
+ }
+ for i := 0; i < max; i++ {
+ w.now++
+ w.completeDue(false)
+ if p := w.recPend; p != nil && p.at <= w.now {
+ w.recPend = nil
+ if w.rec == nil {
+ w.rec = p.rec
+ }
+ }
+ w.runStalled()
+ if w.perRegion > 1 {
+ w.twoBoxStep()
+ }
+ if done() {
+ return true
+ }
+ for _, b := range w.liveBoxes() {
+ for _, act := range []func(){
+ func() { w.worker(b) }, func() { w.obligationRunner(b) }, func() { w.reconcile(b) },
+ func() {
+ if len(b.plans) == 0 {
+ w.passPlan(b, "config")
+ w.passPlan(b, "adopt")
+ }
+ w.passExec(b)
+ },
+ w.wakeExec,
+ } {
+ act()
+ if len(w.violations) > 0 {
+ return false
+ }
+ if done() {
+ return true
+ }
+ }
+ }
+ w.recoveryPass()
+ w.checkStep()
+ if len(w.violations) > 0 {
+ return false
+ }
+ if done() {
+ return true
+ }
+ }
+ w.violate("INV-3 (witness): %s not reached within %d steps: %s", what, max, w.state())
+ return false
+}
+
+// atPhase: the relocation is exactly in phase p (not beyond it).
+func (w *world) atPhase(p string) func() bool {
+ return func() bool { c := w.claim; return c.Op.Kind == "relocating" && c.Op.Phase == p && c.Op.Stuck == "" }
+}
+
+// liveIn counts region r's live boxes.
+func (w *world) liveIn(r int) int { return len(w.regionBoxes(r)) }
+
+// ---------- finding 6: the termination matrix, exact ----------
+
+// TestL18TerminationAtEveryExactPhase (L18 "termination during every
+// relocation phase", rev 4): the relocation is stopped exactly in each
+// phase of §5.8's table, probe-failed included, between two single
+// actor transitions; the phase and the region's live topology are
+// asserted immediately before the participant is terminated; no spare
+// exists beforehand, and replacements come only from the modelled group
+// after the loss. One-box region: the failover's target (the box the
+// group launched) is terminated. Two-box region: the drain's source or
+// target is.
+func TestL18TerminationAtEveryExactPhase(t *testing.T) {
+ phases := []string{"starting", "releasing", "pinning", "installing", "installed", "probing", "probe-failed", "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
+ }
+ if phase == "probe-failed" && who == "source" {
+ continue // the source has released before any probe; its loss is the pinning case
+ }
+ name := fmt.Sprintf("%s/%s@%s", 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)
+ var target func() *box
+ if boxes == 2 {
+ w.drainTo(t, nil)
+ tgt := w.boxByID(w.claim.Op.Tgt)
+ tgt.FailApp = phase == "probe-failed"
+ target = func() *box { return w.boxByID(w.claim.Op.Tgt) }
+ } else {
+ w.terminate(from)
+ if !w.stepUntil(t, 400, "a target named", func() bool { c := w.claim; return c.Op.Kind == "relocating" && c.Op.Tgt >= 0 }) {
+ w.mustHold(t)
+ }
+ w.boxByID(w.claim.Op.Tgt).FailApp = phase == "probe-failed"
+ target = func() *box { return w.boxByID(w.claim.Op.Tgt) }
+ }
+ if !w.stepUntil(t, 2000, "relocation exactly at "+phase, w.atPhase(phase)) {
+ w.mustHold(t)
+ }
+ // immediately before the injection: the exact phase, and the
+ // region's live topology (no spare)
+ if c := w.claim; c.Op.Phase != phase {
+ t.Fatalf("not at %s: %s", phase, w.state())
+ }
+ if n := w.liveIn(0); n != boxes {
+ t.Fatalf("region 0 has %d live boxes before the injection, want %d", n, boxes)
+ }
+ victim := from
+ if who == "target" {
+ victim = target()
+ }
+ if victim == nil || victim.Destroyed {
+ t.Fatalf("no live %s at %s: %s", who, phase, w.state())
+ }
+ w.terminate(victim)
+ w.servingOn(t, nil)
+ w.finish(t)
+ w.mustHold(t)
+ if c := w.claim; c.Op.Exec == victim.ID {
+ t.Fatalf("serving on the terminated %s", who)
+ }
+ })
+ }
+ }
+ }
+}
+
+// ---------- finding 2: stuck refuses admission ----------
+
+// TestStuckSourceRefusesAdmission (reloc-stuck-refuses-admission): three
+// targets die in starting, so the exhausted fence leaves the live source
+// op.exec with its ready row and the operation stuck. A request then
+// reaches the source, whose app has stopped: no writer is admitted.
+func TestStuckSourceRefusesAdmission(t *testing.T) {
+ w := twoBoxWitness()
+ from := w.boxes[0]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ b30, b31 := w.extraBox(0, 30), w.extraBox(0, 31)
+ w.userRelocate()
+ for _, x := range []*box{w.boxByID(w.claim.Op.Tgt), b30, b31, w.boxes[3]} {
+ 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 c := w.claim; c.Op.Stuck == "" || c.Op.Exec != from.ID || c.Op.Phase != "starting" || from.Row == nil || from.Row.Status != "ready" {
+ t.Fatalf("not stuck in starting with the live source op.exec and its row ready: %s", w.state())
+ }
+ from.Procs = nil // the app stopped; the next request wakes it
+ before := w.stats["admissions: cold"] + w.stats["admissions: warm"]
+ w.drive(30, never, func() { w.wake(from) })
+ w.mustHold(t)
+ if n := w.stats["admissions: cold"] + w.stats["admissions: warm"] - before; n != 0 || hasMain(from) {
+ t.Fatalf("a stuck operation admitted %d writers on its source (running %v)", n, hasMain(from))
+ }
+}
+
+// ---------- finding 3: the sole box is checked in an HA region ----------
+
+// TestHASoleBoxIsCheckedLikeAnyOther (ha-sole-box-eligibility): in a
+// one-box region with region HA on, the only box is refused when it is
+// unenrolled, lacks the secrets key, runs an old executable, is on lost or
+// owes a park-local for the host; in the one-box shape (region HA off)
+// the region's box is chosen as today.
+func TestHASoleBoxIsCheckedLikeAnyOther(t *testing.T) {
+ for _, why := range []string{"unenrolled", "no key", "old executable", "lost", "owes a park-local"} {
+ t.Run(why, func(t *testing.T) {
+ w := regionWorld(1)
+ b := w.boxes[0]
+ w.serve(w.boxes[1], 1, app0) // the claim lives in region 1; region 0 is the create's
+ switch why {
+ case "unenrolled":
+ b.Enrolled = false
+ case "no key":
+ b.HasKey = false
+ case "old executable":
+ b.Old = true
+ case "lost":
+ w.claim.Lost = addUniq(w.claim.Lost, b.ID)
+ case "owes a park-local":
+ w.claim.Obl = append(w.claim.Obl, obligation{ID: "pl", Op: "old", Kind: "park-local", Box: b.ID, Region: -1, Ret: "old"})
+ }
+ if got := w.chooseBox(0, app0); got != nil {
+ t.Fatalf("region HA chose its sole box %d although it is %s", got.ID, why)
+ }
+ })
+ }
+ t.Run("one-box shape keeps today's pick", func(t *testing.T) {
+ w := newWitness()
+ w.boxes[0].Enrolled = false
+ if got := w.chooseBox(0, app0); got != w.boxes[0] {
+ t.Fatalf("the one-box shape did not pick the region's box: %v", got)
+ }
+ })
+}
+
+// ---------- finding 4: the acknowledgement barrier at every transition ----------
+
+// TestReplacementWithoutAcknowledgementHoldsEachTransition
+// (reloc-exec-ack-barrier): the target dies at installing and the spare
+// that replaces it has not acknowledged. Driving the installation's steps
+// without the spare's acknowledgement, the operation stays at installing
+// (ToInstalled's requireAcks); once the spare acknowledges, it proceeds.
+func TestReplacementWithoutAcknowledgementHoldsEachTransition(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)
+ if !w.reach(t, 1500, "installing", w.atPhase("installing"), w.act(from, to), w.recoveryPass) {
+ w.mustHold(t)
+ }
+ w.terminate(to)
+ w.reach(t, 60, "replaced at installing", func() bool { return w.claim.Op.Exec == spare.ID }, w.act(from))
+ if contains(w.claim.Op.Acks, spare.ID) {
+ t.Fatalf("the spare acknowledged before it ran")
+ }
+ // the spare's installation steps, without its acknowledgement
+ spare.ExecUntil = w.localNow(spare) + execTTL
+ for i := 0; i < 40; i++ {
+ w.tick()
+ w.relocInstalling(spare, w.claim.clone())
+ }
+ w.mustHold(t)
+ if c := w.claim; c.Op.Phase != "installing" {
+ t.Fatalf("left installing without the spare's acknowledgement: %s", w.state())
+ }
+ w.servingOn(t, spare)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// ---------- finding 5: deadlines and delays from the fence record ----------
+
+// TestPinWaitsForEveryFenceObservation (reloc-pin-delay-every-fence): the
+// source's release is journaled, then the target is terminated and
+// replaced by a spare before any pin. The pin waits out the target's
+// fence observation plus the delay, a release notwithstanding.
+func TestPinWaitsForEveryFenceObservation(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)
+ if !w.stepUntil(t, 1500, "the source's release journaled", func() bool { c := w.claim; return c.Op.Release != nil && c.Op.Pin == nil }) {
+ w.mustHold(t)
+ }
+ w.terminate(to)
+ ok := w.stepUntil(t, 400, "the pin", func() bool {
+ c := w.claim
+ if c.Op.Pin != nil && len(c.Op.Observed) > 0 && w.now < latestObservation(c.Op)+failoverPinDelay {
+ t.Fatalf("pinned at %d, before the target's observation %d plus %d", w.now, latestObservation(c.Op), failoverPinDelay)
+ }
+ return c.Op.Pin != nil
+ })
+ if !ok {
+ w.mustHold(t)
+ }
+ if !contains(w.claim.Lost, to.ID) || w.claim.Op.Tgt != spare.ID {
+ t.Fatalf("the target was not replaced before the pin: %s", w.state())
+ }
+ w.servingOn(t, spare)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// TestDeadTargetDeadlineFromObservation (reloc-deadline-from-observed-at):
+// the drain's target dies at installing while no worker of the region can
+// observe it (the source is frozen) for longer than the deadline, and the
+// group launches no spare. The first observation writes the fence record;
+// the operation waits a whole deadline from it before it is stuck.
+func TestDeadTargetDeadlineFromObservation(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)
+ w.oneBoxRegion[0] = true // no replacement launches: no spare
+ if !w.reach(t, 1500, "installing", w.atPhase("installing"), w.act(from, to), w.recoveryPass) {
+ w.mustHold(t)
+ }
+ w.terminate(to)
+ from.Frozen = true
+ w.drive(releaseDeadline+20, never, w.act(w.boxes[1], w.boxes[2]))
+ from.Frozen = false
+ w.stepUntil(t, 40, "the fence record", func() bool { return to.FenceObservedAt > 0 })
+ seen := w.now
+ w.drive(releaseDeadline-5, never, w.act(from), w.recoveryPass)
+ if w.claim.Op.Stuck != "" {
+ t.Fatalf("stuck %d steps after the observation at %d, before its deadline (terminated at %d)", w.now-seen, seen, to.TerminatedAt)
+ }
+ w.reach(t, 60, "stuck after the deadline", func() bool { return w.claim.Op.Stuck != "" }, w.act(from), w.recoveryPass)
+ w.mustHold(t)
+}
+
+// ---------- finding 7: the five guards, each discriminated ----------
+
+// TestOnlyTheExecutorWritesP (reloc-exec-only-installs): at installing,
+// only the source (not op.exec) runs: nothing is written under P and no
+// local install is made on it.
+func TestOnlyTheExecutorWritesP(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)
+ if !w.stepUntil(t, 1500, "installing", w.atPhase("installing")) {
+ w.mustHold(t)
+ }
+ p := w.claim.Op.Prepared
+ for _, k := range w.buckets[0].keys(dataKey(w.claim.Holder.Location, p, "")) {
+ w.buckets[0].objs[k] = nil // whatever the target began is set aside: only the source acts now
+ delete(w.buckets[0].objs, k)
+ }
+ w.drive(40, never, w.act(from))
+ if ks := w.buckets[0].keys(dataKey(w.claim.Holder.Location, p, "")); len(ks) > 0 || from.receipt != nil || from.Files != nil && from.Files.Gen == p {
+ t.Fatalf("the source, not op.exec, wrote P or installed it: %v", ks)
+ }
+}
+
+// TestInstallAppliesThePinnedManifest (files-pin-exact): a newer manifest
+// of the dead executor lands between the pin and the install; the install
+// applies the pinned version, never the newest.
+func TestInstallAppliesThePinnedManifest(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)
+ loc := w.claim.Holder.Location
+ late := w.timerOn(from)
+ w.holdKey(loc + "files/")
+ w.publishFiles(from) // the newer manifest, carrying the timer, held
+ w.hold = nil
+ w.terminate(from)
+ if !w.stepUntil(t, 400, "the pin journaled", func() bool { c := w.claim; return c.Op.Pin != nil }) {
+ w.mustHold(t)
+ }
+ if n := w.completeHeld(loc + "files/"); n == 0 {
+ t.Fatalf("no held manifest to land")
+ }
+ if !w.servingOn(t, to) {
+ w.mustHold(t)
+ }
+ if contains(to.Timers, late) {
+ t.Fatalf("the install applied the manifest that landed after the pin")
+ }
+}
+
+// TestFailoverPinWaitsOutTheDelay (reloc-failover-pin-delay): no pin
+// before the fence's observed_at plus the delay.
+func TestFailoverPinWaitsOutTheDelay(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.terminate(from)
+ ok := w.stepUntil(t, 400, "the pin", func() bool {
+ c := w.claim
+ if c.Op.Pin != nil && w.now < c.Op.FenceAt+failoverPinDelay {
+ t.Fatalf("pinned at %d, before the fence's observation %d plus %d", w.now, c.Op.FenceAt, failoverPinDelay)
+ }
+ return c.Op.Kind == "relocating" && c.Op.Pin != nil
+ })
+ if !ok {
+ w.mustHold(t)
+ }
+ w.servingOn(t, to)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// TestPinNeverShortOfTheRelease (reloc-pin-reaches-release): the listing
+// lags behind the journaled release (its newest file is not yet visible);
+// no pin is journaled short of the release's final_txid.
+func TestPinNeverShortOfTheRelease(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.appWrite(from)
+ w.drainTo(t, to)
+ if !w.stepUntil(t, 1500, "the release journaled", func() bool { c := w.claim; return c.Op.Release != nil && c.Op.Pin == nil }) {
+ w.mustHold(t)
+ }
+ c := w.claim
+ bk := w.buckets[0]
+ var hidden string
+ var vs []*version
+ for _, k := range bk.keys(dataKey(c.Holder.Location, c.Op.Release.Gen, "")) {
+ if l, _, max, ok := ltxLevel(k); ok && l == 0 && max == c.Op.Release.FinalTX {
+ hidden, vs = k, bk.objs[k]
+ delete(bk.objs, k)
+ }
+ }
+ if hidden == "" {
+ t.Fatalf("no level-0 file at the release's final_txid %d", c.Op.Release.FinalTX)
+ }
+ w.drive(20, never, w.act(from, to))
+ if p := w.claim.Op.Pin; p != nil && p.TX < c.Op.Release.FinalTX {
+ t.Fatalf("pinned at TXID %d, short of the release's %d", p.TX, c.Op.Release.FinalTX)
+ }
+ bk.objs[hidden] = vs
+ w.servingOn(t, to)
+ w.finish(t)
+ w.mustHold(t)
+}
+
+// ---------- finding 8: claim §11's additional witnesses ----------
+
+// TestCrashAtEachOpenBoundary (claim §11 "Crash between prepared
+// admission, marker publication and replication opening"): the target's
+// agent crashes exactly at each boundary of P's first admitted session:
+// admitted with the app starting (its launch environment held unready),
+// admitted with generation.json's PUT in flight (held), and replication
+// open; in a one-box and a two-box region.
+func TestCrashAtEachOpenBoundary(t *testing.T) {
+ for _, stage := range []string{"app", "marker", "open"} {
+ t.Run(stage, func(t *testing.T) {
+ bothShapes(t, func(t *testing.T, w *world, boxes int) {
+ from := w.serveAndPublish(t)
+ w.timerOn(from)
+ loc := w.claim.Holder.Location
+ if boxes == 2 {
+ w.drainTo(t, nil)
+ } else {
+ w.terminate(from)
+ }
+ if stage == "marker" {
+ w.holdKey(genMarkerKey(loc))
+ }
+ var to *box
+ hit := w.stepUntil(t, 2000, "P's open at "+stage, func() bool {
+ c := w.claim
+ if c.Op.Kind != "relocating" || c.Op.Tgt < 0 {
+ return false
+ }
+ to = w.boxByID(c.Op.Tgt)
+ if stage == "app" && c.Op.Phase == "probing" && to.envUnreadyFor == 0 {
+ to.envUnreadyFor = 20 // the launch environment is not ready yet
+ }
+ if stage == "open" {
+ return preparedOpened(c) && to.open == nil && hasMain(to)
+ }
+ return to.open != nil && to.open.Prepared && to.open.stage == stage
+ })
+ if !hit {
+ return
+ }
+ w.crash(to)
+ to.envUnreadyFor = 0
+ w.hold = nil
+ w.servingOn(t, nil)
+ w.finish(t)
+ })
+ })
+ }
+}
+
+// TestAutomaticTargetLossRacesRecordCommit (claim §11 "Automatic target
+// loss racing the record commit CAS"): a move's target is terminated
+// just before, and just after, its record commit CAS; the regional
+// worker's automatic fence takes the operator's record-first path, so the
+// claim follows whichever the record holds: a rollback to the source
+// before the commit, a committed landing (then a failover in the target's
+// region) after it.
+func TestAutomaticTargetLossRacesRecordCommit(t *testing.T) {
+ for _, when := range []string{"before the commit", "after the commit"} {
+ t.Run(when, func(t *testing.T) {
+ w := twoBoxWitness()
+ src := w.boxes[0]
+ w.serve(src, 1, app0)
+ w.running(t, src)
+ w.published(t, src)
+ w.moveTo(1)
+ if w.claim.Op.Kind != "moving" {
+ t.Fatalf("no move: %s", w.state())
+ }
+ tgt := w.boxByID(w.claim.Op.Tgt)
+ ok := w.stepUntil(t, 1500, "the commit boundary", func() bool {
+ c := w.claim
+ if when == "before the commit" {
+ return c.Op.Kind == "landing" && c.Op.LandPhase == "probed" && w.rec != nil && w.rec.MoveState == "landing" && w.now >= c.Op.ProbedAt+15
+ }
+ return w.rec != nil && w.rec.MoveState == "committed" && c.Op.Kind == "landing" && !c.Op.Committed
+ })
+ if !ok {
+ w.mustHold(t)
+ }
+ w.terminate(tgt)
+ w.servingOn(t, nil)
+ w.finish(t)
+ w.mustHold(t)
+ c := w.claim
+ if when == "before the commit" && (w.rec.Region != src.Region || w.execBox(c).Region != src.Region) {
+ t.Fatalf("a loss before the commit did not return the host to the source's region: %s", w.state())
+ }
+ if when == "after the commit" && (w.rec.Region != 1 || w.execBox(c).Region != 1) {
+ t.Fatalf("a loss after the commit did not keep the host in the target's region: %s", w.state())
+ }
+ })
+ }
+}
+
+// ---------- R18-2: the tie-break follows the host ----------
+
+// TestTieHashFollowsTheHost (tie-hash-host; 1b-next R18-2): on equal
+// loads two hostnames may choose different boxes; the model's tie-break
+// hashes each host's own name.
+func TestTieHashFollowsTheHost(t *testing.T) {
+ w := regionWorld(2)
+ a, b := w.boxes[0], w.peer()
+ sum := func(host string, id int) uint32 { // claim P5-D2, computed here independently of the model
+ s := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", host, id)))
+ return uint32(s[0])<<24 | uint32(s[1])<<16 | uint32(s[2])<<8 | uint32(s[3])
+ }
+ wantH, wantH2 := a, a
+ if sum("h", b.ID) > sum("h", a.ID) {
+ wantH = b
+ }
+ if sum("h2", b.ID) > sum("h2", a.ID) {
+ wantH2 = b
+ }
+ if wantH == wantH2 {
+ t.Skip("hosts h and h2 choose the same box among boxes 0 and 3")
+ }
+ if got := w.chooseBoxFor("h", 0, app0); got != wantH {
+ t.Fatalf("host h chose %v, want %d", got, wantH.ID)
+ }
+ if got := w.chooseBoxFor("h2", 0, app0); got != wantH2 {
+ t.Fatalf("host h2 chose %v, want %d", got, wantH2.ID)
+ }
+}
diff --git a/internal/sim/hostlife/regionha_test.go b/internal/sim/hostlife/regionha_test.go
index 9d8e987af..11c1ee7af 100644
--- a/internal/sim/hostlife/regionha_test.go
+++ b/internal/sim/hostlife/regionha_test.go
@@ -166,6 +166,10 @@ func (w *world) relocRelease(b *box, c *claim) *releaseEntry {
if b.pubGen != h.Gen {
b.pubGen, b.pubSeq, b.releaseSeq = h.Gen, 0, 0
}
+ if !w.ensureObject(b, h, files) {
+ w.stats["release: waits for its files object"]++
+ return nil
+ }
if b.releaseSeq == 0 {
b.pubSeq++
b.releaseSeq = b.pubSeq
@@ -257,9 +261,86 @@ func (w *world) selectPin(b *box, c *claim) *pinEntry {
w.markStuck(c, fmt.Sprintf("the pinned files carry no instance.key for a nonempty database (TXID %d)", n))
return nil
}
+ if m := w.pinnedManifestOf(b.Region, pin); m != nil && m.Files != nil && m.Files.Obj != "" && !w.originAuthorized(b.Region, c, m.Files.Obj) {
+ // filesPin's referencedOrigins (1b-next rev 12 finding 1, R11-1):
+ // the object the pinned manifest names must be current in this
+ // region's bucket, under this holder's location, at an origin the
+ // pin may cite; otherwise the pin refuses (ErrOrigin) and the
+ // worker retries on a later pass
+ w.stats["pin: files object refused"]++
+ w.logf("DEBUG pin refused obj %s cur=%v", m.Files.Obj, w.buckets[b.Region].current(m.Files.Obj) != nil)
+ return nil
+ }
return pin
}
+// ---------- files objects (rev 4, Astra R1-8; spec §5.6 "Objects are immutable") ----------
+
+// objectKey is a files object: <holder.location>/files/<generation>/objects/<content>,
+// written create-only by the generation that first held the content.
+func objectKey(loc, gen string, f *filesDir) string {
+ return dataKey(loc, gen, fmt.Sprintf("files/objects/%d-%d", f.Key, f.Env))
+}
+
+// isFilesKey: a manifest or an object of the files tree.
+func isFilesKey(k string) bool {
+ return strings.Contains(k, "files/m-") || strings.Contains(k, "files/objects/")
+}
+
+// objectCurrent: f names an object holding its content, in b's region
+// (the region binding; the mutation of files-objects-region-bound accepts
+// another region's object, as a moved host's carried reference).
+func (w *world) objectCurrent(b *box, f *filesDir) bool {
+ if f == nil || f.Obj == "" || !strings.HasSuffix(f.Obj, fmt.Sprintf("/%d-%d", f.Key, f.Env)) {
+ return false
+ }
+ return f.ObjRegion == b.Region || !on("files-objects-region-bound")
+}
+
+// ensureObject uploads f's content as an object of the current
+// generation when f names none for it in this region (a changed file, or
+// a host that came from another region), and reports once one is
+// acknowledged; an unchanged file keeps naming the object an older
+// generation wrote, which is what keeps that generation's objects live.
+func (w *world) ensureObject(b *box, h *holder, f *filesDir) bool {
+ if f == nil || w.objectCurrent(b, f) {
+ return true
+ }
+ k := objectKey(h.Location, h.Gen, f)
+ if !w.ackedPut(b, k, &blob{Kind: "files-object", Inc: h.Inc, Gen: h.Gen, Files: f.clone()}, true) {
+ return false
+ }
+ f.Obj, f.ObjRegion = k, b.Region
+ w.stats["files objects written"]++
+ return true
+}
+
+// originAuthorized is the pin's check of one referenced object: current
+// in region r's bucket under the holder's location, and at an authorized
+// origin. With the rule (files-origins-referenced) the origins are the
+// generations the pinned manifest references, so any such object is
+// authorized; the mutation derives them from holder.generation and the
+// retained admissions, as rev 11 did, which the trim to eight shortens.
+func (w *world) originAuthorized(r int, c *claim, obj string) bool {
+ h := c.Holder
+ if !strings.HasPrefix(obj, h.Location) || w.buckets[r].current(obj) == nil {
+ return false
+ }
+ if on("files-origins-referenced") {
+ return true
+ }
+ g := genOf(h.Location, obj)
+ if g == h.Gen || c.Op.Prepared != "" && g == c.Op.Prepared {
+ return true
+ }
+ for _, e := range h.Admissions {
+ if e.Gen == g || e.Predecessor.Gen == g {
+ return true
+ }
+ }
+ return false
+}
+
// pinnedManifestOf is the manifest pin names, read by version.
func (w *world) pinnedManifestOf(r int, pin *pinEntry) *blob {
if pin.ManKey == "" {
@@ -469,7 +550,7 @@ func (w *world) relocationLeftovers(n *claim, op opState) {
// mutation of reloc-exec-ack-barrier moves on with no acknowledgement.
func (w *world) relocStarting(b *box, c *claim) {
op := c.Op
- if on("reloc-exec-ack-barrier") && !w.leasesSeen(c) {
+ if on("reloc-exec-ack-barrier") && !acked(c) {
return
}
w.cas(c, func(n *claim) bool {
@@ -527,7 +608,7 @@ func (w *world) relocReleasing(b *box, c *claim) {
}
return
}
- if on("reloc-ack-before-pinning") && !w.leasesSeen(c) {
+ if on("reloc-ack-before-pinning") && !acked(c) {
w.stats["pin after release: waits for the acknowledgements"]++
return
}
@@ -555,7 +636,7 @@ func (w *world) relocReleasing(b *box, c *claim) {
func (w *world) relocPinning(b *box, c *claim) {
op, h := c.Op, c.Holder
if op.Pin == nil {
- if op.Release == nil && on("reloc-failover-pin-delay") && w.now < op.FenceAt+failoverPinDelay {
+ if on("reloc-failover-pin-delay") && (op.Release == nil || on("reloc-pin-delay-every-fence")) && w.now < latestObservation(op)+failoverPinDelay {
return
}
pin := w.selectPin(b, c)
@@ -591,7 +672,7 @@ func (w *world) relocPinning(b *box, c *claim) {
})
return
}
- if on("reloc-exec-ack-barrier") && !w.leasesSeen(c) {
+ if on("reloc-exec-ack-barrier") && !acked(c) {
return
}
to := op.Tgt
@@ -661,6 +742,9 @@ func (w *world) relocInstalling(b *box, c *claim) {
bk := w.buckets[b.Region]
installed := func(n *claim) bool {
ok := sameOp(n.Op.ID, op.ID) && n.Op.Kind == "relocating" && n.Op.Phase == "installing" && n.Op.Exec == op.Exec && n.Op.Prepared == g
+ if ok && on("reloc-exec-ack-barrier") && !acked(n) {
+ ok = false // ToInstalled's requireAcks (rev 4, Astra R1-4)
+ }
if ok {
n.Op.Phase = "installed"
}
@@ -793,6 +877,9 @@ func (w *world) relocInstalled(b *box, c *claim) {
}
probing := func(n *claim) bool {
ok := sameOp(n.Op.ID, op.ID) && n.Op.Kind == "relocating" && n.Op.Phase == "installed" && n.Op.Exec == op.Exec && n.Op.Receipt != nil
+ if ok && on("reloc-exec-ack-barrier") && !acked(n) {
+ ok = false // ToProbing's requireAcks (rev 4, Astra R1-4)
+ }
if ok {
n.Op.Phase, n.Op.ProbingSince = "probing", w.now
}
@@ -1201,6 +1288,7 @@ func (w *world) twoBoxStep() {
w.launchReplacements()
w.enrollStep()
for _, b := range w.liveBoxes() {
+ w.renewController(b)
w.publishFiles(b)
}
if !w.calm && w.fail["terminate"] && w.rng.Intn(200) == 0 {
@@ -1212,6 +1300,10 @@ func (w *world) twoBoxStep() {
if !w.calm && w.rng.Intn(60) == 0 {
w.userRelocate()
}
+ if !w.calm && w.rng.Intn(400) == 0 {
+ bs := w.liveBoxes()
+ w.updateExecutable(bs[w.rng.Intn(len(bs))]) // claim §5.10: refused while a controller record exists (rev 4)
+ }
}
// regionHAOwnsLoss: the loss of b is region HA's to recover (model ruling
@@ -1278,6 +1370,14 @@ func (w *world) fenceStep(b *box, c *claim) bool {
if len(cands) == 0 {
return false
}
+ if x := cands[0]; x.FenceObservedAt == 0 {
+ // hostfence.TerminationWorker: the first worker to observe the
+ // terminated state writes the box-fences record with that
+ // observed_at; every deadline and delay runs from it, never from
+ // the termination itself, which no worker saw (rev 4, Astra R1-5)
+ x.FenceObservedAt = w.now
+ w.logf("box %d observed box %d terminated: fence record, observed_at %d", b.ID, x.ID, w.now)
+ }
before := w.claim.Ver
w.fence(b, c, cands[0])
return w.claim.Ver != before
@@ -1324,7 +1424,34 @@ func (w *world) fence(b *box, c *claim, x *box) {
w.convertToRelocating(c, x)
}
return
- case op.Kind == "moving" && op.Tgt == x.ID, (op.Kind == "landing" || op.Kind == "restoring") && op.Tgt == x.ID, op.Kind == "creating":
+ case op.Kind == "landing" && op.Committed && op.Tgt == x.ID && on("fence-converts-committed-landing"):
+ // finding F5 (rev 4): the record committed the landing, so no
+ // rollback is possible, and its dead target can never finish it.
+ // The fence makes it a failover in the target's region, as a
+ // serving executor's fence does (plan ruling 20); the mutation
+ // leaves it to the operator's path, which has nothing to do
+ id := op.ID
+ if w.cas(c, func(n *claim) bool {
+ if !sameOp(n.Op.ID, id) || n.Op.Kind != "landing" || !n.Op.Committed || contains(n.Lost, x.ID) {
+ return false
+ }
+ w.startFailover(n, x)
+ w.fenceBox(n, x)
+ return true
+ }) {
+ w.stats["fences written"]++
+ w.stats["relocation started: failover of a committed landing"]++
+ w.logf("box %d fenced box %d, the committed landing %s's dead target: failover in region %d", b.ID, x.ID, id, x.Region)
+ w.declareTimers(x, w.claim.Holder)
+ }
+ return
+ case (op.Kind == "landing" || op.Kind == "restoring") && op.Tgt == x.ID:
+ // claim D2's automatic termination for a move's target (rev 4,
+ // claim §11 "automatic target loss racing the record commit CAS"):
+ // the same record-first declared loss the operator makes
+ w.declareTargetLost(c)
+ return
+ case op.Kind == "moving" && op.Tgt == x.ID, op.Kind == "creating":
return
case op.Kind == "relocating" && op.Stuck != "":
return // Worker.Step consumes no fence of a stuck operation: the operator's resolve does
@@ -1368,7 +1495,12 @@ func (w *world) fence(b *box, c *claim, x *box) {
// regional work with the dead box, where nothing runs it.
func (w *world) fenceBox(n *claim, x *box) {
n.Lost = addUniq(n.Lost, x.ID)
- n.Op.FenceBox, n.Op.FenceAt = x.ID, w.now
+ at := x.FenceObservedAt
+ if at == 0 {
+ at = w.now
+ }
+ n.Op.FenceBox, n.Op.FenceAt = x.ID, at
+ n.Op.Observed = append(n.Op.Observed, at) // fence.<box>.observed_at, retained for every later pin (JournalPin)
var parts []int
for _, p := range n.Op.Parts {
if p != x.ID {
@@ -1521,7 +1653,7 @@ func (w *world) replaceTarget(n *claim, x *box) bool {
}
next := w.chooseBoxExcept(n.Op.SrcRegion, n.Holder.App, relocExcept(n.Op, x.ID)...)
if next == nil {
- if on("reloc-dead-target-bound") && w.now > x.TerminatedAt+releaseDeadline {
+ if on("reloc-dead-target-bound") && x.FenceObservedAt > 0 && w.now > observedAt(x)+releaseDeadline {
// FenceTargetWithoutSpare (1b-next rev 18 finding 2): the fence
// is consumed ReleaseDeadline after its observed_at and the
// operation is stuck; the mutation waits for a spare for ever
@@ -1633,9 +1765,12 @@ func (w *world) publishFiles(b *box) {
b.pubGen, b.pubSeq, b.releaseSeq = h.Gen, 0, 0
}
state := filesState(b.Timers, b.Files)
- if state == b.published {
+ if state == b.published && w.objectCurrent(b, b.Files) {
return
}
+ if !w.ensureObject(b, h, b.Files) {
+ return // the object first: a manifest names only durable objects (§5.6)
+ }
b.pubSeq++
w.issued++
bl := &blob{Kind: "files", Inc: h.Inc, Gen: h.Gen, Seq: b.pubSeq, Files: b.Files.clone(), Timers: append([]int(nil), b.Timers...), Base: w.issued}
@@ -1710,9 +1845,12 @@ func (w *world) filesLive(c *claim, k string) bool {
// filesLiveSet is the files live set itself, which the residue oracle
// reads whatever is mutated.
func (w *world) filesLiveSet(c *claim, k string) bool {
- if c == nil || c.Holder == nil || !isManifestKey(k) {
+ if c == nil || c.Holder == nil || !isFilesKey(k) {
return false
}
+ if !isManifestKey(k) {
+ return w.objectReferenced(c, k)
+ }
loc := c.Holder.Location
if !strings.HasPrefix(k, loc) {
return false
@@ -1766,3 +1904,203 @@ func (w *world) regionMin(r int) int {
}
return w.perRegion
}
+
+// ---------- acknowledgements (rev 4, Astra R1-4) ----------
+
+// acked is requireAcks: every current participant has a durable
+// exec_ack.<box> entry in the journal. It reads only the claim; whether a
+// participant's controller is ready now is the participant's own check
+// before its effects (recordAck, and role()'s lease), never the barrier.
+func acked(c *claim) bool {
+ for _, p := range c.Op.Parts {
+ if !contains(c.Op.Acks, p) {
+ return false
+ }
+ }
+ return true
+}
+
+// recordAck is AcknowledgeExecution: a participant whose controller is
+// ready (its execution lease current, role() renews it first) journals
+// exec_ack.<box> once; the entry outlives a lost lease or an agent
+// restart (1b-next rev 2 finding 10: the claim keeps the first
+// acknowledgement). A box replaced out of the participants keeps its
+// entry, which the barrier no longer reads.
+func (w *world) recordAck(b *box, c *claim) {
+ if c == nil || c.Op.Kind != "relocating" || !contains(c.Op.Parts, b.ID) || contains(c.Op.Acks, b.ID) || contains(c.Lost, b.ID) {
+ return
+ }
+ if b.ExecUntil == 0 || w.localNow(b) > b.ExecUntil {
+ return // no ready controller: nothing to acknowledge with
+ }
+ id := c.Op.ID
+ // the controller's durable record exists before the acknowledgement
+ // (1b-next Task 7: journaled before the open), and stays until a
+ // successful scoped cleanup clears it (finishParticipation)
+ b.execRecord, b.cleanupPending = id, true
+ if w.cas(c, func(n *claim) bool {
+ ok := sameOp(n.Op.ID, id) && n.Op.Kind == "relocating" && contains(n.Op.Parts, b.ID) && !contains(n.Op.Acks, b.ID)
+ if ok {
+ n.Op.Acks = append(n.Op.Acks, b.ID)
+ }
+ return ok
+ }) {
+ w.logf("relocation %s: box %d acknowledged execution", id, b.ID)
+ }
+}
+
+// finishParticipation is the participant's Sweep and Finish (1b-next
+// Task 6 and 7, rulings 20): once the claim no longer runs the operation
+// with this box as a live participant, the scoped cleanup runs (it
+// retires an unopened P's local install of that operation, and is a
+// no-op on a box that admitted a writer or serves the host); only when it
+// succeeds is the durable record cleared (rule reloc-clear-after-cleanup;
+// the mutation clears it after a failed cleanup, as rev 4 of 1b-next
+// did). A box whose cleanup fails keeps the record, and root refuses an
+// executable update while it exists (claim §5.10).
+func (w *world) finishParticipation(b *box, c *claim) {
+ if b.execRecord == "" || c == nil {
+ return
+ }
+ if c.Op.Kind == "relocating" && c.Op.ID == b.execRecord && contains(c.Op.Parts, b.ID) && !contains(c.Lost, b.ID) {
+ return // still a participant
+ }
+ if b.cleanupFails {
+ w.stats["relocation controller: scoped cleanup failed"]++
+ if on("reloc-clear-after-cleanup") {
+ return // the record stays, and so does the refusal of updates
+ }
+ b.execRecord = "" // the mutation: cleared anyway
+ return
+ }
+ if f := b.Files; f != nil && f.Gen != "" && c.Holder != nil && f.Inc == c.Holder.Inc && strings.HasPrefix(f.Gen, "P") && !(c.Op.Kind == "serving" && c.Op.Exec == b.ID) && f.Gen != c.Holder.Gen && !w.ancestorGen(c.Holder.Location, c.Holder.Gen, f.Gen) {
+ w.retireArtifacts(b, f.Inc, f.Stamp) // an unopened P's local install of the finished operation
+ }
+ b.execRecord, b.cleanupPending = "", false
+ w.stats["relocation controller: record cleared"]++
+}
+
+// updateExecutable is an executable update on b (claim §5.10, region HA
+// §5.10): root refuses it while a relocation controller's record exists.
+// INV-29 is the oracle: an update never runs on a box whose relocation
+// participation's scoped cleanup has not succeeded.
+func (w *world) updateExecutable(b *box) bool {
+ if b.Destroyed {
+ return false
+ }
+ if b.execRecord != "" {
+ w.stats["executable update refused: controller record"]++
+ return false
+ }
+ if b.cleanupPending {
+ w.violate("INV-29: box %d updated its executable while a relocation's scoped cleanup had not succeeded (claim §5.10)", b.ID)
+ }
+ w.stats["executable updated"]++
+ return true
+}
+
+// latestObservation is the newest observed_at the operation's journal
+// retains (JournalPin checks every fence.<box>.observed_at, a release or
+// not: rev 4, Astra R1-5); FenceAt covers a converted move's fence.
+func latestObservation(op opState) int {
+ at := op.FenceAt
+ for _, o := range op.Observed {
+ if o > at {
+ at = o
+ }
+ }
+ return at
+}
+
+// observedAt is when the fence record says the box was observed
+// terminated (FenceTargetWithoutSpare's deadline basis); the mutation of
+// reloc-deadline-from-observed-at uses the termination itself, which no
+// worker may have seen yet.
+func observedAt(x *box) int {
+ if !on("reloc-deadline-from-observed-at") {
+ return x.TerminatedAt
+ }
+ return x.FenceObservedAt
+}
+
+// objectReferenced: a live manifest (the chain's newest in any region,
+// the pinned one, holder.generation's or P's) names the object, or a
+// live box's files directory does (its next manifest will): the files
+// live set is every object the live manifests reference (claim §6.2a).
+func (w *world) objectReferenced(c *claim, k string) bool {
+ loc := c.Holder.Location
+ names := func(m *blob) bool { return m != nil && m.Files != nil && m.Files.Obj == k }
+ for r := 0; r < nRegions; r++ {
+ if _, _, _, _, m := w.newestManifest(r, loc, c.Holder.Gen); names(m) {
+ return true
+ }
+ bk := w.buckets[r]
+ for _, g := range []string{c.Holder.Gen, c.Op.Prepared} {
+ if g == "" && g != c.Holder.Gen {
+ continue
+ }
+ for _, mk := range bk.keys(dataKey(loc, g, "files/m-")) {
+ if v := bk.current(mk); v != nil && names(v.B) {
+ return true
+ }
+ }
+ }
+ }
+ if c.Op.Kind == "relocating" && c.Op.Pin != nil && c.Op.Pin.ManKey != "" {
+ if names(w.pinnedManifestOf(c.Op.SrcRegion, c.Op.Pin)) {
+ return true
+ }
+ }
+ for _, b := range w.liveBoxes() {
+ if b.Files != nil && b.Files.Obj == k {
+ return true
+ }
+ }
+ return false
+}
+
+// keepFilesLive wraps a moves park's keep (a push's whole-location park,
+// a landing's install park, a rollback's return park) so it never
+// archives the files live set (finding F4, rev 4: claim §6.2a's live set
+// binds every park; moves' parks predate region HA's files tree). Only
+// the two-box shape has files keys, so the one-box shape is unchanged.
+// The mutation of files-live-set-moves-parks archives them with the rest.
+func (w *world) keepFilesLive(keep func(string) bool) func(string) bool {
+ return func(k string) bool {
+ return keep(k) || on("files-live-set-moves-parks") && w.filesLive(w.claim, k)
+ }
+}
+
+// renewController is a relocation controller's own renewal loop (hostexec
+// renews its lease on its own clock, not when the regional worker happens
+// to run; rev 4): an agent that is up and holds a current lease for a
+// relocation it participates in keeps it current. A lease is still lost
+// when the agent is down, paused, frozen or partitioned past the TTL,
+// which is the scoped cleanup's case. Two-box shape only.
+func (w *world) renewController(b *box) {
+ c := w.claim
+ if c == nil || c.Op.Kind != "relocating" || !contains(c.Op.Parts, b.ID) || !w.agentUp(b) || b.Partitioned {
+ return
+ }
+ if b.ExecUntil > 0 && w.localNow(b) <= b.ExecUntil && b.ExecUntil-w.localNow(b) < execRenew {
+ b.ExecUntil = w.localNow(b) + execTTL
+ w.execLease[b.ID] = execLease{Op: c.Op.ID, Expires: w.now + execTTL}
+ }
+}
+
+// preparedManifestHere is moves rev 7.22 §7.6's one install routine (L16,
+// L17; claim R18-1) for a landing or a return in the two-box shape (rev
+// 4, claim §11 "move landing, source cleanup, then target failure with an
+// unchanged file"): the generation's prepared files manifest, create-only
+// at sequence 0 in this region, naming objects in this region's bucket
+// (an unchanged file the host carried from another region is uploaded
+// here first), acknowledged before the install's CAS. Without it a
+// target lost right after its commit would leave no files in its region.
+func (w *world) preparedManifestHere(b *box, c *claim, gen string) bool {
+ h := c.Holder
+ if !w.ensureObject(b, h, b.Files) {
+ return false
+ }
+ bl := &blob{Kind: "files", Inc: h.Inc, Gen: gen, Seq: 0, Files: b.Files.clone(), Timers: append([]int(nil), b.Timers...), Text: c.Op.ID, Base: w.issued}
+ return w.ackedPut(b, manifestKey(h.Location, gen, 0), bl, true)
+}
diff --git a/internal/sim/hostlife/regionha_witness_test.go b/internal/sim/hostlife/regionha_witness_test.go
index 2523a7f01..575717734 100644
--- a/internal/sim/hostlife/regionha_witness_test.go
+++ b/internal/sim/hostlife/regionha_witness_test.go
@@ -822,3 +822,148 @@ func wDeadTargetBound(t *testing.T, w *world) {
w.servingOn(t, from)
w.finish(t)
}
+
+// rev 4: Astra's round 1, claim §11's witnesses
+
+// wMoveUnchangedFile (files-objects-region-bound; claim §11 "move
+// landing, source cleanup, then target failure with an unchanged file"):
+// the host moves to region 1 carrying a file unchanged since region 0
+// published it; the landing's prepared manifest and the target's
+// publications name an object in region 1's bucket; the source region is
+// cleaned up; the target is terminated and the failover in region 1 pins
+// that object. Without the region binding the manifests keep naming
+// region 0's object, which no failover in region 1 can pin (INV-3).
+func wMoveUnchangedFile(t *testing.T, w *world) {
+ w.twoBox()
+ src := w.boxes[0]
+ w.serve(src, 1, app0)
+ w.running(t, src)
+ w.published(t, src)
+ w.moveTo(1)
+ if !w.servingOn(t, nil) {
+ return
+ }
+ e := w.execBox(w.claim)
+ if e.Region != 1 {
+ w.violate("witness: the move did not land in region 1: %s", w.state())
+ return
+ }
+ w.reach(t, 1200, "the source region cleaned up", func() bool { return len(w.claim.Obl) == 0 }, w.actAll(), w.recoveryPass)
+ w.published(t, e)
+ w.terminate(e)
+ w.reach(t, 3000, "serving again after the target's failover", func() bool {
+ c := w.claim
+ return c.Op.Kind == "serving" && c.Op.Exec >= 0 && c.Op.Exec != e.ID
+ }, w.actAll(), w.recoveryPass, w.operator, w.wakeExec)
+ w.finish(t)
+}
+
+// wOriginsAfterTrim (files-origins-referenced; 1b-next rev 12 finding 1,
+// R11-1): a file published in the origin generation never changes while
+// ten cold admissions follow, so holder.admissions is trimmed past it
+// while the live manifest still names the origin's object. The failover
+// pins that object; authorized from the trimmed admissions alone, the pin
+// is refused for ever (INV-3).
+func wOriginsAfterTrim(t *testing.T, w *world) {
+ w.twoBox()
+ from := w.boxes[0]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.published(t, from)
+ for i := 0; i < 10; i++ {
+ w.crash(from)
+ if !w.reach(t, 400, "a fresh cold admission serving", func() bool {
+ return hasMain(from) && from.open == nil && len(w.claim.Obl) == 0
+ }, w.actAll(), w.recoveryPass, func() { w.wake(from) }) {
+ return
+ }
+ }
+ for _, e := range w.claim.Holder.Admissions {
+ if e.Gen == "" || e.Predecessor.Gen == "" {
+ w.violate("witness: the origin generation is still in holder.admissions: %s", w.state())
+ return
+ }
+ }
+ w.terminate(from)
+ w.servingOn(t, w.boxes[3])
+ w.finish(t)
+}
+
+// wClearAfterCleanup (reloc-clear-after-cleanup; claim §11 "a failed
+// cleanup leaving a controller record that blocks executable updates"):
+// the drain completes; the former source's participation is over, but its
+// scoped cleanup fails, so its controller record stays and root refuses
+// an executable update; once the cleanup succeeds the record is cleared
+// and the update runs. Without the rule the record is cleared after the
+// failed cleanup and the update runs over it (INV-29).
+func wClearAfterCleanup(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.drainTo(t, to)
+ from.cleanupFails = true
+ if !w.servingOn(t, to) {
+ return
+ }
+ w.drive(20, never, w.act(from))
+ if w.updateExecutable(from) {
+ if on("reloc-clear-after-cleanup") {
+ w.violate("witness: an update ran while the controller record existed")
+ }
+ return
+ }
+ from.cleanupFails = false
+ w.reach(t, 100, "the record cleared", func() bool { return from.execRecord == "" }, w.act(from))
+ if !w.updateExecutable(from) {
+ w.violate("INV-3 (witness): the update stayed refused after a successful cleanup")
+ }
+ w.finish(t)
+}
+
+// wCommittedLandingTargetLost (fence-converts-committed-landing; claim
+// §11 "automatic target loss racing the record commit CAS", finding F5):
+// the record commits the landing, and the target is terminated before
+// the claim follows; the fence makes the committed landing a failover in
+// region 1, which serves; without it nothing ever finishes it (INV-3).
+func wCommittedLandingTargetLost(t *testing.T, w *world) {
+ w.twoBox()
+ src := w.boxes[0]
+ w.serve(src, 1, app0)
+ w.running(t, src)
+ w.published(t, src)
+ w.moveTo(1)
+ tgt := w.boxByID(w.claim.Op.Tgt)
+ if !w.stepUntil(t, 1500, "the record committed, the claim not yet", func() bool {
+ c := w.claim
+ return w.rec != nil && w.rec.MoveState == "committed" && c.Op.Kind == "landing" && !c.Op.Committed
+ }) {
+ return
+ }
+ w.terminate(tgt)
+ w.servingOn(t, nil)
+ w.finish(t)
+}
+
+// wPushKeepsLiveFiles (files-live-set-moves-parks; finding F4): a push
+// parks the whole location before installing its generation; the files
+// live set survives it, so a later failover pins the live manifest's
+// object. Without the rule the push archives them, and the failover can
+// never pin the host's files (INV-3).
+func wPushKeepsLiveFiles(t *testing.T, w *world) {
+ w.twoBox()
+ from := w.boxes[0]
+ w.serve(from, 1, app0)
+ w.running(t, from)
+ w.published(t, from)
+ w.userPush()
+ if !w.reach(t, 800, "the push served", func() bool { c := w.claim; return c.Op.Kind == "serving" && c.Holder.Gen != "" && len(c.Obl) == 0 }, w.actAll(), w.recoveryPass, func() { w.wake(from) }) {
+ return
+ }
+ w.drive(10, never, w.act(from))
+ w.completeDue(true)
+ w.terminate(from)
+ w.servingOn(t, w.boxes[3])
+ w.finish(t)
+}
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go
index 7af7a8064..598d7ba0a 100644
--- a/internal/sim/hostlife/rules_test.go
+++ b/internal/sim/hostlife/rules_test.go
@@ -186,4 +186,15 @@ var allRules = []string{
"reloc-dead-target-bound", // 1b-next rev 18 finding 2 (FenceTargetWithoutSpare): a dead target with no spare is fenced into stuck ReleaseDeadline after its observed_at; the mutation waits for ever (INV-3)
"reloc-abandoned-prepared", // 1b-next ruling 27, rev 18 finding 3 (abandonPrepared, abandoned_prepared): every unopened P that is replaced or dropped, on every path, is parked at completion; the mutation records none (INV-10)
"files-pin-needs-key", // 1b-next ruling 25 as rev 18 amended it (F2, L21): a nonempty database with an admitted writer pins only a manifest carrying instance.key, else stuck; the mutation pins it keyless and the install mints a fresh key (INV-4)
+ // Astra's round 1 on this plan (rev 4)
+ "reloc-stuck-refuses-admission", // 1b-next relocationAdmission's stuck check (ruling 9, R1-16): a stuck operation admits no writer, the live source's ordinary wake included; the mutation admits it
+ "ha-sole-box-eligibility", // region HA §2 with region HA on: the last remaining box of a region is checked for enrollment, key, executable, lost and park-local like any other; the mutation keeps the one-box shortcut
+ "reloc-pin-delay-every-fence", // JournalPin checks every retained fence observation, after a release too (a target replaced after the source's release); the mutation skips the delay once a release exists
+ "reloc-deadline-from-observed-at", // FenceTargetWithoutSpare's deadline runs from the fence record's observed_at; the mutation runs it from the termination, which no worker saw
+ "tie-hash-host", // claim P5-D2, 1b-next R18-2 (ExecutorChooser hashes the record's host): the tie-break is SHA-256(host + "\x00" + box); the mutation hashes a constant name
+ "files-origins-referenced", // 1b-next rev 12 finding 1 (filesPin's referencedOrigins, R11-1): the pin authorizes the generations the pinned manifest's objects lie in, never only those the trimmed admission list names; the mutation refuses an unchanged file's old origin and the failover waits for ever (INV-3)
+ "files-objects-region-bound", // §5.6 region binding (filesPin: the object in this region's data bucket): a host that came from another region uploads its unchanged files again before naming them; the mutation keeps naming the source region's objects and the failover can never pin (INV-3)
+ "files-live-set-moves-parks", // finding F4 (rev 4), claim §6.2a: moves' parks (a push's whole-location park, a landing's install park, a rollback's return park) keep the files live set; the mutation archives it with the rest and the next failover can never pin the live files (INV-3)
+ "reloc-clear-after-cleanup", // 1b-next ruling 20 and rev 6 R4-5 (Finish clears the record only after a successful scoped cleanup): a failed cleanup keeps the controller record, which blocks executable updates (claim §11's witness); the mutation clears it and an update runs over the unfinished cleanup (INV-29)
+ "fence-converts-committed-landing", // finding F5 (rev 4; claim §11 "automatic target loss racing the record commit CAS"): a landing whose record committed and whose target is then terminated becomes a failover in the target's region; the mutation leaves it to the operator, and it never completes (INV-3)
}
diff --git a/internal/sim/hostlife/sim_test.go b/internal/sim/hostlife/sim_test.go
index da4a709fb..acce63db3 100644
--- a/internal/sim/hostlife/sim_test.go
+++ b/internal/sim/hostlife/sim_test.go
@@ -401,7 +401,8 @@ func (w *world) checkStep() {
// enrolled box that is not on lost; execution authority never names a
// fenced or unenrolled reference
if c := w.claim; c != nil && c.Op.Kind == "relocating" && c.Op.Exec >= 0 {
- if e := w.boxByID(c.Op.Exec); e == nil || !e.Enrolled || contains(c.Lost, e.ID) {
+ released := c.Op.Release != nil && c.Op.Release.Box == c.Op.Exec // a released executor fenced before PinAfterRelease (FenceSource after the release, rev 4)
+ if e := w.boxByID(c.Op.Exec); e == nil || !e.Enrolled || contains(c.Lost, e.ID) && !released {
w.violate("INV-27: relocation %s names box %d as op.exec, which is unenrolled or lost", c.Op.ID, c.Op.Exec)
}
}
diff --git a/internal/sim/hostlife/twobox_test.go b/internal/sim/hostlife/twobox_test.go
index 0d6c828e6..34a2f02dd 100644
--- a/internal/sim/hostlife/twobox_test.go
+++ b/internal/sim/hostlife/twobox_test.go
@@ -1,11 +1,14 @@
package hostlife
import (
+ "crypto/sha256"
"fmt"
"hash/fnv"
"os"
)
+var _ = fnv.New32a // rev 1's stand-in hash, kept importable
+
// Two boxes per region (region HA spec 1 §9 L18; claim rev 6.30 §11).
//
// boxesPerRegion is the world's shape: one box per region, today's model,
@@ -50,10 +53,19 @@ func (w *world) addBox(r int) *box {
// tieHash is the model's stand-in for claim P5-D2's SHA-256 of
// host + "\x00" + box reference: a fixed function of the reference, never
// a draw, so the choice is the same on every replay.
-func tieHash(id int) uint32 {
- h := fnv.New32a()
- h.Write([]byte(fmt.Sprintf("%s\x00%d", host, id)))
- return h.Sum32()
+func tieHash(id int) uint32 { return tieHashFor(host, id) }
+
+// tieHashFor is claim P5-D2's tie-break for the named host (rev 4, Astra
+// R1-8 and 1b-next R18-2): the first four bytes of SHA-256(host + "\x00" +
+// box reference), so two hostnames on equal loads may choose different
+// boxes. The mutation of tie-hash-host hashes a constant name, as the
+// round-18 chooser bug did.
+func tieHashFor(hostName string, id int) uint32 {
+ if !on("tie-hash-host") {
+ hostName = host
+ }
+ sum := sha256.Sum256([]byte(fmt.Sprintf("%s\x00%d", hostName, id)))
+ return uint32(sum[0])<<24 | uint32(sum[1])<<16 | uint32(sum[2])<<8 | uint32(sum[3])
}
// hostsOn counts the instances of app placed on b (app "" counts every
@@ -105,8 +117,16 @@ func (w *world) chooseBox(r int, app string) *box { return w.chooseBoxExcept(r,
// world; the caller records the choice in the claim's intent and never
// substitutes (rule create-choice-once).
func (w *world) chooseBoxExcept(r int, app string, except ...int) *box {
+ return w.chooseBoxFor(host, r, app, except...)
+}
+
+// chooseBoxFor is choose() for the named host (its tie-break hashes that
+// name). The sole-box shortcut, today's behaviour, is the one-box shape's
+// alone: with region HA on, one remaining candidate is checked like any
+// other (rev 4, Astra R1-3; rule ha-sole-box-eligibility).
+func (w *world) chooseBoxFor(hostName string, r int, app string, except ...int) *box {
bs := w.regionBoxes(r)
- if len(bs) <= 1 {
+ if len(bs) <= 1 && (w.perRegion <= 1 || !on("ha-sole-box-eligibility")) {
if len(bs) == 0 || contains(except, bs[0].ID) {
return nil
}
@@ -125,7 +145,7 @@ func (w *world) chooseBoxExcept(r int, app string, except ...int) *box {
if on("reloc-enrolled-only") && !b.Enrolled {
continue
}
- key := [3]int{w.hostsOn(b, app), w.hostsOn(b, ""), -int(tieHash(b.ID))}
+ key := [3]int{w.hostsOn(b, app), w.hostsOn(b, ""), -int(tieHashFor(hostName, b.ID))}
if best == nil || key[0] < bestKey[0] || key[0] == bestKey[0] && (key[1] < bestKey[1] || key[1] == bestKey[1] && key[2] < bestKey[2]) {
best, bestKey = b, key
}
diff --git a/internal/sim/hostlife/witness_test.go b/internal/sim/hostlife/witness_test.go
index 5b7fa7720..a93c223cf 100644
--- a/internal/sim/hostlife/witness_test.go
+++ b/internal/sim/hostlife/witness_test.go
@@ -241,6 +241,17 @@ var witnesses = []witness{
{"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},
// rev 3: 1b-next rev 17 to 19's fixes for the model's findings, and the L21 window
+ // rev 4: Astra's round 1 and claim §11's additional witnesses
+ {"files-objects-region-bound", "files-objects-region-bound", "INV-3",
+ "claim §11: the host moves to region 1 with a file unchanged since region 0 published it, the source region is cleaned up, then the target dies; the failover pins an object in region 1's bucket, or, without the region binding, can never pin region 0's", wMoveUnchangedFile},
+ {"files-origins-referenced", "files-origins-referenced", "INV-3",
+ "R11-1: a file unchanged through ten cold admissions, holder.admissions trimmed past its origin; the failover pins the origin's object, or, authorized from the trimmed list, is refused for ever", wOriginsAfterTrim},
+ {"reloc-clear-after-cleanup", "reloc-clear-after-cleanup", "INV-29",
+ "claim §11: the former source's scoped cleanup fails after the drain; its controller record stays and root refuses an executable update until the cleanup succeeds; without the rule the update runs over the unfinished cleanup", wClearAfterCleanup},
+ {"fence-converts-committed-landing", "fence-converts-committed-landing", "INV-3",
+ "claim §11, F5: the record commits a landing and its target dies before the claim follows; the fence makes it a failover in the target's region, or nothing ever finishes it", wCommittedLandingTargetLost},
+ {"files-live-set-moves-parks", "files-live-set-moves-parks", "INV-3",
+ "F4: a push parks the whole location; the files live set survives, so the later failover pins the live object, or can never pin the host's files", wPushKeepsLiveFiles},
{"files-pin-needs-key", "files-pin-needs-key", "INV-4",
"L21, explicit: the pre-start manifest copy landed without instance.key, the app minted the key after starting and replicated rows sealed with it, and the box died before the keyed manifest landed; with the rule recovery is stuck and installs nothing, without it the keyless pin installs a fresh key over the sealed rows", wL21Window},
{"reloc-no-spare-bound", "reloc-no-spare-bound", "INV-3",
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go
index cfb51f788..a1b97b293 100644
--- a/internal/sim/hostlife/world_test.go
+++ b/internal/sim/hostlife/world_test.go
@@ -259,6 +259,11 @@ type filesDir struct {
Env int // admin token identity in <host>.env
Done bool // markers written last
Stamp int // the installation stamp that produced it (claim rev 6.5 §6.3)
+ // Obj is the files object that holds this content (region HA §5.6,
+ // rev 4): written by the generation that first held it, in ObjRegion's
+ // bucket; an unchanged file keeps naming it generations later
+ Obj string
+ ObjRegion int
}
func (f *filesDir) clone() *filesDir {
@@ -360,8 +365,15 @@ type box struct {
FailSettle bool // the app passes one readiness check, works, then fails
Nested bool // nested layout: children survive an agent crash
// region HA (two boxes per region, L18)
- Terminated bool // the cloud terminated the instance (H1): it never runs again and never returns; a Destroyed box that is not Terminated is stopped and may start again with its disk
- TerminatedAt int
+ Terminated bool // the cloud terminated the instance (H1): it never runs again and never returns; a Destroyed box that is not Terminated is stopped and may start again with its disk
+ TerminatedAt int
+ // a relocation controller's durable record (hostexec, 1b-next Task 7):
+ // the operation it belongs to, whether its scoped cleanup is still owed
+ // (ground truth for INV-29), and the fault that makes the cleanup fail
+ execRecord string
+ cleanupPending bool
+ cleanupFails bool
+ FenceObservedAt int // the box-fences record's observed_at: when a worker first saw it terminated; 0 no record (rev 4)
Enrolled bool // the roster holds an active entry with its proven cloud identity (H11); a replacement box is enrolled automatically enrollDelay steps after it launches (H16, H17)
EnrollAt int
DeleteOnTermination bool // every volume the box had attached carried the flag while it ran (§5.9): decides only the automatic discharge of its park-local chores (H19), never a fence
@@ -826,6 +838,8 @@ type opState struct {
Attempts int // probe_attempts.<op.exec>: counted before each attempt, at most probeTries (ruling 19)
PreparedOpened string // prepared_opened: journaled by the admission that opened P (rev 12 S1)
Resolved bool // an operator's resolve (model ruling 13): the source is eligible as a target again, and an empty listing is accepted
+ Observed []int // every fence.<box>.observed_at the operation consumed, kept through replacements (rev 4)
+ Acks []int // exec_ack.<box>: the participants that acknowledged, durable (rev 4)
Abandoned []string // abandoned_prepared: every unopened P the operation replaced or dropped, parked at serving (1b-next ruling 27)
// journal
@@ -1222,6 +1236,8 @@ func (c *claim) clone() *claim {
n.Op.InstalledOn = append([]int(nil), c.Op.InstalledOn...)
n.Op.Discharged = append([]int(nil), c.Op.Discharged...)
n.Op.Abandoned = append([]string(nil), c.Op.Abandoned...)
+ n.Op.Acks = append([]int(nil), c.Op.Acks...)
+ n.Op.Observed = append([]int(nil), c.Op.Observed...)
if r := c.Op.Release; r != nil {
cp := *r
cp.Files = r.Files.clone()