Repository navigation
fix(purchase): keep account scope on per-account retry successors - #1655
Conversation
A multi-account plan fans out into one purchase_executions row per cloud account, each stamped with cloud_account_id and a per-account idempotency key "<root-key>:<accountID>". Those rows reach status="failed" on their own, so History offers Retry on them. persistRetryExecution copied the lineage key, plan and step onto the successor but not cloud_account_id. The successor was therefore born account-scopeless, and executePurchase reads a nil cloud_account_id on a planned execution as "root execution" and re-enters the fan-out: for a plan covering accounts A, B and C where only C failed, one Retry click on C re-purchased A and B as well, under re-suffixed keys "<root-key>:C:A" whose derived tokens match neither the AWS ClientToken, the EC2 RI tag-guard nor the Azure two-step lookup, so nothing deduped them. At $300 upfront per reserved instance that is $600 of unintended spend from a single click on a three-account plan, scaling linearly with the plan's account count. Two gates now: - persistRetryExecution carries cloud_account_id onto the successor (by value, so the successor and the historical failed row never alias the same pointee). A per-account retry stays a single-account re-drive against the identical lineage key, so the provider dedupes it. - executePurchase refuses to fan out a row whose lineage key already terminates in one of the plan's own account IDs while the row carries no cloud_account_id. That covers successors minted by the old handler and still pending/approved when this deploys: they fail loud with zero purchases instead of re-buying the accounts that already committed. The refusal is deliberately narrow: a genuine root row still fans out across every plan account under its own per-account key, because a legitimate multi-account purchase silently never happening is the same defect with the opposite sign. Regression coverage reproduces the real scenario end to end (retry handler -> real purchase.Manager, counting the commitments that reach the provider). Against pre-fix code the per-account retry fires three purchases instead of one; both directions are guarded: retries of different accounts' rows keep distinct provider tokens, and a root-row retry still buys once per account. No schema change. Closes #1537
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 19 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (4)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
Review follow-up on the #1537 guard. Two defects in the second gate, both in internal/purchase/execution.go. The gate detected a per-account row by matching the lineage key's terminal segment against the plan's CURRENT account set. That fails OPEN precisely when the scoped account has since been removed from the plan, or deleted and re-added under a new ID: nothing matches, no refusal fires, and the row re-fans-out exactly as it did before the fix. Demonstrated against the previous commit -- plan {A, B}, scopeless row keyed "root-lineage:acct-C" -> no error and two commitments, under tokens derived from "root-lineage:acct-C:acct-A" and ":acct-B", neither of which matches anything the first attempt used. At $300 upfront per reserved instance that is $600 undeduplicated. Detect per-account-ness by the colon instead. executeForAccount is the only writer that puts a colon in a lineage key; every other producer yields a colon-free value (SavePurchaseExecution mints a bare UUID, persistRetryExecution copies verbatim or falls back to an ExecutionID, which is also a UUID, and the read path round-trips the column). So on a scopeless row a colon is by itself proof the row is per-account, and the detector no longer depends on mutable plan membership. The gate also had no way out. It errored, the row landed failed, and retrying it produced another scopeless successor carrying the same key, which the gate refused again -- through the retry threshold and through force. The predecessor could not be re-driven either: it already has retry_execution_id set, so the retry endpoint 409s with "act on its descendant instead", and the descendant is the row that can never execute. Only DB surgery or cancelling ended it. So when the terminal segment does match a current plan account, repair rather than refuse: stamp CloudAccountID and run the single-account re-drive the row was always meant to be. The lineage key is durable proof of the intended scope, and the derived per-rec token is then reproduced exactly, which makes this strictly safer than erroring -- same purchase count, correct dedupe, plus a way out. The repair persists: executeAndFinalize saves the row and cloud_account_id is in SavePurchaseExecution's ON CONFLICT SET. The fail-loud error is reserved for the unrecoverable case above, and now names the departed account so the operator knows which one to re-add. Also corrects an overstatement in persistRetryExecution's comment: carrying the account scope is the precondition for provider-side dedupe, but it is not sufficient for Azure savings-plans, which has no server-side idempotency key and names its order alias from time.Now().UnixNano(). recIsSafeToRedrive encodes that exclusion but gates only the reaper, not the user-facing retry; filed as #1668. Refs #1537
|
Pushed 1. The guard failed open when the scoped account had left the plan. It detected per-account-ness by matching the lineage key's terminal segment against the plan's current account set, so a scopeless row keyed It now detects the colon instead. 2. The refusal had no recovery path. A refused row lands When the terminal segment does match a current plan account, the executor now repairs rather than refuses: it stamps Tests. Those two hashes are exactly Also corrected an overstatement. Filed, not fixed: #1669 (p1) — Gates, all exit 0: @coderabbitai full review |
|
✏️ Learnings added
✅ Action performedFull review finished. Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 19 minutes. |
The double spend
A multi-account plan fans out into one
purchase_executionsrow per cloud account (purchase.executeForAccount), each stamped withcloud_account_idand a per-account idempotency key"<root-key>:<accountID>". Those rows reachstatus="failed"on their own, andloadAndValidateRetryRequestonly requiresStatus == "failed"— so History offers Retry on them.persistRetryExecutioncopiedIdempotencyKey,PlanID,StepNumber,RecommendationsandCapacityPercentonto the successor, but notCloudAccountID. The successor was born account-scopeless, andexecutePurchasereads a nilcloud_account_idon a planned execution as "root execution" and re-enters the fan-out.Concretely, for plan P over accounts A, B, C where A and B committed real RIs and only C failed:
K:AK:BK:C:ADerive("K:A", i)K:C:BDerive("K:B", i)The re-suffixed keys derive tokens matching neither the AWS
ClientToken, the EC2 RI tag-guard, nor the Azure two-step lookup, so nothing deduped them. At $300 upfront per reserved instance that is $600 of unintended spend from a single Retry click on a three-account plan, scaling linearly with the plan's account count.The fix
Two gates.
internal/api/handler_purchases.go—persistRetryExecutioncarriesCloudAccountIDonto the successor, by value (via a newclonePtr) so the successor and the historical failed row never alias the same pointee, matching the existing defensive deep copy ofRecommendations. A per-account retry is then a single-account re-drive against the identical lineage key, so the provider dedupes it.internal/purchase/execution.go—executePurchaserefuses to fan out a row whose lineage key already terminates in one of the plan's own account IDs while the row carries nocloud_account_id. This is the issue's second fix direction ("refuse to re-derive a lineage key that already contains an account suffix") and it covers successors minted by the pre-fix handler that are stillpending/approvedwhen this deploys: they fail loud with zero purchases rather than re-buying the accounts that already committed. Matching against the plan's account set (rather than parsing for a UUID-shaped tail) is what makes it precise — a root key is a UUID or a legacy execution ID and cannot end in":"plus one of this plan's account IDs.The refusal is deliberately narrow. A genuine root row still fans out across every plan account under its own per-account key, because a legitimate multi-account purchase silently never happening is the same defect with the opposite sign.
No schema change.
Regression coverage
The guards reproduce the real scenario end to end — the retry HTTP handler building the successor, then the real
purchase.Managerexecuting it — and count the commitments that actually reach the provider. A unit test on either half alone would stay green while the double-spend lived in the seam between them.Against pre-fix code,
TestRetryOfFailedPerAccountRowDoesNotRefanOutfails with three purchases where one was expected:and
TestScopelessPerAccountKeyRefusesToFanOutfails withShould be zero, but was 2.Both directions are guarded:
TestRetryOfFailedPerAccountRowDoesNotRefanOutTestRetryOfPerAccountRowsKeepsAccountsDistinctTestRetryOfFailedRootRowStillFansOutToEveryAccountTestScopelessPerAccountKeyRefusesToFanOutTestRootKeyStillFansOutThroughExecutePurchaseGates
go build ./...go vet ./internal/...go test ./internal/...go test -race ./internal/purchase/...go test -race ./internal/api/...gocyclo -over 10(pre-commit scope: non-test files)golangci-lint run ./internal/...(v2.10.1, the CI pin)0 issues.gofmt -l internal/persistRetryExecutionsat at complexity 10 with the propagation inlined; extractingclonePtrreturns it to 9.Note on the reaper
The issue observes that
reaper.goappends "; safe to retry" wheneverallRecsSafeToRedriveis true, "blind to the per-account-row shape". With both gates in place that hint is now accurate for a per-account row — the retry is a single-account re-drive under the identical token — so it is left unchanged.Closes #1537