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.
Comments
No comments yet. Be the first.