Skip to content

fix(purchases): AWS-scoped auto-re-drive in RecoverStrandedApprovals (partial #632) - #728

Merged
cristim merged 5 commits into
feat/multicloud-web-frontendfrom
fix/632-aws-scoped-auto-redrive
May 26, 2026
Merged

fix(purchases): AWS-scoped auto-re-drive in RecoverStrandedApprovals (partial #632)#728
cristim merged 5 commits into
feat/multicloud-web-frontendfrom
fix/632-aws-scoped-auto-redrive

Conversation

@cristim

@cristim cristim commented May 25, 2026

Copy link
Copy Markdown
Member

Refs #632. Full Azure inclusion blocked on #721#639; this PR ships the AWS-only subset so the field-observed strand class (all AWS) is auto-recovered today.

What changed

internal/purchase/manager.go:

  1. New allRecsAWS(exec *config.PurchaseExecution) bool predicate — returns true when every rec has Provider == 'aws' or Provider == '' (empty = pre-multi-cloud legacy) AND at least one rec exists.
  2. New branch in RecoverStrandedApprovals: when allRecsAWS(exec) && exec.ExecutionID != '' → re-drives via executeAndFinalize. The existing DeriveIdempotencyToken(exec.ExecutionID, i) at execution.go:428 produces the same token on re-drive, making the second AWS API call a safe no-op via the per-executor idempotency machinery (feat(purchases): extend IdempotencyToken to all commitment executors (AWS RDS/ElastiCache/MemoryDB/OpenSearch/Redshift, Azure, GCP) — blocks #639 #641).
  3. New safeFail helper extracted from RecoverStrandedApprovals to keep cyclomatic complexity at 10 (pre-commit hook enforces gocyclo:10). The safe-fail path for mixed / Azure-only / legacy executions is unchanged.

Tests (4 new + 1 updated)

  • TestManager_RecoverStrandedApprovals_AWSOnlyRedrives — re-drive path, row ends as completed.
  • TestManager_RecoverStrandedApprovals_MixedAWSAzureSafeFails — mixed execution falls through to safe-fail.
  • TestManager_RecoverStrandedApprovals_PureAzureSafeFails — Azure-only falls through to safe-fail.
  • TestManager_RecoverStrandedApprovals_LegacyNoExecutionIDSafeFails — empty ExecutionID falls through.
  • TestManager_RecoverStrandedApprovals_FailsStrandedRow updated from Provider: aws to Provider: azure so it stays on the safe-fail path (original intent preserved).

1457 tests pass across purchase + api. go vet / gofmt / golangci-lint clean.

Scope guard

Azure-only and mixed strands deliberately fall through to today's safe-fail-and-Retry. Once #721 (Azure DoPurchaseTwoStep idempotency regression) lands, #639 can flip the predicate to all-providers.

Summary by CodeRabbit

  • Bug Fixes

    • Improved recovery for approvals stuck in "approved": AWS-only cases are claimed and re-driven; mixed/non-AWS/legacy cases are atomically marked failed. Recovery counting now reflects only successful recoveries and distinguishes persistence/audit-loss from execution errors.
  • Tests

    • Added regression tests covering AWS re-drives, safe-fail routing, race conditions, claim-loss, persistence failures, and error propagation.
  • Documentation

    • Clarified error semantics and introduced a distinct audit-loss error to signal persistence failures after execution.

Review Change Stack

…(partial #632)

Refs #632 (full Azure inclusion blocked by #721 -> #639). AWS-only
strands now re-drive idempotently; mixed/Azure strands fall through to
safe-fail-and-Retry.

- Add allRecsAWS predicate: true when every rec has Provider=="aws" or
  Provider=="" (empty = legacy pre-multi-cloud rows). Returns false for
  an empty Recommendations slice so zero-rec executions safe-fail.
- Add AWS-only + non-empty ExecutionID branch in RecoverStrandedApprovals:
  calls executeAndFinalize directly, reusing the existing idempotency
  tokens derived via DeriveIdempotencyToken(exec.ExecutionID, i) at
  execution.go:428. AWS dedupes on the same token, so a second call
  is safe even if the first created a commitment.
- Mixed/Azure/GCP or legacy rows without a stable ExecutionID continue
  to use the atomic TransitionExecutionStatus->failed path.
- Update existing FailsStrandedRow test to use an Azure rec so it stays
  on the safe-fail path (test intent preserved, provider changed).
- Add 4 new tests: AWSOnlyRedrives, MixedAWSAzureSafeFails,
  PureAzureSafeFails, LegacyNoExecutionIDSafeFails.
@cristim cristim added 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/s Hours type/bug Defect labels May 25, 2026
@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71654b32-c6a1-4717-9bdf-4b7d4a47d2a1

📥 Commits

Reviewing files that changed from the base of the PR and between 15308ee and 418c20b.

📒 Files selected for processing (2)
  • internal/purchase/manager.go
  • internal/purchase/manager_test.go

📝 Walkthrough

Walkthrough

Refactors recovery of purchase executions stuck in approved: adds AWS-only detection, extracts claimAndRedrive and safeFail helpers, wraps persistence failures with ErrAuditLoss, and updates RecoverStrandedApprovals dispatch and tests for AWS re-drive vs atomic safe-fail flows.

Changes

Stranded Approval Recovery with AWS Redrive Path

Layer / File(s) Summary
Sentinel errors and docs
internal/config/errors.go
Expand ErrExecutionNotInExpectedStatus docs and add exported ErrAuditLoss sentinel for persistence/audit-loss failures.
executeAndFinalize: audit-loss wrapping
internal/purchase/manager.go
executeAndFinalize always wraps SavePurchaseExecution failures with config.ErrAuditLoss, preserving original execution errors when present.
AWS predicate and claim+redrive
internal/purchase/manager.go
Adds allRecsAWS predicate and claimAndRedrive (CAS approved->running then executeAndFinalize); treats CAS-loss as benign and propagates audit-loss/persistence failures.
Atomic safe-fail helper
internal/purchase/manager.go
Adds safeFail to atomically transition approvedfailed, stamp a recovery error, probe after CAS failures, and surface genuine store/read errors.
RecoverStrandedApprovals docs and dispatch
internal/purchase/manager.go
Updates docstring and per-execution loop to choose AWS claim+redrive for AWS-only w/ ExecutionID or safe-fail for others; adjusts Recovered counting and early-abort behavior.
Test coverage for recovery paths
internal/purchase/manager_test.go
Adds tests for AWS-only re-drive (ends completed), mixed/Azure/legacy safe-fail cases (end failed with no provider calls), benign ErrNotFound/CAS-race handling, CAS-claim-loss behavior, and persistence-failure propagation wrapping ErrAuditLoss; adds fmt import and comment tweaks.

Sequence Diagram(s)

sequenceDiagram
  participant Manager
  participant Store
  participant Executor
  Manager->>Store: TransitionExecutionStatus(id, approved -> running) (CAS)
  alt CAS success
    Manager->>Executor: executeAndFinalize(execution)
    Executor-->>Manager: execution result
    Manager->>Store: SavePurchaseExecution (may wrap ErrAuditLoss)
  else CAS failure
    Manager->>Store: GetExecutionByID(id) probe to detect race
    alt Already transitioned or missing
      Note right of Manager: treat as benign, no recovered increment
    else Store error
      Note right of Manager: abort sweep on store error
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

Suggested labels

effort/m

Poem

A rabbit nibbles through the stuck-approved heap,
AWS gets a redrive, Azure sleeps deep,
CAS races probed gently, true faults ring the bell,
Audit-loss wrapped tidy — no secrets to tell,
Hop-on, hop-off, recovery done — time to leap! 🐰

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly reflects the main change: AWS-scoped auto-re-drive logic added to RecoverStrandedApprovals, matching the core objective of enabling AWS-only executions to be re-driven idempotently.
Docstring Coverage ✅ Passed Docstring coverage is 92.86% which is sufficient. The required threshold is 80.00%.
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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/632-aws-scoped-auto-redrive

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

@cristim

cristim commented May 25, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 25, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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.

@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

🤖 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 `@internal/purchase/manager.go`:
- Around line 274-289: This AWS re-drive branch currently lacks an atomic
ownership step so two workers can both call executeAndFinalize for the same
approved row; add a CAS-style claim before invoking executeAndFinalize in the
allRecsAWS(exec) && exec.ExecutionID != "" branch: call a new or existing atomic
claim method (e.g., m.claimExecution(ctx, exec.ID or exec.ExecutionID) returning
(bool, error)) right after the logging/info line, proceed to executeAndFinalize
only if the claim returns true, and if the claim returns false or an error,
log/continue without calling executeAndFinalize; ensure the claim persists
ownership such that subsequent SavePurchaseExecution/updatePlanProgress from
executeAndFinalize are only done by the claimer.
- Around line 211-228: When handling the result of
m.config.TransitionExecutionStatus in RecoverStrandedApprovals, treat a CAS "row
vanished" as a benign race-loss by checking errors.Is(txErr, config.ErrNotFound)
(or equivalent exported sentinel) before calling GetExecutionByID; if that check
is true return (false, nil). In other words, add an early branch after obtaining
txErr that returns (false, nil) when errors.Is(txErr, config.ErrNotFound),
otherwise keep the existing read-back probe using m.config.GetExecutionByID and
the current logging/propagation behavior.
🪄 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: 91b7aeb8-4aec-4ab7-9122-f37371da599e

📥 Commits

Reviewing files that changed from the base of the PR and between 5abf40b and 40b0756.

📒 Files selected for processing (2)
  • internal/purchase/manager.go
  • internal/purchase/manager_test.go

Comment thread internal/purchase/manager.go
Comment thread internal/purchase/manager.go
…d as benign in safe-fail (CR #728)

Finding 1: RecoverStrandedApprovals previously called executeAndFinalize on
AWS-only stranded rows without claiming ownership first. Two concurrent sweeps
(or a late original completion racing the re-drive) could both call
executeAndFinalize on the same approved row, risking a double-purchase. Fix:
extract claimAndRedrive helper that calls TransitionExecutionStatus
("approved" -> "running") as an atomic CAS claim before the re-drive. Only the
winner of the CAS proceeds; ErrNotFound and ErrExecutionNotInExpectedStatus are
both treated as benign race-losses (log and skip). Real DB errors propagate.
Extracting the helper also brings RecoverStrandedApprovals back within
gocyclo:10.

Finding 2: safeFail's TransitionExecutionStatus error path called
GetExecutionByID even when the error was config.ErrNotFound, which indicates
the row has already been deleted, making the follow-up probe redundant. Fix:
add an early branch that returns (false, nil) immediately on ErrNotFound,
skipping the unnecessary round-trip.

Both fixes reuse the established TransitionExecutionStatus CAS primitive.
Tests: SafeFail_ErrNotFoundIsBenign asserts no GetExecutionByID call on
ErrNotFound; AWSClaimLost_NoRedrive asserts executeAndFinalize is not reached
when the CAS claim is lost. Existing AWSOnlyRedrives test updated to expect
the new CAS claim call (approved -> running).
@cristim

cristim commented May 26, 2026

Copy link
Copy Markdown
Member Author

CR #728 findings addressed

Both findings from the 2026-05-26T04:52:40Z review have been resolved in commit d0addd8.

Finding 1 (Major): CAS claim before AWS re-drive

RecoverStrandedApprovals now calls TransitionExecutionStatus("approved" -> "running") as an atomic CAS claim before invoking executeAndFinalize on any stranded AWS-only row. Only the sweep that wins the CAS proceeds to re-drive; the loser gets ErrNotFound or ErrExecutionNotInExpectedStatus (both benign race-losses) and logs a warning before skipping. Real DB errors propagate so the next tick retries.

The claim logic was extracted into a claimAndRedrive helper (same pattern as the existing safeFail helper) both for clarity and to keep RecoverStrandedApprovals within the gocyclo:10 limit that was otherwise breached by the inline CAS block.

New test: TestManager_RecoverStrandedApprovals_AWSClaimLost_NoRedrive -- asserts that when the CAS claim returns ErrExecutionNotInExpectedStatus (concurrent sweep already claimed the row), executeAndFinalize is never reached and no provider call is made. MockProviderFactory.AssertExpectations enforces the invariant.

Finding 2 (Minor): ErrNotFound is benign in safeFail

Added an early branch at the top of safeFail's error handler: if TransitionExecutionStatus returns config.ErrNotFound, we immediately return false, nil without calling GetExecutionByID. A not-found error already tells us the row is gone; the follow-up probe was a redundant round-trip.

New test: TestManager_RecoverStrandedApprovals_SafeFail_ErrNotFoundIsBenign -- asserts (0, nil) return and verifies GetExecutionByID is not called when TransitionExecutionStatus wraps config.ErrNotFound. MockConfigStore.AssertExpectations via t.Cleanup enforces no unexpected calls.

Primitive reused

Both fixes build on the existing TransitionExecutionStatus CAS pattern already used by safeFail (approved -> failed), the cancel handler (pending -> cancelled), and the pause/resume handlers.

@coderabbitai review

@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: 1

🤖 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 `@internal/purchase/manager.go`:
- Around line 204-207: The helper currently swallows all errors from
executeAndFinalize, but executeAndFinalize can return persistence/audit failures
(e.g., when SavePurchaseExecution fails after CAS to running) which must not be
treated as benign; update executeAndFinalize (or its callers) to return a
distinguishable error type or sentinel for persistence/audit failures and change
the caller in manager.go to propagate or handle that error (do not return
(false, nil) for persistence failures), ensuring the execution row is either
retried/requeued or the persistence error is returned up the stack; add a
regression test that simulates SavePurchaseExecution failing after the CAS to
running and asserts the manager does not drop the row as still running but
instead surfaces/handles the persistence error.
🪄 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: bf07239c-6275-4983-b90f-a5fa83675134

📥 Commits

Reviewing files that changed from the base of the PR and between 40b0756 and d0addd8.

📒 Files selected for processing (2)
  • internal/purchase/manager.go
  • internal/purchase/manager_test.go

Comment thread internal/purchase/manager.go
… (CR #728 round 2)

When SavePurchaseExecution failed after the CAS-to-running CAS succeeded,
claimAndRedrive returned (false, nil), silently dropping the stranded-in-
running row. Add config.ErrAuditLoss sentinel and wrap the save error with
it in executeAndFinalize; claimAndRedrive now propagates that sentinel so
the sweep surfaces the error rather than losing the row.

Benign provider/rec errors still return (false, nil) as before -- those
paths already result in a persisted terminal status via finalizeExecution.
@cristim

cristim commented May 26, 2026

Copy link
Copy Markdown
Member Author

CR #728 round-2 fix: propagate persistence failures from executeAndFinalize

Option taken: A -- sentinel error config.ErrAuditLoss + wrap at the persistence boundary.

Problem

claimAndRedrive returned (false, nil) for ALL errors from executeAndFinalize, including the non-benign case where:

  1. The CAS approved -> running succeeded (row is now "running" in the DB), and
  2. SavePurchaseExecution failed after the purchase completed.

In that scenario, executeAndFinalize already returns a non-nil error (wrapping the save failure with "audit loss: ...") but claimAndRedrive silently dropped it. The row is permanently stranded in "running" -- no terminal state was persisted and no error was surfaced to the sweep.

Fix

Three minimal changes:

  1. internal/config/errors.go: Added ErrAuditLoss sentinel with a docstring explaining the exact scenario (CAS succeeded, persistence failed).

  2. internal/purchase/manager.go -- executeAndFinalize: Changed the save-failure wrap from fmt.Errorf("audit loss: %w", err) to fmt.Errorf("%w: %w", config.ErrAuditLoss, err). Both the sentinel and the underlying DB error are traversable via errors.Is.

  3. internal/purchase/manager.go -- claimAndRedrive: Before the silent return false, nil, check errors.Is(driveErr, config.ErrAuditLoss). If true, propagate as (false, err) -- the sweep stops for this row and surfaces the error. Benign provider/rec errors (where finalizeExecution already committed a terminal status and SavePurchaseExecution succeeded) still return (false, nil) as before.

Why not B or C

  • Option B (third return value) would require changing the signature of executeAndFinalize, touching every call site, and adding a new field to ProcessResult -- disproportionate blast radius for one caller.
  • Option C (rollback CAS in executeAndFinalize) would add a best-effort TransitionExecutionStatus call inside executeAndFinalize, which itself can fail, and would make executeAndFinalize aware of the CAS it did not perform -- coupling it to claimAndRedrive's internals.

Option A keeps the sentinel close to the error definition (config/errors.go), the wrap at the persistence boundary, and the routing decision in the one caller that needs it.

Regression test added

TestManager_RecoverStrandedApprovals_AWSRedrive_PersistenceFailurePropagates simulates:

  • CAS approved -> running succeeds
  • PurchaseCommitment succeeds (commitment is real)
  • SavePurchaseExecution returns a DB error

Asserts:

  • RecoverStrandedApprovals returns a non-nil error wrapping config.ErrAuditLoss
  • recovered == 0 (the stranded row is not miscounted)
  • All mock expectations met (no extra calls, no double-purchase)

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

@cristim: The Option A design looks correct and well-contained. A few notes on what I'll be looking for in the full review:

  1. fmt.Errorf("%w: %w", config.ErrAuditLoss, err) — valid Go 1.20+ multi-error wrapping; both the sentinel and the underlying DB error remain traversable via errors.Is / errors.Unwrap. Good for observability.

  2. Blast radius on existing executeAndFinalize callers — those callers already propagated non-nil errors from save failures; they now get a richer error chain, not a different type, so their behavior is unchanged.

  3. Row stuck in "running" after ErrAuditLoss — aborting the sweep is consistent with how safeFail handles real store errors (safety > throughput). The row won't be re-picked by the next "approved" sweep, but that's the right call: surface the audit loss loudly. A staleness alarm on long-running "running" rows would complement this, but that's pre-existing scope.

  4. ErrAuditLoss sentinel scope — placing it in internal/config/errors.go alongside the other sentinels is the right home; the purchase layer wraps it, the config layer defines it, and no layer is coupled to another's internals.

Triggering the full review now.

✅ 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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.

@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: 1

♻️ Duplicate comments (1)
internal/purchase/manager.go (1)

163-171: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wrap audit loss on every terminal-state persistence failure.

The execErr == nil guard is too narrow. If executePurchase returns a provider error or partialPurchaseError and SavePurchaseExecution then fails, the terminal state was never persisted, so the row is still stranded in running. claimAndRedrive only propagates errors matching config.ErrAuditLoss, so those paths are still silently dropped.

Please mark any SavePurchaseExecution failure here as ErrAuditLoss and preserve the original execution error as context, then add a regression test for the non-nil execErr case.

Possible fix
 	if !wasMultiAccount {
 		if err := m.config.SavePurchaseExecution(ctx, exec); err != nil {
 			logging.Errorf("AUDIT LOSS: failed to save execution status: %v", err)
-			if execErr == nil {
-				// Wrap with ErrAuditLoss so callers (e.g. claimAndRedrive) can
-				// distinguish a persistence failure -- where the row is left
-				// stranded in "running" -- from a benign provider/rec error
-				// where finalizeExecution already committed a terminal status.
-				execErr = fmt.Errorf("%w: %w", config.ErrAuditLoss, err)
-			}
+			if execErr != nil {
+				execErr = fmt.Errorf("%w: terminal execution error %v; save failed: %v", config.ErrAuditLoss, execErr, err)
+			} else {
+				execErr = fmt.Errorf("%w: %w", config.ErrAuditLoss, err)
+			}
 		}
 	}
🤖 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 `@internal/purchase/manager.go` around lines 163 - 171, The
SavePurchaseExecution failure handling should always be wrapped as an audit-loss
error regardless of execErr: when m.config.SavePurchaseExecution(ctx, exec)
returns an error, replace the conditional guard and set execErr =
fmt.Errorf("%w: %w", config.ErrAuditLoss, originalErr) while preserving any
existing execErr as context (e.g., include execErr in the wrapped error or chain
it so the original provider/partialPurchaseError isn't lost); update the code in
the SavePurchaseExecution/error block (referencing
m.config.SavePurchaseExecution, execErr, and config.ErrAuditLoss) and add a
regression test that simulates executePurchase returning a non-nil
provider/partialPurchaseError and then SavePurchaseExecution failing to assert
the final propagated error matches or wraps config.ErrAuditLoss and contains the
original execErr context.
🤖 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 `@internal/purchase/manager_test.go`:
- Around line 739-756: The mocks for side-effectful operations are not
constrained to run exactly once, weakening the test; update the expectations to
include .Once() on the side-effectful calls: add .Once() to
mockStore.TransitionExecutionStatus(...), mockStore.SavePurchaseHistory(...),
mockEmail.SendPurchaseConfirmation(...), mockStore.SavePurchaseExecution(...),
and mockServiceClient.PurchaseCommitment(...) so each of those expectations must
be invoked exactly once (leave non-side-effect mocks like GetPurchasePlan,
GetCallerIdentity, CreateAndValidateProvider, GetServiceClient as appropriate).

---

Duplicate comments:
In `@internal/purchase/manager.go`:
- Around line 163-171: The SavePurchaseExecution failure handling should always
be wrapped as an audit-loss error regardless of execErr: when
m.config.SavePurchaseExecution(ctx, exec) returns an error, replace the
conditional guard and set execErr = fmt.Errorf("%w: %w", config.ErrAuditLoss,
originalErr) while preserving any existing execErr as context (e.g., include
execErr in the wrapped error or chain it so the original
provider/partialPurchaseError isn't lost); update the code in the
SavePurchaseExecution/error block (referencing m.config.SavePurchaseExecution,
execErr, and config.ErrAuditLoss) and add a regression test that simulates
executePurchase returning a non-nil provider/partialPurchaseError and then
SavePurchaseExecution failing to assert the final propagated error matches or
wraps config.ErrAuditLoss and contains the original execErr context.
🪄 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: 8021bbf5-32f5-4721-aaf0-896930994f21

📥 Commits

Reviewing files that changed from the base of the PR and between d0addd8 and d74d26a.

📒 Files selected for processing (3)
  • internal/config/errors.go
  • internal/purchase/manager.go
  • internal/purchase/manager_test.go

Comment on lines +739 to +756
mockStore.On("TransitionExecutionStatus", ctx, "exec-aws-persist-fail", []string{"approved"}, "running").
Return(&runningRow, nil)
mockStore.On("GetPurchasePlan", ctx, "plan-aws-persist-456").Return(plan, nil).Once()
mockStore.On("SavePurchaseHistory", ctx, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil)
mockEmail.On("SendPurchaseConfirmation", ctx, mock.AnythingOfType("email.NotificationData")).Return(nil)
// SavePurchaseExecution fails AFTER the CAS-to-running succeeded.
mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).
Return(saveErr)

mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).Return(&sts.GetCallerIdentityOutput{
Account: aws.String("123456789012"),
}, nil)
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProvider, nil)
mockProvider.On("GetServiceClient", mock.Anything, common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
mockServiceClient.On("PurchaseCommitment", mock.Anything, mock.AnythingOfType("common.Recommendation"), mock.AnythingOfType("common.PurchaseOptions")).Return(common.PurchaseResult{
Success: true,
CommitmentID: "ri-persist-fail-12345",
}, nil)

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.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In github.com/stretchr/testify/mock v1.11.1, does mock.On(...).Return(...)withoutOnce()/Twice()/Times(n)permit multiple calls while still passingAssertExpectations?

💡 Result:

Yes. In testify/mock v1.11.1, if you set an expectation with mock.On(...).Return(...) and you do not add .Once(), .Twice(), or .Times(n), then the expectation is allowed to match and return repeatedly, and it will still pass AssertExpectations as long as the method/arguments match at least once. Reason (v1.11.1 behavior): the Call struct has a Repeatability field where the comments state that 0 means “to always return the value” [1]. The expectation satisfaction check in checkExpectation only fails on repeatability when call.Repeatability > 0; when Repeatability is 0, it does not enforce an upper bound [1]. AssertExpectations iterates over expectations and fails only when a non-optional, never-called expectation remains (call.totalCalls == 0 and not optional) or when repeatability is violated [1]. Practical implication: - If you write m.On("DoSomething", args...).Return(ret...) with no .Once/.Twice/.Times, it can be invoked multiple times and still satisfy AssertExpectations. - If you want to ensure it’s called only N times (or exactly once), you must set .Times(n)/.Once()/.Twice() because that sets Repeatability to a positive number that checkExpectation will use to fail when the repeatability condition is exceeded [1].

Citations:


Constrain side-effectful mock expectations to .Once() in internal/purchase/manager_test.go (lines 739-756).

testify/mock expectations defined as On(...).Return(...) without .Once()/.Times(n) can match multiple times and still pass AssertExpectations, so this test can be weakened against double-execution. Add .Once() to the side-effectful calls (transition/purchase/redrive/email/storage) to enforce the “no double-redrive/no double-purchase” contract.

Proposed tightening
 mockStore.On("TransitionExecutionStatus", ctx, "exec-aws-persist-fail", []string{"approved"}, "running").
-		Return(&runningRow, nil)
+		Return(&runningRow, nil).Once()
 mockStore.On("GetPurchasePlan", ctx, "plan-aws-persist-456").Return(plan, nil).Once()
-mockStore.On("SavePurchaseHistory", ctx, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil)
-mockEmail.On("SendPurchaseConfirmation", ctx, mock.AnythingOfType("email.NotificationData")).Return(nil)
+mockStore.On("SavePurchaseHistory", ctx, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil).Once()
+mockEmail.On("SendPurchaseConfirmation", ctx, mock.AnythingOfType("email.NotificationData")).Return(nil).Once()
 // SavePurchaseExecution fails AFTER the CAS-to-running succeeded.
 mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).
-		Return(saveErr)
+		Return(saveErr).Once()

 mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).Return(&sts.GetCallerIdentityOutput{
 		Account: aws.String("123456789012"),
-	}, nil)
-mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProvider, nil)
-mockProvider.On("GetServiceClient", mock.Anything, common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
+	}, nil).Once()
+mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProvider, nil).Once()
+mockProvider.On("GetServiceClient", mock.Anything, common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil).Once()
 mockServiceClient.On("PurchaseCommitment", mock.Anything, mock.AnythingOfType("common.Recommendation"), mock.AnythingOfType("common.PurchaseOptions")).Return(common.PurchaseResult{
 		Success:      true,
 		CommitmentID: "ri-persist-fail-12345",
-	}, nil)
+	}, nil).Once()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
mockStore.On("TransitionExecutionStatus", ctx, "exec-aws-persist-fail", []string{"approved"}, "running").
Return(&runningRow, nil)
mockStore.On("GetPurchasePlan", ctx, "plan-aws-persist-456").Return(plan, nil).Once()
mockStore.On("SavePurchaseHistory", ctx, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil)
mockEmail.On("SendPurchaseConfirmation", ctx, mock.AnythingOfType("email.NotificationData")).Return(nil)
// SavePurchaseExecution fails AFTER the CAS-to-running succeeded.
mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).
Return(saveErr)
mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).Return(&sts.GetCallerIdentityOutput{
Account: aws.String("123456789012"),
}, nil)
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProvider, nil)
mockProvider.On("GetServiceClient", mock.Anything, common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil)
mockServiceClient.On("PurchaseCommitment", mock.Anything, mock.AnythingOfType("common.Recommendation"), mock.AnythingOfType("common.PurchaseOptions")).Return(common.PurchaseResult{
Success: true,
CommitmentID: "ri-persist-fail-12345",
}, nil)
mockStore.On("TransitionExecutionStatus", ctx, "exec-aws-persist-fail", []string{"approved"}, "running").
Return(&runningRow, nil).Once()
mockStore.On("GetPurchasePlan", ctx, "plan-aws-persist-456").Return(plan, nil).Once()
mockStore.On("SavePurchaseHistory", ctx, mock.AnythingOfType("*config.PurchaseHistoryRecord")).Return(nil).Once()
mockEmail.On("SendPurchaseConfirmation", ctx, mock.AnythingOfType("email.NotificationData")).Return(nil).Once()
// SavePurchaseExecution fails AFTER the CAS-to-running succeeded.
mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).
Return(saveErr).Once()
mockSTS.On("GetCallerIdentity", ctx, mock.AnythingOfType("*sts.GetCallerIdentityInput")).Return(&sts.GetCallerIdentityOutput{
Account: aws.String("123456789012"),
}, nil).Once()
mockFactory.On("CreateAndValidateProvider", mock.Anything, "aws", mock.Anything).Return(mockProvider, nil).Once()
mockProvider.On("GetServiceClient", mock.Anything, common.ServiceEC2, "us-east-1").Return(mockServiceClient, nil).Once()
mockServiceClient.On("PurchaseCommitment", mock.Anything, mock.AnythingOfType("common.Recommendation"), mock.AnythingOfType("common.PurchaseOptions")).Return(common.PurchaseResult{
Success: true,
CommitmentID: "ri-persist-fail-12345",
}, nil).Once()
🤖 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 `@internal/purchase/manager_test.go` around lines 739 - 756, The mocks for
side-effectful operations are not constrained to run exactly once, weakening the
test; update the expectations to include .Once() on the side-effectful calls:
add .Once() to mockStore.TransitionExecutionStatus(...),
mockStore.SavePurchaseHistory(...), mockEmail.SendPurchaseConfirmation(...),
mockStore.SavePurchaseExecution(...), and
mockServiceClient.PurchaseCommitment(...) so each of those expectations must be
invoked exactly once (leave non-side-effect mocks like GetPurchasePlan,
GetCallerIdentity, CreateAndValidateProvider, GetServiceClient as appropriate).

…only on success (CR #728 round 3)

The previous guard (`if execErr == nil`) only wrapped SavePurchaseExecution
failures as ErrAuditLoss when executePurchase had itself succeeded. When
executePurchase returned a provider error or partialPurchaseError AND
SavePurchaseExecution then failed, the save failure was silently discarded:
the row remained stranded in "running" with no terminal status persisted,
and claimAndRedrive returned (false, nil), hiding the problem.

Fix: always wrap SavePurchaseExecution failure as ErrAuditLoss. When
execErr != nil, chain the original error as the innermost %w so
errors.As/errors.Is callers can still reach it for diagnosis. When
execErr == nil, keep the existing two-verb wrap (ErrAuditLoss + save err).

Regression test: RecoverStrandedApprovals_AWSRedrive_ExecAndPersistBothFail
simulates GetPurchasePlan failing (execErr != nil) followed by
SavePurchaseExecution also failing. Asserts that the sweep surfaces a
non-nil error, that errors.Is(err, config.ErrAuditLoss) holds, and that
the original execution error remains reachable via errors.Is.
@cristim

cristim commented May 26, 2026

Copy link
Copy Markdown
Member Author

CR #728 round-3 fix: wrap ErrAuditLoss on every terminal save failure

What was wrong

The previous if execErr == nil guard in executeAndFinalize only wrapped SavePurchaseExecution failures as ErrAuditLoss when executePurchase itself had succeeded. The gap: if executePurchase returned a provider error or a partialPurchaseError and SavePurchaseExecution then also failed, the save failure was silently discarded. The row was left stranded in "running" with no terminal status persisted, and claimAndRedrive returned (false, nil), hiding the problem from the sweep.

Fix

internal/purchase/manager.go executeAndFinalize now wraps any SavePurchaseExecution failure as ErrAuditLoss regardless of execErr:

  • execErr != nil: fmt.Errorf("%w: terminal save failed (%v); original execution error: %w", config.ErrAuditLoss, err, execErr) — ErrAuditLoss sentinel chains forward, save error is human-readable, and the original execErr remains unwrappable via errors.As/errors.Is.
  • execErr == nil: unchanged fmt.Errorf("%w: %w", config.ErrAuditLoss, err).

The second SavePurchaseExecution call in safeFail (line ~295) does not need this treatment: TransitionExecutionStatus already committed the row to "failed" atomically before that call, so the terminal state is durable regardless of whether the explanatory error string persists. That call is an audit-gap (logged as AUDIT GAP:), not an audit-loss.

Regression test

TestManager_RecoverStrandedApprovals_AWSRedrive_ExecAndPersistBothFail exercises the exact scenario CR flagged:

  1. Stranded AWS-only execution, CAS claim succeeds (approved -> running)
  2. GetPurchasePlan fails -> executePurchase returns a non-nil error
  3. SavePurchaseExecution also fails -> DB row stays in "running"

Asserts:

  • RecoverStrandedApprovals returns a non-nil error (not silently dropped)
  • errors.Is(err, config.ErrAuditLoss) is true (sentinel present, claimAndRedrive will propagate it)
  • errors.Is(err, planErr) is true (original execution error remains reachable for diagnosis)
  • recovered count is 0

The pre-existing AWSRedrive_PersistenceFailurePropagates test covers the execErr == nil (purchase succeeded, save fails) path. The new test adds the execErr != nil (purchase failed, save also fails) path, closing the gap CR identified.

All 188 package tests pass.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

@cristim: The round-3 fix is correct and closes the gap cleanly. A few observations:

  1. The audit-loss / audit-gap distinction is the right framing. safeFail's second SavePurchaseExecution is explicitly excluded because TransitionExecutionStatus already durably committed "failed" before that call — the terminal state is safe regardless of the save. executeAndFinalize has no such prior atomic commit, so any SavePurchaseExecution failure there genuinely leaves the row in an unresolved "running" state, warranting ErrAuditLoss regardless of execErr.

  2. The dual-failure error format"%w: terminal save failed (%v); original execution error: %w" — wraps both the sentinel and execErr as traversable (errors.Is/errors.As work on both), while the save error appears as a human-readable string via %v. The save error itself not being programmatically traversable seems intentional: claimAndRedrive only needs to route on ErrAuditLoss, and execErr carries the diagnostic signal. Logs get the save error's text for observability.

  3. Test coverage is complete for the executeAndFinalize matrix:

    • execErr == nil, save fails → AWSRedrive_PersistenceFailurePropagates (pre-existing)
    • execErr != nil, save fails → AWSRedrive_ExecAndPersistBothFail (new)
    • execErr != nil, save succeeds → benign path, claimAndRedrive returns (false, nil) as before

Triggering the full review now.

✅ 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
internal/purchase/manager.go (1)

271-297: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Short-circuit ErrExecutionNotInExpectedStatus before the read-back probe.

TransitionExecutionStatus already does its own GetExecutionByID and wraps the benign CAS-loss case with config.ErrExecutionNotInExpectedStatus. Re-reading here can turn that race into a hard sweep error if the second read flakes or the row disappears after the CAS rejection. claimAndRedrive and internal/purchase/reaper.go already treat this sentinel as terminally benign.

Suggested fix
 	updated, txErr := m.config.TransitionExecutionStatus(ctx, exec.ExecutionID, []string{"approved"}, "failed")
 	if txErr != nil {
 		// ErrNotFound means the row vanished between the stale SELECT and
 		// this CAS attempt (e.g. deleted by an operator or a concurrent
 		// sweep already claimed and deleted it). That is a benign race-loss:
 		// there is nothing left to fail, and the caller should not be
 		// charged with an error.
 		if errors.Is(txErr, config.ErrNotFound) {
 			logging.Warnf("Skipping recovery of %s (row no longer exists, benign race-loss): %v", exec.ExecutionID, txErr)
 			return false, nil
 		}
+		if errors.Is(txErr, config.ErrExecutionNotInExpectedStatus) {
+			logging.Warnf("Skipping recovery of %s (already transitioned out of approved): %v", exec.ExecutionID, txErr)
+			return false, nil
+		}
+
 		// Distinguish benign races (row already left the "approved"
 		// state - concurrent sweep handled it, or the original run
 		// finished after the LIST snapshot) from real store
🤖 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 `@internal/purchase/manager.go` around lines 271 - 297,
TransitionExecutionStatus can return the sentinel
config.ErrExecutionNotInExpectedStatus for a benign CAS-loss; before performing
the expensive/read-back probe, check if txErr is errors.Is(txErr,
config.ErrExecutionNotInExpectedStatus) and treat it as a benign race by logging
and returning (false, nil) (same behavior as
claimAndRedrive/internal/purchase/reaper.go uses), only falling back to the
GetExecutionByID probe for other txErr values so you don't convert a benign CAS
rejection into a hard sweep error.
🤖 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.

Outside diff comments:
In `@internal/purchase/manager.go`:
- Around line 271-297: TransitionExecutionStatus can return the sentinel
config.ErrExecutionNotInExpectedStatus for a benign CAS-loss; before performing
the expensive/read-back probe, check if txErr is errors.Is(txErr,
config.ErrExecutionNotInExpectedStatus) and treat it as a benign race by logging
and returning (false, nil) (same behavior as
claimAndRedrive/internal/purchase/reaper.go uses), only falling back to the
GetExecutionByID probe for other txErr values so you don't convert a benign CAS
rejection into a hard sweep error.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e7916170-cd7d-4ed0-8729-05cd263288a9

📥 Commits

Reviewing files that changed from the base of the PR and between d74d26a and 15308ee.

📒 Files selected for processing (2)
  • internal/purchase/manager.go
  • internal/purchase/manager_test.go

…ail before read-back (CR #728 round 4)

TransitionExecutionStatus already wraps the benign CAS-loss case with
config.ErrExecutionNotInExpectedStatus when the row exists but its status
has moved out of "approved". Re-reading via GetExecutionByID after receiving
this sentinel is unnecessary: the sentinel itself already confirms the row
left the target state. The probe can turn the race into a hard sweep error
if the second read flakes or the row disappears between the CAS rejection
and the read.

Add a short-circuit returning (false, nil) for ErrExecutionNotInExpectedStatus
immediately after the existing ErrNotFound short-circuit, mirroring the
pattern already used by claimAndRedrive (manager.go:231) and the purchase
reaper (reaper.go:186), both of which treat this sentinel as terminally
benign.

Regression test: TestManager_RecoverStrandedApprovals_SafeFail_ErrExecutionNotInExpectedStatusIsBenign
asserts GetExecutionByID is never called and the sweep returns (0, nil).
@cristim

cristim commented May 26, 2026

Copy link
Copy Markdown
Member Author

CR round-4 fix: short-circuit ErrExecutionNotInExpectedStatus in safeFail before the read-back probe

Commit: 418c20b

What changed

internal/purchase/manager.go (safeFail): added a second early-return immediately after the existing ErrNotFound short-circuit:

if errors.Is(txErr, config.ErrExecutionNotInExpectedStatus) {
    logging.Warnf("Skipping recovery of %s (row already left approved state, benign CAS race-loss): %v", exec.ExecutionID, txErr)
    return false, nil
}

Why

TransitionExecutionStatus already wraps the benign CAS-loss scenario in config.ErrExecutionNotInExpectedStatus when the row exists but its status has moved out of \"approved\". The sentinel alone is sufficient to classify the outcome: nothing left for safeFail to do. Reaching GetExecutionByID after receiving it is a redundant round-trip that can turn the benign race into a hard sweep error if the second read flakes or the row disappears between the CAS rejection and the probe.

Symmetry with existing callers

Both existing callers already treat this sentinel identically:

  • claimAndRedrive (manager.go:231): errors.Is(claimErr, config.ErrNotFound) || errors.Is(claimErr, config.ErrExecutionNotInExpectedStatus) -> return false, nil
  • purchase reaper.go:186: same two-sentinel guard -> log INFO + result.RaceLost++ + return

safeFail now matches that pattern.

Regression test

TestManager_RecoverStrandedApprovals_SafeFail_ErrExecutionNotInExpectedStatusIsBenign mocks TransitionExecutionStatus to return config.ErrExecutionNotInExpectedStatus, then asserts:

  • GetExecutionByID is never called (mockStore.AssertNotCalled)
  • SavePurchaseExecution is never called
  • RecoverStrandedApprovals returns (0, nil)

All 189 tests in ./internal/purchase/... pass.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 2026

Copy link
Copy Markdown
Contributor

@cristim: The round-4 fix is correct and closes the last asymmetry in safeFail's error-handling matrix cleanly.

What's right about it:

  1. Classification is complete at the sentinel boundary. ErrExecutionNotInExpectedStatus from TransitionExecutionStatus already fully encodes "row exists, status is not approved" — a concurrent sweep (AWS re-drive or another safe-fail sweep) already claimed or resolved it. There is nothing left for safeFail to learn from GetExecutionByID; the probe was pure overhead that could race against a subsequent delete/update.

  2. Two-sentinel symmetry is now consistent across all three callers:

    • claimAndRedrive: ErrNotFound || ErrExecutionNotInExpectedStatusreturn false, nil
    • reaper.go: same two-sentinel guard → result.RaceLost++ + return
    • safeFail (after this fix): same two-sentinel guard → return false, nil

    Having three callers handle these sentinels inconsistently was a latent hazard; this brings safeFail in line without touching the other two.

  3. Test coverage is complete for safeFail's error matrix:

    • ErrNotFound → benign, no probe (SafeFail_ErrNotFoundIsBenign, pre-existing)
    • ErrExecutionNotInExpectedStatus → benign, no probe (SafeFail_ErrExecutionNotInExpectedStatusIsBenign, new)
    • Real store error → propagates (existing path)

    mockStore.AssertNotCalled on GetExecutionByID and SavePurchaseExecution is the right enforcement mechanism here.

Triggering the full review now.

✅ 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 26, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 26, 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.

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

Labels

effort/s Hours 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