| 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, |