⏰ Kaal
Kaal is a distributed cron scheduler for Ruby that safely executes scheduled tasks across multiple nodes.
✨ Overview
kaal lets you bind cron expressions to Ruby code or shell commands without depending on any specific job system or scheduler.
It guarantees that:
- Each scheduled tick enqueues work exactly once across all running Ruby nodes.
- You remain agnostic to your background job system (
ActiveJob,Sidekiq,Resque, etc.). - Locks are coordinated safely via Redis or PostgreSQL advisory locks.
- Cron syntax can be validated, linted, humanized, and translated with i18n.
🧩 Why Kaal?
| Problem | Kaal Solution |
|---|---|
| Multiple nodes running the same cron | Distributed locks → exactly-once execution |
| Cron syntax not human-friendly | Built-in parser + to_human translations |
| Missed runs during downtime | Configurable lookback window replays missed ticks |
| Coupled to job system | Scheduler-agnostic, works with any Ruby queue backend |
⚙️ Installation
Add to your Gemfile:
gem "kaal"
Then run:
bundle install
bin/rails g kaal:install --backend=sqlite
The install generator creates config/initializers/kaal.rb and, for database-backed backends, generates only the migrations you need:
--backend=sqlite: dispatches, locks, and definitions tables--backend=postgresor--backend=mysql: dispatches and definitions tables--backend=redisor--backend=memory: no database migrations
Example initializer (config/initializers/kaal.rb):
Kaal.configure do |c|
# Choose your backend adapter
# See the Kaal documentation for backend-specific setup and the full
# configuration reference.
#
# Redis (recommended)
# c.backend = Kaal::Backend::RedisAdapter.new(Redis.new(url: ENV["REDIS_URL"]))
# or Postgres advisory locks
# c.backend = Kaal::Backend::PostgresAdapter.new
c.tick_interval = 5 # seconds between scheduler ticks
c.window_lookback = 120 # recover missed runs (seconds)
c.lease_ttl = 125 # must be >= window_lookback + tick_interval
c.recovery_window = 3600
c.enable_dispatch_recovery = true
c.enable_log_dispatch_registry = false
# Scheduler file loading
c.scheduler_config_path = "config/scheduler.yml"
c.scheduler_conflict_policy = :error # :error, :code_wins, :file_wins
c.scheduler_missing_file_policy = :warn # :warn, :error
end
Example scheduler file (config/scheduler.yml):
defaults:
jobs:
- key: "reports:weekly_summary"
cron: "0 9 * * 1"
job_class: "WeeklySummaryJob"
enabled: true
queue: "default"
args:
- "{{fire_time.iso8601}}"
kwargs:
idempotency_key: "{{idempotency_key}}"
metadata:
owner: "ops"
production:
jobs:
- key: "reports:daily_digest"
cron: "<%= ENV.fetch('DAILY_DIGEST_CRON', '0 7 * * *') %>"
job_class: "DailyDigestJob"
scheduler.yml supports ERB and environment sections (defaults, development, test, production, etc.).
Allowed runtime placeholders in args and kwargs values (not keys): {{fire_time.iso8601}}, {{fire_time.unix}}, {{idempotency_key}}, {{key}}.
👉 See full installation guide →
🚀 Quick Start
Register a scheduled job anywhere during boot (e.g., config/initializers/kaal_jobs.rb):
Kaal.register(
key: "reports:weekly_summary",
cron: "0 9 * * 1", # every Monday at 9 AM
enqueue: ->(fire_time:, idempotency_key:) {
WeeklySummaryJob.perform_later(fire_time: fire_time, key: idempotency_key)
}
)
Start the scheduler:
Kaal does not auto-start by default. Start it explicitly via Kaal.start! or kaal:start.
bundle exec rails kaal:start
💡 Recommended: run as a dedicated process in production (Procfile, systemd, Kubernetes).
🧰 CLI & Rake Tasks
CLI Examples
$ kaal explain "*/15 * * * *"
Every 15 minutes
$ kaal next "0 9 * * 1" --count 3
2025-11-03 09:00:00 UTC
2025-11-10 09:00:00 UTC
2025-11-17 09:00:00 UTC
Rails Tasks
bin/rails kaal:start # Start scheduler loop
bin/rails kaal:status # Show registry & configuration
bin/rails kaal:tick # Trigger one tick manually
bin/rails kaal:explain["*/5 * * * *"] # Humanize cron expression
🧠 Cron Utilities
Kaal.valid?("0 * * * *") # => true
Kaal.simplify("0 0 * * *") # => "@daily"
Kaal.lint("*/61 * * * *") # => ["invalid minute step: 61"]
I18n.locale = :fr
Kaal.to_human("0 9 * * 1") # => "À 09h00 chaque lundi"
🈶 All tokens are i18n-based — override translations under
kaal.*keys.
🧱 Running the Scheduler
Run the scheduler as a dedicated process in production.
Do not run it inside web server processes by default.
Procfile (process manager):
web: bundle exec puma -C config/puma.rb
scheduler: bundle exec rails kaal:start
systemd unit:
[Unit]
Description=Kaal scheduler
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/var/apps/myapp/current
Environment=RAILS_ENV=production
ExecStart=/usr/bin/bash -lc 'bundle exec rails kaal:start'
ExecStartPre=/usr/bin/bash -lc 'bundle exec rails kaal:status'
ExecReload=/bin/kill -TERM $MAINPID
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Kubernetes Deployment:
apiVersion: apps/v1
kind: Deployment
metadata:
name: kaal-scheduler
spec:
replicas: 1
selector:
matchLabels:
app: kaal-scheduler
template:
metadata:
labels:
app: kaal-scheduler
spec:
containers:
- name: scheduler
image: your-app:latest
command: ["bash", "-lc", "bundle exec rails kaal:start"]
For Kubernetes, the scheduler process is the container's main process; if it exits, Kubernetes restarts it.
Do not use kaal:status as a liveness/readiness probe for scheduler thread health because it runs in a separate process.
Use one of these for health checks:
- Process-level checks from your runtime/supervisor for the main scheduler process.
- A shared heartbeat/lease signal (Redis, Postgres, pidfile, etc.) written by the scheduler and read by probes.
🔍 Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Jobs run multiple times | Using memory lock | Use Redis or Postgres adapter |
| Missed jobs after downtime | Short lookback window | Increase window_lookback |
| Scheduler exits early | Normal SIGTERM | Exits gracefully after tick |
| Redis timeout | Network latency | Increase Redis timeout or switch adapter |
🧪 Testing Example
RSpec.describe "multi-node safety" do
it "dispatches exactly once across two threads" do
redis = FakeRedis::Redis.new
lock = Kaal::Backend::RedisAdapter.new(redis)
Kaal.configure { |c| c.backend = lock }
threads = 2.times.map { Thread.new { Kaal.tick! } }
threads.each(&:join)
expect(redis.keys.grep(/dispatch/).size).to eq(1)
end
end
Local Test Commands
Run the fast unit suite from the gem directory:
bin/rspec-unit
Run end-to-end adapter coverage with the same entrypoint used in CI:
bin/rspec-e2e memory
bin/rspec-e2e sqlite
DATABASE_URL=postgres://postgres:[email protected]:5432/kaal_test bin/rspec-e2e pg
DATABASE_URL=mysql2://root:rootROOT\[email protected]:3306/kaal_test bin/rspec-e2e mysql
REDIS_URL=redis://127.0.0.1:6379/0 bin/rspec-e2e redis
pg and mysql require DATABASE_URL. redis requires REDIS_URL. memory and sqlite use local test defaults.
🧩 Roadmap
| Area | Description | Label |
|---|---|---|
| Registry & API | Cron registration and validation | feature |
| Coordinator Loop | Safe ticking and dispatch | feature |
| Lock Adapters | Redis / Postgres | build |
| CLI Tool | kaal executable |
build |
| i18n Humanizer | Multi-language support | lang |
| Docs & Examples | Developer onboarding | lang |
🤝 Contributing
- Fork the repository
- Create a feature branch (
git checkout -b feature/my-feature) - Run tests (
bundle exec rspec) - Open a PR using the Feature Request template
Labels: feature, build, ci, lang
📚 Documentation
📄 License
Released under the MIT License. © 2025 Codevedas Inc. — All rights reserved.