mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] fix slow rooms badge query
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
|
||||||
|
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
|
||||||
|
-- room read — asks this table for the published badges of the rooms on the page, and without an
|
||||||
|
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
|
||||||
|
-- page. At the time of writing it was half of the database's row reads.
|
||||||
|
--
|
||||||
|
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
|
||||||
|
-- a partial index only when the query's WHERE contains its term as written, and an expression
|
||||||
|
-- index only when the query names the same expression, so the query must keep BOTH
|
||||||
|
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
|
||||||
|
-- change either and the index silently stops being used. The key is an expression rather than
|
||||||
|
-- a new generated column so the query reads the same whether or not this migration has run yet
|
||||||
|
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
|
||||||
|
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
|
||||||
|
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
|
||||||
|
WHERE outfit_type = 1000;
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
||||||
|
|
||||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
|
||||||
export const SCHEMA_DDL: string[] = [
|
export const SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
|
|||||||
)`,
|
)`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||||
|
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
|
||||||
|
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
|
||||||
]
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
UNITY_ASSET_SCHEMA_DDL,
|
UNITY_ASSET_SCHEMA_DDL,
|
||||||
} from '@repo/domain'
|
} from '@repo/domain'
|
||||||
|
|
||||||
|
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||||
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
|
||||||
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
import { SCHEMA_DDL as ROOM_VOTE_SCHEMA_DDL } from '../../../../api/src/votes-db'
|
||||||
import { NotificationType } from '../../../../notify/src/notification-types'
|
import { NotificationType } from '../../../../notify/src/notification-types'
|
||||||
@@ -134,6 +135,7 @@ beforeAll(async () => {
|
|||||||
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_VOTE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
// Inventions (owned by the api worker) — a room save writes each one's room count.
|
||||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of ROOM_INVITE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
// Baked unity asset bundles — what `POST /unity_assets/baked/bulk` serves.
|
||||||
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of UNITY_ASSET_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
@@ -7209,3 +7211,39 @@ it('omits hydrated Badges when persisting a room settings edit', async () => {
|
|||||||
}>()
|
}>()
|
||||||
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
|
expect(JSON.parse(stored!.data)).not.toHaveProperty('Badges')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The badge lookup runs on every room page, served by a partial index over the badge rows
|
||||||
|
// (api migrations/0035, mirrored in SCHEMA_DDL above). Only a PUBLISHED badge with a built save
|
||||||
|
// hydrates; a draft stays on its creator's Studio shelf.
|
||||||
|
it('GET /rooms/:id hydrates the room\'s published badges', async () => {
|
||||||
|
await seedRoomWithSubRooms(env.DB, {
|
||||||
|
RoomId: 987671,
|
||||||
|
Name: 'BadgeHydration',
|
||||||
|
CreatorAccountId: 987671,
|
||||||
|
SubRooms: [],
|
||||||
|
})
|
||||||
|
const badge = (id: string, roomId: number, accessibility: number, saves: unknown[]) =>
|
||||||
|
JSON.stringify({
|
||||||
|
CustomAvatarItemId: id,
|
||||||
|
CreatorAccountId: 987671,
|
||||||
|
Name: id,
|
||||||
|
OutfitType: 1000,
|
||||||
|
Accessibility: accessibility,
|
||||||
|
CurrentSaves: saves,
|
||||||
|
CustomBadgeMetadata: { RoomId: roomId },
|
||||||
|
})
|
||||||
|
const insert = env.DB.prepare('INSERT INTO custom_avatar_item (data) VALUES (?)')
|
||||||
|
await env.DB.batch([
|
||||||
|
insert.bind(badge('badge-published', 987671, 1, [{ BodyType: 0 }])),
|
||||||
|
insert.bind(badge('badge-draft', 987671, 0, [{ BodyType: 0 }])),
|
||||||
|
insert.bind(badge('badge-unbuilt', 987671, 1, [])),
|
||||||
|
insert.bind(badge('badge-other-room', 987670, 1, [{ BodyType: 0 }])),
|
||||||
|
])
|
||||||
|
// Twice: the second read takes the path that remembers the table exists.
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const res = await SELF.fetch(`${ORIGIN}/rooms/987671`)
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const room = (await res.json()) as { Badges: Array<{ CustomAvatarItemId: string }> }
|
||||||
|
expect(room.Badges.map((b) => b.CustomAvatarItemId)).toEqual(['badge-published'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -2846,10 +2846,18 @@ async function hydrateRooms(db: D1Database, rooms: Room[]): Promise<Room[]> {
|
|||||||
return rooms
|
return rooms
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `custom_avatar_item` has been seen on this database. API owns that table's migration
|
||||||
|
* and room deployments can precede it, so {@link attachBadges} probes for it — but a table
|
||||||
|
* never goes away once created, so a hit is remembered and the probe (a scan of
|
||||||
|
* `sqlite_master`, on every room page) runs only until the first hit.
|
||||||
|
*/
|
||||||
|
let badgeTableSeen = false
|
||||||
|
|
||||||
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
|
/** Published room-linked image badges; drafts remain on the creator's Studio shelf. */
|
||||||
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
||||||
if (!rooms.length) return
|
if (!rooms.length) return
|
||||||
// API owns this table's migration; room deployments can precede that migration.
|
if (!badgeTableSeen) {
|
||||||
const exists = await db
|
const exists = await db
|
||||||
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='custom_avatar_item'")
|
.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='custom_avatar_item'")
|
||||||
.first()
|
.first()
|
||||||
@@ -2857,6 +2865,12 @@ async function attachBadges(db: D1Database, rooms: Room[]): Promise<void> {
|
|||||||
for (const room of rooms) room.Badges = []
|
for (const room of rooms) room.Badges = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
badgeTableSeen = true
|
||||||
|
}
|
||||||
|
// Served by `idx_custom_avatar_item_badge_room` (api migrations/0035), a PARTIAL index over
|
||||||
|
// `outfit_type = 1000` keyed by the `json_extract(data,'$.CustomBadgeMetadata.RoomId')`
|
||||||
|
// expression. SQLite matches both only as written: drop or rephrase either term and this
|
||||||
|
// goes back to scanning every custom avatar item on every room page.
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
|
"SELECT data,json_extract(data,'$.CustomBadgeMetadata.RoomId') AS room_id FROM custom_avatar_item WHERE outfit_type=1000 AND accessibility<>0 AND json_array_length(json_extract(data,'$.CurrentSaves'))>0 AND json_extract(data,'$.CustomBadgeMetadata.RoomId') IN (SELECT value FROM json_each(?))"
|
||||||
|
|||||||
Reference in New Issue
Block a user