RUBY ON RAILS · 16 MIN READ ·

Solid Queue Recurring Jobs: Vervang Whenever en Sidekiq-Cron in Rails 8

Solid Queue recurring jobs vervangen whenever en sidekiq-cron in Rails 8. Leer recurring task configuratie, dispatcher setup en het testen van gepland werk.

Solid Queue Recurring Jobs: Vervang Whenever en Sidekiq-Cron in Rails 8

Drie jaar geleden was ik de vierde engineer die een Capistrano deploy-script aanraakte dat cron job management had verzameld als een koraalrif dat poliepen verzamelt. Twaalf jobs verspreid over vier locaties: een whenever-schema dat naar de crontab op de app-server schreef, drie Heroku Scheduler-entries die niemand had gedocumenteerd, een sidekiq-cron-initializer voor de Redis-varianten, en twee rake tasks rechtstreeks in de crontab via een commentaar dat luidde: “niet aanraken, toegevoegd door Dave, 2019.” Dave had het bedrijf verlaten. Niemand wist wat die twee taken deden. Iedereen was bang ze te verwijderen.

Dit alles in productie draaien betekende: de app-server had cron-state, Redis had cron-state, Heroku’s scheduler had cron-state, en de Rails-codebase had hier helemaal geen zicht op. Als een job om 3 uur ‘s nachts misliep, was het enige bewijs een ontbrekende rij in de database en een e-mail in een gedeelde inbox die niemand bijhield.

Met Rails 8 en Solid Queue recurring jobs is die architectuur eindelijk overbodig. Alles staat in de database. Het schema is een YAML-bestand in je repository. Het dispatcher-process regelt de timing. Mislukkingen verschijnen op dezelfde plek als elke andere mislukte job. Ik heb het afgelopen jaar vier productiecode-bases van sidekiq-cron en whenever afgehaald en de oude aanpak geen moment gemist.

Waarom de Oude Aanpakken Tekort Schieten

whenever schrijft een crontab op de app-server. Dit is een probleem zodra je meer dan één server hebt — elke server draait zijn eigen kopie van het schema, en je hebt externe coördinatie nodig om dubbele uitvoering te voorkomen. Het is bovendien onzichtbaar voor Ruby: de crontab valt buiten de verantwoordelijkheid van de applicatie. Exception-trackers weten er niets van. Deploys kunnen nalaten de crontab bij te werken als Capistrano verkeerd is geconfigureerd. En het jobproces wordt voor elke uitvoering opnieuw gestart, wat betekent: geen warme connection pool, geen gedeelde state, en een koude Ruby-boot bij elke run.

sidekiq-cron is beter — het draait binnen het Sidekiq-process en gebruikt Lua-scripts in Redis voor een gedistribueerde lock op geplande uitvoering. Maar het vereist Redis, een extra gem bovenop Sidekiq, en de schemaconfiguratie wordt doorgaans gedefinieerd in een Ruby-initializer die bij het opstarten wordt geladen. Testomgevingen booten vaak met het cron-schema uitgeschakeld via environment-conditionals die iemand vroeg of laat kapot maakt. En als je overstap naar Solid Queue, verlies je sidekiq-cron sowieso en heb je een vervanger nodig.

Solid Queue’s recurring tasks lossen dit alles op. Het schema is een YAML-bestand in versiebeheer. Het dispatcher-process leest het bij het opstarten, slaat het schema op in je Postgres-database en vuurt uitvoeringen op het juiste moment af. Eén server, tien servers — de database biedt de gedistribueerde lock. Mislukkingen verschijnen als mislukte SolidQueue::Job-records, identiek aan elke andere mislukte background job.

Solid Queue Recurring Jobs Configureren

Het herhalende schema staat standaard in config/recurring.yml. Een minimaal productieschema:

# config/recurring.yml
production:
  cleanup_stale_sessions:
    class: CleanupStaleSessionsJob
    schedule: "0 2 * * *"
    queue: maintenance

  send_weekly_digest:
    class: SendWeeklyDigestJob
    schedule: "0 9 * * 1"
    args: [{ force: false }]
    queue: mailers
    priority: 5

  refresh_exchange_rates:
    class: RefreshExchangeRatesJob
    schedule: "*/15 * * * *"
    queue: default

Elke entry heeft een naam die als identifier in de database wordt gebruikt, een class die verwijst naar een ActiveJob-subklasse, en een schedule in standaard vijf-veld cron-formaat. Optionele velden zijn args (direct doorgegeven aan perform), queue en priority.

