Kaal
Distributed cron scheduling for plain Ruby.
kaal is the core engine gem. It owns scheduler/runtime behavior, the registry APIs, the plain Ruby CLI, delayed-job dispatch, and the optional SQL backend surfaces.
Installation
Use kaal by itself when you want the engine plus non-SQL coordination backends.
gem 'kaal'
Then install and initialize:
bundle install
bundle exec kaal init --backend=memory
kaal init creates:
config/kaal.rbconfig/scheduler.yml
Supported backends:
memoryredis
If you want SQL persistence instead, add the runtime libraries your app uses, such as sequel, activerecord, sqlite3, pg, or mysql2, then configure one of the explicit Kaal::Backend::* SQL backends.
Configuration
Generated config/kaal.rb is the primary entrypoint:
require 'kaal'
Kaal.configure do |config|
config.backend = Kaal::Backend::MemoryAdapter.new
config.tick_interval = 5
config.window_lookback = 120
config.lease_ttl = 125
config.scheduler_config_path = 'config/scheduler.yml'
end
Redis path:
require 'redis'
redis = Redis.new(url: ENV.fetch('REDIS_URL'))
Kaal.configure do |config|
config.backend = Kaal::Backend::RedisAdapter.new(redis)
config.scheduler_config_path = 'config/scheduler.yml'
end
Time zone behavior is explicit:
- use
config.time_zone = 'America/Toronto'when needed - otherwise scheduling runs in
UTC
Scheduler File
Default scheduler definitions live at config/scheduler.yml:
defaults:
jobs:
- key: "example:heartbeat"
cron: "*/5 * * * *"
job_class: "ExampleHeartbeatJob"
enabled: true
args:
- "{{fire_time.iso8601}}"
kwargs:
idempotency_key: "{{idempotency_key}}"
job_class must resolve to a Ruby constant that responds to one of:
.perform(*args, **kwargs).perform_later(*args, **kwargs).set(queue: ...).perform_later(*args, **kwargs)
For plain Ruby .perform jobs, Kaal treats the dispatch as successful unless the job raises an exception. Return values are ignored.
CLI
bundle exec kaal init --backend=memory
bundle exec kaal start
bundle exec kaal status
bundle exec kaal tick
bundle exec kaal explain "*/15 * * * *"
bundle exec kaal next "0 9 * * 1" --count 3
E2E Verification
bin/rspec-e2e memory
REDIS_URL=redis://127.0.0.1:6379/0 bin/rspec-e2e redis
Runtime API
Recurring jobs:
Kaal.register(
key: 'reports:daily',
cron: '0 9 * * *',
enqueue: ->(fire_time:, idempotency_key:) {
ReportsJob.perform(fire_time: fire_time, idempotency_key: idempotency_key)
}
)
Kaal.start!
Delayed jobs:
Kaal.enqueue_at(
at: Time.now.utc + 300,
job_class: "InvoiceReminderJob",
args: [123],
queue: "mailers",
job_id: "invoice-reminder:123"
)
Rules shared by the runtime surface:
- delayed jobs use
job_idas their identity and require it to be unique while pending - delayed-job
argsare positional only - recurring and delayed jobs share the same job-class dispatch rules
- string job classes are constantized and class or module values are used directly
To restrict delayed-job class names:
Kaal.configure do |config|
config.delayed_job_allowed_class_prefixes = ["Reports::", "Billing::"]
end
An empty delayed_job_allowed_class_prefixes list leaves delayed-job class resolution unrestricted. That is reasonable for local or trusted deployments. On shared Redis or SQL backends in production, set a restrictive prefix list.
SQL Backends
Use the explicit SQL backends when you want persisted registries:
Kaal::Backend::SQLiteKaal::Backend::PostgresKaal::Backend::MySQLkaal-railsfor Rails-native install and auto-wiring
For SQL-backed deployments, run the generated migrations so kaal_delayed_jobs exists alongside the recurring scheduler tables.
Postgres and supported MySQL versions claim due delayed jobs with SKIP LOCKED. Older SQL paths still preserve correctness with delete confirmation, and Kaal adds a small pre-claim jitter there to reduce multi-node contention.