wurk 1.9.0 → 1.10.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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/wurk/api_controller.rb +4 -1
  3. data/lib/wurk/boot_checks.rb +87 -0
  4. data/lib/wurk/capsule.rb +33 -8
  5. data/lib/wurk/cli.rb +73 -8
  6. data/lib/wurk/client/buffered.rb +240 -26
  7. data/lib/wurk/compat.rb +11 -0
  8. data/lib/wurk/configuration.rb +114 -16
  9. data/lib/wurk/cron.rb +45 -0
  10. data/lib/wurk/debounce.rb +2 -2
  11. data/lib/wurk/error_coalescer.rb +35 -0
  12. data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
  13. data/lib/wurk/fetcher/capped.rb +4 -0
  14. data/lib/wurk/fetcher/many_queues.rb +17 -1
  15. data/lib/wurk/fetcher/oom_drain.rb +102 -0
  16. data/lib/wurk/fetcher/orphan_grace.rb +60 -44
  17. data/lib/wurk/fetcher/private_list_key.rb +1 -16
  18. data/lib/wurk/fetcher/reaper.rb +169 -127
  19. data/lib/wurk/fetcher/reliable.rb +26 -4
  20. data/lib/wurk/fetcher/self_reconcile.rb +175 -0
  21. data/lib/wurk/fetcher/unit_of_work.rb +32 -8
  22. data/lib/wurk/health.rb +23 -4
  23. data/lib/wurk/health_probe.rb +64 -0
  24. data/lib/wurk/heartbeat.rb +55 -3
  25. data/lib/wurk/job/options.rb +9 -1
  26. data/lib/wurk/job.rb +2 -24
  27. data/lib/wurk/job_record.rb +17 -9
  28. data/lib/wurk/job_retry.rb +38 -10
  29. data/lib/wurk/job_set.rb +13 -5
  30. data/lib/wurk/launcher.rb +134 -52
  31. data/lib/wurk/leader.rb +9 -3
  32. data/lib/wurk/limiter/base.rb +17 -1
  33. data/lib/wurk/lua/debounce.lua +11 -6
  34. data/lib/wurk/lua/loader.rb +13 -1
  35. data/lib/wurk/lua.rb +71 -8
  36. data/lib/wurk/manager.rb +1 -1
  37. data/lib/wurk/metrics/process_counters.rb +27 -0
  38. data/lib/wurk/metrics/prometheus.rb +35 -14
  39. data/lib/wurk/middleware/current_attributes.rb +26 -10
  40. data/lib/wurk/middleware/poison_pill.rb +1 -2
  41. data/lib/wurk/process_set.rb +3 -1
  42. data/lib/wurk/processor.rb +82 -18
  43. data/lib/wurk/queue_consumers.rb +134 -0
  44. data/lib/wurk/rails_boot.rb +40 -2
  45. data/lib/wurk/rails_reloader.rb +51 -0
  46. data/lib/wurk/railtie.rb +20 -1
  47. data/lib/wurk/redact.rb +12 -0
  48. data/lib/wurk/redis_pool.rb +34 -9
  49. data/lib/wurk/scheduled.rb +142 -67
  50. data/lib/wurk/sentry/death_handler.rb +29 -0
  51. data/lib/wurk/sentry/error_handler.rb +52 -10
  52. data/lib/wurk/sentry/middleware.rb +8 -1
  53. data/lib/wurk/sentry/retry_policy.rb +12 -3
  54. data/lib/wurk/sentry.rb +42 -5
  55. data/lib/wurk/sorted_entry.rb +38 -13
  56. data/lib/wurk/swarm/child_boot.rb +42 -31
  57. data/lib/wurk/swarm/crash_watch.rb +65 -0
  58. data/lib/wurk/swarm.rb +79 -11
  59. data/lib/wurk/testing.rb +66 -0
  60. data/lib/wurk/version.rb +1 -1
  61. data/lib/wurk/web/extension.rb +6 -1
  62. data/lib/wurk/work_set.rb +6 -0
  63. data/lib/wurk/worker/setter.rb +11 -6
  64. data/lib/wurk/worker.rb +17 -1
  65. data/lib/wurk.rb +1 -1
  66. data/vendor/assets/dashboard/assets/{ArgsValue-DDqX6XWH.js → ArgsValue-CmaXArv8.js} +1 -1
  67. data/vendor/assets/dashboard/assets/{BatchDetail-lIytcn2C.js → BatchDetail-nc5yePZW.js} +1 -1
  68. data/vendor/assets/dashboard/assets/{Batches-BEumt-1O.js → Batches-DgEcyOZ_.js} +1 -1
  69. data/vendor/assets/dashboard/assets/{Busy-Vk9HmZcN.js → Busy-CUZncvCy.js} +1 -1
  70. data/vendor/assets/dashboard/assets/{Cron-CWcM-Cfr.js → Cron-CIZbaaZs.js} +1 -1
  71. data/vendor/assets/dashboard/assets/{Dashboard-Bgv1kusv.js → Dashboard-BWmfAook.js} +1 -1
  72. data/vendor/assets/dashboard/assets/{Dead-Di5Rct2H.js → Dead-CGKsTZHq.js} +1 -1
  73. data/vendor/assets/dashboard/assets/{Extension-LZY5-imc.js → Extension-Doy4bJoA.js} +1 -1
  74. data/vendor/assets/dashboard/assets/{FilterBox-BDo-7IAy.js → FilterBox-C4bO26_d.js} +1 -1
  75. data/vendor/assets/dashboard/assets/{FlowDetail-CehwRLTt.js → FlowDetail-Dh3SJdFh.js} +1 -1
  76. data/vendor/assets/dashboard/assets/{FlowState-glK2Tnf8.js → FlowState-yb7virqR.js} +1 -1
  77. data/vendor/assets/dashboard/assets/{Flows-cgy-Fsbp.js → Flows-C5SMRuhq.js} +1 -1
  78. data/vendor/assets/dashboard/assets/{JobDetailModal-Cuk3jruU.js → JobDetailModal-Cg6sAsJB.js} +1 -1
  79. data/vendor/assets/dashboard/assets/{Limiters-Ci8G2yZG.js → Limiters-BIxwDOJM.js} +1 -1
  80. data/vendor/assets/dashboard/assets/{Metrics-CQFUeZTR.js → Metrics-Qp9q0nF8.js} +1 -1
  81. data/vendor/assets/dashboard/assets/{NotFound-CYjhuDsI.js → NotFound-DwjPxFjU.js} +1 -1
  82. data/vendor/assets/dashboard/assets/{PageHeader-DjIAFTyX.js → PageHeader-vWeBbSPF.js} +1 -1
  83. data/vendor/assets/dashboard/assets/{Profiles-DTowqIRg.js → Profiles-7dabYVe3.js} +1 -1
  84. data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
  85. data/vendor/assets/dashboard/assets/{Retries-SJ3BaKC0.js → Retries-CNX8ZtTH.js} +1 -1
  86. data/vendor/assets/dashboard/assets/{Scheduled-DxSRpmFZ.js → Scheduled-ClVBj0nc.js} +1 -1
  87. data/vendor/assets/dashboard/assets/{Search-bBbi5JNK.js → Search-BAsVE2qw.js} +1 -1
  88. data/vendor/assets/dashboard/assets/{charts-Bg2YBcaD.js → charts-DZs138BV.js} +1 -1
  89. data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
  90. data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
  91. data/vendor/assets/dashboard/assets/{index-BOTVSP9M.js → index-CMzPFxNE.js} +3 -3
  92. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BRPNyV2M.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
  93. data/vendor/assets/dashboard/assets/{useSort-B-BLJik7.js → useSort-jBYVwXvg.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{utils-CGyrKpTh.js → utils-B0kQFNE8.js} +1 -1
  95. data/vendor/assets/dashboard/index.html +3 -3
  96. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  97. metadata +41 -30
  98. data/vendor/assets/dashboard/assets/Queues-DUGRPjME.js +0 -1
  99. data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +0 -1
  100. data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +0 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e5540591682172d88b9972b1cfc33cfca88c9c88d823c0b50fe80cb3556e8c08
4
- data.tar.gz: a19e9d8e68fd2f9f93131cfad4f845595bc95f45aacb65047da452da52916cdd
3
+ metadata.gz: ce85d34d0d8e1f5aa0837c76310a94c2ca3e584ff0e9ca4d9e977c0347510f89
4
+ data.tar.gz: 113f78f30121c728a4d7f3db5dc6d1472f13ab1b6b7b50d6ee73223d681585f4
5
5
  SHA512:
6
- metadata.gz: 0d264b8039051e56d2f6cec5aaebb4ce1f99d62ad498a247bece1b6464c316685f43562a76c87ec46f31e846ff6cb6d536f51a11b6d9d06348c32c999d4f2be5
7
- data.tar.gz: 9f695bfc7da4e5ad9e24e6874431a115b80bf3dab52f175a11b8f383d0586694b2d611f3137b6698eb896c1c2107ef18e97d33b7877a1a4fd3af60f380f0f47a
6
+ metadata.gz: ca00027cf185252fd95e78c3283a90d4a0c21acbc3cd6623bc1a4a1e2add8b6625d8436f60fc80b934a2fb35325d013903791dcad3606605b580b7633958fa9f
7
+ data.tar.gz: bcae6990d734afd7dbc30e951aead9b7d35b77d060c1198a11d4d34d17ba866e6e81ba1ab168a417b115c1a6a690e8cc4c9926951fec58615d7c2e40a9619fda
@@ -62,7 +62,10 @@ module Wurk
62
62
  end
63
63
 
64
64
  def queues
65
- render json: ::Wurk::Stats.new.queue_summaries.map { |q| ::Wurk::Api::Serializers.queue_summary(q) }
65
+ consumers = ::Wurk.redis(idempotent: true) { |conn| ::Wurk::QueueConsumers.counts(conn) }
66
+ render json: ::Wurk::Stats.new.queue_summaries.map { |q|
67
+ ::Wurk::Api::Serializers.queue_summary(q).merge(consumers: consumers.fetch(q.name, 0))
68
+ }
66
69
  end
67
70
 
68
71
  def queue
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'keys'
4
+ require_relative 'queue_consumers'
5
+
6
+ module Wurk
7
+ # What a standalone runner says once at boot, after the app loaded. Log only,
8
+ # and never allowed to stop a boot: none of this changes behaviour by backend,
9
+ # version or peers.
10
+ module BootChecks
11
+ # Oldest version each non-Redis backend has been run against.
12
+ TESTED_FLOORS = { 'dragonfly' => '1.34.0', 'valkey' => '8.0.0' }.freeze
13
+ MAX_LISTED = 20
14
+
15
+ module_function
16
+
17
+ def run(config, info)
18
+ log_backend(config.logger, info)
19
+ warn_on_foreign_processes(config)
20
+ warn_on_ignored_locks(config.logger)
21
+ rescue StandardError => e
22
+ config.logger.warn { "wurk boot checks skipped: #{e.class}: #{e.message}" }
23
+ end
24
+
25
+ def backend(info)
26
+ return ['dragonfly', info['dragonfly_version'].to_s.delete_prefix('df-v')] if info['dragonfly_version']
27
+ return ['valkey', info['valkey_version'].to_s] if info['valkey_version']
28
+
29
+ ['redis', info['redis_version'].to_s]
30
+ end
31
+
32
+ def log_backend(logger, info)
33
+ name, version = backend(info)
34
+ logger.info { "Wurk #{Wurk::VERSION} on #{name} #{version}" }
35
+ floor = TESTED_FLOORS[name]
36
+ return unless floor && ::Gem::Version.correct?(version) && ::Gem::Version.new(version) < ::Gem::Version.new(floor)
37
+
38
+ logger.warn { "#{name} #{version} is older than the oldest version Wurk is tested against (#{floor})." }
39
+ end
40
+
41
+ # Both runtimes share `processes` and the same queues, so a mix is safe only
42
+ # if it is deliberate. Wurk's heartbeat always carries `cores`; stock does not.
43
+ def warn_on_foreign_processes(config)
44
+ foreign = process_infos(config).reject { |_, info| info.nil? || ::JSON.parse(info).key?('cores') }.map(&:first)
45
+ return if foreign.empty?
46
+
47
+ config.logger.warn do
48
+ "Live processes that are not Wurk share this Redis: #{listing(foreign)}. " \
49
+ 'They fetch from the same queues. If this is not a deliberate rolling cutover, stop them.'
50
+ end
51
+ end
52
+
53
+ def process_infos(config)
54
+ config.redis(idempotent: true) do |conn|
55
+ ids = conn.call('SMEMBERS', Keys::PROCESSES)
56
+ ids.empty? ? [] : ids.zip(conn.pipelined { |pipe| ids.each { |id| pipe.call('HGET', id, 'info') } })
57
+ end
58
+ end
59
+
60
+ # Without the gem's middleware the option does nothing: the "unique" job
61
+ # simply enqueues twice. Only classes the app has loaded by now are seen.
62
+ def warn_on_ignored_locks(logger)
63
+ return if defined?(::SidekiqUniqueJobs)
64
+
65
+ classes = locked_job_classes
66
+ return if classes.empty?
67
+
68
+ logger.warn do
69
+ 'These jobs set `lock:` but sidekiq-unique-jobs is not loaded, so the option does nothing and ' \
70
+ "duplicates will run: #{listing(classes)}. Keep the gem, or move them to `unique_for:`."
71
+ end
72
+ end
73
+
74
+ def locked_job_classes
75
+ ::ObjectSpace.each_object(::Class).filter_map do |klass|
76
+ next unless klass.name && klass < Wurk::Worker
77
+
78
+ klass.name if klass.get_sidekiq_options.key?('lock')
79
+ end.sort
80
+ end
81
+
82
+ def listing(names)
83
+ extra = names.size - MAX_LISTED
84
+ names.first(MAX_LISTED).join(', ') + (extra.positive? ? " and #{extra} more" : '')
85
+ end
86
+ end
87
+ end
data/lib/wurk/capsule.rb CHANGED
@@ -53,6 +53,8 @@ module Wurk
53
53
  # %w[high default low] → mode :strict, weights all 0
54
54
  # %w[high,3 default,2 low,1] → mode :weighted, weights {q=>w}
55
55
  # %w[a,1 b,1 c,1] → mode :random, weights all 1
56
+ # %w[a,2 b] → mode :weighted, unweighted b fetched as if 1
57
+ # %w[a,0 b,0] → mode :strict, 0 being the same as no weight
56
58
  # `@queues` is expanded by weight so a uniform shuffle gives weighted
57
59
  # fairness (e.g. ["high","high","high","default","default","low"]).
58
60
  def queues=(val)
@@ -188,6 +190,16 @@ module Wurk
188
190
  @pools.clear
189
191
  end
190
192
 
193
+ # The fork-path form: closes idle sockets but keeps every pool usable, for a
194
+ # parent whose own threads may be mid-checkout.
195
+ def reload_redis_pools!
196
+ @pools.each_value(&:reload!)
197
+ end
198
+
199
+ def redis_pools_built?
200
+ !@pools.empty?
201
+ end
202
+
191
203
  def redis(idempotent: false, &)
192
204
  PoolCheckout.with(redis_pool, idempotent, &)
193
205
  end
@@ -229,19 +241,32 @@ module Wurk
229
241
  copy = server_middleware
230
242
  return copy if frozen?
231
243
 
232
- @config.server_middleware.entries.each do |entry|
244
+ global = @config.server_middleware.entries
245
+ global.each_with_index do |entry, index|
233
246
  next if @server_middleware_seen.include?(entry.klass)
234
247
 
235
248
  @server_middleware_seen << entry.klass
236
- next if copy.exists?(entry.klass)
237
-
238
- rebound = entry.dup
239
- rebound.config = self
240
- copy.entries << rebound
249
+ adopt_server_middleware(copy, entry, global.drop(index + 1)) unless copy.exists?(entry.klass)
241
250
  end
242
251
  copy
243
252
  end
244
253
 
254
+ def adopt_server_middleware(copy, entry, followers)
255
+ rebound = entry.dup
256
+ rebound.config = self
257
+ copy.entries.insert(refresh_position(copy, followers), rebound)
258
+ end
259
+
260
+ # Ahead of the first entry that follows it in the global chain, so a late
261
+ # `prepend` still wraps what it was prepended to. -1 appends.
262
+ def refresh_position(copy, followers)
263
+ followers.each do |follower|
264
+ position = copy.entries.index { |entry| entry.klass == follower.klass }
265
+ return position if position
266
+ end
267
+ -1
268
+ end
269
+
245
270
  # Drop-in fetch pluggability (spec §5 / §4.1): instantiate
246
271
  # `config[:fetch_class]` when the host set one — a custom or Pro fetcher —
247
272
  # else the default reliable BLMOVE fetcher (`Sidekiq::BasicFetch`). A
@@ -276,7 +301,7 @@ module Wurk
276
301
  return [qname, 0] if weight.nil?
277
302
 
278
303
  weight = Integer(weight)
279
- raise ArgumentError, "queue weight must be > 0: `#{entry}`" if weight <= 0
304
+ raise ArgumentError, "queue weight must not be negative: `#{entry}`" if weight.negative?
280
305
 
281
306
  [qname, weight]
282
307
  end
@@ -295,7 +320,7 @@ module Wurk
295
320
  def expand_by_weight(parsed, mode)
296
321
  return parsed.map(&:first) if %i[strict random].include?(mode)
297
322
 
298
- parsed.flat_map { |q, w| [q] * w }
323
+ parsed.flat_map { |q, w| [q] * [w, 1].max }
299
324
  end
300
325
 
301
326
  # `config.redis = { size: N }` pins the main pool at N; otherwise
data/lib/wurk/cli.rb CHANGED
@@ -11,6 +11,8 @@ require_relative 'configuration'
11
11
  require_relative 'component'
12
12
  require_relative 'capsule'
13
13
  require_relative 'launcher'
14
+ require_relative 'rails_boot'
15
+ require_relative 'boot_checks'
14
16
 
15
17
  module Wurk
16
18
  # Standalone CLI. Loads from `exe/wurk` — never loads `wurk/rails` so the
@@ -29,7 +31,7 @@ module Wurk
29
31
  # Subcommands. Bare `wurk` runs the worker — the historical shape, and the
30
32
  # only one the swarm binaries accept — so a subcommand is never anything
31
33
  # but the first argument, and every argument after it is that command's.
32
- COMMANDS = %w[api].freeze
34
+ COMMANDS = %w[api health].freeze
33
35
 
34
36
  # `wurk api` defaults. 0.0.0.0 because the point of the machine API is for
35
37
  # another service to reach it; it is bearer-gated, and answers 404 to
@@ -99,6 +101,15 @@ module Wurk
99
101
  ['-s', '--server NAME', :api_server, 'Rack handler to serve with (default: the first installed)']
100
102
  ].freeze
