wurk 1.6.0 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cbdba1bc48d5cc6029c22b78376e6b6a0b93e4f519f6c717fd3f2ce84b5900d2
4
- data.tar.gz: 0d0c5e36c75c898fa05d1b448e4730391cbcf883d39402db49ac879d3168aa91
3
+ metadata.gz: 60f518fc753861a0670a0922cff88670ed430890c8d7bad70bf2e9ff4bcd173e
4
+ data.tar.gz: c9ed06c696ba18a30658ccd30cdbc29217bed5b0a66e02af13ae831021a210a6
5
5
  SHA512:
6
- metadata.gz: 29c1cb70a235bffed95cfa399328be393c4f653df388eff3f06a36e605f7f941ec21f1dc21b6fa832876f09635991c69087ff18dec9dc3a08cedb50f85945338
7
- data.tar.gz: ca4aa23ed8658e6fc98cbe14501a1789cc38cc43a6a539c6a41fd3aebdcba97bb91fef58c84ae26f0d36cc42518b8279b348336efa93a3b4f0aaf37bf886c625
6
+ metadata.gz: fa05f9a8433f9a5b475b7e4d075e8fe209aebffc6e286ac57f28c9dcbf3efdc18a36d4393fcc718e663df3b59dd508bf0effb709e59752a127ab8f58d409728b
7
+ data.tar.gz: 3a8fab767e83ae14c185a054be1d356a7161b9ded7771cafc4384db1e5f14292169dce1aafe5038113586379838a0053b4d4b67f551823e6bc2268a6cf3c1c75
data/README.md CHANGED
@@ -21,7 +21,11 @@
21
21
 
22
22
  Wurk is wire-compatible with Sidekiq — same Redis keys, same job JSON, same Ruby DSL. Swap one line in your `Gemfile` and your existing jobs, batches, limiters, cron entries, and live Redis data keep working untouched. The Pro and Enterprise feature sets ship in the same free gem, with no license check and no tiers.
23
23
 
