Skip to content

fix(purchase): refuse to execute armed pre-#1668 retry successors (#1718) - #1721

Merged
cristim merged 1 commit into
mainfrom
fix/1718-executor-redrive-gate
Aug 8, 2026
Merged

cristim merged 1 commit into
mainfrom
fix/1718-executor-redrive-gate

Conversation

@cristim

@cristim cristim commented Aug 5, 2026 •

Copy link
Copy Markdown
Member

The gap

#1668 gates the user-facing Retry at creation time, so no new Azure savings-plans successor can be built. A creation-time gate cannot reach successors a pre-#1668 retry already created. Those rows sit in pending / notified / approved / scheduled with retry_attempt_n > 0 and buy a second, non-cancelable savings plan the moment they are approved.

The fix

Refuse at the executor, on the conjunction:

RetryAttemptN > 0 && RedriveRefusalReason(exec) != ""

Both halves are load-bearing. RetryAttemptN > 0 restricts the refusal to retry successors, so a first savings-plan purchase still goes through; re-drive safety alone would block every legitimate first buy. RedriveRefusalReason reuses the predicate #1668 introduced rather than adding a second notion of re-drive safety.

There are four executors, not two

The issue named claimAndExecute and ApproveAndExecute. Enumerating every caller of executeAndFinalize gives four:

Executor Reaches an armed row? Status
claimAndExecute (manager.go) — SQS execute_purchase + cron sweep yes now gated
ApproveAndExecute (approvals.go) — token + session approve yes now gated
fireOneDue (scheduled_fire.go) — delayed-approval scheduler sweep yes, and it is the most likely path now gated
claimAndRedrive (manager.go) — reaper re-drive no already gated by allRecsSafeToRedrive, which subsumes this condition, so the new check cannot fire there

fireOneDue matters because approveWithDelay (internal/api/handler_purchases.go) transitions an approved row pending → scheduled and returns; the row fires later from the scheduler sweep without passing through ApproveAndExecute at all. Gating only the two named executors would have left that path open for exactly the rows this issue is about.

So the check goes in executeAndFinalize, the single funnel all four reach money through: executePurchase has exactly one production caller, immediately below the check.

Why the funnel, and please do not "simplify" this back out to the call sites. The placement is not a style preference. This issue was written naming two executors when there are four, and the one it missed is the one an approved row is most likely to take. That is direct evidence that per-call-site duplication is the fragile choice here: enumerating executors correctly is demonstrably easy to get wrong, and a copy-per-call-site gate is only as good as the most recent enumeration. A gate at the chokepoint cannot be missed by a fifth executor added later; three copies at three call sites can.

The refused row is defused, not just skipped

Refusing with an error rather than skipping means finalizeExecution stamps the row failed with the reason and executeAndFinalize persists it, so an armed row leaves the executable set and is visible in History instead of staying armed for the next sweep. Retrying it from the UI then hits the #1668 gate, which refuses, so it cannot be re-armed either.

Tests: fail before, pass after

Each test drives a real executor entry point and asserts on the provider call count, not on statuses.

Against current main (fix reverted, tests kept)

