# Wurk
> Wurk is a 100% API-compatible, free, faster drop-in replacement for Sidekiq, Sidekiq Pro, and Sidekiq Enterprise. It uses the same Redis key schema, the same job JSON, and the same Ruby DSL, so an existing Sidekiq app migrates with a one-line Gemfile change. Pro and Enterprise features (batches, rate limiters, unique jobs, periodic/cron, encryption) ship in the same gem with no license check. It is faster than Sidekiq via a fork-based swarm that gives real multi-core parallelism. Requires Ruby >= 3.2 and Redis >= 7.0; on JRuby/TruffleRuby/Windows it falls back to threads-only mode, behaviorally equivalent to stock Sidekiq.
> Prefer everything in one fetch? See [llms-full.txt](https://developerz-ai.github.io/wurk/llms-full.txt) — this map plus the full text of every guide below, inlined.
## Three pillars
- **Drop-in.** Same Redis key schema, job JSON, and Ruby DSL. Every public `Wurk::*` class is also exposed under its `Sidekiq::*` name (`Sidekiq::Job`, `Sidekiq::Worker`, `Sidekiq::Batch`, `Sidekiq::Limiter`, `Sidekiq.configure_server`, `Sidekiq::Client`, `Sidekiq::Pro::Web`, `Sidekiq::Enterprise`). Existing jobs, initializers, `sidekiq_options`, and live Redis data keep working. `Sidekiq.pro?` / `Sidekiq.ent?` return `false` (Wurk advertises as free OSS) — never gate behavior on them.
- **Free.** Pro + Enterprise feature parity in one gem. No tiers, no flags, no license check.
- **Faster.** Every release is benchmarked against stock Sidekiq on enqueue, fetch+execute, bulk enqueue, swarm boot, and memory.
## Concurrency vs parallelism (the #1 migration gotcha)
Sidekiq runs one process with a thread pool. Wurk runs a swarm of forked processes, each with its own thread pool, for real CPU parallelism on MRI. Two independent knobs:
- **Parallelism** = number of forked worker processes. Set with the `WURK_COUNT` env var (alias `SIDEKIQ_COUNT`). Defaults to the CPU core count. There is **no Sidekiq equivalent** — Sidekiq never forks.
- **Concurrency** = threads per process. Set with `config.concurrency`, the CLI `-c` flag, YAML `:concurrency`, or the `RAILS_MAX_THREADS` env var. Defaults to 5. There is **no `WURK_CONCURRENCY` env var.**
- **Total in-flight jobs = `WURK_COUNT × concurrency`.** Each forked process opens its own DB pool and Redis pool, so on a 16-core box the default is 16 processes — size your database `max_connections` and memory before the first deploy.
## Running it
- **Rails engine (default).** Mount `Wurk::Engine => "/wurk"` for the dashboard. The railtie auto-starts an embedded swarm in every non-console Rails process unless `WURK_DISABLED=1`.
- **Clustered Puma gotcha.** Under clustered Puma (`workers > 0`) every Puma worker would fork its own swarm. Run a dedicated worker process and set `WURK_DISABLED=1` on the web role so it serves HTTP (and the dashboard) without forking workers.
- **Standalone worker.** There is no `sidekiq` binary; the gem ships `wurk` (single process, like `sidekiq`) and `wurkswarm` (alias `sidekiqswarm`; forks `WURK_COUNT` children for real parallelism — the only way to get multi-process parallelism without Rails). `WURK_COUNT` only affects `wurkswarm` and the Rails engine swarm, not plain `wurk`. Neither runner loads the dashboard engine but both boot your app and define the ActiveJob `:wurk` / `:sidekiq` adapters before the environment loads. Example: `bundle exec wurkswarm -C config/wurk.yml -e production`.
## Third-party gem mappings
- `sidekiq-cron` → native `config.periodic { |mgr| mgr.register("*/5 * * * *", MyJob) }`. There is no `Sidekiq::Cron::Job` shim by design.
- `sidekiq-unique-jobs` → native uniqueness: call `Sidekiq::Enterprise.unique!` once, then `sidekiq_options unique_for: 600, unique_until: :success` (`:start` releases the lock at perform start). Customize the dedup key with `self.sidekiq_unique_context(job)`.
- `sidekiq-scheduler`, `sidekiq-status`, `sidekiq-failures`, `sidekiq-throttled` work unchanged; their upstream suites run against Wurk in CI.
## Docs
- [Migration guide (Sidekiq → Wurk)](https://github.com/developerz-ai/wurk/blob/main/docs/migrate-from-sidekiq.md): the comprehensive cutover — parallelism, clustered Puma, dedicated worker, gem mappings, known incompatibilities, cutover checklist.
- [Generated API reference (YARD)](https://developerz-ai.github.io/wurk/api/): params, return types, and examples for the public classes (`Wurk::Worker`, `Wurk::Client`, `Wurk::Configuration`, `Wurk::Batch`, `Wurk::Limiter`, `Wurk::Unique`, the `Sidekiq::*` aliases).
- [README](https://github.com/developerz-ai/wurk/blob/main/README.md): overview, install, dashboard.
- [Configuration reference](https://github.com/developerz-ai/wurk/blob/main/docs/configuration.md): every option, env var, YAML key and CLI flag, with precedence, pool sizing, capsules, lifecycle events, error handlers, logging.
- [Deployment](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md): systemd unit, Capistrano, Heroku/Procfile, signals, rolling restarts, `SIDEKIQ_MAXMEM_MB`, Docker, Kubernetes probes, leader election, sizing.
- [Active Job adapter](https://github.com/developerz-ai/wurk/blob/main/docs/active-job.md): `queue_adapter = :wurk`.
- [Testing jobs](https://github.com/developerz-ai/wurk/blob/main/docs/testing.md): fake/inline/disable modes, the jobs array, Minitest + RSpec setup. NOTE: `require "sidekiq/testing"` does NOT imply fake mode in Wurk — set the mode explicitly.
- [Retries, backoff & the dead set](https://github.com/developerz-ai/wurk/blob/main/docs/retries.md): the failure lifecycle, `retry:` values, the `(count ** 4) + 15` backoff, `sidekiq_retry_in`, exhaustion, death handlers, dead-set limits.
- [Batches](https://github.com/developerz-ai/wurk/blob/main/docs/batches.md): `Sidekiq::Batch`, success/complete/death callbacks, `Batch::Status`, nesting, expiry.
- [Rate limiting](https://github.com/developerz-ai/wurk/blob/main/docs/rate-limiting.md): concurrent/bucket/window/leaky/points limiters, `within_limit`, `OverLimit` handling.
- [Periodic (cron) jobs](https://github.com/developerz-ai/wurk/blob/main/docs/periodic-jobs.md): `config.periodic`, cron syntax, timezones, leader-gated single fire, missed windows.
- [Unique jobs](https://github.com/developerz-ai/wurk/blob/main/docs/unique-jobs.md): `unique_for`/`unique_until`, lock-key derivation, `sidekiq_unique_context`, what it does not guarantee.
- [Iterable jobs](https://github.com/developerz-ai/wurk/blob/main/docs/iterable-jobs.md): `build_enumerator`/`each_iteration`, cursors, interruption and resumption, cancellation.
- [Reliability](https://github.com/developerz-ai/wurk/blob/main/docs/reliability.md): the delivery guarantee and its limits, reliable fetch, the orphan reaper, `reliable_scheduler!` (NOT a no-op), client-side outage buffering, transaction-aware client.
- [Middleware](https://github.com/developerz-ai/wurk/blob/main/docs/middleware.md): client vs server chains, `call` signatures, registration and ordering, the built-ins.
- [Data API](https://github.com/developerz-ai/wurk/blob/main/docs/api.md): `Stats`, `Queue` (incl. pause/unpause), `JobRecord`, the sorted sets, `ProcessSet`/`WorkSet`, the fast Lua paths, leader election.
- [Metrics](https://github.com/developerz-ai/wurk/blob/main/docs/metrics.md): job-execution metrics, the `Metrics::Query` API, Statsd/DogStatsD, custom historical metrics.
- [Metrics history](https://github.com/developerz-ai/wurk/blob/main/docs/metrics-history.md): the rollup buckets and retention behind the dashboard charts.
- [Encryption](https://github.com/developerz-ai/wurk/blob/main/docs/encryption.md): AES-256-GCM on the last argument, key sources, rotation, decryption failures.
- [Profiling](https://github.com/developerz-ai/wurk/blob/main/docs/profiling.md): per-job Vernier profiles, storage and retention, viewing them.
- [Securing the dashboard](https://github.com/developerz-ai/wurk/blob/main/docs/dashboard.md): auth quick reference and web-extension registration for the mounted engine.
- [Authentication & authorization](https://github.com/developerz-ai/wurk/blob/main/docs/authentication.md): full auth guide — Devise/Warden, Sorcery, Basic auth, API tokens, route gates, role-based read/write, read-only mode, CSRF.
## Authoritative API specs
- [Sidekiq OSS surface](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-free.md): the exact free API Wurk matches.
- [Sidekiq Pro surface](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-pro.md): batches and reliability.
- [Sidekiq Enterprise surface](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-ent.md): rate limiters, unique jobs, periodic, encryption.
---
# Full documents
The complete text of each guide linked above, inlined for single-fetch reading.
---
# Migrating from Sidekiq to Wurk
Wurk is a clean-room, **wire-compatible** drop-in for Sidekiq + Sidekiq Pro + Sidekiq
Enterprise: the same Redis key schema, the same job JSON, and the same Ruby DSL. In
the common case the migration is a one-line `Gemfile` change — your existing jobs,
batches, limiters, cron entries, and live Redis data keep working untouched, and the
Pro/Enterprise features ship in the same free gem with no license check.
This guide covers what stays the same, the two knobs that surprise people
(parallelism vs concurrency), how to run a dedicated worker, the third-party gem
mappings, and a one-page cutover.
- **Authoritative API surface:** [`docs/target/sidekiq-free.md`](target/sidekiq-free.md) ·
[`sidekiq-pro.md`](target/sidekiq-pro.md) · [`sidekiq-ent.md`](target/sidekiq-ent.md)
- **Generated API reference (YARD):**
- **Why this is legal:** [`docs/clean-room.md`](clean-room.md)
> Verified against Wurk's Sidekiq-compat layer (`lib/wurk/compat.rb`), which mirrors
> Sidekiq **8.1.x**. Requires **Ruby ≥ 3.2** and **Redis ≥ 7.0**. On JRuby /
> TruffleRuby / Windows, Wurk falls back to threads-only mode (no fork),
> behaviorally equivalent to stock Sidekiq.
---
## TL;DR — flip the switch
```diff
# Gemfile
- gem "sidekiq"
- gem "sidekiq-pro" # if you had them
- gem "sidekiq-ent"
+ gem "wurk"
```
```bash
bundle install
```
That's it for code. Every public `Sidekiq::*` name resolves to its Wurk
implementation (`Sidekiq::Worker`, `Sidekiq::Job`, `Sidekiq::Batch`,
`Sidekiq::Limiter`, `Sidekiq.configure_server`, `Sidekiq::Client`, …), so your jobs,
initializers, and `sidekiq_options` keep compiling as-is.
The dashboard is a mountable Rails engine (precompiled — no Node needed):
```ruby
# config/routes.rb
mount Wurk::Engine => "/wurk" # replaces `mount Sidekiq::Web => "/sidekiq"`
```
Optionally scaffold an initializer:
```bash
bin/rails g wurk:install # writes config/initializers/wurk.rb
```
**Because the Redis schema is identical, a rolling deploy is safe** — Sidekiq and Wurk
processes can run against the same Redis during cutover, each picking up the other's
enqueued jobs. Roll back by reverting the `Gemfile` line; no data migration either way.
> ⚠️ **The one thing to size before you ship:** Wurk forks one worker process *per CPU
> core* by default, each running its own thread pool — so a single Sidekiq process
> becomes N processes on the same box. Read [§2](#2-concurrency-vs-parallelism-read-this)
> before the first production deploy; it's the only behavioral surprise in the swap.
---
## 1. Configuration: `Sidekiq.configure_server` ↔ `Wurk.configure_server`
The configuration block is identical, and `Sidekiq.configure_server` /
`Sidekiq.configure_client` are aliased to the Wurk methods — so existing initializers
need **no change**. Written natively:
```ruby
# Sidekiq # Wurk (Sidekiq.* aliases also work)
Sidekiq.configure_server do |config| Wurk.configure_server do |config|
config.redis = { url: ENV["REDIS_URL"] } config.redis = { url: ENV["REDIS_URL"] }
config.concurrency = 10 config.concurrency = 10
config.queues = %w[critical default] config.queues = %w[critical default]
end end
```
Config options verified identical (`lib/wurk/configuration.rb`):
| Option | Notes |
|---|---|
| `concurrency` | threads per worker process (default `5`) |
| `queues` | ordered/weighted queue list |
| `redis = { url:, … }` | defaults to `ENV["REDIS_URL"]` → `redis://localhost:6379/0` |
| `logger`, `logger =` | standard `Logger` |
| `timeout` | job + shutdown grace seconds (default `25`) |
| `error_handlers`, `death_handlers` | arrays of callables |
| `client_middleware` / `server_middleware` | same `Chain#add/remove/insert_before` API |
| `on(:startup\|fork\|quiet\|shutdown\|exit\|heartbeat\|beat\|leader)` | lifecycle hooks |
| `capsule(name) { … }` | multi-queue capsules (Sidekiq 7+) |
| `periodic { \|mgr\| mgr.register(...) }` | cron jobs (Enterprise parity, free) |
> ℹ️ **`config.on(:fork)`** fires in each swarm child after fork — Wurk already
> reconnects DB/Redis for you (it closes parent connections before forking and each
> child opens a fresh pool), so you only need a fork hook for *your own* non-fork-safe
> libraries (sockets, threads). It does not fire in single-process (non-swarm) mode.
---
## 2. Concurrency vs parallelism (read this)
This is the **single biggest difference** from Sidekiq, and the #1 source of
migration surprises. Sidekiq runs one process with a thread pool. Wurk runs **a swarm
of forked processes, each with its own thread pool**, for real CPU parallelism on
MRI (the GIL means threads alone can't parallelize Ruby CPU work).
Two independent knobs:
| Knob | What it controls | How to set it | Default |
|---|---|---|---|
| **Parallelism** | Number of forked **worker processes** (real OS processes, true CPU parallelism) | `WURK_COUNT` (or the `SIDEKIQ_COUNT` alias) env var | **CPU core count** (`Etc.nprocessors`) |
| **Concurrency** | **Threads per process** (Sidekiq-style; great for IO-bound, GIL-bound for CPU) | `config.concurrency`, CLI `-c`, YAML `:concurrency`, or `RAILS_MAX_THREADS` | `5` |
> There is **no `WURK_CONCURRENCY` env var.** Threads-per-process is `config.concurrency`
> / `-c` / `RAILS_MAX_THREADS` (the same env knob Sidekiq honors). `WURK_COUNT` is the
> *new* knob — it has no Sidekiq equivalent because Sidekiq never forks.
> ⚠️ **`WURK_COUNT` only applies to the forking runners** — the Rails engine's
> auto-boot swarm and the standalone `bundle exec wurkswarm` (alias `sidekiqswarm`).
> Plain `bundle exec wurk` is a **single process** (one thread pool, like `sidekiq`);
> it ignores `WURK_COUNT`. Use `wurkswarm` when you want multi-process parallelism
> outside Rails. See [§3](#3-running-a-worker-process).
### Total in-flight jobs = `WURK_COUNT × concurrency`
A whole-number `WURK_COUNT` is an absolute process count; a fractional value is a CPU
multiplier (`WURK_COUNT=0.5` → half the cores, rounded). The result is floored at 1.
```text
16-core box, defaults: 16 processes × 5 threads = 80 jobs in flight at once
+ 16 separate DB connection pools
```
**That last line is the foot-gun.** Each forked process opens its own DB pool, its
own Redis pool, and carries its own memory footprint. A Sidekiq box that comfortably
ran `concurrency: 25` in one process can exhaust your Postgres `max_connections` or
your RAM the moment it becomes 16 processes × 25 threads = 400 connections.
### Worked example: mapping a Sidekiq `concurrency: 10` app
Your Sidekiq process ran 10 threads = 10 jobs in flight, 10 DB connections.
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.concurrency = 5 # threads per process
end
```
```bash
# Pick the process count to land near your old in-flight number. WURK_COUNT
# drives the forking runner (wurkswarm), or the Rails engine's auto-boot swarm:
WURK_COUNT=2 bundle exec wurkswarm # 2 × 5 = 10 jobs in flight (matches Sidekiq, now on 2 cores)
WURK_COUNT=4 bundle exec wurkswarm # 4 × 5 = 20 in flight — 2× the throughput, 4× the CPU parallelism
bundle exec wurk -c 10 # or stay single-process (no fork) with 10 threads, Sidekiq-like
```
In a Rails app you don't run either binary — the engine auto-boots the swarm and
reads `WURK_COUNT` itself; set the env var on the worker role.
**Size your database pool for the per-process thread count, then check the total.**
Each process needs `pool >= concurrency` in `database.yml`:
```yaml
# config/database.yml
production:
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5).to_i %> # per-process; must cover concurrency
```
Then verify the whole box fits: **`WURK_COUNT × pool ≤ your DB's spare connections`**.
On the 16-core default that's 16 × 5 = 80 connections from one host — size
`max_connections` (or PgBouncer) accordingly, or cap `WURK_COUNT`.
> **Rule of thumb:** start with `WURK_COUNT` = cores you want to dedicate to jobs and
> `concurrency` = 5 for IO-bound work. Raise `concurrency` for IO-heavy jobs (HTTP,
> Redis, slow SQL); raise `WURK_COUNT` for CPU-heavy jobs. Always re-check the DB-pool
> and memory math after either change.
---
## 3. Running a worker process
### Dedicated worker: `wurk` vs `wurkswarm`
The gem ships two standalone runners (and an alias) — there is **no `sidekiq`
binary**, so update any `bundle exec sidekiq` invocation, Procfile line, and
systemd/Capistrano unit:
| Binary | What it does | Sidekiq equivalent |
|---|---|---|
| `bundle exec wurk` | One process, one thread pool | `sidekiq` |
| `bundle exec wurkswarm` | Forks `WURK_COUNT` worker children from one preloaded parent — fork-based real parallelism | `sidekiqswarm` |
`sidekiqswarm` is shipped as an alias for `wurkswarm`, so an existing Enterprise
invocation drops in unchanged. **Use `wurkswarm` for a multi-process worker host**
(it's the only way to get fork-based parallelism without Rails); use `wurk` for a
single-process worker. Both take the familiar flags (`-c` concurrency, `-q` queue,
`-r` require, `-t` timeout, `-e` environment, `-C` config), read a YAML config with
`-C path`, and auto-discover `config/wurk.yml` then `config/sidekiq.yml` (`.erb`
supported). The YAML structure matches Sidekiq's `sidekiq.yml`.
```bash
bundle exec wurkswarm -C config/wurk.yml -e production # forked swarm (real parallelism)
bundle exec wurk -C config/wurk.yml -e production # single process
```
Neither runner loads the Rails engine (the dashboard) — by design, so a worker host
stays lean. They do fully boot your Rails app (`-r`/the environment) so your jobs and
models are available.
> ✅ **ActiveJob works standalone.** If your app uses
> `config.active_job.queue_adapter = :wurk` (or `:sidekiq`), the standalone CLI now
> defines the adapter *before* the Rails environment loads, so a dedicated worker
> process boots cleanly. (Earlier builds raised `uninitialized constant WurkAdapter`
> in standalone mode — fixed in [#253](https://github.com/developerz-ai/wurk/issues/253).)
For an example systemd unit and the Capistrano / deploy signal dance (replacing
`capistrano-sidekiq`), see [`docs/deployment.md`](deployment.md). A `Procfile`
worker line is simply:
```procfile
worker: bundle exec wurkswarm -e production
```
### Clustered Puma + the embedded swarm (important)
When you mount the engine, the railtie **auto-starts an embedded swarm inside every
non-console Rails process** (unless `WURK_DISABLED=1`, Rails console, or the Rails
test env). Under **clustered Puma** (`workers > 0`) that means **every Puma worker
forks its own Wurk swarm** — N Puma workers × `WURK_COUNT` children = a lot of
duplicate worker processes you didn't intend, all fetching the same queues.
**The fix: run workers in a dedicated process and disable the embedded swarm on the
web role.**
```bash
# Web dyno / Puma role — serve HTTP only, no jobs:
WURK_DISABLED=1 bundle exec puma -C config/puma.rb
# Worker dyno / role — run the jobs (wurkswarm for multi-process parallelism):
bundle exec wurkswarm -e production
```
This mirrors the standard Sidekiq topology (Puma for web, a separate `sidekiq`
process for jobs) — you just set `WURK_DISABLED=1` on web so the engine mount keeps
serving the dashboard without also forking workers. Leave the embedded swarm on only
if you intentionally want web processes to also run jobs (single-dyno / hobby setups).
---
## 4. Redis key layout: identical, no namespace
Wurk reads and writes the **exact same keys** as Sidekiq OSS (`lib/wurk/keys.rb`),
with **no global namespace/prefix** (matching Sidekiq OSS). Job payloads are **JSON**
(never MessagePack), with args stored as-is.
| Key | Type | Same as Sidekiq? |
|---|---|---|
| `queue:` | LIST | ✅ identical |
| `queues` | SET | ✅ identical |
| `paused` | SET | ✅ identical |
| `schedule`, `retry`, `dead` | ZSET (score = float Unix seconds) | ✅ identical |
| `processes` + per-process HASH | SET/HASH | ✅ identical |
| `stat:processed[:]`, `stat:failed[:]` | STRING | ✅ identical |
| `b-*`, `batches` | HASH/SET/ZSET | ✅ Pro batch schema |
| `loops:`, `periodic` | HASH/SET | ✅ Enterprise periodic schema |
Job JSON fields are the Sidekiq set: `class, args, queue, jid, created_at,
enqueued_at, retry, retry_count, failed_at, retried_at, error_class, error_message,
error_backtrace` (base64+zlib), plus the optional Pro/Ent fields (`bid, tags,
expiry, …`). The dead set is trimmed by `dead_max_jobs` (default 10,000) and
`dead_timeout_in_seconds` (default 180 days), same as Sidekiq.
**Implication:** a mixed fleet (some Sidekiq, some Wurk) on one Redis is safe, and
the Sidekiq web UI / `redis-cli` introspection you already use keeps working.
---
## 5. `sidekiq_options` mapping
Define jobs exactly as before — `include Sidekiq::Job` (or `Sidekiq::Worker`) and
`sidekiq_options`. Enqueue with `perform_async` / `perform_in` / `perform_at` /
`perform_bulk` / `set(...)`. Defaults: `{ retry: true, queue: "default" }`.
| `sidekiq_options` key | Supported | Behavior in Wurk |
|---|---|---|
| `queue:` | ✅ | routes to `queue:`; default `"default"` |
| `retry:` (`true` / `false` / `N`) | ✅ | `true` → up to 25 attempts; `N` → max attempts; `false` → no retry (→ dead set unless `dead: false`) |
| `dead:` (`true` / `false`) | ✅ | `false` skips the morgue on exhaustion (discard instead). Default `true` |
| `backtrace:` (`true` / `N`) | ✅ | store backtrace lines on failure (base64+zlib), Sidekiq-compatible |
| `expires_in:` | ✅ (Pro, free) | drop the job before `perform` if it sits past the window; counts as processed |
| `retry_queue:`, `retry_for:` | ✅ | route retries to another queue / cap total retry duration |
| `tags:` | ✅ | array of strings; surfaced in the dashboard + logs |
| `batch` | ✅ (Pro, free) | not a `sidekiq_options` key — `bid` is stamped automatically inside `Sidekiq::Batch#jobs { … }`; access via `#bid` / `#batch` |
| `pool:` | ✅ | selects the client Redis pool; stripped from the stored payload |
| `lock:` | ⚠️ not native | Wurk's native uniqueness uses `unique_for:` / `unique_until:` (see [§6](#6-third-party-gem-mappings)). The `sidekiq-unique-jobs` gem and its `lock:` option also run against Wurk in the ecosystem CI suite |
Custom retry hooks are unchanged: `sidekiq_retry_in { |count, ex, msg| … }` and
`sidekiq_retries_exhausted { |msg, ex| … }`. The retry backoff formula matches
Sidekiq: `count**4 + 15 + rand(10 * (count + 1))` seconds.
### Pro / Enterprise options (free in Wurk)
- **Unique jobs:** enable with `Sidekiq::Enterprise.unique!`, then
`sidekiq_options unique_for: 10.minutes, unique_until: :success` (or `:start`).
*(This is Enterprise's API — not the `sidekiq-unique-jobs` gem's `lock:` DSL; see
[§6](#6-third-party-gem-mappings) for the gem mapping.)*
- **Encryption:** `Sidekiq::Enterprise::Crypto.enable(active_version: 1) { |v| key }`,
then `sidekiq_options encrypt: true` (the last arg is encrypted).
- **Batches:** `Sidekiq::Batch.new` with `on(:success/:complete/:death)`, nesting,
and `Sidekiq::Batch::Status`.
- **Rate limiters:** `Sidekiq::Limiter.concurrent/bucket/window/leaky/points`.
---
## 6. Third-party gem mappings
Wurk ships native replacements for the most common add-on gems, so you can **drop the
gem** and use the built-in feature — or keep the gem, since its upstream test suite is
run against Wurk in the [`ecosystem` CI job](../.github/workflows/ecosystem.yml). The
native path is recommended (fewer dependencies, first-class dashboard support).
### `sidekiq-cron` → native periodic jobs
Wurk has Enterprise-grade periodic jobs built in. Register them in a `config.periodic`
block at boot. By design there is **no `Sidekiq::Cron::Job` shim** ([#204](https://github.com/developerz-ai/wurk/issues/204));
real Sidekiq never defined that constant, and faking it would break the drop-in
contract.
```ruby
# sidekiq-cron (old): # Wurk (native):
# config/schedule.yml + Sidekiq::Cron::Job Wurk.configure_server do |config|
# config.periodic do |mgr|
# mgr.register("*/5 * * * *", ReportJob)
# mgr.register("0 0 * * *", NightlyJob, tz: "UTC")
# end
# end
```
`mgr.register(cron, JobClass, **opts)` takes a standard 5-field cron string and the
worker class; `tz:` sets the timezone. Periodic state lives in the `periodic` / `loops:`
Redis keys and is visible in the dashboard.
### `sidekiq-unique-jobs` → native `unique_for:` / `unique_until:`
Activate Enterprise uniqueness once, then declare it per worker:
```ruby
# config/initializers/wurk.rb
Sidekiq::Enterprise.unique! # required to activate the unique middleware
class ChargeJob
include Sidekiq::Job
sidekiq_options unique_for: 600, # seconds (or 10.minutes); the lock TTL
unique_until: :success # :success (default) | :start
end
```
Mapping from `sidekiq-unique-jobs`:
| `sidekiq-unique-jobs` | Wurk native |
|---|---|
| `lock: :until_executed` | `unique_until: :success` (lock held through retries, released on success) |
| `lock: :until_executing` / `:while_executing` | `unique_until: :start` (server middleware releases the lock when the job starts) |
| `lock_ttl` / `lock_timeout` | `unique_for: ` (also accepts an `ActiveSupport::Duration`) |
| `lock_args_method` / custom uniqueness args | define `self.sidekiq_unique_context(job)` on the worker, returning any JSON-serializable value |
> ⚠️ Unique jobs and encryption are **mutually exclusive on the same worker** — each
> encryption produces different ciphertext, which defeats the uniqueness digest.
### Quick reference — other ecosystem gems
These run their own upstream suites against Wurk in CI; most work unchanged because
they only touch the Sidekiq API surface and Redis keys Wurk already mirrors.
| Gem | Status on Wurk | Notes |
|---|---|---|
| `sidekiq-scheduler` | ✅ works unchanged | uses the standard schedule ZSET |
| `sidekiq-status` | ✅ works unchanged | rides the standard job lifecycle + middleware |
| `sidekiq-failures` | ✅ works unchanged | reads the standard `retry`/`dead` sets |
| `sidekiq-throttled` | ✅ works unchanged | client/server middleware contract is identical |
| `sidekiq-cron` | ⚠️ prefer native | works in CI, but native `config.periodic` is recommended (no `Sidekiq::Cron::Job` constant) |
| `sidekiq-unique-jobs` | ⚠️ prefer native | works in CI, but native `unique_for:` is recommended |
---
## 7. Known incompatibilities — what *not* to expect
Wurk aims for 100% drop-in. A couple of Sidekiq Pro-isms simply no-op or alias
(items 1–2 — there to reassure, not to fix); the rest are genuine differences worth
knowing. Hit something on a real migration that isn't listed here? **Please open an
issue** — that feedback is part of the v1.0.0 acceptance gate for this guide.
1. **`config.super_fetch!` does nothing** (accepted no-op). Wurk's fetcher is
*always* reliable (atomic `BLMOVE` to a per-process private list, with orphan
reclamation), so a Sidekiq Pro initializer drops in unchanged — the call just
no-ops rather than toggling anything.
**`config.reliable_scheduler!` is not a no-op — keep it.** The default
`scheduled_enq` pops due jobs then pushes them, which has a job-loss window if
the process dies in between; `reliable_scheduler!` swaps in the atomic
promoter that closes it. See [`docs/reliability.md`](reliability.md).
(`Wurk::Client.reliable_push!` also exists for client-side buffering during a
Redis outage.)
2. **`Sidekiq::Pro::Web` works** — it aliases the same dashboard as `Sidekiq::Web`,
so `mount Sidekiq::Pro::Web` (or `Sidekiq::Web`, or `Wurk::Engine`) all resolve to
the wurk dashboard.
3. **`config.workers` / `config.shutdown_timeout` are not Configuration setters.**
Use `config.concurrency` for threads-per-process and `config[:timeout]` for the
shutdown grace; process/fork count is governed by `WURK_COUNT` and the swarm
topology (`config.topology = Wurk::Topology.flat(count:, queues:, concurrency:)`),
not a `workers=` accessor. See [§2](#2-concurrency-vs-parallelism-read-this).
4. **Unique jobs + encryption are mutually exclusive on the same worker** — each
encryption produces different ciphertext, which defeats the uniqueness digest.
5. **No Redis namespacing** in the free gem (same as Sidekiq OSS). One logical
Sidekiq dataset per Redis.
6. **Ruby ≥ 3.2, Redis ≥ 7.0** required (Sidekiq 8 allows slightly older Ruby).
7. **Fork-based by default.** On MRI, Wurk forks worker processes for real
parallelism (load your app *before* the fork; the swarm closes parent
connections pre-fork and children reconnect). On JRuby / TruffleRuby / Windows it
falls back to threads-only, behaviorally equivalent to stock Sidekiq.
8. **`Sidekiq.pro?` and `Sidekiq.ent?` return `false`** — Wurk is free and reports
itself as OSS, even though the Pro/Ent features are present. Don't gate behavior on
these predicates.
---
## 8. Cutover checklist
1. **Swap the gem** — replace `sidekiq` (+ `sidekiq-pro` / `sidekiq-ent`) with `wurk`
in the `Gemfile`; `bundle install`.
2. **Size parallelism × concurrency** — decide `WURK_COUNT` (processes) and
`concurrency` (threads), then check your DB pool and memory against
`WURK_COUNT × concurrency`. See [§2](#2-concurrency-vs-parallelism-read-this). This
is the only step that needs real thought.
3. **Keep your config as-is** — `config.super_fetch!` is an accepted no-op
(already the default) and `config.reliable_scheduler!` still does real work, so
there's nothing to strip out either way.
4. **Re-point the dashboard route** — `mount Wurk::Engine => "/wurk"` (gate it behind
your app auth — see [`docs/authentication.md`](authentication.md)).
5. **Split web from workers** — run a dedicated `bundle exec wurkswarm` process and set
`WURK_DISABLED=1` on the web role so clustered Puma doesn't fork duplicate swarms.
See [§3](#3-running-a-worker-process). Deploying under systemd/Capistrano? See
[`docs/deployment.md`](deployment.md).
6. **Map any add-on gems** — swap `sidekiq-cron` → `config.periodic` and
`sidekiq-unique-jobs` → `unique_for:` if you want the native path. See
[§6](#6-third-party-gem-mappings).
7. **Verify on the same Redis** — enqueue a test job, watch it run, and confirm the
dashboard + your existing `redis-cli` checks look normal. Because the schema is
shared, you can roll one process at a time.
8. **Roll back anytime** — revert the `Gemfile` line. No schema changes were made.
---
*Found a blocker not covered here? File an issue at
— closing the loop on real migrations
is how this guide earns its v1.0.0 sign-off.*
---
# Starting the Wurk process
How to boot a Wurk worker — inside Rails (where it usually starts itself) and
standalone, with no Rails engine at all.
> Production init systems (systemd, capistrano, Heroku) and the full signal
> table live in [`docs/deployment.md`](deployment.md). This doc is about getting
> a worker running.
---
## Inside Rails: it auto-starts
In a Rails app you usually start **nothing**. The mountable engine's railtie
boots the swarm during `after_initialize`, so a normal `rails server` (or a
worker dyno that just boots the app) already has workers forking and fetching.
Set `WURK_DISABLED=1` on any process that should *not* fork workers — typically
your web process if you'd rather run the workers in their own unit:
```bash
WURK_DISABLED=1 bundle exec rails server # web only, no workers
```
The auto-fork is also skipped automatically in the Rails console and the Rails
test environment, so those never spawn a swarm.
If you'd rather run the worker as its own process even under Rails, disable the
auto-fork as above and use one of the standalone runners below pointed at your
app directory.
### Under a preforking web server (Puma cluster, Unicorn, Passenger)
Auto-fork is only safe from a process that **won't fork itself.** A clustered
Puma, Unicorn, or Passenger already forks its own web workers. Forking the swarm
from one of them is unsafe two ways:
- **No app preloading** — every web worker re-runs the railtie hook and forks
its own full swarm, multiplying your worker count by the web concurrency.
- **With app preloading** — the swarm boots in the server master, and its
supervisor thread ends up tangled with the server's own fork and signal
handling.
So Wurk **refuses** to fork there and logs an actionable notice instead:
```text
wurk: preforking web server detected (Puma cluster / Unicorn / Passenger).
Refusing to fork the worker swarm from a process that forks its own workers.
...
```
Pick one:
- **Run the swarm as its own process — recommended.** `bundle exec wurkswarm`
(or `wurk`) on its own dyno/unit, the standard production topology. Set
`WURK_DISABLED=1` on the web process to silence the notice.
- **Run workers inside the web process — threads only, no fork.** Opt into
embedded mode (the same model as Sidekiq embedded — worker threads share the
web process, no swarm):
```ruby
# config/application.rb
config.wurk.embed_in_web = true
```
Set this in `config/application.rb`, **not** an initializer — server mode is
decided before `config/initializers/*` load, so a later flag would arrive too
late and your `Sidekiq.configure_server` blocks would be skipped.
Single-mode Puma (`workers 0`, the `rails server` default) is threaded, not
preforking, so auto-fork stays on there — nothing to change for local dev.
Detection is best-effort: a cluster it misses falls back to the normal fork path,
and an over-eager match is always escapable via `embed_in_web`, `WURK_DISABLED=1`,
or simply running `wurkswarm` separately.
---
## The two runners
| Binary | What it does | Sidekiq equivalent |
|---|---|---|
| `bundle exec wurk` | One process, a thread pool | `sidekiq` |
| `bundle exec wurkswarm` | Forks N worker children from one preloaded parent — fork-based real parallelism | `sidekiqswarm` |
`sidekiqswarm` ships as an alias for `wurkswarm`, so an existing Enterprise
invocation drops in unchanged. There is **no `sidekiq` binary** — point any
`bundle exec sidekiq` command at `wurk`.
```bash
bundle exec wurk -e production # single process
bundle exec wurkswarm -e production # forked swarm (real parallelism)
```
`wurkswarm` is the only way to get fork-based parallelism **without** Rails —
the auto-boot path is Rails-only.
### Common flags
Identical to Sidekiq:
| Flag | Meaning |
|---|---|
| `-c INT` | Processor threads (concurrency). Defaults to `RAILS_MAX_THREADS`, else `5`. |
| `-q queue[,weight]` | Queue to process, optionally weighted. Repeatable. Defaults to `default`. |
| `-r PATH` | App to load — a Rails app **directory**, or a single `.rb` file (see below). |
| `-t NUM` | Shutdown timeout in seconds (default 25). |
| `-e ENV` | Application environment. |
| `-g TAG` | Process tag shown in the procline / dashboard. |
| `-C PATH` | Path to a YAML config file. |
| `-v` | Verbose logging. |
With no `-C`, Wurk auto-discovers `config/wurk.yml`, then `config/sidekiq.yml`
(`.erb` variants too), relative to the required path.
---
## Running without Rails
Wurk's standalone runners never load the engine, so they work in any Ruby app —
a Sinatra service, a plain Rack app, a CLI daemon, whatever. You point `-r` at a
single Ruby file that loads your code and defines your jobs.
**1. A boot file that requires your app and defines jobs:**
```ruby
# app.rb
require "wurk"
Wurk.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end
require_relative "lib/my_app" # your own code / job classes
class EmailJob
include Sidekiq::Worker # or `include Wurk::Job` — same thing
def perform(user_id)
MyApp.deliver_welcome(user_id)
end
end
```
**2. Start a worker against it:**
```bash
bundle exec wurk -r ./app.rb -q default -c 5 # single process
bundle exec wurkswarm -r ./app.rb -q default # forked swarm
```
When `-r` is a **file**, Wurk just `require`s it. When `-r` is a **directory**
it's treated as a Rails app root and `config/environment.rb` is loaded instead —
that's the only Rails-aware branch, and it's the host app's call, not the gem's.
**3. Enqueue from anywhere** (web process, console, cron) — just load `wurk`,
point it at the same Redis, and call the job:
```ruby
require "wurk"
Wurk.configure_client { |c| c.redis = { url: ENV.fetch("REDIS_URL") } }
EmailJob.perform_async(42) # or EmailJob.perform_in(5.minutes, 42)
```
### Config file instead of flags
Anything you can pass as a flag can live in a YAML file (handy for the swarm's
queue/concurrency topology). Per-environment overlays work like Sidekiq's:
```yaml
# config/wurk.yml
:concurrency: 10
:queues:
- critical # plain list = strict priority (first wins)
- default
:production:
:concurrency: 25
```
For weighted fetch, give every queue a weight (Sidekiq's nested-array form). Mixing
weighted and unweighted entries puts the unweighted ones at weight 0, so weight them
all:
```yaml
:queues:
- [critical, 2]
- [default, 1]
```
```bash
bundle exec wurk -C config/wurk.yml -e production
```
ERB is evaluated, so `<%= ENV["..."] %>` works inside the file.
---
## Stopping it
Send `TERM` (or `INT`) for a graceful drain — fetching stops, in-flight jobs
finish up to the shutdown timeout (`-t`, default 25s), then the process exits. A
`SIGKILL` is always safe: reliable fetch keeps in-flight jobs on a per-process
private list in Redis and reclaims them on the next boot. The full signal table
(quiet, rolling restart, thread dump, log reopen) is in
[`docs/deployment.md`](deployment.md#signals).
---
# Configuration reference
Every knob Wurk reads, where it comes from, and what it defaults to. The
authoritative source for each row is `lib/wurk/configuration.rb`,
`lib/wurk/capsule.rb`, `lib/wurk/cli.rb`, `lib/wurk/redis_pool.rb`, and
`lib/wurk/logger.rb`.
Configuration arrives from four places:
| # | Source | Applies to | Where |
|---|--------|-----------|-------|
| 1 | Ruby (`Wurk.configure_server` / `configure_client`) | every runner | `config/initializers/wurk.rb` |
| 2 | CLI flags | `wurk`, `wurkswarm` only | command line |
| 3 | YAML config file | `wurk`, `wurkswarm` only | `config/wurk.yml`, `config/sidekiq.yml` |
| 4 | Environment variables | every runner | process env |
| 5 | `Configuration::DEFAULTS` | fallback | `lib/wurk/configuration.rb` |
`Sidekiq.configure_server`, `Sidekiq.configure_client`, `Sidekiq.configure_embed`
and `Sidekiq::Config` are aliases for the Wurk equivalents, so an existing
Sidekiq initializer works unchanged.
> **The Rails engine never reads CLI flags or YAML.** When the railtie auto-boots
> the swarm inside a Rails host, options 2 and 3 don't exist — configure in Ruby
> and env. The YAML file and flags belong to the standalone `wurk` / `wurkswarm`
> binaries.
---
## Precedence
For the standalone runners, `Wurk::CLI#parse` builds options in this order
(`setup_options`):
1. Parse CLI flags into `opts`.
2. Resolve the environment: `-e` → `APP_ENV` → `RAILS_ENV` → `RACK_ENV` →
`"development"`.
3. Locate the YAML file (`-C`, else auto-discovery).
4. `parse_config(file).merge(opts)` — **CLI flags win over YAML**. Inside the
YAML, the per-environment section is merged over the top-level keys, so
**environment overlay wins over top-level**.
5. `queues` defaults to `["default"]`; `concurrency` falls back to
`RAILS_MAX_THREADS` **only if neither CLI nor YAML set it**.
6. Merge the result into the `Wurk::Configuration`.
Then `Wurk::CLI#run` boots your application — which is when
`config/initializers/wurk.rb` runs. So:
```
Ruby (configure_server) > CLI flags > YAML env overlay > YAML top-level
> RAILS_MAX_THREADS (concurrency only) > DEFAULTS
```
**Ruby wins because it runs last.** `config.concurrency = 10` in an initializer
overrides `wurk -c 4`. This matches Sidekiq. If you want the flag to win, read
the env/flag yourself in the initializer, or don't set the value in Ruby.
Env vars that have no Ruby/CLI/YAML equivalent (`WURK_COUNT`, `WURK_DISABLED`,
`WURK_PRELOAD*`, `WURK_LEADER`) are read directly at the point of use and are
not part of this chain. Two env vars *are* overridable from Ruby, and there the
explicit Ruby value wins: `SIDEKIQ_MAXMEM_MB`/`WURK_MAXMEM_MB` (beaten by
`config.memory_limit_mb =`) and `WURK_WEB_READ_ONLY` (beaten by a later
`config.web.read_only =`).
---
## `configure_server` / `configure_client`
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
config.concurrency = 10
config.queues = %w[critical default low]
config[:timeout] = 25
end
Wurk.configure_client do |config|
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
end
```
The Sidekiq alias forms are identical in behavior:
```ruby
Sidekiq.configure_server { |config| config.concurrency = 10 }
Sidekiq.configure_client { |config| config.redis = { url: ENV["REDIS_URL"] } }
```
- The block yields the **same** `Wurk::Configuration` singleton
(`Wurk.configuration`) in both cases.
- `configure_server` runs its block only when `config[:server] == true`;
`configure_client` only when it is not.
- Server mode is entered **before** initializers load — by the
`wurk.server_mode` railtie initializer in a Rails host, and by
`Wurk.enter_server_mode` in `Wurk::CLI#run` / `#run_swarm` before the app is
required. A Rails process that will not run workers (see `WURK_DISABLED`,
Rails console, test env, a refused preforking boot) is *not* a server, so its
`configure_server` blocks are skipped and `configure_client` blocks run.
- Both are safe to call multiple times; blocks accumulate side effects on the
one config object.
Embedded mode (threads in your own process, no fork):
```ruby
instance = Wurk.configure_embed do |config|
config.queues = %w[default]
end
instance.run # then #quiet / #stop
```
`configure_embed` forces `config.concurrency = 2` before yielding — the GIL
makes more threads counterproductive inside a host process that has its own
pool — and raises `FrozenError` if the configuration has already been frozen by
a running launcher.
---
## Config options
`Wurk::Configuration` is hash-like: `config[:key]`, `config[:key] = v`,
`fetch`, `key?`/`has_key?`, `merge!`, `dig`. Third-party gems rely on this, so
every option below is readable that way even when a named accessor also exists.
### `DEFAULTS`
Verbatim from `Wurk::Configuration::DEFAULTS`.
| Option | Type | Default | Meaning |
|---|---|---|---|
| `:labels` | `Set` | `Set.new` | Free-form labels published in the heartbeat and shown in the dashboard |
| `:require` | String | `"."` | Rails app dir or `.rb` file the standalone CLI loads (`-r`) |
| `:environment` | String / nil | `nil` | App environment; set by `-e` / `APP_ENV` / `RAILS_ENV` / `RACK_ENV` |
| `:concurrency` | Integer | `5` | Threads per worker process (seeds the default capsule) |
| `:timeout` | Integer | `25` | Seconds in-flight jobs get to finish on shutdown |
| `:poll_interval_average` | Float / nil | `nil` | Scheduler poll interval; `nil` → `process_count × average_scheduled_poll_interval` |
| `:average_scheduled_poll_interval` | Integer | `5` | Per-process factor for the scaled scheduler interval |
| `:on_complex_arguments` | `:raise`/`:warn`/`false` | `:raise` | What `verify_json` does with non-JSON-native job arguments |
| `:max_iteration_runtime` | Integer / nil | `nil` | Accepted for drop-in compatibility; **not consumed by Wurk** |
| `:error_handlers` | Array | `[]` → seeded with `ERROR_HANDLER` | `(ex, ctx, cfg)` callables |
| `:death_handlers` | Array | `[]` | `(job, ex)` callables run when a job exhausts retries |
| `:lifecycle_events` | Hash | `{startup: [], fork: [], quiet: [], shutdown: [], exit: [], heartbeat: [], beat: [], leader: []}` | Registered via `config.on(...)` |
| `:dead_max_jobs` | Integer | `10_000` | Dead set is trimmed to this many entries on every kill |
| `:dead_timeout_in_seconds` | Integer | `15_552_000` (180 days) | Dead entries older than this are trimmed |
| `:reloader` | callable | `proc { |&b| b.call }` | Wraps each job execution (Rails sets the code reloader) |
| `:backtrace_cleaner` | callable | `->(bt) { bt }` | Filters backtraces stored on the retry/dead payload |
| `:logged_job_attributes` | Array\ | `["bid", "tags"]` | Job hash keys copied into the log context |
| `:redis_idle_timeout` | Integer / nil | `nil` | Accepted for drop-in compatibility; **not consumed by Wurk** |
| `:redis_error_handlers` | Array | `[]` | Registered via `config.on_redis_error`; receives one Hash |
### Additional keys read at runtime
Not in `DEFAULTS` — unset unless you assign them.
| Option | Type | Default when unset | Meaning |
|---|---|---|---|
| `:tag` | String | basename of the app dir | Process tag shown in the dashboard / procline (`-g`) |
| `:identity` | String | `::` | Set by the CLI before boot; the heartbeat key |
| `:verbose` | Boolean | `false` | `-v`; raises the logger to `DEBUG` |
| `:server` | Boolean | `false` | Set by `Wurk.enter_server_mode`; gates `configure_server` |
| `:max_retries` | Integer | `25` | Default retry attempts (`JobRetry::DEFAULT_MAX_RETRY_ATTEMPTS`) |
| `:fetch_class` | Class | `Wurk::Fetcher::Reliable` | Custom fetcher class, instantiated per capsule |
| `:fetch_setup` | callable | — | Called with the freshly built fetcher |
| `:fetch_poll_interval` | Numeric | `2` (`Fetcher::Reliable::TIMEOUT`) | BLMOVE block seconds when every served queue is empty |
| `:scheduled_enq` | Class | poller default | Set to `Wurk::Scheduled::ReliableEnq` by `config.reliable_scheduler!` |
| `:job_logger` | Class | `Wurk::JobLogger` | Per-job start/done logger |
| `:skip_default_job_logging` | Boolean | `false` | Silences the per-job start/done lines |
| `:scheduler_initial_wait` | Numeric | `10` | Seconds before the scheduler's first sweep |
| `:cron_tick_interval` | Numeric | `60` | Periodic (cron) tick cadence |
| `:metrics_rollup_interval` | Numeric | `60` | Leader-only metrics + per-queue gauge rollup cadence |
| `:super_fetch_reaper_interval` | Numeric | `60` | Orphan-reclamation sweep cadence |
| `:leader_ttl` | Integer | `30` | Cluster leader lock TTL |
| `:leader_renew_interval` | Integer | `15` | Leader renew cadence |
| `:leader_follower_interval` | Integer | `60` | Follower re-campaign cadence |
| `:history_stream_cap` | Integer | `10_000` | Cap on the historical-metrics stream |
| `:health_check_options` | Hash | — | Set by `config.health_check(port:)` |
| `:web_pool_size` | Integer | `5` | Connections in the dedicated dashboard Redis pool |
### Named accessors and methods
| Call | Notes |
|---|---|
| `config.concurrency` / `= n` | Threads per process; reads/writes the **default capsule** |
| `config.queues` / `= [...]` | Ordered/weighted queue list on the default capsule |
| `config.total_concurrency` | Sum of `concurrency` across all capsules |
| `config.capsule(name) { |cap| … }` | Create/lookup a capsule |
| `config.default_capsule { |cap| … }` | The `"default"` capsule |
| `config.client_middleware { |chain| … }` | Client chain |
| `config.server_middleware { |chain| … }` | Server chain |
| `config.redis = { … }` | Merges into the Redis connection hash |
| `config.redis { |conn| … }` | Checkout from the default capsule's main pool |
| `config.redis_pool` | Default capsule's main pool |
| `config.new_redis_pool(size, name = "custom")` | Build an extra pool |
| `config.web_pool_size` / `= n` | Dashboard pool size (default `5`) |
| `config.web_redis_pool` | Lazily built dashboard pool |
| `config.reset_redis_pools!` | Disconnect + drop every cached pool (used around fork) |
| `config.on_redis_error { |info| … }` | Telemetry on pool retries/give-ups |
| `config.error_handlers` / `config.death_handlers` | Arrays of callables |
| `config.handle_exception(ex, ctx = {})` | Dispatch to the error handlers |
| `config.on(event) { … }` | Lifecycle hook |
| `config.logger` / `= logger` | Process logger |
| `config.thread_priority` / `= n` | Default `-1`; stored, **not applied** by Wurk |
| `config.register(name, obj)` / `config.lookup(name, default_class = nil)` | Extension service locator |
| `config.topology` / `= Wurk::Topology…` | Swarm layout |
| `config.memory_limit_mb` / `= n` | RSS recycle threshold; `nil`/`0` disables |
| `config.periodic { |mgr| … }` | Cron registration (`mgr.register(cron, JobClass, **opts)`) |
| `config.retain_history(seconds = 30) { |s| … }` | Ent historical-metrics snapshotter |
| `config.dogstatsd = -> { … }` | Statsd/Dogstatsd client factory, invoked once per process after fork |
| `config.health_check(port:, bind: "0.0.0.0", ready_window: 30)` | Opt-in `/live` + `/ready` listener |
| `config.web` | The `Wurk::Web.config` singleton (see [authentication.md](authentication.md)) |
| `config.super_fetch!` | Sidekiq Pro toggle; reliable fetch is already the default, so this is a no-op apart from capturing its optional recovery block |
| `config.reliable_scheduler!` | **Not a no-op.** Swaps `:scheduled_enq` to the atomic promoter. The default poller pops then pushes and has a job-loss window — see [Reliability](reliability.md) |
| `config.fetch_poll_interval` / `= seconds` | Empty-poll BLMOVE backoff |
| `config.freeze!` / `config.frozen?` | The launcher freezes options + capsules at boot |
`config.freeze!` runs in `Launcher#run`, so every mutation must happen before
workers start. Post-freeze writes raise `FrozenError`.
---
## Environment variables
Every `ENV` read in `lib/`. `WURK_*` is the native name; the `SIDEKIQ_*` form
is the drop-in alias and is checked second (native wins).
| Variable | Read by | Effect |
|---|---|---|
| `REDIS_URL` | `Configuration#initialize`, `RedisPool::DEFAULT_URL` | Redis URL. Default `redis://localhost:6379/0` |
| `WURK_COUNT` / `SIDEKIQ_COUNT` | `Configuration#default_child_count` | Swarm child processes. Whole number = absolute count; fractional = CPU multiplier (`0.5` → half the cores, rounded). Floored at 1. Unparseable → CPU count. Default `Etc.nprocessors` |
| `WURK_MAXMEM_MB` / `SIDEKIQ_MAXMEM_MB` | `Configuration#memory_limit_mb` | Parent TERMs + respawns any child whose RSS exceeds this. Unset/unparseable → recycling off |
| `WURK_DISABLED` | `RailsBoot.skip_boot?` | `=1` skips both server mode and the swarm boot in a Rails host |
| `WURK_LEADER` / `SIDEKIQ_LEADER` | `Leader.opted_out?` | `=false` (case-insensitive) makes this process never campaign for leadership |
| `WURK_PRELOAD` / `SIDEKIQ_PRELOAD` | `CLI#preload_groups` | Comma-separated Bundler groups `Bundler.require`d in the swarm parent before fork. Default `default`; an explicit empty value disables the preload |
| `WURK_PRELOAD_APP` / `SIDEKIQ_PRELOAD_APP` | `CLI#preload_app?` | `=1` eager-loads the whole Rails app in the swarm parent before forking (more copy-on-write sharing, slower parent boot) |
| `WURK_WEB_READ_ONLY` | `Web::Config#env_read_only?` | `=1` boots the dashboard in read-only mode |
| `WURK_DEBUG` | `Configuration::ERROR_HANDLER` | Any value makes the default error handler log `full_message` (with backtrace) instead of `detailed_message` |
| `WURK_VITE_DEV` | `Engine`, `DashboardController` | `=1` serves the SPA from the Vite dev server instead of the precompiled bundle |
| `RAILS_MAX_THREADS` | `CLI#apply_defaults!` | Fallback `concurrency` when neither CLI nor YAML set it |
| `APP_ENV`, `RAILS_ENV`, `RACK_ENV` | `CLI#set_environment` | Environment, in that order, after `-e`. Default `development`. The CLI then writes `RACK_ENV`/`RAILS_ENV` back before booting the app |
| `DEBUG_INVOCATION` | `CLI#initialize_logger` | `=1` sets the logger to `DEBUG` (same as `-v`) |
| `RUBY_DISABLE_WARMUP` | `CLI#run`, `#run_swarm` | `=1` skips `Process.warmup` |
| `DYNO` | `Component#hostname`, `Logger`, `Fetcher::Reliable`, `Leader` | Heroku: used as the hostname in the process identity and switches the log formatter to the timestamp-less variant |
| `WEB_CONCURRENCY` | `RailsBoot#puma_worker_count` | Read only to detect Puma cluster mode when Puma's parsed config is unavailable |
| `SECRET_KEY_BASE_DUMMY` | `RailsBoot#building?` | Presence marks a build/precompile step; the swarm never forks there |
There is **no `WURK_CONCURRENCY`**. Threads per process are
`config.concurrency` / `-c` / `RAILS_MAX_THREADS`.
---
## YAML config file
Read only by `wurk` and `wurkswarm`. Pass `-C path`, or let the CLI
auto-discover, in `/config/`, the first of:
```
wurk.yml wurk.yml.erb sidekiq.yml sidekiq.yml.erb
```
Files are run through ERB (`trim_mode: "-"`) then `YAML.safe_load` with
`permitted_classes: [Symbol], aliases: true`. Keys are symbolized recursively,
so `concurrency:` and `:concurrency:` are equivalent.
```yaml
# config/wurk.yml
:concurrency: 5
:timeout: 25
:queues:
- default
- [critical, 2] # weighted; "critical,2" also works
:capsules:
high_priority:
:concurrency: 3
:queues:
- critical
production:
:concurrency: 10
:queues:
- critical
- default
```
- A top-level key matching the resolved environment (`production:`, `staging:`,
…) is an **overlay**: it is deleted from the hash and merged over the
top-level keys.
- `:strict` is dropped — Sidekiq removed strict fetch in 8.x.
- `:capsules` is the only nested section with special handling; per capsule only
`:queues` and `:concurrency` are honored.
- Every other key is merged into the config's option hash verbatim, so any
option from the tables above (`:tag`, `:max_retries`, `:dead_max_jobs`,
`:fetch_poll_interval`, …) can be set from YAML.
- Queue entries accept `name`, `"name,weight"`, `[name]`, and `[name, weight]`.
Weight `0` is not allowed; a weight must be `> 0`.
---
## CLI flags
`wurk` (single process, one thread pool) and `wurkswarm` (parent forks
`WURK_COUNT` children and supervises them) share one option parser.
`sidekiqswarm` is shipped as an alias for `wurkswarm`. There is no `sidekiq`
binary.
| Flag | Option | Notes |
|---|---|---|
| `-c`, `--concurrency INT` | `:concurrency` | Processor threads per process |
| `-e`, `--environment ENV` | `:environment` | App environment |
| `-g`, `--tag TAG` | `:tag` | Process tag for the procline / dashboard |
| `-q`, `--queue QUEUE[,WEIGHT]` | `:queues` | Repeatable; appends |
| `-r`, `--require [PATH\|DIR]` | `:require` | Rails app dir or a single `.rb` file |
| `-t`, `--timeout NUM` | `:timeout` | Shutdown grace seconds |
| `-v`, `--verbose` | `:verbose` | `DEBUG` logging |
| `-C`, `--config PATH` | `:config_file` | YAML config path |
| `-V`, `--version` | — | Print version and exit |
| `-h`, `--help` | — | Print help and exit |
```bash
bundle exec wurkswarm -C config/wurk.yml -e production # forked swarm
bundle exec wurk -r ./app.rb -q critical,2 -q default -c 10
```
Boot validation, in order: the `-r` path must exist (and, for a directory,
contain `config/application.rb`); `concurrency` and `timeout` must both be
`> 0`; Redis must report `>= 7.0.0`; a non-`noeviction` `maxmemory-policy`
logs a warning; and every capsule's pool size must be `>= its concurrency`
(`Pool size too small for ` otherwise).
Neither runner loads the Rails engine — a worker host stays lean and serves no
dashboard — but both fully boot your app.
---
## Redis connection and pool sizing
```ruby
Wurk.configure_server do |config|
config.redis = {
url: ENV.fetch("REDIS_URL"),
size: 15, # pins the capsule's main pool
pool_timeout: 1.0, # checkout wait
connect_timeout: 1.0,
read_timeout: 2.5,
write_timeout: 2.5,
reconnect_attempts: 1
}
end
```
`config.redis =` **merges** into the existing hash. `:size` and `:name` are
pool-structural and consumed by Wurk; everything else forwards verbatim to
`RedisClient.config` (so `driver:`, `ssl_params:`, `username:`, `password:`,
sentinel options, … pass through).
| Key | Default | Source |
|---|---|---|
| `url` | `ENV["REDIS_URL"]` or `redis://localhost:6379/0` | `RedisPool::DEFAULT_URL` |
| `pool_timeout` | `1.0` | `RedisPool::DEFAULT_POOL_TIMEOUT` |
| `connect_timeout` | `1.0` | `RedisPool::DEFAULT_CONNECT_TIMEOUT` |
| `read_timeout` | `2.5` | `RedisPool::DEFAULT_READ_TIMEOUT` |
| `write_timeout` | `2.5` | `RedisPool::DEFAULT_WRITE_TIMEOUT` |
| `reconnect_attempts` | `1` | `RedisPool::DEFAULT_RECONNECT_ATTEMPTS` |
### Three disjoint pools
Each worker process opens more than one pool, deliberately — a dashboard burst
must not starve fetch or heartbeat.
| Pool | Size | Used by |
|---|---|---|
| `-main` | `config.redis[:size]`, else `max(concurrency + 5, 10)` | Heartbeat, scheduler, leader election, cron, metrics rollups, reaper, history, health probe, and your job code |
| `-fetch` | `concurrency` | Only the reliable fetcher's blocking `BLMOVE` |
| `web` | `config.web_pool_size` (default `5`), checkout timeout pinned to `1.0` | Dashboard, JSON API, SSE |
Setting `config.redis[:size]` pins **the main pool only**; the fetch pool
always tracks `concurrency` and the web pool always tracks `web_pool_size`.
Redis connections are **never shared across forks**: the swarm parent calls
`reset_redis_pools!` before forking and each child rebuilds lazily.
### Transient-failure handling
`RedisPool#with` absorbs blips before raising:
- `READONLY` / `NOREPLICAS` / `UNBLOCKED` — a failover; close and retry once
immediately.
- `RedisClient::ConnectionError` — close, sleep `(0.5 × 2ⁿ) + rand×0.25`, retry
up to 3 attempts total, then raise.
- `ConnectionPool::TimeoutError` — one retry after `0.1–0.3s`, then raise.
Sustained checkout starvation is a sizing bug; fix the size.
Every retry and final give-up is reported to `config.on_redis_error`:
```ruby
Wurk.configure_server do |config|
config.on_redis_error do |info|
# info => { error: , attempt: 1, retried: true, pool: "default-main" }
Sentry.capture_message("redis retry", extra: info) unless info[:retried]
end
end
```
Handlers are opt-in (the array starts empty) and a raising handler is logged and
skipped.
---
## Concurrency vs parallelism
Two independent knobs. Consistent with
[migrate-from-sidekiq.md §2](migrate-from-sidekiq.md#2-concurrency-vs-parallelism-read-this).
| Knob | Controls | Set with | Default |
|---|---|---|---|
| **Parallelism** | Forked worker **processes** | `WURK_COUNT` (alias `SIDEKIQ_COUNT`), or an explicit `config.topology` | CPU core count (`Etc.nprocessors`) |
| **Concurrency** | **Threads per process** | `config.concurrency`, `-c`, YAML `:concurrency`, `RAILS_MAX_THREADS` | `5` |
```text
Total in-flight jobs = WURK_COUNT × concurrency
16-core box, defaults: 16 processes × 5 threads = 80 jobs at once
+ 16 separate DB connection pools
```
`WURK_COUNT` only applies to the **forking** runners: the Rails engine's
auto-boot swarm and `wurkswarm`. Plain `wurk` is a single process and ignores
it. A whole number is an absolute count; a fractional value is a CPU multiplier
(`0.5` → half the cores, rounded); the result is floored at 1.
Sizing:
```bash
WURK_COUNT=2 bundle exec wurkswarm # 2 × 5 = 10 in flight (matches a Sidekiq concurrency:10 box)
WURK_COUNT=4 bundle exec wurkswarm # 4 × 5 = 20 in flight
bundle exec wurk -c 10 # single process, 10 threads, Sidekiq-shaped
```
Each forked process opens its own DB pool, Redis pools, and memory footprint.
Size `database.yml`'s `pool` for the **per-process** thread count, then check
`WURK_COUNT × pool` against your database's spare connections. Start with
`WURK_COUNT` = cores you want to dedicate to jobs and `concurrency = 5` for
IO-bound work; raise `concurrency` for IO-heavy jobs, `WURK_COUNT` for CPU-heavy
ones.
Memory-based recycling caps the damage:
```ruby
Wurk.configure_server { |config| config.memory_limit_mb = 1024 }
# or: SIDEKIQ_MAXMEM_MB=1024 / WURK_MAXMEM_MB=1024
```
The swarm parent checks child RSS every 10s and TERMs + respawns any child over
the limit. `nil` or `0` disables it (the default).
---
## Capsules
A capsule is one processing unit: a set of threads and queues with its own
fetcher, middleware chains, and Redis pools. The `"default"` capsule is created
implicitly, and `config.concurrency` / `config.queues` read and write it.
```ruby
Wurk.configure_server do |config|
config.concurrency = 5
config.queues = %w[default low]
config.capsule("critical") do |cap|
cap.concurrency = 2
cap.queues = %w[critical]
end
end
```
| Capsule member | Notes |
|---|---|
| `cap.name` | String |
| `cap.concurrency` / `= n` | Threads; defaults to `config[:concurrency]` (else `5`) |
| `cap.queues` / `= [...]` | Weight-expanded list; default `["default"]` |
| `cap.queue_specs` | Lossless `"name,weight"` round-trip form |
| `cap.mode` | `:strict` (all weights 0) · `:random` (all weights equal, non-zero) · `:weighted` |
| `cap.weights` | `{queue => weight}` |
| `cap.redis_pool` / `cap.redis { }` | Main pool |
| `cap.fetch_redis_pool` / `cap.fetch_redis { }` | Fetch-only pool |
| `cap.client_middleware` / `cap.server_middleware` | Capsule-bound copies of the global chains |
| `cap.fetcher` / `= obj` | Defaults to `Wurk::Fetcher::Reliable` |
Queue mode is derived, not declared: `%w[high default low]` → `:strict`;
`%w[high,3 default,2 low,1]` → `:weighted`; `%w[a,1 b,1 c,1]` → `:random`.
Each capsule gets its own `Manager` inside every worker process, so a
three-capsule config on a 4-process swarm runs 12 managers.
`config.total_concurrency` sums threads across capsules.
## Topology
Capsules split queues *inside* a process. The topology DSL splits them *across*
forked processes — stronger isolation, since a wedged slot can't starve another
slot's threads.
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.topology = Wurk::Topology.new
.slot(count: 2, queues: %w[critical], concurrency: 3)
.slot(count: 4, queues: %w[default low,2], concurrency: 10)
end
```
| Method | Notes |
|---|---|
| `Topology#slot(count:, queues:, concurrency:)` | Declares one *kind* of fork; returns `self` so calls chain. `count` and `concurrency` must be positive Integers; `queues` must be non-empty |
| `Topology.flat(count:, queues:, concurrency:)` | Convenience: `count` identical forks |
| `#slots` / `#assignments` / `#total_processes` | `assignments` is the flat ordered fork list (a `count: 2` slot yields two entries) |
| `#empty?` | No slots declared |
When you don't assign a topology, `config.topology` builds
`Topology.flat(count: , queues: , concurrency: )`.
An explicit topology therefore **supersedes `WURK_COUNT`** — the process count
comes from the slots.
Each child applies its slot's queues and concurrency to the default capsule
after fork, before its launcher starts.
---
## Error handlers and death handlers
### `config.error_handlers`
Called for every exception Wurk catches — job failures, heartbeat errors,
scheduler errors. **Signature: `(exception, context_hash, config)`.** The third
argument has a default in the shipped handler, but always accept three.
```ruby
Wurk.configure_server do |config|
config.error_handlers << ->(ex, ctx, cfg) do
Sentry.capture_exception(ex, extra: ctx)
end
end
```
- The array is seeded with `Wurk::Configuration::ERROR_HANDLER` when it is empty
at construction time. Appending keeps the default logging; assigning a fresh
array replaces it.
- `ctx` typically carries `:context` plus `:job` / `:jid` where available.
- `config.handle_exception(ex, ctx)` runs the chain; a handler that raises is
logged (`error_handler raised: …`) and the remaining handlers still run.
- The default handler logs `full_message` when `$DEBUG`, `WURK_DEBUG`, or the
logger is at `DEBUG`; otherwise `detailed_message`. `RedisClient::Error` and
`ConnectionPool::TimeoutError` are logged at `WARN`, everything else at
`INFO`.
### `config.death_handlers`
Called when a job exhausts its retries and moves to the dead set.
**Signature: `(job_hash, exception)`** — the job hash is the parsed job JSON
(string keys: `"class"`, `"args"`, `"jid"`, …).
```ruby
Wurk.configure_server do |config|
config.death_handlers << ->(job, ex) do
PagerDuty.trigger("#{job['class']} died: #{ex.message}", job_id: job["jid"])
end
end
```
Fired from `JobRetry` when retries are exhausted or `sidekiq_retry_in` returns
`:discard`, and from `DeadSet#kill` when `notify: true`. A raising death handler
is routed to the error handlers and does not abort the rest of the chain.
---
## Lifecycle events
`config.on(event) { … }`. The event must be one of
`Wurk::Configuration::LIFECYCLE_EVENTS` — anything else raises `ArgumentError`,
as does calling `on` without a block.
| Event | Fires | Where |
|---|---|---|
| `:startup` | Once, before processor threads spin up. Exceptions **re-raise** and abort boot. In a swarm it fires in each child (after `:fork`), not in the parent | `CLI#run`, `Embedded#run`, `Swarm::ChildBoot#run` |
| `:fork` | In each swarm child, after fork and after Wurk's own ActiveRecord/Redis reconnect, before `:startup`. Never in the parent; never in single-process or embedded mode | `Swarm::ChildBoot#run` |
| `:quiet` | On TSTP / dashboard-issued quiet, after managers stop fetching. LIFO (reverse registration order) | `Launcher#quiet` |
| `:shutdown` | During `stop`, while managers drain. LIFO | `Launcher#stop` |
| `:exit` | At the very end of `stop`, after the heartbeat is cleared. LIFO | `Launcher#stop` |
| `:heartbeat` | On the **first** successful beat, and again after recovery from a Redis partition | `Heartbeat#beat!` |
| `:beat` | Every beat (`BEAT_PAUSE = 10` seconds). **Not oneshot** — the only recurring event | `Heartbeat#beat!` |
| `:leader` | On every follower → leader transition | `Leader#acquire` |
All events except `:beat` are oneshot: the bucket is cleared after dispatch. In
a swarm each child clears its own forked copy, so siblings still fire theirs.
```ruby
Wurk.configure_server do |config|
config.on(:fork) { MyLib.reconnect! } # your non-fork-safe sockets/threads
config.on(:startup) { Rails.logger.info "wurk up" }
config.on(:quiet) { StatsD.increment("wurk.quiet") }
config.on(:shutdown) { MyMetrics.flush }
config.on(:beat) { MyMetrics.gauge("wurk.alive", 1) }
config.on(:leader) { Rails.logger.info "elected leader" }
end
```
Wurk already reconnects ActiveRecord and opens a fresh Redis pool in each child
— `:fork` is for *your* libraries only.
---
## Logging
The default logger is `Wurk::Logger.new($stdout)` at level `INFO`.
```ruby
Wurk.configure_server do |config|
config.logger.level = Logger::WARN
config.logger.formatter = Wurk::Logger::Formatters::JSON.new # NDJSON for log aggregators
end
```
| Concern | Behavior |
|---|---|
| Destination | `$stdout`. Assign `config.logger = Wurk::Logger.new("/var/log/wurk.log")` for a file |
| Level | `INFO`. `DEBUG` via `-v` or `DEBUG_INVOCATION=1` |
| Formatter | `Formatters::Pretty` by default; `Formatters::WithoutTimestamp` when `DYNO` is set (Heroku already prefixes a timestamp); `Formatters::JSON` opt-in |
| Context | All three formatters read the thread-local `Wurk::Context`, so every line carries `jid`/`bid`/`tags` without threading a hash through |
| Per-job lines | `Wurk::JobLogger` emits start/done. Silence with `config[:skip_default_job_logging] = true`; swap the class with `config[:job_logger]`; choose which job keys reach the log context with `config[:logged_job_attributes]` (default `["bid", "tags"]`) |
| Log rotation | `SIGUSR2` reopens the log file. Send it to the swarm parent — it relays USR2 to every child, and each child calls `logger.reopen`. The standalone `wurk` process handles USR2 itself |
Pretty output, for reference:
```text
INFO 2026-07-20T12:00:00.000Z pid=421 tid=abc1 jid=9f8e7d: start
```
---
## Dashboard configuration
Dashboard-specific settings live on `config.web` (which is the process-wide
`Wurk::Web.config`, so `Wurk::Web.configure { |c| … }` and
`Sidekiq::Web.configure { |c| … }` reach the same object):
```ruby
Wurk.configure_server do |config|
config.web.read_only = true
config.web.read_only_message = "Production board — retries are handled by on-call."
end
```
`read_only` starts from `WURK_WEB_READ_ONLY == "1"` and accepts string values
(`""`, `"0"`, `"false"`, `"no"`, `"off"` mean off). Authorization hooks, Rack
middleware, and CSRF are covered in [authentication.md](authentication.md).
---
## Health checks
Off by default. Opt in and the launcher starts a thin TCP listener inside each
worker process:
```ruby
Wurk.configure_server do |config|
config.health_check(port: 7433, bind: "0.0.0.0", ready_window: 30)
end
```
- `GET /live` → 200 while the process is not stopping.
- `GET /ready` → 200 only when Redis is reachable **and** a heartbeat fired
within `ready_window` seconds.
- `port` must be `0..65535`, `ready_window` must be `> 0`, `bind` non-empty —
all validated at call time.
- The listener starts last in the boot sequence and is closed during shutdown.
---
## Divergences from the Sidekiq spec
Everything above is verified against the implementation. Where Wurk's surface
differs from `docs/target/sidekiq-free.md`:
| Divergence | Detail |
|---|---|
| Extra lifecycle events | Wurk's `LIFECYCLE_EVENTS` adds `:fork` (Ent §7.4) and `:leader` (Ent §6) to the free-spec set |
| Extra default key | `:redis_error_handlers` is in `DEFAULTS`; there is no Sidekiq equivalent (`config.on_redis_error`) |
| Pool sizing | Spec: per-capsule pool = `concurrency`, internal = 10. Wurk: main pool = `max(concurrency + 5, 10)`, **plus** a dedicated fetch pool of `concurrency` and a dedicated web pool of `web_pool_size` |
| `reap_idle_redis_connections` | Not implemented. `:redis_idle_timeout` is accepted (it is in `DEFAULTS`) but never consumed |
| `Config#redis_info` / `#to_json` | Not implemented on the configuration object. `Wurk::RedisPool#info` returns parsed `INFO` merged with the pool's `size`/`available` |
| `:max_iteration_runtime` | Accepted for drop-in compatibility; not consumed by `Wurk::IterableJob` |
| `thread_priority` | Stored and readable (default `-1`) but not applied to worker threads |
| `USR2` | Spec marks it "Pro only". In Wurk it always reopens logs, and the swarm parent relays it to children |
| Fetch mode | There is no "basic fetch". The default fetcher is always the reliable `BLMOVE` fetcher, so `config.super_fetch!` exists only so a Pro initializer drops in unchanged. `config.reliable_scheduler!` still does real work — keep it |
| `WURK_PRELOAD_APP` | Sidekiq's `SIDEKIQ_PRELOAD_APP` defaults to off *and* controls whether the app loads in the parent. Wurk's swarm always loads the app entrypoint in the parent (children inherit it); this flag toggles only the extra Rails `eager_load!` |
| YAML `:strict` | Silently dropped — strict fetch was removed in Sidekiq 8.x |
---
## Related
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — the one-line swap, and
§2 on concurrency vs parallelism.
- [Running Wurk](running.md) — runners, signals, and process layout.
- [Deployment](deployment.md) — production topology and rollout.
- [Authentication & authorization](authentication.md) — dashboard gating.
---
# Deploying Wurk
Wurk is a drop-in for Sidekiq, so it deploys the same way: a long-running worker
process supervised by your init system, signalled for quiet/reload/stop during a
deploy. If you already run Sidekiq under systemd or capistrano-sidekiq, the only
change is the binary name (`sidekiq` → `wurk`) — the signals, flags, and config
file are identical.
> Authoritative signal reference: [`docs/target/sidekiq-free.md`](target/sidekiq-free.md) §21 (CLI).
> Just getting a worker running (incl. standalone, no Rails)? See [`docs/running.md`](running.md).
> Migrating an existing app? Start with [`docs/migrate-from-sidekiq.md`](migrate-from-sidekiq.md).
---
## The runner
| Binary | What it does | Sidekiq equivalent |
|---|---|---|
| `bundle exec wurk` | Single worker process, thread pool | `sidekiq` |
| `bundle exec wurkswarm` | Forks N worker children from one preloaded parent (real parallelism) | `sidekiqswarm` |
`sidekiqswarm` ships as an alias for `wurkswarm`, so an existing Enterprise
`sidekiqswarm` invocation keeps working unchanged.
In a Rails app the engine auto-starts the swarm during boot, so your web/worker
dynos often need no separate worker command at all — set `WURK_DISABLED=1` on
processes that should *not* fork workers (e.g. the web process). The standalone
runners below are for non-Rails apps, or when you want the worker in its own unit.
Common flags (same as Sidekiq): `-c` concurrency, `-q queue[,weight]`, `-r` require
path, `-t` shutdown timeout (seconds), `-e` environment, `-C` config YAML. With no
`-C`, Wurk auto-discovers `config/wurk.yml` then `config/sidekiq.yml` (`.erb` ok).
---
## Signals
These are what your deploy tooling sends. Wurk implements the full Sidekiq set.
| Signal | Send to | Effect |
|---|---|---|
| `TERM` / `INT` | `wurk`, swarm parent | Graceful shutdown — stop fetching, let in-flight jobs finish up to the shutdown timeout (`-t`, default **25s**), then exit. The swarm parent relays `TERM` to every child and waits `timeout + 5s` before `KILL`ing stragglers. |
| `TSTP` | `wurk`, swarm parent | Quiet — stop fetching new work; in-flight jobs finish. **One-way: there is no resume.** Quiet, then `TERM` to shut down. |
| `USR1` | swarm parent **only** | Zero-downtime rolling restart — fork a replacement child, wait for its heartbeat, `TERM` the old one, then the next slot. See [§ Rolling restarts](#rolling-restarts). |
| `TTIN` / `INFO` | `wurk` only | Dump every thread's backtrace to the log (for diagnosing a stuck worker). |
| `USR2` | `wurk`, swarm parent | Reopen log files (logrotate). The swarm parent relays it to every child. |
Two sharp edges, both provable from the trap tables (`Wurk::CLI::SIGNAL_HANDLERS`,
`Wurk::Swarm::SWARM_SIGNALS`, `Wurk::Swarm::ChildBoot::CHILD_SIGNALS`):
- **Don't send `USR1` to a plain `wurk` process.** The single-process CLI traps
`INT TERM TSTP TTIN INFO USR2` and nothing else, so `USR1` hits its default
disposition and terminates the process abruptly, mid-job. Rolling restart is a
swarm-parent feature. (Swarm *children* trap `USR1` as an explicit no-op, so a
stray signal to a child pid is harmless.)
- **`TTIN` / `INFO` are single-process only.** The swarm parent and its children do
not trap them, so use them against `bundle exec wurk`, not `wurkswarm`.
**Quiet is global and sticky.** `TSTP` on the swarm parent sets a flag that is relayed
to every live child *and* inherited by every future fork — a child that crashes or is
memory-recycled while the swarm is quiet boots already quieted, so nothing resumes
fetching behind your back during maintenance.
The standard deploy dance is **quiet, then stop**: send `TSTP` early in the deploy so
the old worker stops claiming new jobs, then `TERM` once the new release is live. A
`SIGKILL` at any point is safe — reliable fetch keeps in-flight jobs on a per-process
private list in Redis, and they're reclaimed on the next boot (each booting worker runs
an orphan sweep immediately, plus a locked cluster sweep every 60s).
---
## Rolling restarts
`SIGUSR1` to a **swarm parent** cycles every child onto new code without dropping a
job and without a window where the fleet is empty. The parent runs a non-blocking
state machine (`Wurk::Swarm::Restart`), advancing **one slot at a time**, one phase per
0.2s supervise tick:
```text
spawn replacement → await its first heartbeat → TERM the old child
→ await the old child's exit → next slot
```
| Phase | Bounded by | What happens on timeout |
|---|---|---|
| `await_heartbeat` | `HEARTBEAT_WAIT` = **30s** | Logs a warning and proceeds to `TERM` the old child anyway. |
| `await_exit` | shutdown timeout **+ 5s** (`SHUTDOWN_GRACE`) | `KILL`s the old child once, then waits for the reaper to confirm the exit. |
The replacement is a fresh fork of the **parent's** already-loaded application, so a
rolling restart picks up new code only if the parent itself is running the new release
— i.e. `USR1` is for reloading *state* (leaked memory, stale connections) in a
long-lived parent. **To deploy new code you must restart the parent process** (new
release directory, new container image, `systemctl restart`). This is the same
constraint `sidekiqswarm` has.
**Guarantees and costs:**
- **No fetch gap.** The old child keeps fetching until its replacement has written a
heartbeat to Redis, so the slot is never unstaffed.
- **No dropped jobs.** The old child gets a normal `TERM` drain; anything it doesn't
finish stays on its private list and is reclaimed.
- **Duration ≈ N × (child boot + drain).** One slot is in flight at a time, so a
16-child swarm takes 16 sequential cycles. Worst case per slot is 30s (heartbeat
wait) + shutdown timeout + 5s.
- **`TERM` always wins.** The state machine advances one phase per tick and never
sleeps, so a shutdown lands within a tick regardless of restart state; `TERM` aborts
the restart and drains the replacement and the old child as ordinary children.
- **Failed replacements don't cost you the slot.** If a replacement dies before it
heartbeats, the old child is *kept*, a per-slot exponential backoff is applied
(1s → 2s → 4s …, capped at 30s), and the slot is retried. A crash-looping new
release degrades to "old workers keep running", not "no workers".
**Use `USR1` when** the parent is healthy and you want to recycle children (memory
growth, connection churn). **Use a full parent restart when** you are shipping new
code, changing `WURK_COUNT`/topology, or changing anything read at parent boot.
> ⚠️ **Not available on the Rails auto-boot swarm.** When the railtie boots the swarm
> inside your Rails process it calls `swarm.boot(install_signals: false)` — the host
> app owns the process signals, so no `USR1` handler is installed and `USR1` would hit
> the *host's* disposition. Rolling restart is for `wurkswarm` (or a swarm you boot
> yourself with `install_signals: true`).
---
## Memory-based auto-restart
Ruby worker processes leak. The swarm parent can recycle a child that grows past an
RSS ceiling.
| Knob | Value |
|---|---|
| `SIDEKIQ_MAXMEM_MB` | Drop-in name (Sidekiq Enterprise). |
| `WURK_MAXMEM_MB` | Native alias. Takes precedence over `SIDEKIQ_MAXMEM_MB`. |
| `config.memory_limit_mb = 1024` | Ruby setter. An explicit value wins over both env vars. |
| Default | **Disabled.** `nil`, `0`, empty, or unparseable input disables recycling (it never raises at boot). |
```bash
SIDEKIQ_MAXMEM_MB=1500 bundle exec wurkswarm -e production
```
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.memory_limit_mb = 1500
end
```
**How it's measured.** Every **10s** (`MEMORY_CHECK_INTERVAL`) the parent reads
`/proc//statm` for each child and converts the resident-pages field to KB
(pages × 4KB). No gem, no `ps` fork. On a platform without `/proc` the read returns
`nil` and the child is simply never recycled — **memory recycling is Linux-only in
practice**.
**What happens on breach.** The child is handed to the *same* rolling-restart state
machine described above: replacement forked → heartbeat awaited → old child `TERM`ed
and drained. So a recycle is graceful, never drops a job, and can't overlap a rolling
restart already in flight on that slot (the queue de-duplicates by pid). The parent
logs:
```text
swarm: child 4242 RSS 1574912KB >= 1536000KB; recycling
```
> **Divergence from Sidekiq Enterprise.** The Ent spec
> ([`docs/target/sidekiq-ent.md`](target/sidekiq-ent.md) §7.5) describes signalling
> the bloated child with `USR2` first. Wurk does not — `USR2` is reserved for log
> reopening, and the graceful replacement-then-`TERM` path already gives a cleaner
> hand-off. The observable contract (child exceeding the threshold is gracefully
> replaced) is the same.
---
## Leader election
Every worker process campaigns for one cluster-wide lock so that fleet-wide periodic
work happens exactly once, not once per process.
| Detail | Value |
|---|---|
| Redis key | `dear-leader` (STRING) holding `::` |
| Acquisition | `SET NX EX`, TTL **30s** |
| Renewal | every **15s** while leader |
| Follower re-check | every **60s** |
| Fencing token | `INCR leader-token` on every follower→leader transition, exposed as `Leader#token` |
| Opt out | `WURK_LEADER=false` (or `SIDEKIQ_LEADER=false`) — the process never campaigns |
| Hook | `config.on(:leader) { … }` fires when this process gains leadership |
Leader-gated work — every process runs the loop, but only the leader acts:
- **Periodic (cron) jobs** — only the leader enqueues per tick.
- **Metrics rollup** and the **per-queue gauge sampler** that feed the dashboard's
historical charts.
- **Historical metrics snapshots** (when `config.retain_history` is set).
Explicitly *not* leader-gated: the **scheduled/retry poller** and the **reliable-fetch
orphan reaper**. The reaper uses its own `SET NX EX` lock so it keeps sweeping even if
the leader is dead.
**What this means for a deploy:**
- Leadership is best-effort, not Raft. A partitioned ex-leader can briefly co-exist
with a new one until the 30s TTL expires. Idempotency-guard anything in an
`on(:leader)` hook; use the fencing token if you need a monotonic guard.
- A **graceful** shutdown CAS-releases the lock (only if we still own it), so a
follower promotes immediately instead of waiting out the TTL. A `SIGKILL`ed leader
costs the cluster up to **30s** of no leader — one missed cron minute is possible.
- **`TSTP` does not stand a leader down.** Quiet stops *fetching*; the cron poller is
deliberately left running, so a quieted leader keeps enqueueing periodic jobs. Only
a full shutdown stops it. Quiet the fleet, don't expect cron to pause.
- Use `WURK_LEADER=false` on hot-standby or canary pools that must never own cron.
---
## systemd
A ready-to-edit unit ships at [`examples/wurk.service`](../examples/wurk.service).
Copy it, adjust the paths/user, and install:
```bash
sudo cp examples/wurk.service /etc/systemd/system/wurk.service
sudo systemctl daemon-reload
sudo systemctl enable --now wurk
```
Then:
```bash
sudo systemctl reload wurk # SIGTSTP — quiet (stop fetching, finish in-flight)
sudo systemctl stop wurk # SIGTERM — graceful drain up to the shutdown timeout
sudo systemctl restart wurk # quiet+stop+start
journalctl -u wurk -f # tail logs
```
Two details that differ from a stock Sidekiq unit:
- **`Type=simple`, not `Type=notify`.** Wurk does not emit `sd_notify` readiness or
watchdog pings, so there's no `WatchdogSec`. systemd considers the process ready as
soon as `ExecStart` forks.
- **`TimeoutStopSec` ≥ shutdown timeout.** The unit sets `TimeoutStopSec=30` against
the default 25s drain so a clean shutdown always wins before systemd's `SIGKILL`.
If you raise `-t`, raise `TimeoutStopSec` to match.
### Multiple workers
Run several workers on one host with a template unit. Save the example as
`/etc/systemd/system/wurk@.service` and start instances:
```bash
sudo systemctl enable --now wurk@1 wurk@2 wurk@3
```
Or run one `wurkswarm` unit instead, which forks children itself — change
`ExecStart` to `bundle exec wurkswarm -e production` and
`ExecReload=/bin/kill -USR1 $MAINPID` (rolling restart instead of plain quiet).
---
## capistrano-sidekiq
[`capistrano-sidekiq`](https://github.com/seuros/capistrano-sidekiq) shells out to
the runner and sends the signals above for quiet/restart/stop. The signal handling is
fully drop-in:
- `Sidekiq::CLI` is aliased to `Wurk::CLI`, and
- the `TSTP` / `TERM` / `USR1` signal semantics match exactly.
The one thing to map is the binary name. Wurk ships `wurk`, `wurkswarm`, and a
`sidekiqswarm` alias — but there is **no `sidekiq` binary** (see
[`docs/migrate-from-sidekiq.md`](migrate-from-sidekiq.md)). So an Enterprise
`sidekiqswarm` invocation drops in unchanged, while a `bundle exec sidekiq` invocation
needs pointing at `wurk`: set `sidekiq_role`/`sidekiq_command` config to
`wurk`/`wurkswarm`, or add a tiny `bin/sidekiq` shim that `exec`s `wurk`. The systemd
integration mode drops in the same way — replace the generated unit's `ExecStart` with
`bundle exec wurk` and keep the rest.
---
## Heroku / Procfile
```procfile
worker: bundle exec wurkswarm -e production
```
Use `wurkswarm` for fork-based parallelism inside the dyno (set `WURK_COUNT` to the
dyno's core count), or `bundle exec wurk` for a single process if you'd rather scale
out by adding worker dynos. Heroku sends `SIGTERM` on dyno cycle, which triggers
Wurk's graceful drain — set the shutdown timeout (`-t`) below Heroku's 30s kill window
so jobs finish cleanly.
---
## Docker
The repo's own [`Dockerfile`](../Dockerfile) (which builds the public demo image) is a
usable template for a worker image. The shape that matters:
```dockerfile
# Dockerfile
# Stage 1 — build the dashboard SPA. Only needed if you build Wurk from source;
# consumers installing the gem get the precompiled bundle in vendor/assets.
FROM oven/bun:1.3.14-slim AS spa
WORKDIR /src
COPY frontend/package.json frontend/bun.lock ./frontend/
RUN cd frontend && bun install --frozen-lockfile
COPY frontend/ ./frontend/
RUN cd frontend && bun run build
# Stage 2 — Ruby runtime. No Node/bun at runtime.
FROM ruby:3.4-slim-bookworm
ENV RAILS_ENV=production
RUN useradd --create-home --shell /bin/bash wurk
WORKDIR /app
COPY . /app
RUN bundle install --jobs 4 --retry 3 && chown -R wurk:wurk /app
USER wurk
EXPOSE 3000 7433 # 3000 = your web app, 7433 = Wurk health probes
ENTRYPOINT ["/app/bin/entrypoint"]
CMD ["web"]
```
**One image, two roles.** The demo entrypoint switches on its argument — `web` runs
Puma with `WURK_DISABLED=1` (so the web container never forks workers), `worker` runs
the swarm. Copy that pattern rather than building two images:
```bash
# bin/entrypoint
case "${1:-web}" in
web)
export WURK_DISABLED=1
exec bundle exec rails server -b 0.0.0.0 -p "${PORT:-3000}"
;;
worker)
exec bundle exec wurkswarm -e production
;;
esac
```
### PID 1 and signals
This is the one thing containers get wrong, and it silently costs you the graceful
drain.
- **`exec`, always.** `exec bundle exec wurkswarm` makes the swarm parent PID 1 so it
receives `SIGTERM` directly. A shell wrapper that *doesn't* `exec` keeps the shell as
PID 1, and the shell will not forward `TERM` to the swarm — Docker's grace period
elapses and everything gets `SIGKILL`ed mid-job.
- **Prefer exec-form `ENTRYPOINT`/`CMD`** (`["bundle", "exec", "wurkswarm"]`). Shell
form (`ENTRYPOINT bundle exec wurkswarm`) wraps the command in `/bin/sh -c`, which
reintroduces the same problem.
- **PID 1 has no default signal dispositions**, but Wurk installs explicit traps for
everything it cares about (`TERM INT TSTP USR1 USR2` on the swarm parent), so it
works as PID 1 without an init shim. You still may want `--init` / `tini` so the
reaping of *your app's* stray grandchildren isn't the swarm's problem — the swarm
reaps only its own children.
- **`docker stop` grace must exceed the drain.** `docker stop` sends `TERM` then
`KILL` after 10s by default; the swarm waits `shutdown_timeout + 5s` (default 30s)
for its children. Use `docker stop -t 40` / `stopGracePeriod: 40s`.
- **Redis must be reachable at boot.** Each forked child `PING`s its fresh pool and
crashes (to be respawned with backoff) if it can't reach Redis, so a container
started before Redis is up will crash-loop its children rather than silently idle.
---
## Kubernetes
### Health probes
Wurk ships a dependency-free HTTP listener for probes — a raw `TCPServer` and one
accept thread, no Rack — so it works in a swarm child, the standalone CLI, or embedded
mode alike. It is **opt-in**:
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.health_check(port: 7433) # bind: "0.0.0.0", ready_window: 30
end
```
| Method + path | 200 when | 503 when |
|---|---|---|
| `GET /live` | The launcher is running. | The launcher is stopping — i.e. after `quiet` (`TSTP`) or `stop` (`TERM`). Body: `{"status":"down","check":"live","reason":"stopping"}` |
| `GET /ready` | Redis answers `PING` **and** the heartbeat fired within `ready_window` (default **30s**). | Either check fails. Body carries `"reason":"redis unreachable"` or `"reason":"heartbeat stale"`. |
Anything else returns `404` JSON; any non-`GET` returns `405`. All responses are
`Content-Type: application/json` with `Connection: close`.
Knobs (`Wurk::Configuration#health_check`): `port:` (required, 0–65535 — `0` lets the
kernel pick), `bind:` (default `"0.0.0.0"`), `ready_window:` (default `30`, must be
> 0). The listener starts **last** in the launcher's boot order, so probes are not
accepted until pollers, managers, and the reaper are up.
> **Swarm mode: the port is shared, and it fails over.** All children try to bind the
> same port; the first wins and the rest poll every **5s** to take it over. When the
> owning child exits (crash-respawn, rolling restart, memory recycle) a survivor picks
> the port up within ~5s, so probes ride out ordinary child churn instead of going
> dark. Probes therefore report *a* child in the pod, not every child.
> ⚠️ **`/live` goes 503 on quiet.** `TSTP` sets `stopping?`. If `/live` is wired to
> `livenessProbe`, quieting a pod makes the kubelet restart it. That's usually what you
> want during a rolling update (quiet happens inside `preStop`, right before the pod
> dies anyway) but never quiet a pod you intend to keep.
### Deployment manifest
```yaml
# k8s/worker-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: wurk-worker
spec:
replicas: 3
selector:
matchLabels: { app: wurk-worker }
template:
metadata:
labels: { app: wurk-worker }
spec:
# shutdown_timeout (25s) + the swarm's 5s grace + slack for the preStop sleep.
terminationGracePeriodSeconds: 60
containers:
- name: worker
image: registry.example.com/myapp:1.4.2
args: ["worker"]
env:
- name: RAILS_ENV
value: production
- name: WURK_COUNT # forked children per pod
value: "4"
- name: SIDEKIQ_MAXMEM_MB # graceful child recycle above 1.5GB RSS
value: "1500"
- name: REDIS_URL
valueFrom:
secretKeyRef: { name: wurk, key: redis-url }
ports:
- name: health
containerPort: 7433
# Give a cold Ruby boot up to 60 × 2s before liveness starts judging.
startupProbe:
httpGet: { path: /live, port: health }
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet: { path: /live, port: health }
periodSeconds: 10
failureThreshold: 3
# Readiness gates on Redis + a fresh heartbeat. Heartbeats are written
# every 10s, and the default ready_window is 30s, so periodSeconds must
# stay comfortably above the beat interval.
readinessProbe:
httpGet: { path: /ready, port: health }
periodSeconds: 15
failureThreshold: 2
lifecycle:
preStop:
exec:
# Quiet first so the pod stops claiming new jobs, then let the
# TERM that follows drain what is already in flight.
command: ["/bin/sh", "-c", "kill -TSTP 1 && sleep 5"]
resources:
requests: { cpu: "2", memory: 2Gi }
limits: { memory: 3Gi }
```
**`terminationGracePeriodSeconds` vs `shutdown_timeout`.** The kubelet runs `preStop`,
sends `TERM`, then `SIGKILL`s the pod once the grace period elapses — the clock starts
at the *beginning* of `preStop`. Budget:
```text
terminationGracePeriodSeconds > preStop duration
+ shutdown_timeout (-t, default 25s)
+ 5s (the swarm's SHUTDOWN_GRACE before it KILLs children)
```
With the defaults that's 5 + 25 + 5 = 35s, so 60s leaves real headroom. If you raise
`-t`, raise the grace period with it. Getting this wrong isn't data loss — a `SIGKILL`ed
worker's in-flight jobs stay on its private list and are reclaimed on the next boot —
but it does mean retries and duplicate work you didn't need.
**Set memory `limits` above `SIDEKIQ_MAXMEM_MB × WURK_COUNT` plus the parent's
footprint.** The recycle threshold is per child; the cgroup limit is per pod. If the
cgroup OOM-kills first you get a hard `SIGKILL` instead of the graceful replace.
**Rolling updates ship new code by replacing pods, not by `USR1`.** Kubernetes already
gives you the zero-downtime property that rolling restart gives a long-lived swarm
parent — leave `maxUnavailable` at its default and let the new ReplicaSet come up
`Ready` (i.e. Redis-reachable and heartbeating) before the old pods drain.
---
## Capacity and sizing
Two independent knobs, and the total is their product. Full treatment, including the
DB-pool math, is in
[`docs/migrate-from-sidekiq.md` §2](migrate-from-sidekiq.md#2-concurrency-vs-parallelism-read-this) —
the deploy-time summary:
| Knob | Controls | How to set it | Default |
|---|---|---|---|
| **Parallelism** | Forked worker **processes** per host/pod | `WURK_COUNT` env var (`SIDEKIQ_COUNT` alias) | CPU core count (`Etc.nprocessors`) |
| **Concurrency** | **Threads** per process | `config.concurrency`, `-c`, YAML `:concurrency`, or `RAILS_MAX_THREADS` | `5` |
```text
in-flight jobs per host = WURK_COUNT × concurrency
DB connections per host = WURK_COUNT × pool (each fork opens its own pool)
```
- A whole-number `WURK_COUNT` is an absolute process count; a fractional value is a CPU
multiplier (`WURK_COUNT=0.5` → half the cores, rounded). The result is floored at 1,
and unparseable input falls back to the core count.
- **`WURK_COUNT` only applies to the forking runners** — `wurkswarm` and the Rails
engine's auto-boot swarm. Plain `bundle exec wurk` is a single process and ignores
it. There is no `WURK_CONCURRENCY` env var.
- **The default is aggressive on a big box.** 16 cores × 5 threads = 80 in-flight jobs
and 80 DB connections from one host. Pin `WURK_COUNT` explicitly in production
rather than inheriting the core count from whatever instance type you land on —
especially in containers, where `nprocessors` may report the *node's* cores, not your
CPU limit.
- Size each process's DB pool to cover `concurrency`, then check
`WURK_COUNT × pool ≤ your database's spare connections`.
- Rule of thumb: `concurrency: 5` and `WURK_COUNT` = cores you want to dedicate to
jobs. Raise concurrency for IO-bound work, raise `WURK_COUNT` for CPU-bound work,
re-check the connection and memory math after either change.
---
## Zero-downtime deploy checklist
The whole page in the order you'd actually execute it. Steps 3–5 differ by platform;
everything else is the same everywhere.
1. **Ship the new release** alongside the old one (new release dir, new image tag) —
don't touch the running workers yet.
2. **Quiet the old workers.** `TSTP` to the swarm parent (or `systemctl reload wurk`,
or a `preStop` hook). They stop claiming new jobs; in-flight work continues. Quiet
is one-way and sticky — the only way forward is `TERM`.
3. **Wait out the long tail.** Give the drain roughly your longest job's runtime, or
watch the Busy count on the dashboard hit zero. This is the step people skip.
4. **Start the new workers**, pointing at the new release. Under Kubernetes the new
ReplicaSet's `readinessProbe` (`/ready`) does this for you: a pod is Ready only when
Redis answers and it has heartbeated within `ready_window`.
5. **Stop the old workers.** `TERM`. In-flight jobs get up to `shutdown_timeout`
(`-t`, default 25s); the swarm parent waits `timeout + 5s` before `KILL`ing
stragglers. Anything killed anyway is reclaimed from its private list on the next
boot, so the worst case is a retry, not a loss.
6. **Confirm the fleet.** The dashboard's Busy page should show only new-release
processes; the swarm parent logs a line per respawn/restart.
Notes that decide *which* mechanism you use:
- **New code ⇒ restart the parent.** `USR1` re-forks children from the parent's
already-loaded app, so it recycles memory and connections, not code.
- **Migrations first, backwards-compatible always.** Old and new workers overlap during
steps 2–5, and job arguments enqueued by the old release will be dequeued by the new
one (and vice versa). Deploy schema changes and argument-shape changes in two passes.
- **Sidekiq and Wurk can share one Redis during a cutover** — same key schema, same job
JSON — so the same checklist works for the migration deploy itself.
- **Expect a leader handover** in step 5. A graceful shutdown releases `dear-leader`
immediately; a `SIGKILL`ed leader costs up to 30s, during which cron does not fire.
---
# Active Job adapter
Wurk ships an Active Job queue adapter, so Rails apps that enqueue through
`ActiveJob::Base` (mailers, `deliver_later`, Turbo broadcasts, your own
`ApplicationJob` subclasses) run on Wurk without touching a single job class.
> Authoritative surface: [`docs/target/sidekiq-free.md`](target/sidekiq-free.md) §28.
> Adapter source: [`lib/active_job/queue_adapters/wurk_adapter.rb`](../lib/active_job/queue_adapters/wurk_adapter.rb).
---
## Enabling it
```ruby
# config/application.rb (or config/environments/*.rb)
config.active_job.queue_adapter = :wurk
```
Rails resolves `:wurk` to `ActiveJob::QueueAdapters::WurkAdapter` by convention.
The engine loads the adapter eagerly at boot, so the constant exists before your
config line runs — no extra `require`.
Per-job overrides work the same as any adapter:
```ruby
class HardJob < ApplicationJob
self.queue_adapter = :wurk
queue_as :critical
end
```
---
## Migrating apps already on `:sidekiq`
You don't have to change `queue_adapter = :sidekiq` to flip the switch. When the
real `sidekiq` gem is **not** in your bundle, Wurk resolves `:sidekiq` to a
Wurk-backed adapter as well — same enqueue path, same payload — so a one-line
gem swap keeps a `config.active_job.queue_adapter = :sidekiq` app working
untouched (pillar 1). If the genuine `sidekiq` gem *is* still bundled (a mixed
mid-migration setup), Wurk leaves its adapter alone and never clobbers it.
So: leave `:sidekiq` as-is for a zero-edit cutover, or switch to `:wurk` once
Sidekiq is fully removed. Both run on Wurk.
---
## Wire compatibility
Active Job jobs are wrapped in `Sidekiq::ActiveJob::Wrapper` before they hit
Redis — the canonical `class` string Sidekiq itself writes. That means:
- Jobs enqueued by stock Sidekiq before the swap deserialize and run on Wurk.
- A mixed pool of Sidekiq and Wurk workers can drain the same Active Job queue.
- The legacy `ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper` constant and
the old `Wurk::ActiveJob::Wrapper` name both resolve to the same wrapper, so
payloads written by any Rails version load on the gem swap.
The wrapper's `provider_job_id` is set to the Wurk `jid`, so
`job.provider_job_id` works exactly as it does under Sidekiq.
## Behavior worth knowing
| Behavior | Detail |
|---|---|
| **Transaction-safe enqueue** | `enqueue_after_transaction_commit?` is `true`. A job enqueued inside a DB transaction is pushed only after that transaction commits, so you never dispatch a job whose row hasn't landed. Matches Sidekiq. |
| **Bulk enqueue** | `enqueue_all` (Rails 7.1+ `perform_all_later`) groups jobs by class and queue and pushes each group in one `push_bulk` pipeline. |
| **`sidekiq_options` on AJ classes** | Native Active Job classes gain `sidekiq_options` (retry count, queue, etc.) via `Wurk::Job::Options`, so existing `sidekiq_options retry: 5`-style config on an AJ class keeps working. |
| **Quiet on shutdown** | The adapter's `stopping?` flips `true` on the `:quiet` lifecycle event, so Active Job's own shutdown checks behave as under Sidekiq. |
## Requirements
`activejob >= 7.0`. If Active Job isn't in the bundle the adapter is simply
unavailable — same as Sidekiq, no error at boot.
---
# Testing jobs
Wurk ships a `Sidekiq::Testing`-compatible harness in the gem — there is nothing
extra to require. `Wurk::Testing`, `Wurk::Queues`, and the class-level helpers on
your job classes are all aliased under their `Sidekiq::*` names, so an existing
suite that calls `Sidekiq::Testing.fake!` or asserts on `MyJob.jobs.size` keeps
working on the one-line gem swap.
| Alias | Real constant |
|---|---|
| `Sidekiq::Testing` | `Wurk::Testing` |
| `Sidekiq::Queues` | `Wurk::Queues` |
| `Sidekiq::EmptyQueueError` | `Wurk::Testing::EmptyQueueError` |
| `Sidekiq::Job` / `Sidekiq::Worker` | `Wurk::Job` / `Wurk::Worker` |
| `Sidekiq.testing!` / `Sidekiq.testing?` | `Wurk.testing!` / `Wurk.testing?` |
The mode is read by `Wurk::Client#raw_push` — the single place every enqueue path
funnels through — so `perform_async`, `perform_in`, `perform_bulk`,
`Sidekiq::Client.push`, `push_bulk`, and the ActiveJob adapter all honor it
without special-casing.
> **Under Rails' test environment the swarm never forks.** `Wurk::RailsBoot`
> skips boot when `Rails.env.test?` (as it does for `WURK_DISABLED=1` and the
> console), so no worker process will pick up anything you push to Redis from a
> test. Executing a job in a test is always something you do explicitly, with
> one of the modes below.
---
## 1. The three modes
| Mode | Setter | What a push does |
|---|---|---|
| `:disable` | `Sidekiq::Testing.disable!` | Real Redis write. **This is the default.** |
| `:fake` | `Sidekiq::Testing.fake!` | Payload collected in the in-memory `Sidekiq::Queues` store; nothing runs |
| `:inline` | `Sidekiq::Testing.inline!` | Job executed synchronously, in the calling thread, the instant it's pushed |
Predicates: `enabled?` (true in `:fake` or `:inline`), `disabled?`, `fake?`,
`inline?`. `Sidekiq.testing?` is the same as `Sidekiq::Testing.enabled?`.
**No-block form sets the mode process-globally. Block form sets it on the
current thread only, for the duration of the block:**
```ruby
Sidekiq::Testing.fake! # global, until changed
Sidekiq::Testing.inline! do # this thread, this block
OrderJob.perform_async(order.id) # runs right here
end # back to the global mode
```
`Sidekiq.testing!(:fake)` / `Sidekiq.testing!(:inline) { … }` is the same entry
point under the modern name; it defaults to `:fake` when called with no argument.
Two things to know about the block form:
- The thread-local wins over the global mode, so a block inside a globally-fake
suite is properly scoped.
- **Nesting block forms raises `Sidekiq::Testing::TestModeAlreadySetError`.**
`fake! { inline! { … } }` is rejected, matching Sidekiq 8. Set the outer mode
globally if you need to switch inside it.
Because the mode is a process global plus a thread local, and the fake store is
process-global, a threaded parallel test runner shares both. Wurk's own suite
sidesteps this by forking (`minitest-parallel_fork`); if you parallelize with
threads, use the block form and don't assert on `Sidekiq::Worker.jobs` across
threads.
---
## 2. The fake store
In `:fake` mode each payload is appended to `Sidekiq::Queues`, keyed by queue
name. `enqueued_at` is stamped with the current epoch-ms unless the job is
scheduled — same as the real client. `perform_async` still returns a jid, and
`perform_bulk` still returns the array of jids.
### Per-class helpers
Defined on every class that includes `Sidekiq::Job`:
| Method | Semantics |
|---|---|
| `MyJob.jobs` | Array of payload hashes for this class, across every queue |
| `MyJob.clear` | Remove this class's fake jobs (leaves other classes alone) |
| `MyJob.drain` | Run **and remove** every fake job for this class — including ones enqueued mid-drain. Returns the count processed |
| `MyJob.perform_one` | Run and remove the first fake job for this class. Raises `Sidekiq::EmptyQueueError` if there are none. Returns the job's `perform` value |
| `MyJob.process_job(hash)` | Execute one normalized payload hash through the inline server chain |
| `MyJob.execute_job(instance, args)` | The innermost call — `instance.perform(*args)` |
| `MyJob.queue` | The class's configured queue name |
```ruby
# test/jobs/order_job_test.rb
Sidekiq::Testing.fake! do
OrderJob.perform_async(1)
OrderJob.perform_async(2)
end
assert_equal 2, OrderJob.jobs.size
assert_equal [1, 2], OrderJob.jobs.map { |j| j["args"].first }
assert_equal 2, OrderJob.drain
assert_empty OrderJob.jobs
```
A payload is the wire hash, so assert on it with string keys: `"class"`,
`"queue"`, `"args"`, `"jid"`, `"enqueued_at"`, and `"at"` for scheduled jobs.
### Cross-class helpers
`Sidekiq::Job.*` and `Sidekiq::Worker.*` both work — they're the same methods:
| Method | Semantics |
|---|---|
| `Sidekiq::Worker.jobs` | Every fake payload, all classes, flattened |
| `Sidekiq::Worker.clear_all` | Empty the whole store |
| `Sidekiq::Worker.drain_all` | Run everything until the store is empty; returns the count |
### `Sidekiq::Queues` directly
| Method | Semantics |
|---|---|
| `Sidekiq::Queues["default"]` | The **live** array for that queue — `.clear` on it mutates the store |
| `Sidekiq::Queues.jobs` | Every payload, flattened |
| `Sidekiq::Queues.jobs_by_queue` | Live `queue => [jobs]` hash |
| `Sidekiq::Queues.jobs_by_class` | `class name => [jobs]` (alias `jobs_by_worker`) |
| `Sidekiq::Queues.push(queue, klass, job)` | Append a payload by hand |
| `Sidekiq::Queues.delete_for(jid, queue, klass)` | Drop one payload by jid |
| `Sidekiq::Queues.clear_for(queue, klass)` | Drop one class's payloads from one queue |
| `Sidekiq::Queues.clear_all` | Empty the store |
The store is mutex-guarded, and the lock is released before a job runs during a
drain — so a job that enqueues more work doesn't deadlock and its children are
picked up by the same drain.
---
## 3. Minitest
Set the default mode once and clear the store between tests:
```ruby
# test/test_helper.rb
require "wurk"
Sidekiq::Testing.fake!
module ActiveSupport
class TestCase
setup { Sidekiq::Worker.clear_all }
end
end
```
Override per test with the block form:
```ruby
# test/models/order_test.rb
class OrderTest < ActiveSupport::TestCase
test "enqueues a receipt" do
order = orders(:one)
assert_difference -> { ReceiptJob.jobs.size }, 1 do
order.complete!
end
assert_equal [order.id], ReceiptJob.jobs.last["args"]
end
test "receipt job charges the card" do
Sidekiq::Testing.inline! do
orders(:one).complete!
end
assert_predicate orders(:one).reload, :receipted?
end
end
```
## 4. RSpec
```ruby
# spec/spec_helper.rb
require "wurk"
Sidekiq::Testing.fake!
RSpec.configure do |config|
config.before { Sidekiq::Worker.clear_all }
end
```
Tag-driven per-example modes:
```ruby
# spec/spec_helper.rb
RSpec.configure do |config|
config.around(:each, :inline) { |ex| Sidekiq::Testing.inline!(&ex) }
config.around(:each, :real_redis) { |ex| Sidekiq::Testing.disable!(&ex) }
end
```
```ruby
# spec/models/order_spec.rb
it "charges immediately", :inline do
expect { order.complete! }.to change { order.reload.receipted? }.to(true)
end
```
`Sidekiq::Testing.inline!(&ex)` works because the block form takes any callable
block — RSpec's example object responds to `call`.
---
## 5. Scheduled jobs
`perform_in` / `perform_at` / `set(wait:)` / `set(wait_until:)` compute an
absolute epoch-seconds `at` and put it on the payload. An interval below
`1_000_000_000` is seconds-from-now; at or above, it's an absolute timestamp. If
the resulting time is **not in the future**, `at` is dropped and the job enqueues
immediately — so `set(wait: 0)` is an ordinary push in every mode.
**In `:fake` mode there is no separate scheduled set.** The payload lands in the
same in-memory queue array as an immediate job, distinguished only by its `"at"`
key (and by *not* having `"enqueued_at"`):
```ruby
Sidekiq::Testing.fake! { ReminderJob.perform_in(1.hour, lead.id) }
job = ReminderJob.jobs.last
assert_in_delta Time.now.to_f + 3600, job["at"], 1.0
assert_nil job["enqueued_at"]
```
Consequences worth internalizing:
- `MyJob.drain`, `perform_one`, and `drain_all` **run scheduled jobs too**, right
away, ignoring `at`. There is no clock to advance.
- `Sidekiq::ScheduledSet` reads real Redis. In `:fake` mode it is empty no matter
how many `perform_in` calls you made. To assert on the scheduled ZSET, test
with the mode disabled (see § 8).
- In `:inline` mode a scheduled job runs **immediately** — the delay is
discarded, not honored.
---
## 6. What inline execution does and does not do
`:inline` mode (and `drain` / `perform_one`) calls `MyJob.process_job(payload)`,
which instantiates the class, assigns `jid` (and `bid` if the class has one), and
invokes `perform` through `Sidekiq::Testing.server_middleware`.
That is a **separate, empty-by-default chain** — not
`Wurk.configuration.server_middleware`. Add to it explicitly if your test needs
server middleware to run:
```ruby
# test/test_helper.rb
Sidekiq::Testing.server_middleware do |chain|
chain.add MyApp::TenantMiddleware
end
```
Client middleware, by contrast, **does** run in every mode: the test hook sits
downstream of the client chain, so a middleware that mutates or halts the push
behaves the same fake, inline, or real.
Things that are absent from the inline path because the real
`Wurk::Processor` isn't involved:
- **No retry.** An exception propagates straight out of `perform_async` /
`drain` to your test. Nothing is written to the retry set, `sidekiq_retry_in` /
`sidekiq_retries_exhausted` blocks don't fire, and no death handlers run. Test
retry behavior by calling the hook blocks directly, or against real Redis.
- **No job logging, no stats, no `Sidekiq::WorkSet` entry**, no heartbeat.
- **No interrupt handling.** `Sidekiq::Job::Interrupted` is raised by the
interrupt-handler server middleware, which isn't in the testing chain — so
iterable jobs run to completion inline rather than yielding.
---
## 7. Batches, unique jobs, and limiters still use Redis
This is the sharpest edge on the page. The testing modes intercept **the job
push and nothing else.** Features implemented as direct Redis calls are
untouched by them:
| Feature | Behavior under `:fake` / `:inline` |
|---|---|
| `Sidekiq::Batch` | Batch creation, `bid` metadata, callbacks, `linger`, `invalidate_all` all write to real Redis. A Redis connection is required even in `:fake` mode |
| Batch callbacks (`on(:success)`, `on(:complete)`) | **Never fire.** They're driven by the server-side completion path, which inline execution bypasses. Counters do not decrement |
| Batch autoflush buffering | Skipped — the test hook short-circuits ahead of the buffer, so each push is collected/executed individually |
| `Sidekiq::Enterprise.unique!` | The client middleware acquires its lock against real Redis before the test hook runs, so duplicates **are** suppressed in fake mode — and the lock persists past the test unless you flush |
| `Sidekiq::Limiter` | Talks to Redis directly from inside your job body; unaffected by the mode |
| `Sidekiq::Queue`, `RetrySet`, `ScheduledSet`, `DeadSet`, `Stats` | Read real Redis; they never see the fake store |
So: point your test environment at a real (ideally per-worker, isolated) Redis
database even when you run in `:fake` mode, and flush it between tests. Batch and
unique-job assertions belong in the real-Redis tier below.
---
## 8. Testing against real Redis
The fake store is a convenience for "did my model enqueue the right thing".
Anything about *how Wurk handles the job* — retries, the scheduled ZSET, the dead
set, batch completion, uniqueness — is behavior of Redis-backed code, and mocking
Redis to test it only tests the mock. Wurk's own suite forbids mocked Redis in
integration and parity tests for exactly that reason; the same logic applies to
your app.
Run those tests with the mode disabled, against a throwaway Redis database:
```ruby
# test/integration/reminder_scheduling_test.rb
class ReminderSchedulingTest < ActiveSupport::TestCase
def setup
Sidekiq.redis { |c| c.call("FLUSHDB") }
end
test "parks the job in the scheduled set" do
Sidekiq::Testing.disable! do
ReminderJob.perform_in(3600, 42)
end
entry = Sidekiq::ScheduledSet.new.first
assert_equal "ReminderJob", entry.klass
assert_equal [42], entry.args
assert_in_delta Time.now.to_f + 3600, entry.score, 5.0
end
end
```
Guidance that holds up in practice:
- **Never `FLUSHDB` a database you also develop against.** Point the test env at
a dedicated logical DB (`redis://localhost:6379/15`) and flush that one only.
- Give each parallel test worker its own logical DB (1–15) so concurrent tests
can't see each other's keys. Wurk's own suite assigns one per forked worker.
- To exercise the *whole* pipeline (fetch, execute, retry) rather than just the
enqueue, boot workers explicitly — `bundle exec wurk` in a subprocess, or
`Wurk::Embedded` — since the railtie won't fork under `Rails.env.test?`.
---
## 9. ActiveJob
Jobs enqueued through ActiveJob are pushed as
`Sidekiq::ActiveJob::Wrapper`, with the serialized ActiveJob payload as the
single argument. That's the wire shape Sidekiq emits, and it's what the fake
store records:
```ruby
Sidekiq::Testing.fake! { MyMailer.welcome(user).deliver_later }
assert_equal 1, Sidekiq::ActiveJob::Wrapper.jobs.size
```
`MyMailerJob.jobs` will be empty — the payload's `"class"` is the wrapper, not
your job class. If you'd rather assert in ActiveJob's own vocabulary
(`assert_enqueued_with`, `perform_enqueued_jobs`), set
`ActiveJob::Base.queue_adapter = :test` in the test environment and leave Wurk's
modes out of it. `:inline` mode does run wrapped ActiveJob payloads correctly —
the wrapper resolves and calls `ActiveJob::Base.execute`.
---
## 10. Migrating an existing Sidekiq suite
The whole `Sidekiq::Testing` / `Sidekiq::Queues` / `Sidekiq::Job` test surface is
implemented, under the same names, with the same semantics. In practice a suite
moves over untouched. Two things to check:
**1. `require "sidekiq/testing"` switches you into fake mode**, exactly as it
does under Sidekiq — so a suite that relied on that side effect keeps working
untouched. It prints a deprecation warning once per process, matching upstream,
and it never overrides a mode you chose explicitly, so this is safe:
```ruby
# test/test_helper.rb
require "sidekiq/testing"
Sidekiq::Testing.disable! # respected — the require does not fight you
```
Prefer setting the mode explicitly and dropping the require. Note the side
effect is process-global: don't `require "sidekiq/testing"` from a file that a
non-testing process loads.
**2. `Sidekiq::Testing` is a module, not a class.** Wurk implements it as a
module with singleton methods. Every documented call (`fake!`, `inline!`,
`disable!`, the predicates, `server_middleware`, `__set_test_mode`) is identical;
only `Sidekiq::Testing.is_a?(Class)` and subclassing it would differ, and neither
appears in real suites.
There is no `Sidekiq::TestingClient` monkey-patch of `atomic_push` — inline
dispatch is handled inside `raw_push` instead. Behavior is the same; only reach
for this if you patched Sidekiq's internals yourself.
---
## Related
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — the full swap checklist.
- [ActiveJob](active-job.md) — adapter setup and wire format.
- [Running Wurk](running.md) — how workers actually boot outside the test env.
---
# Retries, backoff & the dead set
When `perform` raises, Wurk does not lose the job. It stamps the error onto the
job payload, drops it into the `retry` sorted set with a future timestamp, and
a poller promotes it back onto its queue when the time comes. After enough
failures the job stops retrying and lands in the dead set (the "morgue"), where
it sits until you retry it, delete it, or it ages out.
All of it is wire-compatible with Sidekiq: the same `retry` / `dead` ZSETs, the
same score format, the same `error_class` / `retry_count` / `failed_at` payload
fields. Every class below is aliased — `Sidekiq::RetrySet`, `Sidekiq::DeadSet`,
`Sidekiq::ScheduledSet`, `Sidekiq::SortedEntry`, `Sidekiq::JobRetry` — so an
existing Sidekiq initializer, death handler, or ops script keeps working on the
one-line gem swap.
| Surface | What it controls |
|---------|------------------|
| `sidekiq_options retry:` | Whether and how many times a job retries |
| `sidekiq_options retry_for:` | Retry by elapsed wall-clock instead of a count |
| `sidekiq_options retry_queue:` | Which queue retries go back onto |
| `sidekiq_options dead:` | Whether an exhausted job reaches the morgue |
| `sidekiq_options backtrace:` | Whether the backtrace is stored on the payload |
| `sidekiq_retry_in { }` | Per-class custom backoff, `:discard`, or `:kill` |
| `sidekiq_retries_exhausted { }` | Per-class callback on the final failure |
| `config.death_handlers` | Global callback on every death |
| `config[:max_retries]` | Fleet-wide default attempt count (default `25`) |
| `config[:dead_max_jobs]` | Morgue capacity (default `10_000`) |
| `config[:dead_timeout_in_seconds]` | Morgue TTL (default 180 days) |
---
## The lifecycle of a failing job
1. **`perform` raises.** The Processor wraps every job in two retry guards:
`JobRetry#global` (outermost, no worker instance needed — catches
const-lookup and reloader failures too) and `JobRetry#local` (inner, runs
once the worker instance exists so per-class blocks can fire). Both rescue
`Exception`, not just `StandardError`.
2. **`Wurk::Shutdown` is exempt.** If the exception *is* a shutdown, or has one
anywhere in its `cause` chain, it is re-raised instead of booked as a
failure. A `rescue => e` in your job that swallows a shutdown will not
silently turn a deploy into 5,000 retries.
3. **The error is stamped onto the payload.**
| Field | Value |
|-------|-------|
| `error_class` | `exception.class.name` |
| `error_message` | `exception.message`, truncated to 10,000 chars, forced to UTF-8 and scrubbed |
| `retry_count` | `0` on the first failure, `+1` on each subsequent one |
| `failed_at` | epoch **milliseconds**, set on the first failure only |
| `retried_at` | epoch milliseconds, set on every failure after the first |
| `error_backtrace` | only when `backtrace:` is set — `base64(zlib(JSON(lines)))` |
4. **The queue is decided.** `msg["queue"]` becomes `retry_queue` if the class
sets one, otherwise the queue the job was running on.
5. **Exhaustion is checked** (see [§ Exhaustion](#exhaustion)). If the job is
done retrying, it goes to the morgue path instead of the retry set.
6. **The delay is computed** and the payload is `ZADD`ed into the `retry` ZSET
with score `now + delay + jitter` (epoch float seconds). A
`jobs.retried` statsd counter is incremented, tagged with worker class and
queue.
7. **The unit of work is acked.** `JobRetry` raises `Handled`, which the
Processor treats as a clean exit — the payload is removed from the
per-process private list. The only copy of the job now lives in `retry`.
8. **The scheduler promotes it.** One `Scheduled::Poller` thread per process
wakes on a randomized interval and drains both the `retry` and `schedule`
ZSETs via an atomic Lua pop-by-score, pushing each due job back onto
`queue:`. The poll cadence scales with the number of live processes
(`process_count * config[:average_scheduled_poll_interval]`, default `5`),
so total scheduler traffic against Redis stays roughly constant as you add
processes. The first sweep of a freshly booted process waits 10 seconds so a
fleet-wide deploy doesn't dogpile Redis.
9. **A worker picks it up again**, and the loop repeats from step 1 — with
`retry_count` one higher.
10. **Retries run out.** The per-class `sidekiq_retries_exhausted` block runs,
the payload is `ZADD`ed into the `dead` ZSET, the morgue is trimmed on both
axes, and every `config.death_handlers` entry fires.
Redis structures involved, all unnamespaced and identical to Sidekiq:
| Key | Type | Role |
|-----|------|------|
| `queue:` | LIST | The live queue |
| `retry` | ZSET | Pending retries, score = epoch float seconds of next attempt |
| `schedule` | ZSET | Future-dated jobs (`perform_in`), drained by the same poller |
| `dead` | ZSET | The morgue, score = epoch float seconds of death |
| `stat:failed`, `stat:failed:YYYY-MM-DD` | STRING | Failure counters |
> **A `SIGKILL`ed process is not a failure.** The in-flight payload never
> leaves the per-process private list, so it is reclaimed by the reaper on the
> next boot and re-run — no `retry_count` bump, no dead-set entry.
---
## `retry:` — what each value does
Set on the class with `sidekiq_options`. The default for every job is
`retry: true` (`Wurk::DEFAULT_JOB_OPTIONS`).
```ruby
# app/jobs/charge_job.rb
class ChargeJob
include Wurk::Job
sidekiq_options retry: 5, queue: "payments"
def perform(charge_id) = Charge.find(charge_id).submit!
end
```
| Value | Behavior on failure |
|-------|---------------------|
| `true` (default) | Retry up to `config[:max_retries]` times — `25` unless you change it |
| an Integer `N` | Retry up to `N` times, then dead set |
| `0` | No retry at all; the **first** failure goes straight to the dead set |
| `false` | No retry, **no dead set** — death handlers fire and the job is gone |
The distinction between `0` and `false` matters. `retry: 0` still counts as
"retryable with zero attempts left", so the first failure runs the exhaustion
path: `sidekiq_retries_exhausted`, morgue, death handlers. `retry: false`
short-circuits earlier — the exception propagates out of the inner guard to the
outer one, which runs `config.death_handlers` and drops the job on the floor.
Nothing is written to `retry` or `dead`. If you set `retry: false`, your death
handler *is* your error reporting.
`retry: N` counts retries, not executions: `retry: 5` means one initial attempt
plus five retries, six runs total.
### Related options
```ruby
class ReportJob
include Wurk::Job
sidekiq_options retry: 10,
retry_queue: "low", # retries go here, not back to the origin queue
backtrace: 20, # store the first 20 backtrace lines on the payload
dead: false # exhausted → discarded, never reaches the morgue
end
```
- **`retry_queue:`** — retries are re-enqueued onto this queue instead of the
one they failed on. Useful for keeping a flapping job out of a latency-
sensitive queue.
- **`backtrace:`** — `true` stores every line, an Integer stores the first N.
Stored compressed (`base64(zlib(JSON))`) to keep the Redis payload bounded.
Off by default; the dashboard can only show a backtrace when this is set. A
`config[:backtrace_cleaner]` callable, if registered, filters the lines
before they're stored.
- **`dead: false`** — the job still retries normally, but on exhaustion it is
discarded (`discarded_at` stamped, death handlers fired) rather than written
to the morgue.
- **`retry_for:`** — retry by elapsed time instead of attempt count. Takes
seconds. When set, **the count-based limit is ignored entirely**: the job
keeps retrying until `failed_at + retry_for` is in the past, then exhausts.
Values above 1,000,000,000 are rejected at enqueue time with an
`ArgumentError`.
---
## Backoff
The default delay, in seconds, where `count` is the job's `retry_count`
*after* the current failure was booked (so `0` on the first failure):
```
delay = (count ** 4) + 15
jitter = rand(10 * (count + 1))
retry_at = Time.now.to_f + delay + jitter
```
The jitter is uniform, non-negative, and grows with the attempt number — it
exists so a Redis outage that fails 10,000 jobs at once doesn't cause all
10,000 to come back in the same second.
| `retry_count` | Base delay | Jitter | Actual wait |
|---|---|---|---|
| 0 | 15s | 0–9s | 15–24s |
| 1 | 16s | 0–19s | 16–35s |
| 2 | 31s | 0–29s | 31–60s |
| 3 | 96s | 0–39s | ~1.6–2.3 min |
| 4 | 271s | 0–49s | ~4.5–5.3 min |
| 5 | 640s | 0–59s | ~11–12 min |
| 10 | 10,015s | 0–109s | ~2.8 hours |
| 15 | 50,640s | 0–159s | ~14 hours |
| 20 | 160,015s | 0–209s | ~1.9 days |
| 24 | 331,791s | 0–249s | ~3.8 days |
Twenty-five retries at the default formula span just over 20 days of
wall-clock before the job dies. If that's longer than your incident response
window, lower `retry:` per class rather than leaving jobs to rot in `retry`.
### Custom backoff — `sidekiq_retry_in`
```ruby
# app/jobs/api_sync_job.rb
class ApiSyncJob
include Wurk::Job
sidekiq_retry_in do |count, exception, jobhash|
case exception
when RateLimited then 60 * (count + 1) # linear, 1 min per attempt
when RecordNotFound then :discard # gone for good; don't bury it
when CorruptPayload then :kill # bury it for inspection
else nil # fall back to the default formula
end
end
def perform(id) = ExternalApi.sync!(id)
end
```
The block is called with three arguments in this order:
| Arg | Value |
|-----|-------|
| `count` | `retry_count` after the current failure — `0` on the first failure |
| `exception` | The exception instance that was raised |
| `jobhash` | The full job payload Hash, already stamped with `error_class` etc. |
Return values the code honors:
| Return | Effect |
|--------|--------|
| positive Integer | Used as the delay in seconds; **jitter is still added on top** |
| Float | Truncated to an Integer, then treated as above |
| `:discard` | `discarded_at` is stamped, death handlers fire, nothing is written to `retry` or `dead` |
| `:kill` | Takes the exhaustion path immediately: `sidekiq_retries_exhausted`, morgue, death handlers |
| `nil`, `0`, a negative Integer, or anything else | Falls back to the default `(count ** 4) + 15` formula |
If the block itself raises, the exception is routed to your error handlers with
context `"Failure scheduling retry via \`sidekiq_retry_in\`"` and the default
formula is used. A broken backoff block can't strand a job.
For ActiveJob and other wrapper classes, the block is looked up on the wrapped
class (`jobhash["wrapped"]`) first and falls back to the wrapper's own block.
---
## Exhaustion
A job stops retrying when either condition holds:
- `retry_for` is set on the payload and `failed_at + retry_for` is in the past
(when `retry_for` is set, the attempt count is not consulted at all), **or**
- `retry_count >= max_attempts`, where `max_attempts` is `retry:` when it's an
Integer, otherwise `config[:max_retries]`, otherwise `25`.
Then, in this exact order:
1. **`sidekiq_retries_exhausted`** — the per-class block, if defined.
2. **Morgue or discard** — unless the block returned `:discard` or the payload
has `dead: false`, the job is written to the dead set. Otherwise
`discarded_at` is stamped and nothing is written.
3. **`config.death_handlers`** — every registered global handler, in
registration order. These fire **on every death**, morgue or discard,
including the `retry: false` path and `:discard` from `sidekiq_retry_in`.
### `sidekiq_retries_exhausted`
```ruby
# app/jobs/import_job.rb
class ImportJob
include Wurk::Job
sidekiq_retries_exhausted do |jobhash, exception|
Import.find(jobhash["args"].first).update!(state: "failed")
:discard # optional — skip the morgue for this class
end
def perform(import_id) = Import.find(import_id).run!
end
```
Two arguments: the job payload Hash and the exception. Returning `:discard`
keeps the job out of the morgue. Any other return value is ignored. If the
block raises, the error goes to your error handlers with context
`"Error calling retries_exhausted"` and the job still proceeds to the morgue.
### Global death handlers
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.death_handlers << lambda do |job, exception|
Sentry.capture_exception(
exception,
extra: { jid: job["jid"], class: job["class"], args: job["args"] }
)
end
end
```
`death_handlers` is a plain Array on the config — push callables onto it.
`Sidekiq.configure_server` is the alias, so an existing Sidekiq initializer
needs no change.
- Every handler receives `(job_hash, exception)`.
- Handlers are called in registration order; a handler that raises
`StandardError` is reported to your error handlers with context
`"Error calling death handler"` and the remaining handlers still run.
- They fire on **manual kills too**. `DeadSet#kill` and `SortedEntry#kill`
default to `notify_failure: true`, and when there's no real exception they
synthesize `RuntimeError.new("Job killed by API")` — matching Sidekiq
byte-for-byte, so handlers that pattern-match on that message keep working.
Pass `notify_failure: false` to suppress.
---
## The dead set
A capped ZSET at the `dead` key, scored by time of death.
| Knob | Default | Effect |
|------|---------|--------|
| `config[:dead_max_jobs]` | `10_000` | Maximum entries; the oldest are evicted past this |
| `config[:dead_timeout_in_seconds]` | `15_552_000` (180 days) | Entries older than this are evicted |
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config[:dead_max_jobs] = 50_000
config[:dead_timeout_in_seconds] = 30 * 24 * 60 * 60
end
```
Trimming runs on **every** write to the morgue — exhausted retries, API kills,
and unparseable payloads alike. It's a two-axis pipelined trim:
`ZREMRANGEBYSCORE` drops everything older than the timeout, then
`ZREMRANGEBYRANK 0 -dead_max_jobs` drops the oldest entries beyond the cap.
Eviction is oldest-first; there is no priority or per-class quota. **A busy
morgue silently evicts your older evidence** — if you care about a dead job,
get it out of the set, don't rely on it still being there tomorrow.
Malformed JSON on the queue also lands here: the Processor can't parse it, so
it reports the `JSON::ParserError`, writes the raw bytes to `dead` via
`kill_raw`, and acks. No death handlers fire — there's no parseable job to hand
one.
### From the dashboard
The **Dead** page lists entries newest-first and offers **Retry** and
**Delete**, per entry, in bulk, or across the whole set. The **Retries** page
additionally offers **Kill**, which moves an entry from `retry` straight to
`dead`. The **Scheduled** page offers **Delete** and **Add to queue**.
Mutating dashboard actions are gated by whatever auth you've configured and by
read-only mode — see [Authentication & authorization](authentication.md).
### From Ruby
The whole data API is loaded by `require "wurk"` — there's no separate api
require to remember (`require "sidekiq/api"` is accepted and just loads Wurk).
```ruby
Wurk::RetrySet.new.size # => 143
Wurk::DeadSet.new.size # => 12
Wurk::ScheduledSet.new.size # => 4
```
---
## The sorted-set API
`RetrySet`, `ScheduledSet`, and `DeadSet` all subclass `JobSet` and share one
surface. Every method below is available under both the `Wurk::` and
`Sidekiq::` names.
| Method | Redis | Notes |
|--------|-------|-------|
| `size` | `ZCARD` | O(1) |
| `each { |entry| }` | paged `ZRANGE … REV` | Newest-first, 50 per page; yields `SortedEntry` |
| `scan(match)` | `ZSCAN` with `*match*` | Yields raw JSON + score |
| `find_job(jid)` | `ZSCAN` | First entry with that exact jid, or `nil`. O(n) |
| `fetch(score, jid = nil)` | `ZRANGEBYSCORE` | `score` may be a `Time`, `Numeric`, or `Range` |
| `schedule(timestamp, job)` | `ZADD` | Insert a payload at an explicit time |
| `retry_all` | — | Re-enqueue every entry; returns the count |
| `kill_all(notify_failure: true, ex: nil)` | — | Move every entry to `dead`; returns the count |
| `clear` | `UNLINK` | Drops the whole set |
| `delete_by_value(name, value)` | `ZREM` | Exact-bytes removal |
| `delete_by_jid(score, jid)` | `ZRANGEBYSCORE` + `ZREM` | Aliased as `delete` |
`Enumerable` is included, so `select`, `map`, `count`, and friends work — at
the cost of paging the whole set through Redis.
```ruby
# Find one job and act on it.
entry = Wurk::RetrySet.new.find_job("a1b2c3d4e5f6a1b2c3d4e5f6")
entry.klass # => "ChargeJob"
entry.args # => [42]
entry.jid # => "a1b2c3d4e5f6a1b2c3d4e5f6"
entry.score # => 1721480000.123 (epoch float, when it next runs)
entry.at # => 2026-07-20 12:00:00 UTC
entry.id # => "1721480000.123|a1b2c3d4e5f6a1b2c3d4e5f6"
entry.error? # => true
entry["error_class"] # => "Net::ReadTimeout"
entry.failed_at # => Time
entry.retried_at # => Time
```
`SortedEntry` mutations:
| Method | Effect |
|--------|--------|
| `retry` | Removes the entry and re-pushes it, **decrementing `retry_count` by 1** so a manual retry doesn't consume an attempt |
| `add_to_queue` | Removes and re-pushes with the payload untouched — `retry_count` is not changed |
| `kill` | Removes and writes to the dead set, firing death handlers with the synthesized "Job killed by API" exception |
| `delete` | Removes the entry and nothing else |
| `reschedule(at)` | `ZINCRBY` to shift the entry's score to a new time |
```ruby
# Retry every dead ChargeJob for one customer.
Wurk::DeadSet.new.each do |entry|
entry.retry if entry.klass == "ChargeJob" && entry.args.first == customer_id
end
```
Each mutation removes the entry from Redis first and only re-pushes if the
removal actually succeeded, so two operators clicking "Retry" at the same
moment can't produce two copies of the job.
`retry_all` and `kill_all` loop until the set is empty and are **not**
transactional — an error mid-iteration leaves the set partially processed.
---
## Poison pills
Retries handle jobs that *raise*. A job that kills the whole process — an OOM,
a segfault in a native extension, an infinite loop that hits the shutdown
timeout — never raises, so it never gets a `retry_count`. Its payload stays in
the dead process's private list, the reaper moves it back to the public queue,
another worker picks it up, and it kills that one too.
Wurk breaks that loop. Every job recovered from a dead process's private list
is counted at `super_fetch:recovered:` (a 72-hour-TTL counter). Once a jid
has been recovered **3** times, the next recovery is treated as a poison pill:
the payload is moved to the dead set, a `jobs.poison` statsd counter fires, the
copy on the public queue is `LREM`'d so it can't run again, and any registered
poison callbacks are invoked.
```ruby
# config/initializers/wurk.rb
Wurk::Middleware::PoisonPill.on_poison do |pill|
# pill => { jid:, klass:, count:, queue: }
PagerDuty.trigger("poison pill: #{pill[:klass]} #{pill[:jid]}")
end
```
Poison-pill kills pass `notify_failure: false`, so **`config.death_handlers` do
not fire for them** — register `on_poison` if you want to be paged. The Pro
`config.super_fetch! { |jobstr, pill| }` callback also fires on every recovery
(with `pill` nil on a plain recovery, populated on the kill path).
`Wurk::Middleware::PoisonPill.recovery_count(jid)` reads the counter without
bumping it; `.clear!(jid)` resets it.
---
## Operating this
**Make jobs idempotent.** Retries are at-least-once by construction: a job that
succeeds and then crashes before acking will run again, and a manual "Retry
now" from the dashboard re-runs the whole `perform`. Write jobs that can run
twice — check for the record you're about to create, use a unique constraint,
key the side effect on the jid.
**Watch the dead set's size, not just its contents.** `Wurk::Stats.new` exposes
`retry_size`, `dead_size`, and `scheduled_size`. A growing `retry_size` means
failures are outrunning recoveries; a `dead_size` pinned at `dead_max_jobs`
means you are silently evicting evidence and should raise the cap or fix the
source.
```ruby
stats = Wurk::Stats.new
stats.retry_size # jobs waiting to be retried
stats.dead_size # jobs that gave up
stats.failed # lifetime failure counter
```
**Don't leave `retry: true` on jobs that can never succeed.** A job whose
arguments reference a deleted record will burn 25 attempts over 20 days for
nothing. Either return `:discard` from `sidekiq_retry_in` for that exception
class, or rescue it in `perform`.
**Don't set `retry: false` and call it error handling.** Nothing is persisted
and nothing is visible in the dashboard — a death handler is the only place the
failure surfaces.
**Set `backtrace:` on the jobs you actually debug**, not globally. It's stored
compressed, but it's still bytes in Redis on every retry of every job.
---
## Related
- [Running Wurk](running.md) — the swarm, signals, and what a graceful drain
does to in-flight jobs.
- [Authentication & authorization](authentication.md) — gating the retry, kill,
and delete actions in the dashboard.
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — `sidekiq_options`
mapping and the Redis key layout.
- [ActiveJob](active-job.md) — `sidekiq_options` on native Active Job classes,
and how the wrapper payload is shaped.
---
# Batches
A **batch** is a group of jobs with a shared identity in Redis and callbacks
that fire when the group reaches a terminal state. You enqueue N jobs, and Wurk
tells you when all N succeeded (`:success`), when all N stopped running
(`:complete`), or when one of them died for good (`:death`) — without you
polling, chaining, or writing a counter of your own.
Use one when the *aggregate* matters: "email the report once every shard
finished", "flip the feature flag after the backfill completes", "page someone
if any row of this import dies". If you only care about individual jobs, you
don't need a batch.
This is Sidekiq Pro's Batches feature, in the same gem, free. Every class is
exposed under its Sidekiq name — `Sidekiq::Batch`, `Sidekiq::BatchSet`,
`Sidekiq::Batch::Status`, `Sidekiq::Batch::DeadSet`, `Sidekiq::Pro::BatchStatus`
— so existing Pro code and existing batch data in Redis keep working on the
one-line gem swap.
| Surface | Class | Purpose |
|---|---|---|
| `Sidekiq::Batch` | `Wurk::Batch` | Create/reopen a batch, register callbacks, enqueue jobs |
| `Sidekiq::Batch::Status` | `Wurk::Batch::Status` | Read-only snapshot of a batch by bid |
| `Sidekiq::BatchSet` | `Wurk::BatchSet` | Enumerate all batches, tag lookup |
| `Sidekiq::Batch::DeadSet` | `Wurk::Batch::DeadSet` | Enumerate batches that hit `:death` |
| `Sidekiq::Pro::BatchStatus` | `Wurk::Web::BatchStatus` | Rack middleware serving batch JSON for progress bars |
---
## Creating a batch
```ruby
batch = Sidekiq::Batch.new
batch.description = "Nightly import for account #{account.id}"
batch.on(:success, ImportCallback, "account_id" => account.id)
batch.jobs do
rows.each { |row| ImportRowJob.perform_async(row.id) }
end
batch.bid # => "kx3Hs9dQ0aTZbA" — hand this to your UI, store it, poll it
```
`Batch.new` allocates a fresh bid (URL-safe base64 of 10 random bytes) but
writes **nothing** to Redis. The first `#jobs` call is what flushes: it `HSET`s
the core `b-` hash, adds the bid to the global `batches` sorted set, writes
the tag indexes, and stamps the expiry.
Inside the block, every `perform_async` is stamped with the batch's bid by the
batch client middleware (the active batch lives in
`Thread.current[:wurk_current_batch]`), and each push goes through a single Lua
script that increments `total`/`pending`, registers the jid in the live set, and
`LPUSH`es the payload — atomically, so a batch's counters can never disagree
with its queue contents.
An **empty** `#jobs` block is legal: Wurk enqueues a `Wurk::Batch::Empty` no-op
marker so the callbacks still fire rather than the batch hanging at `total: 0`
forever.
`perform_in` / `perform_at` inside the block work too — a scheduled job is
registered into the batch at *creation* time (not at promotion), so `total` and
`pending` reflect it immediately.
### Instance API
| Method | Returns | Notes |
|---|---|---|
| `#bid` | String | Batch id, generated at `new` |
| `#description` / `#description=` | String | Shown in the dashboard |
| `#callback_queue` / `#callback_queue=` | String | Queue for callback jobs (default `"default"`) |
| `#callback_class` / `#callback_class=` | String or Class | The class a bare `"#method"` callback spec resolves against. A `Class` is normalized to its name; set it **before the first `#jobs` call** |
| `#tags` / `#tags=` | Array<String> | Coerced to strings; indexed at `tags:` |
| `#autoflush` / `#autoflush=` | `true` or positive Integer | Buffer pushes inside `#jobs` |
| `#linger` / `#linger=` | Integer seconds | Post-success retention override |
| `#parent_bid` | String / nil | Set when flushed inside another batch's `#jobs` block |
| `#parent` | `Sidekiq::Batch` / nil | Reopened parent batch |
| `#mutable?` | Boolean | True until the first `#jobs` flush; false for a reopened batch |
| `#include?(jid)` | Boolean | Is this jid still live in the batch |
| `#remove_jobs(*jids)` | Integer | Drops jids from the live set, decrements `total`/`pending` by the number actually removed |
| `#invalidate_all` | nil | Marks this batch and every descendant invalid |
| `#valid?` | Boolean | False once invalidated |
| `#status` | `Batch::Status` | Snapshot |
| `#expires_in(duration)` | self | Overrides the 30-day pending TTL; chainable |
| `#on(event, target, options = {})` | self | Register a callback |
| `#jobs { … }` | self | Enqueue block |
`Batch.new(bid)` **reopens** an existing batch: it `HGETALL`s the hash and
restores description, callback queue, tags, linger and callbacks. Reopening is
for jobs and callbacks — `mutable?` is false, because the first flush already
happened.
### autoflush
By default each `perform_async` inside `#jobs` is its own Redis round-trip.
`autoflush` buffers them instead:
```ruby
batch = Sidekiq::Batch.new
batch.autoflush = true # buffer the whole block, flush once at exit
batch.autoflush = 500 # flush every 500 jobs
```
Anything else truthy (`0`, `-1`, `"5"`) raises `ArgumentError` rather than
silently degrading. Scheduled (`at`) jobs bypass the buffer.
---
## Callbacks
```ruby
batch.on(:success, ImportCallback, "account_id" => 42)
batch.on(:complete, "ImportCallback#finished", "account_id" => 42)
batch.on(:death, PagerCallback)
```
The target may be a Class, a `"Klass"` string, a `"Klass#method"` string, or a
class-less `"#method"` string that resolves against the batch's
`callback_class`. Anything else raises `ArgumentError`, as does an unknown
event or a non-Hash `options`.
| Target | Invokes |
|---|---|
| `ImportCallback` | `ImportCallback.new.on_success(status, options)` |
| `"ImportCallback"` | `ImportCallback.new.on_success(status, options)` |
| `"ImportCallback#finished"` | `ImportCallback.new.finished(status, options)` |
| `"#finished"` | `.new.finished(status, options)` |
Set `callback_class` **before the first `#jobs` call** if you use the
class-less form — like `description` and `tags`, it is only persisted at first
flush, and a `"#method"` target can't resolve without it.
A target that can't be resolved at run time — unknown constant, a module rather
than a class, a missing or private method, or a class-less form with no
`callback_class` — raises `Wurk::Batch::CallbackJob::UnresolvableTarget` and
goes **straight to the dead set on the first attempt** rather than retrying for
25 attempts. A `NameError` doesn't heal with time, so you see it in the Dead
tab immediately; fix the class and retry the entry from there. Errors raised by
the callback body itself keep the normal retry backoff.
The contract is a **class with a no-argument constructor**:
```ruby
# app/jobs/import_callback.rb
class ImportCallback
def on_success(status, options)
Account.find(options["account_id"]).touch(:imported_at)
end
def on_complete(status, options)
Rails.logger.info("batch #{status.bid}: #{status.failures} still failing")
end
def on_death(status, options)
Pager.alert("import #{status.bid} lost #{status.dead_jids.size} jobs")
end
# "ImportCallback#finished" calls this instead of on_
def finished(status, options); end
end
```
`status` is a `Sidekiq::Batch::Status` built fresh from the bid. `options` is
the hash you passed to `#on`, round-tripped through **JSON** — so keys come back
as strings and only JSON types survive. Never pass an ActiveRecord object; pass
its id.
Multiple callbacks per event are fine; they run as independent jobs.
### When each fires
| Event | Fires when | Semantics |
|---|---|---|
| `:complete` | The live-jid set drains — every job either succeeded or died — and every child batch has finished | At most once per batch |
| `:success` | `:complete`'s condition, plus `pending == 0` and no death anywhere in the subtree | At most once per batch |
| `:death` | A job exhausts its retries (or carries `dead: false` and is discarded) and the batch's died set goes from empty to non-empty | At most once per batch, ever |
- `:success` and `:death` are not mutually exclusive over time. A death
suppresses `:success` while it stands; if you retry the dead job from the
morgue and it succeeds, the batch's death mark clears and `:success` can fire
later. `:death` itself never fires twice — its dedup key survives the
recovery.
- Child batches fire before their parent: a parent's `:complete`/`:success` are
gated on `b--pkids` (pending children) being empty.
- A death cascades **up** the parent chain: every ancestor gets `:death` and
loses its chance at `:success` until the subtree recovers.
- At-most-once is enforced with `SET NX` marker keys (`b--success`,
`-complete`, `-death`), so racing workers acking the last job can't
double-fire.
### How callbacks run
Callbacks are **ordinary jobs**, not in-process hooks. Wurk enqueues a
`Wurk::Batch::CallbackJob` onto the batch's `callback_queue` with
`retry: true`, and that job instantiates your class and calls the method.
Consequences, all of them load-bearing:
- **Your callback runs in a worker process**, minutes later, possibly on another
host. It has no access to whatever was in scope when you built the batch.
- **It retries like any job**, so it must be idempotent.
- **A worker must be listening on `callback_queue`.** Set it to a queue your
fleet actually consumes — the default is `"default"`.
- One callback raising during *enqueue* is logged and skipped; the other
callbacks for that event still fire.
### Registering callbacks late
`#on` before the first `#jobs` buffers in memory and is written with the first
flush. `#on` **after** the first flush — or on a batch reopened by bid —
appends to Redis immediately via an atomic server-side Lua append, so
concurrent registrations from different processes can't clobber each other.
Two things to know about the late path:
- Registering on a batch whose Redis hash is gone (expired, deleted) raises
`ArgumentError`.
- Registering for an event that **already fired** logs a warning and the
callback never runs.
---
## `Batch::Status`
```ruby
status = Sidekiq::Batch::Status.new(bid)
return render_404 unless status.exists?
```
Construction `HGETALL`s `b-` once; the readers below are served from that
snapshot unless noted. A blank bid raises `ArgumentError`.
| Method | Returns | Source |
|---|---|---|
| `#bid` | String | — |
| `#exists?` | Boolean | False when `b-` is gone (never created, or expired) |
| `#total` | Integer | Distinct jobs added |
| `#pending` | Integer | Not yet succeeded (a death does **not** decrement it) |
| `#failures` | Integer | Jobs *currently* in a failing/retrying state |
| `#created_at` / `#complete_at` / `#success_at` / `#death_at` | Float epoch / nil | Event timestamps |
| `#complete?` | Boolean | Hash flag, falling back to a live recount |
| `#invalidated?` | Boolean | Set by `invalidate_all` |
| `#description` / `#parent_bid` / `#callback_queue` | String / nil | — |
| `#tags` | Array<String> | Parsed from the hash; `[]` on malformed JSON |
| `#failed_jids` | Array<String> | Live `SMEMBERS b--failed` |
| `#dead_jids` | Array<String> | Live `SMEMBERS b--died` |
| `#child_count` | Integer | Live `SCARD b--kids` |
| `#failure_info` | `[]` | Deprecated pre-Pro-8 surface; always empty (see [Divergences](#divergences-from-sidekiq-pro)) |
| `#data` | Hash | JSON-serializable summary — what the dashboard and polling endpoint serve |
| `#reload!` | self | Re-reads the hash |
| `#join` | nil | Blocks the calling thread, polling every 0.5s until `complete?` |
| `#delete` | nil | `UNLINK`s every batch key and drops the bid from `batches`/`dead-batches` |
`#join` is for tests and scripts. Calling it from a worker thread burns a thread
waiting on Redis and defeats the point of asynchronous batches.
`#delete` while jobs are in flight leaves those jobs acking against a batch that
no longer exists: callbacks won't fire and counts stay inconsistent. It also
leaves the `SET NX` callback dedup keys behind (they carry their own 30-day
TTL), so a recreated batch under the same bid would not re-fire.
`failures` is a gauge, not a counter: it goes up when a job raises and down when
that job's retry finally succeeds or when it dies. A batch where every failure
eventually passed reports `failures: 0`.
### Progress polling without the dashboard
```ruby
# config.ru
use Sidekiq::Pro::BatchStatus
run Rails.application
```
`GET /batch_status/.json` returns `Status#data` as JSON; an unknown or
expired bid returns 404, and every other request passes straight through. That
is the whole middleware — drive your progress bar off `total`, `pending`,
`failures`, `complete`.
---
## Reading the batch from inside a job
Every job class gets three helpers, whether or not it was enqueued in a batch:
```ruby
# app/jobs/import_row_job.rb
class ImportRowJob
include Sidekiq::Job
def perform(row_id)
return unless valid_within_batch? # batch was invalidate_all'd — skip
bid # => "kx3Hs9dQ0aTZbA", or nil outside a batch
batch # => Sidekiq::Batch (reopened by bid), or nil
batch&.jobs do # add siblings to my OWN batch
FollowUpJob.perform_async(row_id)
end
end
end
```
There is no `Batch.current` — `bid` and `batch` on the job instance are the
access path, populated from `job_hash['bid']` before `perform` runs.
Adding jobs to your own batch from inside a job is legal and grows `total`,
which is how you fan out work whose shape isn't known up front. The batch is
only "done" once these later jobs finish too.
---
## Nesting
Open a batch inside another batch's `#jobs` block and it links automatically:
`parent_bid` is recorded, and the child bid joins the parent's `b--kids`
(all children) and `b--pkids` (children not yet finished) sets. The parent
can't fire `:complete` or `:success` until every child's subtree has drained.
The two legal moves:
- A **job** opens its own batch (`batch.jobs { … }`) to add siblings.
- A **callback** opens its **parent** batch (`Sidekiq::Batch.new(status.parent_bid)`)
to start the next stage of a workflow.
```ruby
# app/jobs/fulfillment_callbacks.rb
class FulfillmentCallbacks
def step1_done(status, options)
parent = Sidekiq::Batch.new(status.parent_bid)
parent.jobs do
step2 = Sidekiq::Batch.new
step2.on(:success, "FulfillmentCallbacks#step2_done", "oid" => options["oid"])
step2.jobs do
PackJob.perform_async(options["oid"])
LabelJob.perform_async(options["oid"])
end
end
end
end
```
A callback cannot mutate the batch it belongs to — that batch is already
terminal, and `#on` against it would only log the "already fired" warning.
---
## Failure handling
| What happens | Effect on the batch |
|---|---|
| Job raises, will retry | jid joins `b--failed`, `failures` +1 (idempotent per jid) |
| Retry finally succeeds | jid leaves `failed`, `failures` -1, `pending` -1 |
| Job exhausts retries (or `dead: false` discard) | jid moves `failed` → `b--died`, leaves the live set, `:death` fires, batch joins `dead-batches`. **`pending` is not decremented** |
| Dead job retried from the morgue and succeeds | jid rejoins the live set with no recount; when the died set drains, the death mark clears and `:success` becomes reachable again |
| Batch invalidated | Jobs stay queued but the server middleware short-circuits them; they ack as successes without running |
Because a death leaves `pending` untouched, a batch with a dead job reaches
`:complete` (live set empty) but never `:success` — exactly the signal you want.
`Sidekiq::Batch::DeadSet.new.each { |status| … }` enumerates every batch that
hit `:death`, newest first.
Clean, handled exits are neither a success nor a failure and ack nothing: a
shutdown interrupt, a rate-limiter reschedule, and cooperative `IterableJob`
interruption all leave the jid live so the retry counts once.
---
## Discovery
```ruby
Sidekiq::BatchSet.new.size # ZCARD batches
Sidekiq::BatchSet.new.each do |status| # newest first, 100 bids per page
puts "#{status.bid} #{status.pending}/#{status.total}"
end
Sidekiq::BatchSet.new.scan_tags("customer:1234") do |bid|
Sidekiq::Batch::Status.new(bid)
end
```
`BatchSet` is `Enumerable`, so `first`, `take`, `select`, `lazy` all work — but
`#each` builds a `Status` per bid, and each `Status` is a Redis round-trip.
Iterating a set with hundreds of thousands of batches costs accordingly; prefer
`scan_tags` (bids only, no parsing) when you know the tag.
The dashboard's **Batches** page paginates `BatchSet` and shows bid,
description, total, pending, failures, a progress bar and creation time. The
detail page adds tags, parent bid, child count, completion time, the invalidated
badge, and the failed/dead jid lists — all straight from `Status#data`, served
by `GET /api/batches` and `GET /api/batches/:bid`.
---
## Expiry and Redis footprint
| Phase | TTL | Constant |
|---|---|---|
| Pending (from first flush) | 30 days | `Batch::DEFAULT_EXPIRY_SECONDS`, override per batch with `expires_in` |
| After `:success` fires | 24 hours | `Batch::POST_SUCCESS_EXPIRY_SECONDS`, override per batch with `linger=` |
| Callback dedup markers | 30 days | `Batch::CALLBACK_NOTIFY_TTL` |
A batch costs one hash (`b-`) plus the sets it actually needs — `-jids`,
`-failed`, `-died`, `-kids`, `-pkids` — each holding at most one entry per job or
child, plus one small marker key per callback event that fired. The footprint is
bounded entirely by TTL: Wurk never sweeps batches, Redis expires them.
Two behaviours worth knowing:
- A job that dies **after** its batch keys expired would recreate them with no
TTL. The death handler re-stamps with `EXPIRE … NX`, so resurrected keys get a
clock and live batches keep theirs.
- `linger=` on an already-flushed batch writes straight to Redis, so it works on
a batch reopened by bid. `description=`, `callback_queue=`, `callback_class=`,
`tags=` and `expires_in` do **not** — they are only persisted by the first
flush. Set them before the first `#jobs` call.
---
## Testing
`Sidekiq::Testing.fake!` and `inline!` short-circuit `Client#raw_push` before
the Redis write, and the inline server-middleware chain is empty by default.
What that means for batches:
- `Batch#jobs` still talks to Redis — the `b-` hash and the `batches` entry
are created for real.
- The jobs themselves are **not** registered into the batch: no `BATCH_PUSH`, so
`total` and `pending` stay `0`, and the empty-marker job is enqueued as if the
block had been empty.
- No acks and therefore **no callbacks**, in either mode.
- `bid` and `batch` inside a job still work — `process_job` sets the bid from
the payload.
So `fake!`/`inline!` are right for asserting that your code *enqueued* what you
expected (`ImportRowJob.jobs.size`, and each payload carries a `bid`), and wrong
for asserting callback behaviour.
Two ways to test the rest:
```ruby
# Call the callback object directly — it's a plain class, not a job.
ImportCallback.new.on_success(Sidekiq::Batch::Status.new(bid), { "account_id" => 42 })
```
```ruby
# Or opt the batch middleware into the inline chain so acks and callbacks run.
Sidekiq::Testing.server_middleware { |c| c.add(Wurk::Batch::ServerMiddleware) }
```
Wurk's own batch suites take neither: real Redis, real forks, and assertions
against the callback queue's contents. Never mock Redis for batch tests — the
whole feature is Lua-script atomicity.
---
## Divergences from Sidekiq Pro
Wurk implements the batch surface in `docs/target/sidekiq-pro.md` §2. Two
deliberate differences:
- **`Status#failure_info` always returns `[]`.** The Pro 8 data model drops the
`b--failinfo` hash in favour of the `failed_jids` set, which Wurk tracks;
per-jid error payloads are intentionally not persisted. The method exists so
drop-in callers don't `NameError`.
- **`#autoflush` accepts `true` as well as a positive Integer.** `true` buffers
the whole `#jobs` block and flushes once at exit; an Integer flushes every N.
Values Pro would silently accept (`0`, `"5"`) raise `ArgumentError`.
---
## Related
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — what a one-line gem swap
does and doesn't change.
- [Dashboard](dashboard.md) — the Batches pages, and gating them.
- [Running Wurk](running.md) — making sure a worker actually consumes your
`callback_queue`.
---
# Rate limiting
Wurk ships the Sidekiq Enterprise rate limiters — free, in the same gem, no
flag. A limiter is a small object you construct once and call with a block:
```ruby
STRIPE = Sidekiq::Limiter.concurrent("stripe", 50)
class ChargeJob
include Sidekiq::Job
def perform(order_id)
STRIPE.within_limit { Stripe::Charge.create(...) }
end
end
```
All state lives in Redis and every acquire is a single Lua script, so the limit
holds across processes, forks and hosts. **All timing inside Lua comes from
Redis `TIME`**, not from the calling host's clock — client clock skew cannot
push a limiter over its limit.
`Sidekiq::Limiter` is an alias of `Wurk::Limiter` (and `Sidekiq::Limiter::OverLimit`
of `Wurk::Limiter::OverLimit`). Existing Enterprise code keeps working on the
one-line gem swap; use either name.
---
## The five types
| Type | Bounds | Pick it when | Constructor |
|---|---|---|---|
| `concurrent` | How many blocks may run **at the same time** | You have N connections/seats/licenses and care about simultaneity, not rate | `concurrent(name, limit, wait_timeout: 5, lock_timeout: 30, policy: :raise, backoff: nil, ttl: 7776000)` |
| `bucket` | Operations per **calendar period**, reset at the top of the unit | The remote API says "1000 per hour", counted per clock hour | `bucket(name, count, interval, wait_timeout: 5, backoff: nil, ttl: 7776000, reschedule: 20)` |
| `window` | Operations per **sliding window** ending now | The remote API says "60 in any 60 seconds" — no boundary to burst across | `window(name, count, interval, wait_timeout: 5, backoff: nil, ttl: 7776000, reschedule: 20)` |
| `leaky` | Burst size **plus** a steady drain rate | You may burst up to N but must average out to a fixed rate | `leaky(name, bucket_size, drain, wait_timeout: 5, backoff: nil, ttl: 7776000)` |
| `points` | Non-uniform **cost** per operation | Calls differ in weight (API "credits", token budgets) | `points(name, initial_points, refill_per_second, backoff: nil, ttl: 7776000)` |
| `unlimited` | Nothing | Tests, or swapping a limiter out at a call site | `unlimited(*ignored, **ignored)` |
`ttl` (default `90 * 24 * 3600`) is the Redis TTL on the limiter's keys, refreshed on
every use. **Values under `86_400` raise `ArgumentError`** — a metadata hash that
expires mid-job orphans slots.
### concurrent
```ruby
DB = Sidekiq::Limiter.concurrent("legacy-db", 5, lock_timeout: 60, wait_timeout: 10)
DB.within_limit { LegacyDB.query(...) }
```
- A slot is a member of a ZSET scored with its expiry (`now + lock_timeout`).
Acquire first evicts expired slots, then adds yours if there's headroom.
- `lock_timeout` is the safety net for a crashed worker: after that many
seconds the slot is reclaimed even if the block never finished. Set it above
your realistic worst-case block duration, or you'll run over the limit.
- `policy: :ignore` **skips the block silently** when no slot is free —
`within_limit` returns `nil`, nothing is raised, nothing is rescheduled.
`policy: :raise` (the default) raises `OverLimit` after `wait_timeout`.
- Only `concurrent` keeps metric counters (see [Introspection](#introspection)).
### bucket
```ruby
MAIL = Sidekiq::Limiter.bucket("sendgrid", 1000, :hour)
MAIL.within_limit { SendGrid.deliver(...) }
```
- `interval` must be one of `:second :minute :hour :day` — a **raw Integer
raises `ArgumentError`**, because "reset at the boundary" needs a unit. The
interval is validated in the constructor, so a typo fails at boot.
- The counter is keyed by epoch index (`now / interval`) and resets by rolling
onto a new key, not by decrementing. Two full buckets back-to-back across a
boundary means a `2 × count` burst — that's what `window` avoids.
- `within_limit(used: n)` charges `n` units atomically (default `1`); a call is
admitted only if the *whole* charge fits.
### window
```ruby
API = Sidekiq::Limiter.window("partner-api", 60, :minute)
API.within_limit { Partner.get(...) }
FAST = Sidekiq::Limiter.window("burst", 5, 10) # 5 per any 10 seconds
```
- `interval` accepts the same symbols **or a raw Integer of seconds**.
- Backed by a ZSET of timestamps, trimmed to `now - interval` on every acquire.
No boundary to burst across.
- `within_limit(used: n)` charges `n` entries in one atomic script.
### leaky
```ruby
SMS = Sidekiq::Limiter.leaky("twilio", 100, :minute) # burst 100, drains 100/min
SMS.within_limit { Twilio.send(...) }
```
- `bucket_size` is the burst capacity; `drain` is the period over which a full
bucket drains, as a symbol (`:second :minute :hour :day`) or a positive
Integer count of seconds. The drip rate is `bucket_size / drain` per second.
- Each admitted call adds 1 to the level; the level leaks continuously.
- A non-positive `bucket_size` or `drain` raises `ArgumentError` rather than
producing a silent zero rate.
### points
```ruby
LLM = Sidekiq::Limiter.points("anthropic-tokens", 100_000, 500) # cap, refill/sec
LLM.within_limit(estimate: 2_000) do |handle|
resp = Anthropic.complete(...)
handle.points_used(resp.total_tokens) # settle up: refund or over-charge
end
```
- `estimate:` is **required** and must be positive; otherwise `ArgumentError`.
- The block is yielded a handle. `handle.points_used(actual)` applies the
signed delta `estimate - actual` — positive refunds, negative charges more.
The balance is clamped to `[0, initial_points]`. Calling it is optional.
- **`points` never waits.** If the refilled balance is below `estimate` it
raises `OverLimit` immediately; it has no `wait_timeout`.
### unlimited
```ruby
worker.limiter = Sidekiq::Limiter.unlimited
```
Accepts any arguments and any `within_limit` keywords, runs the block
unconditionally, and yields a zero-cost handle to `points`-style blocks so a
swap needs no call-site change. It touches Redis not at all — it never
registers, so it does not appear in the dashboard.
---
## `within_limit` semantics
```ruby
limiter.within_limit { ... } # concurrent, bucket, window, leaky
limiter.within_limit(used: 3) { ... } # bucket, window only
limiter.within_limit(estimate: 200) { |handle| ... } # points only
```
A block is required — `within_limit` without one raises `ArgumentError`.
| Type | On exhaustion | Poll interval | Timeout |
|---|---|---|---|
| `concurrent` | retry loop until a slot frees | 50 ms | `OverLimit` past `wait_timeout` (or return `nil` under `policy: :ignore`) |
| `bucket` | retry loop, sleeping toward the next boundary | ≤ 50 ms | `OverLimit` past `wait_timeout` |
| `window` | retry loop until the oldest entry slides out | 500 ms | `OverLimit` past `wait_timeout` |
| `leaky` | retry loop while the bucket drains | 50 ms | `OverLimit` past `wait_timeout` |
| `points` | no wait at all | — | `OverLimit` immediately |
The wait blocks the calling **thread** — it holds a worker thread, not a
process. `wait_timeout: 0` means "fail immediately"; the default is `5`
seconds. The deadline covers only the acquire, never the block itself.
`within_limit` returns whatever the block returns. On block exit the
`concurrent` slot is always released (in an `ensure`); the other types decay on
their own clock and have nothing to release.
---
## `OverLimit` and the retry system
```ruby
Sidekiq::Limiter::OverLimit < StandardError
#limiter # the limiter object that refused
#job # the job hash in flight (set by the server middleware)
```
The message reads `limit 'stripe' (concurrent) reached`.
**Inside a job you normally do nothing.** `Wurk::Limiter::ServerMiddleware` is
registered in the default server chain, catches `OverLimit`, and:
1. Increments `job['overrated']` on the job hash.
2. If the limiter's `reschedule` is `0`, re-raises — the job goes through the
normal retry/dead pipeline like any other error.
3. Otherwise re-pushes the job onto the **same queue** at `Time.now + backoff`
and raises an internal skip signal, so the run counts as neither success nor
failure: no retry is booked, no failure is recorded, and an enclosing batch
acks nothing.
4. When `job['overrated']` reaches the `reschedule` cap (default `20`), the
**poison brake** fires: the job goes straight to the dead set with
`error_message` prefixed `rate_limited:`, death handlers run, and the
`jobs.rate_limited` StatsD counter is bumped. It is not also retried.
The backoff is `limiter.options[:backoff]` if set, else
`Sidekiq::Limiter.config.backoff`, else the default
`(300 * overrated) + rand(300) + 1` seconds — i.e. escalating five-minute steps
with jitter. A proc receives `(limiter, job_hash, exception)` and returns
seconds.
Only `bucket` and `window` take a `reschedule:` keyword. `concurrent`, `leaky`
and `points` have no such option, so the middleware applies the default cap of
20 to them.
### Global config
```ruby
# config/initializers/wurk.rb
Sidekiq::Limiter.configure do |config|
config.backoff = ->(_limiter, job, _exc) { 60 * job["overrated"] }
config.redis = { size: 10, url: "redis://limiter:6379/0" }
config.errors << MyApp::UpstreamRateLimited
end
```
| Setting | Default | Effect |
|---|---|---|
| `backoff` | `(300 * overrated) + rand(300) + 1` | Fallback delay proc for limiters with no own `backoff:` |
| `redis` | `nil` (shares `Wurk.redis_pool`) | A `Hash` (`{ size:, url:, … }`, size defaults to 10) or a built `Wurk::RedisPool`. Dedicated pool so limiter traffic can't starve fetching |
| `errors` | `[OverLimit]` | Extra exception classes the middleware treats exactly like `OverLimit` |
`config.errors` is the hook for third-party throttling: push your HTTP client's
"429" exception in and any job that raises it gets the same
increment-and-reschedule treatment. Such an exception carries no `limiter`, so
the default cap and the global `backoff` apply.
All three are re-read on every job, so changing them at runtime takes effect
immediately.
---
## Naming and Redis keys
The name is the Redis key suffix, so it is also the unit of sharing. It must
match `/\A[\w\-:.\#@]+\z/` — word characters plus `- : . # @`. Anything else
(spaces, slashes) raises `ArgumentError`.
Interpolation is the intended way to get per-tenant limits:
```ruby
def perform(account_id)
Sidekiq::Limiter.window("shopify-#{account_id}", 40, :second).within_limit do
Shopify.call(...)
end
end
```
| Key | Type | Contents |
|---|---|---|
| `lmtr:{name}` | HASH | Metadata: `type`, `options` (JSON), `fingerprint` |
| `lmtr-list` | SET | Every registered limiter name — what the dashboard lists |
| `lmtr-cs:{name}` | ZSET | `concurrent`: held slots, score = expiry epoch |
| `lmtr-stats:{name}` | HASH | `concurrent`: metric counters |
| `lmtr-b:{name}:{epoch}` | STRING | `bucket`: counter for one period |
| `lmtr-w:{name}` | ZSET | `window`: timestamps inside the interval |
| `lmtr-l:{name}` | HASH | `leaky`: `{ level, last }` |
| `lmtr-p:{name}` | HASH | `points`: `{ points, last }` |
Every key carries `EXPIRE ttl`, refreshed on use, so an interpolated name that
goes quiet cleans itself up. Constructing a limiter writes its metadata and
adds the name to `lmtr-list`; a limiter for a tenant you'll never see again
stays visible in the dashboard until its TTL lapses, or until you call
`#delete`.
`#fingerprint` is a SHA256 over `type | name | options`, so the dashboard can
group interpolated names that share a shape.
---
## Introspection
Every limiter answers the same shape:
```ruby
limiter.name #=> "stripe"
limiter.type #=> :concurrent
limiter.options #=> { limit: 50, wait_timeout: 5, … }
limiter.size #=> current usage (Integer, or Float for leaky/points)
limiter.status #=> { used:, limit:, reset_at:, available?: }
limiter.reset # clears state, keeps the limiter registered
limiter.delete # clears state + metadata, removes it from lmtr-list
```
`reset_at` is epoch seconds (Float) or `nil` when the type has no clock-driven
reset: the next boundary for `bucket`, when the oldest entry leaves for
`window`, the soonest slot expiry for `concurrent`, when the drip/refill frees
room for `leaky`/`points`.
`concurrent#status` additionally merges its counters — `held`, `held_time`,
`immediate`, `waited`, `wait_time`, `overages`, `reclaimed`. `immediate` counts
acquires that didn't wait, `waited`/`wait_time` those that did, `overages`
timeouts, `reclaimed` slots evicted because they outlived `lock_timeout`.
---
## Using a limiter outside a job
From a controller, a rake task, or any non-job code there is **no server
middleware**, so nothing catches `OverLimit` and nothing reschedules. Handle it
yourself:
```ruby
class ChargesController < ApplicationController
STRIPE = Sidekiq::Limiter.concurrent("stripe", 50, wait_timeout: 1)
def create
STRIPE.within_limit { Stripe::Charge.create(charge_params) }
head :created
rescue Sidekiq::Limiter::OverLimit
head :too_many_requests
end
end
```
Keep `wait_timeout` small in request paths — the wait blocks the web worker.
The usual answer is to not do the work inline at all: enqueue a job and let the
limiter reschedule it.
---
## Dashboard
The dashboard has a **Limiters** page (left rail, `/limiters`) backed by
`GET /api/limiters`. It lists every name in `lmtr-list` with type, `used`,
`limit`, a usage bar, an available/exhausted badge, and — for `concurrent` —
the metric counters. It paginates and takes a `substr` name filter, and each
row has a **Reset** button (`POST /api/limiters/:name/reset`) that drops the
limiter's state and stats keys while keeping the row.
Reset is a mutation, so it's hidden in read-only mode and gated by the
`authorization` hook like every other write — see
[Authentication & authorization](authentication.md).
Two caveats about the dashboard Reset: it clears the fixed state keys, but a
`bucket` counter lives under an epoch-suffixed key, so use `limiter.reset` from
Ruby if you need to zero a bucket mid-period. And there is no Delete button —
`#delete` is Ruby-only.
---
## Gotchas
- **Limiters are cheap but not free to construct.** The constructor does a
Redis round-trip (`SADD` + `HSET` + `EXPIRE`) to register itself. Hoisting
one into a constant, as in the examples, is the right default; constructing
per-tenant limiters inside `perform` is fine but pays that write each time.
Pass `register: false` to the class constructor for read-only introspection.
- **Limiters don't compose.** There is no AND combinator. For "5/min AND
100/hr", enforce the tighter bound and let the upstream API police the rest.
- **`concurrent` fairness is not guaranteed.** Waiters poll every 50 ms and the
winner is whoever's poll lands first — under contention, latency is not
ordered by arrival.
- **`lock_timeout` shorter than your work means over-admission.** The slot is
reclaimed while your block still runs and another worker walks in; you'll see
it as a rising `reclaimed` counter.
- **`bucket` bursts across boundaries.** A full bucket at 10:59:59 plus a full
one at 11:00:00 is `2 × count` inside two seconds. Use `window` when the
upstream limit is a rolling one.
- **Clock skew across app hosts still matters for `wait_timeout`.** The limiter
state itself is safe — every Lua script reads Redis `TIME` — but the wait
deadlines are measured on the calling host. Run NTP.
- **`policy: :ignore` silently drops work.** The block never runs and the job
is acked as a success. Only use it for work that is genuinely optional.
- **Blocking a worker thread is real capacity.** A `wait_timeout` of 5 seconds
on a saturated limiter means threads parked for 5 seconds. Prefer a short
timeout plus the automatic reschedule over a long wait.
---
## Related
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — the alias contract and
what a swap changes.
- [Securing the dashboard & web extensions](dashboard.md) — the Limiters page
and dashboard config.
- [Authentication & authorization](authentication.md) — gating the reset
action.
---
# Periodic (cron) jobs
Wurk has Enterprise-grade periodic jobs built in — no `sidekiq-cron`, no
`sidekiq-scheduler`, no `fugit`. You register loops at boot, the elected cluster
leader ticks once a minute, and exactly one process in the cluster enqueues.
| Surface | What it is | Where |
|---|---|---|
| `config.periodic { \|mgr\| … }` | Registration DSL, run at boot | `config/initializers/wurk.rb` |
| `Wurk::Cron::LoopSet` | Enumerable view of every registered loop | Ruby |
| `Wurk::Cron::ConfigTester` | Boot-time validator (cron syntax + class resolution) | Ruby / tests |
| `Wurk::Cron.fire!(lid)` | Fire one loop now, bypassing leader + schedule | Ruby / specs |
| Dashboard **Cron** tab | Pause, unpause, enqueue-now, run history | `/cron` |
The Sidekiq Enterprise names are aliases of the same objects:
`Sidekiq::Periodic` → `Wurk::Cron`, `Sidekiq::Periodic::LoopSet`,
`Sidekiq::Periodic::ConfigTester`, `Sidekiq::Periodic.fire!`, and
`Sidekiq::CronParser` → `Wurk::Cron::Parser`. There is deliberately **no
`Sidekiq::Cron`** constant — that namespace belongs to the third-party
`sidekiq-cron` gem, and squatting it breaks the gem when it runs alongside Wurk.
---
## Registering loops
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.periodic do |mgr|
mgr.tz = "America/Chicago" # default for subsequent calls
mgr.register("*/5 * * * *", "ReportJob")
mgr.register("0 4 * * *", "NightlyJob", queue: "low", retry: 2, args: ["nightly"])
mgr.register("0 * * * *", "TZJob", tz: "Asia/Tokyo")
end
end
```
`configure_server` only yields in server processes, so registration writes to
Redis when a worker boots — not from your web dynos or a `rails console`.
Multiple `config.periodic` blocks accumulate onto one shared Manager.
### `mgr.register(cron, klass, **opts)`
| Param | Type | Notes |
|---|---|---|
| `cron` | `String` | 5-field crontab or an `@alias`. One minute is the finest resolution. |
| `klass` | `String` or constant | A constant is accepted and `to_s`'d, but a String avoids boot-order problems (the class needn't be loaded yet). |
| `queue:` | `String` | Defaults to `"default"`. |
| `retry:` | any | Written verbatim into the job payload. Defaults to `true`. |
| `args:` | `Array` | Static, evaluated **once at boot**, splatted into `perform_async` on every fire. |
| `tz:` | `String` / `ActiveSupport::TimeZone` / `TZInfo::Timezone` | Overrides `mgr.tz` for this call. Never enters the job payload. |
| `paused:` | `true` / `"1"` | Registers the loop in a paused state. |
Any other key you pass is stored in the loop's options hash and — because it
feeds the loop identity — changes the loop's `lid`. It is *not* copied into the
job payload; only `class`, `args`, `queue`, and `retry` are pushed.
`register` returns the `Wurk::Cron::Loop` and persists it to Redis immediately.
An invalid cron string or an empty/non-String class name raises `ArgumentError`
right there at boot.
There is also a convenience module method, `Wurk::Cron.register(name, cron,
worker_class, args = [], **opts)`, which stores `name` as a `label` option. The
label participates in the loop identity, so it is not a rename-safe handle —
prefer the `config.periodic` DSL.
---
## Cron syntax
Five fields, `minute hour day-of-month month day-of-week`, plus these aliases:
| Alias | Expands to |
|---|---|
| `@hourly` | `0 * * * *` |
| `@daily`, `@midnight` | `0 0 * * *` |
| `@weekly` | `0 0 * * 0` |
| `@monthly` | `0 0 1 * *` |
| `@yearly`, `@annually` | `0 0 1 1 *` |
Each field supports `*`, a value, a `a,b,c` list, an `a-b` range, and a
`base/step`. Ranges are validated (`5-1` and out-of-range values raise), steps
must be `>= 1`, and day-of-week `7` normalizes to `0` (Sunday).
Step bases follow Vixie cron: a bare value base means *from that value to the
field maximum*, so `5/15` in the minute field is `5,20,35,50` — not just `5`.
If **both** day-of-month and day-of-week are restricted they are OR'd, so
`0 0 13 * 5` means "the 13th, or any Friday". If only one is restricted, that
one must match.
Not supported — the parser rejects or ignores them, so don't port them from
another scheduler:
- No seconds field (6-field expressions raise; minimum frequency is one minute).
- No name aliases: use `0`–`7` and `1`–`12`, not `MON` or `JAN`.
- No `L`, `W`, `#`, `?`, or `@reboot`.
---
## Timezones
`tz` may be an IANA string (`"Europe/Berlin"`), an `ActiveSupport::TimeZone`, or
a `TZInfo::Timezone`. Nil means UTC.
The parser walks forward minute by minute evaluating wall-clock components in
the loop's zone, so DST is handled without special cases:
- **Spring forward:** the skipped local hour simply never matches, so a
`0 2 * * *` loop does not fire on that date.
- **Fall back:** a *fixed-hour* schedule fires once — the repeated wall-clock
minute is detected and the duplicate dropped. An *hourly* schedule (wildcard
hour) keeps both repeated hours, because both are genuine runs.
IANA strings are resolved through `tzinfo` (a soft dependency, always present
under Rails via ActiveSupport) and memoized process-wide. A missing gem or an
unknown identifier logs one warning and evaluates the loop **as UTC** rather
than crashing the poller. Wurk never mutates `ENV['TZ']` — that is process-global
and would leak the schedule's zone into your `perform` code.
---
## The registry: loops, lids, and Redis
A loop's identity is `SHA1(schedule | klass | sorted options)`, truncated to 16
hex chars. Re-registering the same triple rewrites the same Redis keys, which is
what makes boot idempotent across every worker process and every deploy.
| Key | Type | Contents |
|---|---|---|
| `periodic` | SET | every `lid` |
| `loops:{lid}` | HASH | `schedule`, `klass`, `options` (JSON), `tz`, `paused`, plus the poller's `lf` (last fire) / `nf` (next fire) marks |
| `loop-history:{lid}` | LIST | newest-first `[fired_at, jid]` tuples, capped at 25 |
Read it back from anywhere that has Redis:
```ruby
loops = Wurk::Cron::LoopSet.new # or Sidekiq::Periodic::LoopSet.new
loops.size
loops.each do |lp|
lp.lid #=> "0a4f…"
lp.schedule #=> "*/5 * * * *"
lp.klass #=> "ReportJob"
lp.options #=> { "queue" => "low", "args" => ["nightly"] }
lp.queue #=> "low"
lp.args #=> ["nightly"]
lp.paused? #=> false
lp.tz_name #=> "Asia/Tokyo" or nil
lp.next_fire_at #=> epoch seconds
lp.last_fired_at #=> epoch seconds, or nil if never fired
lp.history #=> [[fired_at, jid], …] newest first
end
loops.fetch(lid) #=> Loop, or nil
```
`LoopSet#each` re-reads Redis on every iteration; it's sized for the dashboard's
list view, not for a hot loop.
---
## Leader election and the single-fire guarantee
Every worker process starts a cron poller, but `Poller#tick` returns
immediately unless the process holds the cluster lock — it doesn't even iterate
the LoopSet. That single-leader invariant is what gives you one enqueue per
(loop, tick) across the whole swarm.
The lock lives at the `dear-leader` Redis key with a 30s TTL (see
`lib/wurk/leader.rb`): the leader renews every 15s, followers re-check every
60s. `Component#leader?` caches the answer for ~5s so a one-minute tick doesn't
double Redis traffic.
Consequences worth internalizing:
- **Leader death costs up to ~60s** of leaderless time. Ticks in that gap are
not backfilled.
- **A quieted leader keeps firing.** `SIGTSTP` stops that process *fetching*;
the cron poller is intentionally left running. Only a full shutdown
(`SIGTERM`) terminates it. That matches Sidekiq Enterprise.
- **Opt a process out** of ever leading with `WURK_LEADER=false` (or its alias
`SIDEKIQ_LEADER=false`) — useful for hot-standby pools.
- Election is best-effort, not Raft. A partitioned ex-leader can briefly
co-exist with a new one until the TTL lapses. If a duplicate run would be
harmful, make the job idempotent or combine it with unique jobs
(`sidekiq_options unique_for:`).
---
## Ticks, downtime, and missed windows
The poller sleeps one interval (60s) *before* its first tick, so a short-lived
process exits without ticking and a freshly-elected leader isn't hit by a burst
at boot.
On each tick, for each unpaused loop the poller reads the `nf` mark, fires if
`nf <= now`, then computes the following fire from **now** — not from the missed
slot. So after downtime:
- Each due loop fires **once**, immediately, on the first leader tick after
recovery.
- The ticks you missed in between are **not** backfilled, however long you were
down.
- If the fire is more than 90s late, the poller logs a warning:
`[cron] missed tick lid=… klass=… expected_at=… fired_at=… drift=…s`.
A loop with no `nf` yet (just registered) computes its first fire from
`now - tick_interval`, so a schedule that matched in the preceding minute can
fire on the very first tick after registration.
If a schedule can never match again within ~4 years of lookahead (`0 0 29 2 *`
landing outside any leap year, say), `next_fire_at` returns nil, the `nf` mark
is cleared, and the loop stops firing silently.
---
## Job payloads and ActiveJob
A plain Sidekiq/Wurk job is pushed as `{"class", "args", "queue", "retry"}` with
the loop's configured values.
If the class name resolves to an `ActiveJob::Base` subclass, the poller enqueues
through the AJ adapter with `perform_later(*args)` instead, so callbacks and
argument serialization run normally. Two differences apply on that path:
- `retry:` is ignored — AJ's `retry_on` / `discard_on` govern retries.
- The queue is only overridden when the loop set one explicitly. A loop left at
the default `"default"` queue defers to the job's own `queue_as`.
---
## Pausing, running now, and history
The dashboard's **Cron** tab lists every loop with its schedule, class, queue,
last fire, next fire, and status. Per-row actions (hidden in read-only mode):
| Action | Endpoint | Effect |
|---|---|---|
| Pause / Unpause | `POST /api/cron/:lid/pause` \| `/unpause` | Writes `paused` on the loop HASH; a paused loop is skipped by the poller and shows no next fire |
| Enqueue | `POST /api/cron/:lid/enqueue` | Pushes one run now with the loop's klass/args/queue/retry. Does **not** touch the fire marks or history |
| (row click) | `GET /api/cron/:lid/history` | The last 25 `[fired_at, jid]` fires |
The same operations are available in Ruby via
`Wurk::Web::Enterprise::Periodic.pause/unpause/enqueue_now/history/list/fetch`.
For tests and ops there is also `Wurk::Cron.fire!(lid)`: it bypasses both the
leader gate and the due-check, enqueues, records history, and advances the fire
marks exactly like a real tick. It returns the jid, or nil for an unknown lid.
There is **no delete action** — not in the dashboard, not in the API. Removing a
loop is `Wurk::Cron.unregister(lid)`, which drops it from the `periodic` set and
deletes its hash and history.
---
## Deploys: what happens when a schedule changes
This is the part that surprises people, so read it before you ship your first
schedule change.
The `lid` is derived from schedule + class + options. Change any of them and you
get a **different loop** — so registration prunes the ones it supersedes.
After a `config.periodic` block registers, Wurk removes every loop in the
registry whose `klass` matches a class that block registered and whose `lid`
isn't one it just wrote. So:
- **Edited a schedule** (`"0 4 * * *"` → `"0 5 * * *"`)? The old loop is pruned
on the next worker boot. Only the new schedule fires.
- **Changed `args`/`queue`/`retry`?** Same — superseded, pruned.
- **Deleted the `register` line entirely?** **Not** pruned. Nothing in the
surviving code mentions that class, so no scope can see it. It keeps firing.
Remove it explicitly.
Pruning is scoped by class precisely so it stays safe: a process that registers
only some of your loops never touches the others, and a process that registers
none — a client-only or web process — deletes nothing.
```ruby
# a deleted register line still needs a manual removal, once, after deploy
Wurk::Cron::LoopSet.new.each do |lp|
next unless lp.klass == "RetiredJob"
Wurk::Cron.unregister(lp.lid)
end
```
> **Two registrars that disagree about one class will flap.** If process A
> registers `K` on one schedule and process B registers `K` on another, each
> boot prunes the other's loop. This includes the window in a rolling deploy
> where an old-code process boots after a new-code one; it converges once only
> one code version is booting. Don't register the same class from two different
> configs.
Pause state belongs to the runtime, not to the code: `paused` is written with
`HSETNX`, so a code-declared `paused: true` is the **initial** value at first
registration, and a pause or unpause from the dashboard survives every
subsequent boot.
---
## Validating the schedule at boot
`ConfigTester#verify` runs your periodic block against a disposable Manager and
resolves every class name, so a typo surfaces in CI instead of on the first tick
in production:
```ruby
# config/initializers/wurk.rb
PERIODIC_JOBS = lambda do |mgr|
mgr.register("*/5 * * * *", "ReportJob")
end
Wurk.configure_server { |config| config.periodic(&PERIODIC_JOBS) }
```
```ruby
# test/periodic_config_test.rb
Wurk::Cron::ConfigTester.new.verify(&PERIODIC_JOBS) # raises ArgumentError on a bad cron or class
```
`verify` persists loops as it registers them (that is what `register` does), so
on failure it rolls the whole batch back with `unregister` before re-raising —
a half-applied schedule never survives a validation error.
Unlike Sidekiq Enterprise there is no separate `sidekiq-ent/periodic/testing`
file to require; `ConfigTester` ships loaded with the rest of `Wurk::Cron`.
---
## Migrating
### From `sidekiq-cron`
Drop `config/schedule.yml` and the gem; move each entry into a
`config.periodic` block. There is no `Sidekiq::Cron::Job` shim by design — real
Sidekiq never defined that constant, so faking it would break the drop-in
contract and collide with the gem itself.
| `sidekiq-cron` | Wurk |
|---|---|
| `cron:` | first argument to `mgr.register` |
| `class:` | second argument |
| `queue:` | `queue:` |
| `args:` | `args:` |
| `name:` | no equivalent — loops are keyed by `lid`, not a name |
| `Sidekiq::Cron::Job.create/destroy` | `mgr.register` / `Wurk::Cron.unregister(lid)` |
| `Sidekiq::Cron::Job.all` | `Wurk::Cron::LoopSet.new` |
| natural-language / 6-field schedules (fugit) | not supported — use 5-field crontab |
If you'd rather keep the gem, you can: its upstream suite runs against Wurk in
the ecosystem CI job. The native path is recommended — fewer dependencies,
first-class dashboard support.
### From `sidekiq-scheduler`
Same shape. `every: '5m'` style intervals have no equivalent; express them as
crontab (`*/5 * * * *`). `sidekiq-scheduler`'s `enabled: false` maps to
`paused: true`, and its `description` has no counterpart.
---
## Gotchas
- **`args` is static.** It's serialized into Redis once at boot. `args: [Date.today]`
freezes the date of the deploy, forever.
- **Registration happens in server processes only.** If no worker has booted
since you added a loop, it isn't in Redis and the dashboard won't show it.
- **Loops persist across deploys.** Superseded ones are pruned by class on the
next registration; a loop whose `register` line was deleted outright is not.
See the deploy section above.
- **A dashboard pause survives deploys** — `paused` is only initialized from
code, never overwritten by it.
- **Enqueue-now doesn't move the schedule.** The next scheduled fire happens as
planned.
- **History is capped at 25 fires** per loop and holds only `[fired_at, jid]`.
It is not an audit log — the job's own outcome lives in the normal
processed/failed metrics.
- **`Wurk::Cron.reset!` wipes every loop in the cluster.** It's a test helper;
never call it from production code.
---
## Related
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — §6 covers the
gem-by-gem mapping including `sidekiq-cron`.
- [Dashboard](dashboard.md) — mounting, read-only mode, extension tabs.
- [Running Wurk](running.md) — swarm, standalone, and embedded modes.
---
# Unique jobs
Wurk ships native unique jobs — the Sidekiq Enterprise feature, free and in the
same gem. Enqueueing a job takes a Redis lock keyed by a digest of the job; a
second push of the same job while that lock is held is **dropped** instead of
enqueued.
This is a **best-effort dedup at enqueue time**, not a distributed mutex. Read
[§ What it does not guarantee](#what-it-does-not-guarantee) before you rely on
it for correctness.
Everything here is Sidekiq-compatible: `Sidekiq::Enterprise.unique!`,
`sidekiq_options unique_for:`, `sidekiq_unique_context`, and
`Sidekiq::Enterprise::Unique.locked?` are the Enterprise names, and the
implementation lives under `Wurk::Unique`. An existing Enterprise initializer
keeps working on the one-line gem swap, and the Redis key layout
(`unique:`) is unchanged, so locks written by Sidekiq Enterprise are
honored by Wurk and vice versa.
> **Not the `sidekiq-unique-jobs` gem.** That gem's `lock:` DSL is a different
> API. Wurk's native surface is `unique_for:` / `unique_until:` — see
> [§ Migrating from sidekiq-unique-jobs](#migrating-from-sidekiq-unique-jobs).
---
## 1. Enable it
Uniqueness is **off** until you turn it on. The client and server middleware
are only installed by `unique!`, so `sidekiq_options unique_for:` on a worker
is a silent no-op without this call:
```ruby
# config/initializers/wurk.rb
Sidekiq::Enterprise.unique!
```
- Installs the client middleware (lock on push), the server middleware (release
on success/start), and a death handler (release on automatic death).
- Idempotent — calling it twice does not double-register anything.
- Global, process-wide. `Sidekiq::Enterprise.unique?` reports the flag.
- Call it at boot, from an initializer, in **every** process that enqueues and
every process that runs jobs. A web process without it will happily push
duplicates.
---
## 2. Declare uniqueness per worker
```ruby
# app/jobs/charge_job.rb
class ChargeJob
include Sidekiq::Job
sidekiq_options unique_for: 10.minutes, unique_until: :success
def perform(account_id, cents) = Billing.charge!(account_id, cents)
end
```
| Option | Type | Default | Meaning |
|---|---|---|---|
| `unique_for` | `Integer` seconds, `ActiveSupport::Duration`, any `Numeric`, or `false` | none — no lock | Lock TTL in seconds. Floats are truncated. `false` or absent disables uniqueness for that push. |
| `unique_until` | `:success` or `:start` | `:success` | When the lock is released. Anything else falls back to `:success`. |
There is **no default TTL**. A worker with no `unique_for` is never deduped,
even with `unique!` on — the option is what opts a worker in. Keep the TTL
short (minutes): after a hard process kill the lock survives until it expires,
and until then that job cannot be enqueued again.
### Per-call override
`set` merges over the class-level `sidekiq_options`, so both the TTL and the
opt-out are per-call:
```ruby
ChargeJob.set(unique_for: false).perform_async(1, 2_500) # skip the lock entirely
ChargeJob.set(unique_for: 60).perform_async(1, 2_500) # 60s instead of 10 minutes
```
---
## 3. What happens on a duplicate push
The client middleware does `SET unique: NX EX `. If the key
already exists and is held by a **different** JID, the push is dropped:
- The job is **never written to Redis** — no queue entry, no schedule entry, no
metric, no `enqueued` event.
- `perform_async` / `perform_in` return **`nil`** instead of a JID. Code that
stores the returned JID must handle `nil`.
- `perform_bulk` / `Sidekiq::Client.push_bulk` return an array with a `nil` in
the position of every dropped job; surviving jobs keep their JIDs.
- One line is logged at `info`:
`Wurk::Unique: duplicate ChargeJob dropped (jid=… blocked by jid=…)`.
- Nothing raises. A dropped duplicate is normal operation, not an error.
Two pushes are **not** duplicates when the JID already holding the lock is the
job's own JID — that is a re-push of the same job (schedule or retry
promotion), and it proceeds.
---
## 4. When the lock is released
| Event | `unique_until: :success` (default) | `unique_until: :start` |
|---|---|---|
| Push | lock acquired with the TTL | same |
| Duplicate push | dropped, `nil` JID | dropped, `nil` JID |
| `perform` about to start | lock retained | **lock released** before `perform` runs |
| `perform` raises | lock retained → the retry can run; duplicates still blocked | already released → a duplicate can be enqueued mid-run |
| `perform` succeeds | **lock released** | already released |
| Retries exhausted / `retry: false` (automatic death) | lock released by the death handler | already released |
| Manual kill from the dashboard or `RetrySet#kill` | **lock retained** until TTL expiry | already released |
| Process crash (SIGKILL, OOM) | lock retained until TTL expiry | depends on whether `perform` had started |
Every release is an atomic compare-and-delete in Lua: the key is deleted only
if it still holds *this* job's JID. A long-overdue retry can therefore never
release a fresh lock taken by a later enqueue after the original TTL expired.
The manual-kill row is deliberate Enterprise parity — a human killing a job
keeps the slot held, so an automated re-enqueue can't immediately reintroduce
the job the operator just removed. Delete the key yourself if you want it back
sooner ([§ 8](#8-inspecting-and-clearing-locks)).
If Redis is unreachable when the server middleware tries to release, the
failure is logged at `warn` (`Wurk::Unique release failed: …`) and the job
still completes; the lock then expires on its TTL.
---
## 5. How the lock key is derived
```
unique:
```
A single Redis STRING holding the owning JID, with `EX `. Consequences of
that triple:
- **Queue is part of the key.** The same job pushed to `default` and to
`critical` are two different locks.
- **Args are compared after JSON round-tripping**, exactly as they are stored.
`perform_async(1)` and `perform_async("1")` are different jobs.
- **Argument order matters**, and so does every element — a trailing options
hash with a timestamp in it defeats dedup entirely.
### Customizing the digest — `sidekiq_unique_context`
Define a class method on the worker returning any JSON-serializable value; it
replaces the whole `[class, queue, args]` triple:
```ruby
# app/jobs/sync_job.rb
class SyncJob
include Sidekiq::Job
sidekiq_options unique_for: 5.minutes
# Dedup on the account id only — ignore the trailing options hash.
def self.sidekiq_unique_context(job)
["SyncJob", job["queue"], [job["args"].first]]
end
def perform(account_id, opts = {}) = Sync.run(account_id, **opts.symbolize_keys)
end
```
- The receiver is the class named by `job["class"]`, resolved from the payload.
If that constant isn't loaded in the pushing process, the hook is skipped and
the default triple is used — so an enqueue-only process that doesn't load
your job classes will compute a *different* key than the worker process.
Make sure both sides load the class.
- Return whatever identity you want, but return it consistently: the same value
must come back for the same logical job on both push and release, or the
server middleware will fail to find the key it should delete.
- Dropping the queue from the context makes the lock queue-independent; keep it
in if you rely on per-queue locks.
---
## 6. Scheduled jobs, retries, and batches
**Scheduled jobs.** `perform_in(delay, …)` extends the TTL to
`unique_for + delay` (rounded up), so the lock spans the wait *and* the
execution window. An `at` in the past uses the base TTL unchanged. When the
scheduler promotes the job from the `schedule` ZSET to its queue, the client
chain runs again — the lock is still held by that job's own JID, so promotion
is recognized as a re-push and the job is not lost.
**Retries.** Same mechanism: a `retry` ZSET entry promoted back to its queue
re-enters the client chain holding its own lock, and proceeds. With
`unique_until: :success` the lock is deliberately held across the whole retry
chain — that is the point of the mode — so the retry window must fit inside
`unique_for` or a duplicate becomes enqueueable mid-retry.
**Batches.** Batch jobs go through the same client middleware, and the batch
counters are incremented by the push that the middleware halts. A deduped job
therefore never joins the batch at all: totals and pending counts stay
consistent, and the batch's callbacks still fire when the jobs that *were*
added finish. Be aware that `Batch#jobs` returning fewer jobs than you pushed
is the expected outcome, not a bug — check the return values if you need to
know.
---
## 7. What it does not guarantee
- **Not exactly-once execution.** The lock is taken at push time. Anything that
bypasses the client chain (a raw `LPUSH`, a payload already sitting in Redis
from before you enabled `unique!`) is not deduped.
- **Not mutual exclusion at runtime.** Two copies of a job can run
concurrently: with `:start` the lock is gone before `perform`; with `:success`
a duplicate becomes enqueueable the moment the TTL expires, even mid-run. If
you need "only one running at a time", use a `Sidekiq::Limiter.concurrent`
limiter inside `perform`, or make the job idempotent.
- **Not durable past the TTL.** A crashed process leaves the lock behind; it
disappears when `unique_for` elapses and never before.
- **Not a queue-wide guarantee.** Locks are per digest, and the digest includes
the queue by default.
---
## 8. Inspecting and clearing locks
```ruby
Sidekiq::Enterprise::Unique.locked?("default", "ChargeJob", [1, 2_500])
#=> "9a1f…" the JID holding the lock, or nil if free
Sidekiq::Enterprise::Unique.locked?("ChargeJob", [1, 2_500])
# two-arg form: assumes the default queue from Wurk.default_job_options
```
The probe routes through the same derivation as the push path, so it honors
`sidekiq_unique_context` and the ActiveJob narrowing below.
`Wurk::Unique.lock_key(klass, queue, args)` stays available if you want the
verbatim triple digest without any context hook applied.
There is no built-in "unlock" API and **no dashboard view of unique locks** —
the dashboard shows queues, retries, and dead jobs, not lock keys. To clear a
lock manually, delete the key:
```ruby
key = Wurk::Unique.lock_key("ChargeJob", "default", [1, 2_500])
Wurk.redis { |conn| conn.call("DEL", key) }
```
`redis-cli --scan --pattern 'unique:*'` lists every live lock, but the keys are
opaque digests — you can count them and check TTLs, not reverse them into job
names.
---
## 9. Encryption is incompatible
**Do not combine `encrypt: true` with `unique_for:` on the same worker.**
Encryption rewrites the last argument into fresh AES-256-GCM ciphertext on
every push (new IV each time), so no two pushes of the "same" job ever produce
the same args — and therefore never the same digest. Dedup silently stops
working.
Wurk **enforces this**: pushing a worker that sets both `unique_for:` and
`encrypt: true` while encryption is enabled raises
`Wurk::Unique::ConfigurationError` naming the worker and both options. The
check runs per push in the client middleware, so it fires the same way in
either initializer order.
This is stricter than Sidekiq Enterprise, which documents the incompatibility
but lets the silent mis-dedup through. If you need both, encrypt a payload
stored elsewhere and pass an opaque, stable id as the job argument.
---
## 10. Testing
- **`Sidekiq::Testing.fake!`** — the client middleware still runs, so pushes are
deduped and the fake store reflects that. Locks are taken in real Redis.
- **`Sidekiq::Testing.inline!`** — jobs execute through
`Sidekiq::Testing.server_middleware`, which is **empty by default** and does
*not* inherit the globally registered server middleware. The enqueue-time
lock is therefore taken and never released until its TTL expires. Register it
explicitly if inline tests need release semantics:
```ruby
Sidekiq::Testing.server_middleware { |c| c.add(Wurk::Unique::ServerMiddleware) }
```
- **`perform_inline` / `perform_sync`** run the real client and server chains
(matching Sidekiq 8), so a lock is taken and released around the run.
- Tests that enqueue the same job repeatedly will see `nil` JIDs unless each
test clears the `unique:*` keys or uses distinct args. The usual fix is to
skip activation in the test env: `Sidekiq::Enterprise.unique! unless Rails.env.test?`.
---
## 11. ActiveJob
`sidekiq_options unique_for:` works on an ActiveJob class — the adapter merges
the wrapped class's options into the payload, and Wurk narrows the digest for
you. An ActiveJob payload's args are `[job.serialize]`, a hash carrying a fresh
`job_id` and `enqueued_at` on every push; digesting it verbatim would never
match. So when the wire class is an ActiveJob wrapper and the single arg
carries `job_class` + `arguments`, the context becomes:
```ruby
[job["class"], job["queue"], data["job_class"], data["arguments"]]
```
Per-push fields are dropped: `job_id`, `provider_job_id`, `enqueued_at`,
`scheduled_at`, `executions`, `exception_executions`, `priority`, `locale`,
`timezone`. Retry counters are excluded deliberately — keeping them would make
a retry re-push compute a different key and miss its own lock. The wrapper
class name stays in the context so an ActiveJob `Foo` and a plain worker `Foo`
with the same args can't collide.
Matching is by wire class name, so it works in an enqueue-only process that has
never loaded the job class. An app that subclasses the wrapper under its own
name won't be matched — use the hook below for that.
To override the default, define the context hook on the wrapper class, which is
what `job["class"]` names on the wire:
```ruby
# config/initializers/wurk.rb
class Sidekiq::ActiveJob::Wrapper
def self.sidekiq_unique_context(job)
data = job["args"].first
[data["job_class"], job["queue"], data["arguments"]]
end
end
```
That is global to every ActiveJob in the app. If only some of your jobs need
uniqueness, prefer a plain `include Sidekiq::Job` worker for those.
---
## 12. Migrating from sidekiq-unique-jobs
The gem still works against Wurk (it runs in the ecosystem CI suite), but the
native path has no extra dependency and no separate lock store. Swap
`Sidekiq::Enterprise.unique!` in and translate:
| `sidekiq-unique-jobs` | Wurk native |
|---|---|
| `lock: :until_executed` | `unique_until: :success` (held through retries, released on success) |
| `lock: :until_executing` / `:while_executing` | `unique_until: :start` (released just before `perform`) |
| `lock_ttl` / `lock_timeout` | `unique_for:` — seconds, or an `ActiveSupport::Duration` |
| `lock_args_method` / custom uniqueness args | `def self.sidekiq_unique_context(job)` on the worker |
| other `lock:` modes (`:until_and_while_executing`, `:until_expired`, …) | no equivalent — only `:success` and `:start` exist |
Run one deploy with both disabled to drain in-flight locks, or accept that the
gem's old lock keys (a different namespace) simply expire on their own — Wurk
never reads them. See [§6 of the migration guide](migrate-from-sidekiq.md#6-third-party-gem-mappings)
for the surrounding gem-by-gem mapping.
---
## Gotchas
- `unique!` not called → `unique_for:` is silently ignored. There is no warning.
- `unique_for` missing on the worker → no lock, silently. Uniqueness is
opt-in per worker, not global.
- `perform_async` returning `nil` is the dedup signal. Code that does
`Job.perform_async(x).then { |jid| … }` will `NoMethodError` on the drop.
- The digest includes the queue. Moving a job between queues, even
temporarily, splits its lock.
- The digest includes *all* args. A timestamp, a UUID, or a hash with
`Time.now` in it makes every push unique — use `sidekiq_unique_context`.
- Long TTLs are the failure mode that hurts. A 24-hour `unique_for` plus one
SIGKILL means a day of silently dropped pushes.
- `unique_until: :success` holds the lock across the entire retry chain. If
your retry backoff can exceed `unique_for`, the lock expires mid-chain and a
duplicate becomes enqueueable.
- Custom `sidekiq_unique_context` requires the worker class to be **loaded** in
the enqueuing process, or the default triple is used instead.
- No dashboard surface for locks. Use `Unique.locked?` or `redis-cli`.
---
## Related
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — gem-by-gem mappings,
including `sidekiq-unique-jobs`.
- [Rate limiting](rate-limiting.md) — concurrent limiters, for "one at a time"
rather than "one enqueued".
- [Running Wurk](running.md) — processes, signals, and where initializers load.
- [Dashboard](dashboard.md) — what the UI does and does not show.
---
# Iterable jobs
A job that walks a million records is a job that cannot survive a deploy. Your
process gets a `TERM`, the shutdown timeout expires, the thread is killed, and
the work restarts from record zero on the next boot — or worse, gets retried
from zero and re-sends 400,000 emails.
An **iterable job** inverts that. You don't write a loop; you hand Wurk an
enumerator that yields `[item, cursor]` pairs and a method that processes one
item. Wurk drives the loop, checkpoints the cursor into Redis, and — when the
job is interrupted — resumes from the last checkpoint on the next run.
```ruby
# app/jobs/backfill_job.rb
class BackfillJob
include Wurk::IterableJob
def build_enumerator(cursor:)
active_record_records_enumerator(User.where(migrated: false), cursor: cursor)
end
def each_iteration(user)
user.migrate!
end
end
BackfillJob.perform_async
```
Reach for one when the work is **long** (minutes to hours), **chunkable** into
independent units, and each unit is **idempotent**. If the job finishes in a
few seconds, a plain `Wurk::Job` is simpler and cheaper — iterable jobs pay a
Redis write every five seconds for their durability.
`Sidekiq::IterableJob` is an alias for `Wurk::IterableJob`, and the enumerator
classes resolve under their upstream names too — see
[§ Sidekiq compatibility](#sidekiq-compatibility).
---
## 1. The contract
`include Wurk::IterableJob` also includes `Wurk::Job`, so you get the whole
worker DSL (`sidekiq_options`, `perform_async`, `perform_in`, `set`, `jid`,
`logger`) for free. You do **not** `include Wurk::Job` separately.
You must override two methods:
| Method | Signature | Must return / do |
|---|---|---|
| `build_enumerator` | `build_enumerator(*args, cursor:)` | An `Enumerator` yielding `[item, new_cursor]` pairs |
| `each_iteration` | `each_iteration(item, *args)` | Process exactly one item |
`*args` in both is the job's `perform_async` arguments. The base
implementations raise `NotImplementedError`, so a class that forgets either one
fails on its first run, not silently.
**You cannot define `#perform`.** The module owns the run loop, and a
`method_added` guard raises `ArgumentError` at class-definition time:
```ruby
class BadJob
include Wurk::IterableJob
def perform(*) = nil # => ArgumentError: BadJob is an IterableJob;
end # override #each_iteration instead of #perform
```
The guard is installed by `include`, so it only sees methods defined *after*
that line. Put the `include` first.
The cursor you yield must **round-trip through JSON** — it is stored with
`JSON.generate` and reloaded with `JSON.parse`. Integers, strings, arrays and
plain hashes are fine; symbol keys come back as strings, and objects with a
custom `to_json` come back as whatever that produced.
---
## 2. Built-in enumerator builders
Instance methods available inside `#build_enumerator`. All take `cursor:` and
thread it into the underlying API, so resumption is real, not a re-scan-and-skip
(except where noted).
| Builder | Yields | Cursor is |
|---|---|---|
| `array_enumerator(array, cursor:)` | `[item, index]` | integer index |
| `csv_enumerator(csv, cursor:)` | `[row, index]` | integer row index |
| `csv_batches_enumerator(csv, cursor:, batch_size: 100)` | `[rows_batch, index]` | integer batch index |
| `active_record_records_enumerator(relation, cursor:, **opts)` | `[record, record.id]` | primary key |
| `active_record_batches_enumerator(relation, cursor:, **opts)` | `[batch, batch.first.id]` | primary key of batch head |
| `active_record_relations_enumerator(relation, cursor:, **opts)` | `[relation, relation.first.id]` | primary key of batch head |
### Arrays
```ruby
def build_enumerator(*args, cursor:)
array_enumerator(%w[a b c d], cursor: cursor)
end
```
Raises `ArgumentError` unless you pass an actual `Array`. The array is
materialized and `drop(cursor)`-ed eagerly — fine for a few thousand entries,
wrong for a million-row export.
### CSV
Requires the host app to have `require "csv"`; Wurk does not force the
dependency. `csv_enumerator` raises `ArgumentError` unless the argument is a
`CSV` instance (a `String` of CSV text is not accepted).
```ruby
def build_enumerator(path, cursor:)
csv_batches_enumerator(CSV.open(path, headers: true), cursor: cursor, batch_size: 500)
end
def each_iteration(rows, _path)
Contact.insert_all(rows.map(&:to_h))
end
```
The enumerator's lazy `#size` shells out to `wc -l` on `csv.path` (minus one
when `headers` is set) and returns `nil` for a `CSV` with no file behind it.
The run loop never calls `#size`; it exists for progress display.
### ActiveRecord
ActiveRecord is **not** a Wurk dependency. These builders just call the
relation's own batching API (`find_each`, `find_in_batches`, `in_batches`) with
`start: cursor`, so they work whenever the host app has AR loaded and raise a
plain `NoMethodError` otherwise.
`**opts` passes straight through to the underlying AR method — `batch_size:`,
`order:`, `finish:`, and so on. For `active_record_relations_enumerator`,
`batch_size:` is normalized to `in_batches`' `of:` keyword so one option name
works across all three builders; passing both `of:` and `batch_size:` lets
`of:` win instead of raising.
```ruby
def build_enumerator(*, cursor:)
active_record_relations_enumerator(Order.pending, cursor: cursor, batch_size: 5_000)
end
def each_iteration(relation)
relation.update_all(status: "expired")
end
```
You are not limited to the builders — any `Enumerator` yielding `[item, cursor]`
pairs works, including one you write by hand.
---
## 3. The cursor: what is persisted, and where
State lives in a Redis HASH keyed `it-` — the same key and field names
Sidekiq uses, so the data is wire-compatible both directions.
| Field | Type | Meaning |
|---|---|---|
| `ex` | integer | execution count — how many times this jid has started |
| `c` | JSON string | the last checkpointed cursor |
| `rt` | float | accumulated runtime in seconds across all executions |
| `cancelled` | integer | epoch seconds, present only once cancelled |
| Constant | Value | Role |
|---|---|---|
| `STATE_TTL` | 30 days | HASH expiry, refreshed on every checkpoint |
| `STATE_FLUSH_INTERVAL` | 5 seconds | cursor flush cadence **and** cancellation poll cadence |
| `CANCELLATION_PERIOD` | 3 days | shorter TTL applied once `cancelled` is written |
The loop is:
1. `perform` resets in-process state, then `HGETALL it-`.
2. If `ex > 0`, `on_resume` fires; otherwise `on_start`. `ex` is then incremented.
3. `build_enumerator(*args, cursor: )`.
4. For each yielded pair: check cancellation → run `around_iteration { each_iteration(item, *args) }` → store the new cursor in memory → flush to Redis if `STATE_FLUSH_INTERVAL` has elapsed since the last flush.
5. On normal completion: final flush, `on_complete`, then `DEL it-`.
Two consequences worth internalizing:
- **The cursor is flushed on a timer, not per item.** A job killed mid-run
resumes from a cursor that may be up to five seconds stale. Items between the
checkpoint and the kill are replayed.
- **State is deleted on success.** A completed job has no `it-` key, so
introspection returns `nil` for it — that is how you tell "finished" from
"never started".
Persistence is skipped entirely when there is no `jid` (`persistable?`), which
is what makes `MyJob.new.perform` in a unit test a plain in-memory run with no
Redis writes.
---
## 4. Interruption and resumption
| Event | What the run loop sees | Result |
|---|---|---|
| `TERM` / `INT`, in-flight finishes before `shutdown_timeout` | nothing — the loop runs to completion | job completes normally |
| `TERM` / `INT` | the loop sees `interrupted?` at the next iteration boundary | **final** flush, `on_stop`, then a head-of-queue re-push; resumes from the exact cursor |
| `TSTP` (quiet) | same as `TERM` — quiet sets the same processor flag | same as `TERM`; matches upstream Sidekiq |
| `USR1` (rolling restart) | same as `TERM` on the old slot | same as `TERM` |
| Still inside one long iteration at the deadline | `Wurk::Shutdown` (a subclass of `Interrupt`) raised into the thread | the unit of work is bulk-requeued; resumes from the **last periodic checkpoint** |
| `SIGKILL` | nothing | the payload is still in the process's private list and is reclaimed on next boot; resumes from the last checkpoint |
| `Wurk::Job::Interrupted` raised by your code | `rescue Interrupted` in `perform` | same as `TERM` |
The loop polls the processor's shutdown flag at the top of every iteration, so
a graceful stop checkpoints and re-pushes exactly where it left off. Only a
single iteration that outlives `shutdown_timeout` falls back to the hard-kill
path, where the last periodic checkpoint is what survives.
### Interrupting cooperatively
You no longer need to poll the flag yourself for the common case. If one
iteration is long enough that you want to bail out *inside* it, `interrupted?`
comes from `Wurk::Worker` and is true once the processor is stopping:
```ruby
def each_iteration(batch)
batch.each do |row|
raise Wurk::Job::Interrupted if interrupted?
process(row)
end
end
```
That raise is caught by `Wurk::Middleware::InterruptHandler`, which is
**prepended to the top of the server chain automatically** when Wurk loads. It
re-pushes the unchanged job JSON with `LPUSH queue:` — head of queue, so
it is the next thing fetched — and then raises `Wurk::JobRetry::Skip` so the
retry layer books it as a clean exit rather than a failure. The payload is not
modified; the cursor rides in Redis, not in the job args.
> Never `rescue Wurk::Job::Interrupted` in your own code. Swallowing it turns a
> resumable interruption into silent data loss.
---
## 5. Cancellation
Cancellation is cooperative and cross-process. From inside the job:
```ruby
def each_iteration(item)
cancel! if budget_exhausted?
# ...
end
```
From outside, given a jid:
```ruby
Wurk::Client.new.cancel!(jid) # => epoch-seconds timestamp
```
Both write `cancelled` into `it-` and drop the TTL to `CANCELLATION_PERIOD`
(3 days). `cancel!` from inside the job also sets an in-process flag so the
**next** iteration trips immediately; other processes observe the flag on their
next poll, which is rate-limited to once per `STATE_FLUSH_INTERVAL` to keep the
hot loop cheap. So worst-case latency for an external cancel is ~5 seconds plus
the duration of the current iteration.
`cancelled?` returns the combined answer (local flag, or a remote poll).
When the flag trips, the loop raises `Interrupted` **before** running the next
iteration — the item at the current cursor is not processed.
---
## 6. Lifecycle callbacks
All five are no-op instance methods you override. Exact names and firing order:
| Callback | Fires |
|---|---|
| `on_start` | once per jid, before the first iteration, when `ex == 0` |
| `on_resume` | instead of `on_start`, when loaded state has `ex > 0` |
| `around_iteration` | wraps every `each_iteration` call; must `yield` |
| `on_cancel` | on interruption, only when the job was cancelled |
| `on_stop` | on **every** interruption, after `on_cancel` |
| `on_complete` | after the final flush, before the state HASH is deleted |
Ordering on the two terminal paths:
```
completion: … → flush(final) → on_complete → DEL it-
interruption: … → flush(final) → on_stop → re-raise
cancellation: … → on_cancel → on_stop → on_complete → DEL it-
```
`on_stop` fires for a cancellation too — it is "the loop stopped early", not
"the loop stopped without being cancelled". A cancelled run is treated as
*completed* (matching upstream Sidekiq 8.1), so it fires `on_complete` as well
and deletes its state; an interrupted run does not.
`around_iteration` is the hook for per-item instrumentation, timeouts, or
transactions:
```ruby
def around_iteration
ActiveRecord::Base.transaction { yield }
end
```
Inside any callback or iteration you can read:
| Reader | Value |
|---|---|
| `current_object` | the item currently being processed |
| `cursor` | the cursor of the last **completed** iteration (`nil` before the first) |
| `arguments` | the job's `perform_async` args, as an Array |
| `iteration_key` | `"it-#{jid}"` |
---
## 7. Retries
An exception other than `Interrupted` propagates out of `perform` untouched and
lands in the normal retry machinery — same `sidekiq_options retry:` semantics as
any other job.
The cursor is **not** finalized on that path: there is no final flush, so the
retry resumes from whatever the last five-second checkpoint held. Because a
retry keeps the same `jid`, the retried run reads the same `it-` HASH,
fires `on_resume` (since `ex > 0`), and picks up mid-way. This is the desired
behavior for a backfill — but it means **a retry does not start clean**. If your
job needs an all-or-nothing restart, delete the state key or use a fresh jid.
A job that exhausts its retries and lands in the dead set leaves its `it-`
HASH behind until the 30-day TTL expires. Retrying it from the dashboard reuses
the jid, so it resumes rather than restarts.
---
## 8. Introspection
`Wurk::IterableJobQuery` is the read side: a bulk, pipelined `HGETALL` over many
`it-` keys in one round trip.
```ruby
q = Wurk::IterableJobQuery.new([jid1, jid2, jid3])
state = q[jid1] # => State, or nil if no it- HASH exists
state.executions # => Integer (ex)
state.runtime # => Float (rt, seconds)
state.cursor # => the JSON-decoded cursor (c)
state.cancelled # => Integer epoch seconds, or nil
q.each { |s| … } # Enumerable; skips jids with no state, keeps input order
```
`nil` from `[]` means one of three things: the jid is not an iterable job, it
completed (state deleted), or its state expired. There is no "finished"
sentinel.
For a single job you already hold a `JobRecord` for:
```ruby
Wurk::Queue.new("default").each do |job|
state = job.iterable_state # => IterableJobQuery::State | nil
next unless state
puts "#{job.jid}: cursor=#{state.cursor} runs=#{state.executions}"
end
```
**There is no dashboard UI for iterable progress.** Nothing under `app/` or the
SolidJS frontend reads iteration state, and the JSON API does not expose it —
`IterableJobQuery` and `JobRecord#iterable_state` are Ruby-only surfaces today.
Build your own admin view on top of them if you need one.
---
## 9. Sidekiq compatibility
| Upstream constant | Resolves to |
|---|---|
| `Sidekiq::IterableJob` | `Wurk::IterableJob` |
| `Sidekiq::IterableJobQuery` | `Wurk::IterableJobQuery` |
| `Sidekiq::Job::Iterable` | `Wurk::IterableJob` (upstream homes the module here) |
| `Sidekiq::Job::Iterable::CsvEnumerator` | `Wurk::IterableJob::CsvEnumerator` |
| `Sidekiq::Job::Iterable::ActiveRecordEnumerator` | `Wurk::IterableJob::ActiveRecordEnumerator` |
| `Sidekiq::Job::Interrupted` | `Wurk::Job::Interrupted` |
| `Sidekiq::Job::InterruptHandler` | `Wurk::Middleware::InterruptHandler` |
`Wurk::IterableJob::Interrupted` is the same class as `Wurk::Job::Interrupted`;
the alias exists so code that rescues or raises either name behaves identically.
Key schema, field names, cursor semantics, and the 5-second check interval all
match upstream, so a half-finished Sidekiq iterable job resumes correctly after
the gem swap and vice versa.
---
## 10. Gotchas
- **`each_iteration` must be idempotent.** The cursor stored for an item is
that item's own index/primary key, and resumption *includes* it — resuming
from cursor `2` re-yields the element at index `2`. At least one item is
always replayed after an interruption, and up to five seconds' worth after a
hard kill.
- **`build_enumerator` must be deterministic.** It is re-invoked from scratch on
every resume. A relation ordered non-deterministically, a query whose result
set shifts as you mutate it (`User.where(migrated: false)` while
`each_iteration` sets `migrated = true`), or a `Dir.glob` over a changing
directory will skip or duplicate work. Prefer a stable ordering and an
immutable snapshot, or accept — deliberately — that the set shrinks under you.
- **Keep args small.** `*args` is the job payload, re-serialized into Redis on
every re-push. Pass an id, not a 10 MB array — the enumerator exists precisely
so the big collection never enters the payload.
- **The cursor must survive JSON.** Symbols, `Time`s, and AR objects do not
round-trip. Store the primary key.
- **Checkpoint cadence is a constant, not config.** `STATE_FLUSH_INTERVAL` is
fixed at 5 seconds for both flushing and cancellation polling. Iterations
should be short enough that a five-second granularity is meaningful; a single
iteration that takes ten minutes checkpoints once, at its end.
- **A cancelled job terminates instead of re-cycling.** Cancellation returns
rather than raising `Interrupted`, so `InterruptHandler` never re-pushes it,
and the `it-` state — `cancelled` field included — is deleted. The
3-day `CANCELLATION_PERIOD` TTL now only bounds a cancel marker set for a job
that has not started yet.
- **Define `include Wurk::IterableJob` before any `perform`.** The
`method_added` guard is installed by the include and cannot see methods
defined above it.
---
## Related
- [Starting the Wurk process](running.md) — signals, shutdown timeout, quiet.
- [Deploying Wurk](deployment.md) — rolling restarts and what interrupts jobs.
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — the alias contract.
---
# Reliability
Wurk's job-delivery guarantee is **at-least-once**, and it is on by default —
there is no "basic fetch" mode to opt out of and no Pro toggle to turn on. This
page describes exactly what protects a job at each hop, and — just as
importantly — the three places where a job can still be lost or run twice.
The path a job takes:
| Hop | Mechanism | Loss window |
|---|---|---|
| `perform_async` → Redis | Client push (optional [outage buffer](#client-side-redis-outage-buffering)) | Redis unreachable and buffering off → the call raises |
| `schedule` / `retry` ZSET → queue | Scheduler poller | Pop-then-push window, unless [`reliable_scheduler!`](#the-reliable-scheduler) |
| queue → worker | [Reliable fetch](#reliable-fetch) (BLMOVE + private list) | None — that's the point |
| worker → done | ACK on completion, [reaper](#the-reaper) on death | None, but **duplicate execution is possible** |
---
## The guarantee, and its limits
**A job accepted into Redis will be executed at least once, unless you kill it.**
What that buys you: `SIGKILL`, an OOM kill, a lost EC2 instance, a container
eviction, or a hard `deploy` mid-perform all end with the job back on its public
queue and re-run by a live worker.
What it does **not** buy you:
- **Exactly-once.** A job that dies mid-`perform` is re-run **from the
beginning**. Every side effect it had already committed happens again.
Idempotency is [your responsibility](#idempotency-is-yours).
- **Durability of anything Redis itself loses.** Wurk's guarantee is scoped to
"once the payload is in Redis". If your Redis has no AOF, or fails over to a
replica that hadn't received the write, the job is gone and no Wurk mechanism
can recover it. Persistence and replication policy are yours.
- **Jobs still in the client-side outage buffer.** In-memory, per-process.
Process dies → buffered jobs are gone. See the
[caveat](#the-durability-caveat).
- **Jobs killed as poison pills.** A job recovered `3` times inside 72h is moved
to the dead set instead of re-queued. It's preserved, but it stops running.
- **Due jobs promoted by the default scheduler.** See
[the reliable scheduler](#the-reliable-scheduler).
---
## Reliable fetch
`Wurk::Fetcher::Reliable` is the only fetcher Wurk ships. Every fetch is an
atomic move, never a destructive pop.
```
LMOVE queue:default queue:default|web-1|4711|0 RIGHT LEFT
```
The destination is the **private list** — one per (public queue, process):
```
queue:|||
```
- `` is `ENV["DYNO"]` when set, otherwise `Socket.gethostname`.
- `` is currently always `0` — one fetcher per capsule.
- Pipe separators, matching Sidekiq Pro's `super_fetch` naming byte-for-byte, so
third-party tooling that parses these keys keeps working.
The job stays in that private list for the entire duration of `perform`. Only
when the Processor finishes does it ACK:
```
LREM queue:default|web-1|4711|0 1
```
`count = 1` is safe because every payload carries a unique `jid`.
The job is ACKed when the job succeeded **or** when the retry layer booked the
outcome (scheduled a retry, sent it to the dead set, or a middleware
re-enqueued it). If neither happened — the process died — the payload is still
sitting in the private list.
**That is why `SIGKILL` is safe.** There is no in-memory-only state to lose: at
every instant, the payload exists in Redis, in exactly one of the public queue
or one private list.
### Fetch order and polling
Wurk walks the served queues in order with non-blocking `LMOVE`, then falls back
to a blocking `BLMOVE` on the first queue so an idle worker doesn't spin Redis.
`BLMOVE` has no multi-key form, so single-queue blocking is the best Redis
offers.
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.fetch_poll_interval = 5 # BLMOVE block timeout, seconds
end
```
Default is `Wurk::Fetcher::Reliable::TIMEOUT`, **2 seconds**. The socket read
timeout is set one second wider than the block window so a legitimately blocked
`BLMOVE` never trips it.
Paused queues (`SMEMBERS paused`) are skipped on every fetch pass. In-flight
jobs on a paused queue continue to completion.
### Graceful shutdown
On `SIGTERM`, in-flight jobs get until `shutdown_timeout` (default **25s**,
`config[:timeout]`) to finish. Whatever is still running at the deadline is
moved private-list → public queue by `bulk_requeue`, using an LREM-guarded
`RPUSH` in one Lua hop, before the threads are killed.
The LREM guard is what makes this safe against the race where a Processor ACKs
between the snapshot and the move: LREM removes 0 → RPUSH is skipped → a
finished job is never resurrected. `RPUSH` (tail) is deliberate — `LMOVE` pops
the tail, so the reclaimed job is fetched *next*, ahead of fresh enqueues.
> **Divergence from Sidekiq Pro.** Pro's `super_fetch` leaves in-flight jobs in
> the private list until the process boots again. Wurk moves them back to the
> public queue immediately, so a rolling deploy recovers the work without
> waiting for a restart.
---
## The reaper
`bulk_requeue` covers a *graceful* exit. `Wurk::Fetcher::Reaper` covers everything
else: a `kill -9`, a segfault, an OOM kill, a vanished host. Those leave private
lists nobody will ever ACK.
Every worker process runs a reaper thread (`wurk-reaper`). It does two passes:
| Pass | Scope | Cadence | Redis lock key |
|---|---|---|---|
| Scoped | `SCAN MATCH queue:\|*` for queues this process serves | every `60s` (`Reaper::DEFAULT_INTERVAL`) | `super_fetch:reaper` |
| Full | `SCAN MATCH queue:*\|*` — the entire keyspace | at most every `3600s` (`Reaper::FULL_INTERVAL`) | `super_fetch:reaper:full` |
Both passes are gated by a cluster-wide `SET 1 NX EX `, so
exactly one process in the fleet actually sweeps per interval no matter how many
are running. This is deliberately **leader-independent** — it keeps working if
the leader election is down.
The full pass exists because the scoped pass only looks at queues *someone
currently serves*. A private list belonging to a decommissioned or renamed queue
would otherwise be stranded forever.
There's also a **boot-time sweep**: every process runs one unguarded scoped
`reclaim!` on a background thread at startup, with no cluster lock. A SIGKILLed
sibling's jobs are recovered as soon as the replacement boots, not up to a
minute later.
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config[:super_fetch_reaper_interval] = 30 # seconds; default 60
end
```
### How "dead" is decided
Per private-list owner, and the answer differs by host:
- **Same host** — the OS is authoritative: `Process.kill(0, pid)`. Instant. A
`kill -9`ed sibling is reclaimed the moment the supervisor reaps it, without
waiting out any TTL. (`EPERM` is treated as alive.)
- **Other host** — we can't ping the pid, so we trust the heartbeat: the owner
is alive iff some member of the `processes` set whose `info` hash still exists
shares its `:` prefix. The heartbeat hash has a **60s TTL**, so
**cross-host reclaim can lag up to ~60 seconds.** Bare set membership isn't
enough — a member lingers after its hash expires, and that window must count
as dead.
The one blind spot is local pid reuse: if an unrelated process grabs the dead
worker's pid, the private list looks alive. In practice the supervisor respawns
with a fresh pid, so it doesn't arise.
### What reclaim actually does
Job by job, tail-to-tail:
```
LMOVE queue:default|dead-host|9182|0 queue:default RIGHT RIGHT
```
The move happens **before** the poison-pill check, so a crash mid-drain leaves
the job safely on the public queue — at-least-once, never lost.
### Duplicate execution — be honest about this
If a process dies **mid-`perform`**, the job had already started. Its private
list entry is reclaimed and re-pushed, and a live worker runs it **from the top**.
Any writes, emails, charges, or API calls the first attempt completed before
dying will happen a second time. Wurk cannot know how far the first attempt got.
This is the at-least-once contract, and it is identical to Sidekiq Pro's.
The mitigation is idempotent jobs, not configuration.
### Poison pills
A job that crashes its worker *every* time would loop forever. Every reclaimed
job runs through `Wurk::Middleware::PoisonPill`:
- `INCR super_fetch:recovered:` with a **72h TTL** (wire-compatible with
Sidekiq Pro — tooling that watches those keys expects 72h).
- At `RECOVERY_THRESHOLD` (**3**) the job is killed into the dead set and
`LREM`'d back off the public queue so it isn't also re-run.
- Statsd `sidekiq.jobs.recovered.fetch` fires on every recovery;
`sidekiq.jobs.poison` on the kill.
Two callback surfaces, both Pro-shaped:
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.super_fetch! do |jobstr, pill|
Rails.logger.warn "recovered: #{jobstr}"
Rails.logger.warn "poison: #{pill.jid} #{pill.klass} #{pill.count} #{pill.queue}" if pill
end
end
Wurk::Middleware::PoisonPill.on_poison do |info|
Sentry.capture_message("poison pill", extra: info) # {jid:, klass:, count:, queue:}
end
```
`Wurk::Middleware::PoisonPill.recovery_count(jid)` reads the counter without
bumping it.
---
## The reliable scheduler
**This one is not the default, and the difference matters.**
The default poller (`Wurk::Scheduled::Enq`) drains the `retry` and `schedule`
sorted sets with an atomic Lua pop-by-score, then pushes each popped job through
the client. Between the pop and the push, the job exists **only in the poller's
memory**. A crash, or a raise inside the push, loses it. The loss is reported
through your error handler (`context: "scheduler_promote"`) — but the job is
gone.
`reliable_scheduler!` closes that window by swapping in
`Wurk::Scheduled::ReliableEnq`, which promotes due jobs in a single atomic Lua
script: `ZRANGEBYSCORE` → `SADD queues` → `LPUSH queue:` → `ZREM`. Push
before remove, so the worst case is a crash between them — an at-least-once
redelivery, never a loss.
```ruby
# config/initializers/wurk.rb
Wurk.configure_server do |config|
config.reliable_scheduler!
end
```
- Promotes in batches of `PROMOTE_BATCH` (**500**) per Lua call, looping until a
short batch signals the backlog is dry. Redis Lua is atomic and
single-threaded — an unbatched sweep of a 100k-member post-outage backlog
would block every client for its whole duration.
- Not compatible with Redis Cluster: the script touches multiple slots.
Poll cadence is the same either way: one poller thread per process, waking on a
randomized interval of `process_count * average_scheduled_poll_interval`
(default `5`), with a `10s` initial wait so a fleet-wide deploy doesn't dogpile
Redis. Total scheduler traffic therefore stays roughly constant as you add
processes.
> **Divergence from the "it's already the default" claim.** Reliable *fetch* is
> the default. The reliable *scheduler* is not — you must call
> `reliable_scheduler!` to get it. If job loss on scheduler promotion is
> unacceptable to you, turn it on.
---
## Client-side Redis-outage buffering
By default, `perform_async` during a Redis outage raises. `reliable_push!`
turns that into an in-process ring buffer that replays when Redis comes back.
```ruby
# config/initializers/wurk.rb (top level — NOT inside Wurk.configure_*)
Wurk::Client.reliable_push! unless Rails.env.test?
```
`Sidekiq::Client.reliable_push!` is the same method — `Sidekiq::Client` is
`Wurk::Client`.
| Setting | Default | Notes |
|---|---|---|
| `Wurk::Client.reliable_push_buffer = 5_000` | `1_000` | Positive Integer, else `ArgumentError` |
| `Wurk::Client.reliable_push_overflow = :raise` | `:drop_oldest` | `:drop_oldest` is the Pro ring-buffer behavior |
| `Wurk::Client.reliable_push?` | — | Is it installed |
| `Wurk::Client::Buffered.buffer_size` | — | Current depth |
What gets caught: `RedisClient::ConnectionError` (after `Wurk::RedisPool`'s own
retries — 3 attempts with exponential backoff) and `ConnectionPool::TimeoutError`
(checkout starvation).
**Flush behavior.** Every subsequent `push` / `push_bulk` drains the buffer
oldest-first *before* pushing the new job. Draining stops at the first transient
failure and un-shifts the payload back to the head, so order is preserved and
the same job is retried next time. Non-transient errors (OOM, LOADING, READONLY)
also restore the payload before propagating, so a recovering-but-not-ready Redis
can't silently eat one buffered job per tick. Each drained payload increments
statsd `sidekiq.jobs.recovered.push`.
That passive path only fires if something pushes. If your producer goes quiet
mid-outage, the buffer sits there. Wurk adds an opt-in background drainer
(a Wurk extension beyond the Pro spec):
```ruby
Wurk::Client.reliable_push_drainer(interval: 2.0) # implies reliable_push!
Wurk::Client.reliable_push_drainer_running? # => true
Wurk::Client.reliable_push_drainer_stop!
```
**What is not buffered.** Payloads carrying a `bid` — batch creation and
batch-context pushes. Those re-raise, because the batch Lua has atomic counter
side effects that can't be safely replayed. In a mixed `push_bulk`, the bidless
payloads buffer and the batched ones raise.
### The durability caveat
**The buffer is in-memory and per-process. If the process dies, every buffered
job is gone.** Not written to Redis, not written to disk, not recoverable, not
logged as a payload you can replay.
`reliable_push` buys you a bounded window over a *short* Redis blip in a
*surviving* process. It is not a durable outbox. If losing an enqueue is
genuinely unacceptable, write the intent to your own database inside the
business transaction and enqueue from there — that's the outbox pattern, and
Wurk doesn't implement it for you.
Under `:drop_oldest` (the default), sustained outage past the cap silently
evicts your **oldest** jobs. Use `:raise` if you'd rather decide yourself:
```ruby
Wurk::Client.reliable_push_overflow = :raise
begin
MyJob.perform_async(id)
rescue Wurk::Client::Buffered::Overflow => e
Outbox.create!(payload: e.payload) # the offending payload rides along
end
```
---
## Transaction-aware client
A different failure mode entirely, and a common production bug:
```ruby
ActiveRecord::Base.transaction do
order = Order.create!(...)
OrderJob.perform_async(order.id) # Redis write is NOT transactional
raise ActiveRecord::Rollback # row is gone; the job is not
end
```
The job is already in Redis. A worker picks it up, looks for the order, and
finds nothing.
`Wurk::TransactionAwareClient` defers the Redis write to the surrounding
transaction's commit:
```ruby
# config/initializers/wurk.rb
Wurk.transactional_push! # Sidekiq.transactional_push! is an alias
```
This sets `default_job_options["client_class"]`, so every `perform_async`
builds the deferring client. Per-job override via `set(client_class: …)`.
- **The `jid` is pre-allocated and returned synchronously**, so you can
reference it inside the transaction even though the write hasn't happened.
- Dispatch is `ActiveRecord.after_all_transactions_commit` (AR 7.2+), falling
back to the `after_commit_everywhere` gem, falling back to an immediate
yield. AR's hook runs the block *now* when no transaction is open — so "not
in a transaction" and "ActiveRecord absent" both degrade gracefully to a
normal push.
- **`push_bulk` is never deferred** (Sidekiq parity — the batching/scheduling
machinery can't ride a commit hook).
- **Pushes inside a `Batch#jobs` block are never deferred** either: the batch
counts jobs at push time, so deferring would desync its totals.
---
## Sidekiq Pro compatibility
An existing Pro initializer drops in unchanged. What each call actually does
here:
| Pro call | Wurk behavior |
|---|---|
| `config.super_fetch!` | **Effectively a no-op** — reliable fetch is already the only fetcher. The optional block *is* honored: it's stored as the recovery callback and fired per orphan recovery (`pill` nil) and per poison kill (`pill` set). |
| `config.reliable_scheduler!` | **A real call.** Swaps `scheduled_enq` to the atomic promoter. Idempotent. |
| `Sidekiq::Client.reliable_push!` | **A real call.** Installs the outage buffer. Idempotent. |
| `config.fetch_poll_interval =` | **A real call.** Overrides the 2s `BLMOVE` block timeout. |
| `retry: :reliable` | Accepted; the private-list-until-ack behavior it names is already how every job runs. |
Key schema, counter keys, TTLs, lock names, and statsd metric names all match
Pro exactly — external tooling built against `super_fetch:recovered:*`,
`super_fetch:reaper`, `sidekiq.jobs.poison`, or the `queue:|||`
naming works unchanged.
---
## Operating it
### What to monitor
| Signal | Where | Means |
|---|---|---|
| `sidekiq.jobs.recovered.fetch` | statsd (tags `class:`, `queue:`) | Workers are dying with jobs in flight. A steady non-zero rate is a bug, not noise. |
| `sidekiq.jobs.poison` | statsd | A job crossed 3 recoveries in 72h and was killed. Page on this. |
| `sidekiq.jobs.recovered.push` | statsd | Enqueues were buffered through a Redis outage and have now landed. |
| `Wurk::Client::Buffered.buffer_size` | in-process | Non-zero means Redis is unreachable *right now* from this process. |
| Dead set growth | dashboard | Includes poison-pill kills. |
| `queue:*\|*` key count | Redis | Should be ~(processes × queues). A persistent excess means orphaned private lists the reaper isn't clearing. |
Statsd metrics require a client — they're a no-op until you wire one up:
```ruby
Wurk.configure_server do |config|
config.dogstatsd = -> { Datadog::Statsd.new("metrics.example.com", 8125) }
config.server_middleware { |chain| chain.add Wurk::Metrics::Statsd }
end
```
A quick manual check for stranded private lists:
```bash
redis-cli --scan --pattern 'queue:*|*' | head -50
```
Anything whose `|` doesn't correspond to a live process should
disappear within one reaper interval (60s same-host) or one heartbeat TTL
(~60s cross-host). If it doesn't, check that reaper threads are actually
running and that no process is holding the `super_fetch:reaper` lock without
sweeping.
### Idempotency is yours
At-least-once means **write your jobs so a second execution is harmless.** Wurk
provides no exactly-once mechanism, and no configuration will give you one.
Practical rules:
- Key side effects on something stable (`order.id`), not on the attempt.
- Guard external calls with an idempotency key the remote service honors
(Stripe, most payment APIs).
- Prefer `find_or_create_by!` / upserts over blind `create!`.
- Make the job re-entrant: check whether the work is already done and return
early, rather than assuming it isn't.
- Assume the job can be interrupted at *any* line, including between two writes
that "obviously" happen together — wrap those in a DB transaction.
The one thing you should not do is disable reliability to avoid duplicates.
There is no such setting, and losing jobs is worse.
---
## Related
- [Deployment](deployment.md) — signals, rolling restarts, and what a graceful
drain actually waits for.
- [Running Wurk](running.md) — process topology, queues, and concurrency.
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — what changes on the
one-line gem swap.
---
# Middleware
Wurk runs every enqueue and every job execution through an ordered chain of
middleware. There are two chains, and they are completely separate:
| Chain | Runs in | Wraps | Registered on |
|---|---|---|---|
| **Client** | whatever process calls `perform_async` (web, console, another worker) | the Redis write in `Wurk::Client#push` | `config.client_middleware` |
| **Server** | the worker process | `instance.perform(*args)` in `Wurk::Processor` | `config.server_middleware` |
Both chains are `Wurk::Middleware::Chain` instances with an identical
manipulation API. The middleware contract is Sidekiq's, unchanged: an existing
`Sidekiq.configure_server { |c| c.server_middleware.add … }` initializer keeps
working on the one-line gem swap, because `Sidekiq::Middleware`,
`Sidekiq::ServerMiddleware`, and `Sidekiq::ClientMiddleware` are aliases of the
`Wurk::*` constants.
---
## The two `call` signatures
**They differ by one argument.** Client middleware takes four; server
middleware takes three. Getting this wrong is the most common authoring bug.
### Client middleware
```ruby
# config/initializers/wurk.rb
class MyClientMiddleware
include Wurk::Middleware::ClientMiddleware
def call(job_class, job, queue, redis_pool)
yield
end
end
```
- `job_class` — the job class **name as a String**. `Wurk::JobUtil` stringifies
it (`normalized['class'] = job_class.to_s`) before the chain runs, so don't
expect a Class object.
- `job` — the normalized job hash, string keys, mutable. Anything you write
here is what lands in Redis.
- `queue` — the queue name String (already stringified and validated
non-empty).
- `redis_pool` — the pool the push will use.
**The return value decides whether the push happens.** `Wurk::Client#push`
does:
```ruby
payload = invoke_chain(normed)
return nil unless payload
```
So `yield`'s value must be returned for the push to proceed. Returning `nil` or
`false` **halts the push** — the job is silently dropped and `perform_async`
returns `nil` instead of a jid. In `push_bulk`, a halted job contributes a `nil`
entry to the returned jid array. `Wurk::Unique::ClientMiddleware` uses exactly
this to drop duplicates.
### Server middleware
```ruby
# config/initializers/wurk.rb
class MyServerMiddleware
include Wurk::Middleware::ServerMiddleware
def call(job_instance, job, queue)
yield
end
end
```
- `job_instance` — the **instantiated** worker, with `jid` (and `bid` when
batched) already assigned. Not the class.
- `job` — the parsed job hash.
- `queue` — the queue name String.
There is no fourth argument and no `redis_pool` parameter — reach for the pool
through the `redis_pool` / `redis` helpers the mixin gives you (below).
A server middleware that returns without yielding **skips `perform`** without
raising. The processor sees a clean exit and acks the unit of work.
`Wurk::Middleware::Expiry` does this.
### The mixin
`include Wurk::Middleware::ServerMiddleware` (or `ClientMiddleware` — they are
the same module; `ClientMiddleware = ServerMiddleware`) gives you:
| Method | Returns |
|---|---|
| `config` / `config=` | the bound `Wurk::Configuration` or `Wurk::Capsule` |
| `redis_pool` | `config.redis_pool` |
| `redis(&)` | `config.redis(&)` — checkout + yield a connection |
| `logger` | `config.logger` |
`config=` is assigned by the chain at instantiation time
(`Entry#make_new(config)`), only if the instance responds to it. Including the
mixin is the supported way to get it.
---
## Registering middleware
### Where to register
Register at **boot**, from an initializer, before the swarm forks. The boot
order is:
1. Host app boots; `config/initializers/*` run — this is where you register.
2. The railtie's `after_initialize` fires.
3. The swarm closes parent-side Redis/DB sockets.
4. The swarm forks N children.
5. Each child reconnects and starts fetching.
Chains registered in step 1 are inherited by every fork, because the entries
are plain Ruby objects copied by `fork`. **Registering after the fork only
affects the process that did it** — a call from inside a job body mutates that
one child's chain and nothing else.
The fork-safety rule follows from the same place: the chain stores a **klass
plus constructor args**, not a live instance, and `Chain#retrieve` builds a
fresh instance per job. So middleware never carries a Redis connection, a file
handle, or any other fork-hostile state across the fork boundary — provided you
don't open one in `initialize`. Open connections lazily inside `call`, or use
`redis_pool`, which resolves through the per-fork pool.
### `configure_server` vs `configure_client`
Both blocks yield the same global `Wurk::Configuration`; the difference is a
gate on `config.server?`:
```ruby
def configure_server(&block) = yield self if block && server?
def configure_client(&block) = yield self if block && !server?
```
So:
```ruby
# config/initializers/wurk.rb
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add MyServerMiddleware
end
config.client_middleware do |chain|
chain.add MyClientMiddleware # jobs enqueued *from* jobs
end
end
Sidekiq.configure_client do |config|
config.client_middleware do |chain|
chain.add MyClientMiddleware # web/console enqueues
end
end
```
Client middleware you want on **both** sides must be registered in both blocks,
or outside either block entirely (a bare
`Wurk.configuration.client_middleware.add …` always runs).
Server mode is entered *before* `config/initializers` load — the railtie's
`wurk.server_mode` initializer calls `Wurk.enter_server_mode`, and the
standalone CLI does the same before booting the app. That ordering is
load-bearing: if it were reversed, every `configure_server` block would be
silently skipped and your server middleware would never register.
Both chain readers accept a block *or* return the chain, so
`config.server_middleware.add(X)` and `config.server_middleware { |c| c.add X }`
are equivalent.
### The chain API
Every method is available on both chains:
| Method | Effect |
|---|---|
| `add(klass, *args)` | Remove any existing entry for `klass`, then **append**. Appended = innermost. |
| `prepend(klass, *args)` | Remove any existing entry, then insert at index 0 = **outermost**. |
| `insert_before(oldklass, newklass, *args)` | Move/insert `newklass` immediately before `oldklass`. If `oldklass` isn't registered, falls back to index 0. |
| `insert_after(oldklass, newklass, *args)` | Move/insert `newklass` immediately after `oldklass`. If `oldklass` isn't registered, falls back to the end. |
| `remove(klass)` | Delete every entry for `klass`. |
| `exists?(klass)` / `include?(klass)` | Membership test. |
| `empty?` | No entries. |
| `clear` | Drop everything, including the built-ins. |
| `entries` | The raw `Entry` array (`entry.klass` is the registered class). |
| `each` / any `Enumerable` method | Iterate entries. |
| `retrieve` | Build a fresh instance per entry. Called once per job. |
| `invoke(*args, &block)` | Walk the chain around `block`. |
| `copy_for(capsule)` | Duplicate the chain bound to a capsule. |
`*args` are passed straight to `klass.new`, which is how parameterized
middleware works:
```ruby
chain.add MyMiddleware, threshold: 500
# → MyMiddleware.new(threshold: 500) per job
```
**Ordering:** entry 0 is outermost. `add` appends, so a later `add` sits
*inside* an earlier one and runs closer to `perform`. `prepend` puts you
outermost — use it when you must see exceptions that inner middleware would
otherwise swallow (that's why `InterruptHandler` prepends).
`invoke` raises `ArgumentError` without a block, and short-circuits to a plain
`yield` when the chain is empty.
### Capsules
Capsules get their own chain via `copy_for`, which duplicates the entries array
and rebinds `@config` to the capsule — so `redis_pool` / `logger` inside
middleware resolve against that capsule, not the global config. Mutating a
capsule's chain does not affect the global one, and vice versa, **after** the
copy is taken. The copy is taken lazily on first access (and forced by
`Capsule#prepare!` at launcher boot), so global registrations made in an
initializer are picked up.
```ruby
# config/initializers/wurk.rb
Sidekiq.configure_server do |config|
config.capsule("critical") do |cap|
cap.server_middleware { |chain| chain.add CriticalOnlyMiddleware }
end
end
```
---
## A worked example
Fail a job fast when it has been sitting in the queue too long, and log the
latency — a server middleware that needs the job hash, the config, and clean
composition with retries:
```ruby
# config/initializers/wurk.rb
class QueueLatencyGuard
include Wurk::Middleware::ServerMiddleware
def initialize(max_seconds: 300)
@max_seconds = max_seconds
end
def call(_job_instance, job, queue)
enqueued_at = job["enqueued_at"]
latency = enqueued_at ? Time.now.to_f - (enqueued_at.to_f / 1000.0) : 0.0
if latency > @max_seconds
logger.warn { "dropping stale #{job['class']} on #{queue} (#{latency.round}s old)" }
return # no yield → perform never runs, processor acks cleanly
end
yield
end
end
Sidekiq.configure_server do |config|
config.server_middleware do |chain|
chain.add QueueLatencyGuard, max_seconds: 600
end
end
```
Two things to notice. `enqueued_at` is epoch **milliseconds** — the client
stamps it with `Process.clock_gettime(CLOCK_REALTIME, :millisecond)`. And
returning instead of raising means no retry is booked; raise instead if you want
the job to go through the normal retry pipeline.
A client-side counterpart, tagging every job with the request id:
```ruby
# config/initializers/wurk.rb
class RequestIdTagger
include Wurk::Middleware::ClientMiddleware
def call(_job_class, job, _queue, _redis_pool)
job["request_id"] ||= Current.request_id
yield
end
end
Sidekiq.configure_client { |c| c.client_middleware { |chain| chain.add RequestIdTagger } }
Sidekiq.configure_server { |c| c.client_middleware { |chain| chain.add RequestIdTagger } }
```
Note the `||=`. Client middleware runs again when a scheduled or retried job is
promoted, so an unconditional write would clobber the original value.
---
## Built-in middleware
On a default boot the chains look like this (outermost first):
**Client:** `Wurk::Batch::ClientMiddleware`
**Server:** `Wurk::Middleware::InterruptHandler` → `Wurk::Batch::ServerMiddleware`
→ `Wurk::Middleware::Expiry` → `Wurk::Limiter::ServerMiddleware`
→ `Wurk::Metrics::Statsd` → `Wurk::Metrics::History`
| Middleware | Chain | Default | Enable / disable |
|---|---|---|---|
| `Wurk::Middleware::InterruptHandler` | server | **on** (prepended at load) | `chain.remove Wurk::Middleware::InterruptHandler` |
| `Wurk::Batch::ClientMiddleware` | client | **on** (prepended at load) | no-op without a `bid`; `chain.remove` to strip |
| `Wurk::Batch::ServerMiddleware` | server | **on** | no-op without a `bid` |
| `Wurk::Middleware::Expiry` | server | **on** | no-op unless the job carries `expiry` |
| `Wurk::Limiter::ServerMiddleware` | server | **on** | only acts on `Wurk::Limiter` errors |
| `Wurk::Metrics::Statsd` | server | **on** | no-op unless `config.dogstatsd` is set |
| `Wurk::Metrics::History` | server | **on** | `chain.remove Wurk::Metrics::History` |
| `Wurk::Encryption::Client/ServerMiddleware` | both | off | registered by `Wurk::Encryption.enable(active_version:) { … }`; acts only on `encrypt: true` jobs |
| `Wurk::Unique::Client/ServerMiddleware` | both | off | registered by `Wurk::Unique.enable!` (`Sidekiq::Enterprise.unique!`); acts only on `unique_for:` jobs |
| `Wurk::Middleware::I18n::Client/Server` | both | off | `require "wurk/middleware/i18n"` |
| `Wurk::Middleware::CurrentAttributes::Save/Load` | both | off | `require` + `CurrentAttributes.persist(…)` |
| `Wurk::Middleware::PoisonPill` | *neither* | n/a | not a chain middleware — see below |
The auto-registered set is deliberately wider than stock Sidekiq's: Batch,
Expiry, Limiter, and Statsd are Pro/Ent features, and Wurk ships them enabled in
the free gem rather than behind a flag. All of them short-circuit on jobs that
don't opt in, so the cost on an unrelated job is one hash lookup.
### `InterruptHandler`
Catches `Wurk::Job::Interrupted` (raised by an `IterableJob` that hit its
interruption checkpoint, or any cooperatively cancelled job), `LPUSH`es the
unchanged job JSON back to the **head** of its queue so it's fetched next, and
raises `Wurk::JobRetry::Skip` so the retry layer books no retry and the
processor acks. Cursor state lives in the `it-` hash, not the payload —
which is why re-pushing the original JSON resumes rather than restarts.
It **prepends**, deliberately: a middleware registered inside it must not
swallow `Interrupted` before it's seen. If you add your own outermost
middleware with a broad `rescue`, you will break iterable-job resumption.
Aliased as `Wurk::Job::InterruptHandler` (hence `Sidekiq::Job::InterruptHandler`).
### `Expiry` — `expires_in`
```ruby
class ReportJob
include Sidekiq::Job
sidekiq_options expires_in: 1.hour
end
```
At push time `Wurk::JobUtil#stamp_expiry` converts `expires_in` into an
absolute `expiry` (epoch float) on the job hash, so the server does no date
math. Clock origin is `at` for scheduled jobs and `created_at` otherwise —
`perform_in(2.hours)` with `expires_in: 1.hour` expires at 3h, not immediately.
At execution time, if `Time.now.to_f > expiry`, the middleware:
- increments `Wurk::Processor::EXPIRED`, which the heartbeat flushes to
`stat:expired` and `stat:expired:YYYY-MM-DD` (visible in `Wurk::Stats` and the
dashboard),
- emits `jobs.expired` to statsd,
- returns **without yielding** — no exception, so the job is acked and no retry
is booked.
Expiry only preempts *before* `perform` starts. A long job that started in time
runs to completion. Expired jobs still count toward `PROCESSED`
(`executed = processed - failed - expired`).
### `Limiter::ServerMiddleware`
Catches `Wurk::Limiter::OverLimit` (and anything in
`Wurk::Limiter.config.errors`), bumps `job['overrated']`, then either re-raises
(when `reschedule: 0`), re-enqueues at `Time.now + backoff` and raises
`Limiter::Rescheduled`, or — once `overrated` reaches the cap (default 20) —
routes the job to the dead set tagged `rate_limited`. Termination is bounded:
exactly `reschedule` attempts, then dead.
### `Metrics::Statsd` and `Metrics::History`
`Statsd` emits `jobs.count`, success/failure, and a perform duration
distribution. It calls `safe_client` first and yields straight through when
`config.dogstatsd` is unset, so leaving it registered costs nothing.
`History` records per-class processed / failed / total-ms into Redis time
buckets (`j|YYYYMMDD|…`) so the dashboard history pane has data on a default
boot. Both wrap their bookkeeping in a rescue — a metrics write failure never
propagates into the job result. `Wurk::Metrics::Middleware` is an alias of
`History`, matching `Sidekiq::Metrics::Middleware`.
### `I18n` — locale propagation
Opt in with a `require`. The file registers itself on load:
```ruby
# config/initializers/wurk.rb
require "wurk/middleware/i18n"
```
`I18n::Client` writes `job['locale'] ||= I18n.locale.to_s`; `I18n::Server` runs
`perform` inside `I18n.with_locale`, restoring the previous locale in an
`ensure` so nothing leaks between jobs on the same thread. Both are no-ops when
the `I18n` constant is undefined — and the client deliberately does **not**
introduce a `nil` `"locale"` key in that case, because that would change the
wire shape.
`Sidekiq::Middleware::I18n` resolves via the `Sidekiq::Middleware` alias.
### `CurrentAttributes` — request state propagation
Off by default and needs two steps — a `require` plus an explicit `persist`:
```ruby
# config/initializers/wurk.rb
require "wurk/middleware/current_attributes"
Sidekiq::CurrentAttributes.persist(Current)
# or several:
Sidekiq::CurrentAttributes.persist([Current, AuditContext])
```
`persist(klass_or_array, config = Wurk.configuration)` adds `Save` to the
client chain and `Load` to **both** the client and server chains. It raises
`ArgumentError` on an empty list. Calling it twice with the same classes is
safe — `add` dedupes by class.
Wire keys are Sidekiq's exactly: the first registered class uses `"cattr"`, the
rest `"cattr_1"`, `"cattr_2"`, … `Save` uses `job[key] ||= …`, so a
caller-supplied value wins.
`Load` **saves and restores** the surrounding attribute state rather than
resetting it. That matters because `Load` also runs on the client chain, where
an enqueue happens mid-request with request-scoped attributes already set —
blanket-resetting there would wipe the caller's state right after
`perform_async`. The restore runs in an `ensure`, so it survives both raises and
`JobRetry::Skip`.
`Load#call` takes an **optional fourth argument** (`def call(_job_or_class,
job, _queue, _redis_pool = nil)`) precisely because it is registered on both
chains, which pass different arities. If you write your own dual-chain
middleware, do the same.
The alias is the top-level `Sidekiq::CurrentAttributes`, set when the file is
required — not `Sidekiq::Middleware::CurrentAttributes`.
### `PoisonPill` — not a chain middleware
`Wurk::Middleware::PoisonPill` lives in the middleware directory but is
**never registered on either chain**. It's a module driven directly by the
reliable-fetch reaper / `bulk_requeue` paths via
`PoisonPill.track!(payload, queue:)`.
Each recovery of a job out of a dead process's private list `INCR`s
`super_fetch:recovered:` (72h TTL — wire-compatible with Sidekiq Pro's
tooling). At `RECOVERY_THRESHOLD` (3) the payload is moved to the dead set,
`jobs.poison` is emitted, and callbacks fire. `track!` returns `:poison` or
`:recovered`.
```ruby
# config/initializers/wurk.rb
Wurk::Middleware::PoisonPill.on_poison do |pill|
Bugsnag.notify("poison pill: #{pill[:klass]} #{pill[:jid]} x#{pill[:count]}")
end
```
`on_poison` callbacks receive a Hash `{jid:, klass:, count:, queue:}`. The Pro
`config.super_fetch! { |jobstr, pill| … }` callback also fires on every
`track!` — with `nil` for the second argument on plain recovery, and a
`PoisonPill::Pill` struct (`.jid` / `.klass` / `.count` / `.queue`) on the kill
path. Callback exceptions are caught and routed to the configured error
handlers. Introspection: `PoisonPill.recovery_count(jid)`, `PoisonPill.clear!(jid)`.
---
## Interaction with retries, batches, and unique jobs
The server chain sits **inside** the retry onion. `Wurk::Processor#dispatch`
builds it as: job logger → `retrier.global` → job logger → stats → profiler →
reloader → instantiate → `retrier.local` → **server middleware chain** →
`perform`. Consequences:
- **An exception you raise from middleware is a job failure** and is booked as a
retry by `JobRetry`, exactly as if `perform` had raised.
- **Returning without yielding is a clean exit.** No retry, no failure count,
the unit of work is acked.
- **`Wurk::JobRetry::Skip`** (and its subclasses, e.g.
`Wurk::Limiter::Rescheduled`) is the "I already handled this job — ack it, book
nothing" signal. `Processor#process` rescues `JobRetry::Handled` and acks.
Raise `Skip` when your middleware has re-enqueued the job itself.
- **`Wurk::Shutdown`** must propagate. If you `rescue Exception` and swallow it,
the unit of work gets acked during shutdown and the job is lost — the whole
point of reliable fetch is that an unacked payload survives in the private
list.
Batches are visible in the ordering. `Batch::ServerMiddleware` is registered
**before** `Expiry` and `Limiter`, so batch is the outer onion and those two are
inner. That is why:
- An expired job (which *returns*) unwinds outward through batch's `yield`, so
`ack_success` still runs and it counts as a batch success.
- A rate-limited job **raises** `Rescheduled` instead of returning, because a
plain return would make batch ack success for a job that never ran. Batch
rescues `JobRetry::Handled` and `Job::Interrupted` and re-raises them
untouched — acking neither success nor failure.
If you insert middleware **outside** `Batch::ServerMiddleware` (via `prepend` or
`insert_before`), a skip-by-return in your middleware will bypass batch
accounting entirely and the batch will never complete. Register inside it
(`add`, or `insert_after Wurk::Batch::ServerMiddleware`) unless you specifically
need the outer position.
Unique jobs are a two-chain protocol: `Unique::ClientMiddleware` does
`SET NX EX` on the lock key and returns `nil` (halting the push) when another
jid holds it; `Unique::ServerMiddleware` releases it — before `perform` for
`unique_until: :start`, after a successful return for the default `:success`,
which is why a raising job keeps its lock until the TTL. Client middleware
registered **after** the unique middleware never runs for a dropped duplicate.
---
## Job-hash parsing and performance
The processor parses the payload once, eagerly:
`Processor#parse_or_kill` calls `Wurk.load_json(jobstr)` before the dispatch
onion, and malformed JSON goes straight to the dead set. Server middleware
therefore always receives a fully materialized `job` hash — there is no lazy
`args` wrapper to force, and no cost to reading `job['args']`.
What that means for authors: the hash is real, mutable, and shared by the whole
chain. Mutating `job['args']` in middleware changes what `perform` receives —
`Wurk::Encryption::ServerMiddleware` decrypts the last argument exactly this
way. Mutating it in *client* middleware changes what is written to Redis.
The cost you control is per-job allocation. `Chain#retrieve` instantiates every
registered middleware **on every job** — that's what makes middleware
thread-safe by construction, and it's why the chain stores ctor args rather than
a live object. Keep `initialize` trivial: no Redis calls, no file IO, no
`Rails.application.config` walks. Hoist expensive constants to the class level,
and prefer a cheap early `return yield` guard over work that only some jobs
need. The built-ins are all written this way.
---
## Gotchas
- **Client middleware runs again on retry and on scheduled promotion.** Anything
that must be captured once at enqueue time needs `||=`, not `=`.
- **Wrong arity fails at runtime, not at registration.** `add` accepts any
class. A three-argument `call` on the client chain raises `ArgumentError`
inside the first push.
- **Returning falsy from client middleware silently drops the job.** Always
return `yield`'s value. `def call(...) = yield` is fine; a trailing
`logger.info(...)` that returns `nil` is not.
- **`add` removes before appending.** Calling `add(X)` on an already-registered
`X` moves it to the end of the chain. Use `exists?` if you mean "register
once, keep position" — that's what `Wurk::Unique` and `Wurk::Encryption` do.
- **`insert_before`/`insert_after` with an unregistered anchor silently fall
back** to index 0 and the end of the chain respectively. They don't raise, so
a typo'd class name produces a wrong-position middleware, not an error.
- **`chain.clear` removes the built-ins too** — including `InterruptHandler`,
batch accounting, and metrics. Prefer `remove(klass)`.
- **`Wurk::Testing` uses a separate chain.** Inline test mode
(`Sidekiq::Testing.inline!`) invokes `Wurk::Testing.server_middleware`, which
starts **empty** — the production server chain does not apply. Register test
middleware with `Sidekiq::Testing.server_middleware { |c| c.add … }`.
- **`Wurk::Client.new(chain:)`** lets you construct a client with a custom
chain, and `client.middleware { |c| … }` yields a *duplicate* (leaving the
original untouched) while `client.middleware` with no block returns the live
chain.
- **Don't hold a Redis connection in an instance variable.** Instances are
per-job, but the underlying pool is per-fork; check out through `redis_pool` /
`redis` each time.
---
## Related
- [Migrating from Sidekiq](migrate-from-sidekiq.md) — the drop-in alias contract.
- [Running Wurk](running.md) — capsules, concurrency, and the boot sequence.
- [Active Job](active-job.md) — where the adapter sits relative to these chains.
---
# Data API
The Data API is how you inspect and manipulate queues and jobs **from your own
code** — a Rails console, a rake task, an admin controller, a health check.
The server never uses it; it reads and writes the same Redis structures the
server does.
Everything here loads with the gem:
```ruby
require "wurk" # or: require "sidekiq" / require "sidekiq/api"
```
There is no separate `sidekiq/api` load step — `lib/sidekiq/api.rb` is a
one-line passthrough to `wurk`, so an app that already calls
`require "sidekiq/api"` keeps working unchanged.
Every class below is exposed under its `Sidekiq::*` name by
`lib/wurk/compat.rb`. These are **the same object**, not a wrapper:
```ruby
Sidekiq::Queue.equal?(Wurk::Queue) # => true
Sidekiq::Stats.equal?(Wurk::Stats) # => true
```
| Sidekiq name | Wurk class |
|---|---|
| `Sidekiq::Stats` | `Wurk::Stats` |
| `Sidekiq::Queue` | `Wurk::Queue` |
| `Sidekiq::JobRecord` | `Wurk::JobRecord` |
| `Sidekiq::SortedEntry` | `Wurk::SortedEntry` |
| `Sidekiq::ScheduledSet` | `Wurk::ScheduledSet` |
| `Sidekiq::RetrySet` | `Wurk::RetrySet` |
| `Sidekiq::DeadSet` | `Wurk::DeadSet` |
| `Sidekiq::ProcessSet` / `Sidekiq::Process` | `Wurk::ProcessSet` / `Wurk::Process` |
| `Sidekiq::WorkSet` (`Sidekiq::Workers`) / `Sidekiq::Work` | `Wurk::WorkSet` (`Wurk::Workers`) / `Wurk::Work` |
| `Sidekiq::Deploy` | `Wurk::Deploy` |
| `Sidekiq::Web` (so `Sidekiq::Web::Search`) | `Wurk::Web` |
`Wurk::SortedSet` and `Wurk::JobSet` have **no** `Sidekiq::*` alias — see
[§ Divergences](#divergences-from-the-sidekiq-spec).
Examples use the `Wurk::` names throughout; substitute `Sidekiq::` freely.
---
## `Wurk::Stats`
Cluster-wide counters. The cheap ones are fetched in **one pipeline at
construction time**, so a single instance answers many questions without
re-querying Redis — build one, read many fields, throw it away.
```ruby
stats = Wurk::Stats.new
stats.processed # => 129_402
stats.failed # => 17
stats.retry_size # => 3
```
| Method | Returns | Notes |
|---|---|---|
| `processed` | `Integer` | `GET stat:processed`, snapshotted at `new` |
| `failed` | `Integer` | `GET stat:failed`, snapshotted at `new` |
| `expired` | `Integer` | jobs dropped by `expires_in`, snapshotted at `new` |
| `scheduled_size` | `Integer` | `ZCARD schedule` |
| `retry_size` | `Integer` | `ZCARD retry` |
| `dead_size` | `Integer` | `ZCARD dead` |
| `processes_size` | `Integer` | `SCARD processes` |
| `enqueued` | `Integer` | sum of `LLEN` over every known queue — **re-queries**, linear in queue count |
| `workers_size` | `Integer` | sum of the `busy` field over every process — **re-queries**, linear in process count |
| `queues` | `Hash{String=>Integer}` | queue name → depth, **largest first** |
| `queue_summaries` | `Array` | name/size/latency/paused per queue, largest first |
| `default_queue_latency` | `Float` | seconds the oldest `default` job has waited |
| `reset(*stats)` | pipeline result | `SET`s counters to `0` (not `DEL`, so reads stay `Integer`) |
`reset` with no arguments clears `processed`, `failed`, **and** `expired`.
With arguments it clears only the named subset; anything outside those three
names is ignored.
```ruby
Wurk::Stats.new.reset("failed")
Wurk::Stats.new.reset(%w[processed failed])
```
`QueueSummary` is a `Data` class — `name`, `size`, `latency`, `paused`, plus a
`paused?` alias. Destructuring is part of the contract; third-party gems rely
on it.
```ruby
Wurk::Stats.new.queue_summaries.each do |q|
puts "#{q.name}: #{q.size} jobs, #{q.latency.round(1)}s behind#{' (paused)' if q.paused?}"
end
```
The three re-querying readers (`enqueued`, `workers_size`, and the two queue
breakdowns) hit Redis on **every** call. Assign them to a local if you need the
value twice.
---
## `Wurk::Stats::History`
Per-day counters written by the server as `stat::YYYY-MM-DD` strings.
Missing days come back as `0` rather than being omitted.
```ruby
history = Wurk::Stats::History.new(30) # last 30 days, ending today
history.processed # => {"2026-07-20" => 41_233, "2026-07-19" => 39_887, …}
history.failed
history.expired
```
| Method | Returns |
|---|---|
| `new(days_previous, start_date = nil, pool: nil)` | raises `ArgumentError` unless `days_previous` is in `1..1825` |
| `processed` | `Hash{String=>Integer}` keyed `YYYY-MM-DD` |
| `failed` | same shape |
| `expired` | same shape |
`start_date` defaults to `Date.today`; keys run backwards from it. `pool:`
targets a non-default connection pool (a second Redis shard, say); omitted, it
uses `Wurk.redis`.
Don't confuse this with `Wurk::History` (aliased `Sidekiq::History`), which is
the metrics-recording server middleware, not a reader.
---
## `Wurk::Queue`
One named queue: the `queue:` LIST, plus membership in the `queues` SET.
`Enumerable`.
```ruby
q = Wurk::Queue.new("critical")
q.size # => 4_291
q.latency # => 12.4
q.paused? # => false
```
| Method | Returns | Notes |
|---|---|---|
| `Queue.all` | `Array` | every known queue, sorted by name |
| `new(name = "default")` | `Queue` | |
| `name` (alias `id`) | `String` | |
| `size` | `Integer` | `LLEN` — O(1) |
| `latency` | `Float` | seconds since the oldest job (list tail) was enqueued; `0.0` when empty |
| `each { \|JobRecord\| … }` | | paged `LRANGE`, 50 per page |
| `find_job(jid)` | `JobRecord` or `nil` | O(n) full scan — prefer `delete_job` if you only want to remove it |
| `clear` | `true` | `UNLINK` the list + `SREM` from the `queues` set |
| `as_json` | `{name: …}` | |
| `paused?` | `Boolean` | membership in the `paused` SET |
| `pause!` | `true` | `SADD paused` — idempotent |
| `unpause!` | `true` | `SREM paused` — idempotent |
| `delete_job(jid)` | `Integer` | Lua; see [§ Fast Lua API](#fast-lua-api) |
| `delete_by_class(klass)` | `Integer` | Lua; see [§ Fast Lua API](#fast-lua-api) |
### Pausing a queue
Pause/unpause is a Sidekiq **Pro** feature; Wurk ships it free, with the same
Redis structure (a SET named `paused`) and the same method names, so Pro code
drops in.
```ruby
Wurk::Queue.new("bulk_export").pause!
# … deploy a fix, drain a dependency, whatever …
Wurk::Queue.new("bulk_export").unpause!
```
- Fetchers consult the same `paused` SET, so pausing takes effect cluster-wide
without a restart or a signal.
- **Only new fetches stop.** Jobs already running continue to completion.
- Enqueueing is unaffected — the queue keeps growing while paused.
- Both calls are idempotent and return `true` whether or not they changed
anything.
### Enumerating
```ruby
Wurk::Queue.new("default").each do |job|
puts "#{job.jid} #{job.display_class} #{job.args.inspect}"
end
```
Iteration is a snapshot-free paged `LRANGE`: jobs pushed or popped while you
iterate can be seen twice or missed. That's inherent to walking a live LIST —
treat the output as advisory, not as a transaction.
---
## `Wurk::JobRecord`
One job payload, as yielded by `Queue#each`. Wraps the raw JSON string and
parses it **lazily**, so scanning a large queue costs no JSON work for jobs you
never inspect.
| Method | Returns |
|---|---|
| `item` | `Hash` — the parsed payload (memoized) |
| `value` | `String` — the exact JSON bytes stored in Redis |
| `queue` | `String` or `nil` — the queue this record came from |
| `klass` | `String` — `item["class"]` |
| `args` | `Array` |
| `jid` | `String` |
| `bid` | `String` or `nil` — owning batch id |
| `tags` | `Array` — `[]` when absent |
| `enqueued_at` / `created_at` / `failed_at` / `retried_at` | `Time` or `nil` |
| `latency` | `Float` — seconds since `enqueued_at`; `0.0` if missing or clock-skewed into the future |
| `error_backtrace` | `Array` or `nil` — decoded from base64 + zlib; `nil` on corruption |
| `[](name)` | any — raw payload field, for anything not listed above |
| `iterable_state` | iterable-job progress, or `nil` for a non-iterable job |
| `display_class` | `String` — unwraps ActiveJob / ActionMailer wrappers |
| `display_args` | `Array` — same unwrapping, with encrypted args masked as `""` |
| `delete` | `Boolean` — `LREM queue: 1 value`; `true` when ≥1 entry went away |
`display_class` / `display_args` are **UI-facing**: they unwrap
`ActiveJob::QueueAdapters::*::JobWrapper` payloads to the real job class, and
render `ActionMailer` deliveries as `MailerClass#method`. For programmatic
decisions use `klass` and `args`, which are the payload verbatim.
`delete` matches on exact bytes, which is why `value` exists — never
re-serialize a payload and expect `LREM` to find it.
```ruby
q = Wurk::Queue.new("default")
q.select { |job| job.klass == "LegacyJob" }.each(&:delete)
```
Both timestamp shapes Sidekiq has historically written (float epoch seconds,
integer epoch milliseconds) are handled by the `*_at` readers and by `latency`.
---
## Sorted sets — scheduled, retry, dead
Three ZSETs, all scored by epoch seconds: `schedule` (when the job should
run), `retry` (when the next attempt is due), `dead` (when it was killed).
```ruby
Wurk::ScheduledSet.new
Wurk::RetrySet.new
Wurk::DeadSet.new
```
Each takes an optional key name (`RetrySet.new("retry")`) — that exists for
tests operating on a namespaced ZSET. Production callers use the default.
### `Wurk::SortedSet` — the generic ZSET surface
| Method | Returns | Notes |
|---|---|---|
| `name` | `String` | the Redis key |
| `size` | `Integer` | `ZCARD` — O(1) |
| `scan(match, count = 100)` | enumerator, or yields | `ZSCAN` with the match wrapped in `*…*` |
| `clear` | `true` | `UNLINK` the whole key |
| `as_json` | `{name: …}` | |
### `Wurk::JobSet` — the job-aware surface
| Method | Returns | Notes |
|---|---|---|
| `each { \|SortedEntry\| … }` | `Integer` (rows yielded) | paged `ZRANGE … REV`, 50 per page — **newest/furthest-out first** |
| `schedule(timestamp, message)` | `ZADD` result | adds a payload at a score |
| `fetch(score, jid = nil)` | `Array` | `score` is `Time`, `Numeric`, or a `Range`; anything else raises `ArgumentError` |
| `find_job(jid)` | `SortedEntry` or `nil` | `ZSCAN`-based, O(n) |
| `retry_all` | `Integer` | re-enqueues every entry |
| `kill_all(notify_failure: true, ex: nil)` | `Integer` | moves every entry to the dead set |
| `pop_each { \|json, score\| … }` | | `ZPOPMIN` loop until empty — destructive |
| `remove_job(entry)` | `Boolean` | exact-value `ZREM`, falling back to a (score, jid) scan |
| `delete_by_value(name, value)` | `Boolean` | `ZREM` by exact bytes |
| `delete_by_jid(score, jid)` (alias `delete`) | `Boolean` | scans the score bracket, then `ZREM`s |
`fetch` is the cheap lookup — if you kept the score (e.g. from a
`SortedEntry#id`, `"|"`), use it instead of `find_job`:
```ruby
score, jid = entry_id.split("|")
entry = Wurk::RetrySet.new.fetch(score.to_f, jid).first
```
`retry_all` and `kill_all` loop until the set is empty and are **not
transactional** — an exception part-way through leaves the set partly drained.
For large sets, iterate in bounded chunks yourself.
### `Wurk::DeadSet` extras
| Method | Returns | Notes |
|---|---|---|
| `kill(message, opts = {})` | `true` | `ZADD` + trim + death handlers |
| `kill_raw(payload, max_jobs:, timeout:)` | `true` | `ZADD` + trim, **no** death handlers |
| `trim(max_jobs:, timeout:)` | `true` | `ZREMRANGEBYSCORE` (age) + `ZREMRANGEBYRANK` (count) |
| `API_KILL_MESSAGE` | `"Job killed by API"` | the synthesized exception message for kills with no real error |
`kill` takes the raw JSON payload string and an options hash:
| Option | Default | Effect |
|---|---|---|
| `:notify_failure` | `true` | run the configured death handlers |
| `:trim` | `true` | apply the two-axis trim after adding |
| `:ex` | synthesized `RuntimeError` carrying `API_KILL_MESSAGE` | exception handed to death handlers |
| `:max_jobs` / `:timeout` | `dead_max_jobs` / `dead_timeout_in_seconds` config | per-call trim overrides |
Trim runs on every kill, so the morgue stays bounded on both axes without a
separate sweeper.
---
## `Wurk::SortedEntry`
One member of a sorted set. Subclasses `JobRecord`, so every attribute above is
available, plus the score and the set-specific mutations.
| Method | Returns | Notes |
|---|---|---|
| `score` | `Float` | epoch seconds |
| `parent` | `JobSet` | the owning set |
| `id` | `String` | `"\|"` — the wire-compat identifier dashboards use |
| `at` | `Time` | `score` as UTC |
| `error?` | `Boolean` | true when the payload carries an `error_class` |
| `delete` | `Boolean` | removes this entry from its set |
| `reschedule(at)` | `ZINCRBY` result | shifts the score to `at` (sent as a delta, so caller/Redis clock skew doesn't matter) |
| `add_to_queue` | payload `Hash` or `nil` | removes + re-enqueues, payload untouched |
| `retry` | payload `Hash` or `nil` | removes + re-enqueues, **decrementing `retry_count`** so a manual retry doesn't burn an attempt |
| `kill` | payload `Hash` or `nil` | removes + writes to the dead set, firing death handlers |
```ruby
Wurk::RetrySet.new.each do |entry|
entry.kill if entry["error_class"] == "ActiveRecord::RecordNotFound"
end
```
The three mutations return `nil` **without acting** when removal from the
parent set fails — that's how a concurrent retry from the dashboard can't
cause the same job to be pushed twice.
`reschedule` takes the absolute target time:
```ruby
entry = Wurk::ScheduledSet.new.find_job(jid)
entry.reschedule(Time.now + 3600)
```
Note that on entries produced by `each` / `scan` (built from raw JSON),
`#queue` is `nil` — read `entry["queue"]` instead.
---
## `Wurk::ProcessSet` and `Wurk::Process`
Live cluster topology: the `processes` SET plus one heartbeat HASH per
identity.
```ruby
ps = Wurk::ProcessSet.new
ps.total_concurrency # => 50
ps.total_rss_in_kb # => 1_842_912
```
| Method | Returns | Notes |
|---|---|---|
| `new(clean_plz = true)` | `ProcessSet` | runs `cleanup` unless you pass `false` |
| `ProcessSet[identity]` | `Process` or `nil` | `nil` for an unknown **or** lapsed identity |
| `each { \|Process\| … }` | | pipelined `HMGET`, sorted by identity; skips lapsed heartbeats |
| `size` | `Integer` | raw `SCARD` — **not** pruned, may over-count |
| `cleanup` | `Integer` | `SREM`s identities whose heartbeat expired; globally rate-limited to 1/min |
| `total_concurrency` | `Integer` | sum over live processes |
| `total_rss_in_kb` (alias `total_rss`) | `Integer` | sum over live processes |
| `leader` | `String` | `GET dear-leader`; `""` when unset |
`size` counts SET members; heartbeat hashes expire after 60s, so a crashed
process lingers in the count until a `cleanup`. For an accurate number use
`each.count` (or just `count`, since `ProcessSet` is `Enumerable`).
Passing `clean_plz = false` skips the prune — do that on a hot path or when
taking repeated snapshots.
### `Wurk::Process`
| Method | Returns | Notes |
|---|---|---|
| `identity` (alias `id`) | `String` | `::` |
| `[](key)` | any | raw heartbeat/info field (`"busy"`, `"beat"`, `"rss"`, `"rtt_us"`, …) |
| `tag` / `labels` / `version` | `String` / `Array` / `String` | |
| `queues` | `Array` | derived from `capsules`, falling back to the legacy `queues` field |
| `weights` | `Hash` | same fallback; two capsules on one queue name collapse |
| `capsules` | `Hash` or `nil` | |
| `embedded?` | `Boolean` | |
| `stopping?` | `Boolean` | true once the process has accepted a TSTP |
| `leader?` | `Boolean` | compares `identity` against `dear-leader` |
| `quiet!` | | `LPUSH -signals "TSTP"` |
| `stop!` | | `LPUSH -signals "TERM"` |
| `dump_threads` | | `LPUSH -signals "TTIN"` |
The three signal methods are **asynchronous**: they leave a message in a
60-second-TTL list that the target process picks up on its next heartbeat, so
allow up to ~10 seconds for the effect. `quiet!` and `stop!` raise on an
embedded process (there's no separate process to signal).
```ruby
Wurk::ProcessSet.new.each do |process|
process.quiet! if process["rss"].to_i > 2_000_000 && !process.embedded?
end
```
---
## `Wurk::WorkSet` and `Wurk::Work`
What is executing **right now**, across the cluster. Reads one
`:work` HASH per registered process, so it lags reality by up to one
heartbeat (10s).
| Method | Returns | Notes |
|---|---|---|
| `each { \|process_id, thread_id, Work\| … }` | | pipelined `HGETALL`, sorted by `run_at` — oldest in-flight job first |
| `size` | `Integer` | sum of the `busy` field across processes |
| `find_work(jid)` (alias `find_work_by_jid`) | `Work` or `nil` | O(n) — for a human at a console, not for app logic |
`Wurk::Workers` is a deprecated alias of `WorkSet` for gems written against
Sidekiq < 8.
### `Wurk::Work`
| Method | Returns |
|---|---|
| `process_id` / `thread_id` | `String` |
| `queue` | `String` |
| `payload` | `String` — raw JSON being executed |
| `run_at` | `Time` |
| `job` | `JobRecord` — lazily wrapped `payload` |
```ruby
Wurk::WorkSet.new.each do |pid, tid, work|
age = Time.now - work.run_at
warn "#{work.job.display_class} on #{pid}/#{tid} running #{age.round}s" if age > 300
end
```
---
## Fast Lua API
Sidekiq Pro replaces the O(n)-round-trip Ruby loops with server-side Lua.
Wurk ships the same surface free, mixed into `Queue` and `SortedSet` at load
time — there is nothing to require or enable.
| Method | Returns | Notes |
|---|---|---|
| `Queue#delete_job(jid)` | `Integer` | payloads removed; `ArgumentError` on a blank jid |
| `Queue#delete_by_class(klass)` | `Integer` | accepts a `Class`, `String`, or `Symbol`; `ArgumentError` on a blank name |
| `SortedSet#scan(match) { \|SortedEntry\| … }` | | one-argument block form |
| `SortedSet#scan(match) { \|value, score\| … }` | | two-argument block form — the raw pairs |
```ruby
Wurk::Queue.new("default").delete_job(jid) # one round trip
Wurk::Queue.new("default").delete_by_class(MyJob) # one round trip
Wurk::RetrySet.new.scan("NoMethodError") { |entry| entry.delete }
Wurk::DeadSet.new.scan("CustomerJob") { |entry| entry.retry }
```
Which form `scan` yields is decided by **block arity**: a one-parameter block
gets a `SortedEntry` (so you can call `delete` / `retry` / `kill` without
re-parsing JSON); a two-parameter block gets `(value, score)` as the base
`ZSCAN` surface does. With no block, `scan` returns an enumerator over the raw
pairs.
**Prefer the Lua paths on large sets.** `Queue#find_job(jid).delete` walks the
list 50 payloads at a time over the network, parses JSON per candidate, then
issues an `LREM` — on a million-entry queue that's ~20 000 round trips.
`delete_job` is a single `EVALSHA`; the scan happens inside Redis, no payload
crosses the wire. The trade is that the Lua script blocks Redis for the
duration of its `LRANGE` — acceptable for an admin action, not for a loop.
The scripts are `SCRIPT LOAD`ed once per pool and invoked by `EVALSHA`
thereafter. `delete_job` matches on the literal `"jid":""` substring and
`delete_by_class` on `"class":""`, without parsing JSON, so partial
corruption elsewhere in a payload can't break the sweep.
---
## Dashboard search
`Wurk::Web::Search` (also reachable as `Sidekiq::Web::Search`) is the
substring search behind the dashboard's search box, and it has a plain Ruby
entry point. It covers queues **and** the three sorted sets — Sidekiq Pro's
version covers only the sorted sets.
```ruby
search = Wurk::Web::Search.new("NoMethodError", kinds: %w[retry dead], limit: 50)
rows = search.to_a
search.truncated? # => true if a scan bound stopped it early
```
| Method / constant | Returns | Notes |
|---|---|---|
| `new(substring, kinds: KINDS, limit: 100)` | `Search` | unknown kinds are dropped; an empty selection means all kinds |
| `each { \|Hash\| … }` | enumerator without a block | stops at `limit` |
| `to_a` | `Array` | |
| `truncated?` | `Boolean` | meaningful only after the scan has run |
| `substring` / `kinds` / `limit` | | the normalized inputs |
| `KINDS` | `%w[queues retry scheduled dead]` | |
| `DEFAULT_LIMIT` / `MAX_LIMIT` | `100` / `500` | `limit` is clamped into `1..500` |
Each row is a `Hash` with `:kind`, `:name` (queue or set name), `:jid`,
`:klass`, `:args`, `:queue`, `:enqueued_at`, `:created_at`; sorted-set rows
also carry `:score`, `:at`, `:error_class`, `:error_message`, `:retry_count`.
An empty substring yields nothing.
Search is **deliberately bounded** so a keystroke can't full-walk a
multi-million-entry store: at most 5 000 elements per queue and 20 000 per
request across all stores. When a bound stops the scan with elements
unexamined, `truncated?` flips to `true` and the results are partial. If you
need exhaustive matching, iterate the set yourself and accept the cost.
---
## `Wurk::Deploy`
Records a deploy marker so throughput charts can be read against releases.
```ruby
Wurk::Deploy.mark! # label = `git log -1 --format="%h %s"`
Wurk::Deploy.mark!("v2.14.0 hotfix") # explicit label
Wurk::Deploy.new.mark!(label: "v2.14.0", at: Time.now)
```
| Method | Returns | Notes |
|---|---|---|
| `Deploy.mark!(label = nil, at: Time.now, **opts)` | `String` (iso8601) or `nil` | positional label matches Sidekiq; `label:` keyword also accepted, positional wins |
| `#mark!(label: nil, at: Time.now)` | `String` (iso8601) or `nil` | `nil` when the label is blank or the dedupe lock was already held |
| `#fetch(date = Time.now.utc)` | `Hash{String=>String}` | `{iso8601 => label}` for that day |
| `new(pool: nil)` | `Deploy` | `pool:` targets a non-default Redis |
What it writes:
| Key | Type | Contents |
|---|---|---|
| `-marks` | HASH | field = iso8601 timestamp rounded down to the minute, value = label; TTL 90 days |
| `deploylock-