workhorse 1.4.1 → 1.4.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df386a01e22eb4d5e05be449ef11a62341d28c3443fdef76b07ac7e8bca3e3d4
4
- data.tar.gz: 0a4f325c0bf2cb08357a195a2297d0302df275abf3cd6d83329942d68419f7c8
3
+ metadata.gz: 0facaea70a5e826980ea41034914a0609a22fb3ed93c9fbe4a7607eb5a3854c9
4
+ data.tar.gz: 35cc28b0a31206fa5f38df60858c5c2054c28fd7c47a4bf0b4983c9cbbcc02d0
5
5
  SHA512:
6
- metadata.gz: 12b8ec75c276bf6d888e3f60b523a32cc387c6c4a935b20004fce4f63acbc4e7046979315eb7c6651953177d7289a3bf27fabc535da117a3d6a9c7be36f17ccd
7
- data.tar.gz: 1d15f6cf25a9fe2878e9edf45a7b2ce69d0bf0904119efd2d6234e8c15dc7142ce81c427912f22f2459fa27967b58932497424646d5ea9af431d625a1e154a8a
6
+ metadata.gz: 551453702ecb4b89060a7e5fdba4639eccbb60c09c85ebd2fd561eec91787c1b8166be81e81b2bd2da46f250ba70c8426198bca404d1cf1e5fbf1c6c9b5096ee
7
+ data.tar.gz: e1ee8b9ae1267271f8be2befd5455d90614e02c8708b2413bc90f957b8d5fb852b2f805ea4d42b6362844c2e7ed774e2a0a727e83687d570d0c430174e264227
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Workhorse Changelog
2
2
 
3
+ ## 1.4.2 - 2026-02-20
4
+
5
+ * Detach forked worker processes into their own session using `Process.setsid`.
6
+ Previously, when the ShellHandler was the session leader (e.g. started via
7
+ cron), its exit would cause the kernel to send `SIGHUP` to all forked workers,
8
+ potentially crashing them during startup before signal handlers were installed.
9
+
10
+ * Add optional debug logging (`config.debug_log_path`) for diagnosing issues
11
+ with signal handling, process lifecycle, log rotation, and daemon commands.
12
+
13
+ Sitrox reference: #120574.
14
+
3
15
  ## 1.4.1 - 2026-02-18
4
16
 
5
17
  * Close inherited lockfile fd in forked worker processes. Previously the
data/README.md CHANGED
@@ -522,7 +522,6 @@ Gem-internal model class `Workhorse::DbJob`, for example:
522
522
 
523
523
  ```ruby
524
524
  # config/initializers/workhorse.rb
525
-
526
525
  ActiveSupport.on_load :workhorse_db_job do
527
526
  # Code within this block will be run inside of the model class
528
527
  # Workhorse::DbJob.
@@ -530,6 +529,23 @@ ActiveSupport.on_load :workhorse_db_job do
530
529
  end
531
530
  ```
532
531
 
532
+ ## Debug logging
533
+
534
+ Workhorse includes an optional debug log for diagnosing issues with signal
535
+ handling, process lifecycle, log rotation, and daemon commands. To enable,
536
+ set `debug_log_path` to a writable file path:
537
+
538
+ ```ruby
539
+ # config/initializers/workhorse.rb
540
+ Workhorse.setup do |config|
541
+ config.debug_log_path = Rails.root.join('log', 'workhorse.debug.log')
542
+ end
543
+ ```
544
+
545
+ The debug log is designed to be safe for production use: all writes are
546
+ best-effort and silently ignore errors to avoid interfering with normal
547
+ operation. Set `debug_log_path` to `nil` (the default) to disable.
548
+
533
549
  ## Caveats
534
550
 
535
551
  ### Errors during polling / crashed workers
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.4.1
1
+ 1.4.2
@@ -16,43 +16,61 @@ module Workhorse
16
16
  begin
17
17
  case ARGV.first
18
18
  when 'start'
19
+ Workhorse.debug_log('ShellHandler: start command invoked')
19
20
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX)
21
+ Workhorse.debug_log('ShellHandler: lock acquired for start')
20
22
  daemon.lockfile = lockfile
21
23
  status = daemon.start
22
24
  when 'stop'
25
+ Workhorse.debug_log('ShellHandler: stop command invoked')
23
26
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX)
27
+ Workhorse.debug_log('ShellHandler: lock acquired for stop')
24
28
  daemon.lockfile = lockfile
25
29
  status = daemon.stop
26
30
  when 'kill'
31
+ Workhorse.debug_log('ShellHandler: kill command invoked')
27
32
  begin
28
33
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX | File::LOCK_NB)
34
+ Workhorse.debug_log('ShellHandler: lock acquired for kill')
29
35
  daemon.lockfile = lockfile
30
36
  status = daemon.stop(true)
31
37
  rescue LockNotAvailableError
38
+ Workhorse.debug_log('ShellHandler: lock not available for kill')
32
39
  status = 1
33
40
  end
34
41
  when 'status'
42
+ Workhorse.debug_log('ShellHandler: status command invoked')
35
43
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX)
44
+ Workhorse.debug_log('ShellHandler: lock acquired for status')
36
45
  daemon.lockfile = lockfile
37
46
  status = daemon.status
38
47
  when 'watch'
48
+ Workhorse.debug_log('ShellHandler: watch command invoked')
39
49
  begin
40
50
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX | File::LOCK_NB)
51
+ Workhorse.debug_log('ShellHandler: lock acquired for watch')
41
52
  daemon.lockfile = lockfile
42
53
  status = daemon.watch
43
54
  rescue LockNotAvailableError
55
+ Workhorse.debug_log('ShellHandler: lock not available for watch')
44
56
  status = 1
45
57
  end
46
58
  when 'restart'
59
+ Workhorse.debug_log('ShellHandler: restart command invoked')
47
60
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX)
61
+ Workhorse.debug_log('ShellHandler: lock acquired for restart')
48
62
  daemon.lockfile = lockfile
49
63
  status = daemon.restart
50
64
  when 'restart-logging'
65
+ Workhorse.debug_log('ShellHandler: restart-logging command invoked')
51
66
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX)
67
+ Workhorse.debug_log('ShellHandler: lock acquired for restart-logging')
52
68
  daemon.lockfile = lockfile
53
69
  status = daemon.restart_logging
54
70
  when 'soft-restart'
71
+ Workhorse.debug_log('ShellHandler: soft-restart command invoked')
55
72
  lockfile = acquire_lock(lockfile_path, File::LOCK_EX)
73
+ Workhorse.debug_log('ShellHandler: lock acquired for soft-restart')
56
74
  daemon.lockfile = lockfile
57
75
  status = daemon.soft_restart
58
76
  when 'usage'
@@ -66,7 +84,10 @@ module Workhorse
66
84
  warn "#{e.message}\n#{e.backtrace.join("\n")}"
67
85
  status = 99
68
86
  ensure
69
- lockfile&.flock(File::LOCK_UN)
87
+ if lockfile
88
+ Workhorse.debug_log("ShellHandler: releasing lock for #{ARGV.first}")
89
+ lockfile.flock(File::LOCK_UN)
90
+ end
70
91
  exit! status
71
92
  end
72
93
  end
@@ -201,20 +201,27 @@ module Workhorse
201
201
  def restart_logging
202
202
  code = 0
203
203
 
204
+ Workhorse.debug_log("restart_logging: sending HUP to #{@workers.count} worker(s)")
205
+
204
206
  for_each_worker do |worker|
205
207
  _pid_file, pid, active = read_pid(worker)
206
208
 
209
+ Workhorse.debug_log("restart_logging: worker ##{worker.id} (#{worker.name}): pid=#{pid.inspect}, active=#{active.inspect}")
210
+
207
211
  next unless pid && active
208
212
 
209
213
  begin
210
214
  Process.kill 'HUP', pid
215
+ Workhorse.debug_log("restart_logging: HUP sent successfully to PID #{pid}")
211
216
  puts "Worker (#{worker.name}) ##{worker.id}: Sent signal for restart-logging"
212
217
  rescue Errno::ESRCH
218
+ Workhorse.debug_log("restart_logging: HUP failed for PID #{pid}: process not found")
213
219
  warn "Worker (#{worker.name}) ##{worker.id}: Could not send signal for restart-logging, process not found"
214
220
  code = 2
215
221
  end
216
222
  end
217
223
 
224
+ Workhorse.debug_log("restart_logging: done, exit code=#{code}")
218
225
  return code
219
226
  end
220
227
 
@@ -227,20 +234,27 @@ module Workhorse
227
234
  def soft_restart
228
235
  code = 0
229
236
 
237
+ Workhorse.debug_log("Daemon: sending USR1 to #{@workers.count} worker(s)")
238
+
230
239
  for_each_worker do |worker|
231
240
  _pid_file, pid, active = read_pid(worker)
232
241
 
242
+ Workhorse.debug_log("Daemon soft_restart: worker ##{worker.id} (#{worker.name}): pid=#{pid.inspect}, active=#{active.inspect}")
243
+
233
244
  next unless pid && active
234
245
 
235
246
  begin
236
247
  Process.kill 'USR1', pid
248
+ Workhorse.debug_log("Daemon: USR1 sent successfully to PID #{pid}")
237
249
  puts "Worker (#{worker.name}) ##{worker.id}: Sent soft-restart signal"
238
250
  rescue Errno::ESRCH
251
+ Workhorse.debug_log("Daemon: USR1 failed for PID #{pid}: process not found")
239
252
  warn "Worker (#{worker.name}) ##{worker.id}: Process not found"
240
253
  code = 2
241
254
  end
242
255
  end
243
256
 
257
+ Workhorse.debug_log("Daemon soft_restart: done, exit code=#{code}")
244
258
  return code
245
259
  end
246
260
 
@@ -263,7 +277,13 @@ module Workhorse
263
277
  def start_worker(worker)
264
278
  check_rails_env if defined?(Rails)
265
279
 
280
+ Workhorse.debug_log("Daemon: forking worker ##{worker.id} (#{worker.name})")
266
281
  pid = fork do
282
+ # Detach from the parent's session so that the worker is not killed by
283
+ # SIGHUP when the parent (ShellHandler) exits. Without this, the kernel
284
+ # sends SIGHUP to the foreground process group when the session leader
285
+ # (e.g. a cron- or systemd-started ShellHandler) terminates.
286
+ Process.setsid
267
287
  $0 = process_name(worker)
268
288
  # Close inherited lockfile fd to prevent holding the flock after parent exits
269
289
  @lockfile&.close
@@ -278,6 +298,7 @@ module Workhorse
278
298
  worker.pid = pid
279
299
  File.write(pid_file_for(worker), pid)
280
300
  Process.detach(pid)
301
+ Workhorse.debug_log("Daemon: worker ##{worker.id} (#{worker.name}) forked with PID #{pid}")
281
302
  end
282
303
 
283
304
  # Stops a single worker process.
@@ -290,6 +311,7 @@ module Workhorse
290
311
  def stop_worker(pid_file, pid, kill: false)
291
312
  signals = kill ? %w[KILL] : %w[TERM INT]
292
313
 
314
+ Workhorse.debug_log("Daemon: stopping PID #{pid} with signals #{signals.join(', ')}")
293
315
  loop do
294
316
  begin
295
317
  signals.each { |signal| Process.kill(signal, pid) }
@@ -300,6 +322,7 @@ module Workhorse
300
322
  sleep 1
301
323
  end
302
324
 
325
+ Workhorse.debug_log("Daemon: PID #{pid} stopped")
303
326
  File.delete(pid_file)
304
327
  end
305
328
 
@@ -158,6 +158,8 @@ module Workhorse
158
158
  @poller.start
159
159
  log 'Started up'
160
160
 
161
+ Workhorse.debug_log("[Job worker #{id}] Started: PID=#{pid}, logger=#{describe_logger(logger)}")
162
+
161
163
  trap_termination if @auto_terminate
162
164
  trap_log_reopen
163
165
  trap_soft_restart
@@ -189,12 +191,14 @@ module Workhorse
189
191
  mutex.synchronize do
190
192
  assert_state! :running
191
193
 
194
+ Workhorse.debug_log("[Job worker #{id}] Shutdown starting")
192
195
  log 'Shutting down'
193
196
  @state = :shutdown
194
197
 
195
198
  @poller.shutdown
196
199
  @pool.shutdown
197
200
  log 'Shut down'
201
+ Workhorse.debug_log("[Job worker #{id}] Shutdown complete")
198
202
  end
199
203
  end
200
204
 
@@ -267,6 +271,8 @@ module Workhorse
267
271
 
268
272
  return true unless exceeded
269
273
 
274
+ Workhorse.debug_log("[Job worker #{id}] Memory limit exceeded: #{mem}MB > #{max}MB, initiating shutdown")
275
+
270
276
  if defined?(Rails)
271
277
  FileUtils.touch self.class.shutdown_file_for(pid)
272
278
  end
@@ -296,12 +302,14 @@ module Workhorse
296
302
  def trap_log_reopen
297
303
  Signal.trap(LOG_REOPEN_SIGNAL) do
298
304
  Thread.new do
305
+ Workhorse.debug_log("[Job worker #{id}] HUP received, logger state before reopen: #{describe_logger(logger)}")
306
+
299
307
  logger&.reopen
308
+ Workhorse.debug_log("[Job worker #{id}] Logger state after reopen: #{describe_logger(logger)}")
300
309
 
301
- if defined?(ActiveRecord::Base) && ActiveRecord::Base.logger && ActiveRecord::Base.logger != logger
302
- ActiveRecord::Base.logger.reopen
303
- end
310
+ Workhorse.debug_log("[Job worker #{id}] HUP handling complete")
304
311
  rescue Exception => e
312
+ Workhorse.debug_log("[Job worker #{id}] Logger reopen failed: #{e.class}: #{e.message}")
305
313
  log %(Log reopen signal handler error: #{e.message}\n#{e.backtrace.join("\n")}), :error
306
314
  Workhorse.on_exception.call(e)
307
315
  end.join
@@ -320,6 +328,7 @@ module Workhorse
320
328
  # quickly when called multiple times, this does not pose a risk of
321
329
  # keeping open a big number of "shutdown threads".
322
330
  Thread.new do
331
+ Workhorse.debug_log("[Job worker #{id}] #{signal} received, shutting down")
323
332
  log "\nCaught #{signal}, shutting worker down..."
324
333
  shutdown
325
334
  end.join
@@ -339,9 +348,14 @@ module Workhorse
339
348
 
340
349
  return unless @soft_restart_requested.make_true
341
350
 
351
+ Workhorse.debug_log("[Job worker #{id}] Soft restart initiated")
352
+
342
353
  # Create shutdown file for watch to detect
343
354
  shutdown_file = self.class.shutdown_file_for(pid)
344
- FileUtils.touch(shutdown_file) if shutdown_file
355
+ if shutdown_file
356
+ FileUtils.touch(shutdown_file)
357
+ Workhorse.debug_log("[Job worker #{id}] Shutdown file created: #{shutdown_file}")
358
+ end
345
359
 
346
360
  # Monitor in a separate thread to avoid blocking the signal handler
347
361
  @soft_restart_thread = Thread.new do
@@ -361,6 +375,7 @@ module Workhorse
361
375
  # Start a new thread as certain functionality (such as logging) is not
362
376
  # available from within a trap context.
363
377
  Thread.new do
378
+ Workhorse.debug_log("[Job worker #{id}] #{SOFT_RESTART_SIGNAL} received, initiating soft restart")
364
379
  log "\nCaught #{SOFT_RESTART_SIGNAL}, initiating soft restart..."
365
380
  soft_restart
366
381
  rescue Exception => e
@@ -372,16 +387,52 @@ module Workhorse
372
387
  end
373
388
  end
374
389
 
390
+ # Returns a human-readable description of a logger's internal state.
391
+ # Used for debug logging to diagnose log rotation issues.
392
+ #
393
+ # @param lgr [Logger, nil] The logger to describe
394
+ # @return [String] Description of the logger's state
395
+ # @private
396
+ def describe_logger(lgr)
397
+ return 'nil' unless lgr
398
+
399
+ parts = ["class=#{lgr.class}"]
400
+
401
+ logdev = lgr.instance_variable_get(:@logdev)
402
+ if logdev
403
+ parts << "filename=#{logdev.filename.inspect}" if logdev.respond_to?(:filename)
404
+
405
+ dev = logdev.respond_to?(:dev) ? logdev.dev : nil
406
+ if dev
407
+ parts << "closed=#{dev.closed?}"
408
+ unless dev.closed?
409
+ fileno = dev.fileno
410
+ parts << "fd=#{fileno}"
411
+ fd_path = "/proc/self/fd/#{fileno}"
412
+ parts << "fd_target=#{File.readlink(fd_path).inspect}" if File.exist?(fd_path)
413
+ end
414
+ end
415
+ else
416
+ parts << 'logdev=nil'
417
+ end
418
+
419
+ parts.join(', ')
420
+ rescue Exception => e
421
+ "error describing logger: #{e.class}: #{e.message}"
422
+ end
423
+
375
424
  # Waits for all jobs to complete, then shuts down the worker.
376
425
  # Called asynchronously from soft_restart.
377
426
  #
378
427
  # @return [void]
379
428
  # @private
380
429
  def wait_for_idle_then_shutdown
430
+ Workhorse.debug_log("[Job worker #{id}] Waiting for idle before soft restart shutdown (pool_size=#{@pool_size})")
381
431
  loop do
382
432
  break if @state == :shutdown
383
433
 
384
434
  if idle == @pool_size
435
+ Workhorse.debug_log("[Job worker #{id}] All threads idle, proceeding with soft restart shutdown")
385
436
  log 'All jobs completed, shutting down for soft restart'
386
437
  shutdown
387
438
  break
data/lib/workhorse.rb CHANGED
@@ -107,6 +107,31 @@ module Workhorse
107
107
  mattr_accessor :max_worker_memory_mb
108
108
  self.max_worker_memory_mb = 0
109
109
 
110
+ # Path to a debug log file for diagnosing log rotation and signal handling issues.
111
+ # When set, Workhorse writes timestamped debug entries to this file at key points
112
+ # (worker startup, HUP signal handling, restart-logging command flow).
113
+ # Set to nil to disable (default).
114
+ #
115
+ # @return [String, nil] Path to debug log file
116
+ mattr_accessor :debug_log_path
117
+ self.debug_log_path = nil
118
+
119
+ # Writes a debug message to the debug log file.
120
+ # Does nothing if {.debug_log_path} is nil.
121
+ # Silently ignores all exceptions to avoid interfering with normal operation.
122
+ #
123
+ # @param message [String] The message to log
124
+ # @return [void]
125
+ def self.debug_log(message)
126
+ return unless debug_log_path
127
+
128
+ File.open(debug_log_path, 'a') do |f|
129
+ f.write("[#{Time.now.iso8601(3)}] [PID #{Process.pid}] #{message}\n")
130
+ f.flush
131
+ end
132
+ rescue Exception # rubocop:disable Lint/SuppressedException
133
+ end
134
+
110
135
  # Configuration method for setting up Workhorse options.
111
136
  #
112
137
  # @yield [self] Configuration block
data/workhorse.gemspec CHANGED
@@ -1,14 +1,14 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: workhorse 1.4.1 ruby lib
2
+ # stub: workhorse 1.4.2 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "workhorse".freeze
6
- s.version = "1.4.1"
6
+ s.version = "1.4.2"
7
7
 
8
8
  s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
9
9
  s.require_paths = ["lib".freeze]
10
10
  s.authors = ["Sitrox".freeze]
11
- s.date = "2026-02-18"
11
+ s.date = "2026-02-20"
12
12
  s.files = [".github/workflows/ruby.yml".freeze, ".gitignore".freeze, ".releaser_config".freeze, ".rubocop.yml".freeze, "CHANGELOG.md".freeze, "FAQ.md".freeze, "Gemfile".freeze, "LICENSE".freeze, "README.md".freeze, "RUBY_VERSION".freeze, "Rakefile".freeze, "VERSION".freeze, "bin/rubocop".freeze, "lib/active_job/queue_adapters/workhorse_adapter.rb".freeze, "lib/generators/workhorse/install_generator.rb".freeze, "lib/generators/workhorse/templates/bin/workhorse.rb".freeze, "lib/generators/workhorse/templates/config/initializers/workhorse.rb".freeze, "lib/generators/workhorse/templates/create_table_jobs.rb".freeze, "lib/workhorse.rb".freeze, "lib/workhorse/active_job_extension.rb".freeze, "lib/workhorse/daemon.rb".freeze, "lib/workhorse/daemon/shell_handler.rb".freeze, "lib/workhorse/db_job.rb".freeze, "lib/workhorse/enqueuer.rb".freeze, "lib/workhorse/jobs/cleanup_succeeded_jobs.rb".freeze, "lib/workhorse/jobs/detect_stale_jobs_job.rb".freeze, "lib/workhorse/jobs/run_active_job.rb".freeze, "lib/workhorse/jobs/run_rails_op.rb".freeze, "lib/workhorse/performer.rb".freeze, "lib/workhorse/poller.rb".freeze, "lib/workhorse/pool.rb".freeze, "lib/workhorse/scoped_env.rb".freeze, "lib/workhorse/worker.rb".freeze, "test/active_job/queue_adapters/workhorse_adapter_test.rb".freeze, "test/lib/db_schema.rb".freeze, "test/lib/jobs.rb".freeze, "test/lib/test_helper.rb".freeze, "test/workhorse/daemon_test.rb".freeze, "test/workhorse/db_job_test.rb".freeze, "test/workhorse/enqueuer_test.rb".freeze, "test/workhorse/performer_test.rb".freeze, "test/workhorse/poller_test.rb".freeze, "test/workhorse/pool_test.rb".freeze, "test/workhorse/worker_test.rb".freeze, "workhorse.gemspec".freeze]
13
13
  s.homepage = "https://github.com/sitrox/workhorse".freeze
14
14
  s.licenses = ["MIT".freeze]
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workhorse
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.1
4
+ version: 1.4.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sitrox
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-02-18 00:00:00.000000000 Z
10
+ date: 2026-02-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport