moves: the lost-box gate: a box the host's claim lists in Lost runs no move step (Task 4: Q9, Plan 4 Task 10)
At the top of each Phase 1 record's round, and in the target's report pass, a host whose claim lists this box in Lost is skipped with one standing notice. The quarantine's local retirement stays the claim's; loss-timer pages are the claim's (platform-1e).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+159
−2
internal/host/movespass.go+47 −2internal/host/movespass_lost_test.go+109 −0internal/host/movesreport.go+3 −0
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 7934771ed..4be316274 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -104,6 +104,9 @@ type movesWorker struct { |
| // landLeases is the landing's own-location lease per host, held from |
| // building through published (movesland.go); guarded by mu. |
| landLeases map[string]park.HeldLease |
| + // lost names the hosts whose claim lists this box in Lost, as this pass |
| + // read them (lostHere); rebuilt every pass under pass. |
| + lost map[string]bool |
| } |
| // staging is the memoized staging writer for a target bucket region. |
| @@ -157,14 +160,47 @@ func (w *movesWorker) strictHost(host string) bool { |
| func (w *movesWorker) setStrict(recs []provision.Record) { |
| m := map[string]bool{} |
| for _, rec := range recs { |
| - if rec.Move != nil && !rec.Move.Legacy() && |
| - (rec.Region == w.cfg.Region || rec.Move.To == w.cfg.Region || rec.Move.From == w.cfg.Region) { |
| + if movesNamesBox(w.cfg, rec) { |
| m[rec.Host] = true |
| } |
| } |
| w.strict.Store(&m) |
| } |
| +// movesNamesBox reports whether rec carries a Phase 1 move naming this |
| +// box's region: its record's, its source's or its target's. |
| +func movesNamesBox(cfg moveCfg, rec provision.Record) bool { |
| + return rec.Move != nil && !rec.Move.Legacy() && (rec.Region == cfg.Region || rec.Move.To == cfg.Region || rec.Move.From == cfg.Region) |
| +} |
| + |
| +// lostHere is the lost-box gate (moves Q9, Plan 4 Task 10, delta M15): a |
| +// box the host's claim lists in Lost runs no move step for the host, in any |
| +// role and at any state, until the claim readmits it, and logs one standing |
| +// notice. The quarantine's local retirement is the claim's (readmission |
| +// requires its retirement receipt); moves adds only this gate. The answer |
| +// holds for the pass (w.lost), so the target's report pass skips the host |
| +// too. A record that names neither this box's region nor a move is not read. |
| +func (w *movesWorker) lostHere(ctx context.Context, s release.Store, rec provision.Record) (bool, error) { |
| + if w.cfg.Box == "" || !movesNamesBox(w.cfg, rec) { |
| + return false, nil |
| + } |
| + obs, err := hostclaim.Read(ctx, s, rec.Host) |
| + if err != nil { |
| + return false, fmt.Errorf("moves: read the host claim: %w", err) |
| + } |
| + if obs.Absent || obs.Claim == nil { |
| + return false, nil |
| + } |
| + for _, box := range obs.Claim.Lost { |
| + if box == w.cfg.Box { |
| + w.lost[rec.Host] = true |
| + w.notice(rec.Host+"#lost", "moves: %s: this box (%s) is lost on this host's claim: it runs no move step until the claim readmits it", rec.Host, box) |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
| + |
| var armedMovesWorker atomic.Pointer[movesWorker] |
| func (w *movesWorker) mirror(ctx context.Context, dstRegion string) (lease.MirrorStore, error) { |
| @@ -804,6 +840,7 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error { |
| return true |
| } |
| if cfg.Enabled { |
| + w.lost = map[string]bool{} |
| w.setStrict(recs) |
| if w.holds != nil { |
| w.holds.refresh(ctx, s, recs) |
| @@ -826,6 +863,14 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error { |
| } |
| continue |
| } |
| + // The lost-box gate (Q9, Plan 4 Task 10): a box the host's claim |
| + // lists in Lost runs no move step for the host. |
| + if lost, err := w.lostHere(ctx, s, rec); err != nil { |
| + w.logTrouble(rec.Host, err) |
| + continue |
| + } else if lost { |
| + continue |
| + } |
| if rec.Move.State == provision.MoveRollingBack && movesParticipantRecord(cfg, rec) == roleSource { |
| // The source's recovery after a rollback (movesrecover.go, |
| // Plan 4 Task 5): the record's region is the source's again, |
diff --git a/internal/host/movespass_lost_test.go b/internal/host/movespass_lost_test.go| new file mode 100644 |
| index 000000000..4f9483b10 |
| --- /dev/null |
| +++ b/internal/host/movespass_lost_test.go |
| @@ -0,0 +1,109 @@ |
| +package host |
| + |
| +import ( |
| + "log" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// count is how many times s was logged (housekeeper_test.go's buffer). |
| +func (l *lockedBuffer) count(s string) int { return strings.Count(l.String(), s) } |
| + |
| +// lostLog sends the log to a buffer for the rest of the test; a pass may |
| +// log from goroutines, so the buffer is housekeeper_test.go's locked one. |
| +func lostLog(t *testing.T) *lockedBuffer { |
| + t.Helper() |
| + buf := &lockedBuffer{} |
| + out, flags := log.Writer(), log.Flags() |
| + log.SetOutput(buf) |
| + t.Cleanup(func() { log.SetOutput(out); log.SetFlags(flags) }) |
| + return buf |
| +} |
| + |
| +// rcLose lists box in the host claim's Lost, as a declaration leaves it. |
| +func rcLose(t *testing.T, h *phase1Harness, box string) { |
| + t.Helper() |
| + rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { |
| + c.Lost = append(c.Lost, box) |
| + if c.LostRevisions == nil { |
| + c.LostRevisions = map[string]uint64{} |
| + } |
| + c.LostRevisions[box] = 1 |
| + }) |
| +} |
| + |
| +// Q9's gate (Plan 4 Task 10, witness wLostQuarantine; delta M15): a box |
| +// the host's claim lists in Lost runs no move step for the host, whatever |
| +// its role and the record's state, and logs one standing notice. The |
| +// quarantine's local retirement is the claim's (readmission requires its |
| +// receipt); moves adds only this gate. |
| +func TestReturningLostBoxRefusesEveryMoveStep(t *testing.T) { |
| + type snapshot struct { |
| + rec provision.Record |
| + claim hostclaim.Claim |
| + } |
| + take := func(t *testing.T, h *phase1Harness) snapshot { |
| + t.Helper() |
| + return snapshot{rec: h.read(t, p1Host), claim: *h.claim(t)} |
| + } |
| + t.Run("the source before the flip", func(t *testing.T) { |
| + buf := lostLog(t) |
| + h := newPhase1Harness(t) |
| + h.start(t, "auto") |
| + rcLose(t, h, "ie-box") |
| + before := take(t, h) |
| + h.pass(t) |
| + h.pass(t) |
| + if after := take(t, h); !reflect.DeepEqual(before, after) { |
| + t.Fatalf("a lost source moved the host:\n%+v\n%+v", before.rec.Move, after.rec.Move) |
| + } |
| + if r, err := readSourceReport(t.Context(), h.s, h.read(t, p1Host)); err != nil || r != nil { |
| + t.Fatalf("a lost source reported: %+v %v", r, err) |
| + } |
| + if n := buf.count("is lost on this host's claim"); n != 1 { |
| + t.Fatalf("notices %d:\n%s", n, buf.String()) |
| + } |
| + // The gate is this box's: the same record moves on once the claim |
| + // no longer lists it. |
| + rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { c.Lost, c.LostRevisions = nil, nil }) |
| + h.pass(t) |
| + if r, err := readSourceReport(t.Context(), h.s, h.read(t, p1Host)); err != nil || r == nil { |
| + t.Fatalf("the readmitted source did not report: %v", err) |
| + } |
| + }) |
| + t.Run("the target before the flip", func(t *testing.T) { |
| + lostLog(t) |
| + h := newPhase1Harness(t) |
| + h.start(t, "auto") |
| + h.pass(t) |
| + rcLose(t, h, "uk-box") |
| + before := take(t, h) |
| + h.targetPass(t, h.targetCfg()) |
| + if after := take(t, h); !reflect.DeepEqual(before, after) { |
| + t.Fatalf("a lost target moved the host:\n%+v\n%+v", before.rec.Move, after.rec.Move) |
| + } |
| + if r, err := readTargetReport(t.Context(), h.s, h.read(t, p1Host)); err != nil || r != nil { |
| + t.Fatalf("a lost target reported: %+v %v", r, err) |
| + } |
| + }) |
| + t.Run("the source's recovery", func(t *testing.T) { |
| + buf := lostLog(t) |
| + h := newRecoverHarness(t, false) |
| + h.released(t) |
| + rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { |
| + c.Lost, c.LostRevisions = append(c.Lost, "ie-box"), map[string]uint64{"ie-box": 1} |
| + }) |
| + rec, claim := h.read(t, p1Host), *h.claim(t) |
| + h.srcPass(t) |
| + if !reflect.DeepEqual(rec, h.read(t, p1Host)) || !reflect.DeepEqual(claim, *h.claim(t)) || len(h.probes) != 0 || h.srcRoute.HoldOp != p1ID { |
| + t.Fatalf("a lost source recovered: %+v", h.read(t, p1Host).Move.Rollback) |
| + } |
| + if n := buf.count("is lost on this host's claim"); n != 1 { |
| + t.Fatalf("notices %d", n) |
| + } |
| + }) |
| +} |
diff --git a/internal/host/movesreport.go b/internal/host/movesreport.go| index 5540fa8a9..e3fc1dcfb 100644 |
| --- a/internal/host/movesreport.go |
| +++ b/internal/host/movesreport.go |
| @@ -148,6 +148,9 @@ func (w *movesWorker) targetReportPass(ctx context.Context, s release.Store, rec |
| if m.State != provision.MovePreparing && m.State != provision.MoveReady { |
| continue |
| } |
| + if w.lost[rec.Host] { |
| + continue // the lost-box gate (movespass.go lostHere) |
| + } |
| if err := w.targetReport(ctx, s, rec, fleet); err != nil { |
| w.logTrouble(rec.Host+" (target)", err) |
| continue |