wurk 1.3.1 → 1.5.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/lib/wurk/batch/callbacks.rb +82 -12
  5. data/lib/wurk/batch/death_handler.rb +7 -4
  6. data/lib/wurk/batch/server_middleware.rb +1 -1
  7. data/lib/wurk/batch.rb +121 -15
  8. data/lib/wurk/capsule.rb +45 -16
  9. data/lib/wurk/cli.rb +48 -14
  10. data/lib/wurk/client/buffered.rb +200 -43
  11. data/lib/wurk/client.rb +181 -38
  12. data/lib/wurk/compat.rb +1 -1
  13. data/lib/wurk/component.rb +36 -4
  14. data/lib/wurk/configuration.rb +47 -7
  15. data/lib/wurk/context.rb +1 -1
  16. data/lib/wurk/cron.rb +94 -37
  17. data/lib/wurk/deploy.rb +5 -3
  18. data/lib/wurk/embedded.rb +13 -0
  19. data/lib/wurk/engine.rb +41 -1
  20. data/lib/wurk/errors.rb +15 -0
  21. data/lib/wurk/fetcher/reaper.rb +125 -58
  22. data/lib/wurk/fetcher/reliable.rb +366 -51
  23. data/lib/wurk/fetcher.rb +6 -0
  24. data/lib/wurk/heartbeat.rb +24 -12
  25. data/lib/wurk/history.rb +13 -1
  26. data/lib/wurk/job_logger.rb +16 -7
  27. data/lib/wurk/job_set.rb +3 -2
  28. data/lib/wurk/job_util.rb +44 -24
  29. data/lib/wurk/launcher.rb +230 -119
  30. data/lib/wurk/leader.rb +63 -14
  31. data/lib/wurk/limiter/base.rb +8 -10
  32. data/lib/wurk/limiter/bucket.rb +1 -1
  33. data/lib/wurk/limiter/concurrent.rb +27 -22
  34. data/lib/wurk/limiter/window.rb +13 -11
  35. data/lib/wurk/limiter.rb +7 -4
  36. data/lib/wurk/logger.rb +1 -1
  37. data/lib/wurk/lua/loader.rb +9 -3
  38. data/lib/wurk/lua.rb +97 -14
  39. data/lib/wurk/manager.rb +29 -13
  40. data/lib/wurk/metrics/accumulator.rb +95 -0
  41. data/lib/wurk/metrics/flusher.rb +70 -0
  42. data/lib/wurk/metrics/history.rb +102 -32
  43. data/lib/wurk/metrics/queue_rollup.rb +13 -1
  44. data/lib/wurk/metrics/rollup.rb +13 -1
  45. data/lib/wurk/metrics/statsd.rb +32 -18
  46. data/lib/wurk/middleware/chain.rb +31 -14
  47. data/lib/wurk/middleware/interrupt_handler.rb +7 -6
  48. data/lib/wurk/middleware/poison_pill.rb +93 -31
  49. data/lib/wurk/middleware.rb +2 -2
  50. data/lib/wurk/pool_checkout.rb +39 -0
  51. data/lib/wurk/process_set.rb +10 -5
  52. data/lib/wurk/processor.rb +83 -9
  53. data/lib/wurk/profiler.rb +9 -4
  54. data/lib/wurk/queue.rb +18 -7
  55. data/lib/wurk/rails_boot.rb +38 -7
  56. data/lib/wurk/redis_client_adapter.rb +49 -5
  57. data/lib/wurk/redis_pool.rb +71 -25
  58. data/lib/wurk/scheduled.rb +30 -2
  59. data/lib/wurk/shutdown_gate.rb +79 -0
  60. data/lib/wurk/stats.rb +19 -10
  61. data/lib/wurk/swarm/child_boot.rb +36 -4
  62. data/lib/wurk/swarm.rb +258 -43
  63. data/lib/wurk/timer_loop.rb +14 -0
  64. data/lib/wurk/version.rb +1 -1
  65. data/lib/wurk/web/config.rb +11 -7
  66. data/lib/wurk/web/enterprise.rb +58 -6
  67. data/lib/wurk/web/extension.rb +1 -1
  68. data/lib/wurk/web/search.rb +5 -3
  69. data/lib/wurk.rb +12 -10
  70. data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
  75. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
  76. data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
  77. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
  78. data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
  81. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
  82. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
  83. data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
  84. data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
  85. data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
  89. data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
  91. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
  92. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
  93. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +32 -27
  98. data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
  99. data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
  100. data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
  101. data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
  102. data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
  103. data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
@@ -19,15 +19,22 @@ module Wurk
19
19
  # Counter key TTL is wire-compat with Sidekiq Pro — third-party tooling
20
20
  # that watches `super_fetch:recovered:*` expects 72h.
21
21
  #