101
103
 
104
+ HEALTH_OPTION_FLAGS = [
105
+ ['-w', '--window SECONDS', :health_window, 'A heartbeat older than this does not count (default 60)', :to_i],
106
+ ['-m', '--min COUNT', :health_min, 'Processes on this host that must be beating (default 1)', :to_i],
107
+ ['-u', '--redis-url URL', :health_redis_url, 'Redis to read (default REDIS_URL)'],
108
+ ['-s', '--strict', :health_strict, 'Fail when Redis cannot be read, rather than passing']
109
+ ].freeze
110
+
111
+ HELP_COMMANDS = "\nCommands:\n health Exit 0 if this host's workers are heartbeating\n\nOptions:"
112
+
102
113
  attr_accessor :launcher, :environment, :config
103
114
 
104
115
  # The subcommand `parse` pulled off argv, or nil for the worker runner.
@@ -127,6 +138,8 @@ module Wurk
127
138
  def parse(args = ARGV.dup)
128
139
  @config ||= Wurk.default_configuration
129
140
  @command = extract_command!(args)
141
+ return parse_health_options(args) if @command == 'health'
142
+
130
143
  setup_options(args)
131
144
  initialize_logger
132
145
  validate!
@@ -134,7 +147,9 @@ module Wurk
134
147
  end
