diff --git a/apps/rooms/migrations/0021_interaction_room_index.sql b/apps/rooms/migrations/0021_interaction_room_index.sql new file mode 100644 index 0000000..30d514b --- /dev/null +++ b/apps/rooms/migrations/0021_interaction_room_index.sql @@ -0,0 +1,12 @@ +-- Index `interaction` by room, covering the two counters. +-- +-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync. +-- +-- The table's primary key is (player_id, room_id), which serves the per-player reads +-- (toggle a cheer, list my favorites) but is useless to the per-ROOM aggregate that every +-- room read runs (`getRoomStats`: `SELECT room_id, SUM(cheered), SUM(favorited) … WHERE +-- room_id IN (…) GROUP BY room_id`). Without this index that query scanned the whole +-- table on every call — 1.29B rows read over five days. With it, each room id is one +-- index range, and the two counters are in the index so the table itself is never read. + +CREATE INDEX IF NOT EXISTS idx_interaction_room ON interaction (room_id, cheered, favorited); diff --git a/packages/domain/src/rooms-db.ts b/packages/domain/src/rooms-db.ts index e9063e3..0a7b893 100644 --- a/packages/domain/src/rooms-db.ts +++ b/packages/domain/src/rooms-db.ts @@ -87,6 +87,11 @@ export const ROOM_SCHEMA_DDL: string[] = [ last_visited_at TEXT, PRIMARY KEY (player_id, room_id) )`, + // Per-ROOM access (migrations/0021_interaction_room_index.sql): the primary key + // serves the per-player reads, but the cheer/favorite aggregate every room read runs + // (`getRoomStats`) filters by room_id, which without this scanned the whole table. + // Covering — both counters are in the index, so the aggregate never touches the table. + `CREATE INDEX IF NOT EXISTS idx_interaction_room ON interaction (room_id, cheered, favorited)`, // Per-room player bans (migrations/0010_room_ban.sql). One row per (room, player), // so re-banning someone already banned updates their row rather than appending. // `ban_mask` is the client's `banMask` field kept verbatim — its meaning isn't known @@ -2205,9 +2210,6 @@ interface RoomStatsRow { /** The counters a room starts life with (and the shape the client expects). */ const ZERO_STATS = { CheerCount: 0, FavoriteCount: 0, VisitorCount: 0, VisitCount: 0 } -/** D1 caps a query at 100 bound parameters, and a feed page can carry more ids than that. */ -const STATS_ID_LIMIT = 90 - /** A room's RoomId, or 0 for a blob without one. */ const roomIdOf = (room: Room): number => (typeof room.RoomId === 'number' ? room.RoomId : 0) @@ -2232,10 +2234,14 @@ export async function recordRoomVisit(db: D1Database, roomId: number): Promise> { const byRoom = new Map() if (roomIds && roomIds.length === 0) return byRoom - const ids = roomIds && roomIds.length <= STATS_ID_LIMIT ? roomIds : [] - const where = - ids.length > 0 ? `WHERE room_id IN (${ids.map((_, i) => `?${i + 1}`).join(',')})` : '' - const { results } = await db - .prepare( - `SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites - FROM interaction ${where} GROUP BY room_id` - ) - .bind(...ids) - .all() - for (const r of results) { - byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 }) + const statement = (ids: number[]): D1PreparedStatement => { + const where = ids.length > 0 ? `WHERE room_id IN (${bindPlaceholders(ids)})` : '' + return db + .prepare( + `SELECT room_id, SUM(cheered) AS cheers, SUM(favorited) AS favorites + FROM interaction ${where} GROUP BY room_id` + ) + .bind(...ids) + } + const chunks = roomIds ? chunkForBinds(roomIds) : [[]] + const batches = + chunks.length === 1 + ? [await statement(chunks[0]!).all()] + : await db.batch(chunks.map(statement)) + for (const { results } of batches) { + for (const r of results) { + byRoom.set(r.room_id, { CheerCount: r.cheers ?? 0, FavoriteCount: r.favorites ?? 0 }) + } } return byRoom }