wurk 0.0.1

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 (116) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/CONTRIBUTING.md +73 -0
  4. data/LICENSE +21 -0
  5. data/README.md +137 -0
  6. data/SECURITY.md +39 -0
  7. data/app/controllers/wurk/api/pagination.rb +67 -0
  8. data/app/controllers/wurk/api/serializers.rb +131 -0
  9. data/app/controllers/wurk/api_controller.rb +248 -0
  10. data/app/controllers/wurk/application_controller.rb +7 -0
  11. data/app/controllers/wurk/dashboard_controller.rb +48 -0
  12. data/config/locales/en.yml +15 -0
  13. data/config/routes.rb +34 -0
  14. data/exe/wurk +22 -0
  15. data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
  16. data/lib/generators/wurk/install/install_generator.rb +22 -0
  17. data/lib/generators/wurk/install/templates/wurk.rb +16 -0
  18. data/lib/wurk/active_job/wrapper.rb +32 -0
  19. data/lib/wurk/api/fast.rb +78 -0
  20. data/lib/wurk/batch/buffer.rb +26 -0
  21. data/lib/wurk/batch/callback_job.rb +37 -0
  22. data/lib/wurk/batch/callbacks.rb +176 -0
  23. data/lib/wurk/batch/client_middleware.rb +27 -0
  24. data/lib/wurk/batch/death_handler.rb +39 -0
  25. data/lib/wurk/batch/empty.rb +21 -0
  26. data/lib/wurk/batch/server_middleware.rb +62 -0
  27. data/lib/wurk/batch/status.rb +140 -0
  28. data/lib/wurk/batch.rb +351 -0
  29. data/lib/wurk/batch_set.rb +67 -0
  30. data/lib/wurk/capsule.rb +176 -0
  31. data/lib/wurk/cli.rb +349 -0
  32. data/lib/wurk/client/buffered.rb +372 -0
  33. data/lib/wurk/client.rb +330 -0
  34. data/lib/wurk/compat.rb +136 -0
  35. data/lib/wurk/component.rb +136 -0
  36. data/lib/wurk/configuration.rb +373 -0
  37. data/lib/wurk/context.rb +35 -0
  38. data/lib/wurk/cron.rb +636 -0
  39. data/lib/wurk/dashboard_manifest.rb +39 -0
  40. data/lib/wurk/dead_set.rb +78 -0
  41. data/lib/wurk/deploy.rb +91 -0
  42. data/lib/wurk/embedded.rb +94 -0
  43. data/lib/wurk/encryption.rb +276 -0
  44. data/lib/wurk/engine.rb +81 -0
  45. data/lib/wurk/fetcher/reaper.rb +264 -0
  46. data/lib/wurk/fetcher/reliable.rb +138 -0
  47. data/lib/wurk/fetcher.rb +11 -0
  48. data/lib/wurk/health.rb +193 -0
  49. data/lib/wurk/heartbeat.rb +211 -0
  50. data/lib/wurk/iterable_job.rb +292 -0
  51. data/lib/wurk/job/options.rb +70 -0
  52. data/lib/wurk/job.rb +33 -0
  53. data/lib/wurk/job_logger.rb +68 -0
  54. data/lib/wurk/job_record.rb +156 -0
  55. data/lib/wurk/job_retry.rb +320 -0
  56. data/lib/wurk/job_set.rb +212 -0
  57. data/lib/wurk/job_util.rb +162 -0
  58. data/lib/wurk/keys.rb +52 -0
  59. data/lib/wurk/launcher.rb +289 -0
  60. data/lib/wurk/leader.rb +221 -0
  61. data/lib/wurk/limiter/base.rb +138 -0
  62. data/lib/wurk/limiter/bucket.rb +80 -0
  63. data/lib/wurk/limiter/concurrent.rb +132 -0
  64. data/lib/wurk/limiter/leaky.rb +91 -0
  65. data/lib/wurk/limiter/points.rb +89 -0
  66. data/lib/wurk/limiter/server_middleware.rb +77 -0
  67. data/lib/wurk/limiter/unlimited.rb +48 -0
  68. data/lib/wurk/limiter/window.rb +80 -0
  69. data/lib/wurk/limiter.rb +255 -0
  70. data/lib/wurk/logger.rb +81 -0
  71. data/lib/wurk/lua/loader.rb +53 -0
  72. data/lib/wurk/lua.rb +187 -0
  73. data/lib/wurk/manager.rb +132 -0
  74. data/lib/wurk/metrics/history.rb +151 -0
  75. data/lib/wurk/metrics/query.rb +173 -0
  76. data/lib/wurk/metrics/rollup.rb +169 -0
  77. data/lib/wurk/metrics/statsd.rb +197 -0
  78. data/lib/wurk/metrics.rb +7 -0
  79. data/lib/wurk/middleware/chain.rb +128 -0
  80. data/lib/wurk/middleware/current_attributes.rb +87 -0
  81. data/lib/wurk/middleware/expiry.rb +50 -0
  82. data/lib/wurk/middleware/i18n.rb +63 -0
  83. data/lib/wurk/middleware/interrupt_handler.rb +45 -0
  84. data/lib/wurk/middleware/poison_pill.rb +149 -0
  85. data/lib/wurk/middleware.rb +34 -0
  86. data/lib/wurk/process_set.rb +243 -0
  87. data/lib/wurk/processor.rb +247 -0
  88. data/lib/wurk/queue.rb +108 -0
  89. data/lib/wurk/queues.rb +80 -0
  90. data/lib/wurk/rails.rb +9 -0
  91. data/lib/wurk/railtie.rb +28 -0
  92. data/lib/wurk/redis_pool.rb +79 -0
  93. data/lib/wurk/retry_set.rb +17 -0
  94. data/lib/wurk/scheduled.rb +189 -0
  95. data/lib/wurk/scheduled_set.rb +18 -0
  96. data/lib/wurk/sorted_entry.rb +95 -0
  97. data/lib/wurk/stats.rb +190 -0
  98. data/lib/wurk/swarm/child_boot.rb +105 -0
  99. data/lib/wurk/swarm.rb +260 -0
  100. data/lib/wurk/testing.rb +102 -0
  101. data/lib/wurk/topology.rb +74 -0
  102. data/lib/wurk/unique.rb +240 -0
  103. data/lib/wurk/version.rb +5 -0
  104. data/lib/wurk/web/config.rb +180 -0
  105. data/lib/wurk/web/enterprise.rb +138 -0
  106. data/lib/wurk/web/search.rb +139 -0
  107. data/lib/wurk/web.rb +25 -0
  108. data/lib/wurk/work_set.rb +116 -0
  109. data/lib/wurk/worker/setter.rb +93 -0
  110. data/lib/wurk/worker.rb +216 -0
  111. data/lib/wurk.rb +238 -0
  112. data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
  113. data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
  114. data/vendor/assets/dashboard/index.html +13 -0
  115. data/vendor/assets/dashboard/wurk-manifest.json +4 -0
  116. metadata +232 -0
