diff --git a/app/features/tournament/TournamentRepository.finalize.test.ts b/app/features/tournament/TournamentRepository.finalize.test.ts index abbd1ae3d..bd7270662 100644 --- a/app/features/tournament/TournamentRepository.finalize.test.ts +++ b/app/features/tournament/TournamentRepository.finalize.test.ts @@ -6,6 +6,14 @@ import { db } from "~/db/sql"; import type { TournamentSummary } from "../tournament-bracket/core/summarizer.server"; import * as TournamentRepository from "./TournamentRepository.server"; +/** SQLite binds at most 32,766 parameters per statement and `PlayerResult` has + * eight columns, so one multi-row insert fits this many rows at most. */ +const PLAYER_RESULT_ROWS_PER_STATEMENT = Math.floor(32766 / 8); + +/** Enough users that every ordered pair of them, for both types, is over + * {@link PLAYER_RESULT_ROWS_PER_STATEMENT}. */ +const USER_COUNT = 46; + const users = UserFactory.pool(); const createTournament = () => @@ -35,10 +43,37 @@ const finalizePriorSeason = async ( }); }; +/** Every ordered pair of the given users, once as mates and once as enemies. */ +const playerResultDeltasForEveryPair = ( + userIds: number[], +): TournamentSummary["playerResultDeltas"] => { + const deltas: TournamentSummary["playerResultDeltas"] = []; + + for (const type of ["MATE", "ENEMY"] as const) { + for (const ownerUserId of userIds) { + for (const otherUserId of userIds) { + if (ownerUserId === otherUserId) continue; + + deltas.push({ + ownerUserId, + otherUserId, + mapWins: 1, + mapLosses: 0, + setWins: 1, + setLosses: 0, + type, + }); + } + } + } + + return deltas; +}; + describe("TournamentRepository.finalize", () => { beforeEach(async () => { - // four users so that the "1-2-3-4" team identifier the tests use names real ones - await users.create(4); + // the "1-2-3-4" team identifier the tests use needs users 1-4 to be real ones + await users.create(USER_COUNT); }); test("matchesCount on a new season's Skill row does not include prior seasons", async () => { @@ -209,4 +244,26 @@ describe("TournamentRepository.finalize", () => { expect(second.matchesCount).toBe(8); }); + test("finalizes a tournament with more player result deltas than fit in one insert statement", async () => { + const { id: tournamentId } = await createTournament(); + const playerResultDeltas = playerResultDeltasForEveryPair(users.ids()); + + expect(playerResultDeltas.length).toBeGreaterThan( + PLAYER_RESULT_ROWS_PER_STATEMENT, + ); + + await TournamentRepository.finalize({ + tournamentId, + season: 1, + summary: { ...emptySummary([]), playerResultDeltas }, + }); + + const inserted = await db + .selectFrom("PlayerResult") + .select(({ fn }) => fn.countAll().as("count")) + .where("season", "=", 1) + .executeTakeFirstOrThrow(); + + expect(inserted.count).toBe(playerResultDeltas.length); + }); }); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 3513756b0..d7bfa1587 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -1397,6 +1397,11 @@ export function reopenTournament(tournamentId: number) { }); } +/** How many rows one multi-row insert of the summary binds at a time. SQLite + * rejects any statement binding over 32,766 parameters, a ceiling a big + * tournament's deltas cross when inserted as a single statement. */ +const SUMMARY_INSERT_CHUNK_SIZE = 1000; + /** * Finalizes a tournament, recording the full summary: skills, seeding skills, * map/player result deltas, badge owners and placements. Use @@ -1469,94 +1474,111 @@ export function finalize({ } } - await trx - .insertInto("SkillTeamUser") - .values(skillTeamUsers) - .onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing()) - .execute(); + for (const chunk of R.chunk(skillTeamUsers, SUMMARY_INSERT_CHUNK_SIZE)) { + await trx + .insertInto("SkillTeamUser") + .values(chunk) + .onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing()) + .execute(); + } // SeedingSkill has `on conflict replace` set in its migration - await trx - .insertInto("SeedingSkill") - .values( - summary.seedingSkills.map((seedingSkill) => ({ - type: seedingSkill.type, - mu: seedingSkill.mu, - sigma: seedingSkill.sigma, - ordinal: seedingSkill.ordinal, - userId: seedingSkill.userId, - })), - ) - .execute(); - - if (summary.mapResultDeltas.length > 0) { - invariant(seasonValue !== null, "Season missing for map result"); + for (const chunk of R.chunk( + summary.seedingSkills, + SUMMARY_INSERT_CHUNK_SIZE, + )) { await trx - .insertInto("MapResult") + .insertInto("SeedingSkill") .values( - summary.mapResultDeltas.map((mapResultDelta) => ({ - mode: mapResultDelta.mode, - stageId: mapResultDelta.stageId, - userId: mapResultDelta.userId, - wins: mapResultDelta.wins, - losses: mapResultDelta.losses, - season: seasonValue, + chunk.map((seedingSkill) => ({ + type: seedingSkill.type, + mu: seedingSkill.mu, + sigma: seedingSkill.sigma, + ordinal: seedingSkill.ordinal, + userId: seedingSkill.userId, })), ) - .onConflict((oc) => - oc - .columns(["userId", "stageId", "mode", "season"]) - .doUpdateSet((eb) => ({ - wins: eb("MapResult.wins", "+", eb.ref("excluded.wins")), - losses: eb("MapResult.losses", "+", eb.ref("excluded.losses")), - })), - ) .execute(); } + if (summary.mapResultDeltas.length > 0) { + invariant(seasonValue !== null, "Season missing for map result"); + for (const chunk of R.chunk( + summary.mapResultDeltas, + SUMMARY_INSERT_CHUNK_SIZE, + )) { + await trx + .insertInto("MapResult") + .values( + chunk.map((mapResultDelta) => ({ + mode: mapResultDelta.mode, + stageId: mapResultDelta.stageId, + userId: mapResultDelta.userId, + wins: mapResultDelta.wins, + losses: mapResultDelta.losses, + season: seasonValue, + })), + ) + .onConflict((oc) => + oc + .columns(["userId", "stageId", "mode", "season"]) + .doUpdateSet((eb) => ({ + wins: eb("MapResult.wins", "+", eb.ref("excluded.wins")), + losses: eb("MapResult.losses", "+", eb.ref("excluded.losses")), + })), + ) + .execute(); + } + } + if (summary.playerResultDeltas.length > 0) { invariant(seasonValue !== null, "Season missing for player result"); - await trx - .insertInto("PlayerResult") - .values( - summary.playerResultDeltas.map((playerResultDelta) => ({ - ownerUserId: playerResultDelta.ownerUserId, - otherUserId: playerResultDelta.otherUserId, - mapWins: playerResultDelta.mapWins, - mapLosses: playerResultDelta.mapLosses, - setWins: playerResultDelta.setWins, - setLosses: playerResultDelta.setLosses, - type: playerResultDelta.type, - season: seasonValue, - })), - ) - .onConflict((oc) => - oc - .columns(["ownerUserId", "otherUserId", "type", "season"]) - .doUpdateSet((eb) => ({ - mapWins: eb( - "PlayerResult.mapWins", - "+", - eb.ref("excluded.mapWins"), - ), - mapLosses: eb( - "PlayerResult.mapLosses", - "+", - eb.ref("excluded.mapLosses"), - ), - setWins: eb( - "PlayerResult.setWins", - "+", - eb.ref("excluded.setWins"), - ), - setLosses: eb( - "PlayerResult.setLosses", - "+", - eb.ref("excluded.setLosses"), - ), + for (const chunk of R.chunk( + summary.playerResultDeltas, + SUMMARY_INSERT_CHUNK_SIZE, + )) { + await trx + .insertInto("PlayerResult") + .values( + chunk.map((playerResultDelta) => ({ + ownerUserId: playerResultDelta.ownerUserId, + otherUserId: playerResultDelta.otherUserId, + mapWins: playerResultDelta.mapWins, + mapLosses: playerResultDelta.mapLosses, + setWins: playerResultDelta.setWins, + setLosses: playerResultDelta.setLosses, + type: playerResultDelta.type, + season: seasonValue, })), - ) - .execute(); + ) + .onConflict((oc) => + oc + .columns(["ownerUserId", "otherUserId", "type", "season"]) + .doUpdateSet((eb) => ({ + mapWins: eb( + "PlayerResult.mapWins", + "+", + eb.ref("excluded.mapWins"), + ), + mapLosses: eb( + "PlayerResult.mapLosses", + "+", + eb.ref("excluded.mapLosses"), + ), + setWins: eb( + "PlayerResult.setWins", + "+", + eb.ref("excluded.setWins"), + ), + setLosses: eb( + "PlayerResult.setLosses", + "+", + eb.ref("excluded.setLosses"), + ), + })), + ) + .execute(); + } } const badgeOwners = badgeReceivers.flatMap((badgeReceiver) => @@ -1607,10 +1629,9 @@ export function finalize({ div: tournamentResult.div, })); - await trx - .insertInto("TournamentResult") - .values(tournamentResults) - .execute(); + for (const chunk of R.chunk(tournamentResults, SUMMARY_INSERT_CHUNK_SIZE)) { + await trx.insertInto("TournamentResult").values(chunk).execute(); + } await trx .updateTable("Tournament")