carlos / platform Public

console: reserveBoxes, releaseBoxes and the fenced reclaim

offlinereserve.go: offlineSelection (Task 5's shape; operator selections
are sentinel selections until Task 5's pinning), reserveBoxes in the
brief's six steps (read, validate, the complete own retry, the switch-off
deployment-wide rule, held keys and the sentinel, one conditional Put with
the projection, one re-read on a lost race), releaseBoxes (removes one
operation's entries, deletes the object when empty), and the reclaim rule:
a move older than the grace period is reclaimed only after a judged read
that does not show it, a fence of its record under the holder's app lock
then hostname exclusion, admitted, whose observation carries no such move,
and (when rows exist) its self-serve row terminal and released and no
request issued for the host. Row and cleanup evidence come through narrow
interfaces on the Server; nil is no rows and no settled cleanup.
Self-serve moves plan 2, Task 3.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b7fe0e6d994c09c8da5fc0b99fec2fb92dbdf5fe parent 51ee1ba
2 files changed, +443 −0
  • internal/console/offlinereserve.go +438 −0
  • internal/console/server.go +5 −0
diff --git a/internal/console/offlinereserve.go b/internal/console/offlinereserve.go
new file mode 100644
index 000000000..1a8ee33aa
--- /dev/null
+++ b/internal/console/offlinereserve.go
@@ -0,0 +1,438 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "sort"
+ "time"
+
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// Per-box reservations (self-serve moves plan 2 Task 3; spec §7.1).
+// control/offline-moves/active.json is a table of reservations keyed by box
+// id, one entry per participating box of every operation from its
+// reservation to its release (moves.OfflineActive). An operation reserves
+// before it writes its own durable state (the record's first move state),
+// and holds the reservation until the move clears. The legacy fields are
+// the table's projection, so a console that predates the table still reads
+// one deployment-wide lock.
+//
+// Rollout and rollback. An old console cannot be reintroduced while any
+// reservation exists that the old console did not create: its release
+// deletes the whole object when the projection names its own move
+// (TestOldConsoleReleaseErasesATable). The supported sequence is roll the
+// consoles, then turn the switch on; rollback is switch off, then wait until
+// every incompatible operation ends (every self-serve row terminal and
+// released, every cleanup reservation gone, every operator reservation
+// created under the table form released), then the old console.
+
+// offlineSelection is a move's participants as the door or the job chose
+// them (Task 5's offlineRegionChecks produces it for self-serve; the
+// operator start and stage doors build one from offlineSelect's fleet
+// today, offlineOperatorSelection). Sentinel is set when any participant's
+// InstanceID is empty: such a selection reserves every box.
+type offlineSelection struct {
+ From, To string
+ Fleet offlineFleet
+ Source []provision.Participant
+ Target provision.Participant
+ Stage provision.StageTarget
+ ReturnOf string
+ Sentinel bool
+}
+
+// reservationKeys are the keys the selection reserves: every participant's
+// BoxID, or the sentinel alone for a sentinel selection. A participant
+// without an InstanceID or a BoxID makes it a sentinel selection whatever
+// Sentinel says, so a box nobody can name is never left unreserved.
+func (sel offlineSelection) reservationKeys() []string {
+ sentinel := []string{moves.OfflineSentinel}
+ if sel.Sentinel {
+ return sentinel
+ }
+ set := map[string]bool{}
+ for _, p := range append(append([]provision.Participant(nil), sel.Source...), sel.Target) {
+ if p.BoxID == "" || p.InstanceID == "" {
+ return sentinel
+ }
+ set[p.BoxID] = true
+ }
+ keys := make([]string, 0, len(set))
+ for k := range set {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// offlineOperatorSelection is the operator start and stage doors'
+// selection: the source region's boxes and the one target box, as
+// offlineSelect found them, labelled and named by the box id each edges file
+// publishes. The operator path pins no InstanceID until Task 5's pinning
+// lands, so every operator selection is a sentinel selection: one operator
+// move at a time across the deployment, as before the table.
+func offlineOperatorSelection(f offlineFleet, from, to string, st provision.StageTarget) offlineSelection {
+ sel := offlineSelection{From: from, To: to, Fleet: f, Stage: st}
+ for _, b := range f.boxesAt(from) {
+ sel.Source = append(sel.Source, provision.Participant{BoxID: b.BoxID, Label: b.Box})
+ }
+ if tgt := f.boxesAt(to); len(tgt) > 0 {
+ sel.Target = provision.Participant{BoxID: tgt[0].BoxID, Label: tgt[0].Box}
+ }
+ sel.Sentinel = true
+ return sel
+}
+
+// selfServeRowEvidence is what reclaim rules (c) and (d) read from the
+// self-serve rows (Task 4's tables): MoveTerminalOrAbsent is true when no
+// row has the move id, or its row is terminal with release_pending 0;
+// HostHasIssuedRequest is true when a row for the host has request_issued
+// set. A nil Server.selfServeRows means no rows exist, so a reservation is
+// reclaimed on rules (a) and (b) alone, as an operator reservation is.
+type selfServeRowEvidence interface {
+ MoveTerminalOrAbsent(id string) (bool, error)
+ HostHasIssuedRequest(acct, app, host string) (bool, error)
+}
+
+// cleanupReportEvidence is what a cleanup reservation's reclaim reads from
+// its owning box's status report (plan 4): CleanupSettled is true when
+// status/boxes/<label>.json, written within two status intervals by the box
+// whose Metrics.BoxID is boxID, lists id terminal with no root request and
+// no release pending, or carries a complete Retirements block that does not
+// list it. Neither the report fields nor their reader exist before plan 4
+// and Task 6, so a nil Server.cleanupReports means no status can settle a
+// cleanup: a cleanup reservation is never reclaimed until then.
+type cleanupReportEvidence interface {
+ CleanupSettled(ctx context.Context, boxID, id string) (bool, error)
+}
+
+// The refusals. busy and unreadable are the sentences the one-move lock
+// answered; mismatch is the job's (it aborts on it).
+func offlineBusy(host string) *offlineRefusal {
+ return &offlineRefusal{status: http.StatusConflict, msg: fmt.Sprintf("%s is moving offline. move one instance at a time", host)}
+}
+
+var (
+ offlineReserveUnreadable = &offlineRefusal{status: http.StatusBadGateway, msg: "could not read the instance"}
+ offlineReserveMismatch = &offlineRefusal{status: http.StatusConflict, msg: "the reservation does not match this move's participants"}
+)
+
+// offlineOp is one operation's identity in the table.
+type offlineOp struct{ kind, id string }
+
+func opOf(e moves.OfflineReservation) offlineOp { return offlineOp{e.Kind, e.ID} }
+
+// reserveBoxes reserves sel's boxes for r's operation, in this order:
+//
+// 1. read active.json (absent is the empty table; a legacy lock is the
+// sentinel entry, moves.ParseOfflineActive, normalised into the table
+// on the first write);
+// 2. validate the whole object: any malformed entry is unreadable;
+// 3. the own retry: entries with r's (Kind, ID) are accepted when they
+// cover exactly sel's keys (nothing is written and their reserved_at
+// stays); a strict subset is a partial reservation whose missing keys
+// are acquired below in the same write; any key outside sel's is the
+// mismatch refusal;
+// 4. with the switch off (cfg.SelfServeMoves != "offline") every entry of
+// another operation conflicts: one operation at a time, today's
+// deployment-wide semantics;
+// 5. otherwise an entry of another operation conflicts when it holds a
+// key sel needs, holds the sentinel, or sel is the sentinel;
+// a conflicting operation is busy unless it is reclaimable
+// (offlineReclaimable), and a reclaimed operation loses all its entries;
+// 6. one conditional Put of the table with every missing key and the
+// projection. A lost race re-reads once and starts again at 2, then
+// answers busy.
+//
+// The caller holds no app or hostname lock: a reclaim takes the holder's
+// (s.lockApp, then withHostname) around its fence only.
+func (s *Server) reserveBoxes(ctx context.Context, sel offlineSelection, r moves.OfflineReservation) *offlineRefusal {
+ if r.ReservedAt.IsZero() {
+ r.ReservedAt = time.Now().UTC()
+ }
+ want := sel.reservationKeys()
+ wanted := map[string]bool{}
+ for _, k := range want {
+ wanted[k] = true
+ }
+ mine := opOf(r)
+ var lastHolder string
+ for attempt := 0; attempt < 2; attempt++ {
+ raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey)
+ table := map[string]moves.OfflineReservation{}
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ case err != nil:
+ log.Printf("console: offline move %s: reading the reservations: %v", r.Host, err)
+ return offlineReserveUnreadable
+ default:
+ if table, err = moves.ParseOfflineActive(raw); err != nil {
+ log.Printf("console: offline move %s: %v", r.Host, err)
+ return offlineReserveUnreadable
+ }
+ }
+ if p := moves.OfflineActiveBody(table); p.Host != "" {
+ lastHolder = p.Host
+ }
+
+ // (3) The own retry.
+ own := map[string]moves.OfflineReservation{}
+ for k, e := range table {
+ if opOf(e) == mine {
+ if !wanted[k] {
+ return offlineReserveMismatch
+ }
+ own[k] = e
+ }
+ }
+ if len(own) == len(want) {
+ return nil
+ }
+ entry := r
+ for _, e := range own {
+ // A partial reservation completes at its own reserved_at.
+ entry = e
+ break
+ }
+
+ // (4) and (5) The operations in the way.
+ sentinelSel := len(want) == 1 && want[0] == moves.OfflineSentinel
+ conflicts := map[offlineOp]map[string]moves.OfflineReservation{}
+ for k, e := range table {
+ op := opOf(e)
+ if op == mine {
+ continue
+ }
+ if s.cfg.SelfServeMoves != "offline" || sentinelSel || k == moves.OfflineSentinel || wanted[k] {
+ if conflicts[op] == nil {
+ conflicts[op] = map[string]moves.OfflineReservation{}
+ }
+ conflicts[op][k] = e
+ }
+ }
+ if ref := s.offlineInTheWay(ctx, conflicts); ref != nil {
+ return ref
+ }
+
+ // (6) One write.
+ next := map[string]moves.OfflineReservation{}
+ for k, e := range table {
+ if conflicts[opOf(e)] == nil {
+ next[k] = e
+ }
+ }
+ for _, k := range want {
+ if _, ok := next[k]; !ok {
+ next[k] = entry
+ }
+ }
+ body, err := json.Marshal(moves.OfflineActiveBody(next))
+ if err != nil {
+ return offlineReserveUnreadable
+ }
+ _, err = s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, etag)
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ log.Printf("console: offline move %s: writing the reservations: %v", r.Host, err)
+ return offlineReserveUnreadable
+ }
+ }
+ if lastHolder == "" {
+ return offlineReserveUnreadable
+ }
+ return offlineBusy(lastHolder)
+}
+
+// offlineInTheWay answers busy, naming the oldest holder, unless every
+// conflicting operation is reclaimable. A young operation is busy without
+// any reclaim being attempted, so nothing is fenced while one is.
+func (s *Server) offlineInTheWay(ctx context.Context, conflicts map[offlineOp]map[string]moves.OfflineReservation) *offlineRefusal {
+ type held struct {
+ op offlineOp
+ entries map[string]moves.OfflineReservation
+ first moves.OfflineReservation
+ }
+ var ops []held
+ for op, entries := range conflicts {
+ var first moves.OfflineReservation
+ for _, e := range entries {
+ if first.ReservedAt.IsZero() || e.ReservedAt.Before(first.ReservedAt) {
+ first = e
+ }
+ }
+ ops = append(ops, held{op, entries, first})
+ }
+ sort.Slice(ops, func(i, j int) bool {
+ a, b := ops[i].first, ops[j].first
+ if !a.ReservedAt.Equal(b.ReservedAt) {
+ return a.ReservedAt.Before(b.ReservedAt)
+ }
+ return ops[i].op.kind+"/"+ops[i].op.id < ops[j].op.kind+"/"+ops[j].op.id
+ })
+ for _, h := range ops {
+ if time.Since(h.first.ReservedAt) < offlineLockGrace {
+ return offlineBusy(h.first.Host)
+ }
+ }
+ for _, h := range ops {
+ if !s.offlineReclaimable(ctx, h.entries) {
+ return offlineBusy(h.first.Host)
+ }
+ }
+ return nil
+}
+
+// offlineReclaimable is the reclaim rule for one operation's entries, all
+// older than offlineLockGrace (the caller checked). Positive evidence only:
+// any read failure, malformed body, refusal or doubt keeps the reservation.
+//
+// A move: (a) the holder's record is fenced (fenceRecord, under the
+// holder's s.lockApp then withHostname, and admitted) and the fence answers
+// fenced or absent; (b) the fenced observation carries no move with the id;
+// (c) the move's self-serve row is absent or terminal with release_pending
+// 0; (d) no row for the host has request_issued set. Before the fence a
+// plain judged read that already shows the move keeps the reservation with
+// nothing written, so a live move is never fenced by a busy check. The fence
+// is what makes the negative read conclusive: a delayed queue or operator
+// start write that commits before it is in the fenced observation, and one
+// that commits after it fails its CAS (the fence advanced record_seq and the
+// ETag), so a paused door that resumes after a reclaim cannot write.
+//
+// A cleanup: the owning box's status settles it (cleanupReportEvidence).
+func (s *Server) offlineReclaimable(ctx context.Context, entries map[string]moves.OfflineReservation) bool {
+ for key, e := range entries {
+ switch e.Kind {
+ case moves.OfflineKindMove:
+ return s.offlineMoveReclaimable(ctx, e)
+ case moves.OfflineKindCleanup:
+ if s.cleanupReports == nil {
+ return false
+ }
+ ok, err := s.cleanupReports.CleanupSettled(ctx, key, e.ID)
+ if err != nil {
+ log.Printf("console: offline move: cleanup %s on %s: status: %v (kept)", e.ID, key, err)
+ return false
+ }
+ if !ok {
+ return false
+ }
+ default:
+ return false
+ }
+ }
+ return len(entries) > 0
+}
+
+func (s *Server) offlineMoveReclaimable(ctx context.Context, e moves.OfflineReservation) bool {
+ carries := func(rec *provision.Record) bool { return rec != nil && rec.Move != nil && rec.Move.ID == e.ID }
+ pre := s.observeRecord(ctx, e.Account, e.App, e.Host)
+ switch {
+ case pre.Verdict == provision.LifetimeUnreadable, pre.Verdict == provision.LifetimeCorrupt:
+ log.Printf("console: offline move: reservation %s of %s: the record could not be read (kept): %v", e.ID, e.Host, pre.Err)
+ return false
+ case pre.Present && carries(pre.Rec):
+ return false
+ }
+ // LOCK ORDER: the holder's app lock, then its hostname exclusion (the
+ // lock table in lifetimes.go), held around the fence only. reserveBoxes'
+ // caller holds neither.
+ var (
+ res fenceResult
+ obs provision.Observation
+ ferr error
+ )
+ func() {
+ defer s.lockAppHost(e.Account, e.App, e.Host)()
+ if ferr = s.admit(e.Host, ""); ferr != nil {
+ return
+ }
+ res, obs, ferr = s.fenceRecord(ctx, e.Account, e.App, e.Host, fenceOwner{})
+ }()
+ if ferr != nil {
+ log.Printf("console: offline move: reservation %s of %s: not fenced (kept): %v", e.ID, e.Host, ferr)
+ return false
+ }
+ switch {
+ case res == fenceAbsent:
+ case res == fenced && obs.Rec != nil && obs.Verdict != provision.LifetimeCorrupt && !carries(obs.Rec):
+ default:
+ return false
+ }
+ if s.selfServeRows == nil {
+ return true
+ }
+ done, err := s.selfServeRows.MoveTerminalOrAbsent(e.ID)
+ if err != nil || !done {
+ if err != nil {
+ log.Printf("console: offline move: reservation %s: its row: %v (kept)", e.ID, err)
+ }
+ return false
+ }
+ issued, err := s.selfServeRows.HostHasIssuedRequest(e.Account, e.App, e.Host)
+ if err != nil {
+ log.Printf("console: offline move: reservation %s: the rows of %s: %v (kept)", e.ID, e.Host, err)
+ return false
+ }
+ return !issued
+}
+
+// releaseBoxes removes every entry of the (kind, id) operation and rewrites
+// the projection, or deletes the object when nothing is left, each under the
+// ETag it read (a lost race reads again, three rounds). Absence, of the
+// object or of the operation's entries, is success; a malformed table is an
+// error and is left as it is.
+func (s *Server) releaseBoxes(ctx context.Context, kind, id string) error {
+ op := offlineOp{kind, id}
+ for round := 0; round < 3; round++ {
+ raw, etag, err := s.cfg.Releases.Get(ctx, moves.OfflineActiveKey)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("console: releasing %s %s: %w", kind, id, err)
+ }
+ table, err := moves.ParseOfflineActive(raw)
+ if err != nil {
+ return fmt.Errorf("console: releasing %s %s: %w", kind, id, err)
+ }
+ next := map[string]moves.OfflineReservation{}
+ for k, e := range table {
+ if opOf(e) != op {
+ next[k] = e
+ }
+ }
+ if len(next) == len(table) {
+ return nil
+ }
+ if len(next) == 0 {
+ err = release.DeleteIfMatch(ctx, s.cfg.Releases, moves.OfflineActiveKey, etag)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ } else {
+ var body []byte
+ if body, err = json.Marshal(moves.OfflineActiveBody(next)); err != nil {
+ return err
+ }
+ _, err = s.cfg.Releases.Put(ctx, moves.OfflineActiveKey, body, etag)
+ }
+ if err == nil {
+ return nil
+ }
+ if !errors.Is(err, release.ErrCASConflict) {
+ return fmt.Errorf("console: releasing %s %s: %w", kind, id, err)
+ }
+ }
+ return fmt.Errorf("console: releasing %s %s: lost 3 rounds", kind, id)
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index a5e63fb5b..d159aca2b 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -881,6 +881,11 @@ type Server struct {
judge *provision.LifetimeJudge
cutover *cutoverGate
appsMaterialized atomic.Bool
+ // The reclaim rule's evidence beyond the record (offlinereserve.go):
+ // the self-serve rows (Task 4 sets it; nil is no rows) and the boxes'
+ // cleanup reports (plan 4; nil settles no cleanup).
+ selfServeRows selfServeRowEvidence
+ cleanupReports cleanupReportEvidence
// The console's move pass (movesrecovery.go): its clock (nil is
// time.Now; a TEST SEAM) and when each close was first seen due.
movesClock func() time.Time