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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +73 -0
- data/LICENSE +21 -0
- data/README.md +137 -0
- data/SECURITY.md +39 -0
- data/app/controllers/wurk/api/pagination.rb +67 -0
- data/app/controllers/wurk/api/serializers.rb +131 -0
- data/app/controllers/wurk/api_controller.rb +248 -0
- data/app/controllers/wurk/application_controller.rb +7 -0
- data/app/controllers/wurk/dashboard_controller.rb +48 -0
- data/config/locales/en.yml +15 -0
- data/config/routes.rb +34 -0
- data/exe/wurk +22 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +96 -0
- data/lib/generators/wurk/install/install_generator.rb +22 -0
- data/lib/generators/wurk/install/templates/wurk.rb +16 -0
- data/lib/wurk/active_job/wrapper.rb +32 -0
- data/lib/wurk/api/fast.rb +78 -0
- data/lib/wurk/batch/buffer.rb +26 -0
- data/lib/wurk/batch/callback_job.rb +37 -0
- data/lib/wurk/batch/callbacks.rb +176 -0
- data/lib/wurk/batch/client_middleware.rb +27 -0
- data/lib/wurk/batch/death_handler.rb +39 -0
- data/lib/wurk/batch/empty.rb +21 -0
- data/lib/wurk/batch/server_middleware.rb +62 -0
- data/lib/wurk/batch/status.rb +140 -0
- data/lib/wurk/batch.rb +351 -0
- data/lib/wurk/batch_set.rb +67 -0
- data/lib/wurk/capsule.rb +176 -0
- data/lib/wurk/cli.rb +349 -0
- data/lib/wurk/client/buffered.rb +372 -0
- data/lib/wurk/client.rb +330 -0
- data/lib/wurk/compat.rb +136 -0
- data/lib/wurk/component.rb +136 -0
- data/lib/wurk/configuration.rb +373 -0
- data/lib/wurk/context.rb +35 -0
- data/lib/wurk/cron.rb +636 -0
- data/lib/wurk/dashboard_manifest.rb +39 -0
- data/lib/wurk/dead_set.rb +78 -0
- data/lib/wurk/deploy.rb +91 -0
- data/lib/wurk/embedded.rb +94 -0
- data/lib/wurk/encryption.rb +276 -0
- data/lib/wurk/engine.rb +81 -0
- data/lib/wurk/fetcher/reaper.rb +264 -0
- data/lib/wurk/fetcher/reliable.rb +138 -0
- data/lib/wurk/fetcher.rb +11 -0
- data/lib/wurk/health.rb +193 -0
- data/lib/wurk/heartbeat.rb +211 -0
- data/lib/wurk/iterable_job.rb +292 -0
- data/lib/wurk/job/options.rb +70 -0
- data/lib/wurk/job.rb +33 -0
- data/lib/wurk/job_logger.rb +68 -0
- data/lib/wurk/job_record.rb +156 -0
- data/lib/wurk/job_retry.rb +320 -0
- data/lib/wurk/job_set.rb +212 -0
- data/lib/wurk/job_util.rb +162 -0
- data/lib/wurk/keys.rb +52 -0
- data/lib/wurk/launcher.rb +289 -0
- data/lib/wurk/leader.rb +221 -0
- data/lib/wurk/limiter/base.rb +138 -0
- data/lib/wurk/limiter/bucket.rb +80 -0
- data/lib/wurk/limiter/concurrent.rb +132 -0
- data/lib/wurk/limiter/leaky.rb +91 -0
- data/lib/wurk/limiter/points.rb +89 -0
- data/lib/wurk/limiter/server_middleware.rb +77 -0
- data/lib/wurk/limiter/unlimited.rb +48 -0
- data/lib/wurk/limiter/window.rb +80 -0
- data/lib/wurk/limiter.rb +255 -0
- data/lib/wurk/logger.rb +81 -0
- data/lib/wurk/lua/loader.rb +53 -0
- data/lib/wurk/lua.rb +187 -0
- data/lib/wurk/manager.rb +132 -0
- data/lib/wurk/metrics/history.rb +151 -0
- data/lib/wurk/metrics/query.rb +173 -0
- data/lib/wurk/metrics/rollup.rb +169 -0
- data/lib/wurk/metrics/statsd.rb +197 -0
- data/lib/wurk/metrics.rb +7 -0
- data/lib/wurk/middleware/chain.rb +128 -0
- data/lib/wurk/middleware/current_attributes.rb +87 -0
- data/lib/wurk/middleware/expiry.rb +50 -0
- data/lib/wurk/middleware/i18n.rb +63 -0
- data/lib/wurk/middleware/interrupt_handler.rb +45 -0
- data/lib/wurk/middleware/poison_pill.rb +149 -0
- data/lib/wurk/middleware.rb +34 -0
- data/lib/wurk/process_set.rb +243 -0
- data/lib/wurk/processor.rb +247 -0
- data/lib/wurk/queue.rb +108 -0
- data/lib/wurk/queues.rb +80 -0
- data/lib/wurk/rails.rb +9 -0
- data/lib/wurk/railtie.rb +28 -0
- data/lib/wurk/redis_pool.rb +79 -0
- data/lib/wurk/retry_set.rb +17 -0
- data/lib/wurk/scheduled.rb +189 -0
- data/lib/wurk/scheduled_set.rb +18 -0
- data/lib/wurk/sorted_entry.rb +95 -0
- data/lib/wurk/stats.rb +190 -0
- data/lib/wurk/swarm/child_boot.rb +105 -0
- data/lib/wurk/swarm.rb +260 -0
- data/lib/wurk/testing.rb +102 -0
- data/lib/wurk/topology.rb +74 -0
- data/lib/wurk/unique.rb +240 -0
- data/lib/wurk/version.rb +5 -0
- data/lib/wurk/web/config.rb +180 -0
- data/lib/wurk/web/enterprise.rb +138 -0
- data/lib/wurk/web/search.rb +139 -0
- data/lib/wurk/web.rb +25 -0
- data/lib/wurk/work_set.rb +116 -0
- data/lib/wurk/worker/setter.rb +93 -0
- data/lib/wurk/worker.rb +216 -0
- data/lib/wurk.rb +238 -0
- data/vendor/assets/dashboard/assets/index-8P3N_m1X.js +152 -0
- data/vendor/assets/dashboard/assets/index-Bqz4_SOQ.css +1 -0
- data/vendor/assets/dashboard/index.html +13 -0
- data/vendor/assets/dashboard/wurk-manifest.json +4 -0
- 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
|
data/lib/wurk/client.rb
ADDED
|
@@ -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
|