diff --git a/.claude/skills/db-benchmark/SKILL.md b/.claude/skills/db-benchmark/SKILL.md index e090f3f9e..f9d3fb8b7 100644 --- a/.claude/skills/db-benchmark/SKILL.md +++ b/.claude/skills/db-benchmark/SKILL.md @@ -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 diff --git a/app/features/chat/ChatRepository.server.test.ts b/app/features/chat/ChatRepository.server.test.ts index d6abc1a4d..e27b39b2e 100644 --- a/app/features/chat/ChatRepository.server.test.ts +++ b/app/features/chat/ChatRepository.server.test.ts @@ -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(); diff --git a/app/features/chat/ChatRepository.server.ts b/app/features/chat/ChatRepository.server.ts index ec9671dc7..089f130d5 100644 --- a/app/features/chat/ChatRepository.server.ts +++ b/app/features/chat/ChatRepository.server.ts @@ -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 { 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; + function noOwner( eb: ExpressionBuilder, 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`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`${sql.ref(`TournamentMatch.${column}`)} ->> '$.id'`; } diff --git a/app/features/chat/ChatRoomResolver.server.test.ts b/app/features/chat/ChatRoomResolver.server.test.ts index 246e6186c..523735103 100644 --- a/app/features/chat/ChatRoomResolver.server.test.ts +++ b/app/features/chat/ChatRoomResolver.server.test.ts @@ -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)); diff --git a/migrations/20260822134022-chat-rooms.ts b/migrations/20260822134022-chat-rooms.ts index 05ab28995..81b94d8a8 100644 --- a/migrations/20260822134022-chat-rooms.ts +++ b/migrations/20260822134022-chat-rooms.ts @@ -130,5 +130,21 @@ export async function up(db: Kysely): Promise { .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(); }); } diff --git a/package.json b/package.json index 52e54b2f4..50dc4dedc 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 961290b7d..23462c694 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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, diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index c4dc99140..1cc8d857f 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -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> | 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 { 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 { 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(); + 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(); + 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> = {}; + 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")