| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "log" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "github.com/carlosframework/platform/internal/provision" |
| 11 | "github.com/carlosframework/platform/internal/release" |
| 12 | ) |
| 13 | |
| 14 | // The target's staging park after the commit and the close (moves spec §7.9 |
| 15 | // steps 4 and 5; Plan 5 Task 3 with rulings K4, K6, K9 and K15), behind |
| 16 | // CARLOS_MOVES_PHASE1. Both run in Plan 3's committed arm on the target |
| 17 | // (committedTarget, moveslandcommit.go), routed as it is; there is no second |
| 18 | // committed dispatcher. |
| 19 | // |
| 20 | // Step 4, the staging park. The commit queued the lane's park-replica for |
| 21 | // the target region in its own write, scoped to moves/<host>/<op> with the |
| 22 | // seal's bound as its evidence (moves.CommitScopes, the one builder the |
| 23 | // console's pass uses too, K9). After the claim half and the open, every |
| 24 | // pass while the record is committed runs it through laneParkTarget with the |
| 25 | // claim at landing/committed (K6, the abort's and the revert's dispatcher): |
| 26 | // the park engine over this box's own bucket archives the lane, answers |
| 27 | // ErrPending until seal bound + SignatureAge + RequestLimit, and then |
| 28 | // acknowledges on a clean listing. A late request found by the verification |
| 29 | // listing is a further retirement event under the same scope. |
| 30 | // |
| 31 | // Step 5, the close (closeMove). Any participant that sees opened_at and |
| 32 | // source_parked_at on the record and no obligation of the op in the claim |
| 33 | // closes: the terminal event `done` (moves.LogEvent when the hook is wired, |
| 34 | // Plan 6; a log line until then), then provision.MoveClose (the record |
| 35 | // first: LastMove{Outcome: "done"}, index D11), then hostclaim.ReleaseMove |
| 36 | // to serving under the target (§7.5, §19 K8 as Paul's ruling M4 amends |
| 37 | // them: record first, then the claim's release). A crash between the two |
| 38 | // leaves the record at rest and the claim still landing/committed with |
| 39 | // nothing owed; no box pass visits a record without a move again, and the |
| 40 | // console's recovery pass releases from LastMove (Recovery.Moves, Plan 5 |
| 41 | // Task 7). A claim found already released by this move (a binary that |
| 42 | // closed claim first) gets only the record's close. The target calls it |
| 43 | // every committed pass; the source calls it in the pass that stamps |
| 44 | // source_parked_at, which is the last pass that routes the record to it. |
| 45 | // |
| 46 | // Production (K4). The staging park and the close are production-live only |
| 47 | // after Plan 5 Task 9 (park arming) and claim Plans 10 and 11. The committed |
| 48 | // arm runs only where the landing is armed (landingArmed), and production |
| 49 | // sets the claim's park leases, view and authorizer (armMovesParks, Plan 5 |
| 50 | // Task 9a) only once the wake-source gate opens (movesWakeSourceRegistered, |
| 51 | // Plan 5 Task 9b); the commit and the open themselves need the probe wake of |
| 52 | // claim Plan 11's launcher; and the source's location park is acknowledged |
| 53 | // by C4's regional runner, whose moves evidence is claim Plan 10's. Until |
| 54 | // then the tests arm everything with fakes. |
| 55 | |
| 56 | // closeMove is the close (§7.9 step 5; see the file comment). fault is the |
| 57 | // caller's test seam, passed "after-close-record" between the record's close |
| 58 | // and the claim's release. It does nothing until the record carries both |
| 59 | // stamps and the claim owes nothing under the op. |
| 60 | func (w *movesWorker) closeMove(ctx context.Context, s release.Store, rec provision.Record, fault func(string) error) error { |
| 61 | host, id, inc := rec.Host, rec.Move.ID, rec.Move.Incarnation |
| 62 | fresh, err := provision.GetRecord(ctx, s, rec.Account, rec.App, host) |
| 63 | if err != nil { |
| 64 | return fmt.Errorf("re-read the record: %w", err) |
| 65 | } |
| 66 | if fresh == nil { |
| 67 | return nil |
| 68 | } |
| 69 | m := fresh.Move |
| 70 | if m == nil || m.Legacy() || m.ID != id || m.Incarnation != inc || m.State != provision.MoveCommitted { |
| 71 | return nil |
| 72 | } |
| 73 | if m.OpenedAt == nil || m.SourceParkedAt == nil { |
| 74 | 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) |
| 75 | return nil |
| 76 | } |
| 77 | obs, err := hostclaim.Read(ctx, s, host) |
| 78 | if err != nil { |
| 79 | return fmt.Errorf("read the host claim: %w", err) |
| 80 | } |
| 81 | if obs.Absent { |
| 82 | return fmt.Errorf("move %s has no host claim", id) |
| 83 | } |
| 84 | c := obs.Claim |
| 85 | // A claim this move already released was released by a binary that |
| 86 | // closed claim first: only the record's half is left. |
| 87 | released := hostclaim.RequireReleasedMove(id)(c) == nil |
| 88 | target := "" |
| 89 | if !released { |
| 90 | if c.Op.ID != id || c.Op.Kind != hostclaim.KindLanding || c.Op.Phase != hostclaim.PhaseCommitted || len(c.Op.Participants) != 2 { |
| 91 | 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) |
| 92 | return nil |
| 93 | } |
| 94 | if c.Holder == nil || c.Holder.Incarnation != inc { |
| 95 | return fmt.Errorf("move %s: the host claim names another incarnation", id) |
| 96 | } |
| 97 | // Nothing owed under the op, checked here before the record's |
| 98 | // close (M4), as the console's grant checks it: a record closed |
| 99 | // over an owed chore would leave a release ReleaseMove refuses. |
| 100 | if owed := owedUnder(c, id); owed > 0 { |
| 101 | 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) |
| 102 | return nil |
| 103 | } |
| 104 | target = c.Op.Participants[1] |
| 105 | } |
| 106 | g := provision.MoveGuard{ID: id, Incarnation: inc} |
| 107 | var pre provision.Record |
| 108 | closed, err := provision.SetRecordMove(ctx, s, fresh.Account, fresh.App, host, observedMove(&pre, provision.MoveClose(g, w.now()))) |
| 109 | if err != nil { |
| 110 | if errors.Is(err, provision.ErrMoveConflict) { |
| 111 | log.Printf("moves: %s: the close of move %s refused (%v); the record moved on", host, id, err) |
| 112 | return nil |
| 113 | } |
| 114 | return fmt.Errorf("close the move: %w", err) |
| 115 | } |
| 116 | w.logTransition(ctx, s, pre, *closed) |
| 117 | log.Printf("moves: %s: move %s is done; the instance is in %s", host, id, m.To) |
| 118 | nudgeSerial(ctx, s) |
| 119 | if released { |
| 120 | return nil |
| 121 | } |
| 122 | if fault != nil { |
| 123 | if err := fault("after-close-record"); err != nil { |
| 124 | return err |
| 125 | } |
| 126 | } |
| 127 | if err := releaseAfterClose(ctx, s, host, id, hostclaim.ReleaseMove(id, target)); err != nil { |
| 128 | return err |
| 129 | } |
| 130 | log.Printf("moves: %s: the claim is serving under %s after move %s", host, target, id) |
| 131 | return nil |
| 132 | } |
| 133 | |
| 134 | // owedUnder counts the obligations of the claim under op id. |
| 135 | func owedUnder(c *hostclaim.Claim, id string) int { |
| 136 | owed := 0 |
| 137 | for _, o := range c.Obligations { |
| 138 | if o.Op == id { |
| 139 | owed++ |
| 140 | } |
| 141 | } |
| 142 | return owed |
| 143 | } |
| 144 | |
| 145 | // releaseAfterClose is the second half of every box close (ruling M4): the |
| 146 | // record already carries the outcome in LastMove, so the claim is released |
| 147 | // from a fresh read with the op's own release transition. A claim this move |
| 148 | // already released (the console's pass finished it first) is success. Any |
| 149 | // other failure is returned and left to the console's recovery pass, which |
| 150 | // releases from LastMove: the box pass never visits a record without a move |
| 151 | // again. |
| 152 | func releaseAfterClose(ctx context.Context, s release.Store, host, id string, release func(*hostclaim.Claim) error) error { |
| 153 | obs, err := hostclaim.Read(ctx, s, host) |
| 154 | if err != nil { |
| 155 | return fmt.Errorf("read the host claim to release move %s: %w", id, err) |
| 156 | } |
| 157 | if obs.Absent { |
| 158 | return fmt.Errorf("move %s has no host claim to release", id) |
| 159 | } |
| 160 | if hostclaim.RequireReleasedMove(id)(obs.Claim) == nil { |
| 161 | return nil |
| 162 | } |
| 163 | if _, err := hostclaim.Update(ctx, s, obs, release); err != nil { |
| 164 | return fmt.Errorf("release the claim after the close of move %s (the console's recovery pass finishes it): %w", id, err) |
| 165 | } |
| 166 | return nil |
| 167 | } |
| 168 | |