Repository navigation
wurk v1.8.0
The squeaky-clean audit release: a production-grade pass for teams moving a high-volume Sidekiq Pro/Ent deployment onto Wurk. It fixes every verified bug from nine audit passes (core runtime, Pro/Ent, dashboard, CI), grows the independent parity oracles from 3 files to 14, adds the operational surface a big deployment needs (Prometheus metrics, child liveness, cgroup-aware sizing, topology CI, a cutover playbook and an incident runbook), and removes dead code. Several Wurk-only behaviours changed to match Sidekiq exactly — read Changed before upgrading; intentional divergences are recorded in docs/idea/parity-divergences.md.
Security
mount Sidekiq::Web/run Sidekiq::Web/Sidekiq::Web.callenforceauthorizationand read-only mode like the engine mount (and Sidekiq Enterprise). Extension routes on that mount were served without authorization to anyone who could reach it.- Extension
redirectcan no longer leave the host (//evil.com,/\evil.com, absolute off-site andjavascript:targets fall back to the extension root). GET /profiles/:key(Firefox profiler upload) refuses cross-site requests.- Encrypted jobs no longer leak their decrypted argument back into Redis. When an encrypted (
encrypt: true) IterableJob was interrupted, or the limiter dead-routed a rate-limited encrypted job, the re-pushed payload carried the plaintext secret into the queue/dead set. The server middleware now restores the envelope once the chain unwinds, and every re-push site seals a copy.
Changed
- Swarm children upload only the Lua scripts Redis is missing (
SCRIPT EXISTSfirst) instead of ~70KB of source per boot (~14% faster child boot measured locally). - wurk requires
connection_pool >= 3.0, the same as Sidekiq 8.1. - The demo image goes to DOCR only (GHCR mirror removed).
- Many-queue fetch takes two Redis round trips instead of one per queue (100 queues: 100 → 2; ~13× faster to reach a job on queue #100); single-queue and busy-first-queue fetch are unchanged.
- The default swarm size follows the container's cgroup CPU quota (v2
cpu.max, v1 CFS) capped at the host core count, and is logged at boot. - In a swarm,
/readyneeds half the children (configurablemin_ready) with fresh heartbeats, not just the port owner. - Docs no longer promise a mixed Sidekiq + Wurk fleet or an undrained rollback: drain cutover is the supported path until a mixed fleet is proven.
- Limiter
ttl:values under 24h are raised to 24h instead of raisingArgumentError(Sidekiq Enterprise accepts any ttl; the 24h floor is kept). - Poison-pill threshold matches Sidekiq Pro: an orphaned job is requeued on its first three recoveries and dead-set on the fourth (was the third).
- Encrypted args are masked as
"[encrypted data]"in the data API and dashboard, the string Sidekiq'sJobRecord#display_argsuses (was"<encrypted>"). Queue#delete_job(jid)returns the deleted job's JSON, or nil when nothing matched, as Sidekiq Pro does (was an Integer count, where 0 is truthy).Queue#pause!/#unpause!return true only when the call changed state, as in Sidekiq Pro.- Dashboard listings return
max_pageand the page actually served; filtered listings also returnfiltered_total/filtered_total_exact. - Metrics and profiles use Sidekiq 8.1's wire format. Minute buckets are
j|YYMMDD|H:MM(8h), plus 10-minute bucketsj|YYMMDD|H:M(3d) andh|<klass>-D-H:Mruntime histograms; the<klass>-YYYYMMDD-Hhash is no longer written. History written by Sidekiq before a swap now shows up, and Sidekiq reads what Wurk writes.<klass>|pcounts failed executions too and|mscovers only executions that did not fail; ActiveJob jobs are recorded under the wrapped class. Per-class history from earlier Wurk versions (4-digit-year keys) is not shown and expires within 3 days. Profiles:tokenis the job'sprofilevalue,typethe job class,elapsedfloat seconds, 1-day expiry,sidleft for the Web UI's profile-store id. JobSet#kill_alldefaults tonotify_failure: false(Sidekiq 8, spec §19.5) and trims the dead set once; dashboard "Kill All" no longer fires death handlers per job.Queue#clear,DeadSet#trimandProcess#signalrun in MULTI like upstream.- The two-argument
scan { |value, score| }form is gone:SortedSet#scanalways yieldsSortedEntry(a two-parameter block gets(entry, nil), as upstream).
Added
:redis_idle_timeoutis honoured andconfig.reap_idle_redis_connections(seconds = 60)sets it: pooled Redis connections unused that long are closed and re-dialed on the next checkout, in every process (off by default, no thread when nil); a bad value fails at configuration time in the parent. (#537)- Demo error reporting to Sentry behind
WURK_DEMO_REPORT_ERRORS=1(#502). - Prometheus
GET /metricson the health listener (queue size/latency, busy, processed/failed totals, retry/dead/scheduled sizes, process count, per-child RSS, build info); no new Redis keys. - The swarm parent replaces a child whose heartbeat stalls (
swarm_heartbeat_timeout, default 60s): TERM, then KILL, then respawn. config.redact_args— redacts job args in the job logger, error-handler fallback logs, dashboard/API JSON and search (viaJobRecord#display_args); Redis andperformare untouched.rake wurk:import:cronimports sidekiq-cron schedules as native periodic loops (dry run by default,APPLY=1writes; sidekiq-cron keys are never touched; same-named entries in different namespaces stay separate loops, labelled<namespace>/<name>).- Redis topology CI (
topologyworkflow): parity + core integration on Valkey 8, and integration tests on TLS, an ACL user, and a Sentinel failover while a fetcher is blocked in BLMOVE (no job lost or duplicated). - Docs: production cutover + rollback playbook (
docs/migrate-from-sidekiq.md§9), incident runbook (docs/runbook.md), supported Redis backends matrix (docs/deployment.md). Sidekiq::Metrics::Query.new(now:)returningResult/JobResult/MarkResultper the Sidekiq API.Sidekiq::JobSet/Sidekiq::SortedSetaliases (sidekiq-unique-jobs releases its locks again),Sidekiq.loader, and💣onQueue/SortedSet.- Drop-in require paths:
sidekiq-pro,sidekiq-ent,sidekiq-ent/web,sidekiq-ent/periodic/testing,sidekiq/pro/web,sidekiq/middleware/i18n,sidekiq/middleware/current_attributes,sidekiq/testing/inline,sidekiq/test_api,sidekiq/metrics/query,sidekiq/profiler, and every other upstream 8.1 file whose constants Wurk provides — a test requires each in a process without the sidekiq gem. Batch#jobsis atomic (Pro §2.3). Pushes are buffered and flushed at block exit; a block that raises enqueues nothing, and a batch cannot fire while itsjobsblock is open.autoflush = Nstill flushes every N jobs; useautoflush = 1for push-per-job.- A child batch's
:complete/:successcallback jobs run inside the parent batch, so the parent waits for them (the §2.9 step workflow now works). Their payloads carry the parent'sbid. - The Rails railtie boots workers only inside a web server.
rails server, Puma, Passenger or Unicorn boot the swarm;rails runner,rails generate, rake tasks and scripts that requireconfig/environmentno longer fork one (they used to fetch jobs and cut them off at exit). Other servers (Falcon, Thin, Pitchfork, a custom rackup) opt in withWURK_EMBED=1.WURK_DISABLED=1still disables boot. - A Rails web process that runs Wurk workers also runs
Sidekiq.configure_clientblocks, so web-side enqueues get client middleware and settings. - CI runs entirely on free GitHub-hosted runners; a push to
mainwhose tree already passed on its PR skips the duplicate run; the demo auto-deploys on every push tomainthat changes the image. - Concurrent suite runs on one Redis can isolate themselves with
WURK_TEST_DB_OFFSET=<k>. bin/checkprobes the Redis atREDIS_URL, not a pinned127.0.0.1:6379. The guard now checks the server the suite actually connects to (REDIS_URL, whichtest_helperandRedisPoolalready read), and falls back to the suite's own default,redis://localhost:6379/0, when it is unset, so nothing changes for a local run.bin/test-ecosystemuses DB 15 of the same server. A developerz.ai box can now run this gate against a Redis on a private port, which leaves 6379 free for another repo's own store on the same box.
Fixed
- The reaper keeps its boot grace when
OBJECT IDLETIMEis unavailable (LFU eviction policy, Dragonfly) by timing it itself across sweeps, so a worker that claims before its first heartbeat is never reclaimed mid-job. - WARN at worker boot when the ActiveRecord pool is smaller than job concurrency.
- Outage-buffer
:drop_oldestoverflow logs one ERROR per drop burst and counts each drop in statsdjobs.dropped.push(was silent). - The reaper WARNs once per unreadable
queue:*|*key (e.g. Sidekiq Pro super_fetch lists it cannot reclaim) and once whenOBJECT IDLETIMEis unavailable; the hourly full sweep usesSCAN COUNT 1000. JobRecord#enqueued_at/#created_at/#failed_at/#retried_atalways return UTC times, whatever timestamp format the payload holds.- A bucket limiter on a short interval (
:second) sleeps until the real boundary using RedisTIMEmicroseconds, instead of a whole second that could overshoot by ~1s and eatwait_timeout. - Data API parity:
JobRecord#queuefalls back to the payload'squeue; invalid-JSON payloads read as{}with the raw bytes asargs;created_atfalls back toenqueued_at;latencyfalls back tocreated_at; Integer-ms timestamps convert without float rounding;display_classhonours adisplay_classfield; ActiveJobdisplay_argsunwraps GlobalIDs and strips_aj_*keys, andMailDeliveryJobshows[params, args]. Client#pushraisesArgumentErrorfor aclasswithoutsidekiq_options, as Sidekiq does.- An encryption key resolver that raises
KeyError/IndexErrorfor a rotated-away version now dead-sets the job like a resolver returning nil, instead of retrying it 25 times. - Dashboard SSE streams can no longer hang the host app. The per-process cap is half the server's request threads (Puma
max_threads/RAILS_MAX_THREADS, at least 1) instead of a fixed 10, which sat above Rails' default of 3 Puma threads;config.web.max_streamsoverrides it (0 = off). Streams recycle every 30s and the SPA reconnects with backoff after a 503. - The health-check server can't be stalled by a slow or idle client: each request head must arrive within 1s and 8KB, handled off the accept thread (up to 16 at once).
- Dashboard ids containing a dot (limiters, cron loops, flows, batches, queue and job deep links) no longer 404.
- Dashboard: a failing API response shows the page's error state instead of crashing the SPA; typing in the Retries/Scheduled/Dead filter keeps focus; pagination stops at the server's limit; Retry/Kill/Delete All are disabled while a filter is active and the match count is shown; partly failed bulk actions report applied/failed and refresh; Sidekiq URLs (
/morgue,/queues/:name,/retries/:key,/scheduled/:key,/metrics/:klass) open the matching page and unknown paths show Not Found; Cmd/Ctrl/middle-click works inside extension views; accessibility labels are translated in all 8 locales. Sidekiq::Web.locales,.views,.middlewaresexist; web extensions supporthalt,json,reload_page,redirect_to,header,render(:erb, …)and Rack-triple returns with their own Content-Type;redirect "#{root_path}…"no longer doubles the extension prefix.- Search
truncatedis accurate at the limit and duplicate ZSCAN hits are dropped; the host middleware chain is cached per mount. RetrySet/ScheduledSet/DeadSet#eachandQueue#eachno longer skip entries when deleting during iteration.SortedSet#scanreturnsSortedEntryin its Enumerator form too, and a pattern that already contains*is passed through.Sidekiq::Testingfake and inline modes round-trip jobs through JSON, so tests see string keys like production.- Opening a profile in the Firefox profiler works (the JWT's
profileTokenis used and cached). - Queue latency falls back to
created_atand reads 0 for a payload with no timestamp (was "since 1970"); the defaultretain_historyemitter includes per-queuesidekiq.queue.size/sidekiq.queue.latencygauges; the statsdworker:tag uses the wrapped class. - Batches:
invalidate_allno longer strands a batch (cancelled jobs count as successes, callbacks fire);remove_jobsis atomic and fires when it removes the last job;Status#deletedetaches the batch from its parent and tag indexes;tags:<tag>indexes expire; parent callbacks fire exactly once when siblings finish together; a Redis error while firing callbacks no longer retries a job that succeeded; a failed callback enqueue is reported and fires on the next attempt instead of being marked fired; the death handler's batch ack is retried; late-registered callbacks no longer corrupt stored callback options (17-digit integers,[]vs{},/); the empty-batch marker is enqueued asSidekiq::Batch::Emptyagain. - Periodic jobs honour the worker's
sidekiq_options(queue,retry,unique_for,encrypt,expires_in), on ticks and on the dashboard's "Enqueue Now"; a loop registeredpaused: truecan be unpaused; missed runs are no longer backfilled after an outage (Ent §2.6); one failing loop no longer stops the loops after it;args: { ... }is passed as one argument. - Bucket limiter counters expire after two intervals instead of 90 days.
- Flow nodes declared with
at:are scheduled instead of enqueued immediately. - Every standalone Lua script passes its keys through
KEYS(throttle, flows, limiter sweep), so they work on Redis Cluster and Dragonfly; a test now fails on any script that builds a key inside Lua. - Read-only dashboards and read-only REST mounts no longer write when listing limiters.
- Job exceptions reach
config.error_handlersagain. Honeybadger, Rollbar, Bugsnag, Airbrake and custom handlers were never told about job failures (Sentry's middleware was); each handler now gets every failure once, withcontext: "Job raised exception"and the job hash, like Sidekiq. - A Redis blip no longer kills the scheduler thread for good. Retries and scheduled jobs stopped being promoted in that process after any outage that spanned a poll tick. Every long-lived thread loop (scheduler, heartbeat, leader, metrics flush/rollups/history, reaper, buffer drainer, swarm supervisor) now survives a Redis error, with a test each.
- The reaper can no longer re-run a live worker's job. A worker writes its first heartbeat before fetching, and the reaper re-checks the owner's heartbeat and requires the private list to be idle for a grace period (30s) right before reclaiming it.
- One undecodable retry/scheduled member no longer wedges promotion cluster-wide under
reliable_scheduler!; it goes to the dead set and the rest promote. sidekiq_retry_inreturning anActiveSupport::Durationor String is honoured instead of silently falling back to the default backoff.- ActiveJob classes' own
sidekiq_options(e.g.retry: false) win over the wrapper defaults, as in Sidekiq. - Sentinel configs work:
name:reaches redis-client as the master name and the default URL is kept. Sidekiq.redisinside a job uses that job's capsule pool, not the default one.- A job is never stranded in a live process's private list when the retry layer or job logger raises; it is requeued before the thread exits.
RedisPool#withno longer replays a non-idempotent block when a nested checkout of another pool times out.- Outage-buffered jobs replay into the Redis they were pushed to (
Client.viashards). UnitOfWork#requeueremoves the private copy, so a requeued job cannot be reclaimed and run twice later.- Retry/scheduled entries are never lost when re-pushing them fails, and stock-Sidekiq payloads with Wurk-only keys are no longer rejected on retry or promotion.
- Dashboard "Retry all" / "Kill all" finish the set instead of 500ing on one bad entry or spinning on jobs that fail again at once.
- Rolling restart no longer loses a slot when both the old and the replacement child die; respawned children no longer inherit the parent's Redis or database sockets; a TSTP during child boot quiets instead of suspending; TTIN dumps threads instead of stopping the process; a TERM during boot leaves no leader campaign or stale process entry; hard shutdown kills processors even when the requeue fails.
- Memory-based recycling and heartbeat RSS read the real kernel page size (16KB/64KB-page arm64 hosts).
perform_ataccepts an absolute epoch andDateTimelike Sidekiq.- The REST API keeps an
Idempotency-Keypending when a write's outcome is unknown, so a client retry cannot double-enqueue. - Limiter Sentinel configs pass
name:as the master name. - The leader lock is refreshed atomically and released only after its loop has stopped.
- The migration guide no longer says untested add-on gems "work unchanged". Only sidekiq-cron has its upstream suite run against Wurk on every PR; sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures and sidekiq-throttled are now marked untested, with their blockers tracked in
docs/idea/14-ecosystem-compat.md. Thesidekiqshim gem — which any kept add-on needs, or itsadd_dependency "sidekiq"reinstalls real Sidekiq next to Wurk — is now documented up front in the guide, the README and llms.txt rather than only indocs/sentry.md. - The README, site and llms.txt drop "millions of jobs an hour". No production throughput numbers are published to back it; the measured numbers are in
docs/benchmarks.md.
Removed
- Default implementations on the internal
Wurk::Limiter::Base(every shipped limiter type defines its own). Wurk::Capsule::MODES(alsoSidekiq::Capsule::MODES) andWurk::Metrics::Histogram::LABELS: unused constants with no Sidekiq counterpart; dead dashboard components/styles and dummy-app stubs.
Tests
- The parity oracle suite grew from 3 files to 14 (≈240 runs): client push wire format, reliable fetch with real SIGKILLed workers, the Data API, error handlers, batches, periodic jobs, metrics query, Ent limiters, unique jobs, encryption and Pro expiration — each written from the spec and upstream source before reading Wurk's code.
- Coverage counts every
lib/**/*.rbfile (track_files), including therequire "sidekiq/…"shims probed in subprocesses; a thread-leak guard fails any test that leaks a Wurk thread; timing-sensitive tests assert measured progress instead of fixed sleeps.