workhorse 2.0.0.rc0 → 2.0.0.rc1

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.
@@ -97,14 +97,11 @@ class Workhorse::WorkerTest < WorkhorseTest
97
97
  with_worker(pool_size: 5, polling_interval: 0.2) do |w|
98
98
  assert_equal 5, w.idle
99
99
 
100
- sleep 0.05
101
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2)
100
+ # Long enough to be seen running at the 0.1s the retries look.
101
+ Workhorse.enqueue BasicJob.new(sleep_time: 1)
102
102
 
103
- sleep 0.25
104
- assert_equal 4, w.idle
105
-
106
- sleep 0.2
107
- assert_equal 5, w.idle
103
+ with_retries { assert_equal 4, w.idle }
104
+ with_retries { assert_equal 5, w.idle }
108
105
  end
109
106
  end
110
107
 
@@ -122,42 +119,37 @@ class Workhorse::WorkerTest < WorkhorseTest
122
119
  end
123
120
 
124
121
  def test_perform
125
- with_worker(polling_interval: 0.2) do
126
- sleep 0.1
127
- Workhorse.enqueue BasicJob.new(sleep_time: 0.1)
128
- assert_equal 'waiting', Workhorse::DbJob.first.state
122
+ # Enqueued before the worker starts, as a running one may pick the job up
123
+ # before its state can be looked at.
124
+ Workhorse.enqueue BasicJob.new(sleep_time: 0.1)
125
+ assert_equal 'waiting', Workhorse::DbJob.first.state
129
126
 
130
- sleep 0.3
127
+ work_until(pool_size: 5, polling_interval: 0.2) do
128
+ assert_equal 'succeeded', Workhorse::DbJob.first.state
131
129
  end
132
-
133
- assert_equal 'succeeded', Workhorse::DbJob.first.state
134
130
  end
135
131
 
136
132
  def test_params
137
133
  BasicJob.results.clear
138
134
 
139
135
  Workhorse.enqueue BasicJob.new(some_param: 5, sleep_time: 0)
140
- work 0.5
141
-
142
- assert_equal 'succeeded', Workhorse::DbJob.first.state
136
+ work_until(pool_size: 5, polling_interval: 0.2) { assert_equal 'succeeded', Workhorse::DbJob.first.state }
143
137
 
144
138
  assert_equal 1, BasicJob.results.count
145
139
  assert_equal 5, BasicJob.results.first
146
140
  end
147
141
 
148
142
  def test_term
149
- with_worker(polling_interval: 0.2) do |w|
143
+ with_worker(polling_interval: 0.2, auto_terminate: true) do |w|
150
144
  Process.kill 'TERM', Process.pid
151
- sleep 0.2
152
- w.assert_state! :shutdown
145
+ with_retries { w.assert_state! :shutdown }
153
146
  end
154
147
  end
155
148
 
156
149
  def test_int
157
- with_worker(polling_interval: 0.2) do |w|
150
+ with_worker(polling_interval: 0.2, auto_terminate: true) do |w|
158
151
  Process.kill 'INT', Process.pid
159
- sleep 0.2
160
- w.assert_state! :shutdown
152
+ with_retries { w.assert_state! :shutdown }
161
153
  end
162
154
  end
163
155
 
@@ -174,6 +166,36 @@ class Workhorse::WorkerTest < WorkhorseTest
174
166
  FileUtils.rm_f Workhorse::Worker.shutdown_file_for(Process.pid)
175
167
  end
176
168
 
169
+ # A fresh checkout or deployment has no tmp/pids. The soft restart touches
170
+ # its shutdown file there after it has stopped accepting jobs, so failing on
171
+ # it used to leave a worker that took no jobs and never exited.
172
+ def test_soft_restart_without_a_pids_directory
173
+ FileUtils.rm_rf(Rails.root.join('tmp', 'pids'))
174
+
175
+ with_worker(pool_size: 1, polling_interval: 0.2) do |w|
176
+ Process.kill 'USR1', Process.pid
177
+
178
+ with_retries { assert_equal :shutdown, w.state }
179
+ assert File.exist?(Workhorse::Worker.shutdown_file_for(Process.pid))
180
+ end
181
+ ensure
182
+ FileUtils.mkdir_p(Rails.root.join('tmp', 'pids'))
183
+ FileUtils.rm_f Workhorse::Worker.shutdown_file_for(Process.pid)
184
+ end
185
+
186
+ def test_heartbeat_without_a_pids_directory
187
+ FileUtils.rm_rf(Rails.root.join('tmp', 'pids'))
188
+ ENV['WORKHORSE_DAEMON_WORKER_ID'] = '42'
189
+
190
+ Workhorse::Worker.new.heartbeat!
191
+
192
+ assert File.exist?(Workhorse::Worker.heartbeat_file_for('42'))
193
+ ensure
194
+ ENV.delete('WORKHORSE_DAEMON_WORKER_ID')
195
+ FileUtils.mkdir_p(Rails.root.join('tmp', 'pids'))
196
+ FileUtils.rm_f(Workhorse::Worker.heartbeat_file_for('42'))
197
+ end
198
+
177
199
  def test_soft_restart_when_busy_waits_for_job
178
200
  with_worker(pool_size: 1, polling_interval: 0.2) do |w|
179
201
  Workhorse.enqueue BasicJob.new(sleep_time: 0.5)
@@ -236,7 +258,7 @@ class Workhorse::WorkerTest < WorkhorseTest
236
258
  end
237
259
 
238
260
  def test_soft_restart_ignored_during_shutdown
239
- with_worker(pool_size: 1, polling_interval: 0.2) do |w|
261
+ with_worker(pool_size: 1, polling_interval: 0.2, auto_terminate: true) do |w|
240
262
  Process.kill 'TERM', Process.pid
241
263
  with_retries { assert_equal :shutdown, w.state }
242
264
 
@@ -252,80 +274,61 @@ class Workhorse::WorkerTest < WorkhorseTest
252
274
 
253
275
  def test_no_queues
254
276
  enqueue_in_multiple_queues
255
- work 0.2, polling_interval: 0.2
256
277
 
257
- jobs = Workhorse::DbJob.order(queue: :asc).to_a
258
- assert_equal 'succeeded', jobs[0].state
259
- assert_equal 'succeeded', jobs[1].state
260
- assert_equal 'succeeded', jobs[2].state
278
+ work_until(pool_size: 5, polling_interval: 0.2) do
279
+ assert_equal %w[succeeded succeeded succeeded], jobs_by_queue.map(&:state)
280
+ end
261
281
  end
262
282
 
263
283
  def test_nil_queue
264
284
  enqueue_in_multiple_queues
265
- work 0.2, queues: [nil], polling_interval: 0.2
266
285
 
267
- jobs = Workhorse::DbJob.order(queue: :asc).to_a
268
- assert_equal 'succeeded', jobs[0].state
269
- assert_equal 'waiting', jobs[1].state
270
- assert_equal 'waiting', jobs[2].state
286
+ # A job the worker wrongly took would be taken in the same poll as the
287
+ # one it rightly took, so is locked by the time that one has succeeded.
288
+ work_until(queues: [nil], pool_size: 5, polling_interval: 0.2) do
289
+ assert_equal %w[succeeded waiting waiting], jobs_by_queue.map(&:state)
290
+ end
271
291
  end
272
292
 
273
293
  def test_queues_with_nil
274
294
  enqueue_in_multiple_queues
275
- work 0.2, queues: [nil, :q1], polling_interval: 0.2
276
295
 
277
- jobs = Workhorse::DbJob.order(queue: :asc).to_a
278
- assert_equal 'succeeded', jobs[0].state
279
- assert_equal 'succeeded', jobs[1].state
280
- assert_equal 'waiting', jobs[2].state
296
+ work_until(queues: [nil, :q1], pool_size: 5, polling_interval: 0.2) do
297
+ assert_equal %w[succeeded succeeded waiting], jobs_by_queue.map(&:state)
298
+ end
281
299
  end