22
- # No server-middleware registration: callers are reaper / bulk_requeue
23
- # paths that drive the lifecycle directly via `track!(payload, queue:)`.
24
- # When that integration lands, it just calls `PoisonPill.track!` on each
25
- # orphan it about to RPUSH back to the public queue.
22
+ # No server-middleware registration: the producer is Reaper#drain, which
23
+ # drives the lifecycle directly via `track!(payload, queue:)` on each
24
+ # orphan it moves back to the public queue. The consumer is the ACK —
25
+ # `Fetcher::Reliable::UnitOfWork#acknowledge` pipelines {clear_in} next to
26
+ # its LREM, so an attempt that finished starts the next one at zero
27
+ # without costing a round trip of its own.
26
28
  module PoisonPill
27
29
  RECOVERY_THRESHOLD = 3
28
30
  RECOVERY_TTL = 72 * 60 * 60
29
31
  KEY_PREFIX = 'super_fetch:recovered:'
30
- DEAD_RECORD_LIMIT = 100
32
+
33
+ # Handed to death handlers (and therefore to `:death` batch callbacks)
34
+ # as the cause when a recovered job is killed as a poison pill. Never
35
+ # raised: nothing is on a stack here — the reaper kills the job from the
36
+ # outside, on behalf of the workers it took down.
37
+ class Poisoned < ::StandardError; end
31
38
 
32
39
  # The `pill` handed to a Pro `super_fetch! { |jobstr, pill| }` recovery
33
40
  # callback on the kill path. Responds to .jid/.klass/.count/.queue so a
@@ -62,7 +69,7 @@ module Wurk
62
69
 
63
70
  jid = job['jid']
64
71
  klass = job['class']
65
- emit_recovered_fetch(klass, queue)
72
+ emit('jobs.recovered.fetch', klass, queue)
66
73
 
67
74
  count = bump_counter(jid) if jid && !jid.empty?
68
75
  if count && count >= RECOVERY_THRESHOLD
@@ -80,25 +87,74 @@ module Wurk
80
87
  def recovery_count(jid)
81
88
  return 0 if jid.nil? || jid.to_s.empty?
82
89
 
83
- Wurk.redis { |conn| conn.call('GET', "#{KEY_PREFIX}#{jid}") }.to_i
90
+ Wurk.redis { |conn| conn.call('GET', counter_key(jid)) }.to_i
84
91
  end
85
92
 
86
- # Resets the counter for a jid — call after a successful perform so a
87
- # job that recovered twice and then completed doesn't accumulate state.
93
+ # Resets the counter for a jid. The hot path uses {clear_in} instead —
94
+ # this is the standalone form for the API/dashboard and for callers with
95
+ # no pipeline of their own.
88
96
  def clear!(jid)
89
97
  return if jid.nil? || jid.to_s.empty?
90
98
 
91
- Wurk.redis { |conn| conn.call('DEL', "#{KEY_PREFIX}#{jid}") }
99
+ Wurk.redis { |conn| conn.call('DEL', counter_key(jid)) }
100
+ end
101
+
102
+ # Queue the counter reset onto a pipeline the caller already has open.
103
+ #
104
+ # The ACK is the reset point. An attempt that got as far as acking —
105
+ # returned, or raised and booked its retry — is proof the job did not
106
+ # take its worker down, and that is the only thing this counter
107
+ # measures. Without the reset, three reclaims caused by three unrelated
108
+ # crashes inside 72h dead-set a job that has been completing all along
109
+ # (jids do come back: a UI/API retry re-pushes the same one, as does any
110
+ # client that supplies its own). Riding a round trip the ACK already
111
+ # makes is what keeps that free for the jobs — nearly all of them — that
112
+ # were never reclaimed at all.
113
+ # Guards the blank jid for the same reason {clear!} and {recovery_count}
114
+ # do, rather than relying on the one caller to do it: `counter_key(nil)`
115
+ # is the bare KEY_PREFIX, so an unguarded DEL here would delete a key
116
+ # that is shared rather than per-job. The caller's own check stays --
117
+ # it also skips queueing the command at all -- but the invariant belongs
118
+ # on the method that builds the key.
119
+ def clear_in(pipe, jid)
120
+ return if jid.nil? || jid.to_s.empty?
121
+
122
+ pipe.call('DEL', counter_key(jid))
123
+ end
124
+
125
+ def counter_key(jid)
126
+ "#{KEY_PREFIX}#{jid}"
92
127
  end
93
128
 
94
129
  # Register a callback fired when a poison pill is detected. Callbacks
95
130
  # receive a single Hash `{jid:, klass:, count:, queue:}` — matches
96
131
  # Sidekiq Pro's documented shape so consumers can drop in unchanged.
