Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions common/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(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);
}
149 changes: 75 additions & 74 deletions services/expo/src/routes/assignments.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -104,88 +105,88 @@ const autoAssign = async (judgeId: number): Promise<Assignment | null> => {
// 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();
Expand Down