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.
Files changed (127) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +15 -13
  3. data/app/controllers/wurk/api_controller.rb +53 -11
  4. data/config/routes.rb +20 -12
  5. data/lib/active_job/queue_adapters/wurk_adapter.rb +8 -2
  6. data/lib/sidekiq/rails.rb +5 -0
  7. data/lib/wurk/api/read_only.rb +1 -1
  8. data/lib/wurk/batch/server_middleware.rb +27 -0
  9. data/lib/wurk/boot_checks.rb +87 -0
  10. data/lib/wurk/capsule.rb +84 -4
  11. data/lib/wurk/cli.rb +77 -10
  12. data/lib/wurk/client/buffered.rb +240 -26
  13. data/lib/wurk/client.rb +16 -11
  14. data/lib/wurk/compat.rb +23 -2
  15. data/lib/wurk/configuration.rb +193 -16
  16. data/lib/wurk/cron.rb +45 -0
  17. data/lib/wurk/debounce.rb +2 -2
  18. data/lib/wurk/encryption.rb +47 -8
  19. data/lib/wurk/error_coalescer.rb +35 -0
  20. data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
  21. data/lib/wurk/fetcher/capped.rb +4 -0
  22. data/lib/wurk/fetcher/many_queues.rb +17 -1
  23. data/lib/wurk/fetcher/oom_drain.rb +102 -0
  24. data/lib/wurk/fetcher/orphan_grace.rb +60 -44
  25. data/lib/wurk/fetcher/private_list_key.rb +17 -15
  26. data/lib/wurk/fetcher/reaper.rb +195 -129
  27. data/lib/wurk/fetcher/reliable.rb +26 -4
  28. data/lib/wurk/fetcher/self_reconcile.rb +175 -0
  29. data/lib/wurk/fetcher/unit_of_work.rb +32 -8
  30. data/lib/wurk/fetcher/unparseable_keys.rb +22 -7
  31. data/lib/wurk/fetcher.rb +6 -1
  32. data/lib/wurk/health.rb +26 -3
  33. data/lib/wurk/health_probe.rb +64 -0
  34. data/lib/wurk/heartbeat.rb +55 -3
  35. data/lib/wurk/job/options.rb +9 -1
  36. data/lib/wurk/job.rb +2 -24
  37. data/lib/wurk/job_logger.rb +1 -1
  38. data/lib/wurk/job_record.rb +17 -9
  39. data/lib/wurk/job_retry.rb +38 -10
  40. data/lib/wurk/job_set.rb +13 -5
  41. data/lib/wurk/job_util.rb +4 -1
  42. data/lib/wurk/launcher.rb +138 -55
  43. data/lib/wurk/leader.rb +9 -3
  44. data/lib/wurk/limiter/base.rb +17 -1
  45. data/lib/wurk/lua/debounce.lua +11 -6
  46. data/lib/wurk/lua/loader.rb +13 -1
  47. data/lib/wurk/lua.rb +72 -22
  48. data/lib/wurk/manager.rb +1 -1
  49. data/lib/wurk/metrics/dashboard_series.rb +21 -2
  50. data/lib/wurk/metrics/history.rb +13 -5
  51. data/lib/wurk/metrics/process_counters.rb +27 -0
  52. data/lib/wurk/metrics/prometheus.rb +35 -14
  53. data/lib/wurk/metrics/query.rb +9 -2
  54. data/lib/wurk/metrics/statsd.rb +8 -0
  55. data/lib/wurk/middleware/chain.rb +85 -13
  56. data/lib/wurk/middleware/current_attributes.rb +26 -10
  57. data/lib/wurk/middleware/poison_pill.rb +1 -2
  58. data/lib/wurk/pool_checkout.rb +1 -1
  59. data/lib/wurk/process_set.rb +3 -1
  60. data/lib/wurk/processor.rb +82 -18
  61. data/lib/wurk/queue_consumers.rb +134 -0
  62. data/lib/wurk/queue_slot.rb +19 -9
  63. data/lib/wurk/rails_boot.rb +44 -5
  64. data/lib/wurk/rails_reloader.rb +51 -0
  65. data/lib/wurk/railtie.rb +27 -4
  66. data/lib/wurk/redact.rb +12 -0
  67. data/lib/wurk/redis_connection.rb +4 -2
  68. data/lib/wurk/redis_pool.rb +34 -9
  69. data/lib/wurk/scheduled.rb +142 -67
  70. data/lib/wurk/sentry/death_handler.rb +29 -0
  71. data/lib/wurk/sentry/error_handler.rb +52 -10
  72. data/lib/wurk/sentry/middleware.rb +8 -1
  73. data/lib/wurk/sentry/retry_policy.rb +12 -3
  74. data/lib/wurk/sentry.rb +42 -5
  75. data/lib/wurk/sorted_entry.rb +38 -13
  76. data/lib/wurk/swarm/child_boot.rb +42 -31
  77. data/lib/wurk/swarm/crash_watch.rb +65 -0
  78. data/lib/wurk/swarm.rb +79 -11
  79. data/lib/wurk/testing.rb +66 -0
  80. data/lib/wurk/unique.rb +11 -8
  81. data/lib/wurk/version.rb +1 -1
  82. data/lib/wurk/web/config.rb +42 -9
  83. data/lib/wurk/web/enterprise.rb +4 -3
  84. data/lib/wurk/web/extension.rb +80 -40
  85. data/lib/wurk/work_set.rb +6 -0
  86. data/lib/wurk/worker/setter.rb +11 -6
  87. data/lib/wurk/worker.rb +17 -1
  88. data/lib/wurk.rb +6 -3
  89. data/vendor/assets/dashboard/assets/{ArgsValue-_MYkd_vA.js → ArgsValue-CmaXArv8.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{BatchDetail-DPmg-hle.js → BatchDetail-nc5yePZW.js} +1 -1
  91. data/vendor/assets/dashboard/assets/{Batches-NTgqSlvD.js → Batches-DgEcyOZ_.js} +1 -1
  92. data/vendor/assets/dashboard/assets/{Busy-2rxcLJMq.js → Busy-CUZncvCy.js} +1 -1
  93. data/vendor/assets/dashboard/assets/{Cron-BFpJgKYb.js → Cron-CIZbaaZs.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{Dashboard-C8VOE2S7.js → Dashboard-BWmfAook.js} +1 -1
  95. data/vendor/assets/dashboard/assets/{Dead-Dse2YEbZ.js → Dead-CGKsTZHq.js} +1 -1
  96. data/vendor/assets/dashboard/assets/Extension-Doy4bJoA.js +1 -0
  97. data/vendor/assets/dashboard/assets/FilterBox-C4bO26_d.js +2 -0
  98. data/vendor/assets/dashboard/assets/{FlowDetail-BWddOtgf.js → FlowDetail-Dh3SJdFh.js} +1 -1
  99. data/vendor/assets/dashboard/assets/{FlowState-6lGsojxE.js → FlowState-yb7virqR.js} +1 -1
  100. data/vendor/assets/dashboard/assets/{Flows-BgKTlYwI.js → Flows-C5SMRuhq.js} +1 -1
  101. data/vendor/assets/dashboard/assets/{JobDetailModal-BrjmO98c.js → JobDetailModal-Cg6sAsJB.js} +1 -1
  102. data/vendor/assets/dashboard/assets/{Limiters-DeUkfbcg.js → Limiters-BIxwDOJM.js} +1 -1
  103. data/vendor/assets/dashboard/assets/Metrics-Qp9q0nF8.js +1 -0
  104. data/vendor/assets/dashboard/assets/{NotFound-CQLLXIfa.js → NotFound-DwjPxFjU.js} +1 -1
  105. data/vendor/assets/dashboard/assets/{PageHeader-CeSaqysY.js → PageHeader-vWeBbSPF.js} +1 -1
  106. data/vendor/assets/dashboard/assets/{Profiles-CI2i2tl8.js → Profiles-7dabYVe3.js} +1 -1
  107. data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
  108. data/vendor/assets/dashboard/assets/{Retries-Dc2RFuTf.js → Retries-CNX8ZtTH.js} +1 -1
  109. data/vendor/assets/dashboard/assets/{Scheduled-B8zOKCLC.js → Scheduled-ClVBj0nc.js} +1 -1
  110. data/vendor/assets/dashboard/assets/{Search-x0eS-V-U.js → Search-BAsVE2qw.js} +1 -1
  111. data/vendor/assets/dashboard/assets/{charts-VggSMFGc.js → charts-DZs138BV.js} +1 -1
  112. data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
  113. data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
  114. data/vendor/assets/dashboard/assets/index-CMzPFxNE.js +141 -0
  115. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BsYWeStI.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
  116. data/vendor/assets/dashboard/assets/{useSort-BWWfpnZH.js → useSort-jBYVwXvg.js} +1 -1
  117. data/vendor/assets/dashboard/assets/{utils-DnzJXljj.js → utils-B0kQFNE8.js} +1 -1
  118. data/vendor/assets/dashboard/index.html +3 -3
  119. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  120. metadata +41 -44
  121. data/vendor/assets/dashboard/assets/Extension-BIc2c1zG.js +0 -1
  122. data/vendor/assets/dashboard/assets/FilterBox-BfZm8ZzH.js +0 -1
  123. data/vendor/assets/dashboard/assets/Metrics-DzmAgBFG.js +0 -1
  124. data/vendor/assets/dashboard/assets/Queues-DUCQ_3St.js +0 -1
  125. data/vendor/assets/dashboard/assets/i18n-B6AbnmJn.js +0 -1
  126. data/vendor/assets/dashboard/assets/index-CTWV8VL4.js +0 -141
  127. 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: e3249733631f049cf81a41880d6355512889dd963fa9e3c159610d27b1c52bd2
4
- data.tar.gz: a5db03ee0bdbf32d765ba439b4aa0e3fbb5d55cfcd8b58471375a62bf2b3367b
3
+ metadata.gz: ce85d34d0d8e1f5aa0837c76310a94c2ca3e584ff0e9ca4d9e977c0347510f89
4
+ data.tar.gz: 113f78f30121c728a4d7f3db5dc6d1472f13ab1b6b7b50d6ee73223d681585f4
5
5
  SHA512:
6
- metadata.gz: 304f23c4742924dc22a9a3d9cdc0c7c28cbcdf089c6e5d5dd485adc858bc81d239fc501687e378b026e0c42e6be9675ce960c7f8b27007e35925a04f133961f0
7
- data.tar.gz: 1fd8a666fa1d02940c4f36d1e49c7c69fad64783f7f52433543cf3bfba77340de2964202ac91224f03cb66b32babc32849fe81694e98ea173023245eda98fc95
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, per-job progress, no separate stack to run. See [Wurk extras](#wurk-extras).
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. Outside contributors' issues and pull requests, 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, license questions and breaking changes, 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).
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:** 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`.
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 dashboard's per-job progress bar |
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
- **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.
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 docs for the public classes (`Wurk::Worker`, `Wurk::Client`, `Wurk::Configuration`, `Wurk::Batch`, `Wurk::Limiter`, `Wurk::Unique`, and the `Sidekiq::*` aliases). 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/01-overview.md)** — how the swarm, manager, fetcher, and processor fit together.
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**: outside contributors' issues and pull requests, and dependency updates, are handled end to end by a maintainer agent (the team's own are left to them, their pull requests still reviewed), 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:
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 suite runs against stock Sidekiq every release and ships the results [as measured](docs/benchmarks.md), including the unflattering ones.
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 oracles, upstream suites, and published numbers above are gates, not decoration, and 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.
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) | left to its author; a pull request is still reviewed, and an @-mention of the bot is answered |
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 | never merged unattended: the drop-in contract is the one promise a revert cannot take back |
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
- render json: ::Wurk::Stats.new.queue_summaries.map { |q| ::Wurk::Api::Serializers.queue_summary(q) }
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 = ::Wurk::Api::Pagination.clamp_int(params[:minutes], 1, ::Wurk::Metrics::Query::MAX_MINUTES, 60)
243
- rows = ::Wurk::Web::Enterprise::Historical.top(minutes: minutes, class_filter: params[:substr])
244
- render json: { minutes: minutes, top_jobs: rows.map { |(klass, totals)| ::Wurk::Api::Serializers.metric_row(klass, totals) } }
245
- rescue ::Wurk::Metrics::Query::WindowTooWide => e
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
- # selects at most a page (200).
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
- minutes = pagination.clamp_int(params[:minutes], 1, ::Wurk::Metrics::Query::MAX_MINUTES, 60) if params[:minutes]
521
- hours = pagination.clamp_int(params[:hours], 1, ::Wurk::Metrics::Query::MAX_HOURS, 24) if params[:hours]
522
- minutes ||= 60 if hours.nil?
523
- [minutes, hours]
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/requeue/clear). The SPA posts to
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
- # Busy-page process control (quiet/stop). `identity` rides in the body (it
54
- # contains ':' and '.', awkward as a path segment); absent or "all" signals
55
- # every live process. Read-only mode 403s these via the Authorization
56
- # middleware. Spec: docs/target/sidekiq-free.md §25.4 (POST /busy).
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 the engine's middleware — the host's
100
- # `Wurk::Web.use` chain and the read-only gate — so a dashboard behind Devise
101
- # keeps gating this path too. A machine client that cannot pass a browser
102
- # login wants mode 2 instead: `mount Wurk::API => '/wurk-api'` in the host's
103
- # own routes, declared BEFORE the engine when its path extends the engine's —
104
- # Rails matches a mounted app by bare string prefix, so `mount Wurk::Engine =>
105
- # '/wurk'` declared first swallows every `/wurk-api/...` request.
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
- Wurk.configure_client { |config| config.on(:quiet, &callback) }
36
- Wurk.configure_server { |config| config.on(:quiet, &callback) }
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'
@@ -52,7 +52,7 @@ module Wurk
52
52
  Problem.render(
53
53
  Problem::READ_ONLY,
54
54
  status: 403,
55
- detail: 'This Wurk deployment is read-only; it answers GET and HEAD only.',
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
- server_middleware
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 ||= @config.server_middleware.copy_for(self))
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 > 0: `#{entry}`" if weight <= 0
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