ruby / clock

I run recurring jobs in a Clock process instead of cron. The Clock inserts jobs into the job queue system, which handles the actual work.

Why not cron

Cron syntax is terse to the point of inscrutability. Debugging failures requires digging through system logs. Your application code is separated from its schedule.

Clock process

Run a single Clock process:

# schedule/clock.rb
require_relative "../lib/db"
require_relative "../lib/jobs/insert"
require_relative "../lib/calendar"

module Schedule
  JOBS = [
    # Daily at midnight
    {
      queue: "discord",
      name: "Discord::Ingest",
      at?: proc { |t| t.min == 30 && t.hour == 0 }
    },
    # Daily cleanup at 8:30am
    {
      queue: "no_throttle",
      name: "Queues::CleanUp",
      at?: proc { |t| t.min == 30 && t.hour == 8 }
    },
    # Weekly on Tuesdays at 3am
    {
      queue: "github",
      name: "Github::IngestStars",
      at?: proc { |t| t.min == 0 && t.hour == 3 && t.tuesday? }
    },
    # Daily at noon, skip holidays
    {
      queue: "mail",
      name: "Mail::Reminders",
      at?: proc { |t| 
        t.min == 0 && t.hour == 12 && 
        !Calendar.holiday?(t)
      }
    }
  ]

  class Clock
    attr_reader :db

    def initialize(db)
      @db = db
    end

    def tick(seconds:)
      i = Jobs::Insert.new(db)

      loop do
        Schedule::JOBS.each do |job|
          if job.fetch(:at?).call(Time.now.utc)
            puts "insert #{job.fetch(:name)}"
            i.call(
              queue: job.fetch(:queue),
              name: job.fetch(:name)
            )
          end
        end

        sleep(seconds)
      end
    end
  end
end

# Validate job definitions
Schedule::JOBS.each do |job|
  if !job[:at?] || !job[:name] || !job[:queue]
    raise "job missing required keys: #{job.inspect}"
  end
end

if $0 == __FILE__
  $stdout.sync = true
  puts "clock running with #{Schedule::JOBS.size} jobs"
  Schedule::Clock.new(DB.new).tick(seconds: 60)
end

Schedule definitions

Each job is a hash with:

The proc receives a Time object in UTC. Use Ruby's time methods for readable schedules:

# Sub-hourly
proc { |t| t.min % 15 == 0 }          # every 15 minutes
proc { |t| t.min == 30 }               # once per hour at :30

# Daily
proc { |t| t.min == 0 && t.hour == 3 } # daily at 3am

# Weekly
proc { |t| t.min == 0 && t.hour == 3 && t.tuesday? }

# Monthly
proc { |t| t.min == 0 && t.hour == 3 && t.day == 1 }   # first of month
proc { |t| t.to_date == Date.new(t.year, t.month, -1) } # last of month

# Conditional
proc { |t| t.min == 0 && t.hour == 12 && !Calendar.holiday?(t) }

Integration

This provides:

The Clock runs as a single process. If it crashes, your process supervisor restarts it. Jobs that should have run during downtime will run on the next matching interval.

Benefits

← All articles