Class: Wurk::CLI

Inherits:
Object
  • Object
show all
Includes:
Component
Defined in:
lib/wurk/cli.rb

Overview

Standalone CLI. Loads from exe/wurk — never loads wurk/rails so the binary works without the Rails engine (the host app might not be Rails). Singleton because there is exactly one process-wide CLI; tests construct fresh .new instances to keep state isolated.

Spec: docs/target/sidekiq-free.md §21 (Sidekiq::CLI).

Defined Under Namespace

Classes: StartupError

Constant Summary collapse

MIN_REDIS_VERSION =

Minimum Redis version Wurk supports — same as Sidekiq 8.x. The job JSON format and Lua scripts rely on commands introduced in Redis 7.

'7.0.0'
COMMANDS =

Subcommands. Bare wurk runs the worker — the historical shape, and the only one the swarm binaries accept — so a subcommand is never anything but the first argument, and every argument after it is that command's.

%w[api boot-check health].freeze
PRELOAD_FIRST_ENV =

Set only across the one re-exec of #load_rails_framework, and removed on arrival.

'WURK_BOOT_PRELOAD_FIRST'
SWARM_EXECUTABLE =

bundle exec loads the binstub in-process, so argv0 is Bundler's executable, and ruby -e "load Gem.bin_path(...)" has -e there: the running program can't name it.

::File.expand_path('../../exe/wurkswarm', __dir__)
WURK_EXECUTABLE =
::File.expand_path('../../exe/wurk', __dir__)
DEFAULT_API_BIND =

wurk api defaults. 0.0.0.0 because the point of the machine API is for another service to reach it; it is bearer-gated, and answers 404 to everything until a token exists. --bind 127.0.0.1 narrows it.

'0.0.0.0'
DEFAULT_API_PORT =
7433
NO_API_TOKEN_MESSAGE =

Serving the API with nothing registered would bind a port and 404 every request — the surface is off until a token exists, so say so at boot rather than leaving an operator to diagnose it over HTTP.

<<~MSG
  No API token is registered, so every request would answer 404.
  Register one where the app loaded by -r configures wurk:

      Wurk.configuration.api_token(ENV.fetch('WURK_API_TOKEN'), scopes: %i[enqueue read])
MSG
BACKTRACE_DUMPER =

Thread-backtrace dumper used by both TTIN and INFO. Same body — INFO is the modern name, TTIN is kept for parity with older Sidekiq users.

lambda do |cli|
  Thread.list.each do |thread|
    cli.logger.warn "Thread TID-#{(thread.object_id ^ ::Process.pid).to_s(36)} #{thread.name}"
    if thread.backtrace
      cli.logger.warn thread.backtrace.join("\n")
    else
      cli.logger.warn '<no backtrace available>'
    end
  end
end
SIGNAL_HANDLERS =
{
  'INT' => ->(_cli) { raise Interrupt },
  'TERM' => ->(_cli) { raise Interrupt },
  'TSTP' => lambda do |cli|
    cli.logger.info 'Received TSTP, no longer accepting new work'
    cli.launcher.quiet
  end,
  'TTIN' => BACKTRACE_DUMPER,
  'INFO' => BACKTRACE_DUMPER,
  'USR2' => lambda do |cli|
    cli.logger.info 'Received USR2, reopening logs'
    # reopen_logs is private — an explicit receiver needs __send__.
    cli.__send__(:reopen_logs)
  end
}.freeze
OPTION_FLAGS =

Table-driven so adding a flag doesn't grow define_value_flags's ABC size and the surface matches the Sidekiq docs row-for-row. The 5th column is the assignment transform: :to_i parses as Integer, :append pushes onto a list (only -q uses that), nil = assign as-is.

[
  ['-c', '--concurrency INT',      :concurrency, 'processor threads to use', :to_i],
  ['-e', '--environment ENV',      :environment, 'Application environment'],
  ['-g', '--tag TAG',              :tag,         'Process tag for procline'],
  ['-q', '--queue QUEUE[,WEIGHT]', :queues,      'Queues to process with optional weights', :append],
  ['-r', '--require [PATH|DIR]',   :require,     'Location of Rails app or .rb file to require'],
  ['-t', '--timeout NUM',          :timeout,     'Shutdown timeout', :to_i],
  ['-v', '--verbose',              :verbose,     'Print more verbose output'],
  ['-C', '--config PATH',          :config_file, 'path to YAML config file']
].freeze
API_OPTION_FLAGS =

Same table shape, offered only when api is the subcommand: none of it means anything to a worker process, and wurk --help stays the worker's.

[
  ['-b', '--bind ADDRESS', :api_bind,   "Address to bind (default #{DEFAULT_API_BIND})"],
  ['-p', '--port PORT',    :api_port,   "Port to listen on (default #{DEFAULT_API_PORT})", :to_i],
  ['-s', '--server NAME',  :api_server, 'Rack handler to serve with (default: the first installed)']
].freeze
BOOT_CHECK_OPTION_FLAGS =
[
  ['-R', '--redis', :boot_check_redis, 'Also read Redis: version, eviction policy, live peers (read-only)'],
  ['-n', '--runner NAME', :boot_check_runner, 'Boot as wurkswarm (default) or wurk, the runner you deploy']
].freeze
BOOT_CHECK_RUNNERS =
%w[wurkswarm wurk].freeze
HEALTH_OPTION_FLAGS =
[
  ['-w', '--window SECONDS', :health_window, 'A heartbeat older than this does not count (default 60)', :to_i],
  ['-m', '--min COUNT', :health_min, 'Processes on this host that must be beating (default 1)', :to_i],
  ['-u', '--redis-url URL', :health_redis_url, 'Redis to read (default REDIS_URL)'],
  ['-s', '--strict', :health_strict, 'Fail when Redis cannot be read, rather than passing'],
  ['-a', '--max-job-age SECONDS', :health_max_job_age, 'Also fail when a job here has run this long (off)', :to_i]
].freeze
HELP_COMMANDS =
"\nCommands:\n    " \
"boot-check Boot as wurkswarm (or --runner wurk) would, then exit; no fork, no fetch (CI)\n    " \
"health    Exit 0 if this host's workers are heartbeating\n    " \
"preflight Read a live Redis: what will carry over to Wurk (read-only)\n    " \
"verify    Post-cutover check of a live fleet (read-only)\n\nOptions:"
HELP_API_NOTE =
"\nThe worker flags -c, -g, -q and -t are accepted here and have no effect.\n\nOptions:"
HELP_SWARM_NOTE =
"\nForks WURK_COUNT worker processes from a preloaded parent and supervises them.\n\nOptions:"
"\nEnvironment:\n    REDIS_URL         Redis to use (default redis://localhost:6379/0)\n    " \
"WURK_COUNT        Worker processes for wurkswarm\n\n" \
'Docs: https://github.com/developerz-ai/wurk#readme'

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize ⇒ CLI

Returns a new instance of CLI.



152
153
154
155
156
157
158
# File 'lib/wurk/cli.rb', line 152

def initialize
  @config = nil
  @launcher = nil
  @environment = nil
  @parser = nil
  @command = nil
end

Instance Attribute Details

#command ⇒ Object (readonly)

The subcommand parse pulled off argv, or nil for the worker runner. The binary picks the mode from it — exe/wurk runs the API for api.



141
142
143
# File 'lib/wurk/cli.rb', line 141

def command
  @command
end

#config ⇒ Object

Returns the value of attribute config.



137
138
139
# File 'lib/wurk/cli.rb', line 137

def config
  @config
end

#config ⇒ Object (readonly) Originally defined in module Component

Returns the value of attribute config.

#environment ⇒ Object

Returns the value of attribute environment.



137
138
139
# File 'lib/wurk/cli.rb', line 137

def environment
  @environment
end

#launcher ⇒ Object

Returns the value of attribute launcher.



137
138
139
# File 'lib/wurk/cli.rb', line 137

def launcher
  @launcher
end

Class Method Details

.instance ⇒ Object



143
144
145
# File 'lib/wurk/cli.rb', line 143

def self.instance
  @instance ||= new
end

.report_failure(program, error, argv: ARGV) ⇒ Object

What the binaries print for a failed start. Only errors this file fully understands drop the trace. Anything else may be a bug in the host app, whose file and line are the point, and asking for -v means changing a deployed command.



176
177
178
179
180
181
182
183
184
# File 'lib/wurk/cli.rb', line 176

def self.report_failure(program, error, argv: ARGV)
  warn "#{program}: #{error.message}"
  hint = failure_hint(error)
  warn "#{program}: #{hint}" if hint
  return unless error.backtrace
  return unless failure_trace_wanted?(error, hint) || argv.intersect?(%w[-v --verbose])

  warn error.backtrace.join("\n")
end

.reset_instance! ⇒ Object

Test seam: parallel suites can't share the singleton.



148
149
150
# File 'lib/wurk/cli.rb', line 148

def self.reset_instance!
  @instance = nil
end

Instance Method Details

#default_tag(dir = Dir.pwd) ⇒ Object Originally defined in module Component

#fire_event(event, oneshot: true, reverse: false, reraise: false) ⇒ Object Originally defined in module Component

Invokes lifecycle hooks for event. Hooks run in registration order (or LIFO when reverse: true, used for teardown). A raise in one hook is reported via handle_exception and does NOT stop the next hook unless reraise: true (used in tests / fail-fast boot). oneshot: true clears the bucket after dispatch so the event can't fire twice.

#handle_exception(ex, ctx = {}) ⇒ Object Originally defined in module Component

#handle_signal(sig) ⇒ Object



332
333
334
335
336
337
338
# File 'lib/wurk/cli.rb', line 332

def handle_signal(sig)
  logger.debug { "Got #{sig} signal" }
  handler = SIGNAL_HANDLERS[sig]
  return logger.warn("No #{sig} signal handler registered, ignoring") unless handler

  handler.call(self)
end

#hostname ⇒ Object Originally defined in module Component

#identity ⇒ Object Originally defined in module Component

#leader? ⇒ Boolean Originally defined in module Component

True iff this process currently holds the cluster dear-leader lock. Cached per Component instance for LEADER_CACHE_TTL_MS (~5s): cron and the metrics rollups call this every tick, and an uncached GET would double their Redis traffic at short intervals for no benefit — the lock's own renewal cadence (60s+, spec §6.1) easily tolerates a few-second-stale read. Returns false unconditionally when WURK_LEADER=false (or SIDEKIQ_LEADER=false) is set on the process (opt-out hot-standby). Any Redis error is swallowed → false, so a transient partition can't propagate as an exception into user code.

Spec: docs/target/sidekiq-ent.md §6.1.

Returns:

  • (Boolean)

#logger ⇒ Object Originally defined in module Component

--- delegated to config -------------------------------------------

#mono_ms ⇒ Object Originally defined in module Component

#parse(args = ARGV.dup) ⇒ Object

parse is split from run so tests can drive option parsing without touching Redis or booting the host app.



162
163
164
165
166
167
168
169
170
171
# File 'lib/wurk/cli.rb', line 162

def parse(args = ARGV.dup)
  @config ||= Wurk.default_configuration
  @command = extract_command!(args)
  return parse_health_options(args) if @command == 'health'

  setup_options(args)
  initialize_logger
  validate!
  self
end

#process_nonce ⇒ Object Originally defined in module Component

#real_ms ⇒ Object Originally defined in module Component

--- clocks ---------------------------------------------------------

