settings-4 task 8: moves carry the build state; a landed move's staging is read as the instance's state
Settings spec rev 11 §4.3a: a move copies build-state.json with the replica, so the destination holds the same last_executed and request state, and a held instance never reads as new before its first wake. - moves.BuildStateKey(host, op): <lane>/state/build-state.json. - Phase 1 lane (sourceRound) mirrors exactly build-state.json from the claim holder's location in every round (mirrorBuildState, own receipts key <op>-state); the cut's byte-final round mirrors it too, after the final checkpoint, and retries if it changed under the copy. - The installer's building step (landing and dirty return) copies the lane's state to holder.Location/build-state.json under the location lease, before the probe and first wake (ruling R-LANDING). No staged state places nothing. The state is not in the seal; it is copied by its current version. - The rollback's return lane carries the target's state back the same way. - buildStates.read reads a legacy move's staging (moves/<host>/, op lanes excluded) only with host claims and Phase 1 off (R-STAGEDREAD). It reads the staging whenever it holds a legacy key, as promoteStagedReplica promotes it (staging wins), rather than only when the own prefix is empty: a returning host's stale own state would otherwise be read. update still writes only the own prefix. - TestALandingMovePromotesTheBuildState pins today's legacy behaviour: promoteStagedReplica already carries build-state.json (passes as is).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+498
−3
internal/activator/promote_buildstate_test.go+30 −0internal/host/buildstate.go+73 −3internal/host/buildstate_moves_test.go+310 −0internal/host/movescut.go+8 −0internal/host/movesinstall.go+36 −0internal/host/movesphase1.go+25 −0internal/host/movesrollback.go+7 −0internal/moves/staging.go+9 −0
diff --git a/internal/activator/promote_buildstate_test.go b/internal/activator/promote_buildstate_test.go| new file mode 100644 |
| index 000000000..38fb17df6 |
| --- /dev/null |
| +++ b/internal/activator/promote_buildstate_test.go |
| @@ -0,0 +1,30 @@ |
| +package activator |
| + |
| +import ( |
| + "context" |
| + "testing" |
| +) |
| + |
| +// Settings spec rev 11 §4.3a: a landing move carries the build state with |
| +// the replica. The legacy round mirrors the whole hibernate prefix to |
| +// moves/<host>/, and promoteStagedReplica copies every staged object over |
| +// the own prefix, build-state.json included. This pins today's behaviour: |
| +// a keep filter added to the promotion later must not drop it. |
| +func TestALandingMovePromotesTheBuildState(t *testing.T) { |
| + b := testExecBackend(t) |
| + m := newFakeMirrorStore() |
| + b.Mirror = m |
| + host := "moved.example.com" |
| + m.put(b.Cfg.Bucket, MovesPrefix(host)+"generations/0000/a.ltx", "e1") |
| + m.put(b.Cfg.Bucket, MovesPrefix(host)+"build-state.json", "e2") |
| + if _, err := b.promoteStagedReplica(context.Background(), host); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, ok := m.buckets[b.Cfg.Bucket][b.Cfg.hibernatePrefix(host)+"/build-state.json"] |
| + if !ok || got.ETag != "e2" { |
| + t.Fatalf("the landing did not carry build-state.json beside the replica: %+v %v", got, ok) |
| + } |
| + if _, ok := m.buckets[b.Cfg.Bucket][b.Cfg.hibernatePrefix(host)+"/generations/0000/a.ltx"]; !ok { |
| + t.Fatal("the landing did not carry the replica") |
| + } |
| +} |
diff --git a/internal/host/buildstate.go b/internal/host/buildstate.go| index a7d3565f0..fec24a364 100644 |
| --- a/internal/host/buildstate.go |
| +++ b/internal/host/buildstate.go |
| @@ -14,6 +14,7 @@ import ( |
| "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -35,9 +36,13 @@ type buildStates struct { |
| // doClaimWake), so every host is located through it. |
| claims func(context.Context, string) (hostclaim.Observed, error) |
| claimBucket string // the claim actor's bucket, where claimed wakes lease |
| - storeFor func(ctx context.Context, bucket string) (release.Store, error) |
| - reg *registry.Registry |
| - optDir string |
| + // staged is the legacy move round's staging prefix for host in the |
| + // hibernate bucket (activator.MovesPrefix). nil unless host claims and |
| + // Phase 1 are both off (ruling R-STAGEDREAD). |
| + staged func(host string) string |
| + storeFor func(ctx context.Context, bucket string) (release.Store, error) |
| + reg *registry.Registry |
| + optDir string |
| // bakeDir is the data dir every agent and root pass hands release.BakeDir: |
| // root's refusal records (release.ReadRefusal) live under it. |
| bakeDir string |
| @@ -68,6 +73,12 @@ func newBuildStates(cfg *activator.Config, reg *registry.Registry, optDir string |
| reg: reg, optDir: optDir, bakeDir: dataDir(), now: time.Now} |
| if cfg.HostWrites.Enabled { |
| b.claims, b.claimBucket = cfg.Claims.Read, cfg.Claims.Bucket |
| + } else if !moves.Enabled(os.Getenv) { |
| + // The legacy move round's staging is promoted at the first wake's |
| + // restore only with Phase 1 off (ExecBackend.Phase1, set from the |
| + // same switch); with host claims on the state is at the claim |
| + // holder's location, which a Phase 1 landing fills. |
| + b.staged = activator.MovesPrefix |
| } |
| b.storeFor = cachedStores(regions, lease.ExecutionStore) |
| return b, nil |
| @@ -200,7 +211,19 @@ func hasReplica(ctx context.Context, store release.Store, prefix string) (bool, |
| // read returns host's build state (nil when there is none) and whether its |
| // replica has any object besides the lease's lock.json and build-state.json. |
| +// |
| +// A legacy move's staging, when there is one, is read in place of the own |
| +// prefix (settings spec rev 11 §4.3a): between the cut and the first wake |
| +// the replica and the build state are only there, and the wake's restore |
| +// promotes the staging over the own prefix whenever it holds anything |
| +// (promoteStagedReplica: staging wins, and its mirror deletes what the |
| +// staging lacks). So the staging is what the instance's state will be. A |
| +// held instance must not read as new in that window, or root installs the |
| +// head. update still writes only the own prefix: the wake promotes first. |
| func (b *buildStates) read(ctx context.Context, host string) (*release.BuildState, bool, error) { |
| + if st, replica, ok, err := b.readStaged(ctx, host); err != nil || ok { |
| + return st, replica, err |
| + } |
| store, prefix, err := b.locate(ctx, host) |
| if err != nil { |
| return nil, false, err |
| @@ -216,6 +239,53 @@ func (b *buildStates) read(ctx context.Context, host string) (*release.BuildStat |
| return st, replica, nil |
| } |
| +// readStaged reads a legacy move's staging for host (ruling R-STAGEDREAD): |
| +// ok is true when moves/<host>/ in the hibernate bucket holds any key of |
| +// the legacy round's, the same "anything staged" promoteStagedReplica |
| +// promotes on. Phase 1 op lanes (moves/<host>/<mv_ or rb_ id>/...) are not |
| +// the legacy round's and are skipped. The staged build-state.json is the |
| +// state, and any other staged object but lock.json is a replica. Off (ok |
| +// false) unless staged is set: host claims and Phase 1 both off. |
| +func (b *buildStates) readStaged(ctx context.Context, host string) (*release.BuildState, bool, bool, error) { |
| + if b.staged == nil { |
| + return nil, false, false, nil |
| + } |
| + prefix := strings.TrimSuffix(b.staged(host), "/") |
| + store, err := b.storeFor(ctx, b.hibBucket) |
| + if err != nil { |
| + return nil, false, false, err |
| + } |
| + keys, err := store.List(ctx, prefix+"/") |
| + if err != nil { |
| + return nil, false, false, fmt.Errorf("list the staged move %s: %w", prefix, err) |
| + } |
| + var staged, state, replica bool |
| + for _, k := range keys { |
| + rel := strings.TrimPrefix(k, prefix+"/") |
| + if first, _, ok := strings.Cut(rel, "/"); ok && moves.ValidOpID(first) { |
| + continue |
| + } |
| + staged = true |
| + switch rel { |
| + case "build-state.json": |
| + state = true |
| + case "lock.json": |
| + default: |
| + replica = true |
| + } |
| + } |
| + if !staged { |
| + return nil, false, false, nil |
| + } |
| + var st *release.BuildState |
| + if state { |
| + if st, _, err = getState(ctx, store, release.BuildStateKey(prefix), host); err != nil { |
| + return nil, false, false, err |
| + } |
| + } |
| + return st, replica, true, nil |
| +} |
| + |
| // refresh stores what read found. A failed read keeps the last known state |
| // for root (a known copy is not wiped by a blip; an unread one stays |
| // unknown) and records the failure in ReadErr, so the box's report says this |
diff --git a/internal/host/buildstate_moves_test.go b/internal/host/buildstate_moves_test.go| new file mode 100644 |
| index 000000000..a0f343c24 |
| --- /dev/null |
| +++ b/internal/host/buildstate_moves_test.go |
| @@ -0,0 +1,310 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostwrite" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Settings spec rev 11 §4.3a: a move carries the build state with the |
| +// replica, and a held instance never reads as new before its first wake on |
| +// the destination. |
| + |
| +const stagedStateHost = "a.example.com" |
| + |
| +func stagedStateBody(host, le string) string { |
| + return `{"schema":1,"host":"` + host + `","last_executed":"` + le + `","at":"2026-10-07T12:00:00Z"}` |
| +} |
| + |
| +func putObject(t *testing.T, s release.Store, key, body string) { |
| + t.Helper() |
| + if _, err := s.Put(context.Background(), key, []byte(body), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// legacyStaged is newBuildStates' staged prefix: the legacy round's |
| +// moves/<host>/, read only with host claims and Phase 1 off. |
| +func legacyStaged(h string) string { return activator.MovesPrefix(h) } |
| + |
| +// Before the first wake promotes a legacy landing's staging, the own prefix |
| +// is empty. The staged build-state.json is the state and a staged object is |
| +// a replica, so a held instance is held on its last_executed, not given the |
| +// head as a new instance. |
| +func TestAStagedMoveIsReadAsTheInstancesState(t *testing.T) { |
| + b, s, _ := testBuildStates(t) |
| + b.staged = legacyStaged |
| + ctx := context.Background() |
| + putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x") |
| + putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| + st, replica, err := b.read(ctx, stagedStateHost) |
| + if err != nil || !replica || st == nil || st.LastExecuted != "v1" { |
| + t.Fatalf("read = %+v, %v, %v; want the staged state and a replica", st, replica, err) |
| + } |
| + // And Target therefore holds a manual instance on v1, not the head. |
| + d := release.Target(release.TargetInput{Pointer: &release.ChannelPointer{Version: "v3"}, |
| + Settings: release.UpdateSettings{Updates: release.UpdatesManual}, |
| + Build: release.BuildStateCopy{Known: true, Replica: replica, State: st}}) |
| + if d.Version != "v1" { |
| + t.Fatalf("Target = %+v", d) |
| + } |
| + // refresh records the same in the registry copy root reads. |
| + if err := b.refresh(ctx, stagedStateHost); err != nil { |
| + t.Fatal(err) |
| + } |
| + if c := copyOf(t, b.reg); !c.Known || !c.Replica || c.State == nil || c.State.LastExecuted != "v1" { |
| + t.Fatalf("copy = %+v", c) |
| + } |
| + // A staged replica with no build state is still a replica: never new. |
| + b2, s2, _ := testBuildStates(t) |
| + b2.staged = legacyStaged |
| + putObject(t, s2, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x") |
| + if st, replica, err := b2.read(ctx, stagedStateHost); err != nil || !replica || st != nil { |
| + t.Fatalf("staged replica only: read = %+v, %v, %v", st, replica, err) |
| + } |
| +} |
| + |
| +// The staged copy is read the way the wake's restore will use it |
| +// (promoteStagedReplica: staging wins when present, and its mirror deletes |
| +// what staging lacks). A host that comes back to a box it left keeps its |
| +// old lineage and build state at the own prefix; the restore replaces them |
| +// with the staging, so the staged build state is the one that counts. |
| +// With nothing staged the own prefix is read as before. |
| +func TestAStagedMoveOutranksAnOlderOwnStateAsTheRestoreDoes(t *testing.T) { |
| + b, s, _ := testBuildStates(t) |
| + b.staged = legacyStaged |
| + ctx := context.Background() |
| + own := "inst/" + stagedStateHost |
| + putObject(t, s, own+"/generations/0000/old.ltx", "old") |
| + putObject(t, s, own+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| + if st, replica, err := b.read(ctx, stagedStateHost); err != nil || !replica || st == nil || st.LastExecuted != "v1" { |
| + t.Fatalf("own prefix alone: read = %+v, %v, %v", st, replica, err) |
| + } |
| + putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/new.ltx", "new") |
| + putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v2")) |
| + if st, replica, err := b.read(ctx, stagedStateHost); err != nil || !replica || st == nil || st.LastExecuted != "v2" { |
| + t.Fatalf("staged over own: read = %+v, %v, %v; want the staged v2", st, replica, err) |
| + } |
| + // update still writes only the own prefix: the wake promotes first. |
| + if _, err := b.update(ctx, stagedStateHost, func(cur *release.BuildState) release.BuildState { |
| + return release.BuildState{LastExecuted: "v2"} |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if raw, _, err := s.Get(ctx, "moves/"+stagedStateHost+"/build-state.json"); err != nil || !strings.Contains(string(raw), `"v2"`) { |
| + t.Fatalf("update touched the staging: %s %v", raw, err) |
| + } |
| +} |
| + |
| +// Only the legacy round's own keys are its staging. moves/<host>/ also |
| +// holds Phase 1 op lanes (moves/<host>/<mv_ or rb_ id>/...), which no |
| +// legacy restore promotes and which say nothing about this box's state. |
| +func TestTheStagedReadExcludesPhase1OpLanes(t *testing.T) { |
| + b, s, _ := testBuildStates(t) |
| + b.staged = legacyStaged |
| + ctx := context.Background() |
| + for _, id := range []string{p1ID, p1RB} { |
| + putObject(t, s, moves.ReplicaPrefix(stagedStateHost, id)+"0000/x.ltx", "x") |
| + putObject(t, s, moves.BuildStateKey(stagedStateHost, id), stagedStateBody(stagedStateHost, "v9")) |
| + } |
| + if st, replica, err := b.read(ctx, stagedStateHost); err != nil || replica || st != nil { |
| + t.Fatalf("read = %+v, %v, %v; want no state and no replica (op lanes only)", st, replica, err) |
| + } |
| + // A legacy key beside the op lanes is staging again. |
| + putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| + if st, _, err := b.read(ctx, stagedStateHost); err != nil || st == nil || st.LastExecuted != "v1" { |
| + t.Fatalf("read = %+v, %v; want the legacy staged v1", st, err) |
| + } |
| +} |
| + |
| +// The staged read is the legacy round's: with Phase 1 on the restore never |
| +// promotes moves/<host>/ (ExecBackend.Phase1), and with host claims on the |
| +// state is at the claim holder's location, so newBuildStates leaves it off. |
| +func TestTheStagedReadIsOffWithPhase1OrClaimsOn(t *testing.T) { |
| + cfg := &activator.Config{Bucket: "hib", Prefix: "inst"} |
| + t.Setenv(moves.SwitchEnv, "off") |
| + b, err := newBuildStates(cfg, nil, "") |
| + if err != nil || b == nil || b.staged == nil || b.staged(stagedStateHost) != "moves/"+stagedStateHost+"/" { |
| + t.Fatalf("Phase 1 off: staged = %v (%v)", b, err) |
| + } |
| + t.Setenv(moves.SwitchEnv, "on") |
| + if b, err := newBuildStates(cfg, nil, ""); err != nil || b == nil || b.staged != nil { |
| + t.Fatalf("Phase 1 on: the staged read is armed (%v)", err) |
| + } |
| + t.Setenv(moves.SwitchEnv, "off") |
| + read := func(context.Context, string) (hostclaim.Observed, error) { return hostclaim.Observed{}, nil } |
| + claimed := &activator.Config{Bucket: "hib", Prefix: "inst", HostWrites: hostwrite.Config{Enabled: true}, |
| + Claims: &activator.ClaimRuntime{Bucket: "data", Read: read}} |
| + if b, err := newBuildStates(claimed, nil, ""); err != nil || b == nil || b.staged != nil { |
| + t.Fatalf("claims on: the staged read is armed (%v)", err) |
| + } |
| + |
| + // With it off, a staged copy is not read. |
| + b2, s, _ := testBuildStates(t) |
| + putObject(t, s, "moves/"+stagedStateHost+"/build-state.json", stagedStateBody(stagedStateHost, "v1")) |
| + putObject(t, s, "moves/"+stagedStateHost+"/generations/0000/x.ltx", "x") |
| + if st, replica, err := b2.read(context.Background(), stagedStateHost); err != nil || replica || st != nil { |
| + t.Fatalf("read = %+v, %v, %v; want nothing staged read", st, replica, err) |
| + } |
| +} |
| + |
| +// p1StateBody is a build state for the Phase 1 harness's host. |
| +func p1StateBody(le string) string { return stagedStateBody(p1Host, le) } |
| + |
| +// storeState puts host's build state naming le at bucket/key, its ETag |
| +// unique to le as a real object's is to its bytes. |
| +func storeState(h *phase1Harness, bucket, key, le string) { |
| + body := p1StateBody(le) |
| + h.f.store(bucket, key, fakeMoveObject{lease.MirrorObject{ETag: "bs-" + le, Size: int64(len(body))}, body}) |
| +} |
| + |
| +// The Phase 1 replica lane carries the build state beside the replica in |
| +// every round: exactly build-state.json from the claim holder's location, |
| +// under the lane's state/ prefix; a later round carries a later state. |
| +func TestThePhase1LaneCopiesTheBuildState(t *testing.T) { |
| + h := newPhase1Harness(t) |
| + h.start(t, "manual") |
| + loc := strings.TrimSuffix(h.claim(t).Holder.Location, "/") |
| + storeState(h, "data-ie", loc+"/build-state.json", "v6") |
| + h.pass(t) |
| + key := moves.BuildStateKey(p1Host, p1ID) |
| + if got := h.f.buckets["data-uk"][key].body; got != p1StateBody("v6") { |
| + t.Fatalf("the lane staged %q at %s, want the holder's build state", got, key) |
| + } |
| + state := stagedKeys(h, moves.StagingPrefix(p1Host, p1ID)+"state/") |
| + if strings.Join(state, ",") != "build-state.json" { |
| + t.Fatalf("the state lane holds %v, want only build-state.json", state) |
| + } |
| + storeState(h, "data-ie", loc+"/build-state.json", "v7") |
| + h.pass(t) |
| + if got := h.f.buckets["data-uk"][key].body; got != p1StateBody("v7") { |
| + t.Fatalf("the next round staged %q, want the later state", got) |
| + } |
| + // Every write of the state lane went through the guard. |
| + var guarded bool |
| + for _, c := range h.f.stagingCalls() { |
| + if c.Key == key { |
| + guarded = !c.SignAt.IsZero() |
| + } |
| + } |
| + if !guarded { |
| + t.Fatalf("the state copy did not go through the guard: %+v", h.f.stagingCalls()) |
| + } |
| +} |
| + |
| +// The cut's byte-final round carries the final build state, not the one a |
| +// preparing round staged before the instance last ran. |
| +func TestTheCutsFinalRoundCopiesTheBuildState(t *testing.T) { |
| + h := newCutHarness(t) |
| + storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| + h.ready(t) // the holder is at cutRoot from here |
| + if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v6") { |
| + t.Fatalf("the preparing rounds staged %q", got) |
| + } |
| + // The instance's last run before the stop wrote a later state: only the |
| + // final round, after the final checkpoint, can carry it. |
| + checkpoint := h.cfg.FinalCheckpoint |
| + h.cfg.FinalCheckpoint = func(ctx context.Context, host, path string, holder hostclaim.Holder, check func(context.Context) error) error { |
| + storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v7") |
| + return checkpoint(ctx, host, path, holder, check) |
| + } |
| + h.cut(t) |
| + h.pass(t) |
| + if m := h.move(t); m.State != provision.MoveLanding { |
| + t.Fatalf("cut did not finish: %+v", m) |
| + } |
| + if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v7") { |
| + t.Fatalf("the cut staged %q, want the final state", got) |
| + } |
| +} |
| + |
| +// The landing places the staged build state beside the location's lease, |
| +// holder.Location/build-state.json in the target's own bucket, before the |
| +// probe: the probe's and the first wake's record merge onto the moved |
| +// state, and a held instance never reads as new there. |
| +func TestTheLandingPlacesTheBuildStateBeforeTheProbe(t *testing.T) { |
| + h := newLandingHarness(t) |
| + storeState(h.phase1Harness, "data-ie", cutRoot+"/build-state.json", "v6") |
| + h.flip(t) |
| + if got := h.f.buckets["data-uk"][moves.BuildStateKey(p1Host, p1ID)].body; got != p1StateBody("v6") { |
| + t.Fatalf("the lane holds %q", got) |
| + } |
| + dst := cutRoot + "/build-state.json" |
| + if _, ok := h.f.buckets["data-uk"][dst]; ok { |
| + t.Fatal("the build state is at the location before the landing") |
| + } |
| + h.land(t) |
| + h.installed(t) |
| + if got := h.f.buckets["data-uk"][dst].body; got != p1StateBody("v6") { |
| + t.Fatalf("after the install the location holds %q, want the moved state", got) |
| + } |
| + // The probe sees it. |
| + if _, err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", p1Host, func(rec *provision.Record) error { |
| + rec.Move.CutAttempts = 1 |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + p := &probeLog{t: t, h: h} |
| + var seen []string |
| + p.before = func(int) { seen = append(seen, h.f.buckets["data-uk"][dst].body) } |
| + h.probe, h.probeLive = p.wake, p.liveProbe |
| + h.holdProbed = true |
| + h.land(t) |
| + if len(seen) == 0 || seen[0] != p1StateBody("v6") { |
| + t.Fatalf("the probe saw %q, want the moved state", seen) |
| + } |
| +} |
| + |
| +// A landing whose source had no build state stages none and places none. |
| +func TestALandingWithNoBuildStatePlacesNone(t *testing.T) { |
| + h := newLandingHarness(t) |
| + h.flip(t) |
| + h.land(t) |
| + h.installed(t) |
| + if _, ok := h.f.buckets["data-uk"][cutRoot+"/build-state.json"]; ok { |
| + t.Fatal("a build state appeared at the location") |
| + } |
| +} |
| + |
| +// The rollback's return lane carries the target's build state back, under |
| +// the lane's state/ prefix in the source's bucket. |
| +func TestTheReturnLaneCarriesTheBuildState(t *testing.T) { |
| + h := newRollbackHarness(t) |
| + storeState(h.phase1Harness, "data-uk", cutRoot+"/build-state.json", "v8") |
| + h.uncover(t) |
| + h.rollBack(t) |
| + h.rollbackPass(t) |
| + rb := h.rb(t) |
| + if rb.TargetPhase != provision.RbTargetReturned { |
| + t.Fatalf("rollback %+v", rb) |
| + } |
| + if got := h.f.buckets["data-ie"][moves.BuildStateKey(p1Host, rb.ID)].body; got != p1StateBody("v8") { |
| + t.Fatalf("the return lane holds %q, want the target's state", got) |
| + } |
| +} |
| + |
| +// The dirty return's install places the returned build state beside the |
| +// source's location lease, as the landing does. |
| +func TestTheReturnInstallPlacesTheBuildState(t *testing.T) { |
| + h := newReturnHarness(t, true) |
| + if _, err := h.vs.PutIfAbsent(context.Background(), "data-ie", moves.BuildStateKey(p1Host, p1RB), []byte(p1StateBody("v8"))); err != nil { |
| + t.Fatal(err) |
| + } |
| + pinned := h.read(t, p1Host).Move.Inputs |
| + if refused, err := h.install(t); err != nil || refused != "" { |
| + t.Fatalf("install: %q %v", refused, err) |
| + } |
| + h.installedReturn(t, pinned) |
| + if got := h.f.buckets["data-ie"][cutRoot+"/build-state.json"].body; got != p1StateBody("v8") { |
| + t.Fatalf("the source's location holds %q, want the returned state", got) |
| + } |
| +} |
diff --git a/internal/host/movescut.go b/internal/host/movescut.go| index 6605bb021..99dab6d3e 100644 |
| --- a/internal/host/movescut.go |
| +++ b/internal/host/movescut.go |
| @@ -913,6 +913,14 @@ func (c *cutRun) finalRound(ctx context.Context, holder hostclaim.Holder, report |
| if err != nil { |
| return false, fmt.Errorf("final round: %w", err) |
| } |
| + // The build state of the stopped instance, so the lane carries the |
| + // final state and not a round-old one (settings spec rev 11 §4.3a). A |
| + // state that changed under the copy is not final: the pass retries. |
| + if st, err := mirrorBuildState(ctx, c.gm, cfg.OwnBucket, holder.Location, dst, host, id); err != nil { |
| + return false, fmt.Errorf("final round: build state lane: %w", err) |
| + } else if st.Deferred > 0 { |
| + return false, fmt.Errorf("final round: the build state changed during the copy; retrying the cut") |
| + } |
| if err := c.fault("after-final-round"); err != nil { |
| return false, err |
| } |
diff --git a/internal/host/movesinstall.go b/internal/host/movesinstall.go| index 972297786..941a71c0e 100644 |
| --- a/internal/host/movesinstall.go |
| +++ b/internal/host/movesinstall.go |
| @@ -577,6 +577,9 @@ func (ir *installRun) building(ctx context.Context) (bool, error) { |
| if err != nil { |
| return false, fmt.Errorf("build the generation: %w", err) |
| } |
| + if err := ir.placeBuildState(leaseContext(ctx, held), leasedStore{VersionedStore: vs, check: held.Check}, holder); err != nil { |
| + return false, err |
| + } |
| if err := held.Check(ctx); err != nil { |
| return false, fmt.Errorf("the location's lease after the build: %w", err) |
| } |
| @@ -600,6 +603,39 @@ func (ir *installRun) building(ctx context.Context) (bool, error) { |
| return ir.advance(ctx, phase, next, installStamp{}) |
| } |
| +// placeBuildState copies the lane's build state (moves.BuildStateKey, the |
| +// only object under the lane's state/ prefix) beside the location's lease, |
| +// holder.Location/build-state.json in this box's own bucket (settings spec |
| +// rev 11 §4.3a, ruling R-LANDING). building calls it under the location's |
| +// lease, so it lands before the probe and the first wake: their record |
| +// merges onto the moved state, and a held instance never reads as new |
| +// here. The object is copied by its current version, as the source's |
| +// rounds left it; the cut's final round wrote the last one. A lane with no |
| +// build state (the source had none) places nothing. A replay copies again. |
| +func (ir *installRun) placeBuildState(ctx context.Context, vs lease.VersionedStore, holder hostclaim.Holder) error { |
| + bucket := ir.cfg().OwnBucket |
| + src := ir.src.Lane + "state/" + buildStateName |
| + objs, err := vs.ListVersions(ctx, bucket, src) |
| + if err != nil { |
| + return fmt.Errorf("list the lane's build state: %w", err) |
| + } |
| + var cur *lease.ObjectVersion |
| + for i := range objs { |
| + if o := &objs[i]; o.Key == src && o.IsLatest { |
| + cur = o |
| + } |
| + } |
| + if cur == nil || cur.DeleteMarker { |
| + return nil |
| + } |
| + dst := release.BuildStateKey(strings.TrimSuffix(holder.Location, "/")) |
| + if _, _, err := vs.CopyVersion(ctx, bucket, src, cur.VersionID, bucket, dst, false); err != nil { |
| + return fmt.Errorf("place the build state at %s: %w", dst, err) |
| + } |
| + log.Printf("moves: %s: the %s placed the moved build state at %s", ir.rec.Host, ir.src.Name, dst) |
| + return nil |
| +} |
| + |
| // published (§7.6): the build is complete and recorded; generation.json is |
| // not written here (R10-3: the probe's cold admission publishes it). The |
| // journal records the phase, the record advances to local and the location's |
diff --git a/internal/host/movesphase1.go b/internal/host/movesphase1.go| index 0e2eb9f8e..09284c180 100644 |
| --- a/internal/host/movesphase1.go |
| +++ b/internal/host/movesphase1.go |
| @@ -389,6 +389,10 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov |
| if err != nil { |
| return nil, nil, fmt.Errorf("replica lane: %w", err) |
| } |
| + // The build state travels with the replica, in every round. |
| + if _, err := mirrorBuildState(ctx, gm, cfg.OwnBucket, obs.Claim.Holder.Location, tgt.DataBucket, rec.Host, id); err != nil { |
| + return nil, nil, fmt.Errorf("build state lane: %w", err) |
| + } |
| if round.BytesCopied >= crossMinBytes { |
| ledger.LastRoundBytes, ledger.LastRoundSeconds, ledger.LastRoundAt = round.BytesCopied, cfg.Now().Sub(start).Seconds(), cfg.Now().UTC() |
| } else if _, at := ledger.crossRate(); at.IsZero() || cfg.Now().Sub(at) > crossRefreshAfter { |
| @@ -427,6 +431,27 @@ func (w *movesWorker) sourceRound(ctx context.Context, s release.Store, rec prov |
| return report(sr) |
| } |
| +// buildStateName is the build-state object's name beside a lease |
| +// (release.BuildStateKey) and in a lane's state/ prefix. |
| +const buildStateName = "build-state.json" |
| + |
| +// mirrorBuildState is the build-state lane (settings spec rev 11 §4.3a): |
| +// the instance's build-state.json, beside the lease at location in |
| +// srcBucket, mirrored to opID's lane state/ prefix in dstBucket, and |
| +// nothing else. The destination must know which build last ran against |
| +// this data. A source with no build state leaves none in the lane (the |
| +// mirror deletes a stale copy). The preparing rounds, the cut's final round |
| +// and a rollback's return lane all call it, so the lane holds the state of |
| +// the round the replica is from. Receipts live in srcBucket beside the |
| +// replica lane's, under their own key. |
| +func mirrorBuildState(ctx context.Context, m lease.MirrorStore, srcBucket, location, dstBucket, host, opID string) (lease.MirrorRound, error) { |
| + src := strings.TrimSuffix(location, "/") + "/" |
| + dst := strings.TrimSuffix(moves.BuildStateKey(host, opID), buildStateName) |
| + keep := func(rel string) bool { return rel == buildStateName } |
| + return lease.MirrorPrefixKeep(ctx, m, srcBucket, src, dstBucket, dst, |
| + lease.Receipts{Bucket: srcBucket, Key: moves.ReceiptsKey(host, opID+"-state")}, keep) |
| +} |
| + |
| // oneBoxRefusal is the one-box rule (D15), re-checked from the edges |
| // snapshot every round (ruling G13): either region publishing more than one |
| // box refuses with M-C21's sentence. It counts every box that publishes the |
diff --git a/internal/host/movesrollback.go b/internal/host/movesrollback.go| index a3e2a4242..36b3e41fa 100644 |
| --- a/internal/host/movesrollback.go |
| +++ b/internal/host/movesrollback.go |
| @@ -704,6 +704,13 @@ func (r *rollbackRun) returning(ctx context.Context) (bool, error) { |
| if err != nil { |
| return false, err |
| } |
| + // The target's build state goes back with its data (settings spec rev 11 |
| + // §4.3a): the source's install places it beside its own lease. |
| + if h := obs.Claim.Holder; h == nil { |
| + return false, fmt.Errorf("move %s: the claim has no holder to return the build state from", id) |
| + } else if _, err := mirrorBuildState(ctx, gm, cfg.OwnBucket, h.Location, gm.dst, host, r.rb); err != nil { |
| + return false, fmt.Errorf("return the build state: %w", err) |
| + } |
| // The bound is read after the last lane write: every request the guard |
| // let a writer sign expires before it. |
| bound, err := held.SafeUntil() |
diff --git a/internal/moves/staging.go b/internal/moves/staging.go| index 6adca63f3..364969a4b 100644 |
| --- a/internal/moves/staging.go |
| +++ b/internal/moves/staging.go |
| @@ -11,6 +11,15 @@ func FilesPrefix(host, opID string) string { return StagingPrefix(host, opID) |
| func TimersPrefix(host, opID string) string { return StagingPrefix(host, opID) + "timers/" } |
| func MoveJSONKey(host, opID string) string { return StagingPrefix(host, opID) + "move.json" } |
| +// BuildStateKey is the lane's copy of the instance's build-state.json |
| +// (settings spec rev 11 §4.3a): the source mirrors it from beside the |
| +// claim holder's lease in every round and in the cut's final round, and |
| +// the installer places it beside the destination's lease before the probe. |
| +// It is the only object under the lane's state/ prefix. |
| +func BuildStateKey(host, opID string) string { |
| + return StagingPrefix(host, opID) + "state/build-state.json" |
| +} |
| + |
| // ScopeLocation is the lane as a park scope location: no trailing slash |
| // (claim rev 6.23 §2 grammar, INV-23). |
| func ScopeLocation(host, opID string) string { return "moves/" + host + "/" + opID } |