fix: freeze SLA misses after resolution (#15024)
## Description Resolved conversations now preserve historical SLA misses without allowing their displayed duration to keep growing. Applied SLAs record a stable completion timestamp that is shared through REST and realtime payloads, and the dashboard freezes FRT, NRT, and RT misses at that point. Legacy completed SLAs without a reliable timestamp remain visible as a static missed state. Terminal SLAs remain frozen when a conversation is reopened; a reopen before finalization continues the same SLA without resetting its deadlines. ### Closes [CW-7597](https://linear.app/chatwoot/issue/CW-7597/freeze-sla-miss-durations-after-conversation-resolution) ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How to reproduce 1. Apply an SLA with a resolution-time threshold to a conversation. 2. Let the threshold breach, then resolve the conversation. 3. Observe that the recorded miss duration continues increasing every minute even though the conversation is resolved. ## What changed - Added nullable `applied_slas.completed_at` and exposed it as `sla_completed_at` in conversation, report, and websocket payloads. - Captured completion before broadcasting resolution and preserved it for terminal applied SLAs. - Frozen recorded FRT, NRT, and RT durations in classic and next-generation conversation labels, including a static fallback for legacy rows. - Added a dry-run-first, resumable Rails runner for account-scoped or explicitly global historical repair without enqueuing jobs or touching `updated_at`. Account-scoped production rollout starts with: ```sh ACCOUNT_ID=168154 bundle exec rails runner script/backfill_applied_sla_completed_at.rb ACCOUNT_ID=168154 APPLY=true bundle exec rails runner script/backfill_applied_sla_completed_at.rb ``` ## How Has This Been Tested? - Verified resolution stamping, nonterminal reopen clearing, and terminal reopen preservation. - Verified dry-run, apply, account/global scope, resume, skip, idempotency, and timestamp-preserving backfill behavior. - Verified all three miss types freeze and existing conversation-card behavior remains intact. - 71 focused RSpec examples and 37 focused Vitest examples pass. - RuboCop, ESLint, and diff checks pass. ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# sla_status :integer default("active")
|
||||
# completed_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
@@ -53,6 +54,7 @@ class AppliedSla < ApplicationRecord
|
||||
sla_status: sla_status,
|
||||
created_at: created_at.to_i,
|
||||
updated_at: updated_at.to_i,
|
||||
sla_completed_at: completed_at&.to_i,
|
||||
sla_description: sla_policy.description,
|
||||
sla_name: sla_policy.name,
|
||||
sla_first_response_time_threshold: sla_policy.first_response_time_threshold,
|
||||
|
||||
@@ -33,6 +33,23 @@ module Enterprise::Conversation
|
||||
|
||||
private
|
||||
|
||||
def handle_resolved_status_change
|
||||
super
|
||||
update_applied_sla_completion
|
||||
end
|
||||
|
||||
def update_applied_sla_completion
|
||||
return unless saved_change_to_status?
|
||||
|
||||
current_applied_sla = applied_sla
|
||||
return if current_applied_sla.blank?
|
||||
|
||||
terminal_sla = current_applied_sla.sla_status.in?(%w[hit missed])
|
||||
return if terminal_sla && (!resolved? || current_applied_sla.completed_at.present?)
|
||||
|
||||
current_applied_sla.update!(completed_at: resolved? ? Time.current : nil)
|
||||
end
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
class Sla::BackfillAppliedSlaCompletedAtService
|
||||
DEFAULT_BATCH_SIZE = 500
|
||||
|
||||
def initialize(**options)
|
||||
options.assert_valid_keys(:account_id, :all_accounts, :apply, :batch_size, :after_id, :output)
|
||||
|
||||
@account_id = options[:account_id]
|
||||
@all_accounts = options.fetch(:all_accounts, false)
|
||||
@apply = options.fetch(:apply, false)
|
||||
@batch_size = options.fetch(:batch_size, DEFAULT_BATCH_SIZE)
|
||||
@after_id = options.fetch(:after_id, 0)
|
||||
@output = options.fetch(:output, $stdout)
|
||||
end
|
||||
|
||||
def perform
|
||||
validate_options!
|
||||
|
||||
scope = candidate_scope
|
||||
eligible_count = scope.count
|
||||
counters = { processed: 0, matched: 0, updated: 0, skipped: 0, last_id: @after_id }
|
||||
|
||||
print_preflight(eligible_count)
|
||||
|
||||
scope.find_in_batches(batch_size: @batch_size, start: @after_id + 1) { |batch| process_batch(batch, counters) }
|
||||
|
||||
result = counters.merge(eligible: eligible_count, dry_run: !@apply)
|
||||
@output.puts "Completed: #{result.inspect}"
|
||||
result
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_batch(batch, counters)
|
||||
resolution_times = resolution_times_for(batch)
|
||||
updated_count = @apply ? bulk_update(resolution_times) : 0
|
||||
|
||||
counters[:processed] += batch.size
|
||||
counters[:matched] += resolution_times.size
|
||||
counters[:updated] += updated_count
|
||||
counters[:skipped] += batch.size - resolution_times.size
|
||||
counters[:last_id] = batch.last.id
|
||||
|
||||
@output.puts "Processed through applied_sla_id=#{counters[:last_id]} " \
|
||||
"(matched=#{counters[:matched]}, updated=#{counters[:updated]}, skipped=#{counters[:skipped]})"
|
||||
end
|
||||
|
||||
def validate_options!
|
||||
account_scope = @account_id.present?
|
||||
raise ArgumentError, 'Provide exactly one of ACCOUNT_ID or ALL_ACCOUNTS=true' if account_scope == @all_accounts
|
||||
raise ArgumentError, 'BATCH_SIZE must be greater than zero' unless @batch_size.positive?
|
||||
raise ArgumentError, 'AFTER_ID must be zero or greater' if @after_id.negative?
|
||||
|
||||
Account.find(@account_id) if account_scope
|
||||
end
|
||||
|
||||
def candidate_scope
|
||||
scope = AppliedSla.where(sla_status: :missed, completed_at: nil).where('applied_slas.id > ?', @after_id)
|
||||
scope = scope.where(account_id: @account_id) if @account_id.present?
|
||||
scope
|
||||
end
|
||||
|
||||
def resolution_times_for(batch)
|
||||
events_by_conversation = ReportingEvent
|
||||
.where(
|
||||
account_id: batch.map(&:account_id).uniq,
|
||||
conversation_id: batch.map(&:conversation_id),
|
||||
name: 'conversation_resolved'
|
||||
)
|
||||
.where.not(event_end_time: nil)
|
||||
.order(:conversation_id, event_end_time: :desc)
|
||||
.group_by(&:conversation_id)
|
||||
|
||||
batch.each_with_object({}) do |applied_sla, resolution_times|
|
||||
event = events_by_conversation.fetch(applied_sla.conversation_id, []).find do |reporting_event|
|
||||
reporting_event.event_end_time.between?(applied_sla.created_at, applied_sla.updated_at)
|
||||
end
|
||||
resolution_times[applied_sla.id] = event.event_end_time if event
|
||||
end
|
||||
end
|
||||
|
||||
def bulk_update(resolution_times)
|
||||
return 0 if resolution_times.empty?
|
||||
|
||||
connection = AppliedSla.connection
|
||||
values = resolution_times.map do |id, completed_at|
|
||||
"(#{connection.quote(id)}, #{connection.quote(completed_at)}::timestamp)"
|
||||
end.join(', ')
|
||||
|
||||
statement = <<~SQL.squish
|
||||
UPDATE #{connection.quote_table_name(AppliedSla.table_name)} AS applied_slas
|
||||
SET completed_at = backfill.completed_at
|
||||
FROM (VALUES #{values}) AS backfill(id, completed_at)
|
||||
WHERE applied_slas.id = backfill.id
|
||||
AND applied_slas.completed_at IS NULL
|
||||
SQL
|
||||
|
||||
connection.exec_update(statement, 'Backfill applied SLA completed_at')
|
||||
end
|
||||
|
||||
def print_preflight(eligible_count)
|
||||
scope = @account_id.present? ? "account_id=#{@account_id}" : 'all accounts'
|
||||
mode = @apply ? 'APPLY' : 'DRY RUN'
|
||||
@output.puts "Applied SLA completed_at backfill: mode=#{mode}, scope=#{scope}, batch_size=#{@batch_size}, after_id=#{@after_id}"
|
||||
@output.puts "Eligible missed applied SLAs: #{eligible_count}"
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,7 @@ json.sla_id resource.sla_policy_id
|
||||
json.sla_status resource.sla_status
|
||||
json.created_at resource.created_at.to_i
|
||||
json.updated_at resource.updated_at.to_i
|
||||
json.sla_completed_at resource.completed_at&.to_i
|
||||
json.sla_description resource.sla_policy.description
|
||||
json.sla_name resource.sla_policy.name
|
||||
json.sla_first_response_time_threshold resource.sla_policy.first_response_time_threshold
|
||||
|
||||
Reference in New Issue
Block a user