Fast, reliable background jobs for Ruby. Every feature open source.
Robin is a Redis-backed job system for Ruby. Batches, reliable fetch, rate limiting, cron, unique jobs, encryption, multi-process swarms with rolling restarts, historical metrics and a modern dashboard with multi-cluster support and authorization hooks are all included under the LGPL-3.0. There is no paid tier.
class SendReceipt
include Robin::Job
robin_options queue: "mailers", retry: 5
def perform(order_id)
OrderMailer.receipt(Order.find(order_id)).deliver_now
end
end
SendReceipt.perform_async(order.id)
SendReceipt.perform_in(300, order.id) # in 5 minutes
SendReceipt.set(queue: "critical").perform_async(order.id)bundle exec robin -r ./app.rb -c 10 -q critical,3 -q defaultFaster. Robin processes jobs 5x as fast as Sidekiq 8.1 OSS on the same machine, 8.8x with Redis 1 ms away, and moves scheduled jobs onto their queues 61x as fast, with an eighth of the Redis CPU per job (benchmarks).
Built not to lose jobs, or run them twice.
- A crash loses nothing. A fetched job stays in Redis until it has
finished or its retry has been recorded. Kill a server with
kill -9in the middle of 1,000 jobs: Robin loses none, Sidekiq OSS loses every job that was running (crash benchmark). The cluster puts a dead process's jobs back on their queues by itself, and a job that keeps killing its process goes to the dead set instead of taking down the next one. - Each step happens once. A due scheduled job or retry is moved by a
script that only queues it if it removed it from its set, so any number
of servers polling queue it once. Each cron tick is claimed with an
atomic
SET NX, so it runs once across the cluster. A job's retry (or death) is recorded in the same transaction that removes it from flight, so a failure is never both retried and lost. A command is only resent after a dropped connection if none of it reached Redis, so a failover doesn't push a job twice. - Duplicates only where they can't be avoided. Delivery is at least once, as with any job system that survives crashes: a job runs again only if its process dies while it runs (or within 2 ms of it finishing) or a shutdown interrupts it. Unique jobs and idempotent jobs cover the rest.
- Tested to the hilt. About 1,900 tests run on CRuby 3.4, 4.0 and JRuby. CI enforces 100% line, branch and method coverage of the Ruby, C, Java and TypeScript code, and 100% mutation coverage of the Ruby, Java and TypeScript code: every deliberate bug a mutation tester plants makes some test fail (details).
- Why Robin
- Features
- Installation
- Jobs
- Running servers
- Reliability
- Batches and workflows
- Rate limiting
- Periodic (cron) jobs
- Unique, debounced, expiring and encrypted jobs
- Long-running (iterable) jobs
- Progress and cancellation
- Middleware
- Errors, retries and the dead set
- Metrics
- Tracing and profiling
- Web dashboard
- Rails and ActiveJob
- Testing
- The data API
- Performance
- Development
- License
| Area | What you get |
|---|---|
| Core | Multithreaded workers, capsules, strict or weighted queue priority, scheduled jobs, retries with exponential backoff (limited by count or by time, optionally on another queue), dead set, client and server middleware, strict JSON arguments, bulk push (optionally spread over time), embedded mode, deferrable pushes (transaction-aware in Rails), a Ruby API for everything in Redis |
| Reliability | Reliable fetch (in-flight jobs survive crashes), automatic recovery from dead processes, poison-pill detection, reliable push (buffers jobs while Redis is unreachable), queue pausing |
| Workflows | Batches with success, complete and death callbacks, nested batches, tags for finding batches, cancelling a batch and its children, a status endpoint your own pages can poll |
| Throughput control | Concurrent, bucket, sliding-window, leaky-bucket and points rate limiters, each with its own reschedule count and backoff, calls that count as several uses, a policy that skips work instead of waiting, limiters on a Redis of their own, plus an unlimited stand-in; expiring jobs |
| Scheduling | Cron jobs (Robin jobs or ActiveJobs) with time zones, run exactly once by an elected leader, and a checker for your test suite |
| Deduplication | Unique jobs, and debounced jobs (Robin-only) |
| Security | AES-256-GCM argument encryption with key rotation, web UI authorization and read-only mode |
| Operations | Swarm mode (multi-process, copy-on-write preload, sized per CPU core), rolling restarts, per-process memory limits, leader election, remote quiet/stop/resize, robin top (a live view in the terminal) |
| Observability | Per-class timings and latency histograms, hourly history kept for 90 days by default, queue size and latency history, deploy markers on the charts, StatsD/DogStatsD (jobs, queues, batches, recovered jobs; Datadog distributions), Prometheus endpoint |
| Long jobs | Iterable jobs that checkpoint and resume across deploys (and yield their worker after a maximum runtime), live progress reporting, cooperative cancellation (Robin-only) |
| Dashboard | TypeScript single-page app in 25 languages (including right-to-left ones) with live updates, light and dark themes, search, editing a dead job's args before retrying it, multi-cluster support, rows of your own on job pages, pages of your own (built from blocks, or with your own script), and assets from a CDN |
# Gemfile
gem "robin"Robin needs Ruby 3.4+ (or JRuby 10) and Redis 6.2+, Valkey or Dragonfly.
CI runs every test on Ruby 3.4 and 4.0 (and Ruby's development version),
JRuby 10.1, Redis 6.2, 7.2, 7.4, 8.2 and 8.10, Valkey 9.1 and
Dragonfly 2.0. CI runs them on TruffleRuby 40 too, where Robin uses pure
Ruby, but TruffleRuby isn't supported yet: a few of its bugs fail tests
(a refused connection is reported as EINVAL, for one).
Dragonfly needs --default_lua_flags=allow-undeclared-keys, because some of
Robin's Lua scripts build key names from the key prefix. Robin has no runtime
dependencies besides json and logger: it ships its own small Redis
client. It works with any Ruby or Rack app, and in a Rails app its
Rails integration turns itself on. An optional C extension, compiled at
install time, reads and parses Redis replies faster. If it can't be built,
Robin falls back to pure Ruby automatically.
Coming from Sidekiq? Replace gem "sidekiq" (and sidekiq-pro or
sidekiq-ent) with gem "robin-compat", and your app runs on Robin
unchanged, picking up Sidekiq's queued, scheduled, retrying and dead jobs
where they are. See robin-compat.
On JRuby (10.1+), RubyGems installs Robin's java-platform gem instead. It
ships a compiled Java extension in place of the C one: the same reply parser
and socket reader, a command encoder, and lock-free bookkeeping for the
connection pool, so worker threads, which run in parallel on the JVM, never
wait on each other. Nothing needs compiling at install time. Give the JVM
a maximum heap (JRUBY_OPTS=-J-Xmx512m, say): without one, it grows the
heap under load toward a quarter of the machine's memory, though a busy
server's live heap is far smaller.
Point Robin at Redis with ROBIN_REDIS_URL or REDIS_URL (or
REDIS_PROVIDER, naming the variable that holds the URL, as some hosting
add-ons expect), or configure it:
Robin.configure do |config|
config.redis = {url: "redis://localhost:6379/0"} # also rediss://, unix://
endOther Redis options: pool_size, pool_timeout, idle_timeout (close
connections idle that many seconds), name, connect_timeout,
read_timeout, write_timeout, ssl_params.
With Redis Sentinel, name the master in the URL (or master_name:) and
list the Sentinels. Robin asks them where the master is on every new
connection, checks it really is the master, and moves to the new one after
a failover:
config.redis = {
url: "redis://mymaster/0",
sentinels: ["10.0.0.1:26379", "10.0.0.2:26379", {host: "10.0.0.3", port: 26379}],
sentinel_password: ENV["SENTINEL_PASSWORD"] # if the Sentinels need one
}To spread jobs over several Redis servers (shards), push through a pool for the shard you want. Each shard needs its own Robin processes:
SHARDS = %w[redis://r1 redis://r2].map do |url|
Robin::Redis::Pool.new(size: 10) { Robin::Redis::Connection.new(url: url) }
end
ReportJob.set(pool: SHARDS.sample).perform_async(42)
Robin::Client.via(SHARDS[1]) { ReportJob.perform_async(1); OtherJob.perform_async(2) }class ResizeImage
include Robin::Job
robin_options queue: "media",
retry: 10, # true (default: 20 retries), false, or a number
retry_for: 86_400, # or keep retrying for a day after the first failure
retry_queue: "slow", # retries run on this queue
backtrace: 20, # keep 20 backtrace lines on failure
tags: ["images"], # shown and searchable in the dashboard
timeout: 120, # last-resort hard timeout in seconds
log_level: :debug # level of its start/done lines (logging: false drops them)
def perform(image_id, width)
# ...
end
end
ResizeImage.perform_async(1, 640)
ResizeImage.perform_at(Time.now + 3600, 1, 640)
ResizeImage.set(queue: "urgent", wait: 30).perform_async(1, 640)
ResizeImage.perform_bulk([[1, 640], [2, 640], [3, 640]]) # pipelined
ResizeImage.perform_bulk(ids.map { [_1, 640] }, spread_interval: 3600) # evenly over the next hour
ResizeImage.perform_inline(1, 640) # run now, in this thread
Robin::Client.together { images.each { ResizeImage.perform_async(_1.id, 640) } } # one round tripRobin::Client.together holds back the jobs its block pushes and sends
them all when it finishes, in one round trip when they go straight to the
same Redis (none if the block raises).
Arguments must be JSON-native: strings, numbers, booleans, nil, arrays, and
hashes with string keys. Robin raises at push time if they aren't. Set
config.strict_args = :warn or false to relax this.
Raise Robin::Discard from a job to stop it without retrying.
timeout: raises Robin::Job::Timeout inside the job. It isn't a
StandardError, so a job's own rescue => e can't swallow it. Like any
Timeout.timeout, though, it can interrupt the job anywhere, even inside
an ensure block or halfway through a database call. Put timeouts on the
slow operations themselves (HTTP clients, queries) and keep this one as a
last resort.
robin -r ./app.rb -c 10 -q critical,3 -q default,1 # weighted
robin -r . -q critical -q default -q low # strict order (app directory)
robin -C config/robin.yml
robin swarm -n 4 # 4 processes, preloaded
robin swarm -n 0.5 # one process per two CPU cores
robin stats # cluster stats as JSON
robin top # live view of queues, processes and running jobs
robin deploy "v42" # mark a deploy on the metrics charts
robin health --health-port 7433 # exit 0 if this host's server is healthy# config/robin.yml
concurrency: 10
queues: [critical, default, low]
timeout: 25
capsules:
serial:
concurrency: 1
queues: [ledger]
production:
concurrency: 20robin top shows a live view of the cluster in the terminal: totals and
queues, processes, and the jobs running now, refreshed every 2 seconds.
1, 2 and 3 (or tab) switch views, r refreshes and q quits. It only reads.
robin stats, robin top and robin deploy load the app as a client,
like a web process: its configure_client blocks run, not its
configure_server blocks.
robin swarm -n takes a number of processes, or a decimal meaning that
many per CPU core (-n 1.5 on 8 cores starts 12). ROBIN_SWARM_COUNT
works too, and the default is one per core. Each child runs
config.on(:fork) hooks first, e.g. to reconnect to a database.
Capsules isolate work inside one process, each with its own threads and queues:
Robin.configure_server do |config|
config.capsule("serial") do |cap|
cap.concurrency = 1
cap.queues = %w[ledger]
end
endA job's capsule (or Robin::Capsule.current, anywhere on a worker's
thread) is the capsule running it, with its name, queues and
concurrency; it's nil outside a server's workers.
The process title shows the version, tag and how many jobs are running
(robin 0.1.0 shop [3 of 10 busy]). The tag defaults to the app
directory's name (the app's, in a Capistrano releases/ directory). On
Heroku, processes are named after their dyno. Robin's Redis connections
are named robin (see CLIENT LIST). The heartbeat warns when Redis
round trips take over 50ms. A trap the app had already set for one of
Robin's signals still runs, after Robin's.
systemd. As a Type=notify service, Robin tells systemd when it's
ready and when it's stopping. With WatchdogSec= (a few heartbeat
intervals, e.g. 30), its heartbeat keeps the watchdog happy.
Signals:
| Signal | Effect |
|---|---|
TERM / INT |
Graceful shutdown: running jobs get timeout seconds, then the rest go back to the queue |
TSTP |
Quiet: stop picking up new jobs |
TTIN (or INFO, Ctrl-T, on macOS and BSD) |
Log every thread's backtrace |
USR2 |
Under robin swarm, rolling restart: a replacement child starts before each old one stops |
You can also quiet or stop any process from the dashboard.
Resizing. A running process can change how many workers a capsule
runs, without a restart: from the dashboard's Busy page (for users with
the :process_control permission), with POST <mount>/api/processes/:identity/resize and a JSON body like
{"concurrency": 20, "capsule": "default"} (1 to 1000; capsule
defaults to "default"), or from Ruby:
Robin::ProcessSet.new.each { |process| process.resize!(20) } # the default capsule
Robin::ProcessSet.new.first.resize!(2, capsule: "serial")The process applies it at its next heartbeat. New workers start right
away; when there are to be fewer, the extra workers finish the job they're
running (it isn't interrupted) and then stop. Robin grows its Redis
connection pool to fit more workers, and never shrinks it. If you set
pool_size yourself, it stays as it is, and a resize that would leave
fewer than two connections beyond the workers is refused with a warning
in that process's log (as is one sent to a quiet process). Each resize is
logged and emits a workers.resized event, and the capsule's
concurrency reports the new count. It lasts until the process
restarts, which starts it with its configured concurrency again. There's
no OS signal for it (TTIN already dumps the threads): it travels like
the dashboard's quiet and stop, as a RESIZE <count> <capsule> remote
command on the process's signal list.
An idle worker waits up to config.fetch_timeout seconds (default 2) for a
job before checking whether it should stop, so an idle process stops within
that time. Lower it for faster shutdowns, at the cost of more round trips
while idle.
config.fetch_batch (default 10) caps how many jobs one round trip to
Redis fetches for a process's waiting workers. Higher means fewer round
trips under heavy load; 1 fetches one job at a time.
Idle time spent for busy time. Robin does work while it's quiet so that it doesn't have to when jobs come:
- A standalone
robinloads all of Robin and tidies its heap (Process.warmup) before it starts. - If you turn it on, a standalone
robincollects its garbage when it goes idle (see below). - On JRuby, a server first runs Robin's push, fetch and middleware code
for about a second and a half, without Redis or your middleware, so
the JIT has compiled it before real jobs come (
config.jit_warmup; on by default except on CRuby). Without it, a new process pushes its first 20,000 or so jobs about a third as fast as one that warmed up. - The cluster's leader wakes its scheduler when the next scheduled job or
retry it knows of is due, rather than at its next poll (about every
config.poll_intervalseconds, 5 by default), so jobs scheduled ahead start within milliseconds of their time.
Garbage collection when idle (off by default). A standalone robin
can run a full GC each time it goes idle: no job running, none finished
for a second. The next burst of jobs then starts on a clean heap, and
doesn't pay for the last burst's garbage. Turn it on in Robin.configure:
config.idle_gc = true # after any work
config.idle_gc = 1_000 # only after at least 1,000 jobs since the last oneIt has costs, and they're why it's off:
- It stops the process. A full GC pauses every thread while it runs: a few milliseconds for a small heap, tens or hundreds for a large one (on JRuby, it pauses the whole JVM). A job that arrives during it waits.
- It costs CPU every time the process goes quiet. For bursty work
with short gaps, that can mean a full GC every few seconds, each one
marking the whole heap, where Ruby's own GC would mostly have run
cheaper minor collections. A minimum number of jobs (
1_000above) limits it to quiet spells that follow real work. - It doesn't make garbage go away. The work is the same, only moved into idle time. If your process is rarely idle, or your jobs allocate little, it gains nothing.
Try it if your jobs come in bursts after quiet spells, and the first jobs of each burst are slower than the rest. Measure the latency of those first jobs, and Robin's CPU use, with it on and off. Embedded servers never do it, as they share their process with your app.
Health checks. With config.health_port = 7433 (or --health-port,
health_port: in robin.yml, ROBIN_HEALTH_PORT), each server answers
HTTP requests on that port with 200 while its heartbeat reaches Redis
and it isn't shutting down, and 503 otherwise, plus a JSON report.
Swarm children listen on the port plus their index. Point a Kubernetes
probe at it:
livenessProbe:
httpGet: {path: /, port: 7433}
# or, without HTTP probes:
# exec: {command: [bundle, exec, robin, health, --health-port, "7433"]}The dashboard has its own /health (e.g. /robin/health) that pings each
cluster's Redis, for load balancers and uptime monitors.
Swarm boots your app once and forks -n children (default: number of
CPUs), so memory is shared through copy-on-write. Children that crash are
restarted. With config.memory_limit_mb = 1024, a child that grows past
the limit restarts gracefully.
Embedded mode runs a server inside another process:
robin = Robin.configure_embed do |config|
config.queues = %w[default]
config.concurrency = 2
end
robin.run
at_exit { robin.stop }In Puma, use the plugin instead. It runs a server in every worker (or in
the one process in single mode), started once the app has loaded, so your
app's own Robin configuration applies, and stopped gracefully whenever
Puma stops a worker: on shutdown, hot restarts (SIGUSR2), phased
restarts (SIGUSR1, pumactl phased-restart) and fork_worker reforks.
robin_config (optional, after plugin :robin) takes the same block as
configure_embed and runs once per worker:
# config/puma.rb
workers 2
prune_bundler
extra_runtime_dependencies ["robin"]
plugin :robin
robin_config do |config|
config.concurrency = 2
endKeep require "robin" out of config/puma.rb, and don't start Robin
from before_worker_boot: both load Robin before the app's bundle is
set up, which activates Robin's gem dependencies at whatever versions
are installed, so after a deploy that changes them, phased restarts boot
workers that fail with "You have already activated ...". The plugin only
loads Robin in workers, after the app. With prune_bundler, add
extra_runtime_dependencies ["robin"] so Puma's master can find the
plugin without activating the gem (the plugin logs a warning if Robin is
loaded in a pruned master). Phased restarts need preload_app! off,
which prune_bundler implies. Jobs running when a worker stops get
config.timeout to finish before they're pushed back to their queues;
keep it (plus 3 seconds) under Puma's worker_shutdown_timeout (30s by
default) or Puma kills the worker first (the plugin warns at boot).
Robin never loses a job that has been fetched:
- Reliable fetch. A worker moves a job from its queue into an
in-flight key of its process in one atomic step. The job is only removed
from it after it finishes, or after its retry or death has been
recorded, and it's removed by deleting that key, so a job's arguments
travel from Redis once, however large. When several workers of a
process ask for jobs at once, one round trip fetches for all of them (up
to
config.fetch_batch, 10 by default), and finished jobs are removed with the next round trip, so under load a job costs a fraction of a round trip. Jobs fetched for a worker stay in flight until they're done; ones no worker takes within a second (because every worker got busy) go back to their queue, as do the fetched jobs of a process that stops or a queue that's paused. A finished job is removed with the next round trip of its process, or by a thread of its own if none comes within 2 milliseconds, so a crash can make only jobs that finished just before it run again. - Jobs must be idempotent. Delivery is at least once: a job runs again if its process crashes while it runs, or just after it finishes, and if a shutdown interrupts it. Write jobs so that running one twice does no harm (check whether the work was already done, or use unique keys or upserts).
- Recovery. If a process crashes, is OOM-killed or loses its node, its
heartbeat expires. The cluster leader then moves that process's in-flight
jobs back to the front of their queues.
config.recovery_handlers(likedeath_handlers) are called with each recovered job and whether it was a poison pill. A server withconfig.leader_candidate = falsenever becomes the leader, so it never runs the leader's duties (periodic jobs, recovery). - Poison pills. A job that was running when its process died more than
config.max_recoveries(3) times goes to the dead set instead of taking down process after process. - Graceful shutdown. Jobs still running when the shutdown timeout ends are interrupted and pushed back to their queue.
- Reliable push. With
config.reliable_push = true, pushes made while Redis is unreachable are buffered in memory (up to 1,000 by default) and sent in order once it's back. When the process exits it tries once more; jobs it still can't push, and jobs dropped from a full buffer, are reported to the error handlers (asRobin::ReliablePush::LostandDropped). - Deferred push.
config.push_deferrercan hold pushed jobs back, e.g. until a database transaction commits: it's called with the jobs and a block that sends them, and returns truthy when it took over. The Rails integration uses it for transaction-aware pushes.
Pause a queue (workers skip it until it's unpaused) with
Robin::Queue.new("mailers").pause! or from the dashboard.
Shutdowns and failovers are handled conservatively too:
- Jobs pushed back by a graceful shutdown don't count toward the poison-pill limit, even if the process is killed while stopping.
Robin::Shutdownonly interrupts a job'sperform, never Robin's own bookkeeping, and interrupted jobs share one 3-second grace period.- After a Redis failover (READONLY, LOADING and similar replies) the connection is dropped so the next command reaches the new primary. A command is only resent if none of it reached the server.
- The heartbeat and the leader's duties use their own Redis connections and higher-priority threads, so busy jobs can't starve them. If a heartbeat lapses anyway, the process logs a warning: its running jobs may have been recovered and run elsewhere.
- Every minute a process checks its own in-flight keys and puts back jobs that no worker holds (for example, a worker that died after finishing a job but before acknowledging it, or a round trip whose reply never arrived).
At boot, Robin refuses to start on Redis older than 6.2, with a concurrency
or shutdown timeout of 0, a Redis pool smaller than the worker count plus
two, or a queue listed twice. It warns if Redis may evict keys
(maxmemory-policy other than noeviction). An exception in a :startup
hook stops the boot.
batch = Robin::Batch.new
batch.description = "Import #{file.name}"
batch.on(:success, ImportCallbacks, "file_id" => file.id) # every job succeeded
batch.on(:complete, "ImportCallbacks#finished", "file_id" => file.id) # every job ran
batch.on(:death, ImportCallbacks) # a job died
bid = batch.jobs do
rows.each_slice(100) { |slice| ImportRows.perform_async(file.id, slice) }
end
class ImportCallbacks
def on_success(status, options)
Notifier.import_done(options["file_id"], status.total)
end
def finished(status, options) = ...
endInside a batch job, batch returns the job's batch, so a job can add more
jobs to its own batch (batch.jobs { ... }). A batch created inside
another batch's jobs block, or inside a running batch job, becomes a
child, and the parent isn't complete until its children are. You can build
multi-step workflows by nesting batches and chaining callbacks.
batch.jobs(atomic: true) { ... } pushes the block's jobs when it
finishes, all at once, and none at all if it raises (a batch it created is
deleted again). Without atomic, jobs are pushed as the block pushes
them.
Inspect batches with Robin::Batch::Status.new(bid) or on the dashboard.
To show a batch's progress on your own pages (a progress bar for an
import, say), mount Robin::Web::BatchStatus and poll it from the browser:
# config/routes.rb
mount Robin::Web::BatchStatus.new => "/batch_status"GET /batch_status/<bid>.json returns {"bid", "total", "pending", "failures", "complete", "success", "dead", "cancelled"}, or a 404 once the
batch is gone. It skips the dashboard's authorization, so it shows counts
and states only (never jobs, arguments, errors or the description), and a
bid's 80 random bits are what keep other people's batches out of reach.
Pass config: to read another Redis.
Tag a batch to find it later. Robin::Batch.list(tag: "imports") lists
the batches with that tag, newest first, and so does the dashboard's
Batches page when you click a tag:
batch.tags = ["imports", "customer:#{file.customer_id}"] # before the first `jobs`Jobs are pushed as they're created inside jobs, never collected first,
so a batch of any size takes no more memory in the process creating it.
Cancel a batch with Robin::Batch.new(bid).cancel! (or from the
dashboard). Its child batches are cancelled too. Their jobs that haven't
started are skipped, and running ones see cancelled? turn true, so they
can stop early. A cancelled batch still completes once every job has run
or been skipped, so complete callbacks fire (check
status.cancelled?), but success never does, and a cancelled child
counts as a failure in its parent.
Limiters work from any Ruby code. If a job hits a limit, it is rescheduled with backoff rather than counted as a failure.
STRIPE = Robin::Limiter.concurrent("stripe", 10, wait_timeout: 5, lock_timeout: 30)
GEO = Robin::Limiter.bucket("geocoder", 50, :second)
EMAIL = Robin::Limiter.window("smtp", 1_000, :hour) # sliding window
CRAWL = Robin::Limiter.leaky("crawler", 60, 60) # drips 1/second
OPENAI = Robin::Limiter.points("llm-tokens", 90_000, 60) # token bucket
def perform(user_id)
STRIPE.within_limit { Stripe::Charge.create(...) }
OPENAI.within_limit(points: estimated_tokens) { ... }
endWhen a limiter doesn't free up within wait_timeout, it raises
Robin::Limiter::OverLimit. Inside a job, Robin catches that and
reschedules the job with a jittered backoff. After
config.max_rate_limit_retries (20) reschedules, normal retries apply.
Points limiters can adjust or refund points once the real cost is known.
For work that can simply be dropped, such as refreshing a cache, give the
limiter policy: :skip: when it doesn't free up in time, within_limit
returns nil without running the block (and the dashboard still counts it
as over the limit).
REFRESH = Robin::Limiter.bucket("price-refresh", 10, :minute, wait_timeout: 0, policy: :skip)
REFRESH.within_limit { PriceCache.refresh(sku) }Bucket and window limiters can count one call as several uses, e.g. an
API call that covers a batch of records: GEO.within_limit(used: 10) { ... }.
Limiters keep their state in the configured Redis. To keep them in another
Redis (a shard of their own, say), wrap the calls in Robin::Limiter.via:
LIMITS = Robin::Redis::Pool.new(size: 10) { Robin::Redis::Connection.new(url: ENV["LIMITER_REDIS_URL"]) }
Robin::Limiter.via(LIMITS) { STRIPE.within_limit { charge! } }The dashboard's Limiters page shows the limiters in the configured Redis only.
Every kind of limiter also takes reschedule: (how many times a job that
hits it is rescheduled before it counts as a failure; 0 fails at once) and
backoff:, a callable that picks the delay:
PARTNER = Robin::Limiter.window("partner-api", 100, :minute,
reschedule: 5, backoff: ->(count, error, payload) { error.retry_after || 30 * count })
Robin.configure_server do |config|
config.rate_limit_backoff = ->(count, error, payload) { 10 * count } # for every limiter without one
endA backoff returning nil gets the default (5 seconds times the count, or
however long the limiter says capacity takes to free up, up to 5 minutes,
with jitter). Robin::Limiter.unlimited never limits and never touches
Redis: swap it in where a limit shouldn't apply, such as in tests.
Robin.configure_server do |config|
config.periodic do |cron|
cron.register "*/5 * * * *", "PruneSessions"
cron.register "0 9 * * MON-FRI", "DailyDigest", queue: "mailers", tz: "America/New_York"
cron.register "@hourly", "SyncInventory", args: ["warehouse-1"], description: "Pull stock levels"
end
endOnly the elected leader enqueues periodic jobs, and each tick is deduplicated in Redis, so a job runs once per tick no matter how many processes you run. After a leader failover, at most one missed tick is caught up. The dashboard lists every periodic job with its next run and history, and lets you run one now or pause it.
IANA time zones need the tzinfo gem. UTC and fixed offsets like +05:30
work without it.
In a Rails app, a periodic job can be an ActiveJob class: Robin pushes it
like perform_later would (on the given queue, else the job's own), but
without running ActiveJob's enqueue callbacks.
To catch a broken schedule before it ships, check it in your test suite.
Robin::Periodic.verify! takes the block you give config.periodic (or a
config, to check all of its blocks) and raises listing every bad cron
expression or time zone, class that doesn't exist or isn't a job, and
argument that JSON would change:
# config/initializers/robin.rb
SCHEDULE = ->(cron) { cron.register "0 3 * * *", "NightlyReport" }
Robin.configure_server { |config| config.periodic(&SCHEDULE) }
# test/robin_schedule_test.rb
test("the schedule is valid") { Robin::Periodic.verify!(&SCHEDULE) }class RebuildSearchIndex
include Robin::Job
robin_options unique_for: 600 # duplicate pushes (same args) for 10 min are dropped
end
class SyncContact
include Robin::Job
robin_options debounce: 30 # runs once, 30s after the *last* push
end
class SendFlashSale
include Robin::Job
robin_options expires_in: 3600 # skipped if it hasn't started within an hour
end
class ChargeCard
include Robin::Job
robin_options encrypt: true # arguments are encrypted at rest
end
Robin.configure do |config|
config.encryption_keys = {1 => ENV["ROBIN_KEY_V1"], 2 => ENV["ROBIN_KEY_V2"]}
end- Unique locks are released when the job succeeds (the default) or, with
unique_until: :start, when it starts. Definerobin_unique_args(args)on the class to choose which arguments count toward uniqueness. - Encryption uses AES-256-GCM, with the job id as authenticated data. New jobs are encrypted with the highest-numbered key, and any listed key can decrypt, so you can rotate keys without downtime. Decrypted arguments are never written back to Redis, including in retries and the dead set.
Iterable jobs save their position as they go. On deploys and restarts they stop between iterations and resume elsewhere, without redoing work.
class BackfillAvatars
include Robin::IterableJob
def build_enumerator(account_id, cursor:)
active_record_records_enumerator(User.where(account_id: account_id), cursor: cursor)
end
def each_iteration(user, _account_id)
user.regenerate_avatar!
end
endAlso available: array_enumerator, active_record_batches_enumerator and
csv_enumerator, plus on_start, on_resume, on_cancel, on_stop
(after an interruption or a cancellation) and on_complete hooks, and
around_iteration, which wraps each iteration (yield to run it). Inside
them and each_iteration, cursor is the last finished item's cursor and
current_object the item being worked on.
A long iteration can hold a worker for hours. Set
config.max_iteration_runtime (seconds), or the max_iteration_runtime
option on a job, and a run that goes past it checkpoints and goes back on
its queue, as it does on a deploy, so other jobs get a turn.
If an iteration raises, the progress made so far is saved first, so the
retry resumes after the last finished item. A cancelled iterable job
stops at its next checkpoint, about every five seconds: saving its
position, reporting progress and checking for cancellation share one
Redis round trip. Progress is a percentage when the enumerator knows its
size (array_enumerator does), and otherwise a count of the items done
(the Active Record and CSV enumerators). Iterable jobs don't define
perform (Robin raises if one does). Robin logs a warning when a single
iteration takes longer than the shutdown timeout, since a deploy could
interrupt it midway.
def perform(ids)
ids.each_with_index do |id, i|
return if cancelled?
process(id)
progress!((i + 1) * 100.0 / ids.size, "#{i + 1} of #{ids.size}")
end
end
jid = Export.perform_async(ids)
Robin.cancel!(jid) # jobs that haven't started are skipped; running ones see cancelled?
Robin.cancel!(*jids) # several, in one round tripProgress shows live on the dashboard's Busy page. Pass nil for the
percentage when the job can't tell how far along it is: the page shows the
message without a percentage.
class TenantMiddleware
include Robin::Middleware::ConfigAware # optional: gives you config, redis, logger
# client side: return without yielding to stop the push
def call(job_class, payload, queue, redis)
payload["tenant"] ||= Current.tenant_id
yield
end
end
class TenantServerMiddleware
def call(job, payload, queue)
Current.set(tenant_id: payload["tenant"]) { yield }
end
end
Robin.configure do |config|
config.client_middleware { |chain| chain.add TenantMiddleware }
end
Robin.configure_server do |config|
config.server_middleware { |chain| chain.add TenantServerMiddleware }
endA middleware is instantiated for every job. If yours keeps no per-job state
in instance variables, include Robin::Middleware::Stateless and one
instance serves every job instead (on every thread). The built-in
middleware all work this way.
A middleware that only matters to some jobs can say which with a class
method applies?, taking the same arguments as call. The chain skips it
for the others, without building it:
class AuditMiddleware
def self.applies?(_job, payload, _queue) = payload.key?("audit")
def call(job, payload, queue) = Audit.around(payload) { yield }
endThe built-in middleware (encryption, expiry, uniqueness, debouncing, batches, profiling) use it, so jobs that use none of those features skip them.
Failed jobs are retried with exponential backoff. The delay starts at about 15 seconds, grows 1.7x per attempt with ±20% jitter, and is capped at one week. The default 20 retries span about ten days. After the last retry the job moves to the dead set, which keeps 10,000 jobs for 180 days by default. From the dashboard you can retry dead jobs, with or without editing their arguments first.
class CallWebhook
include Robin::Job
robin_options retry: 8
robin_retry_in do |count, exception, payload|
case exception
when Net::HTTPTooManyRequests then 60 * count
when ArgumentError then :kill # straight to the dead set
when RecordGone then :discard # drop it
end # nil: default backoff
end
robin_retries_exhausted do |payload, exception|
Alerts.webhook_gave_up(payload["args"].first, exception)
:discard if payload["args"].first.nil? # drop it instead of keeping it in the dead set
end
end
Robin.configure do |config|
config.error_handlers << ->(ex, context, _config) { ErrorTracker.notify(ex, context) }
config.death_handlers << ->(payload, ex) { ... }
endError handlers run in the failed job's logging context (its class, jid, batch and tags). The default handler logs each error as one entry: the error, its context and the start of its backtrace.
Things that aren't errors but are worth knowing about (a job expired,
skipped or rate limited, a worker died, Redis is slow, the heartbeat
lapsed, a reliable push was buffered or lost, and so on) are events.
config.event_handlers are called with each event's name and a Hash
about it; Robin::Events lists them. In a Rails app they're published through
ActiveSupport::Notifications as "<event>.robin".
config.event_handlers << ->(name, payload) { Metrics.increment("robin.#{name}") }retry_for: limits retries by time instead: the job keeps retrying (with
the same backoff) until a retry would come more than that many seconds
after its first failure, and then dies. Without an explicit retry: count
it can take as many attempts as fit; with one, whichever limit comes first
applies. retry_queue: sends a job's retries to another queue, e.g. a
low-priority one, so failing jobs don't hold up fresh ones.
Delays returned by robin_retry_in get up to 10% (at most 10 seconds) of
jitter, so jobs that failed together don't all retry at the same moment.
config.retry_backoff replaces the backoff altogether: a callable
(failures, exception, payload, custom) returning the seconds before the
retry, where custom is the job's robin_retry_in answer (or nil); Robin
adds no jitter to it. An ActiveJob job's retry hooks come from the class
it wraps.
Death handlers run whenever a job won't run again: when it moves to the
dead set, and when it's discarded (retry: false, :discard from
robin_retry_in, dead: false, or :discard from robin_retries_exhausted).
Discarded jobs have payload["discarded_at"] set.
A retry or dead job is stored as it was pushed, plus the failure details.
If perform or a middleware changed the job's arguments in memory, those
changes aren't saved.
Jobs with backtrace: true (or a number of lines) keep the error's
backtrace, compressed. config.backtrace_cleaner trims it first (in a Rails
app, Rails.backtrace_cleaner); read it back with
entry.error_backtrace. retry_all, kill_all and the dashboard's
"all" actions work through a set a page at a time, so huge sets never have
to fit in memory.
Each process tracks per-class counts, total time and a latency histogram in memory (times and histograms cover successful runs only), and flushes them with its heartbeat, so recording adds no Redis round-trips per job. Data kept:
- per-minute data for the last 8 hours;
- hourly rollups for
config.metrics_retention_days(90 by default); - queue size and latency history sampled by the leader.
Robin.configure do |config|
config.statsd = Robin::Statsd.new(host: "127.0.0.1", port: 8125, prefix: "robin",
tags: {env: "production"}, dogstatsd: true)
endBesides job counts and timings and queue sizes and latency, StatsD gets
batch.created, batch.complete, batch.success, batch.death and
batch.cancelled counters and a batch.duration timing (creation to
completion), plus jobs.recovered (in-flight jobs put back from dead
processes), jobs.orphaned (in-flight jobs no worker held) and
jobs.poison (by class) counters. Under DogStatsD, the batch metrics
carry the batch's tags (tag:<tag> for each).
For percentiles across every process, add distributions: true (DogStatsD
only) to also send Datadog distributions: jobs.perform_dist (by class;
this sends a packet per successful job) and batch.duration_dist.
The dashboard also serves Prometheus metrics at <mount>/metrics. Browsers
asking for HTML get the Metrics page at that path instead.
Deploy markers. Mark each deploy, and the dashboard's metrics charts draw a line where it happened, so a change in throughput, failures or timing can be matched to the deploy that caused it:
Robin::Deploy.mark!("v42 #{`git rev-parse --short HEAD`.strip}")or bundle exec robin deploy "v42" from a deploy script. Every process
may mark the same deploy (at boot, say): a label already marked in the
last hour isn't marked again. Markers are kept as long as hourly metrics.
GVL contention. Only one of a process's threads runs Ruby code at a time; the others wait for the GVL. With the C extension, each process measures how long its threads wait, and every heartbeat records its contention: the share of the time its threads could have spent waiting that they did. Near 0%, threads rarely get in each other's way; near 100%, every thread wanted to run all the time, so more threads won't run more Ruby code. Threads waiting on I/O (Redis, a database, an HTTP call) don't wait for the GVL, so an I/O-heavy process stays low however many threads it has.
The dashboard shows each process's contention, StatsD gets it as the
gvl.contention gauge, Prometheus as
robin_process_gvl_contention_percent, and a process whose contention stays at 50% or
more for three heartbeats in a row logs a warning (at most every 10
minutes) and emits a gvl.contended event. When it's persistently high,
CPU-heavy jobs would finish sooner with fewer threads per process and
more processes (or robin swarm). Robin doesn't change your concurrency
itself. Measuring costs under 2% even in code that does nothing but
switch threads; config.gvl_monitoring = false turns it off. JRuby has
no GVL, so there's nothing to measure (nor is there on a 32-bit platform,
where the extension can't keep a wait's start with its thread).
OpenTelemetry. With the opentelemetry-api gem (and an SDK exporting
somewhere), Robin traces pushes and runs:
require "robin/opentelemetry"
Robin.configure do |config|
Robin::OpenTelemetry.install(config) # runs are children of their push
# Robin::OpenTelemetry.install(config, parent: :link) # or new traces, linked to it
endPushing opens a publish <queue> producer span and stores the trace
context in the job; running it opens a process <queue> consumer span.
Both follow the messaging semantic conventions, plus robin.job.class
and robin.job.retry_count.
Profiling one job. With gem "vernier" (CRuby only), push a job with
profile: to record a sampling profile of that run, even if it fails:
ReportJob.set(profile: true).perform_async(42)
ReportJob.set(profile: "big export").perform_async(43) # labelledProfiles are kept in Redis for a week (the newest 100). The dashboard lists
them and opens them in the Firefox Profiler;
from Ruby, use Robin::Profiler.list and Robin::Profiler.data(jid). A
process profiles one job at a time; others asking meanwhile run unprofiled.
# config/routes.rb
require "robin/web"
mount Robin::Web => "/robin"
# or config.ru
require "robin/web"
run Robin::WebThe dashboard is a TypeScript single-page app built with bun and shipped
pre-built inside the gem. Its pages are the dashboard, busy processes and
jobs (with live progress), queues, scheduled, retries, dead, batches,
limiters, cron, job profiles, and per-class metrics. It updates live by polling every
few seconds. Set Robin::Web.live_updates = :stream to push updates
over Server-Sent Events instead. Each open stream occupies a web server
thread, so streaming is off by default.
Robin::Web.authorize do |env, action, resource|
user = env["warden"]&.user
action == :read ? user&.staff? : user&.admin?
end
Robin::Web.read_only = true # nobody can change anything
Robin::Web.clusters = {"us-east" => us_config, "eu-west" => eu_config} # multi-clusterRows of your own on job pages. Add a row to every job's details, as text or as a link (http(s) URLs and paths only). The block gets the job's payload, and returning nil leaves the row out:
Robin::Web.job_info("Customer") do |job|
id = job["args"].first
{text: "##{id}", href: "/admin/customers/#{id}"} if job["class"] == "InvoiceJob"
endAssets from a CDN. Serve the dashboard's scripts, stylesheets, fonts
and images from elsewhere by copying lib/robin/web/dist/assets (that is,
Robin::Web::DIST + "/assets") there for each Robin version. The CDN must
send CORS headers, because the scripts are modules. The content security
policy lets the dashboard load from that host.
Robin::Web.assets_path = "https://cdn.example.com/robin/#{Robin::VERSION}/"Languages. The dashboard speaks Arabic, Chinese (simplified and traditional), Czech, Danish, Dutch, English, French, German, Hebrew, Hindi, Italian, Japanese, Korean, Norwegian, Persian, Polish, Portuguese (Brazil), Russian, Spanish, Swedish, Turkish, Ukrainian, Urdu and Vietnamese, with right-to-left layouts for Arabic, Hebrew, Persian and Urdu. It picks the first of the browser's languages it knows, and can be switched under Settings (the choice is remembered). Numbers, dates and durations follow the language too. Each language is a separate file, loaded only when it's chosen. The translations were machine-made; native speakers are welcome to improve them (see Development).
Changes require a same-origin request with the X-Robin-Request header
(and, from browsers that send it, a same-origin Sec-Fetch-Site), so
cross-site requests can't make them (CSRF protection). The dashboard
also sends a strict Content Security Policy.
Your own pages. Add a tab to the dashboard without writing any frontend code: describe the page with blocks, and attach actions to its buttons.
class BillingPage < Robin::Web::Extension
slug "billing"
title "Billing"
def blocks(request) # request.config, request.params, request.env
[
{type: "stats", items: [{label: "Invoices waiting", value: Robin::Queue.new("invoices").size}]},
{type: "heading", text: "Overdue"},
{type: "table", columns: ["Customer", "Total"], rows: Invoice.overdue.map { [_1.customer, _1.total] }},
{type: "text", text: "Invoices go out at 6am."},
{type: "actions", items: [{label: "Send now", action: "send", confirm: "Send every invoice?"}]}
]
end
action "send" do |request|
SendInvoices.perform_async
"Sending invoices" # the message the user sees
end
end
Robin::Web.register(BillingPage)Values in blocks are always shown as text, never as HTML. Running an
action needs the :extensions permission (the resource passed to
authorize is "extension:billing/send").
For anything blocks can't show, a page can bring its own frontend code: a JavaScript module (and a stylesheet), served from a directory of assets, plus JSON data endpoints for it to call.
class ReportsPage < Robin::Web::Extension
slug "reports"
assets File.expand_path("reports", __dir__) # served at <mount>/extensions/reports/
script "reports.js"
stylesheet "reports.css"
translations File.expand_path("reports/locales", __dir__) # de.yml, ja.yml, ...
data "late" do |request| # GET api/extensions/reports/data/late
Report.late.limit(Integer(request.params.fetch("limit", 20))).map(&:to_h)
end
action "rerun" do |request| # request.body is the JSON sent
RerunReport.perform_async(request.body.fetch("id"))
request.t("Rerunning {name}", name: request.body.fetch("name"))
end
end// reports/reports.js: called with an element to render into and a context
export default function render(element, robin) {
const heading = document.createElement("h2");
heading.textContent = robin.t("Late reports");
element.append(heading);
robin.data("late", { limit: 50 }).then((reports) => { /* draw them */ });
// robin.action("rerun", { id, name }) resolves to the action's message;
// robin.toast(message), robin.navigate("/queues"), robin.locale, robin.slug
return () => { /* the page went away: stop timers, remove listeners */ };
}The script runs with the dashboard's own Content Security Policy, so it
can load further files from its assets directory but nothing from other
sites, and inline <script> and <style> elements are blocked. Translations are YAML files
mapping English text to the language (de.yml: Late reports: Verspätete Berichte); they translate the page's title in the navigation, text passed
to request.t in blocks and actions, and robin.t in the script. Viewing
the page, its assets and its data needs the :read permission.
The Rails integration is part of the robin gem, and turns itself on in
a Rails app: Bundler loads robin after Rails, and robin then joins the
app's boot. Elsewhere it stays off and loads nothing of Rails. If robin is
required before Rails (or outside Bundler.require), add
require "robin/rails" after Rails. It needs Rails (railties and Active
Job) 7.2 or newer, and is tested in CI against the latest 7.2, 8.0 and 8.1
releases.
# Gemfile
gem "robin"bin/rails generate robin:install # config/robin.yml, an initializer, the dashboard route
bin/rails generate robin:job report queue:low retry:5 # app/jobs/report_job.rb and its test
bundle exec robin # boots the Rails app in this directoryrobin:install creates:
config/robin.yml, the server settings (concurrency, queues, timeout, per-environment sections) read bybundle exec robinconfig/initializers/robin.rb, withRobin.configure(every process) andRobin.configure_server(only the robin process) blocks, and a commentedRobin::Web.authorizeexample- a
mount Robin::Web => "/robin"line inconfig/routes.rb. The dashboard can see and change every job, so protect it: withRobin::Web.authorize, or by mounting it inside an authenticated route constraint (e.g. Devise'sauthenticate :user, ->(user) { user.admin? } do ... end).
Running it again changes nothing.
robin:job NAME [option:value ...] creates app/jobs/NAME_job.rb, a
Robin::Job with those robin_options (queue defaults to "default";
numbers, true and false stay literals, and a bare option means
true), and a test: spec/jobs/NAME_job_spec.rb when the app generates
RSpec tests, test/jobs/NAME_job_test.rb otherwise. Pass
--test-framework=rspec or test_unit to choose.
In a Rails app:
- Executor and reloader. Every job runs inside the app's reloader, so code reloading, query caches and connection checkin work as in a request.
bundle exec robinboots the Rails app in the current directory (or the one given with-r), andRAILS_ENVpicks theconfig/robin.ymlsection.-e productionsetsRAILS_ENVtoo.- Environment and logging.
Robin.config.environmentfollowsRails.env(set before your initializers, so they can override it). Outside the robin process (web, console), Robin logs toRails.logger. - Error reporting. Errors go to the Rails error reporter
(
Rails.error), so Sentry, Honeybadger, AppSignal and friends see them through their Rails integration. A failed job is reported as unhandled with source"robin.job"and contextrobin(what failed),job_class,jid,queue,retry_countandbid, never its arguments. Robin's own errors (e.g. Redis unreachable) are reported as handled with source"robin". The executor doesn't report jobs' exceptions itself, andRails.errorreports an exception only once, so nothing is reported twice.Robin::Discardis never reported. - ActiveJob.
config.active_job.queue_adapter = :robinruns ActiveJob jobs on Robin. They can userobin_options(retries, tags, expiry...),perform_all_latertakes one round trip, however many classes and queues its jobs have, and job continuations checkpoint when the server shuts down. Periodic jobs can be ActiveJob classes (cron.register "0 3 * * *", "NightlyReportJob"): Robin pushes them asperform_laterwould, on the given queue or the job's own, but without running enqueue callbacks.Robin::Jobcan't be included in ActiveJob classes: use one or the other. - Transaction-aware pushes (opt-in): jobs pushed inside Active Record transactions are sent once every open transaction commits, and dropped when it rolls back, so a job never runs before its data is visible. The jobs pushed in the same transaction go in one round trip.
- Current attributes (opt-in): the
ActiveSupport::CurrentAttributesclasses you list (e.g.Current) are saved in each job as it's pushed and set while it runs, soCurrent.userorCurrent.tenantmean the same thing in the job as where it was pushed. They're serialized like ActiveJob arguments (records travel as GlobalIDs) and stored as they are, readable in Redis and the dashboard, so leave secrets out. Classes are looked up by name, so code reloading keeps working, and one renamed since a job was pushed is skipped.
# config/application.rb
config.active_job.queue_adapter = :robin
config.robin.report_errors = true # the default; false to stop reporting to Rails.error
config.robin.transactional_push = true # push jobs only after Active Record transactions commit
config.robin.current_attributes = ["Current"] # carry Current.* from where a job is pushed into the job
config.robin.notifications = false # stop publishing Robin's events as "<event>.robin" notificationsWith notifications (on by default), Robin's events (see Robin::Events)
are published through ActiveSupport::Notifications as "<event>.robin":
ActiveSupport::Notifications.subscribe("job.expired.robin") do |event|
Rails.logger.info("expired before it ran: #{event.payload[:job]["jid"]}")
endOutside Rails' configuration, the same pieces are available directly:
Robin.config.push_deferrer = Robin::Rails::TransactionalPush,
Robin.config.error_handlers << Robin::Rails::ErrorReporter.new(Rails.error),
Robin.config.event_handlers << Robin::Rails::Notifications and
Robin::Rails::CurrentAttributes.install(Robin.config, ["Current"]).
require "robin/testing" # fake mode by default
SendReceipt.perform_async(1)
assert_equal 1, SendReceipt.jobs.size
SendReceipt.drain # run them
Robin::Testing.inline! { SendReceipt.perform_async(1) } # run immediately
Robin::Testing.disable! { ... } # talk to real RedisA mode set with a block applies to the current thread only, so tests can run in parallel; without a block it's the default for every thread.
Everything in the dashboard is also available from Ruby:
stats = Robin::Stats.new
stats.processed; stats.failed; stats.enqueued; stats.queues; stats.history(days: 7)
queue = Robin::Queue.new("mailers")
queue.size; queue.latency; queue.pause!
queue.each { |job| job.delete if job.args.first == 42 }
queue.delete_by_class("ReportJob") # inside Redis, a slice at a time
Robin::RetrySet.new.scan("NoMethodError").each(&:retry!)
Robin::DeadSet.new.retry_all
Robin::ScheduledSet.new.find_job(jid)&.add_to_queue
Robin::JobLocator.new.locate(jid) # running, scheduled, retrying, dead or queued
Robin::ProcessSet.new.each(&:quiet!)
Robin::ProcessSet.new.first.resize!(20, capsule: "default") # workers, from its next heartbeat
Robin::WorkSet.new.each { |identity, thread, work| ... }
Robin::Metrics::Query.new.recent(minutes: 60)delete_by_class matches the class the dashboard shows (an ActiveJob's
own class, not the wrapper's) and returns how many jobs it deleted; the
dashboard does the same when the jobs you've selected on a queue's page
share a class. Looking a job up by jid also searches queues inside Redis,
a slice at a time.
With config.jid_index = true, Robin also remembers which queue each job
was pushed to (for a week, or the number of seconds you give), so lookups
search that queue first: one small key per job, in exchange for fast
lookups in large backlogs.
Robin never gets slower: a change that makes it slower at any of its
benchmarks fails CI, whatever else it improves, and is backed out or
fixed at once. That's WebKit's performance policy,
enforced by Duel, which runs
Robin's benchmarks (duel/) on each change and on the code it
changes, side by side, and fails when the change is slower at any of
them: by CPU time, instructions, Redis CPU, allocations, round trips,
Redis commands or peak memory. Each night it also compares main with
the last release, so small slowdowns can't add up unnoticed.
CONTRIBUTING.md has the
policy and how the guards tell a real regression from noise.
On an Apple M1 Max with local Redis, one CRuby process (YJIT) with 10
threads runs about 120,000 no-op jobs per second, a cluster of processes
about 280,000 against one Redis, and perform_bulk pushes about 360,000
jobs per second. Workers asking for jobs at the same time share one round
trip to Redis, so under load a job costs a fraction of one.
Against Sidekiq 8.1 OSS on the same machine and Redis
(rake bench:compare), Robin is many times faster at many operations:
| Robin vs Sidekiq | |
|---|---|
| Processing jobs | 5.1x faster |
| Processing jobs with Redis 1 ms away | 8.8x faster |
| A cluster of processes on one Redis | 4.8x faster |
| Moving scheduled jobs onto queues when due | 61x faster |
| Requeuing retries after an outage | 28x faster |
| Redis CPU per job | 8x faster |
| Jobs lost when a server is killed | 0 vs 10 |
bench/RESULTS.md has the detailed results on Ruby 3.4, Ruby 4.0 and JRuby, what each benchmark tests and how they were run.
The optional C extension encodes commands, then sends them, waits for the replies and reads them in one release of the GVL, checking for dropped connections in the same call. It also parses replies 3–10x faster than pure Ruby, which speeds up the dashboard and the data API on large queues. The Java extension parses, encodes and reads on JRuby and keeps the connection pool's bookkeeping lock-free.
Every key starts with config.key_prefix (default robin:), so Robin can
share a database with other data. Robin uses Lua scripts for its atomic
operations and doesn't support Redis Cluster; use a single primary, with a
replica for failover if you need one.
The keys a job's life runs through (queue:<name>, queues, schedule,
retry, dead, paused, processes, stat:*, and each running
process's record at its identity) keep the formats common Ruby job tools
share: queues are lists pushed at the head and taken from the tail; the
schedule, retry and dead sets are scored in epoch seconds; jobs are JSON
with created_at and enqueued_at in epoch milliseconds, a 0-based
retry_count and a compressed error_backtrace. Robin's own keys
(batches, limiters, metrics, in-flight jobs...) can get a prefix of
their own with config.internal_key_prefix, so with key_prefix = "" the
job keys sit at the top level, where other tools look for them.
A process keeps each job it has fetched in a list of its own,
inflight:<identity>:<n>, registered in the set inflight:<identity>
(listed in inflights) before a job is first moved into it; a key is
used again once its job is acknowledged. Recovery still reads the single
inflight:<identity> list that processes of earlier development versions
kept their jobs in.
bundle install
rake compile # build the C extension (optional); on JRuby, the Java one
rake test # needs a local Redis; uses db 15 (ROBIN_TEST_REDIS_URL)
# one process per test file, TEST_JOBS at a time
# (default: one per CPU); rake test:serial for one process
rake test:rails # just the Rails integration's tests (test/rails/)
rake web:build # rebuild the dashboard (needs bun)
cd web && bun run dev # dashboard watch mode
rake bench
rake bench:compare # Robin vs Sidekiq (installs bench/compare/Gemfile)
rake perf # fails if this tree is slower than origin/main (Duel; needs redis-server)To try things out by hand, demo/ is a small app with
sample jobs and seed data: cd demo && bin/seed && bin/dev.
Robin holds itself to 100% test coverage and 100% mutation coverage, on the Ruby, C, Java and TypeScript sides. CI enforces it:
| Check | Command |
|---|---|
| Ruby lint | bundle exec rubocop and bundle exec standardrb |
| Ruby tests (MRI and JRuby) | rake test |
| Ruby coverage: 100% lines, branches and methods | COVERAGE_STRICT=1 rake test |
| Ruby mutation coverage: 100% | bundle exec mutant run |
Ruby types: RBS signatures (sig/) and Steep, strict (CRuby) |
rake steep |
| Performance: never slower than the baseline at any benchmark (Duel; CONTRIBUTING.md) | rake perf |
C lint: clang-format, clang-tidy, -Werror |
rake c:lint |
| C coverage: 100% lines, branches and functions | rake c:coverage (needs gcovr) |
Java lint: google-java-format, javac -Xlint:all -Werror |
rake java:lint |
| Java coverage: 100% lines, branches and methods (JaCoCo) | rake java:coverage |
| Java mutation coverage: 100% (PIT) | rake java:mutation |
| Frontend lint and types | cd web && bun run lint && bun run typecheck |
| Frontend coverage: 100% | cd web && bun run test:coverage |
| Frontend mutation coverage: 100% | cd web && bun run test:mutation |
The Java tasks run under JRuby with a JDK 21 (or set JRUBY_JAR to JRuby's
lib/jruby.jar); they download their pinned, checksummed tools into
tmp/java-tools. The java-platform gem is built under JRuby with
rake java:compile and then ROBIN_PLATFORM=java gem build robin.gemspec.
Translating the dashboard. Messages are written in English in the
code, wrapped in t() (or tn() for plurals), and each language's
web/src/locales/<code>.ts maps them to translations. bun run scripts/messages.ts de (in web/) lists what German is missing, and the
tests check that every catalogue has every message, keeps its
placeholders and gives each plural form its language uses.
Tests pin how many Redis round trips Robin makes with assert_roundtrips
(from test/support/roundtrips.rb), so a change that adds one fails:
assert_roundtrips(1) { Robin::Client.push("class" => "Thing", "args" => [1]) }It counts the calls, pipelines and transactions the block's thread sends
(all_threads: true for every thread's), not connecting or reloading a
Lua script Redis lost, takes an Integer or a Range, returns the block's
value, and lists the commands of each round trip when the count is wrong.
Assertions are strict (minitest-strict): assert, refute and the
predicate and operator assertions take only true and false, so a
method that returns nil where it means false fails its test. On CRuby,
test/allocations_test.rb holds the paths every job takes (pushing,
fetching, running, the middleware chain, metrics, logging) to allocation
budgets with minitest-memory's assert_allocations; re-measure them with
ROBIN_MEASURE_ALLOCATIONS=1.
Mutation testing starts many test processes at once. Point
ROBIN_TEST_REDIS_URL at a Redis listening on a Unix socket (e.g.
unix:///tmp/redis.sock?db=15) so they don't exhaust local TCP ports.
Robin is released under the GNU Lesser General Public License v3.0 (see also COPYING for the GPL v3 it builds on). You can use Robin in proprietary applications. Changes to Robin itself must be shared under the same license.
The Robin mascot and logos in assets/ are part of the project and
licensed under the same terms.