backburner 1.7.0 → 1.8.0

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +45 -0
  3. data/.github/workflows/github-release.yml +58 -0
  4. data/.github/workflows/release.yml +221 -0
  5. data/CHANGELOG.md +29 -0
  6. data/Gemfile +4 -1
  7. data/HOOKS.md +23 -14
  8. data/README.md +32 -9
  9. data/backburner.gemspec +3 -0
  10. data/bin/backburner +1 -1
  11. data/lib/active_job/queue_adapters/backburner_adapter.rb +10 -3
  12. data/lib/backburner/configuration.rb +7 -0
  13. data/lib/backburner/connection.rb +4 -2
  14. data/lib/backburner/enqueue_pool.rb +88 -0
  15. data/lib/backburner/helpers.rb +77 -22
  16. data/lib/backburner/job.rb +46 -7
  17. data/lib/backburner/logger.rb +9 -0
  18. data/lib/backburner/performable.rb +1 -1
  19. data/lib/backburner/queue.rb +37 -19
  20. data/lib/backburner/version.rb +1 -1
  21. data/lib/backburner/worker.rb +112 -21
  22. data/lib/backburner/workers/forking.rb +4 -2
  23. data/lib/backburner/workers/simple.rb +5 -0
  24. data/lib/backburner/workers/threading.rb +1 -7
  25. data/lib/backburner/workers/threads_on_fork.rb +58 -23
  26. data/lib/backburner.rb +7 -1
  27. data/test/active_job_adapter_test.rb +24 -0
  28. data/test/back_burner_test.rb +13 -0
  29. data/test/connection_test.rb +22 -0
  30. data/test/fixtures/active_job_app.rb +53 -0
  31. data/test/fixtures/autoload/test_broken_name_job.rb +4 -0
  32. data/test/fixtures/autoload/test_broken_no_method_job.rb +4 -0
  33. data/test/fixtures/autoload/test_broken_require_job.rb +4 -0
  34. data/test/fixtures/autoload/test_broken_runtime_job.rb +4 -0
  35. data/test/fixtures/autoload/test_broken_syntax_job.rb +4 -0
  36. data/test/fixtures/autoload/test_broken_timeout_job.rb +4 -0
  37. data/test/fixtures/autoload/test_same_name_job.rb +5 -0
  38. data/test/fixtures/test_fork_jobs.rb +5 -0
  39. data/test/fixtures/test_jobs.rb +53 -0
  40. data/test/fixtures/test_queue_settings.rb +41 -1
  41. data/test/helpers/templogger.rb +1 -1
  42. data/test/helpers_test.rb +184 -0
  43. data/test/job_test.rb +22 -0
  44. data/test/logger_test.rb +27 -0
  45. data/test/performable_test.rb +50 -0
  46. data/test/queue_test.rb +63 -0
  47. data/test/test_helper.rb +57 -0
  48. data/test/worker_test.rb +346 -1
  49. data/test/workers/forking_worker_test.rb +19 -1
  50. data/test/workers/simple_worker_test.rb +272 -0
  51. data/test/workers/threading_worker_test.rb +22 -1
  52. data/test/workers/threads_on_fork_worker_test.rb +279 -12
  53. metadata +54 -6
  54. data/.travis.yml +0 -29
  55. data/circle.yml +0 -3
@@ -256,6 +256,35 @@ describe "Backburner::Workers::Simple module" do
256
256
  assert_equal true, $worker_success
257
257
  end
258
258
 
