console: the open record is written create-only before the open (adopted when it is this move's, 409 when another's), and the evidence skips it and the return keys
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+223
−1
internal/console/apiinstancemove_offline.go+98 −1internal/console/offlineopen_test.go+98 −0internal/console/offlinereturn.go+27 −0
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go| index c32c38773..5bc8e0b22 100644 |
| --- a/internal/console/apiinstancemove_offline.go |
| +++ b/internal/console/apiinstancemove_offline.go |
| @@ -240,11 +240,17 @@ func (s *Server) readOfflineEvidence(ctx context.Context, host, id string, m *pr |
| return offlineEvidence{}, err |
| } |
| var ev offlineEvidence |
| - sealKey := moves.OfflineSealKey(host, id) |
| + sealKey, openKey := moves.OfflineSealKey(host, id), moves.OpenRecordKey(host, id) |
| for _, key := range keys { |
| if key == sealKey { |
| continue // read below, as the seal and not as a report |
| } |
| + if key == openKey || offlineReturnKey(prefix, key) { |
| + // The open record (moves.OpenRecord) and an operator return's |
| + // requests and reports (offlinereturn.go) sit under the move's |
| + // prefix and are never its reports. |
| + continue |
| + } |
| raw, _, err := s.cfg.Releases.Get(ctx, key) |
| if errors.Is(err, os.ErrNotExist) { |
| continue |
| @@ -1481,10 +1487,101 @@ func (s *Server) offlineVerbDoor(verb string) http.HandlerFunc { |
| writeRefusal(w, ref) |
| return |
| } |
| + if verb == offlineVerbOpen { |
| + // The open record goes first (create-only, or this move's own |
| + // adopted), and the open lands at its stamp. |
| + open, err := s.offlineOpenRecord(ctx, d.rec, mv, ev, provision.ProposeStamp(d.rec, time.Now())) |
| + if errors.Is(err, errOpenRecordInTheWay) { |
| + jsonError(w, http.StatusConflict, msgOpenRecordInTheWay) |
| + return |
| + } |
| + if err != nil { |
| + s.offlineWriteFailed(w, d, err) |
| + return |
| + } |
| + step.mut = provision.OfflineOpenTransition(mv.ID, open.OpenedAt, provision.Receipt{}) |
| + } |
| s.offlineAdvance(w, r, d, step.mut) |
| } |
| } |
| +// msgOpenRecordInTheWay is the open door's refusal when the open record's |
| +// key holds another move's record (self-serve moves plan 2 Task 8). |
| +const msgOpenRecordInTheWay = "another move's open record is in the way" |
| + |
| +// errOpenRecordInTheWay: the move's open record key holds a record that is |
| +// not this move's, of this lifetime, complete. |
| +var errOpenRecordInTheWay = errors.New("console: offline move: another move's open record is in the way") |
| + |
| +// offlineOpenRecord writes the move's open record (moves.OpenRecord) |
| +// create-only at moves.OpenRecordKey before the open transition, with |
| +// OpenedAt = proposed: the operator open door and the self-serve job |
| +// alike. A complete record already there for this move and lifetime is |
| +// adopted (a retry after a crash between the record and the open): its |
| +// OpenedAt is the stamp the open carries. Anything else at the key is |
| +// errOpenRecordInTheWay. A read or write that fails is the error. |
| +func (s *Server) offlineOpenRecord(ctx context.Context, rec *provision.Record, mv *provision.MoveState, ev offlineEvidence, proposed time.Time) (moves.OpenRecord, error) { |
| + key := moves.OpenRecordKey(rec.Host, mv.ID) |
| + if key == "" { |
| + return moves.OpenRecord{}, errOpenRecordInTheWay |
| + } |
| + adopt := func(raw []byte) (moves.OpenRecord, error) { |
| + var o moves.OpenRecord |
| + if err := json.Unmarshal(raw, &o); err != nil || !offlineOpenRecordOf(o, rec, mv) { |
| + return moves.OpenRecord{}, errOpenRecordInTheWay |
| + } |
| + return o, nil |
| + } |
| + raw, _, err := s.cfg.Releases.Get(ctx, key) |
| + switch { |
| + case err == nil: |
| + return adopt(raw) |
| + case !errors.Is(err, os.ErrNotExist): |
| + return moves.OpenRecord{}, err |
| + } |
| + o := moves.OpenRecord{MoveID: mv.ID, Host: rec.Host, Account: rec.Account, App: rec.App, Incarnation: rec.Incarnation, |
| + From: mv.From, To: mv.To, StageBucket: mv.StageBucket, StageRegion: mv.StageRegion, Participants: mv.Participants, |
| + Layout: offlineOpenLayout(ev, mv), OpenedAt: proposed.UTC()} |
| + body, err := json.Marshal(o) |
| + if err != nil { |
| + return moves.OpenRecord{}, err |
| + } |
| + if _, err := s.cfg.Releases.Put(ctx, key, body, ""); err != nil { |
| + if !errors.Is(err, release.ErrCASConflict) { |
| + return moves.OpenRecord{}, err |
| + } |
| + // Created first by another writer: adopted only when it is this |
| + // move's. |
| + raw, _, rerr := s.cfg.Releases.Get(ctx, key) |
| + if rerr != nil { |
| + return moves.OpenRecord{}, rerr |
| + } |
| + return adopt(raw) |
| + } |
| + return o, nil |
| +} |
| + |
| +// offlineOpenRecordOf reports whether o is the complete open record of the |
| +// move mv of rec's lifetime. |
| +func offlineOpenRecordOf(o moves.OpenRecord, rec *provision.Record, mv *provision.MoveState) bool { |
| + return o.MoveID == mv.ID && o.Host == rec.Host && o.Account == rec.Account && o.App == rec.App && |
| + o.Incarnation == rec.Incarnation && o.From == mv.From && o.To == mv.To && !o.OpenedAt.IsZero() |
| +} |
| + |
| +// offlineOpenLayout is the replica's layout the open record names: the |
| +// sealer's stopped report's, else any source's, else flat. |
| +func offlineOpenLayout(ev offlineEvidence, mv *provision.MoveState) string { |
| + if rep, ok := ev.get(moves.OfflineRoleSource, mv.Sealer); ok && rep.Layout != "" { |
| + return rep.Layout |
| + } |
| + for _, rep := range ev.byRole(moves.OfflineRoleSource) { |
| + if rep.Layout != "" { |
| + return rep.Layout |
| + } |
| + } |
| + return moves.LayoutFlat |
| +} |
| + |
| func (s *Server) handleAPIInstanceOfflineAbort(w http.ResponseWriter, r *http.Request) { |
| d, ok := s.offlineGate(w, r) |
| if !ok { |
diff --git a/internal/console/offlineopen_test.go b/internal/console/offlineopen_test.go| new file mode 100644 |
| index 000000000..7aa4929a8 |
| --- /dev/null |
| +++ b/internal/console/offlineopen_test.go |
| @@ -0,0 +1,98 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// TestOfflineOpenDoorWritesTheOpenRecordFirst: the operator open door |
| +// writes the move's open record create-only before the open transition, |
| +// and the open lands at its OpenedAt; a record of this move already there |
| +// (a retry after a crash between the two) is adopted, its stamp the open's; |
| +// another move's record at the key is the 409, and nothing is written. |
| +func TestOfflineOpenDoorWritesTheOpenRecordFirst(t *testing.T) { |
| + ctx := context.Background() |
| + readOpen := func(t *testing.T, w *offlineWorld) (moves.OpenRecord, bool) { |
| + t.Helper() |
| + raw, _, err := w.fx.rel.Get(ctx, moves.OpenRecordKey(offlineHost, w.id)) |
| + if err != nil { |
| + return moves.OpenRecord{}, false |
| + } |
| + var o moves.OpenRecord |
| + if err := json.Unmarshal(raw, &o); err != nil { |
| + t.Fatal(err) |
| + } |
| + return o, true |
| + } |
| + t.Run("fresh", func(t *testing.T) { |
| + w := newOfflineWorld(t, oneEach...) |
| + w.toLanded() |
| + w.ok("/open", "", provision.OfflineOpen) |
| + o, ok := readOpen(t, w) |
| + rec := w.record() |
| + switch { |
| + case !ok: |
| + t.Fatal("no open record") |
| + case o.MoveID != w.id || o.Host != offlineHost || o.Incarnation != rec.Incarnation || o.From != "ie" || o.To != "uk" || o.Layout == "": |
| + t.Fatalf("open record %+v", o) |
| + case rec.Move.OpenedAt == nil || !rec.Move.OpenedAt.Equal(o.OpenedAt): |
| + t.Fatalf("opened_at %v, open record %v", rec.Move.OpenedAt, o.OpenedAt) |
| + } |
| + }) |
| + t.Run("this move's record is adopted", func(t *testing.T) { |
| + w := newOfflineWorld(t, oneEach...) |
| + w.toLanded() |
| + rec := w.record() |
| + at := time.Now().Add(-time.Minute).UTC().Truncate(time.Microsecond) |
| + o := moves.OpenRecord{MoveID: w.id, Host: offlineHost, Account: rec.Account, App: rec.App, Incarnation: rec.Incarnation, |
| + From: "ie", To: "uk", Layout: moves.LayoutFlat, OpenedAt: at} |
| + body, _ := json.Marshal(o) |
| + offlinePut(t, w.fx.rel, moves.OpenRecordKey(offlineHost, w.id), body) |
| + w.ok("/open", "", provision.OfflineOpen) |
| + if got := w.record(); got.Move.OpenedAt == nil || !got.Move.OpenedAt.Equal(at) { |
| + t.Fatalf("opened_at %v, want the adopted %v", got.Move.OpenedAt, at) |
| + } |
| + }) |
| + t.Run("another move's record is in the way", func(t *testing.T) { |
| + w := newOfflineWorld(t, oneEach...) |
| + w.toLanded() |
| + rec := w.record() |
| + o := moves.OpenRecord{MoveID: "mv_other", Host: offlineHost, Account: rec.Account, App: rec.App, Incarnation: rec.Incarnation, |
| + From: "ie", To: "uk", OpenedAt: time.Now()} |
| + body, _ := json.Marshal(o) |
| + offlinePut(t, w.fx.rel, moves.OpenRecordKey(offlineHost, w.id), body) |
| + before := w.record().RecordSeq |
| + w.refused(http.MethodPost, "/open", "", http.StatusConflict, "another move's open record is in the way") |
| + if got := w.record(); got.Move.State != provision.OfflineLanded || got.RecordSeq != before { |
| + t.Fatalf("the record moved: %+v seq %d (was %d)", got.Move, got.RecordSeq, before) |
| + } |
| + }) |
| + t.Run("the evidence skips the open record and the return keys", func(t *testing.T) { |
| + w := newOfflineWorld(t, oneEach...) |
| + w.toLanded() |
| + w.ok("/open", "", provision.OfflineOpen) |
| + prefix := moves.OfflineReportPrefix(offlineHost, w.id) |
| + offlinePut(t, w.fx.rel, prefix+"return/1-c-00000000.json", []byte(`{}`)) |
| + offlinePut(t, w.fx.rel, prefix+"reports/return-ready-b-1-c-00000000.json", []byte(`{}`)) |
| + for _, key := range []string{prefix + "open.json", prefix + "return/1-c-00000000.json", prefix + "reports/return-ready-b-1-c-00000000.json"} { |
| + if !offlineReturnKey(prefix, key) && key != moves.OpenRecordKey(offlineHost, w.id) { |
| + t.Fatalf("%s is not skipped", key) |
| + } |
| + } |
| + ev, err := w.fx.srv.readOfflineEvidence(ctx, offlineHost, w.id, w.record().Move) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, r := range ev.reports { |
| + if r.Role == "" { |
| + t.Fatalf("a non-report was read as a report: %+v", r) |
| + } |
| + } |
| + }) |
| +} |
diff --git a/internal/console/offlinereturn.go b/internal/console/offlinereturn.go| new file mode 100644 |
| index 000000000..cf835ab38 |
| --- /dev/null |
| +++ b/internal/console/offlinereturn.go |
| @@ -0,0 +1,27 @@ |
| +package console |
| + |
| +// The operator's return (self-serve moves plan 2 Task 8; spec §7.5): the |
| +// offline move the other way, run with the operator doors, onto the box a |
| +// completed forward move left its leftovers on. The handshake: |
| +// |
| +// 1. POST .../move/offline/return {"region", "return_of"} (operator only) |
| +// checks the forward's open record against the record and writes a |
| +// return request, create-only, under the forward's prefix; |
| +// 2. the moves pass of every box in that region that was a source of the |
| +// forward reads the newest request and writes an immutable |
| +// return-ready report for it, its leftovers listed against the open |
| +// record; |
| +// 3. the operator's stage (or start) door with return_of reads the report |
| +// of the current request from the return's selected target and |
| +// accepts exactly the forward's leftovers (targetReadyDecision in |
| +// return mode). |
| + |
| +import "strings" |
| + |
| +// offlineReturnKey reports whether key, under a move's report prefix, is |
| +// one of an operator return's requests (return/) or reports (reports/): |
| +// never one of the move's own reports. |
| +func offlineReturnKey(prefix, key string) bool { |
| + rest, ok := strings.CutPrefix(key, prefix) |
| + return ok && (strings.HasPrefix(rest, "return/") || strings.HasPrefix(rest, "reports/")) |
| +} |