Repository navigation
fix(api/purchases): guard the email-failure write in finalizePurchaseStatus on pending - #1237
Conversation
…Status on pending finalizePurchaseStatus stamped "failed" over a freshly inserted execution with an unguarded full-row save whenever the approval email could not be sent. A session approve (pending -> running claim), cancel or pause can land while the email is in flight; the unguarded write then overwrote the row, so a commitment that was being bought ended up "failed" and eligible for retry, risking a second purchase. The write now goes through SaveExecutionIfStatus with "pending" as the only expected status (row lock + status check + write in one tx). A lost guard is a no-op with a warning: the row keeps the other writer's status, the caller's in-memory copy is left untouched, and the response reports the row's re-read status instead of a stale "failed". Other save errors keep the existing best-effort "pending" contract. Tests: mock unit tests pin the guarded call and the lost-guard behavior; integration tests on real Postgres drive the real send path with an approve claim landing during a blocked email send, and race the failed write against the approve claim for 40 rounds. Refs LeanerCloud#1232 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. Walkthrough
Merge Risk:
|
Brings in main's CI fixes (LeanerCloud#1236, LeanerCloud#1252) and CI changes since, so this PR gets a fresh CI run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Independent review 2 (claude-fleet, cold): APPROVE 3086bc8 Findings (none blocking):
The four questions:
Mutation probes on a scratch copy, each restored and confirmed identical with
Fail-before, on base: Verified locally, with GOTOOLCHAIN=go1.26.10 and no cloud credentials, under the heavy lock:
Reviewed by an independent agent with no author context (second reviewer). Review only: nothing was pushed, and the GitHub review state is unchanged. |
|
VERDICT: REQUEST_CHANGES 3086bc8 Merge check: merge-tree vs origin/main 2ded648 is clean (tree 2993141d); go build ./... and go vet -tags integration ./internal/api/ pass on the merged tree. Findings:
Attacked:
|
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: REQUEST_CHANGES Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rA3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Body updated for the review at |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Supersedes my earlier REQUEST_CHANGES on this same head ( Blocking:
Non-blocking:
Attacked (from my earlier review of this sha, still valid since the code is unchanged):
Body-stale:
Reviewer: rA3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at
|
|
Marked ready and merged by the owner's agent after 2 independent reviews and local verification (see the review and verification comments above, all at |
This branch carried LeanerCloud#1237's commit (17654cc), which main now has as the squash a59740d. Conflicts in handler_purchases.go and the two finalize test files were resolved by taking main's side for LeanerCloud#1237's changes and re-applying only this PR's own commit (91827b9) on top, so the PR diff against main is exactly that commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Fixes item 1 of #1232: the unguarded
failedwrite infinalizePurchaseStatus.Refs #1232
This PR fixes only item 1 of #1232, so #1232 stays open. Item 2 (legacy EC2 executions without Details leaving a variant unsuppressed after a retry) is in the code #1110 added (
SuppressionVariant/suppressionBucketOfininternal/config/suppressions_build.go). #1110 merged on 2026-10-23, so that gap is now on main. It needs its own small fix against main (treat an EC2 rec with empty Details asunknownVariant, which makes its row a 6-tuple wildcard), as the #1110 review suggested. It's tracked as a follow-up and isn't part of this PR.Root cause
finalizePurchaseStatus(internal/api/handler_purchases.go) runs after the approval email send on both the web submit path (executePurchase) and the retry path (retryPurchase). When the send failed it setexecution.Status = "failed"on the in-memory copy and wrote it withSavePurchaseExecution, a full-row upsert with no status guard. A session approve claims the row with a pending/notified to running CAS before calling the provider. If that claim landed while the email was in flight, the unguarded write overwroterunningwithfailed. The commitment was being bought, but the row became retryable, so a retry could buy it again. The same applies to a cancel or pause landing during the send.Fix
SaveExecutionIfStatus(ctx, &failed, []string{"pending"}). It takes aFOR UPDATErow lock, checks the status and writes in one transaction, so it serializes with the approve CAS.ErrExecutionNotInExpectedStatus) is a no-op with a warning log. The row keeps the other writer's status. The caller's in-memory copy is not mutated, because the write uses a copy that is only applied back on success. The responsestatusis the row's re-read status (for examplerunning) rather than a stalefailed. If the re-read fails, it falls back topending.pending.execution_statusvalues are unchanged and there is no migration.Verification
All commands were run from the repo root on macOS. Tests ran with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null, because this machine's default AWS profile is invalid.New tests:
internal/api/handler_purchases_finalize_test.go(unit, mock store). These pin the guarded call with[]string{"pending"}and check that a lost guard returns the re-read status (approved/running/completed/canceled/paused) without mutating the caller's copy or callingSavePurchaseExecution. They also cover the re-read failure fallback, other save errors, and the email-sent path writing nothing.internal/api/handler_purchases_finalize_race_integration_test.go(integration, real Postgres via testcontainers):TestFinalizePurchaseStatus_ApproveDuringEmailSenddrives the realpersistExecutionAndSuppressions, thensendPurchaseApprovalEmailwith a notifier that blocks. While the send is blocked, the test runs the approve claim CAS (pending to running), then fails the send and runsfinalizePurchaseStatus. It asserts the row staysrunningwith no error text and that the response reportsrunning.TestFinalizePurchaseStatus_ConcurrentWithApproveClaimraces the failed write against the approve claim for 40 rounds. Exactly one may win each round: either the claim won and the row isrunning, or the claim gotErrExecutionNotInExpectedStatusand the row isfailed.Fail-before / pass-after (fix reverted with
git stash push internal/api/handler_purchases.go):Other checks:
Evidence is from unit tests (mock store) and local integration tests against real Postgres 16 in testcontainers. Real SES sends and a real cloud purchase were not exercised. The email failure is simulated by a blocking stub notifier, and the approve is the same
TransitionExecutionStatusclaim CAS thatpurchase.Manager.claimRunninguses.Out of scope
Frontend follow-up for the UI owner
None required. On the rare lost-guard path the submit and retry responses can now carry
status: "running"(orapproved,canceled,paused, ...) together withemail_sent: false, where before they carriedfailed. The UI should already treat unknown or non-failed statuses as "see History".Maintainers: please mirror the issue's triage labels onto this PR:
priority/p1,severity/high,urgency/this-sprint,impact/few,effort/s,type/bug,triaged.🤖 Generated with Claude Code
Summary by CodeRabbit