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.
- checksums.yaml +4 -4
- data/README.md +7 -4
- data/app/controllers/concerns/wurk/sse_streaming.rb +2 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +33 -9
- data/app/controllers/wurk/api/pagination.rb +25 -7
- data/app/controllers/wurk/api_controller.rb +49 -25
- data/app/controllers/wurk/extensions_controller.rb +8 -2
- data/app/controllers/wurk/profiles_controller.rb +45 -9
- data/config/routes.rb +31 -10
- data/lib/generators/wurk/install/templates/wurk.rb +1 -1
- data/lib/sidekiq/capsule.rb +4 -0
- data/lib/sidekiq/component.rb +4 -0
- data/lib/sidekiq/config.rb +4 -0
- data/lib/sidekiq/deploy.rb +4 -0
- data/lib/sidekiq/embedded.rb +4 -0
- data/lib/sidekiq/fetch.rb +4 -0
- data/lib/sidekiq/iterable_job.rb +4 -0
- data/lib/sidekiq/job/interrupt_handler.rb +4 -0
- data/lib/sidekiq/job/iterable/active_record_enumerator.rb +4 -0
- data/lib/sidekiq/job/iterable/csv_enumerator.rb +4 -0
- data/lib/sidekiq/job/iterable.rb +4 -0
- data/lib/sidekiq/job_logger.rb +4 -0
- data/lib/sidekiq/job_util.rb +4 -0
- data/lib/sidekiq/loader.rb +4 -0
- data/lib/sidekiq/logger.rb +4 -0
- data/lib/sidekiq/metrics/query.rb +4 -0
- data/lib/sidekiq/middleware/current_attributes.rb +7 -0
- data/lib/sidekiq/middleware/i18n.rb +6 -0
- data/lib/sidekiq/middleware/modules.rb +4 -0
- data/lib/sidekiq/pro/web.rb +5 -0
- data/lib/sidekiq/profiler.rb +4 -0
- data/lib/sidekiq/redis_client_adapter.rb +4 -0
- data/lib/sidekiq/test_api.rb +5 -0
- data/lib/sidekiq/testing/inline.rb +8 -0
- data/lib/sidekiq/transaction_aware_client.rb +4 -0
- data/lib/sidekiq/worker_compatibility_alias.rb +4 -0
- data/lib/sidekiq-ent/periodic/testing.rb +5 -0
- data/lib/sidekiq-ent/web.rb +5 -0
- data/lib/sidekiq-ent.rb +7 -0
- data/lib/sidekiq-pro.rb +7 -0
- data/lib/wurk/api/fast.rb +8 -33
- data/lib/wurk/api/idempotency.rb +28 -5
- data/lib/wurk/api/swarm.rb +1 -1
- data/lib/wurk/batch/callbacks.rb +143 -92
- data/lib/wurk/batch/death_handler.rb +19 -8
- data/lib/wurk/batch/server_middleware.rb +37 -24
- data/lib/wurk/batch/status.rb +49 -15
- data/lib/wurk/batch.rb +164 -65
- data/lib/wurk/capsule.rb +0 -2
- data/lib/wurk/client/buffered.rb +227 -106
- data/lib/wurk/client.rb +33 -13
- data/lib/wurk/compat.rb +13 -0
- data/lib/wurk/configuration.rb +155 -24
- data/lib/wurk/cpu_count.rb +97 -0
- data/lib/wurk/cron.rb +97 -34
- data/lib/wurk/dead_set.rb +6 -7
- data/lib/wurk/encryption.rb +46 -13
- data/lib/wurk/fetcher/many_queues.rb +65 -0
- data/lib/wurk/fetcher/orphan_grace.rb +67 -0
- data/lib/wurk/fetcher/private_list_key.rb +76 -0
- data/lib/wurk/fetcher/reaper.rb +55 -74
- data/lib/wurk/fetcher/reliable.rb +23 -17
- data/lib/wurk/fetcher/unit_of_work.rb +19 -1
- data/lib/wurk/fetcher/unparseable_keys.rb +56 -0
- data/lib/wurk/flow/completion.rb +28 -3
- data/lib/wurk/flow/creation.rb +24 -4
- data/lib/wurk/flow.rb +24 -2
- data/lib/wurk/health.rb +182 -40
- data/lib/wurk/heartbeat.rb +37 -14
- data/lib/wurk/history.rb +7 -1
- data/lib/wurk/import/sidekiq_cron.rb +248 -0
- data/lib/wurk/job/options.rb +22 -3
- data/lib/wurk/job_logger.rb +13 -3
- data/lib/wurk/job_record.rb +68 -19
- data/lib/wurk/job_retry.rb +12 -6
- data/lib/wurk/job_set.rb +99 -51
- data/lib/wurk/job_util.rb +27 -8
- data/lib/wurk/launcher.rb +84 -40
- data/lib/wurk/leader.rb +53 -28
- data/lib/wurk/limiter/base.rb +11 -30
- data/lib/wurk/limiter/bucket.rb +14 -5
- data/lib/wurk/limiter/server_middleware.rb +2 -1
- data/lib/wurk/limiter.rb +1 -1
- data/lib/wurk/loader.rb +48 -0
- data/lib/wurk/lua/batch_ack_complete.lua +28 -0
- data/lib/wurk/lua/batch_ack_failed.lua +18 -0
- data/lib/wurk/lua/batch_ack_success.lua +35 -0
- data/lib/wurk/lua/batch_append_callback.lua +66 -0
- data/lib/wurk/lua/batch_delete.lua +31 -0
- data/lib/wurk/lua/batch_hold.lua +17 -0
- data/lib/wurk/lua/batch_invalidate.lua +14 -0
- data/lib/wurk/lua/batch_kid_done.lua +14 -0
- data/lib/wurk/lua/batch_push.lua +47 -0
- data/lib/wurk/lua/batch_remove_jobs.lua +29 -0
- data/lib/wurk/lua/batch_schedule.lua +27 -0
- data/lib/wurk/lua/fetch_first.lua +27 -0
- data/lib/wurk/lua/flow_abandon.lua +16 -21
- data/lib/wurk/lua/flow_advance.lua +41 -26
- data/lib/wurk/lua/flow_create.lua +24 -9
- data/lib/wurk/lua/flow_fail.lua +3 -3
- data/lib/wurk/lua/leader_campaign.lua +19 -0
- data/lib/wurk/lua/limiter_bucket_acquire.lua +3 -2
- data/lib/wurk/lua/limiter_list_sweep.lua +8 -9
- data/lib/wurk/lua/loader.rb +36 -23
- data/lib/wurk/lua/throttle_slot.lua +13 -26
- data/lib/wurk/lua.rb +31 -244
- data/lib/wurk/manager.rb +25 -18
- data/lib/wurk/metrics/accumulator.rb +31 -10
- data/lib/wurk/metrics/dashboard_series.rb +163 -0
- data/lib/wurk/metrics/histogram.rb +46 -0
- data/lib/wurk/metrics/history.rb +49 -57
- data/lib/wurk/metrics/prometheus.rb +232 -0
- data/lib/wurk/metrics/query.rb +127 -155
- data/lib/wurk/metrics/rollup.rb +2 -2
- data/lib/wurk/metrics/statsd.rb +1 -1
- data/lib/wurk/middleware/interrupt_handler.rb +6 -2
- data/lib/wurk/middleware/poison_pill.rb +5 -4
- data/lib/wurk/middleware/timeout.rb +8 -8
- data/lib/wurk/process_set.rb +5 -5
- data/lib/wurk/processor.rb +69 -31
- data/lib/wurk/profile_set.rb +19 -24
- data/lib/wurk/profiler.rb +48 -50
- data/lib/wurk/queue.rb +36 -19
- data/lib/wurk/rails_boot.rb +33 -1
- data/lib/wurk/railtie.rb +2 -0
- data/lib/wurk/rake_tasks.rb +18 -0
- data/lib/wurk/redact.rb +39 -0
- data/lib/wurk/redis_connection.rb +6 -3
- data/lib/wurk/redis_options.rb +21 -4
- data/lib/wurk/redis_pool.rb +119 -21
- data/lib/wurk/scheduled.rb +44 -11
- data/lib/wurk/sentry/error_handler.rb +53 -7
- data/lib/wurk/sentry/middleware.rb +7 -8
- data/lib/wurk/shutdown_gate.rb +24 -1
- data/lib/wurk/sorted_entry.rb +16 -5
- data/lib/wurk/stats.rb +9 -4
- data/lib/wurk/status.rb +2 -2
- data/lib/wurk/swarm/child_boot.rb +48 -38
- data/lib/wurk/swarm/liveness.rb +153 -0
- data/lib/wurk/swarm/restart.rb +24 -3
- data/lib/wurk/swarm.rb +115 -24
- data/lib/wurk/telemetry/server_middleware.rb +2 -5
- data/lib/wurk/testing.rb +15 -5
- data/lib/wurk/throttle.rb +16 -1
- data/lib/wurk/topology.rb +1 -5
- data/lib/wurk/unique.rb +1 -1
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/watchdog.rb +2 -2
- data/lib/wurk/web/config.rb +56 -20
- data/lib/wurk/web/enterprise.rb +21 -18
- data/lib/wurk/web/extension.rb +98 -22
- data/lib/wurk/web/rack_app.rb +12 -11
- data/lib/wurk/web/search.rb +18 -8
- data/lib/wurk/worker/setter.rb +17 -14
- data/lib/wurk/worker.rb +6 -67
- data/lib/wurk.rb +2 -3
- data/vendor/assets/dashboard/assets/ArgsValue-DsPb_KZ-.js +1 -0
- data/vendor/assets/dashboard/assets/BatchDetail-BCIXJOpZ.js +1 -0
- data/vendor/assets/dashboard/assets/Batches-IjaQfXW6.js +1 -0
- data/vendor/assets/dashboard/assets/Busy-CaQrf1Ve.js +1 -0
- data/vendor/assets/dashboard/assets/Cron-B18EHn_Y.js +1 -0
- data/vendor/assets/dashboard/assets/Dashboard-DdT5ZaUg.js +1 -0
- data/vendor/assets/dashboard/assets/Dead-DWIy-Mu8.js +1 -0
- data/vendor/assets/dashboard/assets/Extension-BjBUWhHf.js +1 -0
- data/vendor/assets/dashboard/assets/FilterBox-DwH3PXXt.js +1 -0
- data/vendor/assets/dashboard/assets/FlowDetail-DnexL72n.js +1 -0
- data/vendor/assets/dashboard/assets/FlowState-Cr4aX6-d.js +1 -0
- data/vendor/assets/dashboard/assets/Flows-DO6EimbZ.js +1 -0
- data/vendor/assets/dashboard/assets/JobDetailModal-aPjgq-U9.js +2 -0
- data/vendor/assets/dashboard/assets/Limiters-DxpwWCNe.js +1 -0
- data/vendor/assets/dashboard/assets/Metrics-C3JL1GIH.js +1 -0
- data/vendor/assets/dashboard/assets/NotFound-BfP9uAV9.js +1 -0
- data/vendor/assets/dashboard/assets/PageHeader-DbC97afG.js +1 -0
- data/vendor/assets/dashboard/assets/Profiles-BcaHreMe.js +1 -0
- data/vendor/assets/dashboard/assets/Queues-ZlBc5tyD.js +1 -0
- data/vendor/assets/dashboard/assets/Retries-Bg3nkeiL.js +1 -0
- data/vendor/assets/dashboard/assets/Scheduled-BrMZPZGl.js +1 -0
- data/vendor/assets/dashboard/assets/Search-B5X5pGht.js +1 -0
- data/vendor/assets/dashboard/assets/charts-uDyf4PN0.js +1 -0
- data/vendor/assets/dashboard/assets/i18n-CJeYfeVt.js +1 -0
- data/vendor/assets/dashboard/assets/index-B36QCAf-.css +1 -0
- data/vendor/assets/dashboard/assets/index-DQujgZgH.js +141 -0
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-O3-5rMk3.js +1 -0
- data/vendor/assets/dashboard/assets/useSort-xfnwjQAu.js +1 -0
- data/vendor/assets/dashboard/assets/utils-BIKHwNKB.js +1 -0
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +90 -34
- data/config/locales/en.yml +0 -15
- data/vendor/assets/dashboard/assets/ArgsValue-D-x_ifLY.js +0 -1
- data/vendor/assets/dashboard/assets/BatchDetail-C39NJuew.js +0 -1
- data/vendor/assets/dashboard/assets/Batches-CSwo7Asa.js +0 -1
- data/vendor/assets/dashboard/assets/Busy-BOFMu-sq.js +0 -1
- data/vendor/assets/dashboard/assets/Cron-Dy8RQzDI.js +0 -1
- data/vendor/assets/dashboard/assets/Dashboard-BuTHI-O1.js +0 -1
- data/vendor/assets/dashboard/assets/Dead-B9KRvQ0N.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-BnBVHfux.js +0 -1
- data/vendor/assets/dashboard/assets/FilterBox-DC24zite.js +0 -1
- data/vendor/assets/dashboard/assets/FlowDetail-DyLuzUvt.js +0 -1
- data/vendor/assets/dashboard/assets/FlowState-DAPKUahm.js +0 -1
- data/vendor/assets/dashboard/assets/Flows-Fr3rjZM_.js +0 -1
- data/vendor/assets/dashboard/assets/JobDetailModal-N6kiJXq3.js +0 -2
- data/vendor/assets/dashboard/assets/Limiters-kbFA7uS1.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-Dj2uoZ3o.js +0 -1
- data/vendor/assets/dashboard/assets/PageHeader-B_F94azl.js +0 -1
- data/vendor/assets/dashboard/assets/Profiles-D_DjEezN.js +0 -1
- data/vendor/assets/dashboard/assets/Queues-CO4V9hAz.js +0 -1
- data/vendor/assets/dashboard/assets/Retries-DCWnzeLa.js +0 -1
- data/vendor/assets/dashboard/assets/Scheduled-BebDUjLU.js +0 -1
- data/vendor/assets/dashboard/assets/Search-Cvr5fy4Y.js +0 -1
- data/vendor/assets/dashboard/assets/Skeleton-Bu3Ke6rV.js +0 -1
- data/vendor/assets/dashboard/assets/charts-BCs9bQKz.js +0 -1
- data/vendor/assets/dashboard/assets/index-BIwyOC5Q.js +0 -141
- data/vendor/assets/dashboard/assets/index-DBQN6Jk8.css +0 -1
- data/vendor/assets/dashboard/assets/useResetPageOnEmpty-Bzh-BJyL.js +0 -1
- data/vendor/assets/dashboard/assets/useSort-COA3fVJ5.js +0 -1
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d5734527cfa0b903c07b143f7eb0b821fca970cd98a8773f16cbdc12dcbb43c
|
|
4
|
+
data.tar.gz: e3e610d19a29b71e071896a45710a807928123bb0c530348fb809dafb101cfe3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c08daf7f94145029bcfd74e49c4426c8881effdce3aa65329453c5e7c2930ed87d93ac6ea85423ea8ee6a9df3465152837ebac692abfff3376623e69198209bc
|
|
7
|
+
data.tar.gz: a8374380a15e20e599374bdd7afe6e911a3364b6f1acb532fca2ece894d31d8a113baf4375773a7b94ae2fb0a4a1da98767ca16667b705a53c8f58fdde9ad9d0
|
data/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
[](https://wurk.demo.developerz.ai/wurk)
|
|
14
14
|
[](https://rubygems.org/gems/wurk)
|
|
15
15
|
[](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
|
|
16
|
-
[](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
|
|
16
|
+
[](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
|
|
17
17
|
[](https://www.ruby-lang.org)
|
|
18
18
|
[](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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
-
# —
|
|
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 >=
|
|
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
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
316
|
-
#
|
|
317
|
-
#
|
|
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.
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
35
|
-
return head(:not_found)
|
|
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,
|
|
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
|
-
#
|
|
50
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|