mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 11:46:09 -05:00
Optimize
This commit is contained in:
@@ -14,6 +14,7 @@ The DB benchmark (`pnpm bench:db`) times repository READ functions against `db-p
|
||||
| `scripts/benchmark-db.ts` | Harness: CLI, timing loop, stats, output. Rarely needs changes. |
|
||||
| `scripts/benchmark-db/cases.ts` | Case registry, grouped by repository file. **New cases go here.** |
|
||||
| `scripts/benchmark-db/fixtures.ts` | Resolves worst-case arguments (heavy rows) from the DB. New argument kinds go here. |
|
||||
| `scripts/seed-chat.ts` | Fills the production copy with chat data, which it ships without (`pnpm run bench:db:seed-chat`). Run once before the chat cases mean anything. |
|
||||
|
||||
## Adding a case
|
||||
|
||||
|
||||
@@ -328,8 +328,15 @@ describe("ChatRepository.closeExpiredRooms", () => {
|
||||
});
|
||||
|
||||
describe("ChatRepository.deleteOrphanedRooms", () => {
|
||||
test("deletes a room no owner points at", async () => {
|
||||
const orphanedRoom = await ChatRoomFactory.create();
|
||||
// each type is checked against its own owner table alone, so every one needs covering
|
||||
test.each([
|
||||
"SQ_GROUP",
|
||||
"SQ_MATCH",
|
||||
"TOURNAMENT_MATCH",
|
||||
"TOURNAMENT_TEAM",
|
||||
"SCRIM",
|
||||
] as const)("deletes an orphaned %s room", async (type) => {
|
||||
const orphanedRoom = await ChatRoomFactory.create({ type });
|
||||
|
||||
const deletedCount = await ChatRepository.deleteOrphanedRooms();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ExpressionBuilder, Transaction } from "kysely";
|
||||
import { subHours } from "date-fns";
|
||||
import type { ExpressionBuilder, SqlBool, Transaction } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
@@ -11,10 +12,19 @@ import {
|
||||
userChatNameHue,
|
||||
} from "~/utils/kysely.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import type { PersistedSystemMessageType } from "./chat-types";
|
||||
import type { ChatRoomType, PersistedSystemMessageType } from "./chat-types";
|
||||
|
||||
const MESSAGES_DEFAULT_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* How far back the room list looks for the user's SendouQ memberships. An
|
||||
* unmatched group goes inactive an hour after its last action and a match room
|
||||
* lives a day, so no open SQ room hangs off a membership older than this. The
|
||||
* bound is what keeps a veteran's thousands of past groups out of the lookup —
|
||||
* raise it rather than let a room quietly stop showing up.
|
||||
*/
|
||||
const SQ_MEMBERSHIP_LOOKBACK_HOURS = 72;
|
||||
|
||||
/** Chat rooms by id. */
|
||||
export async function findAllRoomsByIds(roomIds: number[]) {
|
||||
if (roomIds.length === 0) return [];
|
||||
@@ -28,127 +38,111 @@ export async function findAllRoomsByIds(roomIds: number[]) {
|
||||
|
||||
/**
|
||||
* Ids of the rooms the user currently participates in (unexpired and unclosed).
|
||||
* Room-first per the resolver spike: drives from the open room set and probes
|
||||
* membership through the owner tables' indexes, never searching on the JSON
|
||||
* opponent ids.
|
||||
* Membership-first: every branch starts from the user's own membership rows and
|
||||
* probes forward to the room, so the cost tracks how much the user takes part in
|
||||
* rather than how many rooms the site has open. Driving from the open room set
|
||||
* instead costs the same for every user, participant or not, and grows with the
|
||||
* site. The SendouQ branches narrow that further to
|
||||
* {@link SQ_MEMBERSHIP_LOOKBACK_HOURS}, past which no room can still be open.
|
||||
*/
|
||||
export async function findAllOpenRoomIdsByUserId(
|
||||
userId: number,
|
||||
): Promise<number[]> {
|
||||
const now = databaseTimestampNow();
|
||||
const openRoom = (chatRoomIdColumn: string) =>
|
||||
isOpenRoom(chatRoomIdColumn, now);
|
||||
const joinedSince = dateToDatabaseTimestamp(
|
||||
subHours(new Date(), SQ_MEMBERSHIP_LOOKBACK_HOURS),
|
||||
);
|
||||
|
||||
const openRooms = () =>
|
||||
db
|
||||
.selectFrom("ChatRoom")
|
||||
.select("ChatRoom.id")
|
||||
.where("ChatRoom.expiresAt", ">", now)
|
||||
.where("ChatRoom.closedAt", "is", null);
|
||||
// the opponent ids live in JSON, and only literal team ids get the two
|
||||
// expression indexes over them picked, so they are fetched first
|
||||
const tournamentTeamIds = (
|
||||
await db
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.select("TournamentTeamMember.tournamentTeamId")
|
||||
.where("TournamentTeamMember.userId", "=", userId)
|
||||
.execute()
|
||||
).map((row) => row.tournamentTeamId);
|
||||
|
||||
const rooms = await Promise.all([
|
||||
openRooms()
|
||||
.innerJoin("Group", "Group.chatRoomId", "ChatRoom.id")
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.whereRef("GroupMember.groupId", "=", "Group.id")
|
||||
.where("GroupMember.userId", "=", userId),
|
||||
),
|
||||
)
|
||||
db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("Group", "Group.id", "GroupMember.groupId")
|
||||
.select("Group.chatRoomId as id")
|
||||
.where("GroupMember.userId", "=", userId)
|
||||
.where("GroupMember.createdAt", ">", joinedSince)
|
||||
.where(openRoom("Group.chatRoomId"))
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("GroupMatch", "GroupMatch.chatRoomId", "ChatRoom.id")
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("GroupMember")
|
||||
.select("GroupMember.userId")
|
||||
.where("GroupMember.userId", "=", userId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
eb.ref("GroupMatch.alphaGroupId"),
|
||||
),
|
||||
eb(
|
||||
"GroupMember.groupId",
|
||||
"=",
|
||||
eb.ref("GroupMatch.bravoGroupId"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
// a side per query so both group id indexes are used, which an `or` denies
|
||||
db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("GroupMatch", "GroupMatch.alphaGroupId", "GroupMember.groupId")
|
||||
.select("GroupMatch.chatRoomId as id")
|
||||
.where("GroupMember.userId", "=", userId)
|
||||
.where("GroupMember.createdAt", ">", joinedSince)
|
||||
.where(openRoom("GroupMatch.chatRoomId"))
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("TournamentMatch", "TournamentMatch.chatRoomId", "ChatRoom.id")
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("TournamentTeamMember")
|
||||
.select("TournamentTeamMember.userId")
|
||||
.where("TournamentTeamMember.userId", "=", userId)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
opponentTeamId("opponentOne"),
|
||||
),
|
||||
eb(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
opponentTeamId("opponentTwo"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("GroupMatch", "GroupMatch.bravoGroupId", "GroupMember.groupId")
|
||||
.select("GroupMatch.chatRoomId as id")
|
||||
.where("GroupMember.userId", "=", userId)
|
||||
.where("GroupMember.createdAt", ">", joinedSince)
|
||||
.where(openRoom("GroupMatch.chatRoomId"))
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("TournamentTeam", "TournamentTeam.chatRoomId", "ChatRoom.id")
|
||||
.where(({ exists, selectFrom }) =>
|
||||
exists(
|
||||
selectFrom("TournamentTeamMember")
|
||||
.select("TournamentTeamMember.userId")
|
||||
.whereRef(
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"=",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.where("TournamentTeamMember.userId", "=", userId),
|
||||
),
|
||||
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(),
|
||||
db
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.innerJoin(
|
||||
"TournamentTeam",
|
||||
"TournamentTeam.id",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
)
|
||||
.select("TournamentTeam.chatRoomId as id")
|
||||
.where("TournamentTeamMember.userId", "=", userId)
|
||||
.where(openRoom("TournamentTeam.chatRoomId"))
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("ScrimPost", "ScrimPost.chatRoomId", "ChatRoom.id")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("ScrimPostUser")
|
||||
.select("ScrimPostUser.userId")
|
||||
.whereRef("ScrimPostUser.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostUser.userId", "=", userId),
|
||||
),
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("ScrimPostRequestUser")
|
||||
.innerJoin(
|
||||
"ScrimPostRequest",
|
||||
"ScrimPostRequest.id",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
)
|
||||
.select("ScrimPostRequestUser.userId")
|
||||
.whereRef("ScrimPostRequest.scrimPostId", "=", "ScrimPost.id")
|
||||
.where("ScrimPostRequest.isAccepted", "=", 1)
|
||||
.where("ScrimPostRequestUser.userId", "=", userId),
|
||||
),
|
||||
]),
|
||||
db
|
||||
.selectFrom("ScrimPostUser")
|
||||
.innerJoin("ScrimPost", "ScrimPost.id", "ScrimPostUser.scrimPostId")
|
||||
.select("ScrimPost.chatRoomId as id")
|
||||
.where("ScrimPostUser.userId", "=", userId)
|
||||
.where(openRoom("ScrimPost.chatRoomId"))
|
||||
.execute(),
|
||||
db
|
||||
.selectFrom("ScrimPostRequestUser")
|
||||
.innerJoin(
|
||||
"ScrimPostRequest",
|
||||
"ScrimPostRequest.id",
|
||||
"ScrimPostRequestUser.scrimPostRequestId",
|
||||
)
|
||||
.innerJoin("ScrimPost", "ScrimPost.id", "ScrimPostRequest.scrimPostId")
|
||||
.select("ScrimPost.chatRoomId as id")
|
||||
.where("ScrimPostRequestUser.userId", "=", userId)
|
||||
.where("ScrimPostRequest.isAccepted", "=", 1)
|
||||
.where(openRoom("ScrimPost.chatRoomId"))
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
return rooms.flat().map((room) => room.id);
|
||||
return R.unique(
|
||||
rooms
|
||||
.flat()
|
||||
.map((room) => room.id)
|
||||
.filter((id) => id !== null),
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the latest `limit` messages of a room, oldest first, authors resolved live. */
|
||||
@@ -386,18 +380,20 @@ export async function closeExpiredRooms(expiredBefore: Date) {
|
||||
return Number(result.numUpdatedRows);
|
||||
}
|
||||
|
||||
/** Deletes rooms no owner row points at any more, returning how many. Backstop for owner deletes that missed their room. */
|
||||
/**
|
||||
* Deletes rooms no owner row points at any more, returning how many. Backstop
|
||||
* for owner deletes that missed their room. A room's type names the one table
|
||||
* that can own it, so each room is checked against that table alone.
|
||||
*/
|
||||
export async function deleteOrphanedRooms() {
|
||||
const result = await db
|
||||
.deleteFrom("ChatRoom")
|
||||
.where((eb) =>
|
||||
eb.and([
|
||||
noOwner(eb, "Group"),
|
||||
noOwner(eb, "GroupMatch"),
|
||||
noOwner(eb, "TournamentMatch"),
|
||||
noOwner(eb, "TournamentTeam"),
|
||||
noOwner(eb, "ScrimPost"),
|
||||
]),
|
||||
eb.or(
|
||||
R.entries(OWNER_TABLE_BY_ROOM_TYPE).map(([type, table]) =>
|
||||
eb.and([eb("ChatRoom.type", "=", type), noOwner(eb, table)]),
|
||||
),
|
||||
),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -411,6 +407,15 @@ type ChatRoomOwnerTable =
|
||||
| "TournamentTeam"
|
||||
| "ScrimPost";
|
||||
|
||||
/** The one table that can own a room of each type. */
|
||||
const OWNER_TABLE_BY_ROOM_TYPE = {
|
||||
SQ_GROUP: "Group",
|
||||
SQ_MATCH: "GroupMatch",
|
||||
TOURNAMENT_MATCH: "TournamentMatch",
|
||||
TOURNAMENT_TEAM: "TournamentTeam",
|
||||
SCRIM: "ScrimPost",
|
||||
} as const satisfies Record<ChatRoomType, ChatRoomOwnerTable>;
|
||||
|
||||
function noOwner(
|
||||
eb: ExpressionBuilder<DB, "ChatRoom">,
|
||||
table: ChatRoomOwnerTable,
|
||||
@@ -425,6 +430,16 @@ function noOwner(
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the owner row's room is one the user can still be in: unexpired and unclosed. */
|
||||
function isOpenRoom(chatRoomIdColumn: string, now: number) {
|
||||
return sql<SqlBool>`exists (
|
||||
select 1 from "ChatRoom"
|
||||
where "ChatRoom"."id" = ${sql.ref(chatRoomIdColumn)}
|
||||
and "ChatRoom"."expiresAt" > ${now}
|
||||
and "ChatRoom"."closedAt" is null
|
||||
)`;
|
||||
}
|
||||
|
||||
function opponentTeamId(column: "opponentOne" | "opponentTwo") {
|
||||
return sql<number>`${sql.ref(`TournamentMatch.${column}`)} ->> '$.id'`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { addHours } from "date-fns";
|
||||
import { addHours, subHours } from "date-fns";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as ScrimPostFactory from "~/db/seed/factories/ScrimPostFactory";
|
||||
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
|
||||
@@ -261,6 +261,20 @@ describe("ChatRoomResolver.findAllByUserId", () => {
|
||||
expect(await ChatRoomResolver.findAllByUserId(outsiderId())).toEqual([]);
|
||||
});
|
||||
|
||||
test("leaves out expired rooms", async () => {
|
||||
const { match, alphaUserIds } = await setupSqMatch(users);
|
||||
await ChatRepository.updateRoomExpiresAt({
|
||||
roomId: match.chatRoomId!,
|
||||
expiresAt: subHours(new Date(), 1),
|
||||
});
|
||||
|
||||
const rooms = await ChatRoomResolver.findAllByUserId(alphaUserIds[0]);
|
||||
|
||||
expect(rooms.map((room) => room.roomId)).toEqual([
|
||||
await groupChatRoomId(match.alphaGroup.id),
|
||||
]);
|
||||
});
|
||||
|
||||
test("leaves out closed rooms", async () => {
|
||||
const { requestUserIds } = await setupAcceptedScrim();
|
||||
await ChatRepository.closeExpiredRooms(addHours(new Date(), 100_000));
|
||||
|
||||
@@ -130,5 +130,21 @@ export async function up(db: Kysely<any>): Promise<void> {
|
||||
.unique()
|
||||
.where(sql.ref("chatRoomId"), "is not", null)
|
||||
.execute();
|
||||
|
||||
// the room list looks a user's scrims up by their id, which the
|
||||
// (scrimPostId, userId) unique constraint can not serve
|
||||
await trx.schema
|
||||
.createIndex("scrim_post_user_user_id")
|
||||
.on("ScrimPostUser")
|
||||
.column("userId")
|
||||
.execute();
|
||||
|
||||
// the room list only wants the memberships recent enough to still have an
|
||||
// open room, which the (userId, groupId) unique constraint can not bound
|
||||
await trx.schema
|
||||
.createIndex("group_member_user_id_created_at")
|
||||
.on("GroupMember")
|
||||
.columns(["userId", "createdAt"])
|
||||
.execute();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"check-translation-jsons:no-write": "node scripts/check-translation-jsons.ts --no-write",
|
||||
"check-articles": "vite-node scripts/check-articles.ts",
|
||||
"bench:db": "cross-env DB_PATH=db-prod.sqlite3 VITE_PROD_MODE=true vite-node scripts/benchmark-db.ts",
|
||||
"bench:db:seed-chat": "cross-env DB_PATH=db-prod.sqlite3 node scripts/seed-chat.ts",
|
||||
"compute-luti-divs": "vite-node scripts/compute-luti-divs.ts",
|
||||
"refresh-prod-db": "node scripts/refresh-prod-db.ts && pnpm run migrate:prod",
|
||||
"biome:check": "biome check --error-on-warnings .",
|
||||
|
||||
@@ -235,6 +235,21 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
|
||||
// ChatRepository
|
||||
add("ChatRepository.findAllRoomsByIds", fx.heavyChatUsers, (chatUsers) =>
|
||||
ChatRepository.findAllRoomsByIds(chatUsers.busiest.openRoomIds),
|
||||
);
|
||||
add(
|
||||
"ChatRepository.findAllOpenRoomIdsByUserId.busiest",
|
||||
fx.heavyChatUsers,
|
||||
(chatUsers) =>
|
||||
ChatRepository.findAllOpenRoomIdsByUserId(chatUsers.busiest.id),
|
||||
);
|
||||
add(
|
||||
"ChatRepository.findAllOpenRoomIdsByUserId.mostConnected",
|
||||
fx.heavyChatUsers,
|
||||
(chatUsers) =>
|
||||
ChatRepository.findAllOpenRoomIdsByUserId(chatUsers.mostConnectedId),
|
||||
);
|
||||
add("ChatRepository.findAllMessagesByRoomId", fx.heavyChatRoomId, (roomId) =>
|
||||
ChatRepository.findAllMessagesByRoomId(roomId),
|
||||
);
|
||||
@@ -243,17 +258,24 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
add(
|
||||
"ChatRepository.findMessageStatsByRoomIds",
|
||||
both(fx.heavyUser, fx.heavyChatRoomId),
|
||||
([user, roomId]) =>
|
||||
ChatRepository.findMessageStatsByRoomIds(user.id, [roomId]),
|
||||
fx.heavyChatUsers,
|
||||
(chatUsers) =>
|
||||
ChatRepository.findMessageStatsByRoomIds(
|
||||
chatUsers.busiest.id,
|
||||
chatUsers.busiest.openRoomIds,
|
||||
),
|
||||
);
|
||||
|
||||
// ChatRoomResolver
|
||||
add("ChatRoomResolver.resolve", fx.heavyChatRoomId, (roomId) =>
|
||||
ChatRoomResolver.resolve(roomId),
|
||||
// a tournament match room is the costliest to resolve: its owner join carries
|
||||
// the team members and the tournament's organizer permissions on top
|
||||
add(
|
||||
"ChatRoomResolver.resolve",
|
||||
fx.openChatRoomIdsByType?.TOURNAMENT_MATCH ?? null,
|
||||
(roomIds) => ChatRoomResolver.resolve(roomIds[0]),
|
||||
);
|
||||
add("ChatRoomResolver.findAllByUserId", fx.heavyUser, (user) =>
|
||||
ChatRoomResolver.findAllByUserId(user.id),
|
||||
add("ChatRoomResolver.findAllByUserId", fx.heavyChatUsers, (chatUsers) =>
|
||||
ChatRoomResolver.findAllByUserId(chatUsers.busiest.id),
|
||||
);
|
||||
|
||||
// FriendRepository
|
||||
@@ -568,6 +590,11 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
|
||||
// ScrimPostRepository
|
||||
add(
|
||||
"ScrimPostRepository.findAllByChatRoomIds",
|
||||
fx.openChatRoomIdsByType?.SCRIM ?? null,
|
||||
(roomIds) => ScrimPostRepository.findAllByChatRoomIds(roomIds),
|
||||
);
|
||||
add("ScrimPostRepository.findById", fx.heavyScrimPostId, (scrimPostId) =>
|
||||
ScrimPostRepository.findById(scrimPostId),
|
||||
);
|
||||
@@ -661,6 +688,11 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
|
||||
// SQMatchRepository
|
||||
add(
|
||||
"SQMatchRepository.findAllByChatRoomIds",
|
||||
fx.openChatRoomIdsByType?.SQ_MATCH ?? null,
|
||||
(roomIds) => SQMatchRepository.findAllByChatRoomIds(roomIds),
|
||||
);
|
||||
add("SQMatchRepository.findById", fx.heavyGroupMatchId, (matchId) =>
|
||||
SQMatchRepository.findById(matchId),
|
||||
);
|
||||
@@ -704,6 +736,11 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
|
||||
// SQGroupRepository
|
||||
add(
|
||||
"SQGroupRepository.findAllByChatRoomIds",
|
||||
fx.openChatRoomIdsByType?.SQ_GROUP ?? null,
|
||||
(roomIds) => SQGroupRepository.findAllByChatRoomIds(roomIds),
|
||||
);
|
||||
add(
|
||||
"SQGroupRepository.findMapModePreferencesByGroupId",
|
||||
fx.heavyGroupIds,
|
||||
@@ -871,6 +908,11 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
|
||||
// TournamentMatchRepository
|
||||
add(
|
||||
"TournamentMatchRepository.findAllByChatRoomIds",
|
||||
fx.openChatRoomIdsByType?.TOURNAMENT_MATCH ?? null,
|
||||
(roomIds) => TournamentMatchRepository.findAllByChatRoomIds(roomIds),
|
||||
);
|
||||
add(
|
||||
"TournamentMatchRepository.findMatchById",
|
||||
fx.heavyTournamentMatchId,
|
||||
@@ -1035,6 +1077,14 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
|
||||
// TournamentRepository
|
||||
add(
|
||||
"TournamentRepository.findOrganizerPermissionsByTournamentIds",
|
||||
fx.recentTournamentIds,
|
||||
(tournamentIds) =>
|
||||
TournamentRepository.findOrganizerPermissionsByTournamentIds(
|
||||
tournamentIds,
|
||||
),
|
||||
);
|
||||
add("TournamentRepository.findById", fx.heavyTournamentId, (tournamentId) =>
|
||||
TournamentRepository.findById(tournamentId),
|
||||
);
|
||||
@@ -1144,6 +1194,16 @@ export function buildCases(fx: Fixtures): {
|
||||
);
|
||||
|
||||
// TournamentTeamRepository
|
||||
add(
|
||||
"TournamentTeamRepository.findAllByChatRoomIds",
|
||||
fx.openChatRoomIdsByType?.TOURNAMENT_TEAM ?? null,
|
||||
(roomIds) => TournamentTeamRepository.findAllByChatRoomIds(roomIds),
|
||||
);
|
||||
add(
|
||||
"TournamentTeamRepository.findAllMembersByTeamIds",
|
||||
fx.manyTournamentTeamIds,
|
||||
(teamIds) => TournamentTeamRepository.findAllMembersByTeamIds(teamIds),
|
||||
);
|
||||
add(
|
||||
"TournamentTeamRepository.findByInviteCode",
|
||||
fx.tournamentTeamInviteCode,
|
||||
|
||||
@@ -2,13 +2,15 @@ import { sub } from "date-fns";
|
||||
import { sql } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import * as ChatRepository from "~/features/chat/ChatRepository.server";
|
||||
import type { ChatRoomType } from "~/features/chat/chat-types";
|
||||
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
|
||||
import type {
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
export interface Fixtures {
|
||||
@@ -41,10 +43,19 @@ export interface Fixtures {
|
||||
recentTournamentIds: number[] | null;
|
||||
heavyTeam: { id: number; customUrl: string; memberUserId: number } | null;
|
||||
heavyCalendarEventId: number | null;
|
||||
/** Chat room with the most messages. Null until the prod copy has post-migration chat data. */
|
||||
/** Chat room with the most messages. Null until the prod copy has chat data (`pnpm run bench:db:seed-chat`). */
|
||||
heavyChatRoomId: number | null;
|
||||
/** Newest chat message. Null until the prod copy has post-migration chat data. */
|
||||
/** Newest chat message. Null until the prod copy has chat data. */
|
||||
heavyChatMessageId: number | null;
|
||||
/** The two users the chat room list is heaviest for; the open-room lookup and the fan-out over the result peak on different ones. */
|
||||
heavyChatUsers: {
|
||||
/** In the most open rooms, so the room list fans out the widest. */
|
||||
busiest: { id: number; openRoomIds: number[] };
|
||||
/** The most membership rows, which is what the open-room lookup walks. */
|
||||
mostConnectedId: number;
|
||||
} | null;
|
||||
/** Open rooms per type, as many as one resolve pass batches. Types with no open room are left out. */
|
||||
openChatRoomIdsByType: Partial<Record<ChatRoomType, number[]>> | null;
|
||||
resultsEventId: number | null;
|
||||
calendarAuthorId: number | null;
|
||||
calendarWindow: { startTime: Date; endTime: Date } | null;
|
||||
@@ -76,6 +87,7 @@ export interface Fixtures {
|
||||
wins: { trophyId: number; userId: number };
|
||||
} | null;
|
||||
manyUserIds: number[] | null;
|
||||
manyTournamentTeamIds: number[] | null;
|
||||
notification: { userId: number; type: Tables["Notification"]["type"] } | null;
|
||||
heavyAssociation: {
|
||||
id: number;
|
||||
@@ -156,6 +168,8 @@ export async function resolveFixtures(): Promise<Fixtures> {
|
||||
heavyCalendarEventId: await resolveHeavyCalendarEventId(),
|
||||
heavyChatRoomId: await resolveHeavyChatRoomId(),
|
||||
heavyChatMessageId: await resolveHeavyChatMessageId(),
|
||||
heavyChatUsers: await resolveHeavyChatUsers(),
|
||||
openChatRoomIdsByType: await resolveOpenChatRoomIdsByType(),
|
||||
resultsEventId: await resolveResultsEventId(),
|
||||
calendarAuthorId: await resolveCalendarAuthorId(),
|
||||
calendarWindow: await resolveCalendarWindow(),
|
||||
@@ -174,6 +188,8 @@ export async function resolveFixtures(): Promise<Fixtures> {
|
||||
badgeManagerUserId: await resolveBadgeManagerUserId(),
|
||||
trophy: await resolveTrophy(),
|
||||
manyUserIds: await resolveManyUserIds(heavyTournamentId),
|
||||
manyTournamentTeamIds:
|
||||
await resolveManyTournamentTeamIds(heavyTournamentId),
|
||||
notification: await resolveNotification(),
|
||||
heavyAssociation: await resolveHeavyAssociation(),
|
||||
lfgAuthorId: await resolveLfgAuthorId(),
|
||||
@@ -613,6 +629,143 @@ async function resolveHeavyChatRoomId() {
|
||||
return row?.roomId ?? null;
|
||||
}
|
||||
|
||||
/** As many rooms of one type as a single resolve pass batches together. */
|
||||
const CHAT_ROOM_BATCH_SIZE = 25;
|
||||
|
||||
/**
|
||||
* The two users the open-room lookup is worst for. Tournament match rooms are
|
||||
* left out of the search: their participants hang off the JSON opponent ids,
|
||||
* which no index covers, so counting them would mean a scan per open room.
|
||||
*/
|
||||
async function resolveHeavyChatUsers() {
|
||||
const openRooms = () =>
|
||||
db
|
||||
.selectFrom("ChatRoom")
|
||||
.where("ChatRoom.closedAt", "is", null)
|
||||
.where("ChatRoom.expiresAt", ">", databaseTimestampNow());
|
||||
|
||||
const participations = await Promise.all([
|
||||
openRooms()
|
||||
.innerJoin("Group", "Group.chatRoomId", "ChatRoom.id")
|
||||
.innerJoin("GroupMember", "GroupMember.groupId", "Group.id")
|
||||
.select("GroupMember.userId")
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("GroupMatch", "GroupMatch.chatRoomId", "ChatRoom.id")
|
||||
.innerJoin("GroupMember", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMember.groupId", "=", eb.ref("GroupMatch.alphaGroupId")),
|
||||
eb("GroupMember.groupId", "=", eb.ref("GroupMatch.bravoGroupId")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select("GroupMember.userId")
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("TournamentTeam", "TournamentTeam.chatRoomId", "ChatRoom.id")
|
||||
.innerJoin(
|
||||
"TournamentTeamMember",
|
||||
"TournamentTeamMember.tournamentTeamId",
|
||||
"TournamentTeam.id",
|
||||
)
|
||||
.select("TournamentTeamMember.userId")
|
||||
.execute(),
|
||||
openRooms()
|
||||
.innerJoin("ScrimPost", "ScrimPost.chatRoomId", "ChatRoom.id")
|
||||
.innerJoin("ScrimPostUser", "ScrimPostUser.scrimPostId", "ScrimPost.id")
|
||||
.select("ScrimPostUser.userId")
|
||||
.execute(),
|
||||
]);
|
||||
|
||||
const roomCountByUserId = new Map<number, number>();
|
||||
for (const row of participations.flat()) {
|
||||
roomCountByUserId.set(
|
||||
row.userId,
|
||||
(roomCountByUserId.get(row.userId) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
|
||||
let busiestUserId: number | null = null;
|
||||
let busiestCount = 0;
|
||||
for (const [userId, count] of roomCountByUserId) {
|
||||
if (count > busiestCount) {
|
||||
busiestUserId = userId;
|
||||
busiestCount = count;
|
||||
}
|
||||
}
|
||||
if (busiestUserId === null) return null;
|
||||
|
||||
const openRoomIds =
|
||||
await ChatRepository.findAllOpenRoomIdsByUserId(busiestUserId);
|
||||
if (openRoomIds.length === 0) return null;
|
||||
|
||||
const mostConnectedId = await resolveMostConnectedUserId();
|
||||
if (mostConnectedId === null) return null;
|
||||
|
||||
return { busiest: { id: busiestUserId, openRoomIds }, mostConnectedId };
|
||||
}
|
||||
|
||||
/** The longest membership history the open-room lookup has to walk, open rooms or not. */
|
||||
async function resolveMostConnectedUserId() {
|
||||
const memberships = await Promise.all([
|
||||
db.selectFrom("GroupMember").select("GroupMember.userId").execute(),
|
||||
db
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.select("TournamentTeamMember.userId")
|
||||
.execute(),
|
||||
db.selectFrom("ScrimPostUser").select("ScrimPostUser.userId").execute(),
|
||||
]);
|
||||
|
||||
const countByUserId = new Map<number, number>();
|
||||
for (const row of memberships.flat()) {
|
||||
countByUserId.set(row.userId, (countByUserId.get(row.userId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
let mostConnectedId: number | null = null;
|
||||
let mostConnectedCount = 0;
|
||||
for (const [userId, count] of countByUserId) {
|
||||
if (count > mostConnectedCount) {
|
||||
mostConnectedId = userId;
|
||||
mostConnectedCount = count;
|
||||
}
|
||||
}
|
||||
|
||||
return mostConnectedId;
|
||||
}
|
||||
|
||||
async function resolveOpenChatRoomIdsByType() {
|
||||
const rows = await db
|
||||
.selectFrom("ChatRoom")
|
||||
.select(["ChatRoom.id", "ChatRoom.type"])
|
||||
.where("ChatRoom.closedAt", "is", null)
|
||||
.where("ChatRoom.expiresAt", ">", databaseTimestampNow())
|
||||
.execute();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
const idsByType: Partial<Record<ChatRoomType, number[]>> = {};
|
||||
for (const row of rows) {
|
||||
const ids = idsByType[row.type] ?? [];
|
||||
if (ids.length < CHAT_ROOM_BATCH_SIZE) ids.push(row.id);
|
||||
idsByType[row.type] = ids;
|
||||
}
|
||||
|
||||
return idsByType;
|
||||
}
|
||||
|
||||
async function resolveManyTournamentTeamIds(heavyTournamentId: number | null) {
|
||||
if (heavyTournamentId === null) return null;
|
||||
|
||||
const rows = await db
|
||||
.selectFrom("TournamentTeam")
|
||||
.select("id")
|
||||
.where("tournamentId", "=", heavyTournamentId)
|
||||
.limit(64)
|
||||
.execute();
|
||||
|
||||
return rows.length > 0 ? rows.map((row) => row.id) : null;
|
||||
}
|
||||
|
||||
async function resolveHeavyChatMessageId() {
|
||||
const row = await db
|
||||
.selectFrom("ChatMessage")
|
||||
|
||||
Reference in New Issue
Block a user