fix(cards): add Serializable isolation + P2034 retry to setDefaultCard#584
fix(cards): add Serializable isolation + P2034 retry to setDefaultCard#584Srejoye wants to merge 2 commits into
Conversation
setDefaultCard ran its two-step clear-then-set under Postgres's default Read Committed isolation. Two concurrent calls for the same user could interleave (Tx2's updateMany landing after Tx1's update), wiping the newly-set default flag and leaving the user with zero isDefault cards — violating the invariant upheld by createCard and deleteCard.
Fix mirrors the pattern already used in createCard:
- Pass { isolationLevel: 'Serializable' } to so Postgres detects write-write conflicts between concurrent setDefaultCard calls and aborts one with error code P2034.
- Wrap the call in a retry loop (max 3 attempts), continuing on P2034 and re-throwing immediately on any other error.
Tests added:
- Asserts isolationLevel: 'Serializable' is forwarded to .
- Retry on P2034: succeeds on second attempt.
- All 3 retries exhausted on persistent P2034 → 500.
- Non-P2034 errors are not retried → single attempt → 500.
- Concurrent calls: one serialization abort → retry → both callers receive 200, confirming the retry loop handles the race correctly.
Affected: apps/backend/src/services/cardService.ts
Tests: apps/backend/src/__tests__/cards.test.ts
|
@Srejoye is attempting to deploy a commit to the Prashantkumar Khatri's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Hi @Srejoye, Thanks for opening this pull request. This PR has been automatically classified based on the files modified. Applied Labels
Primary Review Area
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! |
CI — All Checks PassedBackend — PASS
Mobile — SKIP
Web — SKIP
Last updated: |
Harxhit
left a comment
There was a problem hiding this comment.
Checked this out and tested locally. The fix faithfully mirrors the established createCard retry pattern, and the lost-update analysis in the description is accurate — under Read Committed the updateMany/update pair could interleave and leave a user with zero defaults.
Verified ✅
npm run typecheck→ clean (tsc--noEmit, exit 0).npx vitest run src/__tests__/cards.test.ts→ 26/26 pass, including all 5 new cases (isolation-option forwarding, retry-then-succeed, retry exhaustion, no-retry on non-P2034, concurrent race).npx eslinton both changed files → clean (exit 0).- Confirmed the route handler (
src/routes/cards.ts:137) wraps the call intry/catch → handleDbError, so a thrown error after exhaustion does surface as an error response, and the existencefindFirstcorrectly sits outside the retry loop. - Diff is tightly scoped: only
setDefaultCard+ its tests, no unrelated reformatting.
Suggestions
- (Medium) Real P2034 exhaustion returns 400, not 500. On the final attempt
attempt < maxRetriesis false, so the original error is re-thrown — and a genuine Prisma serialization failure is aPrismaClientKnownRequestErrorwithcode: 'P2034'. InhandleDbErrorthat lands in thePrismaClientKnownRequestErrorbranch and falls through tostatus(400)(Database error: ...), whereas the tests assert 500 (they throw a plain object, which skips that branch). Real and tested behaviour diverge here. Consider re-throwing the trailingError('Failed to set default card...')for the exhausted case (so it maps to 500), or handling P2034 explicitly inhandleDbError— a persistent serialization conflict is arguably a 503/500, not a 400 client error. - (Low)
throw new Error('Failed to set default card...')is effectively unreachable for P2034. Because exhaustion re-throws the original error from inside the loop, the post-loopthrowonly fires in theory. Pairing it with suggestion #1 would make it the actual exhaustion path. - (Nit) The P2034 check is now duplicated verbatim in
createCardandsetDefaultCard. The PR description references anisP2034(error)helper that isn't in the diff (the check is inlined). Extracting a smallisP2034(error: unknown): booleanwould DRY both call sites and match the description. - (Nit) Concurrent-call test is a loose assertion.
toBeGreaterThanOrEqual(2)with shared mutable mock state verifies "both eventually 200" rather than true serialization semantics. Fine as a regression guard, but the comment overstates what it proves.
Overall: correct, well-tested, and cleanly scoped — just worth aligning the exhaustion status code with the tests before merge.
Reviewed locally on branch fix/566-set-default-card-serializable-isolation: typecheck + 26/26 vitest + eslint all green.
Summary
Closes #566.
setDefaultCardran its clear-then-set pair under Postgres's default Read Committed isolation. Two concurrent calls for the same user could interleave so that theupdateManyfrom Tx2 (clear all defaults) committed after Tx1'supdate(set card A as default), wiping the flag without any replacement — leaving the user with zeroisDefault: truecards.This silently breaks any UI or API logic that assumes exactly one default card exists per user (the invariant that
createCardanddeleteCardboth uphold carefully).Root cause
Under Read Committed, the two writes are not serialized against concurrent transactions touching the same rows, so lost-update interleavings are possible.
Fix
Mirror the pattern already used in
createCard:{ isolationLevel: 'Serializable' }to$transaction— Postgres will detect write-write conflicts and abort one of the concurrent transactions withP2034.P2034and re-throwing immediately on any other error.Files changed
apps/backend/src/services/cardService.tsisolationLevel: 'Serializable'+ P2034 retry loop tosetDefaultCardapps/backend/src/__tests__/cards.test.tsTests
New test cases in
describe('PUT /api/cards/:id/default — serialization isolation & retry'):isolationLevel: 'Serializable'is passed to$transactionChecklist
createCard