| index da724862c..e42c65c47 100644 |
| --- a/internal/hostclaim/move_test.go |
| +++ b/internal/hostclaim/move_test.go |
| @@ -6,6 +6,7 @@ import ( |
| "errors" |
| "fmt" |
| "reflect" |
| + "strings" |
| "testing" |
| "time" |
| |
| @@ -290,6 +291,7 @@ const stoppedSource = "moves/bab/app/h/mv/stopped-source.json" |
| func movingToSealed(t *testing.T) *Claim { |
| t.Helper() |
| c := movingTestClaim(t) |
| + c.Holder.Generation = "gen-7" // the source's generation, which the flip must name |
| 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), |
| @@ -336,6 +338,17 @@ func TestCutClaimCASesFollowK3AndK4(t *testing.T) { |
| if err := ExecToNone(op, "ie-box", "moves/other.json", JournalEvidenceSource)(c); !errors.Is(err, ErrWrongState) { |
| t.Fatalf("replay with other evidence: %v", err) |
| } |
| + // With exec already none, an empty box is nobody: it must neither |
| + // replay nor replace the evidence. |
| + given := mustJSON(t, c) |
| + for _, ev := range []string{stoppedSource, "moves/other.json"} { |
| + if err := ExecToNone(op, "", ev, JournalEvidenceSource)(c); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("empty box with %q: %v", ev, err) |
| + } |
| + if got := mustJSON(t, c); string(got) != string(given) { |
| + t.Fatalf("empty box with %q mutated the claim: %s", ev, got) |
| + } |
| + } |
| // 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) |
| @@ -379,6 +392,22 @@ func TestCutClaimCASesFollowK3AndK4(t *testing.T) { |
| func TestFlipIsIdempotentForEitherWriter(t *testing.T) { |
| c := movingToSealed(t) |
| c.Gen = 9 |
| + // The first flip must name the source's own stamp and generation: a |
| + // wrong identity would be journalled as the rollback's return. |
| + sealed := mustJSON(t, c) |
| + for name, fn := range map[string]func(*Claim) error{ |
| + "first flip, wrong stamp": Flip(moveA, "uk-box", 4, "gen-7"), |
| + "first flip, zero stamp": Flip(moveA, "uk-box", 0, "gen-7"), |
| + "first flip, wrong generation": Flip(moveA, "uk-box", 3, "gen-8"), |
| + "first flip, empty generation": Flip(moveA, "uk-box", 3, ""), |
| + } { |
| + if err := fn(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("%s: %v", name, err) |
| + } |
| + if got := mustJSON(t, c); string(got) != string(sealed) { |
| + t.Fatalf("%s mutated the claim:\nbefore %s\nafter %s", name, sealed, got) |
| + } |
| + } |
| if err := Flip(moveA, "uk-box", 3, "gen-7")(c); err != nil { |
| t.Fatal(err) |
| } |
| @@ -390,15 +419,23 @@ func TestFlipIsIdempotentForEitherWriter(t *testing.T) { |
| if c.Holder.Stamp != 10 || !reflect.DeepEqual(c.Op, op) { |
| t.Fatalf("replay moved the claim: %+v stamp %d", c.Op, c.Holder.Stamp) |
| } |
| + // The replay is checked against the journal, not the holder: the holder |
| + // stamp is now F (10), not S (3). |
| + flipped := mustJSON(t, c) |
| 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"), |
| + "another stamp": Flip(moveA, "uk-box", 4, "gen-7"), |
| + "the new holder stamp": Flip(moveA, "uk-box", 10, "gen-7"), |
| + "another generation": Flip(moveA, "uk-box", 3, "gen-8"), |
| + "an empty generation": Flip(moveA, "uk-box", 3, ""), |
| + "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) |
| } |
| + if got := mustJSON(t, c); string(got) != string(flipped) { |
| + t.Fatalf("%s mutated the claim", name) |
| + } |
| } |
| // Once the landing has moved past checking, the replay is stale. |
| c.Op.Phase = "committing" |
| @@ -465,6 +502,28 @@ func TestRevertNeedsEvidenceAndReturnsExecToSource(t *testing.T) { |
| 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) |
| } |
| + // The scope is this move's own lane and this holder's incarnation: not |
| + // the host-wide staging prefix, not another op's lane, not another |
| + // installation's incarnation. |
| + hostWide := scope |
| + hostWide.Location = "moves/" + c.Host |
| + otherOp := scope |
| + otherOp.Location = "moves/" + c.Host + "/" + moveB |
| + otherInc := scope |
| + otherInc.Incarnation = strings.Repeat("ab", 32) |
| + preRevert := mustJSON(t, c) |
| + for name, sc := range map[string]ParkScope{"host-wide": hostWide, "another op's lane": otherOp, "another incarnation": otherInc} { |
| + sc := sc |
| + if err := Revert(op, "ie-box", "uk", &sc, newID)(c); !errors.Is(err, ErrMalformed) { |
| + t.Fatalf("revert with a %s scope: %v", name, err) |
| + } |
| + if got := mustJSON(t, c); string(got) != string(preRevert) { |
| + t.Fatalf("a refused revert (%s scope) mutated the claim", name) |
| + } |
| + } |
| + if ids != 0 { |
| + t.Fatalf("a refused revert allocated %d ids", ids) |
| + } |
| if c.Op.Kind != KindMoving || len(c.Obligations) != 0 { |
| t.Fatalf("a refused revert left changes: %+v", c.Op) |
| } |
| @@ -575,3 +634,49 @@ func TestCutTransitionsPreserveTheRestOfTheClaim(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// A revert replayed after a crash finds the claim already reverting with |
| +// the same op, region, scope and rev_phase: nil, no new ids, one park. |
| +func TestRevertReplayIsIdempotent(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().UTC(), RequestLimit: time.Minute} |
| + ids := 0 |
| + newID := func() string { ids++; return fmt.Sprintf("id%d", ids) } |
| + if err := Revert(moveA, "ie-box", "uk", &scope, newID)(c); err != nil { |
| + t.Fatal(err) |
| + } |
| + after, usedIDs := mustJSON(t, c), ids |
| + if err := Revert(moveA, "ie-box", "uk", &scope, newID)(c); err != nil { |
| + t.Fatalf("replay: %v", err) |
| + } |
| + if got := mustJSON(t, c); string(got) != string(after) || ids != usedIDs || len(c.Obligations) != 1 { |
| + t.Fatalf("replay changed the claim or allocated ids (%d, was %d): %s", ids, usedIDs, mustJSON(t, c)) |
| + } |
| + // A replay that is not the same revert is refused, and changes nothing. |
| + later := scope |
| + later.EvidenceAt = scope.EvidenceAt.Add(time.Second) |
| + for name, fn := range map[string]func(*Claim) error{ |
| + "another scope": Revert(moveA, "ie-box", "uk", &later, newID), |
| + "another region": Revert(moveA, "ie-box", "se", &scope, newID), |
| + "another source": Revert(moveA, "uk-box", "uk", &scope, newID), |
| + "another op": Revert(moveB, "ie-box", "uk", &scope, newID), |
| + } { |
| + if err := fn(c); err == nil { |
| + t.Fatalf("%s: accepted as a replay", name) |
| + } |
| + if got := mustJSON(t, c); string(got) != string(after) || ids != usedIDs { |
| + t.Fatalf("%s changed the claim or allocated ids", name) |
| + } |
| + } |
| + // Once the revert has moved past evidence, the replay is stale. |
| + c.Op.Journal[JournalRevPhase] = "opened" |
| + if err := Revert(moveA, "ie-box", "uk", &scope, newID)(c); !errors.Is(err, ErrWrongState) { |
| + t.Fatalf("replay after the revert moved on: %v", err) |
| + } |
| +} |