wurk 1.1.1 → 1.2.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 +22 -2
- data/app/controllers/wurk/api_controller.rb +2 -2
- data/lib/sidekiq/testing.rb +2 -0
- data/lib/wurk/batch/callback_job.rb +53 -13
- data/lib/wurk/batch.rb +10 -2
- data/lib/wurk/configuration.rb +10 -6
- data/lib/wurk/cron.rb +56 -7
- data/lib/wurk/iterable_job.rb +26 -4
- data/lib/wurk/metrics/history.rb +24 -38
- data/lib/wurk/metrics/query.rb +8 -5
- data/lib/wurk/metrics/rollup.rb +1 -1
- data/lib/wurk/profiler.rb +7 -5
- data/lib/wurk/sorted_entry.rb +6 -0
- data/lib/wurk/testing.rb +20 -1
- data/lib/wurk/unique.rb +80 -11
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/enterprise.rb +24 -2
- data/lib/wurk/worker/setter.rb +29 -2
- data/lib/wurk/worker.rb +4 -4
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 811eecb5136dece5d9fcc6481991485b7183666edaa4cc464998b79d86fda34a
|
|
4
|
+
data.tar.gz: 5637cb5f107647ca79929032653e5410784b153ec72dfb36282199b8cfa45713
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1b09dc0cb8e65feeed5d641717ba0c7757af64f68d3de360a629e83cff338a8af0cf0ef4f0a2dbab7ff730f73bb50f4601a5ce3690fe71d603d757581fd4662
|
|
7
|
+
data.tar.gz: b1b5c4d6914c8fa611cff8532a56bc8e618f0f1bd0c601b3280fb9c12ca6ecd2c17e3b02130f73dc41dec93daca5ba54fe89bb4a3d83a70cd0a80767419d83c7
|
data/README.md
CHANGED
|
@@ -59,9 +59,20 @@ Plus Wurk extras: a worker topology DSL, a Kubernetes liveness/readiness listene
|
|
|
59
59
|
- **[API reference (YARD)](https://developerz-ai.github.io/wurk/api/)** — generated docs for the public classes (`Wurk::Worker`, `Wurk::Client`, `Wurk::Configuration`, `Wurk::Batch`, `Wurk::Limiter`, `Wurk::Unique`, and the `Sidekiq::*` aliases). Machine-readable map for AI agents: **[llms.txt](https://developerz-ai.github.io/wurk/llms.txt)**.
|
|
60
60
|
- **[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.
|
|
61
61
|
- **[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.
|
|
62
|
+
- **[Configuration reference](https://github.com/developerz-ai/wurk/blob/main/docs/configuration.md)** — every option, env var, YAML key, and CLI flag, with precedence and pool sizing.
|
|
63
|
+
- **[Deploying](https://github.com/developerz-ai/wurk/blob/main/docs/deployment.md)** — systemd, Capistrano, Heroku, Docker, Kubernetes, rolling restarts, memory limits.
|
|
62
64
|
- **[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`.
|
|
65
|
+
- **[Testing jobs](https://github.com/developerz-ai/wurk/blob/main/docs/testing.md)** — fake/inline modes, the jobs array, Minitest and RSpec setup.
|
|
63
66
|
- **[Migrating from Sidekiq](#migrating-from-sidekiq)** — the one-line swap and what to expect.
|
|
67
|
+
|
|
68
|
+
**Features:**
|
|
69
|
+
|
|
70
|
+
- **[Retries, backoff & the dead set](https://github.com/developerz-ai/wurk/blob/main/docs/retries.md)** · **[Reliability](https://github.com/developerz-ai/wurk/blob/main/docs/reliability.md)** — the failure lifecycle, and the delivery guarantee with its limits.
|
|
71
|
+
- **[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.
|
|
72
|
+
- **[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)**
|
|
73
|
+
- **[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.
|
|
64
74
|
- **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.
|
|
75
|
+
- **[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.
|
|
65
76
|
- **[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)**
|
|
66
77
|
- **[Compatibility & legal basis](https://github.com/developerz-ai/wurk/blob/main/docs/clean-room.md)** — clean-room implementation: Wurk copies the API, not the code (Google v. Oracle).
|
|
67
78
|
- **Live demo:** [wurk.demo.developerz.ai](https://wurk.demo.developerz.ai)
|
|
@@ -103,7 +114,16 @@ Mount the engine wherever you like:
|
|
|
103
114
|
mount Wurk::Engine => "/wurk"
|
|
104
115
|
```
|
|
105
116
|
|
|
106
|
-
|
|
117
|
+
On Devise, wrap it in `authenticate` so only signed-in admins reach it — unauthenticated visitors get bounced to your login page:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# config/routes.rb
|
|
121
|
+
authenticate :user, ->(u) { u.admin? } do
|
|
122
|
+
mount Wurk::Engine => "/wurk"
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The precompiled SPA ships inside the gem, so consumers never run Node. Outside Rails routing — or when you'd rather keep auth next to the rest of your Wurk config — gate it with any Rack middleware; see **[Authentication & authorization](https://github.com/developerz-ai/wurk/blob/main/docs/authentication.md)** for Devise/Warden/Sorcery/token recipes, role-based read/write splits, and the CSRF model:
|
|
107
127
|
|
|
108
128
|
```ruby
|
|
109
129
|
Wurk::Web.use(Rack::Auth::Basic, "Wurk") { |user, pass| user == ENV["WURK_USER"] && pass == ENV["WURK_PASS"] }
|
|
@@ -156,7 +176,7 @@ end
|
|
|
156
176
|
| `/live` | 200 while the Launcher is running; 503 once `stop`/`quiet` is called. |
|
|
157
177
|
| `/ready` | 200 only when Redis is reachable **and** the heartbeat fired within `ready_window` (default 30s); 503 otherwise. |
|
|
158
178
|
|
|
159
|
-
Knobs: `health_check(port:, bind: "0.0.0.0", ready_window: 30)`. In swarm mode
|
|
179
|
+
Knobs: `health_check(port:, bind: "0.0.0.0", ready_window: 30)`. In swarm mode one child owns the port; the others poll every 5s and take it over if the owner dies, so probes survive a child restart.
|
|
160
180
|
|
|
161
181
|
## Migrating from Sidekiq
|
|
162
182
|
|
|
@@ -232,7 +232,7 @@ module Wurk
|
|
|
232
232
|
|
|
233
233
|
# Cluster-total throughput/failures time-series for the dashboard charts.
|
|
234
234
|
# `:bucket` is 1m/5m/1h; `?window=24h` (s/m/h/d suffix) is clamped to the
|
|
235
|
-
# bucket's retention.
|
|
235
|
+
# bucket's retention. Chart-ready array under `series`.
|
|
236
236
|
def history
|
|
237
237
|
window = parse_window(params[:window])
|
|
238
238
|
series = ::Wurk::Web::Enterprise::Historical.history(params[:bucket].to_s, window: window)
|
|
@@ -255,7 +255,7 @@ module Wurk
|
|
|
255
255
|
# Per-queue size/latency gauge time-series for the Metrics/Historical tab.
|
|
256
256
|
# `:bucket` is 1m/5m/1h; `?window=24h` (s/m/h/d) is clamped to the bucket's
|
|
257
257
|
# retention; optional `?queue=<name>` narrows to one queue. Each queue's
|
|
258
|
-
# `points` are
|
|
258
|
+
# `points` are chart-ready.
|
|
259
259
|
def queue_history
|
|
260
260
|
window = parse_window(params[:window])
|
|
261
261
|
queues = params[:queue].present? ? [params[:queue].to_s] : nil
|
data/lib/sidekiq/testing.rb
CHANGED
|
@@ -4,33 +4,73 @@ require_relative '../worker'
|
|
|
4
4
|
|
|
5
5
|
module Wurk
|
|
6
6
|
class Batch
|
|
7
|
-
# Worker that runs a single batch callback.
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# Worker that runs a single batch callback. The target spec is one of:
|
|
8
|
+
#
|
|
9
|
+
# "MyCallback" → MyCallback.new.on_<event>(status, options)
|
|
10
|
+
# "MyCallback#done" → MyCallback.new.done(status, options)
|
|
11
|
+
# "#done" → <batch callback_class>.new.done(status, options)
|
|
12
|
+
#
|
|
13
|
+
# The class-less form takes its class from the batch's `callback_class`
|
|
14
|
+
# (spec §2.2), read from `b-<bid>` at run time — the spec travels through
|
|
15
|
+
# the job payload as a plain String, so nothing here depends on anything
|
|
16
|
+
# that a JSON round trip could lose.
|
|
17
|
+
#
|
|
18
|
+
# Failures inside the callback retry like any ordinary job — callbacks
|
|
19
|
+
# MUST be idempotent.
|
|
11
20
|
#
|
|
12
21
|
# Spec: docs/target/sidekiq-pro.md §2.4.
|
|
13
22
|
class CallbackJob
|
|
14
23
|
include Wurk::Job
|
|
15
24
|
|
|
25
|
+
# The spec names a class or a method that isn't there. Distinct from a
|
|
26
|
+
# failure raised *by* the callback so the retry policy can tell the two
|
|
27
|
+
# apart.
|
|
28
|
+
class UnresolvableTarget < StandardError; end
|
|
29
|
+
|
|
16
30
|
sidekiq_options retry: true
|
|
17
31
|
|
|
32
|
+
# A missing constant or method does not heal with time, so the default 25
|
|
33
|
+
# retries over ~21 days would only delay the news. Go straight to the dead
|
|
34
|
+
# set on the first attempt: death handlers fire, the operator sees it now,
|
|
35
|
+
# and the Dead tab's retry button is the recovery path once the callback
|
|
36
|
+
# class is restored. Anything else the callback raises keeps normal retry.
|
|
37
|
+
sidekiq_retry_in { |_count, exception| :kill if exception.is_a?(UnresolvableTarget) }
|
|
38
|
+
|
|
18
39
|
def perform(bid, target_spec, event, options)
|
|
19
|
-
klass, method = resolve(target_spec, event)
|
|
40
|
+
klass, method = resolve(bid, target_spec.to_s, event)
|
|
20
41
|
instance = klass.new
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
unless instance.respond_to?(method)
|
|
43
|
+
raise UnresolvableTarget, "batch #{bid}: #{klass}##{method} is not a public method"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
instance.public_send(method, Wurk::Batch::Status.new(bid), options || {})
|
|
23
47
|
end
|
|
24
48
|
|
|
25
49
|
private
|
|
26
50
|
|
|
27
|
-
def resolve(spec, event)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
51
|
+
def resolve(bid, spec, event)
|
|
52
|
+
return [constantize(bid, spec), :"on_#{event}"] unless spec.include?('#')
|
|
53
|
+
|
|
54
|
+
klass_name, method_name = spec.split('#', 2)
|
|
55
|
+
klass_name = callback_class_for(bid) if klass_name.empty?
|
|
56
|
+
[constantize(bid, klass_name), method_name.to_sym]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def constantize(bid, name)
|
|
60
|
+
if name.nil? || name.empty?
|
|
61
|
+
raise UnresolvableTarget, "batch #{bid}: callback spec names no class and the batch has no callback_class"
|
|
33
62
|
end
|
|
63
|
+
|
|
64
|
+
const = Object.const_get(name)
|
|
65
|
+
raise UnresolvableTarget, "batch #{bid}: callback target #{name} is not a Class" unless const.is_a?(::Class)
|
|
66
|
+
|
|
67
|
+
const
|
|
68
|
+
rescue NameError
|
|
69
|
+
raise UnresolvableTarget, "batch #{bid}: callback class #{name.inspect} is not defined"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def callback_class_for(bid)
|
|
73
|
+
Wurk.redis { |conn| conn.call('HGET', "b-#{bid}", 'callback_class') }
|
|
34
74
|
end
|
|
35
75
|
end
|
|
36
76
|
end
|
data/lib/wurk/batch.rb
CHANGED
|
@@ -60,8 +60,8 @@ module Wurk
|
|
|
60
60
|
# accumulate here instead of round-tripping per job.
|
|
61
61
|
BUFFER_KEY = :wurk_batch_buffer
|
|
62
62
|
|
|
63
|
-
attr_reader :bid, :parent_bid, :linger
|
|
64
|
-
attr_accessor :description, :callback_queue, :
|
|
63
|
+
attr_reader :bid, :parent_bid, :linger, :callback_class
|
|
64
|
+
attr_accessor :description, :callback_queue, :autoflush
|
|
65
65
|
|
|
66
66
|
def self.keys_for(bid)
|
|
67
67
|
base = "b-#{bid}"
|
|
@@ -108,6 +108,14 @@ module Wurk
|
|
|
108
108
|
Wurk.redis { |conn| conn.call('HSET', "b-#{@bid}", 'linger', @linger.to_s) }
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
+
# Supplies the class for a class-less `"#method"` callback spec (§2.2).
|
|
112
|
+
# Accepts a Class or a String and stores a String either way, so the
|
|
113
|
+
# in-memory value matches what a batch reopened by bid reads back — and so
|
|
114
|
+
# `CallbackJob` never has to care which form the caller used.
|
|
115
|
+
def callback_class=(value)
|
|
116
|
+
@callback_class = value.nil? ? nil : callback_target(value)
|
|
117
|
+
end
|
|
118
|
+
|
|
111
119
|
def parent
|
|
112
120
|
return nil if @parent_bid.nil? || @parent_bid.empty?
|
|
113
121
|
|
data/lib/wurk/configuration.rb
CHANGED
|
@@ -279,13 +279,17 @@ module Wurk
|
|
|
279
279
|
@options[:fetch_poll_interval]
|
|
280
280
|
end
|
|
281
281
|
|
|
282
|
-
# --- Reliability (Sidekiq Pro drop-in
|
|
282
|
+
# --- Reliability (Sidekiq Pro drop-in toggles) -----------------------
|
|
283
283
|
|
|
284
|
-
# Sidekiq Pro's opt-in
|
|
285
|
-
#
|
|
286
|
-
# the
|
|
287
|
-
#
|
|
288
|
-
#
|
|
284
|
+
# Sidekiq Pro's opt-in toggle for reliable fetch. Already the default in
|
|
285
|
+
# Wurk — the fetcher is always the reliable BLMOVE fetcher with orphan
|
|
286
|
+
# reclamation — so the toggle is a no-op beyond capturing the recovery
|
|
287
|
+
# callback. It exists so a Pro initializer drops in unchanged instead of
|
|
288
|
+
# raising NoMethodError.
|
|
289
|
+
#
|
|
290
|
+
# NOTE: `reliable_scheduler!` below is NOT a no-op. The default
|
|
291
|
+
# `scheduled_enq` pops then pushes and has a job-loss window
|
|
292
|
+
# (Wurk::Scheduled::Enq); only the toggle swaps in the atomic promoter.
|
|
289
293
|
#
|
|
290
294
|
# The optional block is Pro's recovery callback: `|jobstr, pill|`, fired
|
|
291
295
|
# once per orphan recovery (`pill` nil) and once on a poison kill (`pill`
|
data/lib/wurk/cron.rb
CHANGED
|
@@ -24,7 +24,7 @@ module Wurk
|
|
|
24
24
|
# so a re-registration of the same loop is idempotent.
|
|
25
25
|
# * `Cron::Manager` — registration DSL. `mgr.register(cron, klass, **opts)`
|
|
26
26
|
# with `tz=` mass-setter. Writes to Redis (`periodic` SET + `loops:{lid}`
|
|
27
|
-
# HASH).
|
|
27
|
+
# HASH) and prunes superseded loops for the classes it registers.
|
|
28
28
|
# * `Cron::LoopSet` — Enumerable view (`each`/`size`/`fetch(lid)`).
|
|
29
29
|
# * `Cron::ConfigTester` — boot-time validator. Verifies cron syntax and
|
|
30
30
|
# that every worker class constant resolves.
|
|
@@ -397,10 +397,14 @@ module Wurk
|
|
|
397
397
|
attr_accessor :tz
|
|
398
398
|
attr_reader :loops
|
|
399
399
|
|
|
400
|
-
|
|
400
|
+
# `prune: false` registers without superseding — ConfigTester uses it so
|
|
401
|
+
# validating a config never deletes a live loop.
|
|
402
|
+
def initialize(config = nil, prune: true)
|
|
401
403
|
@config = config
|
|
402
404
|
@loops = []
|
|
403
405
|
@tz = nil
|
|
406
|
+
@prune = prune
|
|
407
|
+
@pruned_klasses = {}
|
|
404
408
|
end
|
|
405
409
|
|
|
406
410
|
def register(cron, klass, **opts)
|
|
@@ -410,8 +414,24 @@ module Wurk
|
|
|
410
414
|
loop_obj = Loop.new(schedule: cron, klass: klass_name, options: normalized, tz: tz)
|
|
411
415
|
@loops << loop_obj
|
|
412
416
|
Cron.persist(loop_obj)
|
|
417
|
+
prune_superseded(klass_name)
|
|
413
418
|
loop_obj
|
|
414
419
|
end
|
|
420
|
+
|
|
421
|
+
private
|
|
422
|
+
|
|
423
|
+
# Once per class per boot: everything else registered for that class is
|
|
424
|
+
# a leftover from a previous deploy (the lid hashes schedule+options, so
|
|
425
|
+
# an edited `register` line yields a new loop and orphans the old one).
|
|
426
|
+
# Registering the same class twice in one block is fine — the second
|
|
427
|
+
# register re-persists its loop right after the first one's prune.
|
|
428
|
+
def prune_superseded(klass_name)
|
|
429
|
+
return unless @prune
|
|
430
|
+
return if @pruned_klasses[klass_name]
|
|
431
|
+
|
|
432
|
+
@pruned_klasses[klass_name] = true
|
|
433
|
+
Cron.prune_superseded(klass_name, @loops.map(&:lid))
|
|
434
|
+
end
|
|
415
435
|
end
|
|
416
436
|
|
|
417
437
|
# Enumerable view of every registered loop. Reads `periodic` SET +
|
|
@@ -470,7 +490,7 @@ module Wurk
|
|
|
470
490
|
def verify(&block)
|
|
471
491
|
raise ArgumentError, 'block required' unless block
|
|
472
492
|
|
|
473
|
-
mgr = Manager.new
|
|
493
|
+
mgr = Manager.new(prune: false)
|
|
474
494
|
block.call(mgr)
|
|
475
495
|
mgr.loops.each { |lp| resolve_klass!(lp.klass) }
|
|
476
496
|
mgr.loops
|
|
@@ -666,17 +686,46 @@ module Wurk
|
|
|
666
686
|
Loop.new(schedule: cron, klass: worker_class.to_s, options: merged).tap { |lp| persist(lp) }
|
|
667
687
|
end
|
|
668
688
|
|
|
689
|
+
# `paused` is written with HSETNX, not HSET: a code-declared `paused:`
|
|
690
|
+
# option is only the *initial* value at first registration, after which
|
|
691
|
+
# the field belongs to the runtime (Web UI pause/unpause). Writing it
|
|
692
|
+
# unconditionally un-paused a dashboard-paused loop on the next boot.
|
|
669
693
|
def persist(loop_obj)
|
|
694
|
+
fields = loop_obj.to_redis_hash
|
|
695
|
+
paused = fields.delete('paused')
|
|
696
|
+
key = "#{LOOP_PREFIX}#{loop_obj.lid}"
|
|
670
697
|
Wurk.redis do |c|
|
|
671
698
|
c.call('SADD', PERIODIC_KEY, loop_obj.lid)
|
|
672
|
-
c.call('HSET',
|
|
699
|
+
c.call('HSET', key, *fields.flatten)
|
|
700
|
+
c.call('HSETNX', key, 'paused', paused)
|
|
673
701
|
end
|
|
674
702
|
loop_obj
|
|
675
703
|
end
|
|
676
704
|
|
|
677
|
-
# Drop
|
|
678
|
-
#
|
|
679
|
-
#
|
|
705
|
+
# Drop every registered loop for `klass` except `keep_lids` — the loops a
|
|
706
|
+
# booting process just registered for that class. Editing a schedule or
|
|
707
|
+
# any option changes the lid, so without this the pre-edit loop stays in
|
|
708
|
+
# `periodic` and keeps firing next to its replacement, forever.
|
|
709
|
+
#
|
|
710
|
+
# Scoped to one class deliberately: the registry is fleet-wide, so a
|
|
711
|
+
# process that registers a subset of the fleet's loops (or none at all,
|
|
712
|
+
# e.g. a client-only app) must never delete loops for classes it doesn't
|
|
713
|
+
# register. Idempotent and safe to race — two processes booting the same
|
|
714
|
+
# code compute the same candidate set, and SREM/DEL repeat harmlessly.
|
|
715
|
+
def prune_superseded(klass, keep_lids)
|
|
716
|
+
keep = Array(keep_lids)
|
|
717
|
+
stale = Wurk.redis do |c|
|
|
718
|
+
candidates = c.call('SMEMBERS', PERIODIC_KEY) - keep
|
|
719
|
+
candidates.select { |lid| c.call('HGET', "#{LOOP_PREFIX}#{lid}", 'klass') == klass }
|
|
720
|
+
end
|
|
721
|
+
stale.each { |lid| unregister(lid) }
|
|
722
|
+
stale
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Drop a loop entirely: registry membership, hash, and fire history.
|
|
726
|
+
# Public API for retiring a loop whose `register(...)` line is gone (no
|
|
727
|
+
# process registers its class any more, so `prune_superseded` cannot see
|
|
728
|
+
# it); also the primitive behind pruning and ConfigTester rollback.
|
|
680
729
|
def unregister(lid)
|
|
681
730
|
Wurk.redis do |c|
|
|
682
731
|
c.call('SREM', PERIODIC_KEY, lid)
|
data/lib/wurk/iterable_job.rb
CHANGED
|
@@ -173,9 +173,11 @@ module Wurk
|
|
|
173
173
|
fire_lifecycle_start
|
|
174
174
|
@executions += 1
|
|
175
175
|
|
|
176
|
-
run_iterations(args)
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
if run_iterations(args) == :cancelled
|
|
177
|
+
finalize_cancelled
|
|
178
|
+
else
|
|
179
|
+
finalize_complete
|
|
180
|
+
end
|
|
179
181
|
rescue Interrupted
|
|
180
182
|
finalize_interrupted
|
|
181
183
|
raise
|
|
@@ -205,13 +207,21 @@ module Wurk
|
|
|
205
207
|
def run_iterations(args)
|
|
206
208
|
enum = build_enumerator(*args, cursor: @cursor)
|
|
207
209
|
enum.each do |item, new_cursor|
|
|
208
|
-
|
|
210
|
+
return :cancelled if cancelled?
|
|
211
|
+
|
|
212
|
+
# Cooperative shutdown. `interrupted?` (Wurk::Worker) is true once the
|
|
213
|
+
# owning Processor is stopping — quiet, TERM, or a rolling restart.
|
|
214
|
+
# Without this the loop would run until hard_shutdown raises
|
|
215
|
+
# Wurk::Shutdown (an Interrupt, not a RuntimeError), which skips the
|
|
216
|
+
# rescue below and loses everything since the last periodic flush.
|
|
217
|
+
raise Interrupted if interrupted?
|
|
209
218
|
|
|
210
219
|
@current_object = item
|
|
211
220
|
around_iteration { each_iteration(item, *args) }
|
|
212
221
|
@cursor = new_cursor
|
|
213
222
|
maybe_flush_state
|
|
214
223
|
end
|
|
224
|
+
:completed
|
|
215
225
|
end
|
|
216
226
|
|
|
217
227
|
def finalize_complete
|
|
@@ -220,6 +230,18 @@ module Wurk
|
|
|
220
230
|
delete_state
|
|
221
231
|
end
|
|
222
232
|
|
|
233
|
+
# Cancellation is terminal, not an interruption: the job must NOT be
|
|
234
|
+
# re-pushed, and the state HASH — `cancelled` field included — must go,
|
|
235
|
+
# otherwise the resumed run would trip on its own flag and re-push again
|
|
236
|
+
# every cycle until CANCELLATION_PERIOD expires. Callback order matches
|
|
237
|
+
# upstream Sidekiq, which treats a cancelled run as completed.
|
|
238
|
+
def finalize_cancelled
|
|
239
|
+
on_cancel
|
|
240
|
+
on_stop
|
|
241
|
+
on_complete
|
|
242
|
+
delete_state
|
|
243
|
+
end
|
|
244
|
+
|
|
223
245
|
def finalize_interrupted
|
|
224
246
|
flush_state(final: true)
|
|
225
247
|
on_cancel if @cancelled_at
|
data/lib/wurk/metrics/history.rb
CHANGED
|
@@ -6,42 +6,42 @@ module Wurk
|
|
|
6
6
|
module Metrics
|
|
7
7
|
# Ent feature parity (§5): server middleware that records per-job-class
|
|
8
8
|
# execution metrics into Redis time-buckets. The on-the-wire schema is
|
|
9
|
-
# wire-compat with Sidekiq
|
|
10
|
-
# `j
|
|
9
|
+
# wire-compat with Sidekiq's history pane — Sidekiq keys the per-minute
|
|
10
|
+
# HASH as `j|<YYYYMMDD>|<H>:<M>`, so dashboards (and Sidekiq data migrated
|
|
11
|
+
# in place) keep resolving against the same key after a drop-in swap.
|
|
11
12
|
#
|
|
12
13
|
# Bucket layout (spec: docs/target/sidekiq-free.md §1.6):
|
|
13
14
|
#
|
|
14
|
-
# j|
|
|
15
|
-
# <klass>|p
|
|
16
|
-
# <klass>|f
|
|
17
|
-
# <klass>|ms
|
|
15
|
+
# j|YYYYMMDD|H:M HASH per-minute bucket, TTL = MID_TERM (3 days)
|
|
16
|
+
# <klass>|p INT processed count
|
|
17
|
+
# <klass>|f INT failed count
|
|
18
|
+
# <klass>|ms INT total ms spent
|
|
18
19
|
#
|
|
19
|
-
#
|
|
20
|
-
# TTL = SHORT_TERM (8 hours) — short window for
|
|
21
|
-
# quick aggregate queries without scanning 600 minute keys.
|
|
20
|
+
# <klass>-YYYYMMDD-H HASH per-class hourly histogram, TTL = MID_TERM
|
|
22
21
|
#
|
|
23
|
-
#
|
|
22
|
+
# We deliberately do NOT write a `H:m0` 10-minute rollup. Its key format
|
|
23
|
+
# collides with the real minute-0 bucket, so rolling x1..x9 into it turns
|
|
24
|
+
# that minute's value into a decade total — and the read side (Query) then
|
|
25
|
+
# sums the minute-0 bucket alongside x1..x9 and double-counts. Sidekiq
|
|
26
|
+
# itself doesn't keep that rollup (the daily/hourly rollups are commented
|
|
27
|
+
# out in its ExecutionTracker); Query reads the last N per-minute keys.
|
|
24
28
|
#
|
|
25
|
-
# Every bucket TTL is set on
|
|
26
|
-
#
|
|
27
|
-
# write would keep the bucket alive indefinitely while traffic continues,
|
|
28
|
-
# but that's the desired behavior here: as long as a class keeps running,
|
|
29
|
-
# we keep the minute bucket around for the retention window measured from
|
|
29
|
+
# Every bucket TTL is set on every write (not NX): as long as a class keeps
|
|
30
|
+
# running we keep its bucket around for the retention window measured from
|
|
30
31
|
# *last write*, not from first write. So we EXPIRE unconditionally.
|
|
31
32
|
#
|
|
32
|
-
# The middleware is hot-path — every successful job pays for it. Writes
|
|
33
|
-
#
|
|
34
|
-
#
|
|
33
|
+
# The middleware is hot-path — every successful job pays for it. Writes are
|
|
34
|
+
# pipelined in a single round-trip per job (1 HINCRBY × 2 + 1 EXPIRE per
|
|
35
|
+
# bucket × 2 buckets = 6 commands, batched).
|
|
35
36
|
class History
|
|
36
37
|
include Wurk::Middleware::ServerMiddleware
|
|
37
38
|
|
|
38
39
|
# Per spec §1.6 — naming mirrors the upstream constants so anyone
|
|
39
40
|
# grepping the Sidekiq source for `MID_TERM` lands here.
|
|
40
41
|
MID_TERM = 3 * 24 * 60 * 60 # 3 days, in seconds
|
|
41
|
-
SHORT_TERM = 8 * 60 * 60 # 8 hours, in seconds
|
|
42
42
|
|
|
43
43
|
MINUTE_KEY_PREFIX = 'j|'
|
|
44
|
-
DATE_FORMAT = '%
|
|
44
|
+
DATE_FORMAT = '%Y%m%d' # YYYYMMDD — matches Sidekiq's j| key
|
|
45
45
|
|
|
46
46
|
def call(_worker, job, _queue)
|
|
47
47
|
klass = job['class']
|
|
@@ -74,28 +74,20 @@ module Wurk
|
|
|
74
74
|
|
|
75
75
|
ms = duration_ms.to_i
|
|
76
76
|
ms = 0 if ms.negative?
|
|
77
|
-
buckets = { minute: minute_key(at),
|
|
77
|
+
buckets = { minute: minute_key(at), hour: hour_key(klass, at) }
|
|
78
78
|
with_pool(redis_pool) { |conn| pipeline_write(conn, klass, ms, success, buckets) }
|
|
79
79
|
nil
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
# `p|f|ms`. Pipeline all
|
|
82
|
+
# The minute bucket carries per-class `<klass>|p|f|ms` fields; the
|
|
83
|
+
# hourly bucket is already class-scoped so its fields are bare
|
|
84
|
+
# `p|f|ms`. Pipeline all 6 commands in one round-trip.
|
|
85
85
|
def pipeline_write(conn, klass, ms, success, buckets)
|
|
86
86
|
outcome = success ? 'p' : 'f'
|
|
87
87
|
class_outcome = "#{klass}|#{outcome}"
|
|
88
88
|
class_ms = "#{klass}|ms"
|
|
89
89
|
conn.pipelined do |pipe|
|
|
90
90
|
incr_bucket(pipe, buckets[:minute], [class_outcome, class_ms, ms, MID_TERM])
|
|
91
|
-
# The minute key and the 10-min rollup key coincide whenever the
|
|
92
|
-
# minute ends in 0 (rollup zeroes the last digit). Writing both
|
|
93
|
-
# would double-count the shared field — the minute write above
|
|
94
|
-
# already lands on it — so skip the rollup write then. Minutes
|
|
95
|
-
# x1..x9 still accumulate into the x0 rollup key as normal.
|
|
96
|
-
unless buckets[:rollup] == buckets[:minute]
|
|
97
|
-
incr_bucket(pipe, buckets[:rollup], [class_outcome, class_ms, ms, SHORT_TERM])
|
|
98
|
-
end
|
|
99
91
|
incr_bucket(pipe, buckets[:hour], [outcome, 'ms', ms, MID_TERM])
|
|
100
92
|
end
|
|
101
93
|
end
|
|
@@ -115,12 +107,6 @@ module Wurk
|
|
|
115
107
|
date: t.strftime(DATE_FORMAT), hr: t.hour, min: t.min)
|
|
116
108
|
end
|
|
117
109
|
|
|
118
|
-
def rollup_key(time)
|
|
119
|
-
t = time.utc
|
|
120
|
-
format("#{MINUTE_KEY_PREFIX}%<date>s|%<hr>d:%<min>d",
|
|
121
|
-
date: t.strftime(DATE_FORMAT), hr: t.hour, min: (t.min / 10) * 10)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
110
|
def hour_key(klass, time)
|
|
125
111
|
t = time.utc
|
|
126
112
|
"#{klass}-#{t.strftime(DATE_FORMAT)}-#{t.hour}"
|
data/lib/wurk/metrics/query.rb
CHANGED
|
@@ -12,11 +12,15 @@ module Wurk
|
|
|
12
12
|
# external dashboarding code; both rely on the same HGETALL fan-out
|
|
13
13
|
# over a contiguous range of minute / hour keys.
|
|
14
14
|
#
|
|
15
|
-
# Window caps are spec-enforced:
|
|
15
|
+
# Window caps are spec-enforced (§20 "DoS caps"), one per argument:
|
|
16
16
|
#
|
|
17
|
-
# minutes ≤ 480 (8h
|
|
17
|
+
# minutes ≤ 480 (8h)
|
|
18
18
|
# hours ≤ 72 (3d — same as MID_TERM retention)
|
|
19
19
|
#
|
|
20
|
+
# `hours:` is validated against MAX_HOURS only, never re-checked against
|
|
21
|
+
# MAX_MINUTES after the ×60 conversion: the minute buckets it reads live
|
|
22
|
+
# for MID_TERM (3 days), so the whole 72h window is backed by real data.
|
|
23
|
+
#
|
|
20
24
|
# A wider window has no data to read anyway (the buckets are TTL'd out),
|
|
21
25
|
# so we fail loudly rather than silently returning sparse results.
|
|
22
26
|
module Query # rubocop:disable Metrics/ModuleLength
|
|
@@ -34,9 +38,8 @@ module Wurk
|
|
|
34
38
|
# sorted by volume (p + f) descending so the UI's "top jobs" table
|
|
35
39
|
# renders without a second sort pass.
|
|
36
40
|
def top_jobs(class_filter: nil, minutes: 60, hours: nil, now: ::Time.now)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
rows = aggregate_minutes(now, cap_minutes!(minutes)).to_a
|
|
41
|
+
window = hours ? cap_hours!(hours) * 60 : cap_minutes!(minutes)
|
|
42
|
+
rows = aggregate_minutes(now, window).to_a
|
|
40
43
|
rows = rows.select { |(k, _)| k.start_with?(class_filter) } if class_filter && !class_filter.empty?
|
|
41
44
|
rows.sort_by { |(_k, s)| -(s[:p] + s[:f]) }
|
|
42
45
|
end
|
data/lib/wurk/metrics/rollup.rb
CHANGED
|
@@ -7,7 +7,7 @@ require_relative 'history'
|
|
|
7
7
|
module Wurk
|
|
8
8
|
module Metrics
|
|
9
9
|
# Leader-only background thread that rolls the per-class minute buckets
|
|
10
|
-
# written by Wurk::Metrics::History (`j|
|
|
10
|
+
# written by Wurk::Metrics::History (`j|YYYYMMDD|H:M`) up into compact,
|
|
11
11
|
# cluster-total time-series buckets the dashboard "throughput" / "failures"
|
|
12
12
|
# charts read directly:
|
|
13
13
|
#
|
data/lib/wurk/profiler.rb
CHANGED
|
@@ -50,11 +50,13 @@ module Wurk
|
|
|
50
50
|
key = profile_key(token, jid)
|
|
51
51
|
gz = gzip(gecko_json)
|
|
52
52
|
with_pool(pool) do |conn|
|
|
53
|
-
conn.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
conn.pipelined do |pipe|
|
|
54
|
+
pipe.call('HSET', key, 'jid', jid, 'type', type, 'token', token,
|
|
55
|
+
'started_at', started_at.to_i, 'elapsed', elapsed_ms.to_i,
|
|
56
|
+
'size', gz.bytesize, 'sid', sid.to_s, 'data', gz)
|
|
57
|
+
pipe.call('EXPIRE', key, TTL)
|
|
58
|
+
pipe.call('ZADD', Keys::PROFILES, (now + TTL).to_i, key)
|
|
59
|
+
end
|
|
58
60
|
end
|
|
59
61
|
key
|
|
60
62
|
end
|
data/lib/wurk/sorted_entry.rb
CHANGED
|
@@ -24,6 +24,12 @@ module Wurk
|
|
|
24
24
|
@parent = parent
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# JobRecord resolves the queue eagerly and only from a pre-parsed Hash;
|
|
28
|
+
# a sorted-set entry is almost always built from the raw JSON string, so
|
|
29
|
+
# read it off the payload instead. Lazy on purpose — a scan over a large
|
|
30
|
+
# set must not pay JSON cost for entries nobody inspects.
|
|
31
|
+
def queue = @queue ||= item['queue']
|
|
32
|
+
|
|
27
33
|
def id = "#{score}|#{jid}"
|
|
28
34
|
|
|
29
35
|
def at = ::Time.at(score).utc
|
data/lib/wurk/testing.rb
CHANGED
|
@@ -56,14 +56,33 @@ module Wurk
|
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
# In-process server-middleware chain used
|
|
59
|
+
# In-process server-middleware chain used by `:inline` mode. Empty by
|
|
60
60
|
# default — configure with `Sidekiq::Testing.server_middleware { |c| ... }`.
|
|
61
|
+
# Deliberately NOT the globally configured chain: Sidekiq builds a fresh
|
|
62
|
+
# empty chain here too, and inline tests that suddenly ran batch/limiter/
|
|
63
|
+
# metrics middleware would diverge from a migrated suite's expectations.
|
|
64
|
+
# `perform_inline` is the explicit-execution path and does use the real
|
|
65
|
+
# chains (see Wurk::Worker::Setter#perform_inline).
|
|
61
66
|
def server_middleware
|
|
62
67
|
@server_middleware ||= ::Wurk::Middleware::Chain.new(::Wurk.configuration)
|
|
63
68
|
yield @server_middleware if block_given?
|
|
64
69
|
@server_middleware
|
|
65
70
|
end
|
|
66
71
|
|
|
72
|
+
# `require "sidekiq/testing"` flips Sidekiq into fake mode as a side
|
|
73
|
+
# effect; a migrated suite that never calls `fake!` would otherwise push
|
|
74
|
+
# into real Redis while every `MyJob.jobs` assertion came back empty.
|
|
75
|
+
# Deprecated in Sidekiq 8 in favour of `Sidekiq.testing!` — hence the
|
|
76
|
+
# warning. A mode already chosen (globally or for this thread) means the
|
|
77
|
+
# suite is explicit: don't override it, and don't warn again.
|
|
78
|
+
def deprecated_require!
|
|
79
|
+
return if ::Thread.current[THREAD_KEY] || @mode
|
|
80
|
+
|
|
81
|
+
::Kernel.warn '⛔️ `require "sidekiq/testing"` is deprecated and will be removed in Sidekiq 9.0. ' \
|
|
82
|
+
'Use `Sidekiq.testing!(:fake)` instead.'
|
|
83
|
+
fake!
|
|
84
|
+
end
|
|
85
|
+
|
|
67
86
|
# --- push hooks invoked by Wurk::Client#raw_push -------------------
|
|
68
87
|
|
|
69
88
|
# Route a push through the active test mode (only called when enabled?).
|
data/lib/wurk/unique.rb
CHANGED
|
@@ -8,7 +8,9 @@ require_relative 'lua'
|
|
|
8
8
|
module Wurk
|
|
9
9
|
# Sidekiq Enterprise unique jobs. Best-effort dedup at enqueue time keyed
|
|
10
10
|
# by a SHA256 digest of `[class, queue, args]` (overridable via
|
|
11
|
-
# `sidekiq_unique_context
|
|
11
|
+
# `sidekiq_unique_context`; ActiveJob-wrapped payloads narrow to the wrapped
|
|
12
|
+
# class + its arguments — see `active_job_context`). Three lock-release
|
|
13
|
+
# strategies:
|
|
12
14
|
#
|
|
13
15
|
# * `unique_until: :success` (default) — lock retained through retries;
|
|
14
16
|
# server middleware DELs it on successful perform. Surviving across
|
|
@@ -47,6 +49,21 @@ module Wurk
|
|
|
47
49
|
DEFAULT_UNTIL = :success
|
|
48
50
|
VALID_UNTIL = %i[success start].freeze
|
|
49
51
|
|
|
52
|
+
# Class names that carry an ActiveJob payload as their single arg. The
|
|
53
|
+
# canonical one Wurk writes is `Sidekiq::ActiveJob::Wrapper`; the others
|
|
54
|
+
# appear in payloads enqueued by older Sidekiq/Rails versions still sitting
|
|
55
|
+
# in Redis at gem-swap time.
|
|
56
|
+
ACTIVE_JOB_WRAPPERS = [
|
|
57
|
+
'Sidekiq::ActiveJob::Wrapper',
|
|
58
|
+
'Wurk::ActiveJob::Wrapper',
|
|
59
|
+
'ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper',
|
|
60
|
+
'ActiveJob::QueueAdapters::WurkAdapter::JobWrapper'
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
# Raised at enqueue when a worker opts into both `unique_for:` and
|
|
64
|
+
# `encrypt: true` (§3.8: mutually exclusive).
|
|
65
|
+
class ConfigurationError < StandardError; end
|
|
66
|
+
|
|
50
67
|
# Ent parity: a job that dies automatically releases its lock so a
|
|
51
68
|
# duplicate can enqueue immediately. Manual API/UI kills keep the lock
|
|
52
69
|
# until TTL expiry (Ent wiki, Ent-Unique-Jobs) — they reach death
|
|
@@ -94,9 +111,9 @@ module Wurk
|
|
|
94
111
|
nil
|
|
95
112
|
end
|
|
96
113
|
|
|
97
|
-
# Compute the lock key for an arbitrary `(queue, klass, args)` triple
|
|
98
|
-
#
|
|
99
|
-
#
|
|
114
|
+
# Compute the lock key for an arbitrary `(queue, klass, args)` triple,
|
|
115
|
+
# bypassing `sidekiq_unique_context`. Public so operators can compute
|
|
116
|
+
# the key of a worker that uses the default context (docs §8).
|
|
100
117
|
def lock_key(klass, queue, args)
|
|
101
118
|
context = [klass.to_s, queue.to_s, args]
|
|
102
119
|
"#{KEY_PREFIX}#{Digest::SHA256.hexdigest(JSON.dump(context))}"
|
|
@@ -112,18 +129,49 @@ module Wurk
|
|
|
112
129
|
|
|
113
130
|
# Default: `[class, queue, args]`. Workers may override by defining
|
|
114
131
|
# `self.sidekiq_unique_context(job)` returning any JSON-serializable
|
|
115
|
-
# value (e.g. a subset of args). Spec §3.5.
|
|
132
|
+
# value (e.g. a subset of args). Spec §3.5. ActiveJob-wrapped payloads
|
|
133
|
+
# get a narrowed default (see `active_job_context`) because their raw
|
|
134
|
+
# args can never repeat.
|
|
116
135
|
def unique_context(job)
|
|
117
136
|
klass = resolve_class(job['class'])
|
|
118
|
-
if klass.respond_to?(:sidekiq_unique_context)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
[job['class'], job['queue'], job['args']]
|
|
122
|
-
end
|
|
137
|
+
return klass.sidekiq_unique_context(job) if klass.respond_to?(:sidekiq_unique_context)
|
|
138
|
+
|
|
139
|
+
active_job_context(job) || [job['class'], job['queue'], job['args']]
|
|
123
140
|
end
|
|
124
141
|
|
|
125
142
|
private
|
|
126
143
|
|
|
144
|
+
# An ActiveJob payload's args are `[job.serialize]`, and that hash
|
|
145
|
+
# carries a fresh `job_id` plus an `enqueued_at` timestamp on every
|
|
146
|
+
# push — digesting it verbatim produces a new key every time, so
|
|
147
|
+
# nothing is ever deduped. Identity is therefore narrowed to the two
|
|
148
|
+
# fields that describe *which* job this is:
|
|
149
|
+
#
|
|
150
|
+
# * `job_class` — the real worker; the wire `class` is only the wrapper.
|
|
151
|
+
# * `arguments` — the serialized perform args.
|
|
152
|
+
#
|
|
153
|
+
# Everything else in the serialized hash is per-push state, not
|
|
154
|
+
# identity: `job_id`/`provider_job_id` (fresh per push), `enqueued_at`
|
|
155
|
+
# and `scheduled_at` (timestamps), `executions`/`exception_executions`
|
|
156
|
+
# (retry counters, so a retry re-push would otherwise miss its own
|
|
157
|
+
# lock), `priority`, `locale` and `timezone` (ambient request state —
|
|
158
|
+
# the same logical job enqueued from a different locale is still the
|
|
159
|
+
# same job). `queue_name` is dropped as redundant: the wire-level
|
|
160
|
+
# `job["queue"]` it was copied into is already in the context, keeping
|
|
161
|
+
# the "queue is part of the key" rule identical to the plain path.
|
|
162
|
+
#
|
|
163
|
+
# The wrapper class name stays in the context so an ActiveJob and a
|
|
164
|
+
# plain worker of the same name and args can't collide on one lock.
|
|
165
|
+
def active_job_context(job)
|
|
166
|
+
return nil unless ACTIVE_JOB_WRAPPERS.include?(job['class'].to_s)
|
|
167
|
+
|
|
168
|
+
data = job['args']
|
|
169
|
+
data = data.first if data.is_a?(::Array) && data.size == 1
|
|
170
|
+
return nil unless data.is_a?(::Hash) && data.key?('job_class') && data.key?('arguments')
|
|
171
|
+
|
|
172
|
+
[job['class'], job['queue'], data['job_class'], data['arguments']]
|
|
173
|
+
end
|
|
174
|
+
|
|
127
175
|
def resolve_class(name)
|
|
128
176
|
return nil if name.nil? || name.to_s.empty?
|
|
129
177
|
|
|
@@ -171,7 +219,10 @@ module Wurk
|
|
|
171
219
|
# @return [String, nil] owning jid, or nil when the lock is free.
|
|
172
220
|
def self.locked?(queue_or_klass, klass_or_args = nil, args = nil)
|
|
173
221
|
queue, klass, payload = normalize_locked_args(queue_or_klass, klass_or_args, args)
|
|
174
|
-
|
|
222
|
+
# Routed through lock_key_for, not lock_key: the probe must honor
|
|
223
|
+
# `sidekiq_unique_context` (and the ActiveJob default) or it reports
|
|
224
|
+
# "free" for every worker that customizes its digest.
|
|
225
|
+
key = lock_key_for('class' => klass.to_s, 'queue' => queue.to_s, 'args' => payload)
|
|
175
226
|
Wurk.redis { |c| c.call('GET', key) }
|
|
176
227
|
end
|
|
177
228
|
|
|
@@ -203,11 +254,29 @@ module Wurk
|
|
|
203
254
|
ttl = effective_ttl(job)
|
|
204
255
|
return yield if ttl.nil?
|
|
205
256
|
|
|
257
|
+
reject_encrypted!(job)
|
|
206
258
|
acquire_or_drop(redis_pool, job, Wurk::Unique.lock_key_for(job), ttl, &)
|
|
207
259
|
end
|
|
208
260
|
|
|
209
261
|
private
|
|
210
262
|
|
|
263
|
+
# §3.8: unique + encryption are mutually exclusive. Both features append
|
|
264
|
+
# to the same client chain, so whether the digest sees plaintext args or
|
|
265
|
+
# a random-IV envelope depended purely on which initializer ran first —
|
|
266
|
+
# and in the envelope case uniqueness silently never matched again.
|
|
267
|
+
# Failing the push is the only outcome that is identical in both
|
|
268
|
+
# orderings and impossible to miss. The check is per job, so the two
|
|
269
|
+
# features still coexist fine on *different* workers.
|
|
270
|
+
def reject_encrypted!(job)
|
|
271
|
+
return unless job['encrypt']
|
|
272
|
+
return unless defined?(Wurk::Encryption) && Wurk::Encryption.enabled?
|
|
273
|
+
|
|
274
|
+
raise Wurk::Unique::ConfigurationError,
|
|
275
|
+
"#{job['class']} sets both `unique_for` and `encrypt: true`, which cannot work: " \
|
|
276
|
+
'encryption rewrites the last argument with a fresh IV on every push, so the ' \
|
|
277
|
+
'unique digest never repeats. Drop one of the two options on this worker.'
|
|
278
|
+
end
|
|
279
|
+
|
|
211
280
|
# Add `at - now` delay to the base TTL so a scheduled job's lock
|
|
212
281
|
# spans the wait + execution window (§3.4). Returns nil when the
|
|
213
282
|
# job opts out (`unique_for: false` / missing).
|
data/lib/wurk/version.rb
CHANGED
data/lib/wurk/web/enterprise.rb
CHANGED
|
@@ -42,11 +42,33 @@ module Wurk
|
|
|
42
42
|
# row remains in the UI. Mirrors the §1.5 `#reset` surface — the
|
|
43
43
|
# name is wire-compat, so the trailing-? rule doesn't apply here.
|
|
44
44
|
def reset(name) # rubocop:disable Naming/PredicateMethod
|
|
45
|
+
limiter = rebuild(name)
|
|
46
|
+
# Reconstruct the limiter and delegate: only the type itself knows
|
|
47
|
+
# its state keys. A bucket's counter lives at `lmtr-b:<name>:<epoch>`,
|
|
48
|
+
# so the bare-prefix DEL below never touched it.
|
|
49
|
+
limiter ? limiter.reset : reset_by_prefix(name)
|
|
50
|
+
true
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Read-only reconstruction (`register: false`) from the persisted
|
|
54
|
+
# metadata. Returns nil when the meta HASH is gone or malformed —
|
|
55
|
+
# LIST membership has no TTL, so a name can outlive its metadata.
|
|
56
|
+
def rebuild(name)
|
|
57
|
+
meta = metadata(name)
|
|
58
|
+
return nil if meta.nil? || meta.empty?
|
|
59
|
+
|
|
60
|
+
options = meta['options'] ? ::JSON.parse(meta['options']) : {}
|
|
61
|
+
Wurk::Limiter.build(name, meta['type'], options)
|
|
62
|
+
rescue StandardError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Metadata-less fallback: wipe every type's un-suffixed state key.
|
|
67
|
+
def reset_by_prefix(name)
|
|
45
68
|
Wurk.redis do |c|
|
|
46
|
-
%W[lmtr-cs:#{name} lmtr-
|
|
69
|
+
%W[lmtr-cs:#{name} lmtr-w:#{name} lmtr-l:#{name}
|
|
47
70
|
lmtr-p:#{name} lmtr-stats:#{name}].each { |k| c.call('DEL', k) }
|
|
48
71
|
end
|
|
49
|
-
true
|
|
50
72
|
end
|
|
51
73
|
end
|
|
52
74
|
|
data/lib/wurk/worker/setter.rb
CHANGED
|
@@ -31,8 +31,24 @@ module Wurk
|
|
|
31
31
|
@klass.client_push(@opts.merge('class' => @klass, 'args' => args))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
# Explicit synchronous execution. Runs the payload through the real client
|
|
35
|
+
# AND server middleware chains rather than calling `perform` directly:
|
|
36
|
+
# unique-job locks are taken by the client chain and released only by the
|
|
37
|
+
# server chain (bypassing both leaked every lock until its TTL), and batch
|
|
38
|
+
# callbacks fire from server middleware. Mirrors Sidekiq's Setter#perform_inline,
|
|
39
|
+
# including its return contract — nil when middleware halts the job, else true.
|
|
40
|
+
def perform_inline(*args)
|
|
41
|
+
config = Wurk.configuration
|
|
42
|
+
item = normalize_item(@opts.merge('class' => @klass, 'args' => args))
|
|
43
|
+
pushed = config.client_middleware.invoke(item['class'], item, item['queue'], config.redis_pool) do
|
|
44
|
+
verify_json(item)
|
|
45
|
+
item
|
|
46
|
+
end
|
|
47
|
+
return nil unless pushed
|
|
48
|
+
|
|
49
|
+
# Round-trip through JSON so the job sees exactly the arguments a real
|
|
50
|
+
# worker would after the payload has crossed Redis.
|
|
51
|
+
run_job(Wurk.load_json(Wurk.dump_json(item)), config)
|
|
36
52
|
end
|
|
37
53
|
alias perform_sync perform_inline
|
|
38
54
|
|
|
@@ -58,6 +74,17 @@ module Wurk
|
|
|
58
74
|
|
|
59
75
|
private
|
|
60
76
|
|
|
77
|
+
def run_job(msg, config)
|
|
78
|
+
instance = @klass.new
|
|
79
|
+
instance.jid = msg['jid']
|
|
80
|
+
instance.bid = msg['bid'] if instance.respond_to?(:bid=)
|
|
81
|
+
ran = config.server_middleware.invoke(instance, msg, msg['queue']) do
|
|
82
|
+
instance.perform(*msg['args'])
|
|
83
|
+
true
|
|
84
|
+
end
|
|
85
|
+
ran ? true : nil
|
|
86
|
+
end
|
|
87
|
+
|
|
61
88
|
def absolute_at(interval)
|
|
62
89
|
unless interval.is_a?(Numeric) || interval.is_a?(Time)
|
|
63
90
|
raise ArgumentError, "interval must be Numeric or Time, got #{interval.class}"
|
data/lib/wurk/worker.rb
CHANGED
|
@@ -132,12 +132,12 @@ module Wurk
|
|
|
132
132
|
Wurk::Worker::Setter.new(self, {}).perform_async(*)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
-
# Run
|
|
136
|
-
#
|
|
135
|
+
# Run the job synchronously in the current thread, through both middleware
|
|
136
|
+
# chains (see {Wurk::Worker::Setter#perform_inline}). Useful in tests.
|
|
137
137
|
#
|
|
138
|
-
# @return [
|
|
138
|
+
# @return [true, nil] nil when middleware halted the job, true otherwise
|
|
139
139
|
def perform_inline(*)
|
|
140
|
-
new.
|
|
140
|
+
Wurk::Worker::Setter.new(self, {}).perform_inline(*)
|
|
141
141
|
end
|
|
142
142
|
alias perform_sync perform_inline
|
|
143
143
|
|