Async::Background

A lightweight cron, interval, and job-queue scheduler for Ruby's Async ecosystem. Built for Falcon, works with any Async app.

  • 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

Requirements

  • Ruby >= 3.3
  • async ~> 2.0, fugit ~> 1.0
  • sqlite3 ~> 2.0 (optional, for the job queue)
  • async-utilization >= 0.3, < 0.5 (optional, for metrics)

Install

# Gemfile
gem "async-background"
gem "sqlite3", "~> 2.0"            # optional
gem "async-utilization", ">= 0.3", "< 0.5"  # optional

➡️ Get Started

Full setup walkthrough: schedule config, Falcon integration, 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 One of: interval in seconds, or cron expression
timeout Max execution time in seconds (default: 30)
worker Pin to a specific worker. Default: crc32(name) % total_workers

Gotchas

Docker: SQLite requires a named volume

The SQLite database must not live on Docker's overlay2 filesystem. The overlay2 driver breaks coherence between write() and mmap(), which corrupts SQLite WAL under concurrent access.

# docker-compose.yml
services:
  app:
    volumes:
      - queue-data:/app/tmp/queue   # ← named volume, NOT overlay2

volumes:
  queue-data:

Without this, you will get database crashes in multi-process mode. See Get Started → Step 3 for details. If you can't use a named volume, pass queue_mmap: false to disable mmap entirely.

Other gotchas

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

Async::Background::Queue.migrate!(path: db_path) # ← once, before fork
# Every process opens its own Store lazily after fork.

Two clocks, on purpose. Interval jobs use CLOCK_MONOTONIC (immune to NTP drift). Cron jobs use wall-clock time, because "every day at 3am" needs to mean 3am.


How it works

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

A single Async task sleeps until the next entry is due, then dispatches it under a semaphore that caps concurrency. Overlapping ticks are skipped and rescheduled.

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.

Schema migration during deploy

Run queue migrations once in the release/pre-deploy step, before starting new web or worker processes. This serializes the schema upgrade with BEGIN IMMEDIATE, records the version in SQLite, and avoids a first producer doing DDL under live queue traffic:

Async::Background::Queue.migrate!(path: ENV.fetch("QUEUE_DB_PATH"))

A fresh database still self-initializes on first use for local development, but explicit migration is the production path. For an existing queue, finish or stop 0.7.1 producers/workers, run the migration once, then start 0.7.2 processes.

Future dashboard indexes

The queue does not install dashboard indexes by default. They slow every enqueue even though pending rows never enter terminal or in-flight read-model indexes. When the 1.0 dashboard module is enabled, its installer will run this once in the same release step:

Async::Background::Queue.prepare_dashboard!(path: ENV.fetch("QUEUE_DB_PATH"))

It adds three compact indexes: one each for cursor-sorted done and failed jobs, plus one for the bounded in-flight list. It does not change queue behavior or rerun the core migration.

Metrics

Metrics are an optional integration with async-utilization (>= 0.3, < 0.5). The background worker remains fully functional when that gem is absent. With it installed, each worker publishes counters to a shared-memory segment.

runner.metrics.enabled?
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, ... }]

Metrics.read_all returns [] until the optional gem is installed and a worker has created the file, so an observer can render an unavailable state without rescuing LoadError. Its snapshot is lock-free best effort: cumulative fields (total_runs, total_successes, total_failures, total_timeouts, total_skips) are counters; active_jobs, last_run_at, and last_duration_ms are gauges. Fields can describe adjacent moments in time rather than one globally atomic instant.

By default the file is /tmp/async-background.shm. Set ASYNC_BACKGROUND_METRICS_PATH or pass metrics_shm_path: to Runner.new when another observer runs in a separate process or container; both sides must see the same mounted file.

License

MIT