perf(conversations): throttle agent_last_seen_at updates to reduce DB load (#13355)

High-traffic accounts generate excessive database writes due to agents
frequently switching between conversations. The update_last_seen
endpoint was being called every time an agent loaded a conversation,
resulting in unnecessary updates to agent_last_seen_at and
assignee_last_seen_at even when there were no new messages to mark as
read.

#### Solution
Implemented throttling for the update_last_seen endpoint:

**Unread messages present:**
- Updates immediately without throttling to maintain accurate
read/unread state
- Uses assignee_unread_messages for assignees, unread_messages for other
agents

**No unread messages:**
- Throttles updates to once per hour per conversation
- Checks if agent_last_seen_at is older than 1 hour before updating
- For assignees, checks both agent_last_seen_at AND
assignee_last_seen_at - updates if either timestamp is old
- Skips DB write if all relevant timestamps were updated within the last
hour

- Consolidated two separate update_column calls into a single
update_columns call to reduce DB queries
This commit is contained in:
Pranav
2026-01-23 22:23:41 -08:00
committed by GitHub
parent 747d451387
commit ad2329c237
3 changed files with 101 additions and 3 deletions

View File

@@ -110,6 +110,15 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen
# High-traffic accounts generate excessive DB writes when agents frequently switch between conversations.
# Throttle last_seen updates to once per hour when there are no unread messages to reduce DB load.
# Always update immediately if there are unread messages to maintain accurate read/unread state.
return update_last_seen_on_conversation(DateTime.now.utc, true) if assignee? && @conversation.assignee_unread_messages.any?
return update_last_seen_on_conversation(DateTime.now.utc, false) if !assignee? && @conversation.unread_messages.any?
# No unread messages - apply throttling to limit DB writes
return unless should_update_last_seen?
update_last_seen_on_conversation(DateTime.now.utc, assignee?)
end
@@ -142,12 +151,25 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def update_last_seen_on_conversation(last_seen_at, update_assignee)
updates = { agent_last_seen_at: last_seen_at }
updates[:assignee_last_seen_at] = last_seen_at if update_assignee.present?
# rubocop:disable Rails/SkipsModelValidations
@conversation.update_column(:agent_last_seen_at, last_seen_at)
@conversation.update_column(:assignee_last_seen_at, last_seen_at) if update_assignee.present?
@conversation.update_columns(updates)
# rubocop:enable Rails/SkipsModelValidations
end
def should_update_last_seen?
# Update if at least one relevant timestamp is older than 1 hour or not set
# This prevents redundant DB writes when agents repeatedly view the same conversation
agent_needs_update = @conversation.agent_last_seen_at.blank? || @conversation.agent_last_seen_at < 1.hour.ago
return agent_needs_update unless assignee?
# For assignees, check both timestamps - update if either is old
assignee_needs_update = @conversation.assignee_last_seen_at.blank? || @conversation.assignee_last_seen_at < 1.hour.ago
agent_needs_update || assignee_needs_update
end
def set_conversation_status
@conversation.status = params[:status]
@conversation.snoozed_until = parse_date_time(params[:snoozed_until].to_s) if params[:snoozed_until]

View File

@@ -167,6 +167,10 @@ class Conversation < ApplicationRecord
agent_last_seen_at.present? ? messages.created_since(agent_last_seen_at) : messages
end
def assignee_unread_messages
assignee_last_seen_at.present? ? messages.created_since(assignee_last_seen_at) : messages
end
def unread_incoming_messages
unread_messages.where(account_id: account_id).incoming.last(10)
end

View File

@@ -667,6 +667,8 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'updates last seen' do
conversation.update!(agent_last_seen_at: nil)
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
@@ -676,7 +678,7 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'updates assignee last seen' do
conversation.update!(assignee_id: agent.id)
conversation.update!(assignee_id: agent.id, agent_last_seen_at: nil)
expect(conversation.reload.assignee_last_seen_at).to be_nil
@@ -687,6 +689,76 @@ RSpec.describe 'Conversations API', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.reload.assignee_last_seen_at).not_to be_nil
end
it 'throttles updates within an hour when there are no unread messages' do
conversation.update!(agent_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than agent_last_seen_at (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
# rubocop:enable Rails/SkipsModelValidations
initial_last_seen = conversation.agent_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.agent_last_seen_at).to be_within(1.second).of(initial_last_seen)
end
it 'updates even within an hour when there are unread messages' do
conversation.update!(agent_last_seen_at: 30.minutes.ago)
# Create a new message after agent_last_seen_at (unread message)
create(:message, conversation: conversation, created_at: 5.minutes.ago)
initial_last_seen = conversation.agent_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.agent_last_seen_at).not_to be_within(1.second).of(initial_last_seen)
expect(conversation.reload.agent_last_seen_at).to be > initial_last_seen
end
it 'updates both if one timestamp is old even when the other is recent' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 2.hours.ago, assignee_last_seen_at: 30.minutes.ago)
# Ensure all messages are older than assignee_last_seen_at (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
# rubocop:enable Rails/SkipsModelValidations
initial_agent_last_seen = conversation.agent_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
# Both should be updated because agent_last_seen_at is old
expect(conversation.reload.agent_last_seen_at).to be > initial_agent_last_seen
expect(conversation.reload.assignee_last_seen_at).to be > initial_agent_last_seen
end
it 'throttles only when both timestamps are recent and no unread messages' do
conversation.update!(assignee_id: agent.id, agent_last_seen_at: 30.minutes.ago, assignee_last_seen_at: 30.minutes.ago)
# Ensure all messages are older (no unread messages)
# rubocop:disable Rails/SkipsModelValidations
conversation.messages.update_all(created_at: 1.hour.ago)
# rubocop:enable Rails/SkipsModelValidations
initial_agent_last_seen = conversation.agent_last_seen_at
initial_assignee_last_seen = conversation.assignee_last_seen_at
post "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/update_last_seen",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
# Both should remain unchanged (throttled)
expect(conversation.reload.agent_last_seen_at).to be_within(1.second).of(initial_agent_last_seen)
expect(conversation.reload.assignee_last_seen_at).to be_within(1.second).of(initial_assignee_last_seen)
end
end
end