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
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ module Metrics
5
+ # The in-memory half of Wurk::Metrics::History. Every execution folds into a
6
+ # `{pool => {minute => {class => [processed, failed, ms]}}}` tree under one
7
+ # mutex; Wurk::Metrics::Flusher drains it into Redis on a timer. `HINCRBY`
8
+ # is additive, so N folded executions leave Redis in the same state as the N
9
+ # individual pipelines the hot path used to send.
10
+ #
11
+ # Knows nothing about Redis — History owns the write, this owns the counts.
12
+ #
13
+ # The minute is the bucket the job *ran* in, decided by the caller at record
14
+ # time. That is what keeps the batching a visibility lag instead of a
15
+ # misattribution: a job that runs at 12:03:59 and flushes at 12:04:02 still
16
+ # counts toward 12:03. Signed off in
17
+ # docs/plans/2026/08/06/101-faster-than-sidekiq/00-semantics-signoff.md §2.
18
+ #
19
+ # Keyed by the pool the recording middleware handed us (nil = the process
20
+ # default), so a multi-capsule process flushes each capsule's counts through
21
+ # the pool that capsule's jobs ran on.
22
+ class Accumulator
23
+ # Ceiling on minute buckets retained per pool. Only reachable through
24
+ # #merge_back: a flush that keeps failing puts its counts back every tick,
25
+ # and a Redis outage that outlives the process would otherwise grow a
26
+ # structure fed by the job hot path without bound. The newest buckets win
27
+ # — they are the ones the dashboard is still drawing — and the oldest are
28
+ # dropped, which these counters have always been allowed to do (a Redis
29
+ # failure during a metrics write has never changed a job's outcome).
30
+ MAX_RETAINED_MINUTES = 60
31
+
32
+ def initialize
33
+ @lock = ::Mutex.new
34
+ @pools = {}
35
+ end
36
+
37
+ # Hot path. Three hash lookups under the mutex; allocates only the first
38
+ # time a (pool, minute, class) triple is seen.
39
+ def add(pool, klass, minute, ms, success)
40
+ @lock.synchronize do
41
+ counts = (((@pools[pool] ||= {})[minute] ||= {})[klass] ||= [0, 0, 0])
42
+ counts[success ? 0 : 1] += 1
43
+ counts[2] += ms
44
+ end
45
+ end
46
+
47
+ # Hands the whole tree over and starts a fresh one, so recording never
48
+ # blocks behind the flush's Redis round trip.
49
+ def drain
50
+ @lock.synchronize do
51
+ drained = @pools
52
+ @pools = {}
53
+ drained
54
+ end
55
+ end
56
+
57
+ # Puts one pool's counts back after its write failed, so the next tick
58
+ # retries them instead of dropping the window. Merged rather than
59
+ # assigned: jobs kept recording into the fresh tree while the flush was in
60
+ # flight, and those counts have not been written yet either.
61
+ def merge_back(pool, minutes)
62
+ @lock.synchronize do
63
+ into = (@pools[pool] ||= {})
64
+ minutes.each { |minute, classes| merge_minute(into, minute, classes) }
65
+ trim(into)
66
+ end
67
+ end
68
+
69
+ def empty?
70
+ @lock.synchronize { @pools.empty? }
71
+ end
72
+
73
+ private
74
+
75
+ def merge_minute(into, minute, classes)
76
+ target = (into[minute] ||= {})
77
+ classes.each do |klass, counts|
78
+ existing = target[klass]
79
+ if existing
80
+ counts.each_with_index { |count, i| existing[i] += count }
81
+ else
82
+ target[klass] = counts
83
+ end
84
+ end
85
+ end
86
+
87
+ def trim(minutes)
88
+ excess = minutes.size - MAX_RETAINED_MINUTES
89
+ return if excess <= 0
90
+
91
+ minutes.keys.min(excess).each { |minute| minutes.delete(minute) }
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../component'
4
+ require_relative '../timer_loop'
5
+ require_relative 'history'
6
+
7
+ module Wurk
8
+ module Metrics
9
+ # Drains Wurk::Metrics::History's in-process accumulator into Redis every
10
+ # `History::FLUSH_INTERVAL` seconds.
11
+ #
12
+ # Deliberately NOT leader-gated, unlike every other periodic component here
13
+ # (Metrics::Rollup, Metrics::QueueRollup, Wurk::History). Those publish
14
+ # *cluster* state that one process should own; this publishes counters that
15
+ # only exist inside this process's memory and that no other process can
16
+ # write. A leader gate would strand every follower's metrics until it died.
17
+ #
18
+ # One tick per process, not per capsule: the accumulator is process-wide and
19
+ # already keyed by the pool each capsule's jobs recorded through.
20
+ class Flusher
21
+ include Component
22
+
23
+ # `accumulator` is a collaborator, not an option: every real boot flushes
24
+ # the process-wide one. Tests inject their own so a broken pool stays
25
+ # inside the test that built it.
26
+ def initialize(config, accumulator: History::ACCUMULATOR)
27
+ @config = config
28
+ @accumulator = accumulator
29
+ @timer = TimerLoop.new(History::FLUSH_INTERVAL)
30
+ @thread = nil
31
+ end
32
+
33
+ def start
34
+ return @thread if @thread
35
+
36
+ @timer.reset
37
+ @thread = safe_thread('metrics-flush') { @timer.run { tick } }
38
+ end
39
+
40
+ # Flushes once more in an `ensure`: this stops the only thread that would
41
+ # have written the window accumulated since the last tick, so without it a
42
+ # graceful shutdown would drop up to FLUSH_INTERVAL of counters — the cost
43
+ # the sign-off accepts for a *hard* kill only.
44
+ #
45
+ # Launcher#stop joins the manager drains before the teardown tail reaches
46
+ # us, so by the time this runs no job is still recording.
47
+ #
48
+ # Cleared only on a confirmed join (Thread#join returns nil on timeout): a
49
+ # wedged thread must stay tracked so #start's guard returns it instead of
50
+ # calling @timer.reset, which would un-terminate the loop it is still
51
+ # inside and leave two threads draining the same accumulator.
52
+ def terminate
53
+ @timer.terminate
54
+ @thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
55
+ ensure
56
+ tick
57
+ end
58
+
59
+ # Never raises: this runs on a `safe_thread`, whose watchdog re-raises
60
+ # after reporting, and a reported-then-dead flusher would silently stop
61
+ # every counter in the process for a blip Redis recovers from. The
62
+ # accumulator has already merged the failed window back for the next tick.
63
+ def tick
64
+ History.flush(@accumulator)
65
+ rescue StandardError => e
66
+ handle_exception(e, { context: 'metrics-flush' })
67
+ end
68
+ end
69
+ end
70
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative '../middleware'
4
+ require_relative '../pool_checkout'
5
+ require_relative 'accumulator'
4
6
 
