perf: batch image_url prefetch in list_card_states to eliminate N+1 (#199)

Replace per-row CardModel.get() in _build_card_state_response with a
bulk prefetch in list_card_states: collect variant player IDs, issue at
most 2 queries (BattingCard + PitchingCard), build a (player_id, variant)
-> image_url map, and pass the resolved value directly to the helper.

The single-card get_card_state path is unchanged and still resolves
image_url inline (one extra query is acceptable for a single-item response).

Closes #199

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-04-07 21:04:33 -05:00
parent 3852fe1408
commit b7196c1c56

View File

@ -23,8 +23,12 @@ _NEXT_THRESHOLD_ATTR = {
4: None,
}
# Sentinel used by _build_card_state_response to distinguish "caller did not
# pass image_url" (do the DB lookup) from "caller passed None" (use None).
_UNSET = object()
def _build_card_state_response(state, player_name=None) -> dict:
def _build_card_state_response(state, player_name=None, image_url=_UNSET) -> dict:
"""Serialise a RefractorCardState into the standard API response shape.
Produces a flat dict with player_id and team_id as plain integers,
@ -67,22 +71,27 @@ def _build_card_state_response(state, player_name=None) -> dict:
if player_name is not None:
result["player_name"] = player_name
# Resolve image_url from the variant card row
image_url = None
if state.variant and state.variant > 0:
card_type = (
state.track.card_type if hasattr(state, "track") and state.track else None
)
if card_type:
CardModel = BattingCard if card_type == "batter" else PitchingCard
try:
variant_card = CardModel.get(
(CardModel.player_id == state.player_id)
& (CardModel.variant == state.variant)
)
image_url = variant_card.image_url
except CardModel.DoesNotExist:
pass
# Resolve image_url from the variant card row.
# When image_url is pre-fetched by the caller (batch list path), it is
# passed directly and the per-row DB query is skipped entirely.
if image_url is _UNSET:
image_url = None
if state.variant and state.variant > 0:
card_type = (
state.track.card_type
if hasattr(state, "track") and state.track
else None
)
if card_type:
CardModel = BattingCard if card_type == "batter" else PitchingCard
try:
variant_card = CardModel.get(
(CardModel.player_id == state.player_id)
& (CardModel.variant == state.variant)
)
image_url = variant_card.image_url
except CardModel.DoesNotExist:
pass
result["image_url"] = image_url
return result
@ -230,14 +239,43 @@ async def list_card_states(
query = query.where(RefractorCardState.last_evaluated_at.is_null(False))
total = query.count() or 0
states_page = list(query.offset(offset).limit(limit))
# Pre-fetch image_urls in at most 2 bulk queries (one per card table) so
# that _build_card_state_response never issues a per-row CardModel.get().
batter_pids: set[int] = set()
pitcher_pids: set[int] = set()
for state in states_page:
if state.variant and state.variant > 0:
card_type = state.track.card_type if state.track else None
if card_type == "batter":
batter_pids.add(state.player_id)
elif card_type in ("sp", "rp"):
pitcher_pids.add(state.player_id)
image_url_map: dict[tuple[int, int], str | None] = {}
if batter_pids:
for card in BattingCard.select().where(BattingCard.player_id.in_(batter_pids)):
image_url_map[(card.player_id, card.variant)] = card.image_url
if pitcher_pids:
for card in PitchingCard.select().where(
PitchingCard.player_id.in_(pitcher_pids)
):
image_url_map[(card.player_id, card.variant)] = card.image_url
items = []
for state in query.offset(offset).limit(limit):
for state in states_page:
player_name = None
try:
player_name = state.player.p_name
except Exception:
pass
items.append(_build_card_state_response(state, player_name=player_name))
img_url = image_url_map.get((state.player_id, state.variant))
items.append(
_build_card_state_response(
state, player_name=player_name, image_url=img_url
)
)
return {"count": total, "items": items}