perf(roms): avoid hydrating full Rom rows for siblings on list endpoint

The paginated ROM list eager-loaded sibling_roms via selectinload, which
hydrated full Rom ORM instances (including heavy JSON metadata columns)
for every sibling even though only an existence/count check was needed
on the frontend. On large collections this dominated request latency.

Split sibling handling by response shape:
- SimpleRomSchema (list): siblings is now list[int]; populated per page
  by a single SELECT against the sibling_roms view projecting only
  (rom_id, sibling_rom_id) — no Rom row hydration.
- DetailedRomSchema (detail): keeps full SiblingRomSchema objects, with
  load_only on (id, name, fs_name_no_tags, fs_name_no_ext) so sibling
  rows stop dragging in JSON metadata.

Frontend usage already only consumes siblings.length on list views; the
detail-page VersionSwitcher continues to receive the richer schema.
This commit is contained in:
Claude
2026-05-24 23:17:34 +00:00
parent 63644d0c6f
commit 95b1a99f2a
4 changed files with 68 additions and 25 deletions

View File

@@ -296,7 +296,6 @@ class RomSchema(BaseModel):
missing_from_fs: bool
has_notes: bool
siblings: list[SiblingRomSchema]
rom_user: RomUserSchema
merged_screenshots: list[str]
merged_ra_metadata: RomRAMetadata | None
@@ -304,15 +303,6 @@ class RomSchema(BaseModel):
@classmethod
def populate_properties(cls, db_rom: Rom, request: Request) -> Rom:
db_rom.rom_user = RomUserSchema.for_user(request.user.id, db_rom) # type: ignore
db_rom.siblings = [ # type: ignore
SiblingRomSchema(
id=s.id,
name=s.name,
fs_name_no_tags=s.fs_name_no_tags,
fs_name_no_ext=s.fs_name_no_ext,
)
for s in db_rom.sibling_roms
]
db_rom.has_notes = any( # type: ignore
note.is_public or note.user_id == request.user.id for note in db_rom.notes
)
@@ -330,10 +320,6 @@ class RomSchema(BaseModel):
def sort_files(cls, v: list[RomFileSchema]) -> list[RomFileSchema]:
return sorted(v, key=lambda x: x.file_name)
@field_validator("siblings")
def sort_siblings(cls, v: list[SiblingRomSchema]) -> list[SiblingRomSchema]:
return sorted(v, key=lambda x: x.sort_comparator)
class SiblingRomSchema(BaseModel):
id: int
@@ -355,9 +341,13 @@ class SiblingRomSchema(BaseModel):
class SimpleRomSchema(RomSchema):
siblings: list[int] = []
@classmethod
def from_orm_with_request(cls, db_rom: Rom, request: Request) -> SimpleRomSchema:
db_rom = cls.populate_properties(db_rom, request)
# `sibling_ids` is set on each Rom by the list endpoint before serialization.
db_rom.siblings = getattr(db_rom, "sibling_ids", []) # type: ignore
return cls.model_validate(db_rom)
@classmethod
@@ -387,16 +377,30 @@ class UserCollectionSchema(BaseModel):
class DetailedRomSchema(RomSchema):
siblings: list[SiblingRomSchema] = []
user_saves: list[SaveSchema]
user_states: list[StateSchema]
user_screenshots: list[ScreenshotSchema]
user_collections: list[UserCollectionSchema]
all_user_notes: list[UserNoteSchema]
@field_validator("siblings")
def sort_siblings(cls, v: list[SiblingRomSchema]) -> list[SiblingRomSchema]:
return sorted(v, key=lambda x: x.sort_comparator)
@classmethod
def from_orm_with_request(cls, db_rom: Rom, request: Request) -> DetailedRomSchema:
user_id = request.user.id
db_rom = cls.populate_properties(db_rom, request)
db_rom.siblings = [ # type: ignore
SiblingRomSchema(
id=s.id,
name=s.name,
fs_name_no_tags=s.fs_name_no_tags,
fs_name_no_ext=s.fs_name_no_ext,
)
for s in db_rom.sibling_roms
]
db_rom.user_saves = [ # type: ignore
SaveSchema.model_validate(s) for s in db_rom.saves if s.user_id == user_id

View File

@@ -569,12 +569,20 @@ def get_roms(
with sync_session.begin() as session:
rom_id_index = session.scalars(query.with_only_columns(Rom.id)).all() # type: ignore
def _transform(items):
sibling_ids_by_rom = db_rom_handler.get_sibling_ids_for_roms(
[i.id for i in items]
)
for rom in items:
rom.sibling_ids = sibling_ids_by_rom.get(rom.id, [])
return [
SimpleRomSchema.from_orm_with_request(i, request) for i in items
]
return paginate(
session,
query,
transformer=lambda items: [
SimpleRomSchema.from_orm_with_request(i, request) for i in items
],
transformer=_transform,
additional_data={
"char_index": char_index_dict,
"rom_id_index": rom_id_index,

View File

@@ -38,7 +38,7 @@ from decorators.database import begin_session
from handler.metadata.base_handler import UniversalPlatformSlug as UPS
from models.assets import Save, Screenshot, State
from models.platform import Platform
from models.rom import Rom, RomFile, RomMetadata, RomNote, RomUser
from models.rom import Rom, RomFile, RomMetadata, RomNote, RomUser, SiblingRom
from utils.database import (
json_array_contains_all,
json_array_contains_any,
@@ -146,7 +146,14 @@ def with_details(func):
joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name)
),
selectinload(Rom.sibling_roms).options(
noload(Rom.platform), noload(Rom.metadatum)
load_only(
Rom.id,
Rom.name,
Rom.fs_name_no_tags,
Rom.fs_name_no_ext,
),
noload(Rom.platform),
noload(Rom.metadatum),
),
selectinload(Rom.collections),
selectinload(Rom.notes),
@@ -195,6 +202,33 @@ class DBRomsHandler(DBBaseHandler):
return []
return session.scalars(query.filter(Rom.id.in_(ids))).all()
@begin_session
def get_sibling_ids_for_roms(
self,
rom_ids: Iterable[int],
*,
session: Session = None, # type: ignore
) -> dict[int, list[int]]:
"""Return {rom_id: [sibling_rom_id, ...]} for the given rom IDs.
Single query against the sibling_roms view, projecting only the two
id columns — no Rom row hydration.
"""
ids = list(rom_ids)
if not ids:
return {}
rows = session.execute(
select(SiblingRom.rom_id, SiblingRom.sibling_rom_id).where(
SiblingRom.rom_id.in_(ids)
)
).all()
result: dict[int, list[int]] = {rom_id: [] for rom_id in ids}
for rom_id, sibling_rom_id in rows:
result[rom_id].append(sibling_rom_id)
return result
def filter_by_platform_id(self, query: Query, platform_id: int):
return query.filter(Rom.platform_id == platform_id)
@@ -564,10 +598,8 @@ class DBRomsHandler(DBBaseHandler):
selectinload(Rom.files).options(
joinedload(RomFile.rom).load_only(Rom.fs_path, Rom.fs_name)
),
# Show sibling rom badges on cards
selectinload(Rom.sibling_roms).options(
noload(Rom.platform), noload(Rom.metadatum)
),
# Sibling badges are populated separately via get_sibling_ids_for_roms
# to avoid hydrating full Rom rows (including JSON metadata) per sibling.
# Show notes indicator on cards
selectinload(Rom.notes),
)

View File

@@ -15,7 +15,6 @@ import type { RomMobyMetadata } from './RomMobyMetadata';
import type { RomRAMetadata } from './RomRAMetadata';
import type { RomSSMetadata } from './RomSSMetadata';
import type { RomUserSchema } from './RomUserSchema';
import type { SiblingRomSchema } from './SiblingRomSchema';
export type SimpleRomSchema = {
id: number;
igdb_id: (number | null);
@@ -83,7 +82,7 @@ export type SimpleRomSchema = {
updated_at: string;
missing_from_fs: boolean;
has_notes: boolean;
siblings: Array<SiblingRomSchema>;
siblings: Array<number>;
rom_user: RomUserSchema;
merged_screenshots: Array<string>;
merged_ra_metadata: (RomRAMetadata | null);