workhorse 1.5.2 → 2.0.0.rc0

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +102 -0
  3. data/README.md +303 -74
  4. data/Rakefile +1 -0
  5. data/VERSION +1 -1
  6. data/bin/rubocop +5 -1
  7. data/lib/generators/workhorse/install_generator.rb +10 -1
  8. data/lib/generators/workhorse/templates/config/initializers/workhorse.rb +55 -0
  9. data/lib/generators/workhorse/templates/create_table_jobs.rb +11 -13
  10. data/lib/generators/workhorse/templates/create_table_workhorse_schedules.rb +29 -0
  11. data/lib/workhorse/daemon.rb +52 -6
  12. data/lib/workhorse/db_job.rb +58 -7
  13. data/lib/workhorse/enqueuer.rb +51 -8
  14. data/lib/workhorse/jobs/cleanup_succeeded_jobs.rb +26 -8
  15. data/lib/workhorse/jobs/detect_late_schedules_job.rb +59 -0
  16. data/lib/workhorse/notifiers/base.rb +55 -0
  17. data/lib/workhorse/notifiers/file_system.rb +66 -0
  18. data/lib/workhorse/notifiers/none.rb +8 -0
  19. data/lib/workhorse/notifiers/redis.rb +227 -0
  20. data/lib/workhorse/performer.rb +28 -0
  21. data/lib/workhorse/poller.rb +289 -47
  22. data/lib/workhorse/pool.rb +12 -6
  23. data/lib/workhorse/schedule.rb +288 -0
  24. data/lib/workhorse/schedules.rb +197 -0
  25. data/lib/workhorse/worker.rb +77 -27
  26. data/lib/workhorse.rb +136 -0
  27. data/test/lib/db_schema.rb +21 -1
  28. data/test/lib/jobs.rb +29 -0
  29. data/test/lib/test_helper.rb +9 -14
  30. data/test/workhorse/daemon_test.rb +33 -0
  31. data/test/workhorse/db_job_test.rb +1 -1
  32. data/test/workhorse/notifier_test.rb +500 -0
  33. data/test/workhorse/poller_test.rb +8 -4
  34. data/test/workhorse/schedule_test.rb +967 -0
  35. data/test/workhorse/worker_test.rb +92 -0
  36. data/workhorse.gemspec +6 -5
  37. metadata +29 -3
@@ -0,0 +1,500 @@
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
+ # Holds workhorse's global lock on a connection of its own, so that the code
402
+ # under test sees it as taken by another worker.
403
+ def with_global_lock_held
404
+ connection = ActiveRecord::Base.connection_pool.checkout
405
+ connection.select_value("SELECT GET_LOCK(CONCAT(DATABASE(), '_workhorse'), 1)")
406
+ yield
407
+ ensure
408
+ if connection
409
+ connection.select_value("SELECT RELEASE_LOCK(CONCAT(DATABASE(), '_workhorse'))")
410
+ ActiveRecord::Base.connection_pool.checkin(connection)
411
+ end
412
+ end
413
+
414
+ def file_notifier
415
+ return Workhorse::Notifiers::FileSystem.new(path: wake_path)
416
+ end
417
+
418
+ # Waits until the worker has completed the poll it performs on startup, so
419
+ # that a job enqueued afterwards can only be found through a notification.
420
+ def wait_for_first_poll(logger)
421
+ with_retries(50, interval: 0.05) do
422
+ assert_match(/Polling DB for jobs/, logger.instance_variable_get(:@logdev).dev.string)
423
+ end
424
+ end
425
+
426
+ # Notifier whose token cannot be read, standing in for a broken custom one.
427
+ class RaisingNotifier < Workhorse::Notifiers::Base
428
+ def token
429
+ fail 'notifier is broken'
430
+ end
431
+ end
432
+
433
+ # Minimal stand-in for a Redis client, so that the notifier's logic can be
434
+ # tested without a server. `subscribe` blocks the way the real one does.
435
+ class FakeRedis
436
+ attr_reader :published
437
+
438
+ def initialize(fail_publish: false, fail_subscribe: 0)
439
+ @published = []
440
+ @fail_publish = fail_publish
441
+ @fail_subscribe = fail_subscribe
442
+ @queue = Queue.new
443
+ end
444
+
445
+ def publish(channel, message)
446
+ fail 'Connection refused' if @fail_publish
447
+
448
+ @published << [channel, message]
449
+ end
450
+
451
+ def deliver(channel, message)
452
+ @queue << [channel, message]
453
+ end
454
+
455
+ # Makes the current subscription fail, as a dropped connection would.
456
+ def drop!
457
+ @queue << :drop
458
+ end
459
+
460
+ def dup
461
+ return self
462
+ end
463
+
464
+ def subscribe(_channel)
465
+ if @fail_subscribe > 0
466
+ @fail_subscribe -= 1
467
+ fail 'Connection refused'
468
+ end
469
+
470
+ on = Callbacks.new
471
+ yield on
472
+ on.subscribed!
473
+
474
+ loop do
475
+ item = @queue.pop
476
+ fail 'Connection reset' if item == :drop
477
+
478
+ on.call(*item)
479
+ end
480
+ end
481
+
482
+ class Callbacks
483
+ def message(&block)
484
+ @message = block
485
+ end
486
+
487
+ def subscribe(&block)
488
+ @subscribe = block
489
+ end
490
+
491
+ def subscribed!
492
+ @subscribe&.call
493
+ end
494
+
495
+ def call(channel, message)
496
+ @message&.call(channel, message)
497
+ end
498
+ end
499
+ end
500
+ end
@@ -71,10 +71,8 @@ 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`.
74
+ # Not every adapter returns a result set from `execute`, so querying the
75
+ # valid queues must not rely on its return value.
78
76
  def test_valid_queues_without_usable_execute
79
77
  w = Workhorse::Worker.new(polling_interval: 60)
80
78
 
@@ -139,6 +137,12 @@ class Workhorse::PollerTest < WorkhorseTest
139
137
  10.times do
140
138
  Process.fork do
141
139
  work 3, pool_size: 1, polling_interval: 0.1
140
+ ensure
141
+ # Exit without running the at_exit handlers of the test process: one
142
+ # of them is Minitest's, which joins threads this fork did not
143
+ # inherit and hangs the child, leaving waitall below waiting forever.
144
+ # In an ensure, as a child that raised must not run them either.
145
+ exit!(0)
142
146
  end
143
147
  end
144
148