Skip to content

feat(purchases): make Azure reservation purchases idempotent (refs #641) - #653

Merged
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/641-azure-idem
May 22, 2026
Merged

feat(purchases): make Azure reservation purchases idempotent (refs #641)#653
cristim merged 2 commits into
feat/multicloud-web-frontendfrom
fix/641-azure-idem

Conversation

@cristim

@cristim cristim commented May 21, 2026

Copy link
Copy Markdown
Member

Summary

Makes Azure reservation purchases idempotent so a re-drive / scheduler retry cannot create a duplicate reservation order. Partially addresses #641 (Azure slice). Stacks on #638 (feat/idempotent-commitment-creation) — merge #638 first.

Mechanism + double-buy argument

Previously every Azure purchase minted a fresh random reservationOrderID (uuid.New()), so a re-drive created a second order. Now reservationOrderID is derived deterministically from opts.IdempotencyToken via a shared common.ReservationOrderID(token, fallback) helper (used across all 5 Azure clients). The Reservations API PUTs to reservationOrders/{id} and is idempotent on a stable ID, so a re-drive re-PUTs the same order rather than creating a new one.

A shared helper de-dupes the empty-token guard across the 5 clients (also drops cyclomatic complexity under the gocyclo threshold).

Test plan

  • 134 pkg/common + 220 Azure-service tests pass; build + go vet + gocyclo + pre-commit clean
  • IdempotencyGUID / ReservationOrderID unit tests + per-service re-drive regression (same token -> identical reservationOrder URL; distinct tokens -> distinct orders)

Partially addresses #641 (AWS-other slice + GCP Compute tracked separately).

Summary by CodeRabbit

  • Bug Fixes

    • Azure commitment purchases now correctly reuse the same reservation order when re-driven with the same idempotency token, preventing duplicate orders across multiple services.
  • Tests

    • Added regression tests to verify idempotent purchase behavior prevents duplicate reservation orders on replay.

Review Change Stack

@cristim cristim added bug Something isn't working triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/all-users Affects every user effort/l Weeks type/bug Defect labels May 21, 2026
@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1a3f2757-8813-4e9f-82a8-d1de097138be

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements deterministic idempotency for Azure reservation order purchases by introducing two helper functions that convert idempotency tokens into canonical GUIDs, then applies these helpers across five Azure service clients to derive reservation order IDs instead of using random or timestamp-based values, preventing duplicate orders on re-drives.

Changes

Azure Reservation Purchase Idempotency

Layer / File(s) Summary
Idempotency token helpers and unit tests
pkg/common/tokens.go, pkg/common/tokens_test.go
IdempotencyGUID converts idempotency tokens to deterministic canonical lowercase GUIDs using the first 32 hex characters, returning "" for short tokens. ReservationOrderID selects the derived GUID when available, otherwise falls back to a caller-provided ID. Tests verify canonical formatting, determinism for identical tokens, distinct output for different tokens, and fallback behavior.
Azure service client PurchaseCommitment updates
providers/azure/services/cache/client.go, providers/azure/services/compute/client.go, providers/azure/services/cosmosdb/client.go, providers/azure/services/database/client.go, providers/azure/services/search/client.go
Each service's PurchaseCommitment now derives reservationOrderID deterministically from opts.IdempotencyToken via common.ReservationOrderID(), falling back to random UUIDs or timestamps when no token is provided. This ensures re-drives with the same token target the same Azure reservation order instead of creating duplicates.
Idempotency regression tests for compute and search
providers/azure/services/compute/client_test.go, providers/azure/services/search/client_test.go
Added test helpers to extract request URLs from mock HTTP client history and new regression tests (TestComputeClient_PurchaseCommitment_IdempotentReDrive, TestSearchClient_PurchaseCommitment_IdempotentReDrive) verifying that re-driving with the same token returns identical commitment IDs and reuses the same reservationOrders/{id} PUT URL, while different tokens produce different results.

Sequence Diagram

sequenceDiagram
  participant Request as Purchase Request
  participant PurchaseCommitment as PurchaseCommitment()
  participant ReservationOrderID as ReservationOrderID()
  participant IdempotencyGUID as IdempotencyGUID()
  participant Azure as Azure Reservations API
  Request->>PurchaseCommitment: opts.IdempotencyToken
  PurchaseCommitment->>ReservationOrderID: token, fallback UUID
  ReservationOrderID->>IdempotencyGUID: extract first 32 hex chars
  IdempotencyGUID-->>ReservationOrderID: canonical GUID or empty
  ReservationOrderID-->>PurchaseCommitment: deterministic or random ID
  PurchaseCommitment->>Azure: PUT /reservationOrders/{id}
  Azure-->>PurchaseCommitment: order created/updated
  PurchaseCommitment-->>Request: CommitmentID
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

  • LeanerCloud/CUDly#641: Directly addresses the requirement to make Azure reservation purchases idempotent by deterministically deriving reservation order IDs from idempotency tokens to prevent duplicate orders on re-drives.

Possibly related PRs

  • LeanerCloud/CUDly#638: Both PRs implement idempotency-token infrastructure in pkg/common and thread PurchaseOptions.IdempotencyToken through commitment creation; this PR uses the token to form deterministic reservation order IDs while the related PR adds token derivation and per-recommendation token stability.

Suggested labels

priority/p2, severity/medium, urgency/this-quarter, impact/many, effort/m, type/feat

Poem

🐰 Tokens now flow through Azure's gates,
Where GUIDs turn deterministic, no more fates,
Re-drives land safely on the same old URL,
Idempotency blooms—the rabbit breaks the spell! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(purchases): make Azure reservation purchases idempotent (refs #641)' clearly and specifically summarizes the main change: adding idempotency to Azure reservation purchases.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/641-azure-idem

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim

cristim commented May 21, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
providers/azure/services/search/client_test.go (1)

813-820: ⚡ Quick win

Harden URL capture to only match reservation purchase PUT requests.

Line 814-Line 817 currently returns the last captured request regardless of endpoint/method. Filtering to PUT + reservationOrders makes this regression test resilient to future extra HTTP calls in PurchaseCommitment.

♻️ Proposed hardening
 import (
 	"bytes"
 	"context"
 	"errors"
 	"io"
 	"net/http"
+	"strings"
 	"testing"
 	"time"
@@
 func searchPurchaseURLFromMock(t *testing.T, m *MockHTTPClient) string {
 	t.Helper()
 	for i := len(m.Calls) - 1; i >= 0; i-- {
 		req, ok := m.Calls[i].Arguments.Get(0).(*http.Request)
-		if ok && req != nil {
+		if ok && req != nil &&
+			req.Method == http.MethodPut &&
+			strings.Contains(req.URL.Path, "/reservationOrders/") {
 			return req.URL.String()
 		}
 	}
-	t.Fatalf("no HTTP request captured by mock")
+	t.Fatalf("no reservation purchase PUT request captured by mock")
 	return ""
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@providers/azure/services/search/client_test.go` around lines 813 - 820, The
current test helper iterates m.Calls and returns the last HTTP request
regardless of endpoint; update the loop in the test (the block using m.Calls and
returning req.URL.String()) to only return the URL for requests that match
req.Method == "PUT" and whose URL path or string contains "reservationOrders"
(so it selects the reservation purchase PUT request), and keep the existing
t.Fatalf("no HTTP request captured by mock") fallback if none match; this change
localizes the check to the reservation purchase call used by PurchaseCommitment
and prevents unrelated mock calls from being returned.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/common/tokens.go`:
- Around line 55-61: IdempotencyGUID is formatting any 32+ character string into
a GUID without validating or normalizing it; update IdempotencyGUID to first
extract the first 32 characters, verify all characters are valid hex (0-9,
a-f/A-F), convert them to canonical lowercase, and only then format into the
dash-separated GUID; if validation fails return an empty string. Ensure you
modify the IdempotencyGUID function and use the validated/normalized 32-char hex
slice when building the final formatted string.

In `@providers/azure/services/search/client.go`:
- Around line 251-252: The timestamp-based fallback used when building
reservationOrderID (calling common.ReservationOrderID with opts.IdempotencyToken
and fmt.Sprintf("search-reservation-%d", time.Now().Unix())) is prone to
collisions; replace the fmt.Sprintf timestamp fallback with a
collision-resistant identifier (e.g., a UUID via uuid.NewString() or a
cryptographically-random hex string) so concurrent non-token purchases produce
unique fallback IDs before calling common.ReservationOrderID.

---

Nitpick comments:
In `@providers/azure/services/search/client_test.go`:
- Around line 813-820: The current test helper iterates m.Calls and returns the
last HTTP request regardless of endpoint; update the loop in the test (the block
using m.Calls and returning req.URL.String()) to only return the URL for
requests that match req.Method == "PUT" and whose URL path or string contains
"reservationOrders" (so it selects the reservation purchase PUT request), and
keep the existing t.Fatalf("no HTTP request captured by mock") fallback if none
match; this change localizes the check to the reservation purchase call used by
PurchaseCommitment and prevents unrelated mock calls from being returned.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 463be8d0-5f78-41c6-97e4-9d9bc2d2923d

📥 Commits

Reviewing files that changed from the base of the PR and between 45a92e7 and 52bb84d.

📒 Files selected for processing (9)
  • pkg/common/tokens.go
  • pkg/common/tokens_test.go
  • providers/azure/services/cache/client.go
  • providers/azure/services/compute/client.go
  • providers/azure/services/compute/client_test.go
  • providers/azure/services/cosmosdb/client.go
  • providers/azure/services/database/client.go
  • providers/azure/services/search/client.go
  • providers/azure/services/search/client_test.go

Comment thread pkg/common/tokens.go
Comment thread providers/azure/services/search/client.go Outdated
cristim added a commit that referenced this pull request May 22, 2026
… fallback

IdempotencyGUID now validates the first 32 characters are valid lowercase
hex before formatting them as a GUID, returning "" for any non-hex input
rather than silently producing a malformed GUID.

Azure search PurchaseCommitment replaces the Unix-second timestamp fallback
with uuid.New().String() so the fallback is collision-resistant at
sub-second granularity rather than guaranteed to collide on burst retries.

Co-authored-by: CodeRabbit CR #653
@cristim

cristim commented May 22, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

cristim added 2 commits May 22, 2026 13:06
Extends the #636 IdempotencyToken mechanism to all five Azure reservation
executors (cache, compute, cosmosdb, database, search) so a re-drive (and

Each executor previously minted a fresh reservationOrderID per call
(uuid.New(), or a time.Now() timestamp in search), so a retry created a
second reservation. The Azure Reservations API PUTs to
reservationOrders/{id} and is idempotent on a stable order ID, so the
order ID is now derived deterministically from the IdempotencyToken via
common.IdempotencyGUID: the same token re-PUTs the same order and returns
the existing reservation rather than creating a second. An empty/short
token falls back to the prior non-idempotent ID, preserving behaviour for
non-execution callers.

Adds common.IdempotencyGUID (canonical 8-4-4-4-12 GUID from the first 128
bits of the SHA-256 token) and common.ReservationOrderID, a single helper
that returns the derived GUID or the caller's fallback so each executor
stays a one-statement assignment (keeps gocyclo under threshold and avoids
repeating the empty-token guard across services). Per-provider regression
tests on compute and search assert that a same-token re-drive PUTs to the
identical reservationOrders/{id} URL (no second reservation) while a
distinct token targets a distinct order.

Refs #641.
… fallback

IdempotencyGUID now validates the first 32 characters are valid lowercase
hex before formatting them as a GUID, returning "" for any non-hex input
rather than silently producing a malformed GUID.

Azure search PurchaseCommitment replaces the Unix-second timestamp fallback
with uuid.New().String() so the fallback is collision-resistant at
sub-second granularity rather than guaranteed to collide on burst retries.

Co-authored-by: CodeRabbit CR #653
@cristim
cristim force-pushed the fix/641-azure-idem branch from 519f2a1 to ab06a17 Compare May 22, 2026 11:07
@cristim
cristim merged commit 789f0a6 into feat/multicloud-web-frontend May 22, 2026
4 checks passed
@cristim
cristim deleted the fix/641-azure-idem branch May 22, 2026 11:12
cristim added a commit that referenced this pull request May 22, 2026
GCP Compute CUD creation named commitments cud-<unix-second> and ignored
opts.IdempotencyToken, so a re-drive of the same execution (issue #639's
recovery sweep) would create a SECOND committed-use discount: a financial
double-purchase.

Thread opts.IdempotencyToken into RegionCommitments.Insert via two
deterministic mechanisms so the same token can never buy twice:

- RequestId: GCP's native server-side idempotency key on Insert, which the
  API documents as preventing duplicate commitments. It must be a valid
  non-zero UUID, so we format the SHA-256 token into a canonical UUID with
  the existing common.IdempotencyGUID helper (the same derivation PR #653
  used for the Azure reservationOrderID). The same token always yields the
  same RequestId, so the second Insert is a server-side no-op.
- Name: also derived from the token (cud-<first-32-hex>, RFC1035-valid) as
  defense in depth. Commitment names are unique per project+region, so a
  re-drive that somehow reached Insert collides on the name and GCP rejects
  it with ALREADY_EXISTS rather than creating a duplicate.

An empty token preserves the prior non-idempotent timestamp-based name (the
CLI path, which has no owning execution). The token is masked in logs via
common.MaskToken (never logged verbatim), matching PR #652.

Adds a re-drive regression test mirroring the AWS/Azure ones: the same token
on a second PurchaseCommitment yields an identical RequestId and commitment
name, plus an empty-token test confirming the CLI path stays non-idempotent.

Scope is computeengine only; CloudSQL/Storage/Memorystore were made
advisory-only in #649 and are untouched.

Closes #654
refs #641
cristim added a commit that referenced this pull request May 26, 2026
…eTwoStep (#729)

PR #680 (commit 65ecbf8) switched all 7 Azure reservation executors to
DoPurchaseTwoStep, which mints a fresh server-side reservationOrderId per
calculatePrice call and dropped the deterministic IdempotencyToken
threading PR #653 had added. A re-drive of the same (executionID, recIndex)
would create a SECOND Azure reservation, regressing #641's invariant.

Option A (client-supplied order ID) is not viable: Azure rejects
client-supplied IDs in the two-step flow (the very reason PR #680 had to
adopt it). The fix implements Option B from the issue, mirroring the AWS
EC2 findRIByIdempotencyToken pattern:

  * ApplyPurchaseTags now stamps two tags on every purchase body:
    purchase-automation (attribution, existing) and cudly-idempotency-token
    (new, the deterministic per-rec token).
  * FindReservationOrderByIdempotencyToken lists reservation orders via the
    Microsoft.Capacity REST endpoint, filters by tag, skips terminal-failed
    states (Cancelled/Failed/Expired), and follows nextLink pagination.
  * DoIdempotentPurchaseTwoStep wraps DoPurchaseTwoStep with the
    lookup-first/short-circuit guard. Empty token falls straight through
    (CLI legacy path). Failed lookups refuse to purchase rather than risk
    a double-buy (mirrors EC2 safety contract).

All 7 service executors (cache, compute, cosmosdb, database, managedredis,
search, synapse) now call DoIdempotentPurchaseTwoStep with opts.IdempotencyToken
and ApplyPurchaseTags with both opts.Source and opts.IdempotencyToken.

20 new tests cover both invariants: same token produces one reservation
(short-circuit), different tokens produce distinct reservations.
DoPurchaseTwoStep retains its previous behaviour for callers without an
owning execution. Per-service existing tests continue to pass unchanged.

Closes #721, partial-restore of #641 invariant for Azure, unblocks #639.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working effort/l Weeks impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant