sim: Astra's round 8 on the two-box model: root's stop receipt is tracked apart from the recorded positions, with the flushing controller's identity; only that controller finishes from the positions, and after a restart the receipt is needed too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+137
−20
internal/sim/hostlife/console_test.go+8 −5internal/sim/hostlife/regionha_round1_test.go+92 −11internal/sim/hostlife/regionha_test.go+27 −4internal/sim/hostlife/rules_test.go+1 −0internal/sim/hostlife/world_test.go+9 −0
diff --git a/internal/sim/hostlife/console_test.go b/internal/sim/hostlife/console_test.go| index 9c219e278..d8aae4592 100644 |
| --- a/internal/sim/hostlife/console_test.go |
| +++ b/internal/sim/hostlife/console_test.go |
| @@ -1346,9 +1346,11 @@ func (w *world) operator() { |
| // 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 |
| +// release can ever come. That needs no usable flush record for this |
| +// stop (rev 11: a record counts only with root's durable receipt, or while |
| +// the controller that recorded it is still running), 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, |
| @@ -1358,8 +1360,9 @@ func (w *world) releaseUnrecoverable(b *box, c *claim) bool { |
| 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 |
| + 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 && |
| + (r.Receipt || r.Ctl == b.Epoch || !on("reloc-release-needs-receipt")) { |
| + return false // a recorded flush with root's receipt, or its own controller still alive to finish it (rev 11) |
| } |
| 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 |
diff --git a/internal/sim/hostlife/regionha_round1_test.go b/internal/sim/hostlife/regionha_round1_test.go| index 4e8d93b9d..59c8f7bb0 100644 |
| --- a/internal/sim/hostlife/regionha_round1_test.go |
| +++ b/internal/sim/hostlife/regionha_round1_test.go |
| @@ -811,12 +811,14 @@ func TestReleaseRefusedAfterACrashBeforeTheFlush(t *testing.T) { |
| } |
| // TestReleaseReplaysTheRecordedFlush (reloc-release-needs-flush; rev 9, |
| -// Astra round 6): the source's release sequence completes and records |
| -// its flushed positions (ReleasePositions), and its agent crashes before |
| -// the release is journaled. The restarted agent has no replicator for |
| -// the session, but the record is durable: the release is replayed with |
| -// the recorded positions, Flushed, and the drain completes. The mutation |
| -// records nothing and journals the remote position with Flushed false. |
| +// Astra round 6; rev 11, round 8): the source's release sequence |
| +// completes, records its flushed positions (ReleasePositions), and root |
| +// persists the stop receipt; its agent then crashes before the release |
| +// is journaled. The restarted agent has no replicator for the session, |
| +// but the record and the receipt are durable: the release is replayed |
| +// with the recorded positions, Flushed, and the drain completes. The |
| +// mutation of reloc-release-needs-flush records nothing and journals the |
| +// remote position with Flushed false. |
| func TestReleaseReplaysTheRecordedFlush(t *testing.T) { |
| w := twoBoxWitness() |
| from, to := w.boxes[0], w.boxes[3] |
| @@ -830,7 +832,7 @@ func TestReleaseReplaysTheRecordedFlush(t *testing.T) { |
| } |
| var r *releaseEntry |
| for i := 0; i < 60 && r == nil; i++ { |
| - r = w.relocRelease(from, w.claim.clone()) // steps 1 to 3 and the record, not yet journaled |
| + r = w.relocRelease(from, w.claim.clone()) // steps 1 to 3, the record and the receipt, not yet journaled |
| w.tick() |
| } |
| if r == nil || !r.Flushed { |
| @@ -913,9 +915,9 @@ func TestOperatorResumesASlowRelease(t *testing.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 |
| +// release sequence, records it and gets root's receipt (rev 11), 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) { |
| @@ -923,7 +925,7 @@ func TestOperatorResumesARecordedFlush(t *testing.T) { |
| 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 |
| + r = w.relocRelease(to, w.claim.clone()) // steps 1 to 3, the record and the receipt, not journaled |
| w.tick() |
| } |
| if r == nil { |
| @@ -976,6 +978,85 @@ func TestStoppedTargetKeepsItsLocalCleanup(t *testing.T) { |
| w.mustHold(t) |
| } |
| +// recordWithoutReceipt runs b's release sequence until its flushed |
| +// positions are recorded, and stops before the producers drain and root |
| +// persists the receipt. |
| +func (w *world) recordWithoutReceipt(t *testing.T, b *box) { |
| + for i := 0; i < 60 && b.relRecord == nil; i++ { |
| + w.relocRelease(b, w.claim.clone()) |
| + w.tick() |
| + } |
| + if r := b.relRecord; r == nil || r.Receipt { |
| + t.Fatalf("no record without a receipt on box %d: %+v %s", b.ID, r, w.state()) |
| + } |
| +} |
| + |
| +// TestReleaseRefusedAfterACrashBeforeTheReceipt (reloc-release-needs-receipt; |
| +// rev 11, Astra round 8): the source records its flushed positions, and |
| +// its agent crashes before root persists the stop receipt. The positions |
| +// are not proof of the stop: the restarted agent's release is refused |
| +// (RecoveryReleaseDrain), no release or pin appears, the deadline makes |
| +// the operation stuck, and the resolve returns the host to its source. |
| +// TestReleaseReplaysTheRecordedFlush is the other side: the crash after |
| +// the receipt, replayed. The mutation replays the positions alone. |
| +func TestReleaseRefusedAfterACrashBeforeTheReceipt(t *testing.T) { |
| + w := twoBoxWitness() |
| + from, to := w.boxes[0], w.boxes[3] |
| + w.serve(from, 1, app0) |
| + w.running(t, from) |
| + w.published(t, from) |
| + w.drainTo(t, to) |
| + w.appWrite(from) |
| + if !w.stepUntil(t, 1500, "releasing", w.atPhase("releasing")) { |
| + w.mustHold(t) |
| + } |
| + w.recordWithoutReceipt(t, from) |
| + w.crash(from) |
| + for i := 0; i < releaseDeadline+20; i++ { |
| + w.tick(w.act(from, to)) |
| + c := w.claim |
| + if c.Op.Release != nil || c.Op.Pin != nil { |
| + t.Fatalf("a release or a pin from recorded positions with no receipt (step %d): %s", i, w.state()) |
| + } |
| + if c.Op.Stuck != "" { |
| + break |
| + } |
| + } |
| + if c := w.claim; c.Op.Stuck == "" || c.Op.Phase != "releasing" { |
| + t.Fatalf("not stuck in releasing at the release deadline: %s", w.state()) |
| + } |
| + w.finish(t) |
| + w.mustHold(t) |
| + if c := w.claim; c.Op.Kind != "serving" || c.Op.Exec != from.ID { |
| + t.Fatalf("the operator's resolve did not return the host to its source: %s", w.state()) |
| + } |
| +} |
| + |
| +// TestOperatorStopsATargetWithNoReceipt (reloc-release-needs-receipt, |
| +// operator-stop-unrecoverable-only; rev 11, Astra round 8): the failed |
| +// target records its flushed positions and its agent restarts before root |
| +// persists the receipt. Its release can never come, so the operator stops |
| +// it, and the next resolve replays the operation; the host serves |
| +// elsewhere. The mutation treats the positions as recoverable and resumes |
| +// a release that replays them. |
| +func TestOperatorStopsATargetWithNoReceipt(t *testing.T) { |
| + w := twoBoxWitness() |
| + _, to := w.probeFailedTarget(t) |
| + w.recordWithoutReceipt(t, to) |
| + w.crash(to) |
| + 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 || w.stats["operator stopped a target that cannot release"] == 0 || w.stats["release journaled: failed target"] != 0 { |
| + t.Fatalf("the target was not stopped, or released from positions with no receipt: %v %s", w.stats, w.state()) |
| + } |
| + w.finish(t) |
| + w.mustHold(t) |
| +} |
| + |
| // TestPinNeverShortOfTheRelease (reloc-pin-reaches-release): the listing |
| // lags behind the journaled release (its newest file is not yet visible); |
| // no pin is journaled short of the release's final_txid. |
diff --git a/internal/sim/hostlife/regionha_test.go b/internal/sim/hostlife/regionha_test.go| index 1407d22f6..1d4677fea 100644 |
| --- a/internal/sim/hostlife/regionha_test.go |
| +++ b/internal/sim/hostlife/regionha_test.go |
| @@ -146,11 +146,29 @@ func (w *world) relocRelease(b *box, c *claim) *releaseEntry { |
| } |
| if r := b.relRecord; r != nil && on("reloc-release-needs-flush") && r.Op == c.Op.ID && r.Phase == c.Op.Phase && r.E.Session == h.Session && r.E.Gen == h.Gen { |
| // a flush this box recorded for this stop (ReleasePositions.Recorded): |
| - // the release is replayed with the recorded positions, never |
| - // flushed or guessed again (rev 9) |
| + // never flushed or guessed again (rev 9). Rev 11 (Astra round 8): |
| + // the positions are not proof of the stop. The controller that |
| + // recorded them resumes at the producers' drain, and root persists |
| + // the receipt (step 4); after a restart only root's durable receipt |
| + // for the recorded identity finishes the release, and with none |
| + // RecoveryReleaseDrain refuses: no release, and the deadline |
| + // makes the operation stuck. The mutation of |
| + // reloc-release-needs-receipt replays the positions alone, as rev |
| + // 10 did |
| + switch { |
| + case r.Receipt || !on("reloc-release-needs-receipt"): |
| + if r.Ctl != b.Epoch { |
| + w.stats["release: replayed from the recorded positions"]++ |
| + } |
| + case r.Ctl == b.Epoch: |
| + r.Receipt = true // the producers drain; root persists the receipt |
| + w.stats["release: receipt persisted"]++ |
| + default: |
| + w.stats["release: recorded positions without a receipt, refused"]++ |
| + return nil |
| + } |
| e := r.E |
| e.Files, e.Timers = r.E.Files.clone(), append([]int(nil), r.E.Timers...) |
| - w.stats["release: replayed from the recorded positions"]++ |
| return &e |
| } |
| prefix := dataKey(h.Location, h.Gen, "") |
| @@ -227,8 +245,13 @@ func (w *world) relocRelease(b *box, c *claim) *releaseEntry { |
| Files: files, Timers: append([]int(nil), b.Timers...), At: w.now} |
| if on("reloc-release-needs-flush") { |
| // ReleasePositions.Record: durable before the producers close |
| - b.relRecord = &releaseRecord{Op: c.Op.ID, Phase: c.Op.Phase, E: *e} |
| + b.relRecord = &releaseRecord{Op: c.Op.ID, Phase: c.Op.Phase, E: *e, Ctl: b.Epoch} |
| b.relRecord.E.Files, b.relRecord.E.Timers = files.clone(), append([]int(nil), b.Timers...) |
| + if on("reloc-release-needs-receipt") { |
| + // the producers' drain and root's receipt are the next step: |
| + // a crash here leaves positions and no receipt (rev 11) |
| + return nil |
| + } |
| } |
| return e |
| } |
diff --git a/internal/sim/hostlife/rules_test.go b/internal/sim/hostlife/rules_test.go| index a35e9995f..eef576edf 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) |
| + "reloc-release-needs-receipt", // rev 11 (Astra round 8; 1b-next rev 19 Task 7 Release, RecoveryReleaseDrain): recorded positions finish a release only for the controller that recorded them, or with root's durable receipt after a restart; the mutation replays the positions alone (TestReleaseRefusedAfterACrashBeforeTheReceipt, TestOperatorStopsATargetWithNoReceipt) |
| "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) |
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go| index af8e3645f..7d5827e72 100644 |
| --- a/internal/sim/hostlife/world_test.go |
| +++ b/internal/sim/hostlife/world_test.go |
| @@ -936,6 +936,15 @@ type opState struct { |
| type releaseRecord struct { |
| Op, Phase string |
| E releaseEntry |
| + // Ctl is the flushing controller's identity: the agent process |
| + // (box.Epoch) that flushed and recorded (rev 11). Receipt is root's |
| + // durable stop receipt for this stop, persisted after the producers |
| + // drained (step 4), tracked apart from the recorded positions: only |
| + // the same controller may finish from the positions alone, and after a |
| + // restart the release needs the receipt too (1b-next Task 7 Release, |
| + // RecoveryReleaseDrain; Astra round 8) |
| + Ctl int |
| + Receipt bool |
| } |
| type releaseEntry struct { |