carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package hostclaim
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "slices"
8 "strconv"
9
10 "github.com/carlosframework/platform/internal/regionname"
11 "github.com/carlosframework/platform/internal/release"
12)
13
14const KindRelocating = "relocating"
15
16// PreFlipGuard verifies the durable pre-flip conversion decision in the
17// authoritative instance record. That decision's record CAS competes with
18// MoveFlip and makes the move terminal for every cutting/flip transition.
19// It preserves the old move journal for replay, and binds this op, incarnation,
20// source and exact fence reference. A snapshot of an undecided record is not
21// an implementation of this interface. While the claim is moving the source
22// is its "source" and the region is the record's; once relocating they are
23// its "source_box" and "region". The returned region is the verified engine
24// identity of the record placement, never an empty placement code.
25type PreFlipGuard interface {
26 CheckMovingBeforeFlip(context.Context, Observed, string, FenceRef) (string, error)
27}
28
29// RelocationRecoveryOwner reports replay ownership, never execution authority.
30func RelocationRecoveryOwner(c *Claim) (string, error) {
31 if c == nil || c.Op.Kind != KindRelocating || c.Op.Intent["region"] == "" {
32 return "", ErrWrongState
33 }
34 return "region:" + c.Op.Intent["region"], nil
35}
36
37func validateRelocating(c *Claim) error {
38 if c.Op.Kind != KindRelocating {
39 return nil
40 }
41 if c.Holder == nil || c.Op.Intent["region"] == "" || !validBoxReference(c.Op.Intent["source_box"]) || c.Op.Intent["source_box"] == "none" {
42 return ErrMalformed
43 }
44 switch c.Op.Phase {
45 case "starting", "releasing":
46 if c.Op.Exec != c.Op.Intent["source_box"] && c.Op.Exec != "none" {
47 return ErrMalformed
48 }
49 case "pinning":
50 if c.Op.Exec != "none" {
51 return ErrMalformed
52 }
53 case "installing", "installed", "probing", "probe-failed", "serving":
54 if !validBoxReference(c.Op.Exec) || c.Op.Exec == "none" || c.Op.Exec != c.Op.Intent["target_box"] {
55 return ErrMalformed
56 }
57 default:
58 return ErrMalformed
59 }
60 return nil
61}
62
63// ConvertMovingToRelocating performs exactly one claim CAS. It deliberately
64// exposes no transition capable of granting relocation execution. Callers must
65// be claim-enabled and first persist the record conversion decision.
66func ConvertMovingToRelocating(ctx context.Context, s release.Store, observed Observed, opID string, ref FenceRef, authority FenceAuthority, guard PreFlipGuard) (Observed, error) {
67 if guard == nil || observed.Absent || observed.Claim == nil || observed.ETag == "" {
68 return Observed{}, ErrWrongState
69 }
70 before := observed.Claim
71 if opID == "" || before.Op.ID != opID {
72 return Observed{}, ErrStaleOp
73 }
74 // The move's own intent, as BeginAdmittedMove writes it: "source" and
75 // "target", the source enrollment from the signed roster, and the app
76 // admission the request ran under (claim Plan 5 Task 7).
77 source := before.Op.Intent["source"]
78 if before.Holder == nil || !validBoxReference(source) || source == "none" || before.Op.Intent["source_enrollment"] == "" || before.Op.Intent["app_id"] == "" || before.Op.Intent["admission_id"] != before.Op.ID || before.Holder.Family != "instance" || before.Op.Kind != KindMoving || before.Op.Phase == "landing" || before.Op.Phase == "committed" {
79 return Observed{}, ErrWrongState
80 }
81 // Check the instance record even while the claim still says moving: the
82 // instance flip is earlier than final commit and can precede claim landing.
83 region, err := guard.CheckMovingBeforeFlip(ctx, observed, opID, ref)
84 if err != nil {
85 return Observed{}, err
86 }
87 fence, err := VerifyFence(ctx, s, authority, ref)
88 if err != nil {
89 return Observed{}, err
90 }
91 // The region is the guard's (the authoritative record) and must equal the
92 // fence's roster binding; the claim names only the target region.
93 if region == "" || region != fence.binding.Region || source != fence.record.Box || before.Op.Intent["source_enrollment"] != ref.Enrollment || (before.Op.Exec != fence.record.Box && before.Op.Exec != "none" && before.Op.Exec != "") {
94 return Observed{}, ErrFence
95 }
96 raw, err := json.Marshal(ref)
97 if err != nil {
98 return Observed{}, err
99 }
100 decisionDigest := ""
101 if bound, ok := guard.(interface {
102 ConversionDecisionDigest(context.Context, Observed, string, FenceRef) (string, error)
103 }); ok {
104 decisionDigest, err = bound.ConversionDecisionDigest(ctx, observed, opID, ref)
105 if err != nil {
106 return Observed{}, err
107 }
108 if !isHex64(decisionDigest) {
109 return Observed{}, ErrWrongState
110 }
111 }
112 return Update(ctx, s, observed, func(c *Claim) error {
113 if c.Op.ID != opID || c.Op.Kind != KindMoving || c.Op.Phase != before.Op.Phase {
114 return ErrStaleOp
115 }
116 key := "fence." + fence.record.Box
117 if old, ok := c.Op.Journal[key]; ok && old != string(raw) {
118 return fmt.Errorf("%w: fence reference already bound", ErrFence)
119 }
120 // Recheck the durable record decision immediately before the claim CAS.
121 // Its terminal state prevents any intervening flip.
122 currentRegion, err := guard.CheckMovingBeforeFlip(ctx, observed, opID, ref)
123 if err != nil {
124 return err
125 }
126 if currentRegion != region {
127 return ErrWrongState
128 }
129 if c.Op.Journal == nil {
130 c.Op.Journal = map[string]string{}
131 }
132 c.Op.Journal[key] = string(raw)
133 c.Op.Journal["reason"] = "source-terminated-before-flip"
134 c.Op.Journal["source_stamp"] = strconv.FormatUint(c.Holder.Stamp, 10)
135 c.Op.Journal["source_generation"] = c.Holder.Generation
136 c.Op.Journal["roster_revision"] = strconv.FormatUint(fence.binding.RosterRevision, 10)
137 c.Op.Journal["roster_etag"] = fence.binding.RosterETag
138 c.Op.Journal["app_id"] = before.Op.Intent["app_id"]
139 c.Op.Journal["admission_id"] = before.Op.Intent["admission_id"]
140 c.Op.Journal["conversion_claim_gen"] = strconv.FormatUint(before.Gen, 10)
141 c.Op.Journal["conversion_claim_etag"] = observed.ETag
142 if decisionDigest != "" {
143 c.Op.Journal["conversion_decision_sha256"] = decisionDigest
144 }
145 c.Op.Kind, c.Op.Phase, c.Op.Exec = KindRelocating, "pinning", "none"
146 if c.Op.Intent == nil {
147 c.Op.Intent = map[string]string{}
148 }
149 c.Op.Intent["region"] = region
150 c.Op.Intent["source_box"] = source
151 // The original move target is in a different region. It is not a selected
152 // same-region replacement. Keep the move history without authorizing it.
153 for from, to := range map[string]string{"target": "move_target_box", "to": "move_to_region", "target_enrollment": "move_target_enrollment"} {
154 if v := c.Op.Intent[from]; v != "" {
155 c.Op.Intent[to] = v
156 }
157 delete(c.Op.Intent, from)
158 }
159 delete(c.Op.Intent, "source")
160 participants := make([]string, 0, len(c.Op.Participants))
161 for _, box := range c.Op.Participants {
162 if box != fence.record.Box {
163 participants = append(participants, box)
164 }
165 }
166 c.Op.Participants = participants
167 return nil
168 })
169}
170
171// CommittedLanding is the authoritative instance record's committed move
172// as a CommittedLandingGuard read it: the regions it moved between, as
173// engine regions (park.PlacementRegion of the record's placement codes, so
174// the home is park.HomeRegion, exactly the regions moves' own commit
175// queues its parks in), and the two parks the commit queues, built by
176// moves.CommitScopes from the record's flip time and the claim's seal (the one builder moves' own
177// commit uses). The scopes are used only when the claim has not yet
178// followed the commit.
179type CommittedLanding struct {
180 FromRegion, ToRegion string
181 SourceScope, StagingScope ParkScope
182}
183
184// CommittedLandingGuard verifies in the authoritative instance record that
185// move opID has committed (moves §7.5: the record's commit CAS, record
186// first, terminal) for this claim's incarnation. A record still landing,
187// rolling back, or naming another move or incarnation refuses with
188// ErrWrongState: before the commit the target's fence takes moves'
189// declared-loss rollback (moves §7.10), never this conversion. A snapshot
190// of an undecided record is not an implementation of this interface.
191type CommittedLandingGuard interface {
192 CheckLandingCommitted(context.Context, Observed, string) (CommittedLanding, error)
193}
194
195// landingConvertible reports the landing phases at which a committed
196// record can stand: the record commits only after its probed, so the
197// claim is at probing or probed (lagging, the record-commit race) or has
198// followed it to committed.
199func landingConvertible(phase string) bool {
200 return phase == PhaseLandingProbing || phase == PhaseLandingProbed || phase == PhaseLandingCommitted
201}
202
203// ConvertCommittedLanding is J3 (Paul's ruling J3/F5 on amadan #236; the
204// model's fence-converts-committed-landing): a landing whose move record
205// has committed and whose target is then terminated becomes a same-region
206// relocating failover in the target's region, in exactly one claim CAS.
207// The record's commit is the evidence (guard), never the claim's phase
208// alone; the claim may lag the record at probing or probed, and then the
209// same CAS first follows the commit exactly as moves does (probed, then
210// Commit under KeepWriterSession, queueing the source region's location
211// park, the source's park-local and the staging lane's park), so every
212// cleanup obligation the commit owes exists before the op stops being a
213// landing. Before the commit the guard refuses and the fence is moves'
214// (declared-loss rollback).
215//
216// The result is the failover StartRelocation starts for a fenced
217// executor, without a target: relocating in starting, op.exec none, the
218// dead target as source_box (on lost, its fence journaled with its
219// observation time and the loss retained in evidence_refs by fenceBox, as
220// FenceSource does), the op id, holder and every obligation kept. The
221// target is named in starting (NameTarget) and acknowledges before
222// PinFailover, as an initial failover does. The move's own intent is kept
223// as history under move_* keys and its journal is kept whole. It
224// deliberately exposes no transition capable of granting execution.
225func ConvertCommittedLanding(ctx context.Context, s release.Store, observed Observed, opID, targetBox string, ref FenceRef, authority FenceAuthority, guard CommittedLandingGuard, d *LocalDischarge, newID func() string) (Observed, error) {
226 if guard == nil || newID == nil || observed.Absent || observed.Claim == nil || observed.ETag == "" {
227 return Observed{}, ErrWrongState
228 }
229 before := observed.Claim
230 if opID == "" || before.Op.ID != opID {
231 return Observed{}, ErrStaleOp
232 }
233 // The landing's own intent, as BeginAdmittedMove wrote it: "source",
234 // "target" and "to", the target's enrollment from the signed roster,
235 // and the app admission the move ran under.
236 in := before.Op.Intent
237 if before.Holder == nil || before.Holder.Family != "instance" || before.Op.Kind != KindLanding || !landingConvertible(before.Op.Phase) ||
238 !validBoxReference(targetBox) || targetBox == "none" || in["target"] != targetBox || before.Op.Exec != targetBox || slices.Contains(before.Lost, targetBox) ||
239 !validBoxReference(in["source"]) || in["source"] == "none" || in["source"] == targetBox || in["to"] == "" ||
240 !validEnrollment(in["target_enrollment"]) || in["app_id"] == "" || in["admission_id"] != opID {
241 return Observed{}, ErrWrongState
242 }
243 committed, err := guard.CheckLandingCommitted(ctx, observed, opID)
244 if err != nil {
245 return Observed{}, err
246 }
247 // The committed regions are engine regions, and so is the move's "to"
248 // (BeginClaimMove's park.PlacementRegion); the home's two names are one
249 // region only under the move's own home binding (main's hostclaim reads
250 // it from the intent the same way).
251 home := in["home_region"]
252 if committed.FromRegion == "" || !regionname.SameRegion(home, committed.ToRegion, in["to"]) || regionname.SameRegion(home, committed.FromRegion, committed.ToRegion) {
253 return Observed{}, ErrWrongState
254 }
255 fence, err := VerifyFence(ctx, s, authority, ref)
256 if err != nil {
257 return Observed{}, err
258 }
259 // The fence is the target's, bound to the target's region in the
260 // roster, which is the record's destination. The binding is the roster
261 // name, so a landing in the home matches its park.HomeRegion only under
262 // the move's home binding.
263 if fence.record.Box != targetBox || fence.record.Enrollment != in["target_enrollment"] || !regionname.SameRegion(home, fence.binding.Region, committed.ToRegion) {
264 return Observed{}, ErrFence
265 }
266 return Update(ctx, s, observed, func(c *Claim) error {
267 if c.Op.ID != opID || c.Op.Kind != KindLanding || c.Op.Phase != before.Op.Phase || c.Op.Exec != targetBox || slices.Contains(c.Lost, targetBox) {
268 return ErrStaleOp
269 }
270 // Recheck the record immediately before the claim CAS.
271 again, err := guard.CheckLandingCommitted(ctx, observed, opID)
272 if err != nil {
273 return err
274 }
275 if again.FromRegion != committed.FromRegion || again.ToRegion != committed.ToRegion {
276 return ErrWrongState
277 }
278 phase := c.Op.Phase
279 // The claim half of the record's commit, as moves writes it
280 // (moveslandcommit.go commitClaim): a claim lagging at probing is
281 // stepped to probed first, then Commit, on the same session.
282 if c.Op.Phase == PhaseLandingProbing {
283 if err := KeepWriterSession(opID, SetMovePhase(opID, KindLanding, PhaseLandingProbing, PhaseLandingProbed))(c); err != nil {
284 return err
285 }
286 }
287 if c.Op.Phase == PhaseLandingProbed {
288 if err := KeepWriterSession(opID, Commit(opID, committed.FromRegion, in["source"], committed.ToRegion, committed.SourceScope, committed.StagingScope, newID))(c); err != nil {
289 return err
290 }
291 }
292 // Defensive: SetMovePhase and Commit above leave every accepted
293 // phase at committed, so this cannot fire today. It keeps the
294 // conversion from running over a landing the commit did not reach
295 // if either helper ever changes.
296 if c.Op.Phase != PhaseLandingCommitted {
297 return ErrWrongState
298 }
299 ensureJournal(c)
300 c.Op.Journal["reason"] = ReasonFailover
301 c.Op.Journal["conversion"] = "committed-landing"
302 c.Op.Journal["conversion_landing_phase"] = phase
303 c.Op.Journal["conversion_claim_gen"] = strconv.FormatUint(before.Gen, 10)
304 c.Op.Journal["conversion_claim_etag"] = observed.ETag
305 c.Op.Journal["move_from_region"] = committed.FromRegion
306 c.Op.Journal["roster_revision"] = strconv.FormatUint(fence.binding.RosterRevision, 10)
307 c.Op.Journal["roster_etag"] = fence.binding.RosterETag
308 c.Op.Kind, c.Op.Phase, c.Op.Exec = KindRelocating, PhaseStarting, "none"
309 intent := make(map[string]string, len(c.Op.Intent)+4)
310 for k, v := range c.Op.Intent {
311 intent[k] = v
312 }
313 // The move's other side is in another region and is no participant:
314 // its finalization chores are obligations, kept as they are. Keep
315 // the move's history without authorizing it.
316 for from, to := range map[string]string{"source": "move_source_box", "source_enrollment": "move_source_enrollment", "to": "move_to_region"} {
317 if v := intent[from]; v != "" {
318 intent[to] = v
319 }
320 delete(intent, from)
321 }
322 delete(intent, "target")
323 delete(intent, "target_enrollment")
324 // The relocation's region is the verified roster identity, as
325 // ConvertMovingToRelocating's is (the fence binding, which matched
326 // the destination above): the region worker's own name, never
327 // park.HomeRegion.
328 intent["region"] = fence.binding.Region
329 intent["source_box"] = targetBox
330 intent["source_enrollment"] = in["target_enrollment"]
331 intent["stamp_from"] = strconv.FormatUint(c.Holder.Stamp, 10)
332 intent["generation_from"] = c.Holder.Generation
333 c.Op.Intent = intent
334 c.Op.Participants = nil
335 return fenceBox(c, targetBox, ref, fence.record.ObservedAt, d)
336 })
337}
338
339// Box and Region expose a verified fence's terminated box and its roster
340// region binding, so a caller binds them to the authoritative record rather
341// than to a decoded reference.
342func (v VerifiedFence) Box() string { return v.record.Box }
343func (v VerifiedFence) Region() string { return v.binding.Region }
344