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:
Sony Mathew
2026-07-29 18:21:26 +05:30
committed by GitHub
parent 4ac2432b77
commit 502c45f73b
18 changed files with 823 additions and 178 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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