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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8911da9e550c5d8078c4293ebb69f3b8620a4285692f6e3a6b37d41b711c9219
4
- data.tar.gz: 9cc0cf226409e7fb5f9ffce6749b2a7328324e9319350d7752c8bdf8d33e7437
3
+ metadata.gz: 811eecb5136dece5d9fcc6481991485b7183666edaa4cc464998b79d86fda34a
4
+ data.tar.gz: 5637cb5f107647ca79929032653e5410784b153ec72dfb36282199b8cfa45713
5
5
  SHA512:
6
- metadata.gz: '093767442c103fc2a99fc8b8d0b5fa21c16d50a46cd90be6826b948c82c871217e725c07af71b6df3c98d501788199104601262081cfe8810b7bbd510c099191'
7
- data.tar.gz: fe4977ee515f59cbb6758fe4f42a5d6a8a198c863efc5bebccd211e1c47a2f1ff3f11d90ce85ad1e7faa8f335e1b865ff0bb8efbc0a827773c4d977c968e149b
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
- The precompiled SPA ships inside the gem, so consumers never run Node. Gate it behind your app's auth with one line see **[Securing the dashboard](https://github.com/developerz-ai/wurk/blob/main/docs/dashboard.md)** for Devise/Warden/Sorcery recipes:
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 only the first child to `start` binds the port.
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. Recharts-ready array under `series`.
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 Recharts-ready.
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
@@ -2,3 +2,5 @@
2
2
 
3
3
  # Drop-in require path (see lib/sidekiq.rb). Wurk loads this surface whole.
4
4
  require 'wurk'
5
+
6
+ Wurk::Testing.deprecated_require!
@@ -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. Target may be a Class name
8
- # ("MyCallback" → `MyCallback.new.on_<event>(status, options)`) or a
9
- # "Klass#method" spec ("Foo#bar" `Foo.new.bar(status, options)`).
10
- # Failures retry like any ordinary job — callbacks MUST be idempotent.
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
- status = Wurk::Batch::Status.new(bid)
22
- instance.public_send(method, status, options || {})
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
- if spec.include?('#')
29
- klass_name, method_name = spec.split('#', 2)
30
- [Object.const_get(klass_name), method_name.to_sym]
31
- else
32
- [Object.const_get(spec), :"on_#{event}"]
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, :callback_class, :autoflush
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
 
@@ -279,13 +279,17 @@ module Wurk
279
279
  @options[:fetch_poll_interval]
280
280
  end
281
281
 
282
- # --- Reliability (Sidekiq Pro drop-in no-ops) ------------------------
282
+ # --- Reliability (Sidekiq Pro drop-in toggles) -----------------------
283
283
 
284
- # Sidekiq Pro's opt-in toggles for reliable fetch and the reliable
285
- # scheduler. Both are already the default in Wurk the fetcher is always
286
- # the reliable BLMOVE fetcher with orphan reclamation, and the scheduler is
287
- # always atomic Lua so the toggle itself is a no-op. They exist only so a
288
- # Pro initializer drops in unchanged instead of raising NoMethodError.
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
- def initialize(config = nil)
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', "#{LOOP_PREFIX}#{loop_obj.lid}", *loop_obj.to_redis_hash.flatten)
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 a loop entirely. Used by the Web UI delete action and by the
678
- # config-reload path so a removed `register(...)` line vanishes from
679
- # Redis on next boot.
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)
@@ -173,9 +173,11 @@ module Wurk
173
173
  fire_lifecycle_start
174
174
  @executions += 1
175
175
 
176
- run_iterations(args)
177
-
178
- finalize_complete
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
- raise Interrupted if cancelled?
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
@@ -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 8.x's history pane so dashboards built against
10
- # `j|YYMMDD|H:M` HASH keys keep working unchanged.
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|YYMMDD|H:M HASH per-minute bucket, TTL = MID_TERM (3 days)
15
- # <klass>|p INT processed count
16
- # <klass>|f INT failed count
17
- # <klass>|ms INT total ms spent
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
- # j|YYMMDD|H:m0 HASH 10-minute rollup (last digit zeroed),
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
- # <klass>-YYMMDD-H HASH per-class hourly histogram, TTL = MID_TERM
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 first write (EXPIRE NX-equivalent: only when
26
- # the HASH was newly created in this call) re-asserting TTL on every
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
- # are pipelined in a single round-trip per job (1 HINCRBY × 3 + 1 EXPIRE
34
- # per bucket × 3 buckets = 12 commands, batched).
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 = '%y%m%d' # YYMMDDtwo-digit year per spec
44
+ DATE_FORMAT = '%Y%m%d' # YYYYMMDDmatches 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), rollup: rollup_key(at), hour: hour_key(klass, 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
- # Minute + 10-min rollup share a per-class `|p|f|ms` field layout;
83
- # the hourly bucket is already class-scoped so its fields are bare
84
- # `p|f|ms`. Pipeline all 9 commands in one round-trip.
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}"
@@ -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 — same as the 10-minute rollup retention)
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
- minutes = hours * 60 if hours
38
- cap_hours!(hours) if hours
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
@@ -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|YYMMDD|H:M`) up into compact,
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.call('HSET', key, 'jid', jid, 'type', type, 'token', token,
54
- 'started_at', started_at.to_i, 'elapsed', elapsed_ms.to_i,
55
- 'size', gz.bytesize, 'sid', sid.to_s, 'data', gz)
56
- conn.call('EXPIRE', key, TTL)
57
- conn.call('ZADD', Keys::PROFILES, (now + TTL).to_i, key)
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
@@ -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 for inline execution. Empty by
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`). Three lock-release strategies:
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
- # Used by both the client middleware and the public `locked?` probe so
99
- # they cannot drift.
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
- klass.sidekiq_unique_context(job)
120
- else
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
- key = lock_key(klass, queue, payload)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wurk
4
- VERSION = "1.1.1"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -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-b:#{name} lmtr-w:#{name} lmtr-l:#{name}
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
 
@@ -31,8 +31,24 @@ module Wurk
31
31
  @klass.client_push(@opts.merge('class' => @klass, 'args' => args))
32
32
  end
33
33
 
34
- def perform_inline(*)
35
- @klass.new.perform(*)
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 `#perform` synchronously in the current thread (no Redis). Useful in
136
- # tests and for inline execution.
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 [Object] the return value of `#perform`
138
+ # @return [true, nil] nil when middleware halted the job, true otherwise
139
139
  def perform_inline(*)
140
- new.perform(*)
140
+ Wurk::Worker::Setter.new(self, {}).perform_inline(*)
141
141
  end
142
142
  alias perform_sync perform_inline
143
143
 
@@ -1,4 +1,4 @@
1
1
  {
2
- "version": "1.1.1",
3
- "timestamp": "2026-07-18T13:06:31.576Z"
2
+ "version": "1.2.0",
3
+ "timestamp": "2026-07-21T04:58:05.082Z"
4
4
  }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: wurk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.1
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - developerz.ai