Cron expression parser and scheduler
gem install philiprehberger-cron_kitCron expression parser and scheduler
Add to your Gemfile:
gem "philiprehberger-cron_kit"
Or install directly:
gem install philiprehberger-cron_kit
require "philiprehberger/cron_kit"
expr = Philiprehberger::CronKit.parse("*/5 * * * *")
expr.match?(Time.new(2026, 3, 10, 12, 15)) # => true
expr.match?(Time.new(2026, 3, 10, 12, 13)) # => false
expr.next_at(from: Time.new(2026, 3, 10, 12, 13))
# => 2026-03-10 12:15:00
expr.to_s # => "*/5 * * * *"
Check whether any Time in an enumerable satisfies the expression. Short-circuits on the first match:
expr = Philiprehberger::CronKit.parse("0 9 * * *")
times = [
Time.new(2026, 3, 10, 8, 30),
Time.new(2026, 3, 10, 9, 0),
Time.new(2026, 3, 10, 10, 0)
]
expr.matches_any?(times) # => true
expr.matches_any?([]) # => false
Evaluate cron expressions in a specific timezone. Uses only stdlib — no external gems required.
# Fixed UTC offset
expr = Philiprehberger::CronKit.parse("0 9 * * *", timezone: "+05:30")
# POSIX timezone name (resolved via ENV["TZ"])
expr = Philiprehberger::CronKit.parse("0 9 * * *", timezone: "US/Eastern")
# UTC shorthand
expr = Philiprehberger::CronKit.parse("0 9 * * *", timezone: "UTC")
expr.match?(some_time) # evaluated in the configured timezone
expr.next_at(from: Time.now) # next match in that timezone
Get the next N upcoming execution times from a given start:
expr = Philiprehberger::CronKit.parse("0 * * * *")
expr.next_runs(count: 5, from: Time.now)
# => [2026-03-17 14:00, 2026-03-17 15:00, 2026-03-17 16:00, ...]
Find the most recent past match:
expr = Philiprehberger::CronKit.parse("0 * * * *")
expr.previous_run(from: Time.now)
# => 2026-03-17 13:00:00
Use convenient shorthand aliases instead of full cron expressions:
Philiprehberger::CronKit.parse("@hourly") # => "0 * * * *"
Philiprehberger::CronKit.parse("@daily") # => "0 0 * * *"
Philiprehberger::CronKit.parse("@weekly") # => "0 0 * * 0"
Philiprehberger::CronKit.parse("@monthly") # => "0 0 1 * *"
Philiprehberger::CronKit.parse("@yearly") # => "0 0 1 1 *"
Philiprehberger::CronKit.parse("@annually") # => "0 0 1 1 *"
scheduler = Philiprehberger::CronKit.new
scheduler.every("0 9 * * 1-5") do |time|
puts "Good morning! It's #{time}"
end
scheduler.every("*/10 * * * *") do
puts "Running every 10 minutes"
end
scheduler.start # runs in a background thread
scheduler.running? # => true
scheduler.stop
Skip a job's scheduled tick if the previous run is still active:
scheduler = Philiprehberger::CronKit.new
scheduler.every("*/5 * * * *", overlap: false) do
slow_work # skipped if still running from previous tick
end
By default, overlap is allowed (overlap: true).
Kill jobs that exceed a time limit (in seconds). Timed-out jobs receive a Timeout::Error first, giving ensure blocks a chance to run before the thread is hard-killed.
scheduler = Philiprehberger::CronKit.new
scheduler.every("*/5 * * * *", timeout: 30) do
perform_work # killed if it takes longer than 30 seconds
end
Register a callback to handle job failures:
scheduler = Philiprehberger::CronKit.new
scheduler.on_error do |job, error|
puts "Job #{job.name} failed: #{error.message}"
end
scheduler.every("* * * * *", name: "risky") do
might_fail
end
Check how many jobs are currently executing:
scheduler.running_jobs # => 2
scheduler = Philiprehberger::CronKit.new
scheduler.every("0 9 * * 1-5", name: "morning-report") do
generate_report
end
scheduler.job_names # => ["morning-report"]
scheduler.job?("morning-report") # => true
scheduler.job?("does-not-exist") # => false
scheduler.remove("morning-report")
Trigger a registered job by name without restarting the scheduler — useful for testing or operator-driven re-runs.
scheduler.every("0 9 * * 1-5", name: :morning_report, timeout: 30) do
generate_report
end
scheduler.run_now(:morning_report) # => return value of the block
scheduler.run_now(:missing) # raises KeyError
run_now honors timeout: (raises Timeout::Error) and respects overlap: false
(returns nil when the job is already running).
scheduler.next_runs(from: Time.now)
# => { "morning-report" => 2026-03-13 09:00:00 ... }
| Token | Example | Description |
|---|---|---|
* | * * * * * | Every possible value |
| Value | 5 * * * * | Specific value |
| Range | 1-5 | Values from 1 through 5 |
| Step | */5 | Every 5th value |
| List | 1,3,5 | Values 1, 3, and 5 |
| Alias | @daily | Non-standard shorthand |
| Position | Field | Range |
|---|---|---|
| 1 | Minute | 0-59 |
| 2 | Hour | 0-23 |
| 3 | Day of month | 1-31 |
| 4 | Month | 1-12 |
| 5 | Day of week | 0-6 |
| Method | Description |
|---|---|
Philiprehberger::CronKit.parse(expression, timezone: nil) | Parse a cron expression, returns Expression |
Philiprehberger::CronKit.valid?(expression, timezone: nil) | Return true if the expression parses without error |
Philiprehberger::CronKit.new | Create a new Scheduler |
Expression#match?(time) | Check if a Time matches the expression |
Expression#matches_any?(times) | Check if any Time in the enumerable matches the expression |
Expression#next_at(from:) | Find the next matching Time |
Expression#next_runs(count: 5, from:) | Return the next N matching times |
Expression#previous_run(from:) | Find the most recent past match |
Expression#to_s | Return the original expression string |
Expression#timezone | Return the configured timezone (or nil) |
Scheduler#every(expression, name: nil, timeout: nil, overlap: true, &block) | Register a cron job |
Scheduler#on_error(&block) | Register a callback for job failures |
Scheduler#job_names | List registered job names |
Scheduler#job?(name) | Returns true if a job with the given name is registered |
Scheduler#remove(name) | Remove a job by name |
Scheduler#run_now(name) | Manually trigger a registered job; returns the block's value, raises KeyError for unknown names or Timeout::Error on timeout |
Scheduler#next_runs(from:) | Hash of job names to their next scheduled time |
Scheduler#running_jobs | Count of currently executing job threads |
Scheduler#start | Start the scheduler in a background thread |
Scheduler#stop | Stop the scheduler |
Scheduler#running? | Check if the scheduler is running |
bundle install
bundle exec rspec
bundle exec rubocop
If you find this project useful: