From 2162f46efc514c861cd6bb135a39fa23cf2b3f76 Mon Sep 17 00:00:00 2001 From: Albert Ying Date: Thu, 23 Apr 2026 23:05:44 -0400 Subject: [PATCH 1/3] race condition in auto assign --- services/expo/src/routes/assignments.ts | 129 +++++++++++------------- 1 file changed, 57 insertions(+), 72 deletions(-) diff --git a/services/expo/src/routes/assignments.ts b/services/expo/src/routes/assignments.ts index eb42e71..31ccbdb 100644 --- a/services/expo/src/routes/assignments.ts +++ b/services/expo/src/routes/assignments.ts @@ -104,88 +104,73 @@ const autoAssign = async (judgeId: number): Promise => { // isStarted = false; // } - // Where clause for finding projects - const projectFilter: Prisma.ProjectWhereInput = { - hexathon: config.currentHexathon, - expo: config.currentExpo, - round: config.currentRound, - assignment: { - none: { - userId: judgeToAssign.id, - }, - }, - }; - - // If the judge's category group doesn't have a default category, we need to add - // in the project filter. Otherwise, the judge can judge any project. const defaultCategories = judgeCategories.filter(category => category.isDefault); - if (defaultCategories.length === 0) { - projectFilter.categories = { - some: { - id: { in: judgeCategories.map(category => category.id) }, - }, + + return await prisma.$transaction(async tx => { + // Global advisory lock: serializes all auto-assign calls so COUNT reads + // and inserts are always atomic with no stale data across all groups + await tx.$executeRaw`SELECT pg_advisory_xact_lock(1)`; + + const projectFilter: Prisma.ProjectWhereInput = { + hexathon: config.currentHexathon!, + expo: config.currentExpo, + round: config.currentRound, + assignment: { none: { userId: judgeToAssign.id } }, }; - } - // Get projects from the appropriate expo/round, where at least some of the project's categories match - // the judge's categories and where the project has not been assigned to the judge before - const projectsWithMatchingCategories = await prisma.project.findMany({ - where: projectFilter, - select: { - id: true, - assignment: { - select: { - categoryIds: true, - }, - where: { - status: { - in: ["QUEUED", "COMPLETED"], - }, - categoryIds: { - hasSome: judgeCategories.map(category => category.id), + if (defaultCategories.length === 0) { + projectFilter.categories = { + some: { id: { in: judgeCategories.map(c => c.id) } }, + }; + } + + const projects = await tx.project.findMany({ + where: projectFilter, + select: { + id: true, + categories: true, + assignment: { + select: { categoryIds: true, status: true }, + where: { + status: { in: ["QUEUED", "COMPLETED"] }, + categoryIds: { hasSome: judgeCategories.map(c => c.id) }, }, }, }, - categories: true, - }, - }); + }); - if (projectsWithMatchingCategories.length === 0) { - return null; - } + const eligible = projects.filter(p => p.assignment.length < 3); + if (eligible.length === 0) return null; - // Sort projects by number of assignments that match the judge's categories - projectsWithMatchingCategories.sort((p1, p2) => p1.assignment.length - p2.assignment.length); - - // Find the project(s) with the lowest number of assignments that match the judge's categories - // Then, pick a random project from the projects with the lowest number of assignments - const lowestAssignmentCount = projectsWithMatchingCategories[0].assignment.length; - const projectsWithLowestAssignmentCount = projectsWithMatchingCategories.filter( - proj => proj.assignment.length === lowestAssignmentCount - ); - const selectedProject = - projectsWithLowestAssignmentCount[ - Math.floor(Math.random() * projectsWithLowestAssignmentCount.length) - ]; - - // Filter categories to only include categories that the judge is assigned to. - // If judge's category group judges a default category, add it to the categories to judge - let categoriesToJudge = selectedProject.categories.filter(category => - judgeCategories.map(judgeCategory => judgeCategory.id).includes(category.id) - ); - if (defaultCategories.length > 0) { - categoriesToJudge = categoriesToJudge.concat(defaultCategories); - } + eligible.sort((a, b) => a.assignment.length - b.assignment.length); - const createdAssignment = await prisma.assignment.create({ - data: { - userId: judgeToAssign.id, - projectId: selectedProject.id, - status: AssignmentStatus.QUEUED, - categoryIds: categoriesToJudge.map(category => category.id), - }, + const lowest = eligible[0].assignment.length; + const candidates = eligible.filter(p => p.assignment.length === lowest); + const selected = candidates[Math.floor(Math.random() * candidates.length)]; + + const alreadyQueued = selected.assignment.filter(a => a.status === "QUEUED").length; + if (alreadyQueued > 0) { + console.warn( + `----- [CONCURRENT] Project ${selected.id} assigned to judge ${judgeId} while already QUEUED by ${alreadyQueued} other judge(s)` + ); + } + + let categoriesToJudge = selected.categories.filter(c => + judgeCategories.map(jc => jc.id).includes(c.id) + ); + if (defaultCategories.length > 0) { + categoriesToJudge = categoriesToJudge.concat(defaultCategories); + } + + return await tx.assignment.create({ + data: { + userId: judgeToAssign.id, + projectId: selected.id, + status: AssignmentStatus.QUEUED, + categoryIds: categoriesToJudge.map(c => c.id), + }, + }); }); - return createdAssignment; }; export const assignmentRoutes = express.Router(); From afa87a039b481e927ead6f9d9e918ece28c54333 Mon Sep 17 00:00:00 2001 From: Albert Ying Date: Thu, 23 Apr 2026 23:13:23 -0400 Subject: [PATCH 2/3] limit to one active judge at a time --- services/expo/src/routes/assignments.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/services/expo/src/routes/assignments.ts b/services/expo/src/routes/assignments.ts index 31ccbdb..2adaddd 100644 --- a/services/expo/src/routes/assignments.ts +++ b/services/expo/src/routes/assignments.ts @@ -139,13 +139,21 @@ const autoAssign = async (judgeId: number): Promise => { }, }); - const eligible = projects.filter(p => p.assignment.length < 3); + // Only eligible if no judge is currently assigned (QUEUED) and completed count under cap + const eligible = projects.filter(p => { + const queued = p.assignment.filter(a => a.status === "QUEUED").length; + const completed = p.assignment.filter(a => a.status === "COMPLETED").length; + return queued === 0 && completed < 3; + }); if (eligible.length === 0) return null; - eligible.sort((a, b) => a.assignment.length - b.assignment.length); + const completedCount = (p: (typeof eligible)[number]) => + p.assignment.filter(a => a.status === "COMPLETED").length; + + eligible.sort((a, b) => completedCount(a) - completedCount(b)); - const lowest = eligible[0].assignment.length; - const candidates = eligible.filter(p => p.assignment.length === lowest); + const lowest = completedCount(eligible[0]); + const candidates = eligible.filter(p => completedCount(p) === lowest); const selected = candidates[Math.floor(Math.random() * candidates.length)]; const alreadyQueued = selected.assignment.filter(a => a.status === "QUEUED").length; From 7249eaf14549c0b3fd2ac6e323ca24b471bf7433 Mon Sep 17 00:00:00 2001 From: Albert Ying Date: Tue, 5 May 2026 01:03:47 -0400 Subject: [PATCH 3/3] remove hard cap of 3 --- services/expo/src/routes/assignments.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/expo/src/routes/assignments.ts b/services/expo/src/routes/assignments.ts index 2adaddd..ffb2355 100644 --- a/services/expo/src/routes/assignments.ts +++ b/services/expo/src/routes/assignments.ts @@ -139,11 +139,10 @@ const autoAssign = async (judgeId: number): Promise => { }, }); - // Only eligible if no judge is currently assigned (QUEUED) and completed count under cap + // Only eligible if no judge is currently assigned (QUEUED) const eligible = projects.filter(p => { const queued = p.assignment.filter(a => a.status === "QUEUED").length; - const completed = p.assignment.filter(a => a.status === "COMPLETED").length; - return queued === 0 && completed < 3; + return queued === 0; }); if (eligible.length === 0) return null;