workhorse 1.5.2 → 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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ruby.yml +137 -1
  3. data/CHANGELOG.md +150 -0
  4. data/Gemfile +16 -1
  5. data/README.md +316 -72
  6. data/Rakefile +1 -0
  7. data/VERSION +1 -1
  8. data/bin/rubocop +5 -1
  9. data/lib/generators/workhorse/install_generator.rb +10 -1
  10. data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +55 -0
  11. data/lib/generators/workhorse/templates/create_table_jobs.rb +15 -2
  12. data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +42 -0
  13. data/lib/workhorse/daemon/shell_handler.rb +4 -1
  14. data/lib/workhorse/daemon.rb +57 -7
  15. data/lib/workhorse/db_job.rb +98 -8
  16. data/lib/workhorse/enqueuer.rb +51 -8
  17. data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +26 -8
  18. data/lib/workhorse/jobs/detect_late_schedules_job.rb +59 -0
  19. data/lib/workhorse/notifiers/base.rb +55 -0
  20. data/lib/workhorse/notifiers/file_system.rb +66 -0
  21. data/lib/workhorse/notifiers/none.rb +8 -0
  22. data/lib/workhorse/notifiers/redis.rb +227 -0
  23. data/lib/workhorse/performer.rb +29 -2
  24. data/lib/workhorse/poller.rb +303 -21
  25. data/lib/workhorse/pool.rb +12 -6
  26. data/lib/workhorse/schedule.rb +288 -0
  27. data/lib/workhorse/schedules.rb +197 -0
  28. data/lib/workhorse/worker.rb +102 -31
  29. data/lib/workhorse.rb +136 -0
  30. data/test/lib/db_schema.rb +36 -3
  31. data/test/lib/jobs.rb +29 -0
  32. data/test/lib/test_helper.rb +113 -20
  33. data/test/workhorse/daemon_test.rb +33 -0
  34. data/test/workhorse/db_job_test.rb +2 -4
  35. data/test/workhorse/notifier_test.rb +487 -0
  36. data/test/workhorse/performer_test.rb +7 -9
  37. data/test/workhorse/poller_test.rb +97 -23
  38. data/test/workhorse/schedule_test.rb +967 -0
  39. data/test/workhorse/worker_test.rb +201 -76
  40. data/workhorse.gemspec +6 -5
  41. metadata +29 -3
