| new file mode 100644 |
| index 000000000..8bf9344ff |
| --- /dev/null |
| +++ b/internal/console/selfserve_job.go |
| @@ -0,0 +1,1398 @@ |
| +package console |
| + |
| +// The self-serve job (self-serve moves plan 2 Task 7; spec §7): one step |
| +// per open row per tick, from requested to ready, and the reconciliation |
| +// that lets any crash resume. The record is the truth: a step reads the |
| +// row's deadlines first, then the judged record, reconciles the row forward |
| +// to the phases the record admits (never backwards), runs a pending expiry |
| +// or abort, and only then the phase's own step. Every record write is a |
| +// persisted request (selfServeApply). A job tick never calls an HTTP door. |
| +// |
| +// Task 8 owns the phases from the cut's barrier on (stopping, sealing, |
| +// landing, landed, open-uncertain, opened, health, finishing), the full |
| +// reverting rules and the finaliser's timings; the minimal reverting step |
| +// here carries the pre-offline abort to its finish. |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// The waits' allowances and bounds (spec §6.2, §7.2). |
| +const ( |
| + selfServeReportsWait = 5 * time.Minute |
| + selfServePrechecksWait = 5 * time.Minute |
| + selfServeRoundFloor = 10 * time.Minute |
| + selfServeStagingBound = 30 * time.Minute |
| + selfServeStagingRounds = 10 |
| + selfServeCopyFloor = 5 * time.Minute |
| + selfServeRevertWait = 10 * time.Minute |
| + // selfServeQueueFence is how long a queue request may stay unconfirmed |
| + // on a record with no move before the job fences the record and gives |
| + // the admission up (the reconciliation table's reserving row). |
| + selfServeQueueFence = 30 * time.Second |
| +) |
| + |
| +// The substeps (phase_step) of the phases that write more than once. |
| +const ( |
| + ssStepRound = "round" |
| + ssStepPrecheck = "precheck" |
| + ssStepEstimate = "estimate" |
| + ssStepAbort = "abort" |
| + ssStepFinish = "finish" |
| + ssStepAdopted = "adopted" |
| + ssStepIssue = "issue" |
| + ssStepSeal = "seal" |
| + ssStepGate2 = "gate2" |
| +) |
| + |
| +// maxDuration is the larger of a and b. |
| +func maxDuration(a, b time.Duration) time.Duration { |
| + if a > b { |
| + return a |
| + } |
| + return b |
| +} |
| + |
| +// selfServeRoundAllowance is one staging round's: max(10 min, 3 × the |
| +// copy's estimate). |
| +func selfServeRoundAllowance(row *SelfServeMove) time.Duration { |
| + return maxDuration(selfServeRoundFloor, 3*estimateTermsOf(row.EstimateTerms).Copy) |
| +} |
| + |
| +// selfServeCopyAllowance is a maintenance or cut round's: max(5 min, 2 × |
| +// the copy's estimate), as the cut door computes it. |
| +func selfServeCopyAllowance(row *SelfServeMove) time.Duration { |
| + return maxDuration(selfServeCopyFloor, 2*estimateTermsOf(row.EstimateTerms).Copy) |
| +} |
| + |
| +// selfServePhaseDeadline is the step deadline a phase entered at now gets |
| +// (spec §7.2's table): the reconciliation advance's, and the job's own. |
| +func selfServePhaseDeadline(row *SelfServeMove, phase string, now time.Time) time.Time { |
| + est := estimateTermsOf(row.EstimateTerms) |
| + switch phase { |
| + case ssQueued, ssReports: |
| + return now.Add(selfServeReportsWait) |
| + case ssStaging: |
| + return now.Add(selfServeRoundAllowance(row)) |
| + case ssPrechecks: |
| + return now.Add(selfServePrechecksWait) |
| + case ssRestage, ssBarrier: |
| + return now.Add(selfServeCopyAllowance(row)) |
| + case ssStopping: |
| + return now.Add(maxDuration(5*time.Minute, 2*est.Stop)) |
| + case ssSealing: |
| + return now.Add(maxDuration(10*time.Minute, 3*est.Copy)) |
| + case ssLanding: |
| + return now.Add(maxDuration(10*time.Minute, 3*est.Land)) |
| + case ssLanded, ssOpened: |
| + return now.Add(maxDuration(3*time.Minute, 2*est.Open)) |
| + case ssHealth: |
| + return now.Add(maxDuration(3*time.Minute, 2*est.Promote) + 30*time.Second) |
| + case ssFinishing, ssReverting: |
| + return now.Add(selfServeRevertWait) |
| + } |
| + return time.Time{} |
| +} |
| + |
| +// selfServeOurs is the record's move when it is this row's. |
| +func selfServeOurs(rec *provision.Record, row *SelfServeMove) *provision.MoveState { |
| + if rec != nil && rec.Move != nil && rec.Move.ID == row.ID { |
| + return rec.Move |
| + } |
| + return nil |
| +} |
| + |
| +// selfServePreOpen reports whether a move state is at or before |
| +// offline-landed: the states an abort can still take back losslessly. |
| +func selfServePreOpen(state string) bool { |
| + switch state { |
| + case provision.OfflineQueued, provision.OfflineStaging, provision.OfflineStopping, provision.OfflineSealing, |
| + provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded: |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// selfServeSets is the reconciliation table's compatible sets: for each |
| +// record state, the row phases that state admits, the set's first phase |
| +// and the substep reconciliation enters it at. A row in the set stays (a |
| +// phase whose write has not been issued yet, or whose write is pending, |
| +// shares its predecessor's record state); any other row is advanced to the |
| +// first phase. reverting is in every pre-open set: an abort's own write |
| +// may still be pending. |
| +var selfServeSets = map[string]struct { |
| + phases []string |
| + first, substep string |
| +}{ |
| + provision.OfflineQueued: {[]string{ssQueued, ssReports, ssStaging, ssReverting}, ssQueued, ""}, |
| + provision.OfflineStaging: {[]string{ssStaging, ssPrechecks, ssReady, ssRestage, ssBarrier, ssStopping, ssReverting}, ssStaging, ssStepRound}, |
| + provision.OfflineStopping: {[]string{ssStopping, ssReverting}, ssStopping, ssStepSeal}, |
| + provision.OfflineSealing: {[]string{ssSealing, ssLanding, ssReverting}, ssSealing, ""}, |
| + provision.OfflineSealed: {[]string{ssSealing, ssLanding, ssReverting}, ssSealing, ""}, |
| + provision.OfflineLanding: {[]string{ssLanding, ssReverting}, ssLanding, ""}, |
| + provision.OfflineLanded: {[]string{ssLanded, ssOpenUncertain, ssReverting}, ssLanded, ""}, |
| + provision.OfflineOpen: {[]string{ssOpened, ssHealth, ssFinishing}, ssOpened, ""}, |
| + provision.OfflineFinishing: {[]string{ssFinishing}, ssFinishing, ssStepGate2}, |
| + provision.OfflineReverting: {[]string{ssReverting}, ssReverting, ssStepFinish}, |
| +} |
| + |
| +func containsPhase(phases []string, p string) bool { |
| + for _, x := range phases { |
| + if x == p { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// selfServeOutstanding reports whether the row has an issued request that |
| +// is not consumed, and whether it is confirmed. |
| +func selfServeOutstanding(row *SelfServeMove) (outstanding, confirmed bool) { |
| + if row.RequestIssued.IsZero() || row.Request.Consumed { |
| + return false, false |
| + } |
| + return true, row.Request.Confirmed |
| +} |
| + |
| +// selfServeWaiting reports whether the row's outstanding request is a |
| +// confirmed one of kind: the wait the phase is in. |
| +func selfServeWaiting(row *SelfServeMove, kinds ...string) bool { |
| + out, conf := selfServeOutstanding(row) |
| + if !out || !conf { |
| + return false |
| + } |
| + for _, k := range kinds { |
| + if row.Request.Kind == k { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// selfServeTransient reports whether a check's refusal is one that clears |
| +// on its own (a read that failed, a report not yet current), which the job |
| +// waits out rather than aborting on. |
| +func selfServeTransient(ref *offlineRefusal) bool { |
| + return ref.status >= http.StatusInternalServerError || strings.Contains(ref.msg, "try again") || |
| + strings.HasSuffix(ref.msg, "has not reported recently") |
| +} |
| + |
| +// selfServeStep advances one row by at most one step, in the plan's order: |
| +// (1) the row's deadlines, decided before any evidence is read; (2) the |
| +// judged record; (3) the outstanding request resolved (an expired one |
| +// classified and never replayed) and the row reconciled forward by the |
| +// record; (4) a pending expiry's action; (5) a requested abort; (6) the |
| +// phase's own step. A finished row resumes the finaliser. |
| +func (s *Server) selfServeStep(ctx context.Context, t *leaseToken, row *SelfServeMove) error { |
| + if row.Outcome != "" { |
| + return s.selfServeFinaliseRest(ctx, t, row.ID) |
| + } |
| + now := s.selfServeNow() |
| + |
| + // (1) The deadlines. |
| + if changed, err := s.selfServeDeadlines(row, now); err != nil { |
| + return err |
| + } else if changed { |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + } |
| + |
| + // (2) The judged record. |
| + rec, err := selfServeRecordOf(s.observeRecord(ctx, row.Account, row.App, row.Host), row) |
| + gone := errors.Is(err, errRecordGone) |
| + if err != nil && !gone { |
| + return err |
| + } |
| + |
| + // (3) The outstanding request first. |
| + if out, conf := selfServeOutstanding(row); (out && !conf) || row.Request.Expired && !row.Request.Consumed { |
| + _, rerr := s.selfServeResume(ctx, t, row) |
| + switch { |
| + case errors.Is(rerr, errRequestExpired): |
| + return s.selfServeExpired(ctx, t, row) |
| + case rerr == nil, errors.Is(rerr, errRequestVoided), errors.Is(rerr, errRecordGone), errors.Is(rerr, errRequestRefused): |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + rec, err = selfServeRecordOf(s.observeRecord(ctx, row.Account, row.App, row.Host), row) |
| + gone = errors.Is(err, errRecordGone) |
| + if err != nil && !gone { |
| + return err |
| + } |
| + case errors.Is(rerr, errReplayNeeded): |
| + // The write has not landed and the record is unchanged: the |
| + // phase function replays it (below), unless the reserving |
| + // row's queue has waited past the fence. |
| + default: |
| + return rerr |
| + } |
| + } |
| + if !gone { |
| + s.selfServeCatchUp(ctx, row, rec) |
| + } |
| + if done, err := s.selfServeReconcile(ctx, t, row, rec, gone, now); done || err != nil { |
| + return err |
| + } |
| + |
| + // (4) A pending expiry of this phase. |
| + if row.ExpiryPending && row.ExpiryPhase == row.Phase { |
| + if done, err := s.selfServeExpiryAction(ctx, t, row, rec, now); done || err != nil { |
| + return err |
| + } |
| + } |
| + |
| + // (5) A requested abort. |
| + if !row.AbortRequestedAt.IsZero() { |
| + if done, err := s.selfServeAbortStep(ctx, t, row, rec, now); done || err != nil { |
| + return err |
| + } |
| + } |
| + |
| + // (6) The phase. |
| + err = s.selfServePhase(ctx, t, row, rec, now) |
| + switch { |
| + case errors.Is(err, errRequestExpired): |
| + return s.selfServeExpired(ctx, t, row) |
| + case errors.Is(err, errRequestForeign), errors.Is(err, errRequestRefused), errors.Is(err, errRequestVoided), errors.Is(err, errRecordGone): |
| + // Reconciliation decides next tick. |
| + return nil |
| + } |
| + return err |
| +} |
| + |
| +// selfServeReload reads the row again. |
| +func (s *Server) selfServeReload(id string) (*SelfServeMove, error) { |
| + return s.store.SelfServeMove(id) |
| +} |
| + |
| +// selfServeDeadlines is step (1): an expired step deadline, or an expired |
| +// window while no cut is requested, is recorded as the phase's pending |
| +// expiry against the exact deadline read (no action yet). A pending expiry |
| +// of this phase is left as it is. |
| +func (s *Server) selfServeDeadlines(row *SelfServeMove, now time.Time) (bool, error) { |
| + if row.ExpiryPending && row.ExpiryPhase == row.Phase { |
| + return false, nil |
| + } |
| + var kind string |
| + var saw time.Time |
| + switch { |
| + case !row.WindowDeadline.IsZero() && !now.Before(row.WindowDeadline) && row.CutRequestedAt.IsZero(): |
| + kind, saw = "window", row.WindowDeadline |
| + case !row.StepDeadline.IsZero() && !now.Before(row.StepDeadline): |
| + kind, saw = "step", row.StepDeadline |
| + default: |
| + return false, nil |
| + } |
| + TestHooks.point("expiry:" + row.Phase) |
| + return s.store.SetSelfServeExpiry(row.ID, row.Phase, kind, saw, true) |
| +} |
| + |
| +// selfServeAbort records the job's own decision to abort with reason. |
| +func (s *Server) selfServeAbort(row *SelfServeMove, reason string, now time.Time) error { |
| + TestHooks.point("abort:" + row.Phase) |
| + _, err := s.store.AbortSelfServe(row.ID, reason, now) |
| + log.Printf("console: self-serve move %s (%s): aborting: %s", row.ID, row.Host, reason) |
| + return err |
| +} |
| + |
| +// ---- reconciliation ------------------------------------------------------------ |
| + |
| +// selfServeReconcile is step (3)'s second half, the reconciliation table: |
| +// the record decides, and the row only ever moves forward. It reports |
| +// whether the step ended (a finalisation, an advance, the queue's fence). |
| +func (s *Server) selfServeReconcile(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, gone bool, now time.Time) (bool, error) { |
| + if gone { |
| + if out, conf := selfServeOutstanding(row); out && !conf { |
| + // selfServeResume voids it; a request still outstanding here |
| + // is resolved next tick before the row is finalised. |
| + return true, nil |
| + } |
| + return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeRecreated, "") |
| + } |
| + mv := rec.Move |
| + switch { |
| + case mv == nil && rec.LastMove != nil && rec.LastMove.ID == row.ID: |
| + if out, conf := selfServeOutstanding(row); out && !conf { |
| + return true, nil // resolved first |
| + } |
| + return true, s.selfServeFinalise(ctx, t, row, rec.LastMove.Outcome, s.selfServeOutcomeReason(row, rec.LastMove.Outcome), "") |
| + case mv == nil: |
| + switch row.Phase { |
| + case ssRequested, ssReserving: |
| + return s.selfServeQueueFence(ctx, t, row, rec, now) |
| + } |
| + if out, conf := selfServeOutstanding(row); out && !conf { |
| + return true, nil |
| + } |
| + return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeCouldNotStart, "") |
| + case mv.ID != row.ID: |
| + if out, conf := selfServeOutstanding(row); out && !conf { |
| + return true, nil |
| + } |
| + return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeAnotherMove, "") |
| + } |
| + set, ok := selfServeSets[mv.State] |
| + if !ok || containsPhase(set.phases, row.Phase) { |
| + return false, nil |
| + } |
| + // The record carries this move and the row is behind it: the queue |
| + // landed (reserving, requested), or a write the row has not caught up |
| + // with did (a crash between a confirmation and its advance, an |
| + // operator's transition). Forward to the set's first phase, consuming |
| + // a confirmed request whose wait the record has passed. |
| + nonce := "" |
| + if selfServeWaiting(row, "queue") && set.first == ssQueued { |
| + nonce = row.Request.Nonce |
| + } |
| + if out, conf := selfServeOutstanding(row); out && conf && nonce == "" && set.first != ssStaging { |
| + // A confirmed wait the record has passed (an operator went past |
| + // it): it is left, never retained across phases. |
| + if _, err := s.store.AbandonSelfServeRequest(row.ID, row.Request.Nonce, row.StepDeadline); err != nil { |
| + return true, err |
| + } |
| + } |
| + window := time.Time{} |
| + TestHooks.point("advance:" + row.Phase + "->" + set.first) |
| + won, err := s.store.AdvanceSelfServeToStep(row.ID, row.Phase, set.first, "", set.substep, |
| + selfServePhaseDeadline(row, set.first, now), window, nonce, now) |
| + if err != nil { |
| + return true, err |
| + } |
| + if !won { |
| + // An abort is requested (the advance's predicate), or the row |
| + // moved: the abort step or the next tick decides. |
| + return false, nil |
| + } |
| + return true, nil |
| +} |
| + |
| +// selfServeOutcomeReason is a finished move's reason: none for done; for an |
| +// abort, the reason the job recorded when it decided it, else a |
| +// cancellation's. |
| +func (s *Server) selfServeOutcomeReason(row *SelfServeMove, outcome string) string { |
| + if outcome != provision.OfflineOutcomeAborted { |
| + return "" |
| + } |
| + if row.OutcomeReason != "" { |
| + return row.OutcomeReason |
| + } |
| + if !row.AbortRequestedAt.IsZero() { |
| + return msgSelfServeCancelled |
| + } |
| + return msgSelfServeCouldNotStart |
| +} |
| + |
| +// selfServeQueueFence is the reconciliation table's reserving row for a |
| +// record with no move: an unconfirmed queue request older than 30 s is |
| +// given up only after the fence (fenceRecord under the app and hostname |
| +// locks and admission), so its write can never land later; a fenced |
| +// observation carrying the receipt is the queue landing after all |
| +// (confirmed, the row continues as queued); otherwise the request is voided |
| +// and the outcome persisted before the reservations are released. |
| +// Anything younger, or no queue request, continues the phase. |
| +func (s *Server) selfServeQueueFence(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) (bool, error) { |
| + out, conf := selfServeOutstanding(row) |
| + if !out || conf || row.Request.Kind != "queue" || now.Sub(row.RequestIssued) <= selfServeQueueFence { |
| + return false, nil |
| + } |
| + TestHooks.point("queue:fence") |
| + frec, err := s.selfServeFence(ctx, t, row) |
| + if err != nil { |
| + log.Printf("console: self-serve move %s: the queue's fence: %v (pending)", row.ID, err) |
| + return true, nil |
| + } |
| + if frec != nil && frec.Incarnation == row.Incarnation && selfServeHasReceipt(frec, row.Request.Nonce) { |
| + return true, s.selfServeConfirmFenced(ctx, row, frec) |
| + } |
| + TestHooks.point("queue:void") |
| + if err := s.store.VoidSelfServeRequest(row.ID, row.Request.Nonce); err != nil { |
| + return true, err |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return true, err |
| + } |
| + return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, msgSelfServeCouldNotStart, "") |
| +} |
| + |
| +// selfServeFence fences the row's record: plan 1's fenceRecord under the |
| +// app and hostname locks and admission, its write under the lease. It |
| +// answers the fenced observation's record, nil when the record is absent. |
| +func (s *Server) selfServeFence(ctx context.Context, t *leaseToken, row *SelfServeMove) (*provision.Record, error) { |
| + if !t.valid(s.selfServeNow()) { |
| + return nil, errSelfServeLeaseLost |
| + } |
| + wctx, cancel := s.selfServeWriteCtx(ctx, t) |
| + defer cancel() |
| + var ( |
| + res fenceResult |
| + obs provision.Observation |
| + err error |
| + ) |
| + func() { |
| + defer s.lockAppHost(row.Account, row.App, row.Host)() |
| + if err = s.admit(row.Host, ""); err != nil { |
| + return |
| + } |
| + res, obs, err = s.fenceRecord(wctx, row.Account, row.App, row.Host, fenceOwner{}) |
| + }() |
| + if err != nil { |
| + return nil, err |
| + } |
| + if res == fenceAbsent || obs.Rec == nil { |
| + return nil, nil |
| + } |
| + return obs.Rec, nil |
| +} |
| + |
| +// selfServeConfirmFenced confirms the row's request from a fenced |
| +// observation that carries its receipt, and announces it. |
| +func (s *Server) selfServeConfirmFenced(ctx context.Context, row *SelfServeMove, rec *provision.Record) error { |
| + TestHooks.point(row.Request.Kind + ":confirm") |
| + ok, err := s.store.ConfirmSelfServeRequest(row.ID, row.Request.Nonce, s.selfServeNow()) |
| + if err != nil || !ok { |
| + return err |
| + } |
| + s.selfServeNotify(ctx, row.ID, rec.RecordSeq, row.Request.Kind) |
| + return nil |
| +} |
| + |
| +// selfServeExpired is the fence-and-classify path of a won expiry (the |
| +// only place expiry is decided is selfServeApply): the record observed |
| +// once; its receipt confirms the request (an expiry never undoes a write |
| +// that landed); absent, the record is fenced so the outstanding write can |
| +// never land, and the fenced observation is classified the same way; |
| +// absent again, the request is voided and the abort it obliges recorded in |
| +// one statement. The abort then runs on a record at or before |
| +// offline-landed; a record past it was an operator's progress and the row |
| +// reconciles forward. Never a replay. |
| +func (s *Server) selfServeExpired(ctx context.Context, t *leaseToken, row *SelfServeMove) error { |
| + pr, err := s.store.selfServeRequest(row.ID) |
| + if err != nil || pr == nil || !pr.Expired || pr.Consumed { |
| + return err |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + rec, err := selfServeRecordOf(s.observeRecord(ctx, row.Account, row.App, row.Host), row) |
| + if err != nil && !errors.Is(err, errRecordGone) { |
| + return err |
| + } |
| + if err == nil && selfServeHasReceipt(rec, pr.Nonce) { |
| + return s.selfServeConfirmFenced(ctx, row, rec) |
| + } |
| + TestHooks.point(pr.Kind + ":fence") |
| + frec, err := s.selfServeFence(ctx, t, row) |
| + if err != nil { |
| + log.Printf("console: self-serve move %s: fencing the expired %s request: %v (pending)", row.ID, pr.Kind, err) |
| + return nil |
| + } |
| + if frec != nil && frec.Incarnation == row.Incarnation && selfServeHasReceipt(frec, pr.Nonce) { |
| + return s.selfServeConfirmFenced(ctx, row, frec) |
| + } |
| + TestHooks.point(pr.Kind + ":void-expired") |
| + if _, err := s.store.VoidExpiredSelfServeRequest(row.ID, pr.Nonce, s.selfServeNow(), msgSelfServeCouldNotStart); err != nil { |
| + return err |
| + } |
| + return nil |
| +} |
| + |
| +// ---- 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). |
| +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 |
| + } |
| + if mv := selfServeOurs(rec, row); mv != nil && !selfServePreOpen(mv.State) { |
| + return false, nil // a committed open: never aborted (Task 8) |
| + } |
| + if !row.AbortRequestedAt.IsZero() { |
| + return false, nil |
| + } |
| + return true, s.selfServeAbort(row, s.selfServeExpiryReason(ctx, row, rec, now), now) |
| +} |
| + |
| +// selfServeExpiryReason is what an expired wait names: the window's |
| +// sentence, or the report the phase was still waiting for. |
| +func (s *Server) selfServeExpiryReason(ctx context.Context, row *SelfServeMove, rec *provision.Record, now time.Time) string { |
| + stager := "" |
| + if len(row.Participants.Source) > 0 { |
| + stager = row.Participants.Source[0].Label |
| + } |
| + switch row.Phase { |
| + case ssRequested, ssReserving: |
| + return msgSelfServeNotStarted |
| + case ssReady: |
| + if row.ExpiryKind == "window" { |
| + return msgSelfServeConfirmLate |
| + } |
| + return couldNotDetermine(factStagedCopy, stager) |
| + case ssStaging, ssRestage: |
| + return couldNotDetermine(factStagedCopy, stager) |
| + case ssQueued, ssReports: |
| + if mv := selfServeOurs(rec, row); mv != nil { |
| + if ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv); err == nil { |
| + if fleet, ok := s.readOfflineFleet(ctx); ok { |
| + if v := s.selfServeReportsVerdict(row, mv, ev, fleet); v.waiting != "" { |
| + return v.waiting |
| + } |
| + } |
| + } |
| + } |
| + return couldNotDetermine(factLane, stager) |
| + case ssPrechecks: |
| + 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 != "" { |
| + return v.waiting |
| + } |
| + } |
| + } |
| + return couldNotDetermine(factTargetFacts, row.Participants.Target.Label) |
| + } |
| + return msgSelfServeCouldNotStart |
| +} |
| + |
| +// 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 |
| +// one is fenced and classified first (its receipt confirms it, and the |
| +// record then carries the move; absent, it is voided); a confirmed wait is |
| +// abandoned (leaving it). Then no move on the record is the finaliser |
| +// (aborted; the source never stopped, so it reopened when the abort was |
| +// asked for), and a move goes to reverting, whose step writes the abort. |
| +func (s *Server) selfServeAbortStep(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) (bool, error) { |
| + mv := selfServeOurs(rec, row) |
| + if mv != nil && !selfServePreOpen(mv.State) && mv.State != provision.OfflineReverting { |
| + return false, nil // past the boundary: the row reconciles forward (Task 8) |
| + } |
| + if row.Phase == ssReverting { |
| + return false, nil // the abort's own writes |
| + } |
| + out, conf := selfServeOutstanding(row) |
| + switch { |
| + case out && !conf: |
| + TestHooks.point(row.Request.Kind + ":fence") |
| + frec, err := s.selfServeFence(ctx, t, row) |
| + if err != nil { |
| + log.Printf("console: self-serve move %s: fencing the %s request before the abort: %v (pending)", row.ID, row.Request.Kind, err) |
| + return true, nil |
| + } |
| + if frec != nil && frec.Incarnation == row.Incarnation && selfServeHasReceipt(frec, row.Request.Nonce) { |
| + return true, s.selfServeConfirmFenced(ctx, row, frec) |
| + } |
| + TestHooks.point(row.Request.Kind + ":void") |
| + if err := s.store.VoidSelfServeRequest(row.ID, row.Request.Nonce); err != nil { |
| + return true, err |
| + } |
| + return true, nil // the next tick decides from the record |
| + case out && conf && row.Request.Kind != "abort" && row.Request.Kind != "finish": |
| + TestHooks.point(row.Request.Kind + ":abandon") |
| + if _, err := s.store.AbandonSelfServeRequest(row.ID, row.Request.Nonce, row.StepDeadline); err != nil { |
| + return true, err |
| + } |
| + return true, nil |
| + } |
| + if mv == nil { |
| + if row.SourceReopenedAt.IsZero() { |
| + if err := s.store.SetSelfServeReopened(row.ID, row.AbortRequestedAt); err != nil { |
| + return true, err |
| + } |
| + row.SourceReopenedAt = row.AbortRequestedAt |
| + } |
| + return true, s.selfServeFinalise(ctx, t, row, provision.OfflineOutcomeAborted, s.selfServeOutcomeReason(row, provision.OfflineOutcomeAborted), "") |
| + } |
| + if (mv.State == provision.OfflineQueued || mv.State == provision.OfflineStaging) && row.SourceReopenedAt.IsZero() { |
| + // Never offline: the source served throughout. |
| + if err := s.store.SetSelfServeReopened(row.ID, row.AbortRequestedAt); err != nil { |
| + return true, err |
| + } |
| + } |
| + TestHooks.point("advance:" + row.Phase + "->" + ssReverting) |
| + _, err := s.store.AdvanceSelfServe(row.ID, row.Phase, ssReverting, row.Reason, now.Add(selfServeRevertWait), time.Time{}, "", now) |
| + return true, err |
| +} |
| + |
| +// ---- the finaliser ----------------------------------------------------------- |
| + |
| +// selfServeFinalise ends a row: (1) the outcome (FinishSelfServeMove, which |
| +// consumes a confirmed clearing request and inserts the terminal event in |
| +// one transaction); (2) the terminal effects (the serial bump; Task 9's |
| +// timings); (3) the reservations released; (4) the release recorded. A |
| +// crash between any two leaves the row in OpenSelfServeMoves and the next |
| +// tick resumes at the first unfinished step. A row with an unconfirmed |
| +// request is never finalised: the request is resolved first. |
| +func (s *Server) selfServeFinalise(ctx context.Context, t *leaseToken, row *SelfServeMove, outcome, reason, warning string) error { |
| + if out, conf := selfServeOutstanding(row); out && !conf { |
| + return fmt.Errorf("console: self-serve move %s: a %s request is outstanding; it is resolved before the move is finished", row.ID, row.Request.Kind) |
| + } |
| + if row.Outcome == "" { |
| + now := s.selfServeNow() |
| + if outcome == provision.OfflineOutcomeAborted && row.AbortFinishedAt.IsZero() && !row.AbortRequestedAt.IsZero() { |
| + if err := s.store.SetSelfServeAbortFinished(row.ID, now); err != nil { |
| + return err |
| + } |
| + } |
| + TestHooks.point("finalise:outcome") |
| + if err := s.store.FinishSelfServeMove(row.ID, outcome, reason, warning, now); err != nil { |
| + return err |
| + } |
| + log.Printf("console: self-serve move %s (%s): %s %s", row.ID, row.Host, outcome, reason) |
| + } |
| + return s.selfServeFinaliseRest(ctx, t, row.ID) |
| +} |
| + |
| +// selfServeFinaliseRest is the finaliser from step (2) on, for a row that |
| +// has its outcome. |
| +func (s *Server) selfServeFinaliseRest(ctx context.Context, t *leaseToken, id string) error { |
| + row, err := s.selfServeReload(id) |
| + if err != nil || row == nil || row.Outcome == "" { |
| + return err |
| + } |
| + if !row.EffectsDone { |
| + TestHooks.point("finalise:effects") |
| + if err := s.bumpSerialErr(ctx); err != nil { |
| + return fmt.Errorf("console: self-serve move %s: the terminal bump: %w (retried next tick)", id, err) |
| + } |
| + if err := s.store.SetSelfServeEffectsDone(id); err != nil { |
| + return err |
| + } |
| + } |
| + if row.ReleasePending { |
| + if !t.valid(s.selfServeNow()) { |
| + return errSelfServeLeaseLost |
| + } |
| + TestHooks.point("finalise:release") |
| + wctx, cancel := s.selfServeWriteCtx(ctx, t) |
| + err := s.releaseBoxes(wctx, moves.OfflineKindMove, id) |
| + cancel() |
| + if err != nil { |
| + return err |
| + } |
| + TestHooks.point("finalise:released") |
| + if err := s.store.ClearSelfServeRelease(id); err != nil { |
| + return err |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// ---- the phases -------------------------------------------------------------- |
| + |
| +// selfServePhase is step (6): the phase's own step. |
| +func (s *Server) selfServePhase(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + switch row.Phase { |
| + case ssRequested: |
| + TestHooks.point("advance:requested->reserving") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssRequested, ssReserving, row.Reason, time.Time{}, row.WindowDeadline, "", now) |
| + return err |
| + case ssReserving: |
| + return s.selfServeReserving(ctx, t, row, now) |
| + case ssQueued: |
| + TestHooks.point("advance:queued->reports") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssQueued, ssReports, "", now.Add(selfServeReportsWait), time.Time{}, "", now) |
| + return err |
| + case ssReports: |
| + return s.selfServeReports(ctx, t, row, rec, now) |
| + case ssStaging: |
| + return s.selfServeStaging(ctx, t, row, rec, now) |
| + case ssPrechecks: |
| + return s.selfServePrechecks(ctx, t, row, rec, now) |
| + case ssReady: |
| + return s.selfServeReady(ctx, t, row, rec, now) |
| + case ssRestage: |
| + return s.selfServeRestage(ctx, t, row, rec, now) |
| + case ssReverting: |
| + return s.selfServeReverting(ctx, t, row, rec, now) |
| + } |
| + // barrier through finishing: Task 8. |
| + return nil |
| +} |
| + |
| +// selfServeReplaced is the identity check at a gate: the label of a pinned |
| +// participant whose current status report names another box id or |
| +// instance (moves.ParticipantStillPresent), or no report at all; a report |
| +// too old or too far ahead to judge (moves.OfflineIdentityFresh) decides |
| +// nothing yet. |
| +func selfServeReplaced(row *SelfServeMove, f offlineFleet, now time.Time) string { |
| + for _, p := range append(append([]provision.Participant(nil), row.Participants.Source...), row.Participants.Target) { |
| + m := f.metrics[p.Label] |
| + if m == nil { |
| + if _, reported := f.generated[p.Label]; !reported { |
| + return p.Label |
| + } |
| + continue |
| + } |
| + age := now.Sub(f.generated[p.Label]) |
| + if age > moves.OfflineIdentityFresh || age < -moves.OfflineRequestSkew { |
| + continue |
| + } |
| + if !moves.ParticipantStillPresent(p, m) { |
| + return p.Label |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// selfServeSelectionMoved names a pinned participant the start validation |
| +// no longer selects, by box id and instance. |
| +func selfServeSelectionMoved(row *SelfServeMove, sel offlineSelection) string { |
| + have := map[string]string{} |
| + for _, p := range append(append([]provision.Participant(nil), sel.Source...), sel.Target) { |
| + have[p.BoxID] = p.InstanceID |
| + } |
| + for _, p := range append(append([]provision.Participant(nil), row.Participants.Source...), row.Participants.Target) { |
| + if inst, ok := have[p.BoxID]; !ok || (p.InstanceID != "" && inst != p.InstanceID) { |
| + return p.Label |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +// selfServeReserving reserves the participants (per-box FIFO first), then |
| +// checks the fresh judged record again in full (its class, an open run, a |
| +// move, the record checks and the start validation with the stage rules and |
| +// the participants' identities) and queues the move. |
| +func (s *Server) selfServeReserving(ctx context.Context, t *leaseToken, row *SelfServeMove, now time.Time) error { |
| + open, err := s.store.OpenSelfServeMoves() |
| + if err != nil { |
| + return err |
| + } |
| + if selfServeWaitsForEarlier(open, row) { |
| + if row.Reason != msgSelfServeWaitingEarlier { |
| + return s.store.SetSelfServeReason(row.ID, msgSelfServeWaitingEarlier) |
| + } |
| + return nil |
| + } |
| + fleet, ok := s.readOfflineFleet(ctx) |
| + if !ok { |
| + return nil // the fleet is read again next tick |
| + } |
| + if label := selfServeReplaced(row, fleet, now); label != "" { |
| + return s.selfServeAbort(row, ssReplaced(label), now) |
| + } |
| + if !t.valid(s.selfServeNow()) { |
| + return errSelfServeLeaseLost |
| + } |
| + sel := offlineSelection{From: row.From, To: row.To, Source: row.Participants.Source, Target: row.Participants.Target} |
| + wctx, cancel := s.selfServeWriteCtx(ctx, t) |
| + TestHooks.point("reserve") |
| + ref := s.reserveBoxes(wctx, sel, moves.OfflineReservation{Kind: moves.OfflineKindMove, ID: row.ID, Host: row.Host, |
| + Account: row.Account, App: row.App, ExpectIncarnation: row.Incarnation}) |
| + cancel() |
| + switch { |
| + case ref == nil: |
| + case ref == offlineReserveUnreadable: |
| + return fmt.Errorf("console: self-serve move %s: the reservations could not be read (retried next tick)", row.ID) |
| + case ref == offlineReserveMismatch: |
| + return s.selfServeAbort(row, ref.msg, now) |
| + default: |
| + if row.Reason != msgSelfServeWaitingAnother { |
| + return s.store.SetSelfServeReason(row.ID, msgSelfServeWaitingAnother) |
| + } |
| + return nil |
| + } |
| + |
| + // Reserved: the fresh judged record, checked again before the queue. |
| + rec, err := selfServeRecordOf(s.observeRecord(ctx, row.Account, row.App, row.Host), row) |
| + if err != nil { |
| + return err |
| + } |
| + if out, conf := selfServeOutstanding(row); !(out && !conf && row.Request.Kind == "queue") { |
| + // A queue already issued is replayed as it was decided; a new one |
| + // is decided on this record. |
| + if ref := selfServeBusy(rec); ref != nil { |
| + return s.selfServeRefused(row, ref, now) |
| + } |
| + 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), true, true, "") |
| + if ref != nil { |
| + return s.selfServeRefused(row, ref, now) |
| + } |
| + if label := selfServeSelectionMoved(row, sel); label != "" { |
| + return s.selfServeAbort(row, ssReplaced(label), now) |
| + } |
| + ss := provision.SelfServeMove{ID: row.ID, Incarnation: row.Incarnation, DisplayName: row.DisplayName, |
| + RequestedBy: row.RequestedBy, EstimateSeconds: int(row.EstimateSeconds)} |
| + _, err = s.selfServeApply(ctx, t, row, request{Kind: "queue", Phase: ssReserving}, nil, |
| + func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineQueue(row.ID, row.From, row.To, row.RequestedBy, sel.Stage, row.Participants, ss, at, rc) |
| + }) |
| + if err != nil { |
| + var refusal *requestRefusal |
| + if errors.As(err, &refusal) { |
| + // The record refuses the queue: the request stays outstanding |
| + // until the queue's fence gives the admission up. |
| + log.Printf("console: self-serve move %s: %v", row.ID, err) |
| + return nil |
| + } |
| + return err |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + TestHooks.point("advance:reserving->queued") |
| + _, err = s.store.AdvanceSelfServe(row.ID, ssReserving, ssQueued, "", now.Add(selfServeReportsWait), time.Time{}, row.Request.Nonce, now) |
| + return err |
| +} |
| + |
| +// selfServeRefused is a check's refusal at a gate: a transient one is |
| +// waited out (the row's window or step deadline bounds it); any other |
| +// aborts with its sentence. |
| +func (s *Server) selfServeRefused(row *SelfServeMove, ref *offlineRefusal, now time.Time) error { |
| + if selfServeTransient(ref) { |
| + log.Printf("console: self-serve move %s: waiting: %s", row.ID, ref.msg) |
| + return nil |
| + } |
| + return s.selfServeAbort(row, ref.msg, now) |
| +} |
| + |
| +// ssVerdict is a wait's decision over the reports: pass, fail with the |
| +// sentence the move aborts with, or still waiting for what waiting names. |
| +type ssVerdict struct { |
| + pass bool |
| + fail, waiting string |
| +} |
| + |
| +// selfServeReplicaBytes is the replica's size as the source box last |
| +// measured it for the host. |
| +func selfServeReplicaBytes(row *SelfServeMove, f offlineFleet) int64 { |
| + if len(row.Participants.Source) == 0 { |
| + return 0 |
| + } |
| + if m := f.metrics[row.Participants.Source[0].Label]; m != nil { |
| + return m.Hosts[row.Host].ReplicaBytes |
| + } |
| + return 0 |
| +} |
| + |
| +// selfServeReportsVerdict is the reports phase's decision: the lane from |
| +// the stager, each source's precheck and the target's ready report, each |
| +// from its pinned participant and answering the record's precheck stamp |
| +// (Request == PrecheckAt), read before anything later overwrites their |
| +// keys; then the facts. |
| +func (s *Server) selfServeReportsVerdict(row *SelfServeMove, mv *provision.MoveState, ev offlineEvidence, f offlineFleet) ssVerdict { |
| + if len(row.Participants.Source) == 0 || mv.PrecheckAt == nil { |
| + return ssVerdict{fail: msgSelfServeCouldNotStart} |
| + } |
| + host, src, tgt, at := row.Host, row.Participants.Source[0], row.Participants.Target, *mv.PrecheckAt |
| + lane, ok := ev.get(moves.OfflineRoleLane, src.Label) |
| + if !ok || lane.Lane == nil { |
| + return ssVerdict{waiting: couldNotDetermine(factLane, src.Label)} |
| + } |
| + if !lane.Lane.OK { |
| + return ssVerdict{fail: s.selfServeNoLane(row.From, row.To).msg} |
| + } |
| + for _, p := range row.Participants.Source { |
| + pre, ok := ev.get(moves.OfflineRoleSource, p.Label) |
| + if !ok || pre.Phase != moves.OfflinePhasePrecheck || !pre.Request.Equal(at) { |
| + return ssVerdict{waiting: couldNotDetermine(factSourceFacts, p.Label)} |
| + } |
| + if why := sourcePrecheckDecision(host, p.Label, pre.Precheck); why != "" { |
| + return ssVerdict{fail: why} |
| + } |
| + } |
| + rd, ok := ev.get(moves.OfflineRoleTarget, tgt.Label) |
| + if !ok || rd.Phase != moves.OfflinePhaseReady || !rd.Request.Equal(at) { |
| + return ssVerdict{waiting: couldNotDetermine(factTargetFacts, tgt.Label)} |
| + } |
| + if why := targetReadyDecision(host, tgt.Label, rd.Ready, selfServeReplicaBytes(row, f), true); why != "" { |
| + return ssVerdict{fail: why} |
| + } |
| + return ssVerdict{pass: true} |
| +} |
| + |
| +// selfServeReports waits for the queued move's reports and starts the |
| +// pre-copy: OfflineQueuedToStaging, then staging, which waits for the |
| +// stager's round. |
| +func (s *Server) selfServeReports(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil || mv.State != provision.OfflineQueued { |
| + return nil |
| + } |
| + stage := func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineQueuedToStaging(row.ID, at, rc) |
| + } |
| + if out, conf := selfServeOutstanding(row); !(out && !conf && row.Request.Kind == "stage") { |
| + 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.selfServeReportsVerdict(row, mv, ev, fleet) |
| + switch { |
| + case v.fail != "": |
| + return s.selfServeAbort(row, v.fail, now) |
| + case !v.pass: |
| + return nil |
| + } |
| + } |
| + if _, err := s.selfServeApply(ctx, t, row, request{Kind: "stage", Phase: ssReports}, nil, stage); err != nil { |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + TestHooks.point("advance:reports->staging") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssReports, ssStaging, "", now.Add(selfServeRoundAllowance(row)), time.Time{}, "", now) |
| + return err |
| +} |
| + |
| +// selfServePreOfflineRefusal is a pre-offline write's mutation refusing the |
| +// record it was bound to: the move could not be started, and the abort |
| +// fences and voids the request. Any other error is returned. |
| +func (s *Server) selfServePreOfflineRefusal(row *SelfServeMove, err error, now time.Time) error { |
| + var refusal *requestRefusal |
| + if errors.As(err, &refusal) { |
| + log.Printf("console: self-serve move %s: %v", row.ID, err) |
| + return s.selfServeAbort(row, msgSelfServeCouldNotStart, now) |
| + } |
| + return err |
| +} |
| + |
| +// selfServeRestageMut is the restage the staging, ready and restage |
| +// phases write: staged_at moves to the proposed stamp. |
| +func selfServeRestageMut(id string) func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineRestage(id, at, rc) |
| + } |
| +} |
| + |
| +// selfServeStagerReport is the stager's report for the round the row |
| +// waits on (StageRequest == the confirmed request's stamp), if it is in. |
| +func selfServeStagerReport(row *SelfServeMove, mv *provision.MoveState, ev offlineEvidence) (offlineReport, bool) { |
| + rep, ok := ev.get(moves.OfflineRoleStager, mv.Stager) |
| + if !ok || !rep.StageRequest.Equal(row.StageRequest) { |
| + return offlineReport{}, false |
| + } |
| + return rep, true |
| +} |
| + |
| +// selfServeSuperseded is supersession by stamp: the record's stamp for the |
| +// awaited field (staged_at for a round, precheck_at for a precheck) is |
| +// later than the confirmed request's proposed stamp, so an operator round |
| +// moved it without a receipt. The job abandons its wait (one UPDATE) and |
| +// issues a fresh bound round on the next step; it never adopts the |
| +// operator's round. |
| +func (s *Server) selfServeSuperseded(row *SelfServeMove, mv *provision.MoveState) (bool, error) { |
| + var stamp *time.Time |
| + switch row.Request.Kind { |
| + case "stage", "maintain": |
| + stamp = mv.StagedAt |
| + case "precheck": |
| + stamp = mv.PrecheckAt |
| + default: |
| + return false, nil |
| + } |
| + if stamp == nil || !stamp.After(row.Request.Proposed) { |
| + return false, nil |
| + } |
| + TestHooks.point(row.Request.Kind + ":abandon") |
| + _, err := s.store.AbandonSelfServeRequest(row.ID, row.Request.Nonce, row.StepDeadline) |
| + return true, err |
| +} |
| + |
| +// selfServeStaging is the pre-copy, round after round: a round's report |
| +// (the stager's, for the confirmed request's stamp) either meets the ready |
| +// rule (or the bound: 30 minutes or 10 rounds) and the row goes to the |
| +// prechecks, consuming it in the advance, or is consumed and the next |
| +// round is issued. A failed round aborts with its detail; a round past its |
| +// allowance is the step deadline. |
| +func (s *Server) selfServeStaging(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, "stage", "maintain") { |
| + if sup, err := s.selfServeSuperseded(row, mv); sup || err != nil { |
| + return err |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + rep, ok := selfServeStagerReport(row, mv, ev) |
| + switch { |
| + case !ok: |
| + return nil |
| + case rep.Phase == moves.OfflinePhaseFailed: |
| + return s.selfServeAbort(row, moves.FailedDetail(rep.OfflineReport), now) |
| + case rep.Phase != moves.OfflinePhaseStaged: |
| + return nil |
| + } |
| + if stagingReady(rep.OfflineReport) || now.Sub(row.StagingStartedAt) > selfServeStagingBound || rep.Rounds >= selfServeStagingRounds { |
| + TestHooks.point("advance:staging->prechecks") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssStaging, ssPrechecks, "", now.Add(selfServePrechecksWait), time.Time{}, row.Request.Nonce, now) |
| + return err |
| + } |
| + TestHooks.point("stage:consume") |
| + if _, err := s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, ssStepRound, now.Add(selfServeRoundAllowance(row))); err != nil { |
| + return err |
| + } |
| + var rerr error |
| + if row, rerr = s.selfServeReload(row.ID); rerr != nil || row == nil { |
| + return rerr |
| + } |
| + } |
| + if out, _ := selfServeOutstanding(row); out && row.Request.Kind != "stage" { |
| + return nil // another request: reconciliation decides |
| + } |
| + if !row.StagingStartedAt.IsZero() && now.Sub(row.StagingStartedAt) > selfServeStagingBound && !row.StageRequest.IsZero() { |
| + // Past the bound with no round outstanding: the prechecks run from |
| + // the last completed round. |
| + if out, _ := selfServeOutstanding(row); !out { |
| + TestHooks.point("advance:staging->prechecks") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssStaging, ssPrechecks, "", now.Add(selfServePrechecksWait), time.Time{}, "", now) |
| + return err |
| + } |
| + } |
| + if _, err := s.selfServeApply(ctx, t, row, request{Kind: "stage", Phase: ssStaging}, nil, selfServeRestageMut(row.ID)); err != nil { |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + return nil |
| +} |
| + |
| +// selfServePrechecksVerdict is the prechecks' decision: each source's |
| +// precheck and the target's ready report answering the confirmed precheck |
| +// (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 { |
| + host, tgt, at := row.Host, row.Participants.Target, row.PrecheckRequest |
| + for _, p := range row.Participants.Source { |
| + pre, ok := ev.get(moves.OfflineRoleSource, p.Label) |
| + if !ok || pre.Phase != moves.OfflinePhasePrecheck || !pre.Request.Equal(at) { |
| + return ssVerdict{waiting: couldNotDetermine(factSourceFacts, p.Label)} |
| + } |
| + if why := sourcePrecheckDecision(host, p.Label, pre.Precheck); why != "" { |
| + return ssVerdict{fail: why} |
| + } |
| + } |
| + rd, ok := ev.get(moves.OfflineRoleTarget, tgt.Label) |
| + if !ok || rd.Phase != moves.OfflinePhaseReady || !rd.Request.Equal(at) { |
| + return ssVerdict{waiting: couldNotDetermine(factTargetFacts, tgt.Label)} |
| + } |
| + stager, ok := ev.get(moves.OfflineRoleStager, mv.Stager) |
| + if !ok || stager.Phase != moves.OfflinePhaseStaged { |
| + return ssVerdict{waiting: couldNotDetermine(factStagedCopy, mv.Stager)} |
| + } |
| + 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 { |
| + log.Printf("console: self-serve move %s: %v", row.ID, err) |
| + return ssVerdict{fail: ssStagedMismatch(host)} |
| + } |
| + return ssVerdict{pass: true} |
| +} |
| + |
| +// selfServePrechecks asks for a precheck (OfflinePrecheck), decides its |
| +// reports, then refines the estimate on the row and writes it to the |
| +// record (OfflineSetEstimate; the row is the truth), and goes ready with |
| +// the 24-hour confirmation window. |
| +func (s *Server) selfServePrechecks(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 row.PhaseStep == ssStepPrecheck { |
| + if selfServeWaiting(row, "precheck") { |
| + 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) |
| + switch { |
| + case v.fail != "": |
| + return s.selfServeAbort(row, v.fail, now) |
| + case !v.pass: |
| + return nil |
| + } |
| + if err := s.selfServeRefineEstimate(row, mv, ev); err != nil { |
| + return err |
| + } |
| + TestHooks.point("precheck:consume") |
| + if _, err := s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, ssStepEstimate, row.StepDeadline); err != nil { |
| + return err |
| + } |
| + return nil |
| + } |
| + if out, _ := selfServeOutstanding(row); out && row.Request.Kind != "precheck" { |
| + return nil |
| + } |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "precheck", Phase: ssPrechecks}, 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) |
| + } |
| + // The estimate. |
| + if !selfServeWaiting(row, "estimate") { |
| + if out, _ := selfServeOutstanding(row); out && row.Request.Kind != "estimate" { |
| + return nil |
| + } |
| + seconds := int(row.EstimateSeconds) |
| + if _, err := s.selfServeApply(ctx, t, row, request{Kind: "estimate", Phase: ssPrechecks}, nil, |
| + func(_ time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineSetEstimate(row.ID, seconds, rc) |
| + }); err != nil { |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + var err error |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + } |
| + TestHooks.point("advance:prechecks->ready") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssPrechecks, ssReady, "", time.Time{}, now.Add(selfServeConfirmWindow), row.Request.Nonce, now) |
| + return err |
| +} |
| + |
| +// selfServeRefineEstimate stores the estimate made from what staging |
| +// measured: the last round's copied and deferred bytes still to carry, |
| +// the source's files and the replica. |
| +func (s *Server) selfServeRefineEstimate(row *SelfServeMove, mv *provision.MoveState, ev offlineEvidence) error { |
| + stager, ok := ev.get(moves.OfflineRoleStager, mv.Stager) |
| + if !ok { |
| + return nil |
| + } |
| + var files int64 |
| + for _, p := range row.Participants.Source { |
| + if pre, ok := ev.get(moves.OfflineRoleSource, p.Label); ok && pre.Precheck != nil { |
| + files += pre.Precheck.FilesBytes |
| + } |
| + } |
| + terms, seconds := s.estimate(row.From, row.To, stager.RoundCopiedBytes+stager.RoundDeferredBytes, files, stager.ReplicaBytes) |
| + raw, err := json.Marshal(terms) |
| + if err != nil { |
| + return err |
| + } |
| + return s.store.SetSelfServeEstimate(row.ID, int64(seconds), string(raw)) |
| +} |
| + |
| +// selfServeReady is the ready phase: idle between rounds (no step |
| +// deadline; the only deadline is the 24-hour window), a maintenance |
| +// restage every 10 minutes with its own allowance, consumed back to idle |
| +// only while no cut is requested; and the cut handoff: an outstanding |
| +// maintenance round becomes the cut's first round (restage, adopted), and |
| +// with none restage issues its own (restage, issue). |
| +func (s *Server) selfServeReady(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 |
| + } |
| + out, conf := selfServeOutstanding(row) |
| + maintain := out && row.Request.Kind == "maintain" |
| + if !row.CutRequestedAt.IsZero() { |
| + if !maintain { |
| + TestHooks.point("advance:ready->restage") |
| + _, err := s.store.AdvanceSelfServeToStep(row.ID, ssReady, ssRestage, "", ssStepIssue, now.Add(selfServeCopyAllowance(row)), time.Time{}, "", now) |
| + return err |
| + } |
| + if !conf { |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "maintain", Phase: ssReady}, nil, selfServeRestageMut(row.ID)) |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + rep, ok := selfServeStagerReport(row, mv, ev) |
| + switch { |
| + case !ok: |
| + return nil |
| + case rep.Phase == moves.OfflinePhaseFailed: |
| + return s.selfServeAbort(row, moves.FailedDetail(rep.OfflineReport), now) |
| + case rep.Phase != moves.OfflinePhaseStaged: |
| + return nil |
| + } |
| + TestHooks.point("advance:ready->restage") |
| + _, err = s.store.AdvanceSelfServeToStep(row.ID, ssReady, ssRestage, "", ssStepAdopted, now.Add(selfServeCopyAllowance(row)), time.Time{}, row.Request.Nonce, now) |
| + return err |
| + } |
| + if maintain { |
| + if !conf { |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "maintain", Phase: ssReady}, nil, selfServeRestageMut(row.ID)) |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| + } |
| + if sup, err := s.selfServeSuperseded(row, mv); sup || err != nil { |
| + return err |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + rep, ok := selfServeStagerReport(row, mv, ev) |
| + switch { |
| + case !ok: |
| + return nil |
| + case rep.Phase == moves.OfflinePhaseFailed: |
| + return s.selfServeAbort(row, moves.FailedDetail(rep.OfflineReport), now) |
| + case rep.Phase != moves.OfflinePhaseStaged: |
| + return nil |
| + } |
| + TestHooks.point("maintain:consume") |
| + won, err := s.store.ConsumeSelfServeMaintenance(row.ID, row.Request.Nonce, now.Add(selfServeMaintainEvery)) |
| + if err != nil || won { |
| + return err |
| + } |
| + // A cut won meanwhile: the next step re-reads the row and adopts |
| + // this round as the cut's first. |
| + return nil |
| + } |
| + if out { |
| + return nil // another request: reconciliation decides |
| + } |
| + if now.Before(row.NextMaintenanceAt) { |
| + return nil |
| + } |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "maintain", Phase: ssReady, Deadline: now.Add(selfServeCopyAllowance(row))}, nil, selfServeRestageMut(row.ID)) |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| +} |
| + |
| +// selfServeRestage is the cut's restage: substep issue writes its own |
| +// round and waits for the stager's report of it; substep adopted (the |
| +// maintenance round the cut found outstanding, already reported) has |
| +// nothing to write. Both go to the barrier (Task 8), consuming the round. |
| +func (s *Server) selfServeRestage(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 |
| + } |
| + barrier := now.Add(selfServeCopyAllowance(row)) |
| + if row.PhaseStep == ssStepAdopted { |
| + TestHooks.point("advance:restage->barrier") |
| + _, err := s.store.AdvanceSelfServe(row.ID, ssRestage, ssBarrier, "", barrier, time.Time{}, "", now) |
| + return err |
| + } |
| + if selfServeWaiting(row, "stage") && row.Request.Phase == ssRestage { |
| + if sup, err := s.selfServeSuperseded(row, mv); sup || err != nil { |
| + return err |
| + } |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + rep, ok := selfServeStagerReport(row, mv, ev) |
| + switch { |
| + case !ok: |
| + return nil |
| + case rep.Phase == moves.OfflinePhaseFailed: |
| + return s.selfServeAbort(row, moves.FailedDetail(rep.OfflineReport), now) |
| + case rep.Phase != moves.OfflinePhaseStaged: |
| + return nil |
| + } |
| + TestHooks.point("advance:restage->barrier") |
| + _, err = s.store.AdvanceSelfServe(row.ID, ssRestage, ssBarrier, "", barrier, time.Time{}, row.Request.Nonce, now) |
| + return err |
| + } |
| + if out, _ := selfServeOutstanding(row); out && !(row.Request.Kind == "stage" && row.Request.Phase == ssRestage) { |
| + return nil |
| + } |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "stage", Phase: ssRestage}, nil, selfServeRestageMut(row.ID)) |
| + return s.selfServePreOfflineRefusal(row, err, now) |
| +} |
| + |
| +// 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 |
| +// record included), then substep finish waits for the abort's reports (the |
| +// operator door's own finish check at offline-reverting) and writes |
| +// OfflineFinish, which the finaliser consumes with the outcome. Task 8 |
| +// adds the source-reopened and abort-finished facts after a start. |
| +func (s *Server) selfServeReverting(ctx context.Context, t *leaseToken, row *SelfServeMove, rec *provision.Record, now time.Time) error { |
| + mv := selfServeOurs(rec, row) |
| + if mv == nil { |
| + return nil // reconciliation finalises from last_move |
| + } |
| + if row.PhaseStep != ssStepFinish { |
| + if !selfServeWaiting(row, "abort") { |
| + if out, _ := selfServeOutstanding(row); out && row.Request.Kind != "abort" { |
| + return nil |
| + } |
| + queued := mv.State == provision.OfflineQueued |
| + _, err := s.selfServeApply(ctx, t, row, request{Kind: "abort", Phase: ssReverting}, nil, |
| + func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + if queued { |
| + return provision.OfflineAbortAt(row.ID, at, rc) |
| + } |
| + return provision.OfflineAbort(row.ID, rc) |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if row, err = s.selfServeReload(row.ID); err != nil || row == nil { |
| + return err |
| + } |
| + if queued { |
| + return nil // cleared: the next step finalises from last_move |
| + } |
| + } |
| + TestHooks.point("abort:consume") |
| + _, err := s.store.ConsumeSelfServeRequest(row.ID, row.Request.Nonce, ssStepFinish, now.Add(selfServeRevertWait)) |
| + return err |
| + } |
| + if mv.State != provision.OfflineReverting { |
| + return nil |
| + } |
| + if !selfServeWaiting(row, "finish") { |
| + if out, _ := selfServeOutstanding(row); out && row.Request.Kind != "finish" { |
| + return nil |
| + } |
| + if out, _ := selfServeOutstanding(row); !out { |
| + ev, err := s.readOfflineEvidence(ctx, row.Host, row.ID, mv) |
| + if err != nil { |
| + return err |
| + } |
| + 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 row.AbortFinishedAt.IsZero() { |
| + if err := s.store.SetSelfServeAbortFinished(row.ID, now); err != nil { |
| + return err |
| + } |
| + } |
| + } |
| + if _, err := s.selfServeApply(ctx, t, row, request{Kind: "finish", Phase: ssReverting}, nil, |
| + func(at time.Time, rc provision.Receipt) func(*provision.Record) error { |
| + return provision.OfflineFinish(row.ID, at, rc) |
| + }); err != nil { |
| + return err |
| + } |
| + } |
| + // Cleared: the next step finalises from last_move, consuming the |
| + // confirmed finish with the outcome. |
| + return nil |
| +} |