carlos / platform Public

sim: hostlife: claim rev 6 and the moves side of joint round 3 (rev 7.4)

Claim rev 6 (3ea3f90c), encoded:
- R3-2: orphan cleanup is a console plan with its observations in order
  (record, then the claim fresh, then DeleteIfMatch) and pauses between
  them; rule orphan-order reads the claim first. Record ETags are now
  unique per write. The cleanup asserts it never deletes the record of
  the claim's holder or creating incarnation.
- R3-3: the local retired marker is {incarnation, op}: it refuses a
  writer at or before the fence's op, and a new installation of the
  incarnation replaces it under the guard on a fresh claim read. Plans
  carry the op sequence they validated under. Rule fence-by-installation;
  the mutation is rev 5.2's whole-incarnation marker. The unmutated
  round-trip witness (UK->US->UK) now serves on UK again, running.
- R3-4: the at-rest sweep discovers locations from the namespace;
  retired_incarnations is capped at 32 and anti-reuse only. Rule
  sweep-namespace; its witness runs 33 recreates and lands a held
  replication PUT of incarnation 1.

Moves side of round 3:
- R3-1: restore is a chain, not a union: generation.json names the
  snapshot's version id; LTX objects continue the chain only if their
  history extends it; prefixes are skipped; a conflict refuses. The
  install manifest records the snapshot's version (op.BaseVer).
- R3-5: the edge routes on the row: a public request reaching a landing
  target before the commit is INV-5. commit-before-open has its own
  violation and witness now.
- R3-7: budget_test.go states T11's arithmetic; the attempt count
  depends on the admitted budget, and the test shows rev 7.3's formula
  refused every default move.
- R2-5: a witness for recovery-current-inputs on a dirty rollback after
  a bundle change.
- R3-6: mutate.sh fails on orchestration errors, requires one
  well-formed verdict per mutation, and treats missing witness output
  for a rule that has witnesses as an ERROR.
- Witness evidence: each witness names its failure; the gate runs every
  witness in place, under every proper subset of its mutation set (must
  hold) and under the whole set (must violate with the named failure).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 9108f9583a435cc0bd2c36ea8b37ecf7f0fc982d parent dc7f2a6
11 files changed, +695 −148
  • internal/sim/hostlife/background_test.go +14 −6
  • internal/sim/hostlife/budget_test.go +69 −0
  • internal/sim/hostlife/console_test.go +90 −22
  • internal/sim/hostlife/expected-outcomes.txt +0 −1
  • internal/sim/hostlife/moves_test.go +88 −28
  • internal/sim/hostlife/mutate.sh +34 −4
  • internal/sim/hostlife/protocol_test.go +65 −16
  • internal/sim/hostlife/rules_test.go +3 −0
  • internal/sim/hostlife/sim_test.go +1 −1
  • internal/sim/hostlife/witness_test.go +268 −67
  • internal/sim/hostlife/world_test.go +63 −3
