wurk 0.0.5 → 1.0.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 -0
- data/app/controllers/wurk/api/serializers.rb +48 -2
- data/app/controllers/wurk/api_controller.rb +216 -1
- data/app/controllers/wurk/dashboard_controller.rb +20 -2
- data/app/controllers/wurk/extensions_controller.rb +56 -0
- data/app/controllers/wurk/profiles_controller.rb +68 -0
- data/config/routes.rb +54 -1
- data/exe/sidekiqswarm +8 -0
- data/exe/wurkswarm +23 -0
- data/lib/active_job/queue_adapters/wurk_adapter.rb +35 -0
- data/lib/generators/wurk/install/templates/wurk.rb +14 -3
- data/lib/sidekiq/api.rb +4 -0
- data/lib/sidekiq/cli.rb +9 -0
- data/lib/sidekiq/client.rb +4 -0
- data/lib/sidekiq/job.rb +4 -0
- data/lib/sidekiq/launcher.rb +4 -0
- data/lib/sidekiq/middleware/chain.rb +4 -0
- data/lib/sidekiq/middleware/server/statsd.rb +12 -0
- data/lib/sidekiq/rails.rb +10 -0
- data/lib/sidekiq/redis_connection.rb +4 -0
- data/lib/sidekiq/scheduled.rb +4 -0
- data/lib/sidekiq/testing.rb +4 -0
- data/lib/sidekiq/version.rb +4 -0
- data/lib/sidekiq/web.rb +4 -0
- data/lib/sidekiq/worker.rb +4 -0
- data/lib/sidekiq.rb +16 -0
- data/lib/wurk/batch/callbacks.rb +103 -13
- data/lib/wurk/batch/death_handler.rb +5 -2
- data/lib/wurk/batch/server_middleware.rb +35 -3
- data/lib/wurk/batch/status.rb +9 -0
- data/lib/wurk/batch.rb +23 -1
- data/lib/wurk/capsule.rb +20 -1
- data/lib/wurk/cli.rb +84 -1
- data/lib/wurk/client.rb +20 -17
- data/lib/wurk/compat.rb +44 -2
- data/lib/wurk/component.rb +5 -4
- data/lib/wurk/configuration.rb +120 -3
- data/lib/wurk/cron.rb +51 -9
- data/lib/wurk/dead_set.rb +8 -3
- data/lib/wurk/deploy.rb +8 -4
- data/lib/wurk/encryption.rb +6 -1
- data/lib/wurk/fetcher/reaper.rb +78 -11
- data/lib/wurk/fetcher/reliable.rb +14 -4
- data/lib/wurk/heartbeat.rb +45 -0
- data/lib/wurk/history.rb +174 -0
- data/lib/wurk/iterable_job/active_record_enumerator.rb +71 -0
- data/lib/wurk/iterable_job/csv_enumerator.rb +51 -0
- data/lib/wurk/iterable_job.rb +41 -0
- data/lib/wurk/iterable_job_query.rb +75 -0
- data/lib/wurk/job.rb +8 -0
- data/lib/wurk/job_record.rb +16 -1
- data/lib/wurk/job_set.rb +4 -4
- data/lib/wurk/job_util.rb +15 -6
- data/lib/wurk/keys.rb +10 -0
- data/lib/wurk/launcher.rb +35 -1
- data/lib/wurk/leader.rb +15 -6
- data/lib/wurk/limiter/bucket.rb +14 -3
- data/lib/wurk/limiter/concurrent.rb +1 -1
- data/lib/wurk/limiter/window.rb +2 -1
- data/lib/wurk/limiter.rb +12 -0
- data/lib/wurk/lua/loader.rb +10 -0
- data/lib/wurk/lua.rb +106 -14
- data/lib/wurk/metrics/history.rb +5 -0
- data/lib/wurk/metrics/query.rb +39 -0
- data/lib/wurk/metrics/queue_rollup.rb +151 -0
- data/lib/wurk/metrics/statsd.rb +11 -0
- data/lib/wurk/middleware/current_attributes.rb +29 -6
- data/lib/wurk/middleware/interrupt_handler.rb +5 -0
- data/lib/wurk/middleware/poison_pill.rb +35 -5
- data/lib/wurk/processor.rb +17 -8
- data/lib/wurk/profile_set.rb +65 -0
- data/lib/wurk/profiler.rb +127 -0
- data/lib/wurk/railtie.rb +19 -5
- data/lib/wurk/redis_client_adapter.rb +72 -0
- data/lib/wurk/redis_connection.rb +30 -0
- data/lib/wurk/redis_pool.rb +5 -1
- data/lib/wurk/scheduled.rb +42 -0
- data/lib/wurk/sorted_entry.rb +13 -11
- data/lib/wurk/stats.rb +11 -4
- data/lib/wurk/swarm/child_boot.rb +26 -4
- data/lib/wurk/swarm.rb +1 -1
- data/lib/wurk/transaction_aware_client.rb +69 -0
- data/lib/wurk/unique.rb +49 -7
- data/lib/wurk/version.rb +1 -1
- data/lib/wurk/web/batch_status.rb +42 -0
- data/lib/wurk/web/config.rb +219 -17
- data/lib/wurk/web/enterprise.rb +14 -0
- data/lib/wurk/web/extension.rb +348 -0
- data/lib/wurk/web/rack_app.rb +77 -0
- data/lib/wurk/web.rb +2 -0
- data/lib/wurk/worker/setter.rb +5 -1
- data/lib/wurk/worker.rb +17 -6
- data/lib/wurk.rb +44 -0
- data/vendor/assets/dashboard/assets/fa-brands-400-BP5tdqmh.woff2 +0 -0
- data/vendor/assets/dashboard/assets/fa-regular-400-nyy7hhHF.woff2 +0 -0
- data/vendor/assets/dashboard/assets/fa-solid-900-DRAAbZTg.woff2 +0 -0
- data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +77 -0
- data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +2 -0
- data/vendor/assets/dashboard/assets/wurk-logo-Vy3xW4K0.png +0 -0
- data/vendor/assets/dashboard/favicon.png +0 -0
- data/vendor/assets/dashboard/index.html +10 -3
- data/vendor/assets/dashboard/wurk-manifest.json +2 -2
- metadata +42 -3
- data/vendor/assets/dashboard/assets/index-D2XR0iGw.js +0 -60
- data/vendor/assets/dashboard/assets/index-DlPr4YXw.css +0 -1
data/lib/wurk/cli.rb
CHANGED
|
@@ -93,6 +93,12 @@ module Wurk
|
|
|
93
93
|
|
|
94
94
|
# `boot_app:` / `warmup:` are test seams. Production always passes true.
|
|
95
95
|
def run(boot_app: true, warmup: true)
|
|
96
|
+
# Mark server mode BEFORE the app loads so `configure_server` blocks in
|
|
97
|
+
# the required initializer actually fire (they gate on `config.server?`).
|
|
98
|
+
# Matches Sidekiq, which sets `Sidekiq.server = true` before requiring
|
|
99
|
+
# the app in its CLI. Skipping this silently drops server middleware,
|
|
100
|
+
# error handlers, and lifecycle hooks registered via configure_server.
|
|
101
|
+
enter_server_mode
|
|
96
102
|
boot_application if boot_app
|
|
97
103
|
self_read, self_write = ::IO.pipe
|
|
98
104
|
trap_signals(self_write)
|
|
@@ -107,6 +113,35 @@ module Wurk
|
|
|
107
113
|
launch(self_read)
|
|
108
114
|
end
|
|
109
115
|
|
|
116
|
+
# Standalone multi-process boot — the `sidekiqswarm` entry point (Ent §7).
|
|
117
|
+
# The parent loads the app once, forks N worker children per the configured
|
|
118
|
+
# topology, then supervises them (respawn on crash, rolling restart on
|
|
119
|
+
# SIGUSR1, memory-based recycling). The parent itself never fetches.
|
|
120
|
+
#
|
|
121
|
+
# This is the only way to get fork-based parallelism without Rails — the
|
|
122
|
+
# railtie auto-boot path is Rails-only. Honors the swarm preload knobs
|
|
123
|
+
# (`WURK_PRELOAD`/`SIDEKIQ_PRELOAD` Bundler groups, `WURK_PRELOAD_APP`/
|
|
124
|
+
# `SIDEKIQ_PRELOAD_APP` whole-app eager-load) and boots `Process.warmup`
|
|
125
|
+
# before the fork so children share warmed pages (copy-on-write). Spec §7.
|
|
126
|
+
def run_swarm(boot_app: true, warmup: true)
|
|
127
|
+
# Server mode before the app loads — see #run. The flag rides through the
|
|
128
|
+
# fork into every child (the config object is copied), so configure_server
|
|
129
|
+
# blocks registered in the parent take effect in the workers.
|
|
130
|
+
enter_server_mode
|
|
131
|
+
if boot_app
|
|
132
|
+
preload_bundler_groups
|
|
133
|
+
boot_application
|
|
134
|
+
eager_load_application
|
|
135
|
+
end
|
|
136
|
+
validate_redis!
|
|
137
|
+
validate_pool_sizes!
|
|
138
|
+
@config[:identity] = identity
|
|
139
|
+
::Process.warmup if warmup && ::Process.respond_to?(:warmup) && ENV['RUBY_DISABLE_WARMUP'] != '1'
|
|
140
|
+
@swarm = Wurk::Swarm.new(topology: @config.topology, config: @config)
|
|
141
|
+
@swarm.boot(install_signals: true)
|
|
142
|
+
@swarm.supervise
|
|
143
|
+
end
|
|
144
|
+
|
|
110
145
|
def handle_signal(sig)
|
|
111
146
|
logger.debug { "Got #{sig} signal" }
|
|
112
147
|
handler = SIGNAL_HANDLERS[sig]
|
|
@@ -119,8 +154,11 @@ module Wurk
|
|
|
119
154
|
|
|
120
155
|
# --- run helpers ----------------------------------------------------
|
|
121
156
|
|
|
157
|
+
def enter_server_mode
|
|
158
|
+
Wurk.enter_server_mode(@config)
|
|
159
|
+
end
|
|
160
|
+
|
|
122
161
|
def launch(self_read)
|
|
123
|
-
Wurk.server = true
|
|
124
162
|
@launcher = Wurk::Launcher.new(@config)
|
|
125
163
|
begin
|
|
126
164
|
@launcher.run
|
|
@@ -299,6 +337,51 @@ module Wurk
|
|
|
299
337
|
|
|
300
338
|
# --- application bootstrap --------------------------------------------
|
|
301
339
|
|
|
340
|
+
# Spec §7.2/§7.3. Before the swarm parent forks, `Bundler.require` the
|
|
341
|
+
# configured groups so their gems are resident in the parent and paged
|
|
342
|
+
# copy-on-write into every child. Comma-separated group list; the native
|
|
343
|
+
# `WURK_PRELOAD` wins over the `SIDEKIQ_PRELOAD` drop-in alias. Defaults to
|
|
344
|
+
# the `default` group; an explicit empty value (`WURK_PRELOAD=`) disables
|
|
345
|
+
# the preload. Runs before `boot_application` so groups load before the app.
|
|
346
|
+
def preload_bundler_groups
|
|
347
|
+
return unless defined?(::Bundler)
|
|
348
|
+
|
|
349
|
+
groups = preload_groups
|
|
350
|
+
::Bundler.require(*groups) unless groups.empty?
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def preload_groups
|
|
354
|
+
raw = ENV['WURK_PRELOAD'] || ENV['SIDEKIQ_PRELOAD'] || 'default'
|
|
355
|
+
raw.split(',').map(&:strip).reject(&:empty?).map(&:to_sym)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Spec §7.2. `WURK_PRELOAD_APP=1` (alias `SIDEKIQ_PRELOAD_APP=1`) eager-loads
|
|
359
|
+
# the whole Rails app in the parent before forking, so the entire object
|
|
360
|
+
# space — not just the constants autoload happened to touch — is shared
|
|
361
|
+
# copy-on-write, trading parent boot time for child RSS savings (~20–30%).
|
|
362
|
+
#
|
|
363
|
+
# Intentional divergence from Sidekiq's default-0: wurk's swarm forks
|
|
364
|
+
# children from a preloaded parent — they inherit the app rather than
|
|
365
|
+
# re-requiring it — so the app entrypoint (`-r`) is always loaded in the
|
|
366
|
+
# parent regardless of this flag. PRELOAD_APP only toggles the extra Rails
|
|
367
|
+
# eager-load; for a non-Rails `-r` file there is nothing further to load.
|
|
368
|
+
def eager_load_application
|
|
369
|
+
return unless preload_app?
|
|
370
|
+
|
|
371
|
+
app = rails_application
|
|
372
|
+
app.eager_load! if app.respond_to?(:eager_load!)
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def rails_application
|
|
376
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:application)
|
|
377
|
+
|
|
378
|
+
::Rails.application
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def preload_app?
|
|
382
|
+
(ENV['WURK_PRELOAD_APP'] || ENV.fetch('SIDEKIQ_PRELOAD_APP', nil)) == '1'
|
|
383
|
+
end
|
|
384
|
+
|
|
302
385
|
# Standalone mode must NOT load wurk/rails — even when pointed at a Rails
|
|
303
386
|
# app, that's the host's call to wire the railtie. The CLI only `require`s
|
|
304
387
|
# the host's entrypoint and reads the resulting classes.
|
data/lib/wurk/client.rb
CHANGED
|
@@ -266,19 +266,18 @@ module Wurk
|
|
|
266
266
|
|
|
267
267
|
# Outside of test boots and `SCRIPT FLUSH` the rescue branch is dead
|
|
268
268
|
# code; the eager `script_load_all` after fork keeps the script cache
|
|
269
|
-
# hot for the life of the connection.
|
|
269
|
+
# hot for the life of the connection. The retry uses EVAL (source-embedded)
|
|
270
|
+
# instead of EVALSHA so a freshly-loaded script can't race the retry and
|
|
271
|
+
# NOSCRIPT a second time under heavy CI load (WorkerTest 3.4/7.2 flake).
|
|
272
|
+
# `script_load_all` still primes the cache so the *next* pipeline returns
|
|
273
|
+
# to the EVALSHA fast path.
|
|
270
274
|
def push_batched_pipelined(conn, batched, now)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
attempts += 1
|
|
279
|
-
Wurk::Lua::Loader.script_load_all(conn)
|
|
280
|
-
retry
|
|
281
|
-
end
|
|
275
|
+
conn.pipelined { |pipe| push_batched(pipe, batched, now) }
|
|
276
|
+
rescue RedisClient::CommandError => e
|
|
277
|
+
raise unless e.message.to_s.start_with?('NOSCRIPT')
|
|
278
|
+
|
|
279
|
+
Wurk::Lua::Loader.script_load_all(conn)
|
|
280
|
+
conn.pipelined { |pipe| push_batched(pipe, batched, now, eval_method: :eval_with_source) }
|
|
282
281
|
end
|
|
283
282
|
|
|
284
283
|
def push_plain(conn, payloads, now)
|
|
@@ -297,15 +296,19 @@ module Wurk
|
|
|
297
296
|
# SADDs jid into the live set, registers the queue, LPUSHes the payload —
|
|
298
297
|
# all atomically. One Redis round-trip per job (no pipeline grouping)
|
|
299
298
|
# because the lua needs per-job KEYS bound. Acceptable cost: batch
|
|
300
|
-
# enqueue is not the hot path; correctness is.
|
|
301
|
-
|
|
299
|
+
# enqueue is not the hot path; correctness is. `eval_method` is the
|
|
300
|
+
# Wurk::Lua::Loader entry point (`:eval_cached` for the hot EVALSHA path,
|
|
301
|
+
# `:eval_with_source` for the EVAL-source retry).
|
|
302
|
+
def push_batched(conn, payloads, now, eval_method: :eval_cached)
|
|
302
303
|
payloads.each do |j|
|
|
303
304
|
j['enqueued_at'] = now
|
|
304
|
-
Wurk::Lua::Loader.
|
|
305
|
+
Wurk::Lua::Loader.public_send(
|
|
306
|
+
eval_method,
|
|
305
307
|
conn,
|
|
306
308
|
:batch_push,
|
|
307
|
-
keys: ["b-#{j['bid']}", "b-#{j['bid']}-jids", "queue:#{j['queue']}", 'queues'
|
|
308
|
-
|
|
309
|
+
keys: ["b-#{j['bid']}", "b-#{j['bid']}-jids", "queue:#{j['queue']}", 'queues',
|
|
310
|
+
"b-#{j['bid']}-died", 'dead-batches'],
|
|
311
|
+
argv: [j['queue'], j['jid'], Wurk.dump_json(j), j['bid']]
|
|
309
312
|
)
|
|
310
313
|
end
|
|
311
314
|
end
|
data/lib/wurk/compat.rb
CHANGED
|
@@ -18,7 +18,30 @@ module Sidekiq
|
|
|
18
18
|
# Sidekiq::Enterprise::Crypto, …). Defined so downstream code can nest
|
|
19
19
|
# classes under them — but `Sidekiq.pro?` / `Sidekiq.ent?` still return
|
|
20
20
|
# `false` per docs/target/sidekiq-free.md §32 (Wurk advertises as free OSS).
|
|
21
|
-
module Pro
|
|
21
|
+
module Pro
|
|
22
|
+
# Sidekiq Pro mounts the dashboard at `Sidekiq::Pro::Web`; it's the same
|
|
23
|
+
# board as `Sidekiq::Web` here, so a Pro app's `mount Sidekiq::Pro::Web`
|
|
24
|
+
# drops in unchanged. `Wurk::Web` is already required by the time this file
|
|
25
|
+
# loads (lib/wurk.rb requires wurk/web before wurk/compat).
|
|
26
|
+
Web = Wurk::Web
|
|
27
|
+
|
|
28
|
+
# `use Sidekiq::Pro::BatchStatus` — the polling Rack middleware that serves
|
|
29
|
+
# GET /batch_status/<bid>.json. Spec: docs/target/sidekiq-pro.md §10.3.
|
|
30
|
+
BatchStatus = Wurk::Web::BatchStatus
|
|
31
|
+
|
|
32
|
+
# Pro module-level statsd accessor (spec §1): `Sidekiq::Pro.dogstatsd = ...`.
|
|
33
|
+
# Delegates to the canonical `config.dogstatsd=` (spec §9.1), so either form
|
|
34
|
+
# feeds the same Wurk::Metrics::Statsd client.
|
|
35
|
+
class << self
|
|
36
|
+
def dogstatsd=(builder)
|
|
37
|
+
Wurk.configuration.dogstatsd = builder
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def dogstatsd
|
|
41
|
+
Wurk.configuration.dogstatsd
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
22
45
|
|
|
23
46
|
# Sidekiq Enterprise feature surface (`unique!`, `Crypto`, `Unique.locked?`).
|
|
24
47
|
# Wurk ships these free; the namespace exists for drop-in compat.
|
|
@@ -68,13 +91,19 @@ module Sidekiq
|
|
|
68
91
|
Component = Wurk::Component
|
|
69
92
|
Config = Wurk::Configuration
|
|
70
93
|
Context = Wurk::Context
|
|
71
|
-
|
|
94
|
+
# No `Sidekiq::Cron` alias on purpose (#204): real Sidekiq never defines it
|
|
95
|
+
# — that namespace belongs to the third-party sidekiq-cron gem, and squatting
|
|
96
|
+
# it made the gem's own classes (Poller, Job) collide at load. Wurk's native
|
|
97
|
+
# periodic surface is `Sidekiq::Periodic` (Ent parity) / `Wurk::Cron`.
|
|
98
|
+
CronParser = Wurk::Cron::Parser
|
|
72
99
|
Periodic = Wurk::Cron
|
|
73
100
|
DeadSet = Wurk::DeadSet
|
|
74
101
|
Deploy = Wurk::Deploy
|
|
75
102
|
Embedded = Wurk::Embedded
|
|
76
103
|
Encryption = Wurk::Encryption
|
|
104
|
+
History = Wurk::History
|
|
77
105
|
IterableJob = Wurk::IterableJob
|
|
106
|
+
IterableJobQuery = Wurk::IterableJobQuery
|
|
78
107
|
Job = Wurk::Job
|
|
79
108
|
JobLogger = Wurk::JobLogger
|
|
80
109
|
JobRecord = Wurk::JobRecord
|
|
@@ -92,13 +121,20 @@ module Sidekiq
|
|
|
92
121
|
Process = Wurk::Process
|
|
93
122
|
ProcessSet = Wurk::ProcessSet
|
|
94
123
|
Processor = Wurk::Processor
|
|
124
|
+
Profiler = Wurk::Profiler
|
|
125
|
+
ProfileSet = Wurk::ProfileSet
|
|
126
|
+
ProfileRecord = Wurk::ProfileRecord
|
|
95
127
|
Queue = Wurk::Queue
|
|
128
|
+
RedisClientAdapter = Wurk::RedisClientAdapter
|
|
129
|
+
RedisConnection = Wurk::RedisConnection
|
|
96
130
|
RetrySet = Wurk::RetrySet
|
|
97
131
|
Scheduled = Wurk::Scheduled
|
|
98
132
|
ScheduledSet = Wurk::ScheduledSet
|
|
99
133
|
Shutdown = Wurk::Shutdown
|
|
134
|
+
SortedEntry = Wurk::SortedEntry
|
|
100
135
|
Stats = Wurk::Stats
|
|
101
136
|
Testing = Wurk::Testing
|
|
137
|
+
TransactionAwareClient = Wurk::TransactionAwareClient
|
|
102
138
|
Queues = Wurk::Queues
|
|
103
139
|
EmptyQueueError = Wurk::Testing::EmptyQueueError
|
|
104
140
|
Web = Wurk::Web
|
|
@@ -118,6 +154,11 @@ module Sidekiq
|
|
|
118
154
|
def redis(&) = Wurk.redis(&)
|
|
119
155
|
def redis_pool = Wurk.redis_pool
|
|
120
156
|
def logger = Wurk.logger
|
|
157
|
+
|
|
158
|
+
def logger=(logger)
|
|
159
|
+
Wurk.logger = logger
|
|
160
|
+
end
|
|
161
|
+
|
|
121
162
|
def server? = Wurk.server?
|
|
122
163
|
def pro? = Wurk.pro?
|
|
123
164
|
def ent? = Wurk.ent?
|
|
@@ -128,6 +169,7 @@ module Sidekiq
|
|
|
128
169
|
end
|
|
129
170
|
|
|
130
171
|
def strict_args!(mode = :raise) = Wurk.strict_args!(mode)
|
|
172
|
+
def transactional_push! = Wurk.transactional_push!
|
|
131
173
|
def testing!(mode = :fake, &) = Wurk.testing!(mode, &)
|
|
132
174
|
def testing? = Wurk.testing?
|
|
133
175
|
def load_json(str) = Wurk.load_json(str)
|
data/lib/wurk/component.rb
CHANGED
|
@@ -73,13 +73,14 @@ module Wurk
|
|
|
73
73
|
# True iff this process currently holds the cluster `dear-leader` lock.
|
|
74
74
|
# Per spec, the check is performed at call time (Wurk does not cache);
|
|
75
75
|
# callers must not poll faster than the 60s follower cadence. Returns
|
|
76
|
-
# false unconditionally when `WURK_LEADER=false`
|
|
77
|
-
# (opt-out hot-standby). Any Redis error is swallowed →
|
|
78
|
-
# transient partition can't propagate as an exception into user
|
|
76
|
+
# false unconditionally when `WURK_LEADER=false` (or `SIDEKIQ_LEADER=false`)
|
|
77
|
+
# is set on the process (opt-out hot-standby). Any Redis error is swallowed →
|
|
78
|
+
# false, so a transient partition can't propagate as an exception into user
|
|
79
|
+
# code.
|
|
79
80
|
#
|
|
80
81
|
# Spec: docs/target/sidekiq-ent.md §6.1.
|
|
81
82
|
def leader?
|
|
82
|
-
return false if
|
|
83
|
+
return false if Wurk::Leader.opted_out?
|
|
83
84
|
|
|
84
85
|
redis { |c| c.call('GET', Wurk::Leader::DEFAULT_KEY) } == identity
|
|
85
86
|
rescue StandardError
|
data/lib/wurk/configuration.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'etc'
|
|
3
4
|
require 'logger'
|
|
4
5
|
require_relative 'middleware/chain'
|
|
5
6
|
require_relative 'capsule'
|
|
@@ -29,6 +30,7 @@ module Wurk
|
|
|
29
30
|
death_handlers: [],
|
|
30
31
|
lifecycle_events: {
|
|
31
32
|
startup: [],
|
|
33
|
+
fork: [],
|
|
32
34
|
quiet: [],
|
|
33
35
|
shutdown: [],
|
|
34
36
|
exit: [],
|
|
@@ -44,7 +46,9 @@ module Wurk
|
|
|
44
46
|
redis_idle_timeout: nil
|
|
45
47
|
}.freeze
|
|
46
48
|
|
|
47
|
-
|
|
49
|
+
# :fork fires only inside swarm children, after fork + internal AR/Redis
|
|
50
|
+
# reconnect — apps reopen sockets / non-fork-safe libs there (Ent §7.4).
|
|
51
|
+
LIFECYCLE_EVENTS = %i[startup fork quiet shutdown exit heartbeat beat leader].freeze
|
|
48
52
|
DEFAULT_THREAD_PRIORITY = -1
|
|
49
53
|
|
|
50
54
|
# Default error handler. Wraps the report in the thread-local
|
|
@@ -62,7 +66,7 @@ module Wurk
|
|
|
62
66
|
end
|
|
63
67
|
end
|
|
64
68
|
|
|
65
|
-
attr_reader :capsules, :directory, :redis_config
|
|
69
|
+
attr_reader :capsules, :directory, :redis_config, :super_fetch_callback
|
|
66
70
|
attr_accessor :thread_priority
|
|
67
71
|
|
|
68
72
|
# Pro parity: callable that builds the statsd / dogstatsd client.
|
|
@@ -204,6 +208,45 @@ module Wurk
|
|
|
204
208
|
@options[:average_scheduled_poll_interval] = interval
|
|
205
209
|
end
|
|
206
210
|
|
|
211
|
+
# Reliable-fetch empty-poll backoff: the BLMOVE block timeout (seconds)
|
|
212
|
+
# used when every served queue is empty. Pro super_fetch §3.3's
|
|
213
|
+
# `fetch_poll_interval` knob. Unset (nil) → the fetcher's default
|
|
214
|
+
# (Wurk::Fetcher::Reliable::TIMEOUT, 2s). Also readable as
|
|
215
|
+
# `config[:fetch_poll_interval]`.
|
|
216
|
+
def fetch_poll_interval=(seconds)
|
|
217
|
+
@options[:fetch_poll_interval] = seconds
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def fetch_poll_interval
|
|
221
|
+
@options[:fetch_poll_interval]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# --- Reliability (Sidekiq Pro drop-in no-ops) ------------------------
|
|
225
|
+
|
|
226
|
+
# Sidekiq Pro's opt-in toggles for reliable fetch and the reliable
|
|
227
|
+
# scheduler. Both are already the default in Wurk — the fetcher is always
|
|
228
|
+
# the reliable BLMOVE fetcher with orphan reclamation, and the scheduler is
|
|
229
|
+
# always atomic Lua — so the toggle itself is a no-op. They exist only so a
|
|
230
|
+
# Pro initializer drops in unchanged instead of raising NoMethodError.
|
|
231
|
+
#
|
|
232
|
+
# The optional block is Pro's recovery callback: `|jobstr, pill|`, fired
|
|
233
|
+
# once per orphan recovery (`pill` nil) and once on a poison kill (`pill`
|
|
234
|
+
# responds to .jid/.klass/.count/.queue). The reaper drives it via
|
|
235
|
+
# Wurk::Middleware::PoisonPill.track!. Spec: docs/target/sidekiq-pro.md §3.1.
|
|
236
|
+
def super_fetch!(*, &block)
|
|
237
|
+
@super_fetch_callback = block if block
|
|
238
|
+
nil
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Pro reliable scheduler (§4): promote due jobs from retry/schedule onto
|
|
242
|
+
# their target queue in a single atomic Lua (ZRANGEBYSCORE+ZREM+LPUSH),
|
|
243
|
+
# closing the pop→push job-loss window of the default poller. Swaps the
|
|
244
|
+
# pluggable `scheduled_enq` for the atomic promoter; idempotent.
|
|
245
|
+
def reliable_scheduler!(*)
|
|
246
|
+
self[:scheduled_enq] = Wurk::Scheduled::ReliableEnq
|
|
247
|
+
nil
|
|
248
|
+
end
|
|
249
|
+
|
|
207
250
|
# --- Periodic (Cron) registration ------------------------------------
|
|
208
251
|
|
|
209
252
|
# Yields a Wurk::Cron::Manager so the host app can register periodic
|
|
@@ -218,6 +261,32 @@ module Wurk
|
|
|
218
261
|
@periodic_manager
|
|
219
262
|
end
|
|
220
263
|
|
|
264
|
+
# --- Historical metrics snapshotter (Ent §5) -------------------------
|
|
265
|
+
|
|
266
|
+
HISTORY_DEFAULT_INTERVAL = 30
|
|
267
|
+
|
|
268
|
+
# Enables the Ent Historical Metrics snapshotter: every `seconds` the
|
|
269
|
+
# cluster leader emits a statsd-shaped snapshot to the configured
|
|
270
|
+
# `dogstatsd` client. With no block the default §5.2 gauge set is
|
|
271
|
+
# published; a block receives the dogstatsd client `s` and collects
|
|
272
|
+
# custom metrics instead. The Launcher starts the snapshotter only when
|
|
273
|
+
# this has been called.
|
|
274
|
+
#
|
|
275
|
+
# Spec: docs/target/sidekiq-ent.md §5.1.
|
|
276
|
+
def retain_history(seconds = HISTORY_DEFAULT_INTERVAL, &block)
|
|
277
|
+
guard_frozen!
|
|
278
|
+
interval = Float(seconds)
|
|
279
|
+
raise ArgumentError, 'retain_history interval must be > 0' unless interval.positive?
|
|
280
|
+
|
|
281
|
+
@options[:history_interval] = interval
|
|
282
|
+
@options[:history_collector] = block
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def history_enabled? = @options.key?(:history_interval)
|
|
287
|
+
def history_interval = @options.fetch(:history_interval, HISTORY_DEFAULT_INTERVAL)
|
|
288
|
+
def history_collector = @options[:history_collector]
|
|
289
|
+
|
|
221
290
|
# --- Web dashboard ----------------------------------------------------
|
|
222
291
|
|
|
223
292
|
# Web UI configuration: the authorization hook and read-only mode. Returns
|
|
@@ -312,6 +381,26 @@ module Wurk
|
|
|
312
381
|
@topology = value
|
|
313
382
|
end
|
|
314
383
|
|
|
384
|
+
# Memory-based child recycling (Sidekiq Ent §7.5): the swarm parent TERMs
|
|
385
|
+
# (and respawns) any child whose RSS exceeds this many MB. Set in code or
|
|
386
|
+
# via SIDEKIQ_MAXMEM_MB (WURK_MAXMEM_MB is the native alias); an explicit
|
|
387
|
+
# value wins over the env. nil/0 disables recycling (the default).
|
|
388
|
+
def memory_limit_mb
|
|
389
|
+
@memory_limit_mb || env_memory_limit_mb
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def memory_limit_mb=(value)
|
|
393
|
+
guard_frozen!
|
|
394
|
+
@memory_limit_mb = value.nil? ? nil : Integer(value)
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Threshold in KB, the unit the swarm compares against /proc/<pid>/statm
|
|
398
|
+
# (pages × 4KB). nil when recycling is disabled.
|
|
399
|
+
def memory_limit_kb
|
|
400
|
+
mb = memory_limit_mb
|
|
401
|
+
mb&.positive? ? mb * 1024 : nil
|
|
402
|
+
end
|
|
403
|
+
|
|
315
404
|
def freeze!
|
|
316
405
|
return self if @frozen
|
|
317
406
|
|
|
@@ -338,7 +427,35 @@ module Wurk
|
|
|
338
427
|
# a topology. queue_specs (not queues) so weights survive the round-trip.
|
|
339
428
|
def default_topology
|
|
340
429
|
cap = default_capsule
|
|
341
|
-
Wurk::Topology.flat(count:
|
|
430
|
+
Wurk::Topology.flat(count: default_child_count, queues: cap.queue_specs, concurrency: cap.concurrency)
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# Number of swarm children when the host hasn't declared a topology
|
|
434
|
+
# (Sidekiq Ent §7.2). Defaults to the CPU count. A whole number is an
|
|
435
|
+
# absolute count; a fractional value is a CPU multiplier (e.g. `0.5` → half
|
|
436
|
+
# the cores, rounded), supported since Sidekiq 8.0.2. WURK_COUNT is the
|
|
437
|
+
# native name, SIDEKIQ_COUNT the drop-in alias. Unparseable input falls back
|
|
438
|
+
# to the CPU count; the result is floored at 1 so the swarm always forks.
|
|
439
|
+
def default_child_count
|
|
440
|
+
raw = ENV['WURK_COUNT'] || ENV['SIDEKIQ_COUNT']
|
|
441
|
+
return Etc.nprocessors if raw.nil? || raw.strip.empty?
|
|
442
|
+
|
|
443
|
+
value = Float(raw)
|
|
444
|
+
count = (value % 1).zero? ? value.to_i : (value * Etc.nprocessors).round
|
|
445
|
+
[count, 1].max
|
|
446
|
+
rescue ArgumentError, TypeError
|
|
447
|
+
Etc.nprocessors
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# SIDEKIQ_MAXMEM_MB is the drop-in name; WURK_MAXMEM_MB the native alias.
|
|
451
|
+
# Unparseable / empty input disables recycling rather than raising at boot.
|
|
452
|
+
def env_memory_limit_mb
|
|
453
|
+
raw = ENV['WURK_MAXMEM_MB'] || ENV['SIDEKIQ_MAXMEM_MB']
|
|
454
|
+
return nil if raw.nil? || raw.strip.empty?
|
|
455
|
+
|
|
456
|
+
Integer(raw)
|
|
457
|
+
rescue ArgumentError, TypeError
|
|
458
|
+
nil
|
|
342
459
|
end
|
|
343
460
|
|
|
344
461
|
def guard_frozen!
|
data/lib/wurk/cron.rb
CHANGED
|
@@ -92,6 +92,16 @@ module Wurk
|
|
|
92
92
|
nil
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# Sidekiq Ent `Sidekiq::CronParser#next`: the next fire Time strictly
|
|
96
|
+
# after `from` (default now), or nil if the schedule never matches within
|
|
97
|
+
# the lookahead window. `tz` evaluates the schedule in a timezone (an
|
|
98
|
+
# AS::TimeZone / TZInfo::Tz / IANA String); default is UTC. Thin wrapper
|
|
99
|
+
# over `next_fire_at` so there's a single crontab parser.
|
|
100
|
+
def next(from = ::Time.now, tz = nil)
|
|
101
|
+
epoch = next_fire_at(from.to_i, tz)
|
|
102
|
+
epoch && ::Time.at(epoch)
|
|
103
|
+
end
|
|
104
|
+
|
|
95
105
|
def match?(time, tz = nil)
|
|
96
106
|
match_components?(wall_clock(time.to_i, tz))
|
|
97
107
|
end
|
|
@@ -184,7 +194,7 @@ module Wurk
|
|
|
184
194
|
# * nil → UTC
|
|
185
195
|
# * AS::TimeZone → responds to #at
|
|
186
196
|
# * TZInfo::Tz → responds to #utc_to_local
|
|
187
|
-
# * IANA String →
|
|
197
|
+
# * IANA String → resolved via TZInfo (cached; UTC fallback + warning)
|
|
188
198
|
def wall_clock(epoch, tz)
|
|
189
199
|
t = case tz
|
|
190
200
|
when nil then ::Time.at(epoch).utc
|
|
@@ -198,15 +208,47 @@ module Wurk
|
|
|
198
208
|
return tz.at(epoch) if tz.respond_to?(:at) && !tz.is_a?(String)
|
|
199
209
|
return tz.utc_to_local(::Time.at(epoch).utc) if tz.respond_to?(:utc_to_local)
|
|
200
210
|
|
|
201
|
-
|
|
202
|
-
|
|
211
|
+
zone = self.class.resolve_zone(tz.to_s)
|
|
212
|
+
zone ? zone.utc_to_local(::Time.at(epoch).utc) : ::Time.at(epoch).utc
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# IANA String → TZInfo::Timezone, memoized process-wide. Never mutates
|
|
216
|
+
# ENV['TZ']: ENV is process-global, so the old tzset(3) override leaked
|
|
217
|
+
# the cron loop's zone into every other thread — processors running user
|
|
218
|
+
# perform code observed the wrong timezone mid-evaluation (#210). It was
|
|
219
|
+
# also unreliable: Ruby caches the process zone after first Time use, so
|
|
220
|
+
# the flip wasn't honored consistently anyway.
|
|
221
|
+
#
|
|
222
|
+
# tzinfo is a soft dependency (always present under Rails via
|
|
223
|
+
# activesupport). A missing gem or unknown identifier caches `false` so
|
|
224
|
+
# the warning logs once, not once per evaluated minute, and the loop
|
|
225
|
+
# degrades to UTC instead of crashing the poller.
|
|
226
|
+
@tz_cache = {}
|
|
227
|
+
@tz_cache_mutex = ::Mutex.new
|
|
228
|
+
|
|
229
|
+
class << self
|
|
230
|
+
def resolve_zone(name)
|
|
231
|
+
cached = @tz_cache[name]
|
|
232
|
+
return cached || nil unless cached.nil?
|
|
233
|
+
|
|
234
|
+
@tz_cache_mutex.synchronize do
|
|
235
|
+
@tz_cache.fetch(name) { @tz_cache[name] = load_zone(name) } || nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
203
238
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
239
|
+
private
|
|
240
|
+
|
|
241
|
+
def load_zone(name)
|
|
242
|
+
require 'tzinfo' unless defined?(::TZInfo)
|
|
243
|
+
::TZInfo::Timezone.get(name)
|
|
244
|
+
rescue ::LoadError
|
|
245
|
+
Wurk.logger&.warn("[cron] tzinfo gem unavailable — evaluating timezone #{name.inspect} as UTC. " \
|
|
246
|
+
'Add `gem "tzinfo"` for timezone-aware cron.')
|
|
247
|
+
false
|
|
248
|
+
rescue ::StandardError => e
|
|
249
|
+
Wurk.logger&.warn("[cron] unknown timezone #{name.inspect} (#{e.class}: #{e.message}) — evaluating as UTC")
|
|
250
|
+
false
|
|
251
|
+
end
|
|
210
252
|
end
|
|
211
253
|
end
|
|
212
254
|
|
data/lib/wurk/dead_set.rb
CHANGED
|
@@ -5,11 +5,16 @@ require_relative 'job_set'
|
|
|
5
5
|
module Wurk
|
|
6
6
|
# Capped ZSET of jobs that exhausted retries (the "morgue"). Bounded by
|
|
7
7
|
# `dead_max_jobs` and `dead_timeout_in_seconds` config knobs — every
|
|
8
|
-
# `kill` trims both axes. Death handlers fire
|
|
9
|
-
#
|
|
8
|
+
# `kill` trims both axes. Death handlers fire by default — including on
|
|
9
|
+
# API/UI kills, matching Sidekiq — unless `notify_failure: false`.
|
|
10
10
|
#
|
|
11
11
|
# Spec: docs/target/sidekiq-free.md §19.5, §17.2, §31.8.
|
|
12
12
|
class DeadSet < JobSet
|
|
13
|
+
# Synthesized as the death-handler exception for kills without a real
|
|
14
|
+
# error. Byte-for-byte Sidekiq's message — Wurk::Unique::DEATH_HANDLER
|
|
15
|
+
# matches on it to keep the lock on manual kills (Ent parity), and
|
|
16
|
+
# ecosystem handlers may pattern-match it too.
|
|
17
|
+
API_KILL_MESSAGE = 'Job killed by API'
|
|
13
18
|
# Optional `name` allows tests to operate on a namespaced ZSET; production
|
|
14
19
|
# callers always use the default `'dead'` key (wire-compat with Sidekiq).
|
|
15
20
|
def initialize(name = 'dead')
|
|
@@ -48,7 +53,7 @@ module Wurk
|
|
|
48
53
|
def kill(message, opts = {}) # rubocop:disable Naming/PredicateMethod
|
|
49
54
|
notify = opts.fetch(:notify_failure, true)
|
|
50
55
|
do_trim = opts.fetch(:trim, true)
|
|
51
|
-
ex = opts[:ex] || RuntimeError.new(
|
|
56
|
+
ex = opts[:ex] || RuntimeError.new(API_KILL_MESSAGE).tap { |e| e.set_backtrace(caller) }
|
|
52
57
|
|
|
53
58
|
now = ::Process.clock_gettime(::Process::CLOCK_REALTIME)
|
|
54
59
|
Wurk.redis { |conn| conn.call('ZADD', @name, now.to_s, message) }
|
data/lib/wurk/deploy.rb
CHANGED
|
@@ -24,10 +24,14 @@ module Wurk
|
|
|
24
24
|
# Sidekiq Pro's LABEL_MAKER so existing deploy hooks keep working.
|
|
25
25
|
LABEL_MAKER = -> { `git log -1 --format="%h %s"`.strip }
|
|
26
26
|
|
|
27
|
-
# Class-level shorthand
|
|
28
|
-
#
|
|
29
|
-
def self.mark!(label
|
|
30
|
-
|
|
27
|
+
# Class-level shorthand: builds a one-shot Deploy and delegates. Sidekiq's
|
|
28
|
+
# `Sidekiq::Deploy.mark!` takes a POSITIONAL label (spec §23,
|
|
29
|
+
# `def self.mark!(label = nil)`) — capistrano-sidekiq and custom deploy
|
|
30
|
+
# hooks call `Sidekiq::Deploy.mark!("abc123 release")` that way. The `**opts`
|
|
31
|
+
# also absorbs the `label:` keyword so Wurk's own keyword callers keep
|
|
32
|
+
# working; positional wins when both are given.
|
|
33
|
+
def self.mark!(label = nil, at: ::Time.now, **opts)
|
|
34
|
+
new.mark!(label: label.nil? ? opts[:label] : label, at: at)
|
|
31
35
|
end
|
|
32
36
|
|
|
33
37
|
def initialize(pool: nil)
|
data/lib/wurk/encryption.rb
CHANGED
|
@@ -160,10 +160,15 @@ module Wurk
|
|
|
160
160
|
# array with the last element replaced by the literal `"<encrypted>"`
|
|
161
161
|
# when the job opted in. Cleartext preceding args are untouched so
|
|
162
162
|
# operators can still triage on user_id / object_id / etc.
|
|
163
|
+
#
|
|
164
|
+
# Masks on the `encrypt` flag *or* an envelope-shaped last arg: a stored
|
|
165
|
+
# job hash doesn't always carry `encrypt` (it's a `sidekiq_options`, not
|
|
166
|
+
# persisted on every record), so envelope detection is the real guard —
|
|
167
|
+
# it keeps ciphertext out of the dashboard regardless of the flag.
|
|
163
168
|
def redact_args(job)
|
|
164
169
|
args = job['args'] || job[:args] || []
|
|
165
|
-
return args unless job['encrypt'] || job[:encrypt]
|
|
166
170
|
return args if args.empty?
|
|
171
|
+
return args unless job['encrypt'] || job[:encrypt] || envelope?(args.last)
|
|
167
172
|
|
|
168
173
|
args[0..-2] + ['<encrypted>']
|
|
169
174
|
end
|