@@ -0,0 +1,487 @@
1
+ require 'test_helper'
2
+ require 'tempfile'
3
+
4
+ class Workhorse::NotifierTest < WorkhorseTest
5
+ def setup
6
+ super
7
+ @notifier = Workhorse.notifier
8
+ FileUtils.rm_f(wake_path)
9
+ end
10
+
11
+ def teardown
12
+ Workhorse.notifier = @notifier
13
+ FileUtils.rm_f(wake_path)
14
+ end
15
+
16
+ def test_default_notifier_is_none
17
+ assert_instance_of Workhorse::Notifiers::None, Workhorse.notifier
18
+ assert_nil Workhorse.notifier.token
19
+ end
20
+
21
+ def test_notifier_can_be_selected_by_symbol
22
+ Workhorse.notifier = :file
23
+ assert_instance_of Workhorse::Notifiers::FileSystem, Workhorse.notifier
24
+
25
+ Workhorse.notifier = :redis
26
+ assert_instance_of Workhorse::Notifiers::Redis, Workhorse.notifier
27
+
28
+ Workhorse.notifier = :none
29
+ assert_instance_of Workhorse::Notifiers::None, Workhorse.notifier
30
+ end
31
+
32
+ def test_notifier_can_be_set_to_nil
33
+ Workhorse.notifier = :file
34
+ Workhorse.notifier = nil
35
+
36
+ # Asserted on the stored value: the reader falls back to a None of its
37
+ # own, so it cannot tell a cleared notifier from an unset one.
38
+ assert_instance_of Workhorse::Notifiers::None, Workhorse.instance_variable_get(:@notifier)
39
+ end
40
+
41
+ def test_the_file_notifier_defaults_below_the_rails_root
42
+ assert_equal Rails.root.join('tmp', 'pids', 'workhorse.wake').to_s,
43
+ Workhorse::Notifiers::FileSystem.new.path
44
+ end
45
+
46
+ def test_the_redis_notifier_takes_the_configured_client
47
+ redis = FakeRedis.new
48
+ Workhorse.notification_redis = redis
49
+ notifier = Workhorse::Notifiers::Redis.new
50
+
51
+ notifier.notify(queue: :mailer)
52
+
53
+ assert_equal [[Workhorse::Notifiers::Redis::DEFAULT_CHANNEL, 'mailer']], redis.published
54
+ ensure
55
+ Workhorse.notification_redis = nil
56
+ end
57
+
58
+ def test_unknown_notifier_is_rejected
59
+ assert_raises ArgumentError do
60
+ Workhorse.notifier = :carrier_pigeon
61
+ end
62
+ end
63
+
64
+ def test_file_notifier_token_changes_on_notify
65
+ notifier = file_notifier
66
+
67
+ assert_nil notifier.token
68
+
69
+ notifier.notify
70
+ first = notifier.token
71
+
72
+ refute_nil first
73
+ assert_equal first, notifier.token
74
+
75
+ sleep 0.01
76
+ notifier.notify
77
+
78
+ refute_equal first, notifier.token
79
+ end
80
+
81
+ def test_file_notifier_creates_its_directory
82
+ path = File.join(Dir.mktmpdir, 'deeply', 'nested', 'workhorse.wake')
83
+ notifier = Workhorse::Notifiers::FileSystem.new(path: path)
84
+
85
+ notifier.notify
86
+
87
+ assert File.exist?(path)
88
+ end
89
+
90
+ def test_file_notifier_swallows_errors
91
+ Tempfile.create('workhorse') do |file|
92
+ # A regular file cannot be a directory, on any platform.
93
+ notifier = Workhorse::Notifiers::FileSystem.new(path: File.join(file.path, 'workhorse.wake'))
94
+
95
+ assert_nothing_raised { notifier.notify }
96
+ assert_nil notifier.token
97
+ end
98
+ end
99
+
100
+ def test_enqueueing_notifies
101
+ Workhorse.notifier = file_notifier
102
+
103
+ assert_nil Workhorse.notifier.token
104
+
105
+ Workhorse.enqueue BasicJob.new(sleep_time: 0)
106
+
107
+ refute_nil Workhorse.notifier.token
108
+ end
109
+
110
+ def test_enqueueing_does_not_notify_for_jobs_that_are_not_due
111
+ Workhorse.notifier = file_notifier
112
+
113
+ Workhorse.enqueue BasicJob.new(sleep_time: 0), perform_at: Time.now + 60
114
+
115
+ assert_nil Workhorse.notifier.token
116
+ end
117
+
118
+ def test_notification_happens_after_commit
119
+ Workhorse.notifier = file_notifier
120
+
121
+ ActiveRecord::Base.transaction do
122
+ Workhorse.enqueue BasicJob.new(sleep_time: 0)
123
+
124
+ assert_nil Workhorse.notifier.token, 'must not notify before the commit'
125
+ end
126
+
127
+ refute_nil Workhorse.notifier.token, 'must notify after the commit'
128
+ end
129
+
130
+ def test_worker_starts_an_announced_job_ahead_of_its_polling_interval
131
+ Workhorse.notifier = file_notifier
132
+
133
+ log = capture_log do |logger|
134
+ with_worker(polling_interval: 60, pool_size: 1, auto_terminate: false, logger: logger) do
135
+ wait_for_first_poll(logger)
136
+
137
+ Workhorse.enqueue BasicJob.new(sleep_time: 0)
138
+
139
+ with_retries(30) do
140
+ # Re-announced on every attempt: a single announcement whose poll
141
+ # loses the global lock race would otherwise wait out the interval.
142
+ Workhorse.notifier.notify
143
+ assert_equal 1, Workhorse::DbJob.succeeded.count
144
+ end
145
+ end
146
+ end
147
+
148
+ assert_match(/Job was announced/, log)
149
+ end
150
+
151
+ def test_worker_without_a_notifier_waits_out_its_polling_interval
152
+ capture_log do |logger|
153
+ with_worker(polling_interval: 60, pool_size: 1, auto_terminate: false, logger: logger) do
154
+ wait_for_first_poll(logger)
155
+
156
+ Workhorse.enqueue BasicJob.new(sleep_time: 0)
157
+ sleep 1
158
+
159
+ assert_equal 1, Workhorse::DbJob.waiting.count
160
+ end
161
+ end
162
+ end
163
+
164
+ # Without this, marking every poll as brought forward - which would
165
+ # silently disable the max_global_lock_fails alarm - goes undetected.
166
+ def test_a_poll_that_waited_out_its_interval_is_not_marked_as_brought_forward
167
+ poller = Workhorse::Worker.new(polling_interval: 0.2, pool_size: 1).poller
168
+ poller.instance_variable_set(:@running, true)
169
+
170
+ poller.send(:sleep)
171
+
172
+ refute poller.instance_variable_get(:@poll_brought_forward),
173
+ 'a poll that waited out its interval is the scheduled one'
174
+ end
175
+
176
+ def test_redis_notifier_publishes_and_counts
177
+ redis = FakeRedis.new
178
+ notifier = Workhorse::Notifiers::Redis.new(client: redis, channel: 'test:jobs')
179
+
180
+ notifier.notify(queue: :mailer)
181
+
182
+ assert_equal [['test:jobs', 'mailer']], redis.published
183
+
184
+ notifier.start
185
+ begin
186
+ redis.deliver('test:jobs', 'mailer')
187
+
188
+ with_retries(50, interval: 0.02) do
189
+ assert_equal 1, notifier.token
190
+ end
191
+ ensure
192
+ notifier.stop
193
+ end
194
+ end
195
+
196
+ # A reporter that is itself unreachable must not leave the process without
197
+ # a subscriber for the rest of its life.
198
+ def test_a_raising_exception_handler_does_not_kill_the_subscriber
199
+ redis = FakeRedis.new(fail_subscribe: 1)
200
+ notifier = Workhorse::Notifiers::Redis.new(client: redis, channel: 'test:jobs')
201
+
202
+ with_exception_handler ->(_e) { fail 'reporter is down' } do
203
+ notifier.start
204
+
205
+ begin
206
+ redis.deliver('test:jobs', 'mailer')
207
+
208
+ with_retries(100, interval: 0.05) { assert_equal 1, notifier.token }
209
+ ensure
210
+ notifier.stop
211
+ end
212
+ end
213
+ end
214
+
215
+ # The once-only guard has to re-arm, or only the first outage in a
216
+ # process's whole life is ever reported.
217
+ def test_a_second_outage_is_reported_again
218
+ redis = FakeRedis.new
219
+ notifier = Workhorse::Notifiers::Redis.new(client: redis, channel: 'test:jobs')
220
+ reported = []
221
+
222
+ with_exception_handler ->(e) { reported << e.message } do
223
+ notifier.start
224
+
225
+ begin
226
+ redis.drop!
227
+ with_retries(100, interval: 0.05) { assert_equal 1, reported.size }
228
+
229
+ redis.deliver('test:jobs', 'mailer')
230
+ with_retries(100, interval: 0.05) { assert_equal 1, notifier.token }
231
+
232
+ redis.drop!
233
+ with_retries(100, interval: 0.05) { assert_equal 2, reported.size }
234
+ ensure
235
+ notifier.stop
236
+ end
237
+ end
238
+ end
239
+
240
+ def test_redis_notifier_swallows_publish_errors
241
+ notifier = Workhorse::Notifiers::Redis.new(client: FakeRedis.new(fail_publish: true), channel: 'test:jobs')
242
+
243
+ assert_nothing_raised { notifier.notify(queue: :mailer) }
244
+ end
245
+
246
+ # A broken notifier must cost latency, not the worker.
247
+ def test_a_raising_notifier_does_not_take_the_worker_down
248
+ Workhorse.notifier = RaisingNotifier.new
249
+
250
+ with_worker(polling_interval: 0.2, pool_size: 1, auto_terminate: false) do |w|
251
+ Workhorse.enqueue BasicJob.new(sleep_time: 0)
252
+
253
+ with_retries(30) do
254
+ assert_equal 1, Workhorse::DbJob.succeeded.count
255
+ end
256
+
257
+ assert_equal :running, w.state
258
+ end
259
+ end
260
+
261
+ # Losing that race is expected when several workers are woken at once.
262
+ def test_lock_failures_of_brought_forward_polls_are_not_counted
263
+ Workhorse.notifier = file_notifier
264
+ w = Workhorse::Worker.new(polling_interval: 60, pool_size: 1)
265
+ poller = w.poller
266
+
267
+ poller.instance_variable_set(:@running, true)
268
+ poller.instance_variable_set(:@last_notification, Workhorse.notifier.token)
269
+
270
+ Workhorse.notifier.notify
271
+ poller.send(:sleep)
272
+
273
+ assert poller.instance_variable_get(:@poll_brought_forward),
274
+ 'a poll following an announcement must be marked as brought forward'
275
+
276
+ with_global_lock_held do
277
+ poller.send(:poll)
278
+ end
279
+
280
+ assert_equal 0, poller.instance_variable_get(:@global_lock_fails)
281
+
282
+ poller.instance_variable_set(:@poll_brought_forward, false)
283
+
284
+ with_global_lock_held do
285
+ poller.send(:poll)
286
+ end
287
+
288
+ assert_equal 1, poller.instance_variable_get(:@global_lock_fails)
289
+ end
290
+
291
+ def test_redis_notifier_requires_a_client
292
+ Workhorse.notification_redis = nil
293
+ notifier = Workhorse::Notifiers::Redis.new
294
+
295
+ assert_raises RuntimeError do
296
+ notifier.client
297
+ end
298
+ end
299
+
300
+ # A worker with no free thread must leave the announcement pending, or the
301
+ # job waits out the whole polling interval once capacity frees up.
302
+ def test_an_announcement_arriving_while_busy_is_not_lost
303
+ Workhorse.notifier = file_notifier
304
+
305
+ with_worker(polling_interval: 60, pool_size: 1, auto_terminate: false) do
306
+ blocker = Workhorse.enqueue BasicJob.new(sleep_time: 1)
307
+ with_retries(60, interval: 0.05) { assert_equal 'started', blocker.reload.state }
308
+
309
+ job = Workhorse.enqueue BasicJob.new(sleep_time: 0)
310
+
311
+ with_retries(60, interval: 0.1) { assert_equal 'succeeded', job.reload.state }
312
+ end
313
+ end
314
+
315
+ # Several workers in one process share one subscriber thread; the first to
316
+ # stop must not take it away from the others.
317
+ def test_the_redis_subscriber_survives_until_the_last_worker_stops
318
+ redis = FakeRedis.new
319
+ notifier = Workhorse::Notifiers::Redis.new(client: redis, channel: 'test:jobs')
320
+
321
+ notifier.start
322
+ notifier.start
323
+ notifier.stop
324
+
325
+ begin
326
+ redis.deliver('test:jobs', 'mailer')
327
+
328
+ with_retries(50, interval: 0.02) { assert_equal 1, notifier.token }
329
+ ensure
330
+ notifier.stop
331
+ end
332
+ end
333
+
334
+ def test_the_file_notifier_follows_the_configured_path
335
+ Workhorse.notifier = :file
336
+ Workhorse.notification_path = wake_path
337
+
338
+ assert_equal wake_path, Workhorse.notifier.path
339
+ ensure
340
+ Workhorse.notification_path = nil
341
+ end
342
+
343
+ def test_the_redis_notifier_follows_the_configured_channel
344
+ Workhorse.notifier = :redis
345
+ Workhorse.notification_channel = 'custom:jobs'
346
+
347
+ assert_equal 'custom:jobs', Workhorse.notifier.channel
348
+ ensure
349
+ Workhorse.notification_channel = nil
350
+ end
351
+
352
+ # A subscribed connection cannot also publish, so the callable has to be
353
+ # asked again for the subscriber rather than the publisher being reused.
354
+ def test_the_redis_notifier_subscribes_on_a_client_of_its_own
355
+ built = []
356
+ notifier = Workhorse::Notifiers::Redis.new(
357
+ client: -> { FakeRedis.new.tap { |r| built << r } }, channel: 'test:jobs'
358
+ )
359
+
360
+ notifier.notify(queue: :mailer)
361
+ notifier.start
362
+
363
+ begin
364
+ with_retries(50, interval: 0.02) { assert_equal 2, built.size }
365
+
366
+ refute_equal built.first.object_id, built.last.object_id
367
+ ensure
368
+ notifier.stop
369
+ end
370
+ end
371
+
372
+ def test_the_redis_notifier_builds_a_client_from_a_callable
373
+ built = []
374
+ Workhorse.notification_redis = lambda do
375
+ built << :built
376
+ FakeRedis.new
377
+ end
378
+ notifier = Workhorse::Notifiers::Redis.new
379
+
380
+ notifier.notify(queue: :mailer)
381
+
382
+ assert_equal [:built], built
383
+ ensure
384
+ Workhorse.notification_redis = nil
385
+ end
386
+
387
+ private
388
+
389
+ def with_exception_handler(handler)
390
+ previous = Workhorse.on_exception
391
+ Workhorse.on_exception = handler
392
+ yield
393
+ ensure
394
+ Workhorse.on_exception = previous
395
+ end
396
+
397
+ def wake_path
398
+ return File.join(Dir.tmpdir, 'workhorse_test.wake')
399
+ end
400
+
401
+ def file_notifier
402
+ return Workhorse::Notifiers::FileSystem.new(path: wake_path)
403
+ end
404
+
405
+ # Waits until the worker has completed the poll it performs on startup, so
406
+ # that a job enqueued afterwards can only be found through a notification.
407
+ def wait_for_first_poll(logger)
408
+ with_retries(50, interval: 0.05) do
409
+ assert_match(/Polling DB for jobs/, logger.instance_variable_get(:@logdev).dev.string)
410
+ end
411
+ end
412
+
413
+ # Notifier whose token cannot be read, standing in for a broken custom one.
414
+ class RaisingNotifier < Workhorse::Notifiers::Base
415
+ def token
416
+ fail 'notifier is broken'
417
+ end
418
+ end
419
+
420
+ # Minimal stand-in for a Redis client, so that the notifier's logic can be
421
+ # tested without a server. `subscribe` blocks the way the real one does.
422
+ class FakeRedis
423
+ attr_reader :published
424
+
425
+ def initialize(fail_publish: false, fail_subscribe: 0)
426
+ @published = []
427
+ @fail_publish = fail_publish
428
+ @fail_subscribe = fail_subscribe
429
+ @queue = Queue.new
430
+ end
431
+
432
+ def publish(channel, message)
433
+ fail 'Connection refused' if @fail_publish
434
+
435
+ @published << [channel, message]
436
+ end
437
+
438
+ def deliver(channel, message)
439
+ @queue << [channel, message]
440
+ end
441
+
442
+ # Makes the current subscription fail, as a dropped connection would.
443
+ def drop!
444
+ @queue << :drop
445
+ end
446
+
447
+ def dup
448
+ return self
449
+ end
450
+
451
+ def subscribe(_channel)
452
+ if @fail_subscribe > 0
453
+ @fail_subscribe -= 1
454
+ fail 'Connection refused'
455
+ end
456
+
457
+ on = Callbacks.new
458
+ yield on
459
+ on.subscribed!
460
+
461
+ loop do
462
+ item = @queue.pop
463
+ fail 'Connection reset' if item == :drop
464
+
465
+ on.call(*item)
466
+ end
467
+ end
468
+
469
+ class Callbacks
470
+ def message(&block)
471
+ @message = block
472
+ end
473
+
474
+ def subscribe(&block)
475
+ @subscribe = block
476
+ end
477
+
478
+ def subscribed!
479
+ @subscribe&.call
480
+ end
481
+
482
+ def call(channel, message)
483
+ @message&.call(channel, message)
484
+ end
485
+ end
486
+ end
487
+ end
@@ -8,28 +8,26 @@ class Workhorse::PerformerTest < WorkhorseTest
8
8
  Workhorse.enqueue DbConnectionTestJob.new
