console: a staged start or restage confirms a write whose reply was lost
Fix round 1, minor 3: offlineAdvanceBound, like offlineBegin, reads the record once after a failed bound write; a record carrying this transition's move, state and proposed stamp is the write that landed and answers 202. Anything else keeps today's answers (the 409 for a lost race, the write failure otherwise). The read confirms and never retries.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+24
−0
internal/console/apiinstancemove_offline.go+24 −0
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index 9a775ae7d..57c63db46 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -810,6 +810,17 @@ func (s *Server) offlineAdvanceBound(w http.ResponseWriter, r *http.Request, d o |
| return |
| } |
| rec, err := s.offlineApplyBound(ctx, d, m) |
| + if err != nil { |
| + // A failed write does not prove nothing was written (a commit whose |
| + // reply was lost, surfacing as a timeout or as the 412 of the |
| + // store's own retry): the record carrying this transition's stamp |
| + // is the write that landed. This read confirms; it never retries. |
| + rctx, rcancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) |
| + defer rcancel() |
| + if cur, _, rerr := s.readRecord(rctx, d.acct.Sqid, d.app, d.host); rerr == nil && offlineLanded(cur, &probe, id) { |
| + rec, err = cur, nil |
| + } |
| + } |
| if errors.Is(err, release.ErrCASConflict) { |
| jsonError(w, http.StatusConflict, offlineChangedMsg) |
| return |
| @@ -822,6 +833,19 @@ func (s *Server) offlineAdvanceBound(w http.ResponseWriter, r *http.Request, d o |
| writeJSON(w, http.StatusAccepted, s.offlineViewOf(ctx, rec)) |
| } |
| +// offlineLanded reports whether cur carries the transition that turned the |
| +// observed record into want: the same move, in want's state, at want's |
| +// stamps (requested_at for a staged start, staged_at for a restage). The |
| +// stamp is proposed from the observation and is after every earlier stamp |
| +// on the record (provision.ProposeStamp), so no other write carries it. |
| +func offlineLanded(cur, want *provision.Record, id string) bool { |
| + if cur == nil || cur.Move == nil || want.Move == nil || cur.Move.ID != id || cur.Move.State != want.Move.State { |
| + return false |
| + } |
| + same := func(a, b *time.Time) bool { return (a == nil) == (b == nil) && (a == nil || a.Equal(*b)) } |
| + return cur.Move.RequestedAt.Equal(want.Move.RequestedAt) && same(cur.Move.StagedAt, want.Move.StagedAt) |
| +} |
| + |
| // offlineStartWrite bounds the start door's record write. |
| const offlineStartWrite = 30 * time.Second |