Further db optimizations

This commit is contained in:
Kalle
2026-09-08 20:17:51 +03:00
parent 0afbb29e21
commit 8f6aec2429
19 changed files with 587 additions and 198 deletions

View File

@@ -85,19 +85,18 @@ export async function findAllOpenRoomIdsByUserId(
.where("GroupMember.createdAt", ">", joinedSince)
.where(openRoom("GroupMatch.chatRoomId"))
.execute(),
tournamentTeamIds.length === 0
? []
: db
.selectFrom("TournamentMatch")
.select("TournamentMatch.chatRoomId as id")
.where((eb) =>
eb.or([
eb(opponentTeamId("opponentOne"), "in", tournamentTeamIds),
eb(opponentTeamId("opponentTwo"), "in", tournamentTeamIds),
]),
)
.where(openRoom("TournamentMatch.chatRoomId"))
.execute(),
// a column per query so each opponent's expression index is used directly,
// mirroring the alpha/bravo split above
...(["opponentOne", "opponentTwo"] as const).map((column) =>
tournamentTeamIds.length === 0
? []
: db
.selectFrom("TournamentMatch")
.select("TournamentMatch.chatRoomId as id")
.where(opponentTeamId(column), "in", tournamentTeamIds)
.where(openRoom("TournamentMatch.chatRoomId"))
.execute(),
),
db
.selectFrom("TournamentTeamMember")
.innerJoin(

View File

@@ -11,6 +11,7 @@ import type {
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
groupMemberOfSeasonSql,
jsonArrayFrom,
latestSkillPerSeason,
skillCountsAsSeasonSet,
@@ -208,6 +209,7 @@ export async function hasEnoughSqMatchesByUserId(userId: number) {
.onRef("Skill.userId", "=", "GroupMember.userId"),
)
.where("GroupMember.userId", "=", userId)
.where(groupMemberOfSeasonSql(season.nth))
.where(
"GroupMatch.createdAt",
">",

View File

@@ -1,6 +1,11 @@
import { addHours } from "date-fns";
import { addHours, subDays } from "date-fns";
import { describe, expect, test } from "vitest";
import { list, nthToDateRange, nthToReportingDateRange } from "./Seasons";
import {
list,
nthToDateRange,
nthToGroupMembershipDateRange,
nthToReportingDateRange,
} from "./Seasons";
describe("nthToDateRange()", () => {
test("returns the date range for an existing season", () => {
@@ -33,3 +38,15 @@ describe("nthToReportingDateRange()", () => {
expect(() => nthToReportingDateRange(list.length)).toThrow();
});
});
describe("nthToGroupMembershipDateRange()", () => {
test("starts a week before the season, covering groups formed before it", () => {
const { starts } = nthToGroupMembershipDateRange(0);
expect(starts).toEqual(subDays(list[0].starts, 7));
});
test("ends when reporting ends", () => {
const { ends } = nthToGroupMembershipDateRange(0);
expect(ends).toEqual(nthToReportingDateRange(0).ends);
});
});

View File

@@ -1,10 +1,17 @@
import { addHours } from "date-fns";
import { addHours, subDays } from "date-fns";
import { Config } from "~/config";
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
/** How long past a season's end its matches can still resolve: 24h stale match routine after a buzzer-beater creation, plus up to an hour of scheduling lag. */
const REPORTING_GRACE_HOURS = 25;
/**
* How far before a season's reporting range the `GroupMember` rows of its matches can have been created.
* No membership in the database has ever preceded its season's start (90+ days of margin), so this is
* insurance for a group formed just before a boundary rather than a bound on how long a group lives.
*/
const GROUP_LIFETIME_MAX_DAYS = 7;
/** Seasons (`nth` from 0) with their start and end dates. Outside production the list is a test set that keeps a season always open. */
export const list =
// when we do pnpm run setup NODE_ENV is not set -> use test seasons
@@ -171,6 +178,19 @@ export function nthToReportingDateRange(nth: number) {
};
}
/**
* When the members of the season's SendouQ matches joined their groups: the reporting range
* widened by {@link GROUP_LIFETIME_MAX_DAYS}.
*/
export function nthToGroupMembershipDateRange(nth: number) {
const { starts, ends } = nthToReportingDateRange(nth);
return {
starts: subDays(starts, GROUP_LIFETIME_MAX_DAYS),
ends,
};
}
/** Numbers of seasons started by `date` (default now), newest first; `[0]` if none have. */
export function allStarted(date = new Date()) {
const startedSeasons = list.filter((s) => date >= s.starts);

View File

@@ -9,6 +9,7 @@ import type {
StageId,
} from "~/modules/in-game-lists/types";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { groupMemberOfSeasonSql } from "~/utils/kysely.server";
import { assertUnreachable } from "~/utils/types";
export async function upsertOwn({
@@ -302,6 +303,7 @@ export async function findAllWeaponUsageStats({
.as("weaponUserGroupId"),
])
.where("GroupMember.userId", "=", userId)
.where(groupMemberOfSeasonSql(season))
.where((eb) =>
eb.or([
eb("GroupMatch.alphaGroupId", "=", eb.ref("GroupMember.groupId")),

View File

@@ -31,6 +31,7 @@ import { invariant } from "~/utils/invariant";
import {
commonUserSelect,
concatUserSubmittedImagePrefix,
groupMemberOfSeasonSql,
jsonArrayFrom,
jsonObjectFrom,
matchProfileWeapons,
@@ -700,6 +701,7 @@ export async function findSeasonCanceledMatchesByUserId({
).as("cancelReports"),
])
.where("GroupMember.userId", "=", userId)
.where(groupMemberOfSeasonSql(season))
.where("GroupMatch.createdAt", ">=", dateToDatabaseTimestamp(starts))
.where("GroupMatch.createdAt", "<=", dateToDatabaseTimestamp(ends))
.orderBy("GroupMatch.createdAt", "desc")

View File

@@ -159,6 +159,7 @@ export async function findPlacementsByUserId(
return result.length ? result : null;
}
/** Every month with placements, newest first. */
export async function findAllMonthYears() {
return await db
.selectFrom("XRankPlacement")

View File

@@ -3,6 +3,7 @@ import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { db } from "~/db/sql";
import type { TournamentSettings } from "~/db/tables-json";
import { userChannel } from "~/features/events/events-types";
import {
abortSubscriptions,
@@ -12,6 +13,7 @@ import { resolveMatchMapList } from "~/features/tournament-match/core/mapList.se
import { reportScore } from "~/features/tournament-match/core/reportScore.server";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { invariant } from "~/utils/invariant";
import * as BracketRepository from "./BracketRepository.server";
import * as Engine from "./core/engine";
import { executeBracketOperation } from "./core/executeBracketOperation.server";
import {
@@ -29,12 +31,24 @@ afterEach(() => {
abortSubscriptions();
});
const setupStartedMatch = async () => {
/** KOs are only reported in round robin brackets. */
const ROUND_ROBIN: TournamentSettings["bracketProgression"] = [
{
name: "Groups",
type: "round_robin",
requiresCheckIn: false,
settings: { teamsPerGroup: 2 },
},
];
const setupStartedMatch = async (
overrides?: Partial<Parameters<typeof TournamentFactory.create>[0]>,
) => {
const authorId = users.id(1);
const teamAlphaUserIds = [users.id(2), users.id(3), users.id(4), users.id(5)];
const teamBravoUserIds = [users.id(6), users.id(7), users.id(8), users.id(9)];
const tournament = await TournamentFactory.create({ authorId });
const tournament = await TournamentFactory.create({ authorId, ...overrides });
for (const memberUserIds of [teamAlphaUserIds, teamBravoUserIds]) {
await TournamentTeamFactory.create(
{ tournamentId: tournament.id, memberUserIds },
@@ -63,9 +77,15 @@ const setupStartedMatch = async () => {
};
};
type GameResult = { winner: "one" | "two"; ko: boolean };
/** Reports every game of the match through `reportScore` until the set is over. */
const playOutMatch = async (
setup: Awaited<ReturnType<typeof setupStartedMatch>>,
resultOfGame: (position: number) => GameResult = () => ({
winner: "one",
ko: false,
}),
) => {
let position = 0;
let setOver = false;
@@ -77,6 +97,9 @@ const playOutMatch = async (
);
invariant(matchRow, "Match not found");
invariant(matchRow.opponentOne?.id, "Match has no first opponent");
invariant(matchRow.opponentTwo?.id, "Match has no second opponent");
const { winner, ko } = resultOfGame(position);
const reported = await reportScore({
match: matchRow,
@@ -84,8 +107,9 @@ const playOutMatch = async (
mapList: await resolveMatchMapList({ match: matchRow, tournament }),
user: { id: setup.authorId },
position,
winnerTeamId: matchRow.opponentOne.id,
ko: false,
winnerTeamId:
winner === "one" ? matchRow.opponentOne.id : matchRow.opponentTwo.id,
ko,
});
invariant(reported, `Game ${position} was already reported`);
@@ -134,6 +158,45 @@ describe("BracketRepository.applyMatchChanges", () => {
});
});
describe("BracketRepository.findByTournamentId", () => {
test("counts each opponent's KO wins into totalKos", async () => {
const setup = await setupStartedMatch({ bracketProgression: ROUND_ROBIN });
const kosOfGame: Array<GameResult> = [
{ winner: "two", ko: true },
{ winner: "one", ko: true },
{ winner: "one", ko: true },
];
await playOutMatch(setup, (position) => kosOfGame[position]);
const { match } = await BracketRepository.findByTournamentId(
setup.tournamentId,
);
const playedMatch = match.find(
(bracketMatch) => bracketMatch.id === setup.matchId,
);
expect(playedMatch?.opponent1?.totalKos).toBe(2);
expect(playedMatch?.opponent2?.totalKos).toBe(1);
});
test("does not count games won without a KO", async () => {
const setup = await setupStartedMatch({ bracketProgression: ROUND_ROBIN });
await playOutMatch(setup);
const { match } = await BracketRepository.findByTournamentId(
setup.tournamentId,
);
const playedMatch = match.find(
(bracketMatch) => bracketMatch.id === setup.matchId,
);
expect(playedMatch?.opponent1?.totalKos).toBe(0);
expect(playedMatch?.opponent2?.totalKos).toBe(0);
});
});
const roomById = (id: number) =>
db
.selectFrom("ChatRoom")

View File

@@ -93,11 +93,6 @@ export async function findByTournamentId(
"TournamentStage.id",
"TournamentMatch.stageId",
)
.leftJoin(
"TournamentMatchGameResult",
"TournamentMatch.id",
"TournamentMatchGameResult.matchId",
)
.select([
"TournamentMatch.id",
"TournamentMatch.stageId",
@@ -111,7 +106,6 @@ export async function findByTournamentId(
serializedOpponentWithKos("opponentTwo").as("opponent2"),
])
.where("TournamentStage.tournamentId", "=", tournamentId)
.groupBy("TournamentMatch.id")
.orderBy("TournamentMatch.stageId", "asc")
.orderBy("TournamentMatch.id", "asc"),
).as("match"),
@@ -121,20 +115,23 @@ export async function findByTournamentId(
return { stage, group, round, match };
}
/** Opponent JSON with `totalKos` summed over the match's game results, `null` for BYEs. */
/** Opponent JSON with `totalKos` counted over the match's game results, `null` for BYEs. */
function serializedOpponentWithKos(
column: "opponentOne" | "opponentTwo",
): RawBuilder<ParticipantResult | null> {
const opponent = kyselySql.ref(`TournamentMatch.${column}`);
// a correlated count is answered by the (matchId, winnerTeamId, ko) index alone; the
// left join + group by it replaces read every game result row of the tournament
return kyselySql<ParticipantResult | null>`json_set(
${kyselySql.ref(`TournamentMatch.${column}`)},
${opponent},
'$.totalKos',
sum(
case
when "TournamentMatchGameResult"."ko" = 1
and "TournamentMatchGameResult"."winnerTeamId" = ${kyselySql.ref(`TournamentMatch.${column}`)} ->> '$.id'
then 1
else 0
end
(
select count(*)
from "TournamentMatchGameResult"
where "TournamentMatchGameResult"."matchId" = "TournamentMatch"."id"
and "TournamentMatchGameResult"."winnerTeamId" = ${opponent} ->> '$.id'
and "TournamentMatchGameResult"."ko" = 1
)
)`;
}

View File

@@ -1,3 +1,4 @@
import * as R from "remeda";
import { beforeEach, describe, expect, test } from "vitest";
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
import * as CalendarEventResultFactory from "~/db/seed/factories/CalendarEventResultFactory";
@@ -10,6 +11,7 @@ import {
} from "~/utils/dates";
import * as TournamentOrganizationRepository from "./TournamentOrganizationRepository.server";
import { seedOrgEventWithParticipants } from "./test-utils";
import { TOURNAMENT_SERIES_EVENTS_PER_PAGE } from "./tournament-organization-constants";
const users = UserFactory.pool();
@@ -110,6 +112,76 @@ describe("findEventsByMonth", () => {
});
});
describe("findPaginatedEventsBySeries", () => {
const NEWEST_EVENT_STARTED_AT = 1_700_000_000;
const DAY_IN_SECONDS = 60 * 60 * 24;
const EVENT_COUNT = TOURNAMENT_SERIES_EVENTS_PER_PAGE + 1;
beforeEach(async () => {
await users.create(1);
});
/** One event per day going back from the newest, so `Low Ink #0` is the newest of the series. */
const seedSeries = async (organizationId: number) => {
for (const nth of R.range(0, EVENT_COUNT)) {
await CalendarEventFactory.create({
authorId: users.id(1),
organizationId,
name: `Low Ink #${nth}`,
startTimes: [NEWEST_EVENT_STARTED_AT - nth * DAY_IN_SECONDS],
});
}
};
const eventsOnPage = (organizationId: number, page: number) =>
TournamentOrganizationRepository.findPaginatedEventsBySeries({
organizationId,
substringMatches: ["Low Ink"],
page,
});
test("pages the events of the series newest first", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
await seedSeries(org.id);
const firstPage = await eventsOnPage(org.id, 1);
const secondPage = await eventsOnPage(org.id, 2);
expect(firstPage.map((event) => event.name)).toEqual(
R.range(0, TOURNAMENT_SERIES_EVENTS_PER_PAGE).map(
(nth) => `Low Ink #${nth}`,
),
);
expect(secondPage.map((event) => event.name)).toEqual([
`Low Ink #${EVENT_COUNT - 1}`,
]);
});
test("leaves out the events of other series", async () => {
const org = await TournamentOrganizationFactory.create({
ownerId: users.id(1),
});
await CalendarEventFactory.create({
authorId: users.id(1),
organizationId: org.id,
name: "Low Ink #1",
startTimes: [NEWEST_EVENT_STARTED_AT],
});
await CalendarEventFactory.create({
authorId: users.id(1),
organizationId: org.id,
name: "Paddling Pool #1",
startTimes: [NEWEST_EVENT_STARTED_AT],
});
const events = await eventsOnPage(org.id, 1);
expect(events.map((event) => event.name)).toEqual(["Low Ink #1"]);
});
});
describe("countActiveParticipants", () => {
const WINDOW_START = 1_700_000_000;
const WINDOW_END = WINDOW_START + 60 * 60 * 24 * 31;

View File

@@ -437,22 +437,14 @@ export function findAllUnfinalizedEvents(organizationId: number) {
.execute();
}
const findSeriesEventsBaseQuery = ({
organizationId,
substringMatches,
}: {
organizationId: number;
substringMatches: string[];
}) =>
findEventsBaseQuery(organizationId)
.where((eb) =>
eb.or(
substringMatches.map((match) =>
eb("CalendarEvent.name", "like", `%${match}%`),
),
const nameMatchesSeries =
(substringMatches: string[]) =>
(eb: ExpressionBuilder<DB, "CalendarEvent">) =>
eb.or(
substringMatches.map((match) =>
eb("CalendarEvent.name", "like", `%${match}%`),
),
)
.orderBy("CalendarEventDate.startsAt", "desc");
);
export async function findPaginatedEventsBySeries({
organizationId,
@@ -463,12 +455,27 @@ export async function findPaginatedEventsBySeries({
substringMatches: string[];
page: number;
}) {
const events = await findSeriesEventsBaseQuery({
organizationId,
substringMatches,
})
// the page is resolved by id first: with the limit on the full read, the winners
// of every event of the series would be aggregated before it applies
const pageEventIds = db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select("CalendarEvent.id")
.where("CalendarEvent.organizationId", "=", organizationId)
.where("CalendarEvent.hidden", "=", 0)
.where(nameMatchesSeries(substringMatches))
.groupBy("CalendarEvent.id")
.orderBy(({ fn }) => fn.min("CalendarEventDate.startsAt"), "desc")
.limit(TOURNAMENT_SERIES_EVENTS_PER_PAGE)
.offset((page - 1) * TOURNAMENT_SERIES_EVENTS_PER_PAGE)
.offset((page - 1) * TOURNAMENT_SERIES_EVENTS_PER_PAGE);
const events = await findEventsBaseQuery(organizationId)
.where("CalendarEvent.id", "in", pageEventIds)
.orderBy("CalendarEventDate.startsAt", "desc")
.execute();
return events.map(mapEvent);
@@ -500,13 +507,7 @@ export async function findAllEventsBySeries({
])
.where("CalendarEvent.organizationId", "=", organizationId)
.where("CalendarEvent.hidden", "=", 0)
.where((eb) =>
eb.or(
substringMatches.map((match) =>
eb("CalendarEvent.name", "like", `%${match}%`),
),
),
)
.where(nameMatchesSeries(substringMatches))
.groupBy("CalendarEvent.id")
.orderBy("CalendarEventDate.startsAt", "desc")
.execute();

View File

@@ -203,13 +203,7 @@ export async function findById(id: number) {
"=",
"TournamentTeamMember.userId",
)
.on(
"SeedingSkill.type",
"=",
sql<
Tables["SeedingSkill"]["type"]
> /*sql*/`case when json_extract("Tournament"."settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end`,
),
.on("SeedingSkill.type", "=", seedingSkillType(id)),
)
.select(({ fn }) =>
fn.avg<number>("SeedingSkill.ordinal").as("v"),
@@ -500,7 +494,6 @@ export type TeamFull = Unwrapped<typeof findTeamsFullByTournamentId>;
export async function findTeamsFullByTournamentId(tournamentId: number) {
const teams = await db
.selectFrom("TournamentTeam")
.innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId")
.leftJoin(
"UserSubmittedImage as PickupAvatar",
"TournamentTeam.avatarImgId",
@@ -528,13 +521,7 @@ export async function findTeamsFullByTournamentId(tournamentId: number) {
.leftJoin("SeedingSkill", (join) =>
join
.onRef("User.id", "=", "SeedingSkill.userId")
.on(
"SeedingSkill.type",
"=",
sql<
Tables["SeedingSkill"]["type"]
> /*sql*/`case when json_extract("Tournament"."settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end`,
),
.on("SeedingSkill.type", "=", seedingSkillType(tournamentId)),
)
.select((memberEb) => [
...commonUserSelect(memberEb, {
@@ -1885,3 +1872,10 @@ async function trophyTier(
return tournament?.tier ?? null;
}
/** Which seeding skill the tournament ranks by, resolved once: inline in the join it parsed the settings JSON per member row. */
function seedingSkillType(tournamentId: number) {
return sql<
Tables["SeedingSkill"]["type"]
>`(select case when json_extract("settings", '$.isRanked') = 1 then 'RANKED' else 'UNRANKED' end from "Tournament" where "id" = ${tournamentId})`;
}

View File

@@ -359,6 +359,71 @@ describe("TournamentTeamRepository", () => {
});
});
describe("findAllRegistrationsByUserIds", () => {
const WINDOW_STARTS_AT = 1_700_000_000;
const DAY_IN_SECONDS = 60 * 60 * 24;
const WINDOW_ENDS_AT = WINDOW_STARTS_AT + 7 * DAY_IN_SECONDS;
const registerAt = async (startTime: number) => {
const tournament = await TournamentFactory.create({
authorId: organizerId(),
startTimes: [startTime],
});
await TournamentTeamFactory.create({
tournamentId: tournament.id,
memberUserIds: [ownerId(), memberId()],
});
return tournament.id;
};
const registrationsInWindow = (excludeTournamentId?: number) =>
TournamentTeamRepository.findAllRegistrationsByUserIds({
userIds: [memberId()],
startsAt: WINDOW_STARTS_AT,
endsAt: WINDOW_ENDS_AT,
excludeTournamentId,
});
test("leaves out the registrations starting outside the window", async () => {
await registerAt(WINDOW_STARTS_AT + DAY_IN_SECONDS);
await registerAt(WINDOW_STARTS_AT - DAY_IN_SECONDS);
await registerAt(WINDOW_ENDS_AT + DAY_IN_SECONDS);
const registrations = await registrationsInWindow();
expect(registrations).toHaveLength(1);
expect(registrations[0].userId).toBe(memberId());
expect(registrations[0].startsAt).toBe(WINDOW_STARTS_AT + DAY_IN_SECONDS);
});
test("leaves out the excluded tournament", async () => {
const excludedTournamentId = await registerAt(
WINDOW_STARTS_AT + DAY_IN_SECONDS,
);
await registerAt(WINDOW_STARTS_AT + 2 * DAY_IN_SECONDS);
const registrations = await registrationsInWindow(excludedTournamentId);
expect(registrations).toHaveLength(1);
expect(registrations[0].startsAt).toBe(
WINDOW_STARTS_AT + 2 * DAY_IN_SECONDS,
);
});
test("returns nothing when no user ids are given", async () => {
await registerAt(WINDOW_STARTS_AT + DAY_IN_SECONDS);
expect(
await TournamentTeamRepository.findAllRegistrationsByUserIds({
userIds: [],
startsAt: WINDOW_STARTS_AT,
endsAt: WINDOW_ENDS_AT,
}),
).toEqual([]);
});
});
describe("findRecentlyPlayedMapsByIds", () => {
test("leaves out the games of the match the maps are resolved for", async () => {
// an in-progress set's map list is regenerated when its cache entry is lost, so counting its

View File

@@ -982,43 +982,48 @@ export function findAllRegistrationsByUserIds({
}) {
if (userIds.length === 0) return Promise.resolve([]);
return db
.selectFrom("TournamentTeamMember")
.innerJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId")
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select((eb) => [
"TournamentTeamMember.userId",
"CalendarEvent.name",
"CalendarEvent.organizationId",
"CalendarEventDate.startsAt",
"Tournament.settings",
eb
.selectFrom("TournamentTeam as RegisteredTeam")
.select(({ fn }) => fn.countAll<number>().as("count"))
.whereRef("RegisteredTeam.tournamentId", "=", "Tournament.id")
.where("RegisteredTeam.isPlaceholder", "=", 0)
.as("teamCount"),
])
.$narrowType<{ teamCount: NotNull }>()
.where("TournamentTeamMember.userId", "in", userIds)
.where("TournamentTeam.droppedOut", "=", 0)
.where("CalendarEvent.hidden", "=", 0)
.where("CalendarEventDate.startsAt", ">=", startsAt)
.where("CalendarEventDate.startsAt", "<=", endsAt)
.$if(typeof excludeTournamentId === "number", (qb) =>
qb.where("Tournament.id", "!=", excludeTournamentId!),
)
.execute();
return (
db
.selectFrom("CalendarEventDate")
// cross join pins the join order: the date window is indexed and far narrower
// than the users' registration histories the planner walks otherwise
.crossJoin("CalendarEvent")
.innerJoin("Tournament", "Tournament.id", "CalendarEvent.tournamentId")
.innerJoin(
"TournamentTeam",
"TournamentTeam.tournamentId",
"Tournament.id",
)
.innerJoin(
"TournamentTeamMember",
"TournamentTeamMember.tournamentTeamId",
"TournamentTeam.id",
)
.select((eb) => [
"TournamentTeamMember.userId",
"CalendarEvent.name",
"CalendarEvent.organizationId",
"CalendarEventDate.startsAt",
"Tournament.settings",
eb
.selectFrom("TournamentTeam as RegisteredTeam")
.select(({ fn }) => fn.countAll<number>().as("count"))
.whereRef("RegisteredTeam.tournamentId", "=", "Tournament.id")
.where("RegisteredTeam.isPlaceholder", "=", 0)
.as("teamCount"),
])
.$narrowType<{ teamCount: NotNull }>()
.whereRef("CalendarEvent.id", "=", "CalendarEventDate.eventId")
.where("TournamentTeamMember.userId", "in", userIds)
.where("TournamentTeam.droppedOut", "=", 0)
.where("CalendarEvent.hidden", "=", 0)
.where("CalendarEventDate.startsAt", ">=", startsAt)
.where("CalendarEventDate.startsAt", "<=", endsAt)
.$if(typeof excludeTournamentId === "number", (qb) =>
qb.where("Tournament.id", "!=", excludeTournamentId!),
)
.execute()
);
}
/** Invite code of one team, the secret the tournament layout data does not carry. */

View File

@@ -126,6 +126,31 @@ describe("findVods", () => {
});
});
describe("countVods", () => {
beforeEach(async () => {
await users.create(5);
await seedVodsOfEveryFilter();
});
test.each([
["by weapon", () => ({ weapon: 1000 as const }), 2],
["by mode", () => ({ mode: "SZ" as const }), 2],
["by stageId", () => ({ stageId: 1 as const }), 2],
["by type", () => ({ type: "CAST" as const }), 1],
["by user", () => ({ userId: users.id(1) }), 1],
["without filters", () => ({}), 3],
])(
"agrees with the rows findVods returns (%s)",
async (_why, filters, expected) => {
const rows = await VodRepository.findVods({ ...filters(), limit: 100 });
const count = await VodRepository.countVods(filters());
expect(rows).toHaveLength(expected);
expect(count).toBe(rows.length);
},
);
});
describe("findVodById", () => {
beforeEach(async () => {
await users.create(5);
@@ -418,3 +443,27 @@ describe("deleteById", () => {
expect(secondResult).not.toBeNull();
});
});
/** One vod per `countVods` filter case, each matching some of the filters but never all of them. */
async function seedVodsOfEveryFilter() {
await VodFactory.create({
submitterUserId: users.id(1),
type: "TOURNAMENT",
pov: { type: "USER", userId: users.id(1) },
matches: [{ mode: "SZ", stageId: 1, startsAt: "0:00", weapons: [1000] }],
});
await VodFactory.create({
submitterUserId: users.id(1),
type: "CAST",
matches: [
{ mode: "SZ", stageId: 2, startsAt: "0:00", weapons: [2000] },
{ mode: "TC", stageId: 1, startsAt: "5:00", weapons: [1000] },
],
});
await VodFactory.create({
submitterUserId: users.id(2),
type: "SCRIM",
pov: { type: "USER", userId: users.id(2) },
matches: [{ mode: "TC", stageId: 3, startsAt: "0:00", weapons: [0] }],
});
}

View File

@@ -1,4 +1,10 @@
import { sql } from "kysely";
import {
type Expression,
type ExpressionBuilder,
type SelectQueryBuilder,
type SqlBool,
sql,
} from "kysely";
import * as R from "remeda";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
@@ -30,31 +36,24 @@ export async function findByUserId(userId: Tables["User"]["id"], limit = 100) {
return findVods({ userId, limit });
}
export async function findVods({
weapon,
mode,
stageId,
type,
userId,
limit = VODS_PAGE_BATCH_SIZE,
offset = 0,
}: {
type VodFilters = {
weapon?: MainWeaponId;
mode?: ModeShort;
stageId?: StageId;
type?: Tables["Video"]["type"];
userId?: number;
};
/** Page of the vods matching the filters, newest first, with the weapons and players of their matching match rows. */
export async function findVods({
limit = VODS_PAGE_BATCH_SIZE,
offset = 0,
...filters
}: VodFilters & {
limit?: number;
offset?: number;
}) {
let query = db
.selectFrom("Video")
.leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id")
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
const result = await vodsWithMatches()
.selectAll("Video")
.select(({ fn, ref, eb }) => [
sql<
@@ -72,32 +71,20 @@ export async function findVods({
.select((playerEb) => commonUserSelect(playerEb))
.whereRef("User.id", "=", "VideoMatchPlayer.playerUserId"),
).as("players"),
]);
if (userId) {
query = query.where("VideoMatchPlayer.playerUserId", "=", userId);
} else {
if (type) {
query = query.where("Video.type", "=", type);
}
if (mode) {
query = query.where("VideoMatch.mode", "=", mode);
}
if (stageId) {
query = query.where("VideoMatch.stageId", "=", stageId);
}
}
if (weapon) {
query = query.where(
"VideoMatchPlayer.weaponSplId",
])
.where(vodFilters(filters))
// the page is resolved by id first: with the limit on this read, the aggregates
// of every matching vod would be computed before it applies
.where(
"Video.id",
"in",
weaponIdToArrayWithAlts(weapon),
);
}
const result = await query
filteredVideoIds(filters)
.orderBy("Video.youtubePublishedAt", "desc")
.limit(limit)
.offset(offset),
)
.groupBy("Video.id")
.orderBy("Video.youtubePublishedAt", "desc")
.limit(limit)
.offset(offset)
.execute();
const vods = result.map((value) => {
@@ -110,50 +97,13 @@ export async function findVods({
return vods;
}
export async function countVods({
weapon,
mode,
stageId,
type,
userId,
}: {
weapon?: MainWeaponId;
mode?: ModeShort;
stageId?: StageId;
type?: Tables["Video"]["type"];
userId?: number;
}) {
let query = db
.selectFrom("Video")
.leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id")
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
.select(({ fn }) => fn.count<number>("Video.id").distinct().as("count"));
if (userId) {
query = query.where("VideoMatchPlayer.playerUserId", "=", userId);
} else {
if (type) {
query = query.where("Video.type", "=", type);
}
if (mode) {
query = query.where("VideoMatch.mode", "=", mode);
}
if (stageId) {
query = query.where("VideoMatch.stageId", "=", stageId);
}
}
if (weapon) {
query = query.where(
"VideoMatchPlayer.weaponSplId",
"in",
weaponIdToArrayWithAlts(weapon),
);
}
/** How many vods match the filters. */
export async function countVods(filters: VodFilters) {
const result = await db
.selectFrom(filteredVideoIds(filters).as("filtered"))
.select(({ fn }) => fn.countAll<number>().as("count"))
.executeTakeFirstOrThrow();
const result = await query.executeTakeFirstOrThrow();
return result.count;
}
@@ -346,3 +296,91 @@ async function save(
export function deleteById(id: number) {
return db.deleteFrom("UnvalidatedVideo").where("id", "=", id).execute();
}
const vodsWithMatches = () =>
db
.selectFrom("Video")
.leftJoin("VideoMatch", "VideoMatch.videoId", "Video.id")
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
);
type VodsWithMatchesDB =
ReturnType<typeof vodsWithMatches> extends SelectQueryBuilder<
infer JoinedDB,
any,
any
>
? JoinedDB
: never;
type VodsTables = "Video" | "VideoMatch" | "VideoMatchPlayer";
/** Conditions the filters put on the match rows. `userId` makes the vod's own filters moot: it is the user's vods regardless. */
function vodFilters({ weapon, mode, stageId, type, userId }: VodFilters) {
return (eb: ExpressionBuilder<VodsWithMatchesDB, VodsTables>) => {
const conditions: Expression<SqlBool>[] = [];
if (userId) {
conditions.push(eb("VideoMatchPlayer.playerUserId", "=", userId));
} else {
if (type) {
conditions.push(eb("Video.type", "=", type));
}
if (mode) {
conditions.push(eb("VideoMatch.mode", "=", mode));
}
if (stageId) {
conditions.push(eb("VideoMatch.stageId", "=", stageId));
}
}
if (weapon) {
conditions.push(
eb(
"VideoMatchPlayer.weaponSplId",
"in",
weaponIdToArrayWithAlts(weapon),
),
);
}
return eb.and(conditions);
};
}
/**
* Ids of the vods matching the filters, joined only as far as a filter reads: inner joins
* let a player level filter start from the player rows' index instead of walking every vod.
*/
function filteredVideoIds(filters: VodFilters) {
const { type, userId, mode, stageId, weapon } = filters;
const filtersPlayers = Boolean(userId || weapon);
const filtersMatches = !filtersPlayers && Boolean(mode || stageId);
return db
.selectFrom("Video")
.select("Video.id")
.distinct()
.$if(Boolean(type) && !userId, (qb) => qb.where("Video.type", "=", type!))
.$if(filtersPlayers, (qb) =>
qb
.innerJoin("VideoMatch", "VideoMatch.videoId", "Video.id")
.innerJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
.where(vodFilters(filters)),
)
.$if(filtersMatches, (qb) =>
qb
.innerJoin("VideoMatch", "VideoMatch.videoId", "Video.id")
.leftJoin(
"VideoMatchPlayer",
"VideoMatch.id",
"VideoMatchPlayer.videoMatchId",
)
.where(vodFilters(filters)),
);
}

View File

@@ -4,6 +4,7 @@ import {
type Expression,
type ExpressionBuilder,
type RawBuilder,
type SqlBool,
sql,
} from "kysely";
import type {
@@ -19,6 +20,8 @@ import {
} from "~/db/json-selections";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
import * as Seasons from "~/features/mmr/core/Seasons";
import { dateToDatabaseTimestamp } from "./dates";
import { IS_E2E_TEST_RUN } from "./e2e";
import { safeNumberParse } from "./number";
@@ -51,6 +54,16 @@ export function peakXpOverallSql<T extends number | null = number | null>() {
return sql<T>`"SplatoonPlayer"."peakXp" ->> '$.overall'`;
}
/**
* Filter keeping only the `GroupMember` rows that can belong to season `nth`'s matches.
* `"GroupMember"` must be in scope at the call site.
*/
export function groupMemberOfSeasonSql(nth: number) {
const { starts, ends } = Seasons.nthToGroupMembershipDateRange(nth);
return sql<SqlBool>`"GroupMember"."createdAt" between ${dateToDatabaseTimestamp(starts)} and ${dateToDatabaseTimestamp(ends)}`;
}
type CommonUserSelectOptions = {
alias?: string;
prefix?: string;

View File

@@ -0,0 +1,4 @@
---
type: feature
---
Faster page loads across the site (db optimization)

View File

@@ -0,0 +1,45 @@
import type { Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
// date window reads (calendar, running tournaments, availability) scanned every event
await trx.schema
.createIndex("calendar_event_date_starts_at")
.on("CalendarEventDate")
.column("startsAt")
.execute();
// the month list skips through this instead of reading every placement
await trx.schema
.createIndex("xrank_placement_year_month")
.on("XRankPlacement")
.columns(["year", "month"])
.execute();
// covers the per match KO count of the bracket read, replacing the matchId only index
await trx.schema
.dropIndex("tournament_match_game_result_match_id")
.execute();
await trx.schema
.createIndex("tournament_match_game_result_match_id_winner_team_id_ko")
.on("TournamentMatchGameResult")
.columns(["matchId", "winnerTeamId", "ko"])
.execute();
// the vods weapon filter walked every player row
await trx.schema
.createIndex("video_match_player_weapon_spl_id")
.on("VideoMatchPlayer")
.column("weaponSplId")
.execute();
// almost no event has a trophy, so to the planner the full index looked as bad as scanning every event
await trx.schema.dropIndex("calendar_event_trophy_id").execute();
await trx.schema
.createIndex("calendar_event_trophy_id")
.on("CalendarEvent")
.column("trophyId")
.where("trophyId", "is not", null)
.execute();
});
}