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.
- checksums.yaml +4 -4
- data/README.md +4 -1
- data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
- data/lib/wurk/batch/callbacks.rb +82 -12
- data/lib/wurk/batch/death_handler.rb +7 -4
- data/lib/wurk/batch/server_middleware.rb +1 -1
- data/lib/wurk/batch.rb +121 -15
- data/lib/wurk/capsule.rb +45 -16
- data/lib/wurk/cli.rb +48 -14
- data/lib/wurk/client/buffered.rb +200 -43
- data/lib/wurk/client.rb +181 -38
- data/lib/wurk/compat.rb +1 -1
- data/lib/wurk/component.rb +36 -4
- data/lib/wurk/configuration.rb +47 -7
- data/lib/wurk/context.rb +1 -1
- data/lib/wurk/cron.rb +94 -37
- data/lib/wurk/deploy.rb +5 -3
- data/lib/wurk/embedded.rb +13 -0
- data/lib/wurk/engine.rb +41 -1
- data/lib/wurk/errors.rb +15 -0
- data/lib/wurk/fetcher/reaper.rb +125 -58
- data/lib/wurk/fetcher/reliable.rb +366 -51
- data/lib/wurk/fetcher.rb +6 -0
- data/lib/wurk/heartbeat.rb +24 -12
- data/lib/wurk/history.rb +13 -1
- data/lib/wurk/job_logger.rb +16 -7
- data/lib/wurk/job_set.rb +3 -2
- data/lib/wurk/job_util.rb +44 -24
- data/lib/wurk/launcher.rb +230 -119
- data/lib/wurk/leader.rb +63 -14
- data/lib/wurk/limiter/base.rb +8 -10
- data/lib/wurk/limiter/bucket.rb +1 -1
- data/lib/wurk/limiter/concurrent.rb +27 -22
- data/lib/wurk/limiter/window.rb +13 -11
- data/lib/wurk/limiter.rb +7 -4
- data/lib/wurk/logger.rb +1 -1
- data/lib/wurk/lua/loader.rb +9 -3
- data/lib/wurk/lua.rb +97 -14
- data/lib/wurk/manager.rb +29 -13
- data/lib/wurk/metrics/accumulator.rb +95 -0
- data/lib/wurk/metrics/flusher.rb +70 -0
- data/lib/wurk/metrics/history.rb +102 -32
- data/lib/wurk/metrics/queue_rollup.rb +13 -1
- data/lib/wurk/metrics/rollup.rb +13 -1
- data/lib/wurk/metrics/statsd.rb +32 -18
- data/lib/wurk/middleware/chain.rb +31 -14
- data/lib/wurk/middleware/interrupt_handler.rb +7 -6
- data/lib/wurk/middleware/poison_pill.rb +93 -31
- data/lib/wurk/middleware.rb +2 -2
- data/lib/wurk/pool_checkout.rb +39 -0
- data/lib/wurk/process_set.rb +10 -5
- data/lib/wurk/processor.rb +83 -9
- data/lib/wurk/profiler.rb +9 -4
- data/lib/wurk/queue.rb +18 -7
- data/lib/wurk/rails_boot.rb +38 -7
- data/lib/wurk/redis_client_adapter.rb +49 -5
- data/lib/wurk/redis_pool.rb +71 -25
- data/lib/wurk/scheduled.rb +30 -2
- data/lib/wurk/shutdown_gate.rb +79 -0
- data/lib/wurk/stats.rb +19 -10
- data/lib/wurk/swarm/child_boot.rb +36 -4
- data/lib/wurk/swarm.rb +258 -43
- data/lib/wurk/timer_loop.rb +14 -0
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/config.rb +11 -7
- data/lib/wurk/web/enterprise.rb +58 -6
- data/lib/wurk/web/extension.rb +1 -1
- data/lib/wurk/web/search.rb +5 -3
- data/lib/wurk.rb +12 -10
- data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
- data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
- data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
- data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
- data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
- data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
- data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
- data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
- data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
- data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
- data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
- data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
- data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
- data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
- data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
- data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
- data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
- data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
- data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
- data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
- data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
- data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
- data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
- data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
- data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
- data/vendor/assets/dashboard/index.html +3 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +32 -27
- data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
- data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
- data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
- data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
- data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
- data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
data/lib/wurk/leader.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require 'securerandom'
|
|
4
4
|
require 'socket'
|
|
5
5
|
require_relative 'component'
|
|
6
|
+
require_relative 'lua'
|
|
7
|
+
require_relative 'pool_checkout'
|
|
8
|
+
require_relative 'timer_loop'
|
|
6
9
|
|
|
7
10
|
module Wurk
|
|
8
11
|
# Cluster leader election via Redis `SET NX EX`. Single-leader-per-cluster
|
|
@@ -19,9 +22,12 @@ module Wurk
|
|
|
19
22
|
# too — it is never re-read on subsequent acquires, only on transitions.
|
|
20
23
|
#
|
|
21
24
|
# Cadence per spec: renew every 15s while leader, recheck every 60s as
|
|
22
|
-
# follower, lock TTL 30s.
|
|
23
|
-
#
|
|
24
|
-
#
|
|
25
|
+
# follower, lock TTL 30s. The campaign itself starts `initial_wait` seconds
|
|
26
|
+
# after `#start` — the spec fixes the renew/follower/TTL cadence but says
|
|
27
|
+
# nothing about when the first campaign runs, and holding it back keeps a
|
|
28
|
+
# booting process's opening Redis round trips on the job path. Opt out a
|
|
29
|
+
# process from campaigning entirely with `WURK_LEADER=false` (or its Sidekiq
|
|
30
|
+
# alias `SIDEKIQ_LEADER=false`), useful for hot-standby pools.
|
|
25
31
|
#
|
|
26
32
|
# Spec: docs/target/sidekiq-ent.md §6.
|
|
27
33
|
class Leader
|
|
@@ -30,6 +36,13 @@ module Wurk
|
|
|
30
36
|
DEFAULT_TTL = 30
|
|
31
37
|
DEFAULT_RENEW_INTERVAL = 15
|
|
32
38
|
DEFAULT_FOLLOWER_INTERVAL = 60
|
|
39
|
+
# Delay before the campaign's first `SET NX EX`, so a booting process
|
|
40
|
+
# spends its opening Redis round trips on fetching jobs rather than on a
|
|
41
|
+
# leadership CAS. Every leader-gated consumer (cron, both rollups,
|
|
42
|
+
# history) waits out a 30-60s interval before its own first tick, so
|
|
43
|
+
# nothing observes the gap. Tune with `config[:leader_initial_wait]`;
|
|
44
|
+
# 0 campaigns immediately.
|
|
45
|
+
DEFAULT_INITIAL_WAIT = 1
|
|
33
46
|
OPT_OUT_ENV = 'WURK_LEADER' # native opt-out env
|
|
34
47
|
SIDEKIQ_OPT_OUT_ENV = 'SIDEKIQ_LEADER' # Sidekiq Ent drop-in alias (§6.2/§7.2)
|
|
35
48
|
THREAD_NAME = 'wurk-leader'
|
|
@@ -46,12 +59,14 @@ module Wurk
|
|
|
46
59
|
def initialize(config: nil, key: DEFAULT_KEY, ttl: DEFAULT_TTL, # rubocop:disable Metrics/ParameterLists
|
|
47
60
|
renew_interval: DEFAULT_RENEW_INTERVAL,
|
|
48
61
|
follower_interval: DEFAULT_FOLLOWER_INTERVAL,
|
|
62
|
+
initial_wait: DEFAULT_INITIAL_WAIT,
|
|
49
63
|
pool: nil, owner: nil)
|
|
50
64
|
@config = config
|
|
51
65
|
@key = key
|
|
52
66
|
@ttl = ttl
|
|
53
67
|
@renew_interval = renew_interval
|
|
54
68
|
@follower_interval = follower_interval
|
|
69
|
+
@initial_wait = initial_wait.to_f
|
|
55
70
|
@pool = pool
|
|
56
71
|
@owner = owner || cluster_identity
|
|
57
72
|
@held = false
|
|
@@ -89,9 +104,19 @@ module Wurk
|
|
|
89
104
|
|
|
90
105
|
# CAS DEL — only drop the key if we still own it, otherwise a stale
|
|
91
106
|
# release would yank leadership from whichever follower took over.
|
|
107
|
+
# GET-then-DEL is not that CAS: our key can lapse between the two
|
|
108
|
+
# commands and a follower can win the election in the gap, whereupon
|
|
109
|
+
# the bare DEL deletes *its* lock and leaves the cluster leaderless
|
|
110
|
+
# (or, once the ex-leader re-campaigns, doubly led) for up to one
|
|
111
|
+
# renew interval — double cron fires, double rollups. The compare and
|
|
112
|
+
# the delete happen in one server-side step instead, via the script
|
|
113
|
+
# `Unique.release_if_owner` already shares.
|
|
114
|
+
#
|
|
115
|
+
# `idempotent:` — a replay after a lost reply finds the key either
|
|
116
|
+
# already gone or no longer ours, so it can only be a no-op.
|
|
92
117
|
def release
|
|
93
|
-
redis_call do |c|
|
|
94
|
-
|
|
118
|
+
redis_call(idempotent: true) do |c|
|
|
119
|
+
Wurk::Lua::Loader.eval_cached(c, :release_if_owner, keys: [@key], argv: [@owner])
|
|
95
120
|
end
|
|
96
121
|
@held = false
|
|
97
122
|
@token = nil
|
|
@@ -107,6 +132,11 @@ module Wurk
|
|
|
107
132
|
# follower, it polls every `follower_interval`. Caller must invoke
|
|
108
133
|
# `stop` for orderly shutdown — the thread also releases its lock on
|
|
109
134
|
# exit.
|
|
135
|
+
#
|
|
136
|
+
# The spawn happens *inside* the lock: with it outside, two callers
|
|
137
|
+
# racing into `start` both clear the nil check and both spawn, and the
|
|
138
|
+
# loser's loop is never recorded — an unjoinable second campaigner that
|
|
139
|
+
# outlives `stop` and keeps renewing the cluster lock.
|
|
110
140
|
def start
|
|
111
141
|
return nil if disabled?
|
|
112
142
|
|
|
@@ -114,17 +144,25 @@ module Wurk
|
|
|
114
144
|
return @thread if @thread
|
|
115
145
|
|
|
116
146
|
@done = false
|
|
147
|
+
@thread = spawn_loop_thread
|
|
117
148
|
end
|
|
118
|
-
@thread
|
|
149
|
+
@thread
|
|
119
150
|
end
|
|
120
151
|
|
|
152
|
+
# Bounded at TimerLoop::JOIN_TIMEOUT like every other periodic component: a
|
|
153
|
+
# tick parked on a wedged Redis (the campaign is a `SET NX EX`, so it waits
|
|
154
|
+
# out the socket timeouts) must not hold the whole process's teardown open —
|
|
155
|
+
# the swarm parent SIGKILLs a child that overruns its shutdown grace. The
|
|
156
|
+
# CAS release below runs either way; the loop's own trailing release makes
|
|
157
|
+
# a straggler's second attempt a no-op. A straggler also stays referenced,
|
|
158
|
+
# so a restart after a timed-out stop can't campaign twice in parallel.
|
|
121
159
|
def stop
|
|
122
|
-
@mutex.synchronize do
|
|
160
|
+
thread = @mutex.synchronize do
|
|
123
161
|
@done = true
|
|
124
162
|
@sleeper.signal
|
|
163
|
+
@thread
|
|
125
164
|
end
|
|
126
|
-
@thread
|
|
127
|
-
@thread = nil
|
|
165
|
+
@mutex.synchronize { @thread = nil } if thread.nil? || thread.join(TimerLoop::JOIN_TIMEOUT)
|
|
128
166
|
release
|
|
129
167
|
end
|
|
130
168
|
|
|
@@ -175,13 +213,13 @@ module Wurk
|
|
|
175
213
|
@config.handle_exception(e, event: :leader) if @config.respond_to?(:handle_exception)
|
|
176
214
|
end
|
|
177
215
|
|
|
178
|
-
def redis_call(&)
|
|
216
|
+
def redis_call(idempotent: false, &)
|
|
179
217
|
if @pool
|
|
180
|
-
|
|
218
|
+
PoolCheckout.with(@pool, idempotent, &)
|
|
181
219
|
elsif @config
|
|
182
|
-
@config.redis(&)
|
|
220
|
+
@config.redis(idempotent:, &)
|
|
183
221
|
else
|
|
184
|
-
Wurk.redis(&)
|
|
222
|
+
Wurk.redis(idempotent:, &)
|
|
185
223
|
end
|
|
186
224
|
end
|
|
187
225
|
|
|
@@ -205,6 +243,7 @@ module Wurk
|
|
|
205
243
|
end
|
|
206
244
|
|
|
207
245
|
def run_loop
|
|
246
|
+
wait_initial
|
|
208
247
|
until done?
|
|
209
248
|
tick_once
|
|
210
249
|
wait_next
|
|
@@ -212,6 +251,13 @@ module Wurk
|
|
|
212
251
|
release
|
|
213
252
|
end
|
|
214
253
|
|
|
254
|
+
# Same condvar as #wait_next, so a `stop` landing inside the pre-campaign
|
|
255
|
+
# delay wakes the loop immediately instead of holding shutdown open for a
|
|
256
|
+
# whole initial_wait.
|
|
257
|
+
def wait_initial
|
|
258
|
+
wait_for(@initial_wait) if @initial_wait.positive?
|
|
259
|
+
end
|
|
260
|
+
|
|
215
261
|
def tick_once
|
|
216
262
|
acquire
|
|
217
263
|
rescue StandardError => e
|
|
@@ -219,7 +265,10 @@ module Wurk
|
|
|
219
265
|
end
|
|
220
266
|
|
|
221
267
|
def wait_next
|
|
222
|
-
|
|
268
|
+
wait_for(@held ? @renew_interval : @follower_interval)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def wait_for(interval)
|
|
223
272
|
@mutex.synchronize { @sleeper.wait(@mutex, interval) unless @done }
|
|
224
273
|
end
|
|
225
274
|
|
data/lib/wurk/limiter/base.rb
CHANGED
|
@@ -104,17 +104,15 @@ module Wurk
|
|
|
104
104
|
end
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Metadata HSET + EXPIRE + `lmtr-list` SADD in a single round trip. The
|
|
108
|
+
# script also gates the SADD on the metadata being newly written, so a
|
|
109
|
+
# per-job construction (`stripe-#{user_id}`) stops rewriting a set that
|
|
110
|
+
# grows one member per name — see limiter_register.lua for why that gate
|
|
111
|
+
# is only safe while it is atomic with the HSET.
|
|
107
112
|
def register!
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
'HSET', meta_key,
|
|
112
|
-
'type', type.to_s,
|
|
113
|
-
'options', JSON.dump(serializable_options),
|
|
114
|
-
'fingerprint', fingerprint
|
|
115
|
-
)
|
|
116
|
-
c.call('EXPIRE', meta_key, @options[:ttl])
|
|
117
|
-
end
|
|
113
|
+
lua(:limiter_register,
|
|
114
|
+
keys: [meta_key, LIST_KEY],
|
|
115
|
+
argv: [@name, type.to_s, JSON.dump(serializable_options), fingerprint, ttl])
|
|
118
116
|
end
|
|
119
117
|
|
|
120
118
|
def ttl
|
data/lib/wurk/limiter/bucket.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Wurk
|
|
|
6
6
|
module Limiter
|
|
7
7
|
# Atomic slot acquisition in a ZSET. Score = expiry epoch; the acquire
|
|
8
8
|
# script first evicts expired slots (bumping the `reclaimed` metric)
|
|
9
|
-
# then ZADDs if there's headroom.
|
|
9
|
+
# then ZADDs if there's headroom (bumping the `held` metric).
|
|
10
10
|
#
|
|
11
11
|
# On exhaustion: spin loop with backoff. The spec says "blocks via
|
|
12
12
|
# Redis stream XREAD" — that's a perf optimization; the visible
|
|
@@ -38,33 +38,15 @@ module Wurk
|
|
|
38
38
|
raise ArgumentError, 'block required' unless block
|
|
39
39
|
|
|
40
40
|
started = monotime
|
|
41
|
-
deadline = started + @options[:wait_timeout]
|
|
42
41
|
slot = random_id
|
|
43
|
-
acquired_at =
|
|
44
|
-
|
|
45
|
-
result = acquire(slot)
|
|
46
|
-
if result[0].to_i == 1
|
|
47
|
-
acquired_at = monotime
|
|
48
|
-
break
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
return if @options[:policy] == :ignore
|
|
52
|
-
|
|
53
|
-
remaining = deadline - monotime
|
|
54
|
-
if remaining <= 0
|
|
55
|
-
bump_counter('overages')
|
|
56
|
-
raise OverLimit, self
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
sleep [remaining, WAIT_SLEEP].min
|
|
60
|
-
end
|
|
42
|
+
acquired_at = wait_for_slot(slot, started + @options[:wait_timeout])
|
|
43
|
+
return unless acquired_at
|
|
61
44
|
|
|
62
45
|
begin
|
|
63
46
|
incr_immediate_or_waited(acquired_at - started)
|
|
64
47
|
block.call
|
|
65
48
|
ensure
|
|
66
|
-
|
|
67
|
-
bump_counter('held_time', (monotime - acquired_at).to_i) if acquired_at
|
|
49
|
+
record_release(slot, acquired_at)
|
|
68
50
|
end
|
|
69
51
|
end
|
|
70
52
|
|
|
@@ -98,6 +80,29 @@ module Wurk
|
|
|
98
80
|
"lmtr-stats:#{@name}"
|
|
99
81
|
end
|
|
100
82
|
|
|
83
|
+
# A ZREM that removes nothing means an acquirer already evicted our slot:
|
|
84
|
+
# we outran `lock_timeout`, which is what `overages` counts. Exhausting
|
|
85
|
+
# `wait_timeout` is a different event — it raises OverLimit and the server
|
|
86
|
+
# middleware counts it in the job's `overrated` field.
|
|
87
|
+
def record_release(slot, acquired_at)
|
|
88
|
+
bump_counter('overages') if release(slot).to_i.zero?
|
|
89
|
+
bump_counter('held_time', (monotime - acquired_at).to_i)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Spins until a slot frees up. Returns the monotonic acquire time, nil
|
|
93
|
+
# when `policy: :ignore` gives up; raises OverLimit past `wait_timeout`.
|
|
94
|
+
def wait_for_slot(slot, deadline)
|
|
95
|
+
loop do
|
|
96
|
+
return monotime if acquire(slot)[0].to_i == 1
|
|
97
|
+
return if @options[:policy] == :ignore
|
|
98
|
+
|
|
99
|
+
remaining = deadline - monotime
|
|
100
|
+
raise OverLimit, self if remaining <= 0
|
|
101
|
+
|
|
102
|
+
sleep [remaining, WAIT_SLEEP].min
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
101
106
|
def acquire(slot)
|
|
102
107
|
lua(:limiter_concurrent_acquire,
|
|
103
108
|
keys: [state_key, stats_key],
|
data/lib/wurk/limiter/window.rb
CHANGED
|
@@ -19,18 +19,15 @@ module Wurk
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def size
|
|
22
|
-
|
|
23
|
-
Wurk::Limiter.redis do |c|
|
|
24
|
-
c.call('ZREMRANGEBYSCORE', state_key, '-inf', "(#{cutoff}")
|
|
25
|
-
c.call('ZCARD', state_key).to_i
|
|
26
|
-
end
|
|
22
|
+
window_state.first
|
|
27
23
|
end
|
|
28
24
|
|
|
29
25
|
# used = entries still inside the window; limit = count; reset_at =
|
|
30
26
|
# when the oldest entry slides out (freeing a slot), or nil when
|
|
31
27
|
# idle (#16).
|
|
32
28
|
def status
|
|
33
|
-
|
|
29
|
+
used, oldest = window_state
|
|
30
|
+
build_status(used: used, limit: @options[:count], reset_at: oldest && (oldest + interval_seconds))
|
|
34
31
|
end
|
|
35
32
|
|
|
36
33
|
def within_limit(used: 1, &block)
|
|
@@ -60,11 +57,16 @@ module Wurk
|
|
|
60
57
|
"lmtr-w:#{@name}"
|
|
61
58
|
end
|
|
62
59
|
|
|
63
|
-
#
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
60
|
+
# Count + oldest in-window score, both scoped by the Redis clock, in one
|
|
61
|
+
# read-only round trip. Reads never trim (the acquire script owns that):
|
|
62
|
+
# a dashboard host whose clock ran ahead used to evict live entries just
|
|
63
|
+
# by rendering `status`, freeing the window for a second full charge.
|
|
64
|
+
# Oldest timestamp + interval = the moment it leaves the window; -1 =
|
|
65
|
+
# nothing in window.
|
|
66
|
+
def window_state
|
|
67
|
+
count, oldest = lua(:limiter_window_status, keys: [state_key], argv: [interval_seconds])
|
|
68
|
+
score = oldest.to_f
|
|
69
|
+
[count.to_i, score.negative? ? nil : score]
|
|
68
70
|
end
|
|
69
71
|
|
|
70
72
|
def interval_seconds
|
data/lib/wurk/limiter.rb
CHANGED
|
@@ -4,6 +4,7 @@ require 'json'
|
|
|
4
4
|
require 'digest'
|
|
5
5
|
require 'securerandom'
|
|
6
6
|
require_relative 'lua'
|
|
7
|
+
require_relative 'pool_checkout'
|
|
7
8
|
|
|
8
9
|
module Wurk
|
|
9
10
|
# Sidekiq Enterprise rate limiters: concurrent, bucket, window, leaky,
|
|
@@ -24,7 +25,10 @@ module Wurk
|
|
|
24
25
|
# Layout (one file per type under `lib/wurk/limiter/`):
|
|
25
26
|
# * `Limiter::Base` owns the metadata write (lmtr:{name}) + the global
|
|
26
27
|
# `lmtr-list` registration so the Web UI can list every limiter, and
|
|
27
|
-
# the uniform `status` shape.
|
|
28
|
+
# the uniform `status` shape. Membership is added once per metadata
|
|
29
|
+
# write and swept back out by `Web::Enterprise::Limits.list` once the
|
|
30
|
+
# metadata expires — the two halves of keeping `lmtr-list` bounded
|
|
31
|
+
# under interpolated names.
|
|
28
32
|
# * Per-type subclasses (Concurrent / Bucket / Window / Leaky / Points)
|
|
29
33
|
# own their acquire/wait loop. Each delegates the atomic step to a
|
|
30
34
|
# Lua script in `lib/wurk/lua/limiter_*.lua`.
|
|
@@ -146,9 +150,8 @@ module Wurk
|
|
|
146
150
|
# Redis access: caller-supplied pool (Limiter.configure.redis = …) wins,
|
|
147
151
|
# else fall back to the default Wurk pool. This is the same hierarchy
|
|
148
152
|
# Sidekiq Ent documents — dedicated rate-limiter pool is opt-in.
|
|
149
|
-
def redis(&)
|
|
150
|
-
|
|
151
|
-
pool.with(&)
|
|
153
|
+
def redis(idempotent: false, &)
|
|
154
|
+
PoolCheckout.with(config.pool || Wurk.redis_pool, idempotent, &)
|
|
152
155
|
end
|
|
153
156
|
|
|
154
157
|
# `ZRANGE key 0 0 WITHSCORES` yields a single [member, score] pair, but the
|
data/lib/wurk/logger.rb
CHANGED
data/lib/wurk/lua/loader.rb
CHANGED
|
@@ -23,9 +23,15 @@ module Wurk
|
|
|
23
23
|
# connection errors are the pool wrapper's job (Wurk::RedisPool#with);
|
|
24
24
|
# this only ships the loads.
|
|
25
25
|
def script_load_all(redis)
|
|
26
|
-
redis.pipelined
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
redis.pipelined { |pipe| queue_script_loads(pipe) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The same loads, queued onto a pipeline the caller already owns, for a
|
|
30
|
+
# caller with other work to batch with them: ChildBoot pairs them with
|
|
31
|
+
# its liveness PING so a child's whole Redis validation costs one round
|
|
32
|
+
# trip instead of two, on the boot-critical path.
|
|
33
|
+
def queue_script_loads(pipe)
|
|
34
|
+
SCRIPTS.each_value { |src| pipe.call('SCRIPT', 'LOAD', src) }
|
|
29
35
|
end
|
|
30
36
|
|
|
31
37
|
# @param redis [RedisClient] a single connection (not a pool)
|
data/lib/wurk/lua.rb
CHANGED
|
@@ -131,8 +131,16 @@ module Wurk
|
|
|
131
131
|
# success after the dead job is manually retried to success). The
|
|
132
132
|
# `b-<bid>-death` notify dedup key is untouched, so `:death` cannot
|
|
133
133
|
# re-fire.
|
|
134
|
+
#
|
|
135
|
+
# The two EXPIRE NX calls are what keep the batch bounded: `b-<bid>-jids` is
|
|
136
|
+
# born here, and only `Batch#ensure_first_flush!` ever stamped a TTL — on the
|
|
137
|
+
# hash alone — so an abandoned or invalidated batch leaked its live-jid set
|
|
138
|
+
# forever. A late re-push (retry, scheduled promotion) can also resurrect an
|
|
139
|
+
# already-expired `b-<bid>` through the HINCRBYs above, again TTL-less. NX
|
|
140
|
+
# stamps only keys that currently have no TTL, so a running batch's clock and
|
|
141
|
+
# the shorter post-success `linger` window (Callbacks#apply_linger) both win.
|
|
134
142
|
# KEYS = [b-<bid>, b-<bid>-jids, queue_list, queues_set, b-<bid>-died, dead-batches]
|
|
135
|
-
# ARGV = [queue_name, jid, job_json, bid]
|
|
143
|
+
# ARGV = [queue_name, jid, job_json, bid, expiry_seconds]
|
|
136
144
|
# Returns 1.
|
|
137
145
|
BATCH_PUSH = <<~LUA
|
|
138
146
|
if redis.call("srem", KEYS[5], ARGV[2]) == 1 then
|
|
@@ -147,6 +155,8 @@ module Wurk
|
|
|
147
155
|
redis.call("hincrby", KEYS[1], "pending", 1)
|
|
148
156
|
end
|
|
149
157
|
end
|
|
158
|
+
redis.call("expire", KEYS[1], ARGV[5], "NX")
|
|
159
|
+
redis.call("expire", KEYS[2], ARGV[5], "NX")
|
|
150
160
|
redis.call("sadd", KEYS[4], ARGV[1])
|
|
151
161
|
redis.call("lpush", KEYS[3], ARGV[3])
|
|
152
162
|
return 1
|
|
@@ -165,14 +175,19 @@ module Wurk
|
|
|
165
175
|
# it, the re-push routes through BATCH_PUSH, whose guard finds the jid already
|
|
166
176
|
# live (SADD == 0) → pure LPUSH, no recount. So registration happens exactly
|
|
167
177
|
# once, here, at enqueue.
|
|
178
|
+
#
|
|
179
|
+
# The other path that can create `b-<bid>-jids`, so it carries the same NX
|
|
180
|
+
# expiry stamp as BATCH_PUSH.
|
|
168
181
|
# KEYS = [schedule, b-<bid>, b-<bid>-jids]
|
|
169
|
-
# ARGV = [at_score, job_json, jid]
|
|
182
|
+
# ARGV = [at_score, job_json, jid, expiry_seconds]
|
|
170
183
|
# Returns 1.
|
|
171
184
|
BATCH_SCHEDULE = <<~LUA
|
|
172
185
|
if redis.call("sadd", KEYS[3], ARGV[3]) == 1 then
|
|
173
186
|
redis.call("hincrby", KEYS[2], "total", 1)
|
|
174
187
|
redis.call("hincrby", KEYS[2], "pending", 1)
|
|
175
188
|
end
|
|
189
|
+
redis.call("expire", KEYS[2], ARGV[4], "NX")
|
|
190
|
+
redis.call("expire", KEYS[3], ARGV[4], "NX")
|
|
176
191
|
redis.call("zadd", KEYS[1], ARGV[1], ARGV[2])
|
|
177
192
|
return 1
|
|
178
193
|
LUA
|
|
@@ -208,13 +223,18 @@ module Wurk
|
|
|
208
223
|
# currently in a failing/retrying state. Re-failures of the same jid are
|
|
209
224
|
# idempotent. Cleared by BATCH_ACK_SUCCESS (retry passed) or
|
|
210
225
|
# BATCH_ACK_COMPLETE (job died). Spec §2.5, §2.8.
|
|
226
|
+
#
|
|
227
|
+
# `b-<bid>-failed` is born here, so it carries the same NX expiry stamp as
|
|
228
|
+
# BATCH_PUSH.
|
|
211
229
|
# KEYS = [b-<bid>, b-<bid>-failed]
|
|
212
|
-
# ARGV = [jid]
|
|
230
|
+
# ARGV = [jid, expiry_seconds]
|
|
213
231
|
# Returns 1.
|
|
214
232
|
BATCH_ACK_FAILED = <<~LUA
|
|
215
233
|
if redis.call("sadd", KEYS[2], ARGV[1]) == 1 then
|
|
216
234
|
redis.call("hincrby", KEYS[1], "failures", 1)
|
|
217
235
|
end
|
|
236
|
+
redis.call("expire", KEYS[1], ARGV[2], "NX")
|
|
237
|
+
redis.call("expire", KEYS[2], ARGV[2], "NX")
|
|
218
238
|
return 1
|
|
219
239
|
LUA
|
|
220
240
|
|
|
@@ -224,8 +244,11 @@ module Wurk
|
|
|
224
244
|
# live jids so the batch can fire `:complete` even with terminally failed
|
|
225
245
|
# jobs. `b-<bid>-failed` holds only currently-retrying jids; `b-<bid>-died`
|
|
226
246
|
# holds terminally-dead ones (spec §2.8 — the two sets are distinct).
|
|
247
|
+
#
|
|
248
|
+
# `b-<bid>-died` is born here, so it carries the same NX expiry stamp as
|
|
249
|
+
# BATCH_PUSH.
|
|
227
250
|
# KEYS = [b-<bid>, b-<bid>-jids, b-<bid>-died, b-<bid>-failed]
|
|
228
|
-
# ARGV = [jid]
|
|
251
|
+
# ARGV = [jid, expiry_seconds]
|
|
229
252
|
# Returns [live_jids_remaining, died_count, first_death]. `first_death`
|
|
230
253
|
# is 1 the first time *any* jid is SADDed into the died set, 0 thereafter
|
|
231
254
|
# — caller uses it to fire `:death` exactly once per batch.
|
|
@@ -236,6 +259,8 @@ module Wurk
|
|
|
236
259
|
redis.call("hincrby", KEYS[1], "failures", -1)
|
|
237
260
|
end
|
|
238
261
|
local died_added = redis.call("sadd", KEYS[3], ARGV[1])
|
|
262
|
+
redis.call("expire", KEYS[1], ARGV[2], "NX")
|
|
263
|
+
redis.call("expire", KEYS[3], ARGV[2], "NX")
|
|
239
264
|
local first_death = 0
|
|
240
265
|
if was_pre_existing_death == 0 and died_added == 1 then
|
|
241
266
|
first_death = 1
|
|
@@ -262,10 +287,21 @@ module Wurk
|
|
|
262
287
|
# same reopened batch cannot lose each other's writes. Refuses to write
|
|
263
288
|
# when the batch hash is gone — resurrecting a bare hash would create a
|
|
264
289
|
# batch that can never fire anything.
|
|
290
|
+
#
|
|
291
|
+
# Appends are deduped and capped. Reopening the same batch inside every
|
|
292
|
+
# job and re-registering its callback is a common shape, and each append
|
|
293
|
+
# pays a decode + encode of the whole array: unbounded that is O(N^2) Lua
|
|
294
|
+
# work on the way in and N identical callback jobs at fire time. An
|
|
295
|
+
# identical triple is therefore a no-op — the registration is already
|
|
296
|
+
# satisfied by the entry that is there — and past the cap the append is
|
|
297
|
+
# refused rather than allowed to grow the hash field without limit.
|
|
298
|
+
# Both sides of the comparison are normalised through cjson so an entry
|
|
299
|
+
# written by Ruby's `to_json` at first flush matches one appended here.
|
|
265
300
|
# KEYS = [b-<bid>]
|
|
266
|
-
# ARGV = [callback triple JSON, event name]
|
|
267
|
-
# Returns -1 when the batch hash does not exist
|
|
268
|
-
# fired flag ("1", or nil when
|
|
301
|
+
# ARGV = [callback triple JSON, event name, max callbacks]
|
|
302
|
+
# Returns -1 when the batch hash does not exist and -2 when the cap
|
|
303
|
+
# refused the append; otherwise the event's fired flag ("1", or nil when
|
|
304
|
+
# it has not fired yet).
|
|
269
305
|
BATCH_APPEND_CALLBACK = <<~LUA
|
|
270
306
|
if redis.call("exists", KEYS[1]) == 0 then
|
|
271
307
|
return -1
|
|
@@ -277,19 +313,30 @@ module Wurk
|
|
|
277
313
|
else
|
|
278
314
|
list = {}
|
|
279
315
|
end
|
|
280
|
-
|
|
316
|
+
local entry = cjson.decode(ARGV[1])
|
|
317
|
+
local encoded = cjson.encode(entry)
|
|
318
|
+
for i = 1, #list do
|
|
319
|
+
if cjson.encode(list[i]) == encoded then
|
|
320
|
+
return redis.call("hget", KEYS[1], ARGV[2])
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
if #list >= tonumber(ARGV[3]) then
|
|
324
|
+
return -2
|
|
325
|
+
end
|
|
326
|
+
list[#list + 1] = entry
|
|
281
327
|
redis.call("hset", KEYS[1], "callbacks", cjson.encode(list))
|
|
282
328
|
return redis.call("hget", KEYS[1], ARGV[2])
|
|
283
329
|
LUA
|
|
284
330
|
|
|
285
331
|
# Ent Unique (§3): atomic compare-and-delete of a lock key. Replaces the
|
|
286
332
|
# two-command GET-then-DEL — between those calls the key can expire and a
|
|
287
|
-
# fresh
|
|
333
|
+
# fresh owner can grab it, and the bare DEL would then drop the new
|
|
288
334
|
# owner's lock. Shared by `Unique::ServerMiddleware#release` (normal
|
|
289
|
-
# success/start release)
|
|
290
|
-
# release)
|
|
291
|
-
#
|
|
292
|
-
#
|
|
335
|
+
# success/start release), `Unique::DEATH_HANDLER` (automatic-death
|
|
336
|
+
# release) and `Leader#release` (stepping down from the cluster lock)
|
|
337
|
+
# so those paths cannot drift.
|
|
338
|
+
# KEYS = [the lock key — unique:<sha256> | dear-leader]
|
|
339
|
+
# ARGV = [the owner that must still hold it — jid | <host>:<pid>:<nonce>]
|
|
293
340
|
# Returns 1 when the key was deleted, 0 otherwise.
|
|
294
341
|
RELEASE_IF_OWNER = <<~LUA
|
|
295
342
|
if redis.call("get", KEYS[1]) == ARGV[1] then
|
|
@@ -334,6 +381,41 @@ module Wurk
|
|
|
334
381
|
return removed
|
|
335
382
|
LUA
|
|
336
383
|
|
|
384
|
+
# Ent Periodic (§2): compare-and-swap the fire marks of `loops:<lid>`.
|
|
385
|
+
# The cron poller's leader gate is a cached read (`Component#leader?`,
|
|
386
|
+
# LEADER_CACHE_TTL_MS), so for a few seconds after a handover two
|
|
387
|
+
# processes both believe they lead and both reach the same due loop —
|
|
388
|
+
# HMGET → decide → enqueue → HSET then fires it twice. Claiming the slot
|
|
389
|
+
# atomically is what makes a tick fire exactly once; the loser gets 0 and
|
|
390
|
+
# enqueues nothing. Values written are the same decimal-epoch strings the
|
|
391
|
+
# Ruby path wrote, byte for byte — the dashboard reads these fields.
|
|
392
|
+
#
|
|
393
|
+
# `nf` present: the caller must still be looking at the mark it read
|
|
394
|
+
# (byte compare, so no parse can drift the token).
|
|
395
|
+
# `nf` absent: the caller derived the slot from `lf`, so refuse once `lf`
|
|
396
|
+
# has reached it — that is the exhausted-schedule case, where the winner
|
|
397
|
+
# cleared `nf` and a loser would otherwise re-fire the very same slot.
|
|
398
|
+
# KEYS = [loops:<lid>]
|
|
399
|
+
# ARGV = [expected `nf` (or the derived slot), new `lf`, new `nf` ('' → HDEL)]
|
|
400
|
+
# Returns 1 when this caller claimed the slot, 0 otherwise.
|
|
401
|
+
CRON_CLAIM_FIRE = <<~LUA
|
|
402
|
+
local key = KEYS[1]
|
|
403
|
+
local cur = redis.call("hget", key, "nf")
|
|
404
|
+
if cur and cur ~= "" then
|
|
405
|
+
if cur ~= ARGV[1] then return 0 end
|
|
406
|
+
else
|
|
407
|
+
local lf = tonumber(redis.call("hget", key, "lf") or "")
|
|
408
|
+
if lf and lf >= tonumber(ARGV[1]) then return 0 end
|
|
409
|
+
end
|
|
410
|
+
redis.call("hset", key, "lf", ARGV[2])
|
|
411
|
+
if ARGV[3] == "" then
|
|
412
|
+
redis.call("hdel", key, "nf")
|
|
413
|
+
else
|
|
414
|
+
redis.call("hset", key, "nf", ARGV[3])
|
|
415
|
+
end
|
|
416
|
+
return 1
|
|
417
|
+
LUA
|
|
418
|
+
|
|
337
419
|
# Limiter scripts live in `lib/wurk/lua/limiter_*.lua` — one file per
|
|
338
420
|
# type. Loaded at boot, the file's basename (minus `.lua`) becomes the
|
|
339
421
|
# SCRIPTS key as a symbol. Keeping them as separate files makes diffing
|
|
@@ -358,7 +440,8 @@ module Wurk
|
|
|
358
440
|
batch_append_callback: BATCH_APPEND_CALLBACK,
|
|
359
441
|
fast_delete_job: FAST_DELETE_JOB,
|
|
360
442
|
fast_delete_by_class: FAST_DELETE_BY_CLASS,
|
|
361
|
-
release_if_owner: RELEASE_IF_OWNER
|
|
443
|
+
release_if_owner: RELEASE_IF_OWNER,
|
|
444
|
+
cron_claim_fire: CRON_CLAIM_FIRE
|
|
362
445
|
}.merge(FILE_SCRIPTS).freeze
|
|
363
446
|
|
|
364
447
|
# SHA1 of each script source — matches what `SCRIPT LOAD` returns.
|
data/lib/wurk/manager.rb
CHANGED
|
@@ -24,12 +24,21 @@ module Wurk
|
|
|
24
24
|
|
|
25
25
|
attr_reader :workers, :capsule
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
# `shutdown:` is the owning Launcher's process-wide shutdown request,
|
|
28
|
+
# invoked when concurrency can no longer be held (see #processor_result).
|
|
29
|
+
# Injected rather than reached for: only the entry point knows how this
|
|
30
|
+
# process exits — a swarm child / standalone CLI re-delivers TERM to itself
|
|
31
|
+
# so the installed trap drives the normal drain, while embedded mode owns
|
|
32
|
+
# no traps and must stop the launcher in place. A Manager built without an
|
|
33
|
+
# owner (Sidekiq's `Manager.new(capsule)` arity, kept for the alias) has no
|
|
34
|
+
# route to take, so it only reports.
|
|
35
|
+
def initialize(capsule, shutdown: nil)
|
|
28
36
|
@config = @capsule = capsule
|
|
29
37
|
@count = capsule.concurrency
|
|
30
38
|
raise ArgumentError, "Concurrency of #{@count} is not supported" if @count < 1
|
|
31
39
|
|
|
32
40
|
@done = false
|
|
41
|
+
@shutdown = shutdown
|
|
33
42
|
@workers = Set.new
|
|
34
43
|
@plock = ::Mutex.new
|
|
35
44
|
@count.times do
|
|
@@ -86,8 +95,9 @@ module Wurk
|
|
|
86
95
|
# cleanly or via raised exception. Removes the dead processor from the
|
|
87
96
|
# pool and (unless we're already stopping) spawns a replacement so the
|
|
88
97
|
# capsule's concurrency stays constant. If the replacement itself can't be
|
|
89
|
-
# spawned,
|
|
90
|
-
#
|
|
98
|
+
# spawned, ask the owner to shut this process down (the swarm respawns it at
|
|
99
|
+
# full concurrency) rather than silently dropping a worker for the life of
|
|
100
|
+
# the process. Snapshot under @plock; start the replacement — a
|
|
91
101
|
# side effect — outside the lock.
|
|
92
102
|
def processor_result(processor, _reason = nil)
|
|
93
103
|
replacement = @plock.synchronize do
|
|
@@ -102,10 +112,15 @@ module Wurk
|
|
|
102
112
|
rescue StandardError => e
|
|
103
113
|
# Replacement spawn failed (e.g. ThreadError at the OS thread limit).
|
|
104
114
|
# Silently running one Processor short for the life of the process is
|
|
105
|
-
# invisible degradation
|
|
106
|
-
#
|
|
115
|
+
# invisible degradation, so take the process down (the swarm respawns it
|
|
116
|
+
# at full concurrency) — but through the owner's TERM path, never
|
|
117
|
+
# `Thread.main.raise`: that raise unwound straight past Manager#stop, so
|
|
118
|
+
# the in-flight UnitsOfWork were never bulk_requeued and each one waited
|
|
119
|
+
# out a full reaper interval, and in embedded mode it killed the host's
|
|
120
|
+
# main thread instead of just the worker. Non-blocking by contract — we
|
|
121
|
+
# are on the dying Processor's own thread, which the drain will kill.
|
|
107
122
|
@capsule.config.handle_exception(e, { context: 'Manager could not replace a dead Processor' })
|
|
108
|
-
|
|
123
|
+
@shutdown&.call
|
|
109
124
|
end
|
|
110
125
|
|
|
111
126
|
# Reached when the deadline expired with workers still busy. Atomically
|
|
@@ -146,14 +161,15 @@ module Wurk
|
|
|
146
161
|
@plock.synchronize { @workers.dup }
|
|
147
162
|
end
|
|
148
163
|
|
|
164
|
+
# Drained means "no processor thread is still running", not "the Set is
|
|
165
|
+
# empty": a processor removes itself from @workers from inside its own
|
|
166
|
+
# thread, so the two agree — except for a processor that was never started
|
|
167
|
+
# (a launcher that raised mid-boot and rolled back). That one holds no
|
|
168
|
+
# thread and nothing will ever remove it, so an emptiness test would make
|
|
169
|
+
# #stop poll out the entire shutdown deadline waiting on a worker that
|
|
170
|
+
# never ran.
|
|
149
171
|
def workers_empty?
|
|
150
|
-
@plock.synchronize { @workers.
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
# Seam over Thread.main: lets processor_result's replacement-failure crash
|
|
154
|
-
# be unit-tested against a controlled thread instead of the live runner.
|
|
155
|
-
def main_thread
|
|
156
|
-
Thread.main
|
|
172
|
+
@plock.synchronize { @workers.none? { |w| w.thread&.alive? } }
|
|
157
173
|
end
|
|
158
174
|
|
|
159
175
|
# Polls `condblock` until it returns true or the monotonic deadline
|