wurk 1.7.6 → 1.8.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 (217) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -4
  3. data/app/controllers/concerns/wurk/sse_streaming.rb +2 -1
  4. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +33 -9
  5. data/app/controllers/wurk/api/pagination.rb +25 -7
  6. data/app/controllers/wurk/api_controller.rb +49 -25
  7. data/app/controllers/wurk/extensions_controller.rb +8 -2
  8. data/app/controllers/wurk/profiles_controller.rb +45 -9
  9. data/config/routes.rb +31 -10
  10. data/lib/generators/wurk/install/templates/wurk.rb +1 -1
  11. data/lib/sidekiq/capsule.rb +4 -0
  12. data/lib/sidekiq/component.rb +4 -0
  13. data/lib/sidekiq/config.rb +4 -0
  14. data/lib/sidekiq/deploy.rb +4 -0
  15. data/lib/sidekiq/embedded.rb +4 -0
  16. data/lib/sidekiq/fetch.rb +4 -0
  17. data/lib/sidekiq/iterable_job.rb +4 -0
  18. data/lib/sidekiq/job/interrupt_handler.rb +4 -0
  19. data/lib/sidekiq/job/iterable/active_record_enumerator.rb +4 -0
  20. data/lib/sidekiq/job/iterable/csv_enumerator.rb +4 -0
  21. data/lib/sidekiq/job/iterable.rb +4 -0
  22. data/lib/sidekiq/job_logger.rb +4 -0
  23. data/lib/sidekiq/job_util.rb +4 -0
  24. data/lib/sidekiq/loader.rb +4 -0
  25. data/lib/sidekiq/logger.rb +4 -0
  26. data/lib/sidekiq/metrics/query.rb +4 -0
  27. data/lib/sidekiq/middleware/current_attributes.rb +7 -0
  28. data/lib/sidekiq/middleware/i18n.rb +6 -0
  29. data/lib/sidekiq/middleware/modules.rb +4 -0
  30. data/lib/sidekiq/pro/web.rb +5 -0
  31. data/lib/sidekiq/profiler.rb +4 -0
  32. data/lib/sidekiq/redis_client_adapter.rb +4 -0
  33. data/lib/sidekiq/test_api.rb +5 -0
  34. data/lib/sidekiq/testing/inline.rb +8 -0
  35. data/lib/sidekiq/transaction_aware_client.rb +4 -0
  36. data/lib/sidekiq/worker_compatibility_alias.rb +4 -0
  37. data/lib/sidekiq-ent/periodic/testing.rb +5 -0
  38. data/lib/sidekiq-ent/web.rb +5 -0
  39. data/lib/sidekiq-ent.rb +7 -0
  40. data/lib/sidekiq-pro.rb +7 -0
  41. data/lib/wurk/api/fast.rb +8 -33
  42. data/lib/wurk/api/idempotency.rb +28 -5
  43. data/lib/wurk/api/swarm.rb +1 -1
  44. data/lib/wurk/batch/callbacks.rb +143 -92
  45. data/lib/wurk/batch/death_handler.rb +19 -8
  46. data/lib/wurk/batch/server_middleware.rb +37 -24
  47. data/lib/wurk/batch/status.rb +49 -15
  48. data/lib/wurk/batch.rb +164 -65
  49. data/lib/wurk/capsule.rb +0 -2
  50. data/lib/wurk/client/buffered.rb +227 -106
  51. data/lib/wurk/client.rb +33 -13
  52. data/lib/wurk/compat.rb +13 -0
  53. data/lib/wurk/configuration.rb +155 -24
  54. data/lib/wurk/cpu_count.rb +97 -0
  55. data/lib/wurk/cron.rb +97 -34
  56. data/lib/wurk/dead_set.rb +6 -7
  57. data/lib/wurk/encryption.rb +46 -13
  58. data/lib/wurk/fetcher/many_queues.rb +65 -0
  59. data/lib/wurk/fetcher/orphan_grace.rb +67 -0
  60. data/lib/wurk/fetcher/private_list_key.rb +76 -0
  61. data/lib/wurk/fetcher/reaper.rb +55 -74
  62. data/lib/wurk/fetcher/reliable.rb +23 -17
  63. data/lib/wurk/fetcher/unit_of_work.rb +19 -1
  64. data/lib/wurk/fetcher/unparseable_keys.rb +56 -0
  65. data/lib/wurk/flow/completion.rb +28 -3
  66. data/lib/wurk/flow/creation.rb +24 -4
  67. data/lib/wurk/flow.rb +24 -2
  68. data/lib/wurk/health.rb +182 -40
  69. data/lib/wurk/heartbeat.rb +37 -14
  70. data/lib/wurk/history.rb +7 -1
  71. data/lib/wurk/import/sidekiq_cron.rb +248 -0
  72. data/lib/wurk/job/options.rb +22 -3
  73. data/lib/wurk/job_logger.rb +13 -3
  74. data/lib/wurk/job_record.rb +68 -19
  75. data/lib/wurk/job_retry.rb +12 -6
  76. data/lib/wurk/job_set.rb +99 -51
  77. data/lib/wurk/job_util.rb +27 -8
  78. data/lib/wurk/launcher.rb +84 -40
  79. data/lib/wurk/leader.rb +53 -28
  80. data/lib/wurk/limiter/base.rb +11 -30
  81. data/lib/wurk/limiter/bucket.rb +14 -5
  82. data/lib/wurk/limiter/server_middleware.rb +2 -1
  83. data/lib/wurk/limiter.rb +1 -1
  84. data/lib/wurk/loader.rb +48 -0
  85. data/lib/wurk/lua/batch_ack_complete.lua +28 -0
  86. data/lib/wurk/lua/batch_ack_failed.lua +18 -0
  87. data/lib/wurk/lua/batch_ack_success.lua +35 -0
  88. data/lib/wurk/lua/batch_append_callback.lua +66 -0
  89. data/lib/wurk/lua/batch_delete.lua +31 -0
  90. data/lib/wurk/lua/batch_hold.lua +17 -0
  91. data/lib/wurk/lua/batch_invalidate.lua +14 -0
  92. data/lib/wurk/lua/batch_kid_done.lua +14 -0
  93. data/lib/wurk/lua/batch_push.lua +47 -0
  94. data/lib/wurk/lua/batch_remove_jobs.lua +29 -0
  95. data/lib/wurk/lua/batch_schedule.lua +27 -0
  96. data/lib/wurk/lua/fetch_first.lua +27 -0
  97. data/lib/wurk/lua/flow_abandon.lua +16 -21
  98. data/lib/wurk/lua/flow_advance.lua +41 -26
  99. data/lib/wurk/lua/flow_create.lua +24 -9
  100. data/lib/wurk/lua/flow_fail.lua +3 -3
  101. data/lib/wurk/lua/leader_campaign.lua +19 -0
  102. data/lib/wurk/lua/limiter_bucket_acquire.lua +3 -2
  103. data/lib/wurk/lua/limiter_list_sweep.lua +8 -9
  104. data/lib/wurk/lua/loader.rb +36 -23
  105. data/lib/wurk/lua/throttle_slot.lua +13 -26
  106. data/lib/wurk/lua.rb +31 -244
  107. data/lib/wurk/manager.rb +25 -18
  108. data/lib/wurk/metrics/accumulator.rb +31 -10
  109. data/lib/wurk/metrics/dashboard_series.rb +163 -0
  110. data/lib/wurk/metrics/histogram.rb +46 -0
  111. data/lib/wurk/metrics/history.rb +49 -57
  112. data/lib/wurk/metrics/prometheus.rb +232 -0
  113. data/lib/wurk/metrics/query.rb +127 -155
  114. data/lib/wurk/metrics/rollup.rb +2 -2
  115. data/lib/wurk/metrics/statsd.rb +1 -1
  116. data/lib/wurk/middleware/interrupt_handler.rb +6 -2
  117. data/lib/wurk/middleware/poison_pill.rb +5 -4
  118. data/lib/wurk/middleware/timeout.rb +8 -8
  119. data/lib/wurk/process_set.rb +5 -5
  120. data/lib/wurk/processor.rb +69 -31
  121. data/lib/wurk/profile_set.rb +19 -24
  122. data/lib/wurk/profiler.rb +48 -50
  123. data/lib/wurk/queue.rb +36 -19
  124. data/lib/wurk/rails_boot.rb +33 -1
  125. data/lib/wurk/railtie.rb +2 -0
  126. data/lib/wurk/rake_tasks.rb +18 -0
  127. data/lib/wurk/redact.rb +39 -0
  128. data/lib/wurk/redis_connection.rb +6 -3
  129. data/lib/wurk/redis_options.rb +21 -4
  130. data/lib/wurk/redis_pool.rb +119 -21
  131. data/lib/wurk/scheduled.rb +44 -11
  132. data/lib/wurk/sentry/error_handler.rb +53 -7
  133. data/lib/wurk/sentry/middleware.rb +7 -8
  134. data/lib/wurk/shutdown_gate.rb +24 -1
  135. data/lib/wurk/sorted_entry.rb +16 -5
  136. data/lib/wurk/stats.rb +9 -4
  137. data/lib/wurk/status.rb +2 -2
  138. data/lib/wurk/swarm/child_boot.rb +48 -38
  139. data/lib/wurk/swarm/liveness.rb +153 -0
  140. data/lib/wurk/swarm/restart.rb +24 -3
  141. data/lib/wurk/swarm.rb +115 -24
  142. data/lib/wurk/telemetry/server_middleware.rb +2 -5
  143. data/lib/wurk/testing.rb +15 -5
  144. data/lib/wurk/throttle.rb +16 -1
  145. data/lib/wurk/topology.rb +1 -5
  146. data/lib/wurk/unique.rb +1 -1
  147. data/lib/wurk/version.rb +1 -1
  148. data/lib/wurk/watchdog.rb +2 -2
  149. data/lib/wurk/web/config.rb +56 -20
  150. data/lib/wurk/web/enterprise.rb +21 -18
  151. data/lib/wurk/web/extension.rb +98 -22
  152. data/lib/wurk/web/rack_app.rb +12 -11
  153. data/lib/wurk/web/search.rb +18 -8
  154. data/lib/wurk/worker/setter.rb +17 -14
  155. data/lib/wurk/worker.rb +6 -67
  156. data/lib/wurk.rb +2 -3
  157. data/vendor/assets/dashboard/assets/ArgsValue-DsPb_KZ-.js +1 -0
  158. data/vendor/assets/dashboard/assets/BatchDetail-BCIXJOpZ.js +1 -0
  159. data/vendor/assets/dashboard/assets/Batches-IjaQfXW6.js +1 -0
  160. data/vendor/assets/dashboard/assets/Busy-CaQrf1Ve.js +1 -0
  161. data/vendor/assets/dashboard/assets/Cron-B18EHn_Y.js +1 -0
  162. data/vendor/assets/dashboard/assets/Dashboard-DdT5ZaUg.js +1 -0
  163. data/vendor/assets/dashboard/assets/Dead-DWIy-Mu8.js +1 -0
  164. data/vendor/assets/dashboard/assets/Extension-BjBUWhHf.js +1 -0
  165. data/vendor/assets/dashboard/assets/FilterBox-DwH3PXXt.js +1 -0
  166. data/vendor/assets/dashboard/assets/FlowDetail-DnexL72n.js +1 -0
  167. data/vendor/assets/dashboard/assets/FlowState-Cr4aX6-d.js +1 -0
  168. data/vendor/assets/dashboard/assets/Flows-DO6EimbZ.js +1 -0
  169. data/vendor/assets/dashboard/assets/JobDetailModal-aPjgq-U9.js +2 -0
  170. data/vendor/assets/dashboard/assets/Limiters-DxpwWCNe.js +1 -0
  171. data/vendor/assets/dashboard/assets/Metrics-C3JL1GIH.js +1 -0
  172. data/vendor/assets/dashboard/assets/NotFound-BfP9uAV9.js +1 -0
  173. data/vendor/assets/dashboard/assets/PageHeader-DbC97afG.js +1 -0
  174. data/vendor/assets/dashboard/assets/Profiles-BcaHreMe.js +1 -0
  175. data/vendor/assets/dashboard/assets/Queues-ZlBc5tyD.js +1 -0
  176. data/vendor/assets/dashboard/assets/Retries-Bg3nkeiL.js +1 -0
  177. data/vendor/assets/dashboard/assets/Scheduled-BrMZPZGl.js +1 -0
  178. data/vendor/assets/dashboard/assets/Search-B5X5pGht.js +1 -0
  179. data/vendor/assets/dashboard/assets/charts-uDyf4PN0.js +1 -0
  180. data/vendor/assets/dashboard/assets/i18n-CJeYfeVt.js +1 -0
  181. data/vendor/assets/dashboard/assets/index-B36QCAf-.css +1 -0
  182. data/vendor/assets/dashboard/assets/index-DQujgZgH.js +141 -0
  183. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-O3-5rMk3.js +1 -0
  184. data/vendor/assets/dashboard/assets/useSort-xfnwjQAu.js +1 -0
  185. data/vendor/assets/dashboard/assets/utils-BIKHwNKB.js +1 -0
  186. data/vendor/assets/dashboard/index.html +3 -3
  187. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  188. metadata +90 -34
  189. data/config/locales/en.yml +0 -15
  190. data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +0 -1
  191. data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +0 -1
  192. data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +0 -1
  193. data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +0 -1
  194. data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +0 -1
  195. data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +0 -1
  196. data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +0 -1
  197. data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +0 -1
  198. data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +0 -1
  199. data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +0 -1
  200. data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +0 -1
  201. data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +0 -1
  202. data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +0 -2
  203. data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +0 -1
  204. data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +0 -1
  205. data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +0 -1
  206. data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +0 -1
  207. data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +0 -1
  208. data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +0 -1
  209. data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +0 -1
  210. data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +0 -1
  211. data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +0 -1
  212. data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +0 -1
  213. data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +0 -141
  214. data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +0 -1
  215. data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +0 -1
  216. data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +0 -1
  217. data/vendor/assets/dashboard/assets/utils-BIrvZ1hi.js +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d232f2e0c78233c2bbd71051a07df6e791d1fb1c0d701c9e3ff0820035207077
