Files
chatwoot/app/models/concerns/out_of_offisable.rb
dd7f5c27e5 perf: eliminate N+1 queries on inboxes#index (#14451)
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 <X>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 <scm.mymail@gmail.com>
2026-05-14 14:20:52 +05:30

64 lines
2.2 KiB
Ruby

# frozen_string_literal: true
module OutOfOffisable
extend ActiveSupport::Concern
OFFISABLE_ATTRS = %w[day_of_week closed_all_day open_hour open_minutes close_hour close_minutes open_all_day].freeze
included do
has_many :working_hours, dependent: :destroy_async
after_create :create_default_working_hours
end
def out_of_office?
working_hours_enabled? && working_hours.today.closed_now?
end
def working_now?
!out_of_office?
end
def weekly_schedule
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
# [
# { "day_of_week"=>1,
# "closed_all_day"=>false,
# "open_hour"=>9,
# "open_minutes"=>0,
# "close_hour"=>17,
# "close_minutes"=>0,
# "open_all_day=>false" },...]
def update_working_hours(params)
ActiveRecord::Base.transaction do
params.each do |working_hour|
working_hours.find_by(day_of_week: working_hour['day_of_week']).update(working_hour.slice(*OFFISABLE_ATTRS))
end
end
end
private
def create_default_working_hours
working_hours.create!(day_of_week: 0, closed_all_day: true, open_all_day: false)
working_hours.create!(day_of_week: 1, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 2, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 3, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 4, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 5, open_hour: 9, open_minutes: 0, close_hour: 17, close_minutes: 0, open_all_day: false)
working_hours.create!(day_of_week: 6, closed_all_day: true, open_all_day: false)
end
end