| index c796fd79f..da724862c 100644 |
| --- a/internal/hostclaim/move_test.go |
| +++ b/internal/hostclaim/move_test.go |
| @@ -1,11 +1,15 @@ |
| package hostclaim |
| |
| import ( |
| + "context" |
| "encoding/json" |
| "errors" |
| + "fmt" |
| "reflect" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| |
| const ( |
| @@ -278,3 +282,296 @@ func mustJSON(t *testing.T, c *Claim) []byte { |
| } |
| return b |
| } |
| + |
| +const stoppedSource = "moves/bab/app/h/mv/stopped-source.json" |
| + |
| +// movingToSealed walks a fresh moving claim to the state the flip needs: |
| +// source evidence journalled, exec none, phase sealed. |
| +func movingToSealed(t *testing.T) *Claim { |
| + t.Helper() |
| + c := movingTestClaim(t) |
| + for i, tr := range []func(*Claim) error{ |
| + BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now()), |
| + ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource), |
| + SetJournal(moveA, map[string]string{JournalSeal: `{"n":7}`, JournalCutSession: "5"}), |
| + SetMovePhase(moveA, KindMoving, PhaseMoving, PhaseSealed), |
| + } { |
| + if err := tr(c); err != nil { |
| + t.Fatalf("step %d: %v", i, err) |
| + } |
| + } |
| + return c |
| +} |
| + |
| +func TestCutClaimCASesFollowK3AndK4(t *testing.T) { |
| + c := movingTestClaim(t) |
| + op := moveA |
| + if err := BeginMove(op, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Only the executor gives execution up, and only citing evidence. |
| + if err := ExecToNone(op, "uk-box", stoppedSource, JournalEvidenceSource)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("non-exec to none: %v", err) |
| + } |
| + if err := ExecToNone(op, "ie-box", "", JournalEvidenceSource)(c); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("no evidence: %v", err) |
| + } |
| + if err := ExecToNone(op, "ie-box", stoppedSource, JournalSeal)(c); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("evidence under a non-evidence key: %v", err) |
| + } |
| + if err := ExecToNone(moveB, "ie-box", stoppedSource, JournalEvidenceSource)(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("another op: %v", err) |
| + } |
| + // The flip needs the seal phase and exec none. |
| + if err := Flip(op, "uk-box", 3, "")(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("flip before sealed: %v", err) |
| + } |
| + if err := ExecToNone(op, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil || c.Op.Exec != "" { |
| + t.Fatalf("to none: %v %+v", err, c.Op) |
| + } |
| + // A crash replay of the same give-up is idempotent; other evidence is not. |
| + if err := ExecToNone(op, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + if err := ExecToNone(op, "ie-box", "moves/other.json", JournalEvidenceSource)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("replay with other evidence: %v", err) |
| + } |
| + // Exec none and evidence, but not sealed: still no flip. |
| + if err := Flip(op, "uk-box", 3, "")(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("flip before the seal phase: %v", err) |
| + } |
| + if err := SetJournal(op, map[string]string{JournalSeal: `{"n":7}`, JournalCutSession: "5"})(c); err != nil || c.Op.Journal[JournalSeal] != `{"n":7}` { |
| + t.Fatalf("journal: %v", err) |
| + } |
| + if err := SetJournal(moveB, map[string]string{JournalSeal: "x"})(c); !errors.Is(err, ErrStaleOp) { |
| + t.Fatalf("journal under another op: %v", err) |
| + } |
| + if err := SetMovePhase(op, KindLanding, PhaseMoving, PhaseSealed)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("phase under the wrong kind: %v", err) |
| + } |
| + if err := SetMovePhase(op, KindMoving, PhaseMoving, PhaseSealed)(c); err != nil || c.Op.Phase != PhaseSealed { |
| + t.Fatalf("phase: %v", err) |
| + } |
| + if err := SetMovePhase(op, KindMoving, PhaseMoving, PhaseSealed)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatal("phase replay") |
| + } |
| + // The flip's target must be the move's target. |
| + if err := Flip(op, "us-box", 3, "")(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("flip to a stranger: %v", err) |
| + } |
| + before := c.Holder.Stamp |
| + c.Gen = 9 // what Update will have set |
| + if err := Flip(op, "uk-box", before, "")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Kind != KindLanding || c.Op.Phase != "checking" || c.Op.Exec != "uk-box" || c.Op.Journal[JournalSourceStamp] != "3" || c.Op.Journal[JournalSrcGen] != "" || c.Holder.Stamp != 10 { |
| + t.Fatalf("after flip: %+v stamp %d", c.Op, c.Holder.Stamp) |
| + } |
| + // holder.generation is untouched by the flip (section 7.5). |
| + if c.Holder.Generation != "" { |
| + t.Fatal("flip moved the generation") |
| + } |
| +} |
| + |
| +// Flip is written by either participant (G12): the target may write it |
| +// after a source crash. A second write of the same flip is a no-op that |
| +// does not restamp the holder; a different flip is refused. |
| +func TestFlipIsIdempotentForEitherWriter(t *testing.T) { |
| + c := movingToSealed(t) |
| + c.Gen = 9 |
| + if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + c.Gen = 10 // the replay observes the claim the flip produced |
| + op := c.Op |
| + if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + if c.Holder.Stamp != 10 || !reflect.DeepEqual(c.Op, op) { |
| + t.Fatalf("replay moved the claim: %+v stamp %d", c.Op, c.Holder.Stamp) |
| + } |
| + for name, fn := range map[string]func(*Claim) error{ |
| + "another stamp": Flip(moveA, "uk-box", 4, "gen-7"), |
| + "another generation": Flip(moveA, "uk-box", 3, "gen-8"), |
| + "another target": Flip(moveA, "us-box", 3, "gen-7"), |
| + "another op": Flip(moveB, "uk-box", 3, "gen-7"), |
| + } { |
| + if err := fn(c); err == nil { |
| + t.Fatalf("%s: a different flip was accepted", name) |
| + } |
| + } |
| + // Once the landing has moved past checking, the replay is stale. |
| + c.Op.Phase = "committing" |
| + if err := Flip(moveA, "uk-box", 3, "gen-7")(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("replay after the landing moved on: %v", err) |
| + } |
| +} |
| + |
| +// The flip's holder.stamp is the gen the CAS produces: run it through the |
| +// real Update and compare. |
| +func TestFlipStampIsTheGenUpdateProduces(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + c := movingToSealed(t) |
| + key, err := Key(c.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := s.Put(ctx, key, mustJSON(t, c), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err := Read(ctx, s, c.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + o, err = Update(ctx, s, o, Flip(moveA, "uk-box", c.Holder.Stamp, "gen-7")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if o.Claim.Holder.Stamp != o.Claim.Gen || o.Claim.Gen != c.Gen+1 { |
| + t.Fatalf("stamp %d gen %d, want both %d", o.Claim.Holder.Stamp, o.Claim.Gen, c.Gen+1) |
| + } |
| + if o.Claim.Op.Journal[JournalSourceStamp] != "3" || o.Claim.Op.Journal[JournalSrcGen] != "gen-7" { |
| + t.Fatalf("journal: %v", o.Claim.Op.Journal) |
| + } |
| +} |
| + |
| +func TestRevertNeedsEvidenceAndReturnsExecToSource(t *testing.T) { |
| + c := movingTestClaim(t) |
| + op := moveA |
| + if err := BeginMove(op, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + scope := ParkScope{Location: "moves/" + c.Host + "/" + op, Incarnation: c.Holder.Incarnation, EvidenceAt: time.Now(), RequestLimit: 120 * time.Second} |
| + ids := 0 |
| + newID := func() string { ids++; return fmt.Sprintf("id%d", ids) } |
| + if err := Revert(op, "ie-box", "uk", &scope, newID)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("revert while exec held: %v", err) |
| + } |
| + if err := ExecToNone(op, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Revert(op, "uk-box", "uk", &scope, newID)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("revert to the target: %v", err) |
| + } |
| + if err := Revert(op, "ie-box", "se", &scope, newID)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("revert naming a region the move never targeted: %v", err) |
| + } |
| + if err := Revert(op, "ie-box", "uk", nil, newID)(c); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("revert with no staging scope: %v", err) |
| + } |
| + live := scope |
| + live.Location = c.Holder.Location |
| + if err := Revert(op, "ie-box", "uk", &live, newID)(c); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("revert whose park would reach the live location: %v", err) |
| + } |
| + if c.Op.Kind != KindMoving || len(c.Obligations) != 0 { |
| + t.Fatalf("a refused revert left changes: %+v", c.Op) |
| + } |
| + if err := Revert(op, "ie-box", "uk", &scope, newID)(c); err != nil || c.Op.Kind != KindReverting || c.Op.Exec != "ie-box" || c.Op.Journal[JournalRevPhase] != "evidence" { |
| + t.Fatalf("revert: %v %+v", err, c.Op) |
| + } |
| + if len(c.Obligations) != 1 || c.Obligations[0].Kind != "park-replica" || c.Obligations[0].Region != "uk" || c.Obligations[0].Park == nil || |
| + c.Obligations[0].Owner != "region:uk" || c.Obligations[0].Op != op || c.Obligations[0].Park.Location != scope.Location { |
| + t.Fatalf("revert queued no target staging park: %+v", c.Obligations) |
| + } |
| + c.Gen = 9 |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("claim after revert must validate: %v", err) |
| + } |
| +} |
| + |
| +// A revert keeps rev_phase if a replay already set it, and queues the |
| +// staging park once even when one is already owed under the op. |
| +func TestRevertKeepsRevPhaseAndQueuesTheParkOnce(t *testing.T) { |
| + c := movingTestClaim(t) |
| + if err := BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", time.Now())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + scope := ParkScope{Location: "moves/" + c.Host + "/" + moveA, Incarnation: c.Holder.Incarnation, EvidenceAt: time.Now(), RequestLimit: time.Minute} |
| + c.Obligations = []Obligation{{ID: "o1", Op: moveA, Kind: "park-replica", Region: "uk", Retirement: "r1", Owner: "region:uk", Park: &scope}} |
| + if err := SetJournal(moveA, map[string]string{JournalRevPhase: "staging"})(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Revert(moveA, "ie-box", "uk", &scope, counter())(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Op.Journal[JournalRevPhase] != "staging" || len(c.Obligations) != 1 { |
| + t.Fatalf("rev_phase %q, obligations %+v", c.Op.Journal[JournalRevPhase], c.Obligations) |
| + } |
| +} |
| + |
| +// The cut's transitions own only the op, the journal, the revert's staging |
| +// park and (the flip) the holder's stamp; every other field is carried |
| +// through unchanged. |
| +func TestCutTransitionsPreserveTheRestOfTheClaim(t *testing.T) { |
| + now := time.Date(2026, 10, 6, 9, 0, 0, 0, time.UTC) |
| + fixture := func() *Claim { |
| + 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) |
| + } |
| + return c |
| + } |
| + scope := ParkScope{Location: "moves/a.go.example.com/" + moveA, Incarnation: fixture().Holder.Incarnation, EvidenceAt: now, RequestLimit: time.Minute} |
| + begin := func() []func(*Claim) error { |
| + return []func(*Claim) error{ |
| + BeginMove(moveA, "ops", "ie-box", "enr-ie", "uk-box", "uk", now), |
| + ExecToNone(moveA, "ie-box", stoppedSource, JournalEvidenceSource), |
| + } |
| + } |
| + cases := map[string]struct { |
| + steps []func(*Claim) error |
| + restamp bool // the flip restamps the holder |
| + parks int // staging parks the transition queues |
| + }{ |
| + "flip": {restamp: true, steps: append(begin(), |
| + SetJournal(moveA, map[string]string{JournalSeal: `{"n":7}`}), |
| + SetMovePhase(moveA, KindMoving, PhaseMoving, PhaseSealed), |
| + Flip(moveA, "uk-box", 3, "gen-7"))}, |
| + "revert": {parks: 1, steps: append(begin(), Revert(moveA, "ie-box", "uk", &scope, counter()))}, |
| + } |
| + for name, tc := range cases { |
| + t.Run(name, func(t *testing.T) { |
| + c := fixture() |
| + before := mustJSON(t, c) |
| + for i, tr := range tc.steps { |
| + if err := tr(c); err != nil { |
| + t.Fatalf("step %d: %v", i, err) |
| + } |
| + } |
| + if len(c.Obligations) != 1+tc.parks || !reflect.DeepEqual(c.Obligations[0], fixture().Obligations[0]) { |
| + t.Fatalf("obligations of other ops changed: %+v", c.Obligations) |
| + } |
| + var round Claim |
| + if err := json.Unmarshal(before, &round); err != nil { |
| + t.Fatal(err) |
| + } |
| + round.Op, round.Obligations = c.Op, c.Obligations |
| + if tc.restamp { |
| + round.Holder.Stamp = c.Holder.Stamp |
| + } |
| + if !reflect.DeepEqual(round, *c) { |
| + t.Fatalf("%s changed more than it owns:\nbefore %s\nafter %s", name, before, mustJSON(t, c)) |
| + } |
| + c.Gen++ // what Update would do |
| + if err := c.Validate(); err != nil { |
| + t.Fatalf("after %s: %v", name, err) |
| + } |
| + }) |
| + } |
| +} |