Repository navigation
fix(api): make the purchase submit duplicate guard atomic - #761
Conversation
The duplicate check in persistExecutionAndSuppressions used SELECT ... FOR UPDATE over pending/notified rows. FOR UPDATE cannot see another transaction's uncommitted insert, so two concurrent identical submits both inserted; and a retry that arrived after a direct execute moved its row to approved/running/completed saw nothing and bought the commitment again. Take a per-creator pg_advisory_xact_lock as the first statement of the submit transaction (ListRecentSubmitsTx), then read the creator's recent web executions in pending, notified, approved, running, completed and partially_completed, and insert in the same transaction. A concurrent identical submit waits, then collapses onto the committed row. Failed, cancelled and expired rows do not absorb a resubmit. A collapsed response no longer reports email_sent for a row that already left the approval queue, and its message no longer says "pending". Integration tests (real Postgres) force both submits through their duplicate check before either inserts, and cover every status. Refs #758
|
Warning Review limit reached
This review includes 8 billable files and costs up to $2.00.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 42 minutes for your next included review. View limit detailsLimit details: You’ve used the included review currently available. Your 85 included PR review attempts over the past 7 days set your current allowance at 1 review per hour. Review configuration: ⚙️ Run configuration
📒 Files selected for processing (8)
Comment |
CI lint (misspell) rejected the literal legacy spelling. Use the status constants, which keeps both canceled and the legacy value covered, and fix "recognised" in a comment. Refs #758
Independent review: APPROVE at
|
…1030) Issue #266 reported that persistExecutionAndSuppressions found a duplicate submit by scanning one page of pending rows (GetPendingExecutionsTx: ORDER BY scheduled_date ASC LIMIT 1000 FOR UPDATE), so more than 1000 earlier pending rows hid the first submit and its resubmit bought twice. #761 already replaced that read with ListRecentSubmitsTx, an unbounded predicate on source, dedupe statuses, window start and creator behind a per-creator advisory lock. This adds the regression coverage that pins it: - real Postgres: 1,600 earlier pending rows (1,100 plan-style, 500 same creator in window with a different key), then an identical resubmit collapses onto the first submit - real Postgres: each idempotency-key component, capacity, source and window changed alone is not a duplicate; provider/payment case is - real Postgres: ListRecentSubmitsTx returns exactly the rows matching each clause (source, status, inclusive window start, creator, NULL) - unit: matchDuplicateInList clause table Closes #266 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…or path (#1217) persistRetrySuccessorTx inserts a web-source pending row for the retrying user and the failed row's recommendations, the same fingerprint an identical fresh submit carries, but it took no per-creator submit lock and ran no duplicate check. A retry racing (or following) an identical submit by the same user left two approvable pending rows. The retry transaction now starts with ListRecentSubmitsTx, the same per-creator advisory lock at the same point as persistExecutionAndSuppressions, and after the link CAS refuses with a 409 naming the existing execution when the creator already has a live submit with the successor's fingerprint inside the idempotency window. The lock comes before the ladder tranche lock; the submit path takes only this lock and the reconciler never takes it, so no new lock-order cycle. Also removes findDuplicatePendingExecution and GetPendingExecutionsTx (interface, Postgres store, MockConfigStore and the server/analytics fakes), which have had no production caller since #761, and moves the remaining cases of TestFindDuplicatePendingExecution onto matchDuplicateInList and persistExecutionAndSuppressions. Closes #760 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
What
Closes #758 (MON-01 from the 2026-10-08 review fleet): a retried or double-submitted direct execute could buy the same commitment twice.
Cause.
persistExecutionAndSuppressionschecked for duplicates withSELECT ... FOR UPDATEover pending/notified rows, then inserted.FOR UPDATEcannot see another transaction's uncommitted insert, so two concurrent identical submits both found nothing and both inserted. Separately, once a direct execute moved its row to approved/running/completed (seconds), a retry saw no pending row and created and executed a second execution. Each row has a randomIdempotencyKey, so the providers did not dedupe either.Fix.
ListRecentSubmitsTx(new store method) takes a per-creatorpg_advisory_xact_lockas the first statement of the submit transaction, then returns that creator's recent web executions in pending, notified, approved, running, completed and partially_completed. The handler matches the fingerprint as before and inserts in the same transaction. A concurrent identical submit waits for the first to commit, then collapses onto its row. Failed, cancelled and expired rows do not absorb a resubmit.Behavior to know about:
email_sentunless the row is still pending/notified, and its message no longer says "pending".Tests
internal/api/handler_purchases_submit_dedupe_integration_test.go(-tags integration, real Postgres via testcontainers):ConcurrentIdenticalSubmitsCreateOneRow: a barrier holds each transaction at its INSERT until both have done their duplicate check. On theorigin/mainhandler this yields 2 rows and no duplicate reported; with the fix, 1 row.RetryAfterRowLeftPending: onorigin/main, retries against approved, running, completed and partially_completed rows are not recognized; with the fix every live status collapses, and failed/cancelled/expired do not.DedupeIsPerCreator: two users with identical recommendations each get an execution.TestBuildDuplicatePurchaseResponseextended.go test -shortfor internal/api, config, analytics, server, mocks passes (one package at a time).Real Postgres 16 (not a mock), but a test harness, not the deployed stack. No browser UI run.
Local independent review (2 passes): no actionable findings after the email_sent fix.
Not in this PR
findDuplicatePendingExecution/GetPendingExecutionsTx; take the same lock inpersistRetrySuccessorTx🤖 Generated with Claude Code