282
300
 
283
301
  def test_queues_without_nil
284
302
  enqueue_in_multiple_queues
285
- work 0.2, queues: %i[q1 q2], polling_interval: 0.2
286
303
 
287
- jobs = Workhorse::DbJob.order(queue: :asc).to_a
288
- assert_equal 'waiting', jobs[0].state
289
- assert_equal 'succeeded', jobs[1].state
290
- assert_equal 'succeeded', jobs[2].state
304
+ work_until(queues: %i[q1 q2], pool_size: 5, polling_interval: 0.2) do
305
+ assert_equal %w[waiting succeeded succeeded], jobs_by_queue.map(&:state)
306
+ end
291
307
  end
292
308
 
293
309
  def test_queue_not_parallel
294
310
  Workhorse::DbJob.delete_all
295
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q1
296
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q1
311
+ first = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q1
312
+ second = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q1
297
313
 
298
- work 0.2, polling_interval: 0.2
299
- jobs = Workhorse::DbJob.all.to_a
300
- assert_equal 'succeeded', jobs[0].state
301
- assert_equal 'waiting', jobs[1].state
314
+ assert_queued_one_at_a_time [first], [second]
302
315
  end
303
316
 
304
317
  def test_multiple_queued_same_queue
305
318
  # One queue
306
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q1
307
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q1
319
+ first = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q1
320
+ second = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q1
308
321
 
309
- work 0.2, polling_interval: 0.2
310
-
311
- jobs = Workhorse::DbJob.all.to_a
312
- assert_equal 'succeeded', jobs[0].state
313
- assert_equal 'waiting', jobs[1].state
322
+ assert_queued_one_at_a_time [first], [second]
314
323
 
315
324
  # Two queues
316
325
  Workhorse::DbJob.delete_all
317
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q1
318
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q1
319
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q2
320
- Workhorse.enqueue BasicJob.new(sleep_time: 0.2), queue: :q2
321
-
322
- work 0.2, polling_interval: 0.2
326
+ q1_first = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q1
327
+ q1_second = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q1
328
+ q2_first = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q2
329
+ q2_second = Workhorse.enqueue BasicJob.new(sleep_time: 2), queue: :q2
323
330
 
324
- jobs = Workhorse::DbJob.order(queue: :asc).to_a
325
- assert_equal 'succeeded', jobs[0].state
326
- assert_equal 'waiting', jobs[1].state
327
- assert_equal 'succeeded', jobs[2].state
328
- assert_equal 'waiting', jobs[3].state
331
+ assert_queued_one_at_a_time [q1_first, q2_first], [q1_second, q2_second]
329
332
  end
330
333
 
331
334
  def test_order_with_priorities
@@ -338,7 +341,9 @@ class Workhorse::WorkerTest < WorkhorseTest
338
341
  Workhorse.enqueue BasicJob.new(some_param: 1, sleep_time: 0), priority: 0
339
342
 
340
343
  BasicJob.results.clear
341
- work 1, pool_size: 1, polling_interval: 0.1
344
+ work_until(pool_size: 1, polling_interval: 0.1) do
345
+ assert_equal 6, BasicJob.results.size
346
+ end
342
347
  assert_equal (1..6).to_a, BasicJob.results
343
348
  end
344
349
 
@@ -352,17 +357,23 @@ class Workhorse::WorkerTest < WorkhorseTest
352
357
  end
353
358
 
354
359
  def test_perform_at
355
- Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now
356
- Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now + 600
357
- work 0.1, polling_interval: 0.1
360
+ due = Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now
361
+ later = Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now + 600
362
+
363
+ work_until(polling_interval: 0.1) do
364
+ assert_equal 'succeeded', due.reload.state
365
+ end
358
366
 
