| index bbed8cd81..b75674431 100644 |
| --- a/internal/console/selfserve_job.go |
| +++ b/internal/console/selfserve_job.go |
| @@ -19,6 +19,7 @@ import ( |
| "errors" |
| "fmt" |
| "log" |
| + "slices" |
| "time" |
| |
| "github.com/carlosframework/platform/internal/moves" |
| @@ -51,6 +52,8 @@ const ( |
| ssStepAdopted = "adopted" |
| ssStepIssue = "issue" |
| ssStepSeal = "seal" |
| + ssStepStart = "start" |
| + ssStepGate1 = "gate1" |
| ssStepGate2 = "gate2" |
| ) |
| |
| @@ -220,9 +223,18 @@ func (s *Server) selfServeStep(ctx context.Context, t *leaseToken, row *SelfServ |
| // (2) The judged record. |
| rec, err := selfServeRecordOf(s.observeRecord(ctx, row.Account, row.App, row.Host), row) |
| gone := errors.Is(err, errRecordGone) |
| + if row.Phase == ssLanded || row.Phase == ssOpenUncertain { |
| + // Only the record decides an open that may have landed. |
| + if done, oerr := s.selfServeOpenUncertain(ctx, t, row, rec, err, now); done { |
| + return oerr |
| + } |
| + } |
| if err != nil && !gone { |
| return err |
| } |
| + if row.OpenRecordPending && now.Sub(row.PhaseStartedAt) >= selfServeUncertainPage { |
| + s.selfServePageRow(ctx, row, ssPageOpenRecord, ssPageWhyOpenRecord(row.ID), now) |
| + } |
| |
| // (3) The outstanding request first. |
| if out, conf := selfServeOutstanding(row); (out && !conf) || row.Request.Expired && !row.Request.Consumed { |
| @@ -561,17 +573,29 @@ func (s *Server) selfServeExpired(ctx context.Context, t *leaseToken, row *SelfS |
| |
| // ---- the expiry action and the abort --------------------------------------------- |
| |
| -// selfServeExpiryAction is step (4) for an expiry bound to this phase: |
| -// while the record is at or before offline-landed, abort, with the reason |
| -// the phase's wait names. The phases past the no-return boundary and the |
| -// abort's own (reverting) page and retain their wait (Task 8). |
| +// selfServeExpiryAction is step (4) for an expiry bound to this phase, |
| +// decided by the record: while the record is at or before offline-landed, |
| +// abort, with the reason the phase's wait names; past the no-return |
| +// boundary (opened, finishing) and in the abort's own wait (reverting), |
| +// page and keep waiting, retried every tick; the health window's expiry |
| +// takes precedence: a warning, a page, and finishing. open-uncertain has |
| +// no step deadline: its open request's deadline is decided by the record |
| +// (selfServeOpenUncertain). |
| func (s *Server) selfServeExpiryAction(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) (bool, error) { |
| switch row.Phase { |
| - case ssOpened, ssHealth, ssFinishing, ssOpenUncertain, ssReverting: |
| - return false, nil // Task 8: page and retain, or warn and finish |
| + case ssHealth: |
| + if mv := selfServeOurs(rec, row); mv != nil && mv.State == provision.OfflineOpen { |
| + return true, s.selfServeHealthExpired(ctx, row, now) |
| + } |
| + return false, nil |
| + case ssOpened, ssFinishing, ssReverting: |
| + s.selfServePageBlocked(ctx, row, now) |
| + return false, nil |
| + case ssOpenUncertain: |
| + return false, nil |
| } |
| if mv := selfServeOurs(rec, row); mv != nil && !selfServePreOpen(mv.State) { |
| - return false, nil // a committed open: never aborted (Task 8) |
| + return false, nil // a committed open is never aborted: the row reconciles forward |
| } |
| if !row.AbortRequestedAt.IsZero() { |
| return false, nil |
| @@ -607,19 +631,35 @@ func (s *Server) selfServeExpiryReason(ctx context.Context, row *SelfServeMove, |
| } |
| } |
| return couldNotDetermine(factLane, stager) |
| - case ssPrechecks: |
| + case ssPrechecks, ssBarrier: |
| if mv := selfServeOurs(rec, row); mv != nil { |
| if ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv); err == nil { |
| - if v := s.selfServePrechecksVerdict(row, mv, ev); v.waiting != "" { |
| + if v := s.selfServePrechecksVerdict(row, mv, ev, row.Phase == ssBarrier); v.waiting != "" { |
| return v.waiting |
| } |
| } |
| } |
| return couldNotDetermine(factTargetFacts, row.Participants.Target.Label) |
| + case ssStopping: |
| + return couldNotDetermine(factStop, selfServeSourceLabel(row)) |
| + case ssSealing: |
| + return couldNotDetermine(factSeal, selfServeSourceLabel(row)) |
| + case ssLanding: |
| + return couldNotDetermine(factLanding, row.Participants.Target.Label) |
| + case ssLanded, ssOpenUncertain: |
| + return couldNotDetermine(factOpen, row.Participants.Target.Label) |
| } |
| return msgSelfServeCouldNotStart |
| } |
| |
| +// selfServeSourceLabel is the row's first pinned source (the stager). |
| +func selfServeSourceLabel(row *SelfServeMove) string { |
| + if len(row.Participants.Source) > 0 { |
| + return row.Participants.Source[0].Label |
| + } |
| + return "" |
| +} |
| + |
| // selfServeAbortStep is step (5), the abort before the no-return boundary |
| // (the record at or before offline-landed, or carrying no move of this |
| // row). No row is finalised while a request is outstanding: an unconfirmed |
| @@ -693,11 +733,23 @@ func (s *Server) selfServePhase(ctx context.Context, t *leaseToken, row *SelfSer |
| return s.selfServeReady(ctx, t, row, rec, now) |
| case ssRestage: |
| return s.selfServeRestage(ctx, t, row, rec, now) |
| + case ssBarrier: |
| + return s.selfServeBarrier(ctx, t, row, rec, now) |
| + case ssStopping: |
| + return s.selfServeStopping(ctx, t, row, rec, now) |
| + case ssSealing: |
| + return s.selfServeSealing(ctx, t, row, rec, now) |
| + case ssLanding: |
| + return s.selfServeLanding(ctx, t, row, rec, now) |
| + case ssLanded: |
| + return s.selfServeLanded(ctx, t, row, rec, now) |
| case ssReverting: |
| return s.selfServeReverting(ctx, t, row, rec, now) |
| } |
| - // barrier through finishing: Task 8. |
| - return nil |
| + // open-uncertain is decided before the phase (selfServeOpenUncertain); |
| + // opened, health and finishing are the open's (selfserve_health.go and |
| + // below). |
| + return s.selfServeAfterOpen(ctx, t, row, rec, now) |
| } |
| |
| // selfServeReplaced is the identity check at a gate: the label of a pinned |
| @@ -940,6 +992,9 @@ func (s *Server) selfServeReports(ctx context.Context, t *leaseToken, row *SelfS |
| v := s.selfServeReportsVerdict(row, mv, ev, fleet, now) |
| switch { |
| case v.fail != "": |
| + if v.fail == s.selfServeNoLane(row.From, row.To).msg { |
| + s.selfServePageRow(ctx, row, ssPageLane, ssPageWhyLane(s.offlineName(row.From), s.offlineName(row.To)), now) |
| + } |
| return s.selfServeAbort(row, v.fail, now) |
| case !v.pass: |
| return nil |
| @@ -1072,7 +1127,7 @@ func (s *Server) selfServeStaging(ctx context.Context, t *leaseToken, row *SelfS |
| // (Request == the row's precheck request), the facts again, the target's |
| // instances prefix known and empty, and the stager's book of the last |
| // round equal to the target's staging prefix (moves.StagedMatches). |
| -func (s *Server) selfServePrechecksVerdict(row *SelfServeMove, mv *provision.MoveState, ev offlineEvidence) ssVerdict { |
| +func (s *Server) selfServePrechecksVerdict(row *SelfServeMove, mv *provision.MoveState, ev offlineEvidence, barrier bool) ssVerdict { |
| host, tgt, at := row.Host, row.Participants.Target, row.PrecheckRequest |
| for _, p := range row.Participants.Source { |
| pre, ok := ev.get(moves.OfflineRoleSource, p.Label) |
| @@ -1094,7 +1149,7 @@ func (s *Server) selfServePrechecksVerdict(row *SelfServeMove, mv *provision.Mov |
| if why := targetReadyDecision(host, tgt.Label, rd.Ready, stager.ReplicaBytes, false); why != "" { |
| return ssVerdict{fail: why} |
| } |
| - if err := moves.StagedMatches(stager.OfflineReport, mv.StageBucket, mv.StageRegion, row.StageRequest, rd.Ready.MovesPrefix, false); err != nil { |
| + if err := moves.StagedMatches(stager.OfflineReport, mv.StageBucket, mv.StageRegion, row.StageRequest, rd.Ready.MovesPrefix, barrier); err != nil { |
| log.Printf("console: self-serve move %s: %v", row.ID, err) |
| return ssVerdict{fail: ssStagedMismatch(host)} |
| } |
| @@ -1126,7 +1181,7 @@ func (s *Server) selfServePrechecks(ctx context.Context, t *leaseToken, row *Sel |
| if err != nil { |
| return err |
| } |
| - v := s.selfServePrechecksVerdict(row, mv, ev) |
| + v := s.selfServePrechecksVerdict(row, mv, ev, false) |
| switch { |
| case v.fail != "": |
| return s.selfServeAbort(row, v.fail, now) |
| @@ -1324,6 +1379,46 @@ func (s *Server) selfServeRestage(ctx context.Context, t *leaseToken, row *SelfS |
| return s.selfServePreOfflineRefusal(row, err, now) |
| } |
| |
| +// ssTargetLeft is the abort's detail for a target that did not report its |
| +// revert. |
| +func ssTargetLeft(label string) string { |
| + return fmt.Sprintf("target %s did not report; its staged copy is left for cleanup", label) |
| +} |
| + |
| +// selfServeSourceReopened is the abort's second fact: no tombstone was ever |
| +// taken (no sealer named), or the sealer reports reverting or reverted with |
| +// the tombstone lifted; and every pinned source reports reverted. |
| +func selfServeSourceReopened(row *SelfServeMove, mv *provision.MoveState, ev offlineEvidence) bool { |
| + if mv.Sealer != "" { |
| + rep, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer) |
| + if !ok || !rep.TombstoneLifted || (rep.Phase != moves.OfflinePhaseReverting && rep.Phase != moves.OfflinePhaseReverted) { |
| + return false |
| + } |
| + } |
| + boxes := selfServeSourceLabels(row) |
| + if len(boxes) == 0 { |
| + boxes = mv.Boxes |
| + } |
| + for _, b := range boxes { |
| + if rep, ok := ev.get(moves.OfflineRoleSource, b); !ok || rep.Phase != moves.OfflinePhaseReverted { |
| + return false |
| + } |
| + } |
| + return len(boxes) > 0 |
| +} |
| + |
| +// selfServeTargetLeft is the abort's detail when the pinned target took |
| +// part (it has a report of this move) and has not reported its revert: |
| +// what it extracted is left on it for cleanup (plan 4's). "" otherwise. |
| +func selfServeTargetLeft(row *SelfServeMove, ev offlineEvidence) string { |
| + label := row.Participants.Target.Label |
| + rep, ok := ev.get(moves.OfflineRoleTarget, label) |
| + if label == "" || !ok || rep.Phase == moves.OfflinePhaseReverted { |
| + return "" |
| + } |
| + return ssTargetLeft(label) |
| +} |
| + |
| // selfServeReverting carries an abort to its finish: substep abort writes |
| // it (OfflineAbortAt for a queued move, which clears it and is finalised |
| // from last_move; OfflineAbort otherwise, a repeated one on a reverting |
| @@ -1375,10 +1470,32 @@ func (s *Server) selfServeReverting(ctx context.Context, t *leaseToken, row *Sel |
| if err != nil { |
| return err |
| } |
| + // The second fact: the source reopened (the tombstone lifted, |
| + // when one was taken, and every pinned source reverted). A |
| + // sealer that reverted without lifting (another lease is live) |
| + // is waited on, never taken for the lift. |
| + if row.SourceReopenedAt.IsZero() { |
| + if !selfServeSourceReopened(row, mv, ev) { |
| + return nil |
| + } |
| + TestHooks.point("abort:reopened") |
| + if err := s.store.SetSelfServeReopened(row.ID, now); err != nil { |
| + return err |
| + } |
| + } |
| + // The third: every abort report in (the operator's own finish |
| + // check at offline-reverting). The target is never asked: one |
| + // that stopped reporting after the landing does not hold the |
| + // source, and what it holds is left for cleanup, named. |
| d := offlineDoor{app: row.App, host: row.Host, rec: rec, job: true} |
| if _, ref := s.offlineStepFor(ctx, d, mv, ev, now); ref != nil { |
| return nil // the abort's reports are not all in |
| } |
| + if left := selfServeTargetLeft(row, ev); left != "" && row.Warning == "" { |
| + if err := s.store.SetSelfServeWarning(row.ID, left); err != nil { |
| + return err |
| + } |
| + } |
| if row.AbortFinishedAt.IsZero() { |
| if err := s.store.SetSelfServeAbortFinished(row.ID, now); err != nil { |
| return err |
| @@ -1396,3 +1513,568 @@ func (s *Server) selfServeReverting(ctx context.Context, t *leaseToken, row *Sel |
| // confirmed finish with the outcome. |
| return nil |
| } |
| + |
| +// ---- the cut, from the barrier to the open (Task 8) ------------------------------ |
| + |
| +// selfServeMark is a timing mark: the console's observation time of one of |
| +// the cut's events (offline_at, stopped_at, seal_at, sealed_at, flip_at, |
| +// landed_at, open_at, opened_at, first_ok_at), insert-only. A no-op until |
| +// Task 9's timings fill it. |
| +func (s *Server) selfServeMark(row *SelfServeMove, mark string, at time.Time, bytes int64) {} |
| + |
| +// selfServeBarrier is the cut's last check before anything goes offline: a |
| +// precheck asked for (OfflinePrecheck), then each source's precheck and the |
| +// target's ready report for it, the identities, the decisions and the book |
| +// comparison with nothing deferred (moves.StagedMatches at the barrier). A |
| +// failed fact aborts; all pass, the row goes to stopping, consuming the |
| +// precheck. |
| +func (s *Server) selfServeBarrier(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil || mv.State != provision.OfflineStaging { |
| + return nil |
| + } |
| + if !selfServeWaiting(row, "precheck") { |
| + if out, _ := selfServeOutstanding(row); out && !(row.Request.Kind == "precheck" && row.Request.Phase == ssBarrier) { |
| + return nil |
| + } |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "precheck", Phase: ssBarrier}, nil, |
| + func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflinePrecheck(row.ID, at, rc) |
| + }) |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + if sup, err := s.selfServeSuperseded(row, mv); sup || err != nil { |
| + return err |
| + } |
| + fleet, ok := s.readOfflineFleet(ctx) |
| + if !ok { |
| + return nil |
| + } |
| + if label := selfServeReplaced(row, fleet, now); label != "" { |
| + return s.selfServeAbort(row, ssReplaced(label), now) |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + v := s.selfServePrechecksVerdict(row, mv, ev, true) |
| + switch { |
| + case v.fail != "": |
| + return s.selfServeAbort(row, v.fail, now) |
| + case !v.pass: |
| + return nil |
| + } |
| + TestHooks.point("advance:barrier->stopping") |
| + _, err = s.store.AdvanceSelfServe(row.ID, ssBarrier, ssStopping, "", selfServePhaseDeadline(row, ssStopping, now), time.Time{}, row.Request.Nonce, now) |
| + return err |
| +} |
| + |
| +// selfServeSourceLabels are the pinned sources' labels, in pin order. |
| +func selfServeSourceLabels(row *SelfServeMove) []string { |
| + var out []string |
| + for _, p := range row.Participants.Source { |
| + out = append(out, p.Label) |
| + } |
| + return out |
| +} |
| + |
| +// selfServeStopping takes the instance offline and seals it. Substep start: |
| +// the start validation in full on the fresh record (offlineRecordChecks, |
| +// then offlineRegionChecks with the start's rules and the participants' |
| +// identities, the selection still pinning the row's participants), then |
| +// OfflineStartStaged; substep seal, once every pinned source reports the |
| +// stop: the seal check (offlineSealCheck) over exactly the pinned sources, |
| +// then OfflineSeal naming the sealer, and the row goes to sealing. A |
| +// refusal that clears on its own (a box that has not stopped yet, a report |
| +// not current) is waited out until the phase's deadline; any other aborts. |
| +func (s *Server) selfServeStopping(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil { |
| + return nil |
| + } |
| + out, conf := selfServeOutstanding(row) |
| + switch mv.State { |
| + case provision.OfflineStaging: |
| + if row.PhaseStep != ssStepStart || (out && row.Request.Kind != "start") { |
| + return nil |
| + } |
| + if !(out && !conf) { |
| + if ref := s.offlineRecordChecks(ctx, row.Account, row.App, row.Host, rec); ref != nil { |
| + return s.selfServeRefused(row, ref, now) |
| + } |
| + sel, ref := s.offlineRegionChecks(ctx, row.Account, row.App, row.Host, rec, s.homeRegionOut(row.To), false, true, "") |
| + if ref != nil { |
| + return s.selfServeRefused(row, ref, now) |
| + } |
| + if label := selfServeSelectionMoved(row, sel); label != "" { |
| + return s.selfServeAbort(row, ssReplaced(label), now) |
| + } |
| + } |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "start", Phase: ssStopping}, nil, |
| + func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineStartStaged(row.ID, row.RequestedBy, at, rc) |
| + }) |
| + if err != nil { |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + s.selfServeMark(row, "offline_at", now, 0) |
| + TestHooks.point("start:consume") |
| + _, err = s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, ssStepSeal, row.StepDeadline) |
| + return err |
| + case provision.OfflineStopping: |
| + default: |
| + return nil |
| + } |
| + if selfServeWaiting(row, "start") { |
| + // The start confirmed and not consumed (a crash between the two). |
| + TestHooks.point("start:consume") |
| + _, err := s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, ssStepSeal, row.StepDeadline) |
| + return err |
| + } |
| + if row.PhaseStep == ssStepStart { |
| + // An operator's start: the seal is the job's next write. |
| + _, err := s.store.SetSelfServeStep(row.ID, ssStopping, ssStepStart, ssStepSeal, row.StepDeadline) |
| + return err |
| + } |
| + if out && row.Request.Kind != "seal" { |
| + return nil |
| + } |
| + fleet, ok := s.readOfflineFleet(ctx) |
| + if !ok { |
| + return nil |
| + } |
| + if label := selfServeReplaced(row, fleet, now); label != "" && !(out && !conf) { |
| + return s.selfServeAbort(row, ssReplaced(label), now) |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + sealer, boxes, ref := s.offlineSealCheck(fleet, ev, mv, row.Host, s.offlineName(mv.From), now) |
| + if ref != nil { |
| + if out && !conf { |
| + // The seal already asked for waits for its own confirmation; |
| + // the deadline bounds it. |
| + return nil |
| + } |
| + return s.selfServeCutRefused(row, mv, ref, now) |
| + } |
| + if want := selfServeSourceLabels(row); !slices.Equal(sortedCopy(boxes), sortedCopy(want)) { |
| + for _, b := range append(sortedCopy(want), boxes...) { |
| + if !slices.Contains(boxes, b) || !slices.Contains(want, b) { |
| + return s.selfServeAbort(row, ssReplaced(b), now) |
| + } |
| + } |
| + } |
| + s.selfServeMark(row, "stopped_at", now, 0) |
| + if _, err := s.selfServeApply(ctx, t, row, request{Kind: "seal", Phase: ssStopping}, nil, |
| + func(_ time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineSeal(row.ID, sealer, boxes, rc) |
| + }); err != nil { |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + s.selfServeMark(row, "seal_at", now, 0) |
| + TestHooks.point("advance:stopping->sealing") |
| + _, err = s.store.AdvanceSelfServe(row.ID, ssStopping, ssSealing, "", selfServePhaseDeadline(row, ssSealing, now), time.Time{}, row.Request.Nonce, now) |
| + return err |
| +} |
| + |
| +// selfServeCutRefused is a cut check's refusal (the seal, flip and open |
| +// checks): one that says the evidence is not in yet (a box that has not |
| +// stopped, a report not current, the state's own MV-O25: the seal, the |
| +// landing or the tombstone not reported yet) is waited for, the phase's |
| +// deadline bounding it, whose expiry aborts naming the step; any other |
| +// (a sidecar, local data on two boxes, a stager gone) aborts with its |
| +// sentence. |
| +func (s *Server) selfServeCutRefused(row *SelfServeMove, mv *provision.MoveState, ref *offlineRefusal, now time.Time) error { |
| + if ref.transient || ref.msg == offlineStateRefusal(mv.State, row.Host).msg { |
| + log.Printf("console: self-serve move %s: waiting: %s", row.ID, ref.msg) |
| + return nil |
| + } |
| + return s.selfServeAbort(row, ref.msg, now) |
| +} |
| + |
| +// sortedCopy is a sorted copy of ss. |
| +func sortedCopy(ss []string) []string { |
| + out := append([]string(nil), ss...) |
| + slices.Sort(out) |
| + return out |
| +} |
| + |
| +// selfServeSealing waits for the sealer's seal: the record sealed |
| +// (OfflineSealedBy, the sealer's own write) moves the row to landing; a |
| +// sealer that reports it failed aborts with its detail. |
| +func (s *Server) selfServeSealing(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil { |
| + return nil |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + switch mv.State { |
| + case provision.OfflineSealed: |
| + var copied int64 |
| + if ev.seal != nil { |
| + copied = ev.seal.FinalCopiedBytes |
| + } |
| + s.selfServeMark(row, "sealed_at", now, copied) |
| + TestHooks.point("advance:sealing->landing") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssSealing, ssLanding, "", selfServePhaseDeadline(row, ssLanding, now), time.Time{}, "", now) |
| + return err |
| + case provision.OfflineSealing: |
| + if rep, ok := ev.get(moves.OfflineRoleSealer, mv.Sealer); ok && rep.Phase == moves.OfflinePhaseFailed { |
| + return s.selfServeAbort(row, moves.FailedDetail(rep.OfflineReport), now) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// selfServeLanding flips the region to the target (OfflineFlip, after the |
| +// flip check: the seal the record names, every pinned source still stopped |
| +// with no process) and waits for the pinned target's landing: the record |
| +// landed (OfflineLandedBy, the target's own write) is reconciliation's |
| +// advance to landed. A target that reports it failed aborts with its |
| +// detail. |
| +func (s *Server) selfServeLanding(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil { |
| + return nil |
| + } |
| + out, conf := selfServeOutstanding(row) |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + switch mv.State { |
| + case provision.OfflineSealed: |
| + if out && row.Request.Kind != "flip" { |
| + return nil |
| + } |
| + if ref := s.offlineFlipCheck(ev, mv, row.Host, s.offlineName(mv.From), now); ref != nil { |
| + if out && !conf { |
| + return nil |
| + } |
| + return s.selfServeCutRefused(row, mv, ref, now) |
| + } |
| + if _, err := s.selfServeApply(ctx, t, row, request{Kind: "flip", Phase: ssLanding}, nil, |
| + func(_ time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineFlip(row.ID, rc) |
| + }); err != nil { |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + s.selfServeMark(row, "flip_at", now, 0) |
| + TestHooks.point("flip:consume") |
| + _, err = s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, "", row.StepDeadline) |
| + return err |
| + case provision.OfflineLanding: |
| + if selfServeWaiting(row, "flip") { |
| + TestHooks.point("flip:consume") |
| + _, err := s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, "", row.StepDeadline) |
| + return err |
| + } |
| + if rep, ok := ev.get(moves.OfflineRoleTarget, row.Participants.Target.Label); ok && rep.Phase == moves.OfflinePhaseFailed { |
| + return s.selfServeAbort(row, moves.FailedDetail(rep.OfflineReport), now) |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// selfServeLanded opens the target: the open check (offlineOpenCheck: the |
| +// landing report with every item OK, the seal, the sealer's tombstone held, |
| +// every source still stopped), then the open record written create-only |
| +// (open_record_pending until it exists; this move's own adopted), then |
| +// OfflineOpenTransition at the open record's stamp. A refusal, or the |
| +// deadline while the record is offline-landed, aborts; an open whose |
| +// confirmation could not read the record goes to open-uncertain, where only |
| +// the record decides. |
| +func (s *Server) selfServeLanded(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil || mv.State != provision.OfflineLanded { |
| + return nil |
| + } |
| + out, conf := selfServeOutstanding(row) |
| + if out && row.Request.Kind != "open" { |
| + return nil |
| + } |
| + var proposed *time.Time |
| + if !(out && !conf) { |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + if ref := s.offlineOpenCheck(ev, mv, row.Host, s.offlineName(mv.From), now); ref != nil { |
| + return s.selfServeCutRefused(row, mv, ref, now) |
| + } |
| + if !row.OpenRecordPending { |
| + if err := s.store.SetSelfServeOpenRecordPending(row.ID, true); err != nil { |
| + return err |
| + } |
| + } |
| + TestHooks.point("open:record") |
| + open, err := s.offlineOpenRecord(ctx, rec, mv, ev, provision.ProposeStamp(rec, now)) |
| + if errors.Is(err, errOpenRecordInTheWay) { |
| + return s.selfServeAbort(row, msgOpenRecordInTheWay, now) |
| + } |
| + if err != nil { |
| + return fmt.Errorf("console: self-serve move %s: the open record: %w (retried next tick)", row.ID, err) |
| + } |
| + TestHooks.point("open:recorded") |
| + if err := s.store.SetSelfServeOpenRecordPending(row.ID, false); err != nil { |
| + return err |
| + } |
| + proposed = &open.OpenedAt |
| + } |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "open", Phase: ssLanded}, proposed, |
| + func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineOpenTransition(row.ID, at, rc) |
| + }) |
| + var refusal *requestRefusal |
| + switch { |
| + case err == nil: |
| + case errors.As(err, &refusal): |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + case errors.Is(err, errRecordUnreadable): |
| + return s.selfServeOpenUnconfirmable(row, now) |
| + default: |
| + return err |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + s.selfServeMark(row, "open_at", now, 0) |
| + TestHooks.point("advance:landed->opened") |
| + _, err = s.store.AdvanceSelfServe(row.ID, ssLanded, ssOpened, "", selfServePhaseDeadline(row, ssOpened, now), time.Time{}, row.Request.Nonce, now) |
| + return err |
| +} |
| + |
| +// selfServeOpenUnconfirmable is an open whose write may have landed and |
| +// whose record cannot be read: the row goes to open-uncertain with the |
| +// request outstanding, where it waits for the record to decide. |
| +func (s *Server) selfServeOpenUnconfirmable(row *SelfServeMove, now time.Time) error { |
| + row, err := s.selfServeReload(row.ID) |
| + if err != nil || row == nil { |
| + return err |
| + } |
| + if out, conf := selfServeOutstanding(row); !out || conf || row.Request.Kind != "open" || row.Phase != ssLanded { |
| + return nil |
| + } |
| + TestHooks.point("advance:landed->open-uncertain") |
| + _, err = s.store.AdvanceSelfServeToStep(row.ID, ssLanded, ssOpenUncertain, "", "", time.Time{}, time.Time{}, "", now) |
| + return err |
| +} |
| + |
| +// selfServeOpenUncertain decides an open whose write may have landed, from |
| +// the record alone, before any other step (step (2) for landed and |
| +// open-uncertain rows). It reports whether the step ended. |
| +// |
| +// - The record unreadable: a landed row with its open outstanding goes to |
| +// open-uncertain; an open-uncertain row stays, and pages once it has |
| +// been unreadable for 5 minutes. |
| +// - The record readable and still offline-landed without the open's |
| +// receipt, the open outstanding: before the open request's deadline the |
| +// row goes back to landed, whose phase replays the same bound write; |
| +// after it, the no-replay rule: the expiry decided durably, then |
| +// fenced and classified (the receipt confirms; absent, voided and |
| +// aborted). Never a replay past the deadline. |
| +// - An open-uncertain row with nothing outstanding on a landed record goes |
| +// back to landed to decide again (an abort requested is the abort |
| +// step's). |
| +// |
| +// Anything else (the receipt present, the record opened, reverting or gone) |
| +// is the ordinary step's: confirmation and reconciliation. |
| +func (s *Server) selfServeOpenUncertain(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, recErr error, now time.Time) (bool, error) { |
| + out, conf := selfServeOutstanding(row) |
| + open := out && !conf && row.Request.Kind == "open" |
| + if recErr != nil && errors.Is(recErr, errRecordUnreadable) { |
| + switch { |
| + case row.Phase == ssLanded && open: |
| + TestHooks.point("advance:landed->open-uncertain") |
| + _, err := s.store.AdvanceSelfServeToStep(row.ID, ssLanded, ssOpenUncertain, "", "", time.Time{}, time.Time{}, "", now) |
| + return true, err |
| + case row.Phase == ssOpenUncertain: |
| + if now.Sub(row.PhaseStartedAt) >= selfServeUncertainPage { |
| + s.selfServePageRow(ctx, row, ssPageOpenUncertain, ssPageWhyUncertain(row.ID), now) |
| + } |
| + return true, recErr |
| + } |
| + return false, nil |
| + } |
| + if row.Phase != ssOpenUncertain || recErr != nil { |
| + return false, nil |
| + } |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil || mv.State != provision.OfflineLanded { |
| + return false, nil |
| + } |
| + switch { |
| + case open && selfServeHasReceipt(rec, row.Request.Nonce): |
| + return false, nil |
| + case open && !row.Request.Expired && !row.Request.Deadline.IsZero() && now.Before(row.Request.Deadline): |
| + TestHooks.point("advance:open-uncertain->landed") |
| + _, err := s.store.AdvanceSelfServeToStep(row.ID, ssOpenUncertain, ssLanded, "", "", row.Request.Deadline, time.Time{}, "", now) |
| + return true, err |
| + case open: |
| + if !row.Request.Expired { |
| + TestHooks.point("open:expire") |
| + if _, err := s.store.ExpireSelfServeRequest(row.ID, row.Request.Nonce, row.Request.Deadline, row.Request.CutRequestedAt); err != nil { |
| + return true, err |
| + } |
| + } |
| + return true, s.selfServeExpired(ctx, t, row) |
| + case !out && row.AbortRequestedAt.IsZero(): |
| + TestHooks.point("advance:open-uncertain->landed") |
| + _, err := s.store.AdvanceSelfServeToStep(row.ID, ssOpenUncertain, ssLanded, "", "", selfServePhaseDeadline(row, ssLanded, now), time.Time{}, "", now) |
| + return true, err |
| + } |
| + return false, nil |
| +} |
| + |
| +// selfServeAfterOpen is the phases past the no-return boundary: opened, |
| +// health and finishing. Nothing here aborts: a wait past its deadline |
| +// pages and retries (the expiry action), and the health window finishes |
| +// with a warning. |
| +func (s *Server) selfServeAfterOpen(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + switch row.Phase { |
| + case ssOpened: |
| + return s.selfServeOpened(ctx, row, rec, now) |
| + case ssHealth: |
| + return s.selfServeHealth(ctx, t, row, rec, now) |
| + case ssFinishing: |
| + return s.selfServeFinishing(ctx, t, row, rec, now) |
| + } |
| + return nil |
| +} |
| + |
| +// selfServeOpened waits for the pinned target's opened report (the first |
| +// finish gate needs it; a ready row alone is not enough), then probes. |
| +func (s *Server) selfServeOpened(ctx context.Context, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil || mv.State != provision.OfflineOpen { |
| + return nil |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + if rep, ok := ev.get(moves.OfflineRoleTarget, row.Participants.Target.Label); !ok || rep.Phase != moves.OfflinePhaseOpened { |
| + return nil |
| + } |
| + s.selfServeMark(row, "opened_at", now, 0) |
| + TestHooks.point("advance:opened->health") |
| + _, err = s.store.AdvanceSelfServe(row.ID, ssOpened, ssHealth, "", selfServePhaseDeadline(row, ssHealth, now), time.Time{}, "", now) |
| + return err |
| +} |
| + |
| +// selfServeFinishing is the existing two finish gates in turn, never |
| +// bypassed. Gate 1, from offline-open: the target's opened report and |
| +// every pinned source parked (offlineFinishCheck), then |
| +// OfflineFinishingTransition, the gate recorded as lifted. Gate 2, from |
| +// offline-finishing: the sealer's lifted report and every pinned source |
| +// gone, then OfflineFinish, which clears the move; reconciliation then |
| +// finalises the row done, keeping any warning. Each gate waits 10 minutes, |
| +// then pages and keeps waiting. |
| +func (s *Server) selfServeFinishing(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil { |
| + return nil |
| + } |
| + out, conf := selfServeOutstanding(row) |
| + boxes := mv.Boxes |
| + if len(boxes) == 0 { |
| + boxes = selfServeSourceLabels(row) |
| + } |
| + switch mv.State { |
| + case provision.OfflineOpen: |
| + if out && row.Request.Kind != "finish1" { |
| + return nil |
| + } |
| + if !(out && !conf) { |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + if ref := offlineFinishCheck(ev, mv, row.Host, moves.OfflinePhaseOpened, "", moves.OfflinePhaseParked, boxes); ref != nil { |
| + return nil |
| + } |
| + } |
| + if _, err := s.selfServeApply(ctx, t, row, request{Kind: "finish1", Phase: ssFinishing}, nil, |
| + func(_ time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineFinishingTransition(row.ID, rc) |
| + }); err != nil { |
| + return s.selfServePostOpenRefusal(row, err) |
| + } |
| + return s.selfServeGate2(row, now) |
| + case provision.OfflineFinishing: |
| + default: |
| + return nil |
| + } |
| + if selfServeWaiting(row, "finish1") || row.PhaseStep != ssStepGate2 && !out { |
| + // Gate 1 written (by the job, consumed here after a crash, or by an |
| + // operator's door): gate 2 next. |
| + return s.selfServeGate2(row, now) |
| + } |
| + if out && row.Request.Kind != "finish" { |
| + return nil |
| + } |
| + if !(out && !conf) { |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + if ref := offlineFinishCheck(ev, mv, row.Host, "", moves.OfflinePhaseLifted, moves.OfflinePhaseGone, boxes); ref != nil { |
| + return nil |
| + } |
| + } |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "finish", Phase: ssFinishing}, nil, |
| + func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineFinish(row.ID, at, rc) |
| + }) |
| + // Cleared: the next step finalises from last_move, consuming the |
| + // confirmed finish with the outcome. |
| + return s.selfServePostOpenRefusal(row, err) |
| +} |
| + |
| +// selfServeGate2 records gate 1 as lifted and moves the row to gate 2 with |
| +// its own 10 minutes, consuming gate 1's confirmed request when it is the |
| +// row's. |
| +func (s *Server) selfServeGate2(row *SelfServeMove, now time.Time) error { |
| + row, err := s.selfServeReload(row.ID) |
| + if err != nil || row == nil { |
| + return err |
| + } |
| + if row.FinishGate != "lifted" { |
| + if err := s.store.SetSelfServeFinishGate(row.ID, "lifted"); err != nil { |
| + return err |
| + } |
| + } |
| + TestHooks.point("finish1:consume") |
| + if selfServeWaiting(row, "finish1") { |
| + _, err = s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, ssStepGate2, now.Add(selfServeRevertWait)) |
| + return err |
| + } |
| + _, err = s.store.SetSelfServeStep(row.ID, ssFinishing, row.PhaseStep, ssStepGate2, now.Add(selfServeRevertWait)) |
| + return err |
| +} |
| + |
| +// selfServePostOpenRefusal is a post-open write that the record refused: |
| +// nothing is aborted after the open; the refusal is logged and the row |
| +// decides again next tick (reconciliation follows the record). |
| +func (s *Server) selfServePostOpenRefusal(row *SelfServeMove, err error) error { |
| + var refusal *requestRefusal |
| + if errors.As(err, &refusal) { |
| + log.Printf("console: self-serve move %s: %v (decided again next tick)", row.ID, err) |
| + return nil |
| + } |
| + return err |
| +} |