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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -0
  3. data/app/controllers/wurk/api/serializers.rb +48 -2
  4. data/app/controllers/wurk/api_controller.rb +216 -1
  5. data/app/controllers/wurk/dashboard_controller.rb +20 -2
  6. data/app/controllers/wurk/extensions_controller.rb +56 -0
  7. data/app/controllers/wurk/profiles_controller.rb +68 -0
  8. data/config/routes.rb +54 -1
  9. data/exe/sidekiqswarm +8 -0
  10. data/exe/wurkswarm +23 -0
  11. data/lib/active_job/queue_adapters/wurk_adapter.rb +35 -0
  12. data/lib/generators/wurk/install/templates/wurk.rb +14 -3
  13. data/lib/sidekiq/api.rb +4 -0
  14. data/lib/sidekiq/cli.rb +9 -0
  15. data/lib/sidekiq/client.rb +4 -0
  16. data/lib/sidekiq/job.rb +4 -0
  17. data/lib/sidekiq/launcher.rb +4 -0
  18. data/lib/sidekiq/middleware/chain.rb +4 -0
  19. data/lib/sidekiq/middleware/server/statsd.rb +12 -0
  20. data/lib/sidekiq/rails.rb +10 -0
  21. data/lib/sidekiq/redis_connection.rb +4 -0
  22. data/lib/sidekiq/scheduled.rb +4 -0
  23. data/lib/sidekiq/testing.rb +4 -0
  24. data/lib/sidekiq/version.rb +4 -0
  25. data/lib/sidekiq/web.rb +4 -0
  26. data/lib/sidekiq/worker.rb +4 -0
  27. data/lib/sidekiq.rb +16 -0
  28. data/lib/wurk/batch/callbacks.rb +103 -13
  29. data/lib/wurk/batch/death_handler.rb +5 -2
  30. data/lib/wurk/batch/server_middleware.rb +35 -3
  31. data/lib/wurk/batch/status.rb +9 -0
  32. data/lib/wurk/batch.rb +23 -1
  33. data/lib/wurk/capsule.rb +20 -1
  34. data/lib/wurk/cli.rb +84 -1
  35. data/lib/wurk/client.rb +20 -17
  36. data/lib/wurk/compat.rb +44 -2
  37. data/lib/wurk/component.rb +5 -4
  38. data/lib/wurk/configuration.rb +120 -3
  39. data/lib/wurk/cron.rb +51 -9
  40. data/lib/wurk/dead_set.rb +8 -3
  41. data/lib/wurk/deploy.rb +8 -4
  42. data/lib/wurk/encryption.rb +6 -1
  43. data/lib/wurk/fetcher/reaper.rb +78 -11
  44. data/lib/wurk/fetcher/reliable.rb +14 -4
  45. data/lib/wurk/heartbeat.rb +45 -0
  46. data/lib/wurk/history.rb +174 -0
  47. data/lib/wurk/iterable_job/active_record_enumerator.rb +71 -0
  48. data/lib/wurk/iterable_job/csv_enumerator.rb +51 -0
  49. data/lib/wurk/iterable_job.rb +41 -0
  50. data/lib/wurk/iterable_job_query.rb +75 -0
  51. data/lib/wurk/job.rb +8 -0
  52. data/lib/wurk/job_record.rb +16 -1
  53. data/lib/wurk/job_set.rb +4 -4
  54. data/lib/wurk/job_util.rb +15 -6
  55. data/lib/wurk/keys.rb +10 -0
  56. data/lib/wurk/launcher.rb +35 -1
  57. data/lib/wurk/leader.rb +15 -6
  58. data/lib/wurk/limiter/bucket.rb +14 -3
  59. data/lib/wurk/limiter/concurrent.rb +1 -1
  60. data/lib/wurk/limiter/window.rb +2 -1
  61. data/lib/wurk/limiter.rb +12 -0
  62. data/lib/wurk/lua/loader.rb +10 -0
  63. data/lib/wurk/lua.rb +106 -14
  64. data/lib/wurk/metrics/history.rb +5 -0
  65. data/lib/wurk/metrics/query.rb +39 -0
  66. data/lib/wurk/metrics/queue_rollup.rb +151 -0
  67. data/lib/wurk/metrics/statsd.rb +11 -0
  68. data/lib/wurk/middleware/current_attributes.rb +29 -6
  69. data/lib/wurk/middleware/interrupt_handler.rb +5 -0
  70. data/lib/wurk/middleware/poison_pill.rb +35 -5
  71. data/lib/wurk/processor.rb +17 -8
  72. data/lib/wurk/profile_set.rb +65 -0
  73. data/lib/wurk/profiler.rb +127 -0
  74. data/lib/wurk/railtie.rb +19 -5
  75. data/lib/wurk/redis_client_adapter.rb +72 -0
  76. data/lib/wurk/redis_connection.rb +30 -0
  77. data/lib/wurk/redis_pool.rb +5 -1
  78. data/lib/wurk/scheduled.rb +42 -0
  79. data/lib/wurk/sorted_entry.rb +13 -11
  80. data/lib/wurk/stats.rb +11 -4
  81. data/lib/wurk/swarm/child_boot.rb +26 -4
  82. data/lib/wurk/swarm.rb +1 -1
  83. data/lib/wurk/transaction_aware_client.rb +69 -0
  84. data/lib/wurk/unique.rb +49 -7
  85. data/lib/wurk/version.rb +1 -1
  86. data/lib/wurk/web/batch_status.rb +42 -0
  87. data/lib/wurk/web/config.rb +219 -17
  88. data/lib/wurk/web/enterprise.rb +14 -0
  89. data/lib/wurk/web/extension.rb +348 -0
  90. data/lib/wurk/web/rack_app.rb +77 -0
  91. data/lib/wurk/web.rb +2 -0
  92. data/lib/wurk/worker/setter.rb +5 -1
  93. data/lib/wurk/worker.rb +17 -6
  94. data/lib/wurk.rb +44 -0
  95. data/vendor/assets/dashboard/assets/fa-brands-400-BP5tdqmh.woff2 +0 -0
  96. data/vendor/assets/dashboard/assets/fa-regular-400-nyy7hhHF.woff2 +0 -0
  97. data/vendor/assets/dashboard/assets/fa-solid-900-DRAAbZTg.woff2 +0 -0
  98. data/vendor/assets/dashboard/assets/index-9CFRWpfG.js +77 -0
  99. data/vendor/assets/dashboard/assets/index-CW8AFQIv.css +2 -0
  100. data/vendor/assets/dashboard/assets/wurk-logo-Vy3xW4K0.png +0 -0
  101. data/vendor/assets/dashboard/favicon.png +0 -0
  102. data/vendor/assets/dashboard/index.html +10 -3
  103. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  104. metadata +42 -3
  105. data/vendor/assets/dashboard/assets/index-D2XR0iGw.js +0 -60
  106. 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