Het cron-formaat wordt geparsed door de fugit-gem, een dependency van Solid Queue. Fugit begrijpt standaard cron-syntax plus macro’s zoals @hourly, @daily en @weekly.

Vertel Solid Queue waar het recurring-bestand staat in config/solid_queue.yml:

# config/solid_queue.yml
default: &default
  dispatchers:
    - polling_interval: 1
      batch_size: 500
      recurring_tasks_file: config/recurring.yml

  workers:
    - queues: "*"
      threads: 3
      polling_interval: 0.1

production:
  <<: *default
  workers:
    - queues: [critical, default, mailers, maintenance]
      threads: 5
      polling_interval: 0.1

De dispatchers-sectie is wat recurring tasks mogelijk maakt. Zonder minstens één geconfigureerde dispatcher verwerkt Solid Queue jobs prima, maar plant nooit herhalende uitvoeringen. Eén dispatcher beheert de klok. Meerdere dispatchers zijn veilig — de database-record voorkomt dubbele planning.

De Solid Queue Dispatcher Draaien

In development is de eenvoudigste opzet de gecombineerde supervisor:

bin/rails solid_queue:start

Dit leest config/solid_queue.yml en start de dispatcher- en worker-processen. In productie via Kamal voeg je de worker toe als een aparte service, zodat je hem onafhankelijk van het webproces kunt schalen en herstarten:

# config/deploy.yml
servers:
  web:
    hosts:
      - 192.168.1.1
  worker:
    hosts:
      - 192.168.1.2
    cmd: bin/rails solid_queue:start
    env:
      RAILS_ENV: production

Op een single-server-setup kun je Solid Queue als Puma-plugin draaien, wat de supervisor binnen het Puma-process start:

# config/puma.rb
plugin :solid_queue

De plugin-aanpak is handig maar vervaagt de procesgrenzen. Voor alles buiten een low-traffic single-server-app geef ik de voorkeur aan de aparte worker-service — hiermee kun je jobverwerking onafhankelijk schalen en workers herstarten zonder Puma aan te raken. De Kamal deployment-gids behandelt de multi-service-opzet in detail.

Jobklassen en Argumenten

Recurring jobs zijn gewone ActiveJob-klassen. Er is niets bijzonders vereist:

# app/jobs/cleanup_stale_sessions_job.rb
class CleanupStaleSessionsJob < ApplicationJob
  queue_as :maintenance

  def perform
    cutoff = 30.days.ago
    Session.where("last_active_at < ?", cutoff).in_batches.delete_all
    Rails.logger.info "Cleaned up sessions inactive since #{cutoff}"
  end
end

Wanneer je args doorgeeft in de YAML, worden ze positioneel gekoppeld aan perform-parameters:

# config/recurring.yml
  generate_monthly_report:
    class: GenerateReportJob
    schedule: "0 6 1 * *"    # 6 uur op de 1e van elke maand
    args: [monthly, { recipients: ["ops@example.com"] }]
# app/jobs/generate_report_job.rb
class GenerateReportJob < ApplicationJob
  def perform(period, options = {})
    recipients = options.fetch("recipients", [])
    ReportMailer.with(period: period, recipients: recipients).monthly.deliver_later
  end
end

Praktische noot: args uit YAML worden gedeserialiseerd als gewone Ruby-objecten — strings, integers en hashes met string-keys. Geef geen ActiveRecord-objecten of symbols door; die overleven de YAML-round-trip niet correct. Gebruik IDs en zoek records op binnen perform.

Gemiste Uitvoeringen: Wat Gebeurt Er als de Server Offline Is

Standaard backfilt Solid Queue gemiste uitvoeringen niet. Als je app offline is tijdens een gepland venster, wordt die uitvoering simpelweg overgeslagen. De volgende geplande tijd vuurt normaal af.

Voor de meeste geplande jobs is dit het juiste gedrag. Een wekelijkse digest hoeft niet twee keer te worden verstuurd omdat een deploy uitliep. Een nachtelijke cleanup die één nacht is overgeslagen is prima — die draait de volgende nacht.

Als je een gemiste job echt wilt herstellen, bouw die logica dan in de job zelf in plaats van te vertrouwen op de scheduler:

class GenerateInvoicesJob < ApplicationJob
  def perform(billing_date = Date.current)
    # Aangeroepen op schema zonder args — billing_date is standaard vandaag
    # Handmatig aangeroepen met een specifieke datum voor backfill
    Invoice::Generator.run(for_date: billing_date)
  end
end

Voor herstel enqueue je simpelweg met de gemiste datum:

GenerateInvoicesJob.perform_later(Date.yesterday)

Dit is schoner dan automatische backfill, omdat de job exact weet welke datum hij verwerkt. Automatische backfill bij gemiste vensters leidt tot dubbel werk of race conditions wanneer meerdere processen de onderbreking tegelijk detecteren.

Tijdzone-valkuilen

Cron draait in UTC. Solid Queue’s dispatcher draait in UTC. Rails’ config.time_zone heeft geen effect op wanneer recurring tasks worden afgevuurd.

De wekelijkse digest om 9 uur in de bovenstaande configuratie vuurt af om 09:00 UTC. Als je gebruikers in Amsterdam zitten (UTC+2 in de zomer, UTC+1 in de winter), arriveert die digest om 11 uur in de zomer en 10 uur in de winter — het soort inconsistentie dat klachten oplevert na de zomertijdwisseling.

Voor tijdgevoelige schema’s verwerk je de UTC-offset expliciet in de cron-expressie en documenteer je dat duidelijk. Voor alles dat DST correct moet afhandelen, bouw je een bewaker in de job zelf:

class SendWeeklyDigestJob < ApplicationJob
  def perform
    # Afbreken als we buiten het beoogde lokale bezorgvenster vallen
    local_hour = Time.current.in_time_zone("Amsterdam").hour
    return unless (8..10).cover?(local_hour)

    DigestMailer.weekly.deliver_later
  end
end

Deze idempotente bewaker betekent dat je de job kunt triggeren vanuit staging of een monitoringscript zonder een echte e-mail te produceren buiten kantooruren. Het maakt de tijdzone-intentie ook expliciet in Ruby in plaats van verstopt in een cron-expressiecommentaar.

Solid Queue Recurring Jobs Testen

Test de jobklasse onafhankelijk van het schema. Het schema is configuratie; het jobgedrag is code.

# spec/jobs/cleanup_stale_sessions_job_spec.rb
RSpec.describe CleanupStaleSessionsJob, type: :job do
  describe "#perform" do
    let!(:stale_session) { create(:session, last_active_at: 45.days.ago) }
    let!(:active_session) { create(:session, last_active_at: 5.days.ago) }

    it "verwijdert sessies die langer dan 30 dagen inactief zijn" do
      expect { described_class.new.perform }.to change(Session, :count).by(-1)
      expect { stale_session.reload }.to raise_error(ActiveRecord::RecordNotFound)
    end

    it "bewaart recentelijk actieve sessies" do
      described_class.new.perform
      expect(active_session.reload).to be_persisted
    end
  end
end

Voeg een lichtgewicht spec toe die de YAML-config laadt om te controleren of het schema syntactisch geldig is en verwijst naar echte jobklassen:

# spec/config/recurring_spec.rb
require "fugit"

RSpec.describe "config/recurring.yml" do
  let(:config) do
    YAML.load_file(Rails.root.join("config/recurring.yml"))[Rails.env] || {}
  end

  it "verwijst naar geldige jobklassen" do
    config.each_value do |task|
      expect { task["class"].constantize }.not_to raise_error,
        "#{task["class"]} is geen geldige jobklasse"
    end
  end

  it "gebruikt geldige cron-expressies" do
    config.each do |name, task|
      cron = Fugit::Cron.parse(task["schedule"])
      expect(cron).not_to be_nil,
        "#{name} heeft een ongeldige cron-expressie: #{task["schedule"]}"
    end
  end
end

Dit vangt de twee meest voorkomende fouten bij recurring jobs op — typefouten in klassenamen en foutieve cron-strings — zonder dat er een draaiende dispatcher nodig is.

Observeerbaarheid: Zien Wat Er Werkelijk Is Uitgevoerd

Solid Queue slaat elke uitvoering op in de database. Je kunt direct opvragen vanuit een console:

# Laatste 20 herhalende uitvoeringen
SolidQueue::RecurringExecution.order(created_at: :desc).limit(20).each do |ex|
  puts "#{ex.task_key} — uitgevoerd om #{ex.created_at.strftime("%Y-%m-%d %H:%M UTC")}"
end

# Mislukte jobs in de afgelopen 24 uur
SolidQueue::FailedExecution.where("created_at > ?", 24.hours.ago).count