359
- jobs = Workhorse::DbJob.all.to_a
360
- assert_equal 'succeeded', jobs[0].state
361
- assert_equal 'waiting', jobs[1].state
367
+ assert_equal 'waiting', later.reload.state
362
368
  end
363
369
 
364
370
  def test_controlled_shutdown
365
- Workhorse.max_worker_memory_mb = 100
371
+ # Relative to what this process already uses, as the daemon's workers are
372
+ # forks of it: a fixed 100MB is exceeded from the start once the Oracle
373
+ # client library is loaded, and the worker then stops before its first job.
374
+ # MemHungryJob allocates 250MB, so it still crosses the line.
375
+ baseline_mb = `ps -p #{Process.pid} -o rss=`.strip.to_i / 1024
376
+ Workhorse.max_worker_memory_mb = baseline_mb + 100
366
377
  with_daemon do
367
378
  pid = with_retries do
368
379
  pid = daemon.workers.first.pid
@@ -460,4 +471,26 @@ class Workhorse::WorkerTest < WorkhorseTest
460
471
  Workhorse.enqueue BasicJob.new(some_param: :q1), queue: :q1
461
472
  Workhorse.enqueue BasicJob.new(some_param: :q2), queue: :q2
462
473
  end
474
+
475
+ # Jobs ordered by queue, the job without a queue first. Sorted here rather
476
+ # than with ORDER BY, which puts NULL first on MySQL and last on Oracle.
477
+ # Within a queue, in the order they were enqueued.
478
+ def jobs_by_queue
479
+ return Workhorse::DbJob.all.sort_by { |job| [job.queue.nil? ? 0 : 1, job.queue.to_s, job.id] }
480
+ end
481
+
482
+ # Asserts that while the first job of each queue runs, the ones queued behind
483
+ # it wait. Looked at while they run rather than after a fixed time: the job
484
+ # behind is rightly taken as soon as the one ahead has finished.
485
+ def assert_queued_one_at_a_time(firsts, followers)
486
+ with_worker(pool_size: 5, polling_interval: 0.2, auto_terminate: false) do
487
+ with_retries { assert_equal(%w[started] * firsts.size, firsts.map { |job| job.reload.state }) }
488
+
489
+ # Room for further polls, each a chance to take a follower wrongly, and
490
+ # well short of the 2s the jobs ahead take.
491
+ sleep 0.5
492
+
493
+ assert_equal(%w[waiting] * followers.size, followers.map { |job| job.reload.state })
494
+ end
495
+ end
463
496
  end
data/workhorse.gemspec CHANGED
@@ -1,14 +1,14 @@
1
1
  # -*- encoding: utf-8 -*-
2
- # stub: workhorse 2.0.0.rc0 ruby lib
2
+ # stub: workhorse 2.0.0.rc1 ruby lib
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = "workhorse".freeze
6
- s.version = "2.0.0.rc0".freeze
6
+ s.version = "2.0.0.rc1".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-09-29"
11
+ s.date = "2026-09-30"
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/generators/workhorse/templates/create_table_workhorse_schedules.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_late_schedules_job.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/notifiers/base.rb".freeze, "lib/workhorse/notifiers/file_system.rb".freeze, "lib/workhorse/notifiers/none.rb".freeze, "lib/workhorse/notifiers/redis.rb".freeze, "lib/workhorse/performer.rb".freeze, "lib/workhorse/poller.rb".freeze, "lib/workhorse/pool.rb".freeze, "lib/workhorse/schedule.rb".freeze, "lib/workhorse/schedules.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/notifier_test.rb".freeze, "test/workhorse/performer_test.rb".freeze, "test/workhorse/poller_test.rb".freeze, "test/workhorse/pool_test.rb".freeze, "test/workhorse/schedule_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: 2.0.0.rc0
4
+ version: 2.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sitrox
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-09-29 00:00:00.000000000 Z
10
+ date: 2026-09-30 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: activesupport