carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostrelocate
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "slices"
9 "time"
10
11 "github.com/carlosframework/platform/internal/hostclaim"
12 "github.com/carlosframework/platform/internal/release"
13)
14
15// Effects are a participant box's local effects, each under the host
16// guard and bound to the claim observation it is given. Plan 6b's box
17// assembly implements them (ClaimAssembly); this package only orders them
18// and records each result in the claim.
19type Effects interface {
20 // Acknowledge writes this box's pending controller state, refuses while
21 // root has an update begun (§5.10), takes the execution lease
22 // (hostexec.Open under the agent's lifetime, then Ready) and returns
23 // the acknowledgement to journal. After an agent restart it opens a
24 // fresh controller for the same participation; the journaled
25 // acknowledgement (the first lease, D6) is left as it is.
26 Acknowledge(context.Context, hostclaim.Observed) (hostclaim.ExecutionAcknowledgement, error)
27 // Ready reports whether this box's execution controller for the
28 // operation is open and active now: ErrNoController when none is
29 // retained (the agent restarted, or a controller that lost its lease
30 // was joined and its scoped cleanup repeated), and the join's or the
31 // cleanup's error while a lost one cannot be retired yet.
32 // Every effect after the acknowledgement runs only after Ready.
33 Ready(context.Context, hostclaim.Observed) error
34 // Release runs §5.8's release sequence for this box's current writer
35 // session: queue main's relocation-release obligation, stop every
36 // writer (main's ClaimAssembly.WritersStopped, with root's own
37 // observation), flush the database to its final TXID, publish every
38 // pending file and wake-state mutation, close the replicator and
39 // publisher, complete full stop evidence
40 // (ClaimStopCoordinator.FenceAndStop), and discharge the obligation with
41 // root's receipt. It returns the journal entry; it is idempotent for
42 // one session.
43 Release(context.Context, hostclaim.Observed) (hostclaim.Release, error)
44 // Build builds the prepared generation P from the journaled pin with
45 // moves' shared install routine (L16), under the replica lease.
46 Build(context.Context, hostclaim.Observed) error
47 // InstallLocal makes this box's local install from P's files manifest
48 // and returns the receipt (§5.8 "The local install").
49 InstallLocal(context.Context, hostclaim.Observed) (hostclaim.LocalInstall, error)
50 // EnsureLocal is called before P's first admission, each time: it
51 // verifies that the local install the journaled receipt names is
52 // complete on this box and, when it is not (a lost lease's scoped
53 // cleanup retires an unopened P's local install), restores it under
54 // install_stamp from P's files manifest. It is never called once P is
55 // opened: an opened P is never reinstalled (rev 15, round 14). A
56 // repair may write the claim: the typed inventory, and the receipt
57 // itself when the repaired journal's digest differs (a configuration
58 // changed since it was journaled; final review A3), replaced in one
59 // CAS before any admission. The caller re-reads the same installation
60 // after it.
61 EnsureLocal(context.Context, hostclaim.Observed) error
62 // VerifyReceipt is called before every admission after P is opened:
63 // it reports whether this box's durable local install receipt is
64 // still the one the claim journaled (box, enrollment, install_stamp,
65 // journal digest). It checks the receipt and its binding only, never
66 // the files, env or timers, which the admitted writers have changed
67 // legitimately since. A missing or different receipt is false: the
68 // local state cannot be rebuilt from P, so the operation is stuck
69 // (§5.8, B2-5; rev 16, round 15).
70 VerifyReceipt(context.Context, hostclaim.Observed) (bool, error)
71 // Probe makes one cold probe admission (AdmitWriter, PredecessorCertified
72 // as the caller names it, coverage "" as claim Plan 11's relocating
73 // admission requires), runs the probe and waits for it to complete before
74 // returning (R1: one attempt, its exact health). passed is the probe's
75 // result; false is a failed attempt, never a probe still running. The
76 // caller counts an attempt in the claim before each call, so a Probe
77 // that returned early would spend the attempts without probing.
78 Probe(ctx context.Context, obs hostclaim.Observed, certified bool) (session uint64, passed bool, err error)
79 // PassedProbe reports the probe this box already made for the claim's
80 // admitted session when its own durable attempt record holds it as
81 // healthy (stage B final review A2): after Probe returned passed, the
82 // claim read or the CAS that records the result may have failed,
83 // leaving the live, passed session with no journaled probe. It is
84 // asked once P is opened and no probe is journaled, before Preflight
85 // or any attempt, so the result is recovered rather than a new attempt
86 // launched (none can be: ruling 58, until claim ask C11). ok is false
87 // when the record holds no healthy start for the holder's session; a
88 // record that cannot be read is the error, never "no probe".
89 PassedProbe(ctx context.Context, obs hostclaim.Observed) (session uint64, ok bool, err error)
90 // Preflight is called in probing before an attempt is counted (region
91 // HA ruling 48): root's isolated launch must speak the protocol and
92 // install an attempt reader for relocating, or nothing is counted.
93 Preflight(context.Context, hostclaim.Observed) error
94 // EndProbe stops this box's passed probe session while the op is still
95 // relocating/probing and returns root's receipt for it (ruling 49):
96 // the activator's local fence and stop, then the claim's writer-session
97 // stop through root. Idempotent for one session.
98 EndProbe(context.Context, hostclaim.Observed) (hostclaim.ProbeStop, error)
99 // Retained lists the participations whose execution controllers this
100 // box still holds; Finish closes one and releases its lifetime lock.
101 // A controller is finished once the claim no longer runs that
102 // operation with this box as a participant (Sweep), so the next
103 // relocation of the host can open its own (hostexec keeps the lock
104 // until Close). Retained also lists every participation journaled on
105 // disk whose durable record a restart still has to clear; an entry it
106 // cannot read is the error, returned beside every one it can.
107 Retained() ([]Participation, error)
108 Finish(ctx context.Context, host, op string) error
109}
110
111// Participation names one retained execution controller.
112type Participation struct {
113 Host, Op string
114}
115
116// Participant is one box's half of relocation. Step makes at most one
117// participant transition for one host, chosen from the claim alone.
118type Participant struct {
119 Claims release.Store
120 Box string
121 Enrollment string
122 Effects Effects
123 Now func() time.Time
124}
125
126// Step acts for host if this box has work in its relocation, and reports
127// ErrNotYet when it has none.
128func (p Participant) Step(ctx context.Context, host string) (hostclaim.Observed, error) {
129 if p.Claims == nil || p.Box == "" || p.Enrollment == "" || p.Effects == nil || p.Now == nil {
130 return hostclaim.Observed{}, ErrUnsupported
131 }
132 obs, err := hostclaim.Read(ctx, p.Claims, host)
133 if err != nil || obs.Absent {
134 return obs, err
135 }
136 c := obs.Claim
137 if c.Op.Kind != hostclaim.KindRelocating || c.Op.Journal["stuck"] != "" || !slices.Contains(c.Op.Participants, p.Box) || slices.Contains(c.Lost, p.Box) {
138 return obs, ErrNotYet
139 }
140 id := c.Op.ID
141 if c.Op.Journal["exec_ack."+p.Box] == "" {
142 ack, err := p.Effects.Acknowledge(ctx, obs)
143 if err != nil {
144 return obs, err
145 }
146 return p.update(ctx, obs, hostclaim.AcknowledgeExecution(p.Box, ack))
147 }
148 // The controller is checked before every effect, never inferred from
149 // the historical acknowledgement: a restarted agent reopens one (the
150 // claim keeps the first lease's record) and acts on the next pass.
151 if err := p.Effects.Ready(ctx, obs); errors.Is(err, ErrNoController) {
152 _, err := p.Effects.Acknowledge(ctx, obs)
153 return obs, errors.Join(ErrNotYet, err)
154 } else if err != nil {
155 return obs, err
156 }
157 executor := c.Op.Exec == p.Box
158 switch {
159 case executor && (c.Op.Phase == hostclaim.PhaseReleasing || c.Op.Phase == hostclaim.PhaseProbeFailed) && c.Op.Journal["release"] == "":
160 r, err := p.Effects.Release(ctx, obs)
161 if err != nil {
162 return obs, err
163 }
164 // The release wrote the claim (main's relocation-release obligation,
165 // queued before root's stop and discharged by its receipt): journal
166 // it on a fresh read.
167 fresh, err := hostclaim.Read(ctx, p.Claims, host)
168 if err != nil || fresh.Absent {
169 return obs, errors.Join(ErrNotYet, err)
170 }
171 return p.update(ctx, fresh, hostclaim.JournalRelease(id, r))
172 case executor && c.Op.Phase == hostclaim.PhaseInstalling:
173 return obs, errors.Join(ErrNotYet, p.Effects.Build(ctx, obs))
174 case executor && c.Op.Phase == hostclaim.PhaseInstalled:
175 if c.Op.Journal["local_install"] != "" {
176 // The receipt is a past fact; the files, env and timers it
177 // names are checked (and restored) before probing.
178 if err := p.Effects.EnsureLocal(ctx, obs); err != nil {
179 return obs, err
180 }
181 // A repairing EnsureLocal may have written the claim (the
182 // typed inventory): the transition is made on a fresh read of
183 // the same installation (rev 3, Astra round 2 finding 2).
184 fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled)
185 if err != nil {
186 return obs, err
187 }
188 // The target enters probing itself (§5.8 phase table).
189 return p.update(ctx, fresh, hostclaim.ToProbing(id, p.Now()))
190 }
191 l, err := p.Effects.InstallLocal(ctx, obs)
192 if err != nil {
193 return obs, err
194 }
195 if l.Box != p.Box || l.Enrollment != p.Enrollment {
196 return obs, ErrRefused
197 }
198 // The install recorded its typed inventory in the claim (park's
199 // RecordInventory): record the receipt on a fresh read of the
200 // same installation, never on the stale observation.
201 fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseInstalled)
202 if err != nil {
203 return obs, err
204 }
205 return p.update(ctx, fresh, hostclaim.RecordLocalInstall(id, l))
206 case executor && c.Op.Phase == hostclaim.PhaseRelocatingProbing && c.Op.Journal["probe"] == "":
207 if !hostclaim.PreparedOpened(c) {
208 // Before P's first admission the local install must be whole.
209 if err := p.Effects.EnsureLocal(ctx, obs); err != nil {
210 return obs, err
211 }
212 } else {
213 // After it, the install is never remade; its receipt must
214 // still be this box's, or the operation is stuck.
215 ok, err := p.Effects.VerifyReceipt(ctx, obs)
216 if err != nil {
217 return obs, err
218 }
219 if !ok {
220 return p.update(ctx, obs, hostclaim.MarkStuck(id, "the local install receipt is missing after P was admitted (§5.8, B2-5)"))
221 }
222 // A probe this box already passed whose record never reached
223 // the claim (the read or the CAS after Probe failed) is
224 // recovered from the box's durable attempt record, keyed by
225 // the admitted session, before Preflight can turn the live,
226 // passed session into probe-failed (final review A2).
227 session, passed, err := p.Effects.PassedProbe(ctx, obs)
228 if err != nil {
229 return obs, err
230 }
231 if passed {
232 return p.recordProbe(ctx, obs, session)
233 }
234 }
235 // EnsureLocal may have written the claim: count on a fresh read
236 // of the same installation.
237 if !hostclaim.PreparedOpened(c) {
238 fresh, err := p.sameInstallation(ctx, obs, hostclaim.PhaseRelocatingProbing)
239 if err != nil {
240 return obs, err
241 }
242 obs, c = fresh, fresh.Claim
243 }
244 // The release the admission would certify is read before any
245 // attempt is counted (review I3, M1): a journaled release that is
246 // unreadable, or names another writer than the predecessor, is a
247 // claim defect no attempt can mend, so the operation is stuck for
248 // an operator and no attempt is spent on it.
249 certified, err := CertifiedFor(c)
250 if err != nil {
251 return p.update(ctx, obs, hostclaim.MarkStuck(id, "the journaled release cannot certify P's opening (§5.8, C4): "+err.Error()))
252 }
253 // Root must be able to authorize a relocating launch before an
254 // attempt is spent (ruling 48): a refusal counts nothing. A later
255 // attempt root cannot launch (ruling 58, until claim ask C11) is
256 // probe-failed now, not three uncountable refusals.
257 if err := p.Effects.Preflight(ctx, obs); errors.Is(err, ErrLaterAttemptUnlaunchable) {
258 return p.update(ctx, obs, hostclaim.ToProbeFailed(id, p.Now()))
259 } else if err != nil {
260 return obs, errors.Join(ErrNotYet, err)
261 }
262 // The attempt is counted in the claim before it is made (§5.8:
263 // at most three); the fourth is probe-failed, by this CAS.
264 counted, err := hostclaim.Update(ctx, p.Claims, obs, hostclaim.BeginProbeAttempt(id, p.Box))
265 if errors.Is(err, hostclaim.ErrProbeAttemptsExhausted) {
266 return p.update(ctx, obs, hostclaim.ToProbeFailed(id, p.Now()))
267 }
268 if err != nil {
269 return obs, err
270 }
271 obs, c = counted, counted.Claim
272 session, passed, err := p.Effects.Probe(ctx, obs, certified)
273 if err != nil || !passed {
274 return obs, errors.Join(ErrNotYet, err)
275 }
276 return p.recordProbe(ctx, obs, session)
277 case executor && c.Op.Phase == hostclaim.PhaseRelocatingProbing && c.Op.Journal["probe_stop"] == "":
278 // Ruling 49: the passed probe's session is stopped, with root's
279 // receipt, before the serving CAS ends it.
280 s, err := p.Effects.EndProbe(ctx, obs)
281 if err != nil {
282 return obs, err
283 }
284 fresh, err := hostclaim.Read(ctx, p.Claims, host)
285 if err != nil || fresh.Absent {
286 return obs, errors.Join(ErrNotYet, err)
287 }
288 return p.update(ctx, fresh, hostclaim.RecordProbeStop(id, s))
289 }
290 return obs, ErrNotYet
291}
292
293// recordProbe journals the passed probe of session on a fresh read: the
294// probe's admission wrote the claim, and RecordProbe binds the result to
295// the admitted session. At is the time of this record; for a probe
296// recovered from the box's attempt record (PassedProbe) that is later than
297// the pass, never earlier, so the worker's release deadline (ruling 67)
298// is counted from no sooner than the probe passed.
299func (p Participant) recordProbe(ctx context.Context, obs hostclaim.Observed, session uint64) (hostclaim.Observed, error) {
300 fresh, err := hostclaim.Read(ctx, p.Claims, obs.Host)
301 if err != nil || fresh.Absent {
302 return obs, errors.Join(ErrNotYet, err)
303 }
304 return p.update(ctx, fresh, hostclaim.RecordProbe(obs.Claim.Op.ID, hostclaim.Probe{Box: p.Box, Session: session, At: p.Now()}))
305}
306
307// ErrLaterAttemptUnlaunchable is Preflight's answer for a cold attempt
308// after P's opening while root pins every relocating launch to
309// install_stamp (claimLaunchAuthority, claim ask C11; ruling 58).
310var ErrLaterAttemptUnlaunchable = errors.New("hostrelocate: root cannot launch a relocation attempt after P's opening")
311
312// sameInstallation re-reads the claim after an effect that may have written
313// it and requires the same operation, executor, phase, prepared P and
314// install stamp, unstuck: only the effect's own bookkeeping may differ.
315func (p Participant) sameInstallation(ctx context.Context, obs hostclaim.Observed, phase string) (hostclaim.Observed, error) {
316 fresh, err := hostclaim.Read(ctx, p.Claims, obs.Host)
317 if err != nil {
318 return obs, err
319 }
320 a, b := obs.Claim, fresh.Claim
321 if fresh.Absent || b == nil || b.Holder == nil || b.Op.Kind != hostclaim.KindRelocating || b.Op.ID != a.Op.ID || b.Op.Exec != p.Box || b.Op.Phase != phase ||
322 b.Op.Journal["prepared"] != a.Op.Journal["prepared"] || b.Op.Journal["install_stamp"] != a.Op.Journal["install_stamp"] || b.Op.Journal["stuck"] != "" ||
323 b.Holder.Incarnation != a.Holder.Incarnation || b.Holder.Session != a.Holder.Session || b.Holder.Stamp != a.Holder.Stamp {
324 return obs, errors.Join(ErrNotYet, fmt.Errorf("%w: the installation changed under the effect", ErrRefused))
325 }
326 return fresh, nil
327}
328
329// Sweep finishes every retained controller whose participation is over:
330// the claim no longer runs that operation, or this box is no longer a
331// participant of it. It runs once per pass, for hosts the pass itself
332// skips (a completed relocation is a serving claim).
333func (p Participant) Sweep(ctx context.Context) error {
334 if p.Claims == nil || p.Effects == nil {
335 return ErrUnsupported
336 }
337 retained, err := p.Effects.Retained()
338 errs := []error{err} // reported, while every readable participation is still finished
339 for _, r := range retained {
340 obs, err := hostclaim.Read(ctx, p.Claims, r.Host)
341 if err != nil {
342 errs = append(errs, err)
343 continue
344 }
345 if !obs.Absent && obs.Claim.Op.Kind == hostclaim.KindRelocating && obs.Claim.Op.ID == r.Op && slices.Contains(obs.Claim.Op.Participants, p.Box) && !slices.Contains(obs.Claim.Lost, p.Box) {
346 continue
347 }
348 if err := p.Effects.Finish(ctx, r.Host, r.Op); err != nil {
349 errs = append(errs, err)
350 }
351 }
352 return errors.Join(errs...)
353}
354
355// ErrRelease is a journaled release that is present but unreadable, or
356// that names another writer than the holder's predecessor. Either is a
357// claim defect, never "no release": an uncertified cold opening declares
358// the predecessor's writes lost, which only a failover's evidence (the
359// source lost and fenced, with no release at all) may do.
360var ErrRelease = errors.New("hostrelocate: the journaled release is unreadable or does not name the predecessor")
361
362// CertifiedFor is whether the probe admission certifies its predecessor
363// (§5.8, L9): only the admission that opens P, when this operation
364// journaled a release whose session is the predecessor writer's. Claim
365// Plan 11 gives a relocation no coverage value of its own, so the release
366// is the generic PredecessorCertified, which AdmitWriter accepts on P's
367// opening only with that release and a pin reaching it
368// (relocationProbeOpening). No release at all (a failover, or a host that
369// never had a writer) is an uncertified cold admission; after P's opening
370// every later attempt is one too. A release that is present but
371// unreadable, or that names another writer than the predecessor, is
372// ErrRelease: absence is never evidence, and an unreadable or mismatched
373// entry is not absence (fix round 1, I3).
374func CertifiedFor(c *hostclaim.Claim) (bool, error) {
375 raw, ok := c.Op.Journal["release"]
376 if !ok {
377 return false, nil
378 }
379 var r hostclaim.Release
380 if json.Unmarshal([]byte(raw), &r) != nil || r.ReleasedBy == "" {
381 return false, fmt.Errorf("%w: unreadable", ErrRelease)
382 }
383 if hostclaim.PreparedOpened(c) {
384 return false, nil
385 }
386 if c.Holder == nil {
387 return false, fmt.Errorf("%w: no holder", ErrRelease)
388 }
389 pred := hostclaim.AdmissionRecord{Session: c.Holder.Session, Box: c.Holder.WriterBox, Generation: c.Holder.Generation}
390 if n := len(c.Holder.Admissions); n > 0 {
391 pred = c.Holder.Admissions[n-1]
392 }
393 if pred.Box != r.ReleasedBy || pred.Session != r.Session || pred.Generation != r.Generation {
394 return false, fmt.Errorf("%w: released by %s (session %d, generation %q), the predecessor is %s (session %d, generation %q)", ErrRelease, r.ReleasedBy, r.Session, r.Generation, pred.Box, pred.Session, pred.Generation)
395 }
396 return true, nil
397}
398
399func (p Participant) update(ctx context.Context, obs hostclaim.Observed, tr func(*hostclaim.Claim) error) (hostclaim.Observed, error) {
400 next, err := hostclaim.Update(ctx, p.Claims, obs, tr)
401 if errors.Is(err, hostclaim.ErrAlreadyRecorded) || errors.Is(err, hostclaim.ErrAckAlreadyApplied) {
402 return obs, nil
403 }
404 return next, err
405}
406