wurk 1.10.0 → 1.11.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 +9 -9
- data/app/controllers/wurk/dashboard_controller.rb +11 -6
- data/exe/wurk +7 -2
- data/exe/wurkswarm +1 -2
- data/lib/generators/wurk/install/install_generator.rb +12 -0
- data/lib/sidekiq/web.rb +5 -0
- data/lib/wurk/boot_checks.rb +8 -2
- data/lib/wurk/cli.rb +140 -31
- data/lib/wurk/client.rb +16 -5
- data/lib/wurk/compat.rb +4 -0
- data/lib/wurk/cron.rb +96 -16
- data/lib/wurk/embedded.rb +8 -2
- data/lib/wurk/encryption.rb +20 -5
- data/lib/wurk/leader.rb +62 -8
- data/lib/wurk/preflight/checks.rb +203 -0
- data/lib/wurk/preflight/command.rb +135 -0
- data/lib/wurk/preflight/keyspace.rb +109 -0
- data/lib/wurk/preflight/owners.rb +74 -0
- data/lib/wurk/preflight/payload_sample.rb +108 -0
- data/lib/wurk/preflight/reader.rb +60 -0
- data/lib/wurk/preflight/report.rb +82 -0
- data/lib/wurk/preflight/verify.rb +161 -0
- data/lib/wurk/preflight.rb +40 -0
- data/lib/wurk/swarm/child_boot.rb +2 -0
- data/lib/wurk/swarm/test_mode_guard.rb +32 -0
- data/lib/wurk/testing.rb +12 -4
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +8 -4
- data/lib/wurk/web/extension.rb +35 -61
- data/lib/wurk/web/helpers.rb +133 -0
- data/lib/wurk/web/paginator.rb +49 -0
- data/lib/wurk/web/rack_app.rb +30 -6
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +13 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d9594aab6027f299c217e81215d9d10d5764a5f5533b3e40cde2cd1feaae8a86
|
|
4
|
+
data.tar.gz: 26161ff0d0ea58e60acbabd550e212ca354824f31434f0d085fabbc9401b8490
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3f2c0b2115f2c7132ab9104e7850f85cf34ac694d4d67b89f615cac765112582506197ae139753489de05f7e6217abb7770129aefbd3125e6dd2be17c04a50ac
|
|
7
|
+
data.tar.gz: 0f08e99addbeeebefc503916d3a033aa88dd9ccc3ad1f557ce4aabfd43e7809b07392589e0137deff11b358304b435ed798766d89f3d3f205c3a105484a65875
|
data/README.md
CHANGED
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
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.
|
|
24
24
|
|
|
25
|
-
**In production:** Wurk runs
|
|
25
|
+
**In production:** Wurk runs background work at partner deployments, across many servers, on the fork-based swarm described below. It is not a preview. Throughput numbers from those deployments are not published yet; what has been measured is in [docs/benchmarks.md](docs/benchmarks.md).
|
|
26
26
|
|
|
27
27
|
**At scale:** Wurk is built for fleets, not just for one box. Kubernetes `/live` + `/ready` probes are a config line, not a sidecar; a bearer-scoped [HTTP API](docs/api-http.md) lets non-Ruby services enqueue and inspect; [OpenTelemetry](docs/telemetry.md) traces propagate client → server; per-queue [global concurrency caps](docs/rate-limiting.md) hold cluster-wide limits; and monitoring is the dashboard you already mount — live SSE, charts, no separate stack to run. See [Wurk extras](#wurk-extras).
|
|
28
28
|
|
|
29
|
-
**AI-managed:** Wurk is a 100% AI-managed project. Every author's issues and pull requests, org members' included, and dependency updates, are triaged, reviewed, merged or closed by the [developerz.ai](https://developerz.ai) maintainer agent, which asks a human only for security reports
|
|
29
|
+
**AI-managed:** Wurk is a 100% AI-managed project. Every author's issues and pull requests, org members' included, and dependency updates, are triaged, reviewed, merged or closed by the [developerz.ai](https://developerz.ai) maintainer agent, which asks a human only for security reports, license questions and breaking changes, and judges weekly whether a release is due. New features are still built by us, humans and AI together — see [CONTRIBUTING.md](CONTRIBUTING.md) and [AI-maintained](#ai-maintained). The agent always identifies itself as a bot: it works publicly as `developerz-ai[bot]` and marks every comment it posts as bot-made. Its work and its rules are public too — browse the [repository](https://github.com/developerz-ai/wurk) and read the committed policy in [.dz/maintainer/maintainer.yml](.dz/maintainer/maintainer.yml).
|
|
30
30
|
|
|
31
31
|
**On Sidekiq:** Wurk implements Sidekiq's API because it is a genuinely good API. Sidekiq is human-maintained and funds that work through its paid tiers; Wurk is AI-maintained, which is what lets the same surface be free software. Wurk is independent and not affiliated with or endorsed by Sidekiq or its maintainers — see [Why Wurk exists](#why-wurk-exists).
|
|
32
32
|
|
|
@@ -151,7 +151,7 @@ Method, per-invocation records, workload definitions, and the separate `rake ben
|
|
|
151
151
|
- **[Batches](https://github.com/developerz-ai/wurk/blob/main/docs/batches.md)** · **[Rate limiting](https://github.com/developerz-ai/wurk/blob/main/docs/rate-limiting.md)** · **[Periodic (cron) jobs](https://github.com/developerz-ai/wurk/blob/main/docs/periodic-jobs.md)** · **[Unique jobs](https://github.com/developerz-ai/wurk/blob/main/docs/unique-jobs.md)** — the Pro/Ent features, free.
|
|
152
152
|
- **[Iterable jobs](https://github.com/developerz-ai/wurk/blob/main/docs/iterable-jobs.md)** · **[Middleware](https://github.com/developerz-ai/wurk/blob/main/docs/middleware.md)** · **[Encryption](https://github.com/developerz-ai/wurk/blob/main/docs/encryption.md)** · **[Profiling](https://github.com/developerz-ai/wurk/blob/main/docs/profiling.md)**
|
|
153
153
|
- **[Data API](https://github.com/developerz-ai/wurk/blob/main/docs/api.md)** · **[Metrics](https://github.com/developerz-ai/wurk/blob/main/docs/metrics.md)** — inspect queues and jobs from Ruby; job metrics, Statsd/DogStatsD, custom history.
|
|
154
|
-
- **[Sentry](https://github.com/developerz-ai/wurk/blob/main/docs/sentry.md)** — built-in error reporting (`sentry-sidekiq`
|
|
154
|
+
- **[Sentry](https://github.com/developerz-ai/wurk/blob/main/docs/sentry.md)** — built-in error reporting (`sentry-sidekiq` needs the `sidekiq` shim gem; prefer this); terminal-failure-only reports, per-job scope, no job args.
|
|
155
155
|
- **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.
|
|
156
156
|
- **[Authentication & authorization](https://github.com/developerz-ai/wurk/blob/main/docs/authentication.md)** — gate the dashboard behind Devise/Warden, Sorcery, Basic auth, or a token; role-based read/write; CSRF.
|
|
157
157
|
- **[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)**
|
|
@@ -167,7 +167,7 @@ Method, per-invocation records, workload definitions, and the separate `rake ben
|
|
|
167
167
|
|
|
168
168
|
The full test suite also runs as a non-required `compat` workflow job across Ruby 3.2.x and the newest Ruby on the current Rails series, the oldest supported Rails series on the newest Ruby, and Valkey 8 as a drop-in store alongside Redis 7.x.
|
|
169
169
|
|
|
170
|
-
JRuby, TruffleRuby,
|
|
170
|
+
Without `fork` (JRuby, TruffleRuby, Windows; untested in CI) only the single-process `wurk` runner and `config.wurk.embed_in_web` apply: `wurkswarm` and the railtie's swarm have no fallback.
|
|
171
171
|
|
|
172
172
|
## Running the workers
|
|
173
173
|
|
|
@@ -284,13 +284,13 @@ Infrastructure this basic should be free software. A Rails app shouldn't need a
|
|
|
284
284
|
What has made that hard is maintenance: someone has to be paid to do it. Sidekiq funds a decade of *human* maintenance through its paid tiers, which is an honest trade. Wurk makes a different one — it is maintained **by AI**: every author's issues and pull requests, the team's own included, and dependency updates, are handled end to end by a maintainer agent, and the implementation, parity suite, docs, and benchmarks are written and kept current by AI agents working with the team, which still builds the features and cuts the releases. A fix, a doc update, or a version bump is no longer somebody's week, which is what makes it practical to:
|
|
285
285
|
|
|
286
286
|
- ship the entire Pro + Enterprise surface with no tier, no flag gate, and no license check;
|
|
287
|
-
- keep parity honest mechanically rather than by hand — an independently written parity oracle suite, pinned to a documented Sidekiq revision, plus sidekiq-cron
|
|
287
|
+
- keep parity honest mechanically rather than by hand — an independently written parity oracle suite, pinned to a documented Sidekiq revision, plus the upstream suites of sidekiq-cron, sidekiq-unique-jobs, sidekiq-failures and sentry-sidekiq running against Wurk on every push ([docs/idea/14-ecosystem-compat.md](https://github.com/developerz-ai/wurk/blob/main/docs/idea/14-ecosystem-compat.md) tracks the rest);
|
|
288
288
|
- keep adding surface Sidekiq doesn't have — the [Wurk extras](#wurk-extras) above landed as one release;
|
|
289
289
|
- hold ourselves to published numbers instead of adjectives — the measured comparison against stock Sidekiq is published [as measured](docs/benchmarks.md), including the unflattering ones, and `rake bench:vs_sidekiq` reproduces it. It is a published measurement, not a per-release CI run.
|
|
290
290
|
|
|
291
291
|
DHH makes the broader version of this argument in [Let the agents democratize open source](https://world.hey.com/dhh/let-the-agents-democratize-open-source-9fd630a9): open source fought for everyone's right to change the software they run, and refusing agent-written code re-erects the gate it spent decades tearing down — "all programmers are equal, but some programmers are more equal than others." His subject is contributions; ours is maintenance, which is the same economics from the other end. The reason a licence key guards batches and cron is not that the code is precious, it's that somebody had to be paid to keep it working. Drop that cost and the tier stops being necessary.
|
|
292
292
|
|
|
293
|
-
What makes it work in practice is that the agents run inside machinery built to check them. The parity oracle above is a merge-blocking gate, not decoration;
|
|
293
|
+
What makes it work in practice is that the agents run inside machinery built to check them. The parity oracle above is a merge-blocking gate, not decoration; four gems' upstream suites run against Wurk in CI and report on PRs, but they are not required checks and never gate a merge; the `rake bench` PR regression check flags deltas past a noise-adjusted threshold as an advisory comment; and the published benchmark numbers are one-off historical measurements, reproduced on demand rather than enforced in CI. The release gate has never once let an unverified gem reach RubyGems. Agents supply the pace, the gates supply the certainty — and when a gate does catch something, the fix is to make that class of mistake structurally impossible rather than to slow the agents down. The release lane derives its own tag from `Wurk::VERSION` so the two can't drift apart; [RELEASE.md](https://github.com/developerz-ai/wurk/blob/main/RELEASE.md) walks through it.
|
|
294
294
|
|
|
295
295
|
Wurk is MIT and stays that way. If what you need is a commercial support contract and a human on the other end of an email, buying that is a perfectly good answer.
|
|
296
296
|
|
|
@@ -303,9 +303,9 @@ Wurk is MIT and stays that way. If what you need is a commercial support contrac
|
|
|
303
303
|
+ gem "wurk"
|
|
304
304
|
```
|
|
305
305
|
|
|
306
|
-
`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
|
|
306
|
+
`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. The upstream suites of sidekiq-cron, sidekiq-unique-jobs, sidekiq-failures and sentry-sidekiq run 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-scheduler, sidekiq-status 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).
|
|
307
307
|
|
|
308
|
-
**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
|
|
308
|
+
**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, sidekiq-unique-jobs, sidekiq-failures and sentry-sidekiq are exercised that way, by the `ecosystem` workflow; treat the others as untested.
|
|
309
309
|
|
|
310
310
|
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.
|
|
311
311
|
|
|
@@ -332,7 +332,7 @@ TypeScript end to end, MIT, and as measured about its own claims as this file is
|
|
|
332
332
|
| Dependabot | driven to green and merged |
|
|
333
333
|
| an issue or pull request by anyone with write access (org members, collaborators) | handled like anyone's: triaged, reviewed, driven to green, merged |
|
|
334
334
|
| an outside change to CI, release or publish automation | declined: members maintain the pipeline that publishes the gem |
|
|
335
|
-
| a breaking change |
|
|
335
|
+
| a breaking change | a human decides; a change that breaks the drop-in Sidekiq contract is declined, since a revert cannot take that promise back |
|
|
336
336
|
|
|
337
337
|
Releases follow the work, not a calendar. A weekday checkup asks whether a shipping fix has merged and nobody has released it. When one has, the bot files a release request, its coding fleet opens the `Wurk::VERSION` bump PR, and merging that PR publishes the gem ([RELEASE.md](RELEASE.md)). The bot's policy is committed in [`.dz/maintainer/maintainer.yml`](.dz/maintainer/maintainer.yml).
|
|
338
338
|
|
|
@@ -18,14 +18,18 @@ module Wurk
|
|
|
18
18
|
# Vite dev server (default :5173) so contributors get HMR without
|
|
19
19
|
# rebuilding the bundle on every change.
|
|
20
20
|
class DashboardController < ApplicationController
|
|
21
|
+
# bin/dev exports VITE_PORT and vite.config.ts binds it, with strictPort, so this
|
|
22
|
+
# has to read the same variable or an overridden port answers every page with a 500.
|
|
23
|
+
def self.vite_dev_url
|
|
24
|
+
"http://localhost:#{ENV.fetch('VITE_PORT', '5173')}#{VITE_ASSET_BASE}"
|
|
25
|
+
end
|
|
26
|
+
|
|
21
27
|
# Vite serves the dev shell under its `base` (vite.config.ts), which is the
|
|
22
28
|
# same path the engine mounts assets at — so derive these from
|
|
23
29
|
# AssetMount::PREFIX to keep the Ruby side in lockstep. Fetching the server
|
|
24
30
|
# root instead returns Vite's "did you mean /wurk-assets/" hint page, not the
|
|
25
31
|
# shell (issue #181).
|
|
26
|
-
VITE_DEV_HOST = 'http://localhost:5173'
|
|
27
32
|
VITE_ASSET_BASE = "#{::Wurk::Engine::AssetMount::PREFIX}/".freeze # "/wurk-assets/"
|
|
28
|
-
VITE_DEV_URL = "#{VITE_DEV_HOST}#{VITE_ASSET_BASE}".freeze
|
|
29
33
|
INDEX_REL_PATH = ['vendor', 'assets', 'dashboard', 'index.html'].freeze
|
|
30
34
|
# The SPA mount point, matched without its closing `>` so the locale hint
|
|
31
35
|
# appends to whatever attributes the shell already carries.
|
|
@@ -115,11 +119,12 @@ module Wurk
|
|
|
115
119
|
end
|
|
116
120
|
|
|
117
121
|
def fetch_vite_dev_shell
|
|
118
|
-
uri = ::URI.parse(
|
|
122
|
+
uri = ::URI.parse(self.class.vite_dev_url)
|
|
119
123
|
rewrite_dev_asset_urls(::Net::HTTP.get(uri))
|
|
120
124
|
rescue ::StandardError => e
|
|
121
|
-
raise "Wurk dashboard: cannot reach Vite dev server at #{
|
|
122
|
-
"(#{e.class}: #{e.message}). Run `bin/rake frontend:dev` from the gem root
|
|
125
|
+
raise "Wurk dashboard: cannot reach Vite dev server at #{self.class.vite_dev_url} " \
|
|
126
|
+
"(#{e.class}: #{e.message}). Run `bin/rake frontend:dev` from the gem root, or `bin/dev`, " \
|
|
127
|
+
'with the same VITE_PORT.'
|
|
123
128
|
end
|
|
124
129
|
|
|
125
130
|
# The shell's entry URLs (`src="/wurk-assets/@vite/client"`,
|
|
@@ -130,7 +135,7 @@ module Wurk
|
|
|
130
135
|
# `server.origin` covers runtime-generated asset URLs but not these entry
|
|
131
136
|
# tags, so rewrite them here.
|
|
132
137
|
def rewrite_dev_asset_urls(html)
|
|
133
|
-
html.gsub(/(["'])#{::Regexp.escape(VITE_ASSET_BASE)}/, "\\1#{
|
|
138
|
+
html.gsub(/(["'])#{::Regexp.escape(VITE_ASSET_BASE)}/, "\\1#{self.class.vite_dev_url}")
|
|
134
139
|
end
|
|
135
140
|
|
|
136
141
|
def read_built_index
|
data/exe/wurk
CHANGED
|
@@ -4,12 +4,18 @@
|
|
|
4
4
|
# Standalone runner. Does NOT load the Rails engine.
|
|
5
5
|
# Usage: `exe/wurk -C config/wurk.yml` — worker: one process, thread pool
|
|
6
6
|
# `exe/wurk api --port 7433` — machine HTTP API, runs no jobs
|
|
7
|
+
# `exe/wurk preflight` / `verify` — read-only cutover checks, run no jobs
|
|
7
8
|
|
|
8
9
|
$stdout.sync = true
|
|
9
10
|
$LOAD_PATH.unshift(File.expand_path('../lib', __dir__))
|
|
10
11
|
|
|
11
12
|
require 'wurk'
|
|
12
13
|
|
|
14
|
+
if %w[preflight verify].include?(ARGV.first)
|
|
15
|
+
require 'wurk/preflight/command'
|
|
16
|
+
exit Wurk::Preflight::Command.run(ARGV.dup)
|
|
17
|
+
end
|
|
18
|
+
|
|
13
19
|
begin
|
|
14
20
|
cli = Wurk::CLI.instance
|
|
15
21
|
cli.parse
|
|
@@ -20,7 +26,6 @@ begin
|
|
|
20
26
|
rescue StandardError => e
|
|
21
27
|
raise e if $DEBUG
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
warn e.backtrace.join("\n")
|
|
29
|
+
Wurk::CLI.report_failure('wurk', e)
|
|
25
30
|
exit 1
|
|
26
31
|
end
|
data/exe/wurkswarm
CHANGED
|
@@ -17,6 +17,18 @@ module Wurk
|
|
|
17
17
|
def insert_mount_line
|
|
18
18
|
route(%(# mount Wurk::Engine => "/wurk" # uncomment to expose the Wurk dashboard))
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
def show_next_steps
|
|
22
|
+
say <<~STEPS
|
|
23
|
+
|
|
24
|
+
Next steps:
|
|
25
|
+
1. Set the queue adapter if you use Active Job: config.active_job.queue_adapter = :wurk
|
|
26
|
+
2. Start workers: bundle exec wurkswarm (or bundle exec wurk for one process)
|
|
27
|
+
3. To use the dashboard, uncomment the mount line in config/routes.rb.
|
|
28
|
+
It has no login of its own, so put it behind authentication first:
|
|
29
|
+
docs/authentication.md in the wurk gem's repository
|
|
30
|
+
STEPS
|
|
31
|
+
end
|
|
20
32
|
end
|
|
21
33
|
end
|
|
22
34
|
end
|
data/lib/sidekiq/web.rb
CHANGED
|
@@ -2,3 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
# Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
|
|
4
4
|
require 'wurk'
|
|
5
|
+
|
|
6
|
+
# Extension renderer dependencies, deferred on the worker path (web/extension.rb).
|
|
7
|
+
# Loaded here for a helper called outside a request, as unique-jobs' specs do.
|
|
8
|
+
require 'cgi'
|
|
9
|
+
require 'erb'
|
data/lib/wurk/boot_checks.rb
CHANGED
|
@@ -11,6 +11,8 @@ module Wurk
|
|
|
11
11
|
# Oldest version each non-Redis backend has been run against.
|
|
12
12
|
TESTED_FLOORS = { 'dragonfly' => '1.34.0', 'valkey' => '8.0.0' }.freeze
|
|
13
13
|
MAX_LISTED = 20
|
|
14
|
+
SUBCLASS = ::Module.instance_method(:<)
|
|
15
|
+
NAME = ::Module.instance_method(:name)
|
|
14
16
|
|
|
15
17
|
module_function
|
|
16
18
|
|
|
@@ -71,11 +73,15 @@ module Wurk
|
|
|
71
73
|
end
|
|
72
74
|
end
|
|
73
75
|
|
|
76
|
+
# A walk over every class meets classes that redefine these with other arguments
|
|
77
|
+
# (Faker::Travel::Airport.name takes required keywords), and one such class must
|
|
78
|
+
# not cost the app this check.
|
|
74
79
|
def locked_job_classes
|
|
75
80
|
::ObjectSpace.each_object(::Class).filter_map do |klass|
|
|
76
|
-
next unless
|
|
81
|
+
next unless SUBCLASS.bind_call(klass, Wurk::Worker)
|
|
77
82
|
|
|
78
|
-
|
|
83
|
+
name = NAME.bind_call(klass)
|
|
84
|
+
name if name && klass.get_sidekiq_options.key?('lock')
|
|
79
85
|
end.sort
|
|
80
86
|
end
|
|
81
87
|
|
data/lib/wurk/cli.rb
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
# a Rails app that enqueues and processes jobs pays that on every boot for a
|
|
7
7
|
# command-line parser it never runs. `require` is idempotent, so the lazy call
|
|
8
8
|
# is a $LOADED_FEATURES hash lookup on every invocation after the first.
|
|
9
|
+
require 'rbconfig'
|
|
9
10
|
require_relative 'version'
|
|
10
11
|
require_relative 'configuration'
|
|
11
12
|
require_relative 'component'
|
|
@@ -32,6 +33,14 @@ module Wurk
|
|
|
32
33
|
# only one the swarm binaries accept — so a subcommand is never anything
|
|
33
34
|
# but the first argument, and every argument after it is that command's.
|
|
34
35
|
COMMANDS = %w[api health].freeze
|
|
36
|
+
# Set only across the one re-exec of #load_rails_framework, and removed on arrival.
|
|
37
|
+
PRELOAD_FIRST_ENV = 'WURK_BOOT_PRELOAD_FIRST'
|
|
38
|
+
# `bundle exec` loads the binstub in-process, so argv0 is Bundler's executable, and
|
|
39
|
+
# `ruby -e "load Gem.bin_path(...)"` has `-e` there: the running program can't name it.
|
|
40
|
+
SWARM_EXECUTABLE = ::File.expand_path('../../exe/wurkswarm', __dir__)
|
|
41
|
+
|
|
42
|
+
# A refusal this file raises itself, with nothing a trace would add.
|
|
43
|
+
class StartupError < ArgumentError; end
|
|
35
44
|
|
|
36
45
|
# `wurk api` defaults. 0.0.0.0 because the point of the machine API is for
|
|
37
46
|
# another service to reach it; it is bearer-gated, and answers 404 to
|
|
@@ -108,7 +117,15 @@ module Wurk
|
|
|
108
117
|
['-s', '--strict', :health_strict, 'Fail when Redis cannot be read, rather than passing']
|
|
109
118
|
].freeze
|
|
110
119
|
|
|
111
|
-
HELP_COMMANDS = "\nCommands:\n
|
|
120
|
+
HELP_COMMANDS = "\nCommands:\n " \
|
|
121
|
+
"health Exit 0 if this host's workers are heartbeating\n " \
|
|
122
|
+
"preflight Read a live Redis: what will carry over to Wurk (read-only)\n " \
|
|
123
|
+
"verify Post-cutover check of a live fleet (read-only)\n\nOptions:"
|
|
124
|
+
HELP_API_NOTE = "\nThe worker flags -c, -g, -q and -t are accepted here and have no effect.\n\nOptions:"
|
|
125
|
+
HELP_SWARM_NOTE = "\nForks WURK_COUNT worker processes from a preloaded parent and supervises them.\n\nOptions:"
|
|
126
|
+
HELP_FOOTER = "\nEnvironment:\n REDIS_URL Redis to use (default redis://localhost:6379/0)\n " \
|
|
127
|
+
"WURK_COUNT Worker processes for wurkswarm\n\n" \
|
|
128
|
+
'Docs: https://github.com/developerz-ai/wurk#readme'
|
|
112
129
|
|
|
113
130
|
attr_accessor :launcher, :environment, :config
|
|
114
131
|
|
|
@@ -146,6 +163,32 @@ module Wurk
|
|
|
146
163
|
self
|
|
147
164
|
end
|
|
148
165
|
|
|
166
|
+
# What the binaries print for a failed start. Only errors this file fully
|
|
167
|
+
# understands drop the trace. Anything else may be a bug in the host app, whose
|
|
168
|
+
# file and line are the point, and asking for -v means changing a deployed command.
|
|
169
|
+
def self.report_failure(program, error, argv: ARGV)
|
|
170
|
+
warn "#{program}: #{error.message}"
|
|
171
|
+
hint = failure_hint(error)
|
|
172
|
+
warn "#{program}: #{hint}" if hint
|
|
173
|
+
return unless error.backtrace
|
|
174
|
+
return unless failure_trace_wanted?(error, hint) || argv.intersect?(%w[-v --verbose])
|
|
175
|
+
|
|
176
|
+
warn error.backtrace.join("\n")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.failure_trace_wanted?(error, hint) = hint.nil? && !error.is_a?(StartupError)
|
|
180
|
+
private_class_method :failure_trace_wanted?
|
|
181
|
+
|
|
182
|
+
def self.failure_hint(error)
|
|
183
|
+
case error
|
|
184
|
+
when ::RedisClient::ConnectionError
|
|
185
|
+
'could not reach Redis; check REDIS_URL'
|
|
186
|
+
when ->(e) { defined?(::OptionParser::ParseError) && e.is_a?(::OptionParser::ParseError) }
|
|
187
|
+
'see --help'
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
private_class_method :failure_hint
|
|
191
|
+
|
|
149
192
|
# `boot_app:` / `warmup:` are test seams. Production always passes true.
|
|
150
193
|
def run(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
|
|
151
194
|
exit(run_health) if @command == 'health'
|
|
@@ -159,6 +202,7 @@ module Wurk
|
|
|
159
202
|
# false`).
|
|
160
203
|
enter_server_mode
|
|
161
204
|
boot_application if boot_app
|
|
205
|
+
Swarm::TestModeGuard.enforce!(@config.logger)
|
|
162
206
|
self_read, self_write = ::IO.pipe
|
|
163
207
|
begin
|
|
164
208
|
trap_signals(self_write)
|
|
@@ -192,7 +236,7 @@ module Wurk
|
|
|
192
236
|
# `SIDEKIQ_PRELOAD_APP` whole-app eager-load) and boots `Process.warmup`
|
|
193
237
|
# before the fork so children share warmed pages (copy-on-write). Spec §7.
|
|
194
238
|
def run_swarm(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
|
|
195
|
-
raise
|
|
239
|
+
raise StartupError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
|
|
196
240
|
|
|
197
241
|
# Server mode before the app loads — see #run. The flag rides through the
|
|
198
242
|
# fork into every child (the config object is copied), so configure_server
|
|
@@ -254,7 +298,7 @@ module Wurk
|
|
|
254
298
|
# Load the app here, not on the first request: a broken load should fail
|
|
255
299
|
# the boot an operator is watching, not the first client to call.
|
|
256
300
|
require_relative 'api/app'
|
|
257
|
-
raise
|
|
301
|
+
raise StartupError, NO_API_TOKEN_MESSAGE unless @config.api_enabled?
|
|
258
302
|
|
|
259
303
|
handler ||= api_handler(@config[:api_server])
|
|
260
304
|
logger.info { "Wurk API listening on http://#{api_bind}:#{api_port}#{Wurk::API::VERSION_PREFIX}" }
|
|
@@ -301,8 +345,8 @@ module Wurk
|
|
|
301
345
|
registry = handler_registry
|
|
302
346
|
name ? registry.get(name) : registry.default
|
|
303
347
|
rescue ::LoadError, ::NameError, ::ArgumentError => e
|
|
304
|
-
raise
|
|
305
|
-
|
|
348
|
+
raise StartupError, "wurk api could not start a web server (#{e.message}). Add one to your Gemfile " \
|
|
349
|
+
'— puma, falcon, or rackup + webrick — or name an installed one with --server.'
|
|
306
350
|
end
|
|
307
351
|
|
|
308
352
|
def handler_registry
|
|
@@ -354,7 +398,8 @@ module Wurk
|
|
|
354
398
|
end
|
|
355
399
|
|
|
356
400
|
def signal_names
|
|
357
|
-
|
|
401
|
+
# INFO exists on BSD and macOS only; asking for it on Linux just warned at every boot.
|
|
402
|
+
%w[INT TERM TSTP TTIN INFO USR2].select { |name| ::Signal.list.key?(name) }
|
|
358
403
|
end
|
|
359
404
|
|
|
360
405
|
def reopen_logs
|
|
@@ -389,7 +434,7 @@ module Wurk
|
|
|
389
434
|
|
|
390
435
|
def validate_pool_sizes!
|
|
391
436
|
@config.capsules.each_pair do |name, cap|
|
|
392
|
-
raise
|
|
437
|
+
raise StartupError, "Pool size too small for #{name}" if cap.redis_pool.size < cap.concurrency
|
|
393
438
|
end
|
|
394
439
|
end
|
|
395
440
|
|
|
@@ -455,7 +500,7 @@ module Wurk
|
|
|
455
500
|
end
|
|
456
501
|
|
|
457
502
|
def parse_config(path)
|
|
458
|
-
raise
|
|
503
|
+
raise StartupError, "No such file #{path}" unless ::File.exist?(path)
|
|
459
504
|
|
|
460
505
|
require 'erb'
|
|
461
506
|
require 'yaml'
|
|
@@ -490,6 +535,7 @@ module Wurk
|
|
|
490
535
|
parser = option_parser(opts)
|
|
491
536
|
@parser = parser
|
|
492
537
|
parser.parse!(argv)
|
|
538
|
+
warn "wurk: ignoring #{argv.join(' ')}: not a command or a flag (see --help)" unless argv.empty?
|
|
493
539
|
opts
|
|
494
540
|
end
|
|
495
541
|
|
|
@@ -497,17 +543,37 @@ module Wurk
|
|
|
497
543
|
require 'optparse'
|
|
498
544
|
|
|
499
545
|
::OptionParser.new do |o|
|
|
500
|
-
o.banner =
|
|
501
|
-
o.separator
|
|
546
|
+
o.banner = help_banner
|
|
547
|
+
o.separator help_intro if help_intro
|
|
502
548
|
define_value_flags(o, opts, @command == 'health' ? HEALTH_OPTION_FLAGS : OPTION_FLAGS)
|
|
503
549
|
define_value_flags(o, opts, API_OPTION_FLAGS) if @command == 'api'
|
|
504
550
|
define_meta_flags(o, opts)
|
|
505
551
|
end
|
|
506
552
|
end
|
|
507
553
|
|
|
554
|
+
def swarm_binary? = ::File.basename($PROGRAM_NAME).include?('swarm')
|
|
555
|
+
|
|
556
|
+
def help_banner
|
|
557
|
+
return "wurk #{@command} [options]" if @command
|
|
558
|
+
return "#{::File.basename($PROGRAM_NAME)} [options]" if swarm_binary?
|
|
559
|
+
|
|
560
|
+
"wurk [options]\nwurk COMMAND [options]"
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def help_intro
|
|
564
|
+
return HELP_API_NOTE if @command == 'api'
|
|
565
|
+
return if @command
|
|
566
|
+
|
|
567
|
+
swarm_binary? ? HELP_SWARM_NOTE : HELP_COMMANDS
|
|
568
|
+
end
|
|
569
|
+
|
|
508
570
|
def define_value_flags(parser, opts, flags)
|
|
509
571
|
flags.each do |short, long, key, desc, transform|
|
|
510
|
-
parser.on(short, long, desc)
|
|
572
|
+
parser.on(short, long, desc) do |arg|
|
|
573
|
+
assign_flag(opts, key, arg, transform)
|
|
574
|
+
rescue ArgumentError
|
|
575
|
+
raise StartupError, "#{long.split.first}: #{arg.inspect} is not a number"
|
|
576
|
+
end
|
|
511
577
|
end
|
|
512
578
|
end
|
|
513
579
|
|
|
@@ -525,26 +591,62 @@ module Wurk
|
|
|
525
591
|
exit(0)
|
|
526
592
|
end
|
|
527
593
|
parser.on_tail('-h', '--help', 'Show help') do
|
|
528
|
-
puts parser
|
|
594
|
+
puts parser, HELP_FOOTER
|
|
529
595
|
exit(0)
|
|
530
596
|
end
|
|
531
597
|
end
|
|
532
598
|
|
|
533
599
|
# --- application bootstrap --------------------------------------------
|
|
534
600
|
|
|
535
|
-
# A Rails
|
|
536
|
-
#
|
|
537
|
-
# `
|
|
538
|
-
#
|
|
539
|
-
#
|
|
601
|
+
# A Rails target has two shapes that need opposite orders. A gem may need Rails
|
|
602
|
+
# loaded while it requires: `config/application.rb` requires the framework and
|
|
603
|
+
# only then calls `Bundler.require`, so running the app's first half before the
|
|
604
|
+
# group preload suits it. An app may instead use a gem it expects the swarm to have
|
|
605
|
+
# preloaded, as stock sidekiqswarm's order (spec §7.3) gives it, and then that
|
|
606
|
+
# file raises NameError here. Only the file can tell us which it is, and by then
|
|
607
|
+
# its class body may have half-run, registering initializers and middleware that a
|
|
608
|
+
# second load in this process would register again. So the retry is a fresh process:
|
|
609
|
+
# same pid, same environment as before the attempt, preload first. Once only, so a
|
|
610
|
+
# genuine bug surfaces with its own message.
|
|
540
611
|
def load_rails_framework
|
|
541
612
|
root = rails_app_root(@config[:require])
|
|
542
613
|
return unless root
|
|
543
614
|
|
|
615
|
+
restart = restart_state
|
|
616
|
+
first_error = ENV.delete(PRELOAD_FIRST_ENV)
|
|
544
617
|
ENV['RACK_ENV'] = ENV['RAILS_ENV'] = @environment
|
|
545
618
|
require 'rails'
|
|
546
619
|
define_active_job_adapter
|
|
547
|
-
|
|
620
|
+
return note_preload_first(first_error) if first_error
|
|
621
|
+
|
|
622
|
+
begin
|
|
623
|
+
require ::File.join(root, 'config/application.rb')
|
|
624
|
+
rescue ::NameError => e
|
|
625
|
+
restart_with_preload_first(restart, e)
|
|
626
|
+
end
|
|
627
|
+
end
|
|
628
|
+
|
|
629
|
+
def restart_state
|
|
630
|
+
{ env: ::ENV.to_h, argv: ::ARGV.dup, dir: ::Dir.pwd }
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
def note_preload_first(first_error)
|
|
634
|
+
logger.warn do
|
|
635
|
+
"config/application.rb raised before the preload (#{first_error}), so this boot preloads first. " \
|
|
636
|
+
'If it fails too, that first error may be the one to fix.'
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Bundler may have arrived only inside the failed attempt (`config/boot.rb`), and the
|
|
641
|
+
# snapshot predates it, so the fresh process is told about it explicitly. If the
|
|
642
|
+
# exec itself fails, the app's error is still the one worth reporting.
|
|
643
|
+
def restart_with_preload_first(state, error)
|
|
644
|
+
env = state[:env].merge(PRELOAD_FIRST_ENV => error.message.lines.first.to_s.strip[0, 300])
|
|
645
|
+
env['BUNDLE_GEMFILE'] ||= ::Bundler.default_gemfile.to_s if defined?(::Bundler)
|
|
646
|
+
ruby = [::RbConfig.ruby, *('-rbundler/setup' if defined?(::Bundler))]
|
|
647
|
+
::Process.exec(env, *ruby, SWARM_EXECUTABLE, *state[:argv], unsetenv_others: true, chdir: state[:dir])
|
|
648
|
+
rescue ::SystemCallError
|
|
649
|
+
raise error
|
|
548
650
|
end
|
|
549
651
|
|
|
550
652
|
# The app root for a `-r` that is the app directory or its environment file.
|
|
@@ -559,7 +661,8 @@ module Wurk
|
|
|
559
661
|
# copy-on-write into every child. Comma-separated group list; the native
|
|
560
662
|
# `WURK_PRELOAD` wins over the `SIDEKIQ_PRELOAD` drop-in alias. Defaults to
|
|
561
663
|
# the `default` group; an explicit empty value (`WURK_PRELOAD=`) disables
|
|
562
|
-
# the preload. Runs before `boot_application
|
|
664
|
+
# the preload. Runs before `boot_application`, so before the initializers; see
|
|
665
|
+
# #load_rails_framework for where `config/application.rb` falls.
|
|
563
666
|
def preload_bundler_groups
|
|
564
667
|
return unless defined?(::Bundler)
|
|
565
668
|
|
|
@@ -641,23 +744,29 @@ module Wurk
|
|
|
641
744
|
end
|
|
642
745
|
|
|
643
746
|
def validate!
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
bad_rails = ::File.directory?(require_path.to_s) &&
|
|
647
|
-
!::File.exist?(::File.join(require_path, 'config/application.rb'))
|
|
648
|
-
return print_help_and_die if bad_path || bad_rails
|
|
747
|
+
reason = require_path_problem(@config[:require].to_s)
|
|
748
|
+
return print_help_and_die(reason) if reason
|
|
649
749
|
|
|
650
750
|
%i[concurrency timeout].each do |opt|
|
|
651
|
-
raise
|
|
751
|
+
raise StartupError, "#{opt}: #{@config[opt]} is not a valid value" if @config[opt].to_i <= 0
|
|
652
752
|
end
|
|
653
753
|
end
|
|
654
754
|
|
|
655
|
-
def
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
755
|
+
def require_path_problem(path)
|
|
756
|
+
return 'no such file or directory' unless ::File.exist?(path)
|
|
757
|
+
|
|
758
|
+
return unless ::File.directory?(path) && !::File.exist?(::File.join(path, 'config/application.rb'))
|
|
759
|
+
|
|
760
|
+
'it has no config/application.rb'
|
|
761
|
+
end
|
|
762
|
+
|
|
763
|
+
def print_help_and_die(reason)
|
|
764
|
+
logger.error '=================================================================='
|
|
765
|
+
logger.error " Wurk cannot load job classes from #{@config[:require]}: #{reason}."
|
|
766
|
+
logger.error ' Please point Wurk to a Rails application or a Ruby file'
|
|
767
|
+
logger.error ' to load your job classes with -r [DIR|FILE].'
|
|
768
|
+
logger.error '=================================================================='
|
|
769
|
+
logger.error @parser.to_s
|
|
661
770
|
exit(1)
|
|
662
771
|
end
|
|
663
772
|
end
|
data/lib/wurk/client.rb
CHANGED
|
@@ -57,6 +57,9 @@ module Wurk
|
|
|
57
57
|
# costs one lookup per write phase.
|
|
58
58
|
WRITE_STATE_KEY = :wurk_client_write_state
|
|
59
59
|
|
|
60
|
+
NO_ARGS = [].freeze
|
|
61
|
+
private_constant :NO_ARGS
|
|
62
|
+
|
|
60
63
|
attr_accessor :redis_pool
|
|
61
64
|
|
|
62
65
|
def initialize(pool: nil, config: nil, chain: nil)
|
|
@@ -241,12 +244,20 @@ module Wurk
|
|
|
241
244
|
jids
|
|
242
245
|
end
|
|
243
246
|
|
|
247
|
+
# Only `args` and `jid` differ between the jobs of a slice, so normalizing
|
|
248
|
+
# runs once and each job is a merge over the result. A per-job `at` keeps
|
|
249
|
+
# the full walk: the expiry and deadline stamps are measured from it.
|
|
244
250
|
def build_bulk_payloads(slice, base, ats)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
251
|
+
if ats
|
|
252
|
+
return slice.each_with_index.map do |job_args, idx|
|
|
253
|
+
invoke_chain_verified(normalize_item(base.merge('args' => job_args, 'at' => ats[idx])))
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
template = normalize_item(base.merge('args' => NO_ARGS))
|
|
258
|
+
explicit_jid = base['jid']
|
|
259
|
+
slice.map do |job_args|
|
|
260
|
+
invoke_chain_verified(template.merge('args' => job_args, 'jid' => explicit_jid || SecureRandom.hex(12)))
|
|
250
261
|
end
|
|
251
262
|
end
|
|
252
263
|
|
data/lib/wurk/compat.rb
CHANGED
|
@@ -119,6 +119,8 @@ module Sidekiq
|
|
|
119
119
|
private_constant :RailsNamespace
|
|
120
120
|
|
|
121
121
|
BasicFetch = Wurk::Fetcher::Reliable
|
|
122
|
+
WebHelpers = Wurk::Web::Extension::Helpers
|
|
123
|
+
Paginator = Wurk::Web::Paginator
|
|
122
124
|
Batch = Wurk::Batch
|
|
123
125
|
BatchSet = Wurk::BatchSet
|
|
124
126
|
Capsule = Wurk::Capsule
|
|
@@ -235,6 +237,8 @@ module Sidekiq
|
|
|
235
237
|
name == :Rails ? RailsNamespace : super
|
|
236
238
|
end
|
|
237
239
|
|
|
240
|
+
def gem_version = Gem::Version.new(VERSION)
|
|
241
|
+
def freeze! = Wurk.default_configuration.freeze!
|
|
238
242
|
def strict_args!(mode = :raise) = Wurk.strict_args!(mode)
|
|
239
243
|
def transactional_push! = Wurk.transactional_push!
|
|
240
244
|
def testing!(mode = :fake, &) = Wurk.testing!(mode, &)
|