Voor een volwaardig dashboard mount je de Mission Control-engine in config/routes.rb:

mount MissionControl::Jobs::Engine, at: "/jobs"

Beveilig het achter authenticatie. Snelle HTTP basic auth via credentials:

# config/initializers/mission_control.rb
MissionControl::Jobs.http_basic_auth_credentials = {
  username: Rails.application.credentials.dig(:mission_control, :username),
  password: Rails.application.credentials.dig(:mission_control, :password)
}

Mission Control toont wachtende, actieve, geplande en mislukte jobs met de volledige exception en backtrace voor mislukkingen. Je kunt mislukte jobs via de UI opnieuw proberen zonder de console aan te raken — essentieel als een job om 3 uur ‘s nachts misloopt en je wilt herstellen zonder een engineer wakker te bellen. De Solid Queue achtergrondtaken deep-dive behandelt de volledige Mission Control-opzet inclusief gem-dependencies en auth-opties.

Overlappende Uitvoeringen Voorkomen

Solid Queue slaat een geplande uitvoering niet over als de vorige nog loopt. Als CleanupStaleSessionsJob 90 minuten duurt en elk uur wordt afgevuurd, krijg je overlappende uitvoeringen. Beveilig je hiertegen met een database-level advisory lock:

class CleanupStaleSessionsJob < ApplicationJob
  def perform
    # with_advisory_lock van de with_advisory_lock-gem
    # timeout_seconds: 0 betekent direct stoppen als de lock bezet is
    with_advisory_lock("cleanup_stale_sessions", timeout_seconds: 0) do
      Session.where("last_active_at < ?", 30.days.ago).in_batches.delete_all
    end
  end
end

De with_advisory_lock-gem verwerft een Postgres advisory lock voor de duur van het blok. Als een andere instantie van de job de lock vasthoudt, eindigt de nieuwe uitvoering netjes in plaats van erachter te wachten. De advisory locks post gaat diep in op dit patroon, inclusief de gem-opzet en timeout-semantiek.

FAQ

Kan ik Solid Queue recurring jobs gebruiken zonder Rails 8?

Solid Queue werkt als standalone gem met Rails 7.1 en later. Voeg gem "solid_queue" toe aan je Gemfile, voer bin/rails generate solid_queue:install uit en configureer recurring tasks zoals hierboven beschreven. Rails 8 bevat het standaard en configureert het als queue adapter in nieuwe apps, maar de recurring jobs-functie is niet exclusief voor Rails 8.

Hoe migreer ik van whenever naar Solid Queue recurring jobs?

Verwijder de whenever-gem en zijn config/schedule.rb. Maak voor elke rake task of job in het schema een bijbehorende ActiveJob-klasse aan als die nog niet bestaat, en voeg een entry toe aan config/recurring.yml. Verwijder de crontab-entries op de server — whenever --clear-crontab — of via je deploy-script. Zorg ten slotte dat de Solid Queue dispatcher draait. De Sidekiq naar Solid Queue migratiegids behandelt het verwijderen van dependencies en de deploy-volgorde in detail.

Overleeft de Solid Queue recurring job scheduler een deploy?

Ja. De dispatcher leest config/recurring.yml bij het opstarten en vergelijkt dit met wat er in de database staat. Taken die in de YAML staan maar niet in de database worden ingevoegd. Taken die uit de YAML zijn verwijderd worden uit de database verwijderd. Ongewijzigde taken worden met rust gelaten. Een rolling deploy die het worker-process herstart laadt het schema netjes opnieuw zonder uitvoeringen te dupliceren of te verliezen.

Moet de dispatcher op dezelfde server draaien als de workers?

Nee. De dispatcher en workers communiceren uitsluitend via de database. De dispatcher maakt SolidQueue::ScheduledExecution-records aan; workers pikken ze op. Je kunt de dispatcher op je webserver draaien en workers op dedicated job-servers, of alles samen draaien. De enige vereiste is dat alle processen dezelfde database delen.

Draai jij nog geplande jobs via whenever en een fragiele crontab? TTB Software helpt Rails-teams hun achtergrondtaakinfrastructuur te moderniseren met Solid Queue, Mission Control en degelijke productie-operaties. We doen dit al negentien jaar.

#solid-queue-recurring-jobs #rails-8-cron #whenever-gem-alternative #sidekiq-cron-replacement #rails-scheduled-jobs #solid-queue-dispatcher #rails-8-background-jobs

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software