workhorse 1.4.5 → 1.5.1

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: ef9cd623d06cab42a5c5a849f9a5c334c0bb8f4dd517f2c84e5ba3c4df611b24
4
- data.tar.gz: 9681900417bc660a347afeeaebf6cf57fa342268afc38c24b4e5ce11479ce1e3
3
+ metadata.gz: 5b370f18088a986d133800af502b072749a8ae3b1b56585abc7cd6e086a40f7f
4
+ data.tar.gz: 7554e03f2927f9915013993d0da0401772bf201a993ae56b54e3631d295faa0b
5
5
  SHA512:
6
- metadata.gz: 86a5b9f9db82efbd890dc77fa763dcc1052027df45d922358428e721bfe3e900670e28b1e55af06e04972fef7c6408fe8a445239443f0707063281d4baf344fe
7
- data.tar.gz: 74f47b979b3589b5530947638c7ec7a6acbb9f85599c88bd9eccaf25fcecf5a92addecb58fa9357bc07eb840bf16b31e5a7fe7fe2f9a6e11fa697b31d9aaf526
6
+ metadata.gz: d75f21bb4784267b96dd047cf04f652458265b335a9020f3ae101d5aa85e18078ef37ed16d67c3bb2b6a4f47f696f4b9286817b3138b902644ef7ee799614140
7
+ data.tar.gz: 67aaa64b06d6523e3d4bfa90dfa1053134e79f877208e0077e3adaa6b61fb8a5c6ae10c0c61e6d1f4254131e0106e9cc3ca32546db3bbafd41d6dbff7af938a6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Workhorse Changelog
2
2
 
3
+ ## 1.5.1 - 2026-07-22
4
+
5
+ * Add per-worker heartbeat files. On every successful poll, each worker touches
6
+ `tmp/pids/workhorse.<daemon_id>.heartbeat` (keyed by daemon worker id, the same
7
+ id used for the pidfile). The file's mtime provides a stable per-worker
8
+ "last polled at" clock that survives restarts, letting external monitoring
9
+ detect dead, crash-looping, or hung workers. The daemon exposes the worker id
10
+ to the forked worker process via the `WORKHORSE_DAEMON_WORKER_ID` environment
11
+ variable.
12
+
13
+ Sitrox reference: #151020.
14
+
15
+ ## 1.5.0 - 2026-07-01
16
+
17
+ * Change `DetectStaleJobsJob` to accept `locked_to_started_threshold` and
18
+ `run_time_threshold` as keyword arguments instead of reading from global
19
+ configuration. The config options `Workhorse.stale_detection_locked_to_started_threshold`
20
+ and `Workhorse.stale_detection_run_time_threshold` have been removed.
21
+
22
+ If you were previously configuring thresholds globally:
23
+
24
+ ```ruby
25
+ # Before
26
+ Workhorse.setup do |config|
27
+ config.stale_detection_locked_to_started_threshold = 300
28
+ config.stale_detection_run_time_threshold = 3600
29
+ end
30
+ Workhorse.enqueue(DetectStaleJobsJob.new)
31
+
32
+ # After
33
+ Workhorse.enqueue(DetectStaleJobsJob.new(locked_to_started_threshold: 300, run_time_threshold: 3600))
34
+ ```
35
+
36
+ * Add `queues` keyword argument to `DetectStaleJobsJob` to restrict stale job
37
+ detection to specific queues. When omitted, all queues are checked (existing
38
+ behavior).
39
+
40
+ Sitrox reference: #132256.
41
+
3
42
  ## 1.4.5 - 2026-05-09
4
43
 
5
44
  * Close the lockfile after releasing the lock in the ShellHandler.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.4.5
1
+ 1.5.1
@@ -314,6 +314,10 @@ module Workhorse
314
314
  $stdout.reopen null_out
315
315
  $stderr.reopen null_out
316
316
 
317
+ # Expose the daemon worker id so the worker can key its heartbeat file
318
+ # by slot (see Workhorse::Worker#heartbeat!). Same id as the pidfile.
319
+ ENV['WORKHORSE_DAEMON_WORKER_ID'] = worker.id.to_s
320
+
317
321
  worker.block.call
318
322
  end
319
323
  worker.pid = pid
@@ -5,24 +5,27 @@ module Workhorse::Jobs
5
5
  # exception is thrown (which may cause a notification if you configured
6
6
  # {Workhorse.on_exception} accordingly).
7
7
  #
8
- # The thresholds are obtained from the configuration options
9
- # {Workhorse.stale_detection_locked_to_started_threshold} and
10
- # {Workhorse.stale_detection_run_time_threshold}.
11
- #
12
- # @example Schedule stale job detection
8
+ # @example Schedule stale job detection with default thresholds
13
9
  # Workhorse.enqueue(DetectStaleJobsJob.new)
14
10
  #
15
- # @example Configure thresholds
16
- # Workhorse.setup do |config|
17
- # config.stale_detection_locked_to_started_threshold = 300 # 5 minutes
18
- # config.stale_detection_run_time_threshold = 3600 # 1 hour
19
- # end
11
+ # @example Schedule with custom thresholds
12
+ # Workhorse.enqueue(DetectStaleJobsJob.new(locked_to_started_threshold: 300, run_time_threshold: 3600))
13
+ #
14
+ # @example Only check specific queues
15
+ # Workhorse.enqueue(DetectStaleJobsJob.new(queues: ['mailer', 'reports']))
20
16
  class DetectStaleJobsJob
21
17
  # Creates a new stale job detection job.
22
- # Reads configuration thresholds at initialization time.
23
- def initialize
24
- @locked_to_started_threshold = Workhorse.stale_detection_locked_to_started_threshold
25
- @run_time_threshold = Workhorse.stale_detection_run_time_threshold
18
+ #
19
+ # @param locked_to_started_threshold [Integer] Maximum number of seconds a job is
20
+ # allowed to stay 'locked' before an exception is raised. Set to 0 to skip this check.
21
+ # @param run_time_threshold [Integer] Maximum number of seconds a job is allowed to
22
+ # run before an exception is raised. Set to 0 to skip this check.
23
+ # @param queues [Array<String>, nil] If given, only check jobs in these queues.
24
+ # If `nil` (default), all queues are checked.
25
+ def initialize(locked_to_started_threshold: 3 * 60, run_time_threshold: 12 * 60, queues: nil)
26
+ @locked_to_started_threshold = locked_to_started_threshold
27
+ @run_time_threshold = run_time_threshold
28
+ @queues = queues
26
29
  end
27
30
 
28
31
  # Executes the stale job detection.
@@ -38,6 +41,7 @@ module Workhorse::Jobs
38
41
  if @locked_to_started_threshold != 0
39
42
  rel = Workhorse::DbJob.locked
40
43
  rel = rel.where('locked_at < ?', @locked_to_started_threshold.seconds.ago)
44
+ rel = rel.where(queue: @queues) if @queues
41
45
  ids = rel.pluck(:id)
42
46
 
43
47
  unless ids.empty?
@@ -50,6 +54,7 @@ module Workhorse::Jobs
50
54
  if @run_time_threshold != 0
51
55
  rel = Workhorse::DbJob.started
52
56
  rel = rel.where('started_at < ?', @run_time_threshold.seconds.ago)
57
+ rel = rel.where(queue: @queues) if @queues
53
58
  ids = rel.pluck(:id)
54
59
 
55
60
  unless ids.empty?
@@ -302,6 +302,12 @@ module Workhorse
302
302
  # while we were acquiring the lock or querying jobs.
303
303
  job_ids.each { |job_id| worker.perform(job_id) } if running? && worker.accepting_jobs?
304
304
  end
305
+
306
+ # Record that this worker successfully polled. Done at the very end so it
307
+ # only advances when the poll actually completed (a poll that raises never
308
+ # reaches here). Skipped on the early return above when the worker is no
309
+ # longer accepting jobs, so a soft-restarting worker correctly ages out.
310
+ worker.heartbeat!
305
311
  end
306
312
 
307
313
  # Returns an array of {Workhorse::DbJob}s that can be started.
@@ -65,6 +65,23 @@ module Workhorse
65
65
  Rails.root.join('tmp', 'pids', "workhorse.#{pid}.shutdown")
66
66
  end
67
67
 
68
+ # Returns the path to the heartbeat file for a given daemon worker id.
69
+ #
70
+ # The heartbeat file is touched on every successful poll (see {#heartbeat!})
71
+ # and lets external monitoring determine, from its mtime, how long ago a
72
+ # worker last polled. It is keyed by the daemon worker id (the same id used
73
+ # for the pidfile) rather than the pid, so the mtime survives restarts and
74
+ # forms a stable per-slot "last polled at" clock.
75
+ #
76
+ # @param daemon_id [String, Integer] Daemon worker id (see
77
+ # {Workhorse::Daemon}).
78
+ # @return [Pathname, nil] Path to heartbeat file or nil if not in Rails
79
+ # @private
80
+ def self.heartbeat_file_for(daemon_id)
81
+ return nil unless defined?(Rails)
82
+ Rails.root.join('tmp', 'pids', "workhorse.#{daemon_id}.heartbeat")
83
+ end
84
+
68
85
  # Instantiates a new worker. The worker is not automatically started.
69
86
  #
70
87
  # @param queues [Array] The queues you want this worker to process. If an
@@ -228,6 +245,28 @@ module Workhorse
228
245
  @soft_restart_requested.false?
229
246
  end
230
247
 
248
+ # Touches this worker's heartbeat file to record that it just polled
249
+ # successfully. Called at the end of each poll (see {Workhorse::Poller#poll}).
250
+ #
251
+ # Keyed by the daemon worker id provided via the
252
+ # `WORKHORSE_DAEMON_WORKER_ID` environment variable (set by
253
+ # {Workhorse::Daemon} when forking the worker). No-op when the id is absent
254
+ # (e.g. workers not started through the daemon) or when not running in Rails.
255
+ #
256
+ # This is best-effort: any error is swallowed so that a heartbeat failure can
257
+ # never take down the worker.
258
+ #
259
+ # @return [void]
260
+ def heartbeat!
261
+ daemon_id = ENV.fetch('WORKHORSE_DAEMON_WORKER_ID', nil)
262
+ return unless daemon_id
263
+
264
+ path = self.class.heartbeat_file_for(daemon_id)
265
+ FileUtils.touch(path) if path
266
+ rescue StandardError => e
267
+ Workhorse.debug_log("[Job worker #{id}] Heartbeat touch failed: #{e.class}: #{e.message}")
268
+ end
269
+
231
270
  # Schedules a job for execution in the thread pool.
232
271
  #
233
272
  # @param db_job_id [Integer] The ID of the {Workhorse::DbJob} to perform
data/lib/workhorse.rb CHANGED
@@ -98,18 +98,6 @@ module Workhorse
98
98
  mattr_accessor :clean_stuck_jobs
99
99
  self.clean_stuck_jobs = false
100
100
 
101
- # This setting is for {Workhorse::Jobs::DetectStaleJobsJob} and specifies the
102
- # maximum number of seconds a job is allowed to stay 'locked' before this job
103
- # throws an exception. Set this to 0 to skip this check.
104
- mattr_accessor :stale_detection_locked_to_started_threshold
105
- self.stale_detection_locked_to_started_threshold = 3 * 60
106
-
107
- # This setting is for {Workhorse::Jobs::DetectStaleJobsJob} and specifies the
108
- # maximum number of seconds a job is allowed to run before this job throws an
109
- # exception. Set this to 0 to skip this check.
110
- mattr_accessor :stale_detection_run_time_threshold
111
- self.stale_detection_run_time_threshold = 12 * 60
112
-
113
101
  # Maximum memory usage per {Workhorse::Worker} process in MB.
114
102
  # When exceeded, the watch command will restart the worker. Set to 0 to disable.
115
103
  #
@@ -306,6 +306,53 @@ class Workhorse::WorkerTest < WorkhorseTest
306
306
  Workhorse.max_worker_memory_mb = 0
307
307
  end
308
308
 
309
+ def test_heartbeat_file_for
310
+ assert_equal(
311
+ Rails.root.join('tmp', 'pids', 'workhorse.3.heartbeat').to_s,
312
+ Workhorse::Worker.heartbeat_file_for(3).to_s
313
+ )
314
+ end
315
+
316
+ def test_heartbeat_noop_without_daemon_id
317
+ ENV.delete('WORKHORSE_DAEMON_WORKER_ID')
318
+ path = Workhorse::Worker.heartbeat_file_for('42')
319
+ FileUtils.rm_f(path)
320
+
321
+ Workhorse::Worker.new.heartbeat!
322
+
323
+ assert_not File.exist?(path), 'heartbeat must not be touched without a daemon id'
324
+ ensure
325
+ FileUtils.rm_f(path)
326
+ end
327
+
328
+ def test_heartbeat_touches_file
329
+ ENV['WORKHORSE_DAEMON_WORKER_ID'] = '42'
330
+ path = Workhorse::Worker.heartbeat_file_for('42')
331
+ FileUtils.rm_f(path)
332
+
333
+ Workhorse::Worker.new.heartbeat!
334
+
335
+ assert File.exist?(path), 'heartbeat file expected to be touched'
336
+ ensure
337
+ ENV.delete('WORKHORSE_DAEMON_WORKER_ID')
338
+ FileUtils.rm_f(path)
339
+ end
340
+
341
+ def test_poll_touches_heartbeat
342
+ ENV['WORKHORSE_DAEMON_WORKER_ID'] = '42'
343
+ path = Workhorse::Worker.heartbeat_file_for('42')
344
+ FileUtils.rm_f(path)
345
+
346
+ with_worker(pool_size: 1, polling_interval: 0.1) do
347
+ with_retries do
348
+ assert File.exist?(path), 'expected a successful poll to touch the heartbeat file'
349
+ end
350
+ end
351
+ ensure
352
+ ENV.delete('WORKHORSE_DAEMON_WORKER_ID')
353
+ FileUtils.rm_f(path)
354
+ end
355
+
309
356
  private
310
357
 
311
358
  def assert_process(pid)
data/workhorse.gemspec CHANGED
@@ -1,14 +1,14 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: workhorse 1.4.5 ruby lib
2
+ # stub: workhorse 1.5.1 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "workhorse".freeze
6
- s.version = "1.4.5".freeze
6
+ s.version = "1.5.1".freeze
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-05-09"
11
+ s.date = "2026-07-22"
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, "test/workhorse/yjit_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.5
4
+ version: 1.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sitrox
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-05-09 00:00:00.000000000 Z
10
+ date: 2026-07-22 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport
@@ -119,7 +119,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
119
119
  - !ruby/object:Gem::Version
120
120
  version: '0'
121
121
  requirements: []
122
- rubygems_version: 4.0.2
122
+ rubygems_version: 4.0.11
123
123
  specification_version: 4
124
124
  summary: Multi-threaded job backend with database queuing for ruby.
125
125
  test_files: