| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "strings" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/activator" |
| 10 | "github.com/carlosframework/platform/internal/hostclaim" |
| 11 | "github.com/carlosframework/platform/internal/hostwrite" |
| 12 | "github.com/carlosframework/platform/internal/lease" |
| 13 | "github.com/carlosframework/platform/internal/moves" |
| 14 | "github.com/carlosframework/platform/internal/park" |
| 15 | "github.com/carlosframework/platform/internal/provision" |
| 16 | "github.com/carlosframework/platform/internal/release" |
| 17 | ) |
| 18 | |
| 19 | // Settings spec rev 11 §4.3a: a move carries the build state with the |
| 20 | // replica, and a held instance never reads as new before its first wake on |
| 21 | // the destination. |
| 22 | |
| 23 | const stagedStateHost = "a.example.com" |
| 24 | |
| 25 | func stagedStateBody(host, le string) string { |
| 26 | return `{"schema":1,"host":"` + host + `","last_executed":"` + le + `","at":"2026-10-07T12:00:00Z"}` |
| 27 | } |
| 28 | |
| 29 | func putObject(t *testing.T, s release.Store, key, body string) { |
| 30 | t.Helper() |
| 31 | if _, err := s.Put(context.Background(), key, []byte(body), ""); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // legacyStaged is newBuildStates' staged prefix: the legacy round's |
| 37 | // moves/<host>/, read only with host claims and Phase 1 off. |
| 38 | func legacyStaged(h string) string { return activator.MovesPrefix(h) } |
| 39 | |
| 40 | // Before the first wake promotes a legacy landing's staging, the own prefix |
| 41 | // is empty. The staged build-state.json is the state and a staged object is |
| 42 | // a replica, so a held instance is held on its last_executed, not given the |
| 43 | // head as a new instance. |
| 44 | func TestAStagedMoveIsReadAsTheInstancesState(t *testing.T) { |
| 45 | t.Parallel() |
| 46 | b, s, _ := testBuildStates(t) |
| 47 | b.staged = legacyStaged |
| 48 | ctx := context.Background() |
| 49 | putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x") |
| 50 | putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| 51 | st, replica, err := b.read(ctx, stagedStateHost) |
| 52 | if err != nil || !replica || st == nil || st.LastExecuted != "v1" { |
| 53 | t.Fatalf("read = %+v, %v, %v; want the staged state and a replica", st, replica, err) |
| 54 | } |
| 55 | // And Target therefore holds a manual instance on v1, not the head. |
| 56 | d := release.Target(release.TargetInput{Pointer: &release.ChannelPointer{Version: "v3"}, |
| 57 | Settings: release.UpdateSettings{Updates: release.UpdatesManual}, |
| 58 | Build: release.BuildStateCopy{Known: true, Replica: replica, State: st}}) |
| 59 | if d.Version != "v1" { |
| 60 | t.Fatalf("Target = %+v", d) |
| 61 | } |
| 62 | // refresh records the same in the registry copy root reads. |
| 63 | if err := b.refresh(ctx, stagedStateHost); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if c := copyOf(t, b.reg); !c.Known || !c.Replica || c.State == nil || c.State.LastExecuted != "v1" { |
| 67 | t.Fatalf("copy = %+v", c) |
| 68 | } |
| 69 | // A staged replica with no build state is still a replica: never new. |
| 70 | b2, s2, _ := testBuildStates(t) |
| 71 | b2.staged = legacyStaged |
| 72 | putObject(t, s2, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x") |
| 73 | if st, replica, err := b2.read(ctx, stagedStateHost); err != nil || !replica || st != nil { |
| 74 | t.Fatalf("staged replica only: read = %+v, %v, %v", st, replica, err) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | // The staged copy is read the way the wake's restore will use it |
| 79 | // (promoteStagedReplica: staging wins when present, and its mirror deletes |
| 80 | // what staging lacks). A host that comes back to a box it left keeps its |
| 81 | // old lineage and build state at the own prefix; the restore replaces them |
| 82 | // with the staging, so the staged build state is the one that counts. |
| 83 | // With nothing staged the own prefix is read as before. |
| 84 | func TestAStagedMoveOutranksAnOlderOwnStateAsTheRestoreDoes(t *testing.T) { |
| 85 | b, s, _ := testBuildStates(t) |
| 86 | b.staged = legacyStaged |
| 87 | ctx := context.Background() |
| 88 | own := "inst/" + stagedStateHost |
| 89 | putObject(t, s, own+"/generations/0000/old.ltx", "old") |
| 90 | putObject(t, s, own+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| 91 | if st, replica, err := b.read(ctx, stagedStateHost); err != nil || !replica || st == nil || st.LastExecuted != "v1" { |
| 92 | t.Fatalf("own prefix alone: read = %+v, %v, %v", st, replica, err) |
| 93 | } |
| 94 | putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/new.ltx", "new") |
| 95 | putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v2")) |
| 96 | if st, replica, err := b.read(ctx, stagedStateHost); err != nil || !replica || st == nil || st.LastExecuted != "v2" { |
| 97 | t.Fatalf("staged over own: read = %+v, %v, %v; want the staged v2", st, replica, err) |
| 98 | } |
| 99 | // update still writes only the own prefix: the wake promotes first. |
| 100 | if _, err := b.update(ctx, stagedStateHost, func(cur *release.BuildState) release.BuildState { |
| 101 | return release.BuildState{LastExecuted: "v2"} |
| 102 | }); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | if raw, _, err := s.Get(ctx, "moves/"+stagedStateHost+"/build-state.json"); err != nil || !strings.Contains(string(raw), `"v2"`) { |
| 106 | t.Fatalf("update touched the staging: %s %v", raw, err) |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | // Only the legacy round's own keys are its staging. moves/<host>/ also |
| 111 | // holds Phase 1 op lanes (moves/<host>/<mv_ or rb_ id>/...), which no |
| 112 | // legacy restore promotes and which say nothing about this box's state. |
| 113 | func TestTheStagedReadExcludesPhase1OpLanes(t *testing.T) { |
| 114 | b, s, _ := testBuildStates(t) |
| 115 | b.staged = legacyStaged |
| 116 | ctx := context.Background() |
| 117 | for _, id := range []string{p1ID, p1RB} { |
| 118 | putObject(t, s, moves.ReplicaPrefix(stagedStateHost, id)+"0000/x.ltx", "x") |
| 119 | putObject(t, s, moves.BuildStateKey(stagedStateHost, id), stagedStateBody(stagedStateHost, "v9")) |
| 120 | } |
| 121 | if st, replica, err := b.read(ctx, stagedStateHost); err != nil || replica || st != nil { |
| 122 | t.Fatalf("read = %+v, %v, %v; want no state and no replica (op lanes only)", st, replica, err) |
| 123 | } |
| 124 | // A legacy key beside the op lanes is staging again. |
| 125 | putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| 126 | if st, _, err := b.read(ctx, stagedStateHost); err != nil || st == nil || st.LastExecuted != "v1" { |
| 127 | t.Fatalf("read = %+v, %v; want the legacy staged v1", st, err) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | // The staged read is the legacy round's: with Phase 1 on the restore never |
| 132 | // promotes moves/<host>/ (ExecBackend.Phase1), and with host claims on the |
| 133 | // state is at the claim holder's location, so newBuildStates leaves it off. |
| 134 | func TestTheStagedReadIsOffWithPhase1OrClaimsOn(t *testing.T) { |
| 135 | cfg := &activator.Config{Bucket: "hib", Prefix: "inst"} |
| 136 | t.Setenv(moves.SwitchEnv, "off") |
| 137 | b, err := newBuildStates(cfg, nil, "") |
| 138 | if err != nil || b == nil || b.staged == nil || b.staged(stagedStateHost) != "moves/"+stagedStateHost+"/" { |
| 139 | t.Fatalf("Phase 1 off: staged = %v (%v)", b, err) |
| 140 | } |
| 141 | t.Setenv(moves.SwitchEnv, "on") |
| 142 | if b, err := newBuildStates(cfg, nil, ""); err != nil || b == nil || b.staged != nil { |
| 143 | t.Fatalf("Phase 1 on: the staged read is armed (%v)", err) |
| 144 | } |
| 145 | t.Setenv(moves.SwitchEnv, "off") |
| 146 | read := func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil } |
| 147 | claimed := &activator.Config{Bucket: "hib", Prefix: "inst", HostWrites: hostwrite.Config{Enabled: true}, |
| 148 | Claims: &activator.ClaimRuntime{Bucket: "data", Read: read}} |
| 149 | if b, err := newBuildStates(claimed, nil, ""); err != nil || b == nil || b.staged != nil { |
| 150 | t.Fatalf("claims on: the staged read is armed (%v)", err) |
| 151 | } |
| 152 | |
| 153 | // With it off, a staged copy is not read. |
| 154 | b2, s, _ := testBuildStates(t) |
| 155 | putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| 156 | putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x") |
| 157 | if st, replica, err := b2.read(context.Background(), stagedStateHost); err != nil || replica || st != nil { |
| 158 | t.Fatalf("read = %+v, %v, %v; want nothing staged read", st, replica, err) |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // p1StateBody is a build state for the Phase 1 harness's host. |
| 163 | func p1StateBody(le string) string { return stagedStateBody(p1Host, le) } |
| 164 | |
| 165 | // storeState puts host's build state naming le at bucket/key, its ETag |
| 166 | // unique to le as a real object's is to its bytes. |
| 167 | func storeState(h *phase1Harness, bucket, key, le string) { |
| 168 | body := p1StateBody(le) |
| 169 | h.f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: "bs-" + le, Size: int64(len(body))}, body}) |
| 170 | } |
| 171 | |
| 172 | // The Phase 1 replica lane carries the build state beside the replica in |
| 173 | // every round: exactly build-state.json from the claim holder's location, |
| 174 | // under the lane's state/ prefix; a later round carries a later state. |
| 175 | func TestThePhase1LaneCopiesTheBuildState(t *testing.T) { |
| 176 | h := newPhase1Harness(t) |
| 177 | h.start(t, "manual") |
| 178 | loc := strings.TrimSuffix(h.claim(t).Holder.Location, "/") |
| 179 | storeState(h, "data-ie", loc+"/build-state.json", "v6") |
| 180 | h.pass(t) |
| 181 | key := moves.BuildStateKey(p1Host, p1ID) |
| 182 | if got := h.f.buckets["data-uk"][key].body; got != p1StateBody("v6") { |
| 183 | t.Fatalf("the lane staged %q at %s, want the holder's build state", got, key) |
| 184 | } |
| 185 | state := stagedKeys(h, moves.StagingPrefix(p1Host, p1ID)+"state/") |
| 186 | if strings.Join(state, ",") != "build-state.json" { |
| 187 | t.Fatalf("the state lane holds %v, want only build-state.json", state) |
| 188 | } |
| 189 | storeState(h, "data-ie", loc+"/build-state.json", "v7") |
| 190 | h.pass(t) |
| 191 | if got := h.f.buckets["data-uk"][key].body; got != p1StateBody("v7") { |
| 192 | t.Fatalf("the next round staged %q, want the later state", got) |
| 193 | } |
| 194 | // Every write of the state lane went through the guard. |
| 195 | var guarded bool |
| 196 | for _, c := range h.f.stagingCalls() { |
| 197 | if c.Key == key { |
| 198 | guarded = !c.SignAt.IsZero() |
| 199 | } |
| 200 | } |
| 201 | if !guarded { |
| 202 | t.Fatalf("the state copy did not go through the guard: %+v", h.f.stagingCalls()) |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | // The cut's byte-final round carries the final build state, not the one a |
| 207 | // preparing round staged before the instance last ran. |
| 208 | func TestTheCutsFinalRoundCopiesTheBuildState(t *testing.T) { |
| 209 | h := newCutHarness(t) |
| 210 | storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| 211 | h.ready(t) // the holder is at cutRoot from here |
| 212 | if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v6") { |
| 213 | t.Fatalf("the preparing rounds staged %q", got) |
| 214 | } |
| 215 | // The instance's last run before the stop wrote a later state: only the |
| 216 | // final round, after the final checkpoint, can carry it. |
| 217 | checkpoint := h.cfg.FinalCheckpoint |
| 218 | h.cfg.FinalCheckpoint = func(ctx context.Context, host, path string, holder hostclaim.Holder, check func(context.Context) error) error { |
| 219 | storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v7") |
| 220 | return checkpoint(ctx, host, path, holder, check) |
| 221 | } |
| 222 | h.cut(t) |
| 223 | h.pass(t) |
| 224 | if m := h.move(t); m.State != provision.MoveLanding { |
| 225 | t.Fatalf("cut did not finish: %+v", m) |
| 226 | } |
| 227 | key := moves.BuildStateKey(p1Host, p1ID) |
| 228 | if got := h.f.buckets["data-uk"][key].body; got != p1StateBody("v7") { |
| 229 | t.Fatalf("the cut staged %q, want the final state", got) |
| 230 | } |
| 231 | // The seal names that copy by its version (ruling R-SEAL-STATE). |
| 232 | seal := h.seal(t) |
| 233 | if len(seal.State) != 1 || seal.State[0].Key != key || seal.State[0].VersionID != h.f.versionOf("data-uk", key) || |
| 234 | !lease.UsableVersionID(seal.State[0].VersionID) || seal.State[0].Size != int64(len(p1StateBody("v7"))) { |
| 235 | t.Fatalf("seal state %+v, lane version %s", seal.State, h.f.versionOf("data-uk", key)) |
| 236 | } |
| 237 | } |
| 238 | |
| 239 | // The landing places the staged build state beside the location's lease, |
| 240 | // holder.Location/build-state.json in the target's own bucket, before the |
| 241 | // probe: the probe's and the first wake's record merge onto the moved |
| 242 | // state, and a held instance never reads as new there. |
| 243 | func TestTheLandingPlacesTheBuildStateBeforeTheProbe(t *testing.T) { |
| 244 | t.Parallel() |
| 245 | h := newLandingHarness(t) |
| 246 | storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| 247 | h.flip(t) |
| 248 | if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v6") { |
| 249 | t.Fatalf("the lane holds %q", got) |
| 250 | } |
| 251 | dst := cutRoot + "/build-state.json" |
| 252 | if _, ok := h.f.buckets["data-uk"][dst]; ok { |
| 253 | t.Fatal("the build state is at the location before the landing") |
| 254 | } |
| 255 | h.land(t) |
| 256 | h.installed(t) |
| 257 | if got := h.f.buckets["data-uk"][dst].body; got != p1StateBody("v6") { |
| 258 | t.Fatalf("after the install the location holds %q, want the moved state", got) |
| 259 | } |
| 260 | // The probe sees it. |
| 261 | if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error { |
| 262 | rec.Move.CutAttempts = 1 |
| 263 | return nil |
| 264 | }); err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | p := &probeLog{t: t, h: h} |
| 268 | var seen []string |
| 269 | p.before = func(int) { seen = append(seen, h.f.buckets["data-uk"][dst].body) } |
| 270 | h.probe, h.probeLive = p.wake, p.liveProbe |
| 271 | h.holdProbed = true |
| 272 | h.land(t) |
| 273 | if len(seen) == 0 || seen[0] != p1StateBody("v6") { |
| 274 | t.Fatalf("the probe saw %q, want the moved state", seen) |
| 275 | } |
| 276 | } |
| 277 | |
| 278 | // A landing places exactly the sealed version: a request a stale round |
| 279 | // signed may land a newer version in the lane until the seal's bound, and |
| 280 | // that version is never placed (ruling R-SEAL-STATE). |
| 281 | func TestTheLandingPlacesTheSealedVersionNotALaterOne(t *testing.T) { |
| 282 | t.Parallel() |
| 283 | h := newLandingHarness(t) |
| 284 | h.f.keepHistory = true |
| 285 | storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| 286 | h.flip(t) |
| 287 | key := moves.BuildStateKey(p1Host, p1ID) |
| 288 | sealed := h.seal(t).State |
| 289 | if len(sealed) != 1 || sealed[0].VersionID != h.f.versionOf("data-uk", key) { |
| 290 | t.Fatalf("seal state %+v", sealed) |
| 291 | } |
| 292 | if _, err := h.f.PutVersionedAt(context.Background(), h.clock.Now(), "data-uk", key, []byte(p1StateBody("v5"))); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | if h.f.versionOf("data-uk", key) == sealed[0].VersionID { |
| 296 | t.Fatal("the late write made no new version") |
| 297 | } |
| 298 | h.land(t) |
| 299 | h.installed(t) |
| 300 | if got := h.f.buckets["data-uk"][cutRoot+"/build-state.json"].body; got != p1StateBody("v6") { |
| 301 | t.Fatalf("the location holds %q, want the sealed v6", got) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // A landing whose source had no build state seals none and places none, |
| 306 | // even if a lane object appears after the seal. |
| 307 | func TestALandingWithNoBuildStatePlacesNone(t *testing.T) { |
| 308 | t.Parallel() |
| 309 | h := newLandingHarness(t) |
| 310 | h.flip(t) |
| 311 | if st := h.seal(t).State; len(st) != 0 { |
| 312 | t.Fatalf("seal state %+v, want none", st) |
| 313 | } |
| 314 | if _, err := h.f.PutVersionedAt(context.Background(), h.clock.Now(), "data-uk", moves.BuildStateKey(p1Host, p1ID), []byte(p1StateBody("v5"))); err != nil { |
| 315 | t.Fatal(err) |
| 316 | } |
| 317 | h.land(t) |
| 318 | h.installed(t) |
| 319 | if _, ok := h.f.buckets["data-uk"][cutRoot+"/build-state.json"]; ok { |
| 320 | t.Fatal("a build state appeared at the location") |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | // A landing whose seal carries no state removes what an earlier stay left |
| 325 | // beside the target's lease (Task 12a): otherwise that stay's build state |
| 326 | // would read as this instance's. The removal runs under the location's |
| 327 | // lease, like the placement. |
| 328 | func TestALandingWithNoBuildStateRemovesALeftoverOne(t *testing.T) { |
| 329 | t.Parallel() |
| 330 | h := newLandingHarness(t) |
| 331 | key := cutRoot + "/build-state.json" |
| 332 | storeState(h.phase1Harness, "data-uk", key, "v2") // an earlier stay's |
| 333 | h.flip(t) |
| 334 | if st := h.seal(t).State; len(st) != 0 { |
| 335 | t.Fatalf("seal state %+v, want none", st) |
| 336 | } |
| 337 | h.land(t) |
| 338 | h.installed(t) |
| 339 | if o, ok := h.f.buckets["data-uk"][key]; ok { |
| 340 | t.Fatalf("an earlier stay's build state %q is still beside the lease", o.body) |
| 341 | } |
| 342 | if calls := h.vs.callsOn("delete-version data-uk/" + key); len(calls) == 0 { |
| 343 | t.Fatal("the leftover was not removed by version") |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | // A committed move's source location park acknowledges with a build state |
| 348 | // beside the source's lease (the 8b reviewer's regression): the state is the |
| 349 | // marker's twin, never an object of no generation that would hold the |
| 350 | // park-replica open for ever. |
| 351 | func TestTheSourceLocationParkAcknowledgesWithABuildState(t *testing.T) { |
| 352 | t.Parallel() |
| 353 | lh := newLandingHarness(t) |
| 354 | storeState(lh.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| 355 | h := newFinalHarnessFrom(t, lh) |
| 356 | // One admission on the holder: with none the engine names no parked |
| 357 | // generations at all, and the build state is never classified. |
| 358 | rewriteP1Claim(t, h.s, func(c *hostclaim.Claim) { |
| 359 | c.Holder.Admissions = append(c.Holder.Admissions, hostclaim.AdmissionRecord{Region: "ie", Session: 1, Box: "ie-box", Generation: "cur", Mode: "cold"}) |
| 360 | }) |
| 361 | h.commit(t) |
| 362 | h.clock.Advance(park.SignatureAge + cutRequestLimit + time.Minute) |
| 363 | h.sourcePass(t) |
| 364 | for _, o := range h.claim(t).Obligations { |
| 365 | if o.Kind == "park-replica" && o.Region == "ie" { |
| 366 | t.Fatalf("the ie park-replica is still owed past its horizon: %+v", o) |
| 367 | } |
| 368 | } |
| 369 | } |
| 370 | |
| 371 | // returnSealState is the return seal's state class, checked against the |
| 372 | // lane object it names. |
| 373 | func returnSealState(t *testing.T, h *rollbackHarness, rbID string) (moves.SealedObject, string) { |
| 374 | t.Helper() |
| 375 | seal, err := moves.DecodeSeal(h.claim(t).Op.Journal[moves.JournalReturnSeal]) |
| 376 | if err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | key := moves.BuildStateKey(p1Host, rbID) |
| 380 | if len(seal.State) != 1 || seal.State[0].Key != key || seal.State[0].VersionID != h.f.versionOf("data-ie", key) { |
| 381 | t.Fatalf("return seal state %+v, lane version %s", seal.State, h.f.versionOf("data-ie", key)) |
| 382 | } |
| 383 | return seal.State[0], h.f.buckets["data-ie"][key].body |
| 384 | } |
| 385 | |
| 386 | // The rollback's return lane carries the target's build state back, under |
| 387 | // the lane's state/ prefix in the source's bucket, sealed by version. Once |
| 388 | // the target's install has run, the state beside its lease is the one that |
| 389 | // goes back: here the probe recorded v8 over the moved v6. |
| 390 | func TestTheReturnLaneCarriesTheBuildState(t *testing.T) { |
| 391 | t.Parallel() |
| 392 | h := &rollbackHarness{landingHarness: newLandingHarness(t)} |
| 393 | storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| 394 | h.flip(t) |
| 395 | h.land(t) |
| 396 | h.installed(t) |
| 397 | h.step(t, provision.LandRestored, provision.LandProbing) |
| 398 | h.tpos = h.n |
| 399 | storeState(h.phase1Harness, "data-uk", cutRoot+"/build-state.json", "v8") |
| 400 | h.uncover(t) |
| 401 | h.rollBack(t) |
| 402 | h.rollbackPass(t) |
| 403 | rb := h.rb(t) |
| 404 | if !rb.Dirty || rb.TargetPhase != provision.RbTargetReturned { |
| 405 | t.Fatalf("rollback %+v", rb) |
| 406 | } |
| 407 | if _, body := returnSealState(t, h, rb.ID); body != p1StateBody("v8") { |
| 408 | t.Fatalf("the return lane holds %q, want the target's state", body) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | // A return before the target's install built anything: the target's |
| 413 | // location holds no state of this move, so the forward lane's sealed state |
| 414 | // goes back as given (by its exact version, not the lane's latest). |
| 415 | func TestAReturnBeforeTheInstallCarriesTheSealedState(t *testing.T) { |
| 416 | t.Parallel() |
| 417 | h := &rollbackHarness{landingHarness: newLandingHarness(t)} |
| 418 | h.f.keepHistory = true |
| 419 | storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| 420 | h.flip(t) |
| 421 | if _, err := h.f.PutVersionedAt(context.Background(), h.clock.Now(), "data-uk", moves.BuildStateKey(p1Host, p1ID), []byte(p1StateBody("v5"))); err != nil { |
| 422 | t.Fatal(err) |
| 423 | } |
| 424 | h.landAt = "after-record-" + provision.LandBuilding |
| 425 | h.crashLand(t) |
| 426 | if m := h.read(t, p1Host).Move; m.LandingPhase != provision.LandBuilding { |
| 427 | t.Fatalf("landing phase %s", m.LandingPhase) |
| 428 | } |
| 429 | h.uncover(t) |
| 430 | h.rollBack(t) |
| 431 | h.rollbackPass(t) |
| 432 | rb := h.rb(t) |
| 433 | if !rb.Dirty || rb.TargetPhase != provision.RbTargetReturned { |
| 434 | t.Fatalf("rollback %+v", rb) |
| 435 | } |
| 436 | if _, body := returnSealState(t, h, rb.ID); body != p1StateBody("v6") { |
| 437 | t.Fatalf("the return lane holds %q, want the forward lane's sealed v6", body) |
| 438 | } |
| 439 | } |
| 440 | |
| 441 | // The dirty return's install places the returned build state beside the |
| 442 | // source's location lease, as the landing does. |
| 443 | func TestTheReturnInstallPlacesTheBuildState(t *testing.T) { |
| 444 | t.Parallel() |
| 445 | h := newReturnHarness(t, true) |
| 446 | h.f.keepHistory = true |
| 447 | key := moves.BuildStateKey(p1Host, p1RB) |
| 448 | ver, err := h.vs.PutIfAbsent(context.Background(), "data-ie", key, []byte(p1StateBody("v8"))) |
| 449 | if err != nil { |
| 450 | t.Fatal(err) |
| 451 | } |
| 452 | h.seal.State = []moves.SealedObject{{Key: key, VersionID: ver, Size: int64(len(p1StateBody("v8")))}} |
| 453 | if err := h.seal.Validate(); err != nil { |
| 454 | t.Fatal(err) |
| 455 | } |
| 456 | // A later lane version is not the sealed one and is not placed. |
| 457 | if _, err := h.f.PutVersionedAt(context.Background(), h.clock.Now(), "data-ie", key, []byte(p1StateBody("v5"))); err != nil { |
| 458 | t.Fatal(err) |
| 459 | } |
| 460 | pinned := h.read(t, p1Host).Move.Inputs |
| 461 | if refused, err := h.install(t); err != nil || refused != "" { |
| 462 | t.Fatalf("install: %q %v", refused, err) |
| 463 | } |
| 464 | h.installedReturn(t, pinned) |
| 465 | if got := h.f.buckets["data-ie"][cutRoot+"/build-state.json"].body; got != p1StateBody("v8") { |
| 466 | t.Fatalf("the source's location holds %q, want the returned state", got) |
| 467 | } |
| 468 | } |
| 469 | |
| 470 | // A wake that promoted the staging and then failed to drop it |
| 471 | // (dropStagedReplica is best-effort) leaves the pre-move staged state |
| 472 | // behind its own newer record: when both hold a state, the later At wins. |
| 473 | func TestALaterOwnStateOutranksALeftoverStaging(t *testing.T) { |
| 474 | b, s, _ := testBuildStates(t) |
| 475 | b.staged = legacyStaged |
| 476 | ctx := context.Background() |
| 477 | putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x") |
| 478 | putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| 479 | own := "inst/" + stagedStateHost |
| 480 | putObject(t, s, own+"/generations/0000/x.ltx", "x") |
| 481 | putObject(t, s, own+"/build-state.json", |
| 482 | `{"schema":1,"host":"`+stagedStateHost+`","last_executed":"v2","at":"2026-10-08T12:00:00Z"}`) |
| 483 | if st, replica, err := b.read(ctx, stagedStateHost); err != nil || !replica || st == nil || st.LastExecuted != "v2" { |
| 484 | t.Fatalf("read = %+v, %v, %v; want the own, later v2", st, replica, err) |
| 485 | } |
| 486 | // An older own state (an earlier stay's) loses to the staging. |
| 487 | _, etag, err := s.Get(ctx, own+"/build-state.json") |
| 488 | if err != nil { |
| 489 | t.Fatal(err) |
| 490 | } |
| 491 | if _, err := s.Put(ctx, own+"/build-state.json", |
| 492 | []byte(`{"schema":1,"host":"`+stagedStateHost+`","last_executed":"v0","at":"2026-10-01T12:00:00Z"}`), etag); err != nil { |
| 493 | t.Fatal(err) |
| 494 | } |
| 495 | if st, _, err := b.read(ctx, stagedStateHost); err != nil || st == nil || st.LastExecuted != "v1" { |
| 496 | t.Fatalf("read = %+v, %v; want the staged v1", st, err) |
| 497 | } |
| 498 | } |
| 499 | |
| 500 | // An abort retires the build-state lane's receipts with the replica lane's. |
| 501 | func TestAbortRetiresTheBuildStateReceipts(t *testing.T) { |
| 502 | h := newAbortHarness(t) |
| 503 | loc := strings.TrimSuffix(h.claim(t).Holder.Location, "/") |
| 504 | storeState(h.phase1Harness, "data-ie", loc+"/build-state.json", "v6") |
| 505 | h.pass(t) |
| 506 | key := moves.BuildStateReceiptsKey(p1Host, p1ID) |
| 507 | if _, ok := h.f.buckets["data-ie"][key]; !ok { |
| 508 | t.Fatalf("the state lane wrote no receipts at %s", key) |
| 509 | } |
| 510 | h.abort(t) |
| 511 | h.pass(t) |
| 512 | h.waiting(t) |
| 513 | h.ownStateGone(t) |
| 514 | if _, ok := h.f.buckets["data-ie"][key]; ok { |
| 515 | t.Fatalf("%s survived the abort", key) |
| 516 | } |
| 517 | } |
| 518 | |