This commit is contained in:
Kalle
2026-08-26 21:19:48 +03:00
parent f1bb6a3635
commit 3038bf29da
8 changed files with 394 additions and 127 deletions

View File

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

View File

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

View File

@@ -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'`;
}

View File

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

View File

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

View File

@@ -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 .",

View File

@@ -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,

View File

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