wurk 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +73 -0
  4. data/LICENSE +21 -0
  5. data/README.md +137 -0
  6. data/SECURITY.md +39 -0
  7. data/app/controllers/wurk/api/pagination.rb +67 -0
  8. data/app/controllers/wurk/api/serializers.rb +131 -0
  9. data/app/controllers/wurk/api_controller.rb +248 -0
  10. data/app/controllers/wurk/application_controller.rb +7 -0
  11. data/app/controllers/wurk/dashboard_controller.rb +48 -0
  12. data/config/locales/en.yml +15 -0
  13. data/config/routes.rb +34 -0
  14. data/exe/wurk +22 -0
  15. data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
  16. data/lib/generators/wurk/install/install_generator.rb +22 -0
  17. data/lib/generators/wurk/install/templates/wurk.rb +16 -0
  18. data/lib/wurk/active_job/wrapper.rb +32 -0
  19. data/lib/wurk/api/fast.rb +78 -0
  20. data/lib/wurk/batch/buffer.rb +26 -0
  21. data/lib/wurk/batch/callback_job.rb +37 -0
  22. data/lib/wurk/batch/callbacks.rb +176 -0
  23. data/lib/wurk/batch/client_middleware.rb +27 -0
  24. data/lib/wurk/batch/death_handler.rb +39 -0
  25. data/lib/wurk/batch/empty.rb +21 -0
  26. data/lib/wurk/batch/server_middleware.rb +62 -0
  27. data/lib/wurk/batch/status.rb +140 -0
  28. data/lib/wurk/batch.rb +351 -0
  29. data/lib/wurk/batch_set.rb +67 -0
  30. data/lib/wurk/capsule.rb +176 -0
  31. data/lib/wurk/cli.rb +349 -0
  32. data/lib/wurk/client/buffered.rb +372 -0
  33. data/lib/wurk/client.rb +330 -0
  34. data/lib/wurk/compat.rb +136 -0
  35. data/lib/wurk/component.rb +136 -0
  36. data/lib/wurk/configuration.rb +373 -0
  37. data/lib/wurk/context.rb +35 -0
  38. data/lib/wurk/cron.rb +636 -0
  39. data/lib/wurk/dashboard_manifest.rb +39 -0
  40. data/lib/wurk/dead_set.rb +78 -0
  41. data/lib/wurk/deploy.rb +91 -0
  42. data/lib/wurk/embedded.rb +94 -0
  43. data/lib/wurk/encryption.rb +276 -0
  44. data/lib/wurk/engine.rb +81 -0
  45. data/lib/wurk/fetcher/reaper.rb +264 -0
  46. data/lib/wurk/fetcher/reliable.rb +138 -0
  47. data/lib/wurk/fetcher.rb +11 -0
  48. data/lib/wurk/health.rb +193 -0
  49. data/lib/wurk/heartbeat.rb +211 -0
  50. data/lib/wurk/iterable_job.rb +292 -0
  51. data/lib/wurk/job/options.rb +70 -0
  52. data/lib/wurk/job.rb +33 -0
  53. data/lib/wurk/job_logger.rb +68 -0
  54. data/lib/wurk/job_record.rb +156 -0
  55. data/lib/wurk/job_retry.rb +320 -0
  56. data/lib/wurk/job_set.rb +212 -0
  57. data/lib/wurk/job_util.rb +162 -0
  58. data/lib/wurk/keys.rb +52 -0
  59. data/lib/wurk/launcher.rb +289 -0
  60. data/lib/wurk/leader.rb +221 -0
  61. data/lib/wurk/limiter/base.rb +138 -0
  62. data/lib/wurk/limiter/bucket.rb +80 -0
  63. data/lib/wurk/limiter/concurrent.rb +132 -0
  64. data/lib/wurk/limiter/leaky.rb +91 -0
  65. data/lib/wurk/limiter/points.rb +89 -0
  66. data/lib/wurk/limiter/server_middleware.rb +77 -0
  67. data/lib/wurk/limiter/unlimited.rb +48 -0
  68. data/lib/wurk/limiter/window.rb +80 -0
  69. data/lib/wurk/limiter.rb +255 -0
  70. data/lib/wurk/logger.rb +81 -0
  71. data/lib/wurk/lua/loader.rb +53 -0
  72. data/lib/wurk/lua.rb +187 -0
  73. data/lib/wurk/manager.rb +132 -0
  74. data/lib/wurk/metrics/history.rb +151 -0
  75. data/lib/wurk/metrics/query.rb +173 -0
  76. data/lib/wurk/metrics/rollup.rb +169 -0
  77. data/lib/wurk/metrics/statsd.rb +197 -0
  78. data/lib/wurk/metrics.rb +7 -0
  79. data/lib/wurk/middleware/chain.rb +128 -0
  80. data/lib/wurk/middleware/current_attributes.rb +87 -0
  81. data/lib/wurk/middleware/expiry.rb +50 -0
  82. data/lib/wurk/middleware/i18n.rb +63 -0
  83. data/lib/wurk/middleware/interrupt_handler.rb +45 -0
  84. data/lib/wurk/middleware/poison_pill.rb +149 -0
  85. data/lib/wurk/middleware.rb +34 -0
  86. data/lib/wurk/process_set.rb +243 -0
  87. data/lib/wurk/processor.rb +247 -0
  88. data/lib/wurk/queue.rb +108 -0
  89. data/lib/wurk/queues.rb +80 -0
  90. data/lib/wurk/rails.rb +9 -0
  91. data/lib/wurk/railtie.rb +28 -0
  92. data/lib/wurk/redis_pool.rb +79 -0
  93. data/lib/wurk/retry_set.rb +17 -0
  94. data/lib/wurk/scheduled.rb +189 -0
  95. data/lib/wurk/scheduled_set.rb +18 -0
  96. data/lib/wurk/sorted_entry.rb +95 -0
  97. data/lib/wurk/stats.rb +190 -0
  98. data/lib/wurk/swarm/child_boot.rb +105 -0
  99. data/lib/wurk/swarm.rb +260 -0
  100. data/lib/wurk/testing.rb +102 -0
  101. data/lib/wurk/topology.rb +74 -0
  102. data/lib/wurk/unique.rb +240 -0
  103. data/lib/wurk/version.rb +5 -0
  104. data/lib/wurk/web/config.rb +180 -0
  105. data/lib/wurk/web/enterprise.rb +138 -0
  106. data/lib/wurk/web/search.rb +139 -0
  107. data/lib/wurk/web.rb +25 -0
  108. data/lib/wurk/work_set.rb +116 -0
  109. data/lib/wurk/worker/setter.rb +93 -0
  110. data/lib/wurk/worker.rb +216 -0
  111. data/lib/wurk.rb +238 -0
  112. data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
  113. data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
  114. data/vendor/assets/dashboard/index.html +13 -0
  115. data/vendor/assets/dashboard/wurk-manifest.json +4 -0
  116. metadata +232 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ccc98d05b27f80d2950f50c1a6275c4118bf335094a35442dd9a527cc11d3b90
