mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-09 12:46:10 -05:00
Fix big tournament finalization crash
This commit is contained in:
@@ -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<number>().as("count"))
|
||||
.where("season", "=", 1)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(inserted.count).toBe(playerResultDeltas.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user