wurk 1.8.1 → 1.10.0
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 +15 -13
- data/app/controllers/wurk/api_controller.rb +53 -11
- data/config/routes.rb +20 -12
- data/lib/active_job/queue_adapters/wurk_adapter.rb +8 -2
- data/lib/sidekiq/rails.rb +5 -0
- data/lib/wurk/api/read_only.rb +1 -1
- data/lib/wurk/batch/server_middleware.rb +27 -0
- data/lib/wurk/boot_checks.rb +87 -0
- data/lib/wurk/capsule.rb +84 -4
- data/lib/wurk/cli.rb +77 -10
- data/lib/wurk/client/buffered.rb +240 -26
- data/lib/wurk/client.rb +16 -11
- data/lib/wurk/compat.rb +23 -2
- data/lib/wurk/configuration.rb +193 -16
- data/lib/wurk/cron.rb +45 -0
- data/lib/wurk/debounce.rb +2 -2
- data/lib/wurk/encryption.rb +47 -8
- data/lib/wurk/error_coalescer.rb +35 -0
- data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
- data/lib/wurk/fetcher/capped.rb +4 -0
- data/lib/wurk/fetcher/many_queues.rb +17 -1
- data/lib/wurk/fetcher/oom_drain.rb +102 -0
- data/lib/wurk/fetcher/orphan_grace.rb +60 -44
- data/lib/wurk/fetcher/private_list_key.rb +17 -15
- data/lib/wurk/fetcher/reaper.rb +195 -129
- data/lib/wurk/fetcher/reliable.rb +26 -4
- data/lib/wurk/fetcher/self_reconcile.rb +175 -0
- data/lib/wurk/fetcher/unit_of_work.rb +32 -8
- data/lib/wurk/fetcher/unparseable_keys.rb +22 -7
- data/lib/wurk/fetcher.rb +6 -1
- data/lib/wurk/health.rb +26 -3
- data/lib/wurk/health_probe.rb +64 -0
- data/lib/wurk/heartbeat.rb +55 -3
- data/lib/wurk/job/options.rb +9 -1
- data/lib/wurk/job.rb +2 -24
- data/lib/wurk/job_logger.rb +1 -1
- data/lib/wurk/job_record.rb +17 -9
- data/lib/wurk/job_retry.rb +38 -10
- data/lib/wurk/job_set.rb +13 -5
- data/lib/wurk/job_util.rb +4 -1
- data/lib/wurk/launcher.rb +138 -55
- data/lib/wurk/leader.rb +9 -3
- data/lib/wurk/limiter/base.rb +17 -1
- data/lib/wurk/lua/debounce.lua +11 -6
- data/lib/wurk/lua/loader.rb +13 -1
- data/lib/wurk/lua.rb +72 -22
- data/lib/wurk/manager.rb +1 -1
- data/lib/wurk/metrics/dashboard_series.rb +21 -2
- data/lib/wurk/metrics/history.rb +13 -5
- data/lib/wurk/metrics/process_counters.rb +27 -0
- data/lib/wurk/metrics/prometheus.rb +35 -14
- data/lib/wurk/metrics/query.rb +9 -2
- data/lib/wurk/metrics/statsd.rb +8 -0
- data/lib/wurk/middleware/chain.rb +85 -13
- data/lib/wurk/middleware/current_attributes.rb +26 -10
- data/lib/wurk/middleware/poison_pill.rb +1 -2
- data/lib/wurk/pool_checkout.rb +1 -1
- data/lib/wurk/process_set.rb +3 -1
- data/lib/wurk/processor.rb +82 -18
- data/lib/wurk/queue_consumers.rb +134 -0
- data/lib/wurk/queue_slot.rb +19 -9
- data/lib/wurk/rails_boot.rb +44 -5
- data/lib/wurk/rails_reloader.rb +51 -0
- data/lib/wurk/railtie.rb +27 -4
- data/lib/wurk/redact.rb +12 -0
- data/lib/wurk/redis_connection.rb +4 -2
- data/lib/wurk/redis_pool.rb +34 -9
- data/lib/wurk/scheduled.rb +142 -67
- data/lib/wurk/sentry/death_handler.rb +29 -0
- data/lib/wurk/sentry/error_handler.rb +52 -10
- data/lib/wurk/sentry/middleware.rb +8 -1
- data/lib/wurk/sentry/retry_policy.rb +12 -3
- data/lib/wurk/sentry.rb +42 -5
- data/lib/wurk/sorted_entry.rb +38 -13
- data/lib/wurk/swarm/child_boot.rb +42 -31
- data/lib/wurk/swarm/crash_watch.rb +65 -0
- data/lib/wurk/swarm.rb +79 -11
- data/lib/wurk/testing.rb +66 -0
- data/lib/wurk/unique.rb +11 -8
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +42 -9
- data/lib/wurk/web/enterprise.rb +4 -3
- data/lib/wurk/web/extension.rb +80 -40
- data/lib/wurk/work_set.rb +6 -0
- data/lib/wurk/worker/setter.rb +11 -6
- data/lib/wurk/worker.rb +17 -1
- data/lib/wurk.rb +6 -3
- data/vendor/assets/dashboard/assets/{ArgsValue-_MYkd_vA.js → ArgsValue-CmaXArv8.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-DPmg-hle.js → BatchDetail-nc5yePZW.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-NTgqSlvD.js → Batches-DgEcyOZ_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-2rxcLJMq.js → Busy-CUZncvCy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-BFpJgKYb.js → Cron-CIZbaaZs.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dashboard-C8VOE2S7.js → Dashboard-BWmfAook.js} +1 -1
- data/vendor/assets/dashboard/assets/{Dead-Dse2YEbZ.js → Dead-CGKsTZHq.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-Doy4bJoA.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-C4bO26_d.js +2 -0
- data/vendor/assets/dashboard/assets/{FlowDetail-BWddOtgf.js → FlowDetail-Dh3SJdFh.js} +1 -1
- data/vendor/assets/dashboard/assets/{FlowState-6lGsojxE.js → FlowState-yb7virqR.js} +1 -1
- data/vendor/assets/dashboard/assets/{Flows-BgKTlYwI.js → Flows-C5SMRuhq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-BrjmO98c.js → JobDetailModal-Cg6sAsJB.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-DeUkfbcg.js → Limiters-BIxwDOJM.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-Qp9q0nF8.js +1 -0
- data/vendor/assets/dashboard/assets/{NotFound-CQLLXIfa.js → NotFound-DwjPxFjU.js} +1 -1
- data/vendor/assets/dashboard/assets/{PageHeader-CeSaqysY.js → PageHeader-vWeBbSPF.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-CI2i2tl8.js → Profiles-7dabYVe3.js} +1 -1
- data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
- data/vendor/assets/dashboard/assets/{Retries-Dc2RFuTf.js → Retries-CNX8ZtTH.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-B8zOKCLC.js → Scheduled-ClVBj0nc.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-x0eS-V-U.js → Search-BAsVE2qw.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-VggSMFGc.js → charts-DZs138BV.js} +1 -1
- data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
- data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
- data/vendor/assets/dashboard/assets/index-CMzPFxNE.js +141 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BsYWeStI.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-BWWfpnZH.js → useSort-jBYVwXvg.js} +1 -1
- data/vendor/assets/dashboard/assets/{utils-DnzJXljj.js → utils-B0kQFNE8.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +41 -44
- data/vendor/assets/dashboard/assets/Extension-BIc2c1zG.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-BfZm8ZzH.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-DzmAgBFG.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-DUCQ_3St.js +0 -1
- data/vendor/assets/dashboard/assets/i18n-B6AbnmJn.js +0 -1
- data/vendor/assets/dashboard/assets/index-CTWV8VL4.js +0 -141
- data/vendor/assets/dashboard/assets/index-WpZjU076.css +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ce85d34d0d8e1f5aa0837c76310a94c2ca3e584ff0e9ca4d9e977c0347510f89
|
|
4
|
+
data.tar.gz: 113f78f30121c728a4d7f3db5dc6d1472f13ab1b6b7b50d6ee73223d681585f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca00027cf185252fd95e78c3283a90d4a0c21acbc3cd6623bc1a4a1e2add8b6625d8436f60fc80b934a2fb35325d013903791dcad3606605b580b7633958fa9f
|
|
7
|
+
data.tar.gz: bcae6990d734afd7dbc30e951aead9b7d35b77d060c1198a11d4d34d17ba866e6e81ba1ab168a417b115c1a6a690e8cc4c9926951fec58615d7c2e40a9619fda
|
data/README.md
CHANGED
|
@@ -24,13 +24,13 @@ Wurk is wire-compatible with Sidekiq — same Redis keys, same job JSON, same Ru
|
|
|
24
24
|
|
|
25
25
|
**In production:** Wurk runs the background work at [developerz.ai](https://developerz.ai) and at partner deployments, across many servers, on the fork-based swarm described below. It is not a preview. Throughput numbers from those deployments are not published yet; what has been measured is in [docs/benchmarks.md](docs/benchmarks.md).
|
|
26
26
|
|
|
27
|
-
**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,
|
|
27
|
+
**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, no separate stack to run. See [Wurk extras](#wurk-extras).
|
|
28
28
|
|
|
29
|
-
**AI-managed:** Wurk is a 100% AI-managed project.
|
|
29
|
+
**AI-managed:** Wurk is a 100% AI-managed project. Every author's issues and pull requests, org members' included, and dependency updates, are triaged, reviewed, merged or closed by the [developerz.ai](https://developerz.ai) maintainer agent, which asks a human only for security reports and license questions, and cuts a release when a shipped fix has waited for one. New features are still built by us, humans and AI together — see [CONTRIBUTING.md](CONTRIBUTING.md) and [AI-maintained](#ai-maintained). The agent always identifies itself as a bot: it works publicly as `developerz-ai[bot]` and marks every comment it posts as bot-made. Its work and its rules are public too — browse the [repository](https://github.com/developerz-ai/wurk) and read the committed policy in [.dz/maintainer/maintainer.yml](.dz/maintainer/maintainer.yml).
|
|
30
30
|
|
|
31
31
|
**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).
|
|
32
32
|
|
|
33
|
-
**On speed:**
|
|
33
|
+
**On speed:** The latest published paired measurement (2026-08-07) put Wurk at roughly 0.87×–1.02× of stock Sidekiq depending on workload shape: parity on CPU and I/O, behind on framework overhead (noop) and boot time. This is dated evidence, not a throughput guarantee for today's gem. Numbers, method, and the reproduction command are in [docs/benchmarks.md](docs/benchmarks.md); run them yourself with `rake bench:vs_sidekiq`.
|
|
34
34
|
|
|
35
35
|
**Also from us:** [Ultimate](https://github.com/developerz-ai/ultimate) — the AI-first full-stack framework, for a weekend pet project or the system a company runs on. What a whole stack looks like when the one writing the code is an agent, not a human: [`bunx create-ultimate myapp`](#also-from-developerzai-ultimate).
|
|
36
36
|
|
|
@@ -98,7 +98,7 @@ Sidekiq has no equivalent for any of these — they aren't parity, they're new s
|
|
|
98
98
|
|
|
99
99
|
| Extra | What it does | Give up if you migrate back to Sidekiq |
|
|
100
100
|
|---|---|---|
|
|
101
|
-
| **[Job status, progress & results](docs/job-status.md)** | Opt-in `sidekiq_options track: true` persists a `status:<jid>` row — state, coalesced progress writes, the return value (size-capped, withheld under encryption) | `Wurk::Status` reads/writes and the
|
|
101
|
+
| **[Job status, progress & results](docs/job-status.md)** | Opt-in `sidekiq_options track: true` persists a `status:<jid>` row — state, coalesced progress writes, the return value (size-capped, withheld under encryption) | `Wurk::Status` reads/writes and the `/v1` API's job-status reader (`GET /jobs/:jid`) |
|
|
102
102
|
| **[HTTP producer + observe API](docs/api-http.md)** | A bearer-token-scoped `/v1` JSON API — enqueue, bulk-enqueue, inspect queues/jobs/swarm — mountable standalone, nested in the engine, or via the `wurk api` CLI | The whole `/v1` surface; non-Ruby producers lose their enqueue/inspect path |
|
|
103
103
|
| **[OpenTelemetry tracing](docs/telemetry.md)** | W3C `traceparent`/`tracestate` propagated client → server, one span per attempt, linked (not force-parented) across long delays | Distributed traces across your job graph |
|
|
104
104
|
| **[Flows — DAG-on-batches](docs/flows.md)** | `Wurk::Flow` chains and fans batches out/in with dependency edges, piped results between nodes, cycle/depth/width limits | The DAG builder, `pipe:` result-passing, `Flow.abandon` |
|
|
@@ -113,7 +113,7 @@ AI dashboard panes — anomaly detection, natural-language queries, error triage
|
|
|
113
113
|
|
|
114
114
|
## Benchmarks
|
|
115
115
|
|
|
116
|
-
**
|
|
116
|
+
**The latest published comparison does not show Wurk ahead of stock Sidekiq.** Measured 2026-08-07 — wurk 1.4.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. These are historical results, not a fresh measurement of today's gem.
|
|
117
117
|
|
|
118
118
|
<picture>
|
|
119
119
|
<source media="(prefers-color-scheme: dark)" srcset="docs/assets/bench-throughput-dark.svg">
|
|
@@ -134,8 +134,8 @@ Method, per-invocation records, workload definitions, and the separate `rake ben
|
|
|
134
134
|
## Documentation
|
|
135
135
|
|
|
136
136
|
- **[Website](https://developerz-ai.github.io/wurk/)** · **[Wiki](https://github.com/developerz-ai/wurk/wiki)** — the pitch, install, and short orientation guides; the complete reference is [`docs/`](https://github.com/developerz-ai/wurk/tree/main/docs), linked from every wiki page.
|
|
137
|
-
- **[API reference (YARD)](https://developerz-ai.github.io/wurk/api/)** — generated
|
|
138
|
-
- **[Getting started & architecture](https://github.com/developerz-ai/wurk/blob/main/docs/idea/
|
|
137
|
+
- **[API reference (YARD)](https://developerz-ai.github.io/wurk/api/)** — generated from all of `lib/` (per `.yardopts`), so it documents internal classes (`Wurk::Manager`, `Wurk::Fetcher`, `Wurk::Processor`, `Wurk::Swarm`, the `Wurk::API::*` modules, …) as well as public ones; appearing in it does not make something supported public API. The supported public surface is defined by the parity specs ([`docs/target/sidekiq-free.md`](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)). Machine-readable map for AI agents: **[llms.txt](https://developerz-ai.github.io/wurk/llms.txt)**.
|
|
138
|
+
- **[Getting started & architecture](https://github.com/developerz-ai/wurk/blob/main/docs/idea/02-architecture.md)** — how the swarm, manager, fetcher, and processor fit together.
|
|
139
139
|
- **[Starting the worker](https://github.com/developerz-ai/wurk/blob/main/docs/running.md)** — Rails auto-start, the `wurk`/`wurkswarm` runners, and running standalone without Rails.
|
|
140
140
|
- **[Configuration reference](https://github.com/developerz-ai/wurk/blob/main/docs/configuration.md)** — every option, env var, YAML key, and CLI flag, with precedence and pool sizing.
|
|
141
141
|
- **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
|
|
@@ -165,6 +165,8 @@ Method, per-invocation records, workload definitions, and the separate `rake ben
|
|
|
165
165
|
| Ruby | `>= 3.2.0` |
|
|
166
166
|
| Redis | `>= 7.0.0` |
|
|
167
167
|
|
|
168
|
+
The full test suite also runs as a non-required `compat` workflow job across Ruby 3.2.x and the newest Ruby on the current Rails series, the oldest supported Rails series on the newest Ruby, and Valkey 8 as a drop-in store alongside Redis 7.x.
|
|
169
|
+
|
|
168
170
|
JRuby, TruffleRuby, and Windows fall back to threads-only mode (no fork) — behaviorally equivalent to stock Sidekiq.
|
|
169
171
|
|
|
170
172
|
## Running the workers
|
|
@@ -279,16 +281,16 @@ There is no native Prometheus `/metrics` endpoint — the StatsD export or the `
|
|
|
279
281
|
|
|
280
282
|
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.
|
|
281
283
|
|
|
282
|
-
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 **by AI**:
|
|
284
|
+
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 **by AI**: every author's issues and pull requests, the team's own included, and dependency updates, are handled end to end by a maintainer agent, and the implementation, parity suite, docs, and benchmarks are written and kept current by AI agents working with the team, which still builds the features and cuts the releases. A fix, a doc update, or a version bump is no longer somebody's week, which is what makes it practical to:
|
|
283
285
|
|
|
284
286
|
- ship the entire Pro + Enterprise surface with no tier, no flag gate, and no license check;
|
|
285
287
|
- keep parity honest mechanically rather than by hand — an independently written parity oracle suite, pinned to a documented Sidekiq revision, plus sidekiq-cron's upstream suite running against Wurk on every push ([docs/idea/14-ecosystem-compat.md](https://github.com/developerz-ai/wurk/blob/main/docs/idea/14-ecosystem-compat.md) tracks the rest);
|
|
286
288
|
- keep adding surface Sidekiq doesn't have — the [Wurk extras](#wurk-extras) above landed as one release;
|
|
287
|
-
- hold ourselves to published numbers instead of adjectives — the
|
|
289
|
+
- hold ourselves to published numbers instead of adjectives — the measured comparison against stock Sidekiq is published [as measured](docs/benchmarks.md), including the unflattering ones, and `rake bench:vs_sidekiq` reproduces it. It is a published measurement, not a per-release CI run.
|
|
288
290
|
|
|
289
291
|
DHH makes the broader version of this argument in [Let the agents democratize open source](https://world.hey.com/dhh/let-the-agents-democratize-open-source-9fd630a9): open source fought for everyone's right to change the software they run, and refusing agent-written code re-erects the gate it spent decades tearing down — "all programmers are equal, but some programmers are more equal than others." His subject is contributions; ours is maintenance, which is the same economics from the other end. The reason a licence key guards batches and cron is not that the code is precious, it's that somebody had to be paid to keep it working. Drop that cost and the tier stops being necessary.
|
|
290
292
|
|
|
291
|
-
What makes it work in practice is that the agents run inside machinery built to check them. The
|
|
293
|
+
What makes it work in practice is that the agents run inside machinery built to check them. The parity oracle above is a merge-blocking gate, not decoration; sidekiq-cron's upstream suite runs against Wurk in CI and reports on PRs, but it is not a required check and never gates a merge; the `rake bench` PR regression check flags deltas past a noise-adjusted threshold as an advisory comment; and the published benchmark numbers are one-off historical measurements, reproduced on demand rather than enforced in CI. The release gate has never once let an unverified gem reach RubyGems. Agents supply the pace, the gates supply the certainty — and when a gate does catch something, the fix is to make that class of mistake structurally impossible rather than to slow the agents down. The release lane derives its own tag from `Wurk::VERSION` so the two can't drift apart; [RELEASE.md](https://github.com/developerz-ai/wurk/blob/main/RELEASE.md) walks through it.
|
|
292
294
|
|
|
293
295
|
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.
|
|
294
296
|
|
|
@@ -321,16 +323,16 @@ TypeScript end to end, MIT, and as measured about its own claims as this file is
|
|
|
321
323
|
|
|
322
324
|
## AI-maintained
|
|
323
325
|
|
|
324
|
-
**Wurk is maintained by AI**: by [developerz.ai](https://developerz.ai), the way a human maintainer looks after an open-source project. The developerz-ai org comes back with new ideas once or twice a year. In between, the project is still looked after:
|
|
326
|
+
**Wurk is fully maintained by AI**: by [developerz.ai](https://developerz.ai), the way a human maintainer looks after an open-source project, for every author, org members included (owner ruling, 2026-10-23). The developerz-ai org comes back with new ideas once or twice a year. In between, the project is still looked after:
|
|
325
327
|
|
|
326
328
|
| Who opens it | What happens |
|
|
327
329
|
|---|---|
|
|
328
330
|
| you, an issue | triaged; asked for a reproduction when one is missing; a real bug is fixed and released |
|
|
329
331
|
| you, a pull request | reviewed (CodeRabbit and the developerz.ai reviewer), driven to green, merged |
|
|
330
332
|
| Dependabot | driven to green and merged |
|
|
331
|
-
| an issue or pull request by anyone with write access (org members, collaborators) |
|
|
333
|
+
| an issue or pull request by anyone with write access (org members, collaborators) | handled like anyone's: triaged, reviewed, driven to green, merged |
|
|
332
334
|
| an outside change to CI, release or publish automation | declined: members maintain the pipeline that publishes the gem |
|
|
333
|
-
| a breaking change |
|
|
335
|
+
| a breaking change | the bot's call, not a human's; a change that breaks the drop-in Sidekiq contract is declined, since a revert cannot take that promise back |
|
|
334
336
|
|
|
335
337
|
Releases follow the work, not a calendar. A weekday checkup asks whether a shipping fix has merged and nobody has released it. When one has, the bot files a release request, its coding fleet opens the `Wurk::VERSION` bump PR, and merging that PR publishes the gem ([RELEASE.md](RELEASE.md)). The bot's policy is committed in [`.dz/maintainer/maintainer.yml`](.dz/maintainer/maintainer.yml).
|
|
336
338
|
|
|
@@ -62,7 +62,10 @@ module Wurk
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def queues
|
|
65
|
-
|
|
65
|
+
consumers = ::Wurk.redis(idempotent: true) { |conn| ::Wurk::QueueConsumers.counts(conn) }
|
|
66
|
+
render json: ::Wurk::Stats.new.queue_summaries.map { |q|
|
|
67
|
+
::Wurk::Api::Serializers.queue_summary(q).merge(consumers: consumers.fetch(q.name, 0))
|
|
68
|
+
}
|
|
66
69
|
end
|
|
67
70
|
|
|
68
71
|
def queue
|
|
@@ -238,11 +241,23 @@ module Wurk
|
|
|
238
241
|
render json: { lid: params[:lid].to_s, history: ::Wurk::Web::Enterprise::Periodic.history(params[:lid].to_s) }
|
|
239
242
|
end
|
|
240
243
|
|
|
244
|
+
# `?minutes=` (default 60) is served raw — the Query layer routes ≤480 to
|
|
245
|
+
# per-minute buckets and >480 onto the 10-minute buckets, raising
|
|
246
|
+
# WindowTooWide (400 below) past the 30-day ceiling; a non-positive window
|
|
247
|
+
# raises ArgumentError there and 400s the same way. `?hours=` is clamped
|
|
248
|
+
# to MAX_HOURS (default 24 when present) and always takes the 10-minute
|
|
249
|
+
# path. The response echoes the REQUESTED window so the SPA's label
|
|
250
|
+
# matches the served period.
|
|
241
251
|
def metrics
|
|
242
|
-
minutes =
|
|
243
|
-
rows = ::Wurk::Web::Enterprise::Historical.top(minutes: minutes, class_filter: params[:substr])
|
|
244
|
-
|
|
245
|
-
|
|
252
|
+
minutes, hours = metrics_window_raw(params)
|
|
253
|
+
rows = ::Wurk::Web::Enterprise::Historical.top(minutes: minutes, hours: hours, class_filter: params[:substr])
|
|
254
|
+
payload = { minutes: hours ? hours * 60 : minutes,
|
|
255
|
+
top_jobs: rows.map { |(klass, totals)| ::Wurk::Api::Serializers.metric_row(klass, totals) } }
|
|
256
|
+
# hours is echoed only when the request used it, so a minutes request
|
|
257
|
+
# keeps the two-key shape (minutes + top_jobs) it has always had.
|
|
258
|
+
payload[:hours] = hours if hours
|
|
259
|
+
render json: payload
|
|
260
|
+
rescue ::ArgumentError, ::Wurk::Metrics::Query::WindowTooWide => e
|
|
246
261
|
render json: { error: e.message }, status: :bad_request
|
|
247
262
|
end
|
|
248
263
|
|
|
@@ -387,8 +402,10 @@ module Wurk
|
|
|
387
402
|
|
|
388
403
|
keys = Array(params[:keys]).map(&:to_s).uniq
|
|
389
404
|
# Each key costs a ZRANGEBYSCORE plus per-entry mutations; an uncapped
|
|
390
|
-
# list lets one POST issue tens of thousands of round-trips. The UI
|
|
391
|
-
#
|
|
405
|
+
# list lets one POST issue tens of thousands of round-trips. The UI's
|
|
406
|
+
# per-row selections accumulate across pages (toggleAll works against
|
|
407
|
+
# the visible page's keys only) and the bulk POST sends the whole Set,
|
|
408
|
+
# so this cap — not the page size — is the bound.
|
|
392
409
|
return render(json: { error: 'too many keys (max 1000)' }, status: :bad_request) if keys.size > 1000
|
|
393
410
|
|
|
394
411
|
entries = entries_for(set, keys)
|
|
@@ -515,12 +532,37 @@ module Wurk
|
|
|
515
532
|
Integer(match[1]) * HISTORY_WINDOW_UNITS.fetch(match[2].empty? ? 's' : match[2])
|
|
516
533
|
end
|
|
517
534
|
|
|
535
|
+
# `?hours=` wins over `?minutes=`; minutes > MAX_MINUTES (480) promote onto
|
|
536
|
+
# the 10-minute buckets in the Query layer, so the clamp ceiling is the
|
|
537
|
+
# 30-day equivalent (MAX_HOURS * 60 minutes), not 480. Absent both, the
|
|
538
|
+
# window defaults to 60 minutes.
|
|
518
539
|
def metrics_window(params)
|
|
519
540
|
pagination = ::Wurk::Api::Pagination
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
541
|
+
max_hours = ::Wurk::Metrics::Query::MAX_HOURS
|
|
542
|
+
if params[:hours]
|
|
543
|
+
[nil, pagination.clamp_int(params[:hours], 1, max_hours, 24)]
|
|
544
|
+
else
|
|
545
|
+
[pagination.clamp_int(params[:minutes], 1, max_hours * 60, 60), nil]
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
# #metrics variant: `?minutes=` goes through raw so a window past the
|
|
550
|
+
# 30-day ceiling (MAX_HOURS * 60) reaches the Query layer and raises
|
|
551
|
+
# WindowTooWide — the endpoint answers 400 rather than silently serving
|
|
552
|
+
# a shorter window than the SPA's label claims. Unparseable values fall
|
|
553
|
+
# back to the 60-minute default; `?hours=` is clamped as above.
|
|
554
|
+
def metrics_window_raw(params)
|
|
555
|
+
pagination = ::Wurk::Api::Pagination
|
|
556
|
+
max_hours = ::Wurk::Metrics::Query::MAX_HOURS
|
|
557
|
+
if params[:hours]
|
|
558
|
+
[nil, pagination.clamp_int(params[:hours], 1, max_hours, 24)]
|
|
559
|
+
elsif params[:minutes]
|
|
560
|
+
[Integer(params[:minutes], 10), nil]
|
|
561
|
+
else
|
|
562
|
+
[60, nil]
|
|
563
|
+
end
|
|
564
|
+
rescue ::ArgumentError, ::TypeError
|
|
565
|
+
[60, nil]
|
|
524
566
|
end
|
|
525
567
|
end
|
|
526
568
|
end
|
data/config/routes.rb
CHANGED
|
@@ -16,7 +16,7 @@ Wurk::Engine.routes.draw do
|
|
|
16
16
|
post 'queues/:name/pause', to: 'api#pause_queue', as: :api_pause_queue, constraints: { name: %r{[^/]+} }
|
|
17
17
|
post 'queues/:name/unpause', to: 'api#unpause_queue', as: :api_unpause_queue, constraints: { name: %r{[^/]+} }
|
|
18
18
|
|
|
19
|
-
# Job-set mutations (retry/delete/kill/
|
|
19
|
+
# Job-set mutations (retry/delete/kill/add_to_queue/clear). The SPA posts to
|
|
20
20
|
# these; the Authorization middleware 403s every non-GET in read-only mode,
|
|
21
21
|
# so they need no extra guard. `:key` is "<score>|<jid>" (Wurk::SortedEntry#id);
|
|
22
22
|
# `all/:cmd` operates over the whole set; the bare collection POST is a bulk
|
|
@@ -50,10 +50,10 @@ Wurk::Engine.routes.draw do
|
|
|
50
50
|
post 'dead/:key', to: 'api#dead_job', as: :api_dead_job, constraints: { key: %r{[^/]+} }
|
|
51
51
|
get 'processes', to: 'api#processes'
|
|
52
52
|
get 'workers', to: 'api#workers'
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
53
|
+
# Wurk uses split POST /api/busy/quiet and /api/busy/stop requests, not free
|
|
54
|
+
# spec §25.4 POST /busy with identity/quiet/stop params. Request-shape parity
|
|
55
|
+
# is unsupported; see docs/idea/parity-divergences.md#busy-control-http-request-shape-parity-is-unsupported.
|
|
56
|
+
# Read-only mode 403s these via the Authorization middleware.
|
|
57
57
|
post 'busy/quiet', to: 'api#quiet_process', as: :api_quiet_process
|
|
58
58
|
post 'busy/stop', to: 'api#stop_process', as: :api_stop_process
|
|
59
59
|
# Flows (Wurk::Flow) read the way the batches below them do — a paged index
|
|
@@ -96,13 +96,21 @@ Wurk::Engine.routes.draw do
|
|
|
96
96
|
# version reaches the app and comes back as the same `unsupported_api_version`
|
|
97
97
|
# problem document the other two mounts answer with.
|
|
98
98
|
#
|
|
99
|
-
# Nested here it also inherits
|
|
100
|
-
# `Wurk::Web.use` chain
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
105
|
-
#
|
|
99
|
+
# Nested here it also inherits both engine middlewares — the host's
|
|
100
|
+
# `Wurk::Web.use` chain, then `Wurk::Web::Authorization`
|
|
101
|
+
# (lib/wurk/engine.rb) — so a dashboard behind Devise keeps gating this path
|
|
102
|
+
# too. The `Wurk::Web.configure` authorization hook (403 on falsey,
|
|
103
|
+
# sidekiq-ent §9.2) and the read-only gate both live in Authorization, and
|
|
104
|
+
# the hook runs before the machine-API handoff (lib/wurk/web/config.rb):
|
|
105
|
+
# the host hook gates `/v1` paths too, and a valid machine bearer token does
|
|
106
|
+
# NOT bypass it — bearer auth lives only inside `Wurk::API::App#handle`
|
|
107
|
+
# (lib/wurk/api/app.rb), downstream of both middlewares, so a session-bound
|
|
108
|
+
# hook (`env['warden']&.user`) 403s before the token is ever consulted. A
|
|
109
|
+
# machine client that cannot pass a browser login wants mode 2 instead:
|
|
110
|
+
# `mount Wurk::API => '/wurk-api'` in the host's own routes, declared BEFORE
|
|
111
|
+
# the engine when its path extends the engine's — Rails matches a mounted app
|
|
112
|
+
# by bare string prefix, so `mount Wurk::Engine => '/wurk'` declared first
|
|
113
|
+
# swallows every `/wurk-api/...` request.
|
|
106
114
|
mount Wurk::API => Wurk::API::ENGINE_MOUNT, constraints: ->(request) { Wurk::API.serves?(request.path_info) }
|
|
107
115
|
|
|
108
116
|
# Profiles (v8.0+) — not under /api: `:key/data` streams the gzipped gecko
|
|
@@ -32,8 +32,14 @@ begin
|
|
|
32
32
|
|
|
33
33
|
callback = -> { @@stopping = true } # rubocop:disable Style/ClassVars
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
Wurk.
|
|
35
|
+
# Registered on the configuration directly, not through
|
|
36
|
+
# Wurk.configure_client / Wurk.configure_server: one of the two gates
|
|
37
|
+
# always passes (configure_client while server? is false,
|
|
38
|
+
# configure_server once it is true), so the hook registered either way.
|
|
39
|
+
# Going through a gate would spend the one-time skip warning on Wurk's
|
|
40
|
+
# own internal call and log a spurious WARN — the warning the host's
|
|
41
|
+
# own skipped initializer needs.
|
|
42
|
+
Wurk.configuration.on(:quiet, &callback)
|
|
37
43
|
|
|
38
44
|
# Defer enqueue until the surrounding DB transaction commits so we
|
|
39
45
|
# never push a job whose row hasn't landed. Matches Sidekiq.
|
data/lib/sidekiq/rails.rb
CHANGED
|
@@ -6,5 +6,10 @@
|
|
|
6
6
|
# alone, no ActionDispatch — so this maps to the railtie, NOT the dashboard
|
|
7
7
|
# engine (a wurk-native extra; `require "wurk/rails"` for that). Sidekiq::Web
|
|
8
8
|
# stays mountable as a plain Rack app either way, exactly like upstream.
|
|
9
|
+
# A first require without ActiveJob preloaded needs ActiveSupport's core
|
|
10
|
+
# extensions before rails/railtie. Requiring rails for that bootstrap would
|
|
11
|
+
# also load ActionDispatch, breaking the railtie-only integration boundary.
|
|
9
12
|
require 'wurk'
|
|
13
|
+
require 'active_support'
|
|
14
|
+
require 'active_support/core_ext'
|
|
10
15
|
require 'wurk/railtie'
|
data/lib/wurk/api/read_only.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Wurk
|
|
|
52
52
|
Problem.render(
|
|
53
53
|
Problem::READ_ONLY,
|
|
54
54
|
status: 403,
|
|
55
|
-
detail:
|
|
55
|
+
detail: "This Wurk deployment is read-only; it answers #{SAFE_METHODS.join(', ')} only.",
|
|
56
56
|
instance: request.path
|
|
57
57
|
)
|
|
58
58
|
end
|
|
@@ -41,6 +41,29 @@ module Wurk
|
|
|
41
41
|
|
|
42
42
|
CALLBACK_JOB = 'Wurk::Batch::CallbackJob'
|
|
43
43
|
|
|
44
|
+
# The middleware this one must stay OUTSIDE of — the ordering anchor
|
|
45
|
+
# `Chain#add` consults before appending.
|
|
46
|
+
#
|
|
47
|
+
# Expiry SKIPS an expired job by returning without yielding: inside the
|
|
48
|
+
# batch onion the batch's `ack_success` never runs, the batch's pending
|
|
49
|
+
# never drops and the batch never completes. The Limiter turns an
|
|
50
|
+
# over-limit job into a `Limiter::Rescheduled` (a JobRetry::Skip): inside
|
|
51
|
+
# the batch onion the batch's generic `rescue StandardError` books a
|
|
52
|
+
# spurious failure for a job that never ran.
|
|
53
|
+
#
|
|
54
|
+
# A plain `add` appends, so re-registering this middleware (a
|
|
55
|
+
# `configure_server` block, a test helper) would move it inside both and
|
|
56
|
+
# break the batch's accounting. The anchor keeps it outside.
|
|
57
|
+
#
|
|
58
|
+
# Resolved at call time, not at load time: Expiry is loaded after this
|
|
59
|
+
# file (lib/wurk.rb), so naming the constant here would raise NameError.
|
|
60
|
+
def self.outer_of
|
|
61
|
+
anchors = []
|
|
62
|
+
anchors << Wurk::Middleware::Expiry if defined?(Wurk::Middleware::Expiry)
|
|
63
|
+
anchors << Wurk::Limiter::ServerMiddleware if defined?(Wurk::Limiter::ServerMiddleware)
|
|
64
|
+
anchors
|
|
65
|
+
end
|
|
66
|
+
|
|
44
67
|
def call(_worker, job, _queue, &)
|
|
45
68
|
bid = job['bid']
|
|
46
69
|
return yield unless bid
|
|
@@ -108,4 +131,8 @@ module Wurk
|
|
|
108
131
|
end
|
|
109
132
|
end
|
|
110
133
|
|
|
134
|
+
# Auto-registered on the global server chain. The `outer_of` anchor above
|
|
135
|
+
# keeps this middleware outside Expiry and the Limiter wherever it is
|
|
136
|
+
# registered again later; this registration itself appends as usual, because
|
|
137
|
+
# neither anchor is registered in the chain yet.
|
|
111
138
|
Wurk.configuration.server_middleware.add(Wurk::Batch::ServerMiddleware)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'keys'
|
|
4
|
+
require_relative 'queue_consumers'
|
|
5
|
+
|
|
6
|
+
module Wurk
|
|
7
|
+
# What a standalone runner says once at boot, after the app loaded. Log only,
|
|
8
|
+
# and never allowed to stop a boot: none of this changes behaviour by backend,
|
|
9
|
+
# version or peers.
|
|
10
|
+
module BootChecks
|
|
11
|
+
# Oldest version each non-Redis backend has been run against.
|
|
12
|
+
TESTED_FLOORS = { 'dragonfly' => '1.34.0', 'valkey' => '8.0.0' }.freeze
|
|
13
|
+
MAX_LISTED = 20
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(config, info)
|
|
18
|
+
log_backend(config.logger, info)
|
|
19
|
+
warn_on_foreign_processes(config)
|
|
20
|
+
warn_on_ignored_locks(config.logger)
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
config.logger.warn { "wurk boot checks skipped: #{e.class}: #{e.message}" }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def backend(info)
|
|
26
|
+
return ['dragonfly', info['dragonfly_version'].to_s.delete_prefix('df-v')] if info['dragonfly_version']
|
|
27
|
+
return ['valkey', info['valkey_version'].to_s] if info['valkey_version']
|
|
28
|
+
|
|
29
|
+
['redis', info['redis_version'].to_s]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def log_backend(logger, info)
|
|
33
|
+
name, version = backend(info)
|
|
34
|
+
logger.info { "Wurk #{Wurk::VERSION} on #{name} #{version}" }
|
|
35
|
+
floor = TESTED_FLOORS[name]
|
|
36
|
+
return unless floor && ::Gem::Version.correct?(version) && ::Gem::Version.new(version) < ::Gem::Version.new(floor)
|
|
37
|
+
|
|
38
|
+
logger.warn { "#{name} #{version} is older than the oldest version Wurk is tested against (#{floor})." }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Both runtimes share `processes` and the same queues, so a mix is safe only
|
|
42
|
+
# if it is deliberate. Wurk's heartbeat always carries `cores`; stock does not.
|
|
43
|
+
def warn_on_foreign_processes(config)
|
|
44
|
+
foreign = process_infos(config).reject { |_, info| info.nil? || ::JSON.parse(info).key?('cores') }.map(&:first)
|
|
45
|
+
return if foreign.empty?
|
|
46
|
+
|
|
47
|
+
config.logger.warn do
|
|
48
|
+
"Live processes that are not Wurk share this Redis: #{listing(foreign)}. " \
|
|
49
|
+
'They fetch from the same queues. If this is not a deliberate rolling cutover, stop them.'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def process_infos(config)
|
|
54
|
+
config.redis(idempotent: true) do |conn|
|
|
55
|
+
ids = conn.call('SMEMBERS', Keys::PROCESSES)
|
|
56
|
+
ids.empty? ? [] : ids.zip(conn.pipelined { |pipe| ids.each { |id| pipe.call('HGET', id, 'info') } })
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Without the gem's middleware the option does nothing: the "unique" job
|
|
61
|
+
# simply enqueues twice. Only classes the app has loaded by now are seen.
|
|
62
|
+
def warn_on_ignored_locks(logger)
|
|
63
|
+
return if defined?(::SidekiqUniqueJobs)
|
|
64
|
+
|
|
65
|
+
classes = locked_job_classes
|
|
66
|
+
return if classes.empty?
|
|
67
|
+
|
|
68
|
+
logger.warn do
|
|
69
|
+
'These jobs set `lock:` but sidekiq-unique-jobs is not loaded, so the option does nothing and ' \
|
|
70
|
+
"duplicates will run: #{listing(classes)}. Keep the gem, or move them to `unique_for:`."
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def locked_job_classes
|
|
75
|
+
::ObjectSpace.each_object(::Class).filter_map do |klass|
|
|
76
|
+
next unless klass.name && klass < Wurk::Worker
|
|
77
|
+
|
|
78
|
+
klass.name if klass.get_sidekiq_options.key?('lock')
|
|
79
|
+
end.sort
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def listing(names)
|
|
83
|
+
extra = names.size - MAX_LISTED
|
|
84
|
+
names.first(MAX_LISTED).join(', ') + (extra.positive? ? " and #{extra} more" : '')
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
data/lib/wurk/capsule.rb
CHANGED
|
@@ -40,6 +40,8 @@ module Wurk
|
|
|
40
40
|
@pools = {}
|
|
41
41
|
@client_chain = nil
|
|
42
42
|
@server_chain = nil
|
|
43
|
+
@server_middleware_seen = []
|
|
44
|
+
@prepared = false
|
|
43
45
|
@watchdog = nil
|
|
44
46
|
end
|
|
45
47
|
|
|
@@ -51,6 +53,8 @@ module Wurk
|
|
|
51
53
|
# %w[high default low] → mode :strict, weights all 0
|
|
52
54
|
# %w[high,3 default,2 low,1] → mode :weighted, weights {q=>w}
|
|
53
55
|
# %w[a,1 b,1 c,1] → mode :random, weights all 1
|
|
56
|
+
# %w[a,2 b] → mode :weighted, unweighted b fetched as if 1
|
|
57
|
+
# %w[a,0 b,0] → mode :strict, 0 being the same as no weight
|
|
54
58
|
# `@queues` is expanded by weight so a uniform shuffle gives weighted
|
|
55
59
|
# fairness (e.g. ["high","high","high","default","default","low"]).
|
|
56
60
|
def queues=(val)
|
|
@@ -102,10 +106,38 @@ module Wurk
|
|
|
102
106
|
# hook opened across the fork. Idempotent.
|
|
103
107
|
def prepare_shared!
|
|
104
108
|
client_middleware
|
|
105
|
-
|
|
109
|
+
refresh_server_middleware!
|
|
110
|
+
# A second boot (a launcher run twice) finds the capsule frozen — the
|
|
111
|
+
# flag is already set, so it is not written again.
|
|
112
|
+
@prepared ||= true
|
|
106
113
|
self
|
|
107
114
|
end
|
|
108
115
|
|
|
116
|
+
# True once this capsule has been prepared to run jobs — `prepare_shared!`
|
|
117
|
+
# in the swarm parent before the fork, `prepare!` in the child at boot.
|
|
118
|
+
# Only then is the memoized chain the one the workers run, and only then
|
|
119
|
+
# is it final: a middleware registered on the Configuration afterwards can
|
|
120
|
+
# never reach it.
|
|
121
|
+
def prepared?
|
|
122
|
+
@prepared
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# True when this capsule's server middleware chain has already been
|
|
126
|
+
# snapshotted and that snapshot does not carry `klass`.
|
|
127
|
+
#
|
|
128
|
+
# The snapshot is the copy `server_middleware` memoized; a middleware
|
|
129
|
+
# registered on the Configuration after that copy was taken can never
|
|
130
|
+
# reach this capsule — which is what Wurk::Encryption.enable has to
|
|
131
|
+
# detect before it seals arguments the workers could not open.
|
|
132
|
+
#
|
|
133
|
+
# Pure read: it never materializes the chain and has no side effects.
|
|
134
|
+
def server_middleware_missing?(klass)
|
|
135
|
+
chain = @server_chain
|
|
136
|
+
return false unless chain
|
|
137
|
+
|
|
138
|
+
!chain.exists?(klass)
|
|
139
|
+
end
|
|
140
|
+
|
|
109
141
|
def client_middleware
|
|
110
142
|
chain = (@client_chain ||= @config.client_middleware.copy_for(self))
|
|
111
143
|
yield chain if block_given?
|
|
@@ -116,7 +148,11 @@ module Wurk
|
|
|
116
148
|
# copy_for(self) — not dup — binds the chain's `@config` to this capsule,
|
|
117
149
|
# so middleware that reach for `redis_pool`/`redis`/`logger` resolve them
|
|
118
150
|
# instead of hitting `nil` (a plain dup leaves @config nil).
|
|
119
|
-
chain = (@server_chain ||=
|
|
151
|
+
chain = (@server_chain ||= begin
|
|
152
|
+
config_chain = @config.server_middleware
|
|
153
|
+
@server_middleware_seen = config_chain.entries.map(&:klass)
|
|
154
|
+
config_chain.copy_for(self)
|
|
155
|
+
end)
|
|
120
156
|
yield chain if block_given?
|
|
121
157
|
chain
|
|
122
158
|
end
|
|
@@ -154,6 +190,16 @@ module Wurk
|
|
|
154
190
|
@pools.clear
|
|
155
191
|
end
|
|
156
192
|
|
|
193
|
+
# The fork-path form: closes idle sockets but keeps every pool usable, for a
|
|
194
|
+
# parent whose own threads may be mid-checkout.
|
|
195
|
+
def reload_redis_pools!
|
|
196
|
+
@pools.each_value(&:reload!)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def redis_pools_built?
|
|
200
|
+
!@pools.empty?
|
|
201
|
+
end
|
|
202
|
+
|
|
157
203
|
def redis(idempotent: false, &)
|
|
158
204
|
PoolCheckout.with(redis_pool, idempotent, &)
|
|
159
205
|
end
|
|
@@ -187,6 +233,40 @@ module Wurk
|
|
|
187
233
|
|
|
188
234
|
private
|
|
189
235
|
|
|
236
|
+
# The Configuration's chain may have grown since this capsule copied it —
|
|
237
|
+
# a middleware registered after the copy was taken would otherwise be
|
|
238
|
+
# missing from the chain the workers run. Track classes already seen rather
|
|
239
|
+
# than just those still present, so capsule-local removals stay removed.
|
|
240
|
+
def refresh_server_middleware!
|
|
241
|
+
copy = server_middleware
|
|
242
|
+
return copy if frozen?
|
|
243
|
+
|
|
244
|
+
global = @config.server_middleware.entries
|
|
245
|
+
global.each_with_index do |entry, index|
|
|
246
|
+
next if @server_middleware_seen.include?(entry.klass)
|
|
247
|
+
|
|
248
|
+
@server_middleware_seen << entry.klass
|
|
249
|
+
adopt_server_middleware(copy, entry, global.drop(index + 1)) unless copy.exists?(entry.klass)
|
|
250
|
+
end
|
|
251
|
+
copy
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def adopt_server_middleware(copy, entry, followers)
|
|
255
|
+
rebound = entry.dup
|
|
256
|
+
rebound.config = self
|
|
257
|
+
copy.entries.insert(refresh_position(copy, followers), rebound)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Ahead of the first entry that follows it in the global chain, so a late
|
|
261
|
+
# `prepend` still wraps what it was prepended to. -1 appends.
|
|
262
|
+
def refresh_position(copy, followers)
|
|
263
|
+
followers.each do |follower|
|
|
264
|
+
position = copy.entries.index { |entry| entry.klass == follower.klass }
|
|
265
|
+
return position if position
|
|
266
|
+
end
|
|
267
|
+
-1
|
|
268
|
+
end
|
|
269
|
+
|
|
190
270
|
# Drop-in fetch pluggability (spec §5 / §4.1): instantiate
|
|
191
271
|
# `config[:fetch_class]` when the host set one — a custom or Pro fetcher —
|
|
192
272
|
# else the default reliable BLMOVE fetcher (`Sidekiq::BasicFetch`). A
|
|
@@ -221,7 +301,7 @@ module Wurk
|
|
|
221
301
|
return [qname, 0] if weight.nil?
|
|
222
302
|
|
|
223
303
|
weight = Integer(weight)
|
|
224
|
-
raise ArgumentError, "queue weight must be
|
|
304
|
+
raise ArgumentError, "queue weight must not be negative: `#{entry}`" if weight.negative?
|
|
225
305
|
|
|
226
306
|
[qname, weight]
|
|
227
307
|
end
|
|
@@ -240,7 +320,7 @@ module Wurk
|
|
|
240
320
|
def expand_by_weight(parsed, mode)
|
|
241
321
|
return parsed.map(&:first) if %i[strict random].include?(mode)
|
|
242
322
|
|
|
243
|
-
parsed.flat_map { |q, w| [q] * w }
|
|
323
|
+
parsed.flat_map { |q, w| [q] * [w, 1].max }
|
|
244
324
|
end
|
|
245
325
|
|
|
246
326
|
# `config.redis = { size: N }` pins the main pool at N; otherwise
|