--- FAIL: TestApproveAndExecuteRefusesArmedAzureSavingsPlanRetry
        approving an armed Azure savings-plans retry successor must buy NOTHING;
        one call here is a second, non-cancelable savings plan (issue LeanerCloud/cloud-commitments-cli#1718)
        An error is expected but got nil.
--- FAIL: TestClaimAndExecuteRefusesArmedAzureSavingsPlanRetry
        the SQS/cron executor must not buy a second savings plan for an armed retry successor
--- FAIL: TestFireScheduledDelayedPurchasesRefusesArmedAzureSavingsPlanRetry
        the delayed-approval scheduler must not buy a second savings plan for an armed retry successor
--- PASS: TestFirstAzureSavingsPlanPurchaseStillExecutes
--- PASS: TestSafeProviderRetrySuccessorStillExecutes
--- FAIL: TestArmedRowIsDefusedNotLeftArmed

The two negative controls pass before the fix, as they must: they exist to catch a fix that is too broad.

After

$ go test ./internal/purchase/ -run 'TestApproveAndExecuteRefuses|TestClaimAndExecuteRefuses|TestFireScheduled|TestFirstAzure|TestSafeProvider|TestArmedRow' -count=1
ok  github.com/LeanerCloud/CUDly/internal/purchase

The negative controls are not filler — they are half the proof

TestFirstAzureSavingsPlanPurchaseStillExecutes (retry_attempt_n = 0) and TestSafeProviderRetrySuccessorStillExecutes (an AWS retry successor) each assert the purchase still fires exactly once. They are as load-bearing as the refusals, and they are the reason the RetryAttemptN > 0 half of the conjunction is testable at all.

A gate that simply blocked every Azure savings-plans purchase — including every legitimate first buy — would pass all four refusal tests above. Only these two catch it. They also pass against unpatched main, by design: their job is to fail if a future change makes the guard too broad, not to demonstrate the bug.

The ops query in #1718 was wrong, and I have corrected it

Column names all check out against the schema (execution_id, plan_id, status, retry_attempt_n, scheduled_date, recommendations JSONB). The predicates were wrong in three ways, two of them safety-critical.

I did not just eyeball it: I stood up a throwaway Postgres 16, recreated the table from migrations/000001 plus the retry columns, seeded rows for each case, and ran both queries. Original:

  • misses status = 'scheduled' — the delayed-approval window, i.e. precisely the rows sitting armed in the fireOneDue path
  • misses the savings-plans spelling — ILIKE '%savingsplans%' does not match the hyphenated form, which is equally unsafe and dispatches to the same Azure client
  • false-positives on AWS savings plans, which are safe to re-drive via ClientToken

Measured: the original returned 5 rows, missing 2 genuinely armed ones and including 1 that is not armed. The corrected query returned all 6 armed rows and nothing else.

SELECT execution_id, plan_id, status, retry_attempt_n, scheduled_date, scheduled_execution_at
FROM purchase_executions
WHERE retry_attempt_n > 0
  AND status IN ('pending','notified','approved','scheduled')
  AND EXISTS (
        SELECT 1
        FROM jsonb_array_elements(recommendations) AS r
        WHERE r->>'provider' = 'azure'
          AND r->>'service' IN ('savingsplans','savings-plans')
      )
ORDER BY scheduled_execution_at NULLS LAST, scheduled_date;

Posted on #1718 as a comment so the original reasoning stays intact.

Gates

Gate Result
go build ./... / go vet ./... clean
go test ./... -count=1 6673 passed, 42 packages
golangci-lint run ./... at CI-pinned v2.10.1 exit 0, 0 issues.
gocyclo -over 10 -ignore "_test\.go" exit 0
gofmt -l internal/ clean

Closes #1718

)

#1668 gates the user-facing Retry at creation time, so no new Azure
savings-plans successor can be built. A creation-time gate cannot reach
successors a pre-#1668 retry already created. Those rows sit in
pending/notified/approved/scheduled with retry_attempt_n > 0 and buy a
second, non-cancelable savings plan the moment they are approved.

Refuse them at the executor, on the conjunction:

    RetryAttemptN > 0 && RedriveRefusalReason(exec) != ""

Both halves are load-bearing. RetryAttemptN > 0 restricts the refusal to
retry successors, so a first savings-plan purchase still goes through;
re-drive safety alone would block every legitimate first buy.
RedriveRefusalReason reuses the predicate #1668 introduced rather than
adding a second notion of re-drive safety.

The check goes in executeAndFinalize rather than at the individual
executors. That function is the single funnel every executor reaches money
through, and executePurchase has exactly one production caller immediately
below the check. The issue named two executors, claimAndExecute and
ApproveAndExecute, but there are four: fireOneDue also reaches it, and
that is the path an approved successor is most likely to take, because
approveWithDelay transitions a row pending -> scheduled and returns, so the
row fires later from the scheduler sweep without passing through
ApproveAndExecute at all. Gating only the two named executors would have
left that path open for exactly the rows this issue is about. The fourth
caller, claimAndRedrive, is already gated by allRecsSafeToRedrive, which
subsumes this condition, so the new check cannot fire there.

