Speed up the card list for large accounts#2971
Conversation
There was a problem hiding this comment.
Pull request overview
This PR optimizes Filter#cards (used by CardsController#index) to reduce DB time for large accounts by avoiding an accesses join and by applying DISTINCT only when filters can introduce row fan-out, while preserving access control semantics via board scoping.
Changes:
- Use a new
base_cardsrelation to avoid thecreator.accessible_cardsjoin when the filter is board-scoped. - Apply
DISTINCTonly when assignee/tag/term filters are active (needs_deduplication?). - Add regression tests for board scoping access safety and tag-based deduplication.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| app/models/filter.rb | Changes the base relation used for card queries when boards are selected and avoids unconditional DISTINCT unless a fan-out join is involved. |
| test/models/filter_test.rb | Adds regression tests for board-scoped access safety and multi-tag deduplication behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # When the filter is scoped to specific boards, those boards are already a | ||
| # subset of the creator's accessible boards (see Filter::Resources#boards), | ||
| # so `account.cards.where(board_id: boards.ids)` enforces the same access as | ||
| # `accessible_cards` while letting the query anchor on account_id. That lets | ||
| # the (account_id, last_active_at, status) index serve the default | ||
| # `last_active_at DESC` ordering directly — no accesses join, no filesort over | ||
| # the whole board — instead of scanning and sorting every card on the boards. |
| # `last_active_at DESC` ordering directly — no accesses join, no filesort over | ||
| # the whole board — instead of scanning and sorting every card on the boards. | ||
| def base_cards | ||
| if boards.present? |
There was a problem hiding this comment.
I don't love this, this design feels brittle. Even when we validate that boards are accessible boards, I don't want to lose the .accessible_cards as the starting point. A future bug where we expose cards you don't have access to is scary. Please try to look for an alternative to keep that (e.g alternative index hints)
949ecdb to
7cae224
Compare
| def board_scoped(relation) | ||
| relation = relation.where(cards: { account_id: creator.account_id }).where(board: boards.ids) | ||
| # Redundant account_id predicate + pin keep the last_active_at page on the | ||
| # index reverse scan; a fan-out join needs a different plan, so skip it then. | ||
| needs_deduplication? ? relation : relation.use_index(:index_cards_on_account_id_and_last_active_at_and_status) | ||
| end |
CardsController#index (Filter#cards) got slow on large accounts — hundreds of ms to seconds of DB time for the /<acct>/cards?board_ids[]=... endpoint: the account's card list with the Card.active anti-joins, a board_id IN (...) filter, and the default ORDER BY last_active_at DESC, id DESC. Filtering by board goes through creator.accessible_cards (an accesses INNER JOIN) and anchors the plan on index_cards_on_board_id — a range scan over every published card on the boards — then a filesort by last_active_at to return one page. Keeping accessible_cards preserves the authorization guarantee: the accesses join is enforced by the query and never depends on the board list being pre-filtered. Adding a redundant account_id predicate (every selected board belongs to the creator's account) makes index_cards_on_account_id_and_last_active_at_and_status usable, and pinning it holds the plan on that index's reverse scan — serving the ordering directly and stopping at the page limit, no filesort. With the predicate alone the optimizer estimates the board-index plan as cheaper, so stale statistics can flip it back; the pin keeps the fast plan. It is skipped when an assignee, tag, or term filter adds a has-many join, where a different plan wins. On a 40k-active-card account filtered to 15 boards: 48ms -> 0.9ms of DB time, accesses join retained, no filesort. Result sets unchanged.
7cae224 to
2713913
Compare
| def filter_boards(relation) | ||
| relation = relation.where(cards: { account_id: creator.account_id }).where(board: boards.ids) | ||
| return relation if fans_out? | ||
| # Pin the (account_id, last_active_at, status) index so the ordered page is served by a reverse scan, not a filesort. | ||
| relation.use_index(:index_cards_on_account_id_and_last_active_at_and_status) | ||
| end |
| def filter_boards(relation) | ||
| relation = relation.where(cards: { account_id: creator.account_id }).where(board: boards.ids) | ||
| if joins_has_many? | ||
| relation | ||
| else | ||
| # Pin the (account_id, last_active_at, status) index so the ordered page is served by a reverse scan, not a filesort. | ||
| relation.use_index(:index_cards_on_account_id_and_last_active_at_and_status) | ||
| end |
| assert_not_includes filter.cards, inaccessible_card | ||
| filter.cards.each { |card| assert_includes users(:david).boards, card.board } |
What this fixes
CardsController#index(/<acct>/cards?board_ids[]=…) runs hundreds of ms to seconds of DB time on large accounts — the account's card list with theCard.activeanti-joins, aboard_id IN (…)filter, and the defaultORDER BY last_active_at DESC, id DESC.Why it was slow
Filtering by board goes through
creator.accessible_cards(anaccessesINNER JOIN) and anchors the plan onindex_cards_on_board_id— a range scan over every published card on the boards — then a filesort bylast_active_atto return one page.The fix
creator.accessible_cards. TheaccessesINNER JOIN stays, so authorization is enforced by the query and never depends on the board list being pre-filtered.account_idpredicate + conditional index pin.accessible_cardsscopes byaccesses.user_id, notcards.account_id, so(account_id, last_active_at, status)isn't eligible. Every selected board belongs to the creator's account, sowhere(cards: { account_id: … })makes that index usable, and pinning it holds the plan on its reverse scan — serving the ordering directly and stopping at the page limit, no filesort. Applied only when boards are filtered and no fan-out join (assignee/tag/term) is present.Why pin, not just the predicate
With the predicate alone the optimizer's choice depends on how many boards are in the
INlist: for a handful of boards it still picksindex_cards_on_board_idand filesorts, only switching to the fast index for larger lists — and even there the two plans cost within a few percent, so statistics drift can flip it back. The conditional pin holds the fast plan across every board count.Before / after
MySQL 8, 40k-active-card account filtered to 15 boards, identical result sets (20,005 == 20,005 ids;
accessesjoin present in both):index_cards_on_board_id, filesort.(account_id, last_active_at, status)reverse scan, no filesort.Notes
(account_id, last_active_at, status)index.use_indexhint is an optimizer-hint comment; SQLite/MariaDB ignore it (safe no-op).