132
+ #
133
+ # Dedups by callable identity — an initializer that re-runs (Rails
134
+ # code reloading, a re-required file) and hands back the *same* stored
135
+ # Proc each time won't accumulate duplicate closures (and the bindings
136
+ # they retain). A fresh block literal per call still registers
137
+ # separately, same as before. Returns a {Registration} the caller can
138
+ # `#remove!` to deregister explicitly.
97
139
  def on_poison(&block)
98
140
  raise ArgumentError, 'block required' unless block
99
141
 
100
- callbacks << block
101
- block
142
+ callbacks << block unless callbacks.any? { |cb| cb.equal?(block) }
143
+ Registration.new(callbacks, block)
144
+ end
145
+
146
+ # Handle returned by {on_poison}. Removing it is idempotent and safe
147
+ # even after a {reset!} discarded the underlying list.
148
+ class Registration
149
+ def initialize(list, callback)
150
+ @list = list
151
+ @callback = callback
152
+ end
153
+
154
+ def remove!
155
+ @list.delete(@callback)
156
+ nil
157
+ end
102
158
  end
103
159
 
104
160
  def callbacks
@@ -113,19 +169,21 @@ module Wurk
113
169
  # ---- internals --------------------------------------------------
114
170
 
115
171
  def parse(payload)
116
- case payload
117
- when Hash then payload
118
- when String
119
- begin
120
- Wurk.load_json(payload)
121
- rescue ::JSON::ParserError
122
- nil
123
- end
124
- end
172
+ return payload if payload.is_a?(::Hash)
173
+ return nil unless payload.is_a?(::String)
174
+
175
+ Wurk.load_json(payload)
176
+ rescue ::JSON::ParserError
177
+ nil
125
178
  end
126
179
 
180
+ # No apply-safety claim: INCR is additive, so a block replayed after a
181
+ # lost reply bumps twice and can carry a healthy job past
182
+ # RECOVERY_THRESHOLD into the dead set. Raising instead only leaves the
183
+ # count short — Reaper#drain rescues, and the job is already back on its
184
+ # public queue, so it just misses one poison check.
127
185
  def bump_counter(jid)
128
- key = "#{KEY_PREFIX}#{jid}"
186
+ key = counter_key(jid)
129
187
  Wurk.redis do |conn|
130
188
  count = conn.call('INCR', key).to_i
131
189
  conn.call('EXPIRE', key, RECOVERY_TTL)
@@ -133,25 +191,29 @@ module Wurk
133
191
  end
134
192
  end
135
193
 
136
- def emit_recovered_fetch(klass, queue)
194
+ def emit(metric, klass, queue)
137
195
  tags = []
138
196
  tags << "class:#{klass}" if klass
139
197
  tags << "queue:#{queue}" if queue
140
- Wurk::Metrics::Statsd.increment('jobs.recovered.fetch', tags: tags.empty? ? nil : tags)
198
+ Wurk::Metrics::Statsd.increment(metric, tags: tags.empty? ? nil : tags)
141
199
  end
142
200
 
201
+ # Death handlers fire (`notify_failure` defaults to true). A poison kill
202
+ # is a death like any other exhaustion: suppressing it strands every
203
+ # batch that owns the job — Batch::DeathHandler is a death handler, so a
204
+ # silent kill leaves the batch's pending count stuck forever and neither
205
+ # `:death` nor `:complete` ever runs. Pro's spec is silent here
206
+ # (docs/target/sidekiq-pro.md §12); recorded in docs/idea/parity-divergences.md.
143
207
  def mark_poison(payload, job, queue:, count:)
144
- emit_poison(job['class'], queue)
208
+ emit('jobs.poison', job['class'], queue)
145
209
  json = payload.is_a?(String) ? payload : Wurk.dump_json(job)
146
- Wurk::DeadSet.new.kill(json, notify_failure: false)
210
+ Wurk::DeadSet.new.kill(json, ex: poisoned_error(job['class'], count))
147
211
  fire_callbacks(jid: job['jid'], klass: job['class'], count: count, queue: queue)
148
212
  end
149
213
 
150
- def emit_poison(klass, queue)
151
- tags = []
152
- tags << "class:#{klass}" if klass
153
- tags << "queue:#{queue}" if queue
154
- Wurk::Metrics::Statsd.increment('jobs.poison', tags: tags.empty? ? nil : tags)
214
+ def poisoned_error(klass, count)
215
+ message = "#{klass || 'job'} was recovered #{count} times without completing"
216
+ Poisoned.new(message).tap { |e| e.set_backtrace(caller) }
155
217
  end
156
218
 
157
219
  def fire_callbacks(pill)
@@ -24,8 +24,8 @@ module Wurk
24
24
  config.logger
25
25
  end
26
26
 
27
- def redis(&)
28
- config.redis(&)
27
+ def redis(idempotent: false, &)
28
+ config.redis(idempotent:, &)
29
29
  end
