Fix race condition in partner upsert causing unique constraint violation#3650
Fix race condition in partner upsert causing unique constraint violation#3650
Conversation
When concurrent requests hit /api/tokens/embed/referrals with the same partner email, the nested ProgramEnrollment create inside partner.upsert could violate the unique(partnerId, programId) constraint. Handle P2002 errors by falling back to returning the existing enrollment.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughWraps the partner upsert in error handling to intercept P2002 unique-constraint races, eagerly loads related programs/platforms, and falls back to querying existing program enrollments to return parsed enrollment data or rethrow errors. (46 words) Changes
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/lib/api/partners/create-and-enroll-partner.ts`:
- Around line 195-221: In the P2002 fallback (the block checking if error.code
=== "P2002" and loading existingEnrollment via
prisma.programEnrollment.findFirst), ensure you reconcile tenant differences
before returning: compare the existingEnrollment.tenantId (or the enrollment's
tenant field) to the intended tenantId (the one used earlier — e.g.,
program.tenantId or the incoming tenantId variable), and if they differ call
prisma.programEnrollment.update to set the enrollment to the correct tenantId
(then re-fetch or use the updated record) before passing the data into
EnrolledPartnerSchema.parse (and still include links and
polyfillSocialMediaFields(existingEnrollment.partner.platforms)). This mirrors
the non-racy path’s tenant reassignment so the returned enrollment is attached
to the correct tenant.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2eb52433-443f-4ebe-8af4-098e37105e34
📒 Files selected for processing (1)
apps/web/lib/api/partners/create-and-enroll-partner.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/web/lib/api/partners/create-and-enroll-partner.ts`:
- Around line 195-257: The P2002 fallback currently returns an
EnrolledPartnerSchema.parse early for existingEnrollment (and updatedEnrollment)
which skips createPartnerDefaultLinks and can return a partially-initialized
enrollment; modify the fallback to always run or await the same link-creation
path: after detecting existingEnrollment (or after updating tenantId in
prisma.programEnrollment.update), call or await createPartnerDefaultLinks
(and/or ensure links exist in the DB) then reload the enrollment including links
(or include links in the update result) and only then call
EnrolledPartnerSchema.parse with the fully materialized links and
polyfillSocialMediaFields; keep throwIfExistingTenantEnrollmentExists logic
intact when updating tenantId.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 438c9d1b-c757-4f98-ad6f-1496a2eede92
📒 Files selected for processing (1)
apps/web/lib/api/partners/create-and-enroll-partner.ts
| if (error.code === "P2002") { | ||
| const existingEnrollment = await prisma.programEnrollment.findFirst({ | ||
| where: { | ||
| programId: program.id, | ||
| partner: { | ||
| email: partner.email, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| include: { | ||
| partner: { | ||
| include: { | ||
| platforms: true, | ||
| }, | ||
| }, | ||
| links: true, | ||
| }, | ||
| }); | ||
|
|
||
| if (existingEnrollment) { | ||
| if ( | ||
| partner.tenantId && | ||
| partner.tenantId !== existingEnrollment.tenantId | ||
| ) { | ||
| await throwIfExistingTenantEnrollmentExists({ | ||
| tenantId: partner.tenantId, | ||
| programId: program.id, | ||
| }); | ||
|
|
||
| const updatedEnrollment = await prisma.programEnrollment.update({ | ||
| where: { | ||
| id: existingEnrollment.id, | ||
| }, | ||
| data: { | ||
| tenantId: partner.tenantId, | ||
| }, | ||
| include: { | ||
| partner: { | ||
| include: { | ||
| platforms: true, | ||
| }, | ||
| }, | ||
| links: true, | ||
| }, | ||
| }); | ||
|
|
||
| return EnrolledPartnerSchema.parse({ | ||
| ...updatedEnrollment.partner, | ||
| ...updatedEnrollment, | ||
| id: updatedEnrollment.partner.id, | ||
| links: updatedEnrollment.links, | ||
| ...polyfillSocialMediaFields( | ||
| updatedEnrollment.partner.platforms, | ||
| ), | ||
| }); | ||
| } | ||
|
|
||
| return EnrolledPartnerSchema.parse({ | ||
| ...existingEnrollment.partner, | ||
| ...existingEnrollment, | ||
| id: existingEnrollment.partner.id, | ||
| links: existingEnrollment.links, | ||
| ...polyfillSocialMediaFields(existingEnrollment.partner.platforms), | ||
| }); |
There was a problem hiding this comment.
Don't return from the P2002 fallback before link creation runs.
Line 251 returns the existing enrollment immediately, but this branch skips createPartnerDefaultLinks at Lines 267-289 entirely. In the exact race this PR handles, the losing request can now respond without the default links (or the request-specific link input), and if the winning request later fails during link creation this branch still reports success with a partially initialized enrollment. Please make the fallback route through the same link-creation step, or otherwise wait until those links are materialized before parsing the response.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/web/lib/api/partners/create-and-enroll-partner.ts` around lines 195 -
257, The P2002 fallback currently returns an EnrolledPartnerSchema.parse early
for existingEnrollment (and updatedEnrollment) which skips
createPartnerDefaultLinks and can return a partially-initialized enrollment;
modify the fallback to always run or await the same link-creation path: after
detecting existingEnrollment (or after updating tenantId in
prisma.programEnrollment.update), call or await createPartnerDefaultLinks
(and/or ensure links exist in the DB) then reload the enrollment including links
(or include links in the update result) and only then call
EnrolledPartnerSchema.parse with the fully materialized links and
polyfillSocialMediaFields; keep throwIfExistingTenantEnrollmentExists logic
intact when updating tenantId.
There was a problem hiding this comment.
🧩 Analysis chain
Let me verify the fix in the current state of the file.
🏁 Script executed:
#!/bin/bash
# Check current state of the catch block in create-and-enroll-partner.ts
grep -n "createPartnerDefaultLinks\|P2002\|catch\|existingEnrollment\|throw error" apps/web/lib/api/partners/create-and-enroll-partner.ts
Length of output: 727
Let me look at the full catch block to be thorough.
🏁 Script executed:
#!/bin/bash
sed -n '190,270p' apps/web/lib/api/partners/create-and-enroll-partner.ts
Length of output: 2187
@devkiran, I checked the current file on this branch and the fix doesn't appear to have landed yet. The P2002 catch block (lines 192–263) still returns early from both the updatedEnrollment path and the existingEnrollment path using .links fetched from the DB — createPartnerDefaultLinks at line 267 is only reachable after a successful upsert.
The issue is still present: if two concurrent requests race and the second one hits P2002, it will respond with whatever links already exist in the DB (which could be an empty set if the winner hasn't finished link creation yet), and the caller-supplied link argument is never applied.
Here's the minimal change needed for the existingEnrollment return path:
// After finding existingEnrollment (or updatedEnrollment), create links if needed
const links = await createPartnerDefaultLinks({
workspace: { id: workspace.id, plan: workspace.plan },
program: { id: program.id, defaultFolderId: program.defaultFolderId },
partner: {
id: existingEnrollment.partner.id,
name: partner.name,
email: partner.email,
username: partner.username,
tenantId: partner.tenantId,
},
group: {
defaultLinks: group.partnerGroupDefaultLinks,
utmTemplate: group.utmTemplate,
},
link,
userId,
});
return EnrolledPartnerSchema.parse({
...existingEnrollment.partner,
...existingEnrollment,
id: existingEnrollment.partner.id,
links, // use freshly created/fetched links
...polyfillSocialMediaFields(existingEnrollment.partner.platforms),
});Note that createPartnerDefaultLinks should be idempotent for an already-enrolled partner (it shouldn't duplicate links), so calling it in both paths is safe. The same pattern applies to the updatedEnrollment return path. Also note that group needs to be hoisted above the try block (it already is in this file, so that's fine).
🧠 Learnings used
Learnt from: devkiran
Repo: dubinc/dub PR: 2177
File: apps/web/lib/api/links/bulk-create-links.ts:66-84
Timestamp: 2025-06-06T07:59:03.120Z
Learning: In apps/web/lib/api/links/bulk-create-links.ts, the team accepts the risk of potential undefined results from links.find() operations when building invalidLinks arrays, because existing links are fetched from the database based on the input links, so matches are expected to always exist.
Learnt from: devkiran
Repo: dubinc/dub PR: 3639
File: apps/web/lib/api/partners/bulk-reactivate-partners.ts:30-35
Timestamp: 2026-03-24T09:46:32.889Z
Learning: In the dubinc/dub codebase, `program.defaultGroupId` is always set — programs are created with a default partner group automatically upserted using the `DEFAULT_PARTNER_GROUP` constant. Therefore, the expression `e.groupId || program.defaultGroupId` in `apps/web/lib/api/partners/bulk-reactivate-partners.ts` always resolves to a valid group ID, and it is safe to skip null-checks on the resolved group ID for all program enrollments.
Learnt from: steven-tey
Repo: dubinc/dub PR: 3584
File: apps/web/app/(ee)/api/admin/delete-partner-account/route.ts:120-134
Timestamp: 2026-03-14T20:01:18.394Z
Learning: In the dubinc/dub codebase, `prisma.partner.delete()` throws a Prisma error because `relationMode = "prisma"` enforces Restrict (default) on models without explicit `onDelete`: Payout, PartnerRewind, PartnerReferral, DiscountCode, and NotificationEmail all reference Partner without onDelete Cascade. The workaround is to explicitly `deleteMany` those non-cascade relations before calling `prisma.partner.delete()`.
Learnt from: TWilson023
Repo: dubinc/dub PR: 2857
File: apps/web/lib/actions/partners/update-program.ts:96-0
Timestamp: 2025-09-17T17:44:03.965Z
Learning: In apps/web/lib/actions/partners/update-program.ts, the team prefers to keep the messagingEnabledAt update logic simple by allowing client-provided timestamps rather than implementing server-controlled timestamp logic to avoid added complexity.
Learnt from: TWilson023
Repo: dubinc/dub PR: 2935
File: apps/web/lib/actions/partners/invite-partner-from-network.ts:21-28
Timestamp: 2025-10-06T15:48:14.205Z
Learning: For the network invites limit check in apps/web/lib/actions/partners/invite-partner-from-network.ts, the team accepts that concurrent invites may bypass the limit due to race conditions. Perfect atomicity is not required for this feature.
Learnt from: devkiran
Repo: dubinc/dub PR: 3346
File: apps/web/lib/api/bounties/approve-bounty-submission.ts:165-165
Timestamp: 2026-01-13T12:06:42.476Z
Learning: Use 'noreply' as a sentinel for the replyTo email field: if replyTo === 'noreply', omit the replyTo property by spreading an empty object. Call sites can use replyTo: someEmail || 'noreply' to conditionally set replyTo without null/undefined. This pattern is broadly applicable across the TypeScript codebase; ensure this behavior is documented and that readability is preserved, using explicit conditional logic if the intent may be unclear.
Learnt from: devkiran
Repo: dubinc/dub PR: 3358
File: apps/web/app/(ee)/api/cron/partners/deactivate/route.ts:7-7
Timestamp: 2026-02-03T14:14:47.579Z
Learning: In all TypeScript files under apps/web, import zod using a namespace import: import * as z from 'zod/v4' and avoid using named import: import { z } from 'zod'. This ensures consistent API access and version specificity. During reviews, look for and replace any occurrences of import { z } from 'zod' with import * as z from 'zod/v4', and verify all zod usage continues to compile.
This reverts commit c2ceb27.
When concurrent requests hit
/api/tokens/embed/referralswith the same partner email, the nested ProgramEnrollment is created insidepartner.upsertcould violate theunique(partnerId, programId)constraint. Handle P2002 errors by falling back to returning the existing enrollment.Summary by CodeRabbit