wurk 1.9.0 → 1.11.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -9
  3. data/app/controllers/wurk/api_controller.rb +4 -1
  4. data/app/controllers/wurk/dashboard_controller.rb +11 -6
  5. data/exe/wurk +7 -2
  6. data/exe/wurkswarm +1 -2
  7. data/lib/generators/wurk/install/install_generator.rb +12 -0
  8. data/lib/sidekiq/web.rb +5 -0
  9. data/lib/wurk/boot_checks.rb +93 -0
  10. data/lib/wurk/capsule.rb +33 -8
  11. data/lib/wurk/cli.rb +204 -30
  12. data/lib/wurk/client/buffered.rb +240 -26
  13. data/lib/wurk/client.rb +16 -5
  14. data/lib/wurk/compat.rb +15 -0
  15. data/lib/wurk/configuration.rb +114 -16
  16. data/lib/wurk/cron.rb +141 -16
  17. data/lib/wurk/debounce.rb +2 -2
  18. data/lib/wurk/embedded.rb +8 -2
  19. data/lib/wurk/encryption.rb +20 -5
  20. data/lib/wurk/error_coalescer.rb +35 -0
  21. data/lib/wurk/fetcher/at_maxmemory.rb +79 -0
  22. data/lib/wurk/fetcher/capped.rb +4 -0
  23. data/lib/wurk/fetcher/many_queues.rb +17 -1
  24. data/lib/wurk/fetcher/oom_drain.rb +102 -0
  25. data/lib/wurk/fetcher/orphan_grace.rb +60 -44
  26. data/lib/wurk/fetcher/private_list_key.rb +1 -16
  27. data/lib/wurk/fetcher/reaper.rb +169 -127
  28. data/lib/wurk/fetcher/reliable.rb +26 -4
  29. data/lib/wurk/fetcher/self_reconcile.rb +175 -0
  30. data/lib/wurk/fetcher/unit_of_work.rb +32 -8
  31. data/lib/wurk/health.rb +23 -4
  32. data/lib/wurk/health_probe.rb +64 -0
  33. data/lib/wurk/heartbeat.rb +55 -3
  34. data/lib/wurk/job/options.rb +9 -1
  35. data/lib/wurk/job.rb +2 -24
  36. data/lib/wurk/job_record.rb +17 -9
  37. data/lib/wurk/job_retry.rb +38 -10
  38. data/lib/wurk/job_set.rb +13 -5
  39. data/lib/wurk/launcher.rb +134 -52
  40. data/lib/wurk/leader.rb +71 -11
  41. data/lib/wurk/limiter/base.rb +17 -1
  42. data/lib/wurk/lua/debounce.lua +11 -6
  43. data/lib/wurk/lua/loader.rb +13 -1
  44. data/lib/wurk/lua.rb +71 -8
  45. data/lib/wurk/manager.rb +1 -1
  46. data/lib/wurk/metrics/process_counters.rb +27 -0
  47. data/lib/wurk/metrics/prometheus.rb +35 -14
  48. data/lib/wurk/middleware/current_attributes.rb +26 -10
  49. data/lib/wurk/middleware/poison_pill.rb +1 -2
  50. data/lib/wurk/preflight/checks.rb +203 -0
  51. data/lib/wurk/preflight/command.rb +135 -0
  52. data/lib/wurk/preflight/keyspace.rb +109 -0
  53. data/lib/wurk/preflight/owners.rb +74 -0
  54. data/lib/wurk/preflight/payload_sample.rb +108 -0
  55. data/lib/wurk/preflight/reader.rb +60 -0
  56. data/lib/wurk/preflight/report.rb +82 -0
  57. data/lib/wurk/preflight/verify.rb +161 -0
  58. data/lib/wurk/preflight.rb +40 -0
  59. data/lib/wurk/process_set.rb +3 -1
  60. data/lib/wurk/processor.rb +82 -18
  61. data/lib/wurk/queue_consumers.rb +134 -0
  62. data/lib/wurk/rails_boot.rb +40 -2
  63. data/lib/wurk/rails_reloader.rb +51 -0
  64. data/lib/wurk/railtie.rb +20 -1
  65. data/lib/wurk/redact.rb +12 -0
  66. data/lib/wurk/redis_pool.rb +34 -9
  67. data/lib/wurk/scheduled.rb +142 -67
  68. data/lib/wurk/sentry/death_handler.rb +29 -0
  69. data/lib/wurk/sentry/error_handler.rb +52 -10
  70. data/lib/wurk/sentry/middleware.rb +8 -1
  71. data/lib/wurk/sentry/retry_policy.rb +12 -3
  72. data/lib/wurk/sentry.rb +42 -5
  73. data/lib/wurk/sorted_entry.rb +38 -13
  74. data/lib/wurk/swarm/child_boot.rb +44 -31
  75. data/lib/wurk/swarm/crash_watch.rb +65 -0
  76. data/lib/wurk/swarm/test_mode_guard.rb +32 -0
  77. data/lib/wurk/swarm.rb +79 -11
  78. data/lib/wurk/testing.rb +74 -0
  79. data/lib/wurk/version.rb +1 -1
  80. data/lib/wurk/web/config.rb +8 -4
  81. data/lib/wurk/web/extension.rb +41 -62
  82. data/lib/wurk/web/helpers.rb +133 -0
  83. data/lib/wurk/web/paginator.rb +49 -0
  84. data/lib/wurk/web/rack_app.rb +30 -6
  85. data/lib/wurk/work_set.rb +6 -0
  86. data/lib/wurk/worker/setter.rb +11 -6
  87. data/lib/wurk/worker.rb +17 -1
  88. data/lib/wurk.rb +1 -1
  89. data/vendor/assets/dashboard/assets/{ArgsValue-DDqX6XWH.js → ArgsValue-CmaXArv8.js} +1 -1
  90. data/vendor/assets/dashboard/assets/{BatchDetail-lIytcn2C.js → BatchDetail-nc5yePZW.js} +1 -1
  91. data/vendor/assets/dashboard/assets/{Batches-BEumt-1O.js → Batches-DgEcyOZ_.js} +1 -1
  92. data/vendor/assets/dashboard/assets/{Busy-Vk9HmZcN.js → Busy-CUZncvCy.js} +1 -1
  93. data/vendor/assets/dashboard/assets/{Cron-CWcM-Cfr.js → Cron-CIZbaaZs.js} +1 -1
  94. data/vendor/assets/dashboard/assets/{Dashboard-Bgv1kusv.js → Dashboard-BWmfAook.js} +1 -1
  95. data/vendor/assets/dashboard/assets/{Dead-Di5Rct2H.js → Dead-CGKsTZHq.js} +1 -1
  96. data/vendor/assets/dashboard/assets/{Extension-LZY5-imc.js → Extension-Doy4bJoA.js} +1 -1
  97. data/vendor/assets/dashboard/assets/{FilterBox-BDo-7IAy.js → FilterBox-C4bO26_d.js} +1 -1
  98. data/vendor/assets/dashboard/assets/{FlowDetail-CehwRLTt.js → FlowDetail-Dh3SJdFh.js} +1 -1
  99. data/vendor/assets/dashboard/assets/{FlowState-glK2Tnf8.js → FlowState-yb7virqR.js} +1 -1
  100. data/vendor/assets/dashboard/assets/{Flows-cgy-Fsbp.js → Flows-C5SMRuhq.js} +1 -1
  101. data/vendor/assets/dashboard/assets/{JobDetailModal-Cuk3jruU.js → JobDetailModal-Cg6sAsJB.js} +1 -1
  102. data/vendor/assets/dashboard/assets/{Limiters-Ci8G2yZG.js → Limiters-BIxwDOJM.js} +1 -1
  103. data/vendor/assets/dashboard/assets/{Metrics-CQFUeZTR.js → Metrics-Qp9q0nF8.js} +1 -1
  104. data/vendor/assets/dashboard/assets/{NotFound-CYjhuDsI.js → NotFound-DwjPxFjU.js} +1 -1
  105. data/vendor/assets/dashboard/assets/{PageHeader-DjIAFTyX.js → PageHeader-vWeBbSPF.js} +1 -1
  106. data/vendor/assets/dashboard/assets/{Profiles-DTowqIRg.js → Profiles-7dabYVe3.js} +1 -1
  107. data/vendor/assets/dashboard/assets/Queues-CMg-gyP8.js +1 -0
  108. data/vendor/assets/dashboard/assets/{Retries-SJ3BaKC0.js → Retries-CNX8ZtTH.js} +1 -1
  109. data/vendor/assets/dashboard/assets/{Scheduled-DxSRpmFZ.js → Scheduled-ClVBj0nc.js} +1 -1
  110. data/vendor/assets/dashboard/assets/{Search-bBbi5JNK.js → Search-BAsVE2qw.js} +1 -1
  111. data/vendor/assets/dashboard/assets/{charts-Bg2YBcaD.js → charts-DZs138BV.js} +1 -1
  112. data/vendor/assets/dashboard/assets/i18n-CBewVzQ_.js +1 -0
  113. data/vendor/assets/dashboard/assets/index--xvvNlYx.css +1 -0
  114. data/vendor/assets/dashboard/assets/{index-BOTVSP9M.js → index-CMzPFxNE.js} +3 -3
  115. data/vendor/assets/dashboard/assets/{useResetPageOnEmpty-BRPNyV2M.js → useResetPageOnEmpty-DxkLZCIm.js} +1 -1
  116. data/vendor/assets/dashboard/assets/{useSort-B-BLJik7.js → useSort-jBYVwXvg.js} +1 -1
  117. data/vendor/assets/dashboard/assets/{utils-CGyrKpTh.js → utils-B0kQFNE8.js} +1 -1
  118. data/vendor/assets/dashboard/index.html +3 -3
  119. data/vendor/assets/dashboard/wurk-manifest.json +2 -2
  120. metadata +53 -30
  121. data/vendor/assets/dashboard/assets/Queues-DUGRPjME.js +0 -1
  122. data/vendor/assets/dashboard/assets/i18n-BZClP62Q.js +0 -1
  123. data/vendor/assets/dashboard/assets/index-Rewk4eSE.css +0 -1