@@ -0,0 +1,372 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Wurk
4
+ class Client
5
+ # Pro feature parity: in-process ring buffer that catches enqueue
6
+ # failures during a Redis outage and replays them on the next push.
7
+ # Activated globally — `Wurk::Client.reliable_push!`. Buffer is
8
+ # per-process, in-memory only; crash = lost. Does NOT cover batch
9
+ # creation or batch-context pushes (`bid` on payload): BATCH_PUSH has
10
+ # atomic counter side-effects we can't safely replay.
11
+ #
12
+ # Spec: docs/target/sidekiq-pro.md §5.
13
+ module Buffered
14
+ DEFAULT_BUFFER_CAP = 1_000
15
+ DRAINING_KEY = :wurk_reliable_push_draining
16
+
17
+ # Overflow modes. `:drop_oldest` is the spec default (Sidekiq Pro §5
18
+ # ring buffer). `:raise` lets callers decide what to do on backpressure
19
+ # — Wurk extension surfaced for issue #19's "over-cap pushes raise so
20
+ # callers can decide" requirement.
21
+ OVERFLOW_MODES = %i[drop_oldest raise].freeze
22
+ DEFAULT_OVERFLOW_MODE = :drop_oldest
23
+
24
+ # Raised by `enbuffer` when the cap would be exceeded under
25
+ # `overflow_mode == :raise`. Inherits from RuntimeError so callers
26
+ # can rescue narrowly. The payload that triggered the overflow rides
27
+ # along so the caller can persist/log/forward it.
28
+ class Overflow < RuntimeError
29
+ attr_reader :payload
30
+
31
+ def initialize(payload)
32
+ @payload = payload
33
+ super("reliable_push buffer is full (cap=#{Buffered.buffer_cap})")
34
+ end
35
+ end
36
+
37
+ # Eagerly initialized: `||=` inside an accessor is not atomic — two
38
+ # threads racing first-touch could end up holding distinct Mutex
39
+ # instances and lose all synchronization on the shared buffer.
40
+ INSTALL_MUTEX = Mutex.new
41
+ BUFFER_MUTEX = Mutex.new
42
+
43
+ class << self
44
+ attr_accessor :buffer_client_factory
45
+
46
+ # Idempotent. Prepends the wrapper module into Wurk::Client so push /
47
+ # push_bulk drain the buffer before each call and raw_push catches
48
+ # connection errors. Safe to call from multiple threads.
49
+ def install!
50
+ install_mutex.synchronize do
51
+ return if @installed
52
+
53
+ Wurk::Client.prepend(InstanceMethods)
54
+ @installed = true
55
+ end
56
+ end
57
+
58
+ def installed?
59
+ @installed == true
60
+ end
61
+
62
+ def buffer_cap
63
+ @buffer_cap ||= DEFAULT_BUFFER_CAP
64
+ end
65
+
66
+ def buffer_cap=(value)
67
+ unless value.is_a?(Integer) && value.positive?
68
+ raise ArgumentError, 'reliable_push_buffer must be a positive Integer'
69
+ end
70
+
71
+ @buffer_cap = value
72
+ end
73
+
74
+ def buffer_size
75
+ buffer_mutex.synchronize { buffer.size }
76
+ end
77
+
78
+ def overflow_mode
79
+ @overflow_mode ||= DEFAULT_OVERFLOW_MODE
80
+ end
81
+
82
+ def overflow_mode=(mode)
83
+ begin
84
+ mode = mode.to_sym
85
+ rescue NoMethodError, TypeError
86
+ raise ArgumentError, "overflow_mode must be one of #{OVERFLOW_MODES.inspect}"
87
+ end
88
+
89
+ unless OVERFLOW_MODES.include?(mode)
90
+ raise ArgumentError, "overflow_mode must be one of #{OVERFLOW_MODES.inspect}"
91
+ end
92
+
93
+ @overflow_mode = mode
94
+ end
95
+
96
+ def reset!
97
+ buffer_mutex.synchronize do
98
+ @buffer = []
99
+ @buffer_cap = nil
100
+ @overflow_mode = nil
101
+ @buffer_client_factory = nil
102
+ end
103
+ end
104
+
105
+ # Append payloads to the buffer. Behavior on cap exhaustion depends
106
+ # on `overflow_mode`:
107
+ # * :drop_oldest (default, spec) — ring buffer, oldest evicted.
108
+ # * :raise — Overflow raised, buffer left
109
+ # unchanged for already-appended
110
+ # siblings in the same call; the
111
+ # offending payload is attached
112
+ # to the exception.
113
+ # Drops batched payloads — caller is expected to re-raise for those.
114
+ # If client is provided, captures its pool for drainer to use by default.
115
+ def enbuffer(payloads, client: nil)
116
+ capture_pool_from_client(client)
117
+
118
+ cap = buffer_cap
119
+ mode = overflow_mode
120
+ buffer_mutex.synchronize do
121
+ payloads.each do |p|
122
+ if buffer.size >= cap
123
+ raise Overflow, p if mode == :raise
124
+
125
+ buffer.shift # :drop_oldest
126
+ end
127
+ buffer << p
128
+ end
129
+ end
130
+ end
131
+
132
+ private
133
+
134
+ # Capture the pool from the provided client and set it as the default
135
+ # factory for the drainer. Ensures buffered jobs are drained to the
136
+ # same pool they were pushed to, unless explicitly overridden.
137
+ def capture_pool_from_client(client)
138
+ return unless client && !buffer_client_factory
139
+
140
+ pool = client.instance_variable_get(:@pool)
141
+ self.buffer_client_factory = -> { Wurk::Client.new(pool: pool) }
142
+ end
143
+
144
+ public
145
+
146
+ # Drain payloads through `raw_push` on the given client. Stops on
147
+ # the first ConnectionError, preserving order at the head of the
148
+ # buffer so the next push retries the same payload. Emits statsd
149
+ # `jobs.recovered.push` per drained payload.
150
+ def drain!(client)
151
+ drained = 0
152
+ while (payload = pop_head)
153
+ unless attempt_replay(client, payload)
154
+ buffer_mutex.synchronize { buffer.unshift(payload) }
155
+ break
156
+ end
157
+
158
+ Wurk::Metrics::Statsd.increment('jobs.recovered.push')
159
+ drained += 1
160
+ end
161
+ drained
162
+ end
163
+
164
+ # Internal — visible for tests. Treat as private.
165
+ def buffer
166
+ @buffer ||= []
167
+ end
168
+
169
+ # Start a background drain thread that wakes every `interval`
170
+ # seconds and tries to flush the buffer. Idempotent — replaces
171
+ # any prior drainer with one at the new interval. Issue #19
172
+ # requirement: "Background drain thread flushes on reconnect" —
173
+ # handles the case where push activity stops mid-outage so the
174
+ # passive (drain-on-next-push) path never fires.
175
+ def start_drainer!(interval: Drainer::DEFAULT_INTERVAL, client_factory: nil)
176
+ INSTALL_MUTEX.synchronize do
177
+ @drainer&.stop
178
+ factory = client_factory || buffer_client_factory || -> { Wurk::Client.new }
179
+ @drainer = Drainer.new(interval: interval, client_factory: factory)
180
+ @drainer.start
181
+ end
182
+ end
183
+
184
+ def stop_drainer!
185
+ INSTALL_MUTEX.synchronize do
186
+ @drainer&.stop
187
+ @drainer = nil
188
+ end
189
+ end
190
+
191
+ def drainer_running?
192
+ INSTALL_MUTEX.synchronize { @drainer&.running? == true }
193
+ end
194
+
195
+ private
196
+
197
+ def install_mutex
198
+ INSTALL_MUTEX
199
+ end
200
+
201
+ def buffer_mutex
202
+ BUFFER_MUTEX
203
+ end
204
+
205
+ def pop_head
206
+ buffer_mutex.synchronize { buffer.shift }
207
+ end
208
+
209
+ # Drain marks the thread so our prepended raw_push re-raises
210
+ # ConnectionError back here instead of swallowing it into the buffer
211
+ # (which would spin forever).
212
+ def attempt_replay(client, payload)
213
+ Thread.current[DRAINING_KEY] = true
214
+ client.send(:raw_push, [payload])
215
+ true
216
+ rescue RedisClient::ConnectionError
217
+ false
218
+ ensure
219
+ Thread.current[DRAINING_KEY] = false
220
+ end
221
+ end
222
+
223
+ # Background drain thread. Wakes every `interval` seconds and tries
224
+ # `Buffered.drain!` against a fresh Wurk::Client. drain! already
225
+ # short-circuits on the first ConnectionError, so a still-down Redis
226
+ # just leaves the buffer alone for this tick — no exponential
227
+ # backoff or explicit "reconnect detection" needed; the inner
228
+ # connection retry already lives inside `client.raw_push`.
229
+ class Drainer
230
+ DEFAULT_INTERVAL = 2.0
231
+ STOP_JOIN_TIMEOUT = 5.0
232
+
233
+ def initialize(interval: DEFAULT_INTERVAL, client_factory: -> { Wurk::Client.new })
234
+ unless interval.is_a?(Numeric) && interval.positive?
235
+ raise ArgumentError, 'interval must be a positive Numeric'
236
+ end
237
+
238
+ @interval = interval
239
+ @client_factory = client_factory
240
+ @done = false
241
+ @thread = nil
242
+ @wake = ConditionVariable.new
243
+ @lock = Mutex.new
244
+ end
245
+
246
+ def start
247
+ @lock.synchronize do
248
+ return if @thread&.alive?
249
+
250
+ @done = false
251
+ @thread = Thread.new do
252
+ Thread.current.name = 'wurk-reliable_push-drainer'
253
+ run
254
+ end
255
+ end
256
+ end
257
+
258
+ def stop
259
+ @lock.synchronize do
260
+ @done = true
261
+ @wake.broadcast
262
+ end
263
+ @thread&.join(STOP_JOIN_TIMEOUT)
264
+ @thread = nil
265
+ end
266
+
267
+ def running?
268
+ @thread&.alive? == true
269
+ end
270
+
271
+ private
272
+
273
+ def run
274
+ until @done
275
+ wait_interval
276
+ break if @done
277
+
278
+ begin
279
+ Buffered.drain!(@client_factory.call)
280
+ rescue StandardError
281
+ # Swallow — next tick retries. Don't let a transient blow up
282
+ # the daemon thread.
283
+ end
284
+ end
285
+ end
286
+
287
+ # Mutex+ConditionVariable lets `stop` wake the thread immediately
288
+ # instead of waiting up to `interval` seconds for sleep to return.
289
+ def wait_interval
290
+ @lock.synchronize { @wake.wait(@lock, @interval) unless @done }
291
+ end
292
+ end
293
+
294
+ # Wraps Wurk::Client. push / push_bulk drain the buffer first;
295
+ # raw_push catches ConnectionError and buffers non-batched payloads.
296
+ module InstanceMethods
297
+ def push(item)
298
+ Buffered.drain!(self)
299
+ super
300
+ end
301
+
302
+ def push_bulk(items)
303
+ Buffered.drain!(self)
304
+ super
305
+ end
306
+
307
+ private
308
+
309
+ def raw_push(payloads)
310
+ super
311
+ rescue RedisClient::ConnectionError
312
+ raise if Thread.current[Buffered::DRAINING_KEY]
313
+
314
+ bidless, batched = payloads.partition { |p| !p['bid'] }
315
+ Buffered.enbuffer(bidless, client: self) if bidless.any?
316
+ raise unless batched.empty?
317
+ end
318
+ end
319
+ end
320
+
321
+ class << self
322
+ # Activate reliable_push! mode globally. Idempotent — call from the
323
+ # top level of an initializer (NOT inside Wurk.configure_*). Spec:
324
+ # docs/target/sidekiq-pro.md §5.
325
+ def reliable_push! # rubocop:disable Naming/PredicateMethod
326
+ Buffered.install!
327
+ true
328
+ end
329
+
330
+ def reliable_push?
331
+ Buffered.installed?
332
+ end
333
+
334
+ def reliable_push_buffer
335
+ Buffered.buffer_cap
336
+ end
337
+
338
+ def reliable_push_buffer=(value)
339
+ Buffered.buffer_cap = value
340
+ end
341
+
342
+ def reliable_push_overflow
343
+ Buffered.overflow_mode
344
+ end
345
+
346
+ def reliable_push_overflow=(mode)
347
+ Buffered.overflow_mode = mode
348
+ end
349
+
350
+ # Start an opt-in background drainer thread. Implicitly enables
351
+ # reliable_push! so callers don't have to chain the two. Idempotent;
352
+ # calling again replaces the thread with one at the new interval.
353
+ # Spec for reliable_push (sidekiq-pro.md §5) only requires drain on
354
+ # next push — this is a Wurk extension for issue #19's "Background
355
+ # drain thread flushes on reconnect" so producer-stopped-mid-outage
356
+ # buffers don't sit idle until next push.
357
+ def reliable_push_drainer(interval: Buffered::Drainer::DEFAULT_INTERVAL)
358
+ Buffered.install!
359
+ Buffered.start_drainer!(interval: interval)
360
+ true
361
+ end
362
+
363
+ def reliable_push_drainer_stop!
364
+ Buffered.stop_drainer!
365
+ end
366
+
367
+ def reliable_push_drainer_running?
368
+ Buffered.drainer_running?
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'iterable_job'
4
+ require_relative 'job_util'
5
+ require_relative 'lua'
6
+
7
+ module Wurk
8
+ # Enqueue interface. Pipelined LPUSH / ZADD writes against the canonical
9
+ # Sidekiq Redis schema — never change keys, JSON shape, or score format here:
10
+ # wire-compat is sacred.
11
+ #
12
+ # Spec: docs/target/sidekiq-free.md §7.
13
+ class Client
14
+ include JobUtil
15
+
16
+ # Sidekiq mirrors these exactly. Tests against the upstream parity suite
17
+ # depend on the magic numbers, not just behavior.
18
+ DEFAULT_BATCH_SIZE = 1_000
19
+ SCHEDULED_BATCH_SIZE = 100
20
+ SPREAD_INTERVAL_FLOOR = 5
21
+
22
+ attr_accessor :redis_pool
23
+
24
+ def initialize(pool: nil, config: nil, chain: nil)
25
+ @config = config || Wurk.configuration
26
+ @redis_pool = pool
27
+ @chain = chain || @config.client_middleware
28
+ end
29
+
30
+ # Returns the chain (or a duplicate when a block is given, matching Sidekiq).
31
+ def middleware
32
+ return @chain unless block_given?
33
+
34
+ copy = @chain.dup
35
+ yield copy
36
+ copy
37
+ end
38
+
39
+ # @return [String, nil] jid; nil when client middleware halts the push.
40
+ def push(item)
41
+ normed = normalize_item(item)
42
+ payload = invoke_chain(normed)
43
+ return nil unless payload
44
+
45
+ verify_json(payload)
46
+ raw_push([payload])
47
+ emit_enqueued([payload])
48
+ payload['jid']
49
+ end
50
+
51
+ # @param items [Hash] keys: class, args (Array<Array>), at?, spread_interval?, batch_size?, jid?
52
+ # @return [Array<String, nil>] jids in submission order; nil entries mark middleware-halted jobs.
53
+ def push_bulk(items)
54
+ args = items['args'] || items[:args]
55
+ validate_bulk_shape!(items, args)
56
+ return [] if args.empty?
57
+
58
+ at_values = expand_at(items, args.size)
59
+ batch_sz = items['batch_size'] || items[:batch_size] || (at_values ? SCHEDULED_BATCH_SIZE : DEFAULT_BATCH_SIZE)
60
+ base = bulk_base(items)
61
+ flush_bulk(args, at_values, base, batch_sz)
62
+ end
63
+
64
+ # Marks an IterableJob as cancelled. Returns the Unix epoch timestamp written.
65
+ # Field name + epoch-second value mirror Sidekiq::IterableJob#cancel! exactly.
66
+ # TTL = CANCELLATION_PERIOD so other workers observe the flag well after
67
+ # the dashboard click that issued the cancel.
68
+ def cancel!(jid)
69
+ raise ArgumentError, 'jid must be a non-empty String' if jid.nil? || jid.to_s.empty?
70
+
71
+ ts = ::Process.clock_gettime(::Process::CLOCK_REALTIME).to_i
72
+ pool.with do |conn|
73
+ conn.call('HSET', "it-#{jid}", 'cancelled', ts)
74
+ conn.call('EXPIRE', "it-#{jid}", Wurk::IterableJob::CANCELLATION_PERIOD)
75
+ end
76
+ ts
77
+ end
78
+
79
+ # Flush batched payloads (each carrying a `bid`) to Redis in one pipeline.
80
+ # Public entry point for Wurk::Batch's autoflush buffer — see #push_batched
81
+ # for the per-job BATCH_PUSH semantics it reuses.
82
+ def flush_batched(payloads)
83
+ return if payloads.empty?
84
+
85
+ pool.with { |conn| push_batched_pipelined(conn, payloads, now_in_millis) }
86
+ end
87
+
88
+ class << self
89
+ def push(item) = new.push(item)
90
+ def push_bulk(items) = new.push_bulk(items)
91
+ def enqueue(klass, *) = klass.perform_async(*)
92
+
93
+ def enqueue_to(queue, klass, *)
94
+ klass.set(queue: queue.to_s).perform_async(*)
95
+ end
96
+
97
+ def enqueue_to_in(queue, interval, klass, *)
98
+ klass.set(queue: queue.to_s).perform_in(interval, *)
99
+ end
100
+
101
+ def enqueue_in(interval, klass, *)
102
+ klass.perform_in(interval, *)
103
+ end
104
+
105
+ # Thread-local pool override. Re-entrant calls are rejected — Sidekiq
106
+ # raises here too, because nested `via` would silently shadow. The
107
+ # begin/ensure guards the slot so a raise on entry doesn't clear the
108
+ # outer caller's pool.
109
+ def via(pool)
110
+ raise ArgumentError, 'pool is required' if pool.nil?
111
+ raise 'Wurk::Client.via is not re-entrant' if Thread.current[:wurk_via_pool]
112
+
113
+ Thread.current[:wurk_via_pool] = pool
114
+ begin
115
+ yield
116
+ ensure
117
+ Thread.current[:wurk_via_pool] = nil
118
+ end
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ def invoke_chain(normed)
125
+ @chain.invoke(normed['class'], normed, normed['queue'], pool) do
126
+ verify_json(normed)
127
+ normed
128
+ end
129
+ end
130
+
131
+ def validate_bulk_shape!(items, args)
132
+ raise ArgumentError, "Bulk arguments must be an Array of Arrays: `#{args.inspect}`" unless valid_bulk_args?(args)
133
+ raise ArgumentError, "Job 'jid' is only allowed with a single-job bulk" if explicit_jid?(items) && args.size > 1
134
+ raise ArgumentError, "Cannot pass both 'at' and 'spread_interval'" if conflicting_schedule?(items)
135
+ end
136
+
137
+ def conflicting_schedule?(items)
138
+ (items.key?('at') || items.key?(:at)) &&
139
+ (items.key?('spread_interval') || items.key?(:spread_interval))
140
+ end
141
+
142
+ def valid_bulk_args?(args)
143
+ args.is_a?(Array) && args.all?(Array)
144
+ end
145
+
146
+ def explicit_jid?(items)
147
+ items.key?('jid') || items.key?(:jid)
148
+ end
149
+
150
+ def bulk_base(items)
151
+ base = items.transform_keys(&:to_s)
152
+ %w[args at spread_interval batch_size].each { |k| base.delete(k) }
153
+ base
154
+ end
155
+
156
+ def flush_bulk(args, at_values, base, batch_size)
157
+ jids = []
158
+ args.each_slice(batch_size).with_index do |slice, slice_index|
159
+ offset = slice_index * batch_size
160
+ ats = at_values && at_values[offset, slice.size]
161
+ payloads = build_bulk_payloads(slice, base, ats)
162
+ compacted = payloads.compact
163
+ if compacted.any?
164
+ raw_push(compacted)
165
+ emit_enqueued(compacted)
166
+ end
167
+ jids.concat(payloads.map { |p| p && p['jid'] })
168
+ end
169
+ jids
170
+ end
171
+
172
+ def build_bulk_payloads(slice, base, ats)
173
+ slice.each_with_index.map do |job_args, idx|
174
+ item = base.merge('args' => job_args)
175
+ item['at'] = ats[idx] if ats
176
+ normed = normalize_item(item)
177
+ invoke_chain(normed)
178
+ end
179
+ end
180
+
181
+ def expand_at(items, count)
182
+ return expand_spread(items, count) unless items.key?('at') || items.key?(:at)
183
+
184
+ at = items['at'] || items[:at]
185
+ case at
186
+ when Array
187
+ raise ArgumentError, "'at' array size must match args" unless at.size == count
188
+ raise ArgumentError, "'at' array must contain only Numeric values" unless at.all?(Numeric)
189
+
190
+ at
191
+ when Numeric
192
+ Array.new(count, at)
193
+ else
194
+ raise ArgumentError, "'at' must be Numeric or Array<Numeric>"
195
+ end
196
+ end
197
+
198
+ def expand_spread(items, count)
199
+ return nil unless items.key?('spread_interval') || items.key?(:spread_interval)
200
+
201
+ spread = items['spread_interval'] || items[:spread_interval]
202
+ raise ArgumentError, "'spread_interval' must be positive Numeric" unless spread.is_a?(Numeric) && spread.positive?
203
+
204
+ window = [spread.to_f, SPREAD_INTERVAL_FLOOR].max
205
+ now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
206
+ Array.new(count) { now + (rand * window) }
207
+ end
208
+
209
+ # Inside an autoflush `Batch#jobs` block immediate batched pushes are
210
+ # accumulated in the buffer rather than written; it flushes every N jobs
211
+ # (when autoflush is an Integer) and Batch#jobs drains the remainder at
212
+ # block exit. Scheduled (`at`) or non-batched payloads bypass the buffer.
213
+ #
214
+ # Adds happen one payload at a time so an `autoflush = N` actually bounds
215
+ # the pipeline size — a bulk push of 100 with N=2 must flush 2/2/... not
216
+ # 100 in one shot.
217
+ def raw_push(payloads)
218
+ # Test modes short-circuit the Redis write (and the batch buffer): :fake
219
+ # collects payloads in-memory, :inline runs them now. Client middleware
220
+ # has already run by this point, matching Sidekiq.
221
+ return ::Wurk::Testing.dispatch_push(payloads) if ::Wurk::Testing.enabled?
222
+
223
+ buffer = Thread.current[Wurk::Batch::BUFFER_KEY]
224
+ return buffer_add(buffer, payloads) if buffer && payloads.all? { |p| p['bid'] && !p['at'] }
225
+
226
+ pool.with { |conn| atomic_push(conn, payloads) }
227
+ end
228
+
229
+ # Batch autoflush path: accumulate each non-scheduled batched payload into
230
+ # the active buffer, flushing every N adds (when `buffer.ready?`).
231
+ def buffer_add(buffer, payloads)
232
+ payloads.each do |payload|
233
+ buffer.add([payload])
234
+ flush_batched(buffer.drain) if buffer.ready?
235
+ end
236
+ nil
237
+ end
238
+
239
+ def atomic_push(conn, payloads)
240
+ if payloads.first['at']
241
+ conn.pipelined { |pipe| push_scheduled(pipe, payloads) }
242
+ else
243
+ push_immediate(conn, payloads)
244
+ end
245
+ end
246
+
247
+ def push_scheduled(conn, payloads)
248
+ args = payloads.flat_map do |hash|
249
+ [hash['at'].to_s, Wurk.dump_json(hash.except('enqueued_at', 'at'))]
250
+ end
251
+ conn.call('ZADD', 'schedule', *args)
252
+ end
253
+
254
+ # Plain SADD/LPUSH and Lua BATCH_PUSH must live in separate pipelines.
255
+ # A `NOSCRIPT` from EVALSHA surfaces only at pipeline finalize — never
256
+ # to `eval_cached`'s inline rescue — so an outer retry of a unified
257
+ # pipeline would replay the already-applied plain commands and
258
+ # duplicate non-batched enqueues. Splitting the phases means a Lua
259
+ # script reload only replays the batched pipeline.
260
+ def push_immediate(conn, payloads)
261
+ now = now_in_millis
262
+ batched, plain = payloads.partition { |j| j['bid'] }
263
+ conn.pipelined { |pipe| push_plain(pipe, plain, now) } unless plain.empty?
264
+ push_batched_pipelined(conn, batched, now) unless batched.empty?
265
+ end
266
+
267
+ # Outside of test boots and `SCRIPT FLUSH` the rescue branch is dead
268
+ # code; the eager `script_load_all` after fork keeps the script cache
269
+ # hot for the life of the connection.
270
+ def push_batched_pipelined(conn, batched, now)
271
+ attempts = 0
272
+ begin
273
+ conn.pipelined { |pipe| push_batched(pipe, batched, now) }
274
+ rescue RedisClient::CommandError => e
275
+ raise unless e.message.to_s.start_with?('NOSCRIPT')
276
+ raise if attempts.positive?
277
+
278
+ attempts += 1
279
+ Wurk::Lua::Loader.script_load_all(conn)
280
+ retry
281
+ end
282
+ end
283
+
284
+ def push_plain(conn, payloads, now)
285
+ grouped = payloads.group_by { |j| j['queue'] }
286
+ conn.call('SADD', 'queues', *grouped.keys)
287
+ grouped.each do |queue, jobs|
288
+ serialized = jobs.map do |j|
289
+ j['enqueued_at'] = now
290
+ Wurk.dump_json(j)
291
+ end
292
+ conn.call('LPUSH', "queue:#{queue}", *serialized)
293
+ end
294
+ end
295
+
296
+ # Batched jobs route through BATCH_PUSH: increments b-<bid> total+pending,
297
+ # SADDs jid into the live set, registers the queue, LPUSHes the payload —
298
+ # all atomically. One Redis round-trip per job (no pipeline grouping)
299
+ # because the lua needs per-job KEYS bound. Acceptable cost: batch
300
+ # enqueue is not the hot path; correctness is.
301
+ def push_batched(conn, payloads, now)
302
+ payloads.each do |j|
303
+ j['enqueued_at'] = now
304
+ Wurk::Lua::Loader.eval_cached(
305
+ conn,
306
+ :batch_push,
307
+ keys: ["b-#{j['bid']}", "b-#{j['bid']}-jids", "queue:#{j['queue']}", 'queues'],
308
+ argv: [j['queue'], j['jid'], Wurk.dump_json(j)]
309
+ )
310
+ end
311
+ end
312
+
313
+ def pool
314
+ @redis_pool || Thread.current[:wurk_via_pool] || @config.redis_pool
315
+ end
316
+
317
+ # Best-effort `sidekiq.jobs.enqueued` counter — one increment per payload
318
+ # that actually made it past middleware AND Redis. Tags follow the same
319
+ # `worker:`/`queue:` shape as Wurk::Metrics::Statsd so dashboards built
320
+ # for the server-side emissions work unchanged.
321
+ def emit_enqueued(payloads)
322
+ payloads.each do |p|
323
+ Wurk::Metrics::Statsd.increment(
324
+ 'jobs.enqueued',
325
+ tags: ["worker:#{p['class']}", "queue:#{p['queue']}"]
326
+ )
327
+ end
328
+ end
329
+ end
330
+ end