Cuckoo

Monitor GoodJob Cron Jobs with Cuckoo

Updated

GoodJob can enqueue recurring jobs itself, so you don't need a crontab. That also means the schedule only runs while a GoodJob process with cron enabled is up. If enable_cron is off in production, the worker container is scaled to zero, or the job class was renamed and the cron entry wasn't, nothing runs and nothing complains. The GoodJob dashboard shows executions that happened. It can't show the ones that didn't.

A heartbeat check fills that gap. Each run reports to Cuckoo. A late or failed report triggers an alert.

How Cuckoo sees a GoodJob cron entry

Create one check per cron entry, with the same cron expression and time zone. Each check has a URL:

  • https://cuckoo.metastic.ch/ping/YOUR-UUID/start when perform starts
  • https://cuckoo.metastic.ch/ping/YOUR-UUID when it succeeds
  • https://cuckoo.metastic.ch/ping/YOUR-UUID/fail when it raises (POST body up to 10 KB stored)

Step 1: Configure GoodJob cron

# config/application.rb or config/environments/production.rb
config.good_job.enable_cron = true
config.good_job.cron = {
  nightly_export: {
    cron: "0 3 * * * Europe/Zurich",   # fugit syntax, time zone optional
    class: "NightlyExportJob",
    description: "Export orders to the warehouse"
  },
  hourly_sync: {
    cron: "every hour",
    class: "InventorySyncJob"
  }
}

enable_cron defaults to false. Cron runs inside GoodJob processes: the good_job start CLI, or the web process in :async mode. Enabling it on several processes is safe, because GoodJob uses a unique index so that each run is enqueued only once. For that to work, GoodJob has to keep cron-created job records.

Step 2: A ping helper

# app/lib/heartbeat.rb
require "net/http"

module Cuckoo
  def self.ping(url, suffix = "", body: nil)
    return if url.blank?
    uri = URI(url + suffix)
    Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
      body ? http.post(uri.path, body.byteslice(0, 10_000)) : http.get(uri.path)
    end
  rescue StandardError => e
    Rails.logger.warn("heartbeat ping failed: #{e.message}")
  end
end

Step 3: An around_perform concern

ActiveJob callbacks keep the monitoring out of each job's perform. A concern adds the pings to any job that declares a ping URL:

# app/jobs/concerns/heartbeat_monitored.rb
module CuckooMonitored
  extend ActiveSupport::Concern

  included do
    class_attribute :heartbeat_url, instance_writer: false

    around_perform do |job, block|
      url = job.class.heartbeat_url
      Cuckoo.ping(url, "/start")
      begin
        block.call
        Cuckoo.ping(url)
      rescue Exception => e
        Cuckoo.ping(url, "/fail", body: "#{e.class}: #{e.message}\n#{Array(e.backtrace).first(20).join("\n")}")
        raise
      end
    end
  end
end
class NightlyExportJob < ApplicationJob
  include CuckooMonitored
  self.heartbeat_url = ENV["CUCKOO_NIGHTLY_EXPORT"] # https://cuckoo.metastic.ch/ping/YOUR-UUID

  def perform
    WarehouseExport.run!
  end
end

The exception is re-raised, so GoodJob records the error and applies your retry rules as usual.

Retries and discard rules

ActiveJob handles retry_on and discard_on outside the perform callbacks, so around_perform sees every exception first. That gives you these defaults:

  • Every failed attempt sends /fail, including attempts that retry_on will retry. When a retry succeeds, the success ping follows and Cuckoo sends a recovery message.
  • discard_on errors also send /fail before the job is dropped, so a discarded cron run is still visible.

If you only want an alert once retries are used up, take the rescue branch out of the concern and report from the block that retry_on calls when attempts are exhausted:

class NightlyExportJob < ApplicationJob
  retry_on Net::ReadTimeout, wait: 30.seconds, attempts: 3 do |job, error|
    Cuckoo.ping(job.class.heartbeat_url, "/fail", body: "#{error.class}: #{error.message}")
  end
end

Any other failure still shows up as a missing success ping once the grace time runs out.

Schedule and grace advice

  • Use the same cron expression and time zone in Cuckoo as in config.good_job.cron. For fugit shorthands like every hour, use a simple 1-hour period.
  • Grace time should cover queue wait plus runtime. Give important cron jobs a dedicated queue, or a higher priority via the entry's set: option.
  • Set max runtime for jobs that call slow external APIs. The /start ping makes this possible.

Troubleshooting

  • Nothing runs: confirm GoodJob.configuration.enable_cron? in a production console, and check that a GoodJob process is actually running (good_job start or :async).
  • Cron tab empty in the dashboard: the cron hash didn't load in that environment. Configure it where all environments that run cron will read it.
  • Pings from development: leave the CUCKOO_* variables unset locally. The helper skips blank URLs.

FAQ

Does this work with other ActiveJob backends?

Yes. The around_perform concern is plain ActiveJob, so it works the same with Solid Queue, Sidekiq or any other adapter. Only the cron configuration in step 1 is specific to GoodJob.

Can I check that the GoodJob cron process itself is alive?

Yes. Add a cron entry every five minutes that enqueues a tiny monitored job, and give its check a 5-minute period and grace. If cron stops in every process, that check goes down first.

Why not just watch the GoodJob dashboard?

The dashboard shows jobs that exist. A heartbeat check also catches jobs that were never enqueued, and it alerts you without anyone having to look.

Related guides

All guides · Compare Cuckoo with other tools

Send feedback

Ideas, bugs, questions: we read every message. Or write to hello@metastic.ch.