| index 819644036..fd38e5849 100644 |
| --- a/internal/registry/buildstate_test.go |
| +++ b/internal/registry/buildstate_test.go |
| @@ -2,6 +2,7 @@ package registry |
| |
| import ( |
| "context" |
| + "errors" |
| "path/filepath" |
| "testing" |
| ) |
| @@ -126,6 +127,134 @@ func TestBuildStateSurvivesColdHandover(t *testing.T) { |
| } |
| } |
| |
| +// buildStateColLater is buildStateCol after one more reconcile refresh: only |
| +// read_at moved, as it does every reconcile even for a stopped host. |
| +const buildStateColLater = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:30Z"}` |
| + |
| +// A cold handover replayed after its lost reply must not be refused because |
| +// the agent refreshed the row's build-state copy in between: the column is |
| +// the agent's cache of the bucket object (snapshotChurnCols), not part of |
| +// the installation the replay checks. |
| +func TestColdHandoverReplayIgnoresABuildStateRefresh(t *testing.T) { |
| + t.Setenv("CARLOS_HOST_CLAIM", "1") |
| + ctx := context.Background() |
| + reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} |
| + newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"} |
| + route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner} |
| + if err = reg.Upsert(route); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.SetBuildState(route.Host, buildStateCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + current, err := reg.Get(route.Host) |
| + if err != nil || current == nil { |
| + t.Fatalf("Get: %v", err) |
| + } |
| + old := ColdRows{Route: *current} |
| + next := ColdRows{Route: *current} |
| + next.Route.ClaimOwnership = newOwner |
| + targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}} |
| + if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { |
| + t.Fatalf("handover refused: %v", err) |
| + } |
| + if err = reg.SetBuildState(route.Host, buildStateColLater); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { |
| + t.Fatalf("replay after a build-state refresh refused: %v", err) |
| + } |
| + if got, _ := reg.Get(route.Host); got == nil || got.BuildState != buildStateColLater { |
| + t.Fatalf("the replay rewrote the refreshed copy: %+v", got) |
| + } |
| + // Any other column still decides the replay. |
| + if _, err = reg.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, route.Host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) { |
| + t.Fatalf("replay over a changed row: %v", err) |
| + } |
| +} |
| + |
| +// A recovery handover's rebind (main's RebindClaimRows) reads the row, then |
| +// rebinds it in a transaction, and replays from a fresh read after a lost |
| +// reply or a crash. A build-state refresh between any two of those steps is |
| +// not a concurrent replacement of the installation: it must neither refuse |
| +// the rebind nor its replay, and the rebind must not write the column. |
| +func TestRecoveryRebindIgnoresABuildStateRefresh(t *testing.T) { |
| + for _, mode := range []string{"between-read-and-rebind", "replay-from-a-fresh-read", "replay-with-the-first-read"} { |
| + t.Run(mode, func(t *testing.T) { |
| + r, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer r.Close() |
| + const host = "app.example" |
| + oldOwner := ClaimOwnership{"inc", 1, "old", "old-row"} |
| + newOwner := ClaimOwnership{"inc", 2, "new", "new-row"} |
| + if err = r.Upsert(Route{Host: host, Kind: "instance", ClaimOwnership: oldOwner}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = r.SetBuildState(host, buildStateCol); err != nil { |
| + t.Fatal(err) |
| + } |
| + saved, err := r.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + old := ColdRows{Route: *saved} |
| + next := old |
| + next.Route.ClaimOwnership = newOwner |
| + if mode == "between-read-and-rebind" { |
| + if err = r.SetBuildState(host, buildStateColLater); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err = r.RebindClaimRows(t.Context(), old, next); err != nil { |
| + t.Fatalf("rebind: %v", err) |
| + } |
| + if mode != "between-read-and-rebind" { |
| + if err = r.SetBuildState(host, buildStateColLater); err != nil { |
| + t.Fatal(err) |
| + } |
| + replayOld, replayNext := old, next |
| + if mode == "replay-from-a-fresh-read" { |
| + // As claimCrossBoxHandover.rebindRecoveryRows replays: a |
| + // fresh Get, the old ownership from the inventory. |
| + fresh, err := r.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + replayOld, replayNext = ColdRows{Route: *fresh}, ColdRows{Route: *fresh} |
| + replayOld.Route.ClaimOwnership = oldOwner |
| + } |
| + if err = r.RebindClaimRows(t.Context(), replayOld, replayNext); err != nil { |
| + t.Fatalf("replay after a build-state refresh refused: %v", err) |
| + } |
| + } |
| + got, err := r.Get(host) |
| + if err != nil || got.ClaimOwnership != newOwner || got.BuildState != buildStateColLater { |
| + t.Fatalf("route = %+v, %v", got, err) |
| + } |
| + // Any other column still decides the replay. |
| + if _, err = r.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, host); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err = r.RebindClaimRows(t.Context(), old, next); !errors.Is(err, ErrClaimOwnership) { |
| + t.Fatalf("replay over a changed row: %v", err) |
| + } |
| + }) |
| + } |
| +} |
| + |
| // The host's registry child carries the writer across the transport. |
| func TestRemoteSetBuildState(t *testing.T) { |
| r, _ := remoteTestRegistry(t) |