Refusing with an error rather than skipping means finalizeExecution stamps
the row failed with the reason and executeAndFinalize persists it, so an
armed row is defused and visible in History instead of staying armed for
the next sweep. Retrying it from the UI then hits the #1668 gate, which
refuses, so it cannot be re-armed either.

Regression coverage drives each executor entry point and asserts on the
provider call count rather than on statuses: approve, SQS/cron, and the
delayed-approval scheduler each buy nothing for an armed row, and the
refused row lands in failed with the reason stored. Two negative controls
keep the guard narrow and are as load-bearing as the refusals: a first
Azure savings-plans purchase (retry_attempt_n = 0) and an AWS retry
successor must both still buy exactly once. Without them a gate that
blocked every savings-plan purchase would pass every refusal test.

Closes #1718
@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/critical Major harm when it happens urgency/now Drop other things impact/few Limited audience effort/s Hours type/bug Defect labels Aug 5, 2026
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 22 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6ec16d83-a48f-4d87-b757-17cb3dceb635

📥 Commits

Reviewing files that changed from the base of the PR and between 85a0b4b and 7f94616.

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

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

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Adversarial review — independent, against the committed diff at 7f94616

I did not trust the PR body. Every claim below was re-derived from the tree, and the guard's coverage was verified by mutation rather than by reading. Gates were re-run locally, not taken from the description.

Verdict: the fix is correct, minimal, and the tests genuinely pin it. Four findings, none blocking the code; one blocks the merge gate.


Gates I ran myself

Gate Result
go build ./... exit 0
go vet ./... exit 0
go test ./... -count=1 6673 passed, 42 packages
go test ./internal/purchase -race -count=1 300 passed
golangci-lint run ./... at CI-pinned v2.10.1 (not the newer local 2.11.4) 0 issues.
gocyclo -over 10 -ignore "_test\.go" . exit 0, no output
gofmt -l internal/ clean
merge against current origin/main (148c0cad0, 4 ahead) clean; no Go file overlap

Claim-by-claim

1. "There are four executors, not two" — holds, and the funnel is genuinely complete.

Every production caller of executeAndFinalize: approvals.go:352 (ApproveAndExecute), manager.go:193 (claimAndExecute), scheduled_fire.go:100 (fireOneDue), manager.go:427 (claimAndRedrive). Four.

I also checked the stronger question — does anything reach a provider purchase without passing through executeAndFinalize — rather than only counting callers. PurchaseCommitment has exactly two call sites in the tree: internal/purchase/execution.go:1086, reachable only under m.executePurchase, whose sole production caller is manager.go:225 immediately below the new check; and cmd/multi_service_helpers.go:247, the standalone CLI, which never reads purchase_executions and so cannot see a retry successor. ApproveExecution (approvals.go:55) and directExecutePurchase (handler_purchases.go:2768) both delegate to ApproveAndExecute and are covered transitively. The multi-account fan-out inherits RetryAttemptN by struct copy (execution.go:~225) but is unreachable once the root is refused.

claimAndRedrive really is unreachable for this check: RecoverStrandedApprovals calls it only under allRecsSafeToRedrive(exec) (manager.go:554), which requires RedriveRefusalReason(exec) == "" — the exact negation of the second conjunct.

2. The fireOneDue argument is load-bearing and correct. approveWithDelay is invoked from both approval routes (handler_purchases.go:613 token, :713 session); it calls scheduleApprovedExecution, which CASes pending|notified -> scheduled, stamps ScheduledExecutionAt, and returns a response map — no SDK call, no ApproveAndExecute. fireOneDue later CASes scheduled -> approved and calls executeAndFinalize. With the pre-fire delay configured, gating only the two named executors would indeed have left the primary approval route open.