9
9
  end
10
10
 
11
- work 0.2, polling_interval: 0.2
11
+ work_until(pool_size: 5, polling_interval: 0.2) do
12
+ assert_equal 2, DbConnectionTestJob.db_connections.count
13
+ end
12
14
 
13
- assert_equal 2, DbConnectionTestJob.db_connections.count
14
15
  assert_equal 2, DbConnectionTestJob.db_connections.uniq.count
15
16
  end
16
17
 
17
18
  def test_success
18
19
  Workhorse.enqueue BasicJob.new(sleep_time: 0.1)
19
- work 0.2, polling_interval: 0.2
20
- assert_equal 'succeeded', Workhorse::DbJob.first.state
20
+ work_until(pool_size: 5, polling_interval: 0.2) { assert_equal 'succeeded', Workhorse::DbJob.first.state }
21
21
  end
22
22
 
23
23
  def test_exception
24
24
  Workhorse.enqueue FailingTestJob.new
25
- work 0.2, polling_interval: 0.2
26
- assert_equal 'failed', Workhorse::DbJob.first.state
25
+ work_until(pool_size: 5, polling_interval: 0.2) { assert_equal 'failed', Workhorse::DbJob.first.state }
27
26
  end
28
27
 
29
28
  def test_syntax_exception
30
29
  Workhorse.enqueue SyntaxErrorJob
31
- work 0.2, polling_interval: 0.2
32
- assert_equal 'failed', Workhorse::DbJob.first.state
30
+ work_until(pool_size: 5, polling_interval: 0.2) { assert_equal 'failed', Workhorse::DbJob.first.state }
33
31
  end
34
32
 
35
33
  def test_on_exception
@@ -41,7 +39,7 @@ class Workhorse::PerformerTest < WorkhorseTest
41
39
  end
42
40
 
43
41
  Workhorse.enqueue FailingTestJob.new
44
- work 0.2, polling_interval: 0.2
42
+ work_until(pool_size: 5, polling_interval: 0.2) { assert exception, 'expected on_exception to be called' }
45
43
 
46
44
  assert_equal exception.message, FailingTestJob::MESSAGE
47
45
  ensure