carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 71b0bb5e927a8aa541c1a6b1f07050f4a93ecbf8 parent 04b5aa7
3 files changed, +159 −2
  • internal/host/movespass.go +47 −2
  • internal/host/movespass_lost_test.go +109 −0
  • internal/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