3. Defusal composes with #1713, which is on main. The refusal error hits finalizeExecution's default: branch (manager.go:157-159) → Status = "failed", Error = <reason>, then persisted by executeAndFinalize. Retrying that row from the UI hits checkRetryEligibilityGates (internal/api/handler_purchases.go:1757), which refuses on RedriveRefusalReason before the threshold gate and which ?force=true explicitly does not override. The row cannot be re-armed. Verified against merged main, not against the PR's description of it.


The marker itself is durable — checked because the guard is worthless if it can be silently disarmed

This is the failure mode the PR body does not claim and does not need to, but it is where a gate like this usually dies, so I verified it:

  • retry_attempt_n is INTEGER NOT NULL DEFAULT 0 (migrations/000042, re-asserted idempotently by 000044 step 4).
  • It is deliberately INSERT-only in SavePurchaseExecution: present in the INSERT column list but omitted from ON CONFLICT DO UPDATE SET (store_postgres.go:910-931, with the rationale comment at ~945). No upsert of a partially-populated struct can zero it.
  • Every projection that materialises a PurchaseExecution includes it. I enumerated all 14 FROM purchase_executions / RETURNING sites; the only three without it are CountExecutionsByPlanAndStatus, CountPendingExecutionsForAccount and ListPendingExecutionIDsForAccount, which return counts/IDs, never structs. The load-bearing one is the CAS RETURNING at store_postgres.go:1003-1010 — all three newly-gated executors overwrite their struct with the CAS result before executeAndFinalize, so a missing column there would have made the guard blind while every test stayed green. It is present.
  • No retry successor can pre-date the column: the retry feature and 000042 landed together (feat(history): inline Retry button for failed purchase rows #47), and the handler always stamps RetryAttemptN = failedExec.RetryAttemptN + 1 (handler_purchases.go:1902). There is no legacy class with retry_attempt_n = 0 that the guard would miss.

Mutation testing — the tests fail for the right reasons

Run in a throwaway worktree at this PR's head; each mutation applied alone, tests unmodified.

Mutation Result
M1 — remove the guard from executeAndFinalize (revert to execErr := m.executePurchase(...)) 4 refusal tests FAIL; both negative controls PASS
M2 — delete only the if exec.RetryAttemptN <= 0 { return nil } term TestFirstAzureSavingsPlanPurchaseStillExecutes FAILs alone
M3 — delete only the RedriveRefusalReason term (refuse every retry) TestSafeProviderRetrySuccessorStillExecutes FAILs
M4 — drop the hyphenated "savings-plans" spelling from recRedriveRefusalReason TestFireScheduledDelayedPurchasesRefusesArmedAzureSavingsPlanRetry FAILs (plus the two #1713 dispatch-parity guards)

M2 and M3 are the ones that matter: each half of the conjunction has a test that dies when it is removed, so the "both halves are load-bearing" claim is demonstrated, not asserted. M4 confirms the hyphenated spelling in the fireOneDue test is deliberate coverage rather than decoration.

On vacuity: these are not MockConfigStore expectation assertions. The counter is incremented from a real Run callback on MockServiceClient.PurchaseCommitment, and the two negative controls reaching count == 1 on identical harness wiring prove the path actually reaches the provider. The 0 in the refusal tests is a measured zero, not an unreached one.


Findings

F1 — merge gate, not code: CodeRabbit has never reviewed this PR

The only CodeRabbit artifact is the comment at 2026-08-05T09:47:59Z, and its body is a "Review limit reached" rate-limit warning (Run ID ef653709-..., "Next review available in: 35 minutes"). reviewThreads is empty. Zero unresolved threads here means not reviewed, not clean — the exact false-clean this repo has hit before. The CI rollup showing a green CodeRabbit status does not contradict this; that status is unreliable in both directions.

Recovery must be @coderabbitai full review, not the incremental @coderabbitai review — a throttled incremental pass silently skips the in-flight commits and yields a second false clean.

F2 — minor, test strength: TestClaimAndExecuteRefusesArmedAzureSavingsPlanRetry cannot tell refusal from non-arrival

internal/purchase/armed_redrive_test.go:158-170 discards the return value (_ = mgr.ProcessMessage(...)) and asserts only rec.count() == 0, and no store.AssertExpectations(t) runs, so the .Once() in expectClaim is never enforced. If a future change made claimAndExecute bail before executeAndFinalize — a stricter checkAutoExecuteGate, or CAS-argument drift making the mock return an error — this test would still pass while proving nothing.

Its siblings do not have this gap: the approve test pins the reason (:152, assert.Contains(err, "refusing to execute retry attempt 1")) and the scheduler test pins result.Errored == 1 (:194). Suggest asserting on the returned error text, or adding store.AssertExpectations(t). It is live today (M1 kills it), so this is hardening, not a defect.

F3 — minor, ops doc: the corrected #1718 query is narrower than the guard

For #1718's stated scope the corrected query is exact — I checked the status set, both service spellings, the provider filter and the JSON keys (provider / service, config/types.go:447-448) against recRedriveRefusalReason, and they agree.

But recRedriveRefusalReason's default: branch (manager.go:388) also refuses any unrecognized provider, while the query filters r->>'provider' = 'azure'. A retry_attempt_n > 0 row carrying an unknown or typo'd provider will be refused by the executor and will not appear in the ops list. Worth one sentence in the issue saying the query enumerates the Azure-SP subset rather than the guard's full refusal set, so nobody reads "0 rows" as "the guard will never fire".

F4 — informational: two behaviours the PR body does not state


Proportionality

8 lines of guard, 43 of comment, one reused predicate. No new mode, enum, flag or config knob; RedriveRefusalReason remains the single source of truth for re-drive safety, shared with #1713's creation-time gate and the reaper. The funnel placement is justified by evidence (the issue's own undercount), not by preference. Nothing here needs simplifying.

Nothing in F2-F4 blocks merge. F1 does: this needs a real CodeRabbit pass via @coderabbitai full review before it goes in.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

@cristim Thank you for the independent review and the mutation evidence.

I will run a full review of PR #1721. I will assess the committed diff and the reported test-strength gap in TestClaimAndExecuteRefusesArmedAzureSavingsPlanRetry.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 23 minutes.

@cristim

cristim commented Aug 8, 2026

Copy link
Copy Markdown
Member Author

Requesting a review. CodeRabbit has never reviewed this PR — its single comment here (2026-08-05T09:47:59Z) is a "Review limit reached" Fair Usage warning, not a review, and reviewThreads is empty. Zero unresolved threads on this PR means not reviewed, not clean, and the green CodeRabbit entry in the checks rollup does not contradict that.

Using the full form deliberately: this head was pushed while the quota was exhausted, so its automatic review never ran and is not retried retroactively. An incremental request would skip exactly those commits and produce a second false clean on a diff it never read.

Context for the review: this is a money path. It refuses execution of Azure savings-plan retry successors that a pre-#1668 retry already armed, gating at executeAndFinalize on RetryAttemptN > 0 && RedriveRefusalReason(exec) != "".

An independent adversarial review is already posted above and came back clean, including a mutation matrix where each half of the conjunction has a test that dies when that half is removed, and a check that the retry_attempt_n marker is durable (NOT NULL DEFAULT 0, INSERT-only, present in all 14 read sites including the CAS RETURNING the executors overwrite from).

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Aug 8, 2026 •

Copy link
Copy Markdown
Contributor

@cristim I will run a full review of PR #1721. The review will assess the complete committed diff, including the executor guard and its test coverage.

⚠️ Action not completed

Review rate limited.


Your included review limit is currently reached under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. This review may still proceed through usage-based billing if eligible. Your next included review will be available in 22 minutes.

@cristim
cristim merged commit 40800b8 into main Aug 8, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/few Limited audience priority/p1 Next up; this sprint severity/critical Major harm when it happens triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(purchase): pre-#1713 retry successors can still buy a second Azure savings plan on approval

1 participant