259
+ it "should round a fractional retry delay up to whole seconds" do
260
+ conn = beanstalk_connection
261
+ default_proc = lambda { |min_retry_delay, num_retries| min_retry_delay + (num_retries ** 3) }
262
+ [
263
+ [TestConfigurableRetryJob, 5, lambda { |min_retry_delay, num_retries| 1.5 }, 2], # a retry_delay_proc returning a Float
264
+ [TestConfigurableRetryJob, 0.5, default_proc, 1], # a Float retry_delay
265
+ [TestFloatRetryDelayJob, 5, default_proc, 2] # a class's Float queue_retry_delay (1.5)
266
+ ].each do |job_class, retry_delay, retry_delay_proc, expected|
267
+ clear_jobs!('foo.bar')
268
+ Backburner.configure do |config|
269
+ config.max_job_retries = 1
270
+ config.retry_delay = retry_delay
271
+ config.retry_delay_proc = retry_delay_proc
272
+ end
273
+ id = @worker_class.enqueue(job_class, [5], :queue => 'foo.bar')[:id]
274
+ out = silenced(5) do
275
+ worker = @worker_class.new('foo.bar')
276
+ worker.prepare
277
+ worker.work_one_job
278
+ end
279
+ assert_match(/attempt 1 of 2, retrying in #{expected}s/, out)
280
+ stats = conn.jobs.find(id).stats
281
+ assert_equal [expected, 'delayed'], [stats.delay, stats.state], "#{job_class} with retry_delay #{retry_delay}"
282
+ end
283
+ ensure
284
+ conn.close if conn
285
+ clear_jobs!('foo.bar')
286
+ end
287
+
259
288
  it "should support event hooks without retry" do
260
289
  $hooked_fail_count = 0
261
290
  clear_jobs!('foo.bar.events')
@@ -332,6 +361,205 @@ describe "Backburner::Workers::Simple module" do
332
361
  refute_match(/HookFailError/, out)
333
362
  end # stopping perform
334
363
 
364
+ it "should delete a job whose before_perform hook stopped it" do
365
+ clear_jobs!('foo.bar.events.retry3')
366
+ worker = @worker_class.new('foo.bar.events.retry3')
367
+ silenced(2) do
368
+ HookedObjectBeforePerformFail.async(:queue => 'foo.bar.events.retry3').foo(10)
369
+ worker.prepare
370
+ worker.work_one_job
371
+ end
372
+ stats = worker.connection.tubes['demo.test.foo.bar.events.retry3'].stats
373
+ assert_equal 0, stats.current_jobs_reserved, "the aborted job is still reserved"
374
+ assert_equal 0, stats.current_jobs_ready + stats.current_jobs_delayed + stats.current_jobs_buried
375
+ end # stopping perform deletes
376
+
377
+ it "should keep working when the TTR ran out before the job could be buried" do
378
+ clear_jobs!("foo.bar.ttr")
379
+ @worker_class.enqueue TestOverTTRJob, [], :queue => "foo.bar.ttr"
380
+ worker = @worker_class.new('foo.bar.ttr')
381
+ out = silenced(6) do
382
+ worker.prepare
383
+ worker.work_one_job
384
+ end
385
+ assert_match(/TestOverTTRJob.* hit 1s timeout/, out)
386
+ assert_match(/TestOverTTRJob was no longer reserved/, out)
387
+ # beanstalkd took the job back when its TTR ran out and hands it out again
388
+ pop_one_job("foo.bar.ttr") { |job, body| assert_equal "TestOverTTRJob", body["class"]; job.delete }
389
+ end
390
+
391
+ it "should keep working when the TTR ran out before the job could be retried" do
392
+ Backburner.configure { |config| config.max_job_retries = 1; config.retry_delay = 0 }
393
+ clear_jobs!("foo.bar.ttr")
394
+ @worker_class.enqueue TestOverTTRJob, [], :queue => "foo.bar.ttr"
395
+ worker = @worker_class.new('foo.bar.ttr')
396
+ out = silenced(6) do
397
+ worker.prepare
398
+ worker.work_one_job
399
+ end
400
+ assert_match(/TestOverTTRJob was no longer reserved/, out)
401
+ pop_one_job("foo.bar.ttr") { |job, body| assert_equal "TestOverTTRJob", body["class"]; job.delete }
402
+ end
403
+
404
+ it "should keep working when the connection is lost before the failed job could be retried or buried" do
405
+ clear_jobs!("foo.bar.lost")
406
+ @worker_class.enqueue TestFailJob, [1, 2], :queue => "foo.bar.lost"
407
+ worker = @worker_class.new('foo.bar.lost')
408
+ errors = []
409
+ Backburner.configure { |config| config.on_error = lambda { |e| errors << e } }
410
+ # beanstalkd went away while the job ran: asking for its stats (to retry or bury it)
411
+ # raises NotConnected
412
+ Beaneater::Job.any_instance.stubs(:ttr).returns(10)
413
+ Beaneater::Job.any_instance.stubs(:stats).raises(Beaneater::NotConnected)
414
+ out = silenced(2) do
415
+ worker.prepare
416
+ worker.work_one_job
417
+ end
418
+ assert_match(/TestFailJob was no longer reserved/, out)
419
+ assert_equal [RuntimeError], errors.map(&:class)
420
+ ensure
421
+ Beaneater::Job.any_instance.unstub(:stats)
422
+ Beaneater::Job.any_instance.unstub(:ttr)
423
+ Backburner.configure { |config| config.on_error = nil }
424
+ end
425
+
426
+ describe "with a job whose class can't be found" do
427
+ before do
428
+ clear_jobs!("foo.bar.unknown")
429
+ @errors = []
430
+ Backburner.configure { |config| config.on_error = lambda { |e| @errors << e } }
431
+ @worker = @worker_class.new('foo.bar.unknown')
432
+ end
433
+
434
+ after do
435
+ Backburner.configure { |config| config.on_error = nil }
436
+ clear_jobs!("foo.bar.unknown")
437
+ end
438
+
439
+ def put_job(class_name, ttr)
440
+ body = Backburner.configuration.job_serializer_proc.call(:class => class_name, :args => [1])
441
+ @worker.connection.tubes["demo.test.foo.bar.unknown"].put(body, :ttr => ttr)
442
+ end
443
+
444
+ def tube_stats
445
+ @worker.connection.tubes["demo.test.foo.bar.unknown"].stats
446
+ end
447
+
448
+ it "should release it for its TTR instead of burying it, and keep working" do
449
+ put_job("TestRemovedJob", 30)
450
+ out = silenced(2) do
451
+ @worker.prepare
452
+ @worker.work_one_job
453
+ end
454
+ assert_match(/Exception Backburner::Job::JobNotFound -> TestRemovedJob/, out)
455
+ assert_match(/TestRemovedJob: class not found; released for 30s/, out)
456
+ assert_equal [], @errors, "on_error isn't called for a class this worker lacks"
457
+ assert_equal 0, tube_stats.current_jobs_buried
458
+ assert_equal 1, tube_stats.current_jobs_delayed
459
+ assert_equal 30, @worker.connection.tubes["demo.test.foo.bar.unknown"].peek(:delayed).stats.delay
460
+ end
461
+
462
+ it "should leave it for a worker that has its class, however many retries it is allowed" do
463
+ Backburner.configure { |config| config.max_job_retries = 0 }
464
+ put_job("TestRolloutJob", 2)
465
+ out = silenced(8) do
466
+ @worker.prepare
467
+ @worker.work_one_job # this worker doesn't have the class yet
468
+ Object.const_set(:TestRolloutJob, Class.new { def self.perform(x); $worker_test_count += x; end })
469
+ @worker.work_one_job # now it does: the job comes back after its TTR
470
+ end
471
+ assert_match(/TestRolloutJob: class not found; released for 2s/, out)
472
+ assert_match(/Completed TestRolloutJob/, out)
473
+ assert_equal 1, $worker_test_count
474
+ assert_equal 0, tube_stats.current_jobs_buried
475
+ ensure
476
+ Object.send(:remove_const, :TestRolloutJob) if Object.const_defined?(:TestRolloutJob)
477
+ end
478
+
479
+ it "should release a job whose class an autoloader can't load" do
480
+ put_job("AutoloadingDemo::TestJobGone", 30)
481
+ out = silenced(2) do
482
+ @worker.prepare
483
+ @worker.work_one_job
484
+ end
485
+ assert_match(/AutoloadingDemo::TestJobGone: class not found; released for 30s/, out)
486
+ assert_equal [0, 1], [tube_stats.current_jobs_buried, tube_stats.current_jobs_delayed]
487
+ end
488
+
489
+ [["TestBrokenNameJob", "NameError"], ["TestBrokenRequireJob", "LoadError"], ["TestBrokenNoMethodJob", "NoMethodError"],
490
+ ["TestBrokenRuntimeJob", "ArgumentError"], ["TestSameNameJob", "NameError"], ["TestBrokenSyntaxJob", "SyntaxError"],
491
+ ["not a class", "NameError"], ["TestBrokenTimeoutJob", "Timeout::Error"]].each do |class_name, error|
492
+ it "should retry and bury a job whose class fails to load or can't exist (#{class_name}), calling on_error" do
493
+ Backburner.configure { |config| config.max_job_retries = 1; config.retry_delay = 0 }
494
+ put_job(class_name, 30)
495
+ out = silenced(4) do
496
+ @worker.prepare
497
+ 2.times { @worker.work_one_job }
498
+ end
499
+ assert_match(/Exception Backburner::Job::JobNotFound -> #{class_name} \(#{error}: /, out)
500
+ assert_match(/attempt 1 of 2, retrying in 0s/, out)
501
+ assert_match(/attempt 2 of 2, burying/, out)
502
+ refute_match(/class not found/, out)
503
+ assert_equal [Backburner::Job::JobNotFound] * 2, @errors.map(&:class)
504
+ assert_equal 1, tube_stats.current_jobs_buried
505
+ end
506
+ end
507
+
508
+ %w(Backburner::TestGoneJob TestGoneNamespace::TestJob TestNoFileJob).each do |class_name|
509
+ it "should release a job whose class is missing (#{class_name})" do
510
+ put_job(class_name, 30)
511
+ out = silenced(2) do
512
+ @worker.prepare
513
+ @worker.work_one_job
514
+ end
515
+ assert_match(/#{class_name}: class not found; released for 30s/, out)
516
+ assert_equal [], @errors
517
+ assert_equal [0, 1], [tube_stats.current_jobs_buried, tube_stats.current_jobs_delayed]
518
+ end
519
+ end
520
+
521
+ it "should release it for at most MAX_CLASS_NOT_FOUND_DELAY seconds" do
522
+ put_job("TestRemovedJob", 3600)
523
+ out = silenced(2) do
524
+ @worker.prepare
525
+ @worker.work_one_job
526
+ end
527
+ assert_match(/TestRemovedJob: class not found; released for 60s/, out)
528
+ assert_equal 60, @worker.connection.tubes["demo.test.foo.bar.unknown"].peek(:delayed).stats.delay
529
+ end
530
+
531
+ it "should not call on_error when its TTR ran out before it could be released" do
532
+ put_job("TestRemovedJob", 1)
533
+ def @worker.reserve_job(*args); job = super; job.ttr; sleep 2.2; job; end # its TTR runs out first
534
+ out = silenced(6) do
535
+ @worker.prepare
536
+ @worker.work_one_job
537
+ end
538
+ assert_match(/TestRemovedJob was no longer reserved/, out)
539
+ assert_equal [], @errors
540
+ assert_equal [0, 1], [tube_stats.current_jobs_buried, tube_stats.current_jobs_ready]
541
+ end
542
+ end # unknown class
543
+
544
+ it "should keep working when the on_error handler raises" do
545
+ clear_jobs!("foo.bar.onerror")
546
+ Backburner.configure { |config| config.on_error = lambda { |e| raise SocketError, "error reporter unreachable" } }
547
+ @worker_class.enqueue TestFailJob, [1, 2], :queue => "foo.bar.onerror"
548
+ @worker_class.enqueue TestJob, [1, 2], :queue => "foo.bar.onerror"
549
+ worker = @worker_class.new('foo.bar.onerror')
550
+ out = silenced(4) do
551
+ worker.prepare
552
+ 2.times { worker.work_one_job }
553
+ end
554
+ assert_match(/Finished TestFailJob.*attempt 1 of 1, burying/, out)
555
+ assert_match(/The on_error handler failed for TestFailJob: Exception SocketError -> error reporter unreachable/, out)
556
+ assert_equal 1, worker.connection.tubes["demo.test.foo.bar.onerror"].stats.current_jobs_buried
557
+ assert_equal 3, $worker_test_count
558
+ ensure
559
+ Backburner.configure { |config| config.on_error = nil }
560
+ clear_jobs!("foo.bar.onerror")
561
+ end
562
+
335
563
  it "should use the connection given as an argument" do
336
564
  worker = @worker_class.new('foo.bar')
337
565
  connection = mock('connection')
@@ -347,4 +575,48 @@ describe "Backburner::Workers::Simple module" do
347
575
  end
348
576
  end
349
577
  end # work_one_job
578
+
579
+ describe "when beanstalkd is down for longer than the reconnect window" do
580
+ before do
581
+ pick_beanstalkd_port
582
+ @templogger = Templogger.new('/tmp')
583
+ @beanstalk_url = Backburner.configuration.beanstalk_url
584
+ Backburner.configure do |config|
585
+ config.beanstalk_url = "beanstalk://127.0.0.1:#{@port}"
586
+ config.logger = @templogger.logger
587
+ end
588
+ start_beanstalkd
589
+ $worker_test_count = 0
590
+ end
591
+
592
+ after do
593
+ @worker_thread.kill if @worker_thread
594
+ stop_beanstalkd
595
+ if @templogger
596
+ Backburner.configure do |config|
597
+ config.beanstalk_url = @beanstalk_url
598
+ config.logger = nil
599
+ end
600
+ @templogger.close
601
+ end
602
+ end
603
+
604
+ it "should reconnect, watch its tubes again and resume working" do
605
+ worker = @worker_class.new('foo.bar.outage')
606
+ worker.prepare
607
+ @worker_thread = Thread.new { loop { worker.work_one_job } }
608
+ sleep 0.5 # let the worker block in reserve
609
+
610
+ stop_beanstalkd
611
+ # beaneater's own reconnect attempts fail, then Connection#ensure_connected! gives up too
612
+ wait_for { @templogger.body.scan(/Exception Beaneater::NotConnected/).size >= 2 }
613
+ start_beanstalkd
614
+ @worker_class.enqueue TestJob, [1, 2], :queue => 'foo.bar.outage'
615
+ wait_for(10) { $worker_test_count == 3 }
616
+
617
+ errors = @templogger.body.scan(/ERROR -- /).size
618
+ assert_operator errors, :<, 20, "the worker logged #{errors} errors (busy loop?)"
619
+ assert_equal 3, $worker_test_count
620
+ end
621
+ end # beanstalkd outage
350
622
  end # Worker
@@ -1,6 +1,7 @@
1
1
  require File.expand_path('../../test_helper', __FILE__)
2
2
  require File.expand_path('../../fixtures/test_jobs', __FILE__)
3
3
  require File.expand_path('../../fixtures/hooked', __FILE__)
4
+ require File.expand_path('../../fixtures/test_queue_settings', __FILE__)
4
5
 
5
6
  describe "Backburner::Workers::Threading worker" do
6
7
  before do
@@ -51,6 +52,21 @@ describe "Backburner::Workers::Threading worker" do
51
52
  end
52
53
  end # process_tube_names
53
54
 
55
+ describe "for process_tube_options method" do
56
+ it "should take a shared tube's limit from the class that owns the tube, not from subclasses" do
57
+ worker = @worker_class.new(%w(tube-limits tube-limits-testtubelimitslambdachildjob))
58
+ assert_equal({
59
+ "demo.test.tube-limits" => { :threads => 5 },
60
+ "demo.test.tube-limits-testtubelimitslambdachildjob" => { :threads => 4 }
61
+ }, worker.instance_variable_get("@tubes_data"))
62
+ end
63
+
64
+ it "should fill in a shared tube's limit from a subclass when the tube's class sets none" do
65
+ worker = @worker_class.new(%w(tube-few-limits))
66
+ assert_equal({ "demo.test.tube-few-limits" => { :threads => 3 } }, worker.instance_variable_get("@tubes_data"))
67
+ end
68
+ end # process_tube_options
69
+
54
70
  describe 'working a queue' do
55
71
  before do
56
72
  @worker = @worker_class.new(["foo:3"])
@@ -91,8 +107,13 @@ describe "Backburner::Workers::Threading worker" do
91
107
  assert_equal @thread_count, $worker_test_count
92
108
  end
93
109
 
110
+ # Its own tube and worker: the jobs it leaves reserved go back to ready when their connections are
111
+ # finalized, and must not reach another test's workers
94
112
  it 'forces an exit when a job is stuck' do
95
- 6.times { @worker_class.enqueue TestStuckJob, [1, 0], :queue => "threaded-shutdown" }
113
+ clear_jobs!("threaded-stuck")
114
+ @worker = @worker_class.new(["threaded-stuck:#{@thread_count}"])
115
+ @worker.exit_on_shutdown = false
116
+ 6.times { @worker_class.enqueue TestStuckJob, [1, 0], :queue => "threaded-stuck" }
96
117
  Thread.new { sleep 0.1; @worker.self_write.puts("TERM") }
97
118
  capture_stdout do
98
119
  @worker.start