| new file mode 100644 |
| index 000000000..65614ae2e |
| --- /dev/null |
| +++ b/internal/hostclaim/move_test.go |
| @@ -0,0 +1,275 @@ |
| +package hostclaim |
| + |
| +import ( |
| + "encoding/json" |
| + "errors" |
| + "reflect" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const ( |
| + moveA = "mv_abcdefghijklmnopqrstuvwxyz" |
| + moveB = "mv_bcdefghijklmnopqrstuvwxyza" |
| +) |
| + |
| +// movingTestClaim is this file's fixture (delete_test.go already defines |
| +// servingClaim(t, ctx, s) with another shape). It is a VALID claim: the |
| +// holder's key, incarnation and location follow the grammar Validate |
| +// enforces, and the fixture asserts Validate() so it can never drift from |
| +// the real grammar. |
| +func movingTestClaim(t *testing.T) *Claim { |
| + t.Helper() |
| + created := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) |
| + inc, err := Incarnation("bab", "app", "a.go.example.com", created) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + c := &Claim{V: schemaVersion, Host: "a.go.example.com", Gen: 3, |
| + Holder: &Holder{Family: "instance", Key: "apps/bab/app/instances/a.go.example.com.json", Account: "bab", App: "app", Incarnation: inc, CreatedAt: created, Location: "instances/a.go.example.com/" + inc, Stamp: 3}, |
| + Op: Op{Kind: KindServing, Exec: "ie-box"}, |
| + Touched: Touched{Complete: true, Regions: []string{"ie"}, Boxes: []string{"ie-box"}}} |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("fixture must validate: %v", err) |
| + } |
| + return c |
| +} |
| + |
| +func TestBeginMoveFromServingOnly(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + c := movingTestClaim(t) |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Kind != KindMoving || c.Op.ID != moveA || c.Op.Exec != "ie-box" || c.Op.By != "ops" || !c.Op.StartedAt.Equal(now) || len(c.Op.Participants) != 2 || c.Op.Participants[0] != "ie-box" || c.Op.Participants[1] != "uk-box" || c.Op.Intent["to"] != "uk" { |
| + t.Fatalf("op after BeginMove: %+v", c.Op) |
| + } |
| + if c.Op.Phase != PhaseMoving { |
| + t.Fatalf("phase after BeginMove: %q", c.Op.Phase) |
| + } |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("claim after BeginMove must validate: %v", err) |
| + } |
| + // K12: nothing is touched yet; the source adds the region before its |
| + // first staging round. |
| + if len(c.Touched.Regions) != 1 || len(c.Touched.Boxes) != 1 { |
| + t.Fatalf("touched grew at BeginMove: %+v", c.Touched) |
| + } |
| + // Not from serving. |
| + if err := BeginMove(moveB, "ops", "ie-box", "uk-box", "uk", now)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("second move: %v", err) |
| + } |
| + // exec must be the source. |
| + d := movingTestClaim(t) |
| + if err := BeginMove(moveA, "ops", "us-box", "uk-box", "uk", now)(d); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("source not exec: %v", err) |
| + } |
| + // A pending chore in the target region refuses (CC11). |
| + e := movingTestClaim(t) |
| + e.Obligations = []Obligation{{ID: "o1", Op: "x", Kind: "park-replica", Region: "uk", Retirement: "r", Owner: "region:uk"}} |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(e); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("pending chore: %v", err) |
| + } |
| + // ... and in the target box. |
| + f := movingTestClaim(t) |
| + f.Obligations = []Obligation{{ID: "o1", Op: "x", Kind: "park-local", Box: "uk-box", Retirement: "r", Owner: "box:uk-box"}} |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(f); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("pending box chore: %v", err) |
| + } |
| + // A claim with no holder is not movable. |
| + g := movingTestClaim(t) |
| + g.Holder = nil |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(g); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("no holder: %v", err) |
| + } |
| + // Missing inputs are malformed. |
| + for _, args := range [][4]string{{"", "ie-box", "uk-box", "uk"}, {moveA, "", "uk-box", "uk"}, {moveA, "ie-box", "", "uk"}, {moveA, "ie-box", "uk-box", ""}} { |
| + h := movingTestClaim(t) |
| + if err := BeginMove(args[0], "ops", args[1], args[2], args[3], now)(h); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("args %v: %v", args, err) |
| + } |
| + if h.Op.Kind != KindServing { |
| + t.Fatalf("a refused BeginMove changed the op: %+v", h.Op) |
| + } |
| + } |
| +} |
| + |
| +func TestTouchAppendsOnceUnderTheOp(t *testing.T) { |
| + c := movingTestClaim(t) |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", time.Now())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Touch(moveA, []string{"uk"}, nil)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Touch(moveA, []string{"uk"}, []string{"uk-box"})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.Touched.Regions) != 2 || len(c.Touched.Boxes) != 2 || c.Touched.Boxes[1] != "uk-box" { |
| + t.Fatalf("touched: %+v", c.Touched) |
| + } |
| + if err := Touch("mv_other0000000000000000000000", []string{"se"}, nil)(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("touch under another op: %v", err) |
| + } |
| + if len(c.Touched.Regions) != 2 { |
| + t.Fatalf("a refused Touch changed touched: %+v", c.Touched) |
| + } |
| + if err := Touch("", []string{"se"}, nil)(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("touch under no op: %v", err) |
| + } |
| + if err := RequireMoveOp(moveA, KindMoving, "")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RequireMoveOp(moveA, KindLanding, "")(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("wrong kind: %v", err) |
| + } |
| + if err := RequireMoveOp(moveA, KindMoving, PhaseSealed)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("wrong phase: %v", err) |
| + } |
| + if err := RequireMoveOp(moveB, KindMoving, "")(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("wrong op: %v", err) |
| + } |
| + c.Op.Phase = PhaseSealed |
| + if err := RequireMoveOp(moveA, KindMoving, PhaseSealed)(c); err != nil { |
| + t.Fatalf("sealed: %v", err) |
| + } |
| +} |
| + |
| +func TestReleaseMoveFromMovingNeedsNoChores(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + for _, phase := range []string{PhaseMoving, PhaseAborting} { |
| + c := movingTestClaim(t) |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.Op.Phase = phase |
| + // A chore under this op blocks the release. |
| + c.Obligations = []Obligation{{ID: "o1", Op: moveA, Kind: "park-replica", Region: "uk", Retirement: "r", Owner: "region:uk"}} |
| + if err := ReleaseMove(moveA, "ie-box")(c); !errors.Is(err, ErrPendingChore) { |
| + t.Fatalf("phase %q with a chore: %v", phase, err) |
| + } |
| + if c.Op.Kind != KindMoving { |
| + t.Fatalf("a refused release changed the op: %+v", c.Op) |
| + } |
| + // A chore under another op does not. |
| + c.Obligations[0].Op = "someone-else" |
| + if err := ReleaseMove(moveB, "ie-box")(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("release under another op: %v", err) |
| + } |
| + if err := ReleaseMove(moveA, "ie-box")(c); err != nil { |
| + t.Fatalf("phase %q: %v", phase, err) |
| + } |
| + if c.Op.Kind != KindServing || c.Op.ID != "" || c.Op.Exec != "ie-box" || c.Op.Journal["released_move"] != moveA || c.Op.Phase != "" || len(c.Op.Participants) != 0 { |
| + t.Fatalf("op after release: %+v", c.Op) |
| + } |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("claim after release must validate: %v", err) |
| + } |
| + } |
| + // Not from serving, and not from a sealed move. |
| + s := movingTestClaim(t) |
| + if err := ReleaseMove(moveA, "ie-box")(s); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("release from serving: %v", err) |
| + } |
| + m := movingTestClaim(t) |
| + _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(m) |
| + m.Op.Phase = PhaseSealed |
| + if err := ReleaseMove(moveA, "ie-box")(m); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("release from sealed: %v", err) |
| + } |
| + // Another kind under the same id is not a move. |
| + l := movingTestClaim(t) |
| + _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(l) |
| + l.Op.Kind = KindLanding |
| + if err := ReleaseMove(moveA, "ie-box")(l); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("release from landing: %v", err) |
| + } |
| + // The executor is named. |
| + e := movingTestClaim(t) |
| + _ = BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(e) |
| + if err := ReleaseMove(moveA, "")(e); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("release without exec: %v", err) |
| + } |
| +} |
| + |
| +func TestReleaseMoveLeavesEvidence(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + plain := movingTestClaim(t) |
| + if err := RequireReleasedMove(moveA)(plain); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("a plain serving claim satisfies the replay rule: %v", err) |
| + } |
| + c := movingTestClaim(t) |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RequireReleasedMove(moveA)(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("a moving claim satisfies the replay rule: %v", err) |
| + } |
| + if err := ReleaseMove(moveA, "ie-box")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := RequireReleasedMove(moveA)(c); err != nil { |
| + t.Fatalf("evidence after release: %v", err) |
| + } |
| + if err := RequireReleasedMove(moveB)(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("another move's id: %v", err) |
| + } |
| + if err := RequireReleasedMove("")(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("empty id: %v", err) |
| + } |
| + // A non-serving kind carrying the journal is not evidence. |
| + c.Op.Kind, c.Op.ID = KindMoving, moveB |
| + if err := RequireReleasedMove(moveA)(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("moving claim with a stale journal: %v", err) |
| + } |
| +} |
| + |
| +// BeginMove and ReleaseMove own only the op: the holder (session, writer, |
| +// admissions, generation and their writes_lost), touched, inventory, |
| +// obligations of other ops and the rest are carried through unchanged. |
| +func TestMoveRoundTripPreservesTheRestOfTheClaim(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + c := movingTestClaim(t) |
| + c.Holder.Generation = "gen-7" |
| + c.Holder.Session = 9 |
| + c.Holder.WriterBox = "ie-box" |
| + c.Holder.Admissions = []AdmissionRecord{{Region: "ie", Session: 9, Box: "ie-box", Generation: "gen-7", Mode: "admitted", Coverage: "covered", Covered: true, |
| + WritesLost: &WritesLost{Box: "ie-box", Generation: "gen-6", Session: 8, RemoteMaxTXID: 41}, |
| + AdditionalWritesLost: []WritesLost{{Box: "ie-box", Generation: "gen-5", Session: 7, RemoteMaxTXID: 12}}}} |
| + c.Obligations = []Obligation{{ID: "o1", Op: "old", Kind: "park-replica", Region: "se", Retirement: "r", Owner: "region:se"}} |
| + c.Lost = []string{"se"} |
| + c.RetiredIncarnations = []string{"abc"} |
| + c.QuietAfter = map[string]time.Time{"se": now} |
| + c.Inventory = map[string]InventoryEntry{"ie-box": {Layout: "nested", Paths: map[string]uint64{"/data/x": 3}}} |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("fixture: %v", err) |
| + } |
| + before := mustJSON(t, c) |
| + if err := BeginMove(moveA, "ops", "ie-box", "uk-box", "uk", now)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ReleaseMove(moveA, "ie-box")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Only the op differs. |
| + var round Claim |
| + if err := json.Unmarshal(before, &round); err != nil { |
| + t.Fatal(err) |
| + } |
| + round.Op = c.Op |
| + if !reflect.DeepEqual(round, *c) { |
| + t.Fatalf("round trip changed more than the op:\nbefore %s\nafter %s", before, mustJSON(t, c)) |
| + } |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("after round trip: %v", err) |
| + } |
| +} |
| + |
| +func mustJSON(t *testing.T, c *Claim) []byte { |
| + t.Helper() |
| + b, err := json.Marshal(c) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |