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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/SECURITY.md +39 -0
- data/app/controllers/wurk/api/pagination.rb +67 -0
- data/app/controllers/wurk/api/serializers.rb +131 -0
- data/app/controllers/wurk/api_controller.rb +248 -0
- data/app/controllers/wurk/application_controller.rb +7 -0
- data/app/controllers/wurk/dashboard_controller.rb +48 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +34 -0
- data/exe/wurk +22 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
- data/lib/generators/wurk/install/install_generator.rb +22 -0
- data/lib/generators/wurk/install/templates/wurk.rb +16 -0
- data/lib/wurk/active_job/wrapper.rb +32 -0
- data/lib/wurk/api/fast.rb +78 -0
- data/lib/wurk/batch/buffer.rb +26 -0
- data/lib/wurk/batch/callback_job.rb +37 -0
- data/lib/wurk/batch/callbacks.rb +176 -0
- data/lib/wurk/batch/client_middleware.rb +27 -0
- data/lib/wurk/batch/death_handler.rb +39 -0
- data/lib/wurk/batch/empty.rb +21 -0
- data/lib/wurk/batch/server_middleware.rb +62 -0
- data/lib/wurk/batch/status.rb +140 -0
- data/lib/wurk/batch.rb +351 -0
- data/lib/wurk/batch_set.rb +67 -0
- data/lib/wurk/capsule.rb +176 -0
- data/lib/wurk/cli.rb +349 -0
- data/lib/wurk/client/buffered.rb +372 -0
- data/lib/wurk/client.rb +330 -0
- data/lib/wurk/compat.rb +136 -0
- data/lib/wurk/component.rb +136 -0
- data/lib/wurk/configuration.rb +373 -0
- data/lib/wurk/context.rb +35 -0
- data/lib/wurk/cron.rb +636 -0
- data/lib/wurk/dashboard_manifest.rb +39 -0
- data/lib/wurk/dead_set.rb +78 -0
- data/lib/wurk/deploy.rb +91 -0
- data/lib/wurk/embedded.rb +94 -0
- data/lib/wurk/encryption.rb +276 -0
- data/lib/wurk/engine.rb +81 -0
- data/lib/wurk/fetcher/reaper.rb +264 -0
- data/lib/wurk/fetcher/reliable.rb +138 -0
- data/lib/wurk/fetcher.rb +11 -0
- data/lib/wurk/health.rb +193 -0
- data/lib/wurk/heartbeat.rb +211 -0
- data/lib/wurk/iterable_job.rb +292 -0
- data/lib/wurk/job/options.rb +70 -0
- data/lib/wurk/job.rb +33 -0
- data/lib/wurk/job_logger.rb +68 -0
- data/lib/wurk/job_record.rb +156 -0
- data/lib/wurk/job_retry.rb +320 -0
- data/lib/wurk/job_set.rb +212 -0
- data/lib/wurk/job_util.rb +162 -0
- data/lib/wurk/keys.rb +52 -0
- data/lib/wurk/launcher.rb +289 -0
- data/lib/wurk/leader.rb +221 -0
- data/lib/wurk/limiter/base.rb +138 -0
- data/lib/wurk/limiter/bucket.rb +80 -0
- data/lib/wurk/limiter/concurrent.rb +132 -0
- data/lib/wurk/limiter/leaky.rb +91 -0
- data/lib/wurk/limiter/points.rb +89 -0
- data/lib/wurk/limiter/server_middleware.rb +77 -0
- data/lib/wurk/limiter/unlimited.rb +48 -0
- data/lib/wurk/limiter/window.rb +80 -0
- data/lib/wurk/limiter.rb +255 -0
- data/lib/wurk/logger.rb +81 -0
- data/lib/wurk/lua/loader.rb +53 -0
- data/lib/wurk/lua.rb +187 -0
- data/lib/wurk/manager.rb +132 -0
- data/lib/wurk/metrics/history.rb +151 -0
- data/lib/wurk/metrics/query.rb +173 -0
- data/lib/wurk/metrics/rollup.rb +169 -0
- data/lib/wurk/metrics/statsd.rb +197 -0
- data/lib/wurk/metrics.rb +7 -0
- data/lib/wurk/middleware/chain.rb +128 -0
- data/lib/wurk/middleware/current_attributes.rb +87 -0
- data/lib/wurk/middleware/expiry.rb +50 -0
- data/lib/wurk/middleware/i18n.rb +63 -0
- data/lib/wurk/middleware/interrupt_handler.rb +45 -0
- data/lib/wurk/middleware/poison_pill.rb +149 -0
- data/lib/wurk/middleware.rb +34 -0
- data/lib/wurk/process_set.rb +243 -0
- data/lib/wurk/processor.rb +247 -0
- data/lib/wurk/queue.rb +108 -0
- data/lib/wurk/queues.rb +80 -0
- data/lib/wurk/rails.rb +9 -0
- data/lib/wurk/railtie.rb +28 -0
- data/lib/wurk/redis_pool.rb +79 -0
- data/lib/wurk/retry_set.rb +17 -0
- data/lib/wurk/scheduled.rb +189 -0
- data/lib/wurk/scheduled_set.rb +18 -0
- data/lib/wurk/sorted_entry.rb +95 -0
- data/lib/wurk/stats.rb +190 -0
- data/lib/wurk/swarm/child_boot.rb +105 -0
- data/lib/wurk/swarm.rb +260 -0
- data/lib/wurk/testing.rb +102 -0
- data/lib/wurk/topology.rb +74 -0
- data/lib/wurk/unique.rb +240 -0
- data/lib/wurk/version.rb +5 -0
- data/lib/wurk/web/config.rb +180 -0
- data/lib/wurk/web/enterprise.rb +138 -0
- data/lib/wurk/web/search.rb +139 -0
- data/lib/wurk/web.rb +25 -0
- data/lib/wurk/work_set.rb +116 -0
- data/lib/wurk/worker/setter.rb +93 -0
- data/lib/wurk/worker.rb +216 -0
- data/lib/wurk.rb +238 -0
- data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
- data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
- data/vendor/assets/dashboard/index.html +13 -0
- data/vendor/assets/dashboard/wurk-manifest.json +4 -0
- 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
|
+
[](https://rubygems.org/gems/wurk)
|
|
6
|
+
[](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
|
|
7
|
+
[](https://github.com/developerz-ai/wurk/actions/workflows/test.yml)
|
|
8
|
+
[](https://www.ruby-lang.org)
|
|
9
|
+
[](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
|