30
30
  end
31
31
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'redis_pool'
4
+
5
+ module Wurk
6
+ # The seam every `#redis`-style wrapper checks out through.
7
+ #
8
+ # `idempotent:` is a {Wurk::RedisPool} concept — it re-enables block replay
9
+ # after a command may already have applied server-side (see RedisPool#with).
10
+ # The object behind a wrapper is not always ours, though: the Sidekiq surface
11
+ # Wurk drops into lets a host supply any ConnectionPool-shaped object
12
+ # (`Sidekiq::Client#redis_pool=`, `Sidekiq::Web.redis_pool=`, the `pool:`
13
+ # kwarg on Stats::History / Deploy / Leader / Profiler / Metrics::History),
14
+ # and a bare `def with; yield conn; end` decorator — the shape people reach
15
+ # for to count or trace round trips — is a legal implementation of it. Handing
16
+ # that an unknown keyword is an ArgumentError, so the claim goes only to a pool
17
+ # that understands it.
18
+ #
19
+ # Dropping the claim is always sound: the pool falls back to the conservative
20
+ # no-replay default, which is exactly what a foreign pool did before the
21
+ # keyword existed. Apply-safety is an optimization, never a correctness input.
22
+ module PoolCheckout
23
+ def self.with(pool, idempotent, &)
24
+ return pool.with(&) unless idempotent && pool.is_a?(RedisPool)
25
+
26
+ pool.with(idempotent: true, &)
27
+ end
28
+
29
+ # Same apply-safety forwarding as .with, minus the is_a? probe. Only for
30
+ # call sites whose pool is always this process's own {RedisPool} — never a
31
+ # host-supplied `pool:` kwarg — so the polymorphic check has nothing to
32
+ # decide.
33
+ def self.trusted(pool, idempotent, &)
34
+ return pool.with(&) unless idempotent
35
+
36
+ pool.with(idempotent: true, &)
37
+ end
38
+ end
39
+ end
@@ -32,7 +32,7 @@ module Wurk
32
32
  # absence (process never registered) and expiry (heartbeat lapsed,
33
33
  # info field gone) both return nil.
34
34
  def self.[](identity)
35
- exists, fields = Wurk.redis do |conn|
35
+ exists, fields = Wurk.redis(idempotent: true) do |conn|
36
36
  conn.pipelined do |pipe|
37
37
  pipe.call('SISMEMBER', Keys::PROCESSES, identity)
38
38
  pipe.call('HMGET', identity, *LOOKUP_FIELDS)
@@ -48,6 +48,11 @@ module Wurk
48
48
  # don't dogpile the prune. Returns the number of identities removed
49
49
  # (or 0 when the lock was held by someone else).
50
50
  #
51
+ # No apply-safety claim, unlike the reads around it: a replay re-decides
52
+ # which identities are dead, so it could prune one that registered in
53
+ # between and hasn't written `info` yet. A blip here just skips one prune —
54
+ # the next caller a minute later does it.
55
+ #
51
56
  # Spec: docs/target/sidekiq-free.md §31.17.
52
57
  def cleanup
53
58
  return 0 unless acquired_cleanup_lock?
@@ -84,7 +89,7 @@ module Wurk
84
89
  # SCARD over `processes`. Not pruned — may include identities whose
85
90
  # heartbeat has lapsed. Use `each` for the accurate count.
86
91
  def size
87
- Wurk.redis { |conn| conn.call('SCARD', Keys::PROCESSES) }
92
+ Wurk.redis(idempotent: true) { |conn| conn.call('SCARD', Keys::PROCESSES) }
88
93
  end
89
94
 
90
95
  # Sum of `concurrency` across live processes. Iterates `each` so dead
@@ -104,7 +109,7 @@ module Wurk
104
109
  # `||=` with empty-string fallback distinguishes "leader is unset" from
105
110
  # "memoization not yet computed".
106
111
  def leader
107
- @leader ||= Wurk.redis { |c| c.call('GET', 'dear-leader') } || ''
112
+ @leader ||= Wurk.redis(idempotent: true) { |c| c.call('GET', 'dear-leader') } || ''
108
113
  end
109
114
 
110
115
  class << self
@@ -134,7 +139,7 @@ module Wurk
134
139
  private
135
140
 
136
141
  def fetch_each_rows
137
- Wurk.redis do |conn|
142
+ Wurk.redis(idempotent: true) do |conn|
138
143
  procs = conn.call('SMEMBERS', Keys::PROCESSES).sort
139
144
  next [] if procs.empty?
140
145
 
@@ -223,7 +228,7 @@ module Wurk
223
228
  # Compares identity against the `dear-leader` STRING. Ent-only;
224
229
  # always false in OSS/free.
225
230
  def leader?
226
- Wurk.redis { |c| c.call('GET', 'dear-leader') == identity }
231
+ Wurk.redis(idempotent: true) { |c| c.call('GET', 'dear-leader') == identity }
227
232
  end
228
233
 
229
234
  private
@@ -10,8 +10,11 @@ require_relative 'profiler'
10
10
  module Wurk
11
11
  # Inside each Manager, N Processors run in parallel. Each owns one thread,
12
12
  # pulls a UnitOfWork from the capsule's fetcher, parses the payload, walks
13
- # the server middleware chain, invokes `perform`, then ACKs (removes the
14
- # payload from the per-process private list).
13
+ # the server middleware chain, invokes `perform`, then ACKs (retires the
14
+ # payload from the per-process private list). The ACK is handed to the
15
+ # fetcher, which pipelines it with the next fetch rather than spending a
16
+ # round trip on it — #flush_acks covers the case where there is no next
17
+ # fetch.
15
18
  #
16
19
  # Shutdown is two-stage:
17
20
  # * `terminate` flips a flag; the run loop exits between jobs.
@@ -23,6 +26,26 @@ module Wurk
23
26
  class Processor
24
27
  include Component
25
28
 
29
+ # Interrupt masks for the two `Thread.handle_interrupt` scopes every job
30
+ # runs inside. Frozen constants rather than the inline literals they
31
+ # replace: the mask never varies, and a literal allocated two Hashes per
32
+ # job. Sidekiq hoists the same pair (processor.rb:161-164).
33
+ IGNORE_SHUTDOWN_INTERRUPTS = { Wurk::Shutdown => :never }.freeze
34
+ private_constant :IGNORE_SHUTDOWN_INTERRUPTS
35
+ ALLOW_SHUTDOWN_INTERRUPTS = { Wurk::Shutdown => :immediate }.freeze
36
+ private_constant :ALLOW_SHUTDOWN_INTERRUPTS
37
+
38
+ # Stand-in for the default reloader, `proc { |&b| b.call }`. That default
39
+ # is an identity wrapper, but a block param (`|&b|`) forces MRI to reify
40
+ # the dispatch block into a Proc on every job just to call it straight
41
+ # back; `yield` does not. Same contract, one less allocation per job.
42
+ module IdentityReloader
43
+ def self.call
44
+ yield
45
+ end
46
+ end
47
+ private_constant :IdentityReloader
48
+
26
49
  attr_reader :thread, :job, :capsule
27
50
 
28
51
  def initialize(capsule, &callback)
@@ -32,7 +55,7 @@ module Wurk
32
55
  @done = false
33
56
  @job = nil
34
57
  @thread = nil
35
- @reloader = capsule.config[:reloader] || proc { |&b| b.call }
58
+ @reloader = resolve_reloader(capsule.config[:reloader])
36
59
  @job_logger = (capsule.config[:job_logger] || JobLogger).new(capsule.config)
37
60
  @retrier = JobRetry.new(capsule)
38
61
  end
@@ -116,6 +139,19 @@ module Wurk
116
139
  @lock.synchronize { @work.delete(tid) }
117
140
  end
118
141
 
142
+ # RAII: publish for the duration of the block, always retract. The write
143
+ # lives *inside* this method's ensure frame on purpose — with the `set`
144
+ # one line above a `begin`, an async raise landing in that gap (a host
145
+ # timeout's `Thread#raise`, say) escapes the ensure and strands the
146
+ # entry for the life of the process: the payload String stays reachable
147
+ # and every heartbeat reports the thread as busy.
148
+ def track(tid, hash)
149
+ set(tid, hash)
150
+ yield
151
+ ensure
152
+ delete(tid)
153
+ end
154
+
119
155
  def dup
120
156
  @lock.synchronize { @work.dup }
121
157
  end
@@ -136,8 +172,23 @@ module Wurk
136
172
 
137
173
  private
138
174
 
175
+ # Only the untouched framework default is swapped out — a host-supplied
176
+ # reloader (Rails wraps every job in `Rails.application.reloader`) is used
177
+ # exactly as given. `equal?` against DEFAULTS is sound because
178
+ # Configuration's deep-dup copies Hashes/Arrays only, so an unset
179
+ # `:reloader` is still the very Proc object DEFAULTS holds.
180
+ def resolve_reloader(configured)
181
+ return IdentityReloader if configured.nil? || configured.equal?(Configuration::DEFAULTS[:reloader])
182
+
183
+ configured
184
+ end
185
+
139
186
  def run
140
- process_one until @done
187
+ begin
188
+ process_one until @done
189
+ ensure
190
+ flush_acks
191
+ end
141
192
  @callback&.call(self)
142
193
  rescue Wurk::Shutdown
143
194
  @callback&.call(self)
