[rooms] improve interaction query

This commit is contained in:
Devin Zuczek
2026-09-27 11:19:05 -04:00
parent 08bad7742d
commit fd5d0e6330
2 changed files with 43 additions and 19 deletions
@@ -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);
+24 -12
View File
@@ -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<v
}
/**
* Cheer/favorite counts per room, aggregated from `interaction` in ONE grouped query.
* Restricted to `roomIds` when given (a feed page), otherwise covering every room —
* which is also what a page too large to bind gets, since scanning the whole table is
* cheaper than splitting the query. Rooms nobody has interacted with are absent.
* Cheer/favorite counts per room, aggregated from `interaction` by a grouped query.
* Restricted to `roomIds` when given (a feed page), otherwise covering every room.
* Rooms nobody has interacted with are absent.
*
* The filtered form is an index range per id on `idx_interaction_room`, whose columns
* cover the counters — the table is never read. That's why a page with more ids than
* D1 lets us bind is split into batched queries rather than served by the unfiltered
* scan: every room read runs this, and the scan cost the whole table per call.
*/
export async function getRoomStats(
db: D1Database,
@@ -2243,19 +2249,25 @@ export async function getRoomStats(
): Promise<Map<number, RoomStats>> {
const byRoom = new Map<number, RoomStats>()
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
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)
.all<RoomStatsRow>()
}
const chunks = roomIds ? chunkForBinds(roomIds) : [[]]
const batches =
chunks.length === 1
? [await statement(chunks[0]!).all<RoomStatsRow>()]
: await db.batch<RoomStatsRow>(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
}