Class: Wurk::CLI
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
wurkruns 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 execloads the binstub in-process, so argv0 is Bundler's executable, andruby -e "load Gem.bin_path(...)"has-ethere: the running program can't name it. ::File.('../../exe/wurkswarm', __dir__)
- WURK_EXECUTABLE =
::File.('../../exe/wurk', __dir__)
- DEFAULT_API_BIND =
wurk apidefaults. 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.1narrows 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_iparses as Integer,:appendpushes onto a list (only-quses 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
apiis the subcommand: none of it means anything to a worker process, andwurk --helpstays 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:"- HELP_FOOTER =
"\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
-
#command ⇒ Object
readonly
The subcommand
parsepulled off argv, or nil for the worker runner. -
#config ⇒ Object
Returns the value of attribute config.
-
#config ⇒ Object
included
from Component
readonly
Returns the value of attribute config.
-
#environment ⇒ Object
Returns the value of attribute environment.
-
#launcher ⇒ Object
Returns the value of attribute launcher.
Class Method Summary collapse
- .instance ⇒ Object
-
.report_failure(program, error, argv: ARGV) ⇒ Object
What the binaries print for a failed start.
-
.reset_instance! ⇒ Object
Test seam: parallel suites can't share the singleton.
Instance Method Summary collapse
- #default_tag(dir = Dir.pwd) ⇒ Object included from Component
-
#fire_event(event, oneshot: true, reverse: false, reraise: false) ⇒ Object
included
from Component
Invokes lifecycle hooks for
event. - #handle_exception(ex, ctx = {}) ⇒ Object included from Component
- #handle_signal(sig) ⇒ Object
- #hostname ⇒ Object included from Component
- #identity ⇒ Object included from Component
-
#initialize ⇒ CLI
constructor
A new instance of CLI.
-
#leader? ⇒ Boolean
included
from Component
True iff this process currently holds the cluster
dear-leaderlock. -
#logger ⇒ Object
included
from Component
--- delegated to config -------------------------------------------.
- #mono_ms ⇒ Object included from Component
-
#parse(args = ARGV.dup) ⇒ Object
parseis split fromrunso tests can drive option parsing without touching Redis or booting the host app. - #process_nonce ⇒ Object included from Component
-
#real_ms ⇒ Object
included
from Component
--- clocks ---------------------------------------------------------.
- #redis(idempotent: false) ⇒ Object included from Component
-
#run(boot_app: true, warmup: true) ⇒ Object
boot_app:/warmup:are test seams. -
#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. -
#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. -
#run_health ⇒ Object
wurk health— see Wurk::HealthProbe. -
#run_swarm(boot_app: true) ⇒ Object
Standalone multi-process boot — the
sidekiqswarmentry point (Ent §7). -
#safe_thread(name, priority: nil, &block) ⇒ Object
included
from Component
Spawns a named thread that runs
blockunderwatchdog(name). - #tid ⇒ Object included from Component
-
#watchdog(last_words) ⇒ Object
included
from 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.
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.}" 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.
#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 (args) if @command == 'health' (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.
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.
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. 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.