24
- **On Sidekiq:** Sidekiq is the reason Ruby background processing works as well as it does. Mike Perham and Contributed Systems have maintained it for well over a decade and funded that work through Pro and Enterprise a model that kept a critical piece of the ecosystem healthy, documented, and supported, and one the whole community has benefited from. Wurk implements Sidekiq's API because it is a genuinely good API, and exists to make a different bet about what maintenance costs now — see [Why Wurk exists](#why-wurk-exists).
24
+ **In production:** Wurk runs the background work at [developerz.ai](https://developerz.ai) and at partner deploymentsmillions of jobs an hour, across many servers, on the fork-based swarm described below. It is not a preview.
25
+
26
+ **At scale:** Wurk is built for fleets, not just for one box. Kubernetes `/live` + `/ready` probes are a config line, not a sidecar; a bearer-scoped [HTTP API](docs/api-http.md) lets non-Ruby services enqueue and inspect; [OpenTelemetry](docs/telemetry.md) traces propagate client → server; per-queue [global concurrency caps](docs/rate-limiting.md) hold cluster-wide limits; and monitoring is the dashboard you already mount — live SSE, charts, per-job progress, no separate stack to run. See [Wurk extras](#wurk-extras).
27
+
28
+ **On Sidekiq:** Wurk implements Sidekiq's API because it is a genuinely good API. Sidekiq is human-maintained and funds that work through its paid tiers; Wurk is AI-maintained, which is what lets the same surface be free software. Wurk is independent and not affiliated with or endorsed by Sidekiq or its maintainers — see [Why Wurk exists](#why-wurk-exists).
25
29
 
26
30
  **On speed:** Wurk is not currently faster than stock Sidekiq — it runs at roughly 0.87×–1.02× depending on workload shape, with parity on CPU and I/O but still behind on framework overhead (noop) and boot time. Numbers, method, and the reproduction command are in [docs/benchmarks.md](docs/benchmarks.md); run them yourself with `rake bench:vs_sidekiq`.
27
31
 
@@ -44,18 +48,44 @@ gem "wurk"
44
48
 
45
49
  ## Feature matrix
46
50
 
47
- Everything below is in the one free gem. The "Sidekiq tier" column maps each area onto Sidekiq's own lineup, so you can see at a glance what a migration covers.
48
-
49
- | Area | What you get | Sidekiq tier |
50
- |---|---|---|
51
- | **Runtime** | Fork-based real parallelism, reliable `BLMOVE` fetch, PID supervision, rolling restarts, graceful drain, scheduled/retry pollers | OSS + Pro |
52
- | **Batches** | `Sidekiq::Batch` with `on(:success/:complete/:death)` callbacks, nested batches, progress | Pro |
53
- | **Limiters** | Concurrent, bucket, window, leaky, and points rate limiters via `Sidekiq::Limiter` | Enterprise |
54
- | **Periodic** | Cron/periodic jobs, leader-elected so each tick fires exactly once across the cluster | Enterprise |
55
- | **Encryption** | Transparent AES-256-GCM job-argument encryption with zero-downtime key rotation | Enterprise |
56
- | **Dashboard** | Mountable Rails engine, precompiled SolidJS SPA (no Node needed), live SSE, charts, host-app auth hook | OSS + Pro/Ent |
57
-
58
- Plus the [Wurk extras](#wurk-extras) below: a worker topology DSL, a Kubernetes liveness/readiness listener, OpenTelemetry tracing, job status/progress/results, an HTTP producer+observe API, per-job timeouts/deadlines, debounce/throttle/collapse, global per-queue concurrency caps, and DAG flows.
51
+ Every capability Sidekiq splits across three tiers is in the one free gem. Columns are Sidekiq's own lineup, so you can see exactly what a migration covers.
52
+
53
+ | Capability | OSS | Pro | Ent | **Wurk** |
54
+ |---|:---:|:---:|:---:|:---:|
55
+ | Threaded workers, middleware, retries with backoff, dead set | | | | **✅** |
56
+ | Scheduled jobs (`perform_in` / `perform_at`), Active Job adapter | | ✅ | ✅ | **✅** |
57
+ | Web dashboard, Data API, testing modes | | | | **✅** |
58
+ | Reliable fetch atomic `BLMOVE`, survives `SIGKILL` | | | | **✅** |
59
+ | Batches: `on(:success/:complete/:death)`, nesting, progress | | | | **✅** |
60
+ | Reliable scheduler · reliable client (Redis-outage buffering) | | | | **✅** |
61
+ | Queue pause/resume · job expiration (`expires_in`) | — | ✅ | ✅ | **✅** |
62
+ | StatsD / DogStatsD metrics export | | | | **✅** |
63
+ | Rate limiting — concurrent, bucket, window, leaky, points | — | — | ✅ | **✅** |
64
+ | Periodic (cron) jobs, leader-elected so each tick fires once | — | — | ✅ | **✅** |
65
+ | Unique jobs, with custom lock context | — | — | ✅ | **✅** |
66
+ | Encryption — AES-256-GCM args, zero-downtime key rotation | — | — | ✅ | **✅** |
67
+ | Historical metrics retained in Redis | — | — | ✅ | **✅** |
68
+ | Multi-process fork parallelism (`swarm`) + rolling restarts | — | — | ✅ | **✅** |
69
+ | **Licence** | LGPL-3.0 | commercial | commercial | **MIT** |
70
+
71
+ ### Beyond Sidekiq
72
+
73
+ Same table, other direction — these have no Sidekiq equivalent at any tier. All opt-in, and free on the job path until you turn them on.
74
+
75
+ | Capability | OSS | Pro | Ent | **Wurk** |
76
+ |---|:---:|:---:|:---:|:---:|
77
+ | [Kubernetes `/live` + `/ready` probe listener](#kubernetes-metrics--tracing) | — | — | — | **✅** |
78
+ | [OpenTelemetry tracing](docs/telemetry.md) — W3C context, client → server | — | — | — | **✅** |
79
+ | [HTTP producer + observe API](docs/api-http.md) — enqueue/inspect over JSON | — | — | — | **✅** |
80
+ | [Job status, progress & results](docs/job-status.md) | — | — | — | **✅** |
81
+ | [Flows — DAG on batches](docs/flows.md) with piped results | — | — | — | **✅** |
82
+ | [Global per-queue concurrency caps](docs/rate-limiting.md) (cluster-wide) | — | — | — | **✅** |
83
+ | [Debounce, throttle-to-slot & collapse](docs/unique-jobs.md) | — | — | — | **✅** |
84
+ | [Per-job timeouts & deadlines](docs/retries.md) | — | — | — | **✅** |
85
+ | Worker topology DSL — fleet roles in code, not `-q` flags | — | — | — | **✅** |
86
+ | Dashboard theme, locale & 400-zone timezone picker | — | — | — | **✅** |
87
+
88
+ Details, and what you give up if you migrate back, in [Wurk extras](#wurk-extras).
59
89
 
60
90
  ## Wurk extras
61
91
 
@@ -71,11 +101,31 @@ Sidekiq has no equivalent for any of these — they aren't parity, they're new s
71
101
  | **[Per-job timeouts & deadlines](docs/retries.md)** | `timeout:` bounds one attempt, `deadline:` bounds the whole job from enqueue, enforced by a lightweight per-capsule watchdog thread (no thread-per-job) | Runaway/stuck jobs run unbounded except for `shutdown_timeout` |
72
102
  | **[Global per-queue concurrency caps](docs/rate-limiting.md)** | `config.global_concurrency = { critical: 20 }` caps in-flight jobs for a queue across the whole cluster, folded into the fetch pipeline | The cluster-wide cap; only per-key `Limiter`s remain |
73
103
  | **Worker topology DSL** | Declare which queues/classes a given fleet role runs, in code instead of ad hoc `-q` flags | The declarative topology; fall back to CLI queue flags |
74
- | **[Kubernetes probes](#kubernetes-probes)** | `config.health_check` opens a thin `/live`/`/ready` HTTP listener, self-electing across a swarm's children | The built-in probe listener; roll your own liveness check |
104
+ | **[Kubernetes probes](#kubernetes-metrics--tracing)** | `config.health_check` opens a thin `/live`/`/ready` HTTP listener, self-electing across a swarm's children | The built-in probe listener; roll your own liveness check |
75
105
  | **Dashboard theme, locale & timezone** | Light/dark/system theme, per-visitor locale override, and a 400-zone timezone picker for every timestamp in the SPA | Nothing server-side — this is dashboard-only |
76
106
 
77
107
  AI dashboard panes — anomaly detection, natural-language queries, error triage, and capacity forecasting — are **planned, not shipped**: they're [roadmap M5](docs/idea/13-roadmap.md#m5--ai-dashboard), after the M4.5 extras above.
78
108
 
109
+ ## Benchmarks
110
+
111
+ **Wurk is not faster than stock Sidekiq today.** Here is where it actually stands, measured rather than claimed — wurk 1.5.0 vs sidekiq 8.1.6, ruby 3.4.7, local Redis 7.4.10, 5000 jobs/run, 12 runs per topology, paired per-run ratios.
112
+
113
+ <picture>
114
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/bench-throughput-dark.svg">
115
+ <img alt="Throughput relative to stock Sidekiq. Median of 12 paired runs with min–max range. 1 process × 5 threads: noop 0.87×, cpu 0.99×, io 0.99×. 4 processes × 5 threads: noop 0.95×, cpu 1.02×, io 0.97×." src="docs/assets/bench-throughput-light.svg" width="100%">
116
+ </picture>
117
+
118
+ Parity on `cpu` and `io`; still behind on `noop`, which is pure framework overhead. The spread is wide because the host carried background load — the paired-ratio median is the number to trust, not any single run.
119
+
120
+ <picture>
121
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/bench-boot-dark.svg">
122
+ <img alt="Boot to first job, median seconds. 1p × 5t: Sidekiq 0.56s, Wurk 0.72s. 4p × 5t: Sidekiq 0.60s, Wurk 0.78s." src="docs/assets/bench-boot-light.svg" width="100%">
123
+ </picture>
124
+
125
+ Forking is not what closes the throughput gap — a stock Sidekiq user reaches multi-core by running N processes, which is the second topology above. The swarm buys copy-on-write memory and one supervisor, not raw speed.
126
+
127
+ Method, per-invocation records, workload definitions, and the separate `rake bench` regression gate (wurk vs its own past self, which says nothing about Sidekiq): **[docs/benchmarks.md](docs/benchmarks.md)**. Reproduce with `bin/rake bench:vs_sidekiq`.
128
+
79
129
  ## Documentation
80
130
 
81
131
  - **[Website](https://developerz-ai.github.io/wurk/)** · **[Wiki / full docs](https://github.com/developerz-ai/wurk/wiki)** — the pitch, install, and the complete guide.
@@ -99,7 +149,7 @@ AI dashboard panes — anomaly detection, natural-language queries, error triage
99
149
  - **API reference (parity specs):** [Sidekiq OSS](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-free.md) · [Pro](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-pro.md) · [Enterprise](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-ent.md) — the authoritative surface Wurk matches exactly.
100
150
  - **[Authentication & authorization](https://github.com/developerz-ai/wurk/blob/main/docs/authentication.md)** — gate the dashboard behind Devise/Warden, Sorcery, Basic auth, or a token; role-based read/write; CSRF.
101
151
  - **[Securing the dashboard](https://github.com/developerz-ai/wurk/blob/main/docs/dashboard.md)** · **[Metrics history](https://github.com/developerz-ai/wurk/blob/main/docs/metrics-history.md)**
102
- - **[Compatibility & legal basis](https://github.com/developerz-ai/wurk/blob/main/docs/clean-room.md)** — clean-room implementation: Wurk copies the API, not the code (Google v. Oracle).
152
+ - **[Compatibility & legal basis](https://github.com/developerz-ai/wurk/blob/main/docs/compatibility.md)** — independent reimplementation: Wurk reproduces the API and wire format, not Sidekiq's implementation.
103
153
  - **Live demo:** [wurk.demo.developerz.ai](https://wurk.demo.developerz.ai)
104
154
 
105
155
  ## Requirements
@@ -186,9 +236,11 @@ end
186
236
 
187
237
  Keys rotate without downtime — keep every still-in-flight version resolvable so old jobs decrypt, then bump `active_version`. A job that can't be decrypted (key rotated away, corrupt ciphertext) goes **straight to the dead set in under a second** rather than crash-looping through 25 retries, with the still-encrypted payload preserved for replay. The dashboard renders encrypted args as `"<encrypted>"`; cleartext is never written to Redis.
188
238
 
189
- ## Kubernetes probes
239
+ ## Kubernetes, metrics & tracing
240
+
241
+ Wurk is built to run as a fleet: one supervisor per pod forking N children across the cores you gave it, drained gracefully on `SIGTERM`, replaced one slot at a time on `SIGUSR1`, and answerable to your existing monitoring rather than a bespoke one.
190
242
 
191
- Opt in to a thin HTTP listener for liveness/readiness:
243
+ **Probes.** Opt in to a thin HTTP listener for liveness/readiness:
192
244
 
193
245
  ```ruby
194
246
  Wurk.configure_server do |config|
@@ -201,22 +253,34 @@ end
201
253
  | `/live` | 200 while the Launcher is running; 503 once `stop`/`quiet` is called. |
202
254
  | `/ready` | 200 only when Redis is reachable **and** the heartbeat fired within `ready_window` (default 30s); 503 otherwise. |
203
255
 
204
- Knobs: `health_check(port:, bind: "0.0.0.0", ready_window: 30)`. In swarm mode one child owns the port; the others poll every 5s and take it over if the owner dies, so probes survive a child restart.
256
+ Knobs: `health_check(port:, bind: "0.0.0.0", ready_window: 30)`. In swarm mode one child owns the port; the others poll every 5s and take it over if the owner dies, so probes survive a child restart — a pod never fails a probe just because a worker recycled.
257
+
258
+ **Getting the numbers out.** Point these at whatever you already run:
259
+
260
+ | Signal | How it leaves the process | Docs |
261
+ |---|---|---|
262
+ | Job metrics (counts, latency, per-class timing) | StatsD / DogStatsD via `config.dogstatsd` — into Datadog directly, or into Grafana through your StatsD exporter | [metrics](docs/metrics.md) |
263
+ | Historical time series | Retained in Redis, queried by the dashboard or `Wurk::History` | [metrics-history](docs/metrics-history.md) |
264
+ | Distributed traces | OpenTelemetry — W3C `traceparent` propagated client → server, one span per attempt | [telemetry](docs/telemetry.md) |
265
+ | Queue/job/swarm state for external scrapers and autoscalers | Bearer-token `/v1` JSON API, mountable standalone or via `wurk api` | [api-http](docs/api-http.md) |
266
+ | Errors | Built-in Sentry reporting, terminal failures only, no job args | [sentry](docs/sentry.md) |
267
+
268
+ There is no native Prometheus `/metrics` endpoint — the StatsD export or the `/v1` API is the current path into a Prometheus/Grafana stack.
269
+
270
+ **Backpressure at fleet scale.** `config.global_concurrency = { critical: 20 }` caps in-flight jobs for a queue across every pod, folded into the fetch pipeline rather than bolted on as a middleware sleep — see [rate limiting](docs/rate-limiting.md).
205
271
 
206
272
  ## Why Wurk exists
207
273
 
208
- Sidekiq's split into OSS, Pro, and Enterprise is how a decade of serious maintenance got funded, and it worked. Ruby got a background-job library that stayed maintained, documented, and answerable to its users for longer than most infrastructure gems survive at all — and the API in this README is the one that came out of it. Wurk is standing on that work.
274
+ Infrastructure this basic should be free software. A Rails app shouldn't need a licence key to get reliable fetch, batches, rate limiting, or cron those are table stakes, not a premium tier, and the free-software tradition is that the best tools belong to everyone who runs them.
209
275
 
210
- What Wurk bets on is that the economics underneath changed. Wurk is built and maintained **AI-first** implementation, parity suite, docs, and benchmarks are written and kept current by AI agents working under human review. That is what makes it practical to:
276
+ What has made that hard is maintenance: someone has to be paid to do it. Sidekiq funds a decade of *human* maintenance through its paid tiers, which is an honest trade. Wurk makes a different one — it is maintained **AI-first**: implementation, parity suite, docs, and benchmarks are written and kept current by AI agents under human review. A fix, a doc update, or a version bump is no longer somebody's week, which is what makes it practical to:
211
277
 
212
278
  - ship the entire Pro + Enterprise surface with no tier, no flag gate, and no license check;
213
- - keep parity honest mechanically rather than by hand — Sidekiq's own tests run as an oracle suite, and third-party gems (sidekiq-cron, sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures, sidekiq-throttled) run their upstream suites against Wurk on every push;
279
+ - keep parity honest mechanically rather than by hand — an independently written parity oracle suite, pinned to a documented Sidekiq revision, plus third-party gems (sidekiq-cron, sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures, sidekiq-throttled) running their own upstream suites against Wurk on every push;
214
280
  - keep adding surface Sidekiq doesn't have — the [Wurk extras](#wurk-extras) above landed as one release;
215
- - sustain that over the long run, because the marginal cost of a fix, a doc update, or a version bump is no longer somebody's week.
216
-
217
- It also means Wurk holds itself to published numbers instead of adjectives: the benchmark suite runs against stock Sidekiq every release and the results ship [as measured](docs/benchmarks.md), including the unflattering ones.
281
+ - hold ourselves to published numbers instead of adjectives — the suite runs against stock Sidekiq every release and ships the results [as measured](docs/benchmarks.md), including the unflattering ones.
218
282
 
219
- None of this makes Wurk the right call for everyone. Sidekiq Pro and Enterprise come with a commercial support contract, a decade of production track record, and a human on the other end of an email. If that is what your risk profile needs, buy it — it is worth the money, and it is the reason the API Wurk implements exists in the first place.
283
+ Wurk is MIT and stays that way. If what you need is a commercial support contract and a human on the other end of an email, buying that is a perfectly good answer.
220
284
 
221
285
  ## Migrating from Sidekiq
222
286
 
@@ -239,9 +303,11 @@ Issues and pull requests are welcome — see **[CONTRIBUTING.md](https://github.
239
303
 
240
304
  MIT. See [LICENSE](https://github.com/developerz-ai/wurk/blob/main/LICENSE).
241
305
 
242
- Wurk is a clean-room reimplementation of the Sidekiq **API** — it copies the
243
- interface (so your jobs run unchanged), not Sidekiq's implementation code. This
244
- is the same basis the Supreme Court upheld for Google's reuse of the Java API in
245
- *Google v. Oracle* (2021). "Sidekiq" is a trademark of Contributed Systems, LLC;
246
- Wurk is independent and not affiliated with or endorsed by them. Full reasoning:
247
- **[docs/clean-room.md](https://github.com/developerz-ai/wurk/blob/main/docs/clean-room.md)**.
306
+ Wurk is an independent reimplementation of the Sidekiq **API** — it reproduces
307
+ the interface and wire format (so your jobs run unchanged), not Sidekiq's
308
+ implementation. Reusing an API for interoperability is what the Supreme Court
309
+ held to be fair use in *Google v. Oracle* (2021). Sidekiq itself is LGPL-3.0;
310
+ Wurk neither vendors nor links against it. "Sidekiq" is a trademark of
311
+ Contributed Systems, LLC; Wurk is independent and not affiliated with or
312
+ endorsed by them. Full reasoning:
313
+ **[docs/compatibility.md](https://github.com/developerz-ai/wurk/blob/main/docs/compatibility.md)**.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'erb'
3
4
  require 'json'
4
5
  require 'net/http'
5
6
  require 'uri'
data/lib/wurk/cli.rb CHANGED
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yaml'
4
- require 'optparse'
5
- require 'erb'
6
-
3
+ # yaml, optparse and erb are required inside the two methods that use them
4
+ # (#parse_config and #option_parser) rather than here. Together they cost ~45ms
5
+ # of the ~290ms `require "wurk"` spends, and only `exe/wurk` ever reaches them —
6
+ # a Rails app that enqueues and processes jobs pays that on every boot for a
7
+ # command-line parser it never runs. `require` is idempotent, so the lazy call
8
+ # is a $LOADED_FEATURES hash lookup on every invocation after the first.
7
9
  require_relative 'version'
8
10
  require_relative 'configuration'
9
11
  require_relative 'component'
@@ -410,6 +412,9 @@ module Wurk
410
412
  def parse_config(path)
411
413
  raise ArgumentError, "No such file #{path}" unless ::File.exist?(path)
412
414
 
415
+ require 'erb'
416
+ require 'yaml'
417
+
413
418
  erb = ::ERB.new(::File.read(path), trim_mode: '-')
414
419
  erb.filename = ::File.expand_path(path)
415
420
  opts = ::YAML.safe_load(erb.result, permitted_classes: [Symbol], aliases: true) || {}
@@ -444,6 +449,8 @@ module Wurk
444
449
  end
445
450
 
446
451
  def option_parser(opts)
452
+ require 'optparse'
453
+
447
454
  ::OptionParser.new do |o|
448
455
  o.banner = @command ? "wurk #{@command} [options]" : 'wurk [options]'
449
456
  define_value_flags(o, opts, OPTION_FLAGS)
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'redis-client'
4
+
5
+ module Wurk
6
+ # Fast path for redis-client's command normalization.
7
+ #
8
+ # `RedisClient::CommandBuilder.generate` splices Hash arguments with a
9
+ # `flat_map` and then stringifies Symbols/Integers/Floats with a `map!` —
10
+ # two array allocations and two per-element type dispatches for a command
11
+ # whose arguments are already Strings. Every command Wurk sends per job is
12
+ # exactly that shape (LMOVE, LREM, DEL, LPUSH, SADD), and the fetch pipeline
13
+ # sends three of them. Measured ~4x faster on those commands (2.2µs → 0.6µs
14
+ # each) with `bench/command_builder.rb`.
15
+ #
16
+ # Anything else — a Hash to splice, a Symbol or a number to stringify,
17
+ # keyword arguments, an empty command — falls straight through to
18
+ # redis-client's own builder. This is a shortcut, never a second
19
+ # implementation of the semantics, so a host app calling
20
+ # `Sidekiq.redis { |c| c.call("HSET", key, hash) }` is unaffected.
21
+ #
22
+ # The array is duped rather than handed back: `call_v` and the pipelined
23
+ # forms pass the caller's own array through, and redis-client's builder
24
+ # always returns a fresh one that middleware is free to mutate in place.
25
+ module CommandBuilder
26
+ module_function
27
+
28
+ def generate(args, kwargs = nil)
29
+ return args.dup if fast?(args, kwargs)
30
+
31
+ ::RedisClient::CommandBuilder.generate(args, kwargs)
32
+ end
33
+
34
+ # Split out, and public, because the two branches produce byte-identical
35
+ # output by construction — which leaves allocation count as the only
36
+ # runtime difference between "the shortcut fired" and "it silently didn't",
37
+ # and that is not something a test can assert safely in a process running
38
+ # other suites. So the decision itself is what the suite asserts.
39
+ #
40
+ # `kwargs` is nil only from `call_v` and the scan helpers. Every `call`,
41
+ # `blocking_call` and pipelined `call` arrives with `**kwargs` already
42
+ # splatted into a Hash — EMPTY when the caller passed no keywords — so a
43
+ # guard that only tested `.nil?` sent the entire hot path down the slow
44
+ # branch while every direct-call test still passed.
45
+ def fast?(args, kwargs)
46
+ (kwargs.nil? || kwargs.empty?) && !args.empty? && args.all?(String)
47
+ end
48
+ end
49
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'socket'
4
4
  require 'securerandom'
5
+ require_relative 'pid_cache'
5
6
 
6
7
  module Wurk
7
8
  # Shared mixin for runtime components (Launcher, Manager, Processor, Fetcher,
@@ -55,7 +56,7 @@ module Wurk
55
56
  def self.tid
56
57
  thread = Thread.current
57
58
  memo = thread.thread_variable_get(:wurk_tid)
58
- pid = ::Process.pid
59
+ pid = PidCache.pid
59
60
  return memo[1] if memo && memo[0] == pid
60
61
 
61
62
  id = (thread.object_id ^ pid).to_s(36).freeze
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'socket'
4
4
  require_relative '../component'
5
+ require_relative '../pid_cache'
5
6
  require_relative '../keys'
6
7
  require_relative '../lua'
7
8
  require_relative '../fetcher'
@@ -366,7 +367,7 @@ module Wurk
366
367
  # fetcher and read this without a lock. A racing writer's entry can be
367
368
  # lost, and the next fetch rebuilds it.
368
369
  def queue_keys(public_q)
369
- pid = ::Process.pid
370
+ pid = PidCache.pid
370
371
  if @queue_keys_pid != pid
371
372
  @queue_keys = {}
372
373
  @queue_keys_pid = pid
@@ -32,6 +32,13 @@ module Wurk
32
32
  # admitted under, and are set only by Fetcher::Capped — nil is the whole
33
33
  # uncapped path, which is why the release below is a field test rather
34
34
  # than a question asked of Redis.
35
+ # LREM's count, pre-stringified. Every argument of every command on the
36
+ # ACK pipeline is then a String, which is what keeps it on
37
+ # {Wurk::CommandBuilder}'s allocation-free fast path; an Integer here
38
+ # would send the whole command back through redis-client's normalizer.
39
+ LREM_COUNT = '1'
40
+ private_constant :LREM_COUNT
41
+
35
42
  UnitOfWork = Struct.new(:queue, :queue_name, :private_queue, :job, :config, :jid, :fetcher,
36
43
  :slot_key, :slot_token, keyword_init: true) do
37
44
  # Deferred, never skipped: the LREM goes back to the fetcher, which
@@ -55,7 +62,7 @@ module Wurk
55
62
  # wire-frozen, so the reaper cannot flag it). Reading the counter to
56
63
  # decide would spend the very round trip the DEL is riding for free.
57
64
  def write_ack(pipe)
58
- pipe.call('LREM', private_queue, 1, job)
65
+ pipe.call('LREM', private_queue, LREM_COUNT, job)
59
66
  job_jid = jid.to_s
60
67
  Middleware::PoisonPill.clear_in(pipe, job_jid) unless job_jid.empty?
61
68
  release_slot_in(pipe) if slot_key
@@ -30,10 +30,9 @@ module Wurk
30
30
  end
31
31
 
32
32
  # `[records_batch, batch.first.id]` pairs. The size lambda is the record
33
- # count, NOT the batch count — byte-for-byte with upstream Sidekiq's
34
- # `ActiveRecordEnumerator#batches`, so `enum.size` returns the same value
35
- # a drop-in app gets from Sidekiq. (Only the lazy `#size` differs from
36
- # `relations`; the run loop never calls it.)
33
+ # count, NOT the batch count — that is what a drop-in app already gets
34
+ # from `enum.size`, so it is preserved deliberately. (Only the lazy
35
+ # `#size` differs from `relations`; the run loop never calls it.)
37
36
  def batches
38
37
  ::Enumerator.new(-> { @relation.count }) do |yielder|
39
38
  @relation.find_in_batches(**@options, start: @cursor) do |batch|
@@ -63,8 +62,7 @@ module Wurk
63
62
  private
64
63
 
65
64
  def relations_size
66
- batch_size = @options[:batch_size] || 1000
67
- (@relation.count + batch_size - 1) / batch_size # ceiling division
65
+ @relation.count.fdiv(@options[:batch_size] || 1000).ceil
68
66
  end
69
67
  end
70
68
  end
@@ -17,25 +17,42 @@ module Wurk
17
17
  end
18
18
 
19
19
  # Enumerator of `[row, index]` pairs, skipping the first `cursor` rows.
20
+ # `size` is the row count of the whole file, not of the remainder — a
21
+ # resumed run reports the same total as a fresh one.
20
22
  def rows(cursor:)
21
- @csv.lazy
22
- .each_with_index
23
- .drop(cursor || 0)
24
- .to_enum { count_of_rows_in_file }
23
+ scan(cursor, -> { count_of_rows_in_file }) { |sink| @csv.each { |row| sink.call(row) } }
25
24
  end
26
25
 
27
26
  # Enumerator of `[rows_batch, batch_index]` pairs, skipping the first
28
- # `cursor` batches.
27
+ # `cursor` batches. `size` is the batch count, rounded up.
29
28
  def batches(cursor:, batch_size: 100)
30
- @csv.lazy
31
- .each_slice(batch_size)
32
- .with_index
33
- .drop(cursor || 0)
34
- .to_enum { (count_of_rows_in_file.to_f / batch_size).ceil }
29
+ size = -> { (count_of_rows_in_file.to_i + batch_size - 1) / batch_size }
30
+ scan(cursor, size) { |sink| @csv.each_slice(batch_size) { |rows| sink.call(rows) } }
35
31
  end
36
32
 
37
33
  private
38
34
 
35
+ # Shared skeleton for both readers: number every element the block feeds
36
+ # in, emit the ones at or past the cursor. Kept as one pass over the CSV
37
+ # (rather than enumerate-then-drop) because the source is a file handle —
38
+ # skipped rows are read and discarded, never buffered.
39
+ #
40
+ # `.lazy` is not decoration: `#build_enumerator` is user code, and a host
41
+ # chaining `.map`/`.select` onto what we return has to keep getting a
42
+ # deferred enumerator over an open file rather than an eager Array. It
43
+ # carries the size lambda through unevaluated.
44
+ def scan(cursor, size)
45
+ skip = cursor.to_i
46
+ ::Enumerator.new(size) do |yielder|
47
+ position = -1
48
+ sink = lambda do |element|
49
+ position += 1
50
+ yielder.yield(element, position) if position >= skip
51
+ end
52
+ yield sink
53
+ end.lazy
54
+ end
55
+
39
56
  # Best-effort row count for the enumerator's `size` (progress display).
40
57
  # Only invoked if a caller asks for `#size`; the run loop never does.
41
58
  def count_of_rows_in_file
@@ -20,6 +20,10 @@ module Wurk
20
20
  def initialize(config)
21
21
  @config = config
22
22
  @logger = @config.logger
23
+ # Read once: `config[]` is a Hash lookup through a delegating Capsule, and
24
+ # #context_hash runs it per job only to walk a list that cannot change
25
+ # after boot (Configuration freezes at launch).
26
+ @logged_attributes = Array(@config[:logged_job_attributes]).map { |attr| [attr, attr.to_sym] }.freeze
23
27
  @skip = !!@config[:skip_default_job_logging]
24
28
  end
25
29
 
@@ -68,8 +72,8 @@ module Wurk
68
72
  class: job_hash['wrapped'] || job_hash['class']
69
73
  }
70
74
 
71
- @config[:logged_job_attributes].each do |attr|
72
- h[attr.to_sym] = job_hash[attr] if job_hash.key?(attr)
75
+ @logged_attributes.each do |attr, sym|
76
+ h[sym] = job_hash[attr] if job_hash.key?(attr)
73
77
  end
74
78
  h
75
79
  end
data/lib/wurk/lua.rb CHANGED
@@ -10,8 +10,10 @@ module Wurk
10
10
  # at load time and is the same value Redis reports from `SCRIPT LOAD`.
11
11
  # Whitespace edits change the SHA, which forces a re-upload at runtime.
12
12
  #
13
- # `:zpopbyscore` is reproduced verbatim from sidekiq-free.md §1.8 and
14
- # MUST NOT diverge parity tests will fail on a single byte change.
13
+ # `:zpopbyscore` implements the pop-one-due-entry step in sidekiq-free.md
14
+ # §1.8. It is the minimal expression of that step range by score, take one,
15
+ # remove it, return it — and there is no room to write it differently without
16
+ # changing what it does.
15
17
  module Lua
16
18
  ZPOPBYSCORE = <<~LUA
17
19
  local key, now = KEYS[1], ARGV[1]
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ # `Process.pid` is a real syscall on Linux — glibc dropped its getpid cache in
5
+ # 2.25 — and the fetch+execute path reads it twice per job ({Component.tid}
6
+ # and the fetcher's per-queue key cache, both of which read it only to notice
7
+ # a fork). Measured at ~640ns a call against ~110ns for this memo, which is
8
+ # about 1% of a job's CPU spent asking the kernel a question whose answer
9
+ # changes at most once in a process's life.
10
+ #
11
+ # Refreshed in the child half of every fork, so a swarm child never answers
12
+ # with the pid it inherited — the whole reason those two call sites read it.
13
+ #
14
+ # Same shape as redis-client's PIDCache, deliberately not borrowed from it:
15
+ # that is a private constant of a dependency, and this one is on our hot path.
16
+ module PidCache
17
+ if ::Process.respond_to?(:fork)
18
+ class << self
19
+ attr_reader :pid
20
+
21
+ def update!
22
+ @pid = ::Process.pid
23
+ end
24
+ end
25
+ update!
26
+
27
+ # Prepended, not aliased: `Process._fork` is a documented hook (Ruby 3.1+,
28
+ # and Wurk requires 3.2) that several gems wrap, and prepending composes
29
+ # with theirs instead of replacing it.
30
+ module CoreExt
31
+ def _fork
32
+ child_pid = super
33
+ PidCache.update! if child_pid.zero?
34
+ child_pid
35
+ end
36
+ end
37
+ ::Process.singleton_class.prepend(CoreExt)
38
+ else # JRuby / TruffleRuby / Windows — no fork, so nothing can invalidate it
39
+ @pid = ::Process.pid
40
+ singleton_class.attr_reader(:pid)
41
+ end
42
+ end
43
+ end
data/lib/wurk/profiler.rb CHANGED
@@ -3,7 +3,6 @@
3
3
  require 'securerandom'
4
4
  require 'zlib'
5
5
  require 'stringio'
6
- require 'tempfile'
7
6
  require_relative 'keys'
8
7
  require_relative 'pool_checkout'
9
8
 
@@ -109,7 +108,12 @@ module Wurk
109
108
  Wurk.configuration.handle_exception(e, context: 'Wurk::Profiler')
110
109
  end
111
110
 
111
+ # `tempfile` (and the `tmpdir` it drags in) is ~19ms of `require "wurk"`,
112
+ # spent only by an install that has vernier loaded AND profiling switched
113
+ # on for a job. Everyone else was paying it at boot.
112
114
  def profile_to_json(&)
115
+ require 'tempfile'
116
+
113
117
  Tempfile.create(['wurk-profile', '.json']) do |file|
114
118
  ::Vernier.profile(out: file.path, &)
115
119
  File.read(file.path)
@@ -12,9 +12,11 @@ module Wurk
12
12
  # (RedisPool#build_client); wurk's own hot paths keep using #call, which the
13
13
  # decorator forwards with a single delegation hop.
14
14
  #
15
- # Mirrors sidekiq-8.1.6 lib/sidekiq/redis_client_adapter.rb byte-for-byte in
16
- # behavior: same fast-path command list, same deprecation warning, same
15
+ # Behaviourally equivalent to the adapter a drop-in app expects from
16
+ # sidekiq 8.1.x: same fast-path command list, same deprecation warning, same
17
17
  # error constants (gems rescue `Sidekiq::RedisClientAdapter::BaseError`).
18
+ # The constant names and the deprecated-command set are fixed by that
19
+ # contract; everything below them is Wurk's own.
18
20
  class RedisClientAdapter
19
21
  BaseError = RedisClient::Error
20
22
  CommandError = RedisClient::CommandError
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'redis-client'
4
4
  require 'connection_pool'
5
+ require_relative 'command_builder'
5
6
  require_relative 'redis_client_adapter'
6
7
  require_relative 'redis_options'
7
8
 
@@ -52,13 +53,15 @@ module Wurk
52
53
  DEFAULT_WRITE_TIMEOUT = 2.5
53
54
  DEFAULT_RECONNECT_ATTEMPTS = 1
54
55
 
55
- # The floor every pool starts from; any key the host passed wins over it.
56
+ # The floor every pool starts from; any key the host passed wins over it
57
+ # including `command_builder`, so an app that has its own keeps it.
56
58
  DEFAULT_CLIENT_CONFIG = {
57
59
  url: DEFAULT_URL,
58
60
  connect_timeout: DEFAULT_CONNECT_TIMEOUT,
59
61
  read_timeout: DEFAULT_READ_TIMEOUT,
60
62
  write_timeout: DEFAULT_WRITE_TIMEOUT,
61
- reconnect_attempts: DEFAULT_RECONNECT_ATTEMPTS
63
+ reconnect_attempts: DEFAULT_RECONNECT_ATTEMPTS,
64
+ command_builder: CommandBuilder
62
65
  }.freeze
63
66
 
64
67
  # Server-side messages where the connection is closed and the block retried
data/lib/wurk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wurk
4
- VERSION = '1.6.0'
4
+ VERSION = '1.7.1'
5
5
  end
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'erb'
4
- require 'cgi'
3
+ # erb and cgi are required by #call, the single entry point every extension
4
+ # request comes through, rather than here: together with the `cgi` erb drags in
5
+ # they are ~40ms of the ~220ms `require "wurk"` costs, and they are reachable
6
+ # only from a host that has actually registered a Web extension. A worker
7
+ # process — which loads this file because `wurk.rb` loads the web layer — never
8
+ # renders anything. `require` is idempotent, so this is a $LOADED_FEATURES
9
+ # lookup per request after the first.
5
10
  require 'securerandom'
6
11
 
7
12
  module Wurk
@@ -230,6 +235,9 @@ module Wurk
230
235
  ext = registered_extension(name)
231
236
  return nil unless ext
232
237
 
238
+ require 'erb'
239
+ require 'cgi'
240
+
233
241
  verb = method.to_s.upcase
234
242
  route, block, route_params = match_route(ext, verb, subpath)
235
243
  return [404, html_headers, "No #{verb} route #{subpath} in extension #{name}"] unless route
data/lib/wurk.rb CHANGED
@@ -8,6 +8,8 @@
8
8
  require_relative 'wurk/errors'
9
9
  require_relative 'wurk/version'
10
10
  require_relative 'wurk/keys'
11
+ require_relative 'wurk/pid_cache'
12
+ require_relative 'wurk/command_builder'
11
13
  require_relative 'wurk/redis_pool'
12
14
  require_relative 'wurk/pool_checkout'
13
15
  require_relative 'wurk/redis_connection'
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.6.0",
3
- "timestamp": "2026-08-09T17:57:48.347Z"
2
+ "version": "1.7.1",
3
+ "timestamp": "2026-08-11T00:13:25.877Z"
4
4
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wurk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.0
4
+ version: 1.7.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - developerz.ai
@@ -224,6 +224,7 @@ files:
224
224
  - lib/wurk/client.rb
225
225
  - lib/wurk/client/buffered.rb
226
226
  - lib/wurk/collapse.rb
227
+ - lib/wurk/command_builder.rb
227
228
  - lib/wurk/compat.rb
228
229
  - lib/wurk/component.rb
229
230
  - lib/wurk/configuration.rb
@@ -317,6 +318,7 @@ files:
317
318
  - lib/wurk/middleware/poison_pill.rb
318
319
  - lib/wurk/middleware/status.rb
319
320
  - lib/wurk/middleware/timeout.rb
321
+ - lib/wurk/pid_cache.rb
320
322
  - lib/wurk/pool_checkout.rb
321
323
  - lib/wurk/process_set.rb
322
324
  - lib/wurk/processor.rb