@@ -147,6 +198,24 @@ module Wurk
147
198
  raise
148
199
  end
149
200
 
201
+ # The fetcher holds each finished job's LREM until a fetch can pipeline it
202
+ # (Fetcher::Reliable#defer_ack). This thread has stopped fetching, so
203
+ # nothing else will send the one it may still be holding — and a finished
204
+ # job left in the private list is invisible to Manager#hard_shutdown's
205
+ # in-flight list, so the next boot's reaper would run it a second time.
206
+ #
207
+ # Inside the loop's own ensure rather than the method's: the callback below
208
+ # drops this Processor from the Manager's pool, which is what lets
209
+ # Manager#stop return and close the capsule's Redis pool out from under us.
210
+ # `respond_to?` because a config[:fetch_class] fetcher need not defer, and
211
+ # the capsule has no fetcher at all if the launcher died before prepare!.
212
+ def flush_acks
213
+ fetcher = @capsule.fetcher
214
+ fetcher.flush_pending_acks if fetcher.respond_to?(:flush_pending_acks)
215
+ rescue StandardError => e
216
+ handle_exception(e, { context: 'Error flushing pending acks' })
217
+ end
218
+
150
219
  def fetch
151
220
  @capsule.fetcher.retrieve_work
152
221
  rescue Wurk::Shutdown
@@ -164,11 +233,17 @@ module Wurk
164
233
  job_hash = parse_or_kill(jobstr, uow)
165
234
  return if job_hash.nil?
166
235
 
236
+ # The fetcher never parses, so hand it the jid we just read: the ACK
237
+ # retires this job's poison-pill recovery counter inside the round trip
238
+ # it rides. A fetcher plugged in via `config[:fetch_class]` has no jid
239
+ # slot and simply ACKs — the counter then ages out on its 72h TTL.
240
+ uow.jid = job_hash['jid'] if uow.respond_to?(:jid=)
241
+
167
242
  ack = false
168
243
  begin
169
- Thread.handle_interrupt(Wurk::Shutdown => :never) do
244
+ Thread.handle_interrupt(IGNORE_SHUTDOWN_INTERRUPTS) do
170
245
  dispatch(job_hash, queue, jobstr) do |instance|
171
- Thread.handle_interrupt(Wurk::Shutdown => :immediate) do
246
+ Thread.handle_interrupt(ALLOW_SHUTDOWN_INTERRUPTS) do
172
247
  execute_job(instance, job_hash, queue)
173
248
  end
174
249
  end
@@ -241,15 +316,14 @@ module Wurk
241
316
  # Heartbeat can mirror it into Redis (`<identity>:work`), and increments
242
317
  # PROCESSED/FAILURE counters around the inner block.
243
318
  def stats(jobstr, queue)
244
- WORK_STATE.set(tid, queue: queue, payload: jobstr, run_at: ::Time.now.to_i)
245
- begin
319
+ run_at = ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second)
320
+ WORK_STATE.track(tid, queue: queue, payload: jobstr, run_at: run_at) do
246
321
  yield
247
322
  rescue Exception # rubocop:disable Lint/RescueException
248
323
  FAILURE.incr
249
324
  raise
250
325
  ensure
251
326
  PROCESSED.incr
252
- WORK_STATE.delete(tid)
253
327
  end
254
328
  end
255
329
  end
data/lib/wurk/profiler.rb CHANGED
@@ -5,6 +5,7 @@ require 'zlib'
5
5
  require 'stringio'
6
6
  require 'tempfile'
7
7
  require_relative 'keys'
8
+ require_relative 'pool_checkout'
8
9
 
9
10
  module Wurk
10
11
  # Job profiling (Sidekiq 8.0+, OSS). When a job is pushed with a `profile`
@@ -33,11 +34,15 @@ module Wurk
33
34
  # from the interrupt/expiry middleware) must propagate untouched so the
34
35
  # retry/skip flow works. Only the storage step is made failure-safe
35
36
  # (see #safe_store).
36
- def call(job_hash, &)
37
+ # No `&block` parameter: every job passes through here, and declaring one
38
+ # would reify the caller's block into a Proc even on the `yield`-straight-
39
+ # through path that opted-out jobs take. The capture path pays for its own
40
+ # block instead.
41
+ def call(job_hash)
37
42
  label = job_hash['profile']
38
43
  return yield unless label && defined?(::Vernier)
39
44
 
40
- capture(job_hash, label, &)
45
+ capture(job_hash, label) { yield } # rubocop:disable Style/ExplicitBlockArgument
41
46
  end
42
47
 
43
48
  # Persists a profile. Extracted from capture so it is unit-testable
@@ -113,8 +118,8 @@ module Wurk
113
118
  end
114
119
  end
115
120
 
