Commit ae1e632
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 #18611 parent ac00d9d commit ae1e632
19 files changed
Lines changed: 2372 additions & 134 deletions
File tree
- internal
- analytics
- api
- config
- mocks
- purchase
- server
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
164 | 164 | | |
165 | 165 | | |
166 | 166 | | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
167 | 179 | | |
168 | 180 | | |
169 | 181 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
79 | 79 | | |
80 | 80 | | |
81 | 81 | | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
82 | 92 | | |
83 | | - | |
| 93 | + | |
84 | 94 | | |
85 | 95 | | |
86 | 96 | | |
| |||
339 | 349 | | |
340 | 350 | | |
341 | 351 | | |
342 | | - | |
343 | | - | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
344 | 358 | | |
345 | 359 | | |
346 | 360 | | |
| |||
366 | 380 | | |
367 | 381 | | |
368 | 382 | | |
369 | | - | |
370 | | - | |
371 | | - | |
372 | | - | |
373 | | - | |
374 | | - | |
375 | | - | |
| 383 | + | |
376 | 384 | | |
377 | | - | |
| 385 | + | |
378 | 386 | | |
379 | 387 | | |
380 | 388 | | |
381 | 389 | | |
382 | 390 | | |
383 | 391 | | |
384 | 392 | | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
385 | 472 | | |
386 | 473 | | |
387 | 474 | | |
| |||
0 commit comments