Repository navigation
fix(purchase): re-drive the claimed row, not the pre-claim snapshot - #1095
Conversation
RecoverStrandedApprovals picks the in-place re-drive path from a SELECT, then claimAndRedrive claims the row (approved -> running) and bought the snapshot it was handed, copying only the claimed status onto it. An edit or a concurrent writer landing between the SELECT and the CAS (new recommendations, amount or account) was bought unchecked, and the terminal save then wrote the stale snapshot back over the row. claimAndRedrive now carries the whole claimed row and re-runs the re-drive gate (redrivable: every rec safe to re-drive, durable ExecutionID) on it before any provider call. A claimed row that no longer qualifies is failed the way safeFail fails a listed row in that state: the same recovery message, guarded on "running", and no no_submission stamp or suppression release, since the interrupted original run may already have bought. A failed terminal save surfaces as ErrAuditLoss. Closes LeanerCloud#1037 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Warning Review limit reached
This review includes 4 billable files and costs up to $1.00. Or wait 58 minutes for your next included review. View limit details
Comment |
|
VERDICT: APPROVE 1372642 Reviewed the full diff of #1095 (one commit) against origin/main 56e226a. The base is 8caed39, 39 commits behind. Findings (none blocking):
Checked and correct:
Local results (merged tree, macOS):
Attacked (mutation probes in a scratch copy, reverted after each):
|
|
Independent review 2 (claude-fleet, cold): APPROVE 1372642 Findings (none blocking):
Attacked:
Verified locally, on
Fail-before: both new test files on base fail as the PR body describes:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
Local verification at
|
|
Marked ready and merged by IAmJSD's agent, at IAmJSD's request, after 2 independent reviews and local verification (see the review and verification comments above, all at |
Resolve the conflict in internal/purchase/notifications.go with LeanerCloud#1176: failExistingPlanStep now runs the pinned CAS, reason stamp and suppression release in one transaction, so the LeanerCloud#1195 failure alert moves after that transaction commits. A lost CAS or any error before commit leaves the row pending and sends nothing; the unit table now expects no alert for a rolled-back reason save or suppression release. Alert from failClaimedRedrive (added by LeanerCloud#1095): the sweep that holds the running claim reports the failed row once, even when its terminal save fails, like failClaimedBeforeSubmission. The LeanerCloud#1095 integration test now expects exactly that one purchase-failed email. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve conflicts with LeanerCloud#1095 and LeanerCloud#1176: - internal/purchase/manager.go (safeFail): keep main's strandedRecoveryError(exec), which has the same text, and this branch's stamp guarded on "failed". - internal/purchase/notifications.go (failExistingPlanStep): keep main's single-transaction failPlanStepTx (pinned CAS, stamp and suppression release commit together) and guard its save on "failed" with SaveExecutionIfStatusTx. The CAS's row lock already keeps other writers out, so the guard is defense in depth. - Drop the failExistingPlanStep subtest of TestPostCASSave_SimulatedWriterSurvives. Its replayed writer can no longer land between the CAS and the save, and LeanerCloud#1176's plan_step_fail_atomic_integration_test.go covers this site's concurrent writers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Root cause
RecoverStrandedApprovalslists staleapprovedrows with aSELECT(GetStaleApprovedExecutions), picks the in-place re-drive path from that snapshot (allRecsSafeToRedrive && ExecutionID != ""), then callsclaimAndRedrive. That claims the row (TransitionExecutionStatus approved -> running ... RETURNING), but only copiedclaimed.Statusonto the snapshot and passed the snapshot toexecuteAndFinalize. The claim isn't pinned to the snapshot, so if an edit or another writer changed the row's recommendations, amount or account between theSELECTand the CAS:The re-drive safety gate also only ever saw the snapshot. A row edited from RDS (safe) to EC2 (no provider duplicate guard) was still re-driven.
Confirmed on current main (
8caed39c, which includes #1036). #1036 closed the same check-then-buy gap on the user-triggered paths; the re-drive path doesn't go throughtransitionApproveAndExecute, so #1036 didn't cover it. (git log --grep '#1037'matches4a56a2dc, but that's an older, unrelated PR-number collision about idempotency keys and doesn't touch this path.)Fix
The issue offered two options. This PR takes "re-drive executes the row as returned by the claim", following the pattern
claimAndExecutealready uses:claimAndRedrivenow copies the whole claimed row (*exec = *claimed), not just its status. The purchase, the pre-execution refusals (preExecutionRefusal) and the terminal save all run on the claimed row.redrivable(exec): every rec safe to re-drive and a durableExecutionID. It runs on the listed snapshot to pick the path, as before, and again on the claimed row before any provider call.failClaimedRedrivefails it with the resultsafeFailgives a row listed in that state:status = failed, with the same operator message (strandedRecoveryError, now shared withsafeFail)running, the status this run's claim holdsno_submissionstamp and no suppression release. This is deliberately not abeforeSubmissionrefusal: the interrupted original run may already have bought.safeFail. A failed terminal save returnsErrAuditLoss, like the existing re-drive persistence failure.New helpers are in
internal/purchase/stranded_redrive.go.manager.gowas already over the 500-line guideline, so this keeps its growth to doc comments.Verification
All with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null: this machine's~/.aws/configdefault profile is invalid, and nothing here needs AWS config.Unit tests (mock store, mock provider),
internal/purchase/redrive_claimed_row_test.go:TestRecoverStrandedApprovals_RedrivesClaimedRowNotSnapshot: listed 1 instance at $200, claimed row repriced to 7 at $1,400. The provider must be asked for 7, and the saved row must keep 7 and $1,400.TestRecoverStrandedApprovals_ClaimedRowNoLongerRedrivableIsFailed(claimed rec changed to EC2; recs emptied): no provider call, rowfailedwithno_submission=false, save guarded onrunning, and the recovery message with the right next step.TestRecoverStrandedApprovals_ClaimedRowFailSaveErrorIsAuditLossIntegration tests (
integrationtag, real Postgres via testcontainers, real store and Manager, counting fake provider; no cloud API),internal/purchase/redrive_claimed_row_integration_test.go:TestRecoverStrandedApprovals_ConcurrentSweepsBuyClaimedRowOnce: 4 concurrent sweeps all list the row. A barrier holds them until the row is repriced from 1 to 5 instances through the production upsert, then they race the claim. Result: exactly one provider call, for 5, and the row iscompletedwith 5.TestRecoverStrandedApprovals_ClaimedRowTurnedUnsafeIsFailedNotBought: after the listing, the RDS rec is edited to EC2. Zero provider calls; the row isfailedwithno_submission=false, keeps the EC2 rec, and carries the "start a new purchase" message.GetStaleApprovedExecutionswith a zero age instead of 15 minutes, because the fixture row was written moments earlier. Every other store call is the production one.Fail before / pass after (production
manager.goreverted to8caed39c, tests unchanged):Mutation probe: keep
*exec = *claimedbut drop the post-claimredrivablecheck, and bothClaimedRowNoLongerRedrivableIsFailedsubtests fail.Commands run:
Real-cloud coverage gap: nothing here touched a real provider. Re-drive idempotency against real AWS, Azure and GCP is unchanged and still covered only by the existing provider-level tests.
Out of scope / for the reviewer
staleApprovedThreshold. Pinning toupdated_atwould needupdated_atonPurchaseExecutionand in the sharedscanExecutionRowscolumn list, which all 14 execution queries use. That's a much wider change than this issue needs, and executing the claimed row already closes the check-then-buy gap. Happy to add a pin if you'd rather refuse any change.preExecutionRefusal's stale-Azure-pricing and ladder refusals arebeforeSubmissionerrors on every executor path, including this re-drive. On a stranded row, where the interrupted run may have submitted, a refusal there stampsno_submissionand releases plan suppressions. Worth a separate issue if you agree.Maintainer: please mirror the issue's triage labels onto this PR:
priority/p2,severity/medium,urgency/this-quarter,impact/few,effort/s,type/security,triaged.Closes #1037
🤖 Generated with Claude Code