carlos / platform Public

moves, host: the return request and report keys, and the box's return step: a forward source answers the current request with an immutable report of its leftovers

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev b4d6d7a83a4fe552a548ee06703512b12403df16 parent 4bd6dea
4 files changed, +379 −0
  • internal/host/e2e_bridge.go +6 −0
  • internal/host/movesoffline_return.go +257 −0
  • internal/host/movespass.go +9 −0
  • internal/moves/offline.go +107 −0
diff --git a/internal/host/e2e_bridge.go b/internal/host/e2e_bridge.go
index ba21f4c12..f681f2447 100644
--- a/internal/host/e2e_bridge.go
+++ b/internal/host/e2e_bridge.go
@@ -1074,6 +1074,9 @@ func NewE2E(t testing.TB, store release.Store, opts ...E2EOption) *E2E {
}
b.env[selfServeMovesEnv] = "offline"
b.env[moveLanesEnv] = lane
+ // Both boxes resolve channels, so either can report the facts
+ // of a box a move (or an operator's return) lands on.
+ b.env["CARLOS_RELEASE_PUBKEY"] = e.keys.pubB64
id := "i-e2e-" + b.name
b.instanceID.Store(&id)
armed := true
@@ -1101,6 +1104,9 @@ func (e *E2E) rearm(x *e2eBox) {
e.lw.rearm[x.b]()
label := x.b.name
c := &x.b.w.cfg
+ // The operator return's scan runs every pass here (production: every
+ // minute), so a witness's request is answered on the next pass.
+ c.ReturnScanEvery = -1
c.stageFault = func(point string) error {
if point == "round-start" {
e.fault(FaultCopy + label)
diff --git a/internal/host/movesoffline_return.go b/internal/host/movesoffline_return.go
new file mode 100644
index 000000000..e6430899d
--- /dev/null
+++ b/internal/host/movesoffline_return.go
@@ -0,0 +1,257 @@
+package host
+
+// The operator's return, the box's half (self-serve moves plan 2 Task 8;
+// spec §7.5). A box that was a source of a completed forward move answers
+// each return request with an immutable return-ready report of what the
+// forward left on it: the console's stage or start door accepts exactly
+// that set (internal/console offlinereturn.go).
+//
+// The scan: every offlineReturnScanEvery the box lists the forward moves'
+// prefixes (control/offline-moves/<host>/<id>/) and reads each open record
+// once (cached: an open record is immutable), keeping those it may answer
+// for (moves.ReturnParticipant: a forward source participant by box id,
+// and by instance id when one was pinned; without participants a box of
+// the forward's source region). Every pass it lists the requests of those
+// it keeps, takes the current one (moves.CurrentReturnRequest) and, when no
+// report of this box answers it yet, writes one create-only. A create-only
+// conflict is the request answered already; a newer request gets its own
+// report. Nothing is overwritten, so no ordering between writers matters.
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/layout"
+ "github.com/carlosframework/platform/internal/lease"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/regionname"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// offlineReturnScanEvery is how often the box lists the forward moves'
+// prefixes for open records it may answer returns of (moveCfg.
+// ReturnScanEvery overrides it; negative is every pass).
+const offlineReturnScanEvery = time.Minute
+
+// offlineMovesRoot is the prefix every offline move's reports live under.
+const offlineMovesRoot = "control/offline-moves/"
+
+// returnState is the worker's memory of the scan, under pass.
+type returnState struct {
+ scanned time.Time
+ // opens is every open record read, by "<host>/<id>"; nil value: no
+ // open record there (read again on the next scan).
+ opens map[string]*moves.OpenRecord
+}
+
+// returnScanDue reports whether the forward prefixes are listed this pass.
+func (w *movesWorker) returnScanDue(now time.Time) bool {
+ every := w.cfg.ReturnScanEvery
+ if every == 0 {
+ every = offlineReturnScanEvery
+ }
+ return every < 0 || w.returns.scanned.IsZero() || now.Sub(w.returns.scanned) >= every
+}
+
+// mayAnswer reports whether this box may answer returns of the forward o.
+func (w *movesWorker) mayAnswer(o *moves.OpenRecord) bool {
+ cfg := w.cfg
+ if o.Participants != nil {
+ return moves.ReturnParticipant(*o, cfg.BoxID, cfg.instance(), "")
+ }
+ here := cfg.parkRegion()
+ return here != "" && regionname.PlacementMatches(cfg.ParkHomeRegion, o.From, here)
+}
+
+// offlineReturns is the pass's return step: errors are logged and the next
+// pass tries again.
+func (w *movesWorker) offlineReturns(ctx context.Context, s release.Store) {
+ cfg := w.cfg
+ if cfg.Offline == nil || cfg.BoxID == "" || cfg.OwnBucket == "" {
+ return
+ }
+ now := cfg.now()
+ if w.returns.opens == nil {
+ w.returns.opens = map[string]*moves.OpenRecord{}
+ }
+ if w.returnScanDue(now) {
+ if err := w.scanReturns(ctx, s); err != nil {
+ w.notice("#offline-returns", "moves: the return scan: %v", err)
+ return
+ }
+ w.returns.scanned = now
+ }
+ keys := make([]string, 0, len(w.returns.opens))
+ for k, o := range w.returns.opens {
+ if o != nil && w.mayAnswer(o) {
+ keys = append(keys, k)
+ }
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ if err := w.answerReturn(ctx, s, w.returns.opens[k]); err != nil {
+ w.notice(k+"#offline-return", "moves: %s: the return of %s: %v", w.returns.opens[k].Host, w.returns.opens[k].MoveID, err)
+ }
+ }
+}
+
+// scanReturns lists the forward prefixes and reads the open records not
+// read yet.
+func (w *movesWorker) scanReturns(ctx context.Context, s release.Store) error {
+ hosts, err := s.ListDirs(ctx, offlineMovesRoot)
+ if err != nil {
+ return err
+ }
+ for _, hp := range hosts {
+ host := strings.TrimSuffix(strings.TrimPrefix(hp, offlineMovesRoot), "/")
+ if !provision.ValidHostShape(host) {
+ continue
+ }
+ ids, err := s.ListDirs(ctx, hp)
+ if err != nil {
+ return err
+ }
+ for _, ip := range ids {
+ id := strings.TrimSuffix(strings.TrimPrefix(ip, hp), "/")
+ k := host + "/" + id
+ if w.returns.opens[k] != nil {
+ continue
+ }
+ key := moves.OpenRecordKey(host, id)
+ if key == "" {
+ continue
+ }
+ raw, _, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return err
+ }
+ var o moves.OpenRecord
+ if json.Unmarshal(raw, &o) != nil || o.MoveID != id || o.Host != host || o.OpenedAt.IsZero() {
+ continue
+ }
+ w.returns.opens[k] = &o
+ }
+ }
+ return nil
+}
+
+// answerReturn writes this box's report for the current request of the
+// forward o, once.
+func (w *movesWorker) answerReturn(ctx context.Context, s release.Store, o *moves.OpenRecord) error {
+ cfg := w.cfg
+ prefix := moves.ReturnRequestPrefix(o.Host, o.MoveID)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return err
+ }
+ var reqs []moves.ReturnRequest
+ for _, k := range keys {
+ raw, _, err := s.Get(ctx, k)
+ if err != nil {
+ continue
+ }
+ if rq, err := moves.ParseReturnRequest(raw, o.Host, o.MoveID, k); err == nil {
+ reqs = append(reqs, rq)
+ }
+ }
+ cur, ok := moves.CurrentReturnRequest(reqs)
+ if !ok {
+ return nil
+ }
+ key := moves.ReturnReportKey(o.Host, o.MoveID, cfg.BoxID, cur.Key)
+ if key == "" {
+ return fmt.Errorf("no report key for request %s", cur.Key)
+ }
+ if _, _, err := s.Get(ctx, key); err == nil {
+ return nil // answered
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ rec, err := provision.GetRecord(ctx, s, o.Account, o.App, o.Host)
+ if err != nil {
+ return fmt.Errorf("the record: %w", err)
+ }
+ // The ready facts as for a move of this host onto this box, the
+ // forward's id naming the park directory it left.
+ ret := *rec
+ ret.Move = &provision.MoveState{Kind: provision.MoveKindOffline, ID: o.MoveID, From: o.To, To: o.From}
+ ready, err := w.offlineTargetReady(ctx, s, ret)
+ if err != nil {
+ return err
+ }
+ lo, err := w.returnLeftovers(ctx, o, ready)
+ if err != nil {
+ return err
+ }
+ ready.Leftovers = &lo
+ rep := moves.OfflineReport{V: moves.OfflineReportVersion, Op: o.MoveID, Host: o.Host, Box: cfg.Box, Role: moves.OfflineRoleTarget,
+ Phase: moves.OfflinePhaseReady, At: cfg.now().UTC(), Request: cur.RequestedAt, RequestKey: cur.Key, Ready: &ready,
+ BoxID: cfg.BoxID, InstanceID: cfg.instance()}
+ body, err := json.Marshal(rep)
+ if err != nil {
+ return err
+ }
+ if _, err := s.Put(ctx, key, body, ""); err != nil {
+ if errors.Is(err, release.ErrCASConflict) {
+ return nil // answered by an earlier pass
+ }
+ return err
+ }
+ log.Printf("moves: %s: reported the leftovers of move %s for return request %s", o.Host, o.MoveID, cur.Key)
+ return nil
+}
+
+// returnLeftovers is what the forward o left on this box: the source's
+// lock, its park directories, the nested directories left empty, and the
+// replica's objects written since the forward opened.
+func (w *movesWorker) returnLeftovers(ctx context.Context, o *moves.OpenRecord, ready moves.TargetReadyReport) (moves.ReturnLeftovers, error) {
+ cfg := w.cfg
+ lo := moves.ReturnLeftovers{ForwardID: o.MoveID, Layout: o.Layout, ParkDirs: []string{}, EmptyNested: []string{}}
+ st, err := w.offlineLock(ctx, o.Host)
+ if err != nil {
+ return lo, err
+ }
+ lo.LockOwner, lo.LockLifted = st.owner, lease.IsLiftedTombstone(st.owner, st.expires)
+ parkRoot := filepath.Join(cfg.DataDir, ".offline-moves", o.Host)
+ ids, err := os.ReadDir(parkRoot)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return lo, err
+ }
+ for _, id := range ids {
+ if !id.IsDir() {
+ lo.ParkDirs = append(lo.ParkDirs, id.Name())
+ continue
+ }
+ roles, err := os.ReadDir(filepath.Join(parkRoot, id.Name()))
+ if err != nil {
+ return lo, err
+ }
+ for _, r := range roles {
+ lo.ParkDirs = append(lo.ParkDirs, id.Name()+"/"+r.Name()+"/")
+ }
+ }
+ for _, p := range []string{provision.InstanceDir(cfg.DataDir, o.Host), layout.RawInstanceDir(cfg.DataDir, o.Host)} {
+ if entries, err := os.ReadDir(p); err == nil && len(entries) == 0 {
+ lo.EmptyNested = append(lo.EmptyNested, p)
+ }
+ }
+ for k, obj := range ready.InstancesPrefix.Objects {
+ if !obj.LastModified.Before(o.OpenedAt) {
+ lo.NewerObjects = append(lo.NewerObjects, k)
+ }
+ }
+ sort.Strings(lo.NewerObjects)
+ return lo, nil
+}
diff --git a/internal/host/movespass.go b/internal/host/movespass.go
index e23d9ae17..601a5f021 100644
--- a/internal/host/movespass.go
+++ b/internal/host/movespass.go
@@ -119,6 +119,9 @@ type movesWorker struct {
books map[string]*stageBook
replaced sync.Map // move id → true
replicas *replicaListing
+ // returns is the operator return's scan (movesoffline_return.go),
+ // under pass.
+ returns returnState
}
// staging is the memoized staging writer for a target bucket region.
@@ -1057,6 +1060,10 @@ type moveCfg struct {
// copies, before its report, an error being a crash there. Nil in
// production.
stageFault func(point string) error
+ // ReturnScanEvery is how often the moves pass lists the forward
+ // moves' prefixes for returns to answer (movesoffline_return.go): zero
+ // is offlineReturnScanEvery, negative every pass.
+ ReturnScanEvery time.Duration
// FenceAs installs, or meets again, an owned activator fence
// (activator.Service.FenceAndStopAs under a 2 s deadline,
// ErrStopUnproven counted as installed: fenceAsWith); LiftFenceAs lifts
@@ -1353,6 +1360,8 @@ func movesPass(ctx context.Context, s release.Store, w *movesWorker) error {
w.retirePageTombstones(recs)
}
w.liftFences(recs)
+ // The operator returns this box may answer (movesoffline_return.go).
+ w.offlineReturns(ctx, s)
return nil
}
diff --git a/internal/moves/offline.go b/internal/moves/offline.go
index aa33c8db5..806e10270 100644
--- a/internal/moves/offline.go
+++ b/internal/moves/offline.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
"path/filepath"
"sort"
"time"
@@ -306,6 +307,9 @@ type OfflineReport struct {
TombstoneLiftedAt time.Time `json:"tombstone_lifted_at,omitzero"`
// FinalCopiedBytes is what the seal's own final mirror round copied.
FinalCopiedBytes int64 `json:"final_copied_bytes,omitempty"`
+ // RequestKey is the operator return request a return-ready report
+ // answers (ReturnRequest.Key; Request is its RequestedAt).
+ RequestKey string `json:"request_key,omitempty"`
}
var (
@@ -1059,3 +1063,106 @@ func StagedMatches(r OfflineReport, bucket, region string, request time.Time, ta
}
return nil
}
+
+// ---- the operator's return (self-serve moves plan 2 Task 8) ----
+
+// ReturnRequest is one operator request to return a completed forward move
+// (POST .../move/offline/return), written create-only under the forward's
+// prefix at ReturnRequestKey. Key is the request's identity: a new call is
+// a new request, and the current request is the one whose (RequestedAt,
+// Key) sorts last (CurrentReturnRequest); older ones are ignored.
+type ReturnRequest struct {
+ RequestedAt time.Time `json:"requested_at"`
+ Region string `json:"region"`
+ Console string `json:"console"`
+ Key string `json:"key"`
+}
+
+// NewReturnRequestKey is a request key: the request's time in Unix
+// nanoseconds, the console's id and 8 random hex digits, "" when console is
+// not a plain name or random fails.
+func NewReturnRequestKey(at time.Time, console string, random io.Reader) string {
+ if !offlineSegment(console) {
+ return ""
+ }
+ var b [4]byte
+ if _, err := io.ReadFull(random, b[:]); err != nil {
+ return ""
+ }
+ return fmt.Sprintf("%d-%s-%s", at.UnixNano(), console, hex.EncodeToString(b[:]))
+}
+
+// ReturnRequestPrefix is where a forward move's return requests live:
+// control/offline-moves/<host>/<forward id>/return/. "" for a bad host or
+// id.
+func ReturnRequestPrefix(host, forward string) string {
+ p := OfflineReportPrefix(host, forward)
+ if p == "" {
+ return ""
+ }
+ return p + "return/"
+}
+
+// ReturnRequestKey is one request's key, "" for a bad host, id or key.
+func ReturnRequestKey(host, forward, key string) string {
+ p := ReturnRequestPrefix(host, forward)
+ if p == "" || !offlineSegment(key) {
+ return ""
+ }
+ return p + key + ".json"
+}
+
+// ReturnReportKey is the immutable return-ready report the box boxID writes
+// for the request with requestKey: control/offline-moves/<host>/<forward
+// id>/reports/return-ready-<box id>-<request key>.json, create-only (a
+// conflict means the request was already answered). "" for a bad name.
+func ReturnReportKey(host, forward, boxID, requestKey string) string {
+ p := OfflineReportPrefix(host, forward)
+ if p == "" || !offlineSegment(boxID) || !offlineSegment(requestKey) {
+ return ""
+ }
+ return p + "reports/return-ready-" + boxID + "-" + requestKey + ".json"
+}
+
+// ParseReturnRequest reads one request, refusing one whose key is not the
+// one it is stored at.
+func ParseReturnRequest(raw []byte, host, forward, key string) (ReturnRequest, error) {
+ var r ReturnRequest
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return ReturnRequest{}, err
+ }
+ if r.RequestedAt.IsZero() || r.Key == "" || ReturnRequestKey(host, forward, r.Key) != key {
+ return ReturnRequest{}, fmt.Errorf("return request %s: not a request of this move", key)
+ }
+ return r, nil
+}
+
+// CurrentReturnRequest is the request that sorts last by (RequestedAt,
+// Key), false for none.
+func CurrentReturnRequest(reqs []ReturnRequest) (ReturnRequest, bool) {
+ var cur ReturnRequest
+ found := false
+ for _, r := range reqs {
+ if !found || r.RequestedAt.After(cur.RequestedAt) || (r.RequestedAt.Equal(cur.RequestedAt) && r.Key > cur.Key) {
+ cur, found = r, true
+ }
+ }
+ return cur, found
+}
+
+// ReturnParticipant reports whether a box (boxID, instanceID) may answer a
+// return of the forward move o: with Participants, a forward source
+// participant matched by ParticipantMatches (the BoxID, and the InstanceID
+// when one was pinned); without, a box in the forward's source region
+// (placement, the box's own region code).
+func ReturnParticipant(o OpenRecord, boxID, instanceID, placement string) bool {
+ if o.Participants == nil {
+ return placement == o.From
+ }
+ for _, p := range o.Participants.Source {
+ if ParticipantMatches(p, boxID, instanceID) {
+ return true
+ }
+ }
+ return false
+}