Skip to content

wurk v1.8.0

Choose a tag to compare

@github-actions github-actions released this 05 Oct 09:09
2917967

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.call enforce authorization and 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 redirect can no longer leave the host (//evil.com, /\evil.com, absolute off-site and javascript: 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 EXISTS first) 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, /ready needs half the children (configurable min_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 raising ArgumentError (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's JobRecord#display_args uses (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_page and the page actually served; filtered listings also return filtered_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 buckets j|YYMMDD|H:M (3d) and h|<klass>-D-H:M runtime histograms; the <klass>-YYYYMMDD-H hash is no longer written. History written by Sidekiq before a swap now shows up, and Sidekiq reads what Wurk writes. <klass>|p counts failed executions too and |ms covers 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: token is the job's profile value, type the job class, elapsed float seconds, 1-day expiry, sid left for the Web UI's profile-store id.
  • JobSet#kill_all defaults to notify_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#trim and Process#signal run in MULTI like upstream.
  • The two-argument scan { |value, score| } form is gone: SortedSet#scan always yields SortedEntry (a two-parameter block gets (entry, nil), as upstream).

Added

  • :redis_idle_timeout is honoured and config.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 /metrics on 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 (via JobRecord#display_args); Redis and perform are untouched.
  • rake wurk:import:cron imports sidekiq-cron schedules as native periodic loops (dry run by default, APPLY=1 writes; sidekiq-cron keys are never touched; same-named entries in different namespaces stay separate loops, labelled <namespace>/<name>).
  • Redis topology CI (topology workflow): 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:) returning Result/JobResult/MarkResult per the Sidekiq API.
  • Sidekiq::JobSet / Sidekiq::SortedSet aliases (sidekiq-unique-jobs releases its locks again), Sidekiq.loader, and 💣 on Queue/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#jobs is atomic (Pro §2.3). Pushes are buffered and flushed at block exit; a block that raises enqueues nothing, and a batch cannot fire while its jobs block is open. autoflush = N still flushes every N jobs; use autoflush = 1 for push-per-job.
  • A child batch's :complete/:success callback jobs run inside the parent batch, so the parent waits for them (the §2.9 step workflow now works). Their payloads carry the parent's bid.
  • 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 require config/environment no longer fork one (they used to fetch jobs and cut them off at exit). Other servers (Falcon, Thin, Pitchfork, a custom rackup) opt in with WURK_EMBED=1. WURK_DISABLED=1 still disables boot.
  • A Rails web process that runs Wurk workers also runs Sidekiq.configure_client blocks, so web-side enqueues get client middleware and settings.
  • CI runs entirely on free GitHub-hosted runners; a push to main whose tree already passed on its PR skips the duplicate run; the demo auto-deploys on every push to main that changes the image.
  • Concurrent suite runs on one Redis can isolate themselves with WURK_TEST_DB_OFFSET=<k>.
  • bin/check probes the Redis at REDIS_URL, not a pinned 127.0.0.1:6379. The guard now checks the server the suite actually connects to (REDIS_URL, which test_helper and RedisPool already 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-ecosystem uses 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 IDLETIME is 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_oldest overflow logs one ERROR per drop burst and counts each drop in statsd jobs.dropped.push (was silent).
  • The reaper WARNs once per unreadable queue:*|* key (e.g. Sidekiq Pro super_fetch lists it cannot reclaim) and once when OBJECT IDLETIME is unavailable; the hourly full sweep uses SCAN COUNT 1000.
  • JobRecord#enqueued_at / #created_at / #failed_at / #retried_at always return UTC times, whatever timestamp format the payload holds.
  • A bucket limiter on a short interval (:second) sleeps until the real boundary using Redis TIME microseconds, instead of a whole second that could overshoot by ~1s and eat wait_timeout.
  • Data API parity: JobRecord#queue falls back to the payload's queue; invalid-JSON payloads read as {} with the raw bytes as args; created_at falls back to enqueued_at; latency falls back to created_at; Integer-ms timestamps convert without float rounding; display_class honours a display_class field; ActiveJob display_args unwraps GlobalIDs and strips _aj_* keys, and MailDeliveryJob shows [params, args].
  • Client#push raises ArgumentError for a class without sidekiq_options, as Sidekiq does.
  • An encryption key resolver that raises KeyError/IndexError for 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_streams overrides 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, .middlewares exist; web extensions support halt, 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 truncated is accurate at the limit and duplicate ZSCAN hits are dropped; the host middleware chain is cached per mount.
  • RetrySet/ScheduledSet/DeadSet#each and Queue#each no longer skip entries when deleting during iteration.
  • SortedSet#scan returns SortedEntry in its Enumerator form too, and a pattern that already contains * is passed through.
  • Sidekiq::Testing fake 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 profileToken is used and cached).
  • Queue latency falls back to created_at and reads 0 for a payload with no timestamp (was "since 1970"); the default retain_history emitter includes per-queue sidekiq.queue.size/sidekiq.queue.latency gauges; the statsd worker: tag uses the wrapped class.
  • Batches: invalidate_all no longer strands a batch (cancelled jobs count as successes, callbacks fire); remove_jobs is atomic and fires when it removes the last job; Status#delete detaches 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 as Sidekiq::Batch::Empty again.
  • 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 registered paused: true can 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_handlers again. Honeybadger, Rollbar, Bugsnag, Airbrake and custom handlers were never told about job failures (Sentry's middleware was); each handler now gets every failure once, with context: "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_in returning an ActiveSupport::Duration or 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.redis inside 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#with no 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.via shards).
  • UnitOfWork#requeue removes 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_at accepts an absolute epoch and DateTime like Sidekiq.
  • The REST API keeps an Idempotency-Key pending 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. The sidekiq shim gem — which any kept add-on needs, or its add_dependency "sidekiq" reinstalls real Sidekiq next to Wurk — is now documented up front in the guide, the README and llms.txt rather than only in docs/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 (also Sidekiq::Capsule::MODES) and Wurk::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/**/*.rb file (track_files), including the require "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.