fix(OUT-3971): eliminate flaky synced_contacts duplicate-key race - #73
Conversation
…g-invoice sync createMissingXeroInvoice resolved the contact concurrently with syncInvoiceToXero, which already resolves it, so two get-or-creates raced on the synced_contacts unique index and intermittently threw a duplicate key. Reuse the contact syncInvoiceToXero returns, with a fallback fetch for the already-synced short-circuit so the sync log stays populated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Insert synced_contacts with onConflictDoNothing on the unique index and re-select the winner on conflict, returning its contact and skipping the sync log. Genuinely concurrent syncs for the same client now degrade to a lookup instead of a duplicate-key failure. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Assert the missing-invoice path resolves the contact exactly once (guards the self-race regression), and add an integration test proving createContact returns the existing mapping on a duplicate insert without a second row or sync log. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
This pull request has been ignored for the connected project Preview Branches by Supabase. |
Greptile SummaryThe PR removes duplicate contact resolution from missing-invoice handling and makes the contact mapping insert tolerate concurrent winners.
Confidence Score: 5/5The PR appears safe to merge with no concrete changed-code defect identified. The conflict target matches the existing unique index, the winner lookup uses the same identity columns, and successful missing-invoice paths retain customer details without a second concurrent contact resolution. Important Files Changed
Sequence DiagramsequenceDiagram
participant W as Webhook
participant I as SyncedInvoicesService
participant C as SyncedContactsService
participant X as Xero
participant D as Database
W->>I: create missing Xero invoice
I->>C: resolve contact once
C->>X: create contact if missing
C->>D: INSERT mapping ON CONFLICT DO NOTHING
alt mapping inserted
D-->>C: inserted contact ID
C->>D: write customer sync log
else concurrent mapping won
D-->>C: no inserted row
C->>D: select winning mapping
D-->>C: winning contact ID
end
C-->>I: resolved contact
I->>X: create invoice
I->>D: write invoice sync log with resolved customer details
I-->>W: synced invoice record
Reviews (1): Last reviewed commit: "test(OUT-3971): cover single contact res..." | Re-trigger Greptile |
Summary
Fixes OUT-3971. The integration suite intermittently failed (~1 in 5–7 full-suite runs) with a duplicate key on
uq_synced_contacts_portal_id_tenant_id_client_or_company_id.Root cause (not a cross-test leak):
createMissingXeroInvoiceresolved the contact concurrently withsyncInvoiceToXero, which already resolves it internally — two non-atomic get-or-creates for the same client racing on the unique index. Reproduced deterministically: every duplicate-key failure was a "missing Xero invoice" test (invoice.paid,invoice.voided,payment.succeeded), all funnelling throughcreateMissingXeroInvoice.Is it a real production race? Yes, but low impact — the unique index rejects the duplicate row (no corruption); the losing sync becomes a retryable
failed_sync. Prod check found only 2 lifetime occurrences (~2 orphaned Xero contacts), so no advisory lock was added.Changes
createMissingXeroInvoiceno longer fetches the contact a second time;syncInvoiceToXeronow returns thecustomerName/customerEmailit already resolved. A guarded, sequential fallback fetch keeps the sync log populated on the already-synced short-circuit (cannot race — it runs after the sync completes).createContactinserts withonConflictDoNothingon the unique index and re-selects the winner on conflict, so genuinely concurrent syncs degrade to a lookup instead of a duplicate-key failure. No migration (uses the existing index).Not covered (intentional)
Two concurrent requests can still both call
xero.createContactbefore either inserts, leaving an orphaned Xero contact. Closing that fully needs apg_advisory_xact_lockaround the get-or-create; deferred given only 2 lifetime events. The DB never points at the orphan, so transactions never split across contacts.Testing
tsc --noEmitclean,biomeclean, full suite 61/61.createContactIdempotencytest proves a duplicate insert returns the existing contact with no second row or sync log;missingXeroInvoicetest now asserts the contact is resolved exactly once (self-race regression guard).🤖 Generated with Claude Code