Quick and dirty cron for Rails

Published: May 22, 2024
Want some code to run every few minutes but don't want to load up a scheduling library or background job processor?

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.

Tags: ruby, rails, puma