Skip to content

fix(api): make the purchase submit duplicate guard atomic - #761

Merged
cristim merged 4 commits into
mainfrom
fix/mon01-atomic-submit-dedupe
Oct 9, 2026
Merged

cristim merged 4 commits into
mainfrom
fix/mon01-atomic-submit-dedupe

Conversation

@cristim

@cristim cristim commented Oct 9, 2026

Copy link
Copy Markdown
Member

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. persistExecutionAndSuppressions checked for duplicates with SELECT ... FOR UPDATE over pending/notified rows, then inserted. FOR UPDATE cannot 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 random IdempotencyKey, so the providers did not dedupe either.

Fix. ListRecentSubmitsTx (new store method) takes a per-creator pg_advisory_xact_lock as 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:

  • The same user repeating the same purchase within 2 minutes of a completed one now collapses onto it (the existing window; before this change it only applied to pending rows).
  • A collapsed response no longer carries email_sent unless 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 the origin/main handler this yields 2 rows and no duplicate reported; with the fix, 1 row.
  • RetryAfterRowLeftPending: on origin/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.
  • Unit: TestBuildDuplicatePurchaseResponse extended. go test -short for 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

🤖 Generated with Claude Code

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
@cristim cristim added priority/p0 Drop everything; same-day fix severity/high Significant harm urgency/now Drop other things impact/many Affects most users effort/m Days type/bug Defect triaged Item has been triaged labels Oct 9, 2026
@coderabbitai

coderabbitai Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 8 billable files and costs up to $2.00.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

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.

Check out review usage here.

View limit details

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

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 6e8b2083-ce36-4118-a908-c75c612c9473

📥 Commits

Reviewing files that changed from the base of the PR and between e5ac75d and 1d63b3e.


📒 Files selected for processing (8)
  • internal/analytics/collector_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_guards_test.go
  • internal/api/handler_purchases_submit_dedupe_integration_test.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
  • internal/mocks/stores.go
  • internal/server/test_helpers_test.go


  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

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
@cristim

cristim commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

Independent review: APPROVE at 1d63b3e8d68822a4f71059277da8348a401a5a13

Reviewer: archera-quotes-codex. My full review was at 77e1e992; this delta check covers 4d134602 and the merge 1d63b3e8.

Delta 4d134602 is test and comment only:

  • The status rows now use config.StatusFailed, StatusCanceled, LegacyStatusCanceled and StatusExpired. The test now covers both the canonical canceled and the legacy cancelled (my earlier C1).
  • recognised is now recognized.
  • There is no logic change.

Merge 1d63b3e8 (origin/main e5ac75d2):

  • The PR's own +/- lines against the new merge-base are byte-identical to the reviewed c1265535..4d134602 diff (same 8 files).
  • go vet ./... and go vet -tags integration ./internal/... are clean at this SHA. So no fake StoreInterface that arrived from main is missing ListRecentSubmitsTx.

Reproduced at 1d63b3e8 (go1.26.9, GOWORK=off, -mod=readonly, no -race, source archive):

  • go test -short passes for internal/api, config, mocks and server.
  • The integration tests pass on real Postgres 16 (testcontainers):
    • ConcurrentIdenticalSubmitsCreateOneRow;
    • RetryAfterRowLeftPending: all 10 statuses, including canceled and cancelled;
    • DedupeIsPerCreator.
  • My earlier mutant results at 77e1e992 still apply, because the production code is unchanged:
    • main's handler, lock removal and status narrowing are all killed;
    • removing the creator filter or the creator from the lock key survives, but both are equivalent.

CI at this SHA: 16 SUCCESS, 6 still running when I checked. The merge needs them green. The branch is 1 commit(s) behind origin/main 0e34519e again.

@cristim
cristim merged commit 27c1a30 into main Oct 9, 2026
25 checks passed
cristim pushed a commit that referenced this pull request Oct 16, 2026
…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>
IAmJSD added a commit that referenced this pull request Oct 25, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/many Affects most users priority/p0 Drop everything; same-day fix severity/high Significant harm triaged Item has been triaged type/bug Defect urgency/now Drop other things

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant