Module: Wurk::BootChecks
- Defined in:
- lib/wurk/boot_checks.rb
Overview
What a standalone runner says once at boot, after the app loaded. Log only, and never allowed to stop a boot: none of this changes behaviour by backend, version or peers.
Constant Summary collapse
- TESTED_FLOORS =
Oldest version each non-Redis backend has been run against.
{ 'dragonfly' => '1.34.0', 'valkey' => '8.0.0' }.freeze
- MAX_LISTED =
20- SUBCLASS =
::Module.instance_method(:<)
- NAME =
::Module.instance_method(:name)
- UNIQUE_OPTIONS =
%w[unique_for unique_until].freeze
- STRICT_ENV =
'WURK_STRICT'
Class Method Summary collapse
- .active_job_queue_name(queue) ⇒ Object
-
.active_record_pool ⇒ Object
The pool size the app configured, read without opening a connection; nil without Active Record or a configured database.
- .backend(info) ⇒ Object
-
.flag_real_sidekiq_gem(logger, specs: ::Gem.loaded_specs, strict: ENV[STRICT_ENV] == '1') ⇒ Object
The real gem next to Wurk loads both:
Sidekiq::*is then partly theirs, partly ours. - .framework_queue_set(framework, mod) ⇒ Object
-
.framework_queues ⇒ Object
{ queue => what Rails sends there }, for apps whose ActiveJob adapter is Wurk. -
.job_classes_setting(options) ⇒ Object
Workers, and ActiveJob classes that take
sidekiq_options(Wurk::Job::Options). - .listing(names) ⇒ Object
- .locked_job_classes ⇒ Object
- .log_backend(logger, info) ⇒ Object
-
.log_client_only(logger, reason) ⇒ Object
DEBUG, not INFO: Wurk logs to stdout, and every
rails runner, rake task andwurk preflight --appboots the app; their stdout is someone's output. -
.log_process_role(config, role, ar_pool: active_record_pool) ⇒ Object
The role line of a process that runs every capsule's threads itself.
-
.log_role(config, role, slots, per_process:, ar_pool:) ⇒ Object
One line per process: what it is, and how big.
-
.log_swarm_role(config, role, ar_pool: active_record_pool) ⇒ Object
The role line of a process that forks the topology's children and runs no jobs itself.
- .own_queues(config) ⇒ Object
- .process_infos(config) ⇒ Object
- .real_sidekiq_message(specs, logger) ⇒ Object
-
.redis_pool_size(config, threads) ⇒ Object
Capsule#main_pool_size, without building a pool: a swarm parent describes slots whose pools only its children build.
- .role_skipped(logger, error) ⇒ Object
-
.run(config, info) ⇒ Object
infois nil when there is no Redis to ask (wurk boot-checkwithout--redis): only the checks that read the loaded app run then. - .sidekiq_dependents(specs) ⇒ Object
-
.warn_on_client_config_only(config) ⇒ Object
Stock Sidekiq skips a configure_client block in a server without a word, and so does Configuration: an app normally configures both roles.
-
.warn_on_foreign_processes(config) ⇒ Object
Both runtimes share
processesand the same queues, so a mix is safe only if it is deliberate. -
.warn_on_ignored_locks(logger) ⇒ Object
Without the gem's middleware the option does nothing: the "unique" job simply enqueues twice.
-
.warn_on_ignored_topology(config, runner) ⇒ Object
The topology only shapes what a swarm forks;
wurkand an embedded worker run one process with the capsules' threads, so a declared one silently does nothing there. -
.warn_on_ignored_unique(logger) ⇒ Object
Only the middleware
unique!installs reads these options, so without it every push enqueues. -
.warn_on_small_ar_pool(logger, ar_pool, threads) ⇒ Object
Every job thread that touches the database holds a connection; with fewer in the pool the rest wait, then fail with ActiveRecord::ConnectionTimeoutError.
-
.warn_on_unconsumed_framework_queues(config) ⇒ Object
Rails enqueues its own jobs (mail delivery, Active Storage analysis and purge, Action Mailbox routing) to queues an app never names itself, so they are the ones that go unlistened.
- .wurk_active_job? ⇒ Boolean
Class Method Details
.active_job_queue_name(queue) ⇒ Object
168 169 170 171 |
# File 'lib/wurk/boot_checks.rb', line 168 def active_job_queue_name(queue) prefix = ::ActiveJob::Base.queue_name_prefix prefix ? "#{prefix}#{::ActiveJob::Base.queue_name_delimiter}#{queue}" : queue.to_s end |
.active_record_pool ⇒ Object
The pool size the app configured, read without opening a connection; nil
without Active Record or a configured database. Rails 8.1 renamed pool.
284 285 286 287 288 289 290 291 |
# File 'lib/wurk/boot_checks.rb', line 284 def active_record_pool return unless defined?(::ActiveRecord::Base) db = ::ActiveRecord::Base.connection_db_config db.respond_to?(:max_connections) ? db.max_connections : db.pool rescue ::ActiveRecord::ActiveRecordError nil end |
.backend(info) ⇒ Object
37 38 39 40 41 42 |
# File 'lib/wurk/boot_checks.rb', line 37 def backend(info) return ['dragonfly', info['dragonfly_version'].to_s.delete_prefix('df-v')] if info['dragonfly_version'] return ['valkey', info['valkey_version'].to_s] if info['valkey_version'] ['redis', info['redis_version'].to_s] end |
.flag_real_sidekiq_gem(logger, specs: ::Gem.loaded_specs, strict: ENV[STRICT_ENV] == '1') ⇒ Object
The real gem next to Wurk loads both: Sidekiq::* is then partly theirs,
partly ours. Gems that depend on sidekiq pull it in unless the shim gem,
which depends on Wurk instead, satisfies the dependency. Runs as Wurk loads.
198 199 200 201 202 203 204 |
# File 'lib/wurk/boot_checks.rb', line 198 def flag_real_sidekiq_gem(logger, specs: ::Gem.loaded_specs, strict: ENV[STRICT_ENV] == '1') = (specs, logger) return unless raise Wurk::Error, if strict logger.error { "#{} Set #{STRICT_ENV}=1 to refuse to boot instead." } end |
.framework_queue_set(framework, mod) ⇒ Object
156 157 158 159 160 |
# File 'lib/wurk/boot_checks.rb', line 156 def framework_queue_set(framework, mod) return {} unless mod.respond_to?(:queues) && mod.queues.respond_to?(:to_h) mod.queues.to_h.transform_keys { |kind| "#{framework} #{kind}" } end |
.framework_queues ⇒ Object
{ queue => what Rails sends there }, for apps whose ActiveJob adapter is Wurk.
146 147 148 149 150 151 152 153 154 |
# File 'lib/wurk/boot_checks.rb', line 146 def framework_queues return {} unless wurk_active_job? named = {} named['mail delivery'] = ::ActionMailer::Base.deliver_later_queue_name if defined?(::ActionMailer::Base) named.merge!(framework_queue_set('Active Storage', defined?(::ActiveStorage) && ::ActiveStorage)) named.merge!(framework_queue_set('Action Mailbox', defined?(::ActionMailbox) && ::ActionMailbox)) named.compact.to_h { |what, queue| [active_job_queue_name(queue), what] } end |
.job_classes_setting(options) ⇒ Object
Workers, and ActiveJob classes that take sidekiq_options (Wurk::Job::Options).
A walk over every class meets classes that redefine these with other arguments
(Faker::Travel::Airport.name takes required keywords), and one such class must
not cost the app this check.
186 187 188 189 190 191 192 193 |
# File 'lib/wurk/boot_checks.rb', line 186 def job_classes_setting() ::ObjectSpace.each_object(::Class).filter_map do |klass| next unless SUBCLASS.bind_call(klass, Wurk::Worker) || SUBCLASS.bind_call(klass, Wurk::Job::Options) name = NAME.bind_call(klass) name if name && klass..values_at(*).any? end.sort end |
.listing(names) ⇒ Object
293 294 295 296 |
# File 'lib/wurk/boot_checks.rb', line 293 def listing(names) extra = names.size - MAX_LISTED names.first(MAX_LISTED).join(', ') + (extra.positive? ? " and #{extra} more" : '') end |
.locked_job_classes ⇒ Object
180 |
# File 'lib/wurk/boot_checks.rb', line 180 def locked_job_classes = job_classes_setting(%w[lock]) |
.log_backend(logger, info) ⇒ Object
44 45 46 47 48 49 50 51 |
# File 'lib/wurk/boot_checks.rb', line 44 def log_backend(logger, info) name, version = backend(info) logger.info { "Wurk #{Wurk::VERSION} on #{name} #{version}" } floor = TESTED_FLOORS[name] return unless floor && ::Gem::Version.correct?(version) && ::Gem::Version.new(version) < ::Gem::Version.new(floor) logger.warn { "#{name} #{version} is older than the oldest version Wurk is tested against (#{floor})." } end |
.log_client_only(logger, reason) ⇒ Object
DEBUG, not INFO: Wurk logs to stdout, and every rails runner, rake task
and wurk preflight --app boots the app; their stdout is someone's output.
260 261 262 |
# File 'lib/wurk/boot_checks.rb', line 260 def log_client_only(logger, reason) logger.debug { "wurk: client only (#{reason}); this process enqueues and runs no jobs" } end |
.log_process_role(config, role, ar_pool: active_record_pool) ⇒ Object
The role line of a process that runs every capsule's threads itself.
236 237 238 239 240 241 242 |
# File 'lib/wurk/boot_checks.rb', line 236 def log_process_role(config, role, ar_pool: active_record_pool) capsules = config.capsules.values slots = capsules.map { |cap| [capsules.size > 1 ? "capsule #{cap.name}" : '1 process', cap.concurrency] } log_role(config, role, slots, per_process: slots.sum(&:last), ar_pool: ar_pool) rescue StandardError => e role_skipped(config.logger, e) end |
.log_role(config, role, slots, per_process:, ar_pool:) ⇒ Object
One line per process: what it is, and how big. per_process is the most
job threads one process runs, which is what its Active Record pool covers.
250 251 252 253 254 255 256 |
# File 'lib/wurk/boot_checks.rb', line 250 def log_role(config, role, slots, per_process:, ar_pool:) sizes = slots.map do |label, threads| "#{label} × #{threads} threads (redis pool #{redis_pool_size(config, threads)}, fetch #{threads})" end config.logger.info { "wurk: #{role}; #{sizes.join(' + ')}#{"; AR pool #{ar_pool}" if ar_pool}" } warn_on_small_ar_pool(config.logger, ar_pool, per_process) end |
.log_swarm_role(config, role, ar_pool: active_record_pool) ⇒ Object
The role line of a process that forks the topology's children and runs no jobs itself. Its children each run the threads of one slot.
226 227 228 229 230 231 232 233 |
# File 'lib/wurk/boot_checks.rb', line 226 def log_swarm_role(config, role, ar_pool: active_record_pool) slots = config.topology.slots.map do |slot| ["#{slot.count} #{slot.count == 1 ? 'process' : 'processes'}", slot.concurrency] end log_role(config, role, slots, per_process: slots.map(&:last).max, ar_pool: ar_pool) rescue StandardError => e role_skipped(config.logger, e) end |
.own_queues(config) ⇒ Object
173 174 175 176 177 178 |
# File 'lib/wurk/boot_checks.rb', line 173 def own_queues(config) queues = config.capsules.each_value.flat_map { |cap| cap.weights.keys } return queues unless config.topology_assigned? queues | config.topology.slots.flat_map { |slot| Array(slot.queues).map { |entry| entry.to_s.split(',').first } } end |
.process_infos(config) ⇒ Object
65 66 67 68 69 70 |
# File 'lib/wurk/boot_checks.rb', line 65 def process_infos(config) config.redis(idempotent: true) do |conn| ids = conn.call('SMEMBERS', Keys::PROCESSES) ids.empty? ? [] : ids.zip(conn.pipelined { |pipe| ids.each { |id| pipe.call('HGET', id, 'info') } }) end end |
.real_sidekiq_message(specs, logger) ⇒ Object
206 207 208 209 210 211 212 213 214 215 216 217 |
# File 'lib/wurk/boot_checks.rb', line 206 def (specs, logger) real = specs['sidekiq'] return if real.nil? || real.dependencies.any? { |dep| dep.name == 'wurk' } "The bundle holds the real sidekiq #{real.version} gem, so it loads next to Wurk and the process runs " \ "half on each. Pulled in by: #{sidekiq_dependents(specs)}. Replace it with the shim in the Gemfile: " \ "gem 'sidekiq', github: 'developerz-ai/wurk', tag: 'v#{Wurk::VERSION}', " \ "glob: 'ecosystem/sidekiq-shim/*.gemspec'." rescue StandardError => e logger.warn { "wurk: could not check the bundle for the real sidekiq gem: #{e.class}: #{e.}" } nil end |
.redis_pool_size(config, threads) ⇒ Object
Capsule#main_pool_size, without building a pool: a swarm parent describes slots whose pools only its children build.
266 267 268 |
# File 'lib/wurk/boot_checks.rb', line 266 def redis_pool_size(config, threads) config.redis_config[:size] || [threads + Capsule::POOL_HEADROOM, Capsule::MIN_POOL_SIZE].max end |
.role_skipped(logger, error) ⇒ Object
244 245 246 |
# File 'lib/wurk/boot_checks.rb', line 244 def role_skipped(logger, error) logger.warn { "wurk role line skipped: #{error.class}: #{error.}" } end |
.run(config, info) ⇒ Object
info is nil when there is no Redis to ask (wurk boot-check without
--redis): only the checks that read the loaded app run then.
24 25 26 27 28 29 30 31 32 33 34 35 |
# File 'lib/wurk/boot_checks.rb', line 24 def run(config, info) if info log_backend(config.logger, info) warn_on_foreign_processes(config) warn_on_unconsumed_framework_queues(config) end warn_on_ignored_locks(config.logger) warn_on_ignored_unique(config.logger) warn_on_client_config_only(config) rescue StandardError => e config.logger.warn { "wurk boot checks skipped: #{e.class}: #{e.}" } end |
.sidekiq_dependents(specs) ⇒ Object
219 220 221 222 |
# File 'lib/wurk/boot_checks.rb', line 219 def sidekiq_dependents(specs) names = specs.each_value.select { |spec| spec.dependencies.any? { |dep| dep.name == 'sidekiq' } }.map(&:name) names.empty? ? 'the Gemfile itself' : listing(names.sort) end |
.warn_on_client_config_only(config) ⇒ Object
Stock Sidekiq skips a configure_client block in a server without a word, and so does Configuration: an app normally configures both roles. A server where only client blocks were written ran none of them, so the Redis settings, middleware and handlers they hold are all absent here.
106 107 108 109 110 111 112 113 114 |
# File 'lib/wurk/boot_checks.rb', line 106 def warn_on_client_config_only(config) return unless config.client_config_only? config.logger.warn do 'This is a server process, so its configure_client blocks were skipped, and no configure_server block ran: ' \ 'whatever those blocks set (config.redis, middleware, handlers) does not apply to these workers. Set it ' \ 'in a configure_server block too, or outside both.' end end |
.warn_on_foreign_processes(config) ⇒ Object
Both runtimes share processes and the same queues, so a mix is safe only
if it is deliberate. Wurk's heartbeat always carries cores; stock does not.
55 56 57 58 59 60 61 62 63 |
# File 'lib/wurk/boot_checks.rb', line 55 def warn_on_foreign_processes(config) foreign = process_infos(config).reject { |_, info| info.nil? || ::JSON.parse(info).key?('cores') }.map(&:first) return if foreign.empty? config.logger.warn do "Live processes that are not Wurk share this Redis: #{listing(foreign)}. " \ 'They fetch from the same queues. If this is not a deliberate rolling cutover, stop them.' end end |
.warn_on_ignored_locks(logger) ⇒ Object
Without the gem's middleware the option does nothing: the "unique" job simply enqueues twice. Only classes the app has loaded by now are seen.
74 75 76 77 78 79 80 81 82 83 84 |
# File 'lib/wurk/boot_checks.rb', line 74 def warn_on_ignored_locks(logger) return if defined?(::SidekiqUniqueJobs) classes = locked_job_classes return if classes.empty? logger.warn do 'These jobs set `lock:` but sidekiq-unique-jobs is not loaded, so the option does nothing and ' \ "duplicates will run: #{listing(classes)}. Keep the gem, or move them to `unique_for:`." end end |
.warn_on_ignored_topology(config, runner) ⇒ Object
The topology only shapes what a swarm forks; wurk and an embedded
worker run one process with the capsules' threads, so a declared one
silently does nothing there.
119 120 121 122 123 124 125 126 |
# File 'lib/wurk/boot_checks.rb', line 119 def warn_on_ignored_topology(config, runner) return unless config.topology_assigned? config.logger.warn do "config.topology is set, but #{runner} runs a single process and ignores it: its slots' queues and " \ 'concurrency are not used here. Run wurkswarm to fork it, or set the queues and concurrency on the capsules.' end end |
.warn_on_ignored_unique(logger) ⇒ Object
Only the middleware unique! installs reads these options, so without it
every push enqueues. Turning it on for the app is a human call, not a
boot check's: this only says so.
89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/wurk/boot_checks.rb', line 89 def warn_on_ignored_unique(logger) return if Wurk::Unique.enabled? classes = job_classes_setting(UNIQUE_OPTIONS) return if classes.empty? logger.warn do 'These jobs set `unique_for:` or `unique_until:` but unique jobs are not enabled, so the options do ' \ "nothing and duplicates enqueue: #{listing(classes)}. Enable them once at boot, in an initializer: " \ '`Sidekiq::Enterprise.unique!`.' end end |
.warn_on_small_ar_pool(logger, ar_pool, threads) ⇒ Object
Every job thread that touches the database holds a connection; with fewer in the pool the rest wait, then fail with ActiveRecord::ConnectionTimeoutError.
272 273 274 275 276 277 278 279 280 |
# File 'lib/wurk/boot_checks.rb', line 272 def warn_on_small_ar_pool(logger, ar_pool, threads) return unless ar_pool && ar_pool < threads logger.warn do "The Active Record pool holds #{ar_pool} connections but this process runs up to #{threads} job threads, " \ 'so jobs that use the database wait for a connection and then fail on a timeout. Set `pool:` in ' \ "config/database.yml to at least #{threads} (plus the web server's threads when jobs run in the web process)." end end |
.warn_on_unconsumed_framework_queues(config) ⇒ Object
Rails enqueues its own jobs (mail delivery, Active Storage analysis and purge, Action Mailbox routing) to queues an app never names itself, so they are the ones that go unlistened. Read from Redis, so only with it.
131 132 133 134 135 136 137 138 139 140 141 142 143 |
# File 'lib/wurk/boot_checks.rb', line 131 def warn_on_unconsumed_framework_queues(config) wanted = framework_queues return if wanted.empty? listened = own_queues(config) | config.redis(idempotent: true) { |conn| QueueConsumers.counts(conn).keys } missing = wanted.except(*listened) return if missing.empty? config.logger.warn do "Rails enqueues to #{listing(missing.map { |queue, what| "#{queue} (#{what})" })}, which neither this " \ 'process nor any live one listens to, so those jobs wait forever. Add the queues to a worker.' end end |
.wurk_active_job? ⇒ Boolean
162 163 164 165 166 |
# File 'lib/wurk/boot_checks.rb', line 162 def wurk_active_job? return false unless defined?(::ActiveJob::Base) ::ActiveJob::Base.queue_adapter.class.name.to_s.match?(/Wurk|Sidekiq/) end |