Module: Wurk::RailsBoot

Defined in:
lib/wurk/rails_boot.rb

Overview

Coordinates how Wurk boots inside a Rails host: whether this process should boot at all, whether it may fork the swarm, and the boot itself. The Railtie owns only the Rails hooks (config namespace + server_mode initializer + after_initialize) and delegates every decision and side effect here, so the boot policy stays pure and unit-testable without the Railtie DSL. See docs/idea/03-process-model.md for the exact ordering.

Constant Summary collapse

DRAIN_JOIN_SLACK =

What at_exit waits for the supervise thread on top of the swarm's own drain budget: one supervise tick to notice the request, plus slack for the final reap.

1

Class Method Summary collapse

Class Method Details

.boot(app = ::Rails.application) ⇒ Object

Invoked from after_initialize, once the host app has fully initialized.



32
33
34
35
36
37
38
39
40
# File 'lib/wurk/rails_boot.rb', line 32

def boot(app = ::Rails.application)
  return if skip_boot?

  case boot_action(app)
  when :fork   then boot_swarm
  when :embed  then boot_embedded
  when :refuse then refuse_preforking_boot
  end
end

.boot_action(app = ::Rails.application) ⇒ Object

What boot should do once skip_boot? is false. Pure — reads env / loaded constants / host config, no side effects — so the boot decision is unit-testable without forking:

:fork   — safe to fork the swarm in the background (the default).
:refuse — a preforking web server owns process forking here; don't.
:embed  — host opted into in-process threads-only via embed_in_web.


58
59
60
61
62
# File 'lib/wurk/rails_boot.rb', line 58

def boot_action(app = ::Rails.application)
  return :fork unless preforking_web_server?

  embed_in_web?(app) ? :embed : :refuse
end

.boot_embedded(embedded = Wurk::Embedded) ⇒ Object

Sidekiq-embedded parity: a threads-only worker inside the web process, no fork. Redis validation failure keeps the host serving HTTP (log + carry on). at_exit drains on the graceful shutdown the web server runs on TERM.



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/wurk/rails_boot.rb', line 154

def boot_embedded(embedded = Wurk::Embedded)
  instance = embedded.new(Wurk.configuration)
  # Registered BEFORE run, for the reason boot_swarm registers before the
  # fork: run brings the heartbeat, pollers, managers and health listener up
  # one at a time, and a raise partway through would otherwise leave the ones
  # already running with nothing to drain them on host exit.
  at_exit { instance.stop }
  instance.run
  logger.info { 'wurk: running embedded in the web process (config.wurk.embed_in_web) — threads only, no fork' }
  instance
rescue StandardError => e
  logger.error { "wurk: embedded boot failed: #{e.class}: #{e.message}" }
  instance&.stop
  nil
end

.boot_swarmObject



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
# File 'lib/wurk/rails_boot.rb', line 110

