workhorse 1.5.0 → 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: d1159cb21bdb8fcb4a31ae5857ad5dcc16191879ac6f8025830b97cb6627df8c
4
- data.tar.gz: c963ee57e992cf754cde1e91df6a93d7d207aff663b3bbdae443050605918e63
3
+ metadata.gz: 5b370f18088a986d133800af502b072749a8ae3b1b56585abc7cd6e086a40f7f
4
+ data.tar.gz: 7554e03f2927f9915013993d0da0401772bf201a993ae56b54e3631d295faa0b
5
5
  SHA512:
6
- metadata.gz: 05b57e6750c6bdf4d693d503f0d0659f7dcb23c49c6a3f11d47ed31ba665cf5b5bae1f1aa6c6ea925fa35125260da37e9b31aeccd644e9cbefce26c50284104b
7
- data.tar.gz: f9832222d5b3bd0edb56089c06a6eb1ba36ef4d496580aab83d75a75760c583dd2b74647a0d196be58b3e4f5b91096228bee0cbb7df7cfe9b6efbb7bc92bb32e
6
+ metadata.gz: d75f21bb4784267b96dd047cf04f652458265b335a9020f3ae101d5aa85e18078ef37ed16d67c3bb2b6a4f47f696f4b9286817b3138b902644ef7ee799614140
7
+ data.tar.gz: 67aaa64b06d6523e3d4bfa90dfa1053134e79f877208e0077e3adaa6b61fb8a5c6ae10c0c61e6d1f4254131e0106e9cc3ca32546db3bbafd41d6dbff7af938a6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
3
15
  ## 1.5.0 - 2026-07-01
4
16
 
5
17
  * Change `DetectStaleJobsJob` to accept `locked_to_started_threshold` and
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.5.0
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
@@ -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
@@ -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.5.0 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.5.0".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-07-01"
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.5.0
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-07-01 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