Recent items autocomplete: PG full-text search times out scanning all 64 issue_search_data partitions
### Summary `SearchController#autocomplete` times out on `recent_issues_autocomplete` for common search terms — the recent-items code path routes through `IssuesFinder`'s PG full-text search against the partitioned `issue_search_data` table without a partition-pruning predicate, so PG ends up scanning all 64 partitions for a query that operates on at most 100 known IDs. Sentry shows a `PG::QueryCanceled` with `db_duration_s = 15.014` for params `filter=generic`, `term="terri chu"`. Captured `EXPLAIN (ANALYZE, BUFFERS)` against postgres.ai confirms a single partitioned tsvector scan can burn over 10 seconds before the join with `issues` discards everything. ### Stack trace ``` PG::QueryCanceled: ERROR: canceling statement due to statement timeout active_record/connection_adapters/postgresql_adapter.rb:899:in `exec_params' … app/helpers/search_helper.rb:533:in `recent_issues_autocomplete' app/helpers/search_helper.rb:74:in `recent_items_autocomplete' app/helpers/search_helper.rb:27:in `search_autocomplete_opts' app/controllers/search_controller.rb:143:in `block in autocomplete' ``` ### Root cause `Gitlab::Search::RecentItems#query_items_by_ids` (`lib/gitlab/search/recent_items.rb:70-76`) builds the query as: ```ruby def query_items_by_ids(term, ids) return finder.new(user).klass.none if ids.empty? finder.new(user, search: term, in: 'title', skip_full_text_search_project_condition: true) .execute .limit(SEARCH_LIMIT).without_order.id_in_ordered(ids) end ``` The `skip_full_text_search_project_condition: true` flag was introduced in commit `27e51673286b` (Nov 2022, "Improve issue search performance for recent items") to side-step a different bad plan. In `IssuesFinder#filter_by_full_text_search` (`app/finders/issues_finder.rb:167-175`): ```ruby override :filter_by_full_text_search def filter_by_full_text_search(items) # This project condition is used as a hint to PG about the partitions that need searching # because the search data is partitioned by project. # In certain cases, like the recent items search, the query plan is much better without this condition. return super if params[:skip_full_text_search_project_condition].present? super.with_projects_matching_search_data end ``` The skipped predicate (`issue_search_data.project_id = issues.project_id`) is the partition-pruning hint for `issue_search_data`, which is partitioned by `project_id`. With it missing, the join from `issues` to `issue_search_data` cannot prune partitions. For terms whose tsquery is selective in some partitions but broad in others (common-name queries like `"terri chu"`), PG ends up scanning every partition's GIN index and rechecking the heap for candidates that the subsequent merge join with the ≤100 `issues.id` set will throw away anyway. The fundamental mismatch: this code path operates on a **bounded set of ≤100 issue IDs** pre-fetched from Redis, but uses full-text search machinery designed for unbounded global queries. Tsvector search adds no value once we have 100 candidate rows in hand. ### Plan comparison - BEFORE: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/51813/commands/152847 - AFTER: https://console.postgres.ai/gitlab/gitlab-production-main/sessions/51813/commands/152848 | Metric | BEFORE (current) | AFTER (proposed fix) | Delta | |---|---|---|---| | Total execution time | **10,807 ms** | **210 ms** | **~51× faster** | | Planner cost | 2,782.97 | 869.45 | ~3.2× lower | | Shared buffer hits | 5,295 | 151 | 35× fewer | | Shared buffer reads | 18,467 (≈144 MiB) | 172 (≈1.3 MiB) | ~107× less I/O | | `issue_search_data` partitions scanned | 64 (all of them) | 0 | — | ### Proposed fix In `lib/gitlab/search/recent_items.rb`: ```ruby def query_items_by_ids(term, ids) return finder.new(user).klass.none if ids.empty? items = finder.new(user).execute.without_order.id_in_ordered(ids) items = items.where('LOWER(title) LIKE ?', "%#{ActiveRecord::Base.sanitize_sql_like(term.downcase)}%") if term.present? items.limit(SEARCH_LIMIT) end ``` Behaviour change: - **Filter happens against ≤100 rows, never against the tsvector index.** No partition-prune problem to debate. - **Substring match instead of stemmed tsquery.** For autocomplete the user is matching what they literally typed; substring match is closer to expected UX than stemming. `pg_full_text_search` would tokenize `"terri chu"` into `'terri' & 'chu'` with prefix matching, which is fine for global search but unnecessary for the recent-items popover. - **Removes the need for `skip_full_text_search_project_condition`.** Follow-up MR can delete the flag plumbing in `IssuesFinder#filter_by_full_text_search` since `RecentItems` is the only caller. - **Shared by `RecentIssues`, `RecentMergeRequests`, `RecentWikiPages`.** MR and wiki paths aren't affected by the same partitioned-tsvector bug (MR has no `pg_full_text_searchable`, wiki uses a different finder path), but the simpler query is a net positive for all three. ### Alternatives considered - **Re-enable the partition predicate** (i.e. drop the flag). The MR that introduced the flag found that turned the query into a different bad plan; without re-running that experiment we don't know if index/partition changes since 2022 have shifted things. Dropping FTS altogether removes the question. - **Filter titles in Ruby instead of SQL.** Same plan benefit, but requires moving preload chains (`preload_namespace`, `preload_routables`) from the callers into `RecentItems`. Bigger blast radius for the same win. - **Wrap autocomplete in `with_fast_read_statement_timeout`.** Not pursuing — disallowed by `Performance/ActiveRecordSubtransactionMethods` (relies on subtransactions, see gitlab-org/gitlab#338346), and only bounds the damage rather than fixing the bad plan. ### Suggested labels ~"type::bug" ~"bug::performance" ~"group::global search"
issue