workhorse 1.5.0 → 1.5.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: d1159cb21bdb8fcb4a31ae5857ad5dcc16191879ac6f8025830b97cb6627df8c
4
- data.tar.gz: c963ee57e992cf754cde1e91df6a93d7d207aff663b3bbdae443050605918e63
3
+ metadata.gz: 9ffbb2c24919d7b745fd87bd6a8675fdae5448a6c0e11c3af486d34baf5a9343
4
+ data.tar.gz: 9cf2270f0c3a65d244d679baf56367033e314e4a8b7ed11cc17f4d07e4a6fe63
5
5
  SHA512:
6
- metadata.gz: 05b57e6750c6bdf4d693d503f0d0659f7dcb23c49c6a3f11d47ed31ba665cf5b5bae1f1aa6c6ea925fa35125260da37e9b31aeccd644e9cbefce26c50284104b
7
- data.tar.gz: f9832222d5b3bd0edb56089c06a6eb1ba36ef4d496580aab83d75a75760c583dd2b74647a0d196be58b3e4f5b91096228bee0cbb7df7cfe9b6efbb7bc92bb32e
6
+ metadata.gz: 22b02451a64ae383b75616b9556cc05927f479f47db7d7993347df0f520d36ebfda8c7f3f4de49574c84499dfd603285bab83295df72c6100b0f6f438730d576
7
+ data.tar.gz: '0549ab8a2e2683dd13b60bc85a5d2d85a260dee938a13714c45fe1233af217a433af2e71f899a7056c3afd8f503f6f94e1a5b9cee5e97bbf5fb93fed9ae75a03'
@@ -16,11 +16,15 @@ jobs:
16
16
  - '3.0.1'
17
17
  - '3.1.2'
18
18
  - '3.2.1'
19
+ db-adapter:
20
+ - 'mysql2'
21
+ - 'trilogy'
19
22
  env:
20
23
  DB_DATABASE: workhorse
21
24
  DB_USER: root
22
25
  DB_PASSWORD: 'root'
23
26
  DB_HOST: localhost
27
+ DB_ADAPTER: ${{ matrix.db-adapter }}
24
28
 
25
29
  steps:
26
30
  - uses: actions/checkout@v2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Workhorse Changelog
2
2
 
3
+ ## 1.5.2 - 2026-08-04
4
+
5
+ * Fix `Poller#valid_queues` raising `NoMethodError` on the Oracle adapter. The
6
+ `activerecord-oracle_enhanced-adapter` returns `true` from `#execute` instead
7
+ of a result set, so the subsequent `.to_a` call failed with
8
+ `undefined method 'to_a' for true`. Switched to `#select_values`, which is
9
+ implemented in terms of `#exec_query` and therefore returns the queue names on
10
+ the `mysql2`, `trilogy` and `oracle_enhanced` adapters alike (the latter both
11
+ before and after version 7.0.0).
12
+
13
+ Sitrox reference: #152178.
14
+
15
+ * Run the test suite against both the `mysql2` and the `trilogy` adapter. The
16
+ adapter can be selected using the `DB_ADAPTER` environment variable and
17
+ defaults to `mysql2`.
18
+
19
+ ## 1.5.1 - 2026-07-22
20
+
21
+ * Add per-worker heartbeat files. On every successful poll, each worker touches
22
+ `tmp/pids/workhorse.<daemon_id>.heartbeat` (keyed by daemon worker id, the same
23
+ id used for the pidfile). The file's mtime provides a stable per-worker
24
+ "last polled at" clock that survives restarts, letting external monitoring
25
+ detect dead, crash-looping, or hung workers. The daemon exposes the worker id
26
+ to the forked worker process via the `WORKHORSE_DAEMON_WORKER_ID` environment
27
+ variable.
28
+
29
+ Sitrox reference: #151020.
30
+
3
31
  ## 1.5.0 - 2026-07-01
4
32
 
5
33
  * Change `DetectStaleJobsJob` to accept `locked_to_started_threshold` and
data/Gemfile CHANGED
@@ -12,3 +12,4 @@ gem 'mysql2'
12
12
  gem 'pry'
13
13
  gem 'rake'
14
14
  gem 'rubocop', '~> 1.60'
15
+ gem 'trilogy'
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.5.0
1
+ 1.5.2
@@ -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.
@@ -446,7 +452,12 @@ module Workhorse
446
452
  select.projections = []
447
453
  queues = select.project(:queue)
448
454
 
449
- return Workhorse::DbJob.connection.execute(queues.distinct.to_sql).to_a.flatten
455
+ # Note that `select_values` is used here on purpose: `execute` does not
456
+ # return a result set on every adapter (the Oracle enhanced adapter, for
457
+ # instance, returns `true` for queries), while `select_values` is
458
+ # implemented in terms of `exec_query` and thus behaves the same on the
459
+ # mysql2, trilogy and Oracle enhanced adapters.
460
+ return Workhorse::DbJob.connection.select_values(queues.distinct.to_sql)
450
461
  end
451
462
  end
452
463
  end
@@ -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
@@ -2,11 +2,23 @@ require 'minitest/autorun'
2
2
  require 'active_record'
3
3
  require 'active_job'
4
4
  require 'pry'
5
- require 'mysql2'
6
5
  require 'benchmark'
7
6
  require 'concurrent'
8
7
  require 'jobs'
9
8
 
9
+ # The adapter to run the test suite against. Both MySQL / MariaDB adapters are
10
+ # supported, as workhorse has to work with either of them.
11
+ DB_ADAPTER = ENV.fetch('DB_ADAPTER', 'mysql2')
12
+
13
+ case DB_ADAPTER
14
+ when 'mysql2'
15
+ require 'mysql2'
16
+ when 'trilogy'
17
+ require 'trilogy'
18
+ else
19
+ fail "Unsupported DB_ADAPTER #{DB_ADAPTER.inspect}, use 'mysql2' or 'trilogy'."
20
+ end
21
+
10
22
  class MockRailsEnv < String
11
23
  def production?
12
24
  self == 'production'
@@ -43,10 +55,25 @@ class WorkhorseTest < ActiveSupport::TestCase
43
55
 
44
56
  attr_reader :daemon
45
57
 
58
+ # Temporarily replaces the method `method` on `object` with one that always
59
+ # returns `value`, for the duration of the given block.
60
+ #
61
+ # This is implemented by hand rather than using minitest's `stub`, as
62
+ # `minitest/mock` is not available in every minitest version this gem is
63
+ # tested against.
64
+ def with_return_value(object, method, value)
65
+ object.define_singleton_method(method) { |*, **| value }
66
+ yield
67
+ ensure
68
+ object.singleton_class.send(:remove_method, method)
69
+ end
70
+
46
71
  def clear_locks_and_db_threads!
47
72
  Workhorse::DbJob.connection.execute('SELECT RELEASE_ALL_LOCKS()')
48
73
 
49
- pids = Workhorse::DbJob.connection.execute(<<~SQL.squish).to_a.flatten
74
+ # Use `select_values` rather than `execute`, as the latter does not return a
75
+ # result set on every adapter.
76
+ pids = Workhorse::DbJob.connection.select_values(<<~SQL.squish)
50
77
  SELECT ID FROM INFORMATION_SCHEMA.PROCESSLIST WHERE ID != CONNECTION_ID()
51
78
  SQL
52
79
 
@@ -179,7 +206,7 @@ class WorkhorseTest < ActiveSupport::TestCase
179
206
  end
180
207
 
181
208
  ActiveRecord::Base.establish_connection(
182
- adapter: 'mysql2',
209
+ adapter: DB_ADAPTER,
183
210
  database: ENV.fetch('DB_NAME', nil) || 'workhorse',
184
211
  username: ENV.fetch('DB_USERNAME', nil) || 'root',
185
212
  password: ENV.fetch('DB_PASSWORD', nil) || '',
@@ -71,6 +71,21 @@ class Workhorse::PollerTest < WorkhorseTest
71
71
  assert_equal [], w.poller.send(:valid_queues)
72
72
  end
73
73
 
74
+ # Not every adapter returns a result set from `execute`: the Oracle enhanced
75
+ # adapter, for instance, returns `true` for queries, both before and after
76
+ # version 7.0.0. Querying the valid queues must therefore not rely on the
77
+ # return value of `execute`.
78
+ def test_valid_queues_without_usable_execute
79
+ w = Workhorse::Worker.new(polling_interval: 60)
80
+
81
+ Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: nil
82
+ Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :a
83
+
84
+ with_return_value(Workhorse::DbJob.connection, :execute, true) do
85
+ assert_equal [nil, 'a'], w.poller.send(:valid_queues)
86
+ end
87
+ end
88
+
74
89
  def test_nil_queues
75
90
  w = Workhorse::Worker.new(pool_size: 2, polling_interval: 60)
76
91
 
@@ -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.2 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.2".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-08-04"
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.2
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-08-04 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport