Quick and dirty cron for Rails
Published: May 22, 2024
I maintain a personal RSS reader called mallard that I run on my VPS. It's basic, to the point, and gets the job done. One goal I have is to have as few outside dependencies as possible and maintain a relatively small memory footprint.
When it came to pulling in new feed entries on a schedule, I originally reached for the whenever gem to run a rake task via cron. This option works well, but I wondered if there was a better way. My goal is to run some Ruby code every 20 minutes. It doesn't have to be on the 20th of the hour exactly and could be a simple as every 20 minutes relative to when the Rails server booted.
My solution was to replace whenever
with a simple puma plugin that took a little over 20 lines of code.
# lib/puma/plugin/refresh_feed.rb require "puma/plugin" Puma::Plugin.create do attr_reader :log_writer def start(launcher) @log_writer = launcher.log_writer in_background do loop do sleep 20 * 60 log "Enqueuing feed refresh" RefreshFeedsJob.perform_later end end end private def log(...) log_writer.log(...) end end
For debugging purposes, I'm setting an instance variable @log_writer
to the logger coming from the puma launcher object. The "real work" happens in in_background
which runs with the puma process while everything else in your app is being executed.
In that block, I have an infinite loop that will stop when the puma process terminates. In that, I have very simple sleep
call to have that background process hang tight for about 20 minutes. After that, RefreshFeedsJob
which inherits from ActiveJob
is enqueued.
At this point, one might ask "what background processor are you using?" and "I thought you wanted to limit the number of dependencies in the app!" You are correct. ActiveJob comes with a default async
adaptor which uses the app's existing thread pool. Since this app is used by one person, this is an ok solution. ActiveJob defaults to the async adaptor, so no other configuration is required for the job.
Since Zeitwerk
now defaults to autoloading the lib
directory, we want to have it ignore the plugin file.
# config/application.rb require "boot" # ... module Mallard class Application < Rails::Application # Initialize configuration defaults for originally generated Rails version. config.load_defaults 7.1 # Please, add to the `ignore` list any other `lib` subdirectories that do # not contain `.rb` files, or that should not be reloaded or eager loaded. # Common ones are `templates`, `generators`, or `middleware`, for example. config.autoload_lib(ignore: ["assets", "tasks", "puma"]) # ... end end
Finally, we can require and enable the plugin in the puma config...
# config/puma.rb require_relative "../lib/puma/plugin/refresh_feeds" plugin :refresh_feeds # ...
Does this solution work for me and the use case for this specific app? Yes. Should you blindly copy/paste this code into your production app probably used by multiple concurrent users? No. At minimum, you probably want to use a real ActiveJob backend. That said, for a single-user and light-weight Rails app, this solution has been working out great so far. YMMV.