[rooms] fix slow room role query

This commit is contained in:
Devin Zuczek
2026-10-20 16:25:51 -04:00
parent 71b0215ed3
commit eab86ec223
4 changed files with 204 additions and 35 deletions
+31
View File
@@ -0,0 +1,31 @@
-- Index a room's `Roles` by account, so "rooms this account has a role in"
-- (`getContributedRooms`) is a lookup instead of a scan.
--
-- Roles live in the room blob, and the only way to ask "which rooms name account N" of
-- the blob is a `json_each` over every room's `$.Roles`: the whole table, every blob
-- parsed, on every call. `room_role` is a DERIVED copy of those entries, one row per
-- (account, room). The blob stays the source of truth; every write in
-- `packages/domain/src/rooms-db.ts` that changes `Roles` batches `roomRoleWrites` with it.
--
-- Every entry with an `AccountId` counts, pending invites (`InvitedRole` only) included,
-- which is exactly what the json_each query matched.
CREATE TABLE IF NOT EXISTS room_role (
account_id INTEGER NOT NULL,
room_id INTEGER NOT NULL,
PRIMARY KEY (account_id, room_id)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_room_role_room ON room_role (room_id);
-- Backfill from the rooms already stored. A full rebuild rather than an append, so this can
-- be re-run as-is to catch role changes made by the old code between migrating and deploying.
-- Non-object entries are skipped through a CASE rather than handed to json_extract, which
-- raises on a bare string.
DELETE FROM room_role;
INSERT OR IGNORE INTO room_role (account_id, room_id)
SELECT account_id, room_id FROM (
SELECT CASE WHEN role.type = 'object' THEN json_extract(role.value, '$.AccountId') END AS account_id,
room.room_id AS room_id
FROM room, json_each(room.data, '$.Roles') AS role
) WHERE account_id > 0 AND room_id IS NOT NULL;
@@ -0,0 +1,46 @@
-- Give `room_role` the whole role entry, not just which account a room names.
--
-- 0029 stored (account_id, room_id) alone, which is all `getContributedRooms` asks of it.
-- A table of a room's roles should carry the roles themselves, though: each row now mirrors
-- one `Roles` entry from the room blob, so a query that cares about the tier (managers only,
-- pending invites) can filter on it here instead of going back to `json_each` over every
-- room. The blob is still the source of truth, and `roomRoleWrites` still rewrites a room's
-- rows alongside every write that changes `Roles`.
--
-- Columns follow `roomRoles()`, which fills in what older entries lack: `role` and
-- `invited_role` default to 0 (Role.None), `last_changed_by_account_id` to null.
--
-- Nothing reads the table until the worker deploys, so it is rebuilt rather than altered.
-- The triggers are dropped in case an early draft of 0029 created them; they are not used.
DROP TRIGGER IF EXISTS room_role_insert;
DROP TRIGGER IF EXISTS room_role_update;
DROP TRIGGER IF EXISTS room_role_delete;
DROP TABLE IF EXISTS room_role;
CREATE TABLE room_role (
account_id INTEGER NOT NULL,
room_id INTEGER NOT NULL,
role INTEGER NOT NULL DEFAULT 0,
invited_role INTEGER NOT NULL DEFAULT 0,
last_changed_by_account_id INTEGER,
PRIMARY KEY (account_id, room_id)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS idx_room_role_room ON room_role (room_id);
-- Backfill from the rooms already stored. Like 0029's, it is a full rebuild, so it can be
-- re-run as-is after the deploy to catch role changes the old code made in between.
-- Non-object entries are skipped through a CASE rather than handed to json_extract, which
-- raises on a bare string.
DELETE FROM room_role;
INSERT OR IGNORE INTO room_role (account_id, room_id, role, invited_role, last_changed_by_account_id)
SELECT account_id, room_id, COALESCE(role, 0), COALESCE(invited_role, 0), last_changed_by_account_id
FROM (
SELECT room.room_id AS room_id,
CASE WHEN r.type = 'object' THEN json_extract(r.value, '$.AccountId') END AS account_id,
CASE WHEN r.type = 'object' THEN json_extract(r.value, '$.Role') END AS role,
CASE WHEN r.type = 'object' THEN json_extract(r.value, '$.InvitedRole') END AS invited_role,
CASE WHEN r.type = 'object' THEN json_extract(r.value, '$.LastChangedByAccountId') END AS last_changed_by_account_id
FROM room, json_each(room.data, '$.Roles') AS r
) WHERE account_id > 0 AND room_id IS NOT NULL;
+32 -1
View File
@@ -13,6 +13,7 @@ import {
countRoomsUsingInvention,
createRoomInstance,
decodeInventionUsage,
getRoomById,
getRoomInstance,
MessageType,
NOTIFICATION_SCHEMA_DDL,
@@ -20,11 +21,14 @@ import {
publicStudioBundleFilename,
putUnityAsset,
recordRoomVisit,
removeRoomRole,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_INVITE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
ROOM_XP_SCHEMA_DDL,
roomRoleWrites,
seedRoomWithSubRooms,
setRoomRole,
STUDIO_CLOUD_BUILD_SCHEMA_DDL,
SUBROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
@@ -456,8 +460,20 @@ describe('rooms endpoints', () => {
})
it('GET /rooms/contributedby/me lists rooms the caller owns or has a role in', async () => {
// The roles half reads `room_role`, so a seeded room's roles go there too, as every
// write in rooms-db does.
const seed = (data: Record<string, unknown>) =>
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)).run()
env.DB.batch([
env.DB.prepare('INSERT INTO room (data) VALUES (?1)').bind(JSON.stringify(data)),
...roomRoleWrites(env.DB, Number(data.RoomId), data as Room),
])
const contributed = async (accountId: string) => {
const res = await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`, {
headers: await bearer(accountId),
})
const rooms = (await res.json()) as Array<{ RoomId: number }>
return rooms.map((r) => r.RoomId).sort((a, b) => a - b)
}
// A room somebody else made, where 820 is a co-owner...
await seed({
@@ -545,9 +561,24 @@ describe('rooms endpoints', () => {
// Auth-scoped: `me` is the token, so no token is a 401.
expect((await SELF.fetch(`${ORIGIN}/rooms/contributedby/me`)).status).toBe(401)
// Role changes move a room in and out of the list: granting 829 a role in 30404
// lists it for them, and taking the role back drops it again.
const other = (await getRoomById(env.DB, 30404))!
const granted = await setRoomRole(env.DB, 30404, 829, 10, 821, other)
expect(await contributed('829')).toEqual([30404])
// The row carries the whole entry, as the blob does.
expect(
await env.DB.prepare(
'SELECT role, invited_role, last_changed_by_account_id FROM room_role WHERE room_id = 30404 AND account_id = 829'
).first()
).toEqual({ role: 10, invited_role: 0, last_changed_by_account_id: 821 })
await removeRoomRole(env.DB, 30404, 829, 821, granted!)
expect(await contributed('829')).toEqual([])
// The DB is shared across this file, and these are the only player-made public rooms
// in it — leaving them behind changes what the `new`/`community` room feeds serve.
await env.DB.prepare('DELETE FROM room WHERE room_id BETWEEN 30401 AND 30407').run()
await env.DB.prepare('DELETE FROM room_role WHERE room_id BETWEEN 30401 AND 30407').run()
})
it('GET /rooms/:roomId/experience serves the room’s stored XP settings, no auth', async () => {