data/lib/wurk/cli.rb CHANGED
@@ -6,11 +6,14 @@
6
6
  # a Rails app that enqueues and processes jobs pays that on every boot for a
7
7
  # command-line parser it never runs. `require` is idempotent, so the lazy call
8
8
  # is a $LOADED_FEATURES hash lookup on every invocation after the first.
9
+ require 'rbconfig'
9
10
  require_relative 'version'
10
11
  require_relative 'configuration'
11
12
  require_relative 'component'
12
13
  require_relative 'capsule'
13
14
  require_relative 'launcher'
15
+ require_relative 'rails_boot'
16
+ require_relative 'boot_checks'
14
17
 
15
18
  module Wurk
16
19
  # Standalone CLI. Loads from `exe/wurk` — never loads `wurk/rails` so the
@@ -29,7 +32,15 @@ module Wurk
29
32
  # Subcommands. Bare `wurk` runs the worker — the historical shape, and the
30
33
  # only one the swarm binaries accept — so a subcommand is never anything
31
34
  # but the first argument, and every argument after it is that command's.
32
- COMMANDS = %w[api].freeze
35
+ COMMANDS = %w[api health].freeze
36
+ # Set only across the one re-exec of #load_rails_framework, and removed on arrival.
37
+ PRELOAD_FIRST_ENV = 'WURK_BOOT_PRELOAD_FIRST'
38
+ # `bundle exec` loads the binstub in-process, so argv0 is Bundler's executable, and
39
+ # `ruby -e "load Gem.bin_path(...)"` has `-e` there: the running program can't name it.
40
+ SWARM_EXECUTABLE = ::File.expand_path('../../exe/wurkswarm', __dir__)
41
+
42
+ # A refusal this file raises itself, with nothing a trace would add.
43
+ class StartupError < ArgumentError; end
33
44
 
