Usage
For the exact multi-node claim, assumptions, and evidence, see At-Most-Once Dispatch Guarantee.
Register recurring jobs
Define jobs in config/scheduler.yml:
defaults:
jobs:
- key: "reports:weekly_summary"
cron: "0 9 * * 1"
job_class: "WeeklySummaryJob"
enabled: true
kwargs:
idempotency_key: ""
Kaal loads the scheduler file at boot and dispatches the configured work on each eligible tick.
For Redis, Postgres, and MySQL-backed deployments, the same (key, fire_time) yields the same deterministic idempotency_key. Use that key at the job boundary when downstream systems also need dedupe.
Configure the backend
The registration model stays the same across adapters. What changes is the configured backend.
Plain Ruby with memory
require "kaal"
Kaal.configure do |config|
config.backend = Kaal::Backend::MemoryAdapter.new
config.scheduler_config_path = "config/scheduler.yml"
end
Plain Ruby with Redis
require "kaal"
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
Plain Ruby with Sequel-backed SQL
require "kaal"
require "kaal/sequel"
require "sequel"
database = Sequel.connect(adapter: "sqlite", database: "db/kaal.sqlite3")
Kaal.configure do |config|
config.backend = Kaal::Backend::DatabaseAdapter.new(database)
config.scheduler_config_path = "config/scheduler.yml"
end
For PostgreSQL or MySQL, replace the backend line inside Kaal.configure with one of:
Kaal.configure do |config|
config.backend = Kaal::Backend::PostgresAdapter.new(database)
config.scheduler_config_path = "config/scheduler.yml"
end
Kaal.configure do |config|
config.backend = Kaal::Backend::MySQLAdapter.new(database)
config.scheduler_config_path = "config/scheduler.yml"
end
Plain Ruby with Active Record-backed SQL
require "kaal"
require "kaal/active_record"
Kaal::ActiveRecord::ConnectionSupport.configure!(
adapter: "sqlite3",
database: "db/kaal.sqlite3"
)
Kaal.configure do |config|
config.backend = Kaal::ActiveRecord::DatabaseAdapter.new
config.scheduler_config_path = "config/scheduler.yml"
end
For PostgreSQL or MySQL, replace the backend line inside Kaal.configure with one of:
Kaal.configure do |config|
config.backend = Kaal::ActiveRecord::PostgresAdapter.new
config.scheduler_config_path = "config/scheduler.yml"
end
Kaal.configure do |config|
config.backend = Kaal::ActiveRecord::MySQLAdapter.new
config.scheduler_config_path = "config/scheduler.yml"
end
Rails
gem "kaal-rails"
bundle exec rails generate kaal:install --backend=sqlite
bundle exec rails db:migrate
Rails auto-selects the Active Record-backed adapter from the configured database unless you override Kaal.configuration.backend yourself.
Sinatra
Memory:
require "sinatra/base"
require "kaal/sinatra"
class App < Sinatra::Base
register Kaal::Sinatra::Extension
kaal backend: Kaal::Backend::MemoryAdapter.new,
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
end
Redis:
require "sinatra/base"
require "redis"
require "kaal/sinatra"
class App < Sinatra::Base
REDIS = Redis.new(url: ENV.fetch("REDIS_URL"))
register Kaal::Sinatra::Extension
kaal redis: REDIS,
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
end
SQL:
require "sinatra/base"
require "sequel"
require "kaal/sinatra"
class App < Sinatra::Base
database = Sequel.connect(ENV.fetch("DATABASE_URL"))
register Kaal::Sinatra::Extension
kaal database: database,
adapter: "postgres",
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
end
Roda
Memory:
require "roda"
require "kaal/roda"
class App < Roda
plugin :kaal
kaal backend: Kaal::Backend::MemoryAdapter.new,
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
end
Redis:
require "roda"
require "redis"
require "kaal/roda"
class App < Roda
REDIS = Redis.new(url: ENV.fetch("REDIS_URL"))
plugin :kaal
kaal redis: REDIS,
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
end
SQL:
require "roda"
require "sequel"
require "kaal/roda"
database = Sequel.connect(ENV.fetch("DATABASE_URL"))
class App < Roda
plugin :kaal
kaal database: database,
adapter: "postgres",
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
end
Hanami
Memory:
require "hanami"
require "kaal/hanami"
module MyApp
class App < Hanami::App
Kaal::Hanami.configure!(
self,
backend: Kaal::Backend::MemoryAdapter.new,
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
)
end
end
Redis:
require "hanami"
require "redis"
require "kaal/hanami"
module MyApp
class App < Hanami::App
REDIS = Redis.new(url: ENV.fetch("REDIS_URL"))
Kaal::Hanami.configure!(
self,
redis: REDIS,
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
)
end
end
SQL:
require "hanami"
require "sequel"
require "kaal/hanami"
database = Sequel.connect(ENV.fetch("DATABASE_URL"))
module MyApp
class App < Hanami::App
Kaal::Hanami.configure!(
self,
database: database,
adapter: "postgres",
scheduler_config_path: "config/scheduler.yml",
namespace: "my-app",
start_scheduler: false
)
end
end
CLI
Available plain-Ruby CLI commands:
bundle exec kaal init --backend=memory
bundle exec kaal init --backend=redis
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
kaal init does not provision SQL adapter setups. For SQL-backed installs, configure the adapter gem yourself or use the framework-specific install surface.
Production runtime
Use a dedicated scheduler process when possible.
Procfile:
web: bundle exec puma -C config/puma.rb
scheduler: bundle exec kaal start
systemd:
[Unit]
Description=Kaal scheduler
After=network.target
[Service]
WorkingDirectory=/srv/my-app/current
ExecStart=/usr/bin/bash -lc 'bundle exec kaal start'
ExecStartPre=/usr/bin/bash -lc 'bundle exec kaal status'
Restart=always
[Install]
WantedBy=multi-user.target
Kubernetes:
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app-scheduler
labels:
app: my-app-scheduler
spec:
replicas: 1
selector:
matchLabels:
app: my-app-scheduler
template:
metadata:
labels:
app: my-app-scheduler
spec:
containers:
- name: scheduler
image: my-app:latest
command: ["bundle", "exec", "kaal", "start"]
Framework integrations can co-locate the scheduler inside the web process, but that should be an explicit decision, not the default deployment model.
Operational checks
bundle exec kaal statusShow current runtime settings and registered jobs.bundle exec kaal tickRun a single scheduler tick for smoke-checking a configured environment.bundle exec kaal explain "CRON"Humanize a cron expression.bundle exec kaal next "CRON" --count NPrint upcoming fire times.
For plain Ruby jobs dispatched through .perform(*args, **kwargs), Kaal considers the run successful unless the job raises.