5
7
  module Wurk
6
8
  module Metrics
@@ -30,9 +32,10 @@ module Wurk
30
32
  # running we keep its bucket around for the retention window measured from
31
33
  # *last write*, not from first write. So we EXPIRE unconditionally.
32
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
+ # The middleware is hot-path — every job pays for it — so it does not touch
36
+ # Redis at all. Each execution folds into a process-wide Accumulator and
37
+ # Wurk::Metrics::Flusher writes the whole tree out every FLUSH_INTERVAL, in
38
+ # the same 6-commands-per-(class, minute) shape the per-job pipeline sent.
36
39
  class History
37
40
  include Wurk::Middleware::ServerMiddleware
38
41
 
@@ -43,6 +46,22 @@ module Wurk
43
46
  MINUTE_KEY_PREFIX = 'j|'
44
47
  DATE_FORMAT = '%Y%m%d' # YYYYMMDD — matches Sidekiq's j| key
45
48
 
49
+ # Ceiling on how stale an unflushed counter can be, in seconds. A
50
+ # constant, never a config knob: it exists to bound the dashboard's lag,
51
+ # not to be tuned. Sidekiq — the engine these numbers get compared
52
+ # against — flushes its own process stats on a 10s heartbeat and writes
53
+ # nothing per job, so this is the tighter of the two. Signed off in
54
+ # docs/plans/2026/08/06/101-faster-than-sidekiq/00-semantics-signoff.md §2.
55
+ FLUSH_INTERVAL = 5
56
+
57
+ # Process-wide, like Processor::PROCESSED — the counters belong to the
58
+ # process, not to a middleware instance (the chain builds one per capsule)
59
+ # and not to a job.
60
+ ACCUMULATOR = Accumulator.new
61
+
62
+ # Hourly buckets are already class-scoped, so their fields are bare.
63
+ HOUR_FIELDS = %w[p f ms].freeze
64
+
46
65
  def call(_worker, job, _queue)
47
66
  klass = job['class']
48
67
  started = monotonic_ms
@@ -53,8 +72,11 @@ module Wurk
53
72
  result
54
73
  ensure
55
74
  duration = (monotonic_ms - started).round
56
- # Best-effort: a metrics write failure must never propagate into
57
- # the job result. The processor already finalized the ack path.
75
+ # Best-effort: a metrics failure must never propagate into the job
76
+ # result. The processor already finalized the ack path. Recording is
77
+ # in-memory now, so what this catches is a bad payload (a `class` that
78
+ # is not a String) rather than a Redis blip — the Redis half moved to
79
+ # Flusher, which reports on its own thread.
58
80
  begin
59
81
  self.class.record(klass, duration, success: success, redis_pool: redis_pool)
60
82
  rescue StandardError => e
@@ -64,39 +86,46 @@ module Wurk
64
86
  end
65
87
 
66
88
  class << self
67
- # Single Redis round-trip per job. `success: true` → `<klass>|p`;
68
- # `success: false` → `<klass>|f`. `<klass>|ms` accumulates total
69
- # runtime in milliseconds for *both* outcomes (so an operator can
70
- # ask "how much wall-clock time has FooJob consumed?" without
71
- # branching on outcome).
72
- def record(klass, duration_ms, success:, redis_pool: nil, at: ::Time.now)
89
+ # No Redis. `success: true` → `<klass>|p`; `success: false` →
90
+ # `<klass>|f`. `<klass>|ms` accumulates total runtime in milliseconds
91
+ # for *both* outcomes (so an operator can ask "how much wall-clock time
92
+ # has FooJob consumed?" without branching on outcome).
93
+ #
94
+ # `at:` defaults to nil rather than `::Time.now`: this runs once per job
95
+ # and the bucket only needs whole UTC minutes, which #minute_bucket
96
+ # reads straight off the clock as an Integer.
97
+ def record(klass, duration_ms, success:, redis_pool: nil, at: nil)
73
98
  return if klass.nil? || klass.empty?
74
99
 
75
100
  ms = duration_ms.to_i
76
101
  ms = 0 if ms.negative?
77
- buckets = { minute: minute_key(at), hour: hour_key(klass, at) }
78
- with_pool(redis_pool) { |conn| pipeline_write(conn, klass, ms, success, buckets) }
102
+ ACCUMULATOR.add(redis_pool, klass, minute_bucket(at), ms, success)
79
103
  nil
80
104
  end
