LaunchKit
← All posts
· 14 min read · by The LaunchKit team · 0 views

Rails cron without a crontab

A Rails cron job used to mean a crontab line that shelled out to bin/rails runner, or the whenever gem writing that crontab for you. Solid Queue moved the schedule into the application: a config file, a row in Postgres, and a thread holding a timer. The mechanism is small enough to read in an afternoon, which is worth doing, because four of its behaviours are surprising and two of them are silent.

Everything below was run against solid_queue 1.7.0 (released 2026-08-21), fugit 1.13.0 and rails 8.1.3.1, with a local PostgreSQL 17. The gem source quoted is from ~/.rvm/gems/ruby-4.0.5/gems/solid_queue-1.7.0/.

What actually lands in config/recurring.yml

The path is a constant, SolidQueue::Configuration::DEFAULT_RECURRING_SCHEDULE_FILE_PATH, and it is overridable by the SOLID_QUEUE_RECURRING_SCHEDULE environment variable or bin/jobs --recurring_schedule_file=. This site's file has four tasks:

production:
  clear_solid_queue_finished_jobs:
    command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
    schedule: every hour at minute 12

  deliver_email_sequences:
    class: EmailSequences::DeliverDueJob
    schedule: every hour

  deliver_lead_sequence:
    class: EmailSequences::DeliverLeadSequenceJob
    schedule: every hour at minute 25

  prune_analytics:
    class: PruneAnalyticsJob
    schedule: every day at 4am

The LaunchKit boilerplate ships two of those, clear_solid_queue_finished_jobs and deliver_email_sequences, and nothing else; the retention job and the lead sequence are this site's additions.

Seven keys are read, and no others. RecurringTask.from_configuration (app/models/solid_queue/recurring_task.rb:26) maps class, command, args, schedule, queue, priority and description onto columns, plus a static flag that separates file-declared tasks from ones added at runtime through SolidQueue.schedule_recurring_task. A key you invent is dropped without comment.

The environment key matters more than it looks. Configuration#config_from does config = config[env.to_sym] ? config[env.to_sym] : config, so a file with only a production: block, run in development, hands the whole outer hash to the task builder, where the single entry production has no :schedule key and is compacted away. Booting SolidQueue::Configuration.new in development on this repository:

Rails.env=development
recurring_tasks found: []
processes: [:dispatcher, :worker]
valid? true

No scheduler process, no tasks, and a configuration that reports itself valid. That is the correct behaviour and it is also the first thing to check when a local recurring task does nothing.

Fugit parses the schedule, and cron is only one dialect

parsed_schedule is one line, Fugit.parse(schedule, multi: :fail) at recurring_task.rb:179, and the validation immediately after it insists the result is a Fugit::Cron and not a duration or a point in time. Everything a Fugit::Cron can express is therefore legal. Running the parser directly over the strings that come up:

"every hour at minute 12"      => Fugit::Cron  12 * * * *
"at 5am every day"             => Fugit::Cron  0 5 * * *
"*/15 * * * *"                 => Fugit::Cron  0,15,30,45 * * * *
"every monday at 9am"          => Fugit::Cron  0 9 * * 1
"0 3 * * 1-5 America/New_York" => Fugit::Cron  0 3 * * 1,2,3,4,5 America/New_York
"every 30 seconds"             => Fugit::Cron  0,30 * * * * *
"every second"                 => Fugit::Cron  * * * * * *

The last two are the interesting ones. Fugit supports a six-field cron with a seconds column, and the scheduler runs on an in-memory timer rather than a per-minute wakeup, so every 30 seconds is a real schedule. A crontab cannot express it at all; the standard five-field format has no field below the minute, which is why the usual workaround is a crontab line that runs a shell loop with sleep 30 in it.

Time zones are resolved in apply_default_time_zone_to (recurring_task.rb:182). A schedule with no zone gets one appended from SolidQueue.time_zone, which the engine initialiser sets from app.config.time_zone unless you set it yourself (lib/solid_queue/engine.rb:19). On this application that resolves to "Etc/UTC", so every day at 4am means 04:00 UTC, and it keeps meaning 04:00 UTC in July. Writing the zone into the schedule string moves it: every day at 4am America/New_York parses to the cron 0 4 * * * America/New_York, and asked on 2026-09-24 it answered 2026-09-25T08:00:00Z for its next run, which is the daylight saving offset and will become 09:00:00Z in November without the file changing.

Four intervals that parse clean and run wrong

Cron expresses "at these minutes past the hour". It does not express "every N minutes". When N divides 60 those are the same sentence, and when it does not, Fugit still returns a cron rather than an error:

"every 45 minutes" => 0,45 * * * *
"every 90 minutes" => 0 * * * *
"every 7 minutes"  => 0,7,14,21,28,35,42,49,56 * * * *
"every 3 days"     => 0 0 1,4,7,10,13,16,19,22,25,28,31 * *

Read those carefully. every 45 minutes fires at :00 and :45, which is a 45 minute gap followed by a 15 minute gap. every 90 minutes is not approximated, it is discarded: the schedule is plain hourly, twice as often as asked. every 7 minutes holds up for 56 minutes and then has a 4 minute gap at the hour boundary. every 3 days runs on the 31st and again on the 1st, and skips from the 28th to the 1st in February.

None of these raise, none fail validation, and all four persist happily into solid_queue_recurring_tasks. The task looks configured, the dashboard shows it, and the interval is not the one in the file. This is the one part of the recurring jobs design that reads as a bug rather than a tradeoff, and the fix is available to you today: write the cron string. 12 * * * * cannot lie about what it does, and every hour at minute 12 and 0,45 * * * * are the same length to type.

The rule that falls out of this is that natural language is fine for schedules that fit cron's own shape, which means daily, hourly, weekly and "at minute N". Anything phrased as an interval gets written as cron, or gets a job that reschedules itself.

An invalid schedule stops the supervisor, and the supervisor can stop Puma

Three validations run on each task: the schedule has to parse to a cron, a class or a command has to be present, and a named class has to exist. The messages are worth knowing by sight:

typo    Schedule is not a supported recurring schedule
multi   Schedule generates multiple cron schedules. Please use separate recurring tasks for each
        schedule, or use explicit cron syntax (e.g., '40 0,15 * * *' for multiple times with the
        same minutes)
nojob   Class name doesn't correspond to an existing class
empty   either command or class must be present

The multi-cron one is narrower than it sounds. every day at 5am and 6pm parses fine, as 0 5,18 * * *, because both times share a minute. every day at 5am and 6:30pm raises ArgumentError: multiple crons, which the validation catches and rewrites into the message above.

What happens next is the part to plan for. SolidQueue::Supervisor.start checks the configuration and, on failure, calls abort (lib/solid_queue/supervisor.rb:22). A typo in one schedule string takes down every worker and dispatcher, not just the scheduler.

If jobs run inside the web process, it takes down more than that. This site sets SOLID_QUEUE_IN_PUMA: true, and config/puma.rb has plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]. That plugin forks the supervisor and starts a watchdog thread in the Puma process (lib/puma/plugin/solid_queue.rb:26):

def monitor_solid_queue
  monitor(:solid_queue_fork_dead?, "Detected Solid Queue has gone away, stopping Puma...")
end

def monitor(process_dead, message)
  loop do
    if send(process_dead)
      log message
      Process.kill(:INT, $$)
      break
    end
    sleep 2
  end
end

So the chain is: a misspelled weekday, an invalid RecurringTask, an invalid Configuration, abort, a dead fork, and within two seconds Puma signals itself to stop. The web server goes down because of a cron schedule. bin/jobs check exists precisely so this can be caught in CI instead, and it exits non-zero with the same messages.

What a deploy does to the schedule table

Static tasks are reconciled on every scheduler boot, in two statements (lib/solid_queue/scheduler/recurring_schedule.rb:85):

def persist_static_tasks
  RecurringTask.static.where.not(key: static_task_keys).delete_all
  RecurringTask.create_or_update_all static_tasks
end

Running that twice against a real database, with the second pass simulating an edited file:

deploy 1: [["prune_analytics", "every day at 4am"], ["old_task", "every hour"]]
deploy 2: delete_all removed 1 row(s)
deploy 2: [["prune_analytics", "every day at 6am"]]
prune_analytics id stable across upsert? true

The key is the identity. Change a schedule and the row is updated in place, because attributes_for_upsert drops id, created_at and updated_at and the upsert is unique_by: :key. Rename a task and the old row is deleted and a new one inserted, which matters if anything of yours joins on that id. Dynamic tasks, the ones created through SolidQueue.schedule_recurring_task, are scoped out of the delete_all by static: true and survive deploys untouched.

The missed window is the other half. Each task is scheduled with Concurrent::ScheduledTask for a delay of [(run_at - Time.current).to_f, 0.1].max, and at boot run_at is task.next_time, which is strictly in the future. Asking every day at 4am for its next run from a boot at 2026-09-24T04:05:00Z answered 2026-09-25T04:00:00Z, and so did a boot at exactly 2026-09-24T04:00:00Z, because Fugit::Cron#next_time will not return the instant it is given.

Deploy at 04:05 and the 04:00 daily job does not run. It is not queued late, it is not logged as skipped, it simply does not exist. A crontab behaves the same way, so this is not a regression, but a crontab is owned by the host and restarts roughly never, while the scheduler restarts on every deploy. If a deploy at 04:05 is normal for you, a daily job at 04:00 is the wrong schedule.

Once a task has fired, the chain re-anchors on the scheduled time rather than the wake time: schedule_task(thread_task, run_at: thread_task.next_time_after(thread_task_run_at)). Drift from a slow enqueue does not accumulate.

Two schedulers, one run, and the row that guards it

Redundancy is the case the design is built for, and this is where the database earns its place over a crontab. The dedup lives in one insert. Enqueuing happens inside RecurringExecution.record, which writes a row in the same transaction as the job, against a unique index on (task_key, run_at):

scheduler A inserted, rows=1
scheduler B raised SolidQueue::RecurringExecution::AlreadyRecorded
rows after B: 1
run_at precision stored: 2026-09-25T04:00:00.000000Z

