wurk 1.0.0 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -0
  3. data/app/controllers/wurk/api_controller.rb +22 -1
  4. data/lib/wurk/capsule.rb +17 -3
  5. data/lib/wurk/cron.rb +29 -0
  6. data/lib/wurk/launcher.rb +29 -4
  7. data/lib/wurk/railtie.rb +19 -1
  8. data/lib/wurk/version.rb +1 -1
  9. data/lib/wurk.rb +24 -2
  10. data/vendor/assets/dashboard/assets/geist-sans-latin-400-normal-BOaIZNA2.woff +0 -0
  11. data/vendor/assets/dashboard/assets/geist-sans-latin-400-normal-gapTbOY8.woff2 +0 -0
  12. data/vendor/assets/dashboard/assets/geist-sans-latin-500-normal-CN2lyvyL.woff +0 -0
  13. data/vendor/assets/dashboard/assets/geist-sans-latin-500-normal-uokXdC-Q.woff2 +0 -0
  14. data/vendor/assets/dashboard/assets/geist-sans-latin-600-normal-CA1yjETN.woff +0 -0
  15. data/vendor/assets/dashboard/assets/geist-sans-latin-600-normal-DFOURf8L.woff2 +0 -0
  16. data/vendor/assets/dashboard/assets/geist-sans-latin-700-normal-BmN9tIp5.woff2 +0 -0
  17. data/vendor/assets/dashboard/assets/geist-sans-latin-700-normal-CjScfYeH.woff +0 -0
  18. data/vendor/assets/dashboard/assets/index-CpoPAGXr.css +1 -0
  19. data/vendor/assets/dashboard/assets/index-Xstb8f_d.js +126 -0
  20. data/vendor/assets/dashboard/assets/jetbrains-mono-cyrillic-500-normal-DJqRU3vO.woff +0 -0
  21. data/vendor/assets/dashboard/assets/jetbrains-mono-cyrillic-500-normal-DmUKJPL_.woff2 +0 -0
  22. data/vendor/assets/dashboard/assets/jetbrains-mono-greek-500-normal-D7SFKleX.woff +0 -0
  23. data/vendor/assets/dashboard/assets/jetbrains-mono-greek-500-normal-JpySY46c.woff2 +0 -0
  24. data/vendor/assets/dashboard/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  25. data/vendor/assets/dashboard/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  26. data/vendor/assets/dashboard/assets/jetbrains-mono-latin-ext-500-normal-Cut-4mMH.woff2 +0 -0
  27. data/vendor/assets/dashboard/assets/jetbrains-mono-latin-ext-500-normal-ckzbgY84.woff +0 -0
  28. data/vendor/assets/dashboard/assets/jetbrains-mono-vietnamese-500-normal-DNRqzVM1.woff +0 -0
  29. data/vendor/assets/dashboard/index.html +2 -2
  30. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  31. metadata +60 -3
  32. data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +0 -77
  33. data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +0 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 774727bf58b55972fbc4ace533c90803233ad6e4a42d6e8b8cec0f464972bae3
4
- data.tar.gz: e06ddec0e9138bb50be07a50184d46b500affe8f9212d5aaa290be75b35a73da
3
+ metadata.gz: 000bd71bc3b9148d1f4250d32ebe25e300beee9f384b013ed3efabf2f778b1ce
4
+ data.tar.gz: eec8ea9f45d0ef0ccc0b894515dd6711adf08054757e0475178b45ed8ec81c37
5
5
  SHA512:
6
- metadata.gz: e0f7924f14f94fc5f4143be95389f461c988d2772cb05d4502a77d9959431420d4392a5dc7d9d390038fa372da0b492b4f0d095bb566d6abc0fc4a6bf3fa5605
7
- data.tar.gz: 8cc09f2445d8e75f40cdc06ac84597748de5e2a7cbfd99ec9ad062e88ca5df03f7be7e117c221533c796304a6b90e6e47555f26d307a04a44b550b570449ceeb
6
+ metadata.gz: 7b173cdc536f04367d2eea9b5c549d71f6fe7cbfa27095af3916dc690196421e45356bd663f35a1777a7b340ad57fe6c5b6be24aec3eb57aea3d128de30944a9
7
+ data.tar.gz: f89b858d93982f20efa5a1d79b8b2230736cf53bdd8b3d95d54fcc4c444bb90dc70822124d8ae80060b2110a68aec8116f807759bccc87b2478cd61872fa890a
data/README.md CHANGED
@@ -57,6 +57,8 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
57
57
 
58
58
  - **[Website](https://developerz-ai.github.io/wurk/)** · **[Wiki / full docs](https://github.com/developerz-ai/wurk/wiki)** — the pitch, install, and the complete guide.
59
59
  - **[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.
60
+ - **[Starting the worker](https://github.com/developerz-ai/wurk/blob/main/docs/running.md)** — Rails auto-start, the `wurk`/`wurkswarm` runners, and running standalone without Rails.
61
+ - **[Active Job adapter](https://github.com/developerz-ai/wurk/blob/main/docs/active-job.md)** — run `ActiveJob`/`deliver_later` on Wurk with `queue_adapter = :wurk`.
60
62
  - **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
61
63
  - **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.
62
64
  - **[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)**
@@ -44,7 +44,7 @@ module Wurk
44
44
  def meta
45
45
  config = ::Wurk::Web.config
46
46
  render json: {
47
- read_only: config.read_only?,
47
+ read_only: config.read_only? || !mutations_authorized?(config),
48
48
  read_only_message: config.read_only_message,
49
49
  custom_tabs: config.custom_tabs
50
50
  }
@@ -294,6 +294,27 @@ module Wurk
294
294
 
295
295
  private
296
296
 
297
+ # Engine-relative path probed as a representative mutation. Must be a real
298
+ # mutating route (POST /api/retries — bulk retry/delete/kill) so that a
299
+ # path-sensitive hook resolves it the same way the Authorization middleware
300
+ # will resolve the actual mutation. Probing `request.path_info` (which here
301
+ # is the GET /api/meta path) would let such a hook allow the probe while
302
+ # still 403ing real mutations, reviving the "button shows, then 403s" gap.
303
+ MUTATION_PROBE_PATH = '/api/retries'
304
+
305
+ # Per-request read-only signal for the SPA. When a registered authorization
306
+ # hook would reject a *mutating* request for this user (e.g. a viewer role
307
+ # that may GET but not retry/kill), report `read_only` so the SPA hides the
308
+ # destructive actions — the Authorization middleware already 403s the
309
+ # mutation itself, this just stops the buttons from showing. With no hook
310
+ # registered, `authorized?` is always true, so this is a no-op and the flag
311
+ # keeps reflecting the global read-only mode. Probes POST on a canonical
312
+ # mutating path (SAFE_METHODS are GET/HEAD/OPTIONS) so a path-sensitive hook
313
+ # answers for a real mutation, not the GET /api/meta request carrying it.
314
+ def mutations_authorized?(config)
315
+ config.authorized?(request.env, 'POST', MUTATION_PROBE_PATH)
316
+ end
317
+
297
318
  # Resolves a single entry by "<score>|<jid>" key and applies a whitelisted
298
319
  # action. 400 on an unknown action, 404 when the key matches nothing (e.g.
299
320
  # the entry was already retried/deleted from another tab).
data/lib/wurk/capsule.rb CHANGED
@@ -153,13 +153,27 @@ module Wurk
153
153
  fetcher
154
154
  end
155
155
 
156
+ # Accepts both the CLI/string form (`"name"` / `"name,weight"`) and the
157
+ # nested-array form Sidekiq's YAML produces for weighted queues
158
+ # (`:queues: - [critical, 2]` parses to `["critical", 2]`). Without the Array
159
+ # branch a real sidekiq.yml crashes at boot with `Integer(): " 2]"` (#241).
156
160
  def parse_queue_entry(entry)
161
+ if entry.is_a?(::Array)
162
+ raise ArgumentError, "queue entry must be `[name]` or `[name, weight]`: `#{entry}`" if entry.size > 2
163
+
164
+ return parse_pair_queue_entry(entry[0], entry[1], entry)
165
+ end
166
+
157
167
  qname, weight_str = entry.to_s.split(',', 2)
158
- qname = qname.to_s.strip
168
+ parse_pair_queue_entry(qname, weight_str, entry)
169
+ end
170
+
171
+ def parse_pair_queue_entry(name, weight, entry)
172
+ qname = name.to_s.strip
159
173
  raise ArgumentError, "queue name cannot be empty: `#{entry}`" if qname.empty?
160
- return [qname, 0] if weight_str.nil?
174
+ return [qname, 0] if weight.nil?
161
175
 
162
- weight = Integer(weight_str)
176
+ weight = Integer(weight)
163
177
  raise ArgumentError, "queue weight must be > 0: `#{entry}`" if weight <= 0
164
178
 
165
179
  [qname, weight]
data/lib/wurk/cron.rb CHANGED
@@ -574,6 +574,9 @@ module Wurk
574
574
  end
575
575
 
576
576
  def enqueue!(loop_obj)
577
+ aj = active_job_class(loop_obj.klass)
578
+ return enqueue_active_job(aj, loop_obj) if aj
579
+
577
580
  @client.push(
578
581
  'class' => loop_obj.klass,
579
582
  'args' => loop_obj.args,
@@ -582,6 +585,32 @@ module Wurk
582
585
  )
583
586
  end
584
587
 
588
+ # Resolve a loop's class name to an ActiveJob::Base subclass, or nil when
589
+ # ActiveJob isn't loaded (standalone wurk) or the class isn't one.
590
+ # sidekiq-cron parity: a cron loop targeting an ActiveJob must enqueue
591
+ # through the AJ adapter so the job runs via Sidekiq::ActiveJob::Wrapper
592
+ # with full callbacks/serialization — a bare `client.push('class' => …)`
593
+ # would make the processor call `Klass.new.perform`, skipping all of AJ.
594
+ def active_job_class(name)
595
+ return nil unless defined?(::ActiveJob::Base)
596
+
597
+ const = name.split('::').inject(::Object) { |mod, c| mod.const_get(c) }
598
+ const if const.is_a?(::Class) && const < ::ActiveJob::Base
599
+ rescue ::NameError
600
+ nil
601
+ end
602
+
603
+ # Enqueue via the AJ adapter (→ wurk). Only override the queue when the
604
+ # loop set one explicitly; otherwise the job's own `queue_as` wins. AJ's
605
+ # `retry_on`/`discard_on` govern retries, so the Sidekiq `retry` option
606
+ # doesn't apply here. Returns the wurk jid (provider_job_id) so the fire
607
+ # history records it; nil if a before_enqueue callback halted the push.
608
+ def enqueue_active_job(klass, loop_obj)
609
+ target = loop_obj.queue == 'default' ? klass : klass.set(queue: loop_obj.queue)
610
+ job = target.perform_later(*loop_obj.args)
611
+ job.provider_job_id if job.respond_to?(:provider_job_id)
612
+ end
613
+
585
614
  def read_fire_marks(lid)
586
615
  @config.redis do |c|
587
616
  vals = c.call('HMGET', "#{LOOP_PREFIX}#{lid}", 'lf', 'nf')
data/lib/wurk/launcher.rb CHANGED
@@ -48,7 +48,12 @@ module Wurk
48
48
  def initialize(config, embedded: false)
49
49
  @config = config
50
50
  @embedded = embedded
51
+ # Two separate flags, deliberately. @done = "quieted" (stop fetching, stay
52
+ # alive, report quiet=true). @stopped = "shutting down" (terminate the
53
+ # heartbeat loop). Quiet must NOT stop the heartbeat — otherwise a quieted
54
+ # process never publishes quiet=true and expires out of the live set (#236).
51
55
  @done = false
56
+ @stopped = false
52
57
  @managers = config.capsules.values.map { |cap| Manager.new(cap) }
53
58
  @poller = build_poller
54
59
  @cron_poller = build_cron_poller
@@ -127,6 +132,7 @@ module Wurk
127
132
  # CAS-release the cluster lock now (planned shutdown) so a follower can
128
133
  # take over immediately instead of waiting out the TTL.
129
134
  @leader&.stop
135
+ stop_heartbeat
130
136
  clear_heartbeat
131
137
  fire_event(:exit, reverse: true)
132
138
  end
@@ -217,11 +223,30 @@ module Wurk
217
223
  @health_server&.stop
218
224
  end
219
225
 
220
- # Heartbeat thread loop. `safe_thread` already wraps exceptions; we
221
- # exit the loop the moment `stop` flips @done so the thread doesn't
222
- # outlive the shutdown.
226
+ # Terminate the heartbeat loop and wait for it to exit before clear_heartbeat
227
+ # removes us from the `processes` SET otherwise a final in-flight beat could
228
+ # SADD us back right after the SREM. Wakes the thread out of its BEAT_PAUSE
229
+ # sleep so shutdown isn't delayed up to a full interval.
230
+ def stop_heartbeat
231
+ @stopped = true
232
+ thread = @heartbeat_thread
233
+ return unless thread
234
+
235
+ begin
236
+ thread.wakeup
237
+ rescue ThreadError
238
+ nil
239
+ end
240
+ thread.join(BEAT_PAUSE)
241
+ end
242
+
243
+ # Heartbeat thread loop. `safe_thread` already wraps exceptions. Loops on
244
+ # @stopped — NOT @done — so a *quieted* process keeps beating and publishes
245
+ # `quiet=true` instead of vanishing from the live set (#236). Only `#stop`
246
+ # flips @stopped; its `Thread#wakeup` breaks the sleep so the loop re-checks
247
+ # @stopped and exits without waiting out the interval.
223
248
  def start_heartbeat
224
- until @done
249
+ until @stopped
225
250
  heartbeat
226
251
  sleep BEAT_PAUSE
227
252
  end
data/lib/wurk/railtie.rb CHANGED
@@ -36,7 +36,25 @@ module Wurk
36
36
  # and the swarm boot. Console mode is detected reliably here — the console
37
37
  # command file defines ::Rails::Console before initializers run.
38
38
  def self.skip_boot?
39
- ENV['WURK_DISABLED'] == '1' || defined?(::Rails::Console) || ::Rails.env.test?
39
+ ENV['WURK_DISABLED'] == '1' ||
40
+ building? ||
41
+ defined?(::Rails::Console) ||
42
+ ::Rails.env.test?
43
+ end
44
+
45
+ # A build/precompile step must never fork the swarm (#247). The default
46
+ # Rails Dockerfile runs `SECRET_KEY_BASE_DUMMY=1 ./bin/rails
47
+ # assets:precompile`; that loads `:environment` → fires after_initialize,
48
+ # but there's no Redis during `docker build`, so a fork would hang/fail the
49
+ # build. Same for other env-loading rake tasks (db:prepare, db:migrate).
50
+ # The real server path is unaffected: `rails server` / `puma` boot through
51
+ # Rails::Command, not Rake, and don't set the dummy secret.
52
+ def self.building?
53
+ return true if ENV.key?('SECRET_KEY_BASE_DUMMY')
54
+
55
+ defined?(::Rake) && ::Rake.application.top_level_tasks.any?
56
+ rescue StandardError
57
+ false
40
58
  end
41
59
  end
42
60
  end
data/lib/wurk/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wurk
4
- VERSION = "1.0.0"
4
+ VERSION = "1.0.3"
5
5
  end
data/lib/wurk.rb CHANGED
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Standalone entry point. Loading "wurk" must work without Rails.
4
- # The engine and railtie live under "wurk/rails" and are only loaded
5
- # when the host app opts in.
4
+ # The engine and railtie live under "wurk/rails"; they're auto-loaded at the
5
+ # end of this file when Rails is present (drop-in parity, #246), and stay
6
+ # unloaded otherwise so a no-Rails boot pulls in zero Rails code.
6
7
 
7
8
  require_relative 'wurk/version'
8
9
  require_relative 'wurk/keys'
@@ -280,3 +281,24 @@ require_relative 'wurk/api/fast'
280
281
  # fully defined first. compat.rb only redefines names, it does not gate
281
282
  # behavior, so trailing the load order is safe.
282
283
  require_relative 'wurk/compat'
284
+
285
+ # Drop-in parity (#246): stock Sidekiq auto-loads its Rails integration when
286
+ # Rails is present (`lib/sidekiq.rb`: `require "sidekiq/rails" if
287
+ # defined?(Rails::Engine)`). Without the equivalent here, a host that follows
288
+ # the README/migration guide with a plain `gem "wurk"` (no `require:`) boots
289
+ # with the engine/railtie unloaded, so the `:wurk` / `:sidekiq` ActiveJob
290
+ # adapter constant is never defined and `config.active_job.queue_adapter =
291
+ # :wurk` raises NameError during the railtie phase. Loading the engine here
292
+ # (idempotent with an explicit `require "wurk/rails"`) closes that gap while
293
+ # keeping standalone, no-Rails boot fully Rails-free.
294
+ #
295
+ # Also gate on ActionDispatch::Routing::RouteSet: the engine's class body calls
296
+ # `isolate_namespace Wurk`, which under railties >= 8.1 eager-reads
297
+ # `ActionDispatch::Routing::RouteSet` to set the engine's @route_set_class.
298
+ # Ecosystem test helpers (sidekiq-cron, …) load `rails/engine/railties` for the
299
+ # railtie API alone — Rails::Engine is defined but ActionDispatch isn't — so a
300
+ # bare `defined?(Rails::Engine)` check would crash with `uninitialized constant
301
+ # Rails::Engine::Configuration::ActionDispatch` mid-`require "sidekiq"`. In a
302
+ # real Rails host both are loaded by `rails/all` before Bundler.require, so the
303
+ # stricter gate is invisible there.
304
+ require_relative 'wurk/rails' if defined?(Rails::Engine) && defined?(::ActionDispatch::Routing::RouteSet)