116
- def with_pool(pool, &)
117
- pool ? pool.with(&) : Wurk.redis(&)
121
+ def with_pool(pool, idempotent: false, &)
122
+ pool ? PoolCheckout.with(pool, idempotent, &) : Wurk.redis(idempotent:, &)
118
123
  end
119
124
 
120
125
  def now
data/lib/wurk/queue.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'fetcher/reliable'
3
4
  require_relative 'job_record'
4
5
 
5
6
  module Wurk
@@ -12,6 +13,11 @@ module Wurk
12
13
  # (see `Wurk::Fetcher::Reliable#queues_cmd`). In-flight jobs continue to
13
14
  # completion — pausing only stops new fetches.
14
15
  #
16
+ # Fetchers answer from a `PAUSED_TTL` cache rather than re-reading the SET
17
+ # every pass, so `pause!`/`unpause!` expire this process's copy inline and
18
+ # other processes pick the change up within the TTL. `paused?` is a reporting
19
+ # call and always reads Redis.
20
+ #
15
21
  # Spec: docs/target/sidekiq-free.md §19.2, sidekiq-pro.md §6.
16
22
  class Queue
17
23
  include Enumerable
@@ -25,7 +31,7 @@ module Wurk
25
31
 
26
32
  # @return [Array<Queue>] one per known queue, sorted by name.
27
33
  def self.all
28
- names = Wurk.redis { |conn| conn.call('SMEMBERS', Keys::QUEUES_SET) }
34
+ names = Wurk.redis(idempotent: true) { |conn| conn.call('SMEMBERS', Keys::QUEUES_SET) }
29
35
  names.sort.map { |n| new(n) }
30
36
  end
31
37
 
@@ -35,12 +41,12 @@ module Wurk
35
41
  end
36
42
 
37
43
  def size
38
- Wurk.redis { |conn| conn.call('LLEN', @rname) }
44
+ Wurk.redis(idempotent: true) { |conn| conn.call('LLEN', @rname) }
39
45
  end
40
46
 
41
47
  # Seconds since the oldest job (tail of LIST) was enqueued. 0.0 when empty.
42
48
  def latency
43
- payload = Wurk.redis { |conn| conn.call('LRANGE', @rname, -1, -1).first }
49
+ payload = Wurk.redis(idempotent: true) { |conn| conn.call('LRANGE', @rname, -1, -1).first }
44
50
  return 0.0 if payload.nil?
45
51
 
46
52
  JobRecord.latency_from(Wurk.load_json(payload)['enqueued_at'])
@@ -51,19 +57,21 @@ module Wurk
51
57
  # True iff this queue's name is a member of the `paused` SET. Wurk
52
58
  # implements the Pro contract for free; fetchers consult the same set.
53
59
  def paused?
54
- Wurk.redis { |conn| conn.call('SISMEMBER', Keys::PAUSED_SET, @name) } == 1
60
+ Wurk.redis(idempotent: true) { |conn| conn.call('SISMEMBER', Keys::PAUSED_SET, @name) } == 1
55
61
  end
56
62
 
57
63
  # Pause new fetches against this queue. Idempotent — `SADD` returns
58
64
  # 0 when the name was already present. In-flight jobs are untouched.
59
65
  def pause! # rubocop:disable Naming/PredicateMethod
