[rooms] fix slow engagement counters

This commit is contained in:
Devin Zuczek
2026-10-20 21:03:54 -04:00
parent 5f3cdc5922
commit affdcb85e3
4 changed files with 167 additions and 170 deletions
@@ -0,0 +1,27 @@
-- Cheer and favorite counters on `room`, as columns — the cheer/favorite twins of
-- `visits` (0011).
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- Until now `Stats.CheerCount`/`FavoriteCount` were DERIVED on every read by aggregating
-- the per-player `interaction` rows (`SELECT room_id, SUM(cheered), SUM(favorited) …
-- GROUP BY room_id`). 0021's covering index made the per-room form cheap, but the browse
-- and discovery feeds rank EVERY listable room, so they ran the unfiltered form — a walk
-- of the whole index per call — and the discovery page runs the hot feed once per
-- category row. That was the bulk of the database's reads.
--
-- Now each counter is kept current by the toggles themselves: the same batch that flips a
-- player's `interaction.cheered`/`favorited` applies a `cheers = cheers ± 1` to the room,
-- so a cheer still shows immediately and concurrent toggles can't lose each other. The
-- per-player rows stay the record of WHO cheered; the columns are the count of them.
--
-- The backfill below is the one remaining full aggregate, run once here. `room_id` is a
-- virtual generated column, so the correlated subquery resolves it per row; the sums come
-- off `idx_interaction_room` without touching the table.
ALTER TABLE room ADD COLUMN cheers INTEGER NOT NULL DEFAULT 0;
ALTER TABLE room ADD COLUMN favorites INTEGER NOT NULL DEFAULT 0;
UPDATE room SET
cheers = (SELECT COALESCE(SUM(cheered), 0) FROM interaction WHERE interaction.room_id = room.room_id),
favorites = (SELECT COALESCE(SUM(favorited), 0) FROM interaction WHERE interaction.room_id = room.room_id);
+6 -5
View File
@@ -149,11 +149,12 @@ export const RoomTagDto = z.object({
})
/**
* A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the
* per-player `interaction` rows on every read. `VisitCount` is the room's lifetime
* visits — the `room.visits` column, bumped by the `match` worker on every successful
* matchmake into the room. Nothing records distinct visitors, so `VisitorCount` stays
* at 0.
* A room's engagement counters. `CheerCount`/`FavoriteCount` are the `room.cheers`/
* `favorites` columns, moved by the cheer/favorite toggles as they flip the per-player
* `interaction` row (so a count is how many players currently have the flag set).
* `VisitCount` is the room's lifetime visits — the `room.visits` column, bumped by the
* `match` worker on every successful matchmake into the room. Nothing records distinct
* visitors, so `VisitorCount` stays at 0.
*/
export const RoomStatsDto = z.object({
CheerCount: z.int(),
+16 -1
View File
@@ -4769,7 +4769,7 @@ describe('rooms endpoints', () => {
expect(otherGet).toMatchObject({ Cheered: false, Favorited: false })
})
it('room Stats aggregate cheers/favorites from the interaction table', async () => {
it('room Stats count cheers/favorites on the room row, kept by the toggles', async () => {
type Stats = {
CheerCount: number
FavoriteCount: number
@@ -4818,6 +4818,21 @@ describe('rooms endpoints', () => {
VisitCount: 0,
})
// The counts are columns on the room, not an aggregate taken on read — the toggles
// keep them. A repeated clear changes nothing (the flag was already off), and neither
// does clearing a flag a player never set, so neither counts down again.
await interact('562', 'cheer', 'DELETE')
await interact('563', 'favorite', 'DELETE')
expect(await direct()).toMatchObject({ CheerCount: 1, FavoriteCount: 1 })
expect(
await env.DB.prepare('SELECT cheers, favorites FROM room WHERE room_id = 15').first()
).toEqual({ cheers: 1, favorites: 1 })
// Toggling off and on again lands back where it started.
await interact('561', 'cheer', 'PUT')
expect(await direct()).toMatchObject({ CheerCount: 0 })
await interact('561', 'cheer', 'PUT')
expect(await direct()).toMatchObject({ CheerCount: 1 })
// VisitCount is the `room.visits` column (what match bumps on each matchmake),
// served on every read of the room — here and in the search results — and it
// survives the cheer/favorite aggregation rather than being zeroed by it.