4
+ data.tar.gz: 44689a145f4e136572a6c896c6aecbcf53b48444f76d74d4e204412ade2f6417
5
+ SHA512:
6
+ metadata.gz: 031d251be8efbd5a640f062e6fe8cd95705cc60e814a88f237500312c839cc3200f5fa868408904cf2614dffd0cd5e8ee9d1966d802846a000ea578c285af606
7
+ data.tar.gz: b0c73f74ced829f68b730861bd4f9c9976ba5693d2adf17378e0433f7b3b910acbf1944d1988eb640db7d2494c16baad2609d7729ab3998f814b87a9d0e77ca1
data/CHANGELOG.md ADDED
@@ -0,0 +1,43 @@
1
+ # Changelog
2
+
3
+ All notable changes to Wurk are recorded here. Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versioning: [Semantic Versioning](https://semver.org/).
4
+
5
+ ## [Unreleased]
6
+
7
+ ### Added
8
+ - `Sidekiq::Testing` drop-in: `inline!` / `fake!` / `disable!` (global or block-scoped), the in-memory `Sidekiq::Queues` store, and the `Worker`/`Job` test helpers (`.jobs`, `.clear`, `.drain`, `.perform_one`, `.process_job`, `.drain_all`, `.clear_all`) + `Sidekiq::EmptyQueueError`.
9
+
10
+ ## [0.0.1] - 2026-06-01
11
+
12
+ First public (pre-1.0) release. Wurk is a 100% API-compatible drop-in replacement for Sidekiq + Sidekiq Pro + Sidekiq Enterprise — same Redis key schema, same job JSON, same Ruby DSL — with fork-based real parallelism, the full Pro + Enterprise feature set in one free gem (no license check), and a precompiled React dashboard.
13
+
14
+ ### Runtime
15
+ - Fork-based Swarm: parent forks N children with PID supervision, rolling restart (SIGUSR1), graceful drain (SIGTERM/SIGINT) to `shutdown_timeout`, and global pause/resume (SIGTSTP/SIGCONT).
16
+ - Reliable fetcher — atomic `BLMOVE` from the main queue to a per-process private list — with orphan reclamation on boot; Processor runs the middleware chain; Manager owns the thread pool, lifecycle, and heartbeat.
17
+ - Scheduled-set and retry pollers; EVALSHA-cached Lua on the hot paths; per-fork Redis pool over redis-client; Redis-outage client buffer.
18
+ - `reliable_push` opt-in `:raise` overflow policy with a background drainer.
19
+ - Cluster leader election with periodic firing consolidated onto the leader.
20
+ - StatsD metrics across hot paths; liveness HTTP endpoint for Kubernetes probes; expired-job counter for `expires_in`.
21
+
22
+ ### Batches
23
+ - Sidekiq Pro Batch API: `on(:success/:complete/:death)` callbacks, live progress, nested batches, autoflush + linger, nested death cascade, and per-callback rescue.
24
+
25
+ ### Limiters
26
+ - Enterprise rate limiters — concurrent, bucket, window, leaky, and points — each exposing a uniform live `#status` (`used`/`limit`/`reset_at`/`available?`); concurrent additionally reports metric counters. Includes a poison-pill brake.
27
+
28
+ ### Periodic
29
+ - Cron loops (sidekiq-cron compatible): schedule parsing with timezone/DST handling, leader-gated firing, pause/resume, enqueue-now, and fire history.
30
+
31
+ ### Encryption
32
+ - Transparent job-payload encryption with key rotation and graceful failure modes (a decryption failure degrades rather than crashing the worker).
33
+
34
+ ### Dashboard
35
+ - Precompiled React + TypeScript SPA mounted under the engine — consumers never run Node. Tabs: queues, retries, scheduled, dead, busy, processes, batches (+ per-batch detail), limiters, periodic, metrics, and search. SSE live updates, read-only mode, pagination, i18n with host-app override, and a dark default theme.
36
+
37
+ ### Compat
38
+ - `Sidekiq::*` aliases for every public `Wurk::*` class (`Sidekiq::Worker`, `Sidekiq::Batch`, `Sidekiq::Limiter`, `Sidekiq.configure_server`, …) — the drop-in contract.
39
+ - ActiveJob adapter, `IterableJob`, embedded mode, and a standalone `exe/wurk` runner.
40
+ - Sidekiq client/server middleware contract; third-party ecosystem suites (sidekiq-cron, sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures, sidekiq-throttled) pass against Wurk.
41
+
42
+ [Unreleased]: https://github.com/developerz-ai/wurk/compare/v0.0.1...HEAD
43
+ [0.0.1]: https://github.com/developerz-ai/wurk/releases/tag/v0.0.1
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,73 @@
1
+ # Contributing to Wurk
2
+
3
+ Thanks for helping make Wurk better. This guide covers local setup, the test
4
+ layers, and the conventions a change has to follow to merge.
5
+
6
+ ## Setup
7
+
8
+ ```sh
9
+ git clone https://github.com/developerz-ai/wurk
10
+ cd wurk
11
+ bundle install
12
+ ```
13
+
14
+ You'll need a local **Redis ≥ 7.0** running (tests use real Redis, never a mock).
15
+ Node is only needed if you touch the dashboard frontend (`frontend/`); the
16
+ precompiled bundle is committed under `vendor/assets/`.
17
+
18
+ ## Running the tests
19
+
20
+ | Task | Command |
21
+ |---|---|
22
+ | Full suite (parallel) | `bin/rake test` |
23
+ | A single file | `bin/rake test TEST=test/path/to/file_test.rb` |
24
+ | A single test by name | `bin/rake test TEST=test/foo_test.rb TESTOPTS="--name=/pattern/"` |
25
+ | Parity suite (oracles lifted from Sidekiq) | `bin/rake test:parity` |
26
+ | Ecosystem compatibility | `bin/rake test:ecosystem` |
27
+ | Coverage gate | `COVERAGE=1 bin/rake test` |
28
+ | Benchmarks | `bin/rake bench` |
29
+ | Lint | `bundle exec rubocop` |
30
+
31
+ Test layers:
32
+
33
+ - **unit** — plain Ruby classes in isolation.
34
+ - **engine** — boots the dummy Rails app in `test/dummy/`.
35
+ - **integration** — real forks + real Redis.
36
+ - **parity** (`test/parity/`) — tests lifted from upstream Sidekiq, SHA-pinned.
37
+ These are **oracles**: if Wurk diverges, Wurk is wrong unless the divergence
38
+ is explicitly documented as intentional.
39
+ - **ecosystem** — third-party Sidekiq gem suites run against Wurk.
40
+
41
+ Never mock Redis in integration or parity tests. Each test uses a unique Redis
42
+ key namespace so the parallel runner stays safe.
43
+
44
+ ## Conventions
45
+
46
+ These are non-negotiable — they're what keep Wurk a true drop-in:
47
+
48
+ - **Wire-compat is sacred.** Never change a Redis key, JSON field, or
49
+ sorted-set score format. If an optimization would break compatibility, drop
50
+ the optimization.
51
+ - **SOLID, especially SRP.** One reason to change per class — Manager owns
52
+ lifecycle, Fetcher owns the Redis pop, Processor owns middleware + perform,
53
+ Client owns enqueue.
54
+ - **Match the spec.** Any public Sidekiq surface must match
55
+ `docs/target/sidekiq-{free,pro,ent}.md` exactly.
56
+ - **Frozen string literals everywhere**; per-fork Redis pools (never share a
57
+ socket across a fork).
58
+ - **Comments explain non-obvious _why_**, never restate the code.
59
+ - **Coverage**: line coverage on `lib/` must stay ≥ 90% (the gate blocks PRs
60
+ below it). Branch coverage is tracked and ratcheting toward 90%.
61
+
62
+ ## Pull requests
63
+
64
+ 1. Branch off `main`.
65
+ 2. Keep the change focused; add tests at the right layer.
66
+ 3. Run `bin/rake test`, `bin/rake test:parity`, `bin/rake test:ecosystem`, and `bundle exec rubocop` locally.
67
+ 4. Open the PR — CI runs the matrix (Ruby 3.2/3.3/3.4 × Rails 7.2/8.0), the
68
+ coverage gate, the parity job, and benchmarks. The bench bot comments
69
+ per-benchmark deltas; a real regression fails the check.
70
+ 5. Don't `--no-verify` past a failing hook — fix the hook.
71
+
72
+ By contributing you agree your work is licensed under the project's
73
+ [MIT License](LICENSE).
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+ # Wurk ⚡
2
+
3
+ **A 100% drop-in replacement for Sidekiq + Sidekiq Pro + Sidekiq Enterprise. Free forever. Faster.**
4
+
5
+ [![Gem Version](https://img.shields.io/gem/v/wurk.svg)](https://rubygems.org/gems/wurk)
6
+ [![CI](https://github.com/developerz-ai/wurk/actions/workflows/test.yml/badge.svg)](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
7
+ [![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)
8
+ [![Ruby](https://img.shields.io/badge/ruby-%E2%89%A5%203.2-CC342D.svg)](https://www.ruby-lang.org)
9
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
10
+
11
+ 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.
12
+
13
+ ## Install
14
+
15
+ ```ruby
16
+ # Gemfile
17
+ gem "wurk"
18
+ ```
19
+
20
+ ```diff
21
+ # ...or drop in over an existing Sidekiq stack — delete these, add one line:
22
+ - gem "sidekiq"
23
+ - gem "sidekiq-pro", source: "https://gems.contribsys.com/"
24
+ - gem "sidekiq-ent", source: "https://enterprise.contribsys.com/"
25
+ + gem "wurk"
26
+ ```
27
+
28
+ `bundle install && restart`. That's it — `Sidekiq::Worker`, `Sidekiq::Batch`, `Sidekiq::Limiter`, `Sidekiq.configure_server`, and friends all resolve to Wurk.
29
+
30
+ ## Feature matrix
31
+
32
+ Everything below is in the one free gem. The "Sidekiq tier" column is only there to show what you'd otherwise pay for.
33
+
34
+ | Area | What you get | Sidekiq tier |
35
+ |---|---|---|
36
+ | **Runtime** | Fork-based real parallelism, reliable `BLMOVE` fetch, PID supervision, rolling restarts, graceful drain, scheduled/retry pollers | OSS + Pro |
37
+ | **Batches** | `Sidekiq::Batch` with `on(:success/:complete/:death)` callbacks, nested batches, progress | Pro |
38
+ | **Limiters** | Concurrent, bucket, window, leaky, and points rate limiters via `Sidekiq::Limiter` | Enterprise |
39
+ | **Periodic** | Cron/periodic jobs, leader-elected so each tick fires exactly once across the cluster | Enterprise |
40
+ | **Encryption** | Transparent AES-256-GCM job-argument encryption with zero-downtime key rotation | Enterprise |
41
+ | **Dashboard** | Mountable Rails engine, precompiled React SPA (no Node needed), live SSE, charts, host-app auth hook | OSS + Pro/Ent |
42
+
43
+ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listener, and opt-in AI dashboard panes (anomaly detection, NL queries, backlog forecasting).
44
+
45
+ ## Documentation
46
+
47
+ - **[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.
48
+ - **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
49
+ - **API reference (parity specs):** [Sidekiq OSS](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-free.md) · [Pro](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-pro.md) · [Enterprise](https://github.com/developerz-ai/wurk/blob/main/docs/target/sidekiq-ent.md) — the authoritative surface Wurk matches exactly.
50
+ - **[Securing the dashboard](https://github.com/developerz-ai/wurk/blob/main/docs/dashboard.md)** · **[Metrics history](https://github.com/developerz-ai/wurk/blob/main/docs/metrics-history.md)**
51
+ - **Live demo:** [wurk.demo.developerz.ai](https://wurk.demo.developerz.ai)
52
+
53
+ ## Requirements
54
+
55
+ | Component | Minimum |
56
+ |---|---|
57
+ | Ruby | `>= 3.2.0` |
58
+ | Redis | `>= 7.0.0` |
59
+
60
+ JRuby, TruffleRuby, and Windows fall back to threads-only mode (no fork) — behaviorally equivalent to stock Sidekiq.
61
+
62
+ ## The dashboard
63
+
64
+ Mount the engine wherever you like:
65
+
66
+ ```ruby
67
+ # config/routes.rb
68
+ mount Wurk::Engine => "/wurk"
69
+ ```
70
+
71
+ The precompiled SPA ships inside the gem, so consumers never run Node. Gate it behind your app's auth with one line — see **[Securing the dashboard](https://github.com/developerz-ai/wurk/blob/main/docs/dashboard.md)** for Devise/Warden/Sorcery recipes:
72
+
73
+ ```ruby
74
+ Wurk::Web.use(Rack::Auth::Basic, "Wurk") { |user, pass| user == ENV["WURK_USER"] && pass == ENV["WURK_PASS"] }
75
+ ```
76
+
77
+ Ship a viewer-only board (e.g. a public demo) with no auth code at all by setting `WURK_WEB_READ_ONLY=1` — every mutating request returns 403 and the SPA hides destructive actions.
78
+
79
+ ## Encryption
80
+
81
+ A drop-in for `Sidekiq::Enterprise::Crypto`. It encrypts the **last** positional argument of a job with AES-256-GCM — the client middleware seals it on push, the server middleware opens it before `perform`. Earlier args stay plaintext so you can still triage on `user_id`.
82
+
83
+ ```ruby
84
+ # config/initializers/wurk.rb — point at any key source (file, ENV, KMS)
85
+ Sidekiq::Enterprise::Crypto.enable(active_version: 1) do |version|
86
+ File.binread("config/crypto/secret.#{Rails.env}.#{version}.key") # exactly 32 bytes
87
+ end
88
+ ```
89
+
90
+ ```ruby
91
+ class ChargeCardJob
92
+ include Sidekiq::Job
93
+ sidekiq_options encrypt: true
94
+
95
+ def perform(user_id, secret_bag) # secret_bag arrives already decrypted
96
+ Payments.charge(user_id, secret_bag["pan"], secret_bag["cvv"])
97
+ end
98
+ end
99
+ ```
100
+
101
+ Keys rotate without downtime — keep every still-in-flight version resolvable so old jobs decrypt, then bump `active_version`. A job that can't be decrypted (key rotated away, corrupt ciphertext) goes **straight to the dead set in under a second** rather than crash-looping through 25 retries, with the still-encrypted payload preserved for replay. The dashboard renders encrypted args as `"<encrypted>"`; cleartext is never written to Redis.
102
+
103
+ ## Kubernetes probes
104
+
105
+ Opt in to a thin HTTP listener for liveness/readiness:
106
+
107
+ ```ruby
108
+ Wurk.configure_server do |config|
109
+ config.health_check(port: 7433)
110
+ end
111
+ ```
112
+
113
+ | Path | Meaning |
114
+ |---|---|
115
+ | `/live` | 200 while the Launcher is running; 503 once `stop`/`quiet` is called. |
116
+ | `/ready` | 200 only when Redis is reachable **and** the heartbeat fired within `ready_window` (default 30s); 503 otherwise. |
117
+
118
+ Knobs: `health_check(port:, bind: "0.0.0.0", ready_window: 30)`. In swarm mode only the first child to `start` binds the port.
119
+
120
+ ## Migrating from Sidekiq
121
+
122
+ ```diff
123
+ - gem "sidekiq"
124
+ - gem "sidekiq-pro", source: "https://gems.contribsys.com/"
125
+ - gem "sidekiq-ent", source: "https://enterprise.contribsys.com/"
126
+ + gem "wurk"
127
+ ```
128
+
129
+ `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. Third-party gems (sidekiq-cron, sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures, sidekiq-throttled, …) are exercised by running their own upstream suites 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)).
130
+
131
+ ## Contributing
132
+
133
+ Issues and pull requests are welcome — see **[CONTRIBUTING.md](https://github.com/developerz-ai/wurk/blob/main/CONTRIBUTING.md)** for the dev setup, test layers, and conventions, and **[SECURITY.md](https://github.com/developerz-ai/wurk/blob/main/SECURITY.md)** to report a vulnerability.
134
+
135
+ ## License
136
+
137
+ MIT. See [LICENSE](https://github.com/developerz-ai/wurk/blob/main/LICENSE).
data/SECURITY.md ADDED
@@ -0,0 +1,39 @@
1
+ # Security Policy
2
+
3
+ ## Supported versions
4
+
5
+ Wurk follows semantic versioning. Security fixes land on the latest minor
6
+ release line.
7
+
8
+ | Version | Supported |
9
+ |---|---|
10
+ | 1.x | ✅ |
11
+ | < 1.0 | ❌ |
12
+
13
+ ## Reporting a vulnerability
14
+
15
+ **Please do not open a public issue for security vulnerabilities.**
16
+
17
+ Report privately through GitHub's
18
+ [**Report a vulnerability**](https://github.com/developerz-ai/wurk/security/advisories/new)
19
+ form (Security → Advisories). This opens a private advisory only the
20
+ maintainers can see.
21
+
22
+ Please include:
23
+
24
+ - a description of the issue and its impact,
25
+ - the affected version(s),
26
+ - steps to reproduce or a proof of concept,
27
+ - any suggested remediation.
28
+
29
+ ## What to expect
30
+
31
+ - **Acknowledgement** within 3 business days.
32
+ - An initial assessment and severity within 7 days.
33
+ - Coordinated disclosure: we'll agree on a timeline with you, ship a patched
34
+ release, and credit you in the advisory and `CHANGELOG.md` unless you prefer
35
+ to remain anonymous.
36
+
37
+ Because Wurk is wire-compatible with Sidekiq and runs your job code with access
38
+ to Redis, reports about deserialization, the dashboard's auth surface, or
39
+ argument encryption are especially welcome.
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ module Api
5
+ # Pagination helpers shared by every listing endpoint. The contract:
6
+ # * `?count=` page size (default 25, clamped to 1..200)
7
+ # * `?page=` 0-indexed page number
8
+ # * `?substr=` case-insensitive klass/jid filter on the page
9
+ #
10
+ # Helpers expect an Enumerable that yields whatever JSON-shaped Hash the
11
+ # caller built; substr filtering and slicing happen after serialization so
12
+ # filters work on the same fields the UI reads.
13
+ module Pagination
14
+ DEFAULT_PAGE_SIZE = 25
15
+ MAX_PAGE_SIZE = 200
16
+
17
+ module_function
18
+
19
+ def window(params)
20
+ {
21
+ page: [params[:page].to_i, 0].max,
22
+ count: clamp_int(params[:count], 1, MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE),
23
+ substr: params[:substr].to_s
24
+ }
25
+ end
26
+
27
+ def clamp_int(value, min, max, default)
28
+ Integer(value, 10).clamp(min, max)
29
+ rescue ::ArgumentError, ::TypeError
30
+ default
31
+ end
32
+
33
+ def clamp_float(value, min, max, default)
34
+ Float(value).clamp(min, max)
35
+ rescue ::ArgumentError, ::TypeError
36
+ default
37
+ end
38
+
39
+ # Iterates `enumerable` and collects up to `count` payloads from the
40
+ # requested page after substr filtering. `block` maps each member to a
41
+ # JSON Hash; nil from the block skips the member entirely.
42
+ def slice(enumerable, page)
43
+ results = []
44
+ offset = page[:page] * page[:count]
45
+ idx = 0
46
+ enumerable.each do |member|
47
+ if idx >= offset
48
+ payload = yield(member)
49
+ if payload && match?(payload, page[:substr])
50
+ results << payload
51
+ break if results.size >= page[:count]
52
+ end
53
+ end
54
+ idx += 1
55
+ end
56
+ results
57
+ end
58
+
59
+ def match?(payload, substr)
60
+ return true if substr.nil? || substr.empty?
61
+
62
+ needle = substr.downcase
63
+ payload[:klass].to_s.downcase.include?(needle) || payload[:jid].to_s.downcase.include?(needle)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ module Api
5
+ # Pure mapping from inspector objects → JSON-shaped Hashes for the
6
+ # dashboard SPA. Keeping the serializers out of the controller lets the
7
+ # action methods stay tiny and lets future endpoints share the same
8
+ # field shapes without re-implementing them.
9
+ module Serializers
10
+ module_function
11
+
12
+ # Wire-shape consumed by the React dashboard's landing page + SSE feed.
13
+ # Field names match the SPA's `StatsSnapshot` interface in
14
+ # frontend/src/hooks/useSSE.ts — keep them in sync. The canonical
15
+ # Sidekiq-compatible accessors on Wurk::Stats use `_size` suffixes;
16
+ # this serializer renames them for the dashboard's wire shape only.
17
+ def stats_payload(stats)
18
+ {
19
+ processed: stats.processed,
20
+ failed: stats.failed,
21
+ expired: stats.expired,
22
+ enqueued: stats.enqueued,
23
+ busy: stats.workers_size,
24
+ scheduled: stats.scheduled_size,
25
+ retries: stats.retry_size,
26
+ dead: stats.dead_size,
27
+ processes: stats.processes_size,
28
+ latency: stats.default_queue_latency,
29
+ queues: stats.queue_summaries.map { |q| queue_summary(q) }
30
+ }
31
+ end
32
+
33
+ def queue_summary(summary)
34
+ { name: summary.name, size: summary.size, latency: summary.latency, paused: summary.paused? }
35
+ end
36
+
37
+ def job_record(record)
38
+ {
39
+ jid: record.jid,
40
+ klass: record.display_class,
41
+ args: record.display_args,
42
+ queue: record.queue,
43
+ enqueued_at: record.enqueued_at&.to_f,
44
+ created_at: record.created_at&.to_f
45
+ }
46
+ end
47
+
48
+ def sorted_entry(entry)
49
+ job_record(entry).merge(
50
+ score: entry.score,
51
+ at: entry.at.to_f,
52
+ error_class: entry['error_class'],
53
+ error_message: entry['error_message'],
54
+ retry_count: entry['retry_count']
55
+ )
56
+ end
57
+
58
+ def process_row(process)
59
+ {
60
+ identity: process.identity,
61
+ hostname: process['hostname'],
62
+ pid: process['pid'],
63
+ tag: process.tag,
64
+ concurrency: process['concurrency'],
65
+ busy: process['busy'],
66
+ beat: process['beat'],
67
+ quiet: process.stopping?,
68
+ rss: process['rss'],
69
+ rtt_us: process['rtt_us'],
70
+ labels: process.labels,
71
+ queues: process.queues,
72
+ version: process.version,
73
+ embedded: process.embedded?
74
+ }
75
+ end
76
+
77
+ def limiter_row(name, meta)
78
+ {
79
+ name: name,
80
+ type: meta['type'].to_s,
81
+ fingerprint: meta['fingerprint'].to_s,
82
+ options: parse_options(meta['options']),
83
+ status: limiter_status(name, meta)
84
+ }
85
+ end
86
+
87
+ # Reconstruct the limiter (read-only, `register: false`) just to read
88
+ # its uniform `{ used, limit, reset_at, available? }` status for the
89
+ # Limits tab. Best-effort: a malformed meta hash yields nil rather than
90
+ # 500-ing the whole list.
91
+ def limiter_status(name, meta)
92
+ limiter = ::Wurk::Limiter.build(name, meta['type'], parse_options(meta['options']))
93
+ limiter&.status
94
+ rescue StandardError
95
+ nil
96
+ end
97
+
98
+ def cron_row(loop_obj, now_epoch)
99
+ {
100
+ lid: loop_obj.lid,
101
+ schedule: loop_obj.schedule,
102
+ klass: loop_obj.klass,
103
+ queue: loop_obj.queue,
104
+ tz: loop_obj.tz_name,
105
+ paused: loop_obj.paused?,
106
+ args: loop_obj.args,
107
+ last_fire_at: loop_obj.last_fired_at,
108
+ next_fire_at: loop_obj.next_fire_at(now_epoch)
109
+ }
110
+ end
111
+
112
+ def metric_row(klass, totals)
113
+ { klass: klass, processed: totals[:p], failed: totals[:f], runtime_ms: totals[:ms] }
114
+ end
115
+
116
+ # One point in a cluster-total time-series (Wurk::Metrics::Query.history).
117
+ # `at` is epoch seconds at the bucket start.
118
+ def history_point(row)
119
+ { at: row[:at], processed: row[:p], failed: row[:f], runtime_ms: row[:ms] }
120
+ end
121
+
122
+ def parse_options(raw)
123
+ return {} if raw.nil? || raw.to_s.empty?
124
+
125
+ ::JSON.parse(raw)
126
+ rescue ::JSON::ParserError
127
+ {}
128
+ end
129
+ end
130
+ end
131
+ end