Rufus Scheduler: How Its Main Loop Actually Works
How rufus-scheduler's single-threaded main loop triggers, times out, and runs jobs, and where a timer-based rewrite would scale better.
· 3 min read
Rufus-scheduler is a pure Ruby gem for scheduling blocks of code to run later, with no external dependencies. What’s less obvious from the README is how it actually decides a job is due: a plain loop that wakes up on a fixed interval and checks. That design has real consequences for how well it scales, worth walking through alongside the core mechanism, how it handles long-running jobs, and where it would need to change to scale further.
Core Implementation
Main Scheduling Loop
The heart of Rufus Scheduler is its main loop, implemented in the start method:
def start
@started_at = EoTime.now
@thread = Thread.new do
while @started_at do
begin
unschedule_jobs
trigger_jobs unless @paused_at
timeout_jobs
sleep(@frequency)
rescue => err
on_error(nil, err)
end
end
rejoin
end
@thread[@thread_key] = true
@thread[:rufus_scheduler] = self
@thread[:name] = @opts[:thread_name] || "#{@thread_key}_scheduler"
end
This method:
- Creates a new thread for the scheduler.
- Continuously loops while the scheduler is running.
- In each iteration, it:
- Removes unscheduled jobs
- Triggers due jobs (if not paused)
- Checks for timed-out jobs
- Sleeps for a specified duration
Job Triggering
The trigger_jobs method is responsible for executing due jobs:
def trigger_jobs
now = EoTime.now
@jobs.each(now) do |job|
job.trigger(now)
end
end
Each job’s trigger method adds the job to the scheduler’s work queue:
def trigger(time)
return if @scheduler.down?
@scheduler.work_queue << self
end
Handling Long-Running Jobs
Rufus Scheduler employs several strategies to manage long-running jobs:
-
Work Threads: A pool of threads executes jobs concurrently, reducing the risk of blocking.
-
Job Timeouts: The
timeout_jobsmethod enforces time limits on job execution:def timeout_jobs work_threads(:active).each do |t| job = t[:rufus_scheduler_job] to = t[:rufus_scheduler_timeout] ts = t[:rufus_scheduler_time] next unless job && to && ts to = ts + to unless to.is_a?(EoTime) next if to > EoTime.now t.raise(Rufus::Scheduler::TimeoutError) end end -
Non-blocking Jobs: Jobs can be scheduled with
blocking: falseto allow continued processing of other jobs:scheduler.every '10s', blocking: false do # long-running job end
Limitations and Potential Improvements
While Rufus Scheduler is effective for many use cases, its implementation has some limitations:
- Constant Checking: The continuous loop can be inefficient, especially for infrequent jobs.
- Scalability: As job numbers increase, the constant checking can become a bottleneck.
- Single Process: It typically runs in one process, limiting use in distributed systems.
Potential Improvements
-
Timer-based Approach: Use system timers to wake up the process only when needed:
loop do next_job_time = calculate_next_job_time() timeout = next_job_time - Time.now IO.select(nil, nil, nil, timeout) run_due_jobs() end -
Tiered Scheduling: Use different checking frequencies for different time scales.
-
Sorted Job List: Keep jobs sorted by next run time to check only the most imminent ones.
-
Dynamic Sleep Duration: Calculate sleep time based on the next due job:
def improved_start @thread = Thread.new do while @started_at next_job_time = @jobs.next_job_time now = Time.now if next_job_time > now sleep_duration = [next_job_time - now, MAX_SLEEP].min sleep(sleep_duration) else trigger_due_jobs(now) timeout_jobs end end end end -
System Integration: For long-running applications, consider integrating with system-level schedulers like systemd timers or cron.
Where this breaks down
The main loop with worker threads is fine for a handful of jobs on a fixed cadence. It stops being fine once you have enough jobs, or infrequent enough jobs, that constant polling wastes cycles waiting for nothing to be due. A sorted job list and a dynamic sleep duration, sleeping until the next actual due time instead of a fixed interval, fixes that without changing the public API. It’s a useful design to have read once, since the same tradeoff (poll on a fixed interval vs. wake on the next known event) shows up in most schedulers you’ll build or evaluate.