Repository navigation
Description
Problem
providers/azure/services/savingsplans/client.go PurchaseCommitment 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 theIdempotencyTokenthe purchase-execution flow threads through every other provider. - The
SavingsPlanOrderAliasname wasfmt.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.
Fixed in PR #1045.
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.