diff --git a/internal/sim/hostlife/background_test.go b/internal/sim/hostlife/background_test.go
index 40fed13a1..120d2ced5 100644
--- a/internal/sim/hostlife/background_test.go
+++ b/internal/sim/hostlife/background_test.go
@@ -58,7 +58,17 @@ func (w *world) idleStop(b *box) {
// wake: a request reaches this box's edge for the host.
func (w *world) wake(b *box) {
- if !w.agentUp(b) || b.Row == nil || b.Row.Status != "ready" || hasMain(b) {
+ if !w.agentUp(b) || b.Row == nil || b.Row.Status != "ready" {
+ return
+ }
+ // the edge routes on the row (edge.go:595): a ready row receives the
+ // public request whether or not the instance already runs. INV-5: no
+ // public delivery to a landing target before the commit (R3-5)
+ if c := w.claim; c != nil && c.Op.Kind == "landing" && !c.Op.Committed && c.Op.Tgt == b.ID {
+ w.violate("INV-5: a public request reached the landing target, box %d, before the commit of %s", b.ID, c.Op.ID)
+ return
+ }
+ if hasMain(b) {
return
}
if b.Old {
@@ -196,9 +206,7 @@ func (w *world) reconcileClaimed(b *box, c *claim) {
}
if b.Row == nil && c.Op.Kind == "serving" {
b.Row = &row{Inc: h.Inc, App: h.App, Status: "ready"}
- if b.Retired != nil {
- delete(b.Retired, h.Inc)
- }
+ delete(b.Fence, h.Inc) // the serving executor, on a fresh read: the fence is replaced
}
} else if b.Row != nil && !(on("participant-rows") && w.ownsLocalObligation(c, b)) {
w.retireRow(b)
@@ -225,7 +233,7 @@ func (w *world) passPlan(b *box, kind string) {
if kind == "config" && !b.HasKey {
return
}
- b.plans = append(b.plans, &plan{Kind: kind, Inc: b.Row.Inc, App: b.Row.App, Ver: w.cfgVer[b.Row.App], At: w.now + w.pauseLen()})
+ b.plans = append(b.plans, &plan{Kind: kind, Inc: b.Row.Inc, App: b.Row.App, Ver: w.cfgVer[b.Row.App], At: w.now + w.pauseLen(), Seq: w.nextID})
}
// passExec runs one independent process's write. It needs the box running,
@@ -249,7 +257,7 @@ func (w *world) passExec(b *box) {
return
}
c := w.claim
- if c == nil || c.Holder == nil || c.Holder.Inc != p.Inc || b.Row == nil || b.Row.Inc != p.Inc || b.Retired[p.Inc] {
+ if c == nil || c.Holder == nil || c.Holder.Inc != p.Inc || b.Row == nil || b.Row.Inc != p.Inc || w.fenced(b, p.Inc, p.Seq) {
return
}
if p.Kind == "config" && p.Ver != w.cfgVer[p.App] {
diff --git a/internal/sim/hostlife/budget_test.go b/internal/sim/hostlife/budget_test.go
new file mode 100644
index 000000000..7208e9c7c
--- /dev/null
+++ b/internal/sim/hostlife/budget_test.go
@@ -0,0 +1,69 @@
+package hostlife
+
+import "testing"
+
+// The readiness arithmetic of moves spec §7.3 T11 (rev 7.4, R3-7), in
+// seconds. Rev 7.3's T11 required every one of three probe attempts, each
+// with its settle, to fit the window beside the fixed costs: 150 s of
+// probing alone, against a default window of 120 s, so no default move
+// was ever eligible. Rev 7.4 makes the permitted attempt count depend on
+// the admitted budget.
+type cutEstimate struct {
+ Stop, Checkpoint, Mirror, Archive, Copy, Restore, Render, Open float64
+ Probe, Settle float64 // one attempt
+}
+
+const (
+ cutWindowDefault = 120.0 // the operator's default cut window
+ probeAttemptsMax = 3 // §7.7: never more than three attempts
+ probeSeconds = 30.0
+ settleSeconds = 20.0
+)
+
+func (e cutEstimate) fixed() float64 {
+ return e.Stop + e.Checkpoint + e.Mirror + e.Archive + e.Copy + e.Restore + e.Render + e.Open
+}
+
+// attemptsAdmitted is T11 as rev 7.4 states it: the attempts permitted are
+// what the window leaves after the fixed costs, at most three; the
+// instance is eligible when that is at least one, and the probe loop
+// (§7.7) runs at most that many.
+func (e cutEstimate) attemptsAdmitted(window float64) int {
+ n := int((window - e.fixed()) / (e.Probe + e.Settle))
+ if n > probeAttemptsMax {
+ n = probeAttemptsMax
+ }
+ if n < 0 {
+ n = 0
+ }
+ return n
+}
+
+// fitsRev73 is T11 as rev 7.3 stated it: three attempts, always.
+func (e cutEstimate) fitsRev73(window float64) bool {
+ return e.fixed()+probeAttemptsMax*(e.Probe+e.Settle) <= window
+}
+
+func TestReadinessBudget(t *testing.T) {
+ // a tiny instance: every fixed cost at the floor of its measured rate
+ tiny := cutEstimate{Stop: 2, Checkpoint: 1, Mirror: 2, Archive: 2, Copy: 1, Restore: 1, Render: 1, Open: 5, Probe: probeSeconds, Settle: settleSeconds}
+ if tiny.fitsRev73(cutWindowDefault) {
+ t.Fatalf("rev 7.3's formula admits a tiny instance in the default window: R3-7 is stale")
+ }
+ if n := tiny.attemptsAdmitted(cutWindowDefault); n != 2 {
+ t.Fatalf("rev 7.4's formula admits %d attempts for a tiny instance in %.0f s, want 2", n, cutWindowDefault)
+ }
+ // the boundary: fixed costs of 70 s leave exactly one attempt; 71 s none
+ edge := cutEstimate{Stop: 70 - 5, Open: 5, Probe: probeSeconds, Settle: settleSeconds}
+ if n := edge.attemptsAdmitted(cutWindowDefault); n != 1 {
+ t.Fatalf("fixed costs of 70 s admit %d attempts, want 1", n)
+ }
+ edge.Stop++
+ if n := edge.attemptsAdmitted(cutWindowDefault); n != 0 {
+ t.Fatalf("fixed costs of 71 s admit %d attempts, want 0 (refused)", n)
+ }
+ // a generous window never permits more than three
+ if n := tiny.attemptsAdmitted(600); n != probeAttemptsMax {
+ t.Fatalf("a 600 s window admits %d attempts, want %d", n, probeAttemptsMax)
+ }
+}
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go
index 78167bcb9..b2c701142 100644
--- a/internal/sim/hostlife/console_test.go
+++ b/internal/sim/hostlife/console_test.go
@@ -18,6 +18,12 @@ type consolePlan struct {
Region int
Read bool // a create's registration re-read has passed
Stall int // the console is paused on this plan until then
+ // an orphan cleanup's observations (claim rev 6 §5.1 step 5): the
+ // record's ETag and incarnation, the claim read, and the verdict
+ RecVer int
+ RecInc int
+ Snap *claim
+ Orphan bool
// the claim's ETag as the create read it when it registered: its
// claim CAS is conditional on it (CC22)
SeenVer int
@@ -61,12 +67,12 @@ func (w *world) fixRecord() {
case "serving":
if b := w.execBox(c); b != nil && (w.rec.MoveID != "" || w.rec.Region != b.Region) {
w.rec.Region, w.rec.MoveID, w.rec.MoveState, w.rec.RollbackID = b.Region, "", "", ""
- w.rec.Ver++
+ w.rec.Ver = w.newVer()
}
case "moving":
if w.rec.MoveID == "" {
w.rec.MoveID, w.rec.MoveState = c.Op.ID, ""
- w.rec.Ver++
+ w.rec.Ver = w.newVer()
}
}
w.followRecord(c)
@@ -215,12 +221,15 @@ func (w *world) createSteps(p *consolePlan) bool {
return false // a pending chore, such as a sweep, finishes first (CC11)
}
if w.rec != nil {
- // the record key is occupied: an orphan is removed with
- // DeleteIfMatch and the create retries (CC17); anything else
+ // the record key is occupied: an orphan is removed by the
+ // cleanup of claim rev 6 §5.1 step 5, run by this console as
+ // its own plan, and the create retries (CC17); anything else
// refuses the create
- if c != nil && w.orphan(c) && on("cancelled-creates") {
- w.logf("create %s: removing an orphan record inc %d", p.OpID, w.rec.Inc)
- w.rec = nil
+ if c != nil && w.orphanOf(c, w.rec.Inc) && on("cancelled-creates") {
+ if !w.hasPlan("orphan", "create") {
+ w.logf("create %s: cleaning up an orphan record inc %d", p.OpID, w.rec.Inc)
+ w.console = append(w.console, &consolePlan{Kind: "orphan", Host: "create"})
+ }
return false
}
return abandon()
@@ -259,7 +268,7 @@ func (w *world) createSteps(p *consolePlan) bool {
return abandon()
}
if w.rec == nil && w.recPend == nil {
- rec := &record{Inc: c.Holder.Inc, App: c.Holder.App, Region: c.Op.NewRegion}
+ rec := &record{Ver: w.newVer(), Inc: c.Holder.Inc, App: c.Holder.App, Region: c.Op.NewRegion}
switch n := w.rng.Intn(10); {
case n < 7:
w.rec = rec
@@ -461,7 +470,7 @@ func (w *world) parkingObligations(n *claim, inc int) {
n.Obl = append(n.Obl, w.newObl(n, "park-local", -1, id, "", fmt.Sprint(inc), 0))
}
}
- n.Retired = addUniq(n.Retired, inc)
+ n.Retired = retire(n.Retired, inc)
}
// consoleStep runs the next step of one console plan, chosen at random:
@@ -491,10 +500,68 @@ func (w *world) runPlan(p *consolePlan) bool {
return w.deleteSteps(c)
case "purge":
return w.purgeSteps(p)
+ case "orphan":
+ return w.orphanSteps(p)
}
return true
}
+// hasPlan: a console plan of this kind, by this owner, is in flight.
+func (w *world) hasPlan(kind, owner string) bool {
+ for _, p := range w.console {
+ if p.Kind == kind && p.Host == owner {
+ return true
+ }
+ }
+ return false
+}
+
+// orphanSteps is the orphan cleanup of claim rev 6 §5.1 step 5 (R3-2) as
+// a console plan, so the console can pause between its observations and
+// its delete: read the record (ETag E, incarnation I); read the claim
+// fresh, afterwards; it is an orphan only if that claim names neither I
+// as holder nor a creating operation for I; DeleteIfMatch(E). The
+// mutation reads the claim first and the record second, and decides on
+// the stale claim: a successor that claimed and published in between is
+// then deleted.
+func (w *world) orphanSteps(p *consolePlan) bool {
+ switch p.Step {
+ case 0:
+ if on("orphan-order") {
+ if w.rec == nil {
+ return true
+ }
+ p.RecVer, p.RecInc = w.rec.Ver, w.rec.Inc
+ } else {
+ p.Snap = w.claim.clone()
+ }
+ p.Step = 1
+ return false
+ case 1:
+ if on("orphan-order") {
+ p.Orphan = w.orphanOf(w.claim, p.RecInc) // fresh, after the record
+ } else {
+ if w.rec == nil {
+ return true
+ }
+ p.RecVer, p.RecInc = w.rec.Ver, w.rec.Inc
+ p.Orphan = w.orphanOf(p.Snap, p.RecInc)
+ }
+ p.Step = 2
+ return false
+ default:
+ if !p.Orphan || w.rec == nil || w.rec.Ver != p.RecVer {
+ return true // 412, or nothing to do
+ }
+ if c := w.claim; c != nil && (c.Holder != nil && c.Holder.Inc == w.rec.Inc || c.Op.Kind == "creating" && c.Op.NewInc == w.rec.Inc) {
+ w.violate("claim/record: orphan cleanup deleted the record of incarnation %d, which the claim names", w.rec.Inc)
+ }
+ w.logf("orphan cleanup (%s): removed record inc %d", p.Host, w.rec.Inc)
+ w.rec = nil
+ return true
+ }
+}
+
// deleteSteps: record DeleteIfMatch, then parking with obligations, then
// free once every obligation is acknowledged (claim §5.2).
func (w *world) deleteSteps(c *claim) bool {
@@ -891,11 +958,13 @@ func (w *world) recoveryPass() {
return
}
// a late record PUT from a cancelled create: an orphan, never admitted
- // (C1); DeleteIfMatch when cancelled_creates names its incarnation
- // (claim rev 4 §5.1, J2-R2-7)
- if w.rec != nil && on("cancelled-creates") && w.orphan(c) {
- w.logf("recovery: removing an orphan record inc %d", w.rec.Inc)
- w.rec = nil
+ // (C1); removed by the ordered cleanup of claim rev 6 §5.1 step 5, as
+ // a plan of this console (J2-R2-7, R3-2)
+ if w.rec != nil && on("cancelled-creates") && w.orphanOf(c, w.rec.Inc) {
+ if !w.hasPlan("orphan", "recovery") {
+ w.logf("recovery: cleaning up an orphan record inc %d", w.rec.Inc)
+ w.console = append(w.console, &consolePlan{Kind: "orphan", Host: "recovery"})
+ }
return
}
switch c.Op.Kind {
@@ -927,7 +996,7 @@ func (w *world) recoveryPass() {
}
n.Holder, n.Op = nil, opState{}
n.Regions, n.Boxes = nil, nil
- n.Retired = addUniq(n.Retired, inc)
+ n.Retired = retire(n.Retired, inc)
n.Cancelled = append(n.Cancelled, cancelledCreate{Inc: inc, Until: w.now + 4*horizonH})
return true
}) {
@@ -965,20 +1034,19 @@ func (w *world) recoveryPass() {
// orphan: claim rev 5 §5.1 (CC17). A record whose incarnation is retired,
// or is not the claim's holder, is an orphan at any time; rev 4's
// cancelled_creates window is the mutation.
-func (w *world) orphan(c *claim) bool {
- r := w.rec
- if r == nil || c == nil {
- return false
+func (w *world) orphanOf(c *claim, inc int) bool {
+ if c == nil {
+ return false // no claim at all: a legacy record, adopted (§9), never an orphan
}
- if c.Holder != nil && c.Holder.Inc == r.Inc {
+ if c.Holder != nil && c.Holder.Inc == inc {
return false
}
- if c.Op.Kind == "creating" && c.Op.NewInc == r.Inc {
+ if c.Op.Kind == "creating" && c.Op.NewInc == inc {
return false
}
if !on("orphan-no-deadline") {
for _, x := range c.Cancelled {
- if x.Inc == r.Inc && w.now <= x.Until {
+ if x.Inc == inc && w.now <= x.Until {
return true
}
}
diff --git a/internal/sim/hostlife/expected-outcomes.txt b/internal/sim/hostlife/expected-outcomes.txt
index 2c97a5e1c..6cc69ce95 100644
--- a/internal/sim/hostlife/expected-outcomes.txt
+++ b/internal/sim/hostlife/expected-outcomes.txt
@@ -4,7 +4,6 @@
# is caught (the expectation is stale). Spec §14.2.5 gives the
# combination that catches each, or why none does.
claim-cas-opid missed caught with revalidate-before-effect
-commit-before-open missed caught with dirty-return
config-before-probe missed liveness only: a target that cannot render config fails its probe and rolls back
readiness-key missed liveness only: T2 avoids a pointless cut
readiness-quiet missed unreachable under claim rev 4: acknowledgements wait out their own horizon and CC11 holds
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go
index 11f1cfb90..0df9ad223 100644
--- a/internal/sim/hostlife/moves_test.go
+++ b/internal/sim/hostlife/moves_test.go
@@ -86,7 +86,7 @@ func (w *world) role(b *box, c *claim) {
if b.DB != nil {
inc = b.DB.Inc
}
- if w.localPark(b, inc) {
+ if w.localPark(b, inc, w.nextID) { // a quarantine retires every installation so far
b.Row = nil
}
}
@@ -176,7 +176,16 @@ func (w *world) snapshotBlobOf(b *box, c *claim, gen string) (*blob, *blob) {
db.Writes, db.SealKey = r.Writes, r.Key
}
if b.DB != nil {
- db.Writes = unionInts(db.Writes, b.DB.Writes)
+ // the final checkpoint: the local database continues the replica's
+ // history (R3-1); a local history that does not is never staged
+ switch {
+ case extends(b.DB.Writes, db.Writes):
+ db.Writes = append([]int(nil), b.DB.Writes...)
+ case extends(db.Writes, b.DB.Writes):
+ default:
+ w.logf("box %d snapshot: the local database does not continue the replica", b.ID)
+ w.stats["snapshot refused: conflicting history"]++
+ }
if b.DB.Key != 0 {
db.SealKey = b.DB.Key
}
@@ -333,7 +342,7 @@ func (w *world) moveSource(b *box, c *claim) {
}
if w.rec != nil && w.rec.Inc == c.Holder.Inc && w.rec.MoveID == op.ID && w.rec.MoveState == "" && op.StopAt != 0 {
w.rec.Region, w.rec.MoveState = op.TgtRegion, "landing"
- w.rec.Ver++
+ w.rec.Ver = w.newVer() // every record write has a fresh ETag
w.logf("flip %s: record names region %d", op.ID, op.TgtRegion)
}
}
@@ -633,7 +642,7 @@ func (w *world) rollback(c *claim, why string) {
return
}
r.MoveState, r.Region, r.RollbackID, r.Reason = "rolling_back", op.SrcRegion, w.newID("rb"), why
- r.Ver++
+ r.Ver = w.newVer()
w.logf("rollback %s: %s", op.ID, why)
w.stats["why: "+why+" @"+op.LandPhase]++
}
@@ -709,6 +718,16 @@ func (w *world) landTarget(b *box, c *claim) {
}
switch op.LandPhase {
case "checking":
+ // a new installation of this incarnation here replaces the local
+ // retired marker, under the guard, on a fresh read showing this
+ // operation current and this box its target (claim rev 6 §6.4,
+ // R3-3): a move back to a region the instance left
+ if _, fenced := b.Fence[c.Holder.Inc]; fenced && on("fence-by-installation") {
+ if fc, ok := w.readClaim(b); ok && fc.Op.ID == op.ID && fc.Op.Kind == "landing" && fc.Op.Tgt == b.ID {
+ b.Fence[c.Holder.Inc] = w.opSeq(op.ID) - 1
+ w.logf("box %d: the fence for inc %d now admits %s", b.ID, c.Holder.Inc, op.ID)
+ }
+ }
w.cas(c, func(n *claim) bool {
n.Boxes = addUniq(n.Boxes, b.ID)
n.Op.LandPhase = "install-park"
@@ -748,10 +767,11 @@ func (w *world) landTarget(b *box, c *claim) {
// only ever add an object to this generation
v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: dataKey(loc, op.ID, "base"), srcRegion: b.Region, srcKey: src.Key, srcVer: src.Ver})
if v > 0 {
- w.cas(c, func(n *claim) bool { n.Op.LandPhase = "published"; return sameOp(n.Op.ID, op.ID) })
+ // the install manifest records the snapshot's version (R3-1)
+ w.cas(c, func(n *claim) bool { n.Op.LandPhase, n.Op.BaseVer = "published", v; return sameOp(n.Op.ID, op.ID) })
}
case "published":
- v := w.publishGen(b, loc, op.ID, c.Holder.Inc)
+ v := w.publishGen(b, loc, op.ID, c.Holder.Inc, op.BaseVer)
if v > 0 {
w.cas(c, func(n *claim) bool { n.Op.LandPhase = "local"; return sameOp(n.Op.ID, op.ID) })
}
@@ -831,7 +851,7 @@ func (w *world) landTarget(b *box, c *claim) {
r := w.rec
if r != nil && r.MoveID == op.ID && r.MoveState == "landing" && w.now <= op.CutDeadline {
r.MoveState = "committed"
- r.Ver++
+ r.Ver = w.newVer()
w.logf("commit %s: record committed", op.ID)
if w.now > op.CutAt+cutBudget {
w.violate("INV-9: %s committed at %d, cut at %d (budget %d)", op.ID, w.now, op.CutAt, cutBudget)
@@ -1084,10 +1104,10 @@ func (w *world) recoverSource(b *box, c *claim) {
case "install-copy":
v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: dataKey(c.Holder.Location, op.RollbackID, "base"), srcRegion: b.Region, srcKey: op.ReturnDB.Key, srcVer: op.ReturnDB.Ver})
if v > 0 {
- w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "published"; return sameOp(n.Op.ID, op.ID) })
+ w.cas(c, func(n *claim) bool { n.Op.SrcPhase, n.Op.BaseVer = "published", v; return sameOp(n.Op.ID, op.ID) })
}
case "published":
- v := w.publishGen(b, c.Holder.Location, op.RollbackID, c.Holder.Inc)
+ v := w.publishGen(b, c.Holder.Location, op.RollbackID, c.Holder.Inc, op.BaseVer)
if v > 0 {
w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "local"; return sameOp(n.Op.ID, op.ID) })
}
@@ -1177,10 +1197,10 @@ func (w *world) pushStep(b *box, c *claim) {
}
v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: dataKey(loc, op.ID, "base"), b: &blob{Kind: "base", Inc: c.Holder.Inc, Gen: op.ID, Writes: op.PushWrites, SealKey: key}})
if v > 0 {
- w.cas(c, func(n *claim) bool { n.Op.Phase = "published"; return sameOp(n.Op.ID, op.ID) })
+ w.cas(c, func(n *claim) bool { n.Op.Phase, n.Op.BaseVer = "published", v; return sameOp(n.Op.ID, op.ID) })
}
case "published":
- v := w.publishGen(b, loc, op.ID, c.Holder.Inc)
+ v := w.publishGen(b, loc, op.ID, c.Holder.Inc, op.BaseVer)
if v > 0 {
w.cas(c, func(n *claim) bool { n.Op.Phase = "restore"; return sameOp(n.Op.ID, op.ID) })
}
@@ -1290,25 +1310,64 @@ func (w *world) sweepable(c *claim, r int, k string) bool {
return on("sweep-staging") // CC20
}
h := c.Holder
- if h != nil && strings.HasPrefix(k, h.Location) {
- // a generation within the live location other than holder.generation
- if k == dataKey(h.Location, h.Gen, "base") || k == dataKey(h.Location, h.Gen, "ltx") {
- return false
- }
- if k == genMarkerKey(h.Location) {
- v := w.buckets[r].current(k)
- e := w.execBox(c)
- return v != nil && (v.B.Gen != h.Gen || e == nil || e.Region != r)
+ if !on("sweep-namespace") {
+ // claim rev 5's discovery: retired_incarnations (capped) and the
+ // legacy flag. A location older than the cap is never listed
+ if h != nil && strings.HasPrefix(k, h.Location) {
+ return w.deadGeneration(c, r, k)
+ }
+ for _, inc := range c.Retired {
+ if strings.HasPrefix(k, locationFor(inc, false)) {
+ return true
+ }
}
- return isDataKey(k)
+ return w.legacyRetired(c)
}
- for _, inc := range c.Retired {
- if strings.HasPrefix(k, locationFor(inc, false)) {
- return true
+ // claim rev 6 §6.2a (R3-4): locations come from the namespace itself.
+ // Each child prefix of instances/<host>/ is an incarnation location, or
+ // the legacy location (the host prefix, litestream's level layout):
+ // residue unless it is holder.location, and within holder.location
+ // residue unless it is holder.generation
+ if h != nil && locationOf(k) == h.Location {
+ return w.deadGeneration(c, r, k)
+ }
+ return true
+}
+
+// locationOf is the location a key under instances/<host>/ belongs to:
+// instances/<host>/<incarnation>/ for a child prefix, else the legacy
+// location instances/<host>/ (a level directory directly under the host).
+func locationOf(k string) string {
+ p := "instances/" + host + "/"
+ rest := strings.TrimPrefix(k, p)
+ if i := strings.Index(rest, "/"); i > 0 {
+ child := rest[:i]
+ digits := len(child) > 0
+ for _, r := range child {
+ if r < '0' || r > '9' {
+ digits = false
+ }
+ }
+ if digits {
+ return p + child + "/"
}
}
- // a retired legacy incarnation's location is the bare namespace
- return w.legacyRetired(c)
+ return p
+}
+
+// deadGeneration: within holder.location, a generation other than
+// holder.generation, or a marker that no longer names it.
+func (w *world) deadGeneration(c *claim, r int, k string) bool {
+ h := c.Holder
+ if k == dataKey(h.Location, h.Gen, "base") || k == dataKey(h.Location, h.Gen, "ltx") {
+ return false
+ }
+ if k == genMarkerKey(h.Location) {
+ v := w.buckets[r].current(k)
+ e := w.execBox(c)
+ return v != nil && (v.B.Gen != h.Gen || e == nil || e.Region != r)
+ }
+ return isDataKey(k)
}
// legacyRetired: the host once held a legacy incarnation, now retired, so
@@ -1327,8 +1386,9 @@ func (w *world) legacyRetired(c *claim) bool {
// (moves rev 7.1 §5.1, model 29). generation.json is a reused key, so an
// earlier publication that completes late would otherwise put an older
// generation back, and every wake would refuse.
-func (w *world) publishGen(b *box, loc, gen string, inc int) int {
- r := &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: gen, Inc: inc}}
+func (w *world) publishGen(b *box, loc, gen string, inc int, base int) int {
+ // the marker names the generation's snapshot by version id (R3-1)
+ r := &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: gen, Inc: inc, Base: base}}
if on("generation-conditional") {
r.ifSet = true
if cur := w.buckets[b.Region].current(r.key); cur != nil {
diff --git a/internal/sim/hostlife/mutate.sh b/internal/sim/hostlife/mutate.sh
index 57a80bb71..c4ff0590c 100755
--- a/internal/sim/hostlife/mutate.sh
+++ b/internal/sim/hostlife/mutate.sh
@@ -47,7 +47,7 @@ ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel)
PKG="$ROOT/internal/sim/hostlife"
SCRATCH=${HOSTLIFE_SCRATCH:-${TMPDIR:?}/hostlife-sim}
BIN=${HOSTLIFE_BIN:-$SCRATCH/hostlife.test}
-mkdir -p "$SCRATCH/logs" "$SCRATCH/verdicts"
+mkdir -p "$SCRATCH/logs" "$SCRATCH/verdicts" || { echo "cannot create $SCRATCH" >&2; exit 2; }
if [ ! -x "$BIN" ]; then
echo "no test binary at $BIN; build it with: go test -c -o $BIN ./internal/sim/hostlife/" >&2
exit 2
@@ -60,7 +60,10 @@ RULES="$*"
if [ -z "$RULES" ]; then
RULES=$(sed -n '/^var allRules/,/^}/p' "$PKG/rules_test.go" | grep -o '^\s*"[a-z0-9-]*"' | tr -d ' "\t')
fi
-rm -f "$SCRATCH"/verdicts/*
+# the rules that have witnesses (witness_test.go): a run of one of them
+# with no witness output is an ERROR, never "none" (R3-6)
+WRULES=$(grep -o '^\s*{"[a-z0-9-]*",' "$PKG/witness_test.go" | tr -d ' \t{",' | sort -u)
+rm -f "$SCRATCH"/verdicts/* || exit 2
one() {
r=$1
@@ -104,6 +107,14 @@ one() {
echo "ERROR $r: witness run rc=$wrc; see $wlog" | tee "$verdict"
return
fi
+ if [ "$wit" = none ]; then
+ for x in $(echo "$r" | tr ',' ' '); do
+ if echo "$WRULES" | grep -qx "$x"; then
+ echo "ERROR $r: $x has witnesses but the run produced no witness output; see $wlog" | tee "$verdict"
+ return
+ fi
+ done
+ fi
if [ -z "$sweep" ]; then
echo "ERROR $r: rc=$rc, no parsable sweep verdict; see $log" | tee "$verdict"
return
@@ -125,9 +136,28 @@ one() {
fi
}
export -f one
-export SEEDS PKG SCRATCH BIN
+export SEEDS PKG SCRATCH BIN WRULES
printf '%s\n' $RULES | xargs -P "${HOSTLIFE_PAR:-8}" -I{} bash -c 'one "$@"' _ {}
-bad=$(cat "$SCRATCH"/verdicts/* 2>/dev/null | grep -c "^ERROR\|^MISSED\|^STALE")
+xrc=$?
+if [ $xrc -ne 0 ]; then
+ echo "sweep: orchestration failed (xargs rc=$xrc)" >&2
+ exit 2
+fi
+# exactly one well-formed verdict per requested mutation (R3-6)
+want=$(printf '%s\n' $RULES | wc -l)
+have=$(ls "$SCRATCH"/verdicts 2>/dev/null | wc -l)
+if [ "$have" -ne "$want" ]; then
+ echo "sweep: $want mutation(s) requested, $have verdict(s) written" >&2
+ exit 2
+fi
+for r in $RULES; do
+ v="$SCRATCH/verdicts/$(echo "$r" | tr ',' '+')"
+ if ! grep -qE '^(CAUGHT|MISSED|EXPECTED-MISS|STALE|ERROR) ' "$v" 2>/dev/null; then
+ echo "sweep: no well-formed verdict for $r" >&2
+ exit 2
+ fi
+done
+bad=$(cat "$SCRATCH"/verdicts/* | grep -c "^ERROR\|^MISSED\|^STALE")
if [ "$bad" -gt 0 ]; then
echo "sweep: $bad rule(s) ERROR, MISSED or STALE" >&2
exit 1
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go
index 3ce546929..5b41f7687 100644
--- a/internal/sim/hostlife/protocol_test.go
+++ b/internal/sim/hostlife/protocol_test.go
@@ -192,28 +192,75 @@ func (w *world) restore(b *box, inc int, loc, gen string) *localDB {
}
return db
}
+ // the chain (rev 7.4 §5.1, R3-1): the generation's snapshot, named by
+ // version id in generation.json, then the LTX objects of the
+ // generation whose history extends what the chain holds, longest last.
+ // A version whose history is a prefix of the chain is an older
+ // checkpoint or a duplicate and is skipped; one that is neither is a
+ // conflict, and the restore refuses rather than guess. Rev 7.3's model
+ // took the union of every version's writes, which no restore can do.
+ var history []int
found := false
- for _, k := range []string{dataKey(loc, gen, "base"), dataKey(loc, gen, "ltx")} {
- for _, v := range bk.objs[k] {
- if v.Marker {
- continue
- }
- found = true
- db.Writes = unionInts(db.Writes, v.B.Writes)
+ if gen != "" {
+ m := bk.current(genMarkerKey(loc))
+ if m == nil || m.B.Gen != gen {
+ return nil // the marker does not name this generation: refuse
+ }
+ base := bk.byID(dataKey(loc, gen, "base"), m.B.Base)
+ if base == nil || base.Marker {
+ w.stats["restore refused: snapshot missing"]++
+ return nil
+ }
+ history = append([]int(nil), base.B.Writes...)
+ db.Key = base.B.SealKey
+ if base.B.Inc != 0 {
+ db.Inc = base.B.Inc
+ }
+ found = true
+ }
+ for _, v := range bk.objs[dataKey(loc, gen, "ltx")] {
+ if v.Marker {
+ continue
+ }
+ found = true
+ switch {
+ case extends(v.B.Writes, history):
+ history = append([]int(nil), v.B.Writes...)
if db.Key == 0 {
db.Key = v.B.SealKey
}
if v.B.Inc != 0 {
db.Inc = v.B.Inc
}
+ case extends(history, v.B.Writes):
+ // an older checkpoint, or a duplicate: skipped
+ default:
+ w.logf("box %d restore refused: %s version %d does not continue the chain", b.ID, dataKey(loc, gen, "ltx"), v.ID)
+ w.stats["restore refused: conflicting history"]++
+ return nil
}
}
if !found && gen != "" {
return nil // an installed generation with nothing in it: refuse
}
+ db.Writes = history
return db
}
+// fenced: the local retired marker refuses a writer for incarnation inc
+// carrying operation sequence seq (claim rev 6 §6.4, R3-3). The mutation
+// is rev 5.2's marker, which fences the whole incarnation for good.
+func (w *world) fenced(b *box, inc, seq int) bool {
+ f, ok := b.Fence[inc]
+ if !ok {
+ return false
+ }
+ if !on("fence-by-installation") {
+ return true
+ }
+ return seq <= f
+}
+
func unionInts(a, b []int) []int {
seen := map[int]bool{}
var out []int
@@ -354,8 +401,8 @@ func (w *world) admits(b *box, c *claim, probe string) bool {
if on("exec-admission") && c.Op.Exec != b.ID {
return false
}
- if b.Retired[c.Holder.Inc] {
- return false
+ if w.fenced(b, c.Holder.Inc, w.opSeq(c.Op.ID)) {
+ return false // retired here by this operation or a later one (claim rev 6 §6.4)
}
if w.isParticipant(c, b) && b.ExecUntil > 0 && w.localNow(b) > b.ExecUntil {
return false // self-fenced until the lease is renewed
@@ -502,13 +549,15 @@ func (w *world) parkDepth(b *box, prefix, dest string, quiet int, keep func(stri
// guard: a retired marker first, then each artifact renamed aside, one per
// call, so a crash can fall between any two (claim §6.3). It reports
// whether nothing is left.
-func (w *world) localPark(b *box, inc int) bool {
- if on("hostguard") && !b.Retired[inc] {
- if b.Retired == nil {
- b.Retired = map[int]bool{}
+func (w *world) localPark(b *box, inc int, seq int) bool {
+ if on("hostguard") {
+ if f, ok := b.Fence[inc]; !ok || f < seq {
+ if b.Fence == nil {
+ b.Fence = map[int]int{}
+ }
+ b.Fence[inc] = seq // {incarnation, op of the installation being retired}
+ return false
}
- b.Retired[inc] = true
- return false
}
switch {
case b.DB != nil:
@@ -555,7 +604,7 @@ func (w *world) doObligation(b *box, c *claim, o obligation) {
if b.DB != nil && b.DB.Inc != 0 {
inc = b.DB.Inc
}
- if !w.localPark(b, inc) {
+ if !w.localPark(b, inc, w.opSeq(o.Op)) {
return // the next artifact on a later step
}
if b.Row != nil && b.Row.HoldOp == o.Op || b.Row != nil && (c.Holder == nil || b.ID != c.Op.Exec) {
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go
index d1eed185d..4071d38f5 100644
--- a/internal/sim/hostlife/rules_test.go
+++ b/internal/sim/hostlife/rules_test.go
@@ -54,6 +54,7 @@ var allRules = []string{
"park-skip-lock", // §6.2 step 2/J-N6
"retirement-ids", // §6.2/J-D5/J-N7
"hostguard", // §6.4/J-N8
+ "fence-by-installation", // claim rev 6 §6.4 (R3-3): the retired marker is {incarnation, op}; a new installation replaces it; the mutation fences the whole incarnation for good
"purge-reserve-first", // §5.3/J-N9
"purge-adopts", // this model's finding: purge claims unclaimed legacy records first
"purge-journal", // this model's finding: the reservation is the purge's journal
@@ -65,6 +66,7 @@ var allRules = []string{
"claim-never-deleted", // claim rev 5.2 C5a (CC22): a claim object is never deleted; a fence on an absent claim is released by writing free
"cancelled-creates", // claim rev 4 §5.1 (J2-R2-7): a late record PUT is removed
"orphan-no-deadline", // CC17: ... whenever it lands, not only before `until`
+ "orphan-order", // claim rev 6 §5.1 step 5 (R3-2): read the record, then the claim fresh, then DeleteIfMatch; the mutation reads the claim first
"door-by-version", // §6.5/J-N10
"door-reread", // claim rev 4 §6.5 (J2-R2-4): O2 refuses if the holder or op changed
"strict-spawn", // §6.1/J-D3
@@ -80,6 +82,7 @@ var allRules = []string{
"quiet-before-ack", // this model's finding (late writes after a park)
"straggler-sweep", // claim rev 5 §6.2a (CC15): the at-rest sweep
"sweep-staging", // CC20: the sweep also covers moves/<host>/
+ "sweep-namespace", // claim rev 6 §6.2a (R3-4): the sweep discovers locations from the namespace; the mutation reads the capped retired_incarnations
// moves spec
"seal-by-version", // J-N2: landing copies sealed versions
"staging-quiet", // J-N2: staging retired after its writers' horizon
diff --git a/internal/sim/hostlife/sim_test.go b/internal/sim/hostlife/sim_test.go
index 32acfd089..557a99bc9 100644
--- a/internal/sim/hostlife/sim_test.go
+++ b/internal/sim/hostlife/sim_test.go
@@ -53,7 +53,7 @@ func (w *world) legacyStart() {
b := w.regionBox(r)
w.nextInc++
inc := w.nextInc
- w.rec = &record{Inc: inc, App: app0, Region: r}
+ w.rec = &record{Ver: w.newVer(), Inc: inc, App: app0, Region: r}
w.legacyIncs[inc] = true
key := 7
var writes []int
diff --git a/internal/sim/hostlife/witness_test.go b/internal/sim/hostlife/witness_test.go
index 842abb56b..d2763de54 100644
--- a/internal/sim/hostlife/witness_test.go
+++ b/internal/sim/hostlife/witness_test.go
@@ -39,110 +39,170 @@ func (quiet) Intn(n int) int {
type witness struct {
rule string // the rule it is for
needs string // the mutation set under which it must violate: the rule alone, or a combination the rule belongs to
+ want string // the failure the scenario names: a substring of the first violation (R3 review)
note string // what it shows
run func(t *testing.T, w *world)
}
var witnesses = []witness{
- {"generation-conditional", "generation-conditional",
+ {"generation-conditional", "generation-conditional", "cannot start the host",
"an earlier push's generation.json PUT completes after a later push's; unconditional, it puts the old generation back and the serving box cannot start", wGenerationConditional},
- {"lost-return-park", "lost-return-park",
+ {"lost-return-park", "lost-return-park", "ended with residue",
"a target stopped after its first return PUT is declared lost; without the park the lane stays in the source region at the release (INV-10)", wLostReturnPark},
- {"declare-lost-compute", "declare-lost-compute",
+ {"declare-lost-compute", "declare-lost-compute", "INV-1",
"a frozen target's lease expires; declared lost on expiry alone, it resumes beside the recovered source (INV-1)", wDeclareLostCompute},
- {"watchdog-independent", "declare-lost-compute,watchdog-independent",
+ {"watchdog-independent", "declare-lost-compute,watchdog-independent", "INV-1",
"redundant alone: with compute fencing a paused agent cannot be declared lost; with expiry-based declare-lost, a watchdog inside the paused agent never fences, and the target runs beside the recovered source (INV-1)", wWatchdogIndependent},
- {"lost-quarantine", "lost-quarantine",
+ {"lost-quarantine", "lost-quarantine", "holds local state",
"a lost box starts again with its old disk; without the quarantine it keeps a database and files for a host served elsewhere (INV-10)", wLostQuarantine},
- {"markers-last", "markers-last",
+ {"markers-last", "markers-last", "acknowledged timer",
"the target's agent crashes between extracting the files and writing the marker; a marker written first vouches for timers that were never installed (INV-2)", wMarkersLast},
- {"rollback-journal", "rollback-journal",
+ {"rollback-journal", "rollback-journal", "cold restore",
"the source's agent restarts during a dirty rollback's probe; without the journal it restarts at `claimed`, parks the return generation it had replicated into, and a durable write is lost on cold restore (INV-2)", wRollbackJournal},
- {"seal-by-version", "seal-by-version",
+ {"seal-by-version", "seal-by-version", "cold restore",
"a pre-cut staging round completes after the seal; a landing that copies the current version installs the stale round and loses the writes made after it (INV-2)", wSealByVersion},
- {"staging-quiet", "staging-quiet",
+ {"staging-quiet", "staging-quiet", "ended with residue",
"a staging round completes after the landing's staging park acknowledged; retired at the seal rather than after the writers' horizon, the round is residue at the release (INV-10)", wStagingQuiet},
- {"claim-cas-opid", "claim-cas-opid,revalidate-before-effect",
+ {"claim-cas-opid", "claim-cas-opid,revalidate-before-effect", "store of app",
"redundant alone: a stalled source goroutine with the rollback's last read resumes after a purge claimed the host; revalidation stops it, and so does the op-id condition on its release CAS; with both gone the release overwrites the purge and the store is torn down under a serving host", wClaimCasOpID},
- {"dirty-return", "dirty-return",
+ {"dirty-return", "dirty-return", "INV-2 (local)",
"a target that wrote during its probe is rolled back; without the return its writes are lost (INV-2)", wDirtyReturn},
- {"commit-before-open", "commit-before-open,dirty-return",
- "redundant alone in this model: a row opened before the commit admits nothing while the claim is uncommitted; the only violation the scenario can show is dirty-return's", wDirtyReturn},
- {"exec-admission", "exec-admission,hold-op-open,start-check-claim-gen",
+ {"commit-before-open", "commit-before-open", "INV-5",
+ "a public request reaches the target's edge while its probe runs; a row opened at the probe routes it before the commit (INV-5, R3-5)", wCommitBeforeOpen},
+ {"exec-admission", "exec-admission,hold-op-open,start-check-claim-gen", "INV-1",
"one of three admission fences (J2-R2-8): a paused open from an earlier operation makes the old source ready after the commit; hold_op refuses the open, the exec check refuses the wake, the generation check refuses the start; with all three gone the source serves beside the target (INV-1)", wAdmissionTriple},
- {"hold-op-open", "exec-admission,hold-op-open,start-check-claim-gen", "one of three admission fences (see exec-admission)", wAdmissionTriple},
- {"start-check-claim-gen", "exec-admission,hold-op-open,start-check-claim-gen", "one of three admission fences (see exec-admission)", wAdmissionTriple},
- {"start-check", "exec-admission,hold-op-open,start-check", "the whole start check in place of its generation clause (see exec-admission)", wAdmissionTriple},
- {"strict-spawn", "strict-spawn,evidence-whole-tree",
+ {"hold-op-open", "exec-admission,hold-op-open,start-check-claim-gen", "INV-1", "one of three admission fences (see exec-admission)", wAdmissionTriple},
+ {"start-check-claim-gen", "exec-admission,hold-op-open,start-check-claim-gen", "INV-1", "one of three admission fences (see exec-admission)", wAdmissionTriple},
+ {"start-check", "exec-admission,hold-op-open,start-check", "INV-1", "the whole start check in place of its generation clause (see exec-admission)", wAdmissionTriple},
+ {"strict-spawn", "strict-spawn,evidence-whole-tree", "INV-1",
"redundant alone: an escaped descendant is killed by the whole-tree cleanup; with evidence blind to escapees, a non-strict descendant survives the cut and writes beside the target (INV-1)", wStrictSpawn},
- {"touched-incomplete", "touched-incomplete,legacy-discovery",
+ {"touched-incomplete", "touched-incomplete,legacy-discovery", "freed with residue",
"redundant alone: discovery at adoption parks the other regions; without discovery, an incomplete touched set must park every region at delete, or a pre-release replica is residue after free (INV-10)", wTouchedIncomplete},
- {"door-reread", "door-reread,location-per-incarnation",
+ {"door-reread", "door-reread,location-per-incarnation", "door:",
"subsumed by C3a: a listing authorized for the old incarnation selects, after a recreate into the shared location, the new incarnation's object; O2 refuses it", wDoorReread},
- {"door-by-version", "door-by-version,location-per-incarnation",
+ {"door-by-version", "door-by-version,location-per-incarnation", "door:",
"subsumed by C3a: a handle signed before the delete opens, after a recreate into the shared location, the key's current object unless bound to the version it selected", wDoorByVersion},
- {"registration-check", "registration-check,fence-etag",
+ {"registration-check", "registration-check,fence-etag", "store of app",
"a cheap extra: a creator paused across a whole purge resumes; the fence and the ETag condition refuse it without the re-read; with the ETag condition gone the re-read is what stops it", wRegistrationCheck},
- {"registration-check", "registration-check,purge-claim-fence",
+ {"registration-check", "registration-check,purge-claim-fence", "store of app",
"the same, with the fence gone instead", wRegistrationCheck},
-}
-
-// TestWitnesses runs every witness. With no mutation every witness must
-// hold. Under HOSTLIFE_MUTATE=m it runs the witnesses of the rules in m
-// and reports each as violated or held; a witness whose stated mutation
-// set is within m must violate, or the witness is broken and the test
-// fails. HOSTLIFE_WITNESS=rule runs one rule's witnesses with their trace.
+ // rev 7.4
+ {"fence-by-installation", "fence-by-installation", "INV-3 (witness): instance back on box 0",
+ "a round trip UK→US→UK (R3-3): the second landing replaces the retired marker UK wrote after the first move; a marker for the whole incarnation refuses the probe, the move back rolls back, and the instance never serves on UK again", wRoundTrip},
+ {"orphan-order", "orphan-order", "orphan cleanup deleted the record",
+ "the cleanup reads the claim first and pauses; a create removes the orphan, claims and publishes; the cleanup reads that record, judges it by the stale claim and deletes it (R3-2)", wOrphanOrder},
+ {"sweep-namespace", "sweep-namespace", "residue never retired",
+ "a replication PUT of incarnation 1 completes after 33 recreates dropped it from retired_incarnations; a sweep reading that list never finds it (R3-4)", wSweepNamespace},
+ {"recovery-current-inputs", "recovery-current-inputs", "stuck: source could not recover",
+ "a bundle is published after admission and the target writes; the dirty recovery renders current inputs; gated on move.inputs it never passes its probe (R2-5, §7.8)", wRecoveryInputs},
+}
+
+// TestWitnesses is the deterministic half of the mutation evidence.
+//
+// With no mutation (the gate) it proves every witness's claim in full:
+// the scenario holds with every rule in place, holds under every proper
+// subset of its stated mutation set (so a rule a combination names is
+// shown necessary, not assumed), and under the whole set violates the
+// failure the scenario names, not merely some invariant.
+//
+// Under HOSTLIFE_MUTATE=m (the harness) it runs the witnesses of the
+// rules in m and reports each as violated or held; a witness whose set is
+// within m must violate with its named failure, or the test fails (the
+// witness is broken, not the rule). HOSTLIFE_WITNESS=rule runs one rule's
+// witnesses with their trace.
func TestWitnesses(t *testing.T) {
- set := map[string]bool{}
- for _, m := range strings.Split(mutated, ",") {
- if m != "" {
- set[m] = true
+ only := os.Getenv("HOSTLIFE_WITNESS")
+ if mutated == "" {
+ ran := 0
+ for _, x := range witnesses {
+ if only != "" && x.rule != only {
+ continue
+ }
+ ran++
+ check(t, x, "", false, only != "")
+ for _, s := range properSubsets(x.needs) {
+ check(t, x, s, false, only != "")
+ }
+ check(t, x, x.needs, true, only != "")
+ }
+ if only != "" && ran == 0 {
+ t.Fatalf("no witness for rule %q", only)
}
+ return
}
- only := os.Getenv("HOSTLIFE_WITNESS")
- ran := 0
+ set := mutationSet(mutated)
for _, x := range witnesses {
if only != "" && x.rule != only {
continue
}
- if mutated != "" && !set[x.rule] {
+ if !set[x.rule] {
continue
}
- ran++
- w := newWitness()
- x.run(t, w)
- violated := len(w.violations) > 0
needs := true
for _, m := range strings.Split(x.needs, ",") {
if !set[m] {
needs = false
}
}
- tag := "held"
- if violated {
- tag = "violated: " + w.violations[0]
+ check(t, x, mutated, needs, only != "")
+ }
+}
+
+// check runs one witness under mutation m, reports it, and fails the
+// test if the outcome is not the stated one: held when no violation is
+// expected, the named failure when one is.
+func check(t *testing.T, x witness, m string, expectViolation bool, trace bool) {
+ saved := mutated
+ mutated = m
+ defer func() { mutated = saved }()
+ w := newWitness()
+ x.run(t, w)
+ violated := len(w.violations) > 0
+ tag := "held"
+ if violated {
+ tag = "violated: " + w.violations[0]
+ }
+ t.Logf("witness %s under %q (needs %q): %s", x.rule, m, x.needs, tag)
+ switch {
+ case expectViolation && !violated:
+ t.Errorf("witness %s held under %q, which it must violate", x.rule, m)
+ dump(t, w)
+ case expectViolation && !strings.Contains(w.violations[0], x.want):
+ t.Errorf("witness %s under %q violated, but not its named failure %q", x.rule, m, x.want)
+ dump(t, w)
+ case !expectViolation && violated:
+ t.Errorf("witness %s violated under %q, where it must hold", x.rule, m)
+ dump(t, w)
+ }
+ if trace {
+ dump(t, w)
+ }
+}
+
+func mutationSet(m string) map[string]bool {
+ set := map[string]bool{}
+ for _, x := range strings.Split(m, ",") {
+ if x != "" {
+ set[x] = true
}
- switch {
- case mutated == "":
- t.Logf("witness %s: %s", x.rule, tag)
- if violated {
- t.Errorf("witness %s violated with every rule in place", x.rule)
- dump(t, w)
- }
- default:
- t.Logf("witness %s under %q (needs %q): %s", x.rule, mutated, x.needs, tag)
- if needs && !violated {
- t.Errorf("witness %s held under %q, which it must violate", x.rule, mutated)
- dump(t, w)
+ }
+ return set
+}
+
+// properSubsets: every non-empty proper subset of a comma-separated set.
+func properSubsets(needs string) []string {
+ xs := strings.Split(needs, ",")
+ var out []string
+ for mask := 1; mask < (1<<len(xs))-1; mask++ {
+ var sub []string
+ for i, x := range xs {
+ if mask&(1<<i) != 0 {
+ sub = append(sub, x)
}
}
- if only != "" {
- dump(t, w)
- }
- }
- if only != "" && ran == 0 {
- t.Fatalf("no witness for rule %q", only)
+ out = append(out, strings.Join(sub, ","))
}
+ return out
}
func dump(t *testing.T, w *world) {
@@ -196,7 +256,7 @@ func (w *world) serve(b *box, inc int, app string) {
n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}}
n.Regions, n.Boxes, n.Complete, n.Obl = []int{b.Region}, []int{b.ID}, true, nil
w.claim = n
- w.rec = &record{Inc: inc, App: app, Region: b.Region}
+ w.rec = &record{Ver: w.newVer(), Inc: inc, App: app, Region: b.Region}
b.Row = &row{Inc: inc, App: app, Status: "ready"}
b.DB = &localDB{Inc: inc, Loc: loc, Key: key, Writes: append([]int(nil), writes...)}
b.Files = &filesDir{Inc: inc, Key: key, Env: key, Done: true}
@@ -213,7 +273,7 @@ func (w *world) serve(b *box, inc int, app string) {
func (w *world) legacy(b *box, other int) {
w.nextInc++
inc := w.nextInc
- w.rec = &record{Inc: inc, App: app0, Region: b.Region}
+ w.rec = &record{Ver: w.newVer(), Inc: inc, App: app0, Region: b.Region}
w.legacyIncs[inc] = true
key := 7
var writes []int
@@ -303,23 +363,45 @@ func (w *world) act(bs ...*box) func() {
}
}
-// actAll runs every live box, replacements included.
+// actAll runs every live box, replacements included: its worker,
+// obligation runner and reconcile, and the config-sync and adoption
+// passes a serving row needs before the host is at rest.
func (w *world) actAll() func() {
return func() {
for _, b := range w.liveBoxes() {
w.worker(b)
w.obligationRunner(b)
w.reconcile(b)
+ if len(b.plans) == 0 {
+ w.passPlan(b, "config")
+ w.passPlan(b, "adopt")
+ }
+ w.passExec(b)
}
}
}
// runConsole runs one step of every console plan, in order: a
// deterministic consoleStep.
-func (w *world) runConsole() {
+func (w *world) runConsole() { w.runPlans(nil) }
+
+// runPlans runs one step of every console plan but skip. A plan may add
+// another (a create's orphan cleanup); the new one is kept for the next
+// step.
+func (w *world) runPlans(skip *consolePlan) {
+ plans := append([]*consolePlan(nil), w.console...)
+ done := map[*consolePlan]bool{}
+ for _, p := range plans {
+ if p == skip || p.Stall > w.now {
+ continue
+ }
+ if w.runPlan(p) {
+ done[p] = true
+ }
+ }
var keep []*consolePlan
for _, p := range w.console {
- if p.Stall > w.now || !w.runPlan(p) {
+ if !done[p] {
keep = append(keep, p)
}
}
@@ -736,3 +818,122 @@ func wRegistrationCheck(t *testing.T, w *world) {
w.tick(func() { w.runPlan(p) }) // the creator resumes
}
}
+
+// ---------- rev 7.4 ----------
+
+// wRoundTrip (claim rev 6 §6.4, R3-3): UK→US, then US→UK. The first
+// move's retirement wrote UK's local marker for the incarnation; the
+// second landing must replace it. Unmutated, the instance must serve on
+// UK again, running.
+func wRoundTrip(t *testing.T, w *world) {
+ uk, us := w.boxes[0], w.boxes[1]
+ w.serve(uk, 1, app0)
+ w.moveTo(us.Region)
+ w.reach(t, 1500, "serving on box 1 after the first move", func() bool {
+ c := w.claim
+ return c.Op.Kind == "serving" && c.Op.Exec == us.ID && w.rec != nil && w.rec.MoveID == "" && len(c.Obl) == 0
+ }, w.act(uk, us), w.recoveryPass)
+ if _, fenced := uk.Fence[1]; !fenced {
+ t.Fatalf("witness: box 0 wrote no retired marker: %s%s", w.state(), w.boxesState())
+ }
+ w.moveTo(uk.Region)
+ if w.claim.Op.Kind != "moving" {
+ t.Fatalf("witness: the move back did not start: %s", w.state())
+ }
+ w.reach(t, 1500, "instance back on box 0, running", func() bool {
+ c := w.claim
+ return c.Op.Kind == "serving" && c.Op.Exec == uk.ID && hasMain(uk)
+ }, w.act(uk, us), w.recoveryPass, w.operator)
+ w.finish(t)
+}
+
+// runOthers runs every console plan but skip, one step each, removing
+// the finished ones.
+func (w *world) runOthers(skip *consolePlan) func() {
+ return func() { w.runPlans(skip) }
+}
+
+// wOrphanOrder (claim rev 6 §5.1 step 5, R3-2): a cancelled create's
+// record lands late; the recovery pass starts its cleanup and pauses
+// after its first observation; a create of the next incarnation removes
+// the orphan itself, claims and publishes; the paused cleanup resumes.
+func wOrphanOrder(t *testing.T, w *world) {
+ w.claim = &claim{Ver: w.newVer(), Retired: []int{1}, Cancelled: []cancelledCreate{{Inc: 1, Until: w.now + 1000}}}
+ w.nextInc = 1
+ w.rec = &record{Ver: w.newVer(), Inc: 1, App: app0, Region: 0}
+ w.logf("witness: the record of cancelled incarnation 1 landed")
+ w.recoveryPass()
+ var p1 *consolePlan
+ for _, p := range w.console {
+ if p.Kind == "orphan" {
+ p1 = p
+ }
+ }
+ if p1 == nil {
+ t.Fatalf("witness: the recovery pass started no cleanup: %s", w.state())
+ }
+ w.tick(func() { w.runPlan(p1) }) // the first observation; then the console pauses
+ cr := &consolePlan{Kind: "create", Host: "h", App: app0, OpID: w.newID("cr"), Inc: 2, Region: 0}
+ w.console = append(w.console, cr)
+ w.reach(t, 50, "incarnation 2 claimed and published", func() bool {
+ return w.rec != nil && w.rec.Inc == 2
+ }, w.runOthers(p1))
+ w.tick(func() { w.runPlan(p1) }) // the paused cleanup resumes
+ w.tick(func() { w.runPlan(p1) })
+ w.finish(t)
+}
+
+// wSweepNamespace (claim rev 6 §6.2a, R3-4): a replication PUT of
+// incarnation 1 is accepted and completes after 33 delete-and-recreate
+// cycles, by which time retired_incarnations no longer lists 1.
+func wSweepNamespace(t *testing.T, w *world) {
+ b := w.boxes[0]
+ w.serve(b, 1, app0)
+ w.wake(b)
+ if !hasMain(b) {
+ t.Fatalf("witness: the host did not wake: %s", w.state())
+ }
+ key := dataKey(w.claim.Holder.Location, "", "ltx")
+ w.holdKey(key)
+ w.replicate(b)
+ w.hold = nil
+ for inc := 2; inc <= retiredCap+2; inc++ {
+ w.recreate(t, 0, inc)
+ if len(w.violations) > 0 {
+ return
+ }
+ }
+ if contains(w.claim.Retired, 1) {
+ t.Fatalf("witness: incarnation 1 is still in retired_incarnations after %d recreates", retiredCap+1)
+ }
+ if w.completeHeld(key) != 1 {
+ t.Fatalf("witness: no held replication PUT")
+ }
+ w.finish(t)
+}
+
+// wCommitBeforeOpen (INV-5, R3-5): a public request reaches the target's
+// edge while its probe runs. The edge routes on the row.
+func wCommitBeforeOpen(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ w.wake(tgt)
+ w.checkStep()
+ w.finish(t)
+}
+
+// wRecoveryInputs (§7.8, R2-5): a config bundle is published after the
+// move was admitted; the target does real work and the rollback is
+// dirty; the source's recovery install must render current inputs, not
+// the inputs the move pinned.
+func wRecoveryInputs(t *testing.T, w *world) {
+ src, tgt := w.boxes[0], w.boxes[1]
+ w.serve(src, 1, app0)
+ w.landProbed(t, src, tgt)
+ w.cfgVer[app0]++
+ w.logf("witness: config bundle %s v%d published during the settle window", app0, w.cfgVer[app0])
+ w.appWrite(tgt)
+ w.rollback(w.claim, "settle check failed")
+ w.finish(t)
+}
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go
index 0892dab88..a483ebcdf 100644
--- a/internal/sim/hostlife/world_test.go
+++ b/internal/sim/hostlife/world_test.go
@@ -52,14 +52,40 @@ const (
maxPause = 3 // a typical pause between a check and the act (a VM freeze is longer)
sigAge = 30 // S3's signature-age limit; H covers it plus the longest request
beyondH = 20 // one long-tail request in this many completes after H (R2-1)
+ retiredCap = 32 // retired_incarnations keeps this many (claim rev 6 §2)
)
+// retire adds inc to retired_incarnations, dropping the oldest past the cap.
+func retire(xs []int, inc int) []int {
+ xs = addUniq(xs, inc)
+ if len(xs) > retiredCap {
+ xs = append([]int(nil), xs[len(xs)-retiredCap:]...)
+ }
+ return xs
+}
+
+// extends: history a is b with zero or more later transactions appended
+// (R3-1: a replica object continues the chain only if its history is an
+// extension of what the chain holds).
+func extends(a, b []int) bool {
+ if len(a) < len(b) {
+ return false
+ }
+ for i := range b {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
// ---------- storage ----------
type blob struct {
- Kind string // "ltx", "base", "files", "snap", "lock", "manifest", "lease", "report"
+ Kind string // "ltx", "base", "files", "snap", "lock", "manifest", "lease", "report", "gen"
Inc int // incarnation tag; 0 = untagged legacy
Gen string // lineage that wrote it
+ Base int // a "gen" marker: the version id of the generation's snapshot (R3-1)
Seq int
SealKey int // the instance.key the data is sealed with
Writes []int
@@ -284,8 +310,13 @@ type box struct {
History []int // delivered history (disposable)
Attempt int // runner bookkeeping
Procs []*proc
- Retired map[int]bool // retired-incarnation markers (hostguard)
- Parked int // count of locally parked artifacts
+ // Fence is the local retired marker, per incarnation (claim rev 6 §6.4,
+ // R3-3): {incarnation, op}. It refuses a writer for that incarnation
+ // whose operation is at or before the fence's; a new installation of
+ // the incarnation by a later operation replaces it under the guard.
+ // The value is the sequence of the fence's op (opSeq).
+ Fence map[int]int
+ Parked int // count of locally parked artifacts
ExecUntil int // local exec-lease file (watchdog input); 0 none
// in-memory, lost on crash
@@ -311,6 +342,27 @@ type plan struct {
App string
Ver int // the config bundle version it selected
At int // it takes the guard no earlier than this
+ // Seq is the operation identity the pass validated under (R2-3): the
+ // sequence of the newest operation when it selected the row. The
+ // host guard refuses it once the local fence is at or past it.
+ Seq int
+}
+
+// opSeq orders operations: every op id the model mints carries the
+// global counter, so a later operation has a larger sequence. The serving
+// state has no op id and sorts as "now". Claim rev 6's marker names the
+// op id of the installation; the ordering it needs is this one.
+func (w *world) opSeq(id string) int {
+ if id == "" || id == "sweep" || id == "adopt" {
+ return w.nextID
+ }
+ n := 0
+ for _, r := range id {
+ if r >= '0' && r <= '9' {
+ n = n*10 + int(r-'0')
+ }
+ }
+ return n
}
func (b *box) forget() {
@@ -407,6 +459,11 @@ type opState struct {
// 5.2 §5.3). Its release writes a free claim; the mutation of
// claim-never-deleted deletes the object instead
FencedAbsent bool
+ // BaseVer is the version id of the generation's installed snapshot,
+ // recorded by the install manifest and named by generation.json: the
+ // immutable snapshot identity the restore starts from (rev 7.4 §5.1,
+ // R3-1)
+ BaseVer int
}
type digest struct {
@@ -425,6 +482,9 @@ type claim struct {
Complete bool
Obl []obligation
Lost []int
+ // Retired is retired_incarnations: anti-reuse only, capped at
+ // retiredCap entries (claim rev 6 §2, §6.2a). The sweep does not read
+ // it; it discovers locations from the namespace (R3-4).
Retired []int
Quiet [nRegions]int
PurgeRes bool