| index 20768fd83..465dff847 100644 |
| --- a/internal/hostclaim/move_test.go |
| +++ b/internal/hostclaim/move_test.go |
| @@ -1302,3 +1302,770 @@ func TestCommitRefusesAnythingButAProbingTargetLanding(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// ---- Plan 4 Task 2: the rollback and the return (§7.8, K4, K8) ---- |
| + |
| +const ( |
| + rbA = "rb_abcdefghijklmnopqrstuvwxyz" |
| + rbB = "rb_bcdefghijklmnopqrstuvwxyza" |
| + stoppedTarget = "moves/bab/app/h/rb/stopped-target.json" |
| +) |
| + |
| +// targetWroteLanding is probedLanding with the target's probe admitted as |
| +// the writer: holder.generation and writer_box are the target's, and the |
| +// last admission record is the target's writer (its predecessor the |
| +// source's generation in ie). The flip at gen 7 made the target's |
| +// installation stamp F = 8. |
| +func targetWroteLanding(t *testing.T) *Claim { |
| + t.Helper() |
| + c := probedLanding(t) |
| + c.Holder.Generation, c.Holder.WriterBox = moveA, "uk-box" |
| + if c.Holder.Stamp != 8 { |
| + t.Fatalf("fixture: the flip's stamp is %d, want 8", c.Holder.Stamp) |
| + } |
| + return c |
| +} |
| + |
| +// rolledBack is targetWroteLanding after the rollback's claim half. |
| +func rolledBack(t *testing.T) *Claim { |
| + t.Helper() |
| + c := targetWroteLanding(t) |
| + if err := RollBack(moveA, rbA)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| +} |
| + |
| +func releaseScope(c *Claim) ParkScope { |
| + return ParkScope{Location: c.Holder.Location, Incarnation: c.Holder.Incarnation, EvidenceAt: time.Date(2026, 10, 6, 11, 0, 0, 0, time.UTC), RequestLimit: 2 * time.Minute} |
| +} |
| + |
| +// released is rolledBack after the target's release, with no park-local |
| +// (the target listed no paths of F). |
| +func released(t *testing.T) *Claim { |
| + t.Helper() |
| + c := rolledBack(t) |
| + if err := TargetReleased(moveA, "uk-box", "uk", stoppedTarget, releaseScope(c), counter())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| +} |
| + |
| +func validAfterWrite(t *testing.T, c *Claim) { |
| + t.Helper() |
| + d := *c |
| + d.Gen++ |
| + if err := d.Validate(); err != nil { |
| + t.Fatalf("the claim after the write must validate: %v", err) |
| + } |
| +} |
| + |
| +// The commit and the rollback race on the claim as on the record: whichever |
| +// writes first, the other is refused and leaves the claim as it found it. |
| +func TestRollBackRacesTheCommitOnTheClaim(t *testing.T) { |
| + newID := counter() |
| + // The commit first. |
| + c := targetWroteLanding(t) |
| + src, stg := commitScopes(c) |
| + if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := mustJSON(t, c) |
| + if err := RollBack(moveA, rbA)(c); !errors.Is(err, ErrWrongState) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("rollback after the commit: %v", err) |
| + } |
| + // The rollback first. |
| + d := targetWroteLanding(t) |
| + if err := RollBack(moveA, rbA)(d); err != nil { |
| + t.Fatal(err) |
| + } |
| + if d.Op.Kind != KindRestoring || d.Op.Phase != PhaseReleasing || d.Op.Exec != "uk-box" || d.Op.Intent["rollback"] != rbA || |
| + d.Op.ID != moveA || len(d.Op.Participants) != 2 { |
| + t.Fatalf("op after the rollback: %+v", d.Op) |
| + } |
| + // J15: the target installation's stamp F is journalled for its release. |
| + if d.Op.Journal[JournalTargetStamp] != "8" { |
| + t.Fatalf("target stamp journal %q", d.Op.Journal[JournalTargetStamp]) |
| + } |
| + if d.Holder.Stamp != 8 || d.Holder.Generation != moveA { |
| + t.Fatalf("the rollback touched the holder: %+v", d.Holder) |
| + } |
| + validAfterWrite(t, d) |
| + before = mustJSON(t, d) |
| + if err := Commit(moveA, "ie", "ie-box", "uk", src, stg, newID)(d); !errors.Is(err, ErrWrongState) || string(mustJSON(t, d)) != string(before) { |
| + t.Fatalf("commit after the rollback: %v", err) |
| + } |
| + // A replay of the same rollback is a no-op; another rollback id is refused. |
| + if err := RollBack(moveA, rbA)(d); err != nil || string(mustJSON(t, d)) != string(before) { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + if err := RollBack(moveA, rbB)(d); !errors.Is(err, ErrWrongState) || string(mustJSON(t, d)) != string(before) { |
| + t.Fatalf("another rollback id: %v", err) |
| + } |
| + // Every landing phase before the commit rolls back. |
| + for _, phase := range []string{"checking", "parking", "building", "published", "local", "configured", "restored", "probing", "probed"} { |
| + e := targetWroteLanding(t) |
| + e.Op.Phase = phase |
| + if err := RollBack(moveA, rbA)(e); err != nil || e.Op.Kind != KindRestoring || e.Op.Phase != PhaseReleasing { |
| + t.Fatalf("from landing/%s: %v %+v", phase, err, e.Op) |
| + } |
| + } |
| + // Refusals leave the claim alone. |
| + for name, tc := range map[string]struct { |
| + mutate func(*Claim) |
| + op, rb string |
| + want error |
| + }{ |
| + "another op": {func(*Claim) {}, moveB, rbA, ErrStaleOp}, |
| + "a move id": {func(*Claim) {}, moveA, moveB, ErrMalformed}, |
| + "no id": {func(*Claim) {}, moveA, "", ErrMalformed}, |
| + "short id": {func(*Claim) {}, moveA, "rb_abc", ErrMalformed}, |
| + "serving": {func(c *Claim) { c.Op = Op{Kind: KindServing, Exec: "uk-box"} }, moveA, rbA, ErrStaleOp}, |
| + "moving, sealed": {func(c *Claim) { c.Op.Kind, c.Op.Phase, c.Op.Exec = KindMoving, PhaseSealed, "" }, moveA, rbA, ErrWrongState}, |
| + "reverting": {func(c *Claim) { c.Op.Kind, c.Op.Phase, c.Op.Exec = KindReverting, "", "ie-box" }, moveA, rbA, ErrWrongState}, |
| + "landing, no holder": {func(c *Claim) { c.Holder = nil }, moveA, rbA, ErrWrongState}, |
| + "landing, committed": {func(c *Claim) { c.Op.Phase = PhaseCommitted }, moveA, rbA, ErrWrongState}, |
| + "restoring under rbB": {func(c *Claim) { c.Op.Kind, c.Op.Phase, c.Op.Intent["rollback"] = KindRestoring, PhaseReleasing, rbB }, moveA, rbA, ErrWrongState}, |
| + } { |
| + e := targetWroteLanding(t) |
| + tc.mutate(e) |
| + before := mustJSON(t, e) |
| + if err := RollBack(tc.op, tc.rb)(e); !errors.Is(err, tc.want) || string(mustJSON(t, e)) != string(before) { |
| + t.Fatalf("%s: got %v, want %v", name, err, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// J16: a record already at landing whose claim is still moving/sealed (the |
| +// flip's claim half not yet written) rolls back through RollBackFlipped, |
| +// which writes the flip first, in the same write. |
| +func TestRollBackFlippedWritesTheFlipFirst(t *testing.T) { |
| + c := movingToSealed(t) |
| + c.Gen = 7 |
| + before := mustJSON(t, c) |
| + if err := RollBack(moveA, rbA)(c); !errors.Is(err, ErrWrongState) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("plain rollback over moving/sealed: %v", err) |
| + } |
| + // The flip half refuses (the source's stamp is wrong): nothing is written. |
| + if err := RollBackFlipped(moveA, rbA, "uk-box", 2, "gen-7")(c); !errors.Is(err, ErrWrongState) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("a refused flip half: %v\nbefore %s\nafter %s", err, before, mustJSON(t, c)) |
| + } |
| + if err := RollBackFlipped(moveA, "", "uk-box", 3, "gen-7")(c); !errors.Is(err, ErrMalformed) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("a malformed rollback id: %v", err) |
| + } |
| + if err := RollBackFlipped(moveA, rbA, "uk-box", 3, "gen-7")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Kind != KindRestoring || c.Op.Phase != PhaseReleasing || c.Op.Exec != "uk-box" || c.Op.Intent["rollback"] != rbA { |
| + t.Fatalf("op: %+v", c.Op) |
| + } |
| + if c.Holder.Stamp != 8 || c.Op.Journal[JournalTargetStamp] != "8" || c.Op.Journal[JournalSourceStamp] != "3" || c.Op.Journal[JournalSrcGen] != "gen-7" { |
| + t.Fatalf("stamps: holder %d journal %v", c.Holder.Stamp, c.Op.Journal) |
| + } |
| + validAfterWrite(t, c) |
| + // Over an already flipped (landing) claim it is RollBack, and a replay is a no-op. |
| + d := targetWroteLanding(t) |
| + if err := RollBackFlipped(moveA, rbA, "uk-box", 3, "gen-7")(d); err != nil || d.Op.Kind != KindRestoring { |
| + t.Fatalf("over landing: %v %+v", err, d.Op) |
| + } |
| + after := mustJSON(t, d) |
| + if err := RollBackFlipped(moveA, rbA, "uk-box", 3, "gen-7")(d); err != nil || string(mustJSON(t, d)) != string(after) { |
| + t.Fatalf("replay: %v", err) |
| + } |
| +} |
| + |
| +// §7.8 released, claim rev 6.14 item 6 (R12-3), J15. |
| +func TestTargetReleasedCoversTheTargetRegion(t *testing.T) { |
| + build := func(inventory map[string]InventoryEntry) *Claim { |
| + c := targetWroteLanding(t) |
| + // A restarted probe: its record's predecessor is the dead probe's |
| + // generation, in the target's region. |
| + c.Holder.Admissions = append(c.Holder.Admissions, AdmissionRecord{Region: "uk", PredecessorRegion: "uk", Session: 10, Box: "uk-box", Generation: "gen-r2", Predecessor: moveA, Mode: "cold"}) |
| + c.Holder.Session, c.Holder.Generation = 10, "gen-r2" |
| + c.Inventory = inventory |
| + if err := RollBack(moveA, rbA)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| + } |
| + withF := map[string]InventoryEntry{"uk-box": {Layout: "nested", Paths: map[string]uint64{"/data/uk": 8}}} |
| + c := build(withF) |
| + ids := 0 |
| + newID := func() string { ids++; return fmt.Sprintf("t%d", ids) } |
| + holder := *c.Holder |
| + if err := TargetReleased(moveA, "uk-box", "uk", stoppedTarget, releaseScope(c), newID)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Kind != KindRestoring || c.Op.Phase != PhaseReleased || c.Op.Exec != "" || c.Op.Journal[JournalEvidenceTarget] != stoppedTarget { |
| + t.Fatalf("op: %+v", c.Op) |
| + } |
| + k := obligationKinds(c) |
| + if k["park-replica/uk"] != 1 || k["park-local/uk-box"] != 1 || len(c.Obligations) != 2 { |
| + t.Fatalf("obligations: %+v", c.Obligations) |
| + } |
| + for _, o := range c.Obligations { |
| + if o.Op != moveA || (o.Kind == "park-replica" && (o.Park == nil || o.Park.Location != c.Holder.Location || o.Owner != "region:uk")) { |
| + t.Fatalf("obligation: %+v", o) |
| + } |
| + } |
| + if c.Holder.Admissions[0].Covered || !c.Holder.Admissions[1].Covered { |
| + t.Fatalf("coverage: ie-predecessor %v, uk-predecessor %v", c.Holder.Admissions[0].Covered, c.Holder.Admissions[1].Covered) |
| + } |
| + if c.Holder.Stamp != holder.Stamp || c.Holder.Session != holder.Session || c.Holder.Generation != holder.Generation || c.Holder.WriterBox != holder.WriterBox { |
| + t.Fatalf("the release touched the holder: %+v", c.Holder) |
| + } |
| + validAfterWrite(t, c) |
| + // A replay queues nothing and mints no ids, even once a chore is done. |
| + used, before := ids, mustJSON(t, c) |
| + if err := TargetReleased(moveA, "uk-box", "uk", stoppedTarget, releaseScope(c), newID)(c); err != nil || ids != used || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("replay: %v ids %d->%d", err, used, ids) |
| + } |
| + c.Obligations = c.Obligations[:1] |
| + before = mustJSON(t, c) |
| + if err := TargetReleased(moveA, "uk-box", "uk", stoppedTarget, releaseScope(c), newID)(c); err != nil || ids != used || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("replay after a completed chore: %v ids %d->%d", err, used, ids) |
| + } |
| + // J15: no paths of F on the target, no park-local. |
| + for name, inv := range map[string]map[string]InventoryEntry{ |
| + "no inventory": nil, |
| + "another stamp": {"uk-box": {Layout: "nested", Paths: map[string]uint64{"/data/uk": 3}}}, |
| + "F on another box": {"ie-box": {Layout: "nested", Paths: map[string]uint64{"/data/ie": 8}}}, |
| + } { |
| + d := build(inv) |
| + if err := TargetReleased(moveA, "uk-box", "uk", stoppedTarget, releaseScope(d), counter())(d); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if k := obligationKinds(d); k["park-replica/uk"] != 1 || len(d.Obligations) != 1 { |
| + t.Fatalf("%s: obligations %+v", name, d.Obligations) |
| + } |
| + } |
| + // Refusals leave the claim as found. |
| + for name, tc := range map[string]struct { |
| + mutate func(*Claim) |
| + box, region, ev, op string |
| + want error |
| + }{ |
| + "another op": {func(*Claim) {}, "uk-box", "uk", stoppedTarget, moveB, ErrStaleOp}, |
| + "not rolled back": {func(c *Claim) { c.Op.Kind, c.Op.Phase = KindLanding, "probed" }, "uk-box", "uk", stoppedTarget, moveA, ErrWrongState}, |
| + "the source's box": {func(*Claim) {}, "ie-box", "uk", stoppedTarget, moveA, ErrWrongState}, |
| + "the source's region": {func(*Claim) {}, "uk-box", "ie", stoppedTarget, moveA, ErrWrongState}, |
| + "no evidence": {func(*Claim) {}, "uk-box", "uk", "", moveA, ErrMalformed}, |
| + "exec is not the target": {func(c *Claim) { c.Op.Exec = "ie-box" }, "uk-box", "uk", stoppedTarget, moveA, ErrWrongState}, |
| + "released, other evidence": {func(c *Claim) { |
| + c.Op.Phase, c.Op.Exec, c.Op.Journal[JournalEvidenceTarget] = PhaseReleased, "", "other" |
| + }, "uk-box", "uk", stoppedTarget, moveA, ErrWrongState}, |
| + } { |
| + d := build(withF) |
| + tc.mutate(d) |
| + before := mustJSON(t, d) |
| + if err := TargetReleased(tc.op, tc.box, tc.region, tc.ev, releaseScope(d), counter())(d); !errors.Is(err, tc.want) || string(mustJSON(t, d)) != string(before) { |
| + t.Fatalf("%s: got %v, want %v", name, err, tc.want) |
| + } |
| + } |
| + // A location park that is not the holder's location refuses the whole |
| + // write: exec, phase and coverage stay as found. |
| + d := build(withF) |
| + before = mustJSON(t, d) |
| + bad := releaseScope(d) |
| + bad.Location = "instances/" + d.Host |
| + if err := TargetReleased(moveA, "uk-box", "uk", stoppedTarget, bad, counter())(d); !errors.Is(err, ErrMalformed) || string(mustJSON(t, d)) != string(before) { |
| + t.Fatalf("bad scope: %v\nbefore %s\nafter %s", err, before, mustJSON(t, d)) |
| + } |
| +} |
| + |
| +// Review Focus 3 (wCleanReturnIdentity, INV-16, R11-4): the clean return |
| +// names the source's generation and stamp again and leaves the admissions, |
| +// whose last record is the target's writer, for the re-admission to name. |
| +func TestReturnToSourceCleanNamesSrcGenAndKeepsAdmissions(t *testing.T) { |
| + c := released(t) |
| + admissions := append([]AdmissionRecord(nil), c.Holder.Admissions...) |
| + session, writer := c.Holder.Session, c.Holder.WriterBox |
| + if err := ReturnToSource(moveA, "ie-box", true, ReturnFence{})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Kind != KindRestoring || c.Op.Phase != PhaseClaimed || c.Op.Exec != "ie-box" { |
| + t.Fatalf("op: %+v", c.Op) |
| + } |
| + if c.Holder.Generation != "gen-7" || c.Holder.Stamp != 3 { |
| + t.Fatalf("holder generation %q stamp %d, want gen-7 and 3", c.Holder.Generation, c.Holder.Stamp) |
| + } |
| + if !reflect.DeepEqual(c.Holder.Admissions, admissions) || c.Holder.Session != session || c.Holder.WriterBox != writer { |
| + t.Fatalf("the return touched the admissions: %+v", c.Holder) |
| + } |
| + if last := c.Holder.Admissions[len(c.Holder.Admissions)-1]; last.Box != "uk-box" || last.Generation != moveA { |
| + t.Fatalf("the last admission is not the target's writer: %+v", last) |
| + } |
| + validAfterWrite(t, c) |
| + // A replay is a no-op. |
| + before := mustJSON(t, c) |
| + if err := ReturnToSource(moveA, "ie-box", true, ReturnFence{})(c); err != nil || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + // Refusals. |
| + for name, tc := range map[string]struct { |
| + claim func() *Claim |
| + op string |
| + source string |
| + want error |
| + }{ |
| + "another op": {func() *Claim { return released(t) }, moveB, "ie-box", ErrStaleOp}, |
| + "the target": {func() *Claim { return released(t) }, moveA, "uk-box", ErrWrongState}, |
| + "no source": {func() *Claim { return released(t) }, moveA, "", ErrMalformed}, |
| + "before the release": {func() *Claim { return rolledBack(t) }, moveA, "ie-box", ErrWrongState}, |
| + "landing": {func() *Claim { return targetWroteLanding(t) }, moveA, "ie-box", ErrWrongState}, |
| + "no evidence": {func() *Claim { |
| + c := released(t) |
| + delete(c.Op.Journal, JournalEvidenceTarget) |
| + return c |
| + }, moveA, "ie-box", ErrWrongState}, |
| + "no source stamp": {func() *Claim { |
| + c := released(t) |
| + delete(c.Op.Journal, JournalSourceStamp) |
| + return c |
| + }, moveA, "ie-box", ErrWrongState}, |
| + "no source gen": {func() *Claim { |
| + c := released(t) |
| + delete(c.Op.Journal, JournalSrcGen) |
| + return c |
| + }, moveA, "ie-box", ErrWrongState}, |
| + "exec still a box": {func() *Claim { |
| + c := released(t) |
| + c.Op.Exec = "uk-box" |
| + return c |
| + }, moveA, "ie-box", ErrWrongState}, |
| + "claimed by the other": {func() *Claim { |
| + c := released(t) |
| + c.Op.Phase, c.Op.Exec = PhaseClaimed, "uk-box" |
| + return c |
| + }, moveA, "ie-box", ErrWrongState}, |
| + "probing": {func() *Claim { |
| + c := released(t) |
| + c.Op.Phase, c.Op.Exec = PhaseProbing, "ie-box" |
| + return c |
| + }, moveA, "ie-box", ErrWrongState}, |
| + } { |
| + c := tc.claim() |
| + before := mustJSON(t, c) |
| + if err := ReturnToSource(tc.op, tc.source, true, ReturnFence{})(c); !errors.Is(err, tc.want) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("%s: got %v, want %v", name, err, tc.want) |
| + } |
| + } |
| +} |
| + |
| +// A dirty return names the source's stamp again and leaves holder.generation |
| +// at the target's last admission: the source's probe names the return |
| +// generation at its cold admission (§7.8, §19.1 table). |
| +func TestReturnToSourceDirtyLeavesGeneration(t *testing.T) { |
| + c := released(t) |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Phase != PhaseClaimed || c.Op.Exec != "ie-box" || c.Holder.Stamp != 3 || c.Holder.Generation != moveA { |
| + t.Fatalf("op %+v holder %+v", c.Op, c.Holder) |
| + } |
| + // src_gen may be the origin (""): the clean return then names it. |
| + d := released(t) |
| + d.Op.Journal[JournalSrcGen] = "" |
| + if err := ReturnToSource(moveA, "ie-box", true, ReturnFence{})(d); err != nil || d.Holder.Generation != "" { |
| + t.Fatalf("origin generation: %v %q", err, d.Holder.Generation) |
| + } |
| +} |
| + |
| +// declaredLost is rolledBack (restoring/releasing, exec the target) after |
| +// claim Plan 8's declare-lost transition for the target. |
| +func declaredLost(t *testing.T) *Claim { |
| + t.Helper() |
| + c := rolledBack(t) |
| + in := LostDeclaration{LossID: "loss_1", ExpectedClaimGen: c.Gen, ExpectedClaimETag: `"etag"`, |
| + HolderIncarnation: c.Holder.Incarnation, HolderStamp: c.Holder.Stamp, HolderSession: c.Holder.Session, |
| + OpID: moveA, OpKind: KindRestoring, OpPhase: PhaseReleasing, Box: "uk-box", Enrollment: "enroll-uk", |
| + QuarantineRevision: 1, FencedAt: time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC), |
| + Consequences: LossConsequences{RevokeExecution: true, RemoveParticipation: true, RecoveryIntent: []byte(`{"kind":"lost-target"}`)}} |
| + if err := declareLostTransition(in, testLossOwner{})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Exec != "none" || !reflect.DeepEqual(c.Op.Participants, []string{"ie-box"}) || c.Op.Journal["loss.loss_1"] == "" || c.Op.Journal[JournalEvidenceTarget] != "" || c.Op.Phase != PhaseReleasing { |
| + t.Fatalf("fixture: %+v", c.Op) |
| + } |
| + return c |
| +} |
| + |
| +// §7.10: an operator's declaration stands in for the target's evidence. The |
| +// claim is as Plan 8's transition leaves it (exec "none", the target out of |
| +// participants, loss.<id> journalled, no evidence_target), stepped to |
| +// released by the resolve door (J16) before the source claims. |
| +func TestReturnToSourceAfterADeclaredLoss(t *testing.T) { |
| + c := declaredLost(t) |
| + before := mustJSON(t, c) |
| + // J16: the resolve door steps the claim to released first. |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(c); !errors.Is(err, ErrWrongState) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("at releasing: %v", err) |
| + } |
| + c.Op.Phase = PhaseReleased |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Exec != "ie-box" || c.Op.Phase != PhaseClaimed || c.Holder.Stamp != 3 { |
| + t.Fatalf("op %+v stamp %d", c.Op, c.Holder.Stamp) |
| + } |
| + validAfterWrite(t, c) |
| + // The same without the loss entry is refused. |
| + d := declaredLost(t) |
| + d.Op.Phase = PhaseReleased |
| + delete(d.Op.Journal, "loss.loss_1") |
| + before = mustJSON(t, d) |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(d); !errors.Is(err, ErrWrongState) || string(mustJSON(t, d)) != string(before) { |
| + t.Fatalf("without the loss entry: %v", err) |
| + } |
| + // A loss entry the claim never recorded, or one that left the target a |
| + // participant, is not the target's declaration. |
| + e := declaredLost(t) |
| + e.Op.Phase = PhaseReleased |
| + e.LossRefs = nil |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(e); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("an unrecorded loss: %v", err) |
| + } |
| + f := declaredLost(t) |
| + f.Op.Phase = PhaseReleased |
| + f.Op.Participants = []string{"ie-box", "uk-box"} |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(f); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("the target still a participant: %v", err) |
| + } |
| +} |
| + |
| +// J5: a termination fence for the target, verified by the caller before |
| +// Update (VerifyReturnFence), authorises the return when the closure finds |
| +// the same fence.<target> entry. |
| +func TestReturnToSourceWithAVerifiedFence(t *testing.T) { |
| + const ref = `{"enrollment":"0123456789abcdef0123456789abcdef","key":"control/box-fences/0123456789abcdef0123456789abcdef.json","sha256":"00"}` |
| + fenced := func() *Claim { |
| + c := rolledBack(t) |
| + c.Op.Phase, c.Op.Exec = PhaseReleased, "none" |
| + c.Op.Journal["fence.uk-box"] = ref |
| + return c |
| + } |
| + good := ReturnFence{op: moveA, box: "uk-box", ref: ref} |
| + c := fenced() |
| + if err := ReturnToSource(moveA, "ie-box", true, good)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Exec != "ie-box" || c.Op.Phase != PhaseClaimed || c.Holder.Generation != "gen-7" { |
| + t.Fatalf("op %+v holder %+v", c.Op, c.Holder) |
| + } |
| + for name, f := range map[string]ReturnFence{ |
| + "unverified": {}, |
| + "another op": {op: moveB, box: "uk-box", ref: ref}, |
| + "the source's fence": {op: moveA, box: "ie-box", ref: ref}, |
| + "another journal value": {op: moveA, box: "uk-box", ref: ref + " "}, |
| + } { |
| + d := fenced() |
| + before := mustJSON(t, d) |
| + if err := ReturnToSource(moveA, "ie-box", true, f)(d); !errors.Is(err, ErrWrongState) || string(mustJSON(t, d)) != string(before) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| + // VerifyReturnFence verifies before it vouches: a journal entry that does |
| + // not decode, or no store, is ErrFence. |
| + op := fenced().Op |
| + if _, err := VerifyReturnFence(context.Background(), nil, nil, op, "uk-box"); !errors.Is(err, ErrFence) { |
| + t.Fatalf("no store: %v", err) |
| + } |
| + op.Journal["fence.uk-box"] = "not json" |
| + if _, err := VerifyReturnFence(context.Background(), release.NewDirStore(t.TempDir()), nil, op, "uk-box"); !errors.Is(err, ErrFence) { |
| + t.Fatalf("bad journal: %v", err) |
| + } |
| +} |
| + |
| +// K8 and J6: the release to serving from each terminal move state, once no |
| +// chore under the op remains. The executor named is the one executing. |
| +func TestReleaseMoveWaitsForObligations(t *testing.T) { |
| + states := map[string]struct { |
| + kind, phase, exec string |
| + }{ |
| + "committed landing": {KindLanding, PhaseCommitted, "uk-box"}, |
| + "opened rollback": {KindRestoring, PhaseOpened, "ie-box"}, |
| + "opened revert": {KindReverting, PhaseOpened, "ie-box"}, |
| + "interim abort": {KindMoving, PhaseAborting, "ie-box"}, |
| + "interim abort before cut": {KindMoving, PhaseMoving, "ie-box"}, |
| + } |
| + for name, st := range states { |
| + c := targetWroteLanding(t) |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = st.kind, st.phase, st.exec |
| + c.Obligations = []Obligation{{ID: "o1", Op: moveA, Kind: "park-replica", Region: "ie", Retirement: "r", Owner: "region:ie"}} |
| + before := mustJSON(t, c) |
| + if err := ReleaseMove(moveA, st.exec)(c); !errors.Is(err, ErrPendingChore) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("%s with a chore: %v", name, err) |
| + } |
| + c.Obligations[0].Op = "another-op" |
| + if err := ReleaseMove(moveA, st.exec)(c); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if c.Op.Kind != KindServing || c.Op.Exec != st.exec || c.Op.ID != "" || c.Op.Phase != "" || len(c.Op.Participants) != 0 || c.Op.Journal["released_move"] != moveA || len(c.Op.Journal) != 1 { |
| + t.Fatalf("%s: op after release %+v", name, c.Op) |
| + } |
| + if err := RequireReleasedMove(moveA)(c); err != nil { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + validAfterWrite(t, c) |
| + } |
| + // The new states name their executor exactly. |
| + for name, st := range states { |
| + if st.kind == KindMoving { |
| + continue |
| + } |
| + c := targetWroteLanding(t) |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = st.kind, st.phase, st.exec |
| + other := "ie-box" |
| + if st.exec == "ie-box" { |
| + other = "uk-box" |
| + } |
| + if err := ReleaseMove(moveA, other)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("%s released to %s: %v", name, other, err) |
| + } |
| + } |
| + // Not before those states: a revert's phase is read from op.phase (J1), |
| + // not rev_phase. |
| + for name, st := range map[string]struct{ kind, phase string }{ |
| + "landing probed": {KindLanding, "probed"}, |
| + "restoring probed": {KindRestoring, PhaseProbed}, |
| + "restoring claimed": {KindRestoring, PhaseClaimed}, |
| + "reverting probed": {KindReverting, PhaseProbed}, |
| + "reverting, rev only": {KindReverting, PhaseSealed}, |
| + "moving sealed": {KindMoving, PhaseSealed}, |
| + } { |
| + c := targetWroteLanding(t) |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec = st.kind, st.phase, "ie-box" |
| + if st.phase == PhaseSealed && st.kind == KindReverting { |
| + setJournal(c, JournalRevPhase, PhaseOpened) |
| + } |
| + if err := ReleaseMove(moveA, "ie-box")(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| +} |
| + |
| +// revertingClaim is a revert as Plan 2's Revert leaves it: from the cut's |
| +// stopping (phase "", no files baseline) or from sealed (a baseline). |
| +func revertingClaim(t *testing.T, sealed bool) *Claim { |
| + t.Helper() |
| + var c *Claim |
| + if sealed { |
| + c = movingToSealed(t) |
| + } else { |
| + c = movingTestClaim(t) |
| + for _, tr := range []func(*Claim) error{ |
| + BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", time.Now()), |
| + ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource), |
| + } { |
| + if err := tr(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + lane := ParkScope{Location: "moves/" + c.Host + "/" + moveA, Incarnation: c.Holder.Incarnation, EvidenceAt: time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC), RequestLimit: time.Minute} |
| + if err := Revert(moveA, "ie-box", "uk", &lane, counter())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + return c |
| +} |
| + |
| +// §7.4 and J1, J8: rev_phase runs evidence, verify, probing, probed, opened; |
| +// verify only with the files baseline the record keeps from stopped, and |
| +// straight to probing without it. op.phase mirrors probing, probed and |
| +// opened in the same write. |
| +func TestRevPhaseOrder(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + sealed bool |
| + baseline bool |
| + steps [][2]string |
| + pre string |
| + }{ |
| + {"from stopped, with a baseline", true, true, [][2]string{{"evidence", "verify"}, {"verify", "probing"}, {"probing", "probed"}, {"probed", "opened"}}, PhaseSealed}, |
| + {"from stopping, no baseline", false, false, [][2]string{{"evidence", "probing"}, {"probing", "probed"}, {"probed", "opened"}}, PhaseMoving}, |
| + } { |
| + t.Run(tc.name, func(t *testing.T) { |
| + c := revertingClaim(t, tc.sealed) |
| + if c.Op.Journal[JournalRevPhase] != "evidence" || c.Op.Phase != tc.pre { |
| + t.Fatalf("fixture: %+v", c.Op) |
| + } |
| + // The other entry is refused. |
| + wrong := "probing" |
| + if !tc.baseline { |
| + wrong = "verify" |
| + } |
| + before := mustJSON(t, c) |
| + if err := SetRevPhase(moveA, "evidence", wrong, tc.baseline)(c); !errors.Is(err, ErrWrongState) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("evidence to %s: %v", wrong, err) |
| + } |
| + for _, step := range tc.steps { |
| + if err := SetRevPhase(moveA, step[0], step[1], tc.baseline)(c); err != nil { |
| + t.Fatalf("%s to %s: %v", step[0], step[1], err) |
| + } |
| + if c.Op.Journal[JournalRevPhase] != step[1] { |
| + t.Fatalf("rev_phase %q after %v", c.Op.Journal[JournalRevPhase], step) |
| + } |
| + want := tc.pre |
| + if step[1] != "verify" { |
| + want = step[1] |
| + } |
| + if c.Op.Phase != want || c.Op.Kind != KindReverting || c.Op.Exec != "ie-box" { |
| + t.Fatalf("op after %v: %+v", step, c.Op) |
| + } |
| + validAfterWrite(t, c) |
| + // A replay finds the phase moved. |
| + before := mustJSON(t, c) |
| + if err := SetRevPhase(moveA, step[0], step[1], tc.baseline)(c); !errors.Is(err, ErrWrongState) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("replay of %v: %v", step, err) |
| + } |
| + } |
| + // opened is the last phase; ReleaseMove takes it from there. |
| + if err := SetRevPhase(moveA, "opened", "probing", tc.baseline)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("after opened: %v", err) |
| + } |
| + }) |
| + } |
| + // Skips, other kinds, other ops and a phase that disagrees with rev_phase. |
| + c := revertingClaim(t, true) |
| + for name, tc := range map[string]struct { |
| + op, from, to string |
| + want error |
| + }{ |
| + "skip verify to probed": {moveA, "evidence", "probed", ErrWrongState}, |
| + "skip to opened": {moveA, "evidence", "opened", ErrWrongState}, |
| + "from the wrong phase": {moveA, "verify", "probing", ErrWrongState}, |
| + "backwards": {moveA, "evidence", "evidence", ErrWrongState}, |
| + "an unknown phase": {moveA, "evidence", "stuck", ErrWrongState}, |
| + "another op": {moveB, "evidence", "verify", ErrStaleOp}, |
| + } { |
| + before := mustJSON(t, c) |
| + if err := SetRevPhase(tc.op, tc.from, tc.to, true)(c); !errors.Is(err, tc.want) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("%s: got %v, want %v", name, err, tc.want) |
| + } |
| + } |
| + d := revertingClaim(t, true) |
| + d.Op.Phase = PhaseProbed // disagrees with rev_phase evidence |
| + if err := SetRevPhase(moveA, "evidence", "verify", true)(d); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("op.phase ahead of rev_phase: %v", err) |
| + } |
| + e := rolledBack(t) |
| + setJournal(e, JournalRevPhase, "evidence") |
| + if err := SetRevPhase(moveA, "evidence", "verify", true)(e); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("a restoring op: %v", err) |
| + } |
| +} |
| + |
| +// J1: the source's recovery phases after claimed are mirrored onto the claim |
| +// op in order: probing (before the first recovery attempt), probed, opened. |
| +func TestRestoringPhaseOrder(t *testing.T) { |
| + c := released(t) |
| + if err := SetRestoringPhase(moveA, PhaseReleased, PhaseClaimed)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("released to claimed is ReturnToSource's: %v", err) |
| + } |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, step := range [][2]string{{PhaseClaimed, PhaseProbing}, {PhaseProbing, PhaseProbed}, {PhaseProbed, PhaseOpened}} { |
| + before := mustJSON(t, c) |
| + if err := SetRestoringPhase(moveA, step[0], "nonsense")(c); !errors.Is(err, ErrWrongState) || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("from %s to nonsense: %v", step[0], err) |
| + } |
| + if err := SetRestoringPhase(moveA, step[0], step[1])(c); err != nil { |
| + t.Fatalf("%v: %v", step, err) |
| + } |
| + if c.Op.Phase != step[1] || c.Op.Exec != "ie-box" || c.Op.Kind != KindRestoring { |
| + t.Fatalf("op after %v: %+v", step, c.Op) |
| + } |
| + if err := SetRestoringPhase(moveA, step[0], step[1])(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("replay %v: %v", step, err) |
| + } |
| + } |
| + d := released(t) |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(d); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := SetRestoringPhase(moveA, PhaseClaimed, PhaseProbed)(d); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("skip: %v", err) |
| + } |
| + if err := SetRestoringPhase(moveB, PhaseClaimed, PhaseProbing)(d); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("another op: %v", err) |
| + } |
| + // The source executes from claimed on; a claim whose exec is not the |
| + // source does not step. |
| + d.Op.Exec = "uk-box" |
| + if err := SetRestoringPhase(moveA, PhaseClaimed, PhaseProbing)(d); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("exec not the source: %v", err) |
| + } |
| +} |
| + |
| +// §7.8 probed (dirty): the return lane moves/<host>/<rb id> is parked in the |
| +// source's region, queued once, and the release to serving waits for it. |
| +func TestQueueReturnLaneParkAtProbed(t *testing.T) { |
| + c := released(t) |
| + c.Obligations = nil // the target's chores are done |
| + if err := ReturnToSource(moveA, "ie-box", false, ReturnFence{})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + lane := releaseScope(c) |
| + lane.Location = "moves/" + c.Host + "/" + rbA |
| + ids := 0 |
| + newID := func() string { ids++; return fmt.Sprintf("l%d", ids) } |
| + for _, phase := range []string{PhaseClaimed, PhaseProbing} { |
| + c.Op.Phase = phase |
| + if err := QueueReturnLanePark(moveA, "ie", lane, newID)(c); !errors.Is(err, ErrWrongState) || ids != 0 { |
| + t.Fatalf("at %s: %v", phase, err) |
| + } |
| + } |
| + c.Op.Phase = PhaseProbed |
| + for name, tc := range map[string]struct { |
| + region string |
| + location string |
| + want error |
| + }{ |
| + "the target's region": {"uk", lane.Location, ErrWrongState}, |
| + "the move's lane": {"ie", "moves/" + c.Host + "/" + moveA, ErrMalformed}, |
| + "the live location": {"ie", c.Holder.Location, ErrMalformed}, |
| + "the staging prefix": {"ie", "moves/" + c.Host, ErrMalformed}, |
| + } { |
| + s := lane |
| + s.Location = tc.location |
| + before := mustJSON(t, c) |
| + if err := QueueReturnLanePark(moveA, tc.region, s, newID)(c); !errors.Is(err, tc.want) || ids != 0 || string(mustJSON(t, c)) != string(before) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + } |
| + if err := QueueReturnLanePark(moveA, "ie", lane, newID)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if k := obligationKinds(c); k["park-replica/ie"] != 1 || len(c.Obligations) != 1 || c.Obligations[0].Park.Location != lane.Location || c.Obligations[0].Owner != "region:ie" { |
| + t.Fatalf("obligations: %+v", c.Obligations) |
| + } |
| + validAfterWrite(t, c) |
| + used := ids |
| + if err := QueueReturnLanePark(moveA, "ie", lane, newID)(c); err != nil || ids != used || len(c.Obligations) != 1 { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + other := lane |
| + other.RequestLimit = time.Hour |
| + if err := QueueReturnLanePark(moveA, "ie", other, newID)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("another scope: %v", err) |
| + } |
| + // The release waits for the lane park. |
| + if err := SetRestoringPhase(moveA, PhaseProbed, PhaseOpened)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ReleaseMove(moveA, "ie-box")(c); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("release before the lane park: %v", err) |
| + } |
| + // Once the park engine completes the chore, a replay at probed does not |
| + // queue it again. |
| + c.Obligations = nil |
| + c.Op.Phase = PhaseProbed |
| + if err := QueueReturnLanePark(moveA, "ie", lane, newID)(c); err != nil || len(c.Obligations) != 0 || ids != used { |
| + t.Fatalf("replay after completion: %v %+v", err, c.Obligations) |
| + } |
| + c.Op.Phase = PhaseOpened |
| + if err := ReleaseMove(moveA, "ie-box")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |