From 2162f46efc514c861cd6bb135a39fa23cf2b3f76 Mon Sep 17 00:00:00 2001 From: Albert Ying Date: Thu, 23 Apr 2026 23:05:44 -0400 Subject: [PATCH 1/5] 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/5] 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/5] 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; From 458e74313681f61f35fbde784cb4598717b74fb5 Mon Sep 17 00:00:00 2001 From: crystaltine Date: Tue, 9 Jun 2026 19:46:41 -0700 Subject: [PATCH 4/5] optimize min-judged project selection --- common/src/util.ts | 27 +++++++++++++++++++++++++ services/expo/src/routes/assignments.ts | 9 +++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/common/src/util.ts b/common/src/util.ts index 6530052..667b2b3 100644 --- a/common/src/util.ts +++ b/common/src/util.ts @@ -15,3 +15,30 @@ export const getFullName = (name?: { first: string; middle?: string; last: strin return `${name.first.trim()} ${name.last.trim()}`; }; + +/** + * returns all items from an array that are the "smallest", as defined by `key`, + * or if key isnt provided, just by the items themselves. + * + * Use this instead of sorting and taking [0] and filtering by that + * + * @param arr array to search through + * @param key function to get the key that's used to define "smallest" + * @returns an array of all items that are the smallest according to `key` + */ +export function getAllSmallest(arr: T[], key?: (item: T) => number): T[] { + if (arr.length === 0) return []; + + const getValue = key || ((item: T) => item); + + let minValue = getValue(arr[0]); + + for (const item of arr) { + const value = getValue(item); + if (value < minValue) { + minValue = value; + } + } + + return arr.filter(item => getValue(item) === minValue); +} diff --git a/services/expo/src/routes/assignments.ts b/services/expo/src/routes/assignments.ts index ffb2355..c408917 100644 --- a/services/expo/src/routes/assignments.ts +++ b/services/expo/src/routes/assignments.ts @@ -1,5 +1,5 @@ import express from "express"; -import { asyncHandler, BadRequestError, ConfigError } from "@api/common"; +import { asyncHandler, BadRequestError, ConfigError, getAllSmallest } from "@api/common"; import { prisma } from "../common"; import { getConfig, isAdminOrIsJudging } from "../utils/utils"; @@ -146,13 +146,10 @@ const autoAssign = async (judgeId: number): Promise => { }); if (eligible.length === 0) return null; + // select a random project among the ones that have the least completed assignments const completedCount = (p: (typeof eligible)[number]) => p.assignment.filter(a => a.status === "COMPLETED").length; - - eligible.sort((a, b) => completedCount(a) - completedCount(b)); - - const lowest = completedCount(eligible[0]); - const candidates = eligible.filter(p => completedCount(p) === lowest); + const candidates = getAllSmallest(eligible, completedCount); const selected = candidates[Math.floor(Math.random() * candidates.length)]; const alreadyQueued = selected.assignment.filter(a => a.status === "QUEUED").length; From ad697c079e3dbf6ee216e290eb3789778ba1973c Mon Sep 17 00:00:00 2001 From: crystaltine Date: Mon, 15 Jun 2026 19:47:50 -0700 Subject: [PATCH 5/5] fix autoAssign assignment filtering logic + make db lock unique (pr fix) --- services/expo/src/routes/assignments.ts | 38 ++++++++++++++++--------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/services/expo/src/routes/assignments.ts b/services/expo/src/routes/assignments.ts index c408917..1799ea2 100644 --- a/services/expo/src/routes/assignments.ts +++ b/services/expo/src/routes/assignments.ts @@ -1,4 +1,5 @@ import express from "express"; +import { createHash } from "crypto"; import { asyncHandler, BadRequestError, ConfigError, getAllSmallest } from "@api/common"; import { prisma } from "../common"; @@ -107,9 +108,14 @@ const autoAssign = async (judgeId: number): Promise => { const defaultCategories = judgeCategories.filter(category => category.isDefault); 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)`; + // Scoped advisory lock: serializes auto-assign calls for the same + // hexathon/expo/round so unrelated contexts don't contend on the same lock + // have to use a goofy hash because no strings + const lockKey = createHash("sha256") + .update(`${config.currentHexathon}:${config.currentExpo}:${config.currentRound}`) + .digest() + .readBigInt64BE(0); + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockKey})`; const projectFilter: Prisma.ProjectWhereInput = { hexathon: config.currentHexathon!, @@ -124,6 +130,9 @@ const autoAssign = async (judgeId: number): Promise => { }; } + // Fetch *all* assignments for each candidate project, + // we'll compute category-overlap counts manually so we dont miss queued + // assignments from other judges for non-overlapping categories (thx copilot) const projects = await tx.project.findMany({ where: projectFilter, select: { @@ -131,10 +140,6 @@ const autoAssign = async (judgeId: number): Promise => { categories: true, assignment: { select: { categoryIds: true, status: true }, - where: { - status: { in: ["QUEUED", "COMPLETED"] }, - categoryIds: { hasSome: judgeCategories.map(c => c.id) }, - }, }, }, }); @@ -146,9 +151,18 @@ const autoAssign = async (judgeId: number): Promise => { }); if (eligible.length === 0) return null; - // select a random project among the ones that have the least completed assignments - const completedCount = (p: (typeof eligible)[number]) => - p.assignment.filter(a => a.status === "COMPLETED").length; + const judgeCategoryIds = judgeCategories.map(c => c.id); + + // select a random project among the ones that have the least "relevant" assignments + // Count only completed assignments that ALSO overlap the judge's categories. + // If an assignment is completed but none of the categories overlap, we can still + // be comfortable judging this project (so that asmt won't count toward this total) + // tldr: higher completedCount = less chance of being judged + const completedCount = (proj: (typeof eligible)[number]) => + proj.assignment.filter( + asmt => + asmt.status === "COMPLETED" && asmt.categoryIds.some(id => judgeCategoryIds.includes(id)) + ).length; const candidates = getAllSmallest(eligible, completedCount); const selected = candidates[Math.floor(Math.random() * candidates.length)]; @@ -159,9 +173,7 @@ const autoAssign = async (judgeId: number): Promise => { ); } - let categoriesToJudge = selected.categories.filter(c => - judgeCategories.map(jc => jc.id).includes(c.id) - ); + let categoriesToJudge = selected.categories.filter(c => judgeCategoryIds.includes(c.id)); if (defaultCategories.length > 0) { categoriesToJudge = categoriesToJudge.concat(defaultCategories); }