60
- Wurk.redis { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
66
+ Wurk.redis(idempotent: true) { |conn| conn.call('SADD', Keys::PAUSED_SET, @name) }
67
+ Fetcher::Reliable.invalidate_paused_cache!
61
68
  true
62
69
  end
63
70
 
64
71
  # Resume fetches. Idempotent.
65
72
  def unpause! # rubocop:disable Naming/PredicateMethod
66
- Wurk.redis { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
73
+ Wurk.redis(idempotent: true) { |conn| conn.call('SREM', Keys::PAUSED_SET, @name) }
74
+ Fetcher::Reliable.invalidate_paused_cache!
67
75
  true
68
76
  end
69
77
 
@@ -74,7 +82,7 @@ module Wurk
74
82
  loop do
75
83
  start = page * PAGE_SIZE
76
84
  stop = start + PAGE_SIZE - 1
77
- slice = Wurk.redis { |conn| conn.call('LRANGE', @rname, start, stop) }
85
+ slice = Wurk.redis(idempotent: true) { |conn| conn.call('LRANGE', @rname, start, stop) }
78
86
  slice.each { |value| yield JobRecord.new(value, @name) }
79
87
  break if slice.size < PAGE_SIZE
80
88
 
@@ -91,6 +99,9 @@ module Wurk
91
99
  # UNLINK the list + drop the queue from the `queues` set. Pipelined
92
100
  # so a partial failure leaves at most one of the two ops applied.
93
101
  # Method name is Sidekiq wire-compat — `clear?` would break the alias.
102
+ #
103
+ # Unlike pause!/unpause!, this one can't claim apply-safety: a replay after
104
+ # a lost reply would UNLINK whatever a producer enqueued in between.
94
105
  def clear # rubocop:disable Naming/PredicateMethod
95
106
  Wurk.redis do |conn|
96
107
  conn.pipelined do |pipe|
@@ -8,6 +8,11 @@ module Wurk
8
8
  # boot policy stays pure and unit-testable without the Railtie DSL.
9
9
  # See docs/idea/03-process-model.md for the exact ordering.
10
10
  module RailsBoot
11
+ # What `at_exit` waits for the supervise thread on top of the swarm's own
12
+ # drain budget: one supervise tick to notice the request, plus slack for
13
+ # the final reap.
14
+ DRAIN_JOIN_SLACK = 1
15
+
11
16
  module_function
12
17
 
13
18
  # Invoked from the `wurk.server_mode` initializer, before config/initializers
@@ -103,36 +108,62 @@ module Wurk
103
108
  end
104
109
 
105
110
  def boot_swarm
106
- swarm = Wurk::Swarm.new(topology: Wurk.configuration.topology,
107
- shutdown_timeout: Wurk.configuration[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
111
+ timeout = Wurk.configuration[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT
112
+ swarm = Wurk::Swarm.new(topology: Wurk.configuration.topology, shutdown_timeout: timeout)
113
+ supervisor = nil
114
+ # Registered BEFORE boot, which forks the children one slot at a time: a
115
+ # fork that raises partway through would otherwise leave the children it
116
+ # already spawned with nothing to drain them on host exit. Children
117
+ # inherit the hook — stop_swarm no-ops off the process that forked them.
118
+ at_exit { stop_swarm(swarm, supervisor, timeout + Swarm::SHUTDOWN_GRACE + DRAIN_JOIN_SLACK) }
108
119
  # Co-hosted in the web process (e.g. Puma single mode): the host owns the
109
120
  # process-wide TERM/INT traps. Installing the swarm's own would hijack
110
121
  # them — a deploy TERM would drain the swarm but never stop the HTTP
111
122
  # server. Let the host keep signal ownership and drain the swarm on its
112
123
  # graceful exit (same contract as boot_embedded).
113
124
  swarm.boot(install_signals: false)
114
- at_exit { swarm.shutdown }
115
125
  # supervise must still run somewhere or crashed children never respawn and
116
126
  # memory checks never fire. A background thread keeps the host's main
117
127
  # thread free to serve HTTP.
118
- Thread.new do
128
+ supervisor = Thread.new do
119
129
  swarm.supervise
120
130
  rescue StandardError => e
121
131
  logger.error { "wurk supervisor thread died: #{e.class}: #{e.message}" }
122
132
  end
123
133
  end
124
134
 
135
+ # at_exit fires on the host's main thread, but the supervise thread owns the
136
+ # child table and two threads inside `shutdown` race on it — so request the
137
+ # drain and wait for the supervisor to run it. Draining here is the fallback
138
+ # for when no live supervisor will: it never started (boot raised) or it
139
+ # died early; after one that drained it finds no children and no-ops. A
140
+ # supervisor still alive past the join is wedged mid-drain — leave it be
141
+ # rather than race it; its children self-terminate (OrphanGuard) once this
142
+ # process is gone.
143
+ def stop_swarm(swarm, supervisor, drain_wait)
144
+ return unless swarm.owner?
145
+
146
+ swarm.request_shutdown
147
+ supervisor&.join(drain_wait)
148
+ swarm.shutdown unless supervisor&.alive?
149
+ end
150
+
125
151
  # Sidekiq-embedded parity: a threads-only worker inside the web process, no
126
152
  # fork. Redis validation failure keeps the host serving HTTP (log + carry
127
153
  # on). at_exit drains on the graceful shutdown the web server runs on TERM.
128
- def boot_embedded
129
- instance = Wurk::Embedded.new(Wurk.configuration)
130
- instance.run
154
+ def boot_embedded(embedded = Wurk::Embedded)
155
+ instance = embedded.new(Wurk.configuration)
156
+ # Registered BEFORE run, for the reason boot_swarm registers before the
157
+ # fork: run brings the heartbeat, pollers, managers and health listener up
158
+ # one at a time, and a raise partway through would otherwise leave the ones
159
+ # already running with nothing to drain them on host exit.
131
160
  at_exit { instance.stop }
161
+ instance.run
132
162
  logger.info { 'wurk: running embedded in the web process (config.wurk.embed_in_web) — threads only, no fork' }
133
163
  instance
134
164
  rescue StandardError => e
135
165
  logger.error { "wurk: embedded boot failed: #{e.class}: #{e.message}" }
166
+ instance&.stop
136
167
  nil
137
168
  end
138
169