#redis(idempotent: false) ⇒ Object Originally defined in module Component

#run(boot_app: true, warmup: true) ⇒ Object

boot_app: / warmup: are test seams. Production always passes true.



200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
# File 'lib/wurk/cli.rb', line 200

def run(boot_app: true, warmup: true) # rubocop:disable Metrics/AbcSize
  exit(run_health) if @command == 'health'

  # Mark server mode BEFORE the app loads so `configure_server` blocks in
  # the required initializer actually fire (they gate on `config.server?`).
  # Matches Sidekiq, which sets `Sidekiq.server = true` before requiring
  # the app in its CLI. Each configure_server block the app's load reaches
  # before it would be kept and run only here, after the app's own code.
  enter_server_mode
  boot_application if boot_app
  Swarm::TestModeGuard.enforce!(@config.logger)
  self_read, self_write = ::IO.pipe
  begin
    trap_signals(self_write)
    info = validate_redis!
    validate_pool_sizes!
    BootChecks.run(@config, info)
    BootChecks.warn_on_ignored_topology(@config, 'wurk')
    BootChecks.log_process_role(@config, 'wurk')
    @config[:identity] = identity
    # Force lazy server-middleware chain so worker threads don't race
    # against each other constructing it. Spec: Sidekiq::CLI line 104.
    @config.server_middleware
    warm_up_process if warmup
    fire_event(:startup, reverse: false, reraise: true)
    launch(self_read)
  ensure
    # Every exit path — clean return, a raise out of validate/startup, and
    # the SystemExit that `launch` unwinds on Interrupt. Embedded and test
    # callers survive `run`, so a leaked pair is a real FD leak.
    self_read.close
    self_write.close
  end
end

#run_api(boot_app: true, handler: nil) ⇒ Object

wurk api — serve the machine-facing HTTP API and nothing else: no fetcher, no heartbeat, no job ever runs here. This is mount mode 3, the standalone one: the same Rack app the engine nests and a host can mount itself, served without Rails.

Server mode is entered for the reason #run does it — a host that registers its token inside a configure_server block would otherwise boot an API with no credential and 404 every request. Nothing starts as a side effect: no lifecycle event fires and no Launcher is ever built.

handler: is a test seam; production resolves one from the installed gems.

Raises:



318
319
320
321
322
323
324
325
326
327
328
329
330
# File 'lib/wurk/cli.rb', line 318

def run_api(boot_app: true, handler: nil)
  enter_server_mode
  boot_application if boot_app
  validate_redis!
  # Load the app here, not on the first request: a broken load should fail
  # the boot an operator is watching, not the first client to call.
  require_relative 'api/app'
  raise StartupError, NO_API_TOKEN_MESSAGE unless @config.api_enabled?

  handler ||= api_handler(@config[:api_server])
  logger.info { "Wurk API listening on http://#{api_bind}:#{api_port}#{Wurk::API::VERSION_PREFIX}" }
  handler.run(Wurk::API, Host: api_bind, Port: api_port)
end

#run_boot_check(boot_app: true) ⇒ Object

wurk boot-check — the boot of the runner named by --runner up to where it would fork or fetch: #run_swarm's (the default) or #run's, with the same require order, config file and checks, plus what a worker reaches only later (every constant, the routes), so a CI step finds what would crash-loop a deploy. configure_server blocks run, since their load errors are the point; no lifecycle event fires (:startup included) and periodic loops are never applied. Wurk's Redis pools refuse every command without --redis and every write with it (Preflight::RedisGuard), so boot-time app code cannot touch a real server. Returns the exit code; a boot that raises is the binary's failure report.



285
286
287
288
289
290
291
292
293
294
# File 'lib/wurk/cli.rb', line 285

