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 +4 -4
- data/.github/workflows/ruby.yml +4 -0
- data/CHANGELOG.md +28 -0
- data/Gemfile +1 -0
- data/VERSION +1 -1
- data/lib/workhorse/daemon.rb +4 -0
- data/lib/workhorse/poller.rb +12 -1
- data/lib/workhorse/worker.rb +39 -0
- data/test/lib/test_helper.rb +30 -3
- data/test/workhorse/poller_test.rb +15 -0
- data/test/workhorse/worker_test.rb +47 -0
- data/workhorse.gemspec +3 -3
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9ffbb2c24919d7b745fd87bd6a8675fdae5448a6c0e11c3af486d34baf5a9343
|
|
4
|
+
data.tar.gz: 9cf2270f0c3a65d244d679baf56367033e314e4a8b7ed11cc17f4d07e4a6fe63
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22b02451a64ae383b75616b9556cc05927f479f47db7d7993347df0f520d36ebfda8c7f3f4de49574c84499dfd603285bab83295df72c6100b0f6f438730d576
|
|
7
|
+
data.tar.gz: '0549ab8a2e2683dd13b60bc85a5d2d85a260dee938a13714c45fe1233af217a433af2e71f899a7056c3afd8f503f6f94e1a5b9cee5e97bbf5fb93fed9ae75a03'
|
data/.github/workflows/ruby.yml
CHANGED
|
@@ -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
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.5.
|
|
1
|
+
1.5.2
|
data/lib/workhorse/daemon.rb
CHANGED
|
@@ -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
|
data/lib/workhorse/poller.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/workhorse/worker.rb
CHANGED
|
@@ -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/test/lib/test_helper.rb
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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-
|
|
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.
|
|
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-
|
|
10
|
+
date: 2026-08-04 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activesupport
|