From dd7f5c27e50ed3dd2d9b507cdf2ce4c3b3f45606 Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Thu, 14 May 2026 14:20:52 +0530 Subject: [PATCH] perf: eliminate N+1 queries on inboxes#index (#14451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `inboxes#index` API endpoint was firing 3 queries per inbox while rendering the response — one each for the polymorphic channel, avatar attachment, and working hours. For accounts with many inboxes this turned a routine list call into a multi-second request — measured at 35 seconds on an account with 5,000 inboxes. Two root causes: 1. `policy_scope` was silently discarding the controller's `.includes(...)` chain because `InboxPolicy::Scope#resolve` returns a fresh `user.assigned_inboxes` relation, ignoring whatever scope is passed in. So the eager loading never actually applied. 2. `Inbox#weekly_schedule` called `working_hours.order(...).select(...)` which fires a new query per inbox even when the association is preloaded. The fix chains the eager loads and ordering after `policy_scope` so they apply to the relation the policy actually returns, adds `:portal` and `:working_hours` to the preload list, and refactors `weekly_schedule` to sort the preloaded collection in Ruby and build the hash directly. Result: queries drop from O(n) to a constant ~15 regardless of inbox count, and total request time drops 5–6× across every account size tested. Response payload is byte-identical. ## How to test 1. Open the agent dashboard for an account with a large number of inboxes (1000+). On a stock dev DB you can seed quickly with `Seeders::AccountSeeder` or by creating a few hundred `Channel::Api` inboxes via the rails console. 2. Hit any UI surface that lists inboxes (settings → inboxes, sidebar inbox list, agent assignment dropdowns). Should feel near-instant where it previously hung. 3. Confirm every inbox shows the same data as before — channel type, working hours, avatar, portal/help-center link. No fields should be missing or different. ## Benchmarks Real HTTP requests via curl against a dev Rails server (median of 5 timed trials after warmup; "Server total" is the time reported in Rails' `Completed 200 OK in ms` log line). | Account | Channel mix | OLD | NEW | Speedup | |---|---|---|---|---| | 1000 inboxes | 3 types, no portals/avatars | 5.87 s | 0.96 s | **6.1×** | | 5000 inboxes | 3 types, no portals/avatars | 33.07 s | 6.93 s | **4.8×** | | 5000 inboxes | 10 types, 25% portals, 10% avatars | 35.04 s | 6.55 s | **5.4×** | Detailed breakdown for the realistic 5000-inbox case: | | OLD | NEW | Δ | |-----------------|-----------|----------|----------| | Server total | 35,042 ms | 6,550 ms | −81% | | Views | 32,034 ms | 6,452 ms | −80% | | ActiveRecord | 2,948 ms | 92 ms | −97% | | Allocations | 42.3 M | 11.3 M | −73% | | Queries | ~15,000 | 15 | −99.9% | ## What changed - `app/controllers/api/v1/accounts/inboxes_controller.rb` — chain `.includes(:channel, :portal, :working_hours, avatar_attachment: :blob).order_by_name` *after* `policy_scope(...)`. The previous code passed them into `policy_scope` but the policy resolver dropped the chain. - `app/models/concerns/out_of_offisable.rb` — `weekly_schedule` now sorts the preloaded `working_hours` collection in Ruby and constructs the result hashes inline, avoiding both the per-inbox query from `.order(...).select(...)` and the `as_json` reflection overhead. Co-authored-by: Shivam Mishra --- .../api/v1/accounts/inboxes_controller.rb | 4 +++- app/models/concerns/out_of_offisable.rb | 12 +++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index c7d3e2737..b7c28c1c3 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -9,7 +9,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController include Api::V1::Accounts::Concerns::WhatsappHealthManagement def index - @inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] })) + @inboxes = policy_scope(Current.account.inboxes) + .includes(:channel, :portal, :working_hours, { avatar_attachment: :blob }) + .order_by_name end def show; end diff --git a/app/models/concerns/out_of_offisable.rb b/app/models/concerns/out_of_offisable.rb index f1753a5f8..9b9a59a19 100644 --- a/app/models/concerns/out_of_offisable.rb +++ b/app/models/concerns/out_of_offisable.rb @@ -19,7 +19,17 @@ module OutOfOffisable end def weekly_schedule - working_hours.order(day_of_week: :asc).select(*OFFISABLE_ATTRS).as_json(except: :id) + working_hours.sort_by(&:day_of_week).map do |wh| + { + 'day_of_week' => wh.day_of_week, + 'closed_all_day' => wh.closed_all_day, + 'open_hour' => wh.open_hour, + 'open_minutes' => wh.open_minutes, + 'close_hour' => wh.close_hour, + 'close_minutes' => wh.close_minutes, + 'open_all_day' => wh.open_all_day + } + end end # accepts an array of hashes similiar to the format of weekly_schedule