activator: Restore stops promoting legacy staging under Phase 1; the one strict setter keeps the source strict after the flip (§5.3, M15)
ExecBackend.Phase1 is set by armPhase1 with the switch on; Restore skips promoteStagedReplica then. Off, main's behaviour. setStrict also matches Move.From == this box's region. Strict spawns are Plan 1 Task 7b's.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+115
−4
internal/activator/backend_exec.go+8 −1internal/activator/backend_exec_test.go+52 −0internal/host/movespass.go+10 −2internal/host/movesphase1_test.go+45 −1
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index af083d953..4ae1ea1e7 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -80,6 +80,13 @@ type ExecBackend struct { |
| // vouches for what it restored. Nil = the fallback does not exist, |
| // byte-identical to the pre-move behaviour. |
| Mirror lease.MirrorStore |
| + // Phase1, set by the agent from moves.Enabled, retires the staging |
| + // fallback above: Restore never promotes a moves/<host>/ tree, because |
| + // under Phase 1 the staged copy is the target's to read through the |
| + // landing, and a restore that promoted it would be a second, older |
| + // source of truth (moves design §5.3, M15). Off, the legacy promotion |
| + // is exactly as it was until Plan 6 Task 8 removes it. |
| + Phase1 bool |
| // Region is the box's CARLOS placement code (CARLOS_REGION), handed to |
| // every exec child as its own CARLOS_REGION (issue #329, titogo's |
| // residence design: the app reads where it lives — e.g. to clear a move |
| @@ -625,7 +632,7 @@ func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, err |
| // staged copy (the source's cut deleted the own prefix it left behind, |
| // so a non-empty own prefix is by construction the newer story). |
| var promoted map[string]lease.MirrorObject |
| - if b.Mirror != nil { |
| + if b.Mirror != nil && !b.Phase1 { |
| var err error |
| if promoted, err = b.promoteStagedReplica(ctx, host); err != nil { |
| // HARD failure, same rule as restoreReplica's own error paths |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 1b2af1451..f9a13b19b 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -1230,3 +1230,55 @@ func TestSwitchedOffSpawnsTakeNoNewLock(t *testing.T) { |
| t.Fatalf("a switched-off spawn created host gates: %v", b.strictGates) |
| } |
| } |
| + |
| +// Under Phase 1 the staging-wins promotion is gone (spec §5.3, M15): a |
| +// staging tree beside an empty own prefix is not promoted, the restore is a |
| +// fresh boot, and the tree is left exactly as found. Off, the legacy |
| +// promotion runs as TestRestoreStagingFallback pins. |
| +func TestRestoreDoesNotPromoteStagingUnderPhase1(t *testing.T) { |
| + ctx := context.Background() |
| + b := testExecBackend(t) |
| + f := newFakeMirrorStore() |
| + b.Mirror = f |
| + b.Phase1 = true |
| + host := "moved.example.com" |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| + |
| + oldRestore := restoreReplica |
| + restoreReplica = func(_ context.Context, dbPath, bucket, prefix, region string) (bool, error) { |
| + objs, _ := f.List(context.Background(), bucket, prefix) |
| + if len(objs) == 0 { |
| + return false, nil |
| + } |
| + return true, writeMinimalSQLite(dbPath) |
| + } |
| + defer func() { restoreReplica = oldRestore }() |
| + |
| + // The legacy shape and the Phase 1 shape (moves/<host>/<op>/...). |
| + f.put(b.Cfg.Bucket, "moves/"+host+"/generations/a/snapshot", "e1") |
| + f.put(b.Cfg.Bucket, "moves/"+host+"/mv_abcdefghijklmnopqrstuvwxyz/g-cur/0000/0000000000000001-0000000000000002.ltx", "e2") |
| + before, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| + |
| + fresh, err := b.Restore(ctx, rt) |
| + if err != nil || !fresh { |
| + t.Fatalf("Phase 1 restore with only staging: fresh=%v err=%v, want a fresh boot", fresh, err) |
| + } |
| + if own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)); len(own) != 0 { |
| + t.Fatalf("staging was promoted into the own prefix: %v", own) |
| + } |
| + after, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| + if len(after) != len(before) || len(before) != 2 { |
| + t.Fatalf("staging changed: before %v after %v", before, after) |
| + } |
| + |
| + // An own lineage restores as ever, and still leaves staging alone. |
| + f.put(b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)+"/snapshot", "own") |
| + _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| + fresh, err = b.Restore(ctx, rt) |
| + if err != nil || fresh { |
| + t.Fatalf("own lineage: fresh=%v err=%v, want a restore", fresh, err) |
| + } |
| + if after, _ = f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/"); len(after) != 2 { |
| + t.Fatalf("staging touched by an own-lineage restore: %v", after) |
| + } |
| +} |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 6a548d652..045b90c84 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -122,16 +122,23 @@ func (w *movesWorker) notice(key, format string, args ...any) { |
| // strictHost is ExecBackend.Strict with the switch on (ruling F9): a host |
| // is strict while its record has a Phase 1 move in flight that names this |
| -// box's region, as the last pass read it. |
| +// box's region, as the last pass read it. It is the one strict setter. |
| func (w *movesWorker) strictHost(host string) bool { |
| m := w.strict.Load() |
| return m != nil && (*m)[host] |
| } |
| +// setStrict names the hosts whose spawns on this box must be strict: every |
| +// host with a Phase 1 move in flight of which this box is a participant. |
| +// The record's region is the source until the flip and the target after |
| +// it, so the source is also matched by Move.From: it stays strict after |
| +// the flip for as long as it is a participant (spec §5.3), and the close |
| +// CAS that clears the move ends it. |
| func (w *movesWorker) setStrict(recs []provision.Record) { |
| m := map[string]bool{} |
| for _, rec := range recs { |
| - if rec.Move != nil && !rec.Move.Legacy() && (rec.Region == w.cfg.Region || rec.Move.To == w.cfg.Region) { |
| + if rec.Move != nil && !rec.Move.Legacy() && |
| + (rec.Region == w.cfg.Region || rec.Move.To == w.cfg.Region || rec.Move.From == w.cfg.Region) { |
| m[rec.Host] = true |
| } |
| } |
| @@ -290,6 +297,7 @@ func armPhase1(cfg *moveCfg, w *movesWorker, hib activator.Config, dataDir strin |
| } |
| } |
| eb.Strict = w.strictHost |
| + eb.Phase1 = true |
| } |
| } |
diff --git a/internal/host/movesphase1_test.go b/internal/host/movesphase1_test.go| index d7a64e2ad..3b765a877 100644 |
| --- a/internal/host/movesphase1_test.go |
| +++ b/internal/host/movesphase1_test.go |
| @@ -102,7 +102,8 @@ func TestPhase1StaleReportAfterReadyDemotesNothing(t *testing.T) { |
| // With the switch off, an id'd record is inert: no lease, no staging write, |
| // no claim write, no CAS, and one log line however many passes run. The |
| -// exec backend's Strict stays nil, so every spawn is main's. |
| +// exec backend's Strict stays nil and Phase1 false, so every spawn and |
| +// restore is main's. |
| func TestPhase1IsInertWithTheSwitchOff(t *testing.T) { |
| h := newPhase1Harness(t) |
| before := h.start(t, "auto") |
| @@ -146,6 +147,9 @@ func TestPhase1IsInertWithTheSwitchOff(t *testing.T) { |
| if (eb.Strict != nil) != on { |
| t.Fatalf("switch %v: eb.Strict set = %v", on, eb.Strict != nil) |
| } |
| + if eb.Phase1 != on { |
| + t.Fatalf("switch %v: eb.Phase1 = %v", on, eb.Phase1) |
| + } |
| } |
| } |
| @@ -893,3 +897,43 @@ func TestOnlyAPassingRoundRequestsTheCut(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// One strict setter (ruling F34/G9): a host is strict on every box that is a |
| +// participant of its Phase 1 move. The source stays strict after the flip |
| +// (spec §5.3): its record's region is the target by then, but Move.From |
| +// still names it until the close CAS clears the move. |
| +func TestSetStrictCoversEveryParticipant(t *testing.T) { |
| + const host = "a.go.example.com" |
| + move := func(id, from, to string) *provision.MoveState { |
| + return &provision.MoveState{ID: id, From: from, To: to, State: provision.MoveCutting} |
| + } |
| + cases := []struct { |
| + name string |
| + box string |
| + region string // the record's region |
| + move *provision.MoveState |
| + want bool |
| + }{ |
| + {"source before the flip", "ie", "ie", move(p1ID, "ie", "ca"), true}, |
| + {"source after the flip", "ie", "ca", move(p1ID, "ie", "ca"), true}, |
| + {"target before the flip", "ca", "ie", move(p1ID, "ie", "ca"), true}, |
| + {"target after the flip", "ca", "ca", move(p1ID, "ie", "ca"), true}, |
| + {"a box that is neither", "us", "ca", move(p1ID, "ie", "ca"), false}, |
| + {"the source once the move closed", "ie", "ca", nil, false}, |
| + {"a legacy move names no one", "ie", "ca", move("", "ie", "ca"), false}, |
| + {"a legacy move on the target", "ca", "ie", move("", "ie", "ca"), false}, |
| + {"a record with no move", "ie", "ie", nil, false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + w := &movesWorker{cfg: moveCfg{Region: tc.box}} |
| + w.setStrict([]provision.Record{{Host: host, Region: tc.region, Move: tc.move}}) |
| + if got := w.strictHost(host); got != tc.want { |
| + t.Fatalf("strictHost = %v, want %v", got, tc.want) |
| + } |
| + if w.strictHost("other.example.com") { |
| + t.Fatal("an unrelated host is strict") |
| + } |
| + }) |
| + } |
| +} |