# Rails Scheduled Jobs: Solid Queue, Whenever, and GoodJob Cron

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-25
Canonical: https://cronradar.com/blog/rails-scheduled-jobs

> How Rails 8 runs scheduled jobs with Solid Queue recurring tasks, whenever, or GoodJob cron, why a recurring job doesn't run, and how to alert on misses.

**To run a recurring job in a Rails 8 app, add a task to `config/recurring.yml` with a job class and a `schedule` such as `every day at 3am` or `0 3 * * *`, and keep Solid Queue's scheduler process running.** Solid Queue has been the [default Active Job backend since Rails 8.0](https://guides.rubyonrails.org/active_job_basics.html#default-backend-solid-queue), and its scheduler runs under `bin/jobs` or inside Puma through Solid Queue's plugin. Older apps may use the whenever gem, which writes crontab entries, and apps on GoodJob use its built-in cron. None of them alerts you when a run doesn't happen.

Behavior below is checked against the latest releases on October 25, 2026, mostly in their source: Rails 8.1.4, Solid Queue 1.7.0, whenever 1.1.4, GoodJob 4.22.1, Fugit 1.14.0, and sidekiq-cron 2.4.0.

## Solid Queue, Whenever, and GoodJob Compared

|                                 | Solid Queue recurring tasks     | whenever                              | GoodJob cron                                            |
| ------------------------------- | ------------------------------- | ------------------------------------- | ------------------------------------------------------- |
| Schedule lives in               | `config/recurring.yml`          | `config/schedule.rb`, then crontab    | `config.good_job.cron`                                  |
| Fired by                        | Solid Queue's scheduler process | The cron daemon on each server        | Each GoodJob process with `enable_cron`                 |
| Zone if the schedule names none | `config.time_zone`              | The cron daemon's zone                | `TZ`, else `config.time_zone`                           |
| Several servers                 | One enqueue per task and time   | Runs on every server with the crontab | One enqueue per key and time                            |
| A run due while it was down     | Skipped                         | Skipped                               | Skipped unless `cron_graceful_restart_period` covers it |
| Deploy step                     | None                            | `whenever --update-crontab`           | None                                                    |

