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 eb42e71..1799ea2 100644 --- a/services/expo/src/routes/assignments.ts +++ b/services/expo/src/routes/assignments.ts @@ -1,5 +1,6 @@ import express from "express"; -import { asyncHandler, BadRequestError, ConfigError } from "@api/common"; +import { createHash } from "crypto"; +import { asyncHandler, BadRequestError, ConfigError, getAllSmallest } from "@api/common"; import { prisma } from "../common"; import { getConfig, isAdminOrIsJudging } from "../utils/utils"; @@ -104,88 +105,88 @@ 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 => { + // 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!, + 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) } }, + }; + } + + // 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: { + id: true, + categories: true, + assignment: { + select: { categoryIds: true, status: true }, }, }, - categories: true, - }, - }); + }); - if (projectsWithMatchingCategories.length === 0) { - return null; - } + // Only eligible if no judge is currently assigned (QUEUED) + const eligible = projects.filter(p => { + const queued = p.assignment.filter(a => a.status === "QUEUED").length; + return queued === 0; + }); + if (eligible.length === 0) return null; + + 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)]; + + 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)` + ); + } - // 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); - } + let categoriesToJudge = selected.categories.filter(c => judgeCategoryIds.includes(c.id)); + if (defaultCategories.length > 0) { + categoriesToJudge = categoriesToJudge.concat(defaultCategories); + } - const createdAssignment = await prisma.assignment.create({ - data: { - userId: judgeToAssign.id, - projectId: selectedProject.id, - status: AssignmentStatus.QUEUED, - categoryIds: categoriesToJudge.map(category => category.id), - }, + 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();