sim: Astra's round 7 on the two-box model: only a terminated box's volume evidence discharges its local cleanup, so a stopped box keeps it, and INV-26 checks the operation's discharges; the operator stops a target only when its release is unrecoverable and otherwise resumes it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+165
−4
internal/sim/hostlife/console_test.go+23 −1internal/sim/hostlife/regionha_round1_test.go+127 −0internal/sim/hostlife/regionha_test.go+14 −3internal/sim/hostlife/rules_test.go+1 −0
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go| index ca3a2b689..9c219e278 100644 |
| --- a/internal/sim/hostlife/console_test.go |
| +++ b/internal/sim/hostlife/console_test.go |
| @@ -1252,7 +1252,7 @@ func (w *world) operator() { |
| if dead != nil && !dead.Destroyed { |
| dead = nil |
| } |
| - if ex := w.boxByID(op.Exec); ex != nil && !ex.Destroyed && op.Phase == "probe-failed" && !released && !contains(c.Lost, ex.ID) && on("reloc-release-needs-flush") { |
| + if ex := w.boxByID(op.Exec); ex != nil && !ex.Destroyed && op.Phase == "probe-failed" && !released && !contains(c.Lost, ex.ID) && on("reloc-release-needs-flush") && w.releaseUnrecoverable(ex, c) { |
| // a live target that cannot release (its replicator and its |
| // files are gone and nothing recorded a flush, rev 9): no |
| // release will ever come, so the operator stops the box, and |
| @@ -1344,6 +1344,28 @@ func (w *world) operator() { |
| w.declareTargetLost(c) |
| } |
| +// releaseUnrecoverable is the operator's check before it stops a live |
| +// target that has not released (rev 10, Astra round 7): only when no |
| +// release can ever come. That needs no flush recorded for this stop, and |
| +// either the session's replicator or the box's files directory gone for |
| +// good (relocRelease refuses without either). Any other release, a slow |
| +// acknowledgement or a recorded flush not yet journaled, is resumed: the |
| +// resolve clears stuck and sends the target back to probe-failed. The |
| +// mutation of operator-stop-unrecoverable-only stops every such target, |
| +// as rev 9 did. |
| +func (w *world) releaseUnrecoverable(b *box, c *claim) bool { |
| + if !on("operator-stop-unrecoverable-only") { |
| + return true |
| + } |
| + h := c.Holder |
| + if r := b.relRecord; r != nil && r.Op == c.Op.ID && r.Phase == c.Op.Phase && r.E.Session == h.Session && r.E.Gen == h.Gen { |
| + return false // a recorded flush: the release replays it |
| + } |
| + replicator := b.DB != nil && b.DB.Inc == h.Inc && b.DB.Gen == h.Gen && b.DB.Epoch == b.Epoch && b.DB.Session == h.Session |
| + files := b.Files != nil && b.Files.Inc == h.Inc |
| + return !replicator || !files |
| +} |
| + |
| // declareTargetLost is the declared loss of a landing's or a rollback's |
| // target that is gone (claim D2, moves §7.10): the operator's path, and, |
| // for a terminated target, the regional worker's automatic fence (claim |
diff --git a/internal/sim/hostlife/regionha_round1_test.go b/internal/sim/hostlife/regionha_round1_test.go| index 24937fab6..4e8d93b9d 100644 |
| --- a/internal/sim/hostlife/regionha_round1_test.go |
| +++ b/internal/sim/hostlife/regionha_round1_test.go |
| @@ -849,6 +849,133 @@ func TestReleaseReplaysTheRecordedFlush(t *testing.T) { |
| w.mustHold(t) |
| } |
| +// probeFailedTarget drains the host from box 0 to box 3, whose app |
| +// fails its probe, with a spare box 30 for the nested relocation, and |
| +// stops once the operation is in probe-failed with box 3 op.exec and no |
| +// release journaled. |
| +func (w *world) probeFailedTarget(t *testing.T) (from, to *box) { |
| + from, to = w.boxes[0], w.boxes[3] |
| + to.FailApp = true |
| + w.serve(from, 1, app0) |
| + w.running(t, from) |
| + w.published(t, from) |
| + w.drainTo(t, to) |
| + w.extraBox(0, 30) |
| + if !w.stepUntil(t, 1500, "probe-failed on the target", func() bool { |
| + c := w.claim |
| + return c.Op.Phase == "probe-failed" && c.Op.Exec == to.ID && c.Op.Release == nil |
| + }) { |
| + w.mustHold(t) |
| + } |
| + return from, to |
| +} |
| + |
| +// resolveOnly is the operator's resolve of a stuck relocation and |
| +// nothing else (the destroyed-local declaration is left out). |
| +func (w *world) resolveOnly() { |
| + if c := w.claim; c != nil && c.Op.Kind == "relocating" && c.Op.Stuck != "" { |
| + w.operator() |
| + } |
| +} |
| + |
| +// TestOperatorResumesASlowRelease (operator-stop-unrecoverable-only; rev |
| +// 10, Astra round 7 finding 2): the failed target's final manifest PUT is |
| +// not acknowledged before the release deadline, so the operation is |
| +// stuck. Its replicator and files are intact: the operator's resolve |
| +// resumes the release rather than stopping the box; once the PUT lands |
| +// the release is journaled and the nested relocation serves. The |
| +// mutation stops the box. |
| +func TestOperatorResumesASlowRelease(t *testing.T) { |
| + w := twoBoxWitness() |
| + _, to := w.probeFailedTarget(t) |
| + h := w.claim.Holder |
| + held := dataKey(h.Location, h.Gen, "files/") |
| + w.holdKey(held) |
| + if !w.reach(t, 2*releaseDeadline, "stuck at the release deadline", func() bool { return w.claim.Op.Stuck != "" }, w.actAll(), w.recoveryPass) { |
| + w.mustHold(t) |
| + } |
| + if c := w.claim; c.Op.Phase != "probe-failed" || c.Op.Release != nil { |
| + t.Fatalf("not stuck in probe-failed without a release: %s", w.state()) |
| + } |
| + w.resolveOnly() |
| + if c := w.claim; to.Destroyed || c.Op.Stuck != "" || c.Op.Phase != "probe-failed" || c.Op.Exec != to.ID { |
| + t.Fatalf("the resolve did not resume the target's release (stopped %v): %s", to.Destroyed, w.state()) |
| + } |
| + w.hold = nil |
| + w.completeHeld(held) |
| + w.servingOn(t, nil) |
| + if w.stats["operator stopped a target that cannot release"] != 0 || w.stats["release journaled: failed target"] == 0 { |
| + t.Fatalf("the target was stopped, or never released: %v", w.stats) |
| + } |
| + w.finish(t) |
| + w.mustHold(t) |
| +} |
| + |
| +// TestOperatorResumesARecordedFlush (operator-stop-unrecoverable-only; |
| +// rev 10, Astra round 7 finding 2): the failed target completes its |
| +// release sequence and records it, then its agent restarts (the |
| +// session's replicator is gone) before the release is journaled, and the |
| +// deadline passes. The recorded flush is a release waiting to be |
| +// journaled: the resolve resumes it, the target replays the record, and |
| +// the nested relocation serves. The mutation stops the box. |
| +func TestOperatorResumesARecordedFlush(t *testing.T) { |
| + w := twoBoxWitness() |
| + from, to := w.probeFailedTarget(t) |
| + var r *releaseEntry |
| + for i := 0; i < 60 && r == nil; i++ { |
| + r = w.relocRelease(to, w.claim.clone()) // steps 1 to 3 and the record, not journaled |
| + w.tick() |
| + } |
| + if r == nil { |
| + t.Fatalf("the target's release sequence did not complete: %s", w.state()) |
| + } |
| + w.crash(to) |
| + spare := w.boxByID(30) |
| + if !w.reach(t, 2*releaseDeadline, "stuck at the release deadline", func() bool { return w.claim.Op.Stuck != "" }, w.act(from, spare), w.recoveryPass) { |
| + w.mustHold(t) |
| + } |
| + w.resolveOnly() |
| + if c := w.claim; to.Destroyed || c.Op.Stuck != "" || c.Op.Phase != "probe-failed" { |
| + t.Fatalf("the resolve did not resume the recorded release (stopped %v): %s", to.Destroyed, w.state()) |
| + } |
| + w.servingOn(t, nil) |
| + if w.stats["operator stopped a target that cannot release"] != 0 || w.stats["release: replayed from the recorded positions"] == 0 { |
| + t.Fatalf("the target was stopped, or its record not replayed: %v", w.stats) |
| + } |
| + w.finish(t) |
| + w.mustHold(t) |
| +} |
| + |
| +// TestStoppedTargetKeepsItsLocalCleanup (fence-discharge-qualified, |
| +// operator-stop-unrecoverable-only; rev 10, Astra round 7 findings 1 and |
| +// 2): the failed target loses its database and files with its agent, so |
| +// its release is unrecoverable; the operator stops the box and the next |
| +// resolve declares it lost and replays. The box's volume would be deleted |
| +// on termination, but it was stopped, not terminated: its disk is kept, |
| +// so when the recovery completes its park-local is still owed (claim §7.3 |
| +// D5, H19). The mutation of fence-discharge-qualified discharges it. |
| +func TestStoppedTargetKeepsItsLocalCleanup(t *testing.T) { |
| + w := twoBoxWitness() |
| + _, to := w.probeFailedTarget(t) |
| + to.DeleteOnTermination = true |
| + w.crash(to) |
| + to.Files, to.DB, to.receipt = nil, nil, nil |
| + if !w.reach(t, 3000, "serving after the operator's stop and replay", func() bool { |
| + c := w.claim |
| + return c.Op.Kind == "serving" && c.Op.Exec >= 0 && c.Op.Exec != to.ID |
| + }, w.actAll(), w.recoveryPass, w.wakeExec, w.resolveOnly) { |
| + w.mustHold(t) |
| + } |
| + if !to.Destroyed || to.Terminated || w.stats["operator stopped a target that cannot release"] == 0 { |
| + t.Fatalf("the target was not stopped by the operator: %s", w.state()) |
| + } |
| + if !w.owesLocal(w.claim, to) { |
| + t.Fatalf("the stopped target's local cleanup was discharged: %s", w.state()) |
| + } |
| + w.finish(t) // the operator's destroyed-local declaration, an explicit attestation, retires it |
| + w.mustHold(t) |
| +} |
| + |
| // TestPinNeverShortOfTheRelease (reloc-pin-reaches-release): the listing |
| // lags behind the journaled release (its newest file is not yet visible); |
| // no pin is journaled short of the release's final_txid. |
diff --git a/internal/sim/hostlife/regionha_test.go b/internal/sim/hostlife/regionha_test.go| index 17338b857..1407d22f6 100644 |
| --- a/internal/sim/hostlife/regionha_test.go |
| +++ b/internal/sim/hostlife/regionha_test.go |
| @@ -1665,7 +1665,13 @@ func (w *world) fenceBox(n *claim, x *box) { |
| } |
| } |
| } |
| - qualified := x.DeleteOnTermination |
| + // a fence exists only for a terminated box (TerminationWorker verifies |
| + // terminated), and only a terminated box's volume evidence says |
| + // anything: a stopped box keeps its disk, so its park-local chores |
| + // stay owed whatever DeleteOnTermination says (claim §7.3 D5, H19; |
| + // rev 10, Astra round 7). The operator's declared loss of a stopped |
| + // box reaches this function too, and discharges nothing |
| + qualified := x.Terminated && x.DeleteOnTermination |
| if !on("fence-discharge-qualified") { |
| qualified = true |
| } |
| @@ -1676,8 +1682,8 @@ func (w *world) fenceBox(n *claim, x *box) { |
| var keep []obligation |
| for _, o := range n.Obl { |
| if o.Kind == "park-local" && o.Box == x.ID { |
| - if !x.DeleteOnTermination { |
| - w.violate("INV-26: box %d's park-local chore %s discharged without qualifying volume evidence (§5.9, H19)", x.ID, o.ID) |
| + if !x.Terminated || !x.DeleteOnTermination { |
| + w.violate("INV-26: box %d's park-local chore %s discharged without qualifying volume evidence (§5.9, H19; terminated %v)", x.ID, o.ID, x.Terminated) |
| } |
| w.stats["fence: park-local chores discharged"]++ |
| continue |
| @@ -1685,6 +1691,11 @@ func (w *world) fenceBox(n *claim, x *box) { |
| keep = append(keep, o) |
| } |
| n.Obl = keep |
| + if !x.Terminated || !x.DeleteOnTermination { |
| + // INV-26 (rev 10): a discharge recorded for the operation's |
| + // leftovers is a discharge too, though no chore exists yet |
| + w.violate("INV-26: box %d's local retirement discharged without qualifying volume evidence (§5.9, H19, claim §7.3 D5; terminated %v)", x.ID, x.Terminated) |
| + } |
| n.Op.Discharged = addUniq(n.Op.Discharged, x.ID) |
| } |
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go| index 12fd15926..a35e9995f 100644 |
| --- a/internal/sim/hostlife/rules_test.go |
| +++ b/internal/sim/hostlife/rules_test.go |
| @@ -197,6 +197,7 @@ var allRules = []string{ |
| "files-live-set-moves-parks", // finding F4 (rev 4), claim §6.2a: moves' parks (a push's whole-location park, a landing's install park, a rollback's return park) keep the files live set; the mutation archives it with the rest and the next failover can never pin the live files (INV-3) |
| "reloc-clear-after-cleanup", // 1b-next ruling 20 and rev 6 R4-5 (Finish clears the record only after a successful scoped cleanup): a failed cleanup keeps the controller record, which blocks executable updates (claim §11's witness); the mutation clears it and an update runs over the unfinished cleanup (INV-29) |
| "fence-converts-committed-landing", // finding F5 (rev 4; claim §11 "automatic target loss racing the record commit CAS"): a landing whose record committed and whose target is then terminated becomes a failover in the target's region; the mutation leaves it to the operator, and it never completes (INV-3) |
| + "operator-stop-unrecoverable-only", // rev 10 (Astra round 7; plan ruling 13, no 1b-next code): the operator stops a live target that has not released only when its release is unrecoverable (no recorded flush, and the session's replicator or its files gone); otherwise the resolve resumes the release; the mutation stops every such target (TestOperatorResumesASlowRelease, TestOperatorResumesARecordedFlush) |
| "reloc-release-needs-flush", // rev 9 (Astra round 6; 1b-next rev 19 Task 7 Release, relocationFlushDB, ReleaseFlushFiles, ReleasePositions): a release needs the session's own replicator and the box's own files directory, or the positions it recorded for this stop; otherwise none, and the deadline makes the operation stuck; the mutation journals the remote position with Flushed false (TestReleaseRefusedAfterACrashBeforeTheFlush, TestReleaseReplaysTheRecordedFlush) |
| "reloc-fence-pending-blocks", // rev 8 (Astra round 5; 1b-next rev 19 Worker.Step returns w.replace(...) before its phase switch): while a relocation participant's fence is pending (a dead target, no spare, before its deadline) no regional phase transition runs; the participant's own release still does; the mutation goes on into the phase switch (TestPendingTargetFenceHoldsThePhase) |
| "reloc-name-before-pin", // rev 7 (Astra round 4 finding 1; 1b-next rev 19 Worker.pinning): a relocation at pinning with no target names one before it selects a pin, on every path; the mutation pins first, and a manifest that lands while no box is eligible is left out (a loss bound: TestConvertedMoveWaitsForATargetBeforeItsPin) |