The project is in a healthy, maintained state
A production-grade lightweight background job scheduler built on the Fiber::Scheduler interface itself, not on any one implementation: it runs under Async/Falcon, Itsi, or anything else that installs a scheduler. Single event loop with min-heap timer, skip-overlapping execution, jitter, monotonic clock intervals, semaphore concurrency control, and deterministic worker sharding.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 13.0
~> 3.12
>= 0.3, < 0.5
~> 3.0
~> 2.0

Runtime

~> 1.0
~> 1.0
~> 0.2
 Project Readme

Async::Background

A lightweight cron, interval, and job-queue scheduler for Ruby fibers. Works with any Fiber::Scheduler — Falcon / Async, Itsi, or your own.

  • Cron & interval scheduling on a single event loop with a min-heap.
  • Dynamic job queue backed by SQLite, with delayed jobs (perform_in / perform_at).
  • Cross-process wake-ups over Unix domain sockets — web workers can enqueue and instantly wake background workers.
  • Multi-process safe — deterministic worker sharding, no duplicate execution.
  • Per-job timeouts, skip-on-overlap, startup jitter, optional metrics.

Why fibers?

The whole gem is built around the assumption that the host schedules many fibers on top of one OS thread per process — so the dashboard's SSE stream, the cron scheduler, and the queue worker all share that one thread cooperatively. A blocked fiber yields; a blocked thread doesn't.

Threads vs fibers under different Ruby web servers

That's also why the dashboard (since 1.0.1) runs its SSE loop entirely inside the request fiber: zero extra threads, zero ConditionVariable, ~4 KB per open tab.


Fiber scheduler

Runner#run does not start a reactor. The host must install a Fiber.scheduler first:

Async { runner.run }  # Falcon / Async

require "async/background/scheduler"
Async::Background::Scheduler.run { runner.run }  # ASYNC_BACKGROUND_SCHEDULER=async|itsi

Without a scheduler it raises Runtime::SchedulerRequired. Worker setup for Falcon and Itsi is in Get started.


Requirements

Dependency Version Required?
Ruby >= 3.3 yes
a Fiber::Scheduler async ~> 2.0, itsi-scheduler, … yes — installed by the host, not by this gem
fugit ~> 1.0 yes
console ~> 1.0 yes
sqlite3 ~> 2.0 for the queue & dashboard
async-utilization >= 0.3, < 0.5 for metrics

Install

# Gemfile
gem "async-background"

gem "async", "~> 2.0"          # or `itsi-scheduler`
gem "sqlite3", "~> 2.0"        # queue / dashboard
gem "async-utilization", ">= 0.3", "< 0.5"  # optional metrics

➡️ Get started

Schedule config, Falcon or Itsi workers, Docker, queue, delayed jobs.


Quick look

class SendEmailJob
  include Async::Background::Job

  def perform(user_id, template)
    Mailer.send(User.find(user_id), template)
  end
end

SendEmailJob.perform_async(user_id, "welcome")
SendEmailJob.perform_in(300, user_id, "reminder")
SendEmailJob.perform_at(Time.new(2026, 4, 1, 9), user_id, "scheduled")

Schedule recurring jobs in config/schedule.yml:

sync_products:
  class: SyncProductsJob
  every: 60

daily_report:
  class: DailyReportJob
  cron: "0 3 * * *"
  timeout: 120
Key Description
class Job class — must include Async::Background::Job.
every / cron Interval in seconds, or a cron expression. Exactly one.
timeout Max execution time in seconds. Default: 30.
worker Pin to a specific worker. Default: crc32(name) % total_workers.

Gotchas

Docker + SQLite — use a named volume. SQLite's database must not live on Docker's default overlay2 filesystem: overlay2 breaks coherence between write() and mmap(), which corrupts the WAL under concurrent access. Mount the queue directory as a named volume, or pass mmap: false to Store.new. See Get Started → Docker.

Don't share SQLite connections across fork(). The gem opens connections lazily after fork. If you build a Store manually for schema setup, close it before forking.

Two clocks, on purpose. Interval jobs use CLOCK_MONOTONIC so NTP drift can't fire them twice. Cron jobs use wall-clock time, because "every day at 3am" needs to mean 3am.


How it works

A single fiber sleeps until the next entry is due, then dispatches it under a semaphore that caps concurrency. Overlapping ticks are skipped and rescheduled. Every dispatched job is tracked in a Runtime::TaskGroup, so shutdown drains in-flight work before the SQLite store and the wake-up socket are closed. The drain is bounded by drain_timeout: (30s by default; pass nil for the old unbounded wait) so one wedged job cannot turn SIGTERM into SIGKILL.

schedule.yml → build_heap → MinHeap<Entry> → scheduler loop → Semaphore → run_job

The dynamic queue runs alongside it:

   Producer (web / console)                Consumer (background worker)
          │                                          │
          ▼                                          ▼
    Queue::Client                            Queue::Store#fetch
   push / push_in / push_at                  (run_at <= now)
          │                                          ▲
          ▼                                          │
    Queue::Store ──── SQLite (jobs) ──── SocketWaker │
          │                                          ▲
          └─────────► SocketNotifier ────────────────┘
                      (UNIX socket wake-up, ~80µs)

Jobs are persisted in SQLite, so a missed wake-up is never a lost job — workers also poll every 5 seconds as a safety net.


Metrics

Metrics are an optional integration with async-utilization. With the gem installed, each worker publishes counters to a shared-memory segment:

runner.metrics.values
# => { total_runs: 142, total_successes: 140, total_failures: 2,
#      total_timeouts: 0, total_skips: 5, active_jobs: 1, ... }

Async::Background::Metrics.read_all(total_workers: 2)
# => [{ worker: 1, ... }, { worker: 2, ... }]

Without the gem, runner.metrics.enabled? is false and read_all returns [] — no LoadError to rescue. Configuration and the cross-container shared-memory path are covered in Get Started → Optional metrics.


License

MIT.