From d00867d6368cf54f2593a70241d9b1d6e2a3edff Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 4 May 2026 13:37:25 +0530 Subject: [PATCH] fix: captain auto sync scheduler config (#14336) --- .../trigger_hourly_scheduled_items_job.rb | 7 ++ config/initializers/sidekiq.rb | 5 -- config/installation_config.yml | 6 ++ config/schedule.yml | 6 ++ .../captain/documents/schedule_syncs_job.rb | 3 +- .../trigger_hourly_scheduled_items_job.rb | 7 ++ enterprise/app/models/enterprise/account.rb | 31 ++++++-- enterprise/config/schedule.yml | 10 --- .../documents/schedule_syncs_job_spec.rb | 1 + spec/enterprise/models/account_spec.rb | 74 ++++++++++++------- 10 files changed, 100 insertions(+), 50 deletions(-) create mode 100644 app/jobs/internal/trigger_hourly_scheduled_items_job.rb create mode 100644 enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb delete mode 100644 enterprise/config/schedule.yml diff --git a/app/jobs/internal/trigger_hourly_scheduled_items_job.rb b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb new file mode 100644 index 000000000..f870d742b --- /dev/null +++ b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb @@ -0,0 +1,7 @@ +class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob + queue_as :scheduled_jobs + + def perform; end +end + +Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob') diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 60596156c..7b78b466a 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do if File.exist?(schedule_file) && Sidekiq.server? schedule = YAML.load_file(schedule_file) - # Merge enterprise-only cron entries when running an enterprise build. - # Mirrors the conditional-load pattern already used for enterprise initializers. - enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml') - schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist? - # Cron entries removed from schedule.yml but possibly still in Redis # with source:'dynamic' (predating the source tag). load_from_hash! # only cleans up source:'schedule' entries, so these need explicit removal. diff --git a/config/installation_config.yml b/config/installation_config.yml index 884dd2e58..e374d0948 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -209,6 +209,12 @@ description: 'The limits for the Captain AI service for different plans' value: type: code +- name: CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS + display_title: 'Captain Document Auto Sync Intervals' + description: 'JSON map of plan-wise Captain document auto-sync intervals in hours. Use null to disable auto-sync for a plan.' + value: + locked: false + type: code # End of Captain Config # ------- Context.dev Config ------- # diff --git a/config/schedule.yml b/config/schedule.yml index f1054ad68..f5e335ba3 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -14,6 +14,12 @@ trigger_scheduled_items_job: class: 'TriggerScheduledItemsJob' queue: scheduled_jobs +# executed hourly for scheduled jobs that do not need minute-level cadence +trigger_hourly_scheduled_items_job: + cron: '0 * * * *' + class: 'Internal::TriggerHourlyScheduledItemsJob' + queue: scheduled_jobs + # executed At every minute.. trigger_imap_email_inboxes_job: cron: '*/1 * * * *' diff --git a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb index ab72e9b4a..3c2c55132 100644 --- a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb +++ b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb @@ -7,6 +7,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob def perform @remaining_global_capacity = GLOBAL_HOURLY_CAP + sync_intervals = Enterprise::Account.captain_document_sync_intervals stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 } Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account| @@ -16,7 +17,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob next unless account.feature_enabled?('captain_document_auto_sync') stats[:accounts_enabled] += 1 - interval = account.captain_document_sync_interval + interval = account.captain_document_sync_interval(sync_intervals) next unless interval stats[:accounts_scheduled] += 1 diff --git a/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb b/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb new file mode 100644 index 000000000..9d3baa2d6 --- /dev/null +++ b/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb @@ -0,0 +1,7 @@ +module Enterprise::Internal::TriggerHourlyScheduledItemsJob + def perform + super + + Captain::Documents::ScheduleSyncsJob.perform_later + end +end diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb index e8828cc4c..9b451a23c 100644 --- a/enterprise/app/models/enterprise/account.rb +++ b/enterprise/app/models/enterprise/account.rb @@ -1,10 +1,22 @@ module Enterprise::Account - CAPTAIN_SYNC_INTERVALS = { - 'hacker' => nil, - 'startups' => 7.days, - 'business' => 1.day, - 'enterprise' => 6.hours - }.freeze + class << self + def captain_document_sync_intervals + parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value) + end + + private + + def parse_captain_document_sync_intervals(configured_intervals) + return {} if configured_intervals.blank? + + parsed_intervals = configured_intervals.is_a?(String) ? JSON.parse(configured_intervals) : configured_intervals + return {} unless parsed_intervals.is_a?(Hash) + + parsed_intervals.transform_keys { |plan| plan.to_s.downcase } + rescue JSON::ParserError + {} + end + end # TODO: Remove this when we upgrade administrate gem to the latest version # this is a temporary method since current administrate doesn't support virtual attributes @@ -41,12 +53,15 @@ module Enterprise::Account custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save end - def captain_document_sync_interval + def captain_document_sync_interval(sync_intervals = Enterprise::Account.captain_document_sync_intervals) plan = custom_attributes['plan_name'] plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise? return nil if plan.blank? - CAPTAIN_SYNC_INTERVALS[plan.downcase] + interval_hours = sync_intervals[plan.downcase] + return nil unless interval_hours.is_a?(Integer) && interval_hours.positive? + + interval_hours.hours end def saml_enabled? diff --git a/enterprise/config/schedule.yml b/enterprise/config/schedule.yml deleted file mode 100644 index 05ea11e64..000000000 --- a/enterprise/config/schedule.yml +++ /dev/null @@ -1,10 +0,0 @@ -# Enterprise-only Sidekiq cron schedule. -# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true. -# Add cron entries here when the referenced job class lives under enterprise/. - -# Captain document auto-sync scheduler -# Runs hourly, finds due documents based on plan sync intervals -captain_documents_schedule_syncs_job: - cron: '0 * * * *' - class: 'Captain::Documents::ScheduleSyncsJob' - queue: scheduled_jobs diff --git a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb index e7a16db5b..66828ef9f 100644 --- a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb +++ b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb @@ -5,6 +5,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do let(:assistant) { create(:captain_assistant, account: account) } before do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json) account.enable_features!('captain_document_auto_sync') clear_enqueued_jobs end diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb index 8183c76ff..c69a83256 100644 --- a/spec/enterprise/models/account_spec.rb +++ b/spec/enterprise/models/account_spec.rb @@ -225,46 +225,68 @@ RSpec.describe Account, type: :model do describe 'captain document sync cadence' do let(:account) { create(:account) } - it 'has no cadence on the hacker plan' do - account.update!(custom_attributes: { plan_name: 'hacker' }) - expect(account.captain_document_sync_interval).to be_nil - end - - it 'syncs weekly on the startups plan' do - account.update!(custom_attributes: { plan_name: 'startups' }) - expect(account.captain_document_sync_interval).to eq(7.days) - end - - it 'syncs daily on the business plan' do + it 'has no cadence when installation config is missing' do account.update!(custom_attributes: { plan_name: 'business' }) - expect(account.captain_document_sync_interval).to eq(1.day) - end - - it 'syncs every six hours on the enterprise plan' do - account.update!(custom_attributes: { plan_name: 'enterprise' }) - expect(account.captain_document_sync_interval).to eq(6.hours) - end - - it 'has no cadence when plan is missing' do - account.update!(custom_attributes: {}) expect(account.captain_document_sync_interval).to be_nil end - it 'has no cadence for unknown plans' do - account.update!(custom_attributes: { plan_name: 'mystery' }) - expect(account.captain_document_sync_interval).to be_nil + it 'uses configured plan intervals from installation config' do + intervals = { + business: 48, + enterprise: 24 + } + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json) + account.update!(custom_attributes: { plan_name: 'business' }) + + expect(account.captain_document_sync_interval).to eq(2.days) end - it 'normalizes plan name casing' do + it 'normalizes configured plan name casing' do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24 }.to_json) account.update!(custom_attributes: { plan_name: 'Business' }) + expect(account.captain_document_sync_interval).to eq(1.day) end - it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do + it 'uses the enterprise cadence for self-hosted enterprise installs without a plan_name' do allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true) + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { enterprise: 6 }.to_json) account.update!(custom_attributes: {}) + expect(account.captain_document_sync_interval).to eq(6.hours) end + + it 'allows installation config to disable a plan cadence' do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: nil }.to_json) + account.update!(custom_attributes: { plan_name: 'business' }) + + expect(account.captain_document_sync_interval).to be_nil + end + + it 'has no cadence when installation config is invalid' do + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: 'invalid-json') + account.update!(custom_attributes: { plan_name: 'business' }) + + expect(account.captain_document_sync_interval).to be_nil + end + + it 'treats invalid plan interval values as disabled' do + intervals = { + business: false, + enterprise: { hours: 6 }, + startups: '168' + } + create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json) + + account.update!(custom_attributes: { plan_name: 'business' }) + expect(account.captain_document_sync_interval).to be_nil + + account.update!(custom_attributes: { plan_name: 'enterprise' }) + expect(account.captain_document_sync_interval).to be_nil + + account.update!(custom_attributes: { plan_name: 'startups' }) + expect(account.captain_document_sync_interval).to be_nil + end end describe 'account deletion' do