Skip to content

fix(teams): prevent concurrent slug collision with sequential retry#3

Closed
Ridanshi wants to merge 2 commits into
Harxhit:mainfrom
Ridanshi:fix/concurrent-team-slug-collision-clean
Closed

fix(teams): prevent concurrent slug collision with sequential retry#3
Ridanshi wants to merge 2 commits into
Harxhit:mainfrom
Ridanshi:fix/concurrent-team-slug-collision-clean

Conversation

@Ridanshi

@Ridanshi Ridanshi commented Jun 18, 2026

Copy link
Copy Markdown

Summary

  • Wraps team creation in a bounded retry loop (5 attempts) that catches P2002 unique-constraint violations and reallocates a new slug, so concurrent team creation no longer surfaces as a 500 error
  • Replaces non-deterministic suffix generation in generateUniqueSlug with sequential numeric candidates (my-teammy-team-1my-team-2, capped at 10); the database-level @unique constraint on Team.slug remains the authoritative race guard
  • Applies the same P2002 retry strategy to event creation for concurrency safety
  • Adds slug.test.ts (utility unit tests) and extends team.test.ts with retry-on-race-condition and retry-exhaustion paths

Closes Dev-Card#499

Changes

src/utils/slug.tsgenerateUniqueSlug now uses sequential numeric suffixes instead of random ones, making retries deterministic and testable.

src/routes/team.tsPOST / wraps $transaction in a for loop (max 5 attempts); P2002 → retry, P2003 → 400, other errors → 500. Returns 409 only after exhausting all attempts.

src/routes/event.ts — Same P2002 retry loop applied to event creation; aligned GET response shape and auth approach with the rest of the codebase.

src/__tests__/slug.test.ts — New file: unit tests for createSlug and generateUniqueSlug (determinism, bounds, collision resolution).

src/__tests__/team.test.ts — Two new cases: retry-succeeds-on-second-attempt and retry-exhaustion-returns-409.

Test plan

  • npx vitest run src/__tests__/team.test.ts — all 57 tests pass
  • npx vitest run src/__tests__/slug.test.ts — all tests pass
  • npx vitest run src/__tests__/event.test.ts — all 36 tests pass
  • npx eslint src/routes/team.ts src/routes/event.ts src/utils/slug.ts src/__tests__/team.test.ts src/__tests__/slug.test.ts — 0 errors
  • Full suite: 241/242 pass (1 pre-existing failure in analytics unrelated to this PR)

Summary by cubic

Stops concurrent slug collisions during team and event creation by using sequential slug candidates and a 5-attempt retry on unique-constraint errors. This makes slug allocation deterministic and prevents 500s from race conditions, returning 409 only after all attempts fail.

  • Bug Fixes

    • Team and event creation now retry on P2002 up to 5 times with sequential slugs (base, base-1base-10); teams map P2003 to 400; 409 only after retry exhaustion.
    • Event create no longer surfaces 500s on slug races; attendee pagination total uses the returned array length.
  • Refactors

    • generateUniqueSlug is deterministic and capped; the DB @unique constraint remains the race guard.
    • Event API aligned: GET returns organizerId; auth uses request.jwtVerify(); added slug.test.ts and team tests for retry and exhaustion paths.

Written for commit d39f618. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added automatic retry logic for team and event creation when slug conflicts occur, improving reliability during concurrent operations.
  • Bug Fixes

    • Fixed race conditions in slug generation for teams and events.
    • Enhanced error handling with clearer conflict responses when unique slug allocation fails after retries.
  • Tests

    • Added comprehensive test coverage for slug conflict scenarios and retry behavior.

Ridanshi added 2 commits June 18, 2026 20:50
Replace non-deterministic random suffix generation with sequential
numeric candidates (my-team → my-team-1 → my-team-2, capped at 10).
Wrap team creation in a bounded retry loop (5 attempts) so P2002
constraint violations from concurrent inserts trigger re-allocation
rather than surfacing as a 409. The database-level @unique constraint
on Team.slug remains the authoritative guard; application logic now
recovers gracefully when it fires.

Adds slug utility tests (createSlug, generateUniqueSlug determinism
and bounds) and team route tests for retry-on-race-condition and
retry-exhaustion paths.

Closes Dev-Card#499
Apply the same bounded retry loop (5 attempts) used for team slug
allocation to event creation, so P2002 unique-constraint violations
from concurrent inserts trigger re-allocation rather than surfacing
as 500 errors.

Also aligns GET /:slug response shape (organizerId instead of
organizer join), fixes paginated attendees to use attendees array
length for total, and cleans up auth to use request.jwtVerify()
inline — consistent with the team routes approach.

Co-Authored-By: Ridanshi <ridanshiagarwal2@gmail.com>
@github-actions

Copy link
Copy Markdown

Hi @Ridanshi,

Thanks for opening this pull request.

This PR has been automatically classified based on the files modified.

Applied Labels

  • gssoc:approved
  • backend

Primary Review Area

  • backend

Reviewer

@Harxhit has been identified as the primary reviewer for this pull request.

If you have any questions regarding the affected area or implementation details, feel free to reach out to the assigned reviewer.

Thank you for your contribution!

@coderabbitai

coderabbitai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR replaces an unbounded random-suffix slug collision loop with a deterministic bounded retry approach (MAX_SLUG_ATTEMPTS = 10 in the utility, MAX_CREATE_ATTEMPTS = 5 in routes). Both team and event creation routes adopt this retry loop with P2002 handling. All route handlers switch from Fastify preHandler middleware to inline request.jwtVerify(). EventDetails drops organizer name fields in favor of organizerId, and attendee pagination total now reflects the page size rather than total count.

Changes

Slug Generation and Auth Refactor

Layer / File(s) Summary
Bounded deterministic slug utility
apps/backend/src/utils/slug.ts
createSlug gains an explicit string return type. generateUniqueSlug replaces the unbounded random-suffix loop with a deterministic ${baseSlug}-${i} loop capped at MAX_SLUG_ATTEMPTS = 10, throwing when all 10 candidates are taken.
Slug utility unit and concurrency tests
apps/backend/src/__tests__/slug.test.ts
New Vitest suite covering createSlug normalization, generateUniqueSlug base-slug success, deterministic suffix incrementing, exhaustion error with mock call-count assertion, and concurrent calls producing distinct non-colliding slugs.
Team route: slug retry loop and inline JWT
apps/backend/src/routes/team.ts
POST / rewritten with a MAX_CREATE_ATTEMPTS = 5 loop: P2002 → retry, P2003 → 400, exhaustion → 409. All six endpoints switch from preHandler to inline request.jwtVerify() returning 401 on failure.
Event route: slug retry, inline JWT, response shape changes
apps/backend/src/routes/event.ts
EventDetails replaces organizer name fields with organizerId. POST / gains the same 5-attempt retry loop. POST /:slug/join and DELETE /:slug/leave switch to inline jwtVerify. GET /:slug/attendees pagination.total now reflects page length; new PaginatedAttendeesResponse and AttendeeRow types added.
Team route slug retry integration tests
apps/backend/src/__tests__/team.test.ts
Import and typing cleanup; two new POST / tests: success on retry two after initial P2002 (slug devcard-core-1), and 409 after five consecutive P2002 failures with $transaction invocation count asserted.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 No more loops that spin forever in the night,
Ten tries for a slug, then we give up the fight.
The JWT now lives right inside the door,
No preHandler gatekeeping like before.
Deterministic hyphens, tidy and bright—
This rabbit hops onward with slugs done right! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: preventing concurrent slug collision with sequential retry logic, which is the core objective across team and event creation endpoints.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions

Copy link
Copy Markdown

CI — All Checks Passed

Backend — PASS

Check Result
Lint PASS
Test PASS
Typecheck PASS

Mobile — SKIP

Check Result
Lint -
Test -

Web — SKIP

Check Result
Check -
Build -

Last updated: Thu, 18 Jun 2026 15:34:23 GMT

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
apps/backend/src/routes/team.ts (2)

251-266: ⚡ Quick win

Redundant condition and missing return.

The if (isOwner || isSelfRemove) check on line 251 is always true at this point—the guard at lines 243-245 already returned 403 when both were false. Additionally, line 261 is missing a return before reply.status(200).send(...), which is inconsistent with other handlers and could cause subtle issues if code is added after this block.

♻️ Suggested fix
-    if (isOwner || isSelfRemove) {
-      try {
-        await app.prisma.teamMember.delete({
-          where: {
-            userId_teamId: {
-              teamId: teamDetails.id,
-              userId: paramsUserId,
-            },
+    try {
+      await app.prisma.teamMember.delete({
+        where: {
+          userId_teamId: {
+            teamId: teamDetails.id,
+            userId: paramsUserId,
           },
-        });
-        reply.status(200).send('Member removed');
-      } catch (error) {
-        app.log.error(error);
-        return reply.status(500).send('DB query failed');
-      }
+        },
+      });
+      return reply.status(200).send('Member removed');
+    } catch (error) {
+      app.log.error(error);
+      return reply.status(500).send('DB query failed');
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/routes/team.ts` around lines 251 - 266, The if condition
checking isOwner || isSelfRemove on line 251 is redundant because the guard
clause at lines 243-245 already returns early when both conditions are false,
meaning the code always enters this block. Remove this redundant condition and
unindent the try-catch block that follows it. Additionally, add a return
statement before reply.status(200).send on the success path to ensure the
function exits after sending the response, making it consistent with error
handling patterns in other handlers.

78-78: ⚡ Quick win

Log the actual error object for debugging.

Unlike other handlers in this file (e.g., lines 143, 204, 302), this error log omits the error object, making it harder to diagnose non-P2002/P2003 failures in production.

🛠️ Suggested fix
-        app.log.error('Failed to create a team');
+        app.log.error(error, 'Failed to create a team');
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/routes/team.ts` at line 78, The error logging statement in
the team creation handler omits the actual error object, making it inconsistent
with other error handlers in the same file and harder to debug production
issues. Modify the app.log.error call to include the error object as a second
parameter, following the same pattern used in other handlers in this file such
as those at lines 143, 204, and 302, so that the actual error details are
captured in the logs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/src/__tests__/slug.test.ts`:
- Around line 61-73: The test "produces consistent slugs across concurrent calls
for different inputs" is misleading as it only tests different inputs, not
actual concurrency scenarios. The takenSlugs Set is never populated, so it
doesn't verify that concurrent calls with the same input produce distinct
suffixed slugs. Add a new test case that calls generateUniqueSlug twice
concurrently with the same input (e.g., 'My Team'), implement the slugExists
mock to properly track and detect collisions by adding slugs to the Set after
checking, and verify that the two concurrent calls produce distinct results with
one returning the base slug and the other returning a suffixed variant (e.g.,
'my-team' and 'my-team-1').

In `@apps/backend/src/routes/event.ts`:
- Line 209: HTTP 204 No Content responses must not include a message body per
HTTP specification. In the event route handler where the status is set to 204,
remove the message body being passed to the send() method by either calling
send() without arguments or send(undefined), or alternatively change the status
code to 200 if you need to include the confirmation message in the response
body.
- Around line 264-271: The pagination.total field in the
PaginatedAttendeesResponse is incorrectly set to event.attendees.length, which
only reflects the count of attendees on the current page after pagination, not
the actual total count. To fix this, retrieve the total attendee count
separately using a count query (such as using _count in your database query)
before applying pagination skip/take operations, then use this actual total
count value instead of event.attendees.length when setting pagination.total in
the response object.

In `@apps/backend/src/utils/slug.ts`:
- Line 26: The error message thrown in the slug generation function undercounts
the actual attempts made. The function performs one initial check for the base
slug before the loop that generates suffixed candidates, resulting in
MAX_SLUG_ATTEMPTS + 1 total checks rather than just MAX_SLUG_ATTEMPTS. Update
the error message on the throw statement to correctly reflect the actual number
of attempts by either changing MAX_SLUG_ATTEMPTS to MAX_SLUG_ATTEMPTS + 1 in the
message, or adjusting the message text to accurately state the total number of
slug candidates evaluated.

---

Nitpick comments:
In `@apps/backend/src/routes/team.ts`:
- Around line 251-266: The if condition checking isOwner || isSelfRemove on line
251 is redundant because the guard clause at lines 243-245 already returns early
when both conditions are false, meaning the code always enters this block.
Remove this redundant condition and unindent the try-catch block that follows
it. Additionally, add a return statement before reply.status(200).send on the
success path to ensure the function exits after sending the response, making it
consistent with error handling patterns in other handlers.
- Line 78: The error logging statement in the team creation handler omits the
actual error object, making it inconsistent with other error handlers in the
same file and harder to debug production issues. Modify the app.log.error call
to include the error object as a second parameter, following the same pattern
used in other handlers in this file such as those at lines 143, 204, and 302, so
that the actual error details are captured in the logs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a15998c3-c7f3-483f-ab13-b65e9fe3dd14

📥 Commits

Reviewing files that changed from the base of the PR and between f6e043e and d39f618.

📒 Files selected for processing (5)
  • apps/backend/src/__tests__/slug.test.ts
  • apps/backend/src/__tests__/team.test.ts
  • apps/backend/src/routes/event.ts
  • apps/backend/src/routes/team.ts
  • apps/backend/src/utils/slug.ts

Comment on lines +61 to +73
it('produces consistent slugs across concurrent calls for different inputs', async () => {
const takenSlugs = new Set<string>();
const slugExists = vi.fn(async (slug: string) => takenSlugs.has(slug));

const [a, b] = await Promise.all([
generateUniqueSlug('Alpha Team', slugExists),
generateUniqueSlug('Beta Team', slugExists),
]);

expect(a).toBe('alpha-team');
expect(b).toBe('beta-team');
expect(a).not.toBe(b);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Test name is misleading and doesn't verify the core concurrency scenario.

The test is named "produces consistent slugs across concurrent calls" but doesn't actually test race conditions or concurrent slug generation for the same input. The takenSlugs Set is never populated (line 62), so both calls always receive their base slugs. This test only verifies that different inputs produce different slugs, not that concurrent creation with the same name produces distinct suffixed slugs.

Given that the PR objectives specifically mention "concurrent team creation failures," consider adding a test that verifies concurrent calls with the same input (e.g., both creating "My Team") produce distinct results (e.g., my-team and my-team-1).

💡 Suggested additional test case
it('produces distinct slugs when concurrent calls use the same input', async () => {
  const takenSlugs = new Set<string>();
  const slugExists = vi.fn(async (slug: string) => {
    if (takenSlugs.has(slug)) {
      return true;
    }
    takenSlugs.add(slug);
    return false;
  });

  const [a, b] = await Promise.all([
    generateUniqueSlug('My Team', slugExists),
    generateUniqueSlug('My Team', slugExists),
  ]);

  expect(a).toBe('my-team');
  expect(b).toBe('my-team-1');
  expect(a).not.toBe(b);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/__tests__/slug.test.ts` around lines 61 - 73, The test
"produces consistent slugs across concurrent calls for different inputs" is
misleading as it only tests different inputs, not actual concurrency scenarios.
The takenSlugs Set is never populated, so it doesn't verify that concurrent
calls with the same input produce distinct suffixed slugs. Add a new test case
that calls generateUniqueSlug twice concurrently with the same input (e.g., 'My
Team'), implement the slugExists mock to properly track and detect collisions by
adding slugs to the Set after checking, and verify that the two concurrent calls
produce distinct results with one returning the base slug and the other
returning a suffixed variant (e.g., 'my-team' and 'my-team-1').

},
},
});
return reply.status(204).send({ message: 'User left' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

HTTP 204 should not include a response body.

Per HTTP specification, 204 No Content responses must not contain a message body. Either remove the body or change to 200 if a confirmation message is needed.

Proposed fix
-      return reply.status(204).send({ message: 'User left' });
+      return reply.status(204).send();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return reply.status(204).send({ message: 'User left' });
return reply.status(204).send();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/routes/event.ts` at line 209, HTTP 204 No Content responses
must not include a message body per HTTP specification. In the event route
handler where the status is set to 204, remove the message body being passed to
the send() method by either calling send() without arguments or send(undefined),
or alternatively change the status code to 200 if you need to include the
confirmation message in the response body.

Comment on lines +264 to +271
const response: PaginatedAttendeesResponse = {
attendees,
pagination: {
page,
limit,
total: (event as any).attendees.length,
},
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

pagination.total returns page length instead of total attendee count.

event.attendees.length is the count of items on the current page (after skip/take), not the total number of attendees. This breaks pagination UI—clients cannot determine total pages or display "showing 1-10 of 500".

Use a separate count query or include _count on the event to get the actual total.

Proposed fix using _count
     const event = await app.prisma.event.findUnique({
       where: { slug: paramsSlug },
       include: {
+        _count: {
+          select: { attendees: true },
+        },
         attendees: {
           include: {
             user: {

Then update the response:

     const response: PaginatedAttendeesResponse = {
       attendees,
       pagination: {
         page,
         limit,
-        total: (event as any).attendees.length,
+        total: event._count.attendees,
       },
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/routes/event.ts` around lines 264 - 271, The
pagination.total field in the PaginatedAttendeesResponse is incorrectly set to
event.attendees.length, which only reflects the count of attendees on the
current page after pagination, not the actual total count. To fix this, retrieve
the total attendee count separately using a count query (such as using _count in
your database query) before applying pagination skip/take operations, then use
this actual total count value instead of event.attendees.length when setting
pagination.total in the response object.

if (!(await slugExists(candidate))) { return candidate; }
}

throw new Error(`Unable to generate unique slug for "${name}" after ${MAX_SLUG_ATTEMPTS} attempts`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Error message undercounts the actual attempts.

The error message states "after 10 attempts" but the function actually performs 11 checks: one for the base slug (line 19) plus 10 suffixed candidates (lines 21-24). This could mislead debugging and operations.

📝 Proposed fix
-  throw new Error(`Unable to generate unique slug for "${name}" after ${MAX_SLUG_ATTEMPTS} attempts`);
+  throw new Error(`Unable to generate unique slug for "${name}" after ${MAX_SLUG_ATTEMPTS + 1} attempts`);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
throw new Error(`Unable to generate unique slug for "${name}" after ${MAX_SLUG_ATTEMPTS} attempts`);
throw new Error(`Unable to generate unique slug for "${name}" after ${MAX_SLUG_ATTEMPTS + 1} attempts`);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/backend/src/utils/slug.ts` at line 26, The error message thrown in the
slug generation function undercounts the actual attempts made. The function
performs one initial check for the base slug before the loop that generates
suffixed candidates, resulting in MAX_SLUG_ATTEMPTS + 1 total checks rather than
just MAX_SLUG_ATTEMPTS. Update the error message on the throw statement to
correctly reflect the actual number of attempts by either changing
MAX_SLUG_ATTEMPTS to MAX_SLUG_ATTEMPTS + 1 in the message, or adjusting the
message text to accurately state the total number of slug candidates evaluated.

@Ridanshi

Copy link
Copy Markdown
Author

Superseded by Dev-Card#557 which targets the correct repository.

@Ridanshi Ridanshi closed this Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Concurrent team creation can generate duplicate slugs and cause team namespace collisions

1 participant