Skip to content

Azure Savings Plan purchase is non-idempotent (double-buy on re-drive) #1079

Closed

Description

@cristim

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 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.

Fixed in PR #1045.

Activity

  1. added
    triagedItem has been triaged
    pr-createdA PR has been opened for this issue (dedup guard for the auto-PR loop)
    pr-mergedThe PR for this issue has been merged
    on Jun 7, 2026
  2. cristim commented on Jul 27, 2026

    @cristim
    MemberAuthor

    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.

  3. cristim commented on Aug 23, 2026

    @cristim
    MemberAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    effort/sHoursimpact/fewLimited audiencepr-createdA PR has been opened for this issue (dedup guard for the auto-PR loop)pr-mergedThe PR for this issue has been mergedpriority/p1Next up; this sprintseverity/highSignificant harmtriagedItem has been triagedtype/bugDefecturgency/this-sprintWithin the current sprint

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions