⏰ RailsCron

First release 0.1.0 is empty gem. 🕒 A scheduler-agnostic, multi-node-safe cron runner for Ruby and Rails — with Redis or Postgres advisory locks.

Gem CI Maintainability Code Coverage License PostgreSQL Redis


✨ Overview

rails_cron lets you bind cron expressions to Ruby code or shell commands
without depending on any specific job system or scheduler (like Sidekiq-Cron or Rufus).

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 RailsCron?

Problem RailsCron 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 "rails_cron"

Then run:

bundle install
bin/rails g rails_cron:install

Example initializer (config/initializers/rails_cron.rb):

RailsCron.configure do |c|
  # Choose your distributed lock adapter
  # Redis (recommended)
  # c.lock_adapter = RailsCron::Lock::Redis.new(url: ENV["REDIS_URL"])

  # or Postgres advisory locks
  # c.lock_adapter = RailsCron::Lock::Postgres.new(connection: ActiveRecord::Base.connection)

  c.tick_interval    = 5      # seconds between scheduler ticks
  c.window_lookback  = 120    # recover missed runs (seconds)
  c.lease_ttl        = 60     # lock TTL in seconds
end

👉 See full installation guide →


🚀 Quick Start

Register a scheduled job anywhere during boot (e.g., config/initializers/rails_cron_jobs.rb):

RailsCron.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:

bundle exec rails rails_cron:start

💡 Recommended: run as a dedicated process in production (Procfile, systemd, Kubernetes).


🧰 CLI & Rake Tasks

CLI Examples

$ rails-cron explain "*/15 * * * *"
Every 15 minutes

$ rails-cron 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 rails_cron:start          # Start scheduler loop
bin/rails rails_cron:status         # Show registry & configuration
bin/rails rails_cron:tick           # Trigger one tick manually
bin/rails rails_cron:explain["*/5 * * * *"] # Humanize cron expression

🧠 Cron Utilities

RailsCron.valid?("0 * * * *")     # => true
RailsCron.simplify("0 0 * * *")   # => "@daily"
RailsCron.lint("*/61 * * * *")    # => ["invalid minute step: 61"]

I18n.locale = :fr
RailsCron.to_human("0 9 * * 1")   # => "À 09h00 chaque lundi"

🈶 All tokens are i18n-based — override translations under rails_cron.* keys.


🧱 Running the Scheduler

Procfile (Heroku / Foreman):

web:       bundle exec puma -C config/puma.rb
scheduler: bundle exec rails rails_cron:start

systemd unit:

[Service]
Type=simple
WorkingDirectory=/var/apps/myapp/current
ExecStart=/usr/bin/bash -lc 'bundle exec rails rails_cron:start'
Restart=always

Kubernetes Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: rails-cron
spec:
  replicas: 1
  template:
    spec:
      containers:
        - name: scheduler
          image: your-app:latest
          command: ["bash", "-lc", "bundle exec rails rails_cron:start"]

🔍 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

📖 See FAQ →


🧪 Testing Example

RSpec.describe "multi-node safety" do
  it "dispatches exactly once across two threads" do
    redis = FakeRedis::Redis.new
    lock  = RailsCron::Lock::Redis.new(client: redis)
    RailsCron.configure { |c| c.lock_adapter = lock }

    threads = 2.times.map { Thread.new { RailsCron.tick! } }
    threads.each(&:join)

    expect(redis.keys.grep(/dispatch/).size).to eq(1)
  end
end

🧩 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 rails-cron executable build
i18n Humanizer Multi-language support lang
Docs & Examples Developer onboarding lang

🤝 Contributing

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/my-feature)
  3. Run tests (bundle exec rspec)
  4. 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.