Skip to content

fix(cards): prevent concurrent default card race condition (#344)#385

Open
udaycodespace wants to merge 2 commits into
Dev-Card:mainfrom
udaycodespace:fix/344-concurrent-default-card-race
Open

fix(cards): prevent concurrent default card race condition (#344)#385
udaycodespace wants to merge 2 commits into
Dev-Card:mainfrom
udaycodespace:fix/344-concurrent-default-card-race

Conversation

@udaycodespace
Copy link
Copy Markdown

@udaycodespace udaycodespace commented May 29, 2026

Summary

Fixes a race condition during concurrent first-card creation where multiple cards could be assigned isDefault: true for the same user.

The first-card initialization flow now executes inside a Prisma Serializable transaction with bounded retry handling for serialization conflicts, ensuring deterministic behavior under concurrent requests.

Closes #344

Type of Change

  • Bug fix

What Changed

Concurrency Fix

  • Updated apps/backend/src/services/cardService.ts to wrap first-card creation in a Prisma Serializable transaction.
  • Added bounded retry handling for P2034 serialization conflicts to safely recover from concurrent transaction failures.
  • Added regression tests in apps/backend/src/__tests__/cards.test.ts covering Serializable transaction usage and retry behavior.

Review Feedback Updates (commit bc1cc06)

  • Removed TypeScript-unsafe any usage from the affected service and test code.
  • Added typed response handling for card mappings.
  • Added app.log.error(...) before rethrowing unexpected failures.
  • Improved test typing for transaction mocks and retry scenarios.
  • Kept the original Serializable transaction + retry implementation unchanged.

How to Test

  1. Run the test suite and verify all existing tests continue to pass.
  2. Verify the regression tests covering Serializable transactions and P2034 retry handling pass successfully.
  3. Confirm card creation behavior remains unchanged while ensuring concurrent first-card creation cannot result in multiple default cards.

Checklist

  • My code follows the project's coding style (pnpm -r run lint passes).
  • TypeScript compiles without errors (pnpm -r run typecheck passes).
  • I have added or updated tests for the changes I made.
  • All tests pass locally.
  • No new console.log or debug statements left in the code.

Validation Proof

Unit Tests

Command:

pnpm --filter backend run test src/__tests__/cards.test.ts

Result:

  • Test Files: 1 passed
  • Tests: 23 passed (23/23)

Lint

Command:

pnpm -r run lint

Result:

  • Passed successfully

Diff Summary

git diff --stat

Result:

apps/backend/src/__tests__/cards.test.ts | 16 ++++++++--------
apps/backend/src/services/cardService.ts | 20 +++++++++++---------
2 files changed, 19 insertions(+), 17 deletions(-)

Additional Context

The previous implementation relied on a standalone card.count() check before card creation, which introduced a TOCTOU race condition under concurrent requests.

Using Serializable transaction isolation prevents concurrent transactions from successfully creating multiple default cards for the same user, while bounded retry handling allows serialization conflicts to be resolved transparently without impacting user experience.

Note

Commit bc1cc06 was added after the initial PR submission to address maintainer review feedback related to TypeScript safety, typed responses, logging, and test improvements. The underlying concurrency-fix approach (Serializable transaction + P2034 retry handling) remains unchanged.

@Harxhit Harxhit added the gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking. label May 29, 2026
function wireTransaction() {
mockPrisma.$transaction.mockImplementation(
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma),
async (callback: (tx: typeof mockPrisma) => Promise<unknown>, options?: any) => callback(mockPrisma),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we not use any breaks ts safety.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in the latest commit.

Removed the any usage from the transaction mock and switched to type-safe typings throughout the affected test code.


return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
} catch (error: any) {
if (error.code === 'P2034' && attempt < MAX_RETRIES) continue;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We have handleDB error util function can you use that here?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I checked this one while updating the review comments.

handleDbError currently requires Fastify request/reply objects and is already used in the route handlers. Since cardService.ts is a service-layer module, I kept error propagation there and retained handleDbError usage in the route layer to preserve the existing separation of concerns.

I did add logging for unexpected failures before rethrowing and removed the TypeScript-unsafe error handling in the service.

return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
} catch (error: any) {
if (error.code === 'P2034' && attempt < MAX_RETRIES) continue;
throw error;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can you add app.log.error here

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in the latest update.

Added app.log.error(error) before rethrowing unexpected failures while preserving the existing retry flow for P2034 serialization conflicts.

isolationLevel: 'Serializable' as Prisma.TransactionIsolationLevel
})

return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Return typed response please

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed in the latest update.

Replaced the previous any-based response mapping with a typed response structure and updated the card/link mapping to operate on typed data instead of untyped objects.

@Harxhit
Copy link
Copy Markdown
Collaborator

Harxhit commented May 30, 2026

Could please also add unit and lint tests terminal proof in the PR description.

@udaycodespace udaycodespace requested a review from Harxhit May 30, 2026 05:55
@udaycodespace
Copy link
Copy Markdown
Author

Could please also add unit and lint tests terminal proof in the PR description.

Addressed all review comments in the latest commit:

  • Removed TypeScript-unsafe any usage
  • Added typed response handling
  • Added app.log.error(...) before rethrowing unexpected failures
  • Preserved Serializable transaction + P2034 retry behavior
  • Added unit test and lint validation proof to the PR description

Please re-review when convenient. Thanks!

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

Labels

gssoc:approved Required label for every approved PR. Gives the base +50 points and enables contribution tracking.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[CONSISTENCY] Concurrent first-card creation may allow multiple default cards per user

2 participants