Skip to content

Commit 2ff94b2

Browse files
authored
Fix participant creation race condition: use dedicated DB client for transactions (#2488)
1 parent 9ca3fd4 commit 2ff94b2

3 files changed

Lines changed: 329 additions & 118 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Test for race condition in concurrent participant creation for the same (zid, uid).
3+
*
4+
* Real-world scenarios where this happens:
5+
* 1. Double-click: user clicks vote/comment twice quickly, both requests carry
6+
* the same cookie/JWT and hit ensureParticipant concurrently
7+
* 2. Page load + immediate action: participationInit GET and a vote POST fire
8+
* nearly simultaneously for the same anonymous user
9+
* 3. Legacy cookie migration: request with permanent cookie triggers participant
10+
* lookup while another concurrent request also tries to ensure the participant
11+
* 4. Network retry: client retries a request while the original is still in-flight
12+
*
13+
* Root cause: addParticipant() in participant.ts uses pg.query() (pool-level)
14+
* for BEGIN/INSERT/COMMIT, but each call may grab a different connection from
15+
* the pool, breaking transaction isolation under concurrent load.
16+
*/
17+
import { describe, expect, test, beforeAll } from "@jest/globals";
18+
import { setupAuthAndConvo, newAgent } from "../setup/api-test-helpers";
19+
import { createAnonUser } from "../../src/auth/create-user";
20+
import pg from "../../src/db/pg-query";
21+
import { v4 as uuidv4 } from "uuid";
22+
23+
const NUM_CONCURRENT_REQUESTS = 10;
24+
25+
describe("Concurrent same-participant creation", () => {
26+
let conversationId: string;
27+
let zid: number;
28+
let commentId: number;
29+
30+
beforeAll(async () => {
31+
const setup = await setupAuthAndConvo({ commentCount: 1 });
32+
conversationId = setup.conversationId;
33+
commentId = setup.commentIds[0];
34+
35+
// Get zid from conversation
36+
zid = await new Promise<number>((resolve, reject) => {
37+
pg.query(
38+
"SELECT zid FROM zinvites WHERE zinvite = $1",
39+
[conversationId],
40+
(err: any, results: { rows: { zid: number }[] }) => {
41+
if (err) reject(err);
42+
else resolve(results.rows[0].zid);
43+
}
44+
);
45+
});
46+
});
47+
48+
test("concurrent votes with same legacy cookie should not produce 500 errors", async () => {
49+
// Create a user and participant (via direct DB calls, like the legacy-cookie test)
50+
const uid = await createAnonUser();
51+
52+
// Insert participant directly
53+
await new Promise((resolve, reject) => {
54+
pg.query(
55+
"INSERT INTO participants_extended (zid, uid) VALUES ($1, $2) ON CONFLICT DO NOTHING",
56+
[zid, uid],
57+
(err: any) => (err ? reject(err) : resolve(true))
58+
);
59+
});
60+
await new Promise((resolve, reject) => {
61+
pg.query(
62+
"INSERT INTO participants (pid, zid, uid, created) VALUES (NULL, $1, $2, default) RETURNING *",
63+
[zid, uid],
64+
(err: any) => (err ? reject(err) : resolve(true))
65+
);
66+
});
67+
68+
// Add a permanent cookie
69+
const permanentCookie = uuidv4().replace(/-/g, "");
70+
await new Promise((resolve, reject) => {
71+
pg.query(
72+
`INSERT INTO participants_extended (zid, uid, permanent_cookie)
73+
VALUES ($1, $2, $3)
74+
ON CONFLICT (zid, uid) DO UPDATE SET permanent_cookie = $3`,
75+
[zid, uid, permanentCookie],
76+
(err: any) => (err ? reject(err) : resolve(true))
77+
);
78+
});
79+
80+
// Fire N concurrent requests all using the same legacy cookie.
81+
// Each request triggers ensureParticipant → checkLegacyCookieAndIssueJWT.
82+
// Under the broken-transaction bug, concurrent lookups can miss the
83+
// existing participant and try to re-create it.
84+
const agents = Array.from({ length: NUM_CONCURRENT_REQUESTS }, () =>
85+
newAgent()
86+
);
87+
const resolvedAgents = await Promise.all(agents);
88+
89+
const promises = resolvedAgents.map((agent) =>
90+
agent
91+
.post("/api/v3/votes")
92+
.set("Cookie", `pc=${permanentCookie}`)
93+
.send({
94+
conversation_id: conversationId,
95+
tid: commentId,
96+
vote: 1,
97+
})
98+
);
99+
100+
const results = await Promise.allSettled(promises);
101+
102+
let successes = 0;
103+
let duplicateVotes = 0;
104+
let serverErrors = 0;
105+
106+
for (const result of results) {
107+
if (result.status === "rejected") {
108+
serverErrors++;
109+
console.error("Request rejected:", result.reason);
110+
continue;
111+
}
112+
const { status, text } = result.value;
113+
if (status === 200) {
114+
successes++;
115+
} else if (status === 406 && text?.includes("polis_err_vote_duplicate")) {
116+
duplicateVotes++;
117+
} else {
118+
serverErrors++;
119+
console.error(`Unexpected: status=${status}, body=${text}`);
120+
}
121+
}
122+
123+
console.log(
124+
`Legacy cookie concurrent: ${successes} ok, ${duplicateVotes} dup(406), ${serverErrors} errors`
125+
);
126+
127+
expect(serverErrors).toBe(0);
128+
expect(successes + duplicateVotes).toBe(NUM_CONCURRENT_REQUESTS);
129+
}, 30000);
130+
131+
test("concurrent comment+vote from same user should not produce 500 errors", async () => {
132+
// Simulate: user creates a participant via one endpoint while simultaneously
133+
// hitting another endpoint. Both trigger ensureParticipant for the same uid.
134+
135+
// Create a fresh anonymous user (no participant yet)
136+
const uid = await createAnonUser();
137+
138+
// Add a permanent cookie so the server can identify this user
139+
// (without a JWT, the server uses the cookie to find the uid)
140+
await new Promise((resolve, reject) => {
141+
pg.query(
142+
`INSERT INTO participants_extended (zid, uid, permanent_cookie)
143+
VALUES ($1, $2, $3)
144+
ON CONFLICT (zid, uid) DO UPDATE SET permanent_cookie = $3`,
145+
[zid, uid, uuidv4().replace(/-/g, "")],
146+
(err: any) => (err ? reject(err) : resolve(true))
147+
);
148+
});
149+
150+
// Get the cookie we just stored
151+
const cookieResult = await new Promise<string>((resolve, reject) => {
152+
pg.query(
153+
"SELECT permanent_cookie FROM participants_extended WHERE zid = $1 AND uid = $2",
154+
[zid, uid],
155+
(err: any, results: { rows: { permanent_cookie: string }[] }) => {
156+
if (err) reject(err);
157+
else resolve(results.rows[0].permanent_cookie);
158+
}
159+
);
160+
});
161+
162+
// NOTE: No participant row exists yet! Only participants_extended.
163+
// All requests below will try to create the participant via ensureParticipant.
164+
// We fire many concurrent requests to make the race condition reliable.
165+
166+
const NUM_AGENTS = 10;
167+
const agents = await Promise.all(
168+
Array.from({ length: NUM_AGENTS }, () => newAgent())
169+
);
170+
171+
// Mix of votes and comments — all using the same cookie (same uid)
172+
const promises = agents.map((agent, i) => {
173+
if (i % 2 === 0) {
174+
return agent
175+
.post("/api/v3/votes")
176+
.set("Cookie", `pc=${cookieResult}`)
177+
.send({
178+
conversation_id: conversationId,
179+
tid: commentId,
180+
vote: (i % 3) - 1,
181+
});
182+
} else {
183+
return agent
184+
.post("/api/v3/comments")
185+
.set("Cookie", `pc=${cookieResult}`)
186+
.send({
187+
conversation_id: conversationId,
188+
txt: `concurrent comment ${i} ${Date.now()}`,
189+
});
190+
}
191+
});
192+
193+
const results = await Promise.allSettled(promises);
194+
195+
let successes = 0;
196+
let duplicates = 0;
197+
let serverErrors = 0;
198+
199+
for (const result of results) {
200+
if (result.status === "rejected") {
201+
serverErrors++;
202+
console.error("Request rejected:", result.reason);
203+
continue;
204+
}
205+
const { status, text } = result.value;
206+
if (status === 200) {
207+
successes++;
208+
} else if (status === 406) {
209+
duplicates++;
210+
} else if (status >= 500) {
211+
serverErrors++;
212+
console.error(`Server error: status=${status}, body=${text}`);
213+
} else {
214+
successes++; // other 2xx/3xx are fine
215+
}
216+
}
217+
218+
console.log(
219+
`Concurrent creation: ${successes} ok, ${duplicates} dup, ${serverErrors} errors (of ${NUM_AGENTS})`
220+
);
221+
222+
expect(serverErrors).toBe(0);
223+
expect(successes + duplicates).toBe(NUM_AGENTS);
224+
}, 30000);
225+
});

server/src/auth/ensure-participant.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -229,20 +229,42 @@ async function _getOrCreateParticipant(
229229
return { pid: foundPid, isNewlyCreated: false };
230230
}
231231

232-
// Create new participant with constraint violation protection
233-
try {
234-
const rows = await addParticipantAndMetadata(zid, uid, req);
235-
return { pid: rows[0].pid, isNewlyCreated: true };
236-
} catch (createError) {
237-
// Handle race condition where another request created the participant
238-
if (isDuplicateKey(createError)) {
239-
const retryPid = await getPidPromise(zid, uid, true);
240-
if (retryPid !== -1) {
241-
return { pid: retryPid, isNewlyCreated: false };
232+
// Create new participant with constraint violation protection.
233+
// Retry loop handles the race where a concurrent request is creating the
234+
// same participant: the INSERT hits a duplicate key (23505), but the
235+
// concurrent transaction may not have committed yet so getPidPromise
236+
// can't find the row. We retry a few times to let it commit.
237+
const MAX_RETRIES = 3;
238+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
239+
try {
240+
const rows = await addParticipantAndMetadata(zid, uid, req);
241+
return { pid: rows[0].pid, isNewlyCreated: true };
242+
} catch (createError) {
243+
if (isDuplicateKey(createError)) {
244+
// Wait briefly for the concurrent transaction to commit
245+
if (attempt < MAX_RETRIES) {
246+
await new Promise((r) => setTimeout(r, 50 * (attempt + 1)));
247+
}
248+
const retryPid = await getPidPromise(zid, uid, true);
249+
if (retryPid !== -1) {
250+
return { pid: retryPid, isNewlyCreated: false };
251+
}
252+
if (attempt === MAX_RETRIES) {
253+
logger.error("Failed to find participant after retries", {
254+
zid,
255+
uid,
256+
attempts: MAX_RETRIES + 1,
257+
});
258+
throw createError;
259+
}
260+
// Otherwise loop and retry
261+
} else {
262+
throw createError;
242263
}
243264
}
244-
throw createError;
245265
}
266+
// Should not reach here, but TypeScript needs it
267+
throw new Error("Could not find or create participant");
246268
}
247269

248270
/**

0 commit comments

Comments
 (0)