# 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-