def boot_swarm
  timeout = Wurk.configuration[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT
  swarm = Wurk::Swarm.new(topology: Wurk.configuration.topology, shutdown_timeout: timeout)
  supervisor = nil
  # Registered BEFORE boot, which forks the children one slot at a time: a
  # fork that raises partway through would otherwise leave the children it
  # already spawned with nothing to drain them on host exit. Children
  # inherit the hook — stop_swarm no-ops off the process that forked them.
  at_exit { stop_swarm(swarm, supervisor, timeout + Swarm::SHUTDOWN_GRACE + DRAIN_JOIN_SLACK) }
  # Co-hosted in the web process (e.g. Puma single mode): the host owns the
  # process-wide TERM/INT traps. Installing the swarm's own would hijack
  # them — a deploy TERM would drain the swarm but never stop the HTTP
  # server. Let the host keep signal ownership and drain the swarm on its
  # graceful exit (same contract as boot_embedded).
  swarm.boot(install_signals: false)
  # supervise must still run somewhere or crashed children never respawn and
  # memory checks never fire. A background thread keeps the host's main
  # thread free to serve HTTP.
  supervisor = Thread.new do
    swarm.supervise
  rescue StandardError => e
    logger.error { "wurk supervisor thread died: #{e.class}: #{e.message}" }
  end
end

.building?Boolean

A build/precompile step must never fork the swarm (#247). The default Rails Dockerfile runs SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile; that loads :environment → fires after_initialize, but there's no Redis during docker build, so a fork would hang/fail the build. Same for other env-loading rake tasks (db:prepare, db:migrate). The real server path is unaffected: rails server / puma boot through Rails::Command, not Rake, and don't set the dummy secret.

Returns:

  • (Boolean)


199
200
201
202
203
204
205
# File 'lib/wurk/rails_boot.rb', line 199

def building?
  return true if ENV.key?('SECRET_KEY_BASE_DUMMY')

  defined?(::Rake) && ::Rake.application.top_level_tasks.any?
rescue StandardError
  false
end

.embed_in_web?(app = ::Rails.application) ⇒ Boolean

Host opt-in (config/application.rb): run workers as in-process threads instead of forking, like Sidekiq embedded. Set it in application.rb, not an initializer — server mode is decided before initializers load.

Returns:

  • (Boolean)


104
105
106
107
108
# File 'lib/wurk/rails_boot.rb', line 104

def embed_in_web?(app = ::Rails.application)
  app.config.wurk&.embed_in_web == true
rescue StandardError
  false
end

.enter_server_mode_if_serving(app = ::Rails.application) ⇒ Object

Invoked from the wurk.server_mode initializer, before config/initializers load. This Rails process forks the workers, so it IS the server. Enter server mode now — otherwise the app's Sidekiq.configure_server blocks gate on config.server? (still false) and are silently dropped. A process that won't run workers (skip_boot?, or one that refuses to boot under a preforking web server) is not a server.



24
25
26
27
28
29
# File 'lib/wurk/rails_boot.rb', line 24

def enter_server_mode_if_serving(app = ::Rails.application)
  return if skip_boot?
  return if boot_action(app) == :refuse

  Wurk.enter_server_mode
end

.loggerObject



188
189
190
# File 'lib/wurk/rails_boot.rb', line 188

def logger
  Wurk.configuration.logger
end

.preforking_web_server?Boolean

Preforking / clustered web servers (Puma cluster, Unicorn, Passenger) fork their own worker processes. Forking the swarm from after_initialize in one of them is the highest-risk boot path: without app preloading every server-worker re-runs the hook and forks its own full swarm (N× oversubscription); with preloading the swarm supervisor ends up entangled with the server's own fork/signal supervision. Detect the common three.

Returns:

  • (Boolean)


70
71
72
73
74
75
# File 'lib/wurk/rails_boot.rb', line 70

def preforking_web_server?
  return true if defined?(::PhusionPassenger)
  return true if defined?(::Unicorn)

  puma_cluster?
end

.puma_cluster?Boolean

Puma only preforks in cluster mode (workers > 0); single mode is threaded and safe to co-host the swarm. Best-effort: a missed cluster falls through to the historical fork path; an over-eager match is escapable via embed_in_web / WURK_DISABLED / running the swarm as its own process.

Returns:

  • (Boolean)


81
82
83
84
85
86
# File 'lib/wurk/rails_boot.rb', line 81

def puma_cluster?
  return false unless defined?(::Puma)

  count = puma_worker_count
  count.is_a?(Integer) && count.positive?
end

.puma_worker_countObject

Worker count from Puma's parsed CLI config when the server booted it, else from WEB_CONCURRENCY (the near-universal convention for Puma workers). Guarded — the accessor and options shape vary across Puma versions.



91
92
93
94
95
96
97
98
99
# File 'lib/wurk/rails_boot.rb', line 91

def puma_worker_count
  cfg = ::Puma.cli_config if ::Puma.respond_to?(:cli_config)
  workers = cfg&.options&.[](:workers)
  return workers.to_i if workers

  ENV['WEB_CONCURRENCY']&.to_i
rescue StandardError
  nil
end

.refuse_preforking_bootObject



170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/wurk/rails_boot.rb', line 170

def refuse_preforking_boot
  logger.warn { <<~MSG }
    wurk: preforking web server detected (Puma cluster / Unicorn / Passenger).
    Refusing to fork the worker swarm from a process that forks its own workers.
    Run the swarm as its own process instead:

        bundle exec wurkswarm    # forked swarm, real parallelism
        bundle exec wurk         # single process, thread pool

    Or run workers inside this web process (threads only, no fork):

        # config/application.rb
        config.wurk.embed_in_web = true

    Already running workers elsewhere? Set WURK_DISABLED=1 here to silence this.
  MSG
end

.skip_boot?Boolean

A process that won't run workers isn't a server: skip both server mode and the swarm boot. Console mode is detected reliably here — the console command file defines ::Rails::Console before initializers run.

Returns:

  • (Boolean)


45
46
47
48
49
50
# File 'lib/wurk/rails_boot.rb', line 45

def skip_boot?
  ENV['WURK_DISABLED'] == '1' ||
    building? ||
    defined?(::Rails::Console) ||
    ::Rails.env.test?
end

.stop_swarm(swarm, supervisor, drain_wait) ⇒ Object

at_exit fires on the host's main thread, but the supervise thread owns the child table and two threads inside shutdown race on it — so request the drain and wait for the supervisor to run it. Draining here is the fallback for when no live supervisor will: it never started (boot raised) or it died early; after one that drained it finds no children and no-ops. A supervisor still alive past the join is wedged mid-drain — leave it be rather than race it; its children self-terminate (OrphanGuard) once this process is gone.



143
144
145
146
147
148
149
# File 'lib/wurk/rails_boot.rb', line 143

def stop_swarm(swarm, supervisor, drain_wait)
  return unless swarm.owner?

  swarm.request_shutdown
  supervisor&.join(drain_wait)
  swarm.shutdown unless supervisor&.alive?
end