Skip to content

Commit 007429c

Browse files
Tim020claude
andauthored
Fix N+1 query performance issues in stats and list endpoints (#1161)
cast/stats and character/stats were firing 5,137 individual SQL queries per request (~8s) due to nested lazy-loaded ORM traversal. Replace with selectinload chains that load all required data in ~8 bulk queries, and a single upfront set-query for revision-scoped cuts rather than one check per line part. Also fixes character list N+1 (per-character character_group and mic_allocation lazy loads) via selectinload on CharacterController, and removes Act.lines / Scene.lines from their schemas — both relationships loaded ~950 ORM rows per act/scene on every list request but were never read by either frontend client. Adds 11 behavioural count-verification tests for cast/stats and character/stats to document and guard the exact counting semantics. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent cf8d625 commit 007429c

5 files changed

Lines changed: 468 additions & 42 deletions

File tree

server/controllers/api/v1/show/cast.py

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collections import defaultdict
22

33
from sqlalchemy import select
4+
from sqlalchemy.orm import selectinload
45
from tornado import escape
56

67
from controllers.api.constants import (
@@ -11,8 +12,16 @@
1112
ERROR_LAST_NAME_MISSING,
1213
ERROR_SHOW_NOT_FOUND,
1314
)
14-
from models.script import Script, ScriptLine, ScriptLineType, ScriptRevision
15-
from models.show import Cast, Character, Show
15+
from models.script import (
16+
Script,
17+
ScriptCuts,
18+
ScriptLine,
19+
ScriptLinePart,
20+
ScriptLineRevisionAssociation,
21+
ScriptLineType,
22+
ScriptRevision,
23+
)
24+
from models.show import Cast, CharacterGroup, Show
1625
from rbac.role import Role
1726
from schemas.schemas import CastSchema
1827
from utils.web.base_controller import BaseAPIController
@@ -186,28 +195,50 @@ async def get(self):
186195
select(Script).where(Script.show_id == show.id)
187196
).first()
188197

189-
if script.current_revision:
190-
revision: ScriptRevision = session.get(
191-
ScriptRevision, script.current_revision
192-
)
193-
else:
198+
if not script.current_revision:
194199
self.set_status(400)
195200
await self.finish(
196201
{"message": "Script does not have a current revision"}
197202
)
198203
return
199204

205+
revision: ScriptRevision = session.scalars(
206+
select(ScriptRevision)
207+
.where(ScriptRevision.id == script.current_revision)
208+
.options(
209+
selectinload(ScriptRevision.line_associations)
210+
.selectinload(ScriptLineRevisionAssociation.line)
211+
.options(
212+
selectinload(ScriptLine.line_parts).options(
213+
selectinload(ScriptLinePart.character),
214+
selectinload(
215+
ScriptLinePart.character_group
216+
).selectinload(CharacterGroup.characters),
217+
)
218+
)
219+
)
220+
).first()
221+
222+
# Load all cut line_part_ids for this revision in a single query.
223+
cut_part_ids: set[int] = set(
224+
session.scalars(
225+
select(ScriptCuts.line_part_id).where(
226+
ScriptCuts.revision_id == revision.id
227+
)
228+
).all()
229+
)
230+
200231
line_counts = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))
201232
for line_association in revision.line_associations:
202233
line: ScriptLine = line_association.line
203234
if line.line_type != ScriptLineType.DIALOGUE:
204235
continue
205236
for line_part in line.line_parts:
206-
if line_part.line_part_cuts is not None:
237+
if line_part.id in cut_part_ids:
207238
continue
208239
if line_part.character_id:
209-
character = session.get(Character, line_part.character_id)
210-
if character.played_by:
240+
character = line_part.character
241+
if character and character.played_by:
211242
line_counts[character.played_by][line.act_id][
212243
line.scene_id
213244
] += 1

server/controllers/api/v1/show/characters.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from collections import defaultdict
22

33
from sqlalchemy import select, update
4+
from sqlalchemy.orm import selectinload
45
from tornado import escape
56

67
from controllers.api.constants import (
@@ -15,8 +16,10 @@
1516
)
1617
from models.script import (
1718
Script,
19+
ScriptCuts,
1820
ScriptLine,
1921
ScriptLinePart,
22+
ScriptLineRevisionAssociation,
2023
ScriptLineType,
2124
ScriptRevision,
2225
)
@@ -39,9 +42,19 @@ def get(self):
3942
with self.make_session() as session:
4043
show = session.get(Show, show_id)
4144
if show:
42-
characters = [character_schema.dump(c) for c in show.character_list]
45+
characters = session.scalars(
46+
select(Character)
47+
.where(Character.show_id == show.id)
48+
.options(
49+
selectinload(Character.character_groups),
50+
selectinload(Character.mic_allocations),
51+
selectinload(Character.cast_member),
52+
)
53+
).all()
4354
self.set_status(200)
44-
self.finish({"characters": characters})
55+
self.finish(
56+
{"characters": [character_schema.dump(c) for c in characters]}
57+
)
4558
else:
4659
self.set_status(404)
4760
self.finish({"message": ERROR_SHOW_NOT_FOUND})
@@ -294,24 +307,45 @@ async def get(self):
294307
select(Script).where(Script.show_id == show.id)
295308
).first()
296309

297-
if script.current_revision:
298-
revision: ScriptRevision = session.get(
299-
ScriptRevision, script.current_revision
300-
)
301-
else:
310+
if not script.current_revision:
302311
self.set_status(400)
303312
await self.finish(
304313
{"message": "Script does not have a current revision"}
305314
)
306315
return
307316

317+
revision: ScriptRevision = session.scalars(
318+
select(ScriptRevision)
319+
.where(ScriptRevision.id == script.current_revision)
320+
.options(
321+
selectinload(ScriptRevision.line_associations)
322+
.selectinload(ScriptLineRevisionAssociation.line)
323+
.options(
324+
selectinload(ScriptLine.line_parts).options(
325+
selectinload(
326+
ScriptLinePart.character_group
327+
).selectinload(CharacterGroup.characters),
328+
)
329+
)
330+
)
331+
).first()
332+
333+
# Load all cut line_part_ids for this revision in a single query.
334+
cut_part_ids: set[int] = set(
335+
session.scalars(
336+
select(ScriptCuts.line_part_id).where(
337+
ScriptCuts.revision_id == revision.id
338+
)
339+
).all()
340+
)
341+
308342
line_counts = defaultdict(lambda: defaultdict(lambda: defaultdict(int)))
309343
for line_association in revision.line_associations:
310344
line: ScriptLine = line_association.line
311345
if line.line_type != ScriptLineType.DIALOGUE:
312346
continue
313347
for line_part in line.line_parts:
314-
if line_part.line_part_cuts is not None:
348+
if line_part.id in cut_part_ids:
315349
continue
316350
if line_part.character_id:
317351
line_counts[line_part.character_id][line.act_id][

server/schemas/schemas.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ class Meta:
170170
model = Act
171171
include_relationships = True
172172
load_instance = True
173+
exclude = ("lines",)
173174

174175

175176
@schema
@@ -178,6 +179,7 @@ class Meta:
178179
model = Scene
179180
include_relationships = True
180181
load_instance = True
182+
exclude = ("lines",)
181183

182184

183185
@schema

0 commit comments

Comments
 (0)