Skip to content

Fix race condition in partner upsert causing unique constraint violation#3650

Draft
devkiran wants to merge 4 commits intomainfrom
partner-upsert-race-condition
Draft

Fix race condition in partner upsert causing unique constraint violation#3650
devkiran wants to merge 4 commits intomainfrom
partner-upsert-race-condition

Conversation

@devkiran
Copy link
Copy Markdown
Collaborator

@devkiran devkiran commented Mar 25, 2026

When concurrent requests hit /api/tokens/embed/referrals with the same partner email, the nested ProgramEnrollment is created inside partner.upsert could violate the unique(partnerId, programId) constraint. Handle P2002 errors by falling back to returning the existing enrollment.

Summary by CodeRabbit

  • Bug Fixes
    • Improved stability of partner enrollment when concurrent requests occur by adding a safe fallback to detect and return existing enrollments.
    • Ensures returned partner data is more consistent and updates tenant association when appropriate, reducing duplicate enrollments and race-condition failures.

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.
@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Mar 25, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
dub Ready Ready Preview Mar 25, 2026 1:57pm

Request Review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 25, 2026

Caution

Review failed

The head commit changed during the review from c2ceb27 to 0ec6bbc.

📝 Walkthrough

Walkthrough

Wraps 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

Cohort / File(s) Summary
Partner Enrollment Race Condition Handling
apps/web/lib/api/partners/create-and-enroll-partner.ts
Removed top-level "use server"; replaced direct prisma.partner.upsert with a try/catch that handles P2002 unique-constraint errors. Upsert now includes platforms: true and programs filtered by programId. On P2002, fallback queries prisma.programEnrollment.findFirst for an existing enrollment, may update tenantId, and returns a parsed EnrolledPartnerSchema; non-matching errors are rethrown.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Two writes collided under moonlit code,
I caught the error on the lopsided road.
A fallback query found the partner there,
I patched the tenant, trimmed the data fair.
Now enrollments hop along with care. 🌙✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix race condition in partner upsert causing unique constraint violation' is fully related to the main change in the changeset—it directly describes the core issue being addressed (race conditions causing unique constraint violations in partner upsert).
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch partner-upsert-race-condition

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.

❤️ Share

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 2e44966 and d06a735.

📒 Files selected for processing (1)
  • apps/web/lib/api/partners/create-and-enroll-partner.ts

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between d06a735 and 9a47a55.

📒 Files selected for processing (1)
  • apps/web/lib/api/partners/create-and-enroll-partner.ts

Comment on lines +195 to +257
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),
});
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant