Skip to content

perf(question-feed): memoize contested-card lookups within a single feed request - #729

Merged
WilfordGrimley merged 1 commit into
masterfrom
perf-memoize-contested
Aug 6, 2026
Merged

perf(question-feed): memoize contested-card lookups within a single feed request#729
WilfordGrimley merged 1 commit into
masterfrom
perf-memoize-contested

Conversation

@WilfordGrimley

Copy link
Copy Markdown

Summary

  • get_contested_card_ids() and get_contested_artist_card_ids() were each invoked separately by every tier that consulted them (_tier_2_contested, _tier_4_fresh), recomputing an identical answer within a single get_next_question_feed_item() call - the same duplication shape Pool printing-consensus votes across md5 identity groups #482 already fixed for the voter-exclusion sets (answered_card_ids et al). Both are now resolved once, right before the tier-2 fallthrough, and threaded through as optional parameters that default to None and compute lazily if absent, matching the existing convention exactly.
  • The memoization point is placed right before the tier-2 call rather than eagerly at the top of get_next_question_feed_item (unlike the answered_* sets, which are always needed): a request served by the likely-resolve pool or tier 1 never reaches tier 2/4, so it never pays this cost either way, before or after this change.
  • Isolated per-call cost measured against production (read-only, 3 runs): get_contested_card_ids() 520-562ms, get_contested_artist_card_ids() 18-21ms (currently near-free since the contested-artist set is empty right now).
  • Verified the fix is real, not cosmetic: temporarily reverted question_feed.py only (keeping the new test) and confirmed the new memoization test fails against the old code with call_count=2, then restored the fix and confirmed it passes with call_count=1.
  • Checked against real production data whether the specific tier-2-to-tier-4 duplicate call currently fires for any real voter: it does not right now, for any anonymous_id tested (a fresh voter, the heaviest bot voter at 58,642 votes, and the heaviest human voter at 163 votes) - there are currently 8,339 contested-printing candidates, more than any single voter's exclusion set, so tier 2's printing branch always finds an unanswered one and tier 4 is never reached today. This makes the fix currently latent rather than actively saving wall-clock time in production traffic - it prevents the duplicate from recurring as the contested-printing pool naturally shrinks over time (the site's stated trajectory), at which point tier 4 fallthrough - and the duplicate call - will become common. Framed honestly rather than claiming a live win that isn't measurable today.
  • Separately, get_remaining_estimate() (called by the view alongside get_next_question_feed_item() for every /2/questionFeed/ request) also calls get_contested_card_ids() once, already memoized to exactly one call within itself. That is a second, independent top-level call from views.py this PR does not touch, following Pool printing-consensus votes across md5 identity groups #482's own precedent of scoping the memoization to within get_next_question_feed_item()'s own call tree rather than spanning the view boundary - noted as an open item below.
  • Added TestContestedIdsMemoizedPerRequest with two cases: one proving get_contested_card_ids/get_contested_artist_card_ids are each called exactly once when a request falls through tier 2 into tier 4, and one proving neither is called at all when tier 1 serves the item.
  • No new mechanism introduced (no new calculator, identity relation, extractor, index, or pipeline stage) - this is a within-request memoization of two existing, unmodified functions, so no additional documentation is added.

Separately, investigated (read-only, no index created) whether the four tier queries apply a LIMIT before evaluation, to settle whether a stopgap date_created index is worth building:

  • _likely_resolve_printing_card (question_feed.py:391-433) and _tier_1_confirm_suggestion (question_feed.py:448-467): no LIMIT/slice/.first()/.exists() - both iterate the full .order_by("date_created") queryset via .iterator(), stopping the Python loop early on a match, but the SQL itself has no LIMIT. EXPLAIN (ANALYZE, BUFFERS) against production confirms no Limit node at all: an external merge disk sort over the full pre-filtered set runs to completion (Sort Method: external merge, ~27-28MB per worker) before any row is available.
  • _tier_2_contested (question_feed.py:470-519) and _tier_4_fresh (question_feed.py:542-608): both use .first() on their printing/artist branches, which does add LIMIT 1. Confirmed via EXPLAIN on the actual qs[:1] Django executes: both plans have a top-level Limit node.
    • For _tier_2_contested's printing branch, the Limit sits above a cheap in-memory top-N heapsort (not a disk sort) - the pk__in=contested_card_ids filter (8,339 ids today) already narrows the row count enough that Postgres never spills to disk, Limit or not. An index would not meaningfully change this query.
    • For _tier_4_fresh's printing branch, the Limit sits above a GroupAggregate over the full ~222k-row unresolved population, which must be fully computed - including a correlated per-row CardScanLog subquery for origin_reason, executed 301,902 times in this run - before the final sort/limit can pick row 1. The Limit cannot be pushed below the aggregation, so it buys nothing here: this specific run showed no external-merge disk sort (Postgres chose a merge-join plan instead this time), but the ~5.2s cost is dominated by the repeated correlated subquery, not a sort. A date_created index would not fix this query's dominant cost either.
  • Net verdict: a date_created index would help _likely_resolve_printing_card/_tier_1_confirm_suggestion (letting Postgres produce rows in index order lazily, so the existing .iterator() early-exit can actually save wall-clock time instead of waiting on a full sort) but would not help _tier_2_contested (already cheap) or _tier_4_fresh (bottlenecked upstream of the sort, by the correlated subquery and aggregation, not by the sort itself).
  • Composite (printing_tag_status, date_created) vs plain date_created: since printing_tag_status = 'unresolved' matches 230,385 of 230,501 rows (99.9%), the composite's leading column adds essentially no selectivity a plain date_created index doesn't already provide for this predicate - the two are practically equivalent for this workload. No index was created; snip this is a report only.

Test plan

  • pytest cardpicker/tests/test_question_feed.py cardpicker/tests/test_artist_votes.py (84 passed) against an isolated test Postgres instance, not the production database
  • Confirmed the new memoization test fails against the pre-fix code (call_count=2) and passes against the fix (call_count=1)
  • python -m py_compile and the repo's pre-commit hooks (ruff, isort, black, mypy, prettier) all pass on the changed files

…eed request

get_contested_card_ids() and get_contested_artist_card_ids() were each called
separately by every tier that consulted them (_tier_2_contested,
_tier_4_fresh), recomputing an identical answer within one
get_next_question_feed_item() call - the same duplication shape #482 already
fixed for the voter-exclusion sets. Both are now resolved once, right before
the tier-2 fallthrough (so a request that resolves via the likely-resolve
pool or tier 1 never pays this cost), and threaded through as optional
parameters, matching the existing convention for answered_card_ids et al.
The tiers keep the parameter optional (default None, computed lazily if
absent) so a direct caller - a test or a shell - can still invoke one tier
by anonymous_id alone.
@WilfordGrimley
WilfordGrimley merged commit 11beeca into master Aug 6, 2026
15 checks passed
WilfordGrimley added a commit that referenced this pull request Aug 6, 2026
…_card_ids across the view (#738)

- _voter_answered_printing_card_ids now also reads CardIllustrationVote, not just
  CardPrintingTag. The illustration-cluster answer path (cast_illustration_vote,
  N>1 candidates sharing an illustration) writes only CardIllustrationVote, which
  the printing-tier exclusion could not see - the card stayed eligible and was
  immediately re-served to the same voter.
- get_next_question_feed_item and get_remaining_estimate both accept an optional
  contested_card_ids parameter (extending PR #729's convention across the view
  boundary); views.get_question_feed resolves get_contested_card_ids() once per
  request and threads it into both, instead of each independently paying the
  ~520-627ms query cost.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant