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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ce85d34d0d8e1f5aa0837c76310a94c2ca3e584ff0e9ca4d9e977c0347510f89
4
- data.tar.gz: 113f78f30121c728a4d7f3db5dc6d1472f13ab1b6b7b50d6ee73223d681585f4
3
+ metadata.gz: d9594aab6027f299c217e81215d9d10d5764a5f5533b3e40cde2cd1feaae8a86
4
+ data.tar.gz: 26161ff0d0ea58e60acbabd550e212ca354824f31434f0d085fabbc9401b8490
5
5
  SHA512:
6
- metadata.gz: ca00027cf185252fd95e78c3283a90d4a0c21acbc3cd6623bc1a4a1e2add8b6625d8436f60fc80b934a2fb35325d013903791dcad3606605b580b7633958fa9f
7
- data.tar.gz: bcae6990d734afd7dbc30e951aead9b7d35b77d060c1198a11d4d34d17ba866e6e81ba1ab168a417b115c1a6a690e8cc4c9926951fec58615d7c2e40a9619fda
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 the background work at [developerz.ai](https://developerz.ai) and at partner deployments, across many servers, on the fork-based swarm described below. It is not a preview. Throughput numbers from those deployments are not published yet; what has been measured is in [docs/benchmarks.md](docs/benchmarks.md).
25
+ **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 and license questions, and cuts a release when a shipped fix has waited for one. 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).
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` can't be installed alongside Wurk); terminal-failure-only reports, per-job scope, no job args.
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, and Windows fall back to threads-only mode (no fork) — behaviorally equivalent to stock Sidekiq.
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's upstream suite 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);
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; sidekiq-cron's upstream suite runs against Wurk in CI and reports on PRs, but it is not a required check and never gates 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.
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's upstream suite runs against Wurk in the [`ecosystem` CI job](https://github.com/developerz-ai/wurk/blob/main/.github/workflows/ecosystem.yml) (see [`test/ecosystem/`](https://github.com/developerz-ai/wurk/tree/main/test/ecosystem)). sidekiq-unique-jobs, sidekiq-scheduler, sidekiq-status, sidekiq-failures and sidekiq-throttled are target additions tracked in [docs/idea/14-ecosystem-compat.md](https://github.com/developerz-ai/wurk/blob/main/docs/idea/14-ecosystem-compat.md).
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 is exercised that way on every PR; treat the others as untested.
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 | the bot's call, not a human's; a change that breaks the drop-in Sidekiq contract is declined, since a revert cannot take that promise back |
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(VITE_DEV_URL)
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 #{VITE_DEV_URL} " \
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#{VITE_DEV_URL}")
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
- warn "wurk: #{e.message}"
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,7 +17,6 @@ begin
17
17
  rescue StandardError => e
18
18
  raise e if $DEBUG
19
19
 
20
- warn "wurkswarm: #{e.message}"
21
- warn e.backtrace.join("\n")
20
+ Wurk::CLI.report_failure('wurkswarm', e)
22
21
  exit 1
23
22
  end
@@ -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'
@@ -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 klass.name && klass < Wurk::Worker
81
+ next unless SUBCLASS.bind_call(klass, Wurk::Worker)
77
82
 
78
- klass.name if klass.get_sidekiq_options.key?('lock')
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 health Exit 0 if this host's workers are heartbeating\n\nOptions:"
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 ArgumentError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
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 ArgumentError, NO_API_TOKEN_MESSAGE unless @config.api_enabled?
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 ArgumentError, "wurk api could not start a web server (#{e.message}). Add one to your Gemfile " \
305
- '— puma, falcon, or rackup + webrick — or name an installed one with --server.'
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
- %w[INT TERM TSTP TTIN INFO USR2]
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 ArgumentError, "Pool size too small for #{name}" if cap.redis_pool.size < cap.concurrency
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 ArgumentError, "No such file #{path}" unless ::File.exist?(path)
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 = @command ? "wurk #{@command} [options]" : "wurk [options]\nwurk COMMAND [options]"
501
- o.separator HELP_COMMANDS unless @command
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) { |arg| assign_flag(opts, key, arg, transform) }
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 app expects Rails to be loaded before its gems: `config/application.rb`
536
- # requires the framework and only then calls `Bundler.require`, and gems read
537
- # `defined?(Rails)` or reference ActiveModel while loading. Requiring the groups
538
- # first broke those gems in this boot path only. So for a Rails target, run
539
- # the app's own first half before the group preload.
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
- require ::File.join(root, 'config/application.rb')
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` so groups load before the app.
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
- require_path = @config[:require]
645
- bad_path = !::File.exist?(require_path.to_s)
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 ArgumentError, "#{opt}: #{@config[opt]} is not a valid value" if @config[opt].to_i <= 0
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 print_help_and_die
656
- logger.info '=================================================================='
657
- logger.info ' Please point Wurk to a Rails application or a Ruby file'
658
- logger.info ' to load your job classes with -r [DIR|FILE].'
659
- logger.info '=================================================================='
660
- logger.info @parser
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
- slice.each_with_index.map do |job_args, idx|
246
- item = base.merge('args' => job_args)
247
- item['at'] = ats[idx] if ats
248
- normed = normalize_item(item)
249
- invoke_chain_verified(normed)
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, &)