review round 1: the data-loss fixes — staging wins, hard-fail fallback, two-phase abort, cut re-checkpoint, ownership + class rails, log throttle (#331)
Six findings from the high-effort review, each with a live failure path: - Boomerang moves restored a months-stale own lineage: promoteStagedReplica now treats a staged copy as the newer truth (it was mirrored from live data at cut) and REPLACES the own prefix; abort became two-phase (aborting → the source deletes staging → clear) so abandoned staging can't linger to poison a later landing. - A transient S3 error in the staging fallback fell through to a fresh boot + seed that permanently shadowed the staged data: the fallback now hard-fails the wake, the same rule restoreReplica always had. - The slashless own-prefix guard could let a sibling host's keys block a promotion: gone with the precedence flip. - Non-hibernating and pooled records passed the gate with Quiesced trivially true (purging a live DB / promoting ready over nothing): both classes now refused at the door AND skipped by the worker. - A standby or second same-region box could drive a cut whose child runs on its sibling (per-box registries): the worker only acts on routes its own registry serves. - The activator's stop swallows a failed final checkpoint (safe when the warm cache stood behind it, fatal once the cut deletes that cache): the cut re-checkpoints the local DB with the lease and waits before mirroring. Plus: per-host move-error logging throttled to one line a minute (an unapplied staging grant would otherwise flood at converge cadence).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
8 files changed,
+331
−51
internal/activator/backend_exec.go+18 −8internal/activator/backend_exec_test.go+32 −6internal/console/apiinstancemove.go+13 −1internal/console/apiinstancemove_test.go+36 −6internal/host/movespass.go+108 −19internal/host/movespass_test.go+83 −7internal/provision/provision.go+26 −2internal/provision/provision_test.go+15 −2
diff --git a/internal/activator/backend_exec.go b/internal/activator/backend_exec.go| index 2903a7c35..995870cbf 100644 |
| --- a/internal/activator/backend_exec.go |
| +++ b/internal/activator/backend_exec.go |
| @@ -247,7 +247,12 @@ func (b *ExecBackend) Restore(ctx context.Context, rt registry.Route) (bool, err |
| 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) |
| + // HARD failure, same rule as restoreReplica's own error paths |
| + // (review round 1): "continuing without it" here would fall |
| + // through to a FRESH boot + seed, and once that empty database |
| + // replicates, the staged data is shadowed forever. A failed |
| + // wake retries; a seeded ghost does not. |
| + return false, fmt.Errorf("staging fallback for %s: %w", host, err) |
| } |
| } |
| @@ -1055,13 +1060,18 @@ func PurgeLocalDBArtifacts(dbPath string, full bool) { |
| // 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 |
| - } |
| + // STAGING WINS when present (review round 1). A staged replica exists |
| + // only while an inbound move is landing or has just landed: it was |
| + // mirrored from the then-live data at cut, so it is by construction |
| + // newer than any lineage this box kept from a PREVIOUS residence — the |
| + // boomerang case (ie→uk→ie) that "own lineage never loses" restored |
| + // months-stale data from. An abandoned move's staging can't linger to |
| + // poison this: abort is two-phase, and the source deletes staging |
| + // before the move clears (MoveAborting). |
| + // |
| + // The trailing slash on the guard listing matters: hibernatePrefix has |
| + // none (path.Join), and a slashless List would let app.example.com's |
| + // keys answer for app.example.co. |
| staged, err := b.Mirror.List(ctx, b.Cfg.Bucket, MovesPrefix(host)) |
| if err != nil { |
| return false, err |
diff --git a/internal/activator/backend_exec_test.go b/internal/activator/backend_exec_test.go| index 3c14ea294..84f291c12 100644 |
| --- a/internal/activator/backend_exec_test.go |
| +++ b/internal/activator/backend_exec_test.go |
| @@ -163,22 +163,45 @@ func TestRestoreStagingFallback(t *testing.T) { |
| 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") |
| + // STAGING WINS (review round 1): a fresh staged copy beside a stale |
| + // own lineage is the boomerang landing — the staged bytes replace the |
| + // own prefix and are what gets restored. |
| + f.put(b.Cfg.Bucket, "moves/"+host+"/generations/b/newer-snapshot", "e9") |
| _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| restoredFrom = nil |
| if _, err := b.Restore(ctx, rt); err != nil { |
| t.Fatal(err) |
| } |
| + own, _ = f.List(ctx, b.Cfg.Bucket, b.Cfg.hibernatePrefix(host)) |
| + if len(own) != 1 { |
| + t.Fatalf("own prefix after boomerang promote = %v, want exactly the staged copy", own) |
| + } |
| + for k := range own { |
| + if !strings.Contains(k, "newer-snapshot") { |
| + t.Fatalf("own prefix kept the stale lineage: %v", own) |
| + } |
| + } |
| staged, _ = f.List(ctx, b.Cfg.Bucket, "moves/"+host+"/") |
| - if len(staged) != 1 { |
| - t.Fatalf("staging touched despite an existing own lineage: %v", staged) |
| + if len(staged) != 0 { |
| + t.Fatalf("staging not dropped after the boomerang restore: %v", staged) |
| } |
| + |
| + // A staging-fallback ERROR fails the wake — never a fresh boot + seed |
| + // that would shadow the staged data forever. |
| + f.put(b.Cfg.Bucket, "moves/"+host+"/generations/c/snapshot", "eA") |
| + f.failList = true |
| + _ = os.Remove(filepath.Join(b.DataDir, host+".db")) |
| + if _, err := b.Restore(ctx, rt); err == nil { |
| + t.Fatal("List failure in the staging fallback did not fail the restore") |
| + } |
| + f.failList = false |
| } |
| // fakeMirrorStore is the test double for lease.MirrorStore, bucket→key→etag. |
| -type fakeMirrorStore struct{ buckets map[string]map[string]string } |
| +type fakeMirrorStore struct { |
| + buckets map[string]map[string]string |
| + failList bool |
| +} |
| func newFakeMirrorStore() *fakeMirrorStore { |
| return &fakeMirrorStore{buckets: map[string]map[string]string{}} |
| @@ -190,6 +213,9 @@ func (f *fakeMirrorStore) put(bucket, key, etag string) { |
| f.buckets[bucket][key] = etag |
| } |
| func (f *fakeMirrorStore) List(_ context.Context, bucket, prefix string) (map[string]string, error) { |
| + if f.failList { |
| + return nil, context.DeadlineExceeded |
| + } |
| out := map[string]string{} |
| for k, v := range f.buckets[bucket] { |
| if strings.HasPrefix(k, prefix) { |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 2f5e21f17..69cf3f836 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -63,6 +63,14 @@ func (s *Server) moveGate(w http.ResponseWriter, r *http.Request) (acct *Account |
| jsonError(w, http.StatusBadRequest, "fleet-placed instances keep state-does-not-follow semantics — move is for platform regions") |
| return nil, "", "", nil, false |
| } |
| + if rec.Pool != "" { |
| + jsonError(w, http.StatusBadRequest, "pooled instances do not move in v1 — their data lives on the pool's own box") |
| + return nil, "", "", nil, false |
| + } |
| + if !rec.HibernateOrDefault() { |
| + jsonError(w, http.StatusBadRequest, "a non-hibernating instance runs outside the activator — nothing manages its data, nothing can move it") |
| + return nil, "", "", nil, false |
| + } |
| return acct, app, host, rec, true |
| } |
| @@ -135,10 +143,14 @@ func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request |
| s.moveTransition(w, r, acct, app, host, provision.MoveMarkCutting(), http.StatusAccepted) |
| } |
| +// Abort is two-phase (review round 1): this door marks MoveAborting and the |
| +// SOURCE box deletes the staged copy before clearing the field — a bare |
| +// clear would leave staging behind to win a later landing on the target |
| +// (staging-wins). 202, like its siblings: the caller polls the record. |
| func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) { |
| acct, app, host, _, ok := s.moveGate(w, r) |
| if !ok { |
| return |
| } |
| - s.moveTransition(w, r, acct, app, host, provision.MoveAbort(), http.StatusOK) |
| + s.moveTransition(w, r, acct, app, host, provision.MoveAbort(), http.StatusAccepted) |
| } |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index 463f6e4e0..1aa6258ee 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -20,6 +20,7 @@ func TestAPIInstanceMoveVerbs(t *testing.T) { |
| fx, token := instanceFixture(t, "code-inst-move") |
| m := titoManifest() |
| m.Regions = nil |
| + m.Pool = "" // pooled records are refused at the move door — separate rail, pinned below |
| enableInstances(t, fx, "titogo", m) |
| u := instancesURL(fx, "bab", "titogo") |
| put := func(key, body string) { |
| @@ -72,16 +73,27 @@ func TestAPIInstanceMoveVerbs(t *testing.T) { |
| if code, _, out := rawReq(t, http.MethodPost, moveURL+"/cut", token, ""); code != http.StatusConflict { |
| t.Fatalf("cut from preparing = %d %s, want 409", code, out) |
| } |
| - // Abort from preparing → 200, move cleared; GET shows no move. |
| - if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusOK { |
| - t.Fatalf("abort = %d %s, want 200", code, out) |
| + // Abort from preparing → 202 aborting (two-phase: the SOURCE box |
| + // deletes staging, then clears the field). |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusAccepted || !strings.Contains(out, provision.MoveAborting) { |
| + t.Fatalf("abort = %d %s, want 202 aborting", code, out) |
| + } |
| + // A second abort while aborting → 409; a new request while aborting → |
| + // 409 (still in flight). |
| + if code, _, _ := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict { |
| + t.Fatalf("double abort = %d, want 409", code) |
| + } |
| + if code, _, _ := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusConflict { |
| + t.Fatalf("request while aborting = %d, want 409", code) |
| + } |
| + // The box's half clears it; then a fresh request proceeds. |
| + if err := provision.SetRecordMove(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MoveAbortComplete()); err != nil { |
| + t.Fatal(err) |
| } |
| code, _, out = rawReq(t, http.MethodGet, u+"/acme.go.tito.io", token, "") |
| if code != http.StatusOK || strings.Contains(out, `"move"`) { |
| - t.Fatalf("after abort: %d %s, want no move key", code, out) |
| + t.Fatalf("after abort-complete: %d %s, want no move key", code, out) |
| } |
| - // Promote to ready out-of-band (the box's act), then cut succeeds and |
| - // abort refuses. |
| if code, _, _ := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { |
| t.Fatal("re-request") |
| } |
| @@ -109,6 +121,7 @@ func TestAPIInstanceMoveClassRails(t *testing.T) { |
| fx, token := instanceFixture(t, "code-inst-move-rails") |
| m := titoManifest() |
| m.Regions = nil |
| + m.Pool = "" |
| enableInstances(t, fx, "titogo", m) |
| u := instancesURL(fx, "bab", "titogo") |
| if _, err := fx.rel.Put(context.Background(), "control/edges/uk-box.json", |
| @@ -151,4 +164,21 @@ func TestAPIInstanceMoveClassRails(t *testing.T) { |
| t.Fatalf("move fleet-placed = %d %s, want 400 mentioning fleet", code, out) |
| } |
| _ = fmt.Sprint() |
| + _ = provisionPooledMoveRefusal(t, fx, token, u) |
| +} |
| + |
| +// provisionPooledMoveRefusal pins the pool rail: a pooled record's data |
| +// lives on the pool's box, and v1 refuses the class at the door. |
| +func provisionPooledMoveRefusal(t *testing.T, fx *consoleFixture, token, u string) bool { |
| + t.Helper() |
| + rec := provision.Record{Schema: 1, Host: "pooled.go.tito.io", Slug: "pooled", App: "titogo", |
| + Account: "bab", Pool: "dedicated-x", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(context.Background(), fx.rel, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + code, _, out := rawReq(t, http.MethodPost, u+"/pooled.go.tito.io/move", token, `{"region":"uk"}`) |
| + if code != http.StatusBadRequest || !strings.Contains(out, "pool") { |
| + t.Fatalf("move pooled = %d %s, want 400 mentioning the pool rail", code, out) |
| + } |
| + return true |
| } |
diff --git a/internal/host/movespass.go b/internal/host/movespass.go| index 37df170a4..1772374c5 100644 |
| --- a/internal/host/movespass.go |
| +++ b/internal/host/movespass.go |
| @@ -8,6 +8,7 @@ import ( |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "sync/atomic" |
| "time" |
| @@ -54,16 +55,30 @@ var armedMoveCfg atomic.Pointer[moveCfg] |
| // svc.Start. |
| func armMovesPass(hib activator.Config, dataDir string, reg *registry.Registry, svc *activator.Service) { |
| cfg := moveCfg{ |
| - Region: strings.TrimSpace(os.Getenv("CARLOS_REGION")), |
| - OwnBucket: hib.Bucket, |
| - OwnPrefix: hib.Prefix, |
| - DataDir: dataDir, |
| + Region: strings.TrimSpace(os.Getenv("CARLOS_REGION")), |
| + OwnBucket: hib.Bucket, |
| + OwnBucketRegion: hib.Region, |
| + OwnPrefix: hib.Prefix, |
| + DataDir: dataDir, |
| Mirror: func(ctx context.Context, dstRegion string) (lease.MirrorStore, error) { |
| return lease.NewS3Mirror(ctx, hib.Region, dstRegion) |
| }, |
| Quiesced: svc.Quiesced, |
| SetStatus: reg.SetStatus, |
| - Now: time.Now, |
| + OwnsRoute: func(host string) bool { |
| + rt, err := reg.Get(host) |
| + return err == nil && rt != nil && rt.Provisioned |
| + }, |
| + Checkpoint: func(ctx context.Context, dbPath string) error { |
| + host := strings.TrimSuffix(filepath.Base(dbPath), ".db") |
| + rep, err := lease.ReplicateWithLease(ctx, dbPath, hib.Bucket, activator.HibernatePrefix(hib.Prefix, host), hib.Region) |
| + if err != nil { |
| + return err |
| + } |
| + defer rep.Close(ctx) |
| + return rep.SyncAndWait(ctx) |
| + }, |
| + Now: time.Now, |
| } |
| armedMoveCfg.Store(&cfg) |
| } |
| @@ -82,14 +97,27 @@ func runMovesPass(ctx context.Context, s release.Store) { |
| // moveCfg carries what the worker needs, injectable for tests. |
| type moveCfg struct { |
| - Region string // this box's placement code, "" included (the home) |
| - OwnBucket string // CARLOS_HIBERNATE_BUCKET |
| - OwnPrefix string // CARLOS_HIBERNATE_PREFIX ("instances" in production) |
| - DataDir string // /data — where the local DB lives |
| - Mirror func(ctx context.Context, dstRegion string) (lease.MirrorStore, error) |
| - Quiesced func(host string) bool |
| - SetStatus func(host, status string) error |
| - Now func() time.Time |
| + Region string // this box's placement code, "" included (the home) |
| + OwnBucket string // CARLOS_HIBERNATE_BUCKET |
| + OwnBucketRegion string // CARLOS_HIBERNATE_REGION |
| + OwnPrefix string // CARLOS_HIBERNATE_PREFIX ("instances" in production) |
| + DataDir string // /data — where the local DB lives |
| + Mirror func(ctx context.Context, dstRegion string) (lease.MirrorStore, error) |
| + Quiesced func(host string) bool |
| + SetStatus func(host, status string) error |
| + // OwnsRoute answers whether THIS box's registry serves the host |
| + // (review round 1): region equality alone would let a standby or |
| + // second same-region box drive a cut whose child runs on its sibling — |
| + // each box has its OWN registry, so the sibling's Quiesced is |
| + // trivially, wrongly true. The box that owns the route owns the move. |
| + OwnsRoute func(host string) bool |
| + // Checkpoint re-replicates the LOCAL DB to the own bucket and waits |
| + // (review round 1): the activator's stop logs — and swallows — a failed |
| + // final checkpoint, because pre-move the local warm cache made that |
| + // safe. The cut deletes the warm cache, so it must first prove the own |
| + // replica really carries the tail. Nil skips (tests without S3). |
| + Checkpoint func(ctx context.Context, dbPath string) error |
| + Now func() time.Time |
| } |
| // moveTarget is one box's coordinates as its edges file publishes them. |
| @@ -113,6 +141,18 @@ func movesPass(ctx context.Context, s release.Store, cfg moveCfg) error { |
| if rec.Move == nil || rec.Region != cfg.Region { |
| continue |
| } |
| + // The rails the console's moveGate also holds (and this side must |
| + // re-hold — records have other writers): only an activator-managed |
| + // exec instance's state is this worker's to ship. A pooled record's |
| + // data lives on the pool's box, not this one (v1 refuses the whole |
| + // class), and a non-hibernating instance runs OUTSIDE the activator |
| + // — Quiesced would answer true over a live, writing process. |
| + if rec.Pool != "" || !rec.HibernateOrDefault() || (rec.Kind != "" && rec.Kind != "instance") { |
| + continue |
| + } |
| + if cfg.OwnsRoute != nil && !cfg.OwnsRoute(rec.Host) { |
| + continue // another box's child; its owner drives the move |
| + } |
| if targets == nil { |
| if targets, err = moveTargets(ctx, s); err != nil { |
| log.Printf("moves: %v — every in-flight move waits for the next pass", err) |
| @@ -120,8 +160,11 @@ func movesPass(ctx context.Context, s release.Store, cfg moveCfg) error { |
| } |
| } |
| if err := runMoveRound(ctx, s, cfg, rec, targets); err != nil { |
| - // One move's trouble must not starve its neighbours. |
| - log.Printf("moves: %s: %v", rec.Host, err) |
| + // One move's trouble must not starve its neighbours — and a |
| + // PERSISTENT one (an unapplied staging grant's AccessDenied, |
| + // review round 1) must not flood the journal at the fast |
| + // converge loop's cadence: once a minute per host is plenty. |
| + logMoveTrouble(rec.Host, err) |
| } |
| } |
| return nil |
| @@ -177,6 +220,26 @@ func runMoveRound(ctx context.Context, s release.Store, cfg moveCfg, rec provisi |
| } |
| return nil |
| + case provision.MoveAborting: |
| + // Abort's box half: delete the staged copy, THEN clear the move — |
| + // a lingering staging would win a later landing on the target |
| + // (staging-wins, activator.promoteStagedReplica). |
| + staged, err := m.List(ctx, tgt.DataBucket, dstPrefix) |
| + if err != nil { |
| + return err |
| + } |
| + for k := range staged { |
| + if err := m.Delete(ctx, tgt.DataBucket, k); err != nil { |
| + return err |
| + } |
| + } |
| + err = provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveAbortComplete()) |
| + if err != nil && !isMoveConflict(err) { |
| + return err |
| + } |
| + log.Printf("moves: %s: abort complete — staged copy deleted, instance stays in %q", rec.Host, rec.Region) |
| + return nil |
| + |
| case provision.MoveCutting: |
| // Step 1+2: quiesce. SetStatus is idempotent; a route already |
| // "moving" (a resumed cut) just re-writes the word. |
| @@ -186,13 +249,27 @@ func runMoveRound(ctx context.Context, s release.Store, cfg moveCfg, rec provisi |
| if !cfg.Quiesced(rec.Host) { |
| return nil // the retire is in flight; next pass re-enters here |
| } |
| - // Step 3: the byte-final mirror round. |
| + // Step 3: prove the own replica carries the tail. The activator's |
| + // stop SWALLOWS a failed final checkpoint (safe pre-move: the warm |
| + // cache stood behind it; fatal here: step 5 deletes that cache). |
| + // Re-checkpointing an already-synced DB is cheap and idempotent; a |
| + // held lease (the child not fully released) or an S3 blip returns |
| + // an error and the pass retries. |
| + dbPath := filepath.Join(cfg.DataDir, rec.Host+".db") |
| + if cfg.Checkpoint != nil { |
| + if _, statErr := os.Stat(dbPath); statErr == nil { |
| + if err := cfg.Checkpoint(ctx, dbPath); err != nil { |
| + return fmt.Errorf("final checkpoint: %w", err) |
| + } |
| + } |
| + } |
| + // Step 4: the byte-final mirror round. |
| if _, err := lease.MirrorPrefix(ctx, m, cfg.OwnBucket, srcPrefix, tgt.DataBucket, dstPrefix); err != nil { |
| return err |
| } |
| - // Step 4: the warm-cache purge. |
| - activator.PurgeLocalDBArtifacts(filepath.Join(cfg.DataDir, rec.Host+".db"), true) |
| - // Step 5: the last act — Region flips, Move clears, one write. |
| + // Step 5: the warm-cache purge. |
| + activator.PurgeLocalDBArtifacts(dbPath, true) |
| + // Step 6: the last act — Region flips, Move clears, one write. |
| err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, rec.Host, provision.MoveComplete()) |
| if err != nil && !isMoveConflict(err) { |
| return err |
| @@ -203,6 +280,18 @@ func runMoveRound(ctx context.Context, s release.Store, cfg moveCfg, rec provisi |
| return nil |
| } |
| +// logMoveTrouble rate-limits per-host move errors to one line a minute. |
| +var moveTroubleLast sync.Map // host → time.Time |
| + |
| +func logMoveTrouble(host string, err error) { |
| + now := time.Now() |
| + if last, ok := moveTroubleLast.Load(host); ok && now.Sub(last.(time.Time)) < time.Minute { |
| + return |
| + } |
| + moveTroubleLast.Store(host, now) |
| + log.Printf("moves: %s: %v", host, err) |
| +} |
| + |
| func isMoveConflict(err error) bool { |
| // A lost CAS whose re-read finds the transition already made surfaces |
| // as ErrMoveConflict: someone (this box, last life) already advanced |
diff --git a/internal/host/movespass_test.go b/internal/host/movespass_test.go| index 353a7568d..3772d3c9f 100644 |
| --- a/internal/host/movespass_test.go |
| +++ b/internal/host/movespass_test.go |
| @@ -54,22 +54,29 @@ type moveHarness struct { |
| cfg moveCfg |
| statuses []string |
| quiet bool |
| + owns bool |
| } |
| func newMoveHarness(t *testing.T) *moveHarness { |
| t.Helper() |
| - h := &moveHarness{s: release.NewDirStore(t.TempDir()), f: newFakeMoveMirror(), quiet: true} |
| + h := &moveHarness{s: release.NewDirStore(t.TempDir()), f: newFakeMoveMirror(), quiet: true, owns: true} |
| h.cfg = moveCfg{ |
| - Region: "ie", |
| - OwnBucket: "data-ie", |
| - OwnPrefix: "instances", |
| - DataDir: t.TempDir(), |
| + Region: "ie", |
| + OwnBucket: "data-ie", |
| + OwnBucketRegion: "eu-west-1", |
| + OwnPrefix: "instances", |
| + DataDir: t.TempDir(), |
| Mirror: func(_ context.Context, dstRegion string) (lease.MirrorStore, error) { |
| return h.f, nil |
| }, |
| Quiesced: func(string) bool { return h.quiet }, |
| SetStatus: func(host, status string) error { h.statuses = append(h.statuses, host+"="+status); return nil }, |
| - Now: time.Now, |
| + OwnsRoute: func(host string) bool { return h.owns }, |
| + Checkpoint: func(_ context.Context, dbPath string) error { |
| + h.f.ops = append(h.f.ops, "checkpoint "+filepath.Base(dbPath)) |
| + return nil |
| + }, |
| + Now: time.Now, |
| } |
| // The target's edges file: uk publishes a data bucket. |
| if _, err := h.s.Put(context.Background(), "control/edges/uk-box.json", |
| @@ -172,11 +179,25 @@ func TestMovesPassCutSequence(t *testing.T) { |
| t.Fatalf("mirror ran before quiesce: %v", h.f.ops) |
| } |
| - // Quiesced: the whole tail runs, in order. |
| + // Quiesced: the whole tail runs, in order — the final own-bucket |
| + // checkpoint strictly BEFORE the mirror round (the mirror of a stale |
| + // replica is faithful and useless). |
| h.quiet = true |
| if err := movesPass(context.Background(), h.s, h.cfg); err != nil { |
| t.Fatal(err) |
| } |
| + ckIdx, cpIdx := -1, -1 |
| + for i, op := range h.f.ops { |
| + if strings.HasPrefix(op, "checkpoint ") && ckIdx == -1 { |
| + ckIdx = i |
| + } |
| + if strings.HasPrefix(op, "copy ") && cpIdx == -1 { |
| + cpIdx = i |
| + } |
| + } |
| + if ckIdx == -1 || cpIdx == -1 || ckIdx > cpIdx { |
| + t.Fatalf("cut op order = %v, want checkpoint before the mirror copy", h.f.ops) |
| + } |
| got := h.read(t, host) |
| if got.Region != "uk" || got.Move != nil { |
| t.Fatalf("after cut: region=%q move=%+v", got.Region, got.Move) |
| @@ -216,3 +237,58 @@ func TestMovesPassSkipsWithoutTargetCoords(t *testing.T) { |
| func writeFileForTest(path, content string) error { return os.WriteFile(path, []byte(content), 0o600) } |
| func fileExistsForTest(path string) bool { _, err := os.Stat(path); return err == nil } |
| + |
| +// The rails and the ownership gate (review round 1): pooled, non-hibernate, |
| +// non-instance, and not-my-route records are skipped untouched. |
| +func TestMovesPassRailsAndOwnership(t *testing.T) { |
| + h := newMoveHarness(t) |
| + host := "a.go.example.com" |
| + h.record(t, host, provision.MoveRequest("uk", time.Now())) |
| + h.f.put("data-ie", "instances/"+host+"/generations/g/snapshot", "e1") |
| + |
| + // Not this box's route (a standby sharing the region): untouched. |
| + h.owns = false |
| + if err := movesPass(context.Background(), h.s, h.cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(h.f.ops) != 0 || h.read(t, host).Move.State != provision.MovePreparing { |
| + t.Fatalf("a non-owning box acted: ops=%v", h.f.ops) |
| + } |
| + h.owns = true |
| + |
| + // A pooled sibling with a move field: skipped (the platform box holds |
| + // none of its data — a round here would promote ready over nothing). |
| + pooled := "p.go.example.com" |
| + rec := provision.Record{Schema: 1, Host: pooled, Slug: "p", App: "app", Account: "bab", |
| + Region: "ie", Pool: "dedicated-x", Channel: "stable", CreatedAt: time.Now().UTC()} |
| + if err := provision.CreateRecord(context.Background(), h.s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := provision.SetRecordMove(context.Background(), h.s, "bab", "app", pooled, provision.MoveRequest("uk", time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := movesPass(context.Background(), h.s, h.cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if h.read(t, pooled).Move.State != provision.MovePreparing { |
| + t.Fatal("a pooled record's move advanced on the platform box") |
| + } |
| +} |
| + |
| +// Abort's box half: staging deleted, then the field cleared. |
| +func TestMovesPassAbortPhase(t *testing.T) { |
| + h := newMoveHarness(t) |
| + host := "a.go.example.com" |
| + h.record(t, host, provision.MoveRequest("uk", time.Now()), provision.MoveAbort()) |
| + h.f.put("data-uk", "moves/"+host+"/generations/g/snapshot", "e1") |
| + if err := movesPass(context.Background(), h.s, h.cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + staged, _ := h.f.List(context.Background(), "data-uk", "moves/"+host+"/") |
| + if len(staged) != 0 { |
| + t.Fatalf("staging survived the abort: %v", staged) |
| + } |
| + if got := h.read(t, host); got.Move != nil || got.Region != "ie" { |
| + t.Fatalf("after abort phase: %+v region=%q", got.Move, got.Region) |
| + } |
| +} |
diff --git a/internal/provision/provision.go b/internal/provision/provision.go| index dc4b1c287..05acf3438 100644 |
| --- a/internal/provision/provision.go |
| +++ b/internal/provision/provision.go |
| @@ -695,6 +695,13 @@ const ( |
| MovePreparing = "preparing" |
| MoveReady = "ready" |
| MoveCutting = "cutting" |
| + // MoveAborting is abort's in-flight half (review round 1): the console |
| + // marks it, the SOURCE box deletes the staged copy from the target |
| + // bucket and then clears Move — because a staged replica left behind by |
| + // a bare clear would win a LATER landing on that box (staging-wins, |
| + // backend_exec.go's promoteStagedReplica) with data from an abandoned |
| + // move. |
| + MoveAborting = "aborting" |
| ) |
| // ErrMoveConflict is a transition refused because the record's current move |
| @@ -784,14 +791,31 @@ func MoveMarkCutting() func(*Record) error { |
| } |
| } |
| -// MoveAbort clears a move that has not passed the point of no return. |
| +// MoveAbort marks a move for abort — legal before the point of no return. |
| +// The clear itself is the source box's (MoveAbortComplete), after it has |
| +// deleted the staged copy. |
| func MoveAbort() func(*Record) error { |
| return func(rec *Record) error { |
| if rec.Move == nil { |
| return fmt.Errorf("%w: no move in flight", ErrMoveConflict) |
| } |
| - if rec.Move.State == MoveCutting { |
| + switch rec.Move.State { |
| + case MoveCutting: |
| return fmt.Errorf("%w: past the point of no return — the cut completes forward", ErrMoveConflict) |
| + case MoveAborting: |
| + return fmt.Errorf("%w: already aborting", ErrMoveConflict) |
| + } |
| + rec.Move.State = MoveAborting |
| + return nil |
| + } |
| +} |
| + |
| +// MoveAbortComplete is the source box's half of an abort: staging is gone, |
| +// the move field clears. |
| +func MoveAbortComplete() func(*Record) error { |
| + return func(rec *Record) error { |
| + if rec.Move == nil || rec.Move.State != MoveAborting { |
| + return fmt.Errorf("%w: abort-complete is only legal from aborting", ErrMoveConflict) |
| } |
| rec.Move = nil |
| return nil |
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go| index 69845d5f4..a152520b3 100644 |
| --- a/internal/provision/provision_test.go |
| +++ b/internal/provision/provision_test.go |
| @@ -947,7 +947,8 @@ func TestSetRecordMoveTransitions(t *testing.T) { |
| if got := read(); got.Region != "uk" || got.Move != nil { |
| t.Fatalf("after complete: region=%q move=%+v, want uk/nil in one write", got.Region, got.Move) |
| } |
| - // Abort round-trip from ready on a fresh move. |
| + // Abort is two-phase: ready → aborting (cut and promote both refuse |
| + // there) → cleared only by MoveAbortComplete. |
| if err := mv(MoveRequest("ie", now)); err != nil { |
| t.Fatal(err) |
| } |
| @@ -957,8 +958,20 @@ func TestSetRecordMoveTransitions(t *testing.T) { |
| if err := mv(MoveAbort()); err != nil { |
| t.Fatal(err) |
| } |
| + if got := read(); got.Move == nil || got.Move.State != MoveAborting { |
| + t.Fatalf("after abort: %+v, want aborting", got.Move) |
| + } |
| + if err := mv(MoveMarkCutting()); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("cut from aborting = %v, want ErrMoveConflict", err) |
| + } |
| + if err := mv(MoveAbort()); !errors.Is(err, ErrMoveConflict) { |
| + t.Fatalf("double abort = %v, want ErrMoveConflict", err) |
| + } |
| + if err := mv(MoveAbortComplete()); err != nil { |
| + t.Fatal(err) |
| + } |
| if got := read(); got.Move != nil || got.Region != "uk" { |
| - t.Fatalf("after abort: %+v region=%q", got.Move, got.Region) |
| + t.Fatalf("after abort-complete: %+v region=%q", got.Move, got.Region) |
| } |
| } |