34
45
  # `wurk api` defaults. 0.0.0.0 because the point of the machine API is for
35
46
  # another service to reach it; it is bearer-gated, and answers 404 to
@@ -99,6 +110,23 @@ module Wurk
99
110
  ['-s', '--server NAME', :api_server, 'Rack handler to serve with (default: the first installed)']
100
111
  ].freeze
101
112
 
113
+ HEALTH_OPTION_FLAGS = [
114
+ ['-w', '--window SECONDS', :health_window, 'A heartbeat older than this does not count (default 60)', :to_i],
115
+ ['-m', '--min COUNT', :health_min, 'Processes on this host that must be beating (default 1)', :to_i],
116
+ ['-u', '--redis-url URL', :health_redis_url, 'Redis to read (default REDIS_URL)'],
117
+ ['-s', '--strict', :health_strict, 'Fail when Redis cannot be read, rather than passing']
118
+ ].freeze
119
+
120
+ HELP_COMMANDS = "\nCommands:\n " \
121
+ "health Exit 0 if this host's workers are heartbeating\n " \
122
+ "preflight Read a live Redis: what will carry over to Wurk (read-only)\n " \
123
+ "verify Post-cutover check of a live fleet (read-only)\n\nOptions:"
124
+ HELP_API_NOTE = "\nThe worker flags -c, -g, -q and -t are accepted here and have no effect.\n\nOptions:"
125
+ HELP_SWARM_NOTE = "\nForks WURK_COUNT worker processes from a preloaded parent and supervises them.\n\nOptions:"
126
+ HELP_FOOTER = "\nEnvironment:\n REDIS_URL Redis to use (default redis://localhost:6379/0)\n " \
127
+ "WURK_COUNT Worker processes for wurkswarm\n\n" \
128
+ 'Docs: https://github.com/developerz-ai/wurk#readme'
129
+
102
130
  attr_accessor :launcher, :environment, :config