135
148
 
136
149
  # `boot_app:` / `warmup:` are test seams. Production always passes true.
137
- def run(boot_app: true, warmup: true)
150
+ def run(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
151
+ exit(run_health) if @command == 'health'
152
+
138
153
  # Mark server mode BEFORE the app loads so `configure_server` blocks in
139
154
  # the required initializer actually fire (they gate on `config.server?`).
140
155
  # Matches Sidekiq, which sets `Sidekiq.server = true` before requiring
@@ -149,6 +164,7 @@ module Wurk
149
164
  trap_signals(self_write)
150
165
  validate_redis!
151
166
  validate_pool_sizes!
167
+ BootChecks.run(@config, redis_info)
152
168
  @config[:identity] = identity
153
169
  # Force lazy server-middleware chain so worker threads don't race
154
170
  # against each other constructing it. Spec: Sidekiq::CLI line 104.
@@ -175,23 +191,28 @@ module Wurk
175
191
  # (`WURK_PRELOAD`/`SIDEKIQ_PRELOAD` Bundler groups, `WURK_PRELOAD_APP`/
176
192
  # `SIDEKIQ_PRELOAD_APP` whole-app eager-load) and boots `Process.warmup`
177
193
  # before the fork so children share warmed pages (copy-on-write). Spec §7.
178
- def run_swarm(boot_app: true, warmup: true)
194
+ def run_swarm(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
179
195
  raise ArgumentError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
180
196
 
181
197
  # Server mode before the app loads — see #run. The flag rides through the
182
198
  # fork into every child (the config object is copied), so configure_server
183
199
  # blocks registered in the parent take effect in the workers.
184
200
  enter_server_mode
201
+ # Only the children read it. Rejected here, a typo fails this boot instead of
202
+ # crash-looping the fleet until the crash-loop exit.
203
+ Health.options_from_env
185
204
  if boot_app
205
+ load_rails_framework
186
206
  preload_bundler_groups
187
207
  boot_application
188
208
  eager_load_application
189
209
  end
190
210
  validate_redis!
191
211
  validate_pool_sizes!
212
+ BootChecks.run(@config, redis_info)
192
213
  @config[:identity] = identity
193
214
  warm_up_process if warmup
194
- @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config,
215
+ @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config, exit_on_crash_loop: true,
195
216
  shutdown_timeout: @config[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
196
217
  begin
197
218
  @swarm.boot(install_signals: true)
@@ -202,6 +223,16 @@ module Wurk
202
223
  # A no-op once the loop already drained (no children, pipe closed).
203
224
  @swarm.shutdown
204
225
  end
226
+ exit(1) if @swarm.crash_looped?
227
+ end
228
+
229
+ # `wurk health` — see Wurk::HealthProbe. Boots nothing; returns the exit code.
230
+ def run_health
231
+ require_relative 'health_probe'
232
+ opts = @health_options
233
+ @config.redis = { url: opts[:health_redis_url] } if opts[:health_redis_url]
234
+ HealthProbe.new(@config, window: opts[:health_window] || HealthProbe::DEFAULT_WINDOW,
235
+ min: opts[:health_min] || 1, strict: opts[:health_strict] == true).call
205
236
  end
206
237
 
207
238
  # `wurk api` — serve the machine-facing HTTP API and nothing else: no
@@ -329,8 +360,8 @@ module Wurk
329
360
  def reopen_logs
330
361
  log = logger
331
362
  log.reopen if log.respond_to?(:reopen)
332
- rescue StandardError
333
- nil
363
+ rescue StandardError => e
364
+ warn "wurk: USR2 log reopen failed, still writing to the previous log: #{e.class}: #{e.message}"
334
365
  end
335
366
 
336
367
  def validate_redis!
@@ -372,6 +403,14 @@ module Wurk
372
403
  COMMANDS.include?(args.first) ? args.shift : nil
373
404
  end
374
405
 
406
+ def parse_health_options(args)
407
+ @health_options = {}
408
+ parser = option_parser(@health_options)
409
+ @parser = parser
410
+ parser.parse!(args)
411
+ self
412
+ end
413
+
375
414
  def setup_options(args)
376
415
  opts = parse_options(args)
377
416
  set_environment(opts[:environment])
@@ -458,8 +497,9 @@ module Wurk
458
497
  require 'optparse'
459
498
 
460
499
  ::OptionParser.new do |o|
461
- o.banner = @command ? "wurk #{@command} [options]" : 'wurk [options]'
462
- define_value_flags(o, opts, OPTION_FLAGS)
500
+ o.banner = @command ? "wurk #{@command} [options]" : "wurk [options]\nwurk COMMAND [options]"
501
+ o.separator HELP_COMMANDS unless @command
502
+ define_value_flags(o, opts, @command == 'health' ? HEALTH_OPTION_FLAGS : OPTION_FLAGS)
463
503
  define_value_flags(o, opts, API_OPTION_FLAGS) if @command == 'api'
464
504
  define_meta_flags(o, opts)
465
505
  end
@@ -492,6 +532,28 @@ module Wurk
492
532
 
493
533
  # --- application bootstrap --------------------------------------------
494
534
 
535
+ # A Rails app expects Rails to be loaded before its gems: `config/application.rb`
536
+ # requires the framework and only then calls `Bundler.require`, and gems read
537
+ # `defined?(Rails)` or reference ActiveModel while loading. Requiring the groups
538
+ # first broke those gems in this boot path only. So for a Rails target, run
539
+ # the app's own first half before the group preload.
540
+ def load_rails_framework
541
+ root = rails_app_root(@config[:require])
542
+ return unless root
543
+
544
+ ENV['RACK_ENV'] = ENV['RAILS_ENV'] = @environment
545
+ require 'rails'
546
+ define_active_job_adapter
547
+ require ::File.join(root, 'config/application.rb')
548
+ end
549
+
550
+ # The app root for a `-r` that is the app directory or its environment file.
551
+ def rails_app_root(require_path)
552
+ path = ::File.expand_path(require_path.to_s)
553
+ path = ::File.dirname(path, 2) if ::File.basename(path) == 'environment.rb'
554
+ path if ::File.directory?(path) && ::File.exist?(::File.join(path, 'config/application.rb'))
555
+ end
556
+
495
557
  # Spec §7.2/§7.3. Before the swarm parent forks, `Bundler.require` the
496
558
  # configured groups so their gems are resident in the parent and paged
497
559
  # copy-on-write into every child. Comma-separated group list; the native
@@ -554,6 +616,9 @@ module Wurk
554
616
  def boot_rails_application(require_path)
555
617
  require 'rails'
556
618
  define_active_job_adapter
619
+ # No railtie here (see boot_application), and a host's after_initialize hook
620
+ # may wrap the reloader, so it has to be the Rails one by then.
621
+ ::ActiveSupport.on_load(:after_initialize, yield: true) { |app| RailsBoot.install_reloader(app) }
557
622
  require ::File.expand_path("#{require_path}/config/environment.rb")
558
623
  @config[:tag] ||= default_tag(::Rails.root) if defined?(::Rails) && ::Rails.respond_to?(:root)
559
624
  end