activator: staging-fallback restore, the full local purge, Quiesced (#331)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
3 files changed,
+222
−4
internal/activator/activator.go+12 −0internal/activator/backend_exec.go+94 −4internal/activator/backend_exec_test.go+116 −0
diff --git a/internal/activator/activator.go b/internal/activator/activator.go| index 2ec94c275..609ccf743 100644 |
| --- a/internal/activator/activator.go |
| +++ b/internal/activator/activator.go |
| @@ -1080,6 +1080,18 @@ func (a *Service) RetireExcept(keep map[string]bool) { |
| } |
| } |
| +// Quiesced answers whether a host currently has NO live activator entry — |
| +// no child running, none waking, none mid-stop. The residence-move cut |
| +// (internal/host's move worker) gates its final mirror round on this: the |
| +// route's Status change retires the entry through RetireExcept, and only a |
| +// quiet host's replica prefix is byte-final. |
| +func (a *Service) Quiesced(host string) bool { |
| + a.mu.Lock() |
| + defer a.mu.Unlock() |
| + _, live := a.entries[host] |
| + return !live |
| +} |
| + |
| // Shutdown checkpoints and stops every child — the agent going down takes its |
| // instances with it (they're its children; leaving them orphaned would strand |
| // the leases until TTL expiry and leave the sockets bound against the next |
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index a8fac9f93..2903a7c35 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -60,6 +60,14 @@ type ExecBackend struct { |
| // but a carlos app is a separate binary from the release store — there is |
| // nothing sensible to invent here, and the adopt pass owns the symlinks. |
| Materialize func(ctx context.Context, host string) error |
| + // Mirror, when non-nil, arms Restore's staging fallback (issue #331): |
| + // a host with no replica under the ordinary instances/ prefix may have |
| + // one under moves/<host>/ — a residence move's staged copy, pushed by |
| + // the SOURCE box. Restore then copies it into place (own bucket, own |
| + // IAM), restores as normal, and deletes the staging copy once CheckDB |
| + // vouches for what it restored. Nil = the fallback does not exist, |
| + // byte-identical to the pre-move behaviour. |
| + Mirror lease.MirrorStore |
| // 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 |
| @@ -226,11 +234,22 @@ func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, err |
| // its MetaDirSuffix — spelled out because this package never imports |
| // litestream) as any box-wide backup replicator would use, and the lease |
| // package's activator-scoped ".activator-<db>-litestream". |
| - for _, p := range []string{dbPath + "-wal", dbPath + "-shm"} { |
| - _ = os.Remove(p) |
| + PurgeLocalDBArtifacts(dbPath, false) |
| + |
| + // The staging fallback (issue #331): when the ordinary prefix is empty |
| + // and a move staged a replica here, promote it — server-side, own |
| + // bucket — and restore from the promoted copy. Precedence is the |
| + // ordinary prefix ALWAYS: a host that re-lands here after moving away |
| + // and back must restore its own lineage, never regress to an old |
| + // 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). |
| + promoted := false |
| + if b.Mirror != nil { |
| + var err error |
| + if promoted, err = b.promoteStagedReplica(ctx, host); err != nil { |
| + log.Printf("activator: %s: staging fallback: %v (continuing without it)", host, err) |
| + } |
| } |
| - _ = os.RemoveAll(dbPath + "-litestream") |
| - _ = os.RemoveAll(filepath.Join(filepath.Dir(dbPath), ".activator-"+filepath.Base(dbPath)+"-litestream")) |
| restored, err := restoreReplica(ctx, dbPath, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host), b.Cfg.Region) |
| if err != nil { |
| @@ -270,6 +289,14 @@ func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, err |
| return false, fmt.Errorf("restored replica for %s is corrupt: %w", host, err) |
| } |
| log.Printf("activator: restored %s from s3://%s/%s", host, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| + // A clean restore of a copy promoted THIS call is the moment the |
| + // staging has served its purpose; a corrupt one (above) |
| + // deliberately LEFT it in place as evidence, and staging that was |
| + // never consulted (an abort's leftovers under a live own lineage) |
| + // is not this restore's to touch. |
| + if promoted { |
| + b.dropStagedReplica(ctx, host) |
| + } |
| } else { |
| // Never replicated: a brand-new instance boots fresh and creates its |
| // own DB — the register-only create path (provisioning as a metadata |
| @@ -999,3 +1026,66 @@ func (p *childProc) Stop(ctx context.Context) error { |
| return fmt.Errorf("instance did not exit on SIGTERM; killed") |
| } |
| } |
| + |
| +// movesPrefix is where a residence move stages a replica in the TARGET's |
| +// data bucket (instance-move spec): moves/<host>/ beside the ordinary |
| +// instances/ prefix. A free function so the host's move worker and this |
| +// package spell it identically. |
| +func MovesPrefix(host string) string { return "moves/" + host + "/" } |
| + |
| +// PurgeLocalDBArtifacts removes the local litestream footprint next to a |
| +// DB — WAL, SHM and both meta dirs — and, when full is true, the DB file |
| +// itself. Restore's pre-restore cleanup uses the partial form (the DB's |
| +// absence is what got us here); a residence move's cut uses the full form: |
| +// the activator's warm-cache trust is exactly right for crashes and exactly |
| +// wrong after a cut, where a stale local file would resurrect a moved-away |
| +// instance's history. |
| +func PurgeLocalDBArtifacts(dbPath string, full bool) { |
| + for _, p := range []string{dbPath + "-wal", dbPath + "-shm"} { |
| + _ = os.Remove(p) |
| + } |
| + _ = os.RemoveAll(dbPath + "-litestream") |
| + _ = os.RemoveAll(filepath.Join(filepath.Dir(dbPath), ".activator-"+filepath.Base(dbPath)+"-litestream")) |
| + if full { |
| + _ = os.Remove(dbPath) |
| + } |
| +} |
| + |
| +// promoteStagedReplica copies moves/<host>/ over instances/<host>/ in this |
| +// box's OWN bucket when — and only when — the ordinary prefix is empty. A |
| +// no-op (nil error) when there is nothing staged. |
| +func (b *ExecBackend) promoteStagedReplica(ctx context.Context, host string) (bool, error) { |
| + own, err := b.Mirror.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| + if err != nil { |
| + return false, err |
| + } |
| + if len(own) > 0 { |
| + return false, nil // the ordinary lineage exists; staging never wins |
| + } |
| + staged, err := b.Mirror.List(ctx, b.Cfg.Bucket, MovesPrefix(host)) |
| + if err != nil { |
| + return false, err |
| + } |
| + if len(staged) == 0 { |
| + return false, nil |
| + } |
| + log.Printf("activator: %s: promoting %d staged object(s) from %s (residence move landing)", host, len(staged), MovesPrefix(host)) |
| + if _, err := lease.MirrorPrefix(ctx, b.Mirror, b.Cfg.Bucket, MovesPrefix(host), b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)); err != nil { |
| + return false, err |
| + } |
| + return true, nil |
| +} |
| + |
| +// dropStagedReplica deletes moves/<host>/ after a restore CheckDB vouched |
| +// for. Best-effort: a leftover staging copy is storage, not a correctness |
| +// problem — promoteStagedReplica never consults it again once the ordinary |
| +// prefix exists. |
| +func (b *ExecBackend) dropStagedReplica(ctx context.Context, host string) { |
| + staged, err := b.Mirror.List(ctx, b.Cfg.Bucket, MovesPrefix(host)) |
| + if err != nil { |
| + return |
| + } |
| + for k := range staged { |
| + _ = b.Mirror.Delete(ctx, b.Cfg.Bucket, k) |
| + } |
| +} |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 7e3fb3a8a..3c14ea294 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -2,6 +2,7 @@ package activator |
| import ( |
| "context" |
| + "database/sql" |
| "os" |
| "os/exec" |
| "path/filepath" |
| @@ -9,6 +10,8 @@ import ( |
| "testing" |
| "time" |
| + _ "modernc.org/sqlite" |
| + |
| "github.com/carlosframework/platform/internal/cgroup" |
| "github.com/carlosframework/platform/internal/registry" |
| ) |
| @@ -103,3 +106,116 @@ func TestExecBackendStartChildRegion(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// The staging fallback (issue #331): a host whose ordinary prefix is empty |
| +// restores from a move's staged copy — promoted server-side, dropped after |
| +// a clean restore — and the ordinary prefix ALWAYS wins when it exists. |
| +func TestRestoreStagingFallback(t *testing.T) { |
| + ctx := context.Background() |
| + b := testExecBackend(t) |
| + f := newFakeMirrorStore() |
| + b.Mirror = f |
| + |
| + host := "moved.example.com" |
| + rt := registry.Route{Host: host, Kind: "instance", Socket: filepath.Join(t.TempDir(), "i.sock")} |
| + |
| + // restoreReplica is stubbed for the WHOLE test (the real one dials S3): |
| + // it restores — materializing a genuinely valid SQLite file, so CheckDB |
| + // passes — exactly when the fake bucket holds objects under the prefix |
| + // it was pointed at, which is the promotion's observable effect. |
| + var restoredFrom []string |
| + 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 |
| + } |
| + restoredFrom = append(restoredFrom, bucket+"/"+prefix) |
| + return true, writeMinimalSQLite(dbPath) |
| + } |
| + defer func() { restoreReplica = oldRestore }() |
| + |
| + // Nothing staged, nothing own: plain fresh boot (restored=false→fresh=true). |
| + fresh, err := b.Restore(ctx, rt) |
| + if err != nil || !fresh { |
| + t.Fatalf("empty everything: fresh=%v err=%v, want fresh boot", fresh, err) |
| + } |
| + |
| + // Staged copy present, own prefix empty: promoted then restored. |
| + f.put(b.Cfg.Bucket, "moves/"+host+"/generations/a/snapshot", "e1") |
| + _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| + fresh, err = b.Restore(ctx, rt) |
| + if err != nil { |
| + t.Fatalf("staged restore: %v", err) |
| + } |
| + if fresh { |
| + t.Fatal("staged restore reported a fresh boot") |
| + } |
| + if len(restoredFrom) != 1 || restoredFrom[0] != b.Cfg.Bucket+"/"+b.Cfg.hibernatePrefix(host) { |
| + t.Fatalf("restored from %v, want the promoted ordinary prefix %s", restoredFrom, b.Cfg.hibernatePrefix(host)) |
| + } |
| + own, _ := f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| + if len(own) != 1 { |
| + t.Fatalf("promotion left own prefix = %v", own) |
| + } |
| + staged, _ := f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| + if len(staged) != 0 { |
| + t.Fatalf("staging not dropped after a clean restore: %v", staged) |
| + } |
| + |
| + // Precedence: own prefix present → staging never consulted, never |
| + // dropped. |
| + f.put(b.Cfg.Bucket, "moves/"+host+"/generations/b/snapshot", "e9") |
| + _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| + restoredFrom = nil |
| + if _, err := b.Restore(ctx, rt); err != nil { |
| + t.Fatal(err) |
| + } |
| + staged, _ = f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| + if len(staged) != 1 { |
| + t.Fatalf("staging touched despite an existing own lineage: %v", staged) |
| + } |
| +} |
| + |
| +// fakeMirrorStore is the test double for lease.MirrorStore, bucket→key→etag. |
| +type fakeMirrorStore struct{ buckets map[string]map[string]string } |
| + |
| +func newFakeMirrorStore() *fakeMirrorStore { |
| + return &fakeMirrorStore{buckets: map[string]map[string]string{}} |
| +} |
| +func (f *fakeMirrorStore) put(bucket, key, etag string) { |
| + if f.buckets[bucket] == nil { |
| + f.buckets[bucket] = map[string]string{} |
| + } |
| + f.buckets[bucket][key] = etag |
| +} |
| +func (f *fakeMirrorStore) List(_ context.Context, bucket, prefix string) (map[string]string, error) { |
| + out := map[string]string{} |
| + for k, v := range f.buckets[bucket] { |
| + if strings.HasPrefix(k, prefix) { |
| + out[k] = v |
| + } |
| + } |
| + return out, nil |
| +} |
| +func (f *fakeMirrorStore) Copy(_ context.Context, srcBucket, srcKey, dstBucket, dstKey string) error { |
| + f.put(dstBucket, dstKey, f.buckets[srcBucket][srcKey]) |
| + return nil |
| +} |
| +func (f *fakeMirrorStore) Delete(_ context.Context, bucket, key string) error { |
| + delete(f.buckets[bucket], key) |
| + return nil |
| +} |
| + |
| +// writeMinimalSQLite creates a genuinely valid empty SQLite database so |
| +// lease.CheckDB passes on the stubbed restore's output. |
| +func writeMinimalSQLite(dbPath string) error { |
| + db, err := sql.Open("sqlite", dbPath) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := db.Exec("create table t(x)"); err != nil { |
| + return err |
| + } |
| + return db.Close() |
| +} |