You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Azure Savings Plan purchase is non-idempotent (double-buy on re-drive) #1079
providers/azure/services/savingsplans/client.goPurchaseCommitment is not idempotent and can double-buy an Azure Savings Plan on a re-drive of a stranded purchase execution (issue #636 / #721 class).
The method signature took _ common.PurchaseOptions, discarding the IdempotencyToken the purchase-execution flow threads through every other provider.
The SavingsPlanOrderAlias name was fmt.Sprintf("cudly-%d", time.Now().UnixNano()) -- a fresh value every call.
Evidence
The alias is created with an HTTP PUT on its name: armbillingbenefits.SavingsPlanOrderAliasClient.BeginCreate issues http.MethodPut on /providers/Microsoft.BillingBenefits/savingsPlanOrderAliases/{name}. A PUT with a previously-used name returns the existing order alias rather than creating a second one, so the alias name is the idempotency handle. A non-deterministic name defeats that: a re-driven execution creates a second savings plan and double-charges the customer.
Every Azure reservation client (compute/cache/cosmosdb/database/search/synapse/managedredis) already routes opts.IdempotencyToken through reservations.DoIdempotentPurchaseTwoStep; savingsplans was the only outlier.
Fix
Derive the alias name from opts.IdempotencyToken via common.ReservationOrderID(token, fallback) (the deterministic canonical GUID helper already used for reservation order IDs), with the prior timestamp name as the empty-token CLI fallback.
Regression tests: two re-drives with the same token must reuse the GUID alias name (fails pre-fix); the empty-token CLI path keeps the timestamp name.
Verification sweep against main (101f099fb) on 2026-07-27 finds this already resolved.
Azure Savings Plan purchase now derives its idempotency-token alias name deterministically, with account scope folded in.
Evidence: commit 6e470f2 (PR #1045); internal/purchase/execution.go:237 (acctExec.IdempotencyKey = idempotencyLineageKey(baseExec) + ":" + account.ID).
Recommending close. Reopen if a scope gap remains in a different purchase path.
Already fixed on main, in the exact shape this issue prescribes. Closing with evidence rather than scheduling work.
providers/azure/services/savingsplans/client.go:
PurchaseCommitment takes opts common.PurchaseOptions, not the discarded _ common.PurchaseOptions described here.
Line 282: aliasName := common.ReservationOrderID(opts.IdempotencyToken, fmt.Sprintf("cudly-%d", time.Now().UnixNano())) — the deterministic canonical GUID when a token is present, with the prior timestamp name retained as the empty-token CLI fallback.
The comment block at lines 218-226 records why the alias name is the idempotency handle (PUT on the alias name returns the existing order) and cites the stranded-execution re-drive scenario.
Both regression tests requested here are present in providers/azure/services/savingsplans/client_test.go: TestPurchaseCommitment_DeterministicAliasNameFromToken and TestPurchaseCommitment_EmptyTokenUsesTimestampName.
Landed in 6e470f26b.
If any residual axis remains that this does not cover, please reopen naming it specifically.
Activity
cristim commentedon Jul 27, 2026
Verification sweep against
main(101f099fb) on 2026-07-27 finds this already resolved.Azure Savings Plan purchase now derives its idempotency-token alias name deterministically, with account scope folded in.
Evidence: commit 6e470f2 (PR #1045); internal/purchase/execution.go:237 (acctExec.IdempotencyKey = idempotencyLineageKey(baseExec) + ":" + account.ID).
Recommending close. Reopen if a scope gap remains in a different purchase path.
cristim commentedon Aug 23, 2026
Already fixed on
main, in the exact shape this issue prescribes. Closing with evidence rather than scheduling work.providers/azure/services/savingsplans/client.go:PurchaseCommitmenttakesopts common.PurchaseOptions, not the discarded_ common.PurchaseOptionsdescribed here.aliasName := common.ReservationOrderID(opts.IdempotencyToken, fmt.Sprintf("cudly-%d", time.Now().UnixNano()))— the deterministic canonical GUID when a token is present, with the prior timestamp name retained as the empty-token CLI fallback.Both regression tests requested here are present in
providers/azure/services/savingsplans/client_test.go:TestPurchaseCommitment_DeterministicAliasNameFromTokenandTestPurchaseCommitment_EmptyTokenUsesTimestampName.Landed in
6e470f26b.If any residual axis remains that this does not cover, please reopen naming it specifically.