Skip to content

Commit ae1e632

Browse files
authored
fix(plans): count a ramp step only when every account has bought (#1880)
`CompletePlanStep` advanced the ramp when **any** execution for that step finished successfully. A multi-account plan fans one ramp step out into one execution per cloud account, each succeeding or failing independently, so an operator who repaired and retried a single failed account moved the plan to "step N done" while the other accounts had bought nothing for step N. The plan then proceeded to step N+1 having silently bought less commitment than the customer intended, and nothing durable recorded that it had. This predates #1669 and was unchanged by it: it is visible in that fix's own regression test, where a 3-account plan whose step-3 fan-out committed only account A reaches `CurrentStep = 3` as soon as account B's retry succeeds while account C is still failed. ## Design: sibling-completeness gate, not derived progress The issue nominates deriving `CurrentStep` from the executions table as the preferred option. That was rejected because `CleanupOldExecutions` deletes `status = 'completed'` rows past the retention horizon, so a derived position falls back toward zero as rows age out and the plan re-buys its entire ramp. That trades a stored-convention problem for a data-lifetime problem whose failure direction is spending real money twice. The chosen gate has no equivalent hole: cleanup sweeps only `completed` and `canceled` rows, so the units that age out are exactly the ones that did buy, and the gate degrades permissively rather than toward a re-buy. Deriving also does not avoid the freeze it is credited with avoiding. "Highest fully-bought step" either requires contiguity, which freezes identically on the incomplete step, or lets a later clean step jump over it, which is the overstatement the skipped-predecessor refusal exists to prevent. "Target accounts at the time the step ran" is pinned to the execution rows the fan-out wrote, which are never rewritten. The root row is an aggregate of its children, so an all-accounts-failed step is not blocked by its own container; within each account only the latest attempt counts, ordered by `(retry_execution_id IS NULL) DESC, updated_at DESC`. Both keys are load-bearing: a retry successor shares its predecessor's transaction timestamp, while a root re-drive supersedes nothing. ## The gate needed a real exit A gate that refuses to advance while any account is outstanding can freeze a ramp permanently. The first implementation's comment claimed an operator could "retry the account until it buys, or cancel its row". That exit did not exist: `IsCancelable` admits only `pending`, `notified` and `scheduled`, and `CancelExecutionAtomic` guards `status IN ('pending','notified')`, so a `failed` row is uncancelable by either path, while retry is separately refused whenever `RedriveRefusalReason` fires (Azure savings plans, unrecognised providers). An Azure-SP row that failed could be neither retried nor canceled. Widening the cancel policy was rejected, since a row past `approved` may already have moved money. Instead the gate counts only units whose account the plan still targets: rows decide which units exist, current attachments decide whether a unit still matters, and detaching via `SetPlanAccounts` is the exit. Disabling an account is not an exit, because `GetPlanAccounts` has no `enabled` filter, and no claim is made that it is. ## Frozen ramps must not double-buy, and the guard must be atomic While a ramp is held at step N-1, the create path stamps `StepNumber: CurrentStep + i + 1`, so a fresh create mints a root row for step N that re-fans-out across accounts that already bought. The per-account idempotency token derives from `idempotencyLineageKey(baseExec) + ":" + account.ID`, and a new root mints a fresh UUID key, so it is a different token, provider dedupe never engages, and the commitment is genuinely bought twice. The first attempt at that guard ran the probe before `WithTx` opened, which made it advisory: two concurrent creates could both pass it, and a pending account execution could succeed between the check and the insert. The probe now runs inside the transaction under the per-plan ramp lock, against the state that lock protects, so a create cannot interleave with a concurrent create or a concurrent completion. The pre-transaction plan read is retained only to answer the 404 and is explicitly discarded as an unlocked snapshot. Also fixed: "bought" is `EXISTS any succeeded row` rather than the latest attempt, since a purchase is irreversible and a later failed attempt would otherwise freeze the step and invite a retry that buys twice; the already-counted path no longer stamps an error note on a cleanly-completed row during a benign sibling race; the stuck-step report no longer describes non-ramp plans as blocked ramps; and the interface contract now names both duplicate-completion sentinels, since the #1669 scenario lands on the equal-step case that returns `ErrRampStepCountedBySibling` and callers branch on the distinction. ## How it was verified The issue's exact scenario was reproduced first as a failing test (`expected: 2, actual: 3`) and re-confirmed against the final test code by disabling only the gate call. Tests drive the real executor against real Postgres. The retry fixture was corrected in the process: it previously modelled a retry no production path can produce, because it never stamped `retry_execution_id`. Atomicity is tested as a genuine race, not a sequence: a holder transaction takes the ramp lock and stays **uncommitted** while a competing create runs, asserting it blocks and then observes the winner. Concurrent goroutines alone would prove nothing, because the loser would read the winner's committed row regardless. A mutation harness covers seven mutants, all killed. Two survived the first attempt and were rewritten: making `ever_bought` independent of the representative row meant ordering tests written against a unit that had already bought could not fail. They now run against units that never bought, with execution IDs chosen so the dead attempt wins the last-resort tie-break, and the supersession case moved to a single-transaction retry so both rows share a timestamp as production produces. Gates re-run on the final commit after rebasing onto `ac00d9d5e`: build, vet and the three touched packages all exit 0 with zero failures, and `gocyclo -over 10` is clean (confirmed to be scanning: 339 functions report at threshold 5). ## Known gap, deliberately not fixed here A fan-out that fails to write one account's row advances anyway, because the gate derives its target set only from rows that exist and has no cross-check against fan-out width. Both migration-free remedies introduce a worse freeze: requiring all attached accounts freezes any plan that gains one mid-ramp, and a "row at an earlier step" heuristic freezes a detach and re-attach. `plan_accounts` carries no timestamp, so there is no sound "attached when the step ran" signal. Documented in-code, and not a regression: before this change that account never bought either and the ramp advanced regardless. Deferred: `handler_history.go` describes any completed row carrying a non-empty error as an audit gap, which mis-describes a ramp note. Pre-existing since #1669, contained here by not stamping the newly-frequent transient case; a proper fix wants a dedicated column plus roughly eleven lockstep SELECT projections. Closes #1861
1 parent ac00d9d commit ae1e632

19 files changed

Lines changed: 2372 additions & 134 deletions

internal/analytics/collector_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,18 @@ func (m *mockConfigStore) CompletePlanStep(ctx context.Context, planID string, s
164164
return nil
165165
}
166166

167+
func (m *mockConfigStore) GetStuckRampSteps(_ context.Context) (map[string]config.RampStepBlock, error) {
168+
return nil, nil
169+
}
170+
171+
func (m *mockConfigStore) LockPurchasePlanTx(_ context.Context, _ pgx.Tx, _ string) (*config.PurchasePlan, error) {
172+
return nil, nil
173+
}
174+
175+
func (m *mockConfigStore) OccupiedRampStepsInRangeTx(_ context.Context, _ pgx.Tx, _ string, _, _ int) ([]int, error) {
176+
return nil, nil
177+
}
178+
167179
func (m *mockConfigStore) UpdatePurchasePlanTx(ctx context.Context, _ pgx.Tx, plan *config.PurchasePlan) error {
168180
return nil
169181
}

internal/api/handler_plans.go

Lines changed: 98 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,18 @@ func (h *Handler) attachPlanHealth(ctx context.Context, plans []config.PurchaseP
7979
return result
8080
}
8181

82+
// A blocked ramp is worth up to 25 points, so a score computed without it
83+
// is not a partially-informed score, it is a wrong one -- and it errs
84+
// healthy, on plans that are stopped. Withhold every score rather than
85+
// publish that, exactly as the counts fetch above does.
86+
stuckByPlan, err := h.config.GetStuckRampSteps(ctx)
87+
if err != nil {
88+
logging.Warnf("listPlans: GetStuckRampSteps failed, plan health reported as unknown: %v", err)
89+
return result
90+
}
91+
8292
for i := range plans {
83-
score, factors := computePlanHealth(plans[i], now, countsByPlan[plans[i].ID])
93+
score, factors := computePlanHealth(plans[i], now, countsByPlan[plans[i].ID], stuckByPlan[plans[i].ID])
8494
result[i].HealthScore = &score
8595
result[i].HealthFactors = factors
8696
}
@@ -339,8 +349,12 @@ func (h *Handler) createPlannedPurchases(ctx context.Context, httpReq *events.La
339349
return nil, err
340350
}
341351

342-
plan, err := h.getPlanForPurchaseCreation(ctx, planID)
343-
if err != nil {
352+
// Validation only: this answers the 404 (and maps storage errors to a clean
353+
// message) before a transaction is opened. The plan it returns is
354+
// deliberately discarded -- it is an unlocked snapshot whose ramp position
355+
// can be stale by the time the inserts run, and the authoritative read
356+
// happens under the ramp lock inside the transaction below (issue #1861).
357+
if _, err := h.getPlanForPurchaseCreation(ctx, planID); err != nil {
344358
return nil, err
345359
}
346360

@@ -366,22 +380,95 @@ func (h *Handler) createPlannedPurchases(ctx context.Context, httpReq *events.La
366380
creator := resolveCreatorUserID(session)
367381
created := 0
368382
if err := h.config.WithTx(ctx, func(tx pgx.Tx) error {
369-
n, txErr := h.createPurchaseExecutionsTx(ctx, tx, plan, planID, req.Count, startDate, creator)
370-
if txErr != nil {
371-
return txErr
372-
}
373-
if planErr := h.updatePlanNextExecutionDateTx(ctx, tx, plan, startDate); planErr != nil {
374-
return planErr
375-
}
383+
n, txErr := h.createPlannedPurchasesTx(ctx, tx, planID, req.Count, startDate, creator)
376384
created = n
377-
return nil
385+
return txErr
378386
}); err != nil {
379387
return nil, err
380388
}
381389

382390
return &CreatePlannedPurchasesResponse{Created: created}, nil
383391
}
384392

393+
// createPlannedPurchasesTx is the transactional body of createPlannedPurchases:
394+
// take the per-plan ramp lock, decide against the state that lock protects, and
395+
// write, all before anyone else can read it.
396+
//
397+
// The lock is the point. The step range derives from the plan's CurrentStep and
398+
// the "is this step already covered" test reads the executions of those steps,
399+
// so both inputs are exactly the state a concurrent completion or a concurrent
400+
// create mutates. Read either outside the lock and the check becomes advisory:
401+
// two creates both see the step free and each mints a root row for it, and
402+
// approving both re-fans-out over accounts that already bought. CompletePlanStep
403+
// takes this same lock, so a completion cannot interleave either.
404+
func (h *Handler) createPlannedPurchasesTx(ctx context.Context, tx pgx.Tx, planID string, count int, startDate time.Time, creator *string) (int, error) {
405+
plan, err := h.config.LockPurchasePlanTx(ctx, tx, planID)
406+
if err != nil {
407+
logging.Errorf("createPlannedPurchases: LockPurchasePlanTx failed (plan=%s): %v", planID, err)
408+
return 0, NewClientError(503, "could not lock the plan for scheduling; try again")
409+
}
410+
if plan == nil {
411+
return 0, NewClientError(404, "plan not found")
412+
}
413+
414+
if refuseErr := h.refuseOccupiedRampSteps(ctx, tx, plan, planID, count); refuseErr != nil {
415+
return 0, refuseErr
416+
}
417+
418+
created, createErr := h.createPurchaseExecutionsTx(ctx, tx, plan, planID, count, startDate, creator)
419+
if createErr != nil {
420+
return 0, createErr
421+
}
422+
if planErr := h.updatePlanNextExecutionDateTx(ctx, tx, plan, startDate); planErr != nil {
423+
return 0, planErr
424+
}
425+
return created, nil
426+
}
427+
428+
// refuseOccupiedRampSteps blocks a create whose steps overlap one that is
429+
// already covered (issue #1861). Must be called with the plan's ramp lock held
430+
// for the same transaction, which createPlannedPurchasesTx does.
431+
//
432+
// createPurchaseExecutionsTx stamps CurrentStep+1 .. CurrentStep+count. The
433+
// completeness gate holds CurrentStep still while any account of a step is
434+
// outstanding, so on a plan an operator is trying to unstick, CurrentStep+1 is
435+
// the very step that is partly bought. The row minted for it is a ROOT row:
436+
// approving it re-fans-out across every account on the plan, including the ones
437+
// that already bought, under a fresh idempotency lineage (the new root's key is
438+
// a new UUID, and each account's token derives from it), so the provider-side
439+
// dedupe never engages and the commitment is genuinely bought twice. Per-account
440+
// retry of the outstanding rows is the operation that actually finishes the step.
441+
//
442+
// Fails closed: an unreadable check refuses the create rather than minting rows
443+
// that might double-buy.
444+
func (h *Handler) refuseOccupiedRampSteps(ctx context.Context, tx pgx.Tx, plan *config.PurchasePlan, planID string, count int) error {
445+
from := plan.RampSchedule.CurrentStep + 1
446+
occupied, err := h.config.OccupiedRampStepsInRangeTx(ctx, tx, planID, from, from+count-1)
447+
if err != nil {
448+
logging.Errorf("createPlannedPurchases: OccupiedRampStepsInRangeTx failed (plan=%s): %v", planID, err)
449+
return NewClientError(503, "could not verify which ramp steps are already covered; try again")
450+
}
451+
if len(occupied) == 0 {
452+
return nil
453+
}
454+
return NewClientError(409, fmt.Sprintf(
455+
"ramp step(s) %s of this plan already have purchase executions that bought or are still in flight; "+
456+
"finish or cancel those instead of scheduling the same step again",
457+
formatRampSteps(occupied)))
458+
}
459+
460+
// formatRampSteps renders step numbers for an operator-facing message.
461+
func formatRampSteps(steps []int) string {
462+
out := ""
463+
for i, s := range steps {
464+
if i > 0 {
465+
out += ", "
466+
}
467+
out += fmt.Sprintf("%d", s)
468+
}
469+
return out
470+
}
471+
385472
// parseCreatePurchasesRequest parses and validates the create purchases request.
386473
func (h *Handler) parseCreatePurchasesRequest(body string) (*CreatePlannedPurchasesRequest, time.Time, error) {
387474
var req CreatePlannedPurchasesRequest

0 commit comments

Comments
 (0)