From 2713913f0d02265245912c934105be0bb7491a0c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Jul 2026 18:13:00 +0200 Subject: [PATCH 1/4] Speed up the card list for large accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CardsController#index (Filter#cards) got slow on large accounts — hundreds of ms to seconds of DB time for the //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. --- app/models/filter.rb | 16 +++++++++++++++- test/models/filter_test.rb | 9 +++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/models/filter.rb b/app/models/filter.rb index ab302fc1de..34f195bb3a 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -27,7 +27,7 @@ def cards result = result.unassigned if assignment_status.unassigned? result = result.assigned_to(assignees.ids) if assignees.present? result = result.where(creator_id: creators.ids) if creators.present? - result = result.where(board: boards.ids) if boards.present? + result = filter_boards(result) if boards.present? result = result.tagged_with(tags.ids) if tags.present? result = result.where(cards: { created_at: creation_window }) if creation_window result = result.closed_at_window(closure_window) if closure_window @@ -66,6 +66,20 @@ def only_closed? end private + 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 + + # Assignee, tag, and term filters add a has-many join that fans a card into + # several rows; the ordered reverse scan loses to a different plan then, so + # the pin only applies without them. + def fans_out? + assignees.present? || tags.present? || terms.present? + end + def include_closed_cards? only_closed? || card_ids.present? end diff --git a/test/models/filter_test.rb b/test/models/filter_test.rb index de89161417..53d9f6a353 100644 --- a/test/models/filter_test.rb +++ b/test/models/filter_test.rb @@ -48,6 +48,15 @@ class FilterTest < ActiveSupport::TestCase assert_empty users(:david).filters.new(board_ids: [ boards(:writebook).id ]).boards end + test "board-scoped cards never leak cards from an inaccessible board in the same account" do + inaccessible_card = boards(:private).cards.create!(status: "published", creator: users(:kevin)) + + filter = users(:david).filters.new(board_ids: [ boards(:private).id, boards(:writebook).id ]) + + assert_not_includes filter.cards, inaccessible_card + filter.cards.each { |card| assert_includes users(:david).boards, card.board } + end + test "remembering equivalent filters" do assert_difference "Filter.count", +1 do filter = users(:david).filters.remember(sorted_by: "latest", assignment_status: "unassigned", tag_ids: [ tags(:mobile).id ]) From 90e654e3ecdbd5fca2f861783071f2cbf86a9a1f Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Jul 2026 18:50:14 +0200 Subject: [PATCH 2/4] Use if/else instead of early return in filter_boards --- app/models/filter.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/models/filter.rb b/app/models/filter.rb index 34f195bb3a..67e9bc96cd 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -68,9 +68,12 @@ def only_closed? private 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) + if fans_out? + 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 end # Assignee, tag, and term filters add a has-many join that fans a card into From e629d41dae975b51057534ab848021f46634dd5e Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Jul 2026 18:51:46 +0200 Subject: [PATCH 3/4] Rename fans_out? to filters_by_association? --- app/models/filter.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/filter.rb b/app/models/filter.rb index 67e9bc96cd..f23a400d1a 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -68,7 +68,7 @@ def only_closed? private def filter_boards(relation) relation = relation.where(cards: { account_id: creator.account_id }).where(board: boards.ids) - if fans_out? + if filters_by_association? relation else # Pin the (account_id, last_active_at, status) index so the ordered page is served by a reverse scan, not a filesort. @@ -79,7 +79,7 @@ def filter_boards(relation) # Assignee, tag, and term filters add a has-many join that fans a card into # several rows; the ordered reverse scan loses to a different plan then, so # the pin only applies without them. - def fans_out? + def filters_by_association? assignees.present? || tags.present? || terms.present? end From 33d98a2552db45315d6a6f9f65e389462e2ea66c Mon Sep 17 00:00:00 2001 From: Jorge Manrubia Date: Thu, 9 Jul 2026 18:52:02 +0200 Subject: [PATCH 4/4] Rename to joins_has_many? --- app/models/filter.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/filter.rb b/app/models/filter.rb index f23a400d1a..63884ea022 100644 --- a/app/models/filter.rb +++ b/app/models/filter.rb @@ -68,7 +68,7 @@ def only_closed? private def filter_boards(relation) relation = relation.where(cards: { account_id: creator.account_id }).where(board: boards.ids) - if filters_by_association? + 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. @@ -79,7 +79,7 @@ def filter_boards(relation) # Assignee, tag, and term filters add a has-many join that fans a card into # several rows; the ordered reverse scan loses to a different plan then, so # the pin only applies without them. - def filters_by_association? + def joins_has_many? assignees.present? || tags.present? || terms.present? end