Distributed cron scheduling for Ruby, centered on one core gem plus framework integration gems.
Kaal coordinates recurring and delayed jobs across processes or nodes without changing how your app enqueues work. You choose the package surface that matches your runtime, configure a backend, use the runtime APIs, and run the scheduler in a dedicated process with bundle exec kaal start.
For Redis, Postgres, and MySQL-backed deployments, Kaal guarantees at-most-once dispatch per (key, fire_time) for recurring jobs and at-most-once dispatch per job_id for delayed jobs under the documented crash-and-restart model. Use the provided idempotency_key in your job boundary when downstream effects must also be deduplicated.
Project docs: https://kaal.codevedas.com
Choose the gem surface that matches your app:
kaalPlain Ruby with memory, Redis, Sequel-backed SQL, or Active Record-backed SQL.kaal-railsRails integration with Active Record-backed persistence, generators, and rake tasks.kaal-hanamiHanami integration for memory, Redis, and Sequel-backed SQL.kaal-rodaRoda integration for memory, Redis, and Sequel-backed SQL.kaal-sinatraSinatra integration for memory, Redis, and Sequel-backed SQL.
/repo-root
├── core/
│ └── kaal/ # Core engine gem, CLI, memory backend, redis backend, and SQL backends
├── gems/
│ ├── kaal-hanami/ # Hanami integration gem
│ ├── kaal-rails/ # Rails integration gem
│ ├── kaal-roda/ # Roda integration gem
│ └── kaal-sinatra/ # Sinatra integration gem
├── docs/ # Docs site source
├── scripts/ # Repo-level dev and CI entrypoints
└── README.md
Plain Ruby with the memory backend:
gem "kaal"bundle install
bundle exec kaal init --backend=memorykaal init creates:
config/kaal.rbconfig/scheduler.yml
Register a recurring job in config/scheduler.yml:
defaults:
jobs:
- key: "example:heartbeat"
cron: "*/5 * * * *"
job_class: "ExampleHeartbeatJob"
enabled: true
args:
- "{{fire_time.iso8601}}"
kwargs:
idempotency_key: "{{idempotency_key}}"Start and inspect the scheduler:
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 3kaal init only supports memory and redis. For SQL-backed setups, add the database libraries your app uses and configure the backend yourself, or use the framework-specific install surface.
gem "kaal"Memory:
bundle exec kaal init --backend=memoryRedis:
bundle exec kaal init --backend=redisgem "kaal"
gem "sequel"Example:
require "kaal"
require "sequel"
database = Sequel.connect(adapter: "sqlite", database: "db/kaal.sqlite3")
Kaal.configure do |config|
config.backend = Kaal::Backend::SQLite.new(database: database)
config.scheduler_config_path = "config/scheduler.yml"
endUse Kaal::Backend::Postgres.new(database: database) or Kaal::Backend::MySQL.new(database: database) for PostgreSQL and MySQL.
gem "kaal"
gem "activerecord"Example:
require "kaal"
Kaal.configure do |config|
config.backend = Kaal::Backend::SQLite.new(
connection: {
adapter: "sqlite3",
database: "db/kaal.sqlite3"
}
)
config.scheduler_config_path = "config/scheduler.yml"
endUse Kaal::Backend::Postgres.new(connection: ENV.fetch("DATABASE_URL")) or Kaal::Backend::MySQL.new(connection: ENV.fetch("DATABASE_URL")) for PostgreSQL and MySQL.
gem "kaal-rails"bundle exec rails generate kaal:install --backend=sqlite
bundle exec rails db:migrateFor PostgreSQL or MySQL, swap sqlite for postgres or mysql.
The generated migrations install the full Kaal persistence surface.
gem "kaal-sinatra"Memory example:
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",
start_scheduler: false
endgem "kaal-roda"Memory example:
require "roda"
require "kaal/roda"
class App < Roda
plugin :kaal
kaal backend: Kaal::Backend::MemoryAdapter.new,
scheduler_config_path: "config/scheduler.yml",
start_scheduler: false
endgem "kaal-hanami"Memory example:
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",
start_scheduler: false
)
end
endRun the scheduler in a dedicated process when possible.
Procfile:
web: bundle exec puma -C config/puma.rb
scheduler: bundle exec kaal startsystemd:
[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.targetKubernetes:
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 start the scheduler inside the web process, but that should be an intentional opt-in, not the default production model.
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)
}
)Delayed jobs:
Kaal.enqueue_at(
at: Time.now.utc + 300,
job_class: "BillingReminderJob",
args: [invoice_id],
queue: "mailers",
job_id: "billing-reminder:#{invoice_id}"
)Both surfaces share the same backend and dispatch model. Delayed jobs require unique job_id values while pending, use positional args, and follow the same job-class resolution rules: string names are constantized and class or module values are used directly.
Restrict delayed-job class names when needed:
Kaal.configure do |config|
config.delayed_job_allowed_class_prefixes = ["Reports::", "Billing::"]
endLeave delayed_job_allowed_class_prefixes empty only for local or otherwise trusted deployments. On shared Redis or SQL backends in production, Kaal will warn because delayed jobs resolve stored job_class values at dispatch time.
Kaal's scheduler-side guarantee is:
- at-most-once dispatch per
(key, fire_time)for Redis, Postgres, and MySQL-backed deployments - deterministic
idempotency_keygeneration for the same(key, fire_time)
This guarantee depends on:
- all nodes sharing the same backend
enable_log_dispatch_registry = truelease_ttl >= window_lookback + tick_interval- all nodes sharing the same namespace and scheduler definition set
Kaal guarantees dispatch semantics, not exactly-once external side effects. Use idempotency_key at the job boundary when writing to external APIs, payment systems, queues, or notification systems.
- Bad backend configuration
Missing gems, invalid adapter setup, or an unset
REDIS_URL/DATABASE_URLwill prevent boot. Start by checkingconfig/kaal.rband the adapter-specific README. - Scheduler file loading issues
bundle exec kaal statusandbundle exec kaal startloadconfig/kaal.rband thenconfig/scheduler.ymlrelative to the configured root. Confirm both files exist and thatscheduler_config_pathmatches your app layout. - Duplicate job definitions
Job keys must be unique across the loaded scheduler definition set. Duplicate keys will cause load-time conflicts and must be resolved in
config/scheduler.yml. - Backend outages or reconnect issues Redis and SQL-backed coordination depend on backend availability. A backend outage means ticks cannot coordinate safely; restore backend health before expecting normal dispatch behavior.
- Guarantee assumptions not met If duplicate dispatches appear, verify the shared backend, namespace, dispatch-log registry setting, and lease sizing before assuming a scheduler bug.
Repo-level entrypoints live under scripts/:
scripts/run-rubocop-all
scripts/run-reek-all
scripts/run-rspec-unit-all
scripts/run-rspec-e2e-all
scripts/run-multi-node-cli-allOr run the full repo-level pass in one command:
scripts/run-allYou can also run checks from an individual package directory, for example:
cd gems/kaal
bin/rspec-unit
bin/rubocop
bin/reek- Docs site: https://kaal.codevedas.com
- Contributor guide: CONTRIBUTING.md
- Security policy: SECURITY.md
- Code of conduct: CODE_OF_CONDUCT.md
Released under the MIT License.