Fix big tournament finalization crash
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Tests and checks on push / run-checks-and-tests (push) Has been cancelled
Updates translation progress / update-translation-progress-issue (push) Has been cancelled

This commit is contained in:
Kalle
2026-08-21 07:52:02 +03:00
parent 55889eccc7
commit a21e43c6b2
2 changed files with 160 additions and 82 deletions

View File

@@ -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);
});
});

View File

@@ -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")