81
105
 
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
- def pipeline_write(conn, klass, ms, success, buckets)
86
- outcome = success ? 'p' : 'f'
87
- class_outcome = "#{klass}|#{outcome}"
88
- class_ms = "#{klass}|ms"
89
- conn.pipelined do |pipe|
90
- incr_bucket(pipe, buckets[:minute], [class_outcome, class_ms, ms, MID_TERM])
91
- incr_bucket(pipe, buckets[:hour], [outcome, 'ms', ms, MID_TERM])
106
+ # Drains an accumulator into Redis, one pipeline per pool. Raises the
107
+ # first pool's failure after every other pool has had its turn —
108
+ # Wurk::Metrics::Flusher owns turning that into an error-handler call.
109
+ #
110
+ # Only the *failed* pool's counts are merged back. Putting the whole
111
+ # drained tree back would re-send the writes that already landed, and
112
+ # HINCRBY would count them twice.
113
+ #
114
+ # The argument is a collaborator, not an option: a process has exactly
115
+ # one accumulator and nothing in wurk passes a second. Tests pass their
116
+ # own so a deliberately broken pool cannot leak into a parallel test's
117
+ # flush through the process-wide one.
118
+ def flush(accumulator = ACCUMULATOR)
119
+ error = nil
120
+ accumulator.drain.each do |pool, minutes|
121
+ write(pool, minutes)
122
+ rescue StandardError => e
123
+ accumulator.merge_back(pool, minutes)
124
+ error = e
92
125
  end
93
- end
126
+ raise error if error
94
127
 
95
- def incr_bucket(pipe, key, parts)
96
- outcome_field, ms_field, ms, ttl = parts
97
- pipe.call('HINCRBY', key, outcome_field, 1)
98
- pipe.call('HINCRBY', key, ms_field, ms)
99
- pipe.call('EXPIRE', key, ttl)
128
+ nil
100
129
  end
101
130
 
102
131
  # Public formatters — Wurk::Metrics::Query reuses these so the two
@@ -111,6 +140,47 @@ module Wurk
111
140
  t = time.utc
112
141
  "#{klass}-#{t.strftime(DATE_FORMAT)}-#{t.hour}"
113
142
  end
143
+
144
+ private
145
+
146
+ # Whole UTC minutes since the epoch. Integer arithmetic on a raw clock
147
+ # read, so nothing is allocated per job.
148
+ def minute_bucket(at)
149
+ return ::Process.clock_gettime(::Process::CLOCK_REALTIME, :second) / 60 unless at
150
+
151
+ at.to_i / 60
152
+ end
153
+
154
+ def write(pool, minutes)
155
+ with_pool(pool) do |conn|
156
+ conn.pipelined do |pipe|
157
+ minutes.each do |minute, classes|
158
+ at = ::Time.at(minute * 60).utc
159
+ key = minute_key(at)
160
+ classes.each { |klass, counts| write_class(pipe, key, at, klass, counts) }
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ def write_class(pipe, bucket_key, at, klass, counts)
167
+ incr_bucket(pipe, bucket_key, ["#{klass}|p", "#{klass}|f", "#{klass}|ms"], counts)
168
+ incr_bucket(pipe, hour_key(klass, at), HOUR_FIELDS, counts)
169
+ end
170
+
171
+ # `p`/`f` are emitted only when non-zero, so a bucket ends up with the
172
+ # exact field set the per-job path produced: `HINCRBY <field> 0` would
173
+ # materialize a counter no unbatched write ever created, and the read
174
+ # side would start reporting a `f: 0` series for classes that have never
175
+ # failed. `ms` is unconditional — the per-job path always incremented
176
+ # it, including by zero.
177
+ def incr_bucket(pipe, key, fields, counts)
178
+ processed, failed, ms = counts
179
+ pipe.call('HINCRBY', key, fields[0], processed) if processed.positive?
180
+ pipe.call('HINCRBY', key, fields[1], failed) if failed.positive?
181
+ pipe.call('HINCRBY', key, fields[2], ms)
182
+ pipe.call('EXPIRE', key, MID_TERM)
183
+ end
114
184
  end
115
185
 
116
186
  private
@@ -124,11 +194,11 @@ module Wurk
124
194
  cfg.handle_exception(err, context: 'Wurk::Metrics::History')
125
195
  end
126
196
 
127
- def self.with_pool(pool, &)
197
+ def self.with_pool(pool, idempotent: false, &)
128
198
  if pool
129
- pool.with(&)
199
+ PoolCheckout.with(pool, idempotent, &)
130
200
  else
