You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
fix(api): make the purchase submit duplicate guard atomic (retried direct-execute buys twice) #758
POST /api/purchases/execute (executePurchase, internal/api/handler_purchases.go) dedupes identical submits (same creator, same recommendations, same capacity, 2 minute window) inside persistExecutionAndSuppressions. The guard is not atomic and a retried direct execute buys the same commitment twice.
Concurrent: two identical submits (double click, client retry, two tabs) each run SELECT ... WHERE status IN ('pending','notified') ... FOR UPDATE and then insert. FOR UPDATE locks only rows that already exist, and neither transaction sees the other's uncommitted insert, so both find no duplicate and both insert.
Sequential: with execute_mode=direct, request 1 commits the pending row and moves it to approved/running/completed within seconds. A retry inside the window reads only pending/notified rows, so the in-flight row is invisible and a second execution is created and executed. Each row gets a fresh random IdempotencyKey, so provider tokens differ and the provider does not dedupe.
Found by the 2026-10-08 review fleet (MON-01 / money H1, rv-platform-money.md). #266 (LIMIT 1000) is a different cause and its suggested fix does not close this.
Fix
Take a per-creator pg_advisory_xact_lock as the first statement of the submit transaction, then read that 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 for the first to commit and then collapses onto its row. Failed, cancelled and expired rows do not absorb a resubmit.
Regression tests (integration, real Postgres): forced interleaving at the INSERT (2 rows on main, 1 with the fix) and retry against each status.
Fixed by #761 (squash-merged as 27c1a30). The submit transaction now takes a per-creator advisory lock, reads the creator's recent web executions in pending, notified, approved, running, completed and partially_completed, and inserts in the same transaction, so concurrent identical submits and retries after a direct execute collapse onto one execution. Regression tests (real Postgres, forced interleaving) fail on the previous handler and pass now. Follow-ups: #759 (frontend message on a collapsed submit), #760 (dead code removal and the unlocked retry-successor path).
Activity
test(api): use the canonical canceled status in the submit dedupe test
fix(api): make the purchase submit duplicate guard atomic (#761)
cristim commentedon Oct 9, 2026
Fixed by #761 (squash-merged as 27c1a30). The submit transaction now takes a per-creator advisory lock, reads the creator's recent web executions in pending, notified, approved, running, completed and partially_completed, and inserts in the same transaction, so concurrent identical submits and retries after a direct execute collapse onto one execution. Regression tests (real Postgres, forced interleaving) fail on the previous handler and pass now. Follow-ups: #759 (frontend message on a collapsed submit), #760 (dead code removal and the unlocked retry-successor path).
fix(frontend): say which execution a duplicate submit joined (#925)