103
131
 
104
132
  # The subcommand `parse` pulled off argv, or nil for the worker runner.
@@ -127,14 +155,44 @@ module Wurk
127
155
  def parse(args = ARGV.dup)
128
156
  @config ||= Wurk.default_configuration
129
157
  @command = extract_command!(args)
158
+ return parse_health_options(args) if @command == 'health'
159
+
130
160
  setup_options(args)
131
161
  initialize_logger
132
162
  validate!
133
163
  self
134
164
  end
135
165
 
166
+ # What the binaries print for a failed start. Only errors this file fully
167
+ # understands drop the trace. Anything else may be a bug in the host app, whose
168
+ # file and line are the point, and asking for -v means changing a deployed command.
169
+ def self.report_failure(program, error, argv: ARGV)
170
+ warn "#{program}: #{error.message}"
171
+ hint = failure_hint(error)
172
+ warn "#{program}: #{hint}" if hint
173
+ return unless error.backtrace
174
+ return unless failure_trace_wanted?(error, hint) || argv.intersect?(%w[-v --verbose])
175
+
176
+ warn error.backtrace.join("\n")
177
+ end
178
+
179
+ def self.failure_trace_wanted?(error, hint) = hint.nil? && !error.is_a?(StartupError)
180
+ private_class_method :failure_trace_wanted?
181
+
182
+ def self.failure_hint(error)
183
+ case error
184
+ when ::RedisClient::ConnectionError
185
+ 'could not reach Redis; check REDIS_URL'
186
+ when ->(e) { defined?(::OptionParser::ParseError) && e.is_a?(::OptionParser::ParseError) }
187
+ 'see --help'
188
+ end
189
+ end
190
+ private_class_method :failure_hint
191
+
136
192
  # `boot_app:` / `warmup:` are test seams. Production always passes true.
