| new file mode 100644 |
| index 000000000..ecf858090 |
| --- /dev/null |
| +++ b/internal/host/movesrecover.go |
| @@ -0,0 +1,783 @@ |
| +package host |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "strconv" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/hostbox" |
| + "github.com/carlosframework/platform/internal/hostclaim" |
| + "github.com/carlosframework/platform/internal/hostguard" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The source's recovery after a rollback (moves spec §7.8 source table; Plan |
| +// 4 Task 5), behind CARLOS_MOVES_PHASE1: the target has released, and the |
| +// source box takes the host back, clean or dirty, through opened, and then |
| +// the rolled_back finalization. |
| +// |
| +// Routing. MoveRollBack moved the record's region back to From, so the |
| +// source reaches a rolling_back record through the post-flip participant |
| +// rule (movesParticipantRecord, J10) in movesPass, whether or not it still |
| +// has its row. One pass drives it as far as it can, each phase from durable |
| +// state, under the record's source_phase. A record at X means X's work may |
| +// have begun (Plan 2's convention, G8; provision's "each is written before |
| +// its side effects"): |
| +// |
| +// - waiting: until the target's evidence, released clean or returned |
| +// dirty on the record, and the claim restoring/released with nobody |
| +// executing ("" after the target's evidence, "none" after a |
| +// declaration). Then the record says claimed. |
| +// - claimed: the claim's return (hostclaim.ReturnToSource: this box |
| +// executing at the source's own stamp S; a clean return also names the |
| +// source's generation, src_gen, again), then the guard's activation |
| +// (the fresh read finds holder.stamp == S, matching the installation |
| +// record, or re-initialises a lost one to S), then, for a dirty return |
| +// whose inventory lists paths of S, the source's park-local (J13: the |
| +// installer's local retires S against it). The claim's phase is |
| +// claimed from here through the install (J1). |
| +// - restoring: the row handed from the move's moving hold to the |
| +// rollback's restoring hold (handHold, J3), with the record read fresh |
| +// under the host's hold lock. |
| +// - clean verified: the files plain digest and the timers digest equal |
| +// the source's stopped baselines (Source.Files.Plain, |
| +// Source.TimersSHA256), and the warm database's <dbPath>.gen names |
| +// src_gen (plain text, H11). A mismatch stops for an operator with the |
| +// baseline-mismatch sentence at verified (MoveMarkStuck, INV-4); a |
| +// retry verifies again, never skips it. |
| +// - dirty parking to configured: the shared installer (installGeneration, |
| +// Task 3) bound by recoveryInstallSource to the return seal and the |
| +// return baseline the target journalled in the claim (Task 4), from the |
| +// return lane moves/<host>/<rb id>/ in this box's own bucket, under S; |
| +// configured records the inputs it rendered as recovery_inputs, never |
| +// compared with move.inputs. restored hands over to probing. |
| +// - probing: the claim steps claimed to probing first (J1), then the |
| +// recovery probe: recoveryProbe, Task 9's (BLOCKED-ON-CLAIM C1, rulings |
| +// J2 and J18), a cold admission of kind recovery with nine attempts, |
| +// into the return generation (dirty, coverage sealed) or a fresh one |
| +// restored from src_gen (clean, coverage clean-return; J14: both |
| +// returns are cold and never keep S). The probe records the inputs it |
| +// renders before it renders them (MoveRecordRecoveryInputs). Once the |
| +// record says probing nothing earlier runs again: a restarted probe |
| +// restarts only the probe (INV-2, INV-7). Nine failed attempts stop |
| +// for an operator at probing. |
| +// - probed: the claim steps to probed and, for a dirty return, queues the |
| +// return lane's park in this region in the same write |
| +// (QueueReturnLanePark, its horizon the return seal's bound); then |
| +// recovered_at, and the serial is nudged (the target's cleaning waits |
| +// on it). |
| +// - recovered: the open: the rollback's hold on the row ends |
| +// (ReleaseHeld, a plain registry call under the host's hold lock with |
| +// the record read fresh, J12), the claim steps to opened, and the record |
| +// says opened with opened_at. |
| +// - opened: the finalization. Once recovered_at, opened_at and |
| +// target_parked_at are stamped and no obligation of the op remains in |
| +// the claim, the terminal event (LogEvent, Plan 6's moves.LogEvent), |
| +// the claim's release to serving (ReleaseMove) and the record's close |
| +// (MoveRolledBackClose). Whichever actor stamps last tries; the |
| +// source's next pass, and Plan 5's console pass, finish what a crash |
| +// leaves. |
| +// |
| +// Every record write goes through a provision transition (the record_seq |
| +// rule); every claim write through a hostclaim transition. |
| +// |
| +// Arming (rulings J18, G17). Like the landing and the target's half of the |
| +// rollback, the source's recovery is unarmed in production until Plan 5: |
| +// it needs the park engine's leases (ParkLeases, for the dirty return's |
| +// residue parks), which production does not wire, so recoverySourceArmed |
| +// answers false there and the pass logs a standing notice. The probe hook is |
| +// nil in production too, until Task 9; probing then logs and waits. LogEvent |
| +// is nil in production, where the event is a log line. |
| + |
| +// recoverStepsPerPass bounds the phases one pass runs: the dirty path from |
| +// waiting to the finalization is fifteen. |
| +const recoverStepsPerPass = 16 |
| + |
| +// recoveryProbeAttempts is the recovery probe's budget (§7.8: three times |
| +// the landing's three). |
| +const recoveryProbeAttempts = 9 |
| + |
| +// The recovery probe's admission coverage (§7.14): a dirty return admits |
| +// the return generation from its sealed lane, a clean one restores the |
| +// source's own generation. |
| +const ( |
| + recoveryCoverageSealed = "sealed" |
| + recoveryCoverageClean = "clean-return" |
| +) |
| + |
| +// recoveryProbeOutcome is what one call of the recovery probe found. |
| +type recoveryProbeOutcome int |
| + |
| +const ( |
| + // recoveryProbeWaiting: no answer yet; the next pass calls the probe |
| + // again (and only the probe). |
| + recoveryProbeWaiting recoveryProbeOutcome = iota |
| + // recoveryProbeAnswered: the app answered; the recovery goes to probed. |
| + recoveryProbeAnswered |
| + // recoveryProbeExhausted: every attempt failed; the recovery stops for |
| + // an operator (StuckRecoveryProbeAttempts). |
| + recoveryProbeExhausted |
| +) |
| + |
| +// recoveryProbeRequest is what the source asks of the recovery probe (Task |
| +// 9 builds it against claim Group D for recovery, J2). |
| +type recoveryProbeRequest struct { |
| + // RollbackID names the rollback, whose id names a dirty return's |
| + // generation and journal. |
| + RollbackID string |
| + // Clean is the return's kind; Coverage is the admission's coverage, |
| + // "sealed" (dirty) or "clean-return". |
| + Clean bool |
| + Coverage string |
| + // Generation is the generation the dirty return installed (the |
| + // rollback id); empty for a clean return, whose probe admits a fresh |
| + // one restored from SourceGeneration (src_gen, J14). |
| + Generation, SourceGeneration string |
| + // Attempts is the budget, nine. |
| + Attempts int |
| + // Record records the inputs the probe is about to render, before it |
| + // renders them (MoveRecordRecoveryInputs; Task 3b concern 3). |
| + Record func(context.Context, provision.MoveInputs) error |
| +} |
| + |
| +// recoverySourceArmed reports whether this box can run the source's |
| +// recovery: every hook a dirty return's install and the open call is wired, |
| +// the park engine's leases included. In production ParkLeases is not |
| +// (ruling G17), so this answers false there (J18). |
| +func recoverySourceArmed(cfg moveCfg) bool { |
| + return landingArmed(cfg) && cfg.DataDir != "" && cfg.HandHold != nil && cfg.ReleaseHeld != nil && cfg.Timers != nil |
| +} |
| + |
| +// releaseHeld is the recovery's open in production: registry.ReleaseHold |
| +// under the op that holds the row, then written (holdStageRequester) when |
| +// it landed, so root re-judges what it staged now the hold has ended. The |
| +// caller holds provision.WithHostHoldLock. |
| +func releaseHeld(reg *registry.Registry, written func(host string)) func(host, op string) (bool, error) { |
| + return func(host, op string) (bool, error) { |
| + released, err := reg.ReleaseHold(host, op) |
| + if err == nil && released && written != nil { |
| + written(host) |
| + } |
| + return released, err |
| + } |
| +} |
| + |
| +// recoverRun is one pass's state for one rolling_back record on its source. |
| +type recoverRun struct { |
| + w *movesWorker |
| + s release.Store |
| + rec provision.Record |
| + g provision.MoveGuard |
| + rb string |
| +} |
| + |
| +// recoverSource is the participant step for a rolling_back record on its |
| +// source (movesPass). |
| +func (w *movesWorker) recoverSource(ctx context.Context, s release.Store, rec provision.Record) error { |
| + cfg := w.cfg |
| + m := rec.Move |
| + if m == nil || m.Legacy() || m.State != provision.MoveRollingBack || m.Rollback == nil || m.From != cfg.Region || m.To == cfg.Region { |
| + return nil |
| + } |
| + if !recoverySourceArmed(cfg) { |
| + w.notice(rec.Host+"#recover", "moves: %s: the source's recovery of rollback %s waits: this box's recovery is not armed (Plan 5's park view and the park engine's leases are needed)", rec.Host, m.Rollback.ID) |
| + return nil |
| + } |
| + r := &recoverRun{w: w, s: s, rec: rec, g: provision.MoveGuard{ID: m.ID, Incarnation: m.Incarnation}, rb: m.Rollback.ID} |
| + defer r.settleLease() |
| + for i := 0; i < recoverStepsPerPass; i++ { |
| + advanced, err := r.step(ctx) |
| + if err != nil || !advanced { |
| + return err |
| + } |
| + fresh, err := provision.GetRecord(ctx, s, rec.Account, rec.App, rec.Host) |
| + if err != nil { |
| + return fmt.Errorf("re-read the record: %w", err) |
| + } |
| + if !r.same(fresh) { |
| + return nil |
| + } |
| + r.rec = *fresh |
| + } |
| + return nil |
| +} |
| + |
| +func (r *recoverRun) cfg() moveCfg { return r.w.cfg } |
| +func (r *recoverRun) now() time.Time { return r.w.now() } |
| +func (r *recoverRun) rollback() *provision.MoveRollback { return r.rec.Move.Rollback } |
| +func (r *recoverRun) dbPath() string { return provision.DefaultDBPath(r.cfg().DataDir, r.rec.Host) } |
| + |
| +// same reports whether fresh is still this rollback, rolling back. |
| +func (r *recoverRun) same(fresh *provision.Record) bool { |
| + m := fresh.Move |
| + return m != nil && m.ID == r.g.ID && m.Incarnation == r.g.Incarnation && m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.ID == r.rb |
| +} |
| + |
| +// fault is the test seam after a side effect. |
| +func (r *recoverRun) fault(point string) error { |
| + if f := r.cfg().rcFault; f != nil { |
| + return f(point) |
| + } |
| + return nil |
| +} |
| + |
| +// settleLease releases the own-location lease a dirty return's build holds |
| +// from building through published, once the record is past them or no |
| +// longer this return: the lease is the build's alone. |
| +func (r *recoverRun) settleLease() { |
| + m := r.rec.Move |
| + if m != nil && m.State == provision.MoveRollingBack && m.Rollback != nil && m.Rollback.Dirty && |
| + (m.Rollback.SourcePhase == provision.RbSourceBuilding || m.Rollback.SourcePhase == provision.RbSourcePublished) { |
| + return |
| + } |
| + releaseOwnLease(r.w, r.rec.Host) |
| +} |
| + |
| +// step runs the source's current recovery phase once. advanced is true when |
| +// it wrote the record. |
| +func (r *recoverRun) step(ctx context.Context) (bool, error) { |
| + rb := r.rollback() |
| + switch rb.SourcePhase { |
| + case provision.RbSourceWaiting: |
| + return r.waiting(ctx) |
| + case provision.RbSourceClaimed: |
| + return r.claimed(ctx) |
| + case provision.RbSourceRestoring: |
| + return r.restoring(ctx) |
| + case provision.RbSourceVerified: |
| + return r.verified(ctx) |
| + case provision.RbSourceParking, provision.RbSourceBuilding, provision.RbSourcePublished, provision.RbSourceLocal, provision.RbSourceConfigured: |
| + return r.install(ctx) |
| + case provision.RbSourceRestored: |
| + return r.phase(ctx, provision.RbSourceRestored, provision.RbSourceProbing, nil, "") |
| + case provision.RbSourceProbing: |
| + return r.probing(ctx) |
| + case provision.RbSourceProbed: |
| + return r.probed(ctx) |
| + case provision.RbSourceRecovered: |
| + return r.open(ctx) |
| + case provision.RbSourceOpened: |
| + return r.finalize(ctx) |
| + } |
| + return false, nil |
| +} |
| + |
| +// phase writes one source phase step, then logs the event, if any, and |
| +// passes the step's fault point. A conflict is someone else's decision. |
| +func (r *recoverRun) phase(ctx context.Context, from, to string, stamp func(*provision.MoveRollback), event string) (bool, error) { |
| + host := r.rec.Host |
| + if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveRollbackSourcePhase(r.g, from, to, stamp)); err != nil { |
| + if errors.Is(err, provision.ErrMoveConflict) { |
| + log.Printf("moves: %s: the source's recovery %s to %s refused (%v); the record moved on", host, from, to, err) |
| + return false, nil |
| + } |
| + return false, fmt.Errorf("the source's recovery %s to %s: %w", from, to, err) |
| + } |
| + log.Printf("moves: %s: the source of rollback %s is %s", host, r.rb, to) |
| + if event != "" { |
| + r.w.logEvent(ctx, r.rec, event) |
| + } |
| + if err := r.fault("after-record-" + to); err != nil { |
| + return true, err |
| + } |
| + return true, nil |
| +} |
| + |
| +// stuck stops the recovery for an operator (MoveMarkStuck) with a recorded |
| +// reason; the row stays held and move --retry returns to the phase. |
| +func (r *recoverRun) stuck(ctx context.Context, reason string, why error) (bool, error) { |
| + host := r.rec.Host |
| + log.Printf("moves: %s: the recovery of %s stops for an operator (%v): %s", host, r.rb, why, reason) |
| + if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveMarkStuck(r.g, reason)); err != nil { |
| + if errors.Is(err, provision.ErrMoveConflict) { |
| + log.Printf("moves: %s: the stuck mark of %s refused (%v); the record moved on", host, r.rb, err) |
| + return false, nil |
| + } |
| + return false, fmt.Errorf("mark the recovery stuck: %w", err) |
| + } |
| + r.w.logEvent(ctx, r.rec, "source-stuck") |
| + nudgeSerial(ctx, r.s) |
| + return true, nil |
| +} |
| + |
| +// claim reads the host claim and requires this rollback's restoring op, |
| +// with this box the move's source, for the record's incarnation. |
| +func (r *recoverRun) claim(ctx context.Context) (hostclaim.Observed, error) { |
| + obs, err := hostclaim.Read(ctx, r.s, r.rec.Host) |
| + if err != nil { |
| + return obs, fmt.Errorf("read the host claim: %w", err) |
| + } |
| + if obs.Absent { |
| + return obs, fmt.Errorf("move %s has no host claim", r.g.ID) |
| + } |
| + if err := r.ours(obs.Claim); err != nil { |
| + return obs, fmt.Errorf("move %s: %w", r.g.ID, err) |
| + } |
| + return obs, nil |
| +} |
| + |
| +// ours is the claim check of every source phase before the finalization: |
| +// this rollback's restoring op, this box the move's source, the record's |
| +// incarnation. |
| +func (r *recoverRun) ours(c *hostclaim.Claim) error { |
| + box := r.cfg().Box |
| + if c == nil || c.Op.ID != r.g.ID || c.Op.Kind != hostclaim.KindRestoring || c.Op.Intent["rollback"] != r.rb { |
| + return fmt.Errorf("%w: the claim is not rollback %s", hostclaim.ErrWrongState, r.rb) |
| + } |
| + if c.Holder == nil || c.Holder.Incarnation != r.g.Incarnation { |
| + return fmt.Errorf("%w: the claim names another incarnation", hostclaim.ErrWrongState) |
| + } |
| + if len(c.Op.Participants) == 0 || c.Op.Participants[0] != box || c.Op.Intent["source"] != box { |
| + return fmt.Errorf("%w: this box is not the move's source in the claim", hostclaim.ErrWrongState) |
| + } |
| + return nil |
| +} |
| + |
| +// executing requires the claim at phase with this box executing. |
| +func (r *recoverRun) executing(c *hostclaim.Claim, phase string) error { |
| + if c.Op.Phase != phase || c.Op.Exec != r.cfg().Box { |
| + return fmt.Errorf("move %s: the claim is restoring/%q with executor %q, not %s on this box", r.g.ID, c.Op.Phase, c.Op.Exec, phase) |
| + } |
| + return nil |
| +} |
| + |
| +// sourceStamp is S, the source installation's stamp the flip journalled. |
| +func sourceStamp(c *hostclaim.Claim) (uint64, error) { |
| + s, err := strconv.ParseUint(c.Op.Journal[hostclaim.JournalSourceStamp], 10, 64) |
| + if err != nil || s == 0 { |
| + return 0, fmt.Errorf("%w: the flip's source stamp is not journalled", hostclaim.ErrWrongState) |
| + } |
| + return s, nil |
| +} |
| + |
| +// stillAt is the record check run under the host's hold lock before a row |
| +// write: the record, read fresh, must still be this rollback with the |
| +// source at phase. It reads the record only. |
| +func (r *recoverRun) stillAt(ctx context.Context, phase string) error { |
| + fresh, err := provision.GetRecord(ctx, r.s, r.rec.Account, r.rec.App, r.rec.Host) |
| + if err != nil { |
| + return fmt.Errorf("re-read the record: %w", err) |
| + } |
| + if !r.same(fresh) || fresh.Move.Rollback.SourcePhase != phase { |
| + return fmt.Errorf("move %s: the record is no longer rollback %s at %s", r.g.ID, r.rb, phase) |
| + } |
| + return nil |
| +} |
| + |
| +// waiting (§7.8 source waiting): the target's evidence, released clean or |
| +// returned dirty, and the claim released with nobody executing. |
| +func (r *recoverRun) waiting(ctx context.Context) (bool, error) { |
| + host, rb := r.rec.Host, r.rollback() |
| + if !(rb.TargetPhase == provision.RbTargetReleased && !rb.Dirty) && !(rb.TargetPhase == provision.RbTargetReturned && rb.Dirty) { |
| + r.w.notice(host+"#rc-wait", "moves: %s: the source waits for the target of rollback %s (%s, dirty %t)", host, r.rb, rb.TargetPhase, rb.Dirty) |
| + return false, nil |
| + } |
| + obs, err := r.claim(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + if op := obs.Claim.Op; op.Phase != hostclaim.PhaseReleased || (op.Exec != "" && op.Exec != "none") { |
| + r.w.notice(host+"#rc-wait", "moves: %s: the source waits for the claim's release of rollback %s (restoring/%q, executor %q)", host, r.rb, op.Phase, op.Exec) |
| + return false, nil |
| + } |
| + return r.phase(ctx, provision.RbSourceWaiting, provision.RbSourceClaimed, nil, "") |
| +} |
| + |
| +// claimed (§7.8 source claimed): the claim's return to this box at S (and |
| +// src_gen for a clean return), the guard's activation at S, and a dirty |
| +// return's park-local for its registered pre-move installation (J13). |
| +func (r *recoverRun) claimed(ctx context.Context) (bool, error) { |
| + cfg := r.cfg() |
| + host, id, rb := r.rec.Host, r.g.ID, r.rollback() |
| + clean := !rb.Dirty |
| + obs, err := r.claim(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + if op := obs.Claim.Op; op.Phase != hostclaim.PhaseClaimed || op.Exec != cfg.Box { |
| + if obs, err = hostclaim.Update(ctx, r.s, obs, hostclaim.ReturnToSource(id, cfg.Box, clean, hostclaim.ReturnFence{})); err != nil { |
| + return false, fmt.Errorf("the claim's return to the source: %w", err) |
| + } |
| + log.Printf("moves: %s: the source executes the host again for rollback %s (clean %t)", host, r.rb, clean) |
| + } |
| + if err := r.fault("after-return-claimed"); err != nil { |
| + return false, err |
| + } |
| + c := obs.Claim |
| + s, err := sourceStamp(c) |
| + if err != nil { |
| + return false, err |
| + } |
| + if c.Holder.Stamp != s { |
| + return false, fmt.Errorf("move %s: the holder is at stamp %d, not the source's %d", id, c.Holder.Stamp, s) |
| + } |
| + if clean && c.Holder.Generation != c.Op.Journal[hostclaim.JournalSrcGen] { |
| + return false, fmt.Errorf("move %s: a clean return's holder names generation %q, not src_gen", id, c.Holder.Generation) |
| + } |
| + g := cfg.Guard |
| + if g.Claims == nil { |
| + g.Claims = hostguard.StoreReader{Store: r.s} |
| + } |
| + if err := g.Activate(ctx, host, cfg.Box, nil); err != nil { |
| + return false, fmt.Errorf("activate the source's installation at stamp %d: %w", s, err) |
| + } |
| + if err := r.fault("after-activate"); err != nil { |
| + return false, err |
| + } |
| + if rb.Dirty && hostclaim.InventoryHasStamp(c, cfg.Box, s) { |
| + if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.QueueLocalPark(id, cfg.Box, obligationID)); err != nil { |
| + return false, fmt.Errorf("queue the source's local park: %w", err) |
| + } |
| + } |
| + return r.phase(ctx, provision.RbSourceClaimed, provision.RbSourceRestoring, nil, "") |
| +} |
| + |
| +// restoring (§7.8 source restoring): the row handed from the move's hold to |
| +// the rollback's, as restoring (J3), with the record checked under the |
| +// host's hold lock; then the branch, clean or dirty. |
| +func (r *recoverRun) restoring(ctx context.Context) (bool, error) { |
| + cfg := r.cfg() |
| + host, id := r.rec.Host, r.g.ID |
| + handed, err := cfg.HandHold(host, "restoring", r.rb, id, func() error { return r.stillAt(ctx, provision.RbSourceRestoring) }) |
| + if err != nil { |
| + return false, fmt.Errorf("hand the row's hold to the rollback: %w", err) |
| + } |
| + if !handed { |
| + return false, fmt.Errorf("move %s: this box's row is held by neither the move nor rollback %s", id, r.rb) |
| + } |
| + if err := r.fault("after-hand"); err != nil { |
| + return false, err |
| + } |
| + next := provision.RbSourceVerified |
| + if r.rollback().Dirty { |
| + next = provision.RbSourceParking |
| + } |
| + return r.phase(ctx, provision.RbSourceRestoring, next, nil, "") |
| +} |
| + |
| +// verified (§7.8 clean verified, INV-4): the source's files and timers hash |
| +// to what it recorded when it stopped, and its warm database's marker names |
| +// src_gen. A mismatch stops for an operator; a retry runs this again. |
| +func (r *recoverRun) verified(ctx context.Context) (bool, error) { |
| + cfg := r.cfg() |
| + host, id, m := r.rec.Host, r.g.ID, r.rec.Move |
| + if err := r.fault("verify"); err != nil { |
| + return false, err |
| + } |
| + obs, err := r.claim(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + if err := r.executing(obs.Claim, hostclaim.PhaseClaimed); err != nil { |
| + return false, err |
| + } |
| + srcGen, ok := obs.Claim.Op.Journal[hostclaim.JournalSrcGen] |
| + if !ok { |
| + return false, fmt.Errorf("move %s: the flip's source generation is not journalled", id) |
| + } |
| + if m.Source == nil || m.Source.Files == nil || m.Source.Files.Plain == "" || m.Source.TimersSHA256 == "" { |
| + return false, fmt.Errorf("move %s: the record has no stopped baselines to verify against", id) |
| + } |
| + dir, envPath := cfg.FilesDir(host) |
| + plain, _, _, err := archivePlainDigest(dir, envPath) |
| + why, retry, ferr := archiveFailure(host, err) |
| + switch { |
| + case ferr != nil: |
| + return false, fmt.Errorf("digest the source's files: %w", ferr) |
| + case retry: |
| + r.w.notice(host+"#rc-verify", "moves: %s: a file changed while the source's files were digested; the verification runs again", host) |
| + return false, nil |
| + case why != "": |
| + return r.stuck(ctx, provision.StuckBaselineMismatch, fmt.Errorf("the source's files cannot be digested: %s", why)) |
| + case plain != m.Source.Files.Plain: |
| + return r.stuck(ctx, provision.StuckBaselineMismatch, fmt.Errorf("the files are %s, recorded %s", plain, m.Source.Files.Plain)) |
| + } |
| + rows, err := cfg.Timers(host) |
| + if err != nil { |
| + return false, fmt.Errorf("read the timers: %w", err) |
| + } |
| + if got := moves.TimersDigest(rows); got != m.Source.TimersSHA256 { |
| + return r.stuck(ctx, provision.StuckBaselineMismatch, fmt.Errorf("the timers are %s, recorded %s", got, m.Source.TimersSHA256)) |
| + } |
| + gen, err := hostbox.ReadGenerationMarker(r.dbPath() + ".gen") |
| + if err != nil { |
| + return r.stuck(ctx, provision.StuckBaselineMismatch, fmt.Errorf("the warm database's marker: %w", err)) |
| + } |
| + if gen != srcGen { |
| + return r.stuck(ctx, provision.StuckBaselineMismatch, fmt.Errorf("the warm database's marker names %q, the pre-move generation is %q", gen, srcGen)) |
| + } |
| + log.Printf("moves: %s: the source still matches what it recorded when it stopped", host) |
| + return r.phase(ctx, provision.RbSourceVerified, provision.RbSourceProbing, nil, "") |
| +} |
| + |
| +// returnSeal reads the return seal the target journalled in the claim. |
| +func returnSeal(c *hostclaim.Claim) (moves.Seal, error) { |
| + raw, ok := c.Op.Journal[moves.JournalReturnSeal] |
| + if !ok { |
| + return moves.Seal{}, fmt.Errorf("%w: the claim carries no return seal", moves.ErrBadSeal) |
| + } |
| + return moves.DecodeSeal(raw) |
| +} |
| + |
| +// install (§7.8 dirty parking to configured): one phase of the shared |
| +// installer over the return lane, its seal and baseline the target's |
| +// journalled return. A refusal it records stops the return for an operator. |
| +func (r *recoverRun) install(ctx context.Context) (bool, error) { |
| + host := r.rec.Host |
| + obs, err := r.claim(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + raw, ok := obs.Claim.Op.Journal[moves.JournalReturnBaseline] |
| + if !ok { |
| + return false, fmt.Errorf("move %s: the claim carries no return baseline", r.g.ID) |
| + } |
| + baseline, err := moves.DecodeReturnBaseline(raw) |
| + if err != nil { |
| + return false, err |
| + } |
| + src, err := recoveryInstallSource(r.w, r.s, r.rec, returnSeal, &baseline) |
| + if err != nil { |
| + return false, err |
| + } |
| + advanced, refused, err := installGeneration(ctx, r.s, r.w, r.rec, src) |
| + if refused != "" { |
| + log.Printf("moves: %s: the return of %s stopped for an operator: %s", host, r.rb, refused) |
| + r.w.logEvent(ctx, r.rec, "source-stuck") |
| + } |
| + return advanced, err |
| +} |
| + |
| +// probing (§7.8 source probing): the claim steps to probing before the |
| +// first attempt (J1), then the recovery probe. Nothing earlier is run again |
| +// from here. |
| +func (r *recoverRun) probing(ctx context.Context) (bool, error) { |
| + cfg := r.cfg() |
| + host, id, rb := r.rec.Host, r.g.ID, r.rollback() |
| + obs, err := r.claim(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + if obs.Claim.Op.Phase == hostclaim.PhaseClaimed { |
| + if err := r.executing(obs.Claim, hostclaim.PhaseClaimed); err != nil { |
| + return false, err |
| + } |
| + if obs, err = hostclaim.Update(ctx, r.s, obs, hostclaim.SetRestoringPhase(id, hostclaim.PhaseClaimed, hostclaim.PhaseProbing)); err != nil { |
| + return false, fmt.Errorf("the claim's probing: %w", err) |
| + } |
| + if err := r.fault("after-claim-probing"); err != nil { |
| + return false, err |
| + } |
| + } |
| + if err := r.executing(obs.Claim, hostclaim.PhaseProbing); err != nil { |
| + return false, err |
| + } |
| + if cfg.recoveryProbe == nil { |
| + r.w.notice(host+"#rc-probe", "moves: %s: the recovery probe of rollback %s waits: it is Task 9's, on claim Plan 10's recovery admission", host, r.rb) |
| + return false, nil |
| + } |
| + req := recoveryProbeRequest{RollbackID: r.rb, Clean: !rb.Dirty, Attempts: recoveryProbeAttempts, |
| + SourceGeneration: obs.Claim.Op.Journal[hostclaim.JournalSrcGen], |
| + Record: func(ctx context.Context, in provision.MoveInputs) error { |
| + _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveRecordRecoveryInputs(r.g, r.rb, in)) |
| + return err |
| + }} |
| + if rb.Dirty { |
| + req.Coverage, req.Generation = recoveryCoverageSealed, r.rb |
| + } else { |
| + req.Coverage = recoveryCoverageClean |
| + } |
| + out, err := cfg.recoveryProbe(ctx, r.s, r.rec, req) |
| + if err != nil { |
| + return false, fmt.Errorf("the recovery probe: %w", err) |
| + } |
| + switch out { |
| + case recoveryProbeAnswered: |
| + return r.phase(ctx, provision.RbSourceProbing, provision.RbSourceProbed, nil, "") |
| + case recoveryProbeExhausted: |
| + return r.stuck(ctx, provision.StuckRecoveryProbeAttempts, fmt.Errorf("%d attempts", recoveryProbeAttempts)) |
| + } |
| + r.w.notice(host+"#rc-probe", "moves: %s: the recovery probe of rollback %s has not answered yet", host, r.rb) |
| + return false, nil |
| +} |
| + |
| +// probed (§7.8 source probed): the claim steps to probed and, for a dirty |
| +// return, queues the return lane's park in the same write; then recovered |
| +// with recovered_at, and the serial is nudged for the target's cleaning. |
| +func (r *recoverRun) probed(ctx context.Context) (bool, error) { |
| + cfg := r.cfg() |
| + host, id, rb := r.rec.Host, r.g.ID, r.rollback() |
| + obs, err := r.claim(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + var lane func(*hostclaim.Claim) error |
| + if rb.Dirty { |
| + seal, err := returnSeal(obs.Claim) |
| + if err != nil { |
| + return false, err |
| + } |
| + scope := hostclaim.ParkScope{Location: moves.ScopeLocation(host, r.rb), Incarnation: obs.Claim.Holder.Incarnation, |
| + EvidenceAt: seal.Bound().UTC(), RequestLimit: cutRequestLimit} |
| + lane = hostclaim.QueueReturnLanePark(id, cfg.Region, scope, obligationID) |
| + } |
| + switch obs.Claim.Op.Phase { |
| + case hostclaim.PhaseProbing: |
| + if err := r.executing(obs.Claim, hostclaim.PhaseProbing); err != nil { |
| + return false, err |
| + } |
| + step := hostclaim.SetRestoringPhase(id, hostclaim.PhaseProbing, hostclaim.PhaseProbed) |
| + if _, err := hostclaim.Update(ctx, r.s, obs, func(c *hostclaim.Claim) error { |
| + if err := step(c); err != nil { |
| + return err |
| + } |
| + if lane != nil { |
| + return lane(c) |
| + } |
| + return nil |
| + }); err != nil { |
| + return false, fmt.Errorf("the claim's probed: %w", err) |
| + } |
| + case hostclaim.PhaseProbed: |
| + // A replay after the claim's write: the lane park is idempotent, |
| + // and once the park engine completed it it is not queued again. |
| + if err := r.executing(obs.Claim, hostclaim.PhaseProbed); err != nil { |
| + return false, err |
| + } |
| + if lane != nil { |
| + if _, err := hostclaim.Update(ctx, r.s, obs, lane); err != nil { |
| + return false, fmt.Errorf("queue the return lane's park: %w", err) |
| + } |
| + } |
| + default: |
| + return false, r.executing(obs.Claim, hostclaim.PhaseProbing) |
| + } |
| + if err := r.fault("after-claim-probed"); err != nil { |
| + return false, err |
| + } |
| + ok, err := r.phase(ctx, provision.RbSourceProbed, provision.RbSourceRecovered, func(rb *provision.MoveRollback) { |
| + t := r.now().UTC() |
| + rb.RecoveredAt = &t |
| + }, "source-recovered") |
| + if ok { |
| + nudgeSerial(ctx, r.s) |
| + } |
| + return ok, err |
| +} |
| + |
| +// open (§7.8 source opened): the rollback's hold on the row ends, under the |
| +// host's hold lock with the record read fresh (J12); the claim steps to |
| +// opened; the record says opened with opened_at. A replay that finds the |
| +// row already open goes on. |
| +func (r *recoverRun) open(ctx context.Context) (bool, error) { |
| + cfg := r.cfg() |
| + host, id := r.rec.Host, r.g.ID |
| + var err error |
| + provision.WithHostHoldLock(host, func() { |
| + if err = r.stillAt(ctx, provision.RbSourceRecovered); err != nil { |
| + return |
| + } |
| + var released bool |
| + if released, err = cfg.ReleaseHeld(host, r.rb); err != nil || released { |
| + return |
| + } |
| + var rt *registry.Route |
| + if rt, err = cfg.Route(host); err != nil { |
| + return |
| + } |
| + switch { |
| + case rt == nil: |
| + err = fmt.Errorf("move %s: this box has no row to open", id) |
| + case rt.HoldOp != "": |
| + err = fmt.Errorf("move %s: the row is held %s by %q, not by rollback %s", id, rt.Status, rt.HoldOp, r.rb) |
| + } |
| + }) |
| + if err != nil { |
| + return false, fmt.Errorf("open the row: %w", err) |
| + } |
| + if err := r.fault("after-row-opened"); err != nil { |
| + return false, err |
| + } |
| + obs, err := r.claim(ctx) |
| + if err != nil { |
| + return false, err |
| + } |
| + if obs.Claim.Op.Phase != hostclaim.PhaseOpened { |
| + if err := r.executing(obs.Claim, hostclaim.PhaseProbed); err != nil { |
| + return false, err |
| + } |
| + if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.SetRestoringPhase(id, hostclaim.PhaseProbed, hostclaim.PhaseOpened)); err != nil { |
| + return false, fmt.Errorf("the claim's opened: %w", err) |
| + } |
| + } |
| + if err := r.fault("after-claim-opened"); err != nil { |
| + return false, err |
| + } |
| + return r.phase(ctx, provision.RbSourceRecovered, provision.RbSourceOpened, func(rb *provision.MoveRollback) { |
| + t := r.now().UTC() |
| + rb.OpenedAt = &t |
| + }, "source-opened") |
| +} |
| + |
| +// finalize (§7.8 rolled_back): with the source opened and recovered and the |
| +// target parked, and no obligation of the op left in the claim, the |
| +// terminal event, the claim's release to serving and the record's close. A |
| +// claim already released by this move (a crash before the close) only |
| +// closes. |
| +func (r *recoverRun) finalize(ctx context.Context) (bool, error) { |
| + cfg := r.cfg() |
| + host, id, rb := r.rec.Host, r.g.ID, r.rollback() |
| + if rb.RecoveredAt == nil || rb.OpenedAt == nil || rb.TargetParkedAt == nil { |
| + r.w.notice(host+"#rc-final", "moves: %s: rollback %s waits for the target's park before it closes", host, r.rb) |
| + return false, nil |
| + } |
| + obs, err := hostclaim.Read(ctx, r.s, host) |
| + if err != nil { |
| + return false, fmt.Errorf("read the host claim: %w", err) |
| + } |
| + if obs.Absent { |
| + return false, fmt.Errorf("move %s has no host claim", id) |
| + } |
| + if hostclaim.RequireReleasedMove(id)(obs.Claim) != nil { |
| + if err := r.ours(obs.Claim); err != nil { |
| + return false, fmt.Errorf("move %s: %w", id, err) |
| + } |
| + if err := r.executing(obs.Claim, hostclaim.PhaseOpened); err != nil { |
| + return false, err |
| + } |
| + owed := 0 |
| + for _, o := range obs.Claim.Obligations { |
| + if o.Op == id { |
| + owed++ |
| + } |
| + } |
| + if owed > 0 { |
| + r.w.notice(host+"#rc-final", "moves: %s: rollback %s waits for %d chore(s) of the move to be acknowledged before it closes", host, r.rb, owed) |
| + return false, nil |
| + } |
| + r.w.logEvent(ctx, r.rec, "rolled_back") |
| + if _, err := hostclaim.Update(ctx, r.s, obs, hostclaim.ReleaseMove(id, cfg.Box)); err != nil { |
| + return false, fmt.Errorf("release the claim: %w", err) |
| + } |
| + log.Printf("moves: %s: the claim is serving again after rollback %s", host, r.rb) |
| + if err := r.fault("after-release-move"); err != nil { |
| + return false, err |
| + } |
| + } |
| + if _, err := provision.SetRecordMove(ctx, r.s, r.rec.Account, r.rec.App, host, provision.MoveRolledBackClose(r.g, r.now())); err != nil { |
| + if errors.Is(err, provision.ErrMoveConflict) { |
| + log.Printf("moves: %s: the close of rollback %s refused (%v); the record moved on", host, r.rb, err) |
| + return false, nil |
| + } |
| + return false, fmt.Errorf("close the rolled-back move: %w", err) |
| + } |
| + log.Printf("moves: %s: move %s is rolled back and closed", host, id) |
| + nudgeSerial(ctx, r.s) |
| + return true, nil |
| +} |