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
data/lib/wurk/batch.rb
ADDED
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require_relative 'lua'
|
|
6
|
+
require_relative 'batch/buffer'
|
|
7
|
+
|
|
8
|
+
module Wurk
|
|
9
|
+
# Sidekiq Pro Batches. Group jobs, attach success/complete/death callbacks,
|
|
10
|
+
# track progress. Spec: docs/target/sidekiq-pro.md §2.
|
|
11
|
+
#
|
|
12
|
+
# Lifecycle:
|
|
13
|
+
# 1. `Batch.new` allocates a fresh BID; the batch is `mutable?` until the
|
|
14
|
+
# first `#jobs` block flushes — that flush HSETs the core hash, ZADDs
|
|
15
|
+
# to `batches`, and writes tag indexes.
|
|
16
|
+
# 2. `Batch.new(bid)` reopens an existing batch (legal from inside a job
|
|
17
|
+
# or callback only). `mutable?` is false because reopening implies the
|
|
18
|
+
# first flush already happened.
|
|
19
|
+
# 3. `#jobs { ... }` collects `Job.perform_async` calls via the client
|
|
20
|
+
# middleware (Thread.current[:wurk_current_batch] is the signal),
|
|
21
|
+
# atomically registering each via BATCH_PUSH.
|
|
22
|
+
# 4. Workers ack on success → BATCH_ACK_SUCCESS → pending--.
|
|
23
|
+
# Death handler acks on permanent failure → BATCH_ACK_COMPLETE.
|
|
24
|
+
# 5. When live jids hits zero → fire `:complete`. When pending also
|
|
25
|
+
# hits zero with zero deaths → fire `:success`.
|
|
26
|
+
#
|
|
27
|
+
# Nested batches: a job opening its OWN batch (`batch.jobs { ... }`)
|
|
28
|
+
# increments live counters on the existing batch. A callback opening its
|
|
29
|
+
# PARENT batch links via parent_bid and adds child BID to b-<bid>-kids.
|
|
30
|
+
class Batch
|
|
31
|
+
DEFAULT_EXPIRY_SECONDS = 30 * 24 * 60 * 60
|
|
32
|
+
POST_SUCCESS_EXPIRY_SECONDS = 24 * 60 * 60
|
|
33
|
+
CALLBACK_NOTIFY_TTL = 30 * 24 * 60 * 60
|
|
34
|
+
|
|
35
|
+
# Bid is URL-safe base64 of 10 random bytes — matches Sidekiq Pro's BID
|
|
36
|
+
# generator. Length matters: third-party gems that key off bid prefix
|
|
37
|
+
# (sharded batches in Pro 8) inspect the first character.
|
|
38
|
+
BID_BYTES = 10
|
|
39
|
+
|
|
40
|
+
VALID_EVENTS = %i[success complete death].freeze
|
|
41
|
+
|
|
42
|
+
# The 'live' set tracks jobs that have not yet reached a terminal state.
|
|
43
|
+
# When it's empty, every job has either succeeded or died → `:complete`
|
|
44
|
+
# is allowed to fire.
|
|
45
|
+
KEY_SUFFIXES = %w[jids failed died notify cbsucc kids pkids tags].freeze
|
|
46
|
+
|
|
47
|
+
THREAD_KEY = :wurk_current_batch
|
|
48
|
+
|
|
49
|
+
# Set on the current thread (to a Buffer) only inside an autoflush
|
|
50
|
+
# `#jobs` block. Client#raw_push reads it: when present, batched pushes
|
|
51
|
+
# accumulate here instead of round-tripping per job.
|
|
52
|
+
BUFFER_KEY = :wurk_batch_buffer
|
|
53
|
+
|
|
54
|
+
attr_reader :bid, :parent_bid, :linger
|
|
55
|
+
attr_accessor :description, :callback_queue, :callback_class, :autoflush
|
|
56
|
+
|
|
57
|
+
def self.keys_for(bid)
|
|
58
|
+
base = "b-#{bid}"
|
|
59
|
+
[base, *KEY_SUFFIXES.map { |s| "#{base}-#{s}" }]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def initialize(bid = nil)
|
|
63
|
+
@bid = bid || SecureRandom.urlsafe_base64(BID_BYTES)
|
|
64
|
+
@existing = !bid.nil?
|
|
65
|
+
@description = nil
|
|
66
|
+
@callback_queue = 'default'
|
|
67
|
+
@callback_class = nil
|
|
68
|
+
@tags = []
|
|
69
|
+
@autoflush = nil
|
|
70
|
+
@linger = nil
|
|
71
|
+
@parent_bid = nil
|
|
72
|
+
@callbacks = []
|
|
73
|
+
@expires_in = DEFAULT_EXPIRY_SECONDS
|
|
74
|
+
@mutable = !@existing
|
|
75
|
+
@flushed_once = @existing
|
|
76
|
+
load_existing! if @existing
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Hash assignment writes strings — Sidekiq's UI / third-party gems
|
|
80
|
+
# expect String tags. Array-coercion lets callers pass a String or Set.
|
|
81
|
+
def tags=(value)
|
|
82
|
+
@tags = Array(value).map(&:to_s)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def tags
|
|
86
|
+
@tags.dup
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Per-batch post-success retention override (seconds). nil falls back to
|
|
90
|
+
# POST_SUCCESS_EXPIRY_SECONDS when `:success` fires. See §2.8.
|
|
91
|
+
#
|
|
92
|
+
# After the first flush the value is persisted to `b-<bid>`; `apply_linger`
|
|
93
|
+
# reads from Redis, so a setter that only touched memory would silently
|
|
94
|
+
# ignore the override for any batch reopened by bid.
|
|
95
|
+
def linger=(duration)
|
|
96
|
+
@linger = duration&.to_i
|
|
97
|
+
return unless @flushed_once
|
|
98
|
+
|
|
99
|
+
Wurk.redis { |conn| conn.call('HSET', "b-#{@bid}", 'linger', @linger.to_s) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def parent
|
|
103
|
+
return nil if @parent_bid.nil? || @parent_bid.empty?
|
|
104
|
+
|
|
105
|
+
Batch.new(@parent_bid)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def mutable?
|
|
109
|
+
@mutable
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def include?(jid)
|
|
113
|
+
Wurk.redis { |conn| conn.call('SISMEMBER', "b-#{@bid}-jids", jid) }.to_i.positive?
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Remove jobs from the batch. Decrements pending/total by exactly the
|
|
117
|
+
# count of jids actually removed (idempotent for repeated calls).
|
|
118
|
+
def remove_jobs(*jids)
|
|
119
|
+
return 0 if jids.empty?
|
|
120
|
+
|
|
121
|
+
Wurk.redis do |conn|
|
|
122
|
+
removed = conn.call('SREM', "b-#{@bid}-jids", *jids).to_i
|
|
123
|
+
if removed.positive?
|
|
124
|
+
conn.call('HINCRBY', "b-#{@bid}", 'pending', -removed)
|
|
125
|
+
conn.call('HINCRBY', "b-#{@bid}", 'total', -removed)
|
|
126
|
+
end
|
|
127
|
+
removed
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Mark batch invalid. Pending jobs still exist in their queues; the
|
|
132
|
+
# server middleware short-circuits them when it observes the flag.
|
|
133
|
+
# Cascades to descendant batches via b-<bid>-kids.
|
|
134
|
+
def invalidate_all
|
|
135
|
+
cascade_invalidate(@bid)
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def valid?
|
|
140
|
+
Wurk.redis { |conn| conn.call('HGET', "b-#{@bid}", 'invalidated') } != '1'
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def status
|
|
144
|
+
Status.new(@bid)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def expires_in(duration)
|
|
148
|
+
@expires_in = duration.to_i
|
|
149
|
+
self
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Register a callback. Multiple callbacks of the same event are allowed.
|
|
153
|
+
# The callback target may be a Class, "Foo#bar" string spec, or anything
|
|
154
|
+
# responding to `name`. `options` must be JSON-serializable.
|
|
155
|
+
def on(event, callback, options = {})
|
|
156
|
+
sym = event.to_sym
|
|
157
|
+
raise ArgumentError, "invalid event #{event.inspect}" unless VALID_EVENTS.include?(sym)
|
|
158
|
+
raise ArgumentError, 'callback options must be a Hash' unless options.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
@callbacks << [sym.to_s, callback_target(callback), options]
|
|
161
|
+
self
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Atomic enqueue block. Inside the block, `Job.perform_async` finds
|
|
165
|
+
# this batch via Thread.current[THREAD_KEY] and stamps `bid` onto the
|
|
166
|
+
# payload — the client middleware then uses BATCH_PUSH to register and
|
|
167
|
+
# push atomically. Empty blocks synthesise a Batch::Empty no-op so
|
|
168
|
+
# callbacks still fire.
|
|
169
|
+
def jobs(&block)
|
|
170
|
+
raise ArgumentError, 'jobs requires a block' unless block
|
|
171
|
+
|
|
172
|
+
ensure_first_flush!
|
|
173
|
+
pre_count = job_count
|
|
174
|
+
collect_jobs(&block)
|
|
175
|
+
# By the time we check, the buffer (if any) has flushed, so `total`
|
|
176
|
+
# reflects everything the block pushed — a flat count is reliable.
|
|
177
|
+
enqueue_empty_marker if job_count == pre_count
|
|
178
|
+
@mutable = false
|
|
179
|
+
self
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Runs the block with this batch active so the client middleware stamps
|
|
185
|
+
# the bid. With autoflush on, batched pushes accumulate in a Buffer and
|
|
186
|
+
# flush once at exit; the per-N flushing happens in Client#raw_push.
|
|
187
|
+
def collect_jobs
|
|
188
|
+
previous = Thread.current[THREAD_KEY]
|
|
189
|
+
prev_buffer = Thread.current[BUFFER_KEY]
|
|
190
|
+
buffer = new_buffer
|
|
191
|
+
Thread.current[THREAD_KEY] = self
|
|
192
|
+
Thread.current[BUFFER_KEY] = buffer
|
|
193
|
+
begin
|
|
194
|
+
yield
|
|
195
|
+
flush_buffer(buffer) if buffer
|
|
196
|
+
ensure
|
|
197
|
+
Thread.current[THREAD_KEY] = previous
|
|
198
|
+
Thread.current[BUFFER_KEY] = prev_buffer
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Buffering is opt-in via `autoflush`: `true` buffers the whole block and
|
|
203
|
+
# flushes once at exit; a positive Integer flushes every N jobs. Anything
|
|
204
|
+
# falsy keeps the default per-job immediate push.
|
|
205
|
+
def new_buffer
|
|
206
|
+
return nil unless @autoflush
|
|
207
|
+
|
|
208
|
+
Buffer.new([], autoflush_threshold)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# `true` → buffer the whole block (nil threshold, drained at exit);
|
|
212
|
+
# positive Integer → flush every N. Any other truthy value is a config
|
|
213
|
+
# typo (`0`, `-1`, `"5"`) — fail fast instead of silently degrading to
|
|
214
|
+
# "flush at block exit".
|
|
215
|
+
def autoflush_threshold
|
|
216
|
+
return nil if @autoflush == true
|
|
217
|
+
return @autoflush if @autoflush.is_a?(Integer) && @autoflush.positive?
|
|
218
|
+
|
|
219
|
+
raise ArgumentError, "autoflush must be true or a positive Integer, got #{@autoflush.inspect}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def flush_buffer(buffer)
|
|
223
|
+
payloads = buffer.drain
|
|
224
|
+
Wurk::Client.new.flush_batched(payloads) unless payloads.empty?
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# First flush writes the core hash, registers in the global `batches`
|
|
228
|
+
# zset, and links tag indexes. Subsequent #jobs invocations skip this
|
|
229
|
+
# — `total` is already there and BATCH_PUSH only increments deltas.
|
|
230
|
+
def ensure_first_flush!
|
|
231
|
+
return if @flushed_once
|
|
232
|
+
|
|
233
|
+
now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
|
|
234
|
+
Wurk.redis { |conn| conn.pipelined { |pipe| pipelined_first_flush(pipe, now) } }
|
|
235
|
+
@parent_bid = current_parent_bid
|
|
236
|
+
@flushed_once = true
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def pipelined_first_flush(pipe, now)
|
|
240
|
+
pipe.call('HSET', "b-#{@bid}", *first_flush_hash(now).flatten)
|
|
241
|
+
pipe.call('EXPIRE', "b-#{@bid}", @expires_in)
|
|
242
|
+
pipe.call('ZADD', 'batches', now.to_s, @bid)
|
|
243
|
+
@tags.each { |t| pipe.call('SADD', "tags:#{t}", @bid) }
|
|
244
|
+
link_to_parent(pipe) if current_parent_bid
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def first_flush_hash(now)
|
|
248
|
+
{
|
|
249
|
+
'created_at' => now.to_s,
|
|
250
|
+
'description' => @description.to_s,
|
|
251
|
+
'callback_queue' => @callback_queue.to_s,
|
|
252
|
+
'callback_class' => @callback_class.to_s,
|
|
253
|
+
'parent_bid' => current_parent_bid.to_s,
|
|
254
|
+
'tags' => @tags.to_json,
|
|
255
|
+
'linger' => @linger.to_s,
|
|
256
|
+
'callbacks' => @callbacks.to_json,
|
|
257
|
+
'total' => '0',
|
|
258
|
+
'pending' => '0',
|
|
259
|
+
'failures' => '0'
|
|
260
|
+
}
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# If we're flushing from inside another batch's #jobs block, that
|
|
264
|
+
# parent batch's BID becomes our parent_bid and we get registered into
|
|
265
|
+
# its kids/pkids sets so cascade success/failure is correctly tracked.
|
|
266
|
+
def current_parent_bid
|
|
267
|
+
outer = Thread.current[THREAD_KEY]
|
|
268
|
+
return nil if outer.nil? || outer.bid == @bid
|
|
269
|
+
|
|
270
|
+
outer.bid
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def link_to_parent(pipe)
|
|
274
|
+
pipe.call('SADD', "b-#{current_parent_bid}-kids", @bid)
|
|
275
|
+
pipe.call('SADD', "b-#{current_parent_bid}-pkids", @bid)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def job_count
|
|
279
|
+
Wurk.redis { |conn| conn.call('HGET', "b-#{@bid}", 'total') }.to_i
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def enqueue_empty_marker
|
|
283
|
+
require_relative 'batch/empty'
|
|
284
|
+
previous = Thread.current[THREAD_KEY]
|
|
285
|
+
Thread.current[THREAD_KEY] = self
|
|
286
|
+
begin
|
|
287
|
+
Wurk::Batch::Empty.perform_async
|
|
288
|
+
ensure
|
|
289
|
+
Thread.current[THREAD_KEY] = previous
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def cascade_invalidate(bid)
|
|
294
|
+
Wurk.redis do |conn|
|
|
295
|
+
Wurk::Lua::Loader.eval_cached(conn, :batch_invalidate, keys: ["b-#{bid}", "b-#{bid}-jids"], argv: [])
|
|
296
|
+
kids = conn.call('SMEMBERS', "b-#{bid}-kids") || []
|
|
297
|
+
kids.each { |child| cascade_invalidate(child) }
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def load_existing!
|
|
302
|
+
data = fetch_hash
|
|
303
|
+
load_meta(data)
|
|
304
|
+
@tags = parse_json_array(data['tags'])
|
|
305
|
+
@linger = nil_if_empty(data['linger'])&.to_i
|
|
306
|
+
@callbacks = parse_json_callbacks(data['callbacks'])
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def load_meta(data)
|
|
310
|
+
@description = nil_if_empty(data['description'])
|
|
311
|
+
@callback_queue = nil_if_empty(data['callback_queue']) || 'default'
|
|
312
|
+
@callback_class = nil_if_empty(data['callback_class'])
|
|
313
|
+
@parent_bid = nil_if_empty(data['parent_bid'])
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def fetch_hash
|
|
317
|
+
raw = Wurk.redis { |conn| conn.call('HGETALL', "b-#{@bid}") }
|
|
318
|
+
raw.is_a?(Hash) ? raw : raw.each_slice(2).to_h
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def callback_target(callback)
|
|
322
|
+
case callback
|
|
323
|
+
when Class then callback.name
|
|
324
|
+
when String, Symbol then callback.to_s
|
|
325
|
+
else
|
|
326
|
+
raise ArgumentError, "callback must be Class or String: #{callback.inspect}" unless callback.respond_to?(:name)
|
|
327
|
+
|
|
328
|
+
callback.name
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def nil_if_empty(val)
|
|
333
|
+
val.nil? || val.to_s.empty? ? nil : val
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def parse_json_array(raw)
|
|
337
|
+
return [] if raw.nil? || raw.empty?
|
|
338
|
+
|
|
339
|
+
JSON.parse(raw)
|
|
340
|
+
rescue JSON::ParserError
|
|
341
|
+
[]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def parse_json_callbacks(raw)
|
|
345
|
+
arr = parse_json_array(raw)
|
|
346
|
+
arr.is_a?(Array) ? arr : []
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
require_relative 'batch/status'
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'batch'
|
|
4
|
+
|
|
5
|
+
module Wurk
|
|
6
|
+
# Discovery API over the global `batches` sorted set. `size`, `each` (yields
|
|
7
|
+
# Status), `scan_tags(tag)` for tag-indexed lookup.
|
|
8
|
+
#
|
|
9
|
+
# Spec: docs/target/sidekiq-pro.md §2.7.
|
|
10
|
+
class BatchSet
|
|
11
|
+
include Enumerable
|
|
12
|
+
|
|
13
|
+
PAGE_SIZE = 100
|
|
14
|
+
|
|
15
|
+
def initialize(key: 'batches')
|
|
16
|
+
@key = key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def size
|
|
20
|
+
Wurk.redis { |conn| conn.call('ZCARD', @key) }.to_i
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Newest-first iteration of every batch indexed in the set. Yields
|
|
24
|
+
# `Wurk::Batch::Status` instances — they HGETALL on construction so
|
|
25
|
+
# the caller pays one Redis round-trip per batch.
|
|
26
|
+
def each
|
|
27
|
+
return enum_for(:each) unless block_given?
|
|
28
|
+
|
|
29
|
+
page = 0
|
|
30
|
+
loop do
|
|
31
|
+
start = page * PAGE_SIZE
|
|
32
|
+
stop = start + PAGE_SIZE - 1
|
|
33
|
+
bids = Wurk.redis { |conn| conn.call('ZRANGE', @key, start, stop, 'REV') }
|
|
34
|
+
bids.each { |bid| yield Wurk::Batch::Status.new(bid) }
|
|
35
|
+
break if bids.size < PAGE_SIZE
|
|
36
|
+
|
|
37
|
+
page += 1
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Yields each BID indexed under the given tag (set at `tags:<tag>`).
|
|
42
|
+
# No JSON parsing; cheap iteration. Caller wraps in `Status.new(bid)`
|
|
43
|
+
# if it needs full state.
|
|
44
|
+
def scan_tags(tag, &block)
|
|
45
|
+
return enum_for(:scan_tags, tag) unless block_given?
|
|
46
|
+
|
|
47
|
+
cursor = '0'
|
|
48
|
+
Wurk.redis do |conn|
|
|
49
|
+
loop do
|
|
50
|
+
cursor, members = conn.call('SSCAN', "tags:#{tag}", cursor, 'COUNT', PAGE_SIZE)
|
|
51
|
+
members.each(&block)
|
|
52
|
+
break if cursor == '0'
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Batch
|
|
59
|
+
# Discovery view over batches that triggered `:death`. Iterates Status
|
|
60
|
+
# instances newest-first.
|
|
61
|
+
class DeadSet < ::Wurk::BatchSet
|
|
62
|
+
def initialize
|
|
63
|
+
super(key: 'dead-batches')
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/wurk/capsule.rb
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'redis_pool'
|
|
4
|
+
require_relative 'middleware/chain'
|
|
5
|
+
require_relative 'fetcher/reliable'
|
|
6
|
+
|
|
7
|
+
module Wurk
|
|
8
|
+
# One processing unit: a set of threads + queues sharing a fetcher and a
|
|
9
|
+
# Redis pool. Configurations can hold many capsules; each maps to its own
|
|
10
|
+
# Manager and Processors.
|
|
11
|
+
#
|
|
12
|
+
# Spec: docs/target/sidekiq-free.md §5 (Sidekiq::Capsule).
|
|
13
|
+
class Capsule
|
|
14
|
+
MODES = %i[strict weighted random].freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :name, :queues, :mode, :weights, :config
|
|
17
|
+
attr_accessor :concurrency, :fetcher
|
|
18
|
+
|
|
19
|
+
def initialize(name, config)
|
|
20
|
+
@name = name.to_s
|
|
21
|
+
@config = config
|
|
22
|
+
@concurrency = config[:concurrency] || 5
|
|
23
|
+
@queues = ['default']
|
|
24
|
+
@mode = :strict
|
|
25
|
+
@weights = { 'default' => 0 }
|
|
26
|
+
@fetcher = nil
|
|
27
|
+
@redis_pool = nil
|
|
28
|
+
@local_redis_pool = nil
|
|
29
|
+
@client_chain = nil
|
|
30
|
+
@server_chain = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h
|
|
34
|
+
{ concurrency: @concurrency, mode: @mode, weights: @weights }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Parses queue specs Sidekiq-style:
|
|
38
|
+
# %w[high default low] → mode :strict, weights all 0
|
|
39
|
+
# %w[high,3 default,2 low,1] → mode :weighted, weights {q=>w}
|
|
40
|
+
# %w[a,1 b,1 c,1] → mode :random, weights all 1
|
|
41
|
+
# @queues is expanded by weight so a uniform shuffle gives weighted
|
|
42
|
+
# fairness (e.g. ["high","high","high","default","default","low"]).
|
|
43
|
+
def queues=(val)
|
|
44
|
+
parsed = Array(val).map { |entry| parse_queue_entry(entry) }
|
|
45
|
+
raise ArgumentError, 'queues cannot be empty' if parsed.empty?
|
|
46
|
+
|
|
47
|
+
@weights = parsed.to_h
|
|
48
|
+
@mode = detect_mode(parsed)
|
|
49
|
+
@queues = expand_by_weight(parsed, @mode)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Lossless `name[,weight]` specs (unlike `queues`, which is the
|
|
53
|
+
# weight-expanded list). Lets Configuration#topology rebuild a slot that
|
|
54
|
+
# round-trips back through `queues=` without flattening weights.
|
|
55
|
+
def queue_specs
|
|
56
|
+
@weights.map { |q, w| w.positive? ? "#{q},#{w}" : q }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Materialize everything that lazy-inits via `||=` and default the fetcher,
|
|
60
|
+
# BEFORE Configuration#freeze! freezes the capsule — otherwise the first
|
|
61
|
+
# post-freeze access (a fetch tick, a middleware call) hits a nil fetcher
|
|
62
|
+
# or FrozenErrors building a pool. The swarm's ChildBoot used to do this
|
|
63
|
+
# by hand; centralizing it here covers the standalone CLI and embedded
|
|
64
|
+
# paths too (the bug behind a nil `fetcher` in `exe/wurk`). Idempotent.
|
|
65
|
+
def prepare!
|
|
66
|
+
@fetcher ||= Wurk::Fetcher::Reliable.new(self)
|
|
67
|
+
redis_pool
|
|
68
|
+
local_redis_pool
|
|
69
|
+
client_middleware
|
|
70
|
+
server_middleware
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def client_middleware
|
|
75
|
+
chain = (@client_chain ||= @config.client_middleware.copy_for(self))
|
|
76
|
+
yield chain if block_given?
|
|
77
|
+
chain
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def server_middleware
|
|
81
|
+
# copy_for(self) — not dup — binds the chain's `@config` to this capsule,
|
|
82
|
+
# so middleware that reach for `redis_pool`/`redis`/`logger` resolve them
|
|
83
|
+
# instead of hitting `nil` (a plain dup leaves @config nil).
|
|
84
|
+
chain = (@server_chain ||= @config.server_middleware.copy_for(self))
|
|
85
|
+
yield chain if block_given?
|
|
86
|
+
chain
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Pool size = concurrency + POOL_OVERHEAD. Every processor thread can
|
|
90
|
+
# be parked in a blocking BLMOVE (reliable fetch), holding its slot
|
|
91
|
+
# for ~3s. POOL_OVERHEAD reserves connections for the Launcher's
|
|
92
|
+
# heartbeat thread + the Scheduled::Poller so they can never be
|
|
93
|
+
# starved by busy fetchers. Without this, concurrency=1 deadlocks
|
|
94
|
+
# immediately (worker holds the only slot, heartbeat times out).
|
|
95
|
+
POOL_OVERHEAD = 2
|
|
96
|
+
|
|
97
|
+
def redis_pool
|
|
98
|
+
@redis_pool ||= build_pool(size: @concurrency + POOL_OVERHEAD, name: "#{@name}-main")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def local_redis_pool
|
|
102
|
+
@local_redis_pool ||= build_pool(size: @concurrency, name: "#{@name}-local")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Disconnect and drop cached pools. Called by Wurk::Swarm just before
|
|
106
|
+
# fork (parent side: close inherited sockets) and just after fork
|
|
107
|
+
# (child side: rebuild lazily). Connection_pool#shutdown is terminal,
|
|
108
|
+
# so dropping the reference is required — `redis_pool` will rebuild.
|
|
109
|
+
def reset_redis_pools!
|
|
110
|
+
@redis_pool&.disconnect!
|
|
111
|
+
@redis_pool = nil
|
|
112
|
+
@local_redis_pool&.disconnect!
|
|
113
|
+
@local_redis_pool = nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def redis(&)
|
|
117
|
+
redis_pool.with(&)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def lookup(name)
|
|
121
|
+
@config.lookup(name)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def logger
|
|
125
|
+
@config.logger
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# No-op in OSS. Reserved for Pro/Ent hooks that need to flush per-capsule
|
|
129
|
+
# state on shutdown. Manager#stop invokes this in `ensure` so the contract
|
|
130
|
+
# is honored regardless of how stop unwinds.
|
|
131
|
+
#
|
|
132
|
+
# Spec: docs/target/sidekiq-free.md §5.
|
|
133
|
+
def stop; end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def parse_queue_entry(entry)
|
|
138
|
+
qname, weight_str = entry.to_s.split(',', 2)
|
|
139
|
+
qname = qname.to_s.strip
|
|
140
|
+
raise ArgumentError, "queue name cannot be empty: `#{entry}`" if qname.empty?
|
|
141
|
+
return [qname, 0] if weight_str.nil?
|
|
142
|
+
|
|
143
|
+
weight = Integer(weight_str)
|
|
144
|
+
raise ArgumentError, "queue weight must be > 0: `#{entry}`" if weight <= 0
|
|
145
|
+
|
|
146
|
+
[qname, weight]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def detect_mode(parsed)
|
|
150
|
+
weights = parsed.map(&:last)
|
|
151
|
+
if weights.all?(&:zero?)
|
|
152
|
+
:strict
|
|
153
|
+
elsif weights.uniq.size == 1
|
|
154
|
+
:random
|
|
155
|
+
else
|
|
156
|
+
:weighted
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def expand_by_weight(parsed, mode)
|
|
161
|
+
return parsed.map(&:first) if %i[strict random].include?(mode)
|
|
162
|
+
|
|
163
|
+
parsed.flat_map { |q, w| [q] * w }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def build_pool(size:, name:)
|
|
167
|
+
cfg = @config.redis_config
|
|
168
|
+
RedisPool.new(
|
|
169
|
+
size: size,
|
|
170
|
+
url: cfg[:url] || RedisPool::DEFAULT_URL,
|
|
171
|
+
timeout: cfg[:timeout] || RedisPool::DEFAULT_TIMEOUT,
|
|
172
|
+
name: name
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|