From d166ae73bcbb78640e3e0385018da546517d328f Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Wed, 28 Jan 2026 19:25:20 +0530
Subject: [PATCH] feat: add cron job to remove orphan conversations (#13335)
## Description
This PR includes cron job to delete the orphans
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
---
> [!NOTE]
> Introduces a scheduled cleanup for conversations missing `contact` or
`inbox`.
>
> - Adds `Internal::RemoveOrphanConversationsService` to batch-delete
orphan conversations (scoped by optional `account`, within a
configurable `days` window) with progress logging
> - New `Internal::RemoveOrphanConversationsJob` that invokes the
service; scheduled via `config/schedule.yml` to run every 12 hours on
`housekeeping` queue
> - Refactors rake task `chatwoot:ops:cleanup_orphan_conversations` to
use the service and report `total_deleted` after confirmation
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
59a24715cc59f048d08db3f588cde6fa036f3166. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Muhsin Keloth
---
.../remove_orphan_conversations_job.rb | 11 ++++++
.../remove_orphan_conversations_service.rb | 34 +++++++++++++++++++
config/schedule.yml | 7 ++++
.../ops/cleanup_orphan_conversations.rake | 10 +++---
4 files changed, 57 insertions(+), 5 deletions(-)
create mode 100644 app/jobs/internal/remove_orphan_conversations_job.rb
create mode 100644 app/services/internal/remove_orphan_conversations_service.rb
diff --git a/app/jobs/internal/remove_orphan_conversations_job.rb b/app/jobs/internal/remove_orphan_conversations_job.rb
new file mode 100644
index 000000000..e88611129
--- /dev/null
+++ b/app/jobs/internal/remove_orphan_conversations_job.rb
@@ -0,0 +1,11 @@
+# housekeeping
+# remove conversations that do not have a contact_id
+# orphan conversations without contact cannot be accessed or used
+
+class Internal::RemoveOrphanConversationsJob < ApplicationJob
+ queue_as :housekeeping
+
+ def perform
+ Internal::RemoveOrphanConversationsService.new.perform
+ end
+end
diff --git a/app/services/internal/remove_orphan_conversations_service.rb b/app/services/internal/remove_orphan_conversations_service.rb
new file mode 100644
index 000000000..c52d6082b
--- /dev/null
+++ b/app/services/internal/remove_orphan_conversations_service.rb
@@ -0,0 +1,34 @@
+class Internal::RemoveOrphanConversationsService
+ def initialize(account: nil, days: 1)
+ @account = account
+ @days = days
+ end
+
+ def perform
+ orphan_conversations = build_orphan_conversations_query
+ total_deleted = 0
+
+ Rails.logger.info '[RemoveOrphanConversationsService] Starting removal of orphan conversations'
+
+ orphan_conversations.find_in_batches(batch_size: 1000) do |batch|
+ conversation_ids = batch.map(&:id)
+ Conversation.where(id: conversation_ids).destroy_all
+ total_deleted += batch.size
+ Rails.logger.info "[RemoveOrphanConversationsService] Deleted #{batch.size} orphan conversations (#{total_deleted} total)"
+ end
+
+ Rails.logger.info "[RemoveOrphanConversationsService] Completed. Total deleted: #{total_deleted}"
+ total_deleted
+ end
+
+ private
+
+ def build_orphan_conversations_query
+ base = @account ? @account.conversations : Conversation.all
+ base = base.where('conversations.created_at > ?', @days.days.ago)
+ base = base.left_outer_joins(:contact, :inbox)
+
+ # Find conversations whose associated contact or inbox record is missing
+ base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
+ end
+end
diff --git a/config/schedule.yml b/config/schedule.yml
index cae9b94c8..96e4cfc4f 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -66,3 +66,10 @@ remove_old_notification_job:
cron: '30 22 * * *'
class: 'Notification::RemoveOldNotificationJob'
queue: purgable
+
+# executed every 12 hours
+# to remove orphan conversations without contact
+remove_orphan_conversations_job:
+ cron: '0 */12 * * *'
+ class: 'Internal::RemoveOrphanConversationsJob'
+ queue: housekeeping
diff --git a/lib/tasks/ops/cleanup_orphan_conversations.rake b/lib/tasks/ops/cleanup_orphan_conversations.rake
index f6574b396..20cb207d6 100644
--- a/lib/tasks/ops/cleanup_orphan_conversations.rake
+++ b/lib/tasks/ops/cleanup_orphan_conversations.rake
@@ -15,13 +15,13 @@ namespace :chatwoot do
days_input = $stdin.gets.strip
days = days_input.empty? ? 7 : days_input.to_i
- # Build a common base relation with identical joins for OR compatibility
+ service = Internal::RemoveOrphanConversationsService.new(account: account, days: days)
+
+ # Preview count using the same query logic
base = account
.conversations
.where('conversations.created_at > ?', days.days.ago)
.left_outer_joins(:contact, :inbox)
-
- # Find conversations whose associated contact or inbox record is missing
conversations = base.where(contacts: { id: nil }).or(base.where(inboxes: { id: nil }))
count = conversations.count
@@ -31,8 +31,8 @@ namespace :chatwoot do
print 'Do you want to delete these conversations? (y/N): '
confirm = $stdin.gets.strip.downcase
if %w[y yes].include?(confirm)
- conversations.destroy_all
- puts 'Conversations deleted.'
+ total_deleted = service.perform
+ puts "#{total_deleted} conversations deleted."
else
puts 'No conversations were deleted.'
end