131
- Wurk.redis(&)
201
+ Wurk.redis(idempotent:, &)
132
202
  end
133
203
  end
134
204
  private_class_method :with_pool
@@ -51,11 +51,23 @@ module Wurk
51
51
  end
52
52
 
53
53
  def start
54
- @thread ||= safe_thread('queue-metrics') { @timer.run { tick } } # rubocop:disable Naming/MemoizedInstanceVariableName
54
+ return @thread if @thread
55
+
56
+ @timer.reset
57
+ @thread = safe_thread('queue-metrics') { @timer.run { tick } }
55
58
  end
56
59
 
60
+ # Blocks until the thread is really gone: the launcher releases the
61
+ # cluster lock immediately after this returns, and a sample still in
62
+ # flight would write the same buckets as the next leader's first one.
63
+ #
64
+ # Cleared only on a confirmed join (Thread#join returns nil on timeout):
65
+ # a wedged thread must stay tracked so #start's guard returns it instead
66
+ # of calling @timer.reset, which would un-terminate the loop it is still
67
+ # inside and leave two threads HSETting the same buckets.
57
68
  def terminate
58
69
  @timer.terminate
70
+ @thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
59
71
  end
60
72
 
61
73
  # Leader-gated: only the elected leader samples, so N workers don't each
@@ -59,11 +59,23 @@ module Wurk
59
59
  end
60
60
 
61
61
  def start
62
- @thread ||= safe_thread('metrics-rollup') { @timer.run { tick } } # rubocop:disable Naming/MemoizedInstanceVariableName
62
+ return @thread if @thread
63
+
64
+ @timer.reset
65
+ @thread = safe_thread('metrics-rollup') { @timer.run { tick } }
63
66
  end
64
67
 
68
+ # Blocks until the thread is really gone: the launcher releases the
69
+ # cluster lock immediately after this returns, and a roll still in flight
70
+ # would write the same buckets as the next leader's first one.
71
+ #
72
+ # Cleared only on a confirmed join (Thread#join returns nil on timeout):
73
+ # a wedged thread must stay tracked so #start's guard returns it instead
74
+ # of calling @timer.reset, which would un-terminate the loop it is still
75
+ # inside and leave two threads HSETting the same buckets.
65
76
  def terminate
66
77
  @timer.terminate
78
+ @thread = nil if @thread&.join(TimerLoop::JOIN_TIMEOUT)
67
79
  end
68
80
 
69
81
  # Leader-gated: only the elected leader writes the cluster-total series,
@@ -35,6 +35,15 @@ module Wurk
35
35
  METRIC_PREFIX = 'sidekiq.'
36
36
  DEFAULT_SAMPLE_RATE = 1.0
37
37
 
38
+ # Unresolved state of the {.client} memo, distinct from a resolved nil.
39
+ UNSET = :unset
40
+ private_constant :UNSET
41
+
42
+ # Seeded in the class body (not `class << self`, where `self` is the
43
+ # singleton class) so {.client} is a bare ivar read with no `defined?`
44
+ # guard on the hot path.
45
+ @client = UNSET
46
+
38
47
  class << self
39
48
  attr_accessor :options
40
49
 
@@ -91,19 +100,35 @@ module Wurk
91
100
  # Resolves the live client: invokes the configured `dogstatsd` proc
92
101
  # exactly once per process and memoizes. Returns nil when no proc
93
102
  # is configured, so callers get a clean no-op without raising.
103
+ #
104
+ # The *unconfigured* answer is memoized too, which is what makes the
105
+ # no-client path free: Client#emit_enqueued asks once per payload, so
106
+ # re-reading `Wurk.configuration` here would cost a config lookup per
107
+ # job on every bulk push. Both invalidation points call {.reset!}.
94
108
  def client
95
- return @client if defined?(@client) && !@client.nil?
109
+ memo = @client
110
+ return memo unless UNSET.equal?(memo)
96
111
 
