Repository navigation
test(api): pin the submit dedupe as a predicate, not a 1000-row page (closes #266) - #1030
Conversation
Issue LeanerCloud#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. LeanerCloud#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 LeanerCloud#266 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait... ⚙️ Run configuration
📒 Files selected for processing: 2
✨ Finishing Touches
Comment |
cristim
left a comment
There was a problem hiding this comment.
Independent review of 9e34d25: draft: blocks ready
No BLOCKING code findings. The PR is test-only (+251/-0, two test files).
#266 is already fixed on main (claim verified). persistExecutionAndSuppressions calls ListRecentSubmitsTx at internal/api/handler_purchases.go:3118, not GetPendingExecutionsTx. The query (internal/config/store_postgres.go:1772-1798) takes pg_advisory_xact_lock('purchase-submit:'+creator) and filters on source, submitDedupeStatuses, scheduled_date >= since and creator (IS NOT DISTINCT FROM). It has no LIMIT and no FOR UPDATE. This is the fix #266 itself proposed ("push the predicate into SQL"). matchDuplicateInList (handler_purchases.go:3070) compares the recomputed key in the same tx as the insert. Closing #266 is justified.
Findings
- NON-BLOCKING,
handler_purchases_submit_dedupe_integration_test.go(DedupeSeesPastThousandPendingRows): a SQLLIMIT 1000on the dedupe query survives. Only 500 rows match the predicate (the 1,100 plan rows are excluded by source), so a 1000-row page still holds them all. The test pins "unrelated rows do not count", not "no cap on matching rows". The PR body's probe usedLIMIT 100. Fix: seed more than 1000 matching rows (same creator, web, in window, other key), or soften the test comment and PR text. A real 1000-matching-rows case needs more than 1000 web submits by one creator in 2 minutes, so this is theoretical. - NON-BLOCKING: dead
GetPendingExecutionsTxandfindDuplicatePendingExecutionremain (internal/config/interfaces.go:598-601). Already tracked in #760 and stated in the PR.
What I attacked (real Postgres via testcontainers, worktree at this SHA)
- All new integration tests plus
RetryAfterRowLeftPendingandConcurrentIdenticalSubmitspass. The unitTestMatchDuplicateInListpasses (10 tests). - Probe 1, revert to the paged
GetPendingExecutionsTxread athandler_purchases.go:3118:DedupeSeesPastThousandPendingRowsFAILS, and so doRetryAfterRowLeftPendingapproved/running/completed/partially_completed. The test is not vacuous. The first submit lands past the 1000 earliest rows (1,100 plan rows an hour earlier plus 500 web rows). - Probe 2, SQL
LIMIT 1000: SURVIVES (see finding above). - Probe 3, window
>=to>:TestListRecentSubmitsTx_PredicateFAILS (boundary row). - All probes reverted; the worktree is clean.
- Consistency with
submitDedupeStatuses(store_postgres.go:1760): the status set inTestListRecentSubmitsTx_PredicateandRetryAfterRowLeftPendingmatches it.failed,canceled,cancelledandexpiredare excluded. I did not re-test the #925 frontend toast, which is out of scope for a test-only diff. - Labels: all six triage labels plus
triagedmirror #266. The PR body saysCloses #266.
Gate status (origin/main Review gate)
- Independent review at this SHA: this comment.
- CI: all checks were
pendingwhen I looked, including Unit, Integration, Lint and pre-commit. Not yet green on this SHA. - CodeRabbit skipped because the PR is a draft. I did not ping it.
- Local verification: done above on macOS. The PR body's own evidence is the author's claim.
Blocks ready
- Mark the PR ready for review (it is still a draft).
- CI green on 9e34d25 (Unit, Integration, Lint, pre-commit). Fork runs may need maintainer approval.
- Optional: close the
LIMIT 1000gap above, or soften the test comment and PR text on that point. Any new commit restarts the gate.
|
Gate record for 9e34d25 (origin/main CLAUDE.md "Review gate"):
Marking ready (owner-authorized) and merging at 9e34d25 with --match-head-commit. |
Summary
The bug in #266 is already fixed on
mainby #761 (commit 27c1a30, "make the purchase submit duplicate guard atomic", Refs #758). This PR adds no production change. It adds the regression tests that pin the fix, so #266 can close with evidence.Root cause (as reported) and current state
persistExecutionAndSuppressionsreadGetPendingExecutionsTx(status IN ('pending','notified') AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY scheduled_date ASC LIMIT 1000 FOR UPDATE) and scanned the page in Go withmatchDuplicateInList. With more than 1000 earlier-scheduled pending rows, the first submit fell off the page and an identical resubmit was inserted. That meant two approvable executions, so the commitment could be bought twice.mainnow: the submit transaction takespg_advisory_xact_lock('purchase-submit:'+creator)and then callsListRecentSubmitsTx. That is a predicate query with no LIMIT:source = 'cudly-web' AND status IN (pending, notified, approved, running, completed, partially_completed) AND scheduled_date >= now-2m AND created_by_user_id IS NOT DISTINCT FROM $creator.matchDuplicateInListthen compares the recomputed idempotency key, all inside the same transaction as the insert. The read is bounded by the rows that match, not by a page size. It no longer row-locks unrelated pending rows.Predicate vs the Go scan (current code)
ListRecentSubmitsTx)matchDuplicateInList)source = $3ex.Source != PurchaseSourceWeb→ skipTestListRecentSubmitsTx_Predicate, unit table, key-semantics "not web-submitted"status IN (submitDedupeStatuses)TestListRecentSubmitsTx_Predicate, existingTestPersistExecution_RetryAfterRowLeftPendingscheduled_date >= $1ScheduledDate.Before(cutoff)→ skipTestListRecentSubmitsTx_Predicate(boundary row), unit table (boundary), key-semantics "outside the window"created_by_user_id IS NOT DISTINCT FROM $2exCreator != creatorID→ skipTestListRecentSubmitsTx_Predicate, existingTestPersistExecution_DedupeIsPerCreator, unit tablepurchaseIdempotencyKey(...) == keyTestPersistExecution_DedupeKeySemantics(each field changed alone; provider/payment case and whitespace are still duplicates)The key itself is not pushed into SQL.
purchase_executions.idempotency_keyis the lineage anchor (migration 000066), not the submit fingerprint. Storing the fingerprint would need a new column plus a backfill from JSONB recs, and correctness does not need it because the read is already a bounded predicate.Migration
None.
ListRecentSubmitsTxuses the existingidx_purchase_executions_scheduled_date(000002) for the window range, and the per-creator advisory lock serializes submits. No index or migration number is claimed, so this PR does not interact with the ordering of #1020 (000127) or #1022 (000128).Tests added
internal/api/handler_purchases_submit_dedupe_integration_test.go(real Postgres via testcontainers):TestPersistExecution_DedupeSeesPastThousandPendingRows: 1,100 plan-style pending rows (an hour earlier, non-web), plus 500 web rows from the same creator inside the window with a different key, plus the first submit. The identical resubmit collapses onto the first submit (1,601 rows, not 1,602).TestPersistExecution_DedupeKeySemantics: 18 cases, each changing exactly one dedupe component.TestListRecentSubmitsTx_Predicate: exact returned set per clause, including the inclusive window boundary and a NULL creator.internal/api/handler_purchases_dedupe_test.go:TestMatchDuplicateInListclause table (unit).Verification
Run locally on macOS with
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null. No cloud calls and no purchases.go build ./...,go vet ./...,go vet -tags integration ./...: cleango test -count=1 ./internal/api/ ./internal/config/ ./internal/mocks/: okgo test -tags integration -count=1 ./internal/api/: ok (whole package, 320s)--allow-parallel-runnerson./internal/api/..., with and without--build-tags integration: 0 issues.gofile changed--scanof both new files in a fresh repo with an isolated HOME: clean.bash scripts/test-git-secrets-allowlist.sh: 42 PASS, 0 FAILFail-before evidence: I temporarily put back the pre-#761 read (
h.config.GetPendingExecutionsTx(ctx, tx)in place ofListRecentSubmitsTx) andTestPersistExecution_DedupeSeesPastThousandPendingRowsfails. Withmain's code it passes.Mutation probes (each applied alone, then reverted):
sourceclauseTestListRecentSubmitsTx_Predicate)status IN (...)_Predicate,RetryAfterRowLeftPending)_Predicate)>=→>on the window_Predicate)_Predicate)LIMIT 100page capDedupeSeesPastThousandPendingRows)TestMatchDuplicateInList)purchaseIdempotencyKey, so the key comparison already separates creators.Out of scope
GetPendingExecutionsTx(interface, store and three mocks) andfindDuplicatePendingExecution(which still scans the cappedGetPendingExecutionspage, but only its test calls it). This is tracked in chore(api): remove dead submit-dedupe code and lock the retry-successor path #760, together with the per-creator lock forpersistRetrySuccessorTx.Closes #266
🤖 Generated with Claude Code