fix: prevent race condition in challenge submission (#187)#246
fix: prevent race condition in challenge submission (#187)#246
Conversation
Use atomic conditional UPDATE/INSERT with RETURNING to ensure only one concurrent request can complete a challenge, award XP, and fire PostHog events. Adds a unique index on (userId, challengeId) in userProgress to back the onConflictDoNothing INSERT path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Claude finished @pbrissaud's task in 2m 3s —— View job PR Review
SummaryThis PR correctly addresses the race condition in CorrectnessAtomic UPDATE path — The Atomic INSERT path — Migration — The SQL migration only adds a Potential Issues1. const [completedCount] = await ctx.db
.select({ count: count() })
.from(userProgress)
.where(
and(eq(userProgress.userId, userId), eq(userProgress.status, "completed")),
);
const isFirstChallenge = (completedCount?.count ?? 0) === 0;For the same-challenge race case, this is fine — only the winner gets past 2. const oldXp = existingXp?.totalXp ?? 0;
const newXp = oldXp + xpGain.total;
if (existingXp) {
await ctx.db.update(userXp).set({ totalXp: newXp, ... })...
}This is a read-modify-write on 3. The Minor Nits4. Early if (existingProgress?.status === "completed") {
throw new Error("Challenge already completed");
}This check remains from before the fix. It's still useful as a fast-path for intentional re-submissions (returns a clear error vs. silently returning 5. return { success: true, xpAwarded: 0, totalXp: 0, rank: "", rankUp: false, ... };An empty string for VerdictThe core fix is correct and well-scoped. The |
…eChallenge - Move isFirstChallenge check to after the atomic progress write (only winners evaluate it) and use userXpTransaction count instead of userProgress count — reduces the first-challenge bonus race window for concurrent submissions of different challenges - Replace read-modify-write on userXp.totalXp with an atomic INSERT … ON CONFLICT DO UPDATE SET totalXp = totalXp + delta in both submitChallenge and completeChallenge - Apply the same atomic conditional update/insert pattern to completeChallenge (web UI path) which still used the old non-atomic SELECT-then-UPDATE/INSERT - Add comments clarifying the early-return throws are UX fast-paths, not race guards - Return rank: null (instead of "") in the submitChallenge race-lost early return Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Claude finished @pbrissaud's task in 1m 40s —— View job PR Review
SummaryThe follow-up commit ( Issues Resolved by Follow-up Commit ✅
Remaining Observations1. First-challenge race window is reduced, not eliminated ( The comment says concurrent winners "see each other's committed transaction" — this is only true if one winner has already reached the 2. Unused const _newXp = updatedXp?.totalXp ?? xpGain.total;This value is assigned but never used. The underscore prefix suppresses the TS warning, but if 3. No DB transaction wrapping (pre-existing) The sequence VerdictThe core fix is solid and the follow-up commit resolved all the significant concerns from the previous review. The atomic |
Summary
uniqueIndexon(userId, challengeId)inuserProgressto enforce one row per user/challenge at the DB levelsubmitChallengewith conditional atomic operations using.returning()to detect if the race was lostHow it works
UPDATE path (row exists): adds
ne(status, "completed")to the WHERE clause — only proceeds if status wasn't already flipped by a concurrent request.INSERT path (no row yet): uses
.onConflictDoNothing()backed by the new unique index — concurrent inserts silently no-op and return an empty array.In both cases, an empty RETURNING result means the race was lost and we return
{ success: true, xpAwarded: 0 }without awarding XP or firing analytics events.Test plan
userProgress.status = completedxpAwarded: 0, no duplicate XP transactionsuserXpTransactionrow per challenge per user in DB🤖 Generated with Claude Code