On Sidekiq, [sidekiq-cron](https://github.com/sidekiq-cron/sidekiq-cron/blob/v2.4.0/README.md) parses schedules with Fugit and "doesn't schedule the same job multiple times when more than one Sidekiq process is running," and [sidekiq-scheduler](https://github.com/sidekiq-scheduler/sidekiq-scheduler/blob/v6.0.2/README.md) pushes `cron` jobs once across hosts but `every` and `interval` jobs once per host. [Sidekiq monitoring](https://cronradar.com/blog/sidekiq-monitoring) covers both.

## Solid Queue Recurring Tasks

### Define Tasks Per Environment in config/recurring.yml

```yaml
# config/recurring.yml
production:
  nightly_report:
    class: NightlyReportJob
    queue: reports
    schedule: every day at 3am
  clear_solid_queue_finished_jobs:
    command: 'SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)'
    schedule: every hour at minute 12
```

A task has a `schedule` and either a job `class` or a `command`, Ruby that Solid Queue evaluates inside `SolidQueue::RecurringJob` on the `solid_queue_recurring` queue; `args`, `queue`, `priority`, and `description` are optional ([README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#recurring-tasks)).

Solid Queue reads the section named after the current Rails environment, and when there isn't one it reads the whole file, where `production:` is not a task ([configuration.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/solid_queue/configuration.rb#L236-L262)). So the file `solid_queue:install` generates schedules nothing in development or staging; give each environment that should run tasks its own section.

### Fugit Parses the Schedule

Solid Queue passes `schedule` to [Fugit](https://github.com/floraison/fugit/blob/v1.14.0/README.md) and accepts only a single cron ([recurring_task.rb](https://github.com/rails/solid_queue/blob/v1.7.0/app/models/solid_queue/recurring_task.rb#L117-L130)): five-field cron, six-field cron with seconds first, macros such as `@daily`, extensions such as `L` and `#`, or English such as `every 5 minutes` and `every weekday at five pm`. A phrase that yields two crons, like `every day at 16:15 and 18:30`, is rejected; make it two tasks.

An invalid task isn't skipped. The supervisor prints `Invalid recurring tasks` and exits before starting any process ([supervisor.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/solid_queue/supervisor.rb#L11-L23)), and in the Puma plugin's default fork mode, Puma stops too ([plugin](https://github.com/rails/solid_queue/blob/v1.7.0/lib/puma/plugin/solid_queue.rb#L97-L110)). Run `bin/jobs check` in CI or before a deploy: it validates both files for the current environment without starting anything ([README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#validating-the-configuration)).

### Time Zones

Since Solid Queue 1.5, a schedule that names no zone is read in `config.time_zone`, which Rails defaults to `"UTC"`; `config.solid_queue.time_zone` overrides it ([UPGRADING.md](https://github.com/rails/solid_queue/blob/v1.7.0/UPGRADING.md), [engine.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/solid_queue/engine.rb#L19-L23)). A zone in the schedule wins: `0 9 * * * America/New_York` and `every day at 9am America/New_York` follow New York's clock, daylight saving time included.

Without that default, as in GoodJob, sidekiq-cron, and older Solid Queue, Fugit's time library, et-orbi, resolves the local zone as the `TZ` environment variable, then Active Support's `Time.zone`, then the operating system's zone ([zones.rb](https://github.com/floraison/et-orbi/blob/v1.4.1/lib/et-orbi/zones.rb#L53-L73)). A `TZ` set on a container moves every such schedule, so write the zone into each one, as in `0 3 * * * UTC`.

### Where the Scheduler Runs

`bin/jobs` starts Solid Queue's supervisor, which runs the scheduler beside workers and dispatchers ([README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#workers-dispatchers-and-scheduler)). A new Rails 8 app's `config/puma.rb` has `plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]`, and its Kamal `config/deploy.yml` sets `SOLID_QUEUE_IN_PUMA: true`, so production jobs run inside Puma ([puma.rb](https://github.com/rails/rails/blob/v8.1.4/railties/lib/rails/generators/rails/app/templates/config/puma.rb.tt#L37-L39), [deploy.yml](https://github.com/rails/rails/blob/v8.1.4/railties/lib/rails/generators/rails/app/templates/config/deploy.yml.tt#L54-L57)).

At boot, the scheduler sets an in-memory timer for each task's next occurrence and enqueues the job when it fires ([recurring_schedule.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/solid_queue/scheduler/recurring_schedule.rb#L24-L38)). It never looks back, so a run due while it was down, during a deploy, a crash, or a refused boot, is skipped, and nothing records that it was due. `SOLID_QUEUE_SKIP_RECURRING=true` or `bin/jobs --skip-recurring` turns the scheduler off.

### Several Servers Enqueue Each Run Once

Solid Queue enqueues a task's job and inserts a row into `solid_queue_recurring_executions` in one transaction, and a unique index on `task_key` and `run_at` lets one scheduler win each occurrence ([README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#recurring-tasks), [queue_schema.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/generators/solid_queue/install/templates/db/queue_schema.rb#L79-L86)). That needs `preserve_finished_jobs` at its default of `true`, holds only while the jobs are kept (one day by default), covers only jobs on the Solid Queue adapter, and assumes every scheduler loads the same `recurring.yml`.

It guarantees one enqueue, not one execution: a retry or a manual re-run does the work again. [Distributed cron](https://cronradar.com/blog/distributed-cron) covers locks and idempotency.

## Whenever: Recurring Jobs as Crontab Entries

```ruby
# config/schedule.rb
set :output, "log/cron.log"
env :PATH, ENV["PATH"]

every 1.day, at: "3:00 am" do
  runner "NightlyReportJob.perform_now"
end
```

`bundle exec whenever` prints the crontab without writing it. This command, run from the app's directory, replaces the block marked `nightly_app` in the current user's crontab or appends one ([command_line.rb](https://github.com/javan/whenever/blob/v1.1.4/lib/whenever/command_line.rb#L98-L120)):

```bash
bundle exec whenever --update-crontab nightly_app --set environment=production
```

From `/srv/app`, the job becomes this line in a login shell ([setup.rb](https://github.com/javan/whenever/blob/v1.1.4/lib/whenever/setup.rb)):

```text
0 3 * * * /bin/bash -l -c 'cd /srv/app && bundle exec bin/rails runner -e production '\''NightlyReportJob.perform_now'\'' >> log/cron.log 2>&1'
```

Where it goes wrong:

- **The environment is fixed when the crontab is written.** whenever 1.1 takes `RAILS_ENV` from the shell that runs it, defaulting to `production`, and the job `cd`s into the directory it ran from ([setup.rb](https://github.com/javan/whenever/blob/v1.1.4/lib/whenever/setup.rb#L1-L7)). `--set environment=...` keeps a stray `RAILS_ENV=development` out.
- **A login shell is not your terminal's shell.** `bash -l` reads `/etc/profile` and the first of `~/.bash_profile`, `~/.bash_login`, and `~/.profile`, not `~/.bashrc` ([Bash manual](https://www.gnu.org/software/bash/manual/html_node/Bash-Startup-Files.html)), so rbenv or asdf set up in `~/.bashrc` leaves the job without `bundle` or on a different Ruby. `env :PATH, ENV["PATH"]` writes the generating shell's `PATH` above the jobs ([job_list.rb](https://github.com/javan/whenever/blob/v1.1.4/lib/whenever/job_list.rb#L80-L116)). Keep secrets out of `env` lines, which store them in the crontab; [cron environment variables](https://cronradar.com/blog/cron-environment-variables) covers better ways.
- **Nothing changes until `--update-crontab` runs.** Capistrano 3's recipe runs it after `deploy:updated`, on `:db` servers by default ([whenever.rake](https://github.com/javan/whenever/blob/v1.1.4/lib/whenever/capistrano/v3/tasks/whenever.rake#L39-L55)); other deploys need it as a step. Without an identifier, the block is named after the absolute path of `schedule.rb` ([command_line.rb](https://github.com/javan/whenever/blob/v1.1.4/lib/whenever/command_line.rb#L52-L54)), so running it from a new release directory leaves the old block, still `cd`ing into the old release, beside the new one.
- **Containers usually have no cron.** The Dockerfile `rails new` generates installs curl, the database client, libvips, and jemalloc, but no cron daemon ([app_base.rb](https://github.com/rails/rails/blob/v8.1.4/railties/lib/rails/generators/app_base.rb#L599-L613)), and Kamal never runs whenever.
- **Times are in cron's zone.** `3:00 am` becomes `0 3`, and the cron daemon applies its own zone, normally the system's, not `config.time_zone`. On cronie (Fedora, RHEL, Arch), `env "CRON_TZ", "UTC"` sets the zone for the entries below it ([crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html)); Debian's and Ubuntu's cron "does not support per-user timezones" ([Debian crontab(5)](https://manpages.debian.org/trixie/cron/crontab.5.en.html)).
- **Missed runs stay missed.** After a reboot, cronie and Debian's cron match from the current minute on ([cronie cron.c](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/src/cron.c#L344-L346), [Debian cron.c](https://sources.debian.org/src/cron/3.0pl1-197/cron.c/#L148-L158)).

On Heroku, [Heroku Scheduler](https://cronradar.com/blog/heroku-scheduler) covers the add-on that runs a command in a one-off dyno, and when to use a clock process instead.

## GoodJob Cron

```ruby
# config/application.rb
config.good_job.enable_cron = true
config.good_job.cron_graceful_restart_period = 5.minutes
config.good_job.cron = {
  nightly_report: {
    cron: "0 3 * * * UTC",
    class: "NightlyReportJob",
    description: "Nightly CSV report",
  },
}
```

Cron runs in every GoodJob process with `enable_cron` set, the CLI or `:async` mode in the web server, and it defaults to `false` ([README](https://github.com/bensheldon/good_job/blob/v4.22.1/README.md#configuration-options)). A unique index on `cron_key` and `cron_at` admits one job per key and time across processes, as long as GoodJob keeps cron-created job records ([README](https://github.com/bensheldon/good_job/blob/v4.22.1/README.md#cron-style-repeatingrecurring-jobs)). On start, `cron_graceful_restart_period`, off by default, re-enqueues runs that fell due within that window. GoodJob applies no default zone, so a zoneless schedule follows Fugit's local zone.

## Why a Rails Recurring Job Doesn't Run

Check, in order, that the job was enqueued, that a worker took it, and that it succeeded.

### No Scheduler Is Running, or It Has No Tasks

The deploy doesn't start `bin/jobs`, `SOLID_QUEUE_IN_PUMA` isn't set, `SOLID_QUEUE_SKIP_RECURRING=true` was copied from staging, the tasks sit under another environment's key, or an invalid task stopped the supervisor at boot. On GoodJob, `enable_cron` is off everywhere. In the default fork mode, each Solid Queue process sets its title ([procline.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/solid_queue/processes/procline.rb#L7-L9)): the scheduler's lists its task keys, and each worker's lists its queues.

```bash
ps -eo args | grep '[s]olid-queue'
```

No `solid-queue-scheduler` line means nothing will be enqueued: Solid Queue starts a scheduler only when it finds tasks for the current environment and isn't told to skip them ([configuration.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/solid_queue/configuration.rb#L208-L216)).

### The Job Was Enqueued, but No Worker Took It

Each run the scheduler enqueues leaves a row with its `run_at`. Check a task's last few in `bin/rails console`:

```ruby
SolidQueue::RecurringExecution.where(task_key: "nightly_report")
  .order(run_at: :desc).limit(3)
  .map { |execution| [execution.run_at, execution.job&.status] }
```

No row for last night means the scheduler never enqueued it. `:ready` means the job is waiting for a worker: the task's `queue:`, or the job's `queue_as`, matches no worker's `queues:` in `config/queue.yml` (command tasks need `solid_queue_recurring`). `:failed` means the run raised an error nothing handled. A run that `retry_on` re-enqueued shows `:finished`, because each retry is a new job row with the same `active_job_id`. `nil` means the finished job was already cleared.

### The Job Ran and Raised

Solid Queue has no retries of its own ([README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#failed-jobs-and-retries)). `retry_on` re-enqueues the job, by default about 3 seconds later for up to 5 attempts, then re-raises ([exceptions.rb](https://github.com/rails/rails/blob/v8.1.4/activejob/lib/active_job/exceptions.rb#L64-L90)), and Solid Queue keeps the failed execution until someone retries or discards it. The next run starts on schedule regardless, so a job can fail nightly while its schedule looks fine.

For whenever, `crontab -l` as the jobs' user shows what is installed; check it against the pitfalls above.

## Alert on Missed and Failed Runs With HTTP Check-Ins

There is no CronRadar SDK for Ruby or Rails and no auto-discovery of `recurring.yml`, whenever, or GoodJob cron, so each job reports with its own HTTP check-in. In this [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) pattern the monitor expects a check-in on the job's schedule, so a run that never happens raises an alert. This concern adds check-ins to any Active Job class, whichever scheduler enqueues it:

```ruby
# app/jobs/concerns/cron_radar_monitored.rb
require "erb"
require "net/http"

# Reports a scheduled job's runs to CronRadar. Monitoring errors are logged, never raised.
module CronRadarMonitored
  extend ActiveSupport::Concern

  PING_URL = "https://cron.life/api/v1/ping"
  TIMEOUT = 5 # seconds, for each of connect, write, and read

  included do
    class_attribute :cronradar_monitor, instance_writer: false
    around_perform :report_run_to_cronradar

    # Runs only when Active Job gives up: retry_on is out of attempts,
    # discard_on matched, or nothing handled the error.
    after_discard do |_job, error|
      report_to_cronradar("fail", message: "Raised #{error.class.name}")
    end
  end

  class_methods do
    def monitor_with_cronradar(key, schedule:, grace_period: nil)
      self.cronradar_monitor = { key: key, schedule: schedule, grace_period: grace_period }.freeze
    end
  end

  private

  def report_run_to_cronradar
    report_to_cronradar("start")
    yield
    report_to_cronradar("complete")
  end

  def report_to_cronradar(event, message: nil)
    monitor = cronradar_monitor
    return unless monitor

    api_key = ENV["CRONRADAR_API_KEY"]
    if api_key.blank?
      logger&.warn("CRONRADAR_API_KEY is not set; skipped #{event} for #{monitor[:key]}")
      return
    end

    uri = URI("#{PING_URL}/#{ERB::Util.url_encode(monitor[:key])}/#{event}")
    uri.query = URI.encode_www_form({
      schedule: monitor[:schedule], # five-field cron, UTC
      gracePeriod: monitor[:grace_period],
      run_id: job_id, # unchanged when retry_on re-enqueues the job
      attempt: executions - 1, # executions is 1 on the first attempt
      message: message,
    }.compact)

    request = Net::HTTP::Post.new(uri)
    request.basic_auth(api_key, "")
    request.body = ""

    options = { use_ssl: true, open_timeout: TIMEOUT, write_timeout: TIMEOUT, read_timeout: TIMEOUT }
    response = Net::HTTP.start(uri.host, uri.port, **options) { |http| http.request(request) }
    return if response.is_a?(Net::HTTPSuccess)

    logger&.warn("CronRadar #{event} for #{monitor[:key]} rejected with HTTP #{response.code}")
  rescue StandardError => e
    logger&.warn("CronRadar #{event} for #{monitor[:key]} not sent: #{e.class}")
  end
end
```

Include it in each recurring job, with the schedule in five-field UTC:

```ruby
# app/jobs/nightly_report_job.rb
class NightlyReportJob < ApplicationJob
  include CronRadarMonitored
  # recurring.yml says "every day at 3am", read in UTC.
  monitor_with_cronradar "nightly-report", schedule: "0 3 * * *", grace_period: 900

  queue_as :reports
  retry_on Net::ReadTimeout, wait: 5.minutes, attempts: 3

  def perform
    NightlyReport.generate!
  end
end
```

How it behaves:

- **`start` and `complete` wrap `perform`.** `run_id` is the Active Job ID, which a `retry_on` retry keeps because it re-enqueues the same serialized job ([core.rb](https://github.com/rails/rails/blob/v8.1.4/activejob/lib/active_job/core.rb#L118-L167)). `perform_now` increments `executions` before callbacks run, so `executions - 1` sends 0 on the first attempt ([execution.rb](https://github.com/rails/rails/blob/v8.1.4/activejob/lib/active_job/execution.rb#L45-L58)).
- **`fail` waits for Active Job to give up.** `after_discard` (Rails 7.1+) runs when `retry_on` runs out of attempts, `discard_on` matches, or nothing handles the error ([exceptions.rb](https://github.com/rails/rails/blob/v8.1.4/activejob/lib/active_job/exceptions.rb#L64-L133)). An attempt that will be retried sends nothing; the retry sends `start` again with the same `run_id`. A run that recovers raises no alert; its `attempt` above 0 records that it needed help. A `fail` alerts immediately. To hear about every failed attempt instead, report `fail` from a `rescue` in the around callback and re-raise; [cron job retries](https://cronradar.com/blog/cron-job-retries) weighs the two.
- **The job's result doesn't change.** The concern never rescues the job's errors and logs its own. Connect, write, and read each give up after five seconds, and the calls are synchronous, so a slow network adds that time to the run.
- **The monitor heals itself.** Every check-in sends `schedule`, so the first run creates the monitor and a deleted one comes back. `schedule` and `gracePeriod` apply only when a check-in creates the monitor; change them later in the dashboard.
- **Check-ins arrive when a worker starts the job, not at enqueue.** A backed-up queue shows as a late run, so let the grace period cover normal queue wait. Missed runs are checked each minute: the monitor turns `warning` when the expected time passes and `critical`, with an incident, once it is past the grace period and at least two minutes late.
- **Every run of the class reports**, including one enqueued by hand, so include the concern only in scheduled jobs. A `command:` task runs as `SolidQueue::RecurringJob`; give a task you monitor its own job class.

A worker killed mid-job, say by the out-of-memory killer, runs no callbacks; Solid Queue marks the job failed ([README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#threads-processes-and-signals)), and CronRadar has a `start` with no finish. Every check-in moves the expected time to the job's next occurrence; if that time plus the grace period passes with a `start` as the last event, the monitor goes `critical` as stuck. The next run's `start` resets that window, so a killed or hung run is flagged only if the job then goes silent. Give the work its own deadlines, such as HTTP and database statement timeouts, so a hang becomes an error and then a `fail`.

Each run sends two requests, against a limit of 60 a minute and 1,000 an hour per API key; use one CronRadar application and key per environment, set as `CRONRADAR_API_KEY` from the deploy's secrets. A whenever entry that runs a rake task can use the shell wrapper from the dead man's switch post, and `Sidekiq::Job` classes the middleware in [Sidekiq monitoring](https://cronradar.com/blog/sidekiq-monitoring).

### Convert the Schedule to Five-Field UTC

CronRadar reads [standard five-field cron in UTC](https://docs.cronradar.com/schedules) without seconds, macros, `?`, `L`, `W`, `#`, or `7` for Sunday, and when both day fields are restricted it expects a run only on dates matching both. Ask Fugit what a phrase means, and Solid Queue when each task runs next, in UTC:

```bash
bin/rails runner 'puts Fugit.parse("every day at 3am").to_cron_s'
bin/rails runner 'SolidQueue::RecurringTask.find_each { |t| puts "#{t.key}: next #{t.next_time.iso8601}" }'
```

The first prints `0 3 * * *`. The second lists the tasks the last scheduler to boot saved; compare each with CronRadar's next expected run. On GoodJob, `GoodJob::CronEntry.all` and each entry's `next_at` do the same.

| Rails schedule                          | CronRadar `schedule`                                                               |
| --------------------------------------- | ---------------------------------------------------------------------------------- |
| `every day at 3am`, read in UTC         | `0 3 * * *`                                                                        |
| `every hour at minute 12`               | `12 * * * *`                                                                       |
| `every weekday at five pm`, read in UTC | `0 17 * * 1-5`                                                                     |
| `every day at 9am America/New_York`     | `0 13 * * *`, with a grace period of at least 3,600 seconds plus the run time      |
| `0 9 L * *`                             | Schedule `0 9 28-31 * *` in both; the job returns early unless tomorrow is the 1st |
| `every 15s` or `*/30 * * * * *`         | None: five fields can't express more than one run a minute                         |

09:00 in New York is 13:00 UTC in summer and 14:00 in winter, so in winter the monitor shows `warning` for the hour before each run. If `config.time_zone` isn't UTC, zoneless schedules are in that zone; convert them like the New York row.

## Frequently Asked Questions

### How do I schedule recurring jobs in Rails 8?

Add a task to `config/recurring.yml` under each environment that should run it, with a job class and a Fugit schedule such as `every day at 3am`. Solid Queue's scheduler enqueues it, so `bin/jobs`, or Puma with `SOLID_QUEUE_IN_PUMA` set, must be running.

### Is the whenever gem still needed with Solid Queue?

Not for work that runs as an Active Job: recurring tasks need no crontab, deploy step, or cron environment, and enqueue once across servers. whenever still fits a shell command, or work that must run while Solid Queue itself is down.

### Why isn't my Solid Queue recurring task running?

Usually no scheduler is running, or it found no tasks for the current environment. If `solid_queue_recurring_executions` has a row for the run, it was enqueued, so check that a worker listens on its queue. Runs due while the scheduler was down are skipped.

### Do recurring tasks run twice with multiple servers?

Solid Queue, GoodJob, and sidekiq-cron enqueue each occurrence once however many schedulers run; whenever runs the job on every server whose crontab has it. Enqueuing once doesn't stop a retry from repeating the work, so make jobs safe to run twice.

### How do I monitor Rails scheduled jobs?

Have each job check in with a monitor outside the app that knows its schedule; the schedulers record failures, not runs that never happen. CronRadar has no Ruby SDK, so an Active Job concern sends `start`, `complete`, and `fail` over HTTP with the job's five-field UTC schedule.