97
112
  builder = Wurk.configuration.respond_to?(:dogstatsd) ? Wurk.configuration.dogstatsd : nil
98
- return nil if builder.nil?
99
-
100
113
  @client = builder.respond_to?(:call) ? builder.call : builder
101
114
  end
102
115
 
103
- # Test/lifecycle hook. Reset between specs and after fork so the
104
- # parent's socket doesn't bleed into children.
116
+ # {.client} without the raise: a builder proc that blows up is reported
117
+ # through the error handler and treated as "no client", so misconfigured
118
+ # metrics can never fail the work they were measuring. Callers on a
119
+ # hot path use this to skip building anything the emit would drop.
120
+ def safe_client
121
+ client
122
+ rescue StandardError => e
123
+ handle_error(e)
124
+ nil
125
+ end
126
+
127
+ # Test/lifecycle hook. Reset between specs, after fork so the parent's
128
+ # socket doesn't bleed into children, and on every `config.dogstatsd=`
129
+ # — a memoized nil would otherwise hide a client configured later.
105
130
  def reset!
106
- @client = nil
131
+ @client = UNSET
107
132
  end
108
133
 
109
134
  private
@@ -118,8 +143,7 @@ module Wurk
118
143
  end
119
144
 
120
145
  def call(_worker, job, queue) # rubocop:disable Metrics/AbcSize
121
- client = safe_client
122
- return yield if client.nil?
146
+ return yield if self.class.safe_client.nil?
123
147
 
124
148
  klass = job['class']
125
149
  opts = per_job_options(klass, job, queue)
@@ -146,16 +170,6 @@ module Wurk
146
170
 
147
171
  private
148
172
 
149
- # Wraps `self.class.client` so a misconfigured builder proc never turns
150
- # a metrics-init failure into a job failure. Returns nil on error (the
151
- # caller falls through to a plain `yield`).
152
- def safe_client
153
- self.class.client
154
- rescue StandardError => e
155
- self.class.send(:handle_error, e)
156
- nil
157
- end
158
-
159
173
  # Per-spec §9.2: caller-supplied proc may override tags / sample_rate
160
174
  # on a per-job basis. The job's own `dd_rate` field, when present,
161
175
  # always wins — it's the per-push override hinted in §8.
@@ -7,9 +7,10 @@ module Wurk
7
7
  # pushes to index 0, `insert_before`/`insert_after` anchor relative to an
8
8
  # existing entry, `invoke` walks the chain handing the inner block to each.
9
9
  #
10
- # `retrieve` instantiates a fresh instance per entry on every job — entries
11
- # store klass + ctor args, not the live object. This keeps middleware
12
- # thread-safe by construction and matches Sidekiq's lifecycle.
10
+ # Entries store klass + ctor args, not the live object: every job gets a
11
+ # fresh instance per entry (`invoke` builds each as it reaches it, `retrieve`
12
+ # builds them all up front). This keeps middleware thread-safe by
13
+ # construction and matches Sidekiq's lifecycle.
13
14
  #
14
15
  # Spec: docs/target/sidekiq-free.md §10.1.
15
16
  class Chain
@@ -83,23 +84,28 @@ module Wurk
83
84
  # Walks the chain inside-out. Each middleware receives a block that
84
85
  # advances to the next; the innermost block is the caller's `&block`,
85
86
  # whose return value is propagated back out. Empty chain: `yield`.
87
+ #
88
+ # Hot path — one job pays this per invocation, so instantiation is folded
89
+ # into an index walk over `@entries`: no instance array from `retrieve`,
90
+ # no traversal lambda, and no `shift` (which memmoves the array per entry).
91
+ # Cost is one allocation per entry (the middleware itself), zero to walk.
86
92
  def invoke(*args, &block)
87
93
  raise ArgumentError, 'middleware chain requires a block' unless block
88
94
  return yield if @entries.empty?
89
95
 
