sim: hostlife encodes exactly what the specs say (joint round 2, claim rev 4)
Joint round 2 found the model stronger than the specs. This rebuilds it on claim rev 4 (a3c86fa5) and the moves rev 6 draft: - restore reads holder.location and the generation the claim names, and replays every object of it; the start check compares markers, with the claim's generation as a separate rule (R2-8, R2-1) - record-first flip, commit and rollback, with the claim half a separate step any actor or the recovery pass completes; exec none at stopped - creates, purges and deletes as concurrent console plans over two hostnames, with app admission registration (R2-2) and cancelled creates (R2-7); late record PUTs - host-guard writers as independent processes that pause before the lock (R2-3); door listings with O1 and O2 (R2-4); park manifests with archive and deletion sets (R2-6); adoption discovery of local state (N5) - config bundle versions, pinned at admission (R2-5) - requests that complete after H, and a straggler sweep at rest - per-artifact local parks; the return lane under the staging lease - a read-only final oracle: cold-restore checks of durable writes at every release and at the end, INV-10 at every operation's end; a disk-loss fault - mutate.sh counts only a parsed invariant violation with a seed as CAUGHT; anything else is ERROR, and the sweep fails on ERROR, MISSED or STALE State at this commit: 30,000 seeds clean; 100,000 seeds 1 failure (seed 77725, staging residue), under investigation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+1288
−425
internal/sim/hostlife/background_test.go+52 −10internal/sim/hostlife/console_test.go+317 −114internal/sim/hostlife/expected-outcomes.txt+1 −0internal/sim/hostlife/moves_test.go+301 −96internal/sim/hostlife/mutate.sh+61 −18internal/sim/hostlife/protocol_test.go+159 −98internal/sim/hostlife/rules_test.go+28 −20internal/sim/hostlife/sim_test.go+195 −34internal/sim/hostlife/world_test.go+174 −35
diff --git a/internal/sim/hostlife/background_test.go b/internal/sim/hostlife/background_test.go| index 5781407ad..d8c7c0fe1 100644 |
| --- a/internal/sim/hostlife/background_test.go |
| +++ b/internal/sim/hostlife/background_test.go |
| @@ -35,7 +35,8 @@ func (w *world) replicate(b *box) { |
| return |
| } |
| bl := &blob{Kind: "ltx", Inc: b.DB.Inc, Gen: b.DB.Gen, Writes: append([]int(nil), b.DB.Writes...), SealKey: b.DB.Key} |
| - w.issue(b.ID, &request{kind: rPut, region: b.Region, key: "instances/" + host + "/ltx", b: bl}) |
| + // into the location and generation the database was restored from |
| + w.issue(b.ID, &request{kind: rPut, region: b.Region, key: dataKey(b.DB.Loc, b.DB.Gen, "ltx"), b: bl}) |
| } |
| // idleStop: the activator's ordinary idle stop (main only; a descendant |
| @@ -64,7 +65,7 @@ func (w *world) wake(b *box) { |
| return |
| } |
| c := &claim{Holder: &holder{Inc: w.rec.Inc, App: w.rec.App, Legacy: true}} |
| - w.spawn(b, c, lin{}, false) |
| + w.spawn(b, c, "", false) |
| return |
| } |
| c, ok := w.admissionClaim(b) |
| @@ -74,7 +75,7 @@ func (w *world) wake(b *box) { |
| if b.DB == nil && on("quiet-before-ack") && w.now < c.Quiet[b.Region] { |
| return // a first install into this region waits out quiet_after (claim §6.2a) |
| } |
| - w.spawn(b, c, holderLin(c.Holder), false) |
| + w.spawn(b, c, c.Holder.Gen, false) |
| } |
| // ---------- reconcile ---------- |
| @@ -147,7 +148,7 @@ func (w *world) adopt() bool { |
| if eb == nil { |
| return false |
| } |
| - n := &claim{Ver: 1, Holder: &holder{Inc: w.rec.Inc, App: w.rec.App, Legacy: true}, |
| + n := &claim{Ver: 1, Holder: &holder{Inc: w.rec.Inc, App: w.rec.App, Legacy: true, Location: locationFor(w.rec.Inc, true)}, |
| Op: opState{Kind: "serving", Exec: eb.ID, Parts: []int{eb.ID}}, Regions: []int{w.rec.Region}, Boxes: []int{eb.ID}, Complete: false} |
| if on("legacy-discovery") { |
| // every other region parks whatever it holds: it cannot be the |
| @@ -160,6 +161,13 @@ func (w *world) adopt() bool { |
| n.Obl = append(n.Obl, obligation{ID: w.newID("ob"), Op: "adopt", Kind: "park-replica", Region: r, Box: -1, Prefix: p, Ret: "legacy"}) |
| } |
| } |
| + // and local state on every box but the record region's serving box |
| + // (claim rev 4 §9, the J2 N5 note): it cannot be the live instance's |
| + for _, b := range w.liveBoxes() { |
| + if b.ID != eb.ID { |
| + n.Obl = append(n.Obl, obligation{ID: w.newID("ob"), Op: "adopt", Kind: "park-local", Box: b.ID, Region: -1, Ret: "legacy"}) |
| + } |
| + } |
| } |
| w.claim = n |
| w.logf("adopted legacy host inc %d on box %d", w.rec.Inc, eb.ID) |
| @@ -216,25 +224,40 @@ 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}) |
| + 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()}) |
| } |
| +// passExec runs one independent process's write. It needs the box running, |
| +// not the agent. Under the host guard (an exclusive flock shared by the |
| +// agent and every root command, claim rev 4 §6.4) it reads the claim |
| +// fresh and refuses if the incarnation, the operation or the config |
| +// identity it selected has moved on. The mutation keeps an in-process lock |
| +// only: the process acts on what it validated before it paused. |
| func (w *world) passExec(b *box) { |
| - if !w.agentUp(b) || len(b.plans) == 0 { |
| + if b.Destroyed || b.Frozen || len(b.plans) == 0 { |
| return |
| } |
| i := w.rng.Intn(len(b.plans)) |
| p := b.plans[i] |
| + if p.At > w.now { |
| + return |
| + } |
| b.plans = append(b.plans[:i:i], b.plans[i+1:]...) |
| if on("hostguard") { |
| - c, ok := w.readClaim(b) |
| - if !ok || c == nil || c.Holder == nil || c.Holder.Inc != p.Inc || b.Row == nil || b.Row.Inc != p.Inc || b.Retired[p.Inc] { |
| + if b.Partitioned { |
| + 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] { |
| return |
| } |
| + if p.Kind == "config" && p.Ver != w.cfgVer[p.App] { |
| + return // the selected bundle is no longer the held one |
| + } |
| } |
| switch p.Kind { |
| case "config": |
| - b.Config = p.Inc |
| + b.Config, b.CfgVer = p.Inc, p.Ver |
| case "adopt": |
| b.Live, b.LiveInc = p.App, p.Inc |
| } |
| @@ -306,6 +329,7 @@ func (w *world) fault() { |
| } |
| case 1: |
| b.Procs = nil |
| + b.plans = nil // a reboot ends every process |
| b.AgentDown, b.Frozen, b.AgentPaused = false, false, false |
| b.forget() |
| if !on("floor-boot-recovery") && w.rng.Intn(3) == 0 { |
| @@ -327,13 +351,31 @@ func (w *world) fault() { |
| } |
| case 5: |
| c := w.claim |
| - if w.fail["destroy"] && c != nil && (c.Op.Kind == "landing" || c.Op.Kind == "restoring") && !c.Op.Committed && b.ID == c.Op.Tgt { |
| + committed := c != nil && (c.Op.Committed || w.rec != nil && w.rec.MoveID == c.Op.ID && w.rec.MoveState == "committed") |
| + if w.fail["destroy"] && c != nil && (c.Op.Kind == "landing" || c.Op.Kind == "restoring") && !committed && b.ID == c.Op.Tgt { |
| b.Destroyed = true |
| b.Procs = nil |
| w.logf("box %d destroyed", b.ID) |
| } |
| case 6: |
| w.console = nil // console crash: plans dropped |
| + case 7: |
| + // the serving box loses its database file while the app is idle: the |
| + // next wake restores cold. Writes after the last completed |
| + // checkpoint are lost by design (the RPO), and declared so; every |
| + // durable write must come back (§14.2.3). |
| + c := w.claim |
| + if w.fail["disk"] && c != nil && c.Holder != nil && c.Op.Kind == "serving" && c.Op.Exec == b.ID && b.DB != nil && !hasMain(b) && len(b.Procs) == 0 { |
| + for i := range w.acks { |
| + a := &w.acks[i] |
| + if a.Box == b.ID && a.Inc == b.DB.Inc && !w.isDurable(a.W) { |
| + a.Exempt = true |
| + } |
| + } |
| + b.DB = nil |
| + w.stats["disk-lost"]++ |
| + w.logf("box %d lost its database file", b.ID) |
| + } |
| default: |
| w.completeDue(false) |
| } |
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go| index f89eb43fe..1a403c781 100644 |
| --- a/internal/sim/hostlife/console_test.go |
| +++ b/internal/sim/hostlife/console_test.go |
| @@ -6,18 +6,29 @@ import "fmt" |
| // every plan; the claim-recovery pass must then finish or undo the work |
| // from the claim alone (claim C2). |
| type consolePlan struct { |
| - Kind string // create, delete, purge |
| - Step int |
| - App string |
| - OpID string |
| + Kind string // create, delete, purge |
| + Step int |
| + App string |
| + OpID string |
| + Host string // "h" or "h2" for a create |
| + Inc int |
| + Region int |
| } |
| +// doorPlan is one pull-door listing (claim rev 4 §6.5): O1 is the claim |
| +// read it authorized against; it selects versions under O1's location after |
| +// a pause, reads the claim again (O2), and only then signs handles, which a |
| +// file request opens after another pause. |
| type doorPlan struct { |
| Region int |
| + Loc string |
| + Gen string |
| + Inc int // O1's holder incarnation |
| + OpID string // O1's op |
| Key string |
| Ver int |
| - Inc int |
| - Until int // a stalled read may resume until then (one in four stall long) |
| + Signed bool |
| + Until int // a stalled step may resume until then (one in four stall long) |
| } |
| func (w *world) execBox(c *claim) *box { |
| @@ -27,8 +38,10 @@ func (w *world) execBox(c *claim) *box { |
| return w.boxByID(c.Op.Exec) |
| } |
| -// fixRecord converges the record's region and move id to the claim. It is |
| -// the record half of every flip, release and recovery (claim C2). |
| +// fixRecord is the recovery owner for the record halves of a move (claim |
| +// C2): the request's `move` field after the claim's intent; the claim half |
| +// of a record-first transition (followRecord); and, after a release, the |
| +// CAS that clears `move` and names the executor's region (§7.9 step 5). |
| func (w *world) fixRecord() { |
| c := w.claim |
| if c == nil || c.Holder == nil || w.rec == nil || w.rec.Inc != c.Holder.Inc { |
| @@ -36,17 +49,17 @@ func (w *world) fixRecord() { |
| } |
| switch c.Op.Kind { |
| case "serving": |
| - if b := w.execBox(c); b != nil { |
| - w.rec.Region = b.Region |
| + 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++ |
| + } |
| + case "moving": |
| + if w.rec.MoveID == "" { |
| + w.rec.MoveID, w.rec.MoveState = c.Op.ID, "" |
| + w.rec.Ver++ |
| } |
| - w.rec.MoveID = "" |
| - case "moving", "reverting": |
| - w.rec.Region, w.rec.MoveID = c.Op.SrcRegion, c.Op.ID |
| - case "landing": |
| - w.rec.Region, w.rec.MoveID = c.Op.TgtRegion, c.Op.ID |
| - case "restoring": |
| - w.rec.Region, w.rec.MoveID = c.Op.SrcRegion, c.Op.ID |
| } |
| + w.followRecord(c) |
| } |
| func (w *world) liveBoxes() []*box { |
| @@ -61,26 +74,30 @@ func (w *world) liveBoxes() []*box { |
| // ---------- user operations ---------- |
| +// userCreate starts a create as a console plan: registration, the claim |
| +// CAS, the record and serving are separate steps on separate objects, and |
| +// the console may pause or crash between any two (J2-R2-2, J2-R2-7). |
| func (w *world) userCreate() { |
| - c := w.claim |
| - if c != nil && (c.Holder != nil || (c.Op.Kind != "" && c.Op.Kind != "serving")) { |
| - return |
| - } |
| - if w.rec != nil { |
| - return |
| - } |
| app := app0 |
| if w.rng.Intn(2) == 0 { |
| app = app1 |
| } |
| - if w.purgeRes[app] { |
| + if w.rng.Intn(3) == 0 { |
| + if w.h2.Kind != "" { |
| + return |
| + } |
| + w.nextInc++ |
| + w.console = append(w.console, &consolePlan{Kind: "create", Host: "h2", App: app, OpID: w.newID("cr"), Inc: w.nextInc}) |
| return |
| } |
| - r := w.rng.Intn(nRegions) |
| - b := w.regionBox(r) |
| - if b == nil || (c != nil && contains(c.Lost, b.ID)) { |
| + c := w.claim |
| + if c != nil && (c.Holder != nil || (c.Op.Kind != "" && c.Op.Kind != "serving")) { |
| return |
| } |
| + if w.rec != nil || w.recPend != nil { |
| + return |
| + } |
| + r := w.rng.Intn(nRegions) |
| var inc int |
| if on("fresh-incarnation") || c == nil || len(c.Retired) == 0 { |
| w.nextInc++ |
| @@ -88,20 +105,134 @@ func (w *world) userCreate() { |
| } else { |
| inc = c.Retired[len(c.Retired)-1] // a caller-supplied CreatedAt repeats an identity |
| } |
| - if on("fresh-incarnation") && c != nil && contains(c.Retired, inc) { |
| - return |
| - } |
| - w.serial++ |
| - id := w.newID("cr") |
| - if w.cas(c, func(n *claim) bool { |
| - n.Holder = &holder{Inc: inc, App: app, Serial: w.serial} |
| - n.Op = opState{Kind: "creating", ID: id, Exec: -1, NewInc: inc, NewApp: app, NewRegion: r, ReqAt: w.now} |
| - n.Regions, n.Boxes, n.Complete, n.Obl = []int{r}, []int{b.ID}, true, nil |
| + w.console = append(w.console, &consolePlan{Kind: "create", Host: "h", App: app, OpID: w.newID("cr"), Inc: inc, Region: r}) |
| +} |
| + |
| +func (w *world) createSteps(p *consolePlan) bool { |
| + ad := w.admit[p.App] |
| + abandon := func() bool { |
| + delete(ad.Reg, p.OpID) |
| return true |
| - }) { |
| - w.stores[app] = true |
| - w.console = append(w.console, &consolePlan{Kind: "create", OpID: id}) |
| - w.logf("create %s inc %d app %s in region %d", id, inc, app, r) |
| + } |
| + switch p.Step { |
| + case 0: |
| + // app admission (claim rev 4 §5.1 step 0): register while open. |
| + // The mutation is rev 3's check of the purge reservation, a read of |
| + // another object than the claim the create then writes. |
| + if on("admission-registration") { |
| + if ad.Closed { |
| + return true |
| + } |
| + ad.Reg[p.OpID] = regEntry{Host: p.Host, Until: w.now + 60} |
| + w.logf("create %s registered for app %s (%s)", p.OpID, p.App, p.Host) |
| + } else if w.purgeRes[p.App] { |
| + return true |
| + } |
| + w.stores[p.App] = true // the app exists, or exists again after a purge |
| + p.Step = 1 |
| + return false |
| + case 1: |
| + if e, ok := ad.Reg[p.OpID]; on("admission-registration") && on("registration-recovery") && (!ok || w.now > e.Until-maxPause) { |
| + return abandon() // the registration lease ended before the claim CAS |
| + } |
| + if p.Host == "h2" { |
| + if w.h2.Kind != "" { |
| + return abandon() |
| + } |
| + w.h2 = liteHost{Inc: p.Inc, App: p.App, Kind: "creating", OpID: p.OpID, At: w.now} |
| + w.logf("create %s: second host claimed for app %s", p.OpID, p.App) |
| + p.Step = 2 |
| + return false |
| + } |
| + c := w.claim |
| + if c != nil && (c.Holder != nil || (c.Op.Kind != "" && c.Op.Kind != "serving")) || w.rec != nil || w.recPend != nil { |
| + return abandon() |
| + } |
| + b := w.regionBox(p.Region) |
| + if b == nil || (c != nil && contains(c.Lost, b.ID)) { |
| + return abandon() |
| + } |
| + if on("fresh-incarnation") && c != nil && contains(c.Retired, p.Inc) { |
| + return abandon() |
| + } |
| + w.serial++ |
| + if !w.cas(c, func(n *claim) bool { |
| + n.Holder = &holder{Inc: p.Inc, App: p.App, Serial: w.serial, Location: locationFor(p.Inc, false)} |
| + n.Op = opState{Kind: "creating", ID: p.OpID, Exec: -1, NewInc: p.Inc, NewApp: p.App, NewRegion: p.Region, ReqAt: w.now} |
| + n.Regions, n.Boxes, n.Complete, n.Obl = []int{p.Region}, []int{b.ID}, true, nil |
| + return true |
| + }) { |
| + return abandon() |
| + } |
| + w.logf("create %s inc %d app %s in region %d", p.OpID, p.Inc, p.App, p.Region) |
| + p.Step = 2 |
| + return false |
| + case 2: |
| + if p.Host == "h2" { |
| + if w.h2.OpID != p.OpID || w.h2.Kind != "creating" { |
| + return abandon() |
| + } |
| + w.h2.Rec = true |
| + p.Step = 3 |
| + return false |
| + } |
| + c := w.claim |
| + if c == nil || c.Op.ID != p.OpID || c.Op.Kind != "creating" { |
| + return abandon() |
| + } |
| + if w.rec == nil && w.recPend == nil { |
| + rec := &record{Inc: c.Holder.Inc, App: c.Holder.App, Region: c.Op.NewRegion} |
| + switch n := w.rng.Intn(10); { |
| + case n < 7: |
| + w.rec = rec |
| + case n < 9: |
| + // the response is lost and the PUT lands later, rarely after |
| + // cancelled_creates' window |
| + at := w.now + 1 + w.rng.Intn(2*horizonH) |
| + if w.rng.Intn(beyondH) == 0 { |
| + at = w.now + 4*horizonH + w.rng.Intn(10*horizonH) |
| + } |
| + w.recPend = &pendingRec{rec: rec, at: at} |
| + } |
| + // otherwise it failed outright; the next step retries |
| + } |
| + p.Step = 3 |
| + return false |
| + default: |
| + if p.Host == "h2" { |
| + if w.h2.OpID != p.OpID || w.h2.Kind != "creating" { |
| + return abandon() |
| + } |
| + w.h2.Kind = "serving" // a CAS from creating, like the main host's |
| + w.logf("create %s: second host serving", p.OpID) |
| + delete(ad.Reg, p.OpID) |
| + return true |
| + } |
| + c := w.claim |
| + if c == nil || c.Op.ID != p.OpID || c.Op.Kind != "creating" { |
| + return abandon() |
| + } |
| + if w.rec == nil || w.rec.Inc != c.Holder.Inc { |
| + if w.rec == nil && w.recPend == nil { |
| + p.Step = 2 // publish again |
| + } |
| + return false |
| + } |
| + b := w.regionBox(c.Op.NewRegion) |
| + if b == nil { |
| + return false |
| + } |
| + if w.cas(c, func(n *claim) bool { |
| + if !sameOp(n.Op.ID, p.OpID) { |
| + return false |
| + } |
| + n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}} |
| + return true |
| + }) { |
| + delete(ad.Reg, p.OpID) |
| + return true |
| + } |
| + return false |
| } |
| } |
| @@ -156,7 +287,6 @@ func (w *world) userMove() { |
| Src: src.ID, Tgt: tgt.ID, SrcRegion: src.Region, TgtRegion: r, ReqAt: w.now, Auto: true, App: c.Holder.App} |
| return true |
| }) { |
| - w.fixRecord() |
| w.logf("move %s inc %d from box %d to box %d", id, c.Holder.Inc, src.ID, tgt.ID) |
| } |
| } |
| @@ -230,15 +360,15 @@ func (w *world) parkingObligations(n *claim, inc int) { |
| n.Retired = addUniq(n.Retired, inc) |
| } |
| -// consoleStep runs the next step of the oldest console plan. |
| +// consoleStep runs the next step of one console plan, chosen at random: |
| +// the console's requests run concurrently. |
| func (w *world) consoleStep() { |
| if len(w.console) == 0 { |
| return |
| } |
| - p := w.console[0] |
| - done := w.runPlan(p) |
| - if done { |
| - w.console = w.console[1:] |
| + i := w.rng.Intn(len(w.console)) |
| + if w.runPlan(w.console[i]) { |
| + w.console = append(w.console[:i:i], w.console[i+1:]...) |
| } |
| } |
| @@ -246,28 +376,7 @@ func (w *world) runPlan(p *consolePlan) bool { |
| c := w.claim |
| switch p.Kind { |
| case "create": |
| - if c == nil || c.Op.ID != p.OpID || c.Op.Kind != "creating" { |
| - return true |
| - } |
| - if p.Step == 0 { |
| - if w.rec == nil { |
| - w.rec = &record{Inc: c.Holder.Inc, App: c.Holder.App, Region: c.Op.NewRegion} |
| - } |
| - p.Step = 1 |
| - return false |
| - } |
| - b := w.regionBox(c.Op.NewRegion) |
| - if b == nil { |
| - return false |
| - } |
| - w.cas(c, func(n *claim) bool { |
| - if !sameOp(n.Op.ID, p.OpID) { |
| - return false |
| - } |
| - n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}} |
| - return true |
| - }) |
| - return true |
| + return w.createSteps(p) |
| case "delete": |
| if c == nil || c.Op.ID != p.OpID { |
| return true |
| @@ -325,6 +434,9 @@ func (w *world) free(c *claim) { |
| }) { |
| w.logf("host freed") |
| w.opsDone++ |
| + if r := w.residueAtEnd(); len(r) > 0 { |
| + w.violate("INV-10: the host was freed with residue %v", r) |
| + } |
| w.stats["freed"]++ |
| if w.purgeRes[app] && !on("purge-journal") { |
| w.purgeRes[app] = false // freeing the host drops the reservation |
| @@ -372,36 +484,78 @@ func (w *world) purgeSteps(p *consolePlan) bool { |
| } |
| }() |
| } |
| + ad := w.admit[p.App] |
| switch p.Step { |
| case 0: |
| if on("purge-reserve-first") { |
| reserve() |
| - p.Step = 1 |
| } else { |
| w.stores[p.App] = false // tear down first |
| - p.Step = 1 |
| } |
| + p.Step = 1 |
| return false |
| case 1: |
| - if !claimIt() { |
| - return false |
| + // close admission (claim rev 4 §5.3 step 1b), so no new create can |
| + // register |
| + if on("admission-registration") { |
| + ad.Closed = true |
| + w.logf("purge %s: admission closed", p.App) |
| } |
| p.Step = 2 |
| return false |
| case 2: |
| + // drain: every registered create reaches serving or is cancelled |
| + // before the host set is fixed; an expired registration is dead |
| + if on("admission-registration") { |
| + for id, e := range ad.Reg { |
| + // an expired registration whose host's claim does not name |
| + // its create is dead: the create checks the expiry right |
| + // before its claim CAS, so it can no longer claim (CC16) |
| + claimed := e.Host == "h2" && w.h2.OpID == id && w.h2.Kind == "creating" || |
| + e.Host == "h" && w.claim != nil && w.claim.Op.ID == id && w.claim.Op.Kind == "creating" |
| + if on("registration-recovery") && w.now > e.Until && !claimed { |
| + delete(ad.Reg, id) |
| + continue |
| + } |
| + return false |
| + } |
| + } |
| + p.Step = 3 |
| + return false |
| + case 3: |
| + // the host set: records, plus registered in-flight creates (drained |
| + // above). A second host the purge cannot see is not in it. |
| + if !claimIt() { |
| + return false |
| + } |
| + if w.h2.App == p.App && w.h2.Rec { |
| + switch w.h2.Kind { |
| + case "serving": |
| + w.h2.Kind = "purging" |
| + case "purging": |
| + default: |
| + return false |
| + } |
| + } |
| + p.Step = 4 |
| + return false |
| + case 4: |
| if on("purge-reserve-first") { |
| + w.logf("purge %s: store torn down", p.App) |
| w.stores[p.App] = false |
| } else { |
| reserve() |
| } |
| - p.Step = 3 |
| + p.Step = 5 |
| return false |
| default: |
| + if w.h2.App == p.App && w.h2.Kind == "purging" { |
| + w.h2 = liteHost{} // record deleted, claim freed |
| + } |
| if c == nil || c.Holder == nil || c.Holder.App != p.App { |
| - if c == nil || c.Holder == nil { |
| - w.purgeRes[p.App] = false |
| - delete(w.purgeStep, p.App) |
| - } |
| + w.purgeRes[p.App] = false |
| + delete(w.purgeStep, p.App) |
| + ad.Closed = false // the purge's completion reopens admission |
| return true |
| } |
| w.deleteSteps(c) |
| @@ -434,7 +588,7 @@ func (w *world) resumePurges() { |
| // ---------- the door (claim §6.5) ---------- |
| func (w *world) doorAuthorize() { |
| - c := w.claim |
| + c := w.claim // O1 |
| if c == nil || c.Holder == nil || c.Op.Kind != "serving" { |
| return |
| } |
| @@ -442,33 +596,21 @@ func (w *world) doorAuthorize() { |
| if b == nil { |
| return |
| } |
| - bk := w.buckets[b.Region] |
| - key := "instances/" + host + "/ltx" |
| - d := &doorPlan{Region: b.Region, Key: key, Inc: c.Holder.Inc, Until: w.now + 1 + w.pauseLen()} |
| - if on("door-by-version") { |
| - for _, v := range bk.objs[key] { |
| - if !v.Marker && v.B.Inc == c.Holder.Inc { |
| - d.Ver = v.ID |
| - } |
| - } |
| - if d.Ver == 0 { |
| - return |
| - } |
| - } |
| - w.doorReads = append(w.doorReads, d) |
| + w.doorReads = append(w.doorReads, &doorPlan{Region: b.Region, Loc: c.Holder.Location, Gen: c.Holder.Gen, Inc: c.Holder.Inc, OpID: c.Op.ID, |
| + Key: dataKey(c.Holder.Location, c.Holder.Gen, "ltx"), Until: w.now + 1 + w.pauseLen()}) |
| } |
| +// doorOpen advances one stalled listing or file request. A stalled step |
| +// resumes at its worst moment if one comes before its stall ends: when the |
| +// claim's holder has changed since O1. Otherwise it waits out its stall. |
| func (w *world) doorOpen() { |
| if len(w.doorReads) == 0 { |
| return |
| } |
| i := w.rng.Intn(len(w.doorReads)) |
| - // a stalled read resumes at its worst moment if one comes before its |
| - // stall ends: when its location's current object belongs to another |
| - // incarnation. Otherwise it waits out its stall. |
| worst := -1 |
| for j, d := range w.doorReads { |
| - if cur := w.buckets[d.Region].current(d.Key); cur != nil && !cur.Marker && cur.B.Inc != 0 && cur.B.Inc != d.Inc { |
| + if c := w.claim; c == nil || c.Holder == nil || c.Holder.Inc != d.Inc { |
| worst = j |
| break |
| } |
| @@ -480,8 +622,26 @@ func (w *world) doorOpen() { |
| return |
| } |
| d := w.doorReads[i] |
| - w.doorReads = append(w.doorReads[:i:i], w.doorReads[i+1:]...) |
| bk := w.buckets[d.Region] |
| + if !d.Signed { |
| + // select versions only under O1's location (step 3) |
| + if on("door-by-version") { |
| + v := bk.current(d.Key) |
| + if v == nil { |
| + w.doorReads = append(w.doorReads[:i:i], w.doorReads[i+1:]...) |
| + return |
| + } |
| + d.Ver = v.ID |
| + } |
| + // O2: refuse if the holder or the op changed since O1 (step 5) |
| + if c := w.claim; on("door-reread") && (c == nil || c.Holder == nil || c.Holder.Inc != d.Inc || c.Op.ID != d.OpID) { |
| + w.doorReads = append(w.doorReads[:i:i], w.doorReads[i+1:]...) |
| + return |
| + } |
| + d.Signed, d.Until = true, w.now+1+w.pauseLen() |
| + return |
| + } |
| + w.doorReads = append(w.doorReads[:i:i], w.doorReads[i+1:]...) |
| var v *version |
| if d.Ver != 0 { |
| v = bk.byID(d.Key, d.Ver) |
| @@ -500,15 +660,41 @@ func (w *world) recoveryPass() { |
| return |
| } |
| w.resumePurges() |
| + // the second host's create (control plane only) |
| + if h := w.h2; h.Kind == "creating" && w.now-h.At >= 15 { |
| + if h.Rec { |
| + w.h2.Kind = "serving" |
| + } else { |
| + w.h2 = liteHost{} |
| + } |
| + w.logf("recovery: second host's create %s -> %q", h.OpID, w.h2.Kind) |
| + delete(w.admit[h.App].Reg, h.OpID) |
| + } |
| c := w.claim |
| if c == nil { |
| return |
| } |
| - // an orphan record: the claim is free or names someone else |
| - if w.rec != nil && (c.Holder == nil || c.Holder.Inc != w.rec.Inc) && c.Op.Kind != "creating" { |
| - w.logf("recovery: removing orphan record inc %d", w.rec.Inc) |
| - w.rec = nil |
| - 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 && (c.Holder == nil || c.Holder.Inc != w.rec.Inc) && c.Op.Kind != "creating" && on("cancelled-creates") { |
| + orphan := false |
| + for _, x := range c.Cancelled { |
| + if x.Inc == w.rec.Inc && w.now <= x.Until { |
| + orphan = true |
| + } |
| + } |
| + // CC17: a record PUT has no completion bound either (C3a's |
| + // lesson), so `until` cannot close the window. A record whose |
| + // incarnation is retired is an orphan whenever it lands. |
| + if on("orphan-no-deadline") && contains(c.Retired, w.rec.Inc) { |
| + orphan = true |
| + } |
| + if orphan { |
| + w.logf("recovery: removing a cancelled create's late record inc %d", w.rec.Inc) |
| + w.rec = nil |
| + return |
| + } |
| } |
| switch c.Op.Kind { |
| case "creating": |
| @@ -520,22 +706,32 @@ func (w *world) recoveryPass() { |
| if b == nil { |
| return |
| } |
| - w.cas(c, func(n *claim) bool { |
| + if w.cas(c, func(n *claim) bool { |
| if !sameOp(n.Op.ID, c.Op.ID) { |
| return false |
| } |
| n.Op = opState{Kind: "serving", Exec: b.ID, Parts: []int{b.ID}} |
| return true |
| - }) |
| + }) { |
| + delete(w.admit[c.Holder.App].Reg, c.Op.ID) |
| + } |
| } else if w.rec == nil { |
| - w.cas(c, func(n *claim) bool { |
| - if !sameOp(n.Op.ID, c.Op.ID) { |
| + // authoritatively absent: cancel, retiring the incarnation and |
| + // remembering it, so a late record PUT can be removed |
| + inc, app, id := c.Holder.Inc, c.Holder.App, c.Op.ID |
| + if w.cas(c, func(n *claim) bool { |
| + if !sameOp(n.Op.ID, id) { |
| return false |
| } |
| n.Holder, n.Op = nil, opState{} |
| n.Regions, n.Boxes = nil, nil |
| + n.Retired = addUniq(n.Retired, inc) |
| + n.Cancelled = append(n.Cancelled, cancelledCreate{Inc: inc, Until: w.now + 4*horizonH}) |
| return true |
| - }) |
| + }) { |
| + delete(w.admit[app].Reg, id) |
| + w.logf("recovery: cancelled create %s inc %d", id, inc) |
| + } |
| } |
| case "deleting", "parking": |
| if w.now-c.Op.ReqAt < 15 { |
| @@ -633,14 +829,22 @@ func (w *world) operator() { |
| if !ok { |
| return |
| } |
| + if op.Kind == "landing" { |
| + // it proceeds as a rollback, record first (§7.8, §7.10); the |
| + // claim follows, and the loss is applied to the restoring claim on |
| + // the operator's next call |
| + if w.rec != nil && w.rec.MoveID == op.ID && w.rec.MoveState == "landing" { |
| + w.rollback(c, "declared lost") |
| + } else { |
| + w.followRecord(c) |
| + } |
| + return |
| + } |
| if w.cas(c, func(n *claim) bool { |
| - if !sameOp(n.Op.ID, op.ID) || n.Op.Committed { |
| + if !sameOp(n.Op.ID, op.ID) || n.Op.Committed || n.Op.Kind != "restoring" { |
| return false |
| } |
| n.Lost = addUniq(n.Lost, t.ID) |
| - if n.Op.Kind == "landing" { |
| - n.Op.Kind, n.Op.RollbackID, n.Op.SrcPhase = "restoring", w.newID("rb"), "waiting" |
| - } |
| n.Op.Exec, n.Op.DirtyKnown, n.Op.Dirty, n.Op.Phase, n.Op.LostBox, n.Op.LostAt = -1, true, false, "released", t.ID, w.now |
| var keep []obligation |
| for _, o := range n.Obl { |
| @@ -652,7 +856,7 @@ func (w *world) operator() { |
| n.Obl = keep |
| if owesEvidence { // a box that released already queued these |
| n.Obl = append(n.Obl, |
| - w.newObl(n, "park-superseded", op.TgtRegion, -1, "instances/"+host+"/", "", w.now+horizonH), |
| + w.newObl(n, "park-superseded", op.TgtRegion, -1, c.Holder.Location, "", w.now+horizonH), |
| w.newObl(n, "park-staging", op.TgtRegion, -1, fmt.Sprintf("moves/%s/%s/", host, op.ID), "", op.SealQuiet)) |
| } |
| return true |
| @@ -663,6 +867,5 @@ func (w *world) operator() { |
| } |
| } |
| w.logf("operator declared box %d lost for %s", t.ID, op.ID) |
| - w.fixRecord() |
| } |
| } |
diff --git a/internal/sim/hostlife/expected-outcomes.txt b/internal/sim/hostlife/expected-outcomes.txt| new file mode 100644 |
| index 000000000..1cab9ebd4 |
| --- /dev/null |
| +++ b/internal/sim/hostlife/expected-outcomes.txt |
| @@ -0,0 +1 @@ |
| +# rule outcome reason (see mutate.sh) |
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go| index c0ea5c7c1..e23df1460 100644 |
| --- a/internal/sim/hostlife/moves_test.go |
| +++ b/internal/sim/hostlife/moves_test.go |
| @@ -1,6 +1,9 @@ |
| package hostlife |
| -import "fmt" |
| +import ( |
| + "fmt" |
| + "strings" |
| +) |
| // worker is one box agent's lifecycle worker: exec-lease renewal, the |
| // obligations it owns, then its role in the current operation. One call |
| @@ -30,6 +33,7 @@ func (w *world) workerMode(b *box, obl bool) { |
| return |
| } |
| } |
| + w.sweep(b, c) |
| return |
| } |
| if b.stale != nil && w.rng.Intn(6) == 0 { |
| @@ -63,8 +67,9 @@ func (w *world) workerMode(b *box, obl bool) { |
| if b.DB != nil { |
| inc = b.DB.Inc |
| } |
| - w.localPark(b, inc) |
| - b.Row = nil |
| + if w.localPark(b, inc) { |
| + b.Row = nil |
| + } |
| } |
| } |
| return |
| @@ -117,13 +122,19 @@ func (w *world) leasesSeen(c *claim) bool { |
| return true |
| } |
| -// stagingLease: the shared staging-work lease (claim C3), per target region. |
| +// stagingLease: the shared staging-work lease (claim C3), per destination |
| +// region: the target's for a move's staging, the source's for a dirty |
| +// rollback's return lane. |
| func (w *world) takeStaging(c *claim, b *box) bool { |
| - l := w.staging[c.Op.TgtRegion] |
| - if l.Op != "" && l.Op != c.Op.ID && l.Until >= w.now { |
| + return w.takeStagingFor(c.Op.TgtRegion, c.Op.ID, b) |
| +} |
| + |
| +func (w *world) takeStagingFor(region int, opID string, b *box) bool { |
| + l := w.staging[region] |
| + if l.Op != "" && l.Op != opID && l.Until >= w.now { |
| return false |
| } |
| - w.staging[c.Op.TgtRegion] = stagingLease{Op: c.Op.ID, Box: b.ID, Until: w.now + stagingTTL} |
| + w.staging[region] = stagingLease{Op: opID, Box: b.ID, Until: w.now + stagingTTL} |
| return true |
| } |
| @@ -133,11 +144,23 @@ type stagingLease struct { |
| Until int |
| } |
| +// snapshotBlob is a staging round: the source's final checkpoint over its |
| +// replica, which is what the mirror copies (§7.3). With no local database |
| +// (a lost disk, or never woken) it is the replica alone. |
| func (w *world) snapshotBlob(b *box, c *claim) (*blob, *blob) { |
| + return w.snapshotBlobOf(b, c, c.Holder.Gen) |
| +} |
| + |
| +func (w *world) snapshotBlobOf(b *box, c *claim, gen string) (*blob, *blob) { |
| db := &blob{Kind: "snap", Inc: c.Holder.Inc} |
| + if r := w.restore(b, c.Holder.Inc, c.Holder.Location, gen); r != nil { |
| + db.Writes, db.SealKey = r.Writes, r.Key |
| + } |
| if b.DB != nil { |
| - db.Writes = append([]int(nil), b.DB.Writes...) |
| - db.SealKey = b.DB.Key |
| + db.Writes = unionInts(db.Writes, b.DB.Writes) |
| + if b.DB.Key != 0 { |
| + db.SealKey = b.DB.Key |
| + } |
| db.Inc = b.DB.Inc |
| } |
| files := &blob{Kind: "files", Inc: c.Holder.Inc, Files: b.Files.clone(), Timers: append([]int(nil), b.Timers...)} |
| @@ -148,6 +171,12 @@ func (w *world) moveSource(b *box, c *claim) { |
| op := c.Op |
| switch op.Phase { |
| case "leases": |
| + if w.rec != nil && w.rec.Inc == c.Holder.Inc && w.rec.MoveID == "" { |
| + // the record half of the request, after the claim's intent (C2): |
| + // a crash between is finished here or by the recovery pass |
| + w.rec.MoveID, w.rec.MoveState = op.ID, "" |
| + return |
| + } |
| if w.leasesSeen(c) { |
| w.cas(c, func(n *claim) bool { n.Op.Phase = "preparing"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| @@ -159,15 +188,20 @@ func (w *world) moveSource(b *box, c *claim) { |
| srcReady := !on("source-readiness") || (b.Config == c.Holder.Inc && b.Live == c.Holder.App && b.LiveInc == c.Holder.Inc && b.HasKey) |
| if op.Phase == "ready" && op.ReportOK && w.now-op.ReportAt < 60 && op.Auto && srcReady { |
| // auto-cut: admission is atomic with abort eligibility |
| + if w.rec == nil || w.rec.MoveID != op.ID { |
| + return |
| + } |
| + deadline := w.now + cutBudget |
| + if !on("cut-deadline") { |
| + deadline = 1 << 30 |
| + } |
| w.cas(c, func(n *claim) bool { |
| if !sameOp(n.Op.ID, op.ID) || n.Op.CutAt != 0 || n.Op.Kind != "moving" { |
| return false |
| } |
| n.Op.CutAt = w.now |
| - n.Op.CutDeadline = w.now + cutBudget |
| - if !on("cut-deadline") { |
| - n.Op.CutDeadline = 1 << 30 |
| - } |
| + n.Op.CutDeadline = deadline |
| + n.Op.PinVer = w.cfgVer[c.Holder.App] // move.inputs, pinned at admission |
| n.Op.Phase = "stopping" |
| return true |
| }) |
| @@ -228,6 +262,7 @@ func (w *world) moveSource(b *box, c *claim) { |
| } |
| n.Op.Phase, n.Op.StopAt, n.Op.StopDigest, n.Op.StopFiles = "stopped", w.now, d, files |
| n.Op.Repl, n.Op.Next = repl, next |
| + n.Op.Exec = -1 // K3: the source gives up execution, citing its evidence |
| return true |
| }) |
| case "stopped": |
| @@ -269,15 +304,18 @@ func (w *world) moveSource(b *box, c *claim) { |
| }) |
| } |
| case "sealed": |
| - // the flip: kind and exec move together, citing the source's evidence |
| - if w.cas(c, func(n *claim) bool { |
| - if !sameOp(n.Op.ID, op.ID) || n.Op.Phase != "sealed" || n.Op.StopAt == 0 { |
| - return false |
| - } |
| - n.Op.Kind, n.Op.Exec, n.Op.LandPhase, n.Op.FlipAt = "landing", op.Tgt, "checking", w.now |
| - return true |
| - }) { |
| - w.fixRecord() |
| + // the flip, record first (§7.5): one CAS on the record, then the |
| + // claim follows on a later step. A crash between leaves nobody |
| + // with execution, which is safe; whoever next sees the record |
| + // completes the claim (rev 6 §19 K4). |
| + if w.rec != nil && w.rec.Inc == c.Holder.Inc && w.rec.MoveID == op.ID && w.rec.MoveState == "landing" { |
| + w.followRecord(c) |
| + return |
| + } |
| + 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.logf("flip %s: record names region %d", op.ID, op.TgtRegion) |
| } |
| } |
| } |
| @@ -399,19 +437,24 @@ func (w *world) pauseSign() int { return w.rng.Intn(2*stagingTTL + horizonH + 1) |
| // staging round: the first attempt's goroutine paused before signing and |
| // the agent retried. The paused attempt may sign after the seal. |
| func (w *world) maybeStallDuplicate(b *box, op opState, r *request) { |
| + w.maybeStallDuplicateFor(b, op.TgtRegion, r) |
| +} |
| + |
| +func (w *world) maybeStallDuplicateFor(b *box, region int, r *request) { |
| if w.rng.Intn(3) != 0 { |
| return |
| } |
| dup := *r |
| - w.stalled = append(w.stalled, &stalledSign{box: b.ID, epoch: b.Epoch, req: &dup, signAt: w.now + w.pauseSign(), until: w.staging[op.TgtRegion].Until}) |
| + w.stalled = append(w.stalled, &stalledSign{box: b.ID, epoch: b.Epoch, req: &dup, signAt: w.now + w.pauseSign(), until: w.staging[region].Until}) |
| } |
| type stalledSign struct { |
| - box int |
| - epoch int |
| - req *request |
| - signAt int |
| - until int |
| + box int |
| + epoch int |
| + req *request |
| + signAt int |
| + until int |
| + checked bool // passed its SafeUntil check, not yet signed |
| } |
| // runStalled lets paused goroutines resume: deferred registry writes and |
| @@ -427,10 +470,18 @@ func (w *world) runStalled() { |
| keep = append(keep, s) |
| continue |
| } |
| - if on("sign-before-safeuntil") && w.localNow(b) > s.until-maxSkew { |
| + if s.checked { |
| + w.issue(b.ID, s.req) // signs, at most maxPause after the check |
| + continue |
| + } |
| + if on("sign-before-safeuntil") && w.localNow(b) > s.until-maxSkew-maxPause { |
| continue // checked SafeUntil immediately before signing: too late |
| } |
| - w.issue(b.ID, s.req) |
| + // the check passed; the goroutine may still pause before the |
| + // signature, within the claim's §3.1 bound, which SafeUntil's |
| + // margin covers |
| + s.checked, s.signAt = true, w.now+w.rng.Intn(maxPause+1) |
| + keep = append(keep, s) |
| } |
| w.stalled = keep |
| for _, b := range w.boxes { |
| @@ -495,9 +546,9 @@ func (w *world) revertSource(b *box, c *claim) { |
| return |
| } |
| if b.HasKey { // RenderHeld + MaterializeHeld before every probe |
| - b.Config, b.Live, b.LiveInc = c.Holder.Inc, c.Holder.App, c.Holder.Inc |
| + b.Config, b.CfgVer, b.Live, b.LiveInc = c.Holder.Inc, w.cfgVer[c.Holder.App], c.Holder.App, c.Holder.Inc |
| } |
| - if hasMain(b) || w.spawn(b, c, holderLin(c.Holder), true) { |
| + if hasMain(b) || w.spawn(b, c, c.Holder.Gen, true) { |
| w.cas(c, func(n *claim) bool { n.Op.RevPhase = "probed"; return sameOp(n.Op.ID, op.ID) }) |
| return |
| } |
| @@ -525,20 +576,71 @@ func (w *world) holdSourceRow(b *box, c *claim) { |
| } |
| } |
| +// rollback is record first (§7.8): one CAS on the record, landing → |
| +// rolling_back, guarded by move id. The commit is a CAS on the same record |
| +// field, so exactly one of them wins. The claim follows on a later step. |
| func (w *world) rollback(c *claim, why string) { |
| op := c.Op |
| - if w.cas(c, func(n *claim) bool { |
| - if !sameOp(n.Op.ID, op.ID) || n.Op.Kind != "landing" || n.Op.Committed { |
| - return false |
| - } |
| - n.Op.Kind, n.Op.RollbackID, n.Op.SrcPhase, n.Op.Phase = "restoring", w.newID("rb"), "waiting", "releasing" |
| - n.Op.Reason = why |
| - return true |
| - }) { |
| - w.logf("rollback %s: %s", op.ID, why) |
| - w.stats["why: "+why+" @"+op.LandPhase]++ |
| - w.fixRecord() |
| + r := w.rec |
| + if r == nil || r.Inc != c.Holder.Inc || r.MoveID != op.ID || r.MoveState != "landing" { |
| + return |
| + } |
| + r.MoveState, r.Region, r.RollbackID, r.Reason = "rolling_back", op.SrcRegion, w.newID("rb"), why |
| + r.Ver++ |
| + w.logf("rollback %s: %s", op.ID, why) |
| + w.stats["why: "+why+" @"+op.LandPhase]++ |
| +} |
| + |
| +// followRecord completes the claim half of a record transition the claim |
| +// has not caught up with: the flip, the commit, a rollback. Any actor that |
| +// reads both may do it (rev 6 §19 K4): the CAS is fully determined by the |
| +// record and is conditional on the op id and the claim's prior state. |
| +func (w *world) followRecord(c *claim) bool { |
| + r := w.rec |
| + if c == nil || c.Holder == nil || r == nil || r.Inc != c.Holder.Inc || r.MoveID == "" || r.MoveID != c.Op.ID { |
| + return false |
| + } |
| + op := c.Op |
| + switch { |
| + case r.MoveState == "landing" && op.Kind == "moving" && op.Phase == "sealed": |
| + return w.cas(c, func(n *claim) bool { |
| + if !sameOp(n.Op.ID, op.ID) || n.Op.Kind != "moving" || n.Op.Phase != "sealed" { |
| + return false |
| + } |
| + n.Op.Kind, n.Op.Exec, n.Op.LandPhase, n.Op.FlipAt = "landing", op.Tgt, "checking", w.now |
| + return true |
| + }) |
| + case r.MoveState == "committed" && op.Kind == "landing" && !op.Committed: |
| + return w.cas(c, func(n *claim) bool { |
| + if !sameOp(n.Op.ID, op.ID) || n.Op.Kind != "landing" || n.Op.Committed { |
| + return false |
| + } |
| + n.Op.Committed = true |
| + w.newGen(n.Holder.Location, n.Holder.Gen, op.ID) |
| + n.Holder.Gen = op.ID // the claim names the generation restore reads (C3a) |
| + q := op.StopAt + horizonH |
| + if !on("quiet-before-ack") { |
| + q = 0 |
| + } |
| + n.Obl = append(n.Obl, |
| + w.newObl(n, "park-superseded", op.SrcRegion, -1, c.Holder.Location, "", q), |
| + w.newObl(n, "park-local", -1, op.Src, "", "", 0), |
| + w.newObl(n, "park-staging", op.TgtRegion, -1, fmt.Sprintf("moves/%s/%s/", host, op.ID), "", op.SealQuiet)) |
| + if q > n.Quiet[op.SrcRegion] { |
| + n.Quiet[op.SrcRegion] = q |
| + } |
| + return true |
| + }) |
| + case r.MoveState == "rolling_back" && op.Kind == "landing": |
| + return w.cas(c, func(n *claim) bool { |
| + if !sameOp(n.Op.ID, op.ID) || n.Op.Kind != "landing" || n.Op.Committed { |
| + return false |
| + } |
| + n.Op.Kind, n.Op.RollbackID, n.Op.SrcPhase, n.Op.Phase, n.Op.Reason = "restoring", r.RollbackID, "waiting", "releasing", r.Reason |
| + return true |
| + }) |
| } |
| + return false |
| } |
| func (w *world) landTarget(b *box, c *claim) { |
| @@ -546,7 +648,12 @@ func (w *world) landTarget(b *box, c *claim) { |
| if c.Op.Exec != b.ID { |
| return |
| } |
| + loc := c.Holder.Location |
| if !op.Committed { |
| + if w.rec != nil && w.rec.MoveID == op.ID && w.rec.MoveState != "landing" { |
| + w.followRecord(c) // a commit or rollback the claim has not caught up with |
| + return |
| + } |
| w.holdRow(b, "landing", op.ID, c) |
| if w.now > op.CutDeadline { |
| w.rollback(c, "cut deadline") // N15: every phase before the commit |
| @@ -570,7 +677,8 @@ func (w *world) landTarget(b *box, c *claim) { |
| b.mem[op.ID+"quietwait"] = true |
| w.violate("INV-9: the cut of %s waits on quiet_after[%d] until %d", op.ID, b.Region, c.Quiet[b.Region]) |
| } |
| - if w.parkStep(b, "instances/"+host+"/", fmt.Sprintf("superseded/%s/%s/install/", op.ID, host), 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], nil) { |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase = "install-copy"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "install-copy": |
| @@ -589,9 +697,16 @@ func (w *world) landTarget(b *box, c *claim) { |
| } |
| src.Ver = cur.ID |
| } |
| - v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: "instances/" + host + "/base", srcRegion: b.Region, srcKey: src.Key, srcVer: src.Ver}) |
| + // into this move's own generation: a copy that completes late can |
| + // 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.BaseVer, n.Op.LandPhase = v, "local"; return sameOp(n.Op.ID, op.ID) }) |
| + w.cas(c, func(n *claim) bool { n.Op.LandPhase = "published"; return sameOp(n.Op.ID, op.ID) }) |
| + } |
| + case "published": |
| + v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: op.ID, Inc: c.Holder.Inc}}) |
| + if v > 0 { |
| + w.cas(c, func(n *claim) bool { n.Op.LandPhase = "local"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "local": |
| w.installLocal(b, c, op.ID, op.SealFiles, op.LandPhase, func(n *claim) { n.Op.LandPhase = "config" }) |
| @@ -601,13 +716,21 @@ func (w *world) landTarget(b *box, c *claim) { |
| return |
| } |
| if b.HasKey { |
| - b.Config = c.Holder.Inc |
| + b.Config, b.CfgVer = c.Holder.Inc, w.cfgVer[c.Holder.App] |
| + if b.CfgVer != op.PinVer { |
| + // `configured`: the bundle changed after admission (§7.6) |
| + w.rollback(c, "configuration changed during the move") |
| + return |
| + } |
| } |
| b.Live, b.LiveInc = c.Holder.App, c.Holder.Inc |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase = "restore"; return sameOp(n.Op.ID, op.ID) }) |
| case "restore": |
| - if b.DB == nil { |
| - b.DB = w.restore(b, c.Holder.Inc, c.Holder.Legacy, lin{Op: op.ID, FilesGen: op.ID, Region: b.Region, Ver: op.BaseVer}) |
| + if b.DB == nil || b.DB.Gen != op.ID { |
| + if b.DB != nil { |
| + b.Parked++ |
| + } |
| + b.DB = w.restore(b, c.Holder.Inc, loc, op.ID) |
| } |
| if b.DB == nil { |
| w.rollback(c, "restore failed") |
| @@ -623,7 +746,7 @@ func (w *world) landTarget(b *box, c *claim) { |
| if !w.admits(b, c, op.ID) { |
| return |
| } |
| - if hasMain(b) || w.spawn(b, c, lin{Op: op.ID, FilesGen: op.ID, Region: b.Region, Ver: op.BaseVer}, true) { |
| + if hasMain(b) || w.spawn(b, c, op.ID, true) { |
| if !on("commit-before-open") { |
| w.openRow(b, op.ID) |
| } |
| @@ -651,27 +774,16 @@ func (w *world) landTarget(b *box, c *claim) { |
| w.rollback(c, "settle check failed") |
| return |
| } |
| - if w.cas(c, func(n *claim) bool { |
| - if !sameOp(n.Op.ID, op.ID) || n.Op.Kind != "landing" || w.now > n.Op.CutDeadline { |
| - return false |
| - } |
| - n.Op.Committed = true |
| - n.Holder.Lineage = lineage{Op: op.ID, Region: b.Region, Ver: op.BaseVer} |
| - n.Holder.FilesGen = op.ID |
| - q := op.StopAt + horizonH |
| - if !on("quiet-before-ack") { |
| - q = 0 |
| + // the commit, record first (§7.9): a CAS on the same record |
| + // field the rollback uses, conditional on the deadline |
| + r := w.rec |
| + if r != nil && r.MoveID == op.ID && r.MoveState == "landing" && w.now <= op.CutDeadline { |
| + r.MoveState = "committed" |
| + r.Ver++ |
| + 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) |
| } |
| - n.Obl = append(n.Obl, |
| - w.newObl(n, "park-superseded", op.SrcRegion, -1, "instances/"+host+"/", "", q), |
| - w.newObl(n, "park-local", -1, op.Src, "", "", 0), |
| - w.newObl(n, "park-staging", op.TgtRegion, -1, fmt.Sprintf("moves/%s/%s/", host, op.ID), "", op.SealQuiet)) |
| - if q > n.Quiet[op.SrcRegion] { |
| - n.Quiet[op.SrcRegion] = q |
| - } |
| - return true |
| - }) && 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) |
| } |
| return |
| } |
| @@ -750,8 +862,12 @@ func (w *world) release(c *claim, execBox int) { |
| return true |
| }) { |
| w.logf("released %s to serving on box %d", op.ID, execBox) |
| - w.fixRecord() |
| w.opsDone++ |
| + // the record half (CAS-clear move) follows on a later step (§7.9) |
| + w.checkDurable(w.claim, "at the release of "+op.ID) |
| + if r := w.residueAtEnd(); len(r) > 0 { |
| + w.violate("INV-10: %s ended with residue %v", op.ID, r) |
| + } |
| switch { |
| case op.Kind == "landing": |
| w.stats["move-done"]++ |
| @@ -792,7 +908,7 @@ func (w *world) releaseTarget(b *box, c *claim) { |
| n.Op.Exec, n.Op.DirtyKnown, n.Op.Dirty, n.Op.Phase, n.Op.TgtStopAt = -1, true, dirty, "released", w.now |
| q := w.now + horizonH |
| if on("target-park") { |
| - n.Obl = append(n.Obl, w.newObl(n, "park-superseded", op.TgtRegion, -1, "instances/"+host+"/", "", q)) |
| + n.Obl = append(n.Obl, w.newObl(n, "park-superseded", op.TgtRegion, -1, c.Holder.Location, "", q)) |
| } |
| n.Obl = append(n.Obl, w.newObl(n, "park-staging", op.TgtRegion, -1, fmt.Sprintf("moves/%s/%s/", host, op.ID), "", op.SealQuiet)) |
| if !dirty { |
| @@ -806,10 +922,17 @@ func (w *world) releaseTarget(b *box, c *claim) { |
| return |
| } |
| if op.Dirty && op.Phase == "released" && op.Exec == -1 { |
| - // return the target's writes over the approved lane (M17) |
| - db, files := w.snapshotBlob(b, c) |
| + // return the target's writes over the approved lane (M17), under |
| + // the staging-work lease of the source's region, with the same |
| + // paused signers and SafeUntil rule as the forward path |
| + if !w.takeStagingFor(op.SrcRegion, op.RollbackID, b) { |
| + return |
| + } |
| + db, files := w.snapshotBlobOf(b, c, op.ID) |
| if op.ReturnDB.Ver == 0 { |
| - v := w.issue(b.ID, &request{kind: rPut, region: op.SrcRegion, key: fmt.Sprintf("moves/%s/%s/db", host, op.RollbackID), b: db}) |
| + r := &request{kind: rPut, region: op.SrcRegion, key: fmt.Sprintf("moves/%s/%s/db", host, op.RollbackID), b: db} |
| + w.maybeStallDuplicateFor(b, op.SrcRegion, r) |
| + v := w.issue(b.ID, r) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { |
| n.Op.ReturnDB = ref{Region: op.SrcRegion, Key: fmt.Sprintf("moves/%s/%s/db", host, op.RollbackID), Ver: v} |
| @@ -818,14 +941,19 @@ func (w *world) releaseTarget(b *box, c *claim) { |
| } |
| return |
| } |
| - v := w.issue(b.ID, &request{kind: rPut, region: op.SrcRegion, key: fmt.Sprintf("moves/%s/%s/files", host, op.RollbackID), b: files}) |
| + r := &request{kind: rPut, region: op.SrcRegion, key: fmt.Sprintf("moves/%s/%s/files", host, op.RollbackID), b: files} |
| + w.maybeStallDuplicateFor(b, op.SrcRegion, r) |
| + v := w.issue(b.ID, r) |
| if v > 0 { |
| w.cas(c, func(n *claim) bool { |
| if !sameOp(n.Op.ID, op.ID) { |
| return false |
| } |
| n.Op.ReturnFiles = ref{Region: op.SrcRegion, Key: fmt.Sprintf("moves/%s/%s/files", host, op.RollbackID), Ver: v} |
| - n.Op.ReturnQuiet = w.now + horizonH |
| + n.Op.ReturnQuiet = w.now + stagingTTL + horizonH // the forward path's staging horizon (§7.3) |
| + if !on("staging-quiet") { |
| + n.Op.ReturnQuiet = w.now |
| + } |
| n.Op.Phase = "returned" |
| n.Obl = append(n.Obl, w.newObl(n, "park-local", -1, b.ID, "", "", 0)) |
| return true |
| @@ -878,28 +1006,33 @@ func (w *world) recoverSource(b *box, c *claim) { |
| if !w.evidence(b, op.RollbackID) { |
| return |
| } |
| - if w.parkStep(b, "instances/"+host+"/", fmt.Sprintf("superseded/%s/%s/return/", op.ID, host), op.StopAt+horizonH, "") { |
| + if w.parkStep(b, c.Holder.Location, fmt.Sprintf("superseded/%s/%s/return/", op.ID, host), op.StopAt+horizonH, nil) { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "install-copy"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "install-copy": |
| - v := w.issue(b.ID, &request{kind: rCopy, region: b.Region, key: "instances/" + host + "/base", srcRegion: b.Region, srcKey: op.ReturnDB.Key, srcVer: op.ReturnDB.Ver}) |
| + 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) }) |
| + } |
| + case "published": |
| + v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: genMarkerKey(c.Holder.Location), b: &blob{Kind: "gen", Gen: op.RollbackID, Inc: c.Holder.Inc}}) |
| if v > 0 { |
| - w.cas(c, func(n *claim) bool { n.Op.BaseVer, n.Op.SrcPhase = v, "local"; return sameOp(n.Op.ID, op.ID) }) |
| + w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "local"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "local": |
| w.installLocal(b, c, op.RollbackID, op.ReturnFiles, "", func(n *claim) { n.Op.SrcPhase = "config" }) |
| case "config": |
| - b.Config, b.Live, b.LiveInc = c.Holder.Inc, c.Holder.App, c.Holder.Inc |
| + b.Config, b.CfgVer, b.Live, b.LiveInc = c.Holder.Inc, w.cfgVer[c.Holder.App], c.Holder.App, c.Holder.Inc |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "probing"; return sameOp(n.Op.ID, op.ID) }) |
| case "probing": |
| if !w.admits(b, c, op.RollbackID) { |
| return |
| } |
| - L := holderLin(c.Holder) |
| + want := c.Holder.Gen |
| if op.Dirty { |
| - L = lin{Op: op.RollbackID, FilesGen: op.RollbackID, Region: b.Region, Ver: op.BaseVer} |
| + want = op.RollbackID |
| } |
| - if hasMain(b) || w.spawn(b, c, L, true) { |
| + if hasMain(b) || w.spawn(b, c, want, true) { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "probed"; return sameOp(n.Op.ID, op.ID) }) |
| return |
| } |
| @@ -911,10 +1044,10 @@ func (w *world) recoverSource(b *box, c *claim) { |
| return sameOp(n.Op.ID, op.ID) |
| }) |
| case "probed": |
| - if op.Dirty && c.Holder.Lineage.Op != op.RollbackID { |
| + if op.Dirty && c.Holder.Gen != op.RollbackID { |
| w.cas(c, func(n *claim) bool { |
| - n.Holder.Lineage = lineage{Op: op.RollbackID, Region: b.Region, Ver: op.BaseVer} |
| - n.Holder.FilesGen = op.RollbackID |
| + w.newGen(n.Holder.Location, n.Holder.Gen, op.RollbackID) |
| + n.Holder.Gen = op.RollbackID |
| n.Obl = append(n.Obl, w.newObl(n, "park-staging", op.SrcRegion, -1, fmt.Sprintf("moves/%s/%s/", host, op.RollbackID), "", op.ReturnQuiet)) |
| return sameOp(n.Op.ID, op.ID) |
| }) |
| @@ -932,6 +1065,7 @@ func (w *world) recoverSource(b *box, c *claim) { |
| func (w *world) pushStep(b *box, c *claim) { |
| op := c.Op |
| + loc := c.Holder.Location |
| switch op.Phase { |
| case "stopping": |
| w.holdRow(b, "moving", op.ID, c) |
| @@ -939,8 +1073,11 @@ func (w *world) pushStep(b *box, c *claim) { |
| return |
| } |
| var writes []int |
| + if r := w.restore(b, c.Holder.Inc, loc, c.Holder.Gen); r != nil { |
| + writes = r.Writes // the replica, under the final checkpoint |
| + } |
| if b.DB != nil { |
| - writes = append(writes, b.DB.Writes...) |
| + writes = unionInts(writes, b.DB.Writes) |
| } |
| w.nextW++ |
| mark := w.nextW |
| @@ -954,7 +1091,7 @@ func (w *world) pushStep(b *box, c *claim) { |
| return true |
| }) |
| case "park": |
| - if w.parkStep(b, "instances/"+host+"/", fmt.Sprintf("superseded/%s/%s/push/", op.ID, host), op.StopAt+horizonH, "") { |
| + if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/push/", op.ID, host), op.StopAt+horizonH, nil) { |
| w.cas(c, func(n *claim) bool { n.Op.Phase = "install"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "install": |
| @@ -965,34 +1102,47 @@ func (w *world) pushStep(b *box, c *claim) { |
| if b.Files != nil { |
| key = b.Files.Key |
| } |
| - v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: "instances/" + host + "/base", b: &blob{Kind: "base", Inc: c.Holder.Inc, Gen: op.ID, Writes: op.PushWrites, SealKey: key}}) |
| + 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) }) |
| + } |
| + case "published": |
| + v := w.issue(b.ID, &request{kind: rPut, region: b.Region, key: genMarkerKey(loc), b: &blob{Kind: "gen", Gen: op.ID, Inc: c.Holder.Inc}}) |
| if v > 0 { |
| - w.cas(c, func(n *claim) bool { n.Op.BaseVer, n.Op.Phase = v, "restore"; return sameOp(n.Op.ID, op.ID) }) |
| + w.cas(c, func(n *claim) bool { n.Op.Phase = "restore"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "restore": |
| - if b.DB != nil { |
| + if b.DB != nil && b.DB.Gen != op.ID { |
| b.Parked++ |
| b.DB = nil |
| } |
| - b.DB = w.restore(b, c.Holder.Inc, c.Holder.Legacy, lin{Op: op.ID, FilesGen: c.Holder.FilesGen, Region: b.Region, Ver: op.BaseVer}) |
| + if b.DB == nil { |
| + b.DB = w.restore(b, c.Holder.Inc, loc, op.ID) |
| + } |
| if b.DB == nil { |
| return |
| } |
| + // .files-gen last: the files are unchanged, the marker names the |
| + // new generation (F7 on §5.1) |
| + if b.Files != nil { |
| + b.Files.Gen = op.ID |
| + } |
| // every probe renders config and materializes the release for the |
| // held row first (the passes skip held rows): RenderHeld, MaterializeHeld |
| if !b.HasKey { |
| return |
| } |
| - b.Config, b.Live, b.LiveInc = c.Holder.Inc, c.Holder.App, c.Holder.Inc |
| + b.Config, b.CfgVer, b.Live, b.LiveInc = c.Holder.Inc, w.cfgVer[c.Holder.App], c.Holder.App, c.Holder.Inc |
| w.cas(c, func(n *claim) bool { n.Op.Phase = "probing"; return sameOp(n.Op.ID, op.ID) }) |
| case "probing": |
| if !w.admits(b, c, op.ID) { |
| return |
| } |
| - if hasMain(b) || w.spawn(b, c, lin{Op: op.ID, FilesGen: c.Holder.FilesGen, Region: b.Region, Ver: op.BaseVer}, true) { |
| + if hasMain(b) || w.spawn(b, c, op.ID, true) { |
| w.cas(c, func(n *claim) bool { |
| n.Op.Phase = "probed" |
| - n.Holder.Lineage = lineage{Op: op.ID, Region: b.Region, Ver: op.BaseVer} |
| + w.newGen(n.Holder.Location, n.Holder.Gen, op.ID) |
| + n.Holder.Gen = op.ID |
| q := op.StopAt + horizonH |
| if q > n.Quiet[b.Region] { |
| n.Quiet[b.Region] = q |
| @@ -1018,3 +1168,58 @@ func sameStep(a, b *claim) bool { |
| x.SrcPhase == y.SrcPhase && x.RevPhase == y.RevPhase && x.Exec == y.Exec && x.Committed == y.Committed && |
| x.Opened == y.Opened && len(a.Obl) == len(b.Obl) && (a.Holder == nil) == (b.Holder == nil) |
| } |
| + |
| +// sweep is the straggler sweep (moves rev 6 §6.4, CC15): a request accepted |
| +// before S3's signature limit can complete after it (claim C3a), so a |
| +// retirement's horizon listing can miss it. While the host is at rest (the |
| +// claim free, or serving with no operation and no obligation), each region's |
| +// box lists the host namespace in its own bucket and parks, as a retirement |
| +// event of its own, everything outside the live set: the claim's location |
| +// and current generation in the executor's region, and the parkers' locks. |
| +// It re-reads the claim on every visit and abandons the event the moment |
| +// the host is no longer at rest; every delete names a snapshot version, so |
| +// nothing written later is touched. |
| +func (w *world) sweep(b *box, c *claim) { |
| + if !on("straggler-sweep") { |
| + return |
| + } |
| + rest := c == nil || c.Holder == nil && c.Op.Kind == "" && len(c.Obl) == 0 || |
| + c.Holder != nil && c.Op.Kind == "serving" && len(c.Obl) == 0 |
| + if !rest { |
| + b.sweepID = "" |
| + return |
| + } |
| + live := map[string]bool{} |
| + if c != nil && c.Holder != nil && c.Op.Exec == b.ID { |
| + loc, gen := c.Holder.Location, c.Holder.Gen |
| + live[dataKey(loc, gen, "base")], live[dataKey(loc, gen, "ltx")], live[genMarkerKey(loc)] = true, true, true |
| + } |
| + keep := func(k string) bool { return live[k] } |
| + bk := w.buckets[b.Region] |
| + any := false |
| + for _, p := range []string{"instances/" + host + "/", "moves/" + host + "/"} { |
| + for _, k := range bk.keys(p) { |
| + if !strings.HasSuffix(k, "lock.json") && !live[k] && hasNonMarker(bk.objs[k]) { |
| + any = true |
| + } |
| + } |
| + } |
| + if !any && b.sweepID == "" { |
| + return |
| + } |
| + if b.sweepID == "" { |
| + b.sweepID = w.newID("sw") |
| + } |
| + dest := fmt.Sprintf("parked/sweep/%s/%s/", host, b.sweepID) |
| + done := true |
| + for _, p := range []string{"instances/" + host + "/", "moves/" + host + "/"} { |
| + if !w.parkStep(b, p, dest+strings.TrimSuffix(strings.TrimPrefix(p, ""), "/")+"/", 0, keep) { |
| + done = false |
| + } |
| + } |
| + if done { |
| + w.logf("box %d swept stragglers (%s)", b.ID, b.sweepID) |
| + w.stats["swept"]++ |
| + b.sweepID = "" |
| + } |
| +} |
diff --git a/internal/sim/hostlife/mutate.sh b/internal/sim/hostlife/mutate.sh| index 69331449a..51c731272 100755 |
| --- a/internal/sim/hostlife/mutate.sh |
| +++ b/internal/sim/hostlife/mutate.sh |
| @@ -1,5 +1,5 @@ |
| #!/bin/bash |
| -# Mutation checks for the hostlife model (move-any-app spec §14.2.5). |
| +# Mutation checks for the hostlife model (move-any-app spec §14.2). |
| # |
| # Each rule is dropped in its own scratch copy of the module, never in |
| # place: an interrupted in-place mutation once left a disabled guard in a |
| @@ -7,46 +7,89 @@ |
| # |
| # internal/sim/hostlife/mutate.sh 3000 # every rule |
| # internal/sim/hostlife/mutate.sh 30000 rule-a rule-b # named rules |
| -# internal/sim/hostlife/mutate.sh 30000 rule-a,rule-b # a pair, dropped together |
| +# internal/sim/hostlife/mutate.sh 30000 rule-a,rule-b # a combination, dropped together |
| # |
| -# A rule is CAUGHT when at least one seed fails. Scratch copies live under |
| -# ${HOSTLIFE_SCRATCH:-$HOME/.cache/hostlife-sim} and are removed after use. |
| +# Classification (joint round 2, R2-10): |
| +# |
| +# CAUGHT the test ran to completion and reported at least one seed |
| +# that violated an invariant, with the seed number parsed from |
| +# its output (so it can be replayed with HOSTLIFE_SEED); |
| +# MISSED the test ran to completion and every seed passed; |
| +# ERROR anything else: a build failure, a timeout, a panic, a |
| +# read-only cache, or output the harness cannot parse. An |
| +# error is never a detection. |
| +# |
| +# Expected outcomes live in expected-outcomes.txt beside this script: a |
| +# rule listed there as `missed` (liveness-only, unreachable, or caught |
| +# only in a stated combination) is reported as EXPECTED-MISS. The sweep |
| +# exits nonzero on any ERROR, on any unexpected MISSED, and on any rule |
| +# listed as `missed` that is CAUGHT (the expectation is stale). |
| +# |
| +# Every run's full output is kept in $SCRATCH/logs/<rule>.log. |
| set -u |
| SEEDS=${1:?seeds} |
| shift |
| ROOT=$(git -C "$(dirname "$0")" rev-parse --show-toplevel) |
| +PKG="$ROOT/internal/sim/hostlife" |
| SCRATCH=${HOSTLIFE_SCRATCH:-$HOME/.cache/hostlife-sim} |
| -mkdir -p "$SCRATCH/gotmp" |
| +mkdir -p "$SCRATCH/gotmp" "$SCRATCH/logs" "$SCRATCH/verdicts" |
| RULES="$*" |
| if [ -z "$RULES" ]; then |
| - RULES=$(sed -n '/^var allRules/,/^}/p' "$ROOT/internal/sim/hostlife/rules_test.go" | grep -o '^\s*"[a-z0-9-]*"' | tr -d ' "\t') |
| + RULES=$(sed -n '/^var allRules/,/^}/p' "$PKG/rules_test.go" | grep -o '^\s*"[a-z0-9-]*"' | tr -d ' "\t') |
| fi |
| +rm -f "$SCRATCH"/verdicts/* |
| one() { |
| r=$1 |
| - d="$SCRATCH/mut-$(echo "$r" | tr ',' '+')" |
| + tag=$(echo "$r" | tr ',' '+') |
| + d="$SCRATCH/mut-$tag" |
| + log="$SCRATCH/logs/$tag.log" |
| + verdict="$SCRATCH/verdicts/$tag" |
| + for x in $(echo "$r" | tr ',' ' '); do |
| + if ! sed -n '/^var allRules/,/^}/p' "$PKG/rules_test.go" | grep -q "^\s*\"$x\","; then |
| + echo "ERROR $r: no rule named $x" | tee "$verdict" |
| + return |
| + fi |
| + done |
| rm -rf "$d" |
| mkdir -p "$d/internal/sim" |
| cp "$ROOT/go.mod" "$ROOT/go.sum" "$d/" |
| - cp -a "$ROOT/internal/sim/hostlife" "$d/internal/sim/" |
| + cp -a "$PKG" "$d/internal/sim/" |
| sed -i "s/^var mutated = \"\"/var mutated = \"$r\"/" "$d/internal/sim/hostlife/rules_test.go" |
| if ! grep -q "^var mutated = \"$r\"" "$d/internal/sim/hostlife/rules_test.go"; then |
| - echo "SEDFAIL $r" |
| + echo "ERROR $r: could not set the mutation" | tee "$verdict" |
| + rm -rf "$d" |
| return |
| fi |
| - out=$(cd "$d" && GOTMPDIR="$SCRATCH/gotmp" TMPDIR="$SCRATCH/gotmp" HOSTLIFE_SEEDS=$SEEDS \ |
| - go test ./internal/sim/hostlife/ -run TestHostLifecycle -count=1 -timeout 120m -v 2>&1) |
| + (cd "$d" && GOTMPDIR="$SCRATCH/gotmp" TMPDIR="$SCRATCH/gotmp" HOSTLIFE_SEEDS=$SEEDS \ |
| + go test ./internal/sim/hostlife/ -run TestHostLifecycle -count=1 -timeout 180m -v) >"$log" 2>&1 |
| rc=$? |
| - if echo "$out" | grep -q "violated an invariant"; then |
| - n=$(echo "$out" | grep -o "[0-9]* of $SEEDS seeds violated" | head -1) |
| - echo "CAUGHT $r: $n" |
| - elif [ $rc -ne 0 ]; then |
| - echo "CAUGHT $r: test failed rc=$rc" |
| + expect=$(awk -v r="$r" '$1==r {print $2}' "$PKG/expected-outcomes.txt" 2>/dev/null) |
| + nviol=$(grep -o "[0-9]* of $SEEDS seeds violated an invariant" "$log" | head -1 | awk '{print $1}') |
| + seed=$(grep -o "first seed [0-9]*" "$log" | head -1 | awk '{print $3}') |
| + clean=$(grep -c "^ sim_test.go:[0-9]*: $SEEDS seeds, 0 failed" "$log") |
| + if [ $rc -ne 0 ] && [ -n "$nviol" ] && [ -n "$seed" ] && ! grep -q "^panic:\|build failed\|test timed out" "$log"; then |
| + if [ "$expect" = "missed" ]; then |
| + echo "STALE $r: $nviol of $SEEDS seeds violated (first seed $seed), but expected-outcomes.txt says missed" | tee "$verdict" |
| + else |
| + echo "CAUGHT $r: $nviol of $SEEDS seeds violated (first seed $seed)" | tee "$verdict" |
| + fi |
| + elif [ $rc -eq 0 ] && [ "$clean" -eq 1 ]; then |
| + if [ "$expect" = "missed" ]; then |
| + echo "EXPECTED-MISS $r ($SEEDS seeds)" | tee "$verdict" |
| + else |
| + echo "MISSED $r ($SEEDS seeds)" | tee "$verdict" |
| + fi |
| else |
| - echo "MISSED $r ($SEEDS seeds)" |
| + echo "ERROR $r: rc=$rc, no parsable verdict; see $log" | tee "$verdict" |
| fi |
| rm -rf "$d" |
| } |
| export -f one |
| -export SEEDS ROOT SCRATCH |
| +export SEEDS ROOT PKG SCRATCH |
| 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") |
| +if [ "$bad" -gt 0 ]; then |
| + echo "sweep: $bad rule(s) ERROR, MISSED or STALE" >&2 |
| + exit 1 |
| +fi |
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go| index a5bb6df57..675a9dc5e 100644 |
| --- a/internal/sim/hostlife/protocol_test.go |
| +++ b/internal/sim/hostlife/protocol_test.go |
| @@ -161,50 +161,55 @@ func (w *world) evidence(b *box, opID string) bool { |
| // ---------- restore and spawn ---------- |
| -type lin struct { |
| - Op string |
| - FilesGen string |
| - Region int |
| - Ver int |
| -} |
| - |
| -func (w *world) restore(b *box, inc int, legacy bool, L lin) *localDB { |
| +// restore rebuilds a database from location loc, generation gen, in b's own |
| +// bucket (claim C3a; moves rev 6 §5.1). It replays every replica object of |
| +// that generation, as litestream replays every LTX file of a generation: |
| +// base snapshots and later LTX alike, current and noncurrent. It never |
| +// looks at another location or generation. The mutation restores the |
| +// newest single object anywhere under the location instead, which is what |
| +// a prefix restore without generation identity does. |
| +func (w *world) restore(b *box, inc int, loc, gen string) *localDB { |
| bk := w.buckets[b.Region] |
| - db := &localDB{Inc: inc, Gen: L.Op} |
| - if L.Op != "" { |
| - if L.Region != b.Region { |
| - return nil |
| + db := &localDB{Inc: inc, Loc: loc, Gen: gen} |
| + if !on("restore-by-generation") { |
| + var best *version |
| + for _, k := range bk.keys(loc) { |
| + if !isDataKey(k) { |
| + continue |
| + } |
| + for _, v := range bk.objs[k] { |
| + if !v.Marker && (best == nil || v.ID > best.ID) { |
| + best = v |
| + } |
| + } |
| } |
| - v := bk.byID("instances/"+host+"/base", L.Ver) |
| - if v == nil { |
| - return nil |
| + if best != nil { |
| + db.Writes = append([]int(nil), best.B.Writes...) |
| + db.Key = best.B.SealKey |
| + if best.B.Inc != 0 { |
| + db.Inc = best.B.Inc |
| + } |
| } |
| - db.Writes = append(db.Writes, v.B.Writes...) |
| - db.Key = v.B.SealKey |
| - db.Inc = v.B.Inc |
| + return db |
| } |
| - var best *version |
| - if on("restore-lineage-filter") { |
| - for _, v := range bk.objs["instances/"+host+"/ltx"] { |
| + found := false |
| + for _, k := range []string{dataKey(loc, gen, "base"), dataKey(loc, gen, "ltx")} { |
| + for _, v := range bk.objs[k] { |
| if v.Marker { |
| continue |
| } |
| - okInc := v.B.Inc == inc || (legacy && v.B.Inc == 0) |
| - if okInc && v.B.Gen == L.Op && (best == nil || len(v.B.Writes) >= len(best.B.Writes)) { |
| - best = v |
| + found = true |
| + db.Writes = unionInts(db.Writes, v.B.Writes) |
| + if db.Key == 0 { |
| + db.Key = v.B.SealKey |
| + } |
| + if v.B.Inc != 0 { |
| + db.Inc = v.B.Inc |
| } |
| } |
| - } else { |
| - best = bk.current("instances/" + host + "/ltx") |
| } |
| - if best != nil { |
| - if best.B.Inc != 0 { |
| - db.Inc = best.B.Inc |
| - } |
| - db.Writes = unionInts(db.Writes, best.B.Writes) |
| - if db.Key == 0 { |
| - db.Key = best.B.SealKey |
| - } |
| + if !found && gen != "" { |
| + return nil // an installed generation with nothing in it: refuse |
| } |
| return db |
| } |
| @@ -221,33 +226,68 @@ func unionInts(a, b []int) []int { |
| return out |
| } |
| -// spawn starts the app on b for the holder incarnation, with the given |
| -// expected lineage. It returns true if the app answered its readiness path. |
| -func (w *world) spawn(b *box, c *claim, L lin, probe bool) bool { |
| +// bucketGen reads generation.json at loc in b's own bucket: the generation |
| +// and incarnation it names, or origin when absent. |
| +func (w *world) bucketGen(b *box, loc string) (string, int) { |
| + v := w.buckets[b.Region].current(genMarkerKey(loc)) |
| + if v == nil { |
| + return "", 0 |
| + } |
| + return v.B.Gen, v.B.Inc |
| +} |
| + |
| +// spawn starts the app on b for the holder incarnation. want is the |
| +// generation the claim expects: the holder's current generation for an |
| +// ordinary wake, or the generation an operation is installing for its |
| +// probe. It returns true if the app answered its readiness path. |
| +func (w *world) spawn(b *box, c *claim, want string, probe bool) bool { |
| h := c.Holder |
| + loc := h.Location |
| if b.Old { |
| - // an old executable: no claim, no markers, best-effort placement |
| + // an old executable: no claim, no markers, no locations; it |
| + // restores whatever is newest at the legacy root |
| if b.DB == nil { |
| - b.DB = w.restore(b, h.Inc, true, lin{}) |
| + b.DB = &localDB{Inc: h.Inc, Loc: "instances/" + host + "/"} |
| + if v := w.buckets[b.Region].current("instances/" + host + "/ltx"); v != nil { |
| + b.DB.Writes, b.DB.Key = append([]int(nil), v.B.Writes...), v.B.SealKey |
| + if v.B.Inc != 0 { |
| + b.DB.Inc = v.B.Inc |
| + } |
| + } |
| } |
| } else { |
| + bg, binc := w.bucketGen(b, loc) |
| + if !on("start-check-claim-gen") { |
| + // rev 5's start check: the box's own markers decide, so the |
| + // generation to restore is the one its region's generation.json |
| + // names |
| + want = bg |
| + } |
| + if on("start-check") { |
| + if bg != want || (bg != "" && binc != h.Inc) { |
| + w.logf("box %d start check refused: generation.json %q inc %d, claim wants %q", b.ID, bg, binc, want) |
| + return false |
| + } |
| + // a warm database from an earlier stay is parked, and the box |
| + // restores cold (§5.1) |
| + if b.DB != nil && (b.DB.Gen != bg || b.DB.Loc != loc) { |
| + b.Parked++ |
| + b.DB = nil |
| + } |
| + } |
| if b.DB == nil { |
| - b.DB = w.restore(b, h.Inc, h.Legacy, L) |
| + b.DB = w.restore(b, h.Inc, loc, want) |
| if b.DB == nil { |
| return false |
| } |
| } |
| if on("start-check") { |
| - if b.DB.Gen != L.Op || b.DB.Inc != h.Inc && !(h.Legacy && b.DB.Inc == 0) { |
| - w.logf("box %d start check refused: db gen %q inc %d want %q %d", b.ID, b.DB.Gen, b.DB.Inc, L.Op, h.Inc) |
| + if b.DB.Inc != h.Inc && !(h.Legacy && b.DB.Inc == 0) { |
| + w.logf("box %d start check refused: db inc %d", b.ID, b.DB.Inc) |
| return false |
| } |
| - if b.Files != nil && (b.Files.Gen != L.FilesGen || b.Files.Inc != h.Inc) { |
| - w.logf("box %d start check refused: files gen %q inc %d", b.ID, b.Files.Gen, b.Files.Inc) |
| - return false |
| - } |
| - if b.Files == nil && L.FilesGen != "" { |
| - w.logf("box %d start check refused: files missing for gen %q", b.ID, L.FilesGen) |
| + if b.Files != nil && (b.Files.Gen != bg || b.Files.Inc != h.Inc) { |
| + w.logf("box %d start check refused: files gen %q inc %d, generation.json %q", b.ID, b.Files.Gen, b.Files.Inc, bg) |
| return false |
| } |
| } |
| @@ -264,7 +304,7 @@ func (w *world) spawn(b *box, c *claim, L lin, probe bool) bool { |
| } |
| if b.Files == nil { |
| w.nextID++ |
| - b.Files = &filesDir{Inc: h.Inc, Key: w.nextID, Gen: L.FilesGen, Env: w.nextID, Done: true} |
| + b.Files = &filesDir{Inc: h.Inc, Key: w.nextID, Gen: want, Env: w.nextID, Done: true} |
| } |
| if b.DB.Key == 0 { |
| b.DB.Key = b.Files.Key // unsealed data (never booted) adopts the key |
| @@ -275,28 +315,32 @@ func (w *world) spawn(b *box, c *claim, L lin, probe bool) bool { |
| } |
| // FailApp is the injected "the target cannot run it" fault: landing probes only |
| healthy := b.Config == h.Inc && b.Live == h.App && b.LiveInc == h.Inc && !(probe && b.FailApp && c.Op.Kind == "landing") |
| + // §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 |
| + // records them (rev 6, J2-R2-5); the mutation keeps rev 5's rule, which |
| + // pins recoveries too. |
| + if probe && c.Op.PinVer != 0 && b.CfgVer != c.Op.PinVer { |
| + switch { |
| + case c.Op.Kind == "landing": |
| + healthy = false |
| + case (c.Op.Kind == "reverting" || c.Op.Kind == "restoring") && !on("recovery-current-inputs"): |
| + healthy = false |
| + } |
| + } |
| if !healthy { |
| w.logf("box %d unhealthy start inc %d: cfg=%d live=%s/%d fail=%v kind=%s", b.ID, h.Inc, b.Config, b.Live, b.LiveInc, b.FailApp, c.Op.Kind) |
| return false |
| } |
| strict := !b.Old && (on("strict-spawn") || w.rng.Intn(2) == 0) |
| - b.Procs = append(b.Procs, &proc{Main: true, Strict: strict, Inc: h.Inc, Gen: L.Op, Box: b.ID}) |
| + b.Procs = append(b.Procs, &proc{Main: true, Strict: strict, Inc: h.Inc, Gen: want, Box: b.ID}) |
| if w.rng.Intn(2) == 0 { |
| - b.Procs = append(b.Procs, &proc{Main: false, Strict: strict, Inc: h.Inc, Gen: L.Op, Box: b.ID}) |
| + b.Procs = append(b.Procs, &proc{Main: false, Strict: strict, Inc: h.Inc, Gen: want, Box: b.ID}) |
| } |
| - w.logf("box %d spawned inc %d gen %q (probe=%v strict=%v) claim=%s/%s exec=%d", b.ID, h.Inc, L.Op, probe, strict, c.Op.Kind, c.Op.ID, c.Op.Exec) |
| + w.logf("box %d spawned inc %d gen %q (probe=%v strict=%v) claim=%s/%s exec=%d", b.ID, h.Inc, want, probe, strict, c.Op.Kind, c.Op.ID, c.Op.Exec) |
| return true |
| } |
| -func holderLin(h *holder) lin { |
| - return lin{Op: h.Lineage.Op, FilesGen: h.LinFiles(), Region: h.Lineage.Region, Ver: h.Lineage.Ver} |
| -} |
| - |
| -// LinFiles: the files generation compatible with the lineage. Moves set it |
| -// to the move id; a push (not modelled as a separate generation of files) |
| -// keeps it. |
| -func (h *holder) LinFiles() string { return h.FilesGen } |
| - |
| // ---------- admission (C4, §19.1 of the moves spec) ---------- |
| func (w *world) admits(b *box, c *claim, probe string) bool { |
| @@ -353,16 +397,19 @@ func (w *world) admits(b *box, c *claim, probe string) bool { |
| // parkStep advances one park of `prefix` in b's own bucket into dest. It |
| // returns true when the location is verified empty after the quiet time. |
| -// excludeGen keeps the current lineage's objects (push). |
| -func (w *world) parkStep(b *box, prefix, dest string, quiet int, excludeGen string) bool { |
| - return w.parkDepth(b, prefix, dest, quiet, excludeGen, 0) |
| +// keep, when set, names keys the park must leave alone. |
| +func (w *world) parkStep(b *box, prefix, dest string, quiet int, keep func(string) bool) bool { |
| + return w.parkDepth(b, prefix, dest, quiet, keep, 0) |
| } |
| -func (w *world) parkDepth(b *box, prefix, dest string, quiet int, excludeGen string, depth int) bool { |
| +func (w *world) parkDepth(b *box, prefix, dest string, quiet int, keep func(string) bool, depth int) bool { |
| if depth > 8 { |
| return false // a park that cannot finish: reported as non-convergence |
| } |
| bk := w.buckets[b.Region] |
| + skip := func(k string) bool { |
| + return (strings.HasSuffix(k, "lock.json") && on("park-skip-lock")) || (keep != nil && keep(k)) |
| + } |
| // the parker takes the location's replica lease first (claim §6.2 step 1): |
| // its own lock.json, renewed on every visit |
| w.issue(b.ID, &request{kind: rPut, region: b.Region, key: prefix + "lock.json", b: &blob{Kind: "lock", Text: fmt.Sprintf("box%d@%d", b.ID, w.now)}}) |
| @@ -376,31 +423,32 @@ func (w *world) parkDepth(b *box, prefix, dest string, quiet int, excludeGen str |
| } |
| man := bk.current(manKey) |
| if man == nil { |
| - // snapshot every data version except the live lock |
| - var snap []ref |
| + // the snapshot (claim rev 4 §6.2 step 2): the archive set is every |
| + // non-marker version, current and noncurrent; the deletion set is |
| + // every version, delete markers included. The mutation archives |
| + // and deletes only current versions, so older ones resurface. |
| + var archive, dels []ref |
| for _, k := range bk.keys(prefix) { |
| - if strings.HasSuffix(k, "lock.json") && on("park-skip-lock") { |
| + if skip(k) { |
| continue |
| } |
| vs := bk.objs[k] |
| for i, v := range vs { |
| - if v.Marker { |
| + current := i == len(vs)-1 |
| + if !on("park-all-versions") && !current { |
| continue |
| } |
| - if excludeGen != "" && v.B != nil && v.B.Gen == excludeGen { |
| - continue |
| - } |
| - if !on("park-all-versions") && i != len(vs)-1 { |
| - continue |
| + dels = append(dels, ref{Region: b.Region, Key: k, Ver: v.ID}) |
| + if !v.Marker { |
| + archive = append(archive, ref{Region: b.Region, Key: k, Ver: v.ID}) |
| } |
| - snap = append(snap, ref{Region: b.Region, Key: k, Ver: v.ID}) |
| } |
| } |
| - // copy each version by id; a destination keyed by the source |
| - // version is idempotent, so progress accumulates across attempts |
| + // copy each archive-set version by id; a destination keyed by the |
| + // source version is idempotent, so progress accumulates |
| all := true |
| - for _, r := range snap { |
| - dk := dest + strings.TrimPrefix(r.Key, "instances/"+host+"/") + fmt.Sprintf(".v%d", r.Ver) |
| + for _, r := range archive { |
| + dk := dest + strings.TrimPrefix(r.Key, "instances/"+host+"/") + fmt.Sprintf("@%d", r.Ver) |
| if bk.current(dk) != nil { |
| continue |
| } |
| @@ -410,13 +458,13 @@ func (w *world) parkDepth(b *box, prefix, dest string, quiet int, excludeGen str |
| if !all { |
| return false |
| } |
| - // the manifest last, once every copy exists |
| - w.issue(b.ID, &request{kind: rPut, region: b.Region, key: manKey, b: &blob{Kind: "manifest", Refs: snap}}) |
| + // the manifest last, once every copy exists, with both sets |
| + w.issue(b.ID, &request{kind: rPut, region: b.Region, key: manKey, b: &blob{Kind: "manifest", Refs: archive, Dels: dels}}) |
| return false |
| } |
| - // delete the manifest's versions, in one batch per visit |
| + // replay the manifest's deletion set, by version id, in one batch per visit |
| issued := false |
| - for _, r := range man.B.Refs { |
| + for _, r := range man.B.Dels { |
| if bk.byID(r.Key, r.Ver) == nil { |
| continue |
| } |
| @@ -436,13 +484,12 @@ func (w *world) parkDepth(b *box, prefix, dest string, quiet int, excludeGen str |
| } |
| // a straggler written after the snapshot: park it as a new event |
| for _, k := range bk.keys(prefix) { |
| - if strings.HasSuffix(k, "lock.json") && on("park-skip-lock") { |
| + if skip(k) { |
| continue |
| } |
| for _, v := range bk.objs[k] { |
| - if !v.Marker && !(excludeGen != "" && v.B.Gen == excludeGen) { |
| - // start a further retirement event under a fresh id |
| - return w.parkDepth(b, prefix, dest+"s/", quiet, excludeGen, depth+1) |
| + if !v.Marker { |
| + return w.parkDepth(b, prefix, dest+"s/", quiet, keep, depth+1) |
| } |
| } |
| } |
| @@ -451,19 +498,33 @@ func (w *world) parkDepth(b *box, prefix, dest string, quiet int, excludeGen str |
| } |
| // localPark retires b's local state for incarnation inc under the host |
| -// guard: a retired marker first, then rename everything aside. |
| -func (w *world) localPark(b *box, inc int) { |
| - if on("hostguard") { |
| +// 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{} |
| } |
| b.Retired[inc] = true |
| + return false |
| } |
| - if b.DB != nil || b.Files != nil || b.Config != 0 || b.Live != "" { |
| - b.Parked++ |
| + switch { |
| + case b.DB != nil: |
| + b.DB = nil |
| + case b.Files != nil: |
| + b.Files = nil |
| + case b.Config != 0: |
| + b.Config = 0 |
| + case b.Live != "": |
| + b.Live, b.LiveInc = "", 0 |
| + case len(b.Timers) > 0 || len(b.History) > 0 || b.Attempt != 0: |
| + b.Timers, b.History, b.Attempt = nil, nil, 0 |
| + default: |
| + return true |
| } |
| - b.DB, b.Files, b.Config, b.Live, b.LiveInc = nil, nil, 0, "", 0 |
| - b.Timers, b.History, b.Attempt = nil, nil, 0 |
| + b.Parked++ |
| + return false |
| } |
| // ---------- obligations ---------- |
| @@ -493,7 +554,9 @@ func (w *world) doObligation(b *box, c *claim, o obligation) { |
| if b.DB != nil && b.DB.Inc != 0 { |
| inc = b.DB.Inc |
| } |
| - w.localPark(b, inc) |
| + if !w.localPark(b, inc) { |
| + 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) { |
| b.Row = nil |
| } |
| @@ -519,7 +582,7 @@ func (w *world) doObligation(b *box, c *claim, o obligation) { |
| quiet = b.evidAt[o.ID] + horizonH |
| } |
| } |
| - if !w.parkStep(b, o.Prefix, o.Dest(), quiet, o.excludeGen(c)) { |
| + if !w.parkStep(b, o.Prefix, o.Dest(), quiet, nil) { |
| return |
| } |
| } |
| @@ -552,8 +615,6 @@ func (o obligation) Dest() string { |
| return fmt.Sprintf("superseded/%s/%s/%s/", o.Op, host, o.ID) |
| } |
| -func (o obligation) excludeGen(c *claim) string { return o.Exclude } |
| - |
| func (w *world) newObl(c *claim, kind string, region, boxID int, prefix, ret string, quiet int) obligation { |
| return obligation{ID: w.newID("ob"), Op: c.Op.ID, Kind: kind, Region: region, Box: boxID, Prefix: prefix, Ret: ret, Quiet: quiet} |
| } |
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go| index ba2431bf4..58b8fd7c5 100644 |
| --- a/internal/sim/hostlife/rules_test.go |
| +++ b/internal/sim/hostlife/rules_test.go |
| @@ -43,7 +43,12 @@ var allRules = []string{ |
| "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 |
| + "admission-registration", // claim rev 4 §5.1 step 0, §5.3 step 1b (J2-R2-2) |
| + "registration-recovery", // CC16: a registration is a lease, so a dead create cannot block purge |
| + "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` |
| "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 |
| "evidence-whole-tree", // §6.1: stray and non-strict processes |
| "evidence-from-state", // §6.1/R2-2 |
| @@ -53,26 +58,29 @@ var allRules = []string{ |
| "floor-boot-recovery", // §10/J-N12 |
| "participant-rows", // claim §2/R2-4 |
| "stale-hold-release", // this model's finding: release a hold whose op ended |
| - "restore-lineage-filter", // this model's finding (replication exception) |
| + "restore-by-generation", // C3a + moves rev 6 §5.1: restore replays the claim's generation only |
| "quiet-before-ack", // this model's finding (late writes after a park) |
| + "straggler-sweep", // CC15 + moves rev 6 §6.4: stragglers past H are parked at rest |
| // moves spec |
| - "seal-by-version", // J-N2: landing copies sealed versions |
| - "staging-quiet", // J-N2: staging retired after its writers' horizon |
| - "commit-before-open", // INV-5 |
| - "hold-op-open", // §5.2 conditional open |
| - "start-check", // INV-4 |
| - "markers-last", // R2-6 |
| - "revert-journal", // J-N11 |
| - "rollback-journal", // R2-3 |
| - "dirty-return", // INV-2 |
| - "target-park", // R2-5/INV-10 |
| - "abort-target-owner", // J-N13 |
| - "timer-freeze", // J-N14 runner bookkeeping |
| - "timer-canonical", // J-N14 prune path |
| - "cut-deadline", // J-N15 |
| - "readiness-key", // T2 |
| - "source-readiness", // S1: the source can run the instance before the cut |
| - "readiness-quiet", // T12: the target region's quiet_after has passed |
| - "files-carried", // §8 (the kpmg outage) |
| - "config-before-probe", // L3 (the kpmg outage) |
| + "seal-by-version", // J-N2: landing copies sealed versions |
| + "staging-quiet", // J-N2: staging retired after its writers' horizon |
| + "commit-before-open", // INV-5 |
| + "hold-op-open", // §5.2 conditional open |
| + "start-check", // INV-4: markers agree |
| + "start-check-claim-gen", // rev 6 §5.1 (R2-8): and name the generation the claim expects |
| + "markers-last", // R2-6 |
| + "revert-journal", // J-N11 |
| + "rollback-journal", // R2-3 |
| + "dirty-return", // INV-2 |
| + "target-park", // R2-5/INV-10 |
| + "abort-target-owner", // J-N13 |
| + "timer-freeze", // J-N14 runner bookkeeping |
| + "timer-canonical", // J-N14 prune path |
| + "cut-deadline", // J-N15 |
| + "readiness-key", // T2 |
| + "source-readiness", // S1: the source can run the instance before the cut |
| + "readiness-quiet", // T12: the target region's quiet_after has passed |
| + "files-carried", // §8 (the kpmg outage) |
| + "config-before-probe", // L3 (the kpmg outage) |
| + "recovery-current-inputs", // rev 6 §7.7 (J2-R2-5): a recovery probe is not bound to the move's pinned inputs |
| } |
diff --git a/internal/sim/hostlife/sim_test.go b/internal/sim/hostlife/sim_test.go| index ad04fe588..406b0116a 100644 |
| --- a/internal/sim/hostlife/sim_test.go |
| +++ b/internal/sim/hostlife/sim_test.go |
| @@ -16,12 +16,13 @@ const ( |
| func newWorld(seed int64, verbose bool) *world { |
| w := &world{seed: seed, rng: rand.New(rand.NewSource(seed)), verbose: verbose, |
| - stores: map[string]bool{app0: true, app1: true}, purgeRes: map[string]bool{}, purgeStep: map[string]int{}, |
| - control: newBucket(), delivered: map[int]bool{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}} |
| + stores: map[string]bool{app0: true, app1: true}, cfgVer: map[string]int{app0: 1, app1: 1}, purgeRes: map[string]bool{}, purgeStep: map[string]int{}, |
| + control: newBucket(), delivered: map[int]bool{}, durable: map[string]map[int]bool{}, genParent: map[string]string{}, execLease: map[int]execLease{}, fail: map[string]bool{}, stats: map[string]int{}} |
| for r := 0; r < nRegions; r++ { |
| w.buckets[r] = newBucket() |
| } |
| - for _, f := range []string{"partition", "freeze", "pause", "destroy"} { |
| + w.admit = map[string]*admission{app0: {Reg: map[string]regEntry{}}, app1: {Reg: map[string]regEntry{}}} |
| + for _, f := range []string{"partition", "freeze", "pause", "destroy", "disk"} { |
| w.fail[f] = w.rng.Intn(2) == 0 |
| } |
| for r := 0; r < nRegions; r++ { |
| @@ -61,15 +62,23 @@ func (w *world) legacyStart() { |
| w.acks = append(w.acks, ack{W: w.nextW, Inc: inc, Box: b.ID, At: 0, Kind: "write"}) |
| } |
| b.Row = &row{Inc: inc, App: app0, Status: "ready"} |
| - b.DB = &localDB{Inc: inc, Key: key, Writes: writes} |
| + b.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, Writes: writes} |
| b.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true} |
| - b.Config, b.Live, b.LiveInc = inc, app0, inc |
| + b.Config, b.CfgVer, b.Live, b.LiveInc = inc, 1, app0, inc |
| w.buckets[r].objs["instances/"+host+"/ltx"] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes, SealKey: key}}} |
| + w.markDurable("instances/"+host+"/ltx", &blob{Writes: writes}) |
| other := (r + 1 + w.rng.Intn(nRegions-1)) % nRegions |
| w.buckets[other].objs["instances/"+host+"/ltx"] = []*version{{ID: w.newVer(), B: &blob{Kind: "ltx", Inc: 0, Writes: writes[:1], SealKey: key}}} |
| if w.rng.Intn(2) == 0 { |
| b.Procs = []*proc{{Main: true, Strict: false, Inc: inc, Box: b.ID}, {Main: false, Strict: false, Inc: inc, Box: b.ID}} |
| } |
| + if w.rng.Intn(2) == 0 { |
| + // that earlier move also left the old box's local state behind: |
| + // a database, a files directory and env files, and no row |
| + ob := w.regionBox(other) |
| + ob.DB = &localDB{Inc: inc, Loc: locationFor(inc, true), Key: key, Writes: writes[:1]} |
| + ob.Files = &filesDir{Inc: inc, Key: key, Env: 1, Done: true} |
| + } |
| } |
| // ---------- the scheduler ---------- |
| @@ -77,13 +86,25 @@ func (w *world) legacyStart() { |
| func (w *world) step() { |
| w.now++ |
| w.completeDue(false) |
| + if p := w.recPend; p != nil && p.at <= w.now { |
| + w.recPend = nil |
| + if w.rec == nil { // create-only: it lands only where no record is |
| + w.rec = p.rec |
| + w.logf("a late record PUT for inc %d landed", p.rec.Inc) |
| + } |
| + } |
| w.runStalled() |
| bs := w.liveBoxes() |
| b := bs[w.rng.Intn(len(bs))] |
| n := w.rng.Intn(100) |
| switch { |
| case !w.calm && n < 6: |
| - switch w.rng.Intn(9) { |
| + switch w.rng.Intn(10) { |
| + case 9: |
| + // a config or secrets update publishes a new bundle version |
| + app := []string{app0, app1}[w.rng.Intn(2)] |
| + w.cfgVer[app]++ |
| + w.logf("config bundle %s v%d published", app, w.cfgVer[app]) |
| case 0, 1: |
| w.userCreate() |
| case 2, 7, 8: |
| @@ -174,9 +195,27 @@ func (w *world) heal(final bool) { |
| } |
| func (w *world) quiescent() bool { |
| - if len(w.pending) > 0 || len(w.console) > 0 { |
| + if len(w.pending) > 0 || len(w.console) > 0 || w.recPend != nil || !w.atRest() { |
| return false |
| } |
| + return len(w.residue()) == 0 |
| +} |
| + |
| +// atRest: the claim, record and boxes have converged. |
| +func (w *world) atRest() bool { |
| + if w.h2.Kind == "creating" || w.h2.Kind == "purging" { |
| + return false |
| + } |
| + for _, ad := range w.admit { |
| + if ad.Closed { |
| + return false |
| + } |
| + for _, e := range ad.Reg { |
| + if w.now <= e.Until || !on("registration-recovery") { |
| + return false // a live registration: its create is still in flight |
| + } |
| + } |
| + } |
| c := w.claim |
| if c == nil { |
| return w.rec == nil |
| @@ -217,6 +256,9 @@ func (w *world) checkStep() { |
| w.violate("INV-1: boxes %v run the host at once", writers) |
| } |
| // an app store exists while any instance of the app is held for serving |
| + if w.h2.Kind == "serving" && !w.stores[w.h2.App] { |
| + w.violate("purge: store of app %s destroyed while its second host serves", w.h2.App) |
| + } |
| if c := w.claim; c != nil && c.Holder != nil && !w.stores[c.Holder.App] { |
| switch c.Op.Kind { |
| case "purging", "deleting", "parking": |
| @@ -240,8 +282,98 @@ func (w *world) boxesState() string { |
| return s |
| } |
| +// residue lists live-namespace data that should not exist: everything |
| +// under the host's namespace in a region the host does not serve from, and |
| +// in the serving region anything but the claim's location and generation |
| +// (and the parkers' own locks). Nothing here writes. |
| +func (w *world) residue() []string { return w.residueOf(false) } |
| + |
| +// residueAtEnd is INV-10 as the spec states it, checked when an operation |
| +// ends (a release to serving, a free): no copy outside the live set, except |
| +// a straggler that completed after H, which only the sweep can retire. |
| +func (w *world) residueAtEnd() []string { return w.residueOf(true) } |
| + |
| +func (w *world) residueOf(exceptStragglers bool) []string { |
| + var out []string |
| + c := w.claim |
| + live := map[string]bool{} |
| + serving := -1 |
| + if c != nil && c.Holder != nil { |
| + if e := w.execBox(c); e != nil { |
| + serving = e.Region |
| + loc, gen := c.Holder.Location, c.Holder.Gen |
| + live[dataKey(loc, gen, "base")] = true |
| + live[dataKey(loc, gen, "ltx")] = true |
| + live[genMarkerKey(loc)] = true |
| + } |
| + } |
| + for r := 0; r < nRegions; r++ { |
| + bk := w.buckets[r] |
| + for _, p := range []string{"instances/" + host + "/", "moves/" + host + "/"} { |
| + for _, k := range bk.keys(p) { |
| + if strings.HasSuffix(k, "lock.json") || (r == serving && live[k]) { |
| + continue |
| + } |
| + if exceptStragglers && w.owed(r, k) { |
| + continue // a pending chore of another operation covers it |
| + } |
| + for _, v := range bk.objs[k] { |
| + if !v.Marker && !(exceptStragglers && v.Straggler) { |
| + out = append(out, fmt.Sprintf("r%d:%s", r, k)) |
| + break |
| + } |
| + } |
| + } |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// owed: an open park obligation names this region and a prefix of k. |
| +func (w *world) owed(r int, k string) bool { |
| + if w.claim == nil { |
| + return false |
| + } |
| + for _, o := range w.claim.Obl { |
| + if o.Region == r && o.Prefix != "" && strings.HasPrefix(k, o.Prefix) { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func hasNonMarker(vs []*version) bool { |
| + for _, v := range vs { |
| + if !v.Marker { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// canStart is the start check and admission, computed without starting |
| +// anything: the final oracle never writes. |
| +func (w *world) canStart(e *box, c *claim) string { |
| + if !w.admits(e, c, "") { |
| + return "not admitted" |
| + } |
| + h := c.Holder |
| + bg, binc := w.bucketGen(e, h.Location) |
| + if bg != h.Gen || (bg != "" && binc != h.Inc) { |
| + return fmt.Sprintf("generation.json %q, claim %q", bg, h.Gen) |
| + } |
| + if e.Files != nil && (e.Files.Gen != bg || e.Files.Inc != h.Inc) { |
| + return fmt.Sprintf(".files-gen %q inc %d", e.Files.Gen, e.Files.Inc) |
| + } |
| + return "" |
| +} |
| + |
| func (w *world) final() { |
| if !w.quiescent() { |
| + if len(w.pending) == 0 && len(w.console) == 0 && w.atRest() { |
| + w.violate("INV-10: residue never retired: %v", w.residue()) |
| + return |
| + } |
| w.violate("INV-3: no convergence after %d calm steps: %s%s", calmSteps, w.state(), w.boxesState()) |
| return |
| } |
| @@ -250,11 +382,6 @@ func (w *world) final() { |
| if w.rec != nil { |
| w.violate("claim/record: claim free but record inc %d exists", w.rec.Inc) |
| } |
| - for r := 0; r < nRegions; r++ { |
| - if w.buckets[r].hasData("instances/"+host+"/", true) || w.buckets[r].hasData("moves/"+host+"/", true) { |
| - w.violate("INV-10: region %d still holds data for a freed host", r) |
| - } |
| - } |
| for _, b := range w.liveBoxes() { |
| if b.DB != nil || b.Files != nil { |
| w.violate("INV-10: box %d still holds local state for a freed host", b.ID) |
| @@ -266,37 +393,67 @@ func (w *world) final() { |
| if w.rec.Region != e.Region { |
| w.violate("claim/record: record region %d, claim serves from box %d in region %d", w.rec.Region, e.ID, e.Region) |
| } |
| - for r := 0; r < nRegions; r++ { |
| - if r != e.Region && (w.buckets[r].hasData("instances/"+host+"/", true) || w.buckets[r].hasData("moves/"+host+"/", true)) { |
| - w.violate("INV-10: region %d holds live-namespace data; the host serves from region %d", r, e.Region) |
| - } |
| - } |
| - if w.buckets[e.Region].hasData("moves/"+host+"/", true) { |
| - w.violate("INV-10: staging left in the serving region %d", e.Region) |
| - } |
| for _, b := range w.liveBoxes() { |
| if b != e && (b.DB != nil || b.Files != nil) { |
| w.violate("INV-10: box %d holds local state; the host serves from box %d", b.ID, e.ID) |
| } |
| } |
| - // make the serving box start, then check every acknowledged write |
| if !hasMain(e) { |
| - if !w.admits(e, c, "") || !w.spawn(e, c, holderLin(c.Holder), false) { |
| - w.violate("final: the serving box %d cannot start the host (%s) box: row=%+v db=%+v files=%+v cfg=%d live=%s/%d retired=%v fail=%v until=%d", |
| - e.ID, w.state(), e.Row, e.DB, e.Files, e.Config, e.Live, e.LiveInc, e.Retired, e.FailApp, e.ExecUntil) |
| + if why := w.canStart(e, c); why != "" { |
| + w.violate("final: the serving box %d cannot start the host: %s (%s)", e.ID, why, w.state()) |
| return |
| } |
| } |
| - w.checkAcks(c, e, e.DB.Writes, "local") |
| - // and the replica path: replicate now, cold-restore, check again |
| - bl := &blob{Kind: "ltx", Inc: e.DB.Inc, Gen: e.DB.Gen, Writes: append([]int(nil), e.DB.Writes...), SealKey: e.DB.Key} |
| - w.complete(&request{kind: rPut, region: e.Region, key: "instances/" + host + "/ltx", b: bl}) |
| - db := w.restore(e, c.Holder.Inc, c.Holder.Legacy, holderLin(c.Holder)) |
| - if db == nil { |
| - w.violate("final: cold restore failed on box %d", e.ID) |
| + // the serving box's database as it stands, or, if it has none, the |
| + // cold restore it would do: computed, never written |
| + local := e.DB |
| + if local == nil { |
| + local = w.restore(e, c.Holder.Inc, c.Holder.Location, c.Holder.Gen) |
| + } |
| + if local == nil { |
| + w.violate("final: the serving box %d has no database and nothing to restore", e.ID) |
| + return |
| + } |
| + w.checkAcks(c, e, local.Writes, "local") |
| + w.checkDurable(c, "final") |
| +} |
| + |
| +// checkDurable is the cold-restore promise, checked at every release to |
| +// serving and at the end: a restore of the claim's location and generation |
| +// in the serving region holds every acknowledged write that a completed |
| +// replica object ever held. It reads the bucket and writes nothing. |
| +func (w *world) checkDurable(c *claim, when string) { |
| + if c == nil || c.Holder == nil { |
| + return |
| + } |
| + e := w.execBox(c) |
| + if e == nil { |
| return |
| } |
| - w.checkAcks(c, e, db.Writes, "cold restore") |
| + db := w.restore(e, c.Holder.Inc, c.Holder.Location, c.Holder.Gen) |
| + need := map[int]bool{} |
| + seen := map[string]bool{} |
| + for p := dataKey(c.Holder.Location, c.Holder.Gen, ""); p != "" && !seen[p]; p = w.genParent[p] { |
| + seen[p] = true |
| + for x := range w.durable[p] { |
| + need[x] = true |
| + } |
| + } |
| + have := map[int]bool{} |
| + if db != nil { |
| + for _, x := range db.Writes { |
| + have[x] = true |
| + } |
| + } |
| + for _, a := range w.acks { |
| + if a.Inc != c.Holder.Inc || a.Exempt || a.Kind != "write" || !need[a.W] { |
| + continue |
| + } |
| + if !have[a.W] { |
| + w.violate("INV-2 (cold restore, %s): durable write %d (box %d, t=%d) lost", when, a.W, a.Box, a.At) |
| + return |
| + } |
| + } |
| } |
| func (w *world) checkAcks(c *claim, e *box, writes []int, how string) { |
| @@ -332,8 +489,12 @@ func (w *world) state() string { |
| for _, o := range c.Obl { |
| obl += fmt.Sprintf(" %s:%s/r%d/b%d/%s", o.ID, o.Kind, o.Region, o.Box, o.Prefix) |
| } |
| - return fmt.Sprintf("op=%s/%s phase=%s land=%s src=%s rev=%s exec=%d obl=%d holder=%+v rec=%+v pending=%d console=%d obls=[%s] quiet=%v", |
| - c.Op.Kind, c.Op.ID, c.Op.Phase, c.Op.LandPhase, c.Op.SrcPhase, c.Op.RevPhase, c.Op.Exec, len(c.Obl), c.Holder, w.rec, len(w.pending), len(w.console), obl, c.Quiet) |
| + adm := "" |
| + for app, ad := range w.admit { |
| + adm += fmt.Sprintf(" %s:closed=%v/reg=%d", app, ad.Closed, len(ad.Reg)) |
| + } |
| + return fmt.Sprintf("op=%s/%s phase=%s land=%s src=%s rev=%s exec=%d obl=%d holder=%+v rec=%+v pending=%d console=%d obls=[%s] quiet=%v h2=%+v admission=[%s] residue=%v", |
| + c.Op.Kind, c.Op.ID, c.Op.Phase, c.Op.LandPhase, c.Op.SrcPhase, c.Op.RevPhase, c.Op.Exec, len(c.Obl), c.Holder, w.rec, len(w.pending), len(w.console), obl, c.Quiet, w.h2, adm, w.residue()) |
| } |
| // ---------- running ---------- |
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go| index d225b382c..e61d079b8 100644 |
| --- a/internal/sim/hostlife/world_test.go |
| +++ b/internal/sim/hostlife/world_test.go |
| @@ -1,7 +1,12 @@ |
| // Package hostlife is a deterministic, randomized model of the host |
| // lifecycle claim (docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md, |
| -// rev 2) and Phase 1 of move-any-app |
| -// (docs/superpowers/specs/2026-10-02-move-any-app-design.md, rev 4). |
| +// rev 4, a3c86fa5) and Phase 1 of move-any-app |
| +// (docs/superpowers/specs/2026-10-02-move-any-app-design.md, rev 6). |
| +// |
| +// Since joint round 2 the model encodes what the specs say and nothing |
| +// stronger: restore reads the holder's location and the generation the |
| +// claim names, the start check compares markers, every multi-object step |
| +// is split at its real write boundaries, and the final oracle only reads. |
| // |
| // It is test-only. Nothing here is product code: every file is a _test.go |
| // file, so `go build ./...` never sees it. It exists to find interleavings |
| @@ -44,7 +49,9 @@ const ( |
| stagingTTL = 400 // staging-work lease TTL: longer than the cut budget, as in production |
| probeTries = 3 |
| readyWait = 800 // abort a move whose readiness never comes |
| - maxSkew = 1 // a box clock is within this of true time (SafeUntil's margin) |
| + maxSkew = 1 // a box clock is within this of true time |
| + maxPause = 3 // the claim's §3.1 bound on a pause between a check and the act |
| + beyondH = 20 // one long-tail request in this many completes after H (R2-1) |
| ) |
| // ---------- storage ---------- |
| @@ -58,7 +65,8 @@ type blob struct { |
| Writes []int |
| Files *filesDir |
| Timers []int |
| - Refs []ref |
| + Refs []ref // a park manifest's archive set |
| + Dels []ref // a park manifest's deletion set, delete markers included |
| Text string |
| } |
| @@ -69,9 +77,10 @@ type ref struct { |
| } |
| type version struct { |
| - ID int |
| - B *blob |
| - Marker bool |
| + ID int |
| + B *blob |
| + Marker bool |
| + Straggler bool // created by a request that completed after H |
| } |
| type bucket struct { |
| @@ -139,6 +148,33 @@ func (b *bucket) hasData(prefix string, skipLock bool) bool { |
| return false |
| } |
| +// ---------- locations and generations (claim C3a, moves rev 6 §5.1) ---------- |
| + |
| +// locationFor is holder.location for an incarnation. |
| +func locationFor(inc int, legacy bool) string { |
| + if legacy { |
| + return "instances/" + host + "/" |
| + } |
| + return fmt.Sprintf("instances/%s/%d/", host, inc) |
| +} |
| + |
| +// dataKey is a replica object of generation gen within location loc. The |
| +// origin generation keeps the bare layout; every later generation has its |
| +// own operation-scoped prefix, so a request from one generation that lands |
| +// late can only ever add an object to that generation. |
| +func dataKey(loc, gen, kind string) string { |
| + if gen == "" { |
| + return loc + kind |
| + } |
| + return loc + "g-" + gen + "/" + kind |
| +} |
| + |
| +func genMarkerKey(loc string) string { return loc + "generation.json" } |
| + |
| +func isDataKey(k string) bool { |
| + return strings.HasPrefix(k, "instances/") && (strings.HasSuffix(k, "/base") || strings.HasSuffix(k, "/ltx")) |
| +} |
| + |
| // ---------- requests (late completion) ---------- |
| type reqKind int |
| @@ -160,6 +196,7 @@ type request struct { |
| srcKey string |
| srcVer int |
| doneAt int |
| + beyond bool // completes after H |
| by string |
| // result, for an immediate completion |
| resultVer int |
| @@ -185,6 +222,7 @@ func (f *filesDir) clone() *filesDir { |
| type localDB struct { |
| Inc int |
| + Loc string // the holder location it was restored from (claim C3a) |
| Gen string // <dbPath>.gen |
| Key int // the key it was sealed with |
| Writes []int |
| @@ -237,6 +275,7 @@ type box struct { |
| DB *localDB |
| Files *filesDir |
| Config int // incarnation the config.env was rendered for; 0 none |
| + CfgVer int // the bundle version it was rendered from |
| Live string // app the live symlink points at |
| LiveInc int |
| Timers []int // active once-timers (registration ids) |
| @@ -256,53 +295,59 @@ type box struct { |
| stopped map[string]bool |
| mem map[string]bool |
| evidAt map[string]int |
| + sweepID string |
| pendingTimers []int |
| } |
| -// plan is a background pass's decision, taken now and acted on later: the |
| -// shape of every stale-writer race (J-N8). |
| +// plan is a host-specific write by a separate OS process |
| +// (carlos-config-sync, carlos-adopt), decided now and acted on later: the |
| +// shape of every stale-writer race (J-N8, J2-R2-3). It survives an agent |
| +// crash, because it is not the agent; a reboot ends it. It may pause |
| +// before it takes the host guard. |
| type plan struct { |
| Kind string // "config", "adopt" |
| Inc int |
| App string |
| + Ver int // the config bundle version it selected |
| + At int // it takes the guard no earlier than this |
| } |
| func (b *box) forget() { |
| b.Epoch++ // every goroutine of the old agent is gone |
| b.deferred = nil |
| - b.snap, b.plans, b.stopped, b.stale = nil, nil, nil, nil |
| + b.snap, b.stopped, b.stale = nil, nil, nil |
| b.mem = map[string]bool{} |
| b.evidAt = map[string]int{} |
| + b.sweepID = "" |
| b.pendingTimers = nil |
| } |
| // ---------- control plane ---------- |
| type holder struct { |
| - Inc int |
| - App string |
| - Serial int // ground-truth creation event (model only) |
| - Legacy bool |
| - Lineage lineage |
| - FilesGen string // the files generation compatible with the lineage |
| -} |
| - |
| -type lineage struct { |
| - Op string // "" = origin |
| - Region int |
| - Ver int // version of instances/<host>/base in Region |
| + Inc int |
| + App string |
| + Serial int // ground-truth creation event (model only) |
| + Legacy bool |
| + // Location is holder.location (claim C3a): instances/<host>/<inc>/ for a |
| + // new incarnation, instances/<host>/ for an adopted legacy host. It never |
| + // changes while this holder holds the host. |
| + Location string |
| + // Gen is the generation within the location that the claim names as |
| + // current (moves rev 6 §5.1): "" is origin; a move, a push and a dirty |
| + // rollback each install a generation named by their op id. |
| + Gen string |
| } |
| type obligation struct { |
| - ID string |
| - Op string |
| - Kind string // park-replica, park-local, park-superseded, park-staging |
| - Region int |
| - Box int |
| - Prefix string |
| - Ret string |
| - Quiet int // not before |
| - Exclude string // lineage to leave in place (a push parks only the old one) |
| + ID string |
| + Op string |
| + Kind string // park-replica, park-local, park-superseded, park-staging |
| + Region int |
| + Box int |
| + Prefix string |
| + Ret string |
| + Quiet int // not before |
| } |
| type opState struct { |
| @@ -333,7 +378,6 @@ type opState struct { |
| SealQuiet int |
| LandPhase string |
| Installed digest |
| - BaseVer int |
| ProbeTries int |
| ProbedAt int |
| RevPhase string |
| @@ -350,6 +394,7 @@ type opState struct { |
| Committed bool |
| Opened bool |
| ParkLocalNow bool |
| + PinVer int // move.inputs: the bundle version pinned at admission |
| Auto bool |
| ReqAt int |
| Repl string |
| @@ -379,6 +424,41 @@ type claim struct { |
| Retired []int |
| Quiet [nRegions]int |
| PurgeRes bool |
| + // Cancelled is cancelled_creates (claim rev 4 §5.1, J2-R2-7): creates |
| + // recovery cancelled, so a late record PUT can be removed. It survives free. |
| + Cancelled []cancelledCreate |
| +} |
| + |
| +type cancelledCreate struct { |
| + Inc int |
| + Until int |
| +} |
| + |
| +// liteHost is a second hostname of the same apps, modelled for the |
| +// control plane only: create, app admission and purge (J2-R2-2). |
| +type liteHost struct { |
| + Inc int |
| + App string |
| + Kind string // "", "creating", "serving", "purging" |
| + OpID string |
| + Rec bool // its record is published |
| + At int |
| +} |
| + |
| +// admission is apps/<acct>/<app>/admission.json (claim rev 4 §5.1 step 0). |
| +type admission struct { |
| + Closed bool |
| + Reg map[string]regEntry // create id -> registration |
| +} |
| + |
| +type regEntry struct { |
| + Host string |
| + Until int // a registration is a lease: a create claims before it ends (CC16) |
| +} |
| + |
| +type pendingRec struct { |
| + rec *record |
| + at int |
| } |
| type record struct { |
| @@ -387,6 +467,12 @@ type record struct { |
| App string |
| Region int |
| MoveID string |
| + // MoveState is the record's move journal state for the four transitions |
| + // the moves spec makes record-first (§7.5, §7.8, §7.9): "landing" after |
| + // the flip, "committed", "rolling_back". The claim follows each. |
| + MoveState string |
| + RollbackID string |
| + Reason string |
| } |
| // ---------- the world ---------- |
| @@ -397,7 +483,7 @@ type ack struct { |
| Box int |
| At int |
| Kind string // "write" or "timer" |
| - Exempt bool // lost under a declared loss (M16) |
| + Exempt bool // lost under a declared loss (M16) or past the last checkpoint of a lost disk |
| } |
| type world struct { |
| @@ -411,14 +497,20 @@ type world struct { |
| claim *claim // nil = absent |
| rec *record |
| stores map[string]bool |
| + cfgVer map[string]int // each app's current config bundle version |
| purgeRes map[string]bool |
| purgeStep map[string]int // the reservation's durable phase (purge journal) |
| + admit map[string]*admission |
| + h2 liteHost |
| + recPend *pendingRec // a record PUT whose response was lost |
| floor bool |
| buckets [nRegions]*bucket |
| control *bucket |
| boxes []*box |
| pending []*request |
| acks []ack |
| + durable map[string]map[int]bool // per generation prefix: writes a completed replica object holds |
| + genParent map[string]string // generation prefix -> the one it superseded |
| nextW int |
| nextInc int |
| serial int |
| @@ -527,6 +619,7 @@ func (c *claim) clone() *claim { |
| n.Obl = append([]obligation(nil), c.Obl...) |
| n.Lost = append([]int(nil), c.Lost...) |
| n.Retired = append([]int(nil), c.Retired...) |
| + n.Cancelled = append([]cancelledCreate(nil), c.Cancelled...) |
| return &n |
| } |
| @@ -580,6 +673,12 @@ func (w *world) issue(actorBox int, r *request) int { |
| if w.rng.Intn(4) == 0 { |
| r.doneAt = w.now + horizonH // as late as the signature-age limit allows |
| } |
| + if w.rng.Intn(beyondH) == 0 { |
| + // accepted before the signature limit, completed after it: S3 |
| + // bounds acceptance, not completion (claim C3a, J2-R2-1) |
| + r.doneAt = w.now + horizonH + 1 + w.rng.Intn(10*horizonH) |
| + r.beyond = true |
| + } |
| } |
| r.by = fmt.Sprintf("box:%d", actorBox) |
| w.pending = append(w.pending, r) |
| @@ -591,8 +690,9 @@ func (w *world) complete(r *request) int { |
| bk := w.buckets[r.region] |
| switch r.kind { |
| case rPut: |
| - v := &version{ID: w.newVer(), B: r.b} |
| + v := &version{ID: w.newVer(), B: r.b, Straggler: r.beyond} |
| bk.objs[r.key] = append(bk.objs[r.key], v) |
| + w.markDurable(r.key, r.b) |
| return v.ID |
| case rDelVer: |
| vs := bk.objs[r.key] |
| @@ -612,18 +712,57 @@ func (w *world) complete(r *request) int { |
| if src == nil || src.Marker { |
| return 0 |
| } |
| - v := &version{ID: w.newVer(), B: src.B} |
| + v := &version{ID: w.newVer(), B: src.B, Straggler: r.beyond} |
| bk.objs[r.key] = append(bk.objs[r.key], v) |
| + w.markDurable(r.key, src.B) |
| return v.ID |
| } |
| return 0 |
| } |
| +// markDurable: a write is durable in a generation once a completed replica |
| +// object of that generation holds it. The cold-restore checks (§14.2.3) |
| +// require every write durable in the claim's generation or in any |
| +// generation it superseded. |
| +func (w *world) markDurable(key string, b *blob) { |
| + if b == nil || !isDataKey(key) { |
| + return |
| + } |
| + p := key[:strings.LastIndex(key, "/")+1] |
| + if w.durable[p] == nil { |
| + w.durable[p] = map[int]bool{} |
| + } |
| + for _, x := range b.Writes { |
| + w.durable[p][x] = true |
| + } |
| +} |
| + |
| +// isDurable: W is durable anywhere (the disk-loss fault's RPO line). |
| +func (w *world) isDurable(x int) bool { |
| + for _, m := range w.durable { |
| + if m[x] { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// newGen records that the claim's generation moved from old to gen within |
| +// loc, so the durability promise carries over. |
| +func (w *world) newGen(loc, old, gen string) { |
| + if old != gen { |
| + w.genParent[dataKey(loc, gen, "")] = dataKey(loc, old, "") |
| + } |
| +} |
| + |
| func (w *world) completeDue(force bool) { |
| var keep []*request |
| for _, r := range w.pending { |
| if force || r.doneAt <= w.now { |
| w.complete(r) |
| + if r.beyond { |
| + w.stats["completed beyond H"]++ |
| + } |
| w.logf("late completion %v %s r%d (%s)", r.kind, r.key, r.region, r.by) |
| } else { |
| keep = append(keep, r) |