For the trade against Sidekiq's approach to this, and the rest of the two-backend comparison, Solid Queue vs Sidekiq has the axes laid out; the point here is the lifetime of that guard row, which the comparison does not chase.

solid_queue_recurring_executions has add_foreign_key ... on_delete: :cascade to solid_queue_jobs. The guard is therefore only as durable as the job row it points at, and SolidQueue.clear_finished_jobs_after defaults to 1.day. Clearing finished jobs and then re-inserting the same pair:

before clear: jobs=1 recurring_executions=1
preserve_finished_jobs=true clear_finished_jobs_after=1 day
after clear:  jobs=0 recurring_executions=0
same (task_key, run_at) re-inserted after the cascade: rows=1

The README states the condition plainly: the guarantee "only works if you have preserve_finished_jobs set to true (the default), and the guarantee applies as long as you keep the jobs around". The sharp edge is what happens when it is not the default. With preserve_finished_jobs = false, Job#finished! calls destroy! instead of stamping finished_at, the cascade fires the moment the job completes, and a second scheduler that is seconds behind the first is free to enqueue the same run again. Turning off job retention turns off recurring task deduplication, and nothing in either setting mentions the other.

In practice the collision window is the width of one insert, so a one-day retention is generous cover for schedules of any frequency. The failure mode needs a scheduler whose clock disagrees, or retention turned off entirely.

The command form is an eval on its own queue

A task with command: instead of class: has no job class of its own. It runs on SolidQueue::RecurringJob, which is the whole of it:

class SolidQueue::RecurringJob < ActiveJob::Base
  queue_as :solid_queue_recurring

  def perform(command)
    eval(command)
  end
end

Two things follow. The string in your YAML is evaluated in the job's binding on the worker, which is fine for SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3) and is a reason to treat config/recurring.yml with the same care as config/initializers. And the job lands on the solid_queue_recurring queue, not on default and not on whatever queue your other jobs use.

This site's config/queue.yml declares queues: "*", so the worker takes it. An application that lists its queues explicitly, which is the usual move once queue order starts mattering, will enqueue these jobs perfectly and never run them, because no worker is watching that queue name. Adding queue: to the task is the fix, and SolidQueue::RecurringTask.default_job_class is the other one if you would rather not have an eval in the path at all.

A schedule that parses, persists and still never runs

Every failure above announces itself somehow. The common one does not.

On 2026-09-14 this application had three ActionMailer::MailDeliveryJob rows sitting untouched, the oldest from 2026-08-25. SOLID_QUEUE_IN_PUMA was never set on the host and the Procfile's worker line was commented out, so plugin :solid_queue never loaded and no Solid Queue process ever existed. SolidQueue::FailedExecution.count was 0 the entire time, because nothing was ever picked up, so nothing could fail. A support ticket and two comment confirmations were lost for three weeks and the only outward sign was silence.

A recurring task fails the same way and worse, because it has no user waiting on it. The schedule parses, the row is in solid_queue_recurring_tasks, the configuration validates, and the job is simply never enqueued, so there is not even a pending row to count.

The check that this repository now runs cannot be a recurring job, for the obvious reason. It hangs off enqueue.active_job instead, at most once every five minutes, and asks whether any process has reported a heartbeat inside SolidQueue.process_alive_threshold, which defaults to 5.minutes:

def alive?
  SolidQueue::Process.where(last_heartbeat_at: SolidQueue.process_alive_threshold.ago..).exists?
end

Freshness rather than existence, because a container that died hard leaves its solid_queue_processes row behind until the supervisor prunes it. If you take one operational habit from this page, take that query.

Where a crontab is still the better answer

The whenever gem is not abandoned. Version 1.1.3 shipped on 2026-09-15, against 1.1.2 in January 2026, on 82 million total downloads. The rails crontab approach it wraps has three properties Solid Queue cannot match. It runs when the application cannot boot, which is exactly when you want the backup script to run. It survives a deploy, because crond is not your process. And it is visible to anybody with SSH and no Rails knowledge.

What it costs you is everything this page has been about. There is no (task_key, run_at) index in a crontab, so two machines with the same file run the job twice. There is no per-minute floor to argue with, but there is no seconds field either. And the schedule lives on the host, not in the repository, so it is reviewed by nobody.

sidekiq-cron is at 2.4.0, released 2026-05-06, and remains the right answer for an application already on Sidekiq that does not want the Enterprise tier. It is also a straight substitute for the piece described here rather than for the whole backend.

The position, and what would flip it

For an application that already runs Solid Queue, config/recurring.yml is the correct place for a rails cron job, and the reason is not convenience. It is that the schedule is reviewed in a pull request, deployed atomically with the code it calls, and deduplicated by an index rather than by a convention about which host is the special one.

The reservation is narrow and specific: write cron strings, not intervals. every 90 minutes silently becoming hourly is the kind of defect that survives for months, because the job does run, and running too often looks like working.

What would change the recommendation is a schedule that has to survive the application being broken. Database backups, certificate renewal and the script that pages you when the app is down do not belong in a queue that needs the app to boot. Those stay in a crontab, and they are close to the only things that should.

#rails #jobs

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.