Skip the unread-message subquery when listing joinable games - #1402
Merged
Merged
Conversation
Find Games requests can_join=true, which only returns pending games the user is not a member of. The unread count needs channel membership, so it is always zero there, but the correlated ChannelMessage subquery ran once per member x nation row produced by slots_remaining ordering in both the pagination COUNT and the page query. Annotate a constant zero when the filterset parses can_join as true. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5LGbFJXETpBtFju7A2RUQ
johnpooch
pushed a commit
that referenced
this pull request
Sep 25, 2026
Match can_join the way GameFilter does, so every spelling the filter treats as true (true, True) takes the zero fast path, and the helper is the same one #1402 introduces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016sj2eM1KW37VqohqwKViPh
4 tasks
filter_can_join already receives django-filter's parsed boolean, so the view no longer builds and validates the filterset a second time. Re-annotating total_unread_message_count on the lazy queryset replaces the correlated unread subquery before SQL is compiled, and can_join is applied before ordering, so the slots_remaining aggregation sees the constant zero. GameListView returns to its original shape. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01S5LGbFJXETpBtFju7A2RUQ
johnpooch
pushed a commit
that referenced
this pull request
Sep 25, 2026
filter_can_join() now annotates zero unread messages. The list queryset no longer carries the unread subquery on any path, so #1402's test for non-joinable lists asserts the unread count through the single page-scoped query instead of through the game-list queries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016sj2eM1KW37VqohqwKViPh
johnpooch
added a commit
that referenced
this pull request
Sep 25, 2026
* Count game-list unread messages for the page, after pagination GameListView annotated every game with a correlated ChannelMessage subquery before filtering and pagination. That subquery ran inside the pagination COUNT, was evaluated over member x nation rows under ordering=slots_remaining, and pushed PostgreSQL's cost estimates high enough to spend seconds in JIT compilation. The list queryset no longer carries the annotation. Once the page is known, GameManager.hydrate_total_unread_counts() runs one grouped query over the page's game ids and sets total_unread_message_count on every game, zero included. Anonymous requests and can_join=true (the user is a member of none of the listed games) get zero without a query. The unread predicate (channels the user belongs to, messages after that channel's last_read_at, not sent by the user) moves to ChannelMessageQuerySet.unread_by() and is shared with the retrieve annotation, whose SQL is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016sj2eM1KW37VqohqwKViPh * Detect joinable listings through the game filter's own parsing Match can_join the way GameFilter does, so every spelling the filter treats as true (true, True) takes the zero fast path, and the helper is the same one #1402 introduces. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016sj2eM1KW37VqohqwKViPh * Let unread hydration respect an existing annotation hydrate_total_unread_counts() now fills only games that do not already carry total_unread_message_count. filter_can_join() annotates zero, so joinable listings skip the unread query without the view re-parsing can_join, and the joinable_only flag and lists_joinable_games() go. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016sj2eM1KW37VqohqwKViPh --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this PR does
Speeds up the authenticated Find Games request (
GET /games/?can_join=true&ordering=slots_remaining, sent frompackages/web/src/screens/Home/FindGames.tsx). Whencan_joinparses as true,GameFilter.filter_can_joinnow re-annotatestotal_unread_message_countas a constant integer zero. That replaces the correlated unread-message subquery before any SQL is compiled.Root cause: the query shape
GameListView.get_querysetcallswith_total_unread_counts(user)before django-filter runs. For authenticated users that adds a correlatedChannelMessagesubquery (COALESCE((SELECT COUNT(DISTINCT …) FROM channel_channelmessage …), 0)). Thenordering=slots_remainingaddsCount("members")andCount("variant__nations"), which join members and variant nations andGROUP BYthe game. Because of that aggregate, Django's paginationCOUNT(*)wraps the whole grouped query, unread subquery included. So the subquery ran once per member×nation row, in both the COUNT and the page query.Production EXPLAIN (supplied diagnosis, not re-run for this PR):
Why zero is correct for
can_join=truecan_join=trueselects pending, public games (GameFilter.filter_can_join).channel__member_channels__member__user=user), and non-members can't be channel members. So the subquery always returns 0 for these rows.GameCardonly shows its unread badge for active or finished games.Where the change lives
GameFilter.filter_can_join(service/game/filters.py): the joinable branch ends withqueryset.with_zero_unread_counts().GameFilterand nothing parses the filterset a second time.total_unread_message_countexpression before evaluation.can_joinis applied beforeordering, so theslots_remainingaggregation and the pagination COUNT only see the constant.GameQuerySet.with_zero_unread_counts()(service/game/models.py): new method for the constant-zero annotation. The anonymous-user branch ofwith_total_unread_countsnow uses it too.GameListView(service/game/views.py): identical tomain. It stays thin, and pagination plus the scoped phase hydration from Scope latest-phase hydration on the game list and retrieve to the requested games #1398 (paginate_queryset→hydrate_list_phases(page)) are untouched.?mine, normal list requests and channel lists work exactly as before.can_joinparsingThe optimisation fires only when django-filter's cleaned boolean is true, because it lives in the
if value:branch offilter_can_join.GameFilterdeclaresdjango_filters.BooleanFilter, whose field isforms.NullBooleanFieldwith Django'sNullBooleanSelectwidget. I checked this against the endpoint, not only by reading the source:can_joinvaluetrue,True,2Truefalse,False,3False1,0,yes, other malformed values, absentNone(unknown)1→true and0→false is howdjango_filters.rest_framework.BooleanFilterbehaves, butGameFilterdoesn't use that class. This PR leaves parsing as it is. Whatever the filter treats as true is exactly what gets the zero annotation, so the two can't disagree.Response schema
Unchanged. I generated
manage.py spectacular(camelCase) and the internal snake_case schema (INTERNAL_SCHEMA_SETTINGS) onmainand on this branch, and both are byte-identical. No codegen changes, no database field, no migration.Tests
New tests in
TestGameListViewQueryPerformance(service/game/tests.py):test_list_joinable_games_skips_unread_message_subquery: authenticatedcan_join=true&ordering=slots_remainingover four pending games filled 3/2/1/0 (created in a different order), plus an active game where the user has 2 unread messages. It checks:slots_remainingorder;total_unread_message_countis 0;COUNT(*)query and one page query hitgame_game, and neither containschannel_channelmessage.It fails if the
with_zero_unread_counts()line infilter_can_joinis reverted.test_list_games_counts_unread_messages_unless_can_join_is_true[false|malformed|absent]: withordering=slots_remaining, the game still reports 2 unread messages, and both the COUNT and the page query still contain the unread subquery.Run with
service/.venv/bin/python:python -m pytest game/tests.py -k "skips_unread or counts_unread_messages_unless" -v: 4 passed.python -m pytest game/ channel/ -n auto: 499 passed. This includes the existing unread tests ingame/tests/test_games_list_unread.pyandchannel/tests/test_channel.py, the slots-remaining ordering tests, and the Scope latest-phase hydration on the game list and retrieve to the requested games #1398 hydration tests.python -m pytest -n auto --reuse-db: 2584 passed, 8 skipped, 1 failed. The failure isharness/tests.py::TestQualityMetricAggregation::test_accuracy_covers_ranked_samples_and_skips_the_rest, which also fails on unmodifiedmain(AttributeError: 'NoneType' object has no attribute 'scores') and is unrelated to this change.Checklist
/review-pragainst this PR in Claude Code and addressed (or responded to) its findings🤖 Generated with Claude Code
https://claude.ai/code/session_01S5LGbFJXETpBtFju7A2RUQ