| index 3b2c34b09..9a775ae7d 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -613,8 +613,10 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re |
| if !ok { |
| return |
| } |
| + sel := offlineOperatorSelection(fleet, d.rec.Region, to, provision.StageTarget{}) |
| // A staged instance starts the move it is staged for: staging to |
| - // stopping, the instance offline from now. The lock is the stage's. |
| + // stopping, the instance offline from now. The reservation is the |
| + // stage's, retried as its own. |
| if mv, staged := offlineStaged(d.rec); staged { |
| if to != mv.To { |
| writeRefusal(w, s.offlineStagedFor(d.host, mv.To)) |
| @@ -628,21 +630,19 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re |
| writeRefusal(w, ref) |
| return |
| } |
| - s.offlineAdvance(w, r, d, offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| + s.offlineAdvanceBound(w, r, d, sel, mv.ID, func(at time.Time) func(*provision.Record) error { |
| return provision.OfflineStartStaged(mv.ID, d.addr, at, provision.Receipt{}) |
| - })) |
| + }) |
| return |
| } |
| - s.offlineBegin(w, r, d, func(id string) func(*provision.Record) error { |
| - return offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| - return provision.OfflineStart(id, d.rec.Region, to, d.addr, at, "") |
| - }) |
| + s.offlineBegin(w, r, d, sel, func(id string, at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStart(id, d.rec.Region, to, d.addr, at, "") |
| }) |
| } |
| |
| // handleAPIInstanceOfflineStage is the pre-copy door. On a record at rest it |
| // creates the move in offline-staging, checked exactly as start is and |
| -// under the same one-move lock, and the source region's stager runs one |
| +// under the same reservation, and the source region's stager runs one |
| // round on the serial bump. On a record already staging for that region it |
| // moves staged_at forward (OfflineRestage), so the stager runs one more |
| // round; each round copies only what is new (the receipts). |
| @@ -664,9 +664,10 @@ func (s *Server) handleAPIInstanceOfflineStage(w http.ResponseWriter, r *http.Re |
| writeRefusal(w, ref) |
| return |
| } |
| - s.offlineAdvance(w, r, d, offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| + sel := offlineOperatorSelection(fleet, d.rec.Region, to, provision.StageTarget{Stager: mv.Stager, Bucket: mv.StageBucket, Region: mv.StageRegion}) |
| + s.offlineAdvanceBound(w, r, d, sel, mv.ID, func(at time.Time) func(*provision.Record) error { |
| return provision.OfflineRestage(mv.ID, at, provision.Receipt{}) |
| - })) |
| + }) |
| return |
| } |
| for _, b := range append(fleet.boxesAt(d.rec.Region), fleet.boxesAt(to)...) { |
| @@ -696,17 +697,23 @@ func (s *Server) handleAPIInstanceOfflineStage(w http.ResponseWriter, r *http.Re |
| } |
| } |
| st := provision.StageTarget{Stager: moves.OfflineStager(names), Bucket: tgt.DataBucket, Region: tgt.DataBucketRegion} |
| - s.offlineBegin(w, r, d, func(id string) func(*provision.Record) error { |
| - return offlineStamped(time.Now(), func(at time.Time) func(*provision.Record) error { |
| - return provision.OfflineStage(id, d.rec.Region, to, d.addr, st, at, "") |
| - }) |
| + s.offlineBegin(w, r, d, offlineOperatorSelection(fleet, d.rec.Region, to, st), func(id string, at time.Time) func(*provision.Record) error { |
| + return provision.OfflineStage(id, d.rec.Region, to, d.addr, st, at, "") |
| }) |
| } |
| |
| // offlineBegin mints the move's id and writes the transition begin makes |
| -// with it (OfflineStart or OfflineStage) under the one-move lock, then |
| -// bumps the serial and answers 202 with the status view. |
| -func (s *Server) offlineBegin(w http.ResponseWriter, r *http.Request, d offlineDoor, begin func(id string) func(*provision.Record) error) { |
| +// with it (OfflineStart or OfflineStage), then bumps the serial and answers |
| +// 202 with the status view. Predecessor-bound admission (self-serve moves |
| +// plan 2 Task 3): the door decided on d.rec, the judged record its gate read |
| +// before any reservation; the stamp is proposed from it |
| +// (provision.ProposeStamp), the reservation names its incarnation and |
| +// record_seq, and the write is bound to them (provision.SetRecordMoveAt), |
| +// one attempt. A record that changed since, including the fence of a reclaim |
| +// that took this door's stale reservation and a host recreated at a new |
| +// incarnation, refuses the write: the door answers 409 and releases its |
| +// reservation, and never reads again to retry. |
| +func (s *Server) offlineBegin(w http.ResponseWriter, r *http.Request, d offlineDoor, sel offlineSelection, begin func(id string, at time.Time) func(*provision.Record) error) { |
| ctx := r.Context() |
| host := d.host |
| id, err := moves.NewMoveID(rand.Reader) |
| @@ -715,33 +722,47 @@ func (s *Server) offlineBegin(w http.ResponseWriter, r *http.Request, d offlineD |
| jsonError(w, http.StatusBadGateway, "could not mint a move id") |
| return |
| } |
| - start := begin(id) |
| + start := begin(id, provision.ProposeStamp(d.rec, time.Now())) |
| probe := *d.rec |
| if err := start(&probe); err != nil { |
| jsonError(w, http.StatusConflict, offlineConflict(err)) |
| return |
| } |
| - // One offline move at a time: the lock is taken before the record is |
| - // written, and released if the write fails. |
| - if ref := s.offlineTakeLock(ctx, d, id); ref != nil { |
| + // The boxes are reserved before the record is written. A failed |
| + // reservation writes nothing. |
| + if ref := s.reserveBoxes(ctx, sel, s.offlineReservationOf(d, id)); ref != nil { |
| writeRefusal(w, ref) |
| return |
| } |
| - // The write is bounded well under offlineLockGrace, so the lock cannot be |
| - // judged stale while it is still in flight. |
| + // The write is bounded well under offlineLockGrace, so the reservation |
| + // cannot be judged stale while it is still in flight. |
| wctx, cancel := context.WithTimeout(ctx, offlineStartWrite) |
| defer cancel() |
| - got, err := s.offlineApply(wctx, d, start) |
| + got, err := s.offlineApplyBound(wctx, d, start) |
| if err != nil { |
| // A failed write does not prove nothing was written (a commit whose |
| - // reply was lost). The lock is never released here: if the record |
| - // carries this move the start succeeded, and if it does not the |
| - // lock goes stale and frees itself. |
| + // reply was lost): if the record carries this move the start |
| + // succeeded. This read confirms; it never retries the write. |
| rctx, rcancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) |
| defer rcancel() |
| - if cur, _, rerr := s.readRecord(rctx, d.acct.Sqid, d.app, host); rerr == nil && cur.Move.Offline() && cur.Move.ID == id { |
| + cur, _, rerr := s.readRecord(rctx, d.acct.Sqid, d.app, host) |
| + switch { |
| + case rerr == nil && cur.Move.Offline() && cur.Move.ID == id: |
| got, err = cur, nil |
| + case errors.Is(err, release.ErrCASConflict): |
| + // Not the record the door decided on. The reservation is |
| + // released only once the record is read without this move; |
| + // otherwise it goes stale and is reclaimed on evidence. |
| + if rerr == nil { |
| + if rel := s.releaseBoxes(context.WithoutCancel(ctx), moves.OfflineKindMove, id); rel != nil { |
| + log.Printf("console: offline move %s: releasing the reservation of %s: %v", host, id, rel) |
| + } |
| + } |
| + jsonError(w, http.StatusConflict, offlineChangedMsg) |
| + return |
| } |
| + // Any other failure keeps the reservation: if the record carries |
| + // the move it is the move's, and if it does not it goes stale. |
| } |
| if err != nil { |
| s.offlineWriteFailed(w, d, err) |
| @@ -751,6 +772,56 @@ func (s *Server) offlineBegin(w http.ResponseWriter, r *http.Request, d offlineD |
| writeJSON(w, http.StatusAccepted, s.offlineViewOf(ctx, got)) |
| } |
| |
| +// offlineChangedMsg is the start and stage doors' refusal of a bound write |
| +// whose record changed after the door observed it (self-serve moves plan 2 |
| +// Task 3). |
| +const offlineChangedMsg = "the instance changed while the move was being started. try again" |
| + |
| +// offlineReservationOf is the door's reservation for move id: d's host, |
| +// account and app, bound to the record the door observed. |
| +func (s *Server) offlineReservationOf(d offlineDoor, id string) moves.OfflineReservation { |
| + return moves.OfflineReservation{Kind: moves.OfflineKindMove, ID: id, Host: d.host, Account: d.acct.Sqid, App: d.app, |
| + ReservedAt: time.Now().UTC(), ExpectSeq: d.rec.RecordSeq, ExpectIncarnation: d.rec.Incarnation} |
| +} |
| + |
| +// offlineApplyBound is one transition bound to the record the door |
| +// observed: its incarnation and record_seq, one attempt. |
| +func (s *Server) offlineApplyBound(ctx context.Context, d offlineDoor, mut func(*provision.Record) error) (*provision.Record, error) { |
| + return provision.SetRecordMoveAt(ctx, s.cfg.Releases, d.acct.Sqid, d.app, d.host, |
| + provision.Expect{Incarnation: d.rec.Incarnation, Seq: d.rec.RecordSeq}, mut) |
| +} |
| + |
| +// offlineAdvanceBound is a staged move's start or restage: the move's own |
| +// reservation retried (it is the stage's, so an exact retry writes nothing), |
| +// then the transition bound to d.rec with its stamp proposed from it, the |
| +// serial bumped and 202 with the status view. A record that changed since |
| +// the door observed it is the 409 and the reservation is kept: it belongs |
| +// to the move the record still carries. |
| +func (s *Server) offlineAdvanceBound(w http.ResponseWriter, r *http.Request, d offlineDoor, sel offlineSelection, id string, mut func(at time.Time) func(*provision.Record) error) { |
| + ctx := r.Context() |
| + m := mut(provision.ProposeStamp(d.rec, time.Now())) |
| + probe := *d.rec |
| + if err := m(&probe); err != nil { |
| + s.offlineWriteFailed(w, d, err) |
| + return |
| + } |
| + if ref := s.reserveBoxes(ctx, sel, s.offlineReservationOf(d, id)); ref != nil { |
| + writeRefusal(w, ref) |
| + return |
| + } |
| + rec, err := s.offlineApplyBound(ctx, d, m) |
| + if errors.Is(err, release.ErrCASConflict) { |
| + jsonError(w, http.StatusConflict, offlineChangedMsg) |
| + return |
| + } |
| + if err != nil { |
| + s.offlineWriteFailed(w, d, err) |
| + return |
| + } |
| + s.bumpSerial(ctx) |
| + writeJSON(w, http.StatusAccepted, s.offlineViewOf(ctx, rec)) |
| +} |
| + |
| // offlineStartWrite bounds the start door's record write. |
| const offlineStartWrite = 30 * time.Second |
| |
| @@ -772,7 +843,9 @@ func (s *Server) offlineWriteFailed(w http.ResponseWriter, d offlineDoor, err er |
| |
| // offlineWrite applies one transition and bumps the serial, or answers the |
| // refusal itself. When the move it leaves is cleared (finish, or the finish |
| -// that ends an abort) it releases the lock. |
| +// that ends an abort) it releases the move's reservation; a failure is |
| +// logged and left, and the next reserver reclaims it on evidence (the record |
| +// no longer carries the move). |
| func (s *Server) offlineWrite(w http.ResponseWriter, r *http.Request, d offlineDoor, mut func(*provision.Record) error) (*provision.Record, bool) { |
| ctx := r.Context() |
| rec, err := s.offlineApply(ctx, d, mut) |
| @@ -782,7 +855,9 @@ func (s *Server) offlineWrite(w http.ResponseWriter, r *http.Request, d offlineD |
| } |
| s.bumpSerial(ctx) |
| if rec.Move == nil && d.rec.Move != nil { |
| - s.offlineReleaseLock(ctx, d.host, d.rec.Move.ID) |
| + if err := s.releaseBoxes(context.WithoutCancel(ctx), moves.OfflineKindMove, d.rec.Move.ID); err != nil { |
| + log.Printf("console: offline move %s: releasing the reservation of %s: %v", d.host, d.rec.Move.ID, err) |
| + } |
| } |
| return rec, true |
| } |
| @@ -795,83 +870,11 @@ func (s *Server) offlineAdvance(w http.ResponseWriter, r *http.Request, d offlin |
| } |
| } |
| |
| -// offlineLockGrace is how long a lock is believed whatever its record says: |
| -// the start door takes the lock, then writes the record. |
| +// offlineLockGrace is how long a reservation is believed whatever its |
| +// record says: a reserver reserves, then writes the record. Past it a |
| +// reservation is reclaimable, on evidence only (offlineReclaimable). |
| const offlineLockGrace = 2 * time.Minute |
| |
| -// offlineTakeLock takes control/offline-moves/active.json for this host and |
| -// move, create-only. A lock already there is live while the record it names |
| -// still carries that move, or is younger than offlineLockGrace (MV-O30 naming |
| -// its host); otherwise it is stale (a crashed start, a failed release) and is |
| -// replaced under its etag. A |
| -// lock that cannot be read is not guessed at. |
| -func (s *Server) offlineTakeLock(ctx context.Context, d offlineDoor, id string) *offlineRefusal { |
| - unreadable := &offlineRefusal{status: http.StatusBadGateway, msg: "could not read the instance"} |
| - body, err := json.Marshal(moves.OfflineActive{Host: d.host, Op: id, Account: d.acct.Sqid, App: d.app, At: time.Now().UTC()}) |
| - if err != nil { |
| - return unreadable |
| - } |
| - _, err = s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, "") |
| - if err == nil { |
| - return nil |
| - } |
| - if !errors.Is(err, release.ErrCASConflict) { |
| - log.Printf("console: offline move %s: taking the lock: %v", d.host, err) |
| - return unreadable |
| - } |
| - raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey) |
| - var held moves.OfflineActive |
| - if err != nil || json.Unmarshal(raw, &held) != nil || held.Host == "" { |
| - log.Printf("console: offline move %s: reading the lock: %v", d.host, err) |
| - return unreadable |
| - } |
| - busy := &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s is moving offline. move one instance at a time", held.Host)} |
| - // A lock is taken before its record is written, so a record without the |
| - // move is only a stale lock once the holder has had time to write it. |
| - if time.Since(held.At) < offlineLockGrace { |
| - return busy |
| - } |
| - other, _, err := s.readRecord(ctx, held.Account, held.App, held.Host) |
| - switch { |
| - case errors.Is(err, os.ErrNotExist): |
| - case err != nil: |
| - log.Printf("console: offline move %s: reading the lock holder %s: %v", d.host, held.Host, err) |
| - return unreadable |
| - case other.Move.Offline() && other.Move.ID == held.Op: |
| - return busy |
| - } |
| - if _, err := s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, etag); err != nil { |
| - if errors.Is(err, release.ErrCASConflict) { |
| - return busy |
| - } |
| - log.Printf("console: offline move %s: replacing a stale lock: %v", d.host, err) |
| - return unreadable |
| - } |
| - return nil |
| -} |
| - |
| -// offlineReleaseLock deletes the lock under the etag it read, when it is |
| -// this move's. A failure is logged and left: the next start finds the lock |
| -// stale (its record no longer carries the move) and replaces it. |
| -func (s *Server) offlineReleaseLock(ctx context.Context, host, id string) { |
| - ctx = context.WithoutCancel(ctx) |
| - raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey) |
| - if errors.Is(err, os.ErrNotExist) { |
| - return |
| - } |
| - var held moves.OfflineActive |
| - if err != nil || json.Unmarshal(raw, &held) != nil { |
| - log.Printf("console: offline move %s: reading the lock to release it: %v", host, err) |
| - return |
| - } |
| - if held.Host != host || held.Op != id { |
| - return |
| - } |
| - if err := release.DeleteIfMatch(ctx, s.cfg.Releases, moves.OfflineActiveKey, etag); err != nil { |
| - log.Printf("console: offline move %s: releasing the lock: %v", host, err) |
| - } |
| -} |
| - |
| // ---- the step each state waits for ---- |
| |
| // The verbs. |