137
- def run(boot_app: true, warmup: true)
193
+ def run(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
194
+ exit(run_health) if @command == 'health'
195
+
138
196
  # Mark server mode BEFORE the app loads so `configure_server` blocks in
139
197
  # the required initializer actually fire (they gate on `config.server?`).
140
198
  # Matches Sidekiq, which sets `Sidekiq.server = true` before requiring
@@ -144,11 +202,13 @@ module Wurk
144
202
  # false`).
145
203
  enter_server_mode
146
204
  boot_application if boot_app
205
+ Swarm::TestModeGuard.enforce!(@config.logger)
147
206
  self_read, self_write = ::IO.pipe
148
207
  begin
149
208
  trap_signals(self_write)
150
209
  validate_redis!
151
210
  validate_pool_sizes!
211
+ BootChecks.run(@config, redis_info)
152
212
  @config[:identity] = identity
153
213
  # Force lazy server-middleware chain so worker threads don't race
154
214
  # against each other constructing it. Spec: Sidekiq::CLI line 104.
@@ -175,23 +235,28 @@ module Wurk
175
235
  # (`WURK_PRELOAD`/`SIDEKIQ_PRELOAD` Bundler groups, `WURK_PRELOAD_APP`/
176
236
  # `SIDEKIQ_PRELOAD_APP` whole-app eager-load) and boots `Process.warmup`
177
237
  # before the fork so children share warmed pages (copy-on-write). Spec §7.
178
- def run_swarm(boot_app: true, warmup: true)
179
- raise ArgumentError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
238
+ def run_swarm(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
239
+ raise StartupError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command
180
240
 
181
241
  # Server mode before the app loads — see #run. The flag rides through the
182
242
  # fork into every child (the config object is copied), so configure_server
183
243
  # blocks registered in the parent take effect in the workers.
184
244
  enter_server_mode
245
+ # Only the children read it. Rejected here, a typo fails this boot instead of
246
+ # crash-looping the fleet until the crash-loop exit.
247
+ Health.options_from_env
185
248
  if boot_app
249
+ load_rails_framework
186
250
  preload_bundler_groups
187
251
  boot_application
188
252
  eager_load_application
189
253
  end
190
254
  validate_redis!
191
255
  validate_pool_sizes!
256
+ BootChecks.run(@config, redis_info)
192
257
  @config[:identity] = identity
193
258
  warm_up_process if warmup
194
- @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config,
259
+ @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config, exit_on_crash_loop: true,
195
260
  shutdown_timeout: @config[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
196
261
  begin
197
262
  @swarm.boot(install_signals: true)
@@ -202,6 +267,16 @@ module Wurk
202
267
  # A no-op once the loop already drained (no children, pipe closed).
203
268
  @swarm.shutdown
204
269
  end
270
+ exit(1) if @swarm.crash_looped?
271
+ end
272
+
273
+ # `wurk health` — see Wurk::HealthProbe. Boots nothing; returns the exit code.
274
+ def run_health
275
+ require_relative 'health_probe'
276
+ opts = @health_options
277
+ @config.redis = { url: opts[:health_redis_url] } if opts[:health_redis_url]
278
+ HealthProbe.new(@config, window: opts[:health_window] || HealthProbe::DEFAULT_WINDOW,
279
+ min: opts[:health_min] || 1, strict: opts[:health_strict] == true).call
205
280
  end
206
281
 
207
282
  # `wurk api` — serve the machine-facing HTTP API and nothing else: no
@@ -223,7 +298,7 @@ module Wurk
223
298
  # Load the app here, not on the first request: a broken load should fail
224
299
  # the boot an operator is watching, not the first client to call.
225
300
  require_relative 'api/app'
226
- raise ArgumentError, NO_API_TOKEN_MESSAGE unless @config.api_enabled?
301
+ raise StartupError, NO_API_TOKEN_MESSAGE unless @config.api_enabled?
227
302
 
228
303
  handler ||= api_handler(@config[:api_server])
229
304
  logger.info { "Wurk API listening on http://#{api_bind}:#{api_port}#{Wurk::API::VERSION_PREFIX}" }
@@ -270,8 +345,8 @@ module Wurk
270
345
  registry = handler_registry
271
346
  name ? registry.get(name) : registry.default
272
347
  rescue ::LoadError, ::NameError, ::ArgumentError => e
273
- raise ArgumentError, "wurk api could not start a web server (#{e.message}). Add one to your Gemfile " \
274
- '— puma, falcon, or rackup + webrick — or name an installed one with --server.'
348
+ raise StartupError, "wurk api could not start a web server (#{e.message}). Add one to your Gemfile " \
349
+ '— puma, falcon, or rackup + webrick — or name an installed one with --server.'
275
350
  end
276
351
 
277
352
  def handler_registry
@@ -323,14 +398,15 @@ module Wurk
323
398
  end
324
399
 
325
400
  def signal_names
326
- %w[INT TERM TSTP TTIN INFO USR2]
401
+ # INFO exists on BSD and macOS only; asking for it on Linux just warned at every boot.
402
+ %w[INT TERM TSTP TTIN INFO USR2].select { |name| ::Signal.list.key?(name) }
327
403
  end
328
404
 
329
405
  def reopen_logs
330
406
  log = logger
331
407
  log.reopen if log.respond_to?(:reopen)
332
- rescue StandardError
333
- nil
408
+ rescue StandardError => e
409
+ warn "wurk: USR2 log reopen failed, still writing to the previous log: #{e.class}: #{e.message}"
334
410
  end
335
411
 
336
412
  def validate_redis!
@@ -358,7 +434,7 @@ module Wurk
358
434
 
359
435
  def validate_pool_sizes!
360
436
  @config.capsules.each_pair do |name, cap|
361
- raise ArgumentError, "Pool size too small for #{name}" if cap.redis_pool.size < cap.concurrency
437
+ raise StartupError, "Pool size too small for #{name}" if cap.redis_pool.size < cap.concurrency
362
438
  end
363
439
  end
364
440
 
@@ -372,6 +448,14 @@ module Wurk
372
448
  COMMANDS.include?(args.first) ? args.shift : nil
373
449
  end
374
450
 
451
+ def parse_health_options(args)
452
+ @health_options = {}
453
+ parser = option_parser(@health_options)
454
+ @parser = parser
455
+ parser.parse!(args)
456
+ self
457
+ end
458
+
375
459
  def setup_options(args)
376
460
  opts = parse_options(args)
377
461
  set_environment(opts[:environment])
@@ -416,7 +500,7 @@ module Wurk
416
500
  end
417
501
 
418
502
  def parse_config(path)
419
- raise ArgumentError, "No such file #{path}" unless ::File.exist?(path)
503
+ raise StartupError, "No such file #{path}" unless ::File.exist?(path)
420
504
 
421
505
  require 'erb'
422
506
  require 'yaml'
@@ -451,6 +535,7 @@ module Wurk
451
535
  parser = option_parser(opts)
452
536
  @parser = parser
453
537
  parser.parse!(argv)
538
+ warn "wurk: ignoring #{argv.join(' ')}: not a command or a flag (see --help)" unless argv.empty?
454
539
  opts
455
540
  end
456
541
 
@@ -458,16 +543,37 @@ module Wurk
458
543
  require 'optparse'
459
544
 
460
545
  ::OptionParser.new do |o|
461
- o.banner = @command ? "wurk #{@command} [options]" : 'wurk [options]'
462
- define_value_flags(o, opts, OPTION_FLAGS)
546
+ o.banner = help_banner
547
+ o.separator help_intro if help_intro
548
+ define_value_flags(o, opts, @command == 'health' ? HEALTH_OPTION_FLAGS : OPTION_FLAGS)
463
549
  define_value_flags(o, opts, API_OPTION_FLAGS) if @command == 'api'
464
550
  define_meta_flags(o, opts)
465
551
  end
466
552
  end
467
553
 
554
+ def swarm_binary? = ::File.basename($PROGRAM_NAME).include?('swarm')
555
+
556
+ def help_banner
557
+ return "wurk #{@command} [options]" if @command
558
+ return "#{::File.basename($PROGRAM_NAME)} [options]" if swarm_binary?
559
+
560
+ "wurk [options]\nwurk COMMAND [options]"
561
+ end
562
+
563
+ def help_intro
564
+ return HELP_API_NOTE if @command == 'api'
565
+ return if @command
566
+
567
+ swarm_binary? ? HELP_SWARM_NOTE : HELP_COMMANDS
568
+ end
569
+
468
570
  def define_value_flags(parser, opts, flags)
469
571
  flags.each do |short, long, key, desc, transform|
470
- parser.on(short, long, desc) { |arg| assign_flag(opts, key, arg, transform) }
572
+ parser.on(short, long, desc) do |arg|
573
+ assign_flag(opts, key, arg, transform)
574
+ rescue ArgumentError
575
+ raise StartupError, "#{long.split.first}: #{arg.inspect} is not a number"
576
+ end
471
577
  end
472
578
  end
473
579
 
@@ -485,19 +591,78 @@ module Wurk
485
591
  exit(0)
486
592
  end
487
593
  parser.on_tail('-h', '--help', 'Show help') do
488
- puts parser
594
+ puts parser, HELP_FOOTER
489
595
  exit(0)
490
596
  end
491
597
  end
492
598
 
493
599
  # --- application bootstrap --------------------------------------------
494
600
 
601
+ # A Rails target has two shapes that need opposite orders. A gem may need Rails
602
+ # loaded while it requires: `config/application.rb` requires the framework and
603
+ # only then calls `Bundler.require`, so running the app's first half before the
604
+ # group preload suits it. An app may instead use a gem it expects the swarm to have
605
+ # preloaded, as stock sidekiqswarm's order (spec §7.3) gives it, and then that
606
+ # file raises NameError here. Only the file can tell us which it is, and by then
607
+ # its class body may have half-run, registering initializers and middleware that a
608
+ # second load in this process would register again. So the retry is a fresh process:
609
+ # same pid, same environment as before the attempt, preload first. Once only, so a
610
+ # genuine bug surfaces with its own message.
611
+ def load_rails_framework
612
+ root = rails_app_root(@config[:require])
613
+ return unless root
614
+
615
+ restart = restart_state
616
+ first_error = ENV.delete(PRELOAD_FIRST_ENV)
617
+ ENV['RACK_ENV'] = ENV['RAILS_ENV'] = @environment
618
+ require 'rails'
619
+ define_active_job_adapter
620
+ return note_preload_first(first_error) if first_error
621
+
622
+ begin
623
+ require ::File.join(root, 'config/application.rb')
624
+ rescue ::NameError => e
625
+ restart_with_preload_first(restart, e)
626
+ end
627
+ end
628
+
629
+ def restart_state
630
+ { env: ::ENV.to_h, argv: ::ARGV.dup, dir: ::Dir.pwd }
631
+ end
632
+
633
+ def note_preload_first(first_error)
634
+ logger.warn do
635
+ "config/application.rb raised before the preload (#{first_error}), so this boot preloads first. " \
636
+ 'If it fails too, that first error may be the one to fix.'
637
+ end
638
+ end
639
+
640
+ # Bundler may have arrived only inside the failed attempt (`config/boot.rb`), and the
641
+ # snapshot predates it, so the fresh process is told about it explicitly. If the
642
+ # exec itself fails, the app's error is still the one worth reporting.
643
+ def restart_with_preload_first(state, error)
644
+ env = state[:env].merge(PRELOAD_FIRST_ENV => error.message.lines.first.to_s.strip[0, 300])
645
+ env['BUNDLE_GEMFILE'] ||= ::Bundler.default_gemfile.to_s if defined?(::Bundler)
646
+ ruby = [::RbConfig.ruby, *('-rbundler/setup' if defined?(::Bundler))]
647
+ ::Process.exec(env, *ruby, SWARM_EXECUTABLE, *state[:argv], unsetenv_others: true, chdir: state[:dir])
648
+ rescue ::SystemCallError
649
+ raise error
650
+ end
651
+
652
+ # The app root for a `-r` that is the app directory or its environment file.
653
+ def rails_app_root(require_path)
654
+ path = ::File.expand_path(require_path.to_s)
655
+ path = ::File.dirname(path, 2) if ::File.basename(path) == 'environment.rb'
656
+ path if ::File.directory?(path) && ::File.exist?(::File.join(path, 'config/application.rb'))
657
+ end
658
+
495
659
  # Spec §7.2/§7.3. Before the swarm parent forks, `Bundler.require` the
496
660
  # configured groups so their gems are resident in the parent and paged
497
661
  # copy-on-write into every child. Comma-separated group list; the native
498
662
  # `WURK_PRELOAD` wins over the `SIDEKIQ_PRELOAD` drop-in alias. Defaults to
499
663
  # the `default` group; an explicit empty value (`WURK_PRELOAD=`) disables
500
- # the preload. Runs before `boot_application` so groups load before the app.
664
+ # the preload. Runs before `boot_application`, so before the initializers; see
665
+ # #load_rails_framework for where `config/application.rb` falls.
501
666
  def preload_bundler_groups
502
667
  return unless defined?(::Bundler)
503
668
 
@@ -554,6 +719,9 @@ module Wurk
554
719
  def boot_rails_application(require_path)
555
720
  require 'rails'
556
721
  define_active_job_adapter
722
+ # No railtie here (see boot_application), and a host's after_initialize hook
723
+ # may wrap the reloader, so it has to be the Rails one by then.
724
+ ::ActiveSupport.on_load(:after_initialize, yield: true) { |app| RailsBoot.install_reloader(app) }
557
725
  require ::File.expand_path("#{require_path}/config/environment.rb")
558
726
  @config[:tag] ||= default_tag(::Rails.root) if defined?(::Rails) && ::Rails.respond_to?(:root)
559
727
  end
@@ -576,23 +744,29 @@ module Wurk
576
744
  end
577
745
 
578
746
  def validate!
579
- require_path = @config[:require]
580
- bad_path = !::File.exist?(require_path.to_s)
581
- bad_rails = ::File.directory?(require_path.to_s) &&
582
- !::File.exist?(::File.join(require_path, 'config/application.rb'))
583
- return print_help_and_die if bad_path || bad_rails
747
+ reason = require_path_problem(@config[:require].to_s)
748
+ return print_help_and_die(reason) if reason
584
749
 
585
750
  %i[concurrency timeout].each do |opt|
586
- raise ArgumentError, "#{opt}: #{@config[opt]} is not a valid value" if @config[opt].to_i <= 0
751
+ raise StartupError, "#{opt}: #{@config[opt]} is not a valid value" if @config[opt].to_i <= 0
587
752
  end
588
753
  end
589
754
 
590
- def print_help_and_die
591
- logger.info '=================================================================='
592
- logger.info ' Please point Wurk to a Rails application or a Ruby file'
593
- logger.info ' to load your job classes with -r [DIR|FILE].'
594
- logger.info '=================================================================='
595
- logger.info @parser
755
+ def require_path_problem(path)
756
+ return 'no such file or directory' unless ::File.exist?(path)
757
+
758
+ return unless ::File.directory?(path) && !::File.exist?(::File.join(path, 'config/application.rb'))
759
+
760
+ 'it has no config/application.rb'
761
+ end
762
+
763
+ def print_help_and_die(reason)
764
+ logger.error '=================================================================='
765
+ logger.error " Wurk cannot load job classes from #{@config[:require]}: #{reason}."
766
+ logger.error ' Please point Wurk to a Rails application or a Ruby file'
767
+ logger.error ' to load your job classes with -r [DIR|FILE].'
768
+ logger.error '=================================================================='
769
+ logger.error @parser.to_s
596
770
  exit(1)
597
771
  end
598
772
  end