4
- data.tar.gz: 06d15b628db2d8ab75ab8440b48f372ea0943108a0c19533db0c2cf82a1b74fe
3
+ metadata.gz: 3d5734527cfa0b903c07b143f7eb0b821fca970cd98a8773f16cbdc12dcbb43c
4
+ data.tar.gz: e3e610d19a29b71e071896a45710a807928123bb0c530348fb809dafb101cfe3
5
5
  SHA512:
6
- metadata.gz: 522ee07752a14d58635f770411bf7d74d53fca26141f04f73e0f6596e27374ea580b11d405cdcaf297c53aeaca3e08f06e1409b876159fea34cd6c91bbbcbf89
7
- data.tar.gz: 1ffc5b66a6ce06ee39353e3593296f22ac99f8851000fa1b78fcbfb30ff7617a742804fdf6584a07b9a4b039477b736b26c644e8844c8f420766fb21670fc287
6
+ metadata.gz: c08daf7f94145029bcfd74e49c4426c8881effdce3aa65329453c5e7c2930ed87d93ac6ea85423ea8ee6a9df3465152837ebac692abfff3376623e69198209bc
7
+ data.tar.gz: a8374380a15e20e599374bdd7afe6e911a3364b6f1acb532fca2ece894d31d8a113baf4375773a7b94ae2fb0a4a1da98767ca16667b705a53c8f58fdde9ad9d0
data/README.md CHANGED
@@ -13,7 +13,7 @@
13
13
  [![Live Demo](https://img.shields.io/badge/live%20demo-wurk.demo.developerz.ai-22c55e?logo=googlechrome&logoColor=white)](https://wurk.demo.developerz.ai/wurk)
14
14
  [![Gem Version](https://img.shields.io/gem/v/wurk.svg)](https://rubygems.org/gems/wurk)
15
15
  [![CI](https://github.com/developerz-ai/wurk/actions/workflows/test.yml/badge.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
16
- [![Coverage gate](https://img.shields.io/badge/coverage%20gate-line%20%E2%89%A590%25-brightgreen.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
16
+ [![Coverage gate](https://img.shields.io/badge/coverage%20gate-line%20%2B%20branch%20%E2%89%A590%25-brightgreen.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
17
17
  [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A5%203.2-CC342D.svg)](https://www.ruby-lang.org)
18
18
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
19
19
 
@@ -21,7 +21,7 @@
21
21
 
22
22
  Wurk is wire-compatible with Sidekiq — same Redis keys, same job JSON, same Ruby DSL. Swap one line in your `Gemfile` and your existing jobs, batches, limiters, cron entries, and live Redis data keep working untouched. The Pro and Enterprise feature sets ship in the same free gem, with no license check and no tiers.
23
23
 
24
- **In production:** Wurk runs the background work at [developerz.ai](https://developerz.ai) and at partner deployments — millions of jobs an hour, across many servers, on the fork-based swarm described below. It is not a preview.
24
+ **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).
25
25
 
26
26
  **At scale:** Wurk is built for fleets, not just for one box. Kubernetes `/live` + `/ready` probes are a config line, not a sidecar; a bearer-scoped [HTTP API](docs/api-http.md) lets non-Ruby services enqueue and inspect; [OpenTelemetry](docs/telemetry.md) traces propagate client → server; per-queue [global concurrency caps](docs/rate-limiting.md) hold cluster-wide limits; and monitoring is the dashboard you already mount — live SSE, charts, per-job progress, no separate stack to run. See [Wurk extras](#wurk-extras).
27
27
 
@@ -136,6 +136,7 @@ Method, per-invocation records, workload definitions, and the separate `rake ben
136
136
  - **[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.
137
137
  - **[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.
138
138
  - **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
139
+ - **[Incident runbook](https://github.com/developerz-ai/wurk/blob/main/docs/runbook.md)** — symptom → check → fix for stuck queues, orphaned private lists, latency spikes, a stuck cron leader, poison-job storms, Redis failover, memory growth, stuck batches, dead-set growth, and rollback to Sidekiq.
139
140
  - **[Secrets & credentials](https://github.com/developerz-ai/wurk/blob/main/docs/secrets.md)** — which values are secret vs config, how to supply them (ENV, Rails credentials, an init file), precedence, and what to never commit.
140
141
  - **[Active Job adapter](https://github.com/developerz-ai/wurk/blob/main/docs/active-job.md)** — run `ActiveJob`/`deliver_later` on Wurk with `queue_adapter = :wurk`.
141
142
  - **[Testing jobs](https://github.com/developerz-ai/wurk/blob/main/docs/testing.md)** — fake/inline modes, the jobs array, Minitest and RSpec setup.
@@ -297,9 +298,11 @@ Wurk is MIT and stays that way. If what you need is a commercial support contrac
297
298
  + gem "wurk"
298
299
  ```
299
300
 
300
- `bundle install && restart`. Wurk reads and writes the same Redis schema, so a rolling deploy can run Sidekiq and Wurk against the same Redis during the cutover. sidekiq-cron's upstream suite runs against Wurk in the [`ecosystem` CI job](https://github.com/developerz-ai/wurk/blob/main/.github/workflows/ecosystem.yml) (see [`test/ecosystem/`](https://github.com/developerz-ai/wurk/tree/main/test/ecosystem)). sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures and sidekiq-throttled are target additions tracked in [docs/idea/14-ecosystem-compat.md](https://github.com/developerz-ai/wurk/blob/main/docs/idea/14-ecosystem-compat.md).
301
+ `bundle install && restart`. Wurk reads and writes the same Redis schema, so there is no data migration: Wurk picks up the queues, schedules, retries, batches and periodic loops Sidekiq left behind. Running Sidekiq and Wurk workers against one Redis *at the same time* is not tested yet, so a live production move is a **drain cutover** (stop Sidekiq's workers, confirm nothing is in flight, start Wurk), with a matching rollback; the [production cutover](https://github.com/developerz-ai/wurk/blob/main/docs/migrate-from-sidekiq.md#9-production-cutover) section has every step and command. sidekiq-cron's upstream suite runs against Wurk in the [`ecosystem` CI job](https://github.com/developerz-ai/wurk/blob/main/.github/workflows/ecosystem.yml) (see [`test/ecosystem/`](https://github.com/developerz-ai/wurk/tree/main/test/ecosystem)). sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures and sidekiq-throttled are target additions tracked in [docs/idea/14-ecosystem-compat.md](https://github.com/developerz-ai/wurk/blob/main/docs/idea/14-ecosystem-compat.md).
301
302
 
302
- Full walkthrough — config side-by-side, the Redis key/`sidekiq_options` mapping, known incompatibilities, and a one-page cutover checklist: **[docs/migrate-from-sidekiq.md](https://github.com/developerz-ai/wurk/blob/main/docs/migrate-from-sidekiq.md)**.
303
+ **Keeping a `sidekiq-*` add-on gem?** Those gems declare `add_dependency "sidekiq"`, which would reinstall real Sidekiq next to Wurk. Satisfy it with Wurk's git-only [`sidekiq` shim gem](https://github.com/developerz-ai/wurk/tree/main/ecosystem/sidekiq-shim) instead: `gem "sidekiq", github: "developerz-ai/wurk", glob: "ecosystem/sidekiq-shim/*.gemspec"`. Only sidekiq-cron is exercised that way on every PR; treat the others as untested.
304
+
305
+ Full walkthrough — config side-by-side, the Redis key/`sidekiq_options` mapping, known incompatibilities, moving sidekiq-cron schedules over (`rake wurk:import:cron`), and the production cutover and rollback playbook: **[docs/migrate-from-sidekiq.md](https://github.com/developerz-ai/wurk/blob/main/docs/migrate-from-sidekiq.md)**. On the night, keep the **[incident runbook](https://github.com/developerz-ai/wurk/blob/main/docs/runbook.md)** open.
303
306
 
304
307
  ## Also from developerz.ai: Ultimate
305
308
 
@@ -30,7 +30,8 @@ module Wurk
30
30
  # Headers are already flushed by the time a tick hits Redis, so the
31
31
  # 503 ApplicationController's rescue_from renders for a plain request
32
32
  # can't happen here — emit a structured SSE event instead and close;
33
- # the SPA's EventSource reconnects and gets a fresh chance at Redis.
33
+ # the closed stream is reopened by EventSource after the `retry:`
34
+ # interval and gets a fresh chance at Redis.
34
35
  logger.warn("wurk web: stream redis unavailable (#{e.class}: #{e.message})")
35
36
  sse.write({ error: 'redis_unavailable' }, event: 'error')
36
37
  ensure
@@ -2,35 +2,48 @@
2
2
 
3
3
  module Wurk
4
4
  # Per-process cap on concurrent SSE streams. ActionController::Live pins one
5
- # Puma thread for every open `/api/stream`; without a bound, a burst of stale
6
- # browser tabs could hold every thread and starve the JSON API. Past the cap
7
- # we 503 with Retry-After — the SPA's EventSource reconnects (and its polling
8
- # fallback honors Retry-After) once a slot frees. Per-process is the right
9
- # scope: it's this process's own thread pool we're protecting.
5
+ # server thread for every open `/api/stream`; the cap is half the server's
6
+ # request threads (at least one) so streams can never take the threads the
7
+ # host app's own requests need. A fixed cap of 10 sat above Rails' default of
8
+ # 3 Puma threads, so three dashboard tabs hung the host app for the stream's
9
+ # whole lifetime. Past the cap we 503 (Retry-After is set for non-browser
10
+ # clients; EventSource can't read it, so the SPA backs off on its own from 3s,
11
+ # doubling to 30s).
12
+ # Per-process is the right scope: it's this process's own thread pool we're
13
+ # protecting. `config.web.max_streams` overrides the derived cap.
10
14
  #
11
15
  # Slots are held as thread references rather than tallied in a counter so the
12
16
  # cap can heal itself. A stream whose thread is killed mid-flight never
13
17
  # reaches the `ensure` below (Puma hard-reaps worker threads past
14
18
  # `force_shutdown_after`, and a thread killed inside an uninterruptible read
15
19
  # can skip its ensure), which a counter would record as a slot held by nobody
16
- # — ten of those and `/api/stream` 503s for the life of the process. A dead
17
- # holder is instead evicted by the next acquire.
20
+ # — a cap's worth of those and `/api/stream` 503s for the life of the
21
+ # process. A dead holder is instead evicted by the next acquire.
18
22
  module StreamConcurrencyGuard
19
23
  extend ActiveSupport::Concern
20
24
 
21
- MAX_CONCURRENT_STREAMS = 10
22
25
  RETRY_AFTER_SECONDS = 3
26
+ # Rails' generated puma.rb default (`RAILS_MAX_THREADS || 3`), used when
27
+ # neither Puma nor the environment says how many threads serve requests.
28
+ DEFAULT_SERVER_THREADS = 3
23
29
 
24
30
  @holders = []
25
31
  @lock = Mutex.new
26
32
 
27
33
  class << self
34
+ def max_streams
35
+ configured = ::Wurk::Web.config.max_streams
36
+ return Integer(configured) if configured
37
+
38
+ [server_threads / 2, 1].max
39
+ end
40
+
28
41
  # Reserve a stream slot for the calling thread; false when the cap is
29
42
  # already reached by threads that are still alive.
30
43
  def acquire
31
44
  @lock.synchronize do
32
45
  @holders.keep_if(&:alive?)
33
- return false if @holders.size >= MAX_CONCURRENT_STREAMS
46
+ return false if @holders.size >= max_streams
34
47
 
35
48
  @holders << Thread.current
36
49
  true
@@ -47,6 +60,17 @@ module Wurk
47
60
  @holders.delete_at(index) if index
48
61
  end
49
62
  end
63
+
64
+ private
65
+
66
+ # Puma's own max_threads when it booted through its launcher (`puma`,
67
+ # `rails s`), else RAILS_MAX_THREADS — what the generated puma.rb reads.
68
+ def server_threads
69
+ puma = ::Puma.cli_config&.options&.[](:max_threads) if defined?(::Puma) && ::Puma.respond_to?(:cli_config)
70
+ Integer(puma || ENV.fetch('RAILS_MAX_THREADS', DEFAULT_SERVER_THREADS))
71
+ rescue ::ArgumentError, ::TypeError
72
+ DEFAULT_SERVER_THREADS
73
+ end
50
74
  end
51
75
 
52
76
  private
@@ -34,6 +34,18 @@ module Wurk
34
34
  }
35
35
  end
36
36
 
37
+ # The paging fields every listing answers with. `page` is the page
38
+ # actually served (an out-of-range ?page= is clamped, so the SPA must
39
+ # adopt it) and `max_page` the deepest page the server will walk to.
40
+ # A filtered slice adds `filtered_total` — matching rows found — and
41
+ # `filtered_total_exact`; when false the count is a lower bound.
42
+ def meta(page)
43
+ fields = { page: page[:page], count: page[:count], max_page: MAX_PAGE }
44
+ return fields unless page.key?(:matched)
45
+
46
+ fields.merge(filtered_total: page[:matched], filtered_total_exact: page[:exhausted])
47
+ end
48
+
37
49
  def clamp_int(value, min, max, default)
38
50
  Integer(value, 10).clamp(min, max)
39
51
  rescue ::ArgumentError, ::TypeError
@@ -85,26 +97,32 @@ module Wurk
85
97
 
86
98
  # Filtered path: the offset counts MATCHING rows, so page boundaries
87
99
  # stay stable (raw-index offsets re-emit or skip matches across pages).
100
+ # Records the matches seen in `page[:matched]` and whether the set was
101
+ # walked to its end in `page[:exhausted]`: stopping once the page fills
102
+ # is what keeps a filtered request cheap, so the count is only exact
103
+ # when nothing was left unread.
88
104
  def slice_filtered(enumerable, offset, page)
89
105
  results = []
90
106
  matched = 0
91
107
  examined = 0
92
108
  enumerable.each do |member|
93
- examined += 1
94
- break if examined > FILTER_SCAN_LIMIT
109
+ break if (examined += 1) > FILTER_SCAN_LIMIT
95
110
 
96
111
  payload = yield(member)
97
112
  next unless payload && match?(payload, page[:substr])
98
113
 
99
- if matched >= offset
100
- results << payload
101
- break if results.size >= page[:count]
102
- end
103
- matched += 1
114
+ results << payload if (matched += 1) > offset
115
+ break if results.size >= page[:count]
104
116
  end
117
+ record_match_count(page, matched, examined, results.size)
105
118
  results
106
119
  end
107
120
 
121
+ def record_match_count(page, matched, examined, served)
122
+ page[:matched] = matched
123
+ page[:exhausted] = examined <= FILTER_SCAN_LIMIT && served < page[:count]
124
+ end
125
+
108
126
  def match?(payload, substr)
109
127
  return true if substr.nil? || substr.empty?
110
128
 
@@ -26,7 +26,9 @@ module Wurk
26
26
  include SseStreaming
27
27
 
28
28
  STREAM_TICK_SECONDS = 2.0
29
- STREAM_MAX_DURATION = 120.0
29
+ # Short so a capped slot rotates: a tab parked on the dashboard gives its
30
+ # thread back every half-minute and has to win a slot again to reopen.
31
+ STREAM_MAX_DURATION = 30.0
30
32
  # Positive floor for `?tick=`: a zero/negative tick would `sleep 0` the SSE
31
33
  # loop into a tight Redis-read/write spin (Live runs it in a spawned thread)
32
34
  # for up to STREAM_MAX_DURATION. Clamp before it reaches drive_stream.
@@ -69,7 +71,7 @@ module Wurk
69
71
  jobs = ::Wurk::Api::Pagination.slice(q, page) { |rec| ::Wurk::Api::Serializers.job_record(rec) }
70
72
  render json: {
71
73
  name: q.name, size: q.size, latency: q.latency, paused: q.paused?,
72
- page: page[:page], count: page[:count], jobs: jobs
74
+ **::Wurk::Api::Pagination.meta(page), jobs: jobs
73
75
  }
74
76
  end
75
77
 
@@ -83,7 +85,7 @@ module Wurk
83
85
  # (Fast::QueueExt#delete_job) instead of a paged find_job walk.
84
86
  def delete_queue_job
85
87
  removed = ::Wurk::Queue.new(params[:name].to_s).delete_job(params[:jid].to_s)
86
- return render(json: { error: 'unknown job' }, status: :not_found) if removed.zero?
88
+ return render(json: { error: 'unknown job' }, status: :not_found) if removed.nil?
87
89
 
88
90
  render json: { ok: true, deleted: true }
89
91
  end
@@ -125,12 +127,11 @@ module Wurk
125
127
 
126
128
  def scheduled_all
127
129
  set = ::Wurk::ScheduledSet.new
128
- count = case params[:cmd].to_s
129
- when 'delete' then clear_set(set)
130
- when 'add_to_queue' then drain_set(set, :add_to_queue)
131
- else return render(json: { error: 'unknown action' }, status: :bad_request)
132
- end
133
- render json: { ok: true, count: count }
130
+ case params[:cmd].to_s
131
+ when 'delete' then render json: { ok: true, count: clear_set(set) }
132
+ when 'add_to_queue' then drain_set(set, :add_to_queue)
133
+ else render(json: { error: 'unknown action' }, status: :bad_request)
134
+ end
134
135
  end
135
136
 
136
137
  # --- Dead set mutations --------------------------------------------------
@@ -170,7 +171,7 @@ module Wurk
170
171
  set = ::Wurk::BatchSet.new
171
172
  page = ::Wurk::Api::Pagination.window(params)
172
173
  rows = ::Wurk::Api::Pagination.slice(set, page) { |status| status.data.transform_keys(&:to_sym) }
173
- render json: { total: set.size, page: page[:page], count: page[:count], batches: rows }
174
+ render json: { total: set.size, **::Wurk::Api::Pagination.meta(page), batches: rows }
174
175
  end
175
176
 
176
177
  def batch
@@ -186,7 +187,7 @@ module Wurk
186
187
  set = ::Wurk::FlowSet.new
187
188
  page = ::Wurk::Api::Pagination.window(params)
188
189
  rows = ::Wurk::Api::Pagination.slice(set, page) { |status| ::Wurk::Api::Serializers.flow_row(status) }
189
- render json: { total: set.size, page: page[:page], count: page[:count], flows: rows }
190
+ render json: { total: set.size, **::Wurk::Api::Pagination.meta(page), flows: rows }
190
191
  end
191
192
 
192
193
  def flow
@@ -210,7 +211,7 @@ module Wurk
210
211
  def limiters
211
212
  names = ::Wurk::Web::Enterprise::Limits.list(filter: params[:substr])
212
213
  page = ::Wurk::Api::Pagination.window(params)
213
- render json: { total: names.size, page: page[:page], count: page[:count], limiters: limiter_rows(names, page) }
214
+ render json: { total: names.size, **::Wurk::Api::Pagination.meta(page), limiters: limiter_rows(names, page) }
214
215
  end
215
216
 
216
217
  def reset_limiter
@@ -312,9 +313,12 @@ module Wurk
312
313
 
313
314
  # SSE: one `event: stats` per tick with a fresh Stats snapshot. Caps at
314
315
  # `STREAM_MAX_DURATION` so a stale browser tab can't tie a Rails worker
315
- # forever — the client reconnects automatically when the stream closes.
316
- # Bounded per process by StreamConcurrencyGuard (503 + Retry-After past the
317
- # cap) so a burst of tabs can't pin every Puma thread.
316
+ # forever; a stream that closes normally is reopened by EventSource after
317
+ # the `retry:` interval sent below. Bounded per process by
318
+ # StreamConcurrencyGuard so a burst of tabs can't pin every Puma thread.
319
+ # Past the cap the answer is a 503, which EventSource treats as fatal and
320
+ # whose Retry-After it cannot read: the SPA reopens it itself, backing off
321
+ # from 3s and doubling to 30s (frontend/src/hooks/useSSE.ts).
318
322
  #
319
323
  # `?max_duration=` and `?tick=` are test/debug knobs; the SPA never sets
320
324
  # them. `?max_duration=0` emits one tick and closes.
@@ -373,27 +377,48 @@ module Wurk
373
377
  entries = entries_for(set, [params[:key]])
374
378
  return render(json: { error: 'unknown job' }, status: :not_found) if entries.empty?
375
379
 
376
- entries.each { |entry| entry.public_send(method) }
377
- render json: { ok: true, count: entries.size }
380
+ render_applied(entries.size, apply_each(entries, method))
378
381
  end
379
382
 
380
383
  # Bulk variant: `keys[]` + a single `cmd` applied to every resolved entry.
381
- def bulk_entry_action(set, actions) # rubocop:disable Metrics/AbcSize
384
+ def bulk_entry_action(set, actions)
382
385
  method = actions[params[:cmd].to_s]
383
386
  return render(json: { error: 'unknown action' }, status: :bad_request) unless method
384
387
 
385
- count = 0
386
388
  keys = Array(params[:keys]).map(&:to_s).uniq
387
389
  # Each key costs a ZRANGEBYSCORE plus per-entry mutations; an uncapped
388
390
  # list lets one POST issue tens of thousands of round-trips. The UI
389
391
  # selects at most a page (200).
390
392
  return render(json: { error: 'too many keys (max 1000)' }, status: :bad_request) if keys.size > 1000
391
393
 
392
- entries_for(set, keys).each do |entry|
394
+ entries = entries_for(set, keys)
395
+ render_applied(entries.size, apply_each(entries, method))
396
+ end
397
+
398
+ # One entry the client rejects (a payload failing validation, a raising
399
+ # middleware) must not 500 the request with every later entry unapplied.
400
+ # SortedEntry has already put a failed entry back in its set, so the
401
+ # failure is reported per key and the loop moves on. A Redis error is not
402
+ # per-entry — it propagates to the 503 handler instead of failing every
403
+ # remaining key one by one.
404
+ def apply_each(entries, method)
405
+ entries.each_with_object([]) do |entry, failures|
393
406
  entry.public_send(method)
394
- count += 1
407
+ rescue *::Wurk::Configuration::REDIS_ERROR_CLASSES
408
+ raise
409
+ rescue StandardError => e
410
+ ::Wurk.configuration.handle_exception(e, context: "Wurk::ApiController##{method}", jid: entry.jid)
411
+ failures << { key: entry.id, error: "#{e.class}: #{e.message}" }
395
412
  end
396
- render json: { ok: true, count: count }
413
+ end
414
+
415
+ # 422 when any entry failed, so the SPA's error toast fires; `count` is
416
+ # what was applied either way.
417
+ def render_applied(total, failures)
418
+ body = { ok: failures.empty?, count: total - failures.size }
419
+ return render(json: body) if failures.empty?
420
+
421
+ render json: body.merge(failed: failures), status: 422
397
422
  end
398
423
 
399
424
  # Sends `method` (:quiet! / :stop!) to one process by identity, or to every
@@ -442,15 +467,14 @@ module Wurk
442
467
  # Entries removed concurrently no-op their apply.
443
468
  def drain_set(set, method)
444
469
  entries = set.to_a
445
- entries.each { |entry| entry.public_send(method) }
446
- entries.size
470
+ render_applied(entries.size, apply_each(entries, method))
447
471
  end
448
472
 
449
473
  def render_sorted_set(set)
450
474
  page = ::Wurk::Api::Pagination.window(params)
451
475
  total = set.size
452
476
  entries = ::Wurk::Api::Pagination.slice(set, page) { |entry| ::Wurk::Api::Serializers.sorted_entry(entry) }
453
- render json: { total: total, page: page[:page], count: page[:count], entries: entries }
477
+ render json: { total: total, **::Wurk::Api::Pagination.meta(page), entries: entries }
454
478
  end
455
479
 
456
480
  # substr is already applied by Limits.list (matches on name), so slice the
@@ -37,11 +37,17 @@ module Wurk
37
37
  private
38
38
 
39
39
  def respond_with((status, headers, body))
40
- return redirect_to(headers['Location'], allow_other_host: false) if status == 302
40
+ return redirect_to(headers['Location'], allow_other_host: false) if status == 302 && headers['Location']
41
41
 
42
+ content_type = 'text/html; charset=utf-8'
43
+ headers.each do |key, value|
44
+ next content_type = value if key.casecmp?('content-type')
45
+
46
+ response.headers[key] = value
47
+ end
42
48
  # Extension output is host-registered server code, same trust model as
43
49
  # Sidekiq::Web rendering its extensions — not user input.
44
- render html: body.html_safe, layout: false, status: status
50
+ render body: body, content_type: content_type, status: status
45
51
  end
46
52
  end
47
53
  end
@@ -30,37 +30,73 @@ module Wurk
30
30
  # Firefox profiler store is a side effect — read-only deploys (e.g. the
31
31
  # public demo) must not let any visitor exfiltrate profiling data.
32
32
  return head(:forbidden) if ::Wurk::Web.config.read_only?
33
+ return head(:forbidden) unless navigated_from_dashboard?
33
34
 
34
- blob = profile_blob(params[:key])
35
- return head(:not_found) unless blob
36
-
37
- hash = upload_to_profiler(blob)
38
- return head(:bad_gateway) unless hash
35
+ sid = profile_sid(params[:key])
36
+ return head(:not_found) if sid == :missing
37
+ return head(:bad_gateway) unless sid
39
38
 
40
- redirect_to(format(::Wurk::Web.config.profile_view_url, hash), allow_other_host: true)
39
+ redirect_to(format(::Wurk::Web.config.profile_view_url, sid), allow_other_host: true)
41
40
  end
42
41
 
43
42
  private
44
43
 
44
+ # The upload is a side effect behind a GET, so a cross-site `<img src>`
45
+ # could trigger it with the operator's session. Allow the dashboard's own
46
+ # link (same-origin) and a typed URL or bookmark (`none`). A browser too
47
+ # old to send Sec-Fetch-Site still sends a Referer, which must then be
48
+ # this host.
49
+ def navigated_from_dashboard?
50
+ site = request.headers['Sec-Fetch-Site']
51
+ return %w[same-origin none].include?(site) if site
52
+
53
+ referer = URI.parse(request.referer.to_s)
54
+ referer.host == request.host && referer.port == request.port
55
+ rescue URI::InvalidURIError
56
+ false
57
+ end
58
+
45
59
  def profile_blob(key)
46
60
  ::Wurk::ProfileRecord.data_for(key)
47
61
  end
48
62
 
49
- # POSTs the gzipped profile to the Firefox profiler's compressed-store.
50
- # Returns the public hash (used to build the view URL) or nil on failure.
63
+ # The profile-store id, cached in the HASH's `sid` field after the first
64
+ # upload exactly as Sidekiq's Web UI does, so either UI reuses the other's
65
+ # upload. :missing when the profile is gone, nil when the upload failed.
66
+ def profile_sid(key)
67
+ sid = ::Wurk.redis { |c| c.call('HGET', key, 'sid') }
68
+ return sid if sid
69
+
70
+ blob = profile_blob(key)
71
+ return :missing unless blob
72
+
73
+ sid = upload_to_profiler(blob)
74
+ ::Wurk.redis { |c| c.call('HSET', key, 'sid', sid) } if sid
75
+ sid
76
+ end
77
+
78
+ # POSTs the gzipped profile to the Firefox profiler's compressed-store,
79
+ # which answers with a JWT whose payload carries the `profileToken` the
80
+ # view URL needs. Returns that token, or nil on failure.
51
81
  def upload_to_profiler(gzipped)
52
82
  uri = URI.parse(::Wurk::Web.config.profile_store_url)
53
83
  res = post_gzip(uri, gzipped)
54
- res.is_a?(Net::HTTPSuccess) ? res.body.to_s.strip : nil
84
+ res.is_a?(Net::HTTPSuccess) ? profile_token(res.body.to_s) : nil
55
85
  rescue StandardError => e
56
86
  Wurk.configuration.handle_exception(e, context: 'Wurk::ProfilesController#upload')
57
87
  nil
58
88
  end
59
89
 
90
+ def profile_token(jwt)
91
+ payload = jwt.strip.split('.')[1].to_s.tr('-_', '+/')
92
+ ::JSON.parse(payload.unpack1('m'))['profileToken']
93
+ end
94
+
60
95
  def post_gzip(uri, body)
61
96
  req = Net::HTTP::Post.new(uri)
62
97
  req['Content-Encoding'] = 'gzip'
63
98
  req['Content-Type'] = 'application/json'
99
+ req['Accept'] = 'application/vnd.firefox-profiler+json;version=1.0'
64
100
  req.body = body
65
101
  # Explicit timeouts so a slow/unreachable profiler can't tie up the Rails
66
102
  # request thread for Ruby's ~60s defaults.
data/config/routes.rb CHANGED
@@ -25,6 +25,17 @@ Wurk::Engine.routes.draw do
25
25
  # against the per-set whitelist and returns a JSON 400 for an unknown action,
26
26
  # matching the single/bulk contract. A route-level constraint would turn that
27
27
  # into a 404 route miss instead.
28
+ #
29
+ # Responses: 200 `{ ok: true, count }`; 400 `{ error }` for an unknown cmd
30
+ # or too many keys; 404 `{ error: 'unknown job' }` when a single key is
31
+ # gone. The single (`<set>/:key`), bulk (`<set>`) and
32
+ # `scheduled/all/add_to_queue` paths apply entry by entry and answer 422
33
+ # `{ ok: false, count, failed: [{ key, error }] }` when any entry raised —
34
+ # `count` is what WAS applied, so the client must refetch the list.
35
+ # Listings answer `{ total, page, count, max_page, entries }`; `page` is
36
+ # the page actually served (clamped to `max_page`), and a `?substr=`
37
+ # listing adds `filtered_total` + `filtered_total_exact` (false: lower
38
+ # bound).
28
39
  get 'retries', to: 'api#retries'
29
40
  post 'retries', to: 'api#retries_bulk', as: :api_retries_bulk
30
41
  post 'retries/all/:cmd', to: 'api#retries_all', as: :api_retries_all
@@ -51,17 +62,20 @@ Wurk::Engine.routes.draw do
51
62
  # read-only mode 403s it via the Authorization middleware, no guard needed
52
63
  # here.
53
64
  get 'flows', to: 'api#flows'
54
- get 'flows/:fid', to: 'api#flow', as: :api_flow
55
- post 'flows/:fid/abandon', to: 'api#abandon_flow', as: :api_abandon_flow
65
+ get 'flows/:fid', to: 'api#flow', as: :api_flow, constraints: { fid: %r{[^/]+} }
66
+ post 'flows/:fid/abandon', to: 'api#abandon_flow', as: :api_abandon_flow, constraints: { fid: %r{[^/]+} }
56
67
  get 'batches', to: 'api#batches'
57
- get 'batches/:bid', to: 'api#batch', as: :api_batch
68
+ get 'batches/:bid', to: 'api#batch', as: :api_batch, constraints: { bid: %r{[^/]+} }
58
69
  get 'limiters', to: 'api#limiters'
59
- post 'limiters/:name/reset', to: 'api#reset_limiter', as: :api_reset_limiter
70
+ # Limiter names, loop ids, flow and batch ids are user-chosen and may carry
71
+ # dots; without the segment constraint Rails reads `.b` as a format and the
72
+ # route 404s.
73
+ post 'limiters/:name/reset', to: 'api#reset_limiter', as: :api_reset_limiter, constraints: { name: %r{[^/]+} }
60
74
  get 'cron', to: 'api#cron'
61
- post 'cron/:lid/pause', to: 'api#pause_cron', as: :api_pause_cron
62
- post 'cron/:lid/unpause', to: 'api#unpause_cron', as: :api_unpause_cron
63
- post 'cron/:lid/enqueue', to: 'api#enqueue_cron', as: :api_enqueue_cron
64
- get 'cron/:lid/history', to: 'api#cron_history', as: :api_cron_history
75
+ post 'cron/:lid/pause', to: 'api#pause_cron', as: :api_pause_cron, constraints: { lid: %r{[^/]+} }
76
+ post 'cron/:lid/unpause', to: 'api#unpause_cron', as: :api_unpause_cron, constraints: { lid: %r{[^/]+} }
77
+ post 'cron/:lid/enqueue', to: 'api#enqueue_cron', as: :api_enqueue_cron, constraints: { lid: %r{[^/]+} }
78
+ get 'cron/:lid/history', to: 'api#cron_history', as: :api_cron_history, constraints: { lid: %r{[^/]+} }
65
79
  get 'metrics', to: 'api#metrics'
66
80
  get 'metrics/:klass', to: 'api#metrics_for_job', as: :api_metrics_for_job, constraints: { klass: %r{[^/]+} }
67
81
  get 'history/snapshots', to: 'api#history_snapshots', as: :api_history_snapshots
@@ -109,6 +123,13 @@ Wurk::Engine.routes.draw do
109
123
  get 'ext-assets/:name/*file', to: 'extensions#asset', as: :extension_asset,
110
124
  format: false, constraints: { name: %r{[^/]+} }
111
125
 
112
- # SPA catch-all — let the SolidJS router handle the rest.
113
- get '*path', to: 'dashboard#index', constraints: ->(req) { req.format == :html }
126
+ # SPA catch-all — let the SolidJS router handle the rest. `format: false`
127
+ # because client routes carry user-chosen dots (`/queues/emails.critical`,
128
+ # `/retries/1700000000.5-jid`) that Rails would otherwise read as a format
129
+ # and 404 before the SPA loads; the HTML check then runs on Accept alone. A
130
+ # path ending in a static-file extension still 404s, so a stale asset or
131
+ # source-map URL doesn't come back as a 200 HTML page.
132
+ static_file = /\.(?:js|mjs|css|map|json|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|txt)\z/i
133
+ get '*path', to: 'dashboard#index', format: false,
134
+ constraints: ->(req) { req.format == :html && !req.path.match?(static_file) }
114
135
  end
@@ -16,7 +16,7 @@ Wurk.configure_server do |config|
16
16
  # Seconds to let in-flight jobs finish on shutdown (Sidekiq-compatible key):
17
17
  # config[:timeout] = 25
18
18
 
19
- # By default Wurk forks one worker process per CPU core (set WURK_COUNT, or
19
+ # By default Wurk forks one worker process per usable CPU (cgroup quota aware) (set WURK_COUNT, or
20
20
  # SIDEKIQ_COUNT, to override). For full control declare a topology — `flat`
21
21
  # spawns N identical forks; use `slot`s for dedicated queues. Total in-flight
22
22
  # jobs = forks × concurrency. See docs/idea/03-process-model.md.
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
+ require 'wurk'