def run_boot_check(boot_app: true)
  require_relative 'preflight/redis_guard'
  Preflight::RedisGuard.install(@config[:boot_check_redis] ? :read_only : :closed, label: 'wurk boot-check')
  enter_server_mode
  boot_check_runner == 'wurk' ? boot_check_as_wurk(boot_app) : boot_check_as_swarm(boot_app)
  logger.info { "wurk boot-check: #{@config[:require]} booted as #{boot_check_runner} would" }
  0
ensure
  Preflight::RedisGuard.uninstall
end

#run_health ⇒ Object

wurk health — see Wurk::HealthProbe. Boots nothing; returns the exit code.



297
298
299
300
301
302
303
304
# File 'lib/wurk/cli.rb', line 297

def run_health
  require_relative 'health_probe'
  opts = @health_options
  @config.redis = { url: opts[:health_redis_url] } if opts[:health_redis_url]
  HealthProbe.new(@config, window: opts[:health_window] || HealthProbe::DEFAULT_WINDOW,
                           min: opts[:health_min] || 1, strict: opts[:health_strict] == true,
                           max_job_age: opts[:health_max_job_age]).call
end

#run_swarm(boot_app: true) ⇒ Object

Standalone multi-process boot — the sidekiqswarm entry point (Ent §7). The parent loads the app once, forks N worker children per the configured topology, then supervises them (respawn on crash, rolling restart on SIGUSR1, memory-based recycling). The parent itself never fetches.

This is the only way to get fork-based parallelism without Rails — the railtie auto-boot path is Rails-only. Honors the swarm preload knobs (WURK_PRELOAD/SIDEKIQ_PRELOAD Bundler groups, WURK_PRELOAD_APP/ SIDEKIQ_PRELOAD_APP whole-app eager-load); Swarm#boot warms the heap before the fork so children share its pages (Swarm::Prefork). Spec §7.

Raises:



245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
# File 'lib/wurk/cli.rb', line 245

def run_swarm(boot_app: true) # rubocop:disable Metrics/AbcSize
  raise StartupError, "the swarm runner takes no subcommand; run `wurk #{@command}` instead" if @command

  # Server mode before the app loads — see #run. The flag rides through the
  # fork into every child (the config object is copied), so configure_server
  # blocks registered in the parent take effect in the workers.
  enter_server_mode
  # Only the children read it. Rejected here, a typo fails this boot instead of
  # crash-looping the fleet until the crash-loop exit.
  Health.options_from_env
  boot_swarm_application if boot_app
  info = validate_redis!
  validate_pool_sizes!
  BootChecks.run(@config, info)
  log_swarm_role
  @config[:identity] = identity
  @swarm = Wurk::Swarm.new(topology: @config.topology, config: @config, exit_on_crash_loop: true,
                           shutdown_timeout: @config[:timeout] || Swarm::DEFAULT_SHUTDOWN_TIMEOUT)
  begin
    @swarm.boot(install_signals: true)
    @swarm.supervise
  ensure
    # A fork failure part-way through `boot`, or anything raising out of
    # `supervise`, otherwise leaves live children behind with no supervisor.
    # A no-op once the loop already drained (no children, pipe closed).
    @swarm.shutdown
  end
  exit(1) if @swarm.crash_looped?
end

#safe_thread(name, priority: nil, &block) ⇒ Object Originally defined in module Component

Spawns a named thread that runs block under watchdog(name). The parent must retain the returned Thread; otherwise GC may not, but report_on_exception is disabled so we don't double-log on death.

Priority resolution matches Sidekiq (component.rb:44-48): explicit argument, then config.thread_priority, then -1. Ruby's default of 0 buys a 100ms timeslice; each negative step halves it, so -1 keeps a CPU-heavy capsule from starving its siblings for a whole tick.

#tid ⇒ Object Originally defined in module Component

#watchdog(last_words) ⇒ Object Originally defined in module Component

Wraps a block at a thread boundary: any unhandled exception is reported via handle_exception (so it lands in error_handlers / the log) and then re-raised. last_words is the component label included in the context.