- attempts = 0
272
- begin
273
- conn.pipelined { |pipe| push_batched(pipe, batched, now) }
274
- rescue RedisClient::CommandError => e
275
- raise unless e.message.to_s.start_with?('NOSCRIPT')
276
- raise if attempts.positive?
277
-
278
- attempts += 1
279
- Wurk::Lua::Loader.script_load_all(conn)
280
- retry
281
- end
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
- def push_batched(conn, payloads, now)
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.eval_cached(
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
- argv: [j['queue'], j['jid'], Wurk.dump_json(j)]
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; end
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
- Cron = Wurk::Cron
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)
@@ -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` is set on the process
77
- # (opt-out hot-standby). Any Redis error is swallowed → false, so a
78
- # transient partition can't propagate as an exception into user code.
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 ENV[Wurk::Leader::OPT_OUT_ENV].to_s.downcase == 'false'
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
@@ -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
- LIFECYCLE_EVENTS = %i[startup quiet shutdown exit heartbeat beat leader].freeze
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: 1, queues: cap.queue_specs, concurrency: cap.concurrency)
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 → parsed via ENV TZ override (POSIX `tzset(3)`)
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
- with_tz_env(tz.to_s) { ::Time.at(epoch) }
202
- end
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
- def with_tz_env(name)
205
- old = ENV.fetch('TZ', nil)
206
- ENV['TZ'] = name
207
- yield
208
- ensure
209
- ENV['TZ'] = old
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 on retry-exhausted kills
9
- # (notify_failure: true), not on user-initiated UI kills.
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('Job killed')
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 `Wurk::Deploy.mark!(label: "abc")` builds a
28
- # one-shot Deploy and delegates. Matches Sidekiq's `Sidekiq::Deploy.mark!`.
29
- def self.mark!(label: nil, at: ::Time.now)
30
- new.mark!(label: label, at: at)
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)
@@ -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