package host import ( "context" "errors" "fmt" "log" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // The target's staging park after the commit and the close (moves spec §7.9 // steps 4 and 5; Plan 5 Task 3 with rulings K4, K6, K9 and K15), behind // CARLOS_MOVES_PHASE1. Both run in Plan 3's committed arm on the target // (committedTarget, moveslandcommit.go), routed as it is; there is no second // committed dispatcher. // // Step 4, the staging park. The commit queued the lane's park-replica for // the target region in its own write, scoped to moves// with the // seal's bound as its evidence (moves.CommitScopes, the one builder the // console's pass uses too, K9). After the claim half and the open, every // pass while the record is committed runs it through laneParkTarget with the // claim at landing/committed (K6, the abort's and the revert's dispatcher): // the park engine over this box's own bucket archives the lane, answers // ErrPending until seal bound + SignatureAge + RequestLimit, and then // acknowledges on a clean listing. A late request found by the verification // listing is a further retirement event under the same scope. // // Step 5, the close (closeMove). Any participant that sees opened_at and // source_parked_at on the record and no obligation of the op in the claim // closes: the terminal event `done` (moves.LogEvent when the hook is wired, // Plan 6; a log line until then), then provision.MoveClose (the record // first: LastMove{Outcome: "done"}, index D11), then hostclaim.ReleaseMove // to serving under the target (§7.5, §19 K8 as Paul's ruling M4 amends // them: record first, then the claim's release). A crash between the two // leaves the record at rest and the claim still landing/committed with // nothing owed; no box pass visits a record without a move again, and the // console's recovery pass releases from LastMove (Recovery.Moves, Plan 5 // Task 7). A claim found already released by this move (a binary that // closed claim first) gets only the record's close. The target calls it // every committed pass; the source calls it in the pass that stamps // source_parked_at, which is the last pass that routes the record to it. // // Production (K4). The staging park and the close are production-live only // after Plan 5 Task 9 (park arming) and claim Plans 10 and 11. The committed // arm runs only where the landing is armed (landingArmed), and production // sets the claim's park leases, view and authorizer (armMovesParks, Plan 5 // Task 9a) only once the wake-source gate opens (movesWakeSourceRegistered, // Plan 5 Task 9b); the commit and the open themselves need the probe wake of // claim Plan 11's launcher; and the source's location park is acknowledged // by C4's regional runner, whose moves evidence is claim Plan 10's. Until // then the tests arm everything with fakes. // closeMove is the close (§7.9 step 5; see the file comment). fault is the // caller's test seam, passed "after-close-record" between the record's close // and the claim's release. It does nothing until the record carries both // stamps and the claim owes nothing under the op. func (w *movesWorker) closeMove(ctx context.Context, s release.Store, rec provision.Record, fault func(string) error) error { host, id, inc := rec.Host, rec.Move.ID, rec.Move.Incarnation fresh, err := provision.GetRecord(ctx, s, rec.Account, rec.App, host) if err != nil { return fmt.Errorf("re-read the record: %w", err) } if fresh == nil { return nil } m := fresh.Move if m == nil || m.Legacy() || m.ID != id || m.Incarnation != inc || m.State != provision.MoveCommitted { return nil } if m.OpenedAt == nil || m.SourceParkedAt == nil { w.notice(host+"#close", "moves: %s: move %s closes once the target has opened and the source has parked (opened %t, parked %t)", host, id, m.OpenedAt != nil, m.SourceParkedAt != nil) return nil } obs, err := hostclaim.Read(ctx, s, host) if err != nil { return fmt.Errorf("read the host claim: %w", err) } if obs.Absent { return fmt.Errorf("move %s has no host claim", id) } c := obs.Claim // A claim this move already released was released by a binary that // closed claim first: only the record's half is left. released := hostclaim.RequireReleasedMove(id)(c) == nil target := "" if !released { if c.Op.ID != id || c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted || len(c.Op.Participants) != 2 { w.notice(host+"#close", "moves: %s: move %s waits to close: the claim is %s/%q (%s), not this move's commit", host, id, c.Op.Kind, c.Op.Phase, c.Op.ID) return nil } if c.Holder == nil || c.Holder.Incarnation != inc { return fmt.Errorf("move %s: the host claim names another incarnation", id) } // Nothing owed under the op, checked here before the record's // close (M4), as the console's grant checks it: a record closed // over an owed chore would leave a release ReleaseMove refuses. if owed := owedUnder(c, id); owed > 0 { w.notice(host+"#close", "moves: %s: move %s waits for %d park(s) of the move to be acknowledged before it closes", host, id, owed) return nil } target = c.Op.Participants[1] } g := provision.MoveGuard{ID: id, Incarnation: inc} var pre provision.Record closed, err := provision.SetRecordMove(ctx, s, fresh.Account, fresh.App, host, observedMove(&pre, provision.MoveClose(g, w.now()))) if err != nil { if errors.Is(err, provision.ErrMoveConflict) { log.Printf("moves: %s: the close of move %s refused (%v); the record moved on", host, id, err) return nil } return fmt.Errorf("close the move: %w", err) } w.logTransition(ctx, s, pre, *closed) log.Printf("moves: %s: move %s is done; the instance is in %s", host, id, m.To) nudgeSerial(ctx, s) if released { return nil } if fault != nil { if err := fault("after-close-record"); err != nil { return err } } if err := releaseAfterClose(ctx, s, host, id, hostclaim.ReleaseMove(id, target)); err != nil { return err } log.Printf("moves: %s: the claim is serving under %s after move %s", host, target, id) return nil } // owedUnder counts the obligations of the claim under op id. func owedUnder(c *hostclaim.Claim, id string) int { owed := 0 for _, o := range c.Obligations { if o.Op == id { owed++ } } return owed } // releaseAfterClose is the second half of every box close (ruling M4): the // record already carries the outcome in LastMove, so the claim is released // from a fresh read with the op's own release transition. A claim this move // already released (the console's pass finished it first) is success. Any // other failure is returned and left to the console's recovery pass, which // releases from LastMove: the box pass never visits a record without a move // again. func releaseAfterClose(ctx context.Context, s release.Store, host, id string, release func(*hostclaim.Claim) error) error { obs, err := hostclaim.Read(ctx, s, host) if err != nil { return fmt.Errorf("read the host claim to release move %s: %w", id, err) } if obs.Absent { return fmt.Errorf("move %s has no host claim to release", id) } if hostclaim.RequireReleasedMove(id)(obs.Claim) == nil { return nil } if _, err := hostclaim.Update(ctx, s, obs, release); err != nil { return fmt.Errorf("release the claim after the close of move %s (the console's recovery pass finishes it): %w", id, err) } return nil }