From 688218de0ac8b2dde45903d406711b487b2cf8f9 Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Tue, 17 Mar 2026 14:57:49 +0530 Subject: [PATCH] feat: distributed scheduling for version check job (#13042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change spreads Chatwoot Hub version checks across the day by scheduling each installation at a stable minute derived from its installation identifier, instead of having all instances check at the same fixed time. Closes - https://linear.app/chatwoot/issue/CW-6107/handle-the-spike-at-12-utc-on-chatwoot-hub What changed - Added `Internal::TriggerDailyScheduledItemsJob` to act as the daily trigger for deferred internal jobs. - Updated the version check cron entry to run once daily at `00:00 UTC` and enqueue the actual version check for that installation’s assigned minute of the day. - Used a deterministic minute-of-day derived from `ChatwootHub.installation_identifier` so the check time stays stable across deploys and restarts. - Kept the existing cron schedule key while switching it to the new orchestrator job. How to test - Run `bundle exec rspec spec/jobs/internal/check_new_versions_job_spec.rb spec/jobs/internal/trigger_daily_scheduled_items_job_spec.rb spec/configs/schedule_spec.rb` - In a Rails console, run `Internal::TriggerDailyScheduledItemsJob.perform_now` and verify `Internal::CheckNewVersionsJob` is enqueued with a `wait_until` later the same UTC day. - In Super Admin settings, use Refresh and verify the version check still runs immediately. --------- Co-authored-by: Sojan Jose --- .../trigger_daily_scheduled_items_job.rb | 25 ++++++++++++ config/initializers/sidekiq.rb | 2 + config/schedule.yml | 8 ++-- .../trigger_daily_scheduled_items_job_spec.rb | 39 +++++++++++++++++++ 4 files changed, 71 insertions(+), 3 deletions(-) create mode 100644 app/jobs/internal/trigger_daily_scheduled_items_job.rb create mode 100644 spec/jobs/internal/trigger_daily_scheduled_items_job_spec.rb diff --git a/app/jobs/internal/trigger_daily_scheduled_items_job.rb b/app/jobs/internal/trigger_daily_scheduled_items_job.rb new file mode 100644 index 000000000..80b92ea6b --- /dev/null +++ b/app/jobs/internal/trigger_daily_scheduled_items_job.rb @@ -0,0 +1,25 @@ +class Internal::TriggerDailyScheduledItemsJob < ApplicationJob + queue_as :scheduled_jobs + + def perform + # Schedule daily deferred jobs here so each installation can spread load + # across the day without changing its slot on deploys or restarts. + schedule_version_check + end + + private + + def schedule_version_check + return unless Rails.env.production? + + Internal::CheckNewVersionsJob.set(wait_until: version_check_run_at).perform_later + end + + def version_check_run_at + Time.current.utc.beginning_of_day + designated_minute.minutes + end + + def designated_minute + @designated_minute ||= Digest::MD5.hexdigest(ChatwootHub.installation_identifier).hex % 1440 + end +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index dd5c71a4d..9511ae68e 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -34,5 +34,7 @@ end # https://github.com/ondrejbartas/sidekiq-cron Rails.application.reloader.to_prepare do + # TODO: Switch to `load_from_hash!(..., source: 'schedule')` once we have a + # safe cleanup path for YAML-backed cron jobs already persisted in Redis. Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server? end diff --git a/config/schedule.yml b/config/schedule.yml index 4a264e587..153724c25 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -2,10 +2,12 @@ # use https://crontab.guru/ to validate # validations for this file exist in /spec/configs/schedule_spec.rb -# executed At 12:00 on every day-of-month. +# executed daily at 0000 UTC +# schedules daily deferred jobs at stable times for each installation +# keep the existing schedule key while the cron loader still uses load_from_hash internal_check_new_versions_job: - cron: '0 12 */1 * *' - class: 'Internal::CheckNewVersionsJob' + cron: '0 0 * * *' + class: 'Internal::TriggerDailyScheduledItemsJob' queue: scheduled_jobs # # executed At every 5th minute.. trigger_scheduled_items_job: diff --git a/spec/jobs/internal/trigger_daily_scheduled_items_job_spec.rb b/spec/jobs/internal/trigger_daily_scheduled_items_job_spec.rb new file mode 100644 index 000000000..8b7a6da1c --- /dev/null +++ b/spec/jobs/internal/trigger_daily_scheduled_items_job_spec.rb @@ -0,0 +1,39 @@ +require 'rails_helper' + +RSpec.describe Internal::TriggerDailyScheduledItemsJob do + subject(:perform_job) { described_class.perform_now } + + let(:installation_id) { 'test-installation-id' } + let(:designated_minute) { Digest::MD5.hexdigest(installation_id).hex % 1440 } + let(:scheduled_time) { Time.current.utc.beginning_of_day + designated_minute.minutes } + let(:configured_job) { instance_double(ActiveJob::ConfiguredJob, perform_later: true) } + + before do + allow(ChatwootHub).to receive(:installation_identifier).and_return(installation_id) + allow(Internal::CheckNewVersionsJob).to receive(:set).and_return(configured_job) + end + + it 'enqueues the job' do + expect { described_class.perform_later }.to have_enqueued_job(described_class) + .on_queue('scheduled_jobs') + end + + it 'schedules the version check at a stable minute in production' do + allow(Rails.env).to receive(:production?).and_return(true) + + travel_to Time.zone.parse('2026-03-17 08:00:00 UTC') do + perform_job + + expect(Internal::CheckNewVersionsJob).to have_received(:set).with(wait_until: scheduled_time) + expect(configured_job).to have_received(:perform_later) + end + end + + it 'does not schedule the version check outside production' do + allow(Rails.env).to receive(:production?).and_return(false) + + perform_job + + expect(Internal::CheckNewVersionsJob).not_to have_received(:set) + end +end