90
- chain = retrieve
91
- traverse = lambda do
92
- if chain.empty?
93
- block.call
94
- else
95
- chain.shift.call(*args, &traverse)
96
- end
97
- end
98
- traverse.call
96
+ traverse(@entries, 0, args, &block)
99
97
  end
100
98
 
101
99
  private
102
100
 
101
+ def traverse(entries, index, args, &block)
102
+ return yield if index == entries.size
103
+
104
+ entries[index].make_new(@config).call(*args) do
105
+ traverse(entries, index + 1, args, &block)
106
+ end
107
+ end
108
+
103
109
  # Custom dup semantics: deep-copy the entries array so a child chain's
104
110
  # mutations don't bleed into the parent (or sibling capsules).
105
111
  def initialize_copy(orig)
@@ -108,18 +114,29 @@ module Wurk
108
114
  end
109
115
 
110
116
  # Holds the klass + ctor args for a registered middleware. Defers
111
- # instantiation until `retrieve` runs so each job gets a fresh object.
117
+ # instantiation until `retrieve`/`invoke` runs so each job gets a fresh
118
+ # object.
119
+ #
120
+ # Whether the class declares `config=` is resolved once, at registration,
121
+ # so the common case (the setter comes from `include
122
+ # Wurk::Middleware::ServerMiddleware`) skips a `respond_to?` on every job
123
+ # × every entry. It is a fast path, not the rule: the contract is "set
124
+ # `config` if the instance is respondable" (spec §10.1), which also covers
125
+ # a setter defined on the singleton in `initialize`, one reached through
126
+ # `method_missing`, or one added to the class after it was registered — so
127
+ # a class that does not declare it still gets asked.
112
128
  class Entry
113
129
  attr_reader :klass
114
130
 
115
131
  def initialize(klass, *args)
116
132
  @klass = klass
117
133
  @args = args
134
+ @takes_config = klass.public_method_defined?(:config=)
118
135
  end
119
136
 
120
137
  def make_new(config = nil)
121
138
  instance = @klass.new(*@args)
122
- instance.config = config if config && instance.respond_to?(:config=)
139
+ instance.config = config if config && (@takes_config || instance.respond_to?(:config=))
123
140
  instance
124
141
  end
125
142
  end
@@ -8,14 +8,15 @@ module Wurk
8
8
  module Middleware
9
9
  # Server middleware. Catches `Wurk::Job::Interrupted` raised by an
10
10
  # IterableJob mid-iteration (or any cooperatively-cancelled job),
11
- # re-pushes the job to the head of its queue so it resumes from the
11
+ # re-pushes the job to the tail of its queue so it resumes from the
12
12
  # persisted cursor, and raises `Wurk::JobRetry::Skip` so the retry
13
13
  # layer treats this as a clean exit rather than an error.
14
14
  #
15
- # The re-push uses LPUSH (head of queue) so the same job is the next
16
- # one to be fetched after restart. The job JSON is unchanged: cursor
17
- # state lives in the `it-<jid>` HASH (see IterableJob persistence),
18
- # not in the payload.
15
+ # The re-push uses RPUSH (tail of queue) so the same job is the next
16
+ # one to be fetched: the fetcher's LMOVE pops from the RIGHT (tail),
17
+ # so this job is fetched ahead of fresh LPUSH'd enqueues. The job
18
+ # JSON is unchanged: cursor state lives in the `it-<jid>` HASH (see
19
+ # IterableJob persistence), not in the payload.
19
20
  #
20
21
  # Auto-registered at the top of the server chain when this file is
21
22
  # required. Top-of-chain is important: a downstream middleware must
@@ -36,7 +37,7 @@ module Wurk
36
37
 
37
38
  def repush(job, queue)
38
39
  payload = Wurk.dump_json(job)
39
- redis_pool.with { |conn| conn.call('LPUSH', "queue:#{queue}", payload) }
40
+ redis_pool.with { |conn| conn.call('RPUSH', "queue:#{queue}", payload) }
40
41
  end
41
42
  end
42
43
  end