diff --git a/app/finders/conversation_finder.rb b/app/finders/conversation_finder.rb index d43ed31e7..3a44e0567 100644 --- a/app/finders/conversation_finder.rb +++ b/app/finders/conversation_finder.rb @@ -180,7 +180,9 @@ class ConversationFinder def conversations_base_query @conversations.includes( - :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox + :taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox, + { cached_last_message: { attachments: { file_attachment: [:blob] } } }, + :cached_last_non_activity_message ) end diff --git a/app/jobs/migration/backfill_conversation_cached_message_ids_job.rb b/app/jobs/migration/backfill_conversation_cached_message_ids_job.rb new file mode 100644 index 000000000..da9caad30 --- /dev/null +++ b/app/jobs/migration/backfill_conversation_cached_message_ids_job.rb @@ -0,0 +1,22 @@ +class Migration::BackfillConversationCachedMessageIdsJob < ApplicationJob + queue_as :low + + def perform + Conversation.find_in_batches(batch_size: 1000) do |conversations| + conversations.each do |conversation| + updates = {} + + last_message = conversation.messages.reorder(created_at: :desc).first + updates[:last_message_id] = last_message&.id + + last_incoming = conversation.messages.incoming.reorder(created_at: :desc).first + updates[:last_incoming_message_id] = last_incoming&.id + + last_non_activity = conversation.messages.where.not(message_type: :activity).reorder(created_at: :desc).first + updates[:last_non_activity_message_id] = last_non_activity&.id + + conversation.update_columns(updates) + end + end + end +end diff --git a/app/models/conversation.rb b/app/models/conversation.rb index ca53238e8..65cbcae73 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -105,6 +105,9 @@ class Conversation < ApplicationRecord belongs_to :contact_inbox belongs_to :team, optional: true belongs_to :campaign, optional: true + belongs_to :cached_last_message, class_name: 'Message', optional: true, foreign_key: 'last_message_id' + belongs_to :cached_last_incoming_message, class_name: 'Message', optional: true, foreign_key: 'last_incoming_message_id' + belongs_to :cached_last_non_activity_message, class_name: 'Message', optional: true, foreign_key: 'last_non_activity_message_id' has_many :mentions, dependent: :destroy_async has_many :messages, dependent: :destroy_async, autosave: true diff --git a/app/models/message.rb b/app/models/message.rb index 25a26afa9..beaabf6e2 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -133,6 +133,7 @@ class Message < ApplicationRecord has_many :notifications, as: :primary_actor, dependent: :destroy_async after_create_commit :execute_after_create_commit_callbacks + after_create_commit :update_conversation_cached_message_ids after_update_commit :dispatch_update_event after_commit :reindex_for_search, if: :should_index?, on: [:create, :update] @@ -422,6 +423,15 @@ class Message < ApplicationRecord def reindex_for_search reindex(mode: :async) end + + def update_conversation_cached_message_ids + updates = { last_message_id: id } + updates[:last_incoming_message_id] = id if incoming? + updates[:last_non_activity_message_id] = id unless activity? + # rubocop:disable Rails/SkipsModelValidations + conversation.update_columns(updates) + # rubocop:enable Rails/SkipsModelValidations + end end Message.prepend_mod_with('Message') diff --git a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder index 4cb13f543..5bc8b71c5 100644 --- a/app/views/api/v1/conversations/partials/_conversation.json.jbuilder +++ b/app/views/api/v1/conversations/partials/_conversation.json.jbuilder @@ -27,13 +27,10 @@ json.meta do end json.id conversation.display_id -if conversation.messages.where(account_id: conversation.account_id).last.blank? +if conversation.cached_last_message.blank? json.messages [] else - json.messages [ - conversation.messages.where(account_id: conversation.account_id) - .includes([{ attachments: [{ file_attachment: [:blob] }] }]).last.try(:push_event_data) - ] + json.messages [conversation.cached_last_message.push_event_data] end json.account_id conversation.account_id @@ -54,7 +51,7 @@ json.updated_at conversation.updated_at.to_f json.timestamp conversation.last_activity_at.to_i json.first_reply_created_at conversation.first_reply_created_at.to_i json.unread_count conversation.unread_incoming_messages.count -json.last_non_activity_message conversation.messages.where(account_id: conversation.account_id).non_activity_messages.first.try(:push_event_data) +json.last_non_activity_message conversation.cached_last_non_activity_message&.push_event_data json.last_activity_at conversation.last_activity_at.to_i json.priority conversation.priority json.waiting_since conversation.waiting_since.to_i.to_i diff --git a/db/migrate/20260128194944_add_cached_message_ids_to_conversations.rb b/db/migrate/20260128194944_add_cached_message_ids_to_conversations.rb new file mode 100644 index 000000000..4b0a267da --- /dev/null +++ b/db/migrate/20260128194944_add_cached_message_ids_to_conversations.rb @@ -0,0 +1,11 @@ +class AddCachedMessageIdsToConversations < ActiveRecord::Migration[7.1] + def change + add_column :conversations, :last_message_id, :bigint + add_column :conversations, :last_incoming_message_id, :bigint + add_column :conversations, :last_non_activity_message_id, :bigint + + add_index :conversations, :last_message_id + add_index :conversations, :last_incoming_message_id + add_index :conversations, :last_non_activity_message_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 148e7769c..dee9f19c0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do +ActiveRecord::Schema[7.1].define(version: 2026_01_28_194944) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -71,6 +71,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.jsonb "limits", default: {} t.jsonb "custom_attributes", default: {} t.integer "status", default: 0 + t.integer "contactable_contacts_count", default: 0 t.jsonb "internal_attributes", default: {}, null: false t.jsonb "settings", default: {} t.index ["status"], name: "index_accounts_on_status" @@ -589,13 +590,13 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.bigint "account_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.integer "contacts_count" + t.integer "contacts_count", default: 0, null: false t.index ["account_id", "domain"], name: "index_companies_on_account_and_domain", unique: true, where: "(domain IS NOT NULL)" t.index ["account_id"], name: "index_companies_on_account_id" t.index ["name", "account_id"], name: "index_companies_on_name_and_account_id" end - create_table "contact_inboxes", force: :cascade do |t| + create_table "contact_inboxes", id: :bigint, default: -> { "nextval('contact_inboxes2_id_seq'::regclass)" }, force: :cascade do |t| t.bigint "contact_id" t.bigint "inbox_id" t.text "source_id", null: false @@ -603,14 +604,14 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.datetime "updated_at", null: false t.boolean "hmac_verified", default: false t.string "pubsub_token" - t.index ["contact_id"], name: "index_contact_inboxes_on_contact_id" - t.index ["inbox_id", "source_id"], name: "index_contact_inboxes_on_inbox_id_and_source_id", unique: true - t.index ["inbox_id"], name: "index_contact_inboxes_on_inbox_id" - t.index ["pubsub_token"], name: "index_contact_inboxes_on_pubsub_token", unique: true - t.index ["source_id"], name: "index_contact_inboxes_on_source_id" + t.index ["contact_id"], name: "contact_inboxes2_contact_id_idx" + t.index ["inbox_id", "source_id"], name: "contact_inboxes2_inbox_id_source_id_idx", unique: true + t.index ["inbox_id"], name: "contact_inboxes2_inbox_id_idx" + t.index ["pubsub_token"], name: "contact_inboxes2_pubsub_token_idx", unique: true + t.index ["source_id"], name: "contact_inboxes2_source_id_idx" end - create_table "contacts", id: :serial, force: :cascade do |t| + create_table "contacts", id: :integer, default: -> { "nextval('contacts2_id_seq'::regclass)" }, force: :cascade do |t| t.string "name", default: "" t.string "email" t.string "phone_number" @@ -621,25 +622,27 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.string "identifier" t.jsonb "custom_attributes", default: {} t.datetime "last_activity_at", precision: nil - t.integer "contact_type", default: 0 + t.boolean "resolved", default: false, null: false + t.integer "contact_type", default: 0, null: false t.string "middle_name", default: "" t.string "last_name", default: "" t.string "location", default: "" t.string "country_code", default: "" t.boolean "blocked", default: false, null: false t.bigint "company_id" - t.index "lower((email)::text), account_id", name: "index_contacts_on_lower_email_account_id" + t.index "lower((email)::text), account_id", name: "contacts2_lower_account_id_idx" t.index ["account_id", "contact_type"], name: "index_contacts_on_account_id_and_contact_type" - t.index ["account_id", "email", "phone_number", "identifier"], name: "index_contacts_on_nonempty_fields", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id", "email", "phone_number", "identifier"], name: "contacts2_account_id_email_phone_number_identifier_idx", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["account_id", "last_activity_at"], name: "index_contacts_on_account_id_and_last_activity_at", order: { last_activity_at: "DESC NULLS LAST" } - t.index ["account_id"], name: "index_contacts_on_account_id" - t.index ["account_id"], name: "index_resolved_contact_account_id", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" + t.index ["account_id", "resolved"], name: "index_contacts_on_account_id_and_resolved" + t.index ["account_id"], name: "contacts2_account_id_idx" + t.index ["account_id"], name: "contacts2_account_id_idx1", where: "(((email)::text <> ''::text) OR ((phone_number)::text <> ''::text) OR ((identifier)::text <> ''::text))" t.index ["blocked"], name: "index_contacts_on_blocked" t.index ["company_id"], name: "index_contacts_on_company_id" - t.index ["email", "account_id"], name: "uniq_email_per_account_contact", unique: true - t.index ["identifier", "account_id"], name: "uniq_identifier_per_account_contact", unique: true - t.index ["name", "email", "phone_number", "identifier"], name: "index_contacts_on_name_email_phone_number_identifier", opclass: :gin_trgm_ops, using: :gin - t.index ["phone_number", "account_id"], name: "index_contacts_on_phone_number_and_account_id" + t.index ["email", "account_id"], name: "contacts2_email_account_id_idx", unique: true + t.index ["identifier", "account_id"], name: "contacts2_identifier_account_id_idx", unique: true + t.index ["name", "email", "phone_number", "identifier"], name: "contacts2_name_email_phone_number_identifier_idx", opclass: :gin_trgm_ops, using: :gin + t.index ["phone_number", "account_id"], name: "contacts2_phone_number_account_id_idx" end create_table "conversation_participants", force: :cascade do |t| @@ -667,7 +670,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.datetime "agent_last_seen_at", precision: nil t.jsonb "additional_attributes", default: {} t.bigint "contact_inbox_id" - t.uuid "uuid", default: -> { "gen_random_uuid()" }, null: false + t.uuid "uuid", default: -> { "public.gen_random_uuid()" }, null: false t.string "identifier" t.datetime "last_activity_at", precision: nil, default: -> { "CURRENT_TIMESTAMP" }, null: false t.bigint "team_id" @@ -681,6 +684,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.datetime "waiting_since" t.text "cached_label_list" t.bigint "assignee_agent_bot_id" + t.bigint "last_message_id" + t.bigint "last_incoming_message_id" + t.bigint "last_non_activity_message_id" t.index ["account_id", "display_id"], name: "index_conversations_on_account_id_and_display_id", unique: true t.index ["account_id", "id"], name: "index_conversations_on_id_and_account_id" t.index ["account_id", "inbox_id", "status", "assignee_id"], name: "conv_acid_inbid_stat_asgnid_idx" @@ -692,6 +698,9 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.index ["first_reply_created_at"], name: "index_conversations_on_first_reply_created_at" t.index ["identifier", "account_id"], name: "index_conversations_on_identifier_and_account_id" t.index ["inbox_id"], name: "index_conversations_on_inbox_id" + t.index ["last_incoming_message_id"], name: "index_conversations_on_last_incoming_message_id" + t.index ["last_message_id"], name: "index_conversations_on_last_message_id" + t.index ["last_non_activity_message_id"], name: "index_conversations_on_last_non_activity_message_id" t.index ["priority"], name: "index_conversations_on_priority" t.index ["status", "account_id"], name: "index_conversations_on_status_and_account_id" t.index ["status", "priority"], name: "index_conversations_on_status_and_priority" @@ -758,7 +767,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.string "regex_pattern" t.string "regex_cue" t.index ["account_id"], name: "index_custom_attribute_definitions_on_account_id" - t.index ["attribute_key", "attribute_model", "account_id"], name: "attribute_key_model_index", unique: true end create_table "custom_filters", force: :cascade do |t| @@ -941,7 +949,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.integer "visibility", default: 0 t.bigint "created_by_id" t.bigint "updated_by_id" - t.jsonb "actions", default: {}, null: false + t.jsonb "actions", default: "{}", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["account_id"], name: "index_macros_on_account_id" @@ -960,7 +968,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.index ["user_id"], name: "index_mentions_on_user_id" end - create_table "messages", id: :serial, force: :cascade do |t| + create_table "messages", id: :integer, default: -> { "nextval('messages2_id_seq'::regclass)" }, force: :cascade do |t| t.text "content" t.integer "account_id", null: false t.integer "inbox_id", null: false @@ -979,18 +987,18 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.jsonb "additional_attributes", default: {} t.text "processed_message_content" t.jsonb "sentiment", default: {} - t.index "((additional_attributes -> 'campaign_id'::text))", name: "index_messages_on_additional_attributes_campaign_id", using: :gin + t.index "((additional_attributes -> 'campaign_id'::text))", name: "messages2_expr_idx", using: :gin t.index ["account_id", "content_type", "created_at"], name: "idx_messages_account_content_created" t.index ["account_id", "created_at", "message_type"], name: "index_messages_on_account_created_type" - t.index ["account_id", "inbox_id"], name: "index_messages_on_account_id_and_inbox_id" - t.index ["account_id"], name: "index_messages_on_account_id" - t.index ["content"], name: "index_messages_on_content", opclass: :gin_trgm_ops, using: :gin - t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "index_messages_on_conversation_account_type_created" - t.index ["conversation_id"], name: "index_messages_on_conversation_id" - t.index ["created_at"], name: "index_messages_on_created_at" - t.index ["inbox_id"], name: "index_messages_on_inbox_id" - t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id" - t.index ["source_id"], name: "index_messages_on_source_id" + t.index ["account_id", "inbox_id"], name: "messages2_account_id_inbox_id_idx" + t.index ["account_id"], name: "messages2_account_id_idx" + t.index ["content"], name: "messages2_content_idx", opclass: :gin_trgm_ops, using: :gin + t.index ["conversation_id", "account_id", "message_type", "created_at"], name: "messages2_conversation_id_account_id_message_type_created_a_idx" + t.index ["conversation_id"], name: "messages2_conversation_id_idx" + t.index ["created_at"], name: "messages2_created_at_idx" + t.index ["inbox_id"], name: "messages2_inbox_id_idx" + t.index ["sender_type", "sender_id"], name: "messages2_sender_type_sender_id_idx" + t.index ["source_id"], name: "messages2_source_id_idx" end create_table "notes", force: :cascade do |t| @@ -1048,6 +1056,19 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.index ["user_id"], name: "index_notifications_on_user_id" end + create_table "pg_search_documents", force: :cascade do |t| + t.text "content" + t.bigint "conversation_id" + t.bigint "account_id" + t.string "searchable_type" + t.bigint "searchable_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_pg_search_documents_on_account_id" + t.index ["conversation_id"], name: "index_pg_search_documents_on_conversation_id" + t.index ["searchable_type", "searchable_id"], name: "index_pg_search_documents_on_searchable" + end + create_table "platform_app_permissibles", force: :cascade do |t| t.bigint "platform_app_id", null: false t.string "permissible_type", null: false @@ -1230,7 +1251,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_121402) do t.text "message_signature" t.string "otp_secret" t.integer "consumed_timestep" - t.boolean "otp_required_for_login", default: false + t.boolean "otp_required_for_login", default: false, null: false t.text "otp_backup_codes" t.index ["email"], name: "index_users_on_email" t.index ["otp_required_for_login"], name: "index_users_on_otp_required_for_login" diff --git a/scripts/benchmark_campaigns.rb b/scripts/benchmark_campaigns.rb new file mode 100644 index 000000000..0ee35968a --- /dev/null +++ b/scripts/benchmark_campaigns.rb @@ -0,0 +1,42 @@ +# Usage: load 'scripts/benchmark_campaigns.rb'; benchmark_campaigns('website_token') + +def benchmark_campaigns(website_token) + web_widget = Channel::WebWidget.find_by(website_token: website_token) + raise "WebWidget not found for token #{website_token}" unless web_widget + + inbox = web_widget.inbox + account = inbox.account + + puts "Inbox: #{inbox.id} | Account: #{account.id}" + puts "Campaigns feature enabled: #{account.feature_enabled?('campaigns')}" + + queries = [] + subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, start, finish, _id, payload| + binds = payload[:type_casted_binds] || payload[:binds]&.map(&:value_for_database) || [] + queries << { sql: payload[:sql], duration: (finish - start) * 1000, binds: binds } + end + + ActiveRecord::Base.uncached do + start = Time.now + @campaigns = inbox.campaigns + .where(enabled: true, account_id: account.id) + .includes(:sender).to_a + @query_time = Time.now - start + end + + ActiveSupport::Notifications.unsubscribe(subscription) + + puts "\n=== Timing ===" + puts "Query: #{(@query_time * 1000).round(2)}ms" + puts "Queries: #{queries.size}" + puts "Campaigns found: #{@campaigns.size}" + + puts "\n=== All Queries ===" + queries.each_with_index do |q, i| + puts "\n#{i + 1}. #{q[:duration].round(2)}ms" + puts " #{q[:sql][0..200]}" + puts " Binds: #{q[:binds].inspect[0..150]}" if q[:binds]&.any? + end + + nil +end diff --git a/scripts/benchmark_contacts.rb b/scripts/benchmark_contacts.rb new file mode 100644 index 000000000..b124216cf --- /dev/null +++ b/scripts/benchmark_contacts.rb @@ -0,0 +1,72 @@ +# Usage: load 'scripts/benchmark_contacts.rb'; benchmark_contacts(account_id, user_id) +# With action: benchmark_contacts(account_id, user_id, action: 'search', q: 'john') + +def benchmark_contacts(account_id, user_id, action: 'index', **opts) + account = Account.find(account_id) + user = User.find(user_id) + Current.account = account + Current.user = user + + puts "Account: #{account_id} | User: #{user_id} | Action: #{action}" + + queries = [] + subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, start, finish, _id, payload| + binds = payload[:type_casted_binds] || payload[:binds]&.map(&:value_for_database) || [] + queries << { sql: payload[:sql], duration: (finish - start) * 1000, binds: binds } + end + + ActiveRecord::Base.uncached do + start = Time.now + + case action + when 'index' + contacts = account.contacts.resolved_contacts + @contacts = contacts + .includes(avatar_attachment: [:blob], contact_inboxes: { inbox: :channel }) + .page(opts[:page] || 1) + .per(15).to_a + @count = contacts.count + + when 'search' + q = opts[:q] || '' + contacts = account.contacts.where( + 'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search', + search: "%#{q.strip}%" + ) + @contacts = contacts + .includes(avatar_attachment: [:blob], contact_inboxes: { inbox: :channel }) + .page(opts[:page] || 1) + .per(15).to_a + @count = contacts.count + + when 'filter' + # Requires payload param - simplified version + @contacts = account.contacts.page(1).per(15).to_a + @count = account.contacts.count + end + + @query_time = Time.now - start + end + + ActiveSupport::Notifications.unsubscribe(subscription) + + puts "\n=== Timing ===" + puts "Query: #{(@query_time * 1000).round(2)}ms" + puts "Queries: #{queries.size}" + puts "Contacts found: #{@contacts&.size}" + puts "Total count: #{@count}" + + puts "\n=== Top 10 Slowest Queries ===" + queries.select { |q| q[:duration] }.sort_by { |q| -q[:duration] }.first(10).each_with_index do |q, i| + puts "\n#{i + 1}. #{q[:duration].round(2)}ms" + puts " #{q[:sql][0..200]}" + puts " Binds: #{q[:binds].inspect[0..150]}" if q[:binds]&.any? + end + + puts "\n=== All Queries (grouped by pattern) ===" + queries.group_by { |q| q[:sql][0..100] }.sort_by { |_, qs| -qs.size }.each do |sql, qs| + puts " #{qs.size}x #{qs.sum { |q| q[:duration] }.round(2)}ms - #{sql[0..80]}..." + end + + nil +end diff --git a/scripts/benchmark_conversations.rb b/scripts/benchmark_conversations.rb new file mode 100644 index 000000000..59924f61a --- /dev/null +++ b/scripts/benchmark_conversations.rb @@ -0,0 +1,92 @@ +# Usage: load 'scripts/benchmark_conversations.rb'; benchmark_conversations(91698, 89557) +# With options: benchmark_conversations(91698, 89557, assignee_type: 'all', status: 'open', team_id: 123) + +def benchmark_conversations(account_id, user_id, assignee_type: 'me', status: 'open', team_id: nil) + account = Account.find(account_id) + user = User.find(user_id) + Current.account = account + Current.user = user + params = { status: status, assignee_type: assignee_type, page: 1, sort_by: 'last_activity_at_desc' } + params[:team_id] = team_id if team_id + + queries = [] + subscription = ActiveSupport::Notifications.subscribe('sql.active_record') do |_name, start, finish, _id, payload| + binds = payload[:type_casted_binds] || payload[:binds]&.map(&:value_for_database) || [] + bt = caller.select { |l| l.include?('chatwoot') }.first(5) + queries << { sql: payload[:sql], duration: (finish - start) * 1000, binds: binds, backtrace: bt } + end + + # Disable ActiveRecord query cache + ActiveRecord::Base.uncached do + start = Time.now + finder = ConversationFinder.new(user, params) + result = finder.perform + conversations = result[:conversations] + @finder_time = Time.now - start + @conversations = conversations + + start = Time.now + conversations.each do |conversation| + ApplicationController.renderer.render( + partial: 'api/v1/conversations/partials/conversation', + locals: { conversation: conversation }, + formats: [:json] + ) + end + @render_time = Time.now - start + end + + ActiveSupport::Notifications.unsubscribe(subscription) + finder_time = @finder_time + render_time = @render_time + conversations = @conversations + + puts "\n=== Timing ===" + puts "Finder: #{(finder_time * 1000).round(1)}ms" + puts "Render: #{(render_time * 1000).round(1)}ms" + puts "Total: #{((finder_time + render_time) * 1000).round(1)}ms" + puts "Queries: #{queries.size}" + + puts "\n=== Top 10 Slowest Queries ===" + queries.select { |q| q[:duration] }.sort_by { |q| -q[:duration] }.first(10).each_with_index do |q, i| + puts "\n#{i + 1}. #{q[:duration].round(2)}ms" + puts " #{q[:sql][0..200]}" + puts " Binds: #{q[:binds].inspect[0..200]}" if q[:binds]&.any? + puts " Source: #{q[:backtrace]&.first}" if q[:backtrace]&.any? + end + + # Group queries by conversation_id + conv_ids = conversations.map(&:id) + conv_queries = Hash.new { |h, k| h[k] = [] } + + queries.each do |q| + next unless q[:binds]&.any? + + conv_id = q[:binds].find { |b| conv_ids.include?(b) } + conv_queries[conv_id] << q if conv_id + end + + puts "\n=== Queries Per Conversation ===" + conv_queries.sort_by { |_, qs| -qs.sum { |q| q[:duration] } }.first(5).each do |conv_id, qs| + total_ms = qs.sum { |q| q[:duration] }.round(2) + puts "\nConversation #{conv_id}: #{qs.size} queries, #{total_ms}ms total" + qs.group_by { |q| q[:sql][0..80] }.each do |sql, grouped| + puts " #{grouped.size}x #{grouped.sum { |q| q[:duration] }.round(2)}ms - #{sql[0..60]}..." + end + end + + puts "\n=== Summary ===" + puts "Conversations: #{conversations.size}" + puts "Avg queries/conversation: #{(conv_queries.values.sum(&:size).to_f / conversations.size).round(1)}" + + tied_queries = conv_queries.values.flatten + untied = queries - tied_queries + puts "Queries not tied to conversation: #{untied.size}" + + puts "\n=== Untied Queries (grouped by pattern) ===" + untied.group_by { |q| q[:sql][0..100] }.sort_by { |_, qs| -qs.size }.first(15).each do |sql, qs| + puts " #{qs.size}x #{qs.sum { |q| q[:duration] }.round(2)}ms - #{sql[0..80]}..." + end + + nil +end