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 +4 -4
- data/README.md +97 -31
- data/app/controllers/wurk/dashboard_controller.rb +1 -0
- data/lib/wurk/cli.rb +11 -4
- data/lib/wurk/command_builder.rb +49 -0
- data/lib/wurk/component.rb +2 -1
- data/lib/wurk/fetcher/reliable.rb +2 -1
- data/lib/wurk/fetcher/unit_of_work.rb +8 -1
- data/lib/wurk/iterable_job/active_record_enumerator.rb +4 -6
- data/lib/wurk/iterable_job/csv_enumerator.rb +27 -10
- data/lib/wurk/job_logger.rb +6 -2
- data/lib/wurk/lua.rb +4 -2
- data/lib/wurk/pid_cache.rb +43 -0
- data/lib/wurk/profiler.rb +5 -1
- data/lib/wurk/redis_client_adapter.rb +4 -2
- data/lib/wurk/redis_pool.rb +5 -2
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/extension.rb +10 -2
- data/lib/wurk.rb +2 -0
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60f518fc753861a0670a0922cff88670ed430890c8d7bad70bf2e9ff4bcd173e
|
|
4
|
+
data.tar.gz: c9ed06c696ba18a30658ccd30cdbc29217bed5b0a66e02af13ae831021a210a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
**
|
|
24
|
+
**In production:** Wurk runs the background work at [developerz.ai](https://developerz.ai) and at partner deployments — millions 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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
|
50
|
-
|
|
51
|
-
|
|
|
52
|
-
|
|
|
53
|
-
|
|
|
54
|
-
|
|
|
55
|
-
|
|
|
56
|
-
|
|
|
57
|
-
|
|
58
|
-
|
|
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-
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
|
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 —
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
243
|
-
interface (so your jobs run unchanged), not Sidekiq's
|
|
244
|
-
|
|
245
|
-
*Google v. Oracle* (2021).
|
|
246
|
-
Wurk
|
|
247
|
-
|
|
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)**.
|
data/lib/wurk/cli.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
require
|
|
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
|
data/lib/wurk/component.rb
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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,
|
|
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 —
|
|
34
|
-
# `
|
|
35
|
-
#
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
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
|
data/lib/wurk/job_logger.rb
CHANGED
|
@@ -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
|
-
@
|
|
72
|
-
h[
|
|
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`
|
|
14
|
-
#
|
|
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
|
-
#
|
|
16
|
-
#
|
|
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
|
data/lib/wurk/redis_pool.rb
CHANGED
|
@@ -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
data/lib/wurk/web/extension.rb
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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'
|
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.
|
|
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
|