carlos / platform Public

provision: the move field and its five guarded transitions (#331)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEypt7de2X1G84aQ9rNkCf
Paul Campbell pushed by paul@keymail.dev 8848271685da97ced82de8acdb98670615e766ef parent 9756505
2 files changed, +236 −0
  • internal/provision/provision.go +141 −0
  • internal/provision/provision_test.go +95 −0
diff --git a/internal/provision/provision.go b/internal/provision/provision.go
index 2108074b1..dc4b1c287 100644
--- a/internal/provision/provision.go
+++ b/internal/provision/provision.go
@@ -42,6 +42,12 @@ type Record struct {
CreatedAt time.Time `json:"created_at"`
CreatedBy string `json:"created_by,omitempty"`
+ // Move is an in-flight residence move (issue #331, instance-move spec):
+ // present only between move-prepare and the cut's terminal flip, absent
+ // on every record at rest. Additive + omitempty — pre-move records parse
+ // unchanged. Written ONLY through SetRecordMove's guarded transitions.
+ Move *MoveState `json:"move,omitempty"`
+
// --- shape fields (2026-08-10 route-records design §3) --------------
// Every one is omitempty with a zero value that reproduces the mint
// reconcile hardcoded before they existed: a record written by an older
@@ -671,6 +677,141 @@ func SetRecordRegion(ctx context.Context, s release.Store, acct, app, host, regi
return nil
}
+// MoveState is the record's in-flight move (issue #331). State is one of
+// the Move* constants; To is the target placement code. ReadyAt is stamped
+// by the source box's preparing→ready promotion, so the tenant can see how
+// stale its freeze-window budget is.
+type MoveState struct {
+ To string `json:"to"`
+ State string `json:"state"`
+ RequestedAt time.Time `json:"requested_at"`
+ ReadyAt *time.Time `json:"ready_at,omitempty"`
+}
+
+// The move states, in order. There is no "done" state: the terminal
+// transition flips Region and CLEARS Move in one write — a record at rest
+// never says anything about moves.
+const (
+ MovePreparing = "preparing"
+ MoveReady = "ready"
+ MoveCutting = "cutting"
+)
+
+// ErrMoveConflict is a transition refused because the record's current move
+// state does not admit it — a second prepare while one is in flight, a cut
+// before ready, an abort after the point of no return. The console maps it
+// to 409; the box treats it as "someone else already advanced this" and
+// re-reads.
+var ErrMoveConflict = errors.New("provision: move state conflict")
+
+// SetRecordMove applies one guarded move transition: the same narrow
+// read-CAS-write with one retry that SetRecordRegion uses (and for the same
+// reason — re-reading and re-applying a transition on a lost race changes
+// nothing about what it meant; an ILLEGAL transition after the re-read is
+// ErrMoveConflict, which is the answer, not a race). mut sees the freshly
+// read record and either mutates it or returns the error that names why it
+// can't.
+func SetRecordMove(ctx context.Context, s release.Store, acct, app, host string, mut func(*Record) error) error {
+ key := RecordKey(acct, app, host)
+ update := func() error {
+ raw, etag, err := s.Get(ctx, key)
+ if err != nil {
+ return err
+ }
+ var rec Record
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return fmt.Errorf("provision: record %s/%s/%s: %w", acct, app, host, err)
+ }
+ if err := mut(&rec); err != nil {
+ return err
+ }
+ body, err := json.Marshal(rec)
+ if err != nil {
+ return err
+ }
+ _, err = s.Put(ctx, key, body, etag)
+ return err
+ }
+ if err := update(); errors.Is(err, release.ErrCASConflict) {
+ return update() // one retry on a lost race; a second loss surfaces
+ } else if err != nil {
+ return err
+ }
+ return nil
+}
+
+// The five transitions, each a mut for SetRecordMove. They are the ONLY
+// writers of Record.Move.
+
+// MoveRequest starts a move to region `to`. Refused when one is already in
+// flight — the caller aborts first or waits.
+func MoveRequest(to string, now time.Time) func(*Record) error {
+ return func(rec *Record) error {
+ if rec.Move != nil {
+ return fmt.Errorf("%w: a move to %s is already %s", ErrMoveConflict, rec.Move.To, rec.Move.State)
+ }
+ if rec.Region == to {
+ return fmt.Errorf("%w: the instance is already in %s", ErrMoveConflict, to)
+ }
+ rec.Move = &MoveState{To: to, State: MovePreparing, RequestedAt: now.UTC()}
+ return nil
+ }
+}
+
+// MovePromoteReady is the source box's preparing→ready promotion.
+func MovePromoteReady(now time.Time) func(*Record) error {
+ return func(rec *Record) error {
+ if rec.Move == nil || rec.Move.State != MovePreparing {
+ return fmt.Errorf("%w: ready is only reached from preparing", ErrMoveConflict)
+ }
+ t := now.UTC()
+ rec.Move.State = MoveReady
+ rec.Move.ReadyAt = &t
+ return nil
+ }
+}
+
+// MoveMarkCutting is the console's cut verb: legal only from ready — the
+// tenant freezes writes first, and a cut from preparing would cut over a
+// replica that was never caught up.
+func MoveMarkCutting() func(*Record) error {
+ return func(rec *Record) error {
+ if rec.Move == nil || rec.Move.State != MoveReady {
+ return fmt.Errorf("%w: cut is only legal from ready", ErrMoveConflict)
+ }
+ rec.Move.State = MoveCutting
+ return nil
+ }
+}
+
+// MoveAbort clears a move that has not passed the point of no return.
+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 {
+ return fmt.Errorf("%w: past the point of no return — the cut completes forward", ErrMoveConflict)
+ }
+ rec.Move = nil
+ return nil
+ }
+}
+
+// MoveComplete is the source's terminal act: Region flips to the target and
+// Move clears, in ONE write — every reader that knows nothing about moves
+// sees the old region right up until it sees the new one.
+func MoveComplete() func(*Record) error {
+ return func(rec *Record) error {
+ if rec.Move == nil || rec.Move.State != MoveCutting {
+ return fmt.Errorf("%w: complete is only legal from cutting", ErrMoveConflict)
+ }
+ rec.Region = rec.Move.To
+ rec.Move = nil
+ return nil
+ }
+}
+
// DeleteRecord retires an instance's route authorization. Object deletion
// only — the parked database and blobs under the regional bucket's prefix
// are deliberately untouched (design §3.2); Store's Delete is idempotent,
diff --git a/internal/provision/provision_test.go b/internal/provision/provision_test.go
index c92266f39..69845d5f4 100644
--- a/internal/provision/provision_test.go
+++ b/internal/provision/provision_test.go
@@ -886,3 +886,98 @@ func TestHealthWireTagIsProbeNotHealth(t *testing.T) {
t.Fatalf("marshal used the collided health key: %s", b)
}
}
+
+// The move transitions (issue #331): SetRecordMove is the one writer of
+// Record.Move, and each transition validates the state it leaves from. The
+// terminal MoveComplete flips Region and clears Move in ONE write.
+func TestSetRecordMoveTransitions(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec := Record{Schema: 1, Host: "a.go.example.com", Slug: "a", App: "app", Account: "bab",
+ Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()}
+ if err := CreateRecord(ctx, s, rec); err != nil {
+ t.Fatal(err)
+ }
+ mv := func(mut func(*Record) error) error {
+ return SetRecordMove(ctx, s, "bab", "app", "a.go.example.com", mut)
+ }
+ read := func() Record {
+ t.Helper()
+ got, err := GetRecord(ctx, s, "bab", "app", "a.go.example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return *got
+ }
+ now := time.Now()
+
+ if err := mv(MoveRequest("ie", now)); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("move to the current region = %v, want ErrMoveConflict", err)
+ }
+ if err := mv(MoveMarkCutting()); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("cut with no move in flight = %v, want ErrMoveConflict", err)
+ }
+ if err := mv(MoveRequest("uk", now)); err != nil {
+ t.Fatal(err)
+ }
+ if got := read(); got.Move == nil || got.Move.State != MovePreparing || got.Move.To != "uk" || got.Region != "ie" {
+ t.Fatalf("after request: %+v", got.Move)
+ }
+ if err := mv(MoveRequest("se", now)); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("second request while in flight = %v, want ErrMoveConflict", err)
+ }
+ if err := mv(MoveMarkCutting()); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("cut from preparing = %v, want ErrMoveConflict", err)
+ }
+ if err := mv(MovePromoteReady(now)); err != nil {
+ t.Fatal(err)
+ }
+ if got := read(); got.Move.State != MoveReady || got.Move.ReadyAt == nil {
+ t.Fatalf("after promote: %+v", got.Move)
+ }
+ if err := mv(MoveMarkCutting()); err != nil {
+ t.Fatal(err)
+ }
+ if err := mv(MoveAbort()); !errors.Is(err, ErrMoveConflict) {
+ t.Fatalf("abort from cutting = %v, want ErrMoveConflict (completes forward)", err)
+ }
+ if err := mv(MoveComplete()); err != nil {
+ t.Fatal(err)
+ }
+ 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.
+ if err := mv(MoveRequest("ie", now)); err != nil {
+ t.Fatal(err)
+ }
+ if err := mv(MovePromoteReady(now)); err != nil {
+ t.Fatal(err)
+ }
+ if err := mv(MoveAbort()); 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)
+ }
+}
+
+// A record written before the move field existed round-trips byte-identical
+// through the CAS helper's read-marshal cycle — the wire-shape rule.
+func TestMoveFieldAbsentOnPreMoveRecords(t *testing.T) {
+ raw := []byte(`{"schema":1,"host":"a.go.example.com","slug":"a","app":"app","account":"bab","region":"ie","channel":"stable","created_at":"2026-08-01T00:00:00Z"}`)
+ var rec Record
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ t.Fatal(err)
+ }
+ if rec.Move != nil {
+ t.Fatalf("pre-move record decoded a Move: %+v", rec.Move)
+ }
+ out, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if strings.Contains(string(out), "move") {
+ t.Fatalf("re-marshal grew a move key: %s", out)
+ }
+}