carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 0900c76aa1f66e5b5c578a53971d9beefe428c57 parent 2a40569
4 files changed, +115 −4
  • internal/activator/backend_exec.go +8 −1
  • internal/activator/backend_exec_test.go +52 −0
  • internal/host/movespass.go +10 −2
  • internal/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")
+ }
+ })
+ }
+}