wurk 1.3.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -1
  3. data/app/controllers/concerns/wurk/stream_concurrency_guard.rb +22 -5
  4. data/lib/wurk/batch/callbacks.rb +82 -12
  5. data/lib/wurk/batch/death_handler.rb +7 -4
  6. data/lib/wurk/batch/server_middleware.rb +1 -1
  7. data/lib/wurk/batch.rb +121 -15
  8. data/lib/wurk/capsule.rb +45 -16
  9. data/lib/wurk/cli.rb +48 -14
  10. data/lib/wurk/client/buffered.rb +200 -43
  11. data/lib/wurk/client.rb +181 -38
  12. data/lib/wurk/compat.rb +1 -1
  13. data/lib/wurk/component.rb +36 -4
  14. data/lib/wurk/configuration.rb +47 -7
  15. data/lib/wurk/context.rb +1 -1
  16. data/lib/wurk/cron.rb +94 -37
  17. data/lib/wurk/deploy.rb +5 -3
  18. data/lib/wurk/embedded.rb +13 -0
  19. data/lib/wurk/engine.rb +41 -1
  20. data/lib/wurk/errors.rb +15 -0
  21. data/lib/wurk/fetcher/reaper.rb +125 -58
  22. data/lib/wurk/fetcher/reliable.rb +366 -51
  23. data/lib/wurk/fetcher.rb +6 -0
  24. data/lib/wurk/heartbeat.rb +24 -12
  25. data/lib/wurk/history.rb +13 -1
  26. data/lib/wurk/job_logger.rb +16 -7
  27. data/lib/wurk/job_set.rb +3 -2
  28. data/lib/wurk/job_util.rb +44 -24
  29. data/lib/wurk/launcher.rb +230 -119
  30. data/lib/wurk/leader.rb +63 -14
  31. data/lib/wurk/limiter/base.rb +8 -10
  32. data/lib/wurk/limiter/bucket.rb +1 -1
  33. data/lib/wurk/limiter/concurrent.rb +27 -22
  34. data/lib/wurk/limiter/window.rb +13 -11
  35. data/lib/wurk/limiter.rb +7 -4
  36. data/lib/wurk/logger.rb +1 -1
  37. data/lib/wurk/lua/loader.rb +9 -3
  38. data/lib/wurk/lua.rb +97 -14
  39. data/lib/wurk/manager.rb +29 -13
  40. data/lib/wurk/metrics/accumulator.rb +95 -0
  41. data/lib/wurk/metrics/flusher.rb +70 -0
  42. data/lib/wurk/metrics/history.rb +102 -32
  43. data/lib/wurk/metrics/queue_rollup.rb +13 -1
  44. data/lib/wurk/metrics/rollup.rb +13 -1
  45. data/lib/wurk/metrics/statsd.rb +32 -18
  46. data/lib/wurk/middleware/chain.rb +31 -14
  47. data/lib/wurk/middleware/interrupt_handler.rb +7 -6
  48. data/lib/wurk/middleware/poison_pill.rb +93 -31
  49. data/lib/wurk/middleware.rb +2 -2
  50. data/lib/wurk/pool_checkout.rb +39 -0
  51. data/lib/wurk/process_set.rb +10 -5
  52. data/lib/wurk/processor.rb +83 -9
  53. data/lib/wurk/profiler.rb +9 -4
  54. data/lib/wurk/queue.rb +18 -7
  55. data/lib/wurk/rails_boot.rb +38 -7
  56. data/lib/wurk/redis_client_adapter.rb +49 -5
  57. data/lib/wurk/redis_pool.rb +71 -25
  58. data/lib/wurk/scheduled.rb +30 -2
  59. data/lib/wurk/shutdown_gate.rb +79 -0
  60. data/lib/wurk/stats.rb +19 -10
  61. data/lib/wurk/swarm/child_boot.rb +36 -4
  62. data/lib/wurk/swarm.rb +258 -43
  63. data/lib/wurk/timer_loop.rb +14 -0
  64. data/lib/wurk/version.rb +1 -1
  65. data/lib/wurk/web/config.rb +11 -7
  66. data/lib/wurk/web/enterprise.rb +58 -6
  67. data/lib/wurk/web/extension.rb +1 -1
  68. data/lib/wurk/web/search.rb +5 -3
  69. data/lib/wurk.rb +12 -10
  70. data/vendor/assets/dashboard/assets/{ArgsValue-D74zX0MI.js → ArgsValue-CcR2ya6e.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{BatchDetail-YRymNsrB.js → BatchDetail-CUXJUQ3Q.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Batches-HY4hHdQU.js → Batches-Cxan6Ngw.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Busy-FCEN1Bpx.js → Busy-DC5EGM0g.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{Cron-DO3J2zcp.js → Cron-Dlt8tXJA.js} +1 -1
  75. data/vendor/assets/dashboard/assets/Dashboard-DNLu_WCg.js +1 -0
  76. data/vendor/assets/dashboard/assets/{Dead-Bi4GGk9a.js → Dead-dZ7VGlKS.js} +1 -1
  77. data/vendor/assets/dashboard/assets/Extension-DaFpEIJf.js +1 -0
  78. data/vendor/assets/dashboard/assets/{FilterBox-IJkHYpdm.js → FilterBox-CO3aYWIq.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{JobDetailModal-DS1ypyoc.js → JobDetailModal-DSWbT6G0.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Limiters-Nz7UbNeJ.js → Limiters-Cb4PKXNR.js} +1 -1
  81. data/vendor/assets/dashboard/assets/Metrics-CCGzgCsT.js +1 -0
  82. data/vendor/assets/dashboard/assets/Modal-B86q6ruL.js +1 -0
  83. data/vendor/assets/dashboard/assets/{PageHeader-C44KNMGm.js → PageHeader-fPrCcp_-.js} +1 -1
  84. data/vendor/assets/dashboard/assets/{Profiles-xEVTyS2N.js → Profiles-BnS82nR_.js} +1 -1
  85. data/vendor/assets/dashboard/assets/{Queues-D9PH_THs.js → Queues-CIyPevOy.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Retries-CAKzDgYG.js → Retries-DopwXkXl.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Scheduled-DTYw1X8S.js → Scheduled-1-Z7i1zE.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{Search-c4vFxDG_.js → Search-ByA6eTma.js} +1 -1
  89. data/vendor/assets/dashboard/assets/{Skeleton-DzR7XNxz.js → Skeleton-bC7HfQ9r.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{charts-BVHHGof7.js → charts-CLLzJ7vK.js} +1 -1
  91. data/vendor/assets/dashboard/assets/index-B1N8hQUh.js +141 -0
  92. data/vendor/assets/dashboard/assets/index-BdiUEDXX.css +1 -0
  93. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-B_FsMah6.js → useResetPageOnEmpty-DpBjkf6_.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{useSort-BeYbztkN.js → useSort-DvpwuNQE.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +32 -27
  98. data/vendor/assets/dashboard/assets/Dashboard-B9rOrkzk.js +0 -1
  99. data/vendor/assets/dashboard/assets/Extension-BSv8ddW_.js +0 -1
  100. data/vendor/assets/dashboard/assets/Metrics-BBTDxcaE.js +0 -1
  101. data/vendor/assets/dashboard/assets/Modal-Crrsu64-.js +0 -1
  102. data/vendor/assets/dashboard/assets/index-BxjdeuOa.css +0 -1
  103. data/vendor/assets/dashboard/assets/index-DQu7WY9y.js +0 -141
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. Opt out a process from campaigning entirely
23
- # with `WURK_LEADER=false` (or its Sidekiq alias `SIDEKIQ_LEADER=false`),
24
- # useful for hot-standby pools.
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
- c.call('DEL', @key) if c.call('GET', @key) == @owner
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 = spawn_loop_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&.join
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
- @pool.with(&)
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
- interval = @held ? @renew_interval : @follower_interval
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
 
@@ -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
- Wurk::Limiter.redis do |c|
109
- c.call('SADD', LIST_KEY, @name)
110
- c.call(
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
@@ -38,7 +38,7 @@ module Wurk
38
38
  remaining = deadline - ::Time.now.to_f
39
39
  raise OverLimit, self if remaining <= 0
40
40
 
41
- sleep [remaining, secs_to_next.to_f, 0.05].compact.min.clamp(0.0, remaining)
41
+ sleep [0.05, secs_to_next.to_f].max.clamp(0.0, remaining)
42
42
  end
43
43
  end
44
44
 
@@ -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 = nil
44
- loop do
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
- release(slot)
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],
@@ -19,18 +19,15 @@ module Wurk
19
19
  end
20
20
 
21
21
  def size
22
- cutoff = ::Time.now.to_f - interval_seconds
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
- build_status(used: size, limit: @options[:count], reset_at: oldest_expiry)
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
- # Oldest timestamp + interval = the moment it leaves the window.
64
- def oldest_expiry
65
- row = Wurk::Limiter.redis { |c| c.call('ZRANGE', state_key, 0, 0, 'WITHSCORES') }
66
- score = Wurk::Limiter.first_score(row)
67
- score && (score + interval_seconds)
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
- pool = config.pool || Wurk.redis_pool
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
@@ -28,7 +28,7 @@ module Wurk
28
28
  }.freeze
29
29
 
30
30
  def tid
31
- Thread.current[:wurk_tid] ||= (Thread.current.object_id ^ ::Process.pid).to_s(36)
31
+ Wurk::Component.tid
32
32
  end
33
33
 
34
34
  def format_context(ctxt = Wurk::Context.current)
@@ -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 do |pipe|
27
- SCRIPTS.each_value { |src| pipe.call('SCRIPT', 'LOAD', src) }
28
- end
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; otherwise the event's
268
- # fired flag ("1", or nil when it has not fired yet).
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
- list[#list + 1] = cjson.decode(ARGV[1])
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 enqueue can grab it, and the bare DEL would then drop the new
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) and `Unique::DEATH_HANDLER` (automatic-death
290
- # release) so the two paths cannot drift.
291
- # KEYS = [unique:<sha256>]
292
- # ARGV = [owning jid]
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
- def initialize(capsule)
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, crash the child (the swarm respawns it) rather than silently
90
- # dropping concurrency. Snapshot under @plock; start the replacement — a
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; instead report and crash the child on the main
106
- # thread so the swarm respawns it at full concurrency (plan 02 §6).
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
- main_thread.raise(e)
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.empty? }
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