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 "errors"
7 "fmt"
8 "slices"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/carlosframework/platform/internal/regionname"
14 "github.com/carlosframework/platform/internal/release"
15)
16
17// Relocation (claim §4 "Same-region relocation", region HA spec 1 §5.8):
18// the reasons, the phases and the bound on target replacements. The
19// releasing phase is main's PhaseReleasing (the same "releasing" a
20// restoring op writes) and probing is main's PhaseRelocatingProbing
21// (sessionstep.go): one constant per phase name.
22const (
23 ReasonDrain = "drain"
24 ReasonRebalance = "rebalance"
25 ReasonFailover = "failover"
26
27 PhaseStarting = "starting"
28 PhasePinning = "pinning"
29 PhaseInstalling = "installing"
30 PhaseInstalled = "installed"
31 PhaseProbeFailed = "probe-failed"
32
33 // MaxTargetReplacements is spec 1 §5.8's bound: after two
34 // replacements of the target in one operation it goes to stuck.
35 MaxTargetReplacements = 2
36
37 // MaxProbeAttempts is §5.8's probe bound: at most three admissions
38 // per target installation within the probe deadline; running out is
39 // probe-failed. Attempts are counted in the claim before they are
40 // made, so a crash or a lost response never buys a fourth.
41 MaxProbeAttempts = 3
42
43 // FailoverPinDelay is §5.8's failover pin rule: pin no earlier than
44 // the fence's observed_at plus 30 seconds, so requests S3 had already
45 // accepted from the terminated box have completed (H3's window).
46 FailoverPinDelay = 30 * time.Second
47)
48
49var (
50 ErrRelocation = errors.New("hostclaim: relocation transition refused")
51 ErrReplacementsExceeded = errors.New("hostclaim: relocation has used its target replacements")
52 ErrAlreadyRecorded = errors.New("hostclaim: relocation journal entry already recorded")
53 ErrProbeAttemptsExhausted = errors.New("hostclaim: the target has used its probe attempts")
54)
55
56// RelocationStart is the start CAS's intent (spec 1 §5.8, claim §4). Fence
57// is set for a failover only and names the source's termination fence.
58type RelocationStart struct {
59 OpID, By string
60 Now time.Time
61 Region, Reason string
62 // HomeRegion is the starting box's verified home binding (the fleet's
63 // home roster name when Region is it, hostbox.Actor.HomeRegion), or ""
64 // with no alias. It lets the start guard see the home's chores under
65 // either of its names (regionname.SameRegion), and is recorded as the
66 // op's "home_region" intent, as BeginAdmittedMove records a move's.
67 HomeRegion string
68 SourceBox, SourceEnrollment string
69 TargetBox, TargetEnrollment string
70 DirectoryRevision uint64
71 AdmissionID string
72 Fence *FenceRef
73 Discharge *LocalDischarge
74}
75
76// Release is the journal entry a live box writes after its release
77// sequence (spec 1 §5.8 steps 1 to 4): its stopped database's final TXID,
78// its final files manifest sequence and its full stop receipt.
79type Release struct {
80 ReleasedBy string `json:"released_by"`
81 Enrollment string `json:"enrollment"`
82 Generation string `json:"generation"`
83 Session uint64 `json:"session"`
84 FinalTXID uint64 `json:"final_txid"`
85 ManifestSeq int64 `json:"manifest_seq"`
86 Receipt string `json:"receipt"`
87 ReceiptSHA256 string `json:"receipt_sha256"`
88}
89
90// ReleaseAuthority verifies a journaled release's full stop receipt: the
91// immutable object at Receipt hashes to ReceiptSHA256 and is complete stop
92// evidence (claim §6.1, P6-D1) for this host, operation, box, incarnation
93// and session. Plan 6b's receipt format is its only implementation.
94type ReleaseAuthority interface {
95 VerifyRelease(context.Context, Claim, Release) error
96}
97
98// RecoveryPin is the journal's reference to one pinned recovery set
99// (§5.8 "The pinned recovery set"): the immutable pin object holding the
100// exact versions, its digest, the database position it reaches, the files
101// manifest it pins, and when it was taken.
102type RecoveryPin struct {
103 Key string `json:"key"`
104 SHA256 string `json:"sha256"`
105 Generation string `json:"generation"`
106 MaxTXID uint64 `json:"max_txid"`
107 FilesManifest string `json:"files_manifest"`
108 FilesSeq int64 `json:"files_seq"`
109 PinnedAt time.Time `json:"pinned_at"`
110 // Empty is the evidenced empty bootstrap (claim §6.4a "Empty
111 // bootstrap", spec 1 §5.8 "A host with nothing replicated"): no LTX
112 // file exists because no transaction was ever committed. It is legal
113 // only when a journaled release reports final_txid 0, or the holder
114 // never admitted a writer (session 0). A listing that merely finds
115 // nothing is never evidence (JournalPin refuses it).
116 Empty bool `json:"empty,omitempty"`
117 // Release is the app release version the relocation installs (R9 on
118 // amadan #236). The pinner sets it at pinning from the region
119 // controller's authoritative registry route for the host, never from
120 // the box, the request or the box's install journal; the installing
121 // CAS copies it to op.journal.install_release, which moves' held-stage
122 // relay compares with the request's version. Required: a pin without
123 // it is refused.
124 Release string `json:"release"`
125}
126
127// RecoveryPinKey is where the pin selected against claim generation gen
128// lives: control/relocations/<host>/<op>/pin-<gen>.json. The key binds the
129// object to the exact observation it was selected against, so a pin whose
130// journal CAS lost (the claim moved) is abandoned, never reused, and a
131// retry selects afresh at the new generation (B2-3's round binding).
132func RecoveryPinKey(host, op string, gen uint64) string {
133 return "control/relocations/" + host + "/" + op + "/pin-" + strconv.FormatUint(gen, 10) + ".json"
134}
135
136// PreparedAuthority is the `installed` CAS's record of the remote build
137// (§5.8 "Building the prepared generation"): the install manifest of
138// generation P, verified complete by a fresh read.
139type PreparedAuthority struct {
140 Generation string `json:"generation"`
141 InstallManifest string `json:"install_manifest"`
142 SHA256 string `json:"sha256"`
143 SnapshotTXID uint64 `json:"snapshot_txid"`
144 // FilesSeq and FilesManifestSHA256 identify P's prepared files
145 // manifest (claim rev 6.30 R18-1, moves rev 7.22 §7.6): create-only,
146 // at sequence 0, never replaced; FilesManifestSHA256 is the digest of
147 // the exact bytes a fresh read verified, the exact version the first
148 // admission consumes as its baseline (the session publisher then
149 // starts at sequence 1). SourceFilesSeq is provenance, not identity:
150 // the pinned source manifest's sequence it was built from, which must
151 // be the pin's (rev 15, round 14 finding 1). Required always, an empty
152 // bootstrap included: a P without its files manifest is not built.
153 FilesSeq int64 `json:"files_seq"`
154 SourceFilesSeq int64 `json:"source_files_seq"`
155 FilesManifestSHA256 string `json:"files_manifest_sha256"`
156 // InstallStamp binds the prepared manifest to the installation that
157 // built it ({location, P, op id, installation stamp}): ToInstalled
158 // sets it from install_stamp in the CAS that journals the authority.
159 InstallStamp uint64 `json:"install_stamp,omitempty"`
160}
161
162// LocalInstall is one box's local install receipt (§5.8, B1-2, B2-5).
163type LocalInstall struct {
164 Box string `json:"box"`
165 Enrollment string `json:"enrollment"`
166 InstallStamp uint64 `json:"install_stamp"`
167 FilesManifestVersion int64 `json:"files_manifest_version"`
168 JournalSHA256 string `json:"journal_sha256"`
169}
170
171// Probe is the target's journaled probe result for its admitted session.
172type Probe struct {
173 Box string `json:"box"`
174 Session uint64 `json:"session"`
175 // At is when the probe passed (rev 4, ruling 67): the worker marks the
176 // op stuck ReleaseDeadline after At if no stop receipt is journaled.
177 // Zero in a probe journaled before rev 4: the worker then takes the
178 // probe as passed at probing_since plus ProbeDeadline, the latest it
179 // can have passed, and counts ReleaseDeadline from there.
180 At time.Time `json:"at,omitzero"`
181}
182
183// Target names a box for the relocation's target slot.
184type Target struct {
185 Box, Enrollment string
186}
187
188func journalJSON(c *Claim, key string, v any) error {
189 raw, err := json.Marshal(v)
190 if err != nil {
191 return err
192 }
193 if c.Op.Journal == nil {
194 c.Op.Journal = map[string]string{}
195 }
196 c.Op.Journal[key] = string(raw)
197 return nil
198}
199
200func readJournal(op Op, key string, v any) bool {
201 raw, ok := op.Journal[key]
202 return ok && json.Unmarshal([]byte(raw), v) == nil
203}
204
205// journalUint is a journaled counter or stamp: absent is 0, its starting
206// value; a value that does not parse is an error, never 0 (absence or
207// malformed data is never evidence).
208func journalUint(op Op, key string) (uint64, error) {
209 raw, ok := op.Journal[key]
210 if !ok || raw == "" {
211 return 0, nil
212 }
213 n, err := strconv.ParseUint(raw, 10, 64)
214 if err != nil {
215 return 0, fmt.Errorf("%w: journal %s is malformed: %q", ErrRelocation, key, raw)
216 }
217 return n, nil
218}
219
220// Replacements is the number of target replacements the operation has
221// used (§5.8's bound, MaxTargetReplacements). A malformed count is an
222// error, never 0.
223func Replacements(c *Claim) (uint64, error) {
224 if c == nil {
225 return 0, ErrMalformed
226 }
227 return journalUint(c.Op, "replacements")
228}
229
230func requireRelocation(c *Claim, opID string, phases ...string) error {
231 if err := RequireOp(opID)(c); err != nil {
232 return err
233 }
234 if c.Holder == nil || c.Op.Kind != KindRelocating || !slices.Contains(phases, c.Op.Phase) {
235 return fmt.Errorf("%w: %s/%s", ErrWrongState, c.Op.Kind, c.Op.Phase)
236 }
237 if c.Op.Journal["stuck"] != "" {
238 return fmt.Errorf("%w: operation is stuck", ErrRelocation)
239 }
240 ensureJournal(c)
241 return nil
242}
243
244// ensureJournal makes the journal writable: an empty one round-trips as nil.
245func ensureJournal(c *Claim) {
246 if c.Op.Journal == nil {
247 c.Op.Journal = map[string]string{}
248 }
249}
250
251// requireAcks is the D6 barrier: every current participant has recorded
252// its execution acknowledgement before the operation leaves its phase.
253func requireAcks(c *Claim) error {
254 for _, box := range c.Op.Participants {
255 if c.Op.Journal["exec_ack."+box] == "" {
256 return fmt.Errorf("%w: %s has not acknowledged", ErrRelocation, box)
257 }
258 }
259 return nil
260}
261
262func failover(op Op) bool {
263 return op.Intent["reason"] == ReasonFailover || op.Journal["reason"] == ReasonFailover
264}
265
266// RelocationStartGuard is §4's start guard with the relocation exception
267// (L11): a park-generation chore in the region does not block, a
268// park-replica in the region does, a park-local on the target does, and a
269// park-local on a fenced source does not block a failover. home is the
270// verified home binding or "": a park-replica queued under the home's
271// other name (park.HomeRegion, as moves queues it) is the same region
272// (regionname.SameRegion), as BeginAdmittedMove's start guard reads both.
273func RelocationStartGuard(c *Claim, home, region, source, target string, sourceFenced bool) error {
274 if c.Holder == nil || c.Op.Kind != KindServing || c.Op.ID != "" {
275 return fmt.Errorf("%w: kind %q", ErrWrongState, c.Op.Kind)
276 }
277 fenced := ""
278 if sourceFenced {
279 fenced = source
280 }
281 if err := completionBarrier(c, fenced); err != nil {
282 return err
283 }
284 for _, o := range c.Obligations {
285 switch {
286 case o.Kind == "park-replica" && regionname.SameRegion(home, o.Region, region):
287 return fmt.Errorf("%w: park-replica in %s", ErrPendingChore, region)
288 case o.Kind == "park-local" && o.Box == target:
289 return fmt.Errorf("%w: park-local on %s", ErrPendingChore, target)
290 case o.Kind == "park-local" && o.Box == source && !sourceFenced:
291 return fmt.Errorf("%w: park-local on %s", ErrPendingChore, source)
292 }
293 }
294 return nil
295}
296
297// OwesLocal reports whether box owes a park-local chore on this claim: a
298// local retirement not yet done (spec 1 §2: such a box is never chosen to
299// install the host, and §4: a park-local on the target refuses).
300func OwesLocal(c *Claim, box string) bool {
301 for _, o := range c.Obligations {
302 if o.Kind == "park-local" && o.Box == box {
303 return true
304 }
305 }
306 return false
307}
308
309// LocalChoreBoxes lists every box that owes a park-local chore on the
310// claim, for a chooser's exclusions.
311func LocalChoreBoxes(c *Claim) []string {
312 var out []string
313 for _, o := range c.Obligations {
314 if o.Kind == "park-local" && o.Box != "" && !slices.Contains(out, o.Box) {
315 out = append(out, o.Box)
316 }
317 }
318 return out
319}
320
321// fenceBox puts box on lost with its fence reference (L2, L4), passes the
322// regional work the box owned (park-generation, park-replica owned by
323// box:<box>) to its region, region:<r>, keeping each obligation's id,
324// operation, retirement, region, generation and park scope (L3, exactly
325// as DeclareLost does for a declared loss, lost.go), and, when the volume
326// evidence qualifies (L3), discharges that box's park-local chores for
327// this host, all in the same CAS. It never removes anything else. D4: the
328// same CAS retains the loss outside the operation (evidence_refs,
329// loss_refs) with the box's last writer session, the discharge verdict and
330// timer knowledge "unknown", so the console can materialize the loss record
331// after the operation is long gone.
332func fenceBox(c *Claim, box string, ref FenceRef, observedAt string, d *LocalDischarge) error {
333 key := "fence." + box
334 raw, err := json.Marshal(ref)
335 if err != nil {
336 return err
337 }
338 if old, ok := c.Op.Journal[key]; ok && old != string(raw) {
339 return fmt.Errorf("%w: fence reference already bound", ErrFence)
340 }
341 if c.Op.Journal == nil {
342 c.Op.Journal = map[string]string{}
343 }
344 c.Op.Journal[key] = string(raw)
345 c.Op.Journal[key+".observed_at"] = observedAt
346 if !slices.Contains(c.Lost, box) {
347 c.Lost = append(c.Lost, box)
348 }
349 var reassigned []ObligationRef
350 for i := range c.Obligations {
351 o := &c.Obligations[i]
352 if (o.Kind == "park-generation" || o.Kind == "park-replica") && o.Owner == "box:"+box {
353 if o.Region == "" {
354 return fmt.Errorf("%w: regional obligation %q names no region", ErrWrongState, o.ID)
355 }
356 o.Owner = "region:" + o.Region
357 reassigned = append(reassigned, ObligationRef{ID: o.ID, Op: o.Op, Retirement: o.Retirement})
358 }
359 }
360 if d == nil {
361 d = &LocalDischarge{Reason: "no volume evidence supplied"}
362 }
363 if err := journalJSON(c, "discharge."+box, d); err != nil {
364 return err
365 }
366 if d.Qualified {
367 // Each chore is completed with CompleteObligation's own semantics,
368 // so a discovery operation whose final chore this discharges is
369 // marked complete exactly as a retirement would (delete.go).
370 for {
371 var chore *Obligation
372 for i := range c.Obligations {
373 if c.Obligations[i].Kind == "park-local" && c.Obligations[i].Box == box {
374 chore = &c.Obligations[i]
375 break
376 }
377 }
378 if chore == nil {
379 break
380 }
381 if err := CompleteObligation(chore.Op, chore.ID)(c); err != nil {
382 return err
383 }
384 }
385 }
386 return retainAutomaticLoss(c, box, ref, observedAt, *d, reassigned)
387}
388
389// retainAutomaticLoss is D4's retention, written once per (operation, box,
390// fence): a replay finds its record and changes nothing.
391func retainAutomaticLoss(c *Claim, box string, ref FenceRef, observedAt string, d LocalDischarge, reassigned []ObligationRef) error {
392 id := automaticLossID(c.Op.ID, box, ref)
393 for _, l := range c.EvidenceRefs {
394 if l.ID == id {
395 return nil
396 }
397 }
398 role := "target"
399 if box == c.Op.Intent["source_box"] {
400 role = "source"
401 }
402 loss := AutomaticLoss{ID: id, Box: box, Enrollment: ref.Enrollment, Fence: ref, ObservedAt: observedAt,
403 Op: c.Op.ID, Kind: c.Op.Kind, Phase: c.Op.Phase, Role: role, Discharge: d, Reassigned: reassigned, Timers: TimersUnknown}
404 if h := c.Holder; h != nil {
405 loss.Incarnation, loss.Stamp = h.Incarnation, h.Stamp
406 // The box's last writer session: the live one when it is still
407 // the writer, otherwise its last admission on this holder.
408 if h.WriterBox == box {
409 loss.Generation, loss.Session = h.Generation, h.Session
410 }
411 for i := len(h.Admissions) - 1; i >= 0; i-- {
412 if h.Admissions[i].Box == box {
413 last := h.Admissions[i]
414 loss.LastAdmission = &last
415 if loss.Session == 0 {
416 loss.Generation, loss.Session = last.Generation, last.Session
417 }
418 break
419 }
420 }
421 }
422 c.EvidenceRefs = append(c.EvidenceRefs, loss)
423 if !slices.Contains(c.LossRefs, id) {
424 c.LossRefs = append(c.LossRefs, id)
425 }
426 return nil
427}
428
429func removeParticipant(c *Claim, box string) {
430 c.Op.Participants = slices.DeleteFunc(slices.Clone(c.Op.Participants), func(b string) bool { return b == box })
431}
432
433func addParticipant(c *Claim, box string) {
434 if !slices.Contains(c.Op.Participants, box) {
435 c.Op.Participants = append(c.Op.Participants, box)
436 }
437}
438
439// installedBoxes is every box this operation handed op.exec for an
440// installation; each gets a park-local unless discharged (claim §4:
441// "Every abandoned local installation receives an exact park-local").
442func noteInstalledBox(c *Claim, box string) error {
443 var boxes []string
444 readJournal(c.Op, "installed_boxes", &boxes)
445 if !slices.Contains(boxes, box) {
446 boxes = append(boxes, box)
447 }
448 return journalJSON(c, "installed_boxes", boxes)
449}
450
451// StartRelocation is the start CAS (spec 1 §5.8, claim §4). A drain or
452// rebalance starts with op.exec on the source; a failover cites the
453// source's termination fence, starts with op.exec "none", keeps the stamp,
454// and puts the source on lost. The caller has already checked the roster
455// (hostactors.Directory.CheckBoxStart), the target's eligibility and the
456// app-admission registration; this CAS records what it checked.
457func StartRelocation(ctx context.Context, s release.Store, observed Observed, in RelocationStart, authority FenceAuthority) (Observed, error) {
458 if observed.Absent || observed.Claim == nil || observed.ETag == "" {
459 return Observed{}, ErrWrongState
460 }
461 switch in.Reason {
462 case ReasonDrain, ReasonRebalance:
463 if in.Fence != nil {
464 return Observed{}, fmt.Errorf("%w: only a failover cites a fence", ErrRelocation)
465 }
466 case ReasonFailover:
467 if in.Fence == nil {
468 return Observed{}, fmt.Errorf("%w: a failover cites the source's fence", ErrRelocation)
469 }
470 default:
471 return Observed{}, fmt.Errorf("%w: reason %q", ErrRelocation, in.Reason)
472 }
473 if in.HomeRegion != "" && regionname.PlacementRegion(in.HomeRegion) != in.HomeRegion {
474 return Observed{}, fmt.Errorf("%w: home region %q is not a placement code", ErrRelocation, in.HomeRegion)
475 }
476 if in.OpID == "" || in.Region == "" || in.AdmissionID == "" || in.DirectoryRevision == 0 || in.Now.IsZero() ||
477 !validBoxReference(in.SourceBox) || !validBoxReference(in.TargetBox) || in.SourceBox == "none" || in.TargetBox == "none" ||
478 in.SourceBox == in.TargetBox || in.SourceEnrollment == "" || in.TargetEnrollment == "" {
479 return Observed{}, fmt.Errorf("%w: incomplete start", ErrRelocation)
480 }
481 var observedAt string
482 if in.Fence != nil {
483 v, err := VerifyFence(ctx, s, authority, *in.Fence)
484 if err != nil {
485 return Observed{}, err
486 }
487 if v.record.Box != in.SourceBox || v.record.Enrollment != in.SourceEnrollment || v.binding.Region != in.Region {
488 return Observed{}, ErrFence
489 }
490 observedAt = v.record.ObservedAt
491 }
492 return Update(ctx, s, observed, func(c *Claim) error {
493 if c.Op.Exec != in.SourceBox || slices.Contains(c.Lost, in.TargetBox) {
494 return fmt.Errorf("%w: the source is not the executor, or the target is lost", ErrWrongState)
495 }
496 if err := RelocationStartGuard(c, in.HomeRegion, in.Region, in.SourceBox, in.TargetBox, in.Fence != nil); err != nil {
497 return err
498 }
499 exec, participants := in.SourceBox, []string{in.SourceBox, in.TargetBox}
500 if in.Fence != nil {
501 exec, participants = "none", []string{in.TargetBox}
502 }
503 c.Op = Op{Kind: KindRelocating, ID: in.OpID, Phase: PhaseStarting, By: in.By, StartedAt: in.Now.UTC(),
504 Exec: exec, Participants: participants,
505 Intent: map[string]string{
506 "region": in.Region, "reason": in.Reason,
507 "source_box": in.SourceBox, "source_enrollment": in.SourceEnrollment,
508 "target_box": in.TargetBox, "target_enrollment": in.TargetEnrollment,
509 "stamp_from": strconv.FormatUint(c.Holder.Stamp, 10),
510 "generation_from": c.Holder.Generation,
511 "directory_revision": strconv.FormatUint(in.DirectoryRevision, 10),
512 "admission_id": in.AdmissionID,
513 },
514 Journal: map[string]string{}}
515 if in.HomeRegion != "" {
516 c.Op.Intent["home_region"] = in.HomeRegion
517 }
518 if in.Fence != nil {
519 return fenceBox(c, in.SourceBox, *in.Fence, observedAt, in.Discharge)
520 }
521 return nil
522 })
523}
524
525// FenceSource is §5.8's source-loss rule (B1-6): a drain or rebalance
526// whose source is terminated becomes a failover in one CAS citing the
527// source's fence. In starting it stays in starting with the source off the
528// barrier; in releasing before a release is journaled it goes to pinning
529// with the fence as evidence; after a release (pinning on) the source only
530// goes on lost. Every case keeps the stamp and the intent.
531func FenceSource(ctx context.Context, s release.Store, observed Observed, opID string, ref FenceRef, d *LocalDischarge, authority FenceAuthority) (Observed, error) {
532 if observed.Absent || observed.Claim == nil {
533 return Observed{}, ErrWrongState
534 }
535 before := observed.Claim.Op
536 source := before.Intent["source_box"]
537 v, err := VerifyFence(ctx, s, authority, ref)
538 if err != nil {
539 return Observed{}, err
540 }
541 if v.record.Box != source || v.record.Enrollment != before.Intent["source_enrollment"] || v.binding.Region != before.Intent["region"] {
542 return Observed{}, ErrFence
543 }
544 return Update(ctx, s, observed, func(c *Claim) error {
545 if err := RequireOp(opID)(c); err != nil {
546 return err
547 }
548 if c.Op.Kind != KindRelocating || c.Op.Intent["source_box"] != source {
549 return ErrWrongState
550 }
551 ensureJournal(c)
552 // Before its release is journaled the source's writes are not
553 // kept by a release, so the op becomes a failover. After it, only
554 // the source's liveness changes.
555 if c.Op.Phase == PhaseStarting || (c.Op.Phase == PhaseReleasing && c.Op.Journal["release"] == "") {
556 c.Op.Journal["reason"] = ReasonFailover
557 removeParticipant(c, source)
558 c.Op.Exec = "none"
559 if c.Op.Phase == PhaseReleasing {
560 c.Op.Phase = PhasePinning
561 }
562 }
563 return fenceBox(c, source, ref, v.record.ObservedAt, d)
564 })
565}
566
567// ToReleasing is a drain's barrier CAS: from starting, once every
568// participant has acknowledged. A failover never enters releasing. now
569// starts the release deadline (hostrelocate.ReleaseDeadline): a source
570// that never journals its release goes to stuck.
571func ToReleasing(opID string, now time.Time) func(*Claim) error {
572 return func(c *Claim) error {
573 if err := requireRelocation(c, opID, PhaseStarting); err != nil {
574 return err
575 }
576 if failover(c.Op) {
577 return fmt.Errorf("%w: a failover skips releasing", ErrRelocation)
578 }
579 if err := requireAcks(c); err != nil {
580 return err
581 }
582 if now.IsZero() {
583 return fmt.Errorf("%w: releasing needs its start time", ErrRelocation)
584 }
585 c.Op.Phase = PhaseReleasing
586 c.Op.Journal["releasing_since"] = now.UTC().Format(time.RFC3339Nano)
587 return nil
588 }
589}
590
591// JournalRelease records a live box's completed release sequence. The
592// source records it in releasing; a failed target in probe-failed. The
593// release must be of the box's own current writer session.
594func JournalRelease(opID string, r Release) func(*Claim) error {
595 return func(c *Claim) error {
596 if err := requireRelocation(c, opID, PhaseReleasing, PhaseProbeFailed); err != nil {
597 return err
598 }
599 h := c.Holder
600 writerMismatch := h.Session != 0 && h.WriterBox != r.ReleasedBy // a host never woken has no writer
601 if r.ReleasedBy == "" || r.ReleasedBy != c.Op.Exec || r.Enrollment == "" || r.Receipt == "" || !isHex64(r.ReceiptSHA256) ||
602 r.Generation != h.Generation || r.Session != h.Session || writerMismatch {
603 return fmt.Errorf("%w: release is not the executor's current session", ErrRelocation)
604 }
605 want := c.Op.Intent["source_enrollment"]
606 if c.Op.Phase == PhaseProbeFailed {
607 want = c.Op.Intent["target_enrollment"]
608 }
609 if r.Enrollment != want {
610 return fmt.Errorf("%w: release enrollment", ErrRelocation)
611 }
612 var old Release
613 if readJournal(c.Op, "release", &old) && c.Op.Journal["release.phase"] == c.Op.Phase {
614 if old == r {
615 return ErrAlreadyRecorded
616 }
617 return fmt.Errorf("%w: a different release is journaled", ErrRelocation)
618 }
619 c.Op.Journal["release.phase"] = c.Op.Phase
620 return journalJSON(c, "release", r)
621 }
622}
623
624// PinAfterRelease leaves releasing (a drain) or probe-failed (a failed
625// target, §5.8's nested relocation) for pinning, citing the journaled
626// release's verified receipt. op.exec becomes "none" and the stamp is kept
627// (claim R6-4, D5's non-installing form). From probe-failed, next names
628// the replacement target, which must not be the failed one.
629func PinAfterRelease(ctx context.Context, s release.Store, observed Observed, opID string, authority ReleaseAuthority, next *Target) (Observed, error) {
630 if observed.Absent || observed.Claim == nil || authority == nil {
631 return Observed{}, ErrWrongState
632 }
633 var r Release
634 if !readJournal(observed.Claim.Op, "release", &r) {
635 return Observed{}, fmt.Errorf("%w: no journaled release", ErrRelocation)
636 }
637 if err := authority.VerifyRelease(ctx, *observed.Claim, r); err != nil {
638 return Observed{}, err
639 }
640 return Update(ctx, s, observed, func(c *Claim) error {
641 if err := requireRelocation(c, opID, PhaseReleasing, PhaseProbeFailed); err != nil {
642 return err
643 }
644 var now Release
645 if !readJournal(c.Op, "release", &now) || now != r || c.Op.Journal["release.phase"] != c.Op.Phase || r.ReleasedBy != c.Op.Exec {
646 return fmt.Errorf("%w: release changed", ErrRelocation)
647 }
648 if c.Op.Phase == PhaseReleasing {
649 if next != nil {
650 return fmt.Errorf("%w: a drain keeps its target", ErrRelocation)
651 }
652 // §5.8: a target replaced in releasing acknowledges before the
653 // operation leaves the phase.
654 if err := requireAcks(c); err != nil {
655 return err
656 }
657 } else {
658 if next == nil || next.Box == r.ReleasedBy {
659 return fmt.Errorf("%w: a nested relocation names another target", ErrRelocation)
660 }
661 if err := replaceTargetSlot(c, *next); err != nil {
662 return err
663 }
664 c.Op.Journal["released_by_target"] = r.ReleasedBy
665 }
666 c.Op.Exec, c.Op.Phase = "none", PhasePinning
667 delete(c.Op.Journal, "pin")
668 return nil
669 })
670}
671
672// PinFailover leaves starting for pinning on a failover: the source's
673// fence is journaled, the target is named (a converted committed landing
674// names it in starting, NameTarget) and every remaining participant has
675// acknowledged, so the pin is selected only after the target's
676// acknowledgement.
677func PinFailover(opID string) func(*Claim) error {
678 return func(c *Claim) error {
679 if err := requireRelocation(c, opID, PhaseStarting); err != nil {
680 return err
681 }
682 if !failover(c.Op) || c.Op.Journal["fence."+c.Op.Intent["source_box"]] == "" || c.Op.Exec != "none" {
683 return fmt.Errorf("%w: pinning without a release needs the source's fence", ErrRelocation)
684 }
685 if c.Op.Intent["target_box"] == "" {
686 return fmt.Errorf("%w: a failover names its target before it pins", ErrRelocation)
687 }
688 if err := requireAcks(c); err != nil {
689 return err
690 }
691 c.Op.Phase = PhasePinning
692 return nil
693 }
694}
695
696// JournalPin records the pinned recovery set in pinning. The pin's key
697// must be RecoveryPinKey for this claim generation: the object was selected
698// against exactly this observation (B2-3). After a release it must reach
699// the journaled final TXID and files sequence; on a failover it must be
700// taken no earlier than the fence's observed_at plus FailoverPinDelay. An
701// Empty pin needs its evidence: a release at final_txid 0, or a holder
702// that never admitted a writer.
703func JournalPin(opID string, p RecoveryPin) func(*Claim) error {
704 return func(c *Claim) error {
705 if err := requireRelocation(c, opID, PhasePinning); err != nil {
706 return err
707 }
708 if p.Key != RecoveryPinKey(c.Host, opID, c.Gen) {
709 return fmt.Errorf("%w: pin %s was not selected against this observation (gen %d)", ErrRelocation, p.Key, c.Gen)
710 }
711 if !isHex64(p.SHA256) || p.FilesManifest == "" || p.PinnedAt.IsZero() || (p.Empty != (p.MaxTXID == 0)) || p.Release == "" {
712 return fmt.Errorf("%w: incomplete pin", ErrRelocation)
713 }
714 var r Release
715 released := readJournal(c.Op, "release", &r)
716 if released {
717 if p.MaxTXID < r.FinalTXID || p.FilesSeq < r.ManifestSeq {
718 return fmt.Errorf("%w: pin %d/%d is short of the release's %d/%d", ErrRelocation, p.MaxTXID, p.FilesSeq, r.FinalTXID, r.ManifestSeq)
719 }
720 }
721 if p.Empty && !((released && r.FinalTXID == 0) || (!released && c.Holder.Session == 0)) {
722 return fmt.Errorf("%w: an empty pin needs a release at txid 0 or a holder that never had a writer", ErrRelocation)
723 }
724 for key := range c.Op.Journal {
725 // A fence journaled without its observation time (a move
726 // converted to relocating, which binds fence.<box> alone) owes
727 // the hold too: absence is never evidence, so the pin waits
728 // until FenceSource has consumed the fence and journaled when
729 // it was observed (H3).
730 if strings.HasPrefix(key, "fence.") && !strings.HasSuffix(key, ".observed_at") && c.Op.Journal[key+".observed_at"] == "" {
731 return fmt.Errorf("%w: %s has no observation time; consume the fence through FenceSource before pinning", ErrRelocation, key)
732 }
733 }
734 for key, at := range c.Op.Journal {
735 if len(key) > len(".observed_at") && key[len(key)-len(".observed_at"):] == ".observed_at" {
736 t, err := time.Parse(time.RFC3339Nano, at)
737 if err != nil || p.PinnedAt.Before(t.Add(FailoverPinDelay)) {
738 return fmt.Errorf("%w: pinned before the fence's observation plus %s", ErrRelocation, FailoverPinDelay)
739 }
740 }
741 }
742 if readJournal(c.Op, "pin", new(RecoveryPin)) {
743 return ErrAlreadyRecorded
744 }
745 return journalJSON(c, "pin", p)
746 }
747}
748
749// NameTarget names the target of an operation that has none: a move
750// converted to relocating before its flip (claim §4, moves §7.10), at
751// pinning; or a committed landing converted to a failover
752// (ConvertCommittedLanding), in starting, where the named box then
753// acknowledges before PinFailover selects any pin, as an initial
754// failover's target does. The named box acknowledges (D6) before
755// ToInstalling hands it op.exec. The move's own target (move_target_box,
756// in the region the move was going to) leaves the barrier in the same CAS:
757// readiness binds to the box named here (moves §7.10), and a failover's
758// participant is its target alone (§5.8). It never held op.exec on the
759// converted operation.
760func NameTarget(opID string, t Target) func(*Claim) error {
761 return func(c *Claim) error {
762 if err := requireRelocation(c, opID, PhaseStarting, PhasePinning); err != nil {
763 return err
764 }
765 if c.Op.Phase == PhaseStarting && (!failover(c.Op) || c.Op.Exec != "none") {
766 return fmt.Errorf("%w: only a failover with no executor names its target in starting", ErrRelocation)
767 }
768 if c.Op.Intent["target_box"] != "" {
769 return fmt.Errorf("%w: the operation already has a target", ErrRelocation)
770 }
771 if moved := c.Op.Intent["move_target_box"]; moved != "" {
772 removeParticipant(c, moved)
773 }
774 return replaceTargetSlot(c, t)
775 }
776}
777
778// ToInstalling hands op.exec to the target for a new installation: a new
779// stamp (this CAS's gen, R6-4), journaled as install_stamp, a fresh
780// prepared generation id, and the target added to touched.boxes before its
781// first effect. A converted operation with no target names it here.
782func ToInstalling(opID string, t Target, prepared string) func(*Claim) error {
783 return func(c *Claim) error {
784 if err := requireRelocation(c, opID, PhasePinning); err != nil {
785 return err
786 }
787 if !readJournal(c.Op, "pin", new(RecoveryPin)) || prepared == "" || prepared == c.Holder.Generation {
788 return fmt.Errorf("%w: installing needs a pin and a fresh prepared generation", ErrRelocation)
789 }
790 if t.Box != c.Op.Intent["target_box"] || t.Enrollment != c.Op.Intent["target_enrollment"] || slices.Contains(c.Lost, t.Box) {
791 return fmt.Errorf("%w: not the operation's target", ErrRelocation)
792 }
793 if OwesLocal(c, t.Box) {
794 return fmt.Errorf("%w: park-local on %s", ErrPendingChore, t.Box)
795 }
796 if err := requireAcks(c); err != nil {
797 return err
798 }
799 return install(c, t.Box, prepared, PhaseInstalling)
800 }
801}
802
803// abandonPrepared journals the operation's current P as abandoned when
804// no writer opened it, before a transition replaces or drops it: nothing
805// will open it now, and the serving CAS parks it (rev 17, finding F3;
806// rev 18: every transition that discards an unopened P calls it).
807func abandonPrepared(c *Claim) error {
808 old := c.Op.Journal["prepared"]
809 if old == "" || preparedOpened(c) {
810 return nil
811 }
812 var abandoned []string
813 readJournal(c.Op, "abandoned_prepared", &abandoned)
814 if slices.Contains(abandoned, old) {
815 return nil
816 }
817 return journalJSON(c, "abandoned_prepared", append(abandoned, old))
818}
819
820// install hands op.exec to box for an installation at a new stamp. The
821// same CAS journals install_stamp and install_release, the journaled pin's
822// release (R9): a replayed or re-targeted installation copies the same
823// pin's value and never reads anything else. The two keys are written
824// together and only here, and no transition clears one without the other
825// (only the serving CAS, which ends the operation, drops both), so
826// install_release always names the release of the installation
827// install_stamp names.
828func install(c *Claim, box, prepared, phase string) error {
829 var pin RecoveryPin
830 if !readJournal(c.Op, "pin", &pin) || pin.Release == "" {
831 return fmt.Errorf("%w: an installation needs the journaled pin's release", ErrRelocation)
832 }
833 if c.Op.Journal["prepared"] != prepared {
834 if err := abandonPrepared(c); err != nil {
835 return err
836 }
837 }
838 stamp := c.Gen + 1 // the resulting gen: Update sets gen = observed+1
839 c.Op.Exec, c.Op.Phase = box, phase
840 c.Holder.Stamp = stamp
841 c.Op.Journal["install_stamp"] = strconv.FormatUint(stamp, 10)
842 c.Op.Journal["install_release"] = pin.Release
843 c.Op.Journal["prepared"] = prepared
844 delete(c.Op.Journal, "local_install")
845 if !slices.Contains(c.Touched.Boxes, box) {
846 c.Touched.Boxes = append(c.Touched.Boxes, box)
847 }
848 return noteInstalledBox(c, box)
849}
850
851// ToInstalled records the verified remote build of P (§5.8, the
852// no-reinstall boundary): the install manifest at the pin's N (none for an
853// empty pin) and, always, P's files manifest at the pinned sequence, each
854// verified by a fresh read. Nobody builds P again after this CAS.
855func ToInstalled(opID string, a PreparedAuthority) func(*Claim) error {
856 return func(c *Claim) error {
857 if err := requireRelocation(c, opID, PhaseInstalling); err != nil {
858 return err
859 }
860 var pin RecoveryPin
861 readJournal(c.Op, "pin", &pin)
862 if a.Generation == "" || a.Generation != c.Op.Journal["prepared"] || a.SnapshotTXID != pin.MaxTXID ||
863 (pin.MaxTXID > 0 && (a.InstallManifest == "" || !isHex64(a.SHA256))) ||
864 (pin.MaxTXID == 0 && (a.InstallManifest != "" || a.SHA256 != "")) {
865 return fmt.Errorf("%w: prepared authority is not this pin's build of P", ErrRelocation)
866 }
867 if a.FilesSeq != 0 || a.SourceFilesSeq != pin.FilesSeq || !isHex64(a.FilesManifestSHA256) {
868 return fmt.Errorf("%w: prepared authority lacks P's prepared files manifest (sequence 0, built from the pinned sequence)", ErrRelocation)
869 }
870 if err := requireAcks(c); err != nil {
871 return err
872 }
873 stamp, err := journalUint(c.Op, "install_stamp")
874 if err != nil {
875 return err
876 }
877 c.Op.Phase = PhaseInstalled
878 a.InstallStamp = stamp
879 return journalJSON(c, "prepared_authority", a)
880 }
881}
882
883// PreparedOpened reports whether P has been opened: journaled as
884// prepared_opened by the admission that opened it (rev 12: AdmitWriter
885// trims holder.admissions to eight, so the admission list alone could
886// forget it), or named by a retained admission.
887func PreparedOpened(c *Claim) bool { return preparedOpened(c) }
888
889func preparedOpened(c *Claim) bool {
890 p := c.Op.Journal["prepared"]
891 if p == "" || c.Holder == nil {
892 return false
893 }
894 if c.Op.Journal["prepared_opened"] == p {
895 return true
896 }
897 for _, a := range c.Holder.Admissions {
898 if a.Generation == p {
899 return true
900 }
901 }
902 return false
903}
904
905// RecordLocalInstall journals the executor's local install receipt in
906// installed (B1-2). It is bound to op.exec, the target's enrollment and the
907// journaled install_stamp, and refused once any admission has named P
908// (B2-5): after that the local install is never remade.
909func RecordLocalInstall(opID string, l LocalInstall) func(*Claim) error {
910 return func(c *Claim) error {
911 if err := requireRelocation(c, opID, PhaseInstalled); err != nil {
912 return err
913 }
914 if err := requireReceiptOfInstallation(c, l); err != nil {
915 return err
916 }
917 var old LocalInstall
918 if readJournal(c.Op, "local_install", &old) {
919 if old == l {
920 return ErrAlreadyRecorded
921 }
922 return fmt.Errorf("%w: a different receipt is journaled", ErrRelocation)
923 }
924 return journalJSON(c, "local_install", l)
925 }
926}
927
928// ReplaceLocalInstall replaces the journaled receipt old with l after a
929// repair of the local install under the same installation (stage B final
930// review A3): the repair re-renders the held configuration, which may
931// have changed since old was journaled, so the box's durable journal and
932// its digest are new while the box, enrollment and install stamp are the
933// same. Only before P is admitted (an opened P is never reinstalled), only
934// over exactly old, and only for the same installation: the three readers
935// of the receipt (the pre-admission verification, the post-admission
936// check and the receipt check after P opens) then compare one digest.
937func ReplaceLocalInstall(opID string, old, l LocalInstall) func(*Claim) error {
938 return func(c *Claim) error {
939 if err := requireRelocation(c, opID, PhaseInstalled, PhaseRelocatingProbing); err != nil {
940 return err
941 }
942 if err := requireReceiptOfInstallation(c, l); err != nil {
943 return err
944 }
945 if l.Box != old.Box || l.Enrollment != old.Enrollment || l.InstallStamp != old.InstallStamp {
946 return fmt.Errorf("%w: the repaired receipt is not the journaled installation's", ErrRelocation)
947 }
948 var cur LocalInstall
949 if !readJournal(c.Op, "local_install", &cur) || cur != old {
950 return fmt.Errorf("%w: the journaled receipt is not the one being replaced", ErrRelocation)
951 }
952 if cur == l {
953 return ErrAlreadyRecorded
954 }
955 return journalJSON(c, "local_install", l)
956 }
957}
958
959// requireReceiptOfInstallation is the receipt's binding to the operation's
960// installation (RecordLocalInstall, ReplaceLocalInstall): the executor,
961// its enrollment, the journaled install stamp, a well-formed digest, and
962// P not yet admitted.
963func requireReceiptOfInstallation(c *Claim, l LocalInstall) error {
964 stamp, err := journalUint(c.Op, "install_stamp")
965 if err != nil {
966 return err
967 }
968 if l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] ||
969 l.InstallStamp == 0 || l.InstallStamp != stamp || !isHex64(l.JournalSHA256) {
970 return fmt.Errorf("%w: receipt is not this installation's", ErrRelocation)
971 }
972 if preparedOpened(c) {
973 return fmt.Errorf("%w: P was admitted; the local install is never remade", ErrRelocation)
974 }
975 return nil
976}
977
978// ToProbing enters probing, which requires a local install receipt for
979// exactly op.exec, its enrollment and the journaled install_stamp. now
980// starts the probe deadline (§5.8: 10 minutes).
981func ToProbing(opID string, now time.Time) func(*Claim) error {
982 return func(c *Claim) error {
983 if err := requireRelocation(c, opID, PhaseInstalled); err != nil {
984 return err
985 }
986 stamp, err := journalUint(c.Op, "install_stamp")
987 if err != nil {
988 return err
989 }
990 var l LocalInstall
991 if !readJournal(c.Op, "local_install", &l) || l.Box != c.Op.Exec || l.Enrollment != c.Op.Intent["target_enrollment"] ||
992 l.InstallStamp != stamp {
993 return fmt.Errorf("%w: no local install receipt for the executor", ErrRelocation)
994 }
995 if err := requireAcks(c); err != nil {
996 return err
997 }
998 if now.IsZero() {
999 return fmt.Errorf("%w: probing needs its start time", ErrRelocation)
1000 }
1001 c.Op.Phase = PhaseRelocatingProbing
1002 c.Op.Journal["probing_since"] = now.UTC().Format(time.RFC3339Nano)
1003 return nil
1004 }
1005}
1006
1007// BeginProbeAttempt counts one probe attempt for the executor before it
1008// is made (§5.8 "limits"): the fourth is refused with
1009// ErrProbeAttemptsExhausted, and the participant then enters
1010// probe-failed. The count is per box and per operation, so a replacement
1011// target starts at zero and a fenced one is never counted again.
1012func BeginProbeAttempt(opID, box string) func(*Claim) error {
1013 return func(c *Claim) error {
1014 if err := requireRelocation(c, opID, PhaseRelocatingProbing); err != nil {
1015 return err
1016 }
1017 if box == "" || box != c.Op.Exec {
1018 return fmt.Errorf("%w: only the executor probes", ErrRelocation)
1019 }
1020 key := "probe_attempts." + box
1021 n, err := journalUint(c.Op, key)
1022 if err != nil {
1023 return err
1024 }
1025 if n >= MaxProbeAttempts {
1026 return ErrProbeAttemptsExhausted
1027 }
1028 c.Op.Journal[key] = strconv.FormatUint(n+1, 10)
1029 return nil
1030 }
1031}
1032
1033// RecordProbe journals the executor's passing probe of its admitted session.
1034func RecordProbe(opID string, p Probe) func(*Claim) error {
1035 return func(c *Claim) error {
1036 if err := requireRelocation(c, opID, PhaseRelocatingProbing); err != nil {
1037 return err
1038 }
1039 stamp, err := journalUint(c.Op, "install_stamp")
1040 if err != nil {
1041 return err
1042 }
1043 if p.Box != c.Op.Exec || p.Box != c.Holder.WriterBox || p.Session == 0 || p.Session != c.Holder.Session ||
1044 p.Session < stamp {
1045 return fmt.Errorf("%w: probe is not of the executor's admitted session", ErrRelocation)
1046 }
1047 return journalJSON(c, "probe", p)
1048 }
1049}
1050
1051// ToProbeFailed records that the live target's probe failed or missed its
1052// deadline. The target then runs its release sequence (JournalRelease);
1053// now starts its release deadline.
1054func ToProbeFailed(opID string, now time.Time) func(*Claim) error {
1055 return func(c *Claim) error {
1056 if err := requireRelocation(c, opID, PhaseRelocatingProbing); err != nil {
1057 return err
1058 }
1059 if now.IsZero() {
1060 return fmt.Errorf("%w: probe-failed needs its start time", ErrRelocation)
1061 }
1062 c.Op.Phase = PhaseProbeFailed
1063 c.Op.Journal["probe_failed_since"] = now.UTC().Format(time.RFC3339Nano)
1064 delete(c.Op.Journal, "release")
1065 delete(c.Op.Journal, "release.phase")
1066 return nil
1067 }
1068}
1069
1070// CompleteRelocation is the serving CAS: the target's probe passed for its
1071// admitted session, so the host serves on it alone. Every other box this
1072// operation installed on, and a live source, owes a park-local unless a
1073// fence discharged it.
1074func CompleteRelocation(opID string, newID func() string) func(*Claim) error {
1075 return func(c *Claim) error {
1076 if err := requireRelocation(c, opID, PhaseRelocatingProbing); err != nil {
1077 return err
1078 }
1079 var p Probe
1080 if !readJournal(c.Op, "probe", &p) || p.Box != c.Op.Exec || p.Session != c.Holder.Session || c.Holder.WriterBox != p.Box {
1081 return fmt.Errorf("%w: no passing probe of the current session", ErrRelocation)
1082 }
1083 if !probeStopped(c, p) {
1084 // Ruling 49: the kind change ends the probe's session, so the
1085 // session is stopped, with root's receipt, before it.
1086 return fmt.Errorf("%w: the probe's session is not stopped", ErrRelocation)
1087 }
1088 target := c.Op.Exec
1089 var owed []string
1090 readJournal(c.Op, "installed_boxes", &owed)
1091 owed = append([]string{c.Op.Intent["source_box"]}, owed...)
1092 for _, box := range owed {
1093 if box == target || box == "" {
1094 continue
1095 }
1096 var d LocalDischarge
1097 if readJournal(c.Op, "discharge."+box, &d) && d.Qualified {
1098 continue
1099 }
1100 exists := false
1101 for _, o := range c.Obligations {
1102 if o.Kind == "park-local" && o.Box == box {
1103 exists = true
1104 }
1105 }
1106 if !exists {
1107 c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-local", Box: box, Retirement: newID(), Owner: "box:" + box})
1108 }
1109 }
1110 // Every prepared generation this operation built and no writer
1111 // opened is parked here, in the region, by the claim's own
1112 // park-generation chore (rev 17, finding F3); the at-rest sweep
1113 // stays the backstop the spec names.
1114 var abandoned []string
1115 readJournal(c.Op, "abandoned_prepared", &abandoned)
1116 region := c.Op.Intent["region"]
1117 for _, g := range abandoned {
1118 if g == "" || g == c.Holder.Generation || region == "" {
1119 continue
1120 }
1121 queued := false
1122 for _, o := range c.Obligations {
1123 if o.Kind == "park-generation" && regionname.SameRegion(c.Op.Intent["home_region"], o.Region, region) && o.Generation == g {
1124 queued = true
1125 }
1126 }
1127 if !queued {
1128 c.Obligations = append(c.Obligations, Obligation{ID: newID(), Op: opID, Kind: "park-generation", Region: region, Generation: g, Retirement: newID(), Owner: "region:" + region})
1129 }
1130 }
1131 var stop ProbeStop
1132 readJournal(c.Op, "probe_stop", &stop)
1133 done, err := json.Marshal(CompletedRelocation{Op: opID, Stop: stop, Stamp: c.Holder.Stamp, Phase: c.Op.Phase, Generation: c.Holder.Generation})
1134 if err != nil {
1135 return err
1136 }
1137 // Ruling 56: the serving op keeps the stopped probe session's
1138 // receipt reference for the next cold wake's handover.
1139 next := Op{Kind: KindServing, Exec: target, Participants: []string{target}, Journal: map[string]string{JournalCompleted: string(done)}}
1140 if c.Op.Journal["conversion"] == "committed-landing" {
1141 // M1 on amadan #236: the move this relocation finished is named
1142 // on the serving op, so moves' close can find it.
1143 next.Journal[journalReleasedMove] = opID
1144 }
1145 c.Op = next
1146 return nil
1147 }
1148}
1149
1150// replaceTargetSlot names t as the target, counting a replacement when it
1151// replaces another box. A replaced box is never named again for this host.
1152func replaceTargetSlot(c *Claim, t Target) error {
1153 if !validBoxReference(t.Box) || t.Box == "none" || t.Enrollment == "" || t.Box == c.Op.Intent["source_box"] || slices.Contains(c.Lost, t.Box) {
1154 return fmt.Errorf("%w: invalid target", ErrRelocation)
1155 }
1156 var excluded []string
1157 readJournal(c.Op, "excluded_targets", &excluded)
1158 if slices.Contains(excluded, t.Box) {
1159 return fmt.Errorf("%w: %s was replaced in this operation", ErrRelocation, t.Box)
1160 }
1161 if OwesLocal(c, t.Box) {
1162 // Spec 1 §2: a box owing a local retirement for this host is never
1163 // handed an installation, in any phase a replacement resumes at
1164 // (install is called below without ToInstalling's check).
1165 return fmt.Errorf("%w: park-local on %s", ErrPendingChore, t.Box)
1166 }
1167 old := c.Op.Intent["target_box"]
1168 if old != "" && old != t.Box {
1169 n, err := journalUint(c.Op, "replacements")
1170 if err != nil {
1171 return err
1172 }
1173 if n >= MaxTargetReplacements {
1174 return ErrReplacementsExceeded
1175 }
1176 c.Op.Journal["replacements"] = strconv.FormatUint(n+1, 10)
1177 excluded = append(excluded, old)
1178 if err := journalJSON(c, "excluded_targets", excluded); err != nil {
1179 return err
1180 }
1181 removeParticipant(c, old)
1182 }
1183 c.Op.Intent["target_box"], c.Op.Intent["target_enrollment"] = t.Box, t.Enrollment
1184 addParticipant(c, t.Box)
1185 return nil
1186}
1187
1188// ReplaceTarget is §5.8's replacement table for a target terminated by its
1189// fence. It records the fence (the old target goes on lost), names next,
1190// and resumes at the phase the table gives:
1191//
1192// starting, releasing, pinning -> the same phase
1193// installing -> installing: next is op.exec, new stamp, fresh P'
1194// installed, or probing before P -> installed: next is op.exec, new stamp, same P, no receipt
1195// probing after P, or probe-failed -> pinning: a nested failover from holder.generation
1196//
1197// prepared is P' for installing and ignored otherwise. A third
1198// replacement is refused with ErrReplacementsExceeded; the worker then
1199// marks the operation stuck.
1200func ReplaceTarget(ctx context.Context, s release.Store, observed Observed, opID string, ref FenceRef, d *LocalDischarge, next Target, prepared string, authority FenceAuthority) (Observed, error) {
1201 if observed.Absent || observed.Claim == nil {
1202 return Observed{}, ErrWrongState
1203 }
1204 old := observed.Claim.Op.Intent["target_box"]
1205 v, err := VerifyFence(ctx, s, authority, ref)
1206 if err != nil {
1207 return Observed{}, err
1208 }
1209 if old == "" || v.record.Box != old || v.record.Enrollment != observed.Claim.Op.Intent["target_enrollment"] || v.binding.Region != observed.Claim.Op.Intent["region"] {
1210 return Observed{}, ErrFence
1211 }
1212 return Update(ctx, s, observed, func(c *Claim) error {
1213 if err := requireRelocation(c, opID, PhaseStarting, PhaseReleasing, PhasePinning, PhaseInstalling, PhaseInstalled, PhaseRelocatingProbing, PhaseProbeFailed); err != nil {
1214 return err
1215 }
1216 if c.Op.Intent["target_box"] != old {
1217 return ErrStaleOp
1218 }
1219 phase := c.Op.Phase
1220 if err := fenceBox(c, old, ref, v.record.ObservedAt, d); err != nil {
1221 return err
1222 }
1223 if err := replaceTargetSlot(c, next); err != nil {
1224 return err
1225 }
1226 switch {
1227 case phase == PhaseStarting || phase == PhaseReleasing || phase == PhasePinning:
1228 return nil
1229 case phase == PhaseInstalling:
1230 if prepared == "" || prepared == c.Op.Journal["prepared"] || prepared == c.Holder.Generation {
1231 return fmt.Errorf("%w: a replacement in installing builds a fresh P'", ErrRelocation)
1232 }
1233 return install(c, next.Box, prepared, PhaseInstalling)
1234 case (phase == PhaseInstalled || phase == PhaseRelocatingProbing) && !preparedOpened(c):
1235 return install(c, next.Box, c.Op.Journal["prepared"], PhaseInstalled)
1236 default:
1237 // After P's first admission, or from probe-failed: nested
1238 // failover from pinning, pinning from holder.generation as
1239 // observed. A P no writer opened (a probe deadline before any
1240 // admission) is recorded before it is dropped.
1241 if err := abandonPrepared(c); err != nil {
1242 return err
1243 }
1244 c.Op.Exec, c.Op.Phase = "none", PhasePinning
1245 for _, k := range []string{"pin", "prepared", "prepared_authority", "local_install", "probe", "release", "release.phase"} {
1246 delete(c.Op.Journal, k)
1247 }
1248 return nil
1249 }
1250 })
1251}
1252
1253// FenceExhaustedTarget is the terminal transition for a target terminated
1254// after the operation has used its replacements (§5.8 "The bound"; round 3
1255// finding 5): it consumes the fence (the loss is retained, the box goes on
1256// lost) and records stuck. While another box is op.exec (the live source
1257// in starting or releasing; round 8 finding 1) nothing else changes: the
1258// target's fence revokes nothing of the source's. Otherwise (op.exec is
1259// the dead target, or none) it revokes the dead installation's authority (op.exec "none", the
1260// target off the barrier, its receipt dropped), leaves the operation at
1261// pinning with its pin and, when P was never opened, its remote build kept
1262// for the operator's replacement, or a nested pinning when P was opened,
1263// and records stuck in the same CAS. No third target is chosen. MarkStuck's
1264// "never between installed and a probe" is for a live target; a dead one
1265// has no probe to make.
1266func FenceExhaustedTarget(ctx context.Context, s release.Store, observed Observed, opID string, ref FenceRef, d *LocalDischarge, authority FenceAuthority) (Observed, error) {
1267 return fenceTargetStuck(ctx, s, observed, opID, ref, d, authority, "")
1268}
1269
1270// FenceTargetWithoutSpare is FenceExhaustedTarget's transition for a
1271// terminated target with replacements left but no eligible box to name
1272// (rev 18, round 17 finding 2): the worker calls it once its bound has
1273// passed. The fence is consumed in the same CAS (loss retained, the box
1274// on lost, a dead executor's authority revoked), and the operation is
1275// stuck for an operator with reason, never left waiting unbounded.
1276func FenceTargetWithoutSpare(ctx context.Context, s release.Store, observed Observed, opID string, ref FenceRef, d *LocalDischarge, authority FenceAuthority, reason string) (Observed, error) {
1277 if reason == "" {
1278 return Observed{}, fmt.Errorf("%w: a stuck operation names its reason", ErrRelocation)
1279 }
1280 return fenceTargetStuck(ctx, s, observed, opID, ref, d, authority, reason)
1281}
1282
1283// fenceTargetStuck is both: noSpare "" is the exhausted bound.
1284func fenceTargetStuck(ctx context.Context, s release.Store, observed Observed, opID string, ref FenceRef, d *LocalDischarge, authority FenceAuthority, noSpare string) (Observed, error) {
1285 if observed.Absent || observed.Claim == nil {
1286 return Observed{}, ErrWrongState
1287 }
1288 old := observed.Claim.Op.Intent["target_box"]
1289 v, err := VerifyFence(ctx, s, authority, ref)
1290 if err != nil {
1291 return Observed{}, err
1292 }
1293 if old == "" || v.record.Box != old || v.record.Enrollment != observed.Claim.Op.Intent["target_enrollment"] || v.binding.Region != observed.Claim.Op.Intent["region"] {
1294 return Observed{}, ErrFence
1295 }
1296 return Update(ctx, s, observed, func(c *Claim) error {
1297 if err := RequireOp(opID)(c); err != nil {
1298 return err
1299 }
1300 if c.Op.Kind != KindRelocating || c.Op.Intent["target_box"] != old {
1301 return ErrStaleOp
1302 }
1303 ensureJournal(c)
1304 if c.Op.Journal["stuck"] != "" {
1305 return ErrAlreadyRecorded
1306 }
1307 reason := noSpare
1308 if reason == "" {
1309 n, err := journalUint(c.Op, "replacements")
1310 if err != nil {
1311 return err
1312 }
1313 if n < MaxTargetReplacements {
1314 return fmt.Errorf("%w: replacements remain; replace the target instead", ErrRelocation)
1315 }
1316 reason = fmt.Sprintf("%s terminated after %d target replacements", old, MaxTargetReplacements)
1317 }
1318 if err := fenceBox(c, old, ref, v.record.ObservedAt, d); err != nil {
1319 return err
1320 }
1321 removeParticipant(c, old)
1322 if c.Op.Exec != old && c.Op.Exec != "none" {
1323 // The executor is another box (the live source in starting or
1324 // releasing): the target's fence is no evidence against it.
1325 // Its execution identity, phase and release records stay, so
1326 // root still accepts its own stop (claimstop.go requires the
1327 // requester to be op.exec); only stuck is new.
1328 c.Op.Journal["stuck"] = reason
1329 return nil
1330 }
1331 opened := preparedOpened(c)
1332 c.Op.Exec, c.Op.Phase = "none", PhasePinning
1333 keys := []string{"local_install", "probe", "release", "release.phase", "probing_since", "probe_failed_since"}
1334 if opened {
1335 // Nested: pinning again from holder.generation, as a replacement
1336 // after P's first admission would.
1337 keys = append(keys, "pin", "prepared", "prepared_authority")
1338 }
1339 // An unopened P is kept for the operator's replacement; it is on
1340 // the abandoned list only if a later transition drops it.
1341 for _, k := range keys {
1342 delete(c.Op.Journal, k)
1343 }
1344 if c.Op.Journal["stuck"] == "" {
1345 c.Op.Journal["stuck"] = reason
1346 }
1347 return nil
1348 })
1349}
1350
1351// MarkStuck stops the operation at its current phase for an operator
1352// (`carlos ops host resolve`). It never resets the replacement count and
1353// never invents evidence; every later relocation transition refuses.
1354func MarkStuck(opID, reason string) func(*Claim) error {
1355 return func(c *Claim) error {
1356 if err := RequireOp(opID)(c); err != nil {
1357 return err
1358 }
1359 if c.Op.Kind != KindRelocating || reason == "" {
1360 return ErrWrongState
1361 }
1362 ensureJournal(c)
1363 if c.Op.Phase == PhaseInstalled && c.Op.Journal["local_install"] != "" && !preparedOpened(c) {
1364 return fmt.Errorf("%w: never stop between installed and a probe", ErrRelocation)
1365 }
1366 if c.Op.Journal["stuck"] != "" {
1367 return ErrAlreadyRecorded
1368 }
1369 c.Op.Journal["stuck"] = reason
1370 return nil
1371 }
1372}
1373