Skip to content

fix(api): make the purchase submit duplicate guard atomic (retried direct-execute buys twice) #758

Closed

Description

@cristim

Problem

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.

  1. 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.
  2. 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.

Activity

  1. cristim commented on Oct 9, 2026

    @cristim
    MemberAuthor

    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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions