delayed_job_heartbeat_plugin 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: d23c84e35c7097a52dd7c4ff8431ec634adc72b4
4
+ data.tar.gz: 626b0561368160ea52dd2b7d4cf65240768cd3c7
5
+ SHA512:
6
+ metadata.gz: fa8a6ff6c94fef094132d2625eb534a2f9b68730fae7696e57001ea513aa28ec7966bfff1b8c9f3e41ac10209ee817c3f495e5288aa4c445999def8c83fa2692
7
+ data.tar.gz: 5a13041ce00bf26a2dfccd022831f32c1273587e701c9e759423519ae3bbaf4ffe35677bb11ff4c81f2d9bfc6e66114d37bb979d22c33d322a393e03a7b46156
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format documentation
2
+ --color
@@ -0,0 +1,14 @@
1
+ sudo: false
2
+ language: ruby
3
+ before_install: gem install bundler -v 1.10.6
4
+ env:
5
+ matrix:
6
+ - RAILS_VERSION="~> 3.2.22"
7
+ - RAILS_VERSION="~> 4.0.13"
8
+ - RAILS_VERSION="~> 4.1.13"
9
+ - RAILS_VERSION="~> 4.2.4"
10
+ rvm:
11
+ - 2.0.0
12
+ - 2.1.7
13
+ - 2.2.3
14
+
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in delayed_job_heartbeat_plugin.gemspec
4
+ gemspec
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Joel Turkel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,87 @@
1
+ # Delayed Job Heartbeat Plugin
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/delayed_job_heartbeat_plugin.png)][gem]
4
+ [![Build Status](https://secure.travis-ci.org/salsify/delayed_job_heartbeat_plugin.png?branch=master)][travis]
5
+ [![Code Climate](https://codeclimate.com/github/salsify/delayed_job_heartbeat_plugin.png)][codeclimate]
6
+ [![Coverage Status](https://coveralls.io/repos/salsify/delayed_job_heartbeat_plugin/badge.png)][coveralls]
7
+
8
+ [gem]: https://rubygems.org/gems/delayed_job_heartbeat_plugin
9
+ [travis]: http://travis-ci.org/salsify/delayed_job_heartbeat_plugin
10
+ [codeclimate]: https://codeclimate.com/github/salsify/delayed_job_heartbeat_plugin
11
+ [coveralls]: https://coveralls.io/r/salsify/delayed_job_heartbeat_plugin
12
+
13
+ By default [Delayed Job](https://github.com/collectiveidea/delayed_job) uses the [ClearLocks](https://github.com/collectiveidea/delayed_job/blob/master/lib/delayed/plugins/clear_locks.rb) plugin to unlock jobs when a worker shuts down. Unfortunately this only works if a worker shuts down cleanly. If the worker crashes, the job won't be unlocked until `max_run_time` elapses which can cause unacceptable delays processing background jobs. Enter the Delayed Job Heartbeat Plugin...
14
+
15
+ The Delayed Job Heartbeat Plugin adds a heartbeat to all Delayed Job workers. After a configurable timeout jobs from unresponsive workers can be unlocked either via a Ruby API or a rake task.
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ ```ruby
22
+ gem 'delayed_job_heartbeat_plugin'
23
+ ```
24
+
25
+ And then execute:
26
+
27
+ $ bundle
28
+
29
+ Or install it yourself as:
30
+
31
+ $ gem install delayed_job_heartbeat_plugin
32
+
33
+ Run the required database migrations:
34
+
35
+ $ rails generate delayed_job_heartbeat_plugin:install
36
+ $ rake db:migrate
37
+
38
+ ## Usage
39
+
40
+ There are two parts to using the Delayed Job Heartbeat Plugin:
41
+
42
+ * Configuring the worker's heartbeat options.
43
+ * Periodically unlocking jobs from unresponsive workers.
44
+
45
+ ### Worker Heartbeat Options
46
+
47
+ The worker heartbeat can be configured in an initializer (e.g. `config/initializers/delayed_job_heartbeat.rb`) follows:
48
+
49
+ ```ruby
50
+ Delayed::Heartbeat.configure do |configuration|
51
+ configuration.enabled = Rails.env.production?
52
+ configuration.heartbeat_interval_seconds = 60
53
+ configuration.heartbeat_timeout_seconds = 180
54
+ configuration.worker_termination_enabled = true
55
+ end
56
+ ```
57
+
58
+ The plugin supports the following options (all of which are optional):
59
+
60
+ * `enabled` - enables/disables the plugin entirely (defaults to true in production and false in other environments)
61
+ * `worker_label` - a label for the worker. Consider setting this to `ENV['DYNO']` if running in Heroku to get the dyno's friendly name (defaults to the Delayed Job worker's name)
62
+ * `heartbeat_interval_seconds` - how often workers should send a heartbeat (defaults to 60 seconds)
63
+ * `heartbeat_timeout_seconds` - theshold after which workers are considered dead if they haven't heartbeated (defaults to 180 seconds)
64
+ * `worker_termination_enabled` - controls whether a worker that detects it has not heartbeated within the timeout period (e.g. due to severe memory swapping) should shut itself down (defaults to true in production and false in other environments)
65
+ * `on_worker_termination` - a callback proc that accepts a `Delayed::Heartbeat::Worker` and an `Exception` if the heartbeat fails. This can be useful for reporting to an error monitoring system.
66
+ * `worker_version` - a version number of the worker's source code that is only used if you want to cleanup workers from old source code versions (defaults to `nil`)
67
+
68
+ ### Unlocking Unresponsive Workers
69
+
70
+ Jobs from unresponsive workers can be unlocked by calling `Delayed::Heartbeat.delete_timed_out_workers` or running the `delayed:heartbeat:delete_timed_out_workers` rake task. This should be integrated with a scheduler like cron, [clockwork](https://github.com/tomykaira/clockwork) or the [Heroku Scheduler](https://devcenter.heroku.com/articles/scheduler) (if running in Heroku).
71
+
72
+ Jobs from workers running an old version of the source code can be unlocked by calling `Delayed::Heartbeat.delete_workers_with_different_version` or running the `delayed:heartbeat:delete_workers_with_different_version` rake task.
73
+
74
+ ## Development
75
+
76
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
77
+
78
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
79
+
80
+ ## Contributing
81
+
82
+ Bug reports and pull requests are welcome on GitHub at https://github.com/salsify/delayed_job_heartbeat_plugin.
83
+
84
+
85
+ ## License
86
+
87
+ The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
@@ -0,0 +1,6 @@
1
+ require 'bundler/gem_tasks'
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,7 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+
5
+ bundle install
6
+
7
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,39 @@
1
+ # encoding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'delayed/heartbeat/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'delayed_job_heartbeat_plugin'
8
+ spec.version = Delayed::Heartbeat::VERSION
9
+ spec.authors = ['Joel Turkel']
10
+ spec.email = ['jturkel@salsify.com']
11
+
12
+ spec.summary = 'Delayed::Job plugin to unlock jobs from dead workers'
13
+ spec.homepage = 'https://github.com/salsify/delayed_job_heartbeat_plugin'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files`.split($/)
17
+ spec.test_files = Dir.glob('spec/**/*')
18
+ spec.require_paths = ['lib']
19
+
20
+ spec.required_ruby_version = '>= 2.0'
21
+
22
+ spec.add_dependency 'delayed_job', '>= 4.1.0'
23
+ spec.add_dependency 'delayed_job_active_record', '>= 4.1.0'
24
+
25
+ spec.add_development_dependency 'activerecord', ENV.fetch('RAILS_VERSION', ['>= 3.2', '< 4.3'])
26
+ spec.add_development_dependency 'coveralls'
27
+ spec.add_development_dependency 'database_cleaner', '>= 1.2'
28
+ spec.add_development_dependency 'rake'
29
+ spec.add_development_dependency 'rspec', '3.3.0'
30
+ spec.add_development_dependency 'simplecov', '~> 0.7.1'
31
+ spec.add_development_dependency 'timecop'
32
+
33
+ if RUBY_PLATFORM == 'java'
34
+ spec.add_development_dependency 'jdbc-sqlite3'
35
+ spec.add_development_dependency 'activerecord-jdbcsqlite3-adapter'
36
+ else
37
+ spec.add_development_dependency 'sqlite3'
38
+ end
39
+ end
@@ -0,0 +1,46 @@
1
+ require 'delayed/heartbeat/compatibility'
2
+ require 'delayed/heartbeat/configuration'
3
+ require 'delayed/heartbeat/delete_worker_results'
4
+ require 'delayed/heartbeat/plugin'
5
+ require 'delayed/heartbeat/version'
6
+ require 'delayed/heartbeat/worker'
7
+ require 'delayed/heartbeat/worker_heartbeat'
8
+ require 'delayed/heartbeat/railtie' if defined?(Rails::Railtie)
9
+
10
+ module Delayed
11
+ module Heartbeat
12
+ @configuration = Delayed::Heartbeat::Configuration.new
13
+
14
+ class << self
15
+ def configure
16
+ yield(configuration) if block_given?
17
+ end
18
+
19
+ def configuration
20
+ @configuration
21
+ end
22
+
23
+ def delete_workers_with_different_version(current_version = configuration.worker_version)
24
+ old_workers = Delayed::Heartbeat::Worker.workers_with_different_version(current_version)
25
+ cleanup_workers(old_workers, mark_attempt_failed: false)
26
+ end
27
+
28
+ def delete_timed_out_workers(timeout_seconds = configuration.heartbeat_timeout_seconds)
29
+ dead_workers = Delayed::Heartbeat::Worker.dead_workers(timeout_seconds)
30
+ cleanup_workers(dead_workers, mark_attempt_failed: true)
31
+ end
32
+
33
+ private
34
+
35
+ def cleanup_workers(workers, mark_attempt_failed: true)
36
+ Delayed::Heartbeat::Worker.transaction do
37
+ orphaned_jobs = workers.flat_map do |worker|
38
+ worker.unlock_jobs(mark_attempt_failed: mark_attempt_failed)
39
+ end
40
+ Delayed::Heartbeat::Worker.delete_workers(workers)
41
+ Delayed::Heartbeat::DeleteWorkerResults.new(workers, orphaned_jobs)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,14 @@
1
+ require 'active_support/version'
2
+ require 'active_record/version'
3
+
4
+ module Delayed
5
+ module Heartbeat
6
+ module Compatibility
7
+
8
+ def self.mass_assignment_security_enabled?
9
+ ::ActiveRecord::VERSION::MAJOR < 4 || defined?(::ActiveRecord::MassAssignmentSecurity)
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,29 @@
1
+ module Delayed
2
+ module Heartbeat
3
+ class Configuration
4
+ attr_accessor :enabled, :worker_label, :worker_version,
5
+ :heartbeat_timeout_seconds, :heartbeat_interval_seconds,
6
+ :worker_termination_enabled, :on_worker_termination
7
+ alias_method :enabled?, :enabled
8
+ alias_method :worker_termination_enabled?, :worker_termination_enabled
9
+
10
+ def initialize(options = {})
11
+ options.each do |key, value|
12
+ send("#{key}=", value)
13
+ end
14
+
15
+ if enabled.nil?
16
+ self.enabled = defined?(Rails) ? Rails.env.production? : true
17
+ end
18
+
19
+ if worker_termination_enabled.nil?
20
+ self.worker_termination_enabled = defined?(Rails) ? Rails.env.production? : true
21
+ end
22
+
23
+ self.heartbeat_timeout_seconds ||= 180
24
+ self.heartbeat_interval_seconds ||= 60
25
+ self.on_worker_termination ||= Proc.new { }
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,43 @@
1
+ module Delayed
2
+ module Heartbeat
3
+ class DeleteWorkerResults
4
+ attr_reader :workers, :unlocked_jobs
5
+
6
+ def initialize(workers, unlocked_jobs)
7
+ @workers = workers
8
+ @unlocked_jobs = unlocked_jobs
9
+ end
10
+
11
+ def empty?
12
+ workers.empty? && unlocked_jobs.empty?
13
+ end
14
+
15
+ def to_s
16
+ io = StringIO.new
17
+ workers.each do |worker|
18
+ io.puts("Deleted worker #{worker_description(worker)}")
19
+ end
20
+
21
+ unlocked_jobs.each do |unlocked_job|
22
+ worker = worker_map[unlocked_job.locked_by]
23
+ worker_string = worker ? worker_description(worker) : unlocked_job.locked_by
24
+ io.puts("Unlocked orphaned job #{unlocked_job.id} from worker #{worker_string}")
25
+ end
26
+
27
+ io.string
28
+ end
29
+
30
+ private
31
+
32
+ def worker_map
33
+ @worker_map ||= workers.each_with_object(Hash.new) do |worker, worker_map|
34
+ worker_map[worker.name] = worker
35
+ end
36
+ end
37
+
38
+ def worker_description(worker)
39
+ "#{worker.label}(#{worker.name})"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ require 'set'
2
+
3
+ module Delayed
4
+ module Heartbeat
5
+ class Plugin < Delayed::Plugin
6
+
7
+ callbacks do |lifecycle|
8
+ lifecycle.before(:execute) do |worker|
9
+ if Delayed::Heartbeat.configuration.enabled?
10
+ @heartbeat = Delayed::Heartbeat::WorkerHeartbeat.new(worker.name)
11
+ end
12
+ end
13
+
14
+ lifecycle.after(:execute) do |worker|
15
+ @heartbeat.stop if @heartbeat
16
+ end
17
+ end
18
+
19
+ end
20
+ end
21
+ end
22
+
@@ -0,0 +1,12 @@
1
+ require 'delayed_job'
2
+ require 'rails'
3
+
4
+ module Delayed
5
+ class Railtie < Rails::Railtie
6
+
7
+ rake_tasks do
8
+ load 'delayed/heartbeat/tasks.rb'
9
+ end
10
+
11
+ end
12
+ end
@@ -0,0 +1,24 @@
1
+ namespace :delayed do
2
+ namespace :heartbeat do
3
+ desc 'Cleans up workers that have not heartbeated recently.'
4
+ task delete_timed_out_workers: :environment do
5
+ results = Delayed::Heartbeat.delete_timed_out_workers
6
+ print_results(results)
7
+ end
8
+
9
+ desc 'Cleans up workers running a different version.'
10
+ task delete_workers_with_different_version: :environment do
11
+ results = Delayed::Heartbeat.delete_workers_with_different_version
12
+ print_results(results)
13
+ end
14
+
15
+ def print_results(results)
16
+ puts "Deleted #{results.workers.size} and unlocked #{results.unlocked_jobs.size} orphaned jobs"
17
+ puts results.to_s if verbose? && results.present?
18
+ end
19
+
20
+ def verbose?
21
+ ENV['VERBOSE'].to_s.downcase == 'true'
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,7 @@
1
+ # encoding: UTF-8
2
+
3
+ module Delayed
4
+ module Heartbeat
5
+ VERSION = '0.1.0'.freeze
6
+ end
7
+ end
@@ -0,0 +1,69 @@
1
+ require 'delayed/heartbeat/compatibility'
2
+
3
+ module Delayed
4
+ module Heartbeat
5
+ class Worker < ActiveRecord::Base
6
+ self.table_name = 'delayed_workers'
7
+
8
+ if Delayed::Heartbeat::Compatibility.mass_assignment_security_enabled?
9
+ attr_accessible :name, :version, :last_heartbeat_at, :host_name, :label
10
+ end
11
+
12
+ before_create do |model|
13
+ model.last_heartbeat_at ||= Time.now.utc
14
+ model.host_name ||= Socket.gethostname
15
+ model.label ||= Delayed::Heartbeat.configuration.worker_label || name
16
+ model.version ||= Delayed::Heartbeat.configuration.worker_version
17
+ end
18
+
19
+ def jobs
20
+ Delayed::Job.where(locked_by: name, failed_at: nil)
21
+ end
22
+
23
+ def job
24
+ jobs.first
25
+ end
26
+
27
+ # Returns the jobs that were unlocked
28
+ def unlock_jobs(mark_attempt_failed: true)
29
+ orphaned_jobs = jobs.to_a
30
+ return orphaned_jobs unless orphaned_jobs.present?
31
+
32
+ if mark_attempt_failed
33
+ mark_job_attempts_failed(orphaned_jobs)
34
+ else
35
+ Delayed::Job.where(id: orphaned_jobs.map(&:id)).update_all(locked_at: nil, locked_by: nil)
36
+ end
37
+
38
+ orphaned_jobs
39
+ end
40
+
41
+ def self.dead_workers(timeout_seconds)
42
+ where('last_heartbeat_at < ?', Time.now.utc - timeout_seconds)
43
+ end
44
+
45
+ def self.workers_with_different_version(current_version)
46
+ where('version != ?', current_version)
47
+ end
48
+
49
+ def self.delete_workers(workers)
50
+ where(id: workers.map(&:id)).delete_all if workers.present?
51
+ end
52
+
53
+ private
54
+
55
+ def mark_job_attempts_failed(jobs)
56
+ dj_worker = Delayed::Worker.new
57
+ jobs.each do |job|
58
+ mark_job_attempt_failed(dj_worker, job)
59
+ end
60
+ end
61
+
62
+ def mark_job_attempt_failed(dj_worker, job)
63
+ # If there are more attempts this reschedules the job otherwise marks it as failed
64
+ # and runs appropriate callbacks
65
+ dj_worker.reschedule(job)
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,91 @@
1
+ module Delayed
2
+ module Heartbeat
3
+ class WorkerHeartbeat
4
+
5
+ def initialize(worker_name)
6
+ @worker_model = create_worker_model(worker_name)
7
+
8
+ # Use a self-pipe to safely shutdown the heartbeat thread
9
+ @stop_reader, @stop_writer = IO.pipe
10
+
11
+ yield(self) if block_given?
12
+
13
+ @heartbeat_thread = Thread.new { run_heartbeat_loop }
14
+ # Make this a high priority thread to try to ensure it runs
15
+ @heartbeat_thread.priority = 100
16
+ end
17
+
18
+ def alive?
19
+ @heartbeat_thread.alive?
20
+ end
21
+
22
+ def stop
23
+ # Use the self-pipe to tell the heartbeat thread to cleanly
24
+ # shutdown
25
+ if @stop_writer
26
+ @stop_writer.close
27
+ @stop_writer = nil
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def create_worker_model(worker_name)
34
+ Delayed::Heartbeat::Worker.transaction do
35
+ Delayed::Heartbeat::Worker.where(name: worker_name).delete_all
36
+ Delayed::Heartbeat::Worker.create!(name: worker_name)
37
+ end
38
+ end
39
+
40
+ def run_heartbeat_loop
41
+ loop do
42
+ break if sleep_interruptibly(heartbeat_interval)
43
+ update_heartbeat
44
+ # Return the connection back to the pool since we won't be needing
45
+ # it again for a while.
46
+ Delayed::Backend::ActiveRecord::Job.clear_active_connections!
47
+ end
48
+ rescue => e
49
+ # We don't want the worker to continue running if the heartbeat can't be written.
50
+ # Don't use Thread.abort_on_exception because that will give Delayed::Job a chance
51
+ # to mark the job as failed which will unlock it even though the clock
52
+ # process has likely already unlocked it and another worker may have picked it up.
53
+ Delayed::Heartbeat.configuration.on_worker_termination.call(@worker_model, e)
54
+ exit(false)
55
+ ensure
56
+ @stop_reader.close
57
+ @worker_model.delete
58
+ # Note: The built-in Delayed::Plugins::ClearLocks will unlock the jobs for us
59
+ Delayed::Backend::ActiveRecord::Job.clear_active_connections!
60
+ end
61
+
62
+ def update_heartbeat
63
+ now = Time.now.utc
64
+ heartbeat_delta_seconds = now - @worker_model.last_heartbeat_at
65
+ if heartbeat_delta_seconds < heartbeat_timeout_seconds || self_termination_disabled?
66
+ @worker_model.update_column(:last_heartbeat_at, now)
67
+ else
68
+ raise Timeout::Error, "Worker heartbeat not updated for #{heartbeat_delta_seconds} seconds which " \
69
+ "exceeds timeout\n. Current job: #{ @worker_model.job.inspect}"
70
+ end
71
+ end
72
+
73
+ def self_termination_disabled?
74
+ !Delayed::Heartbeat.configuration.worker_termination_enabled?
75
+ end
76
+
77
+ def heartbeat_timeout_seconds
78
+ Delayed::Heartbeat.configuration.heartbeat_timeout_seconds
79
+ end
80
+
81
+ def heartbeat_interval
82
+ Delayed::Heartbeat.configuration.heartbeat_interval_seconds
83
+ end
84
+
85
+ # Returns a truthy if the sleep was interrupted
86
+ def sleep_interruptibly(secs)
87
+ IO.select([@stop_reader], nil, nil, secs)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,7 @@
1
+ require 'active_support'
2
+ require 'active_record'
3
+ require 'delayed_job'
4
+ require 'delayed_job_active_record'
5
+ require 'delayed/heartbeat'
6
+
7
+ Delayed::Worker.plugins << Delayed::Heartbeat::Plugin
@@ -0,0 +1,20 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+ require 'rails/generators/active_record'
4
+
5
+ module DelayedJobHeartbeatPlugin
6
+ class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ self.source_paths << File.join(File.dirname(__FILE__), 'templates')
10
+
11
+ def create_migration_file
12
+ migration_template('migration.rb', 'db/migrate/create_delayed_workers.rb')
13
+ end
14
+
15
+ def self.next_migration_number(dirname)
16
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
17
+ end
18
+
19
+ end
20
+ end
@@ -0,0 +1,13 @@
1
+ class CreateDelayedWorkers < ActiveRecord::Migration
2
+
3
+ def change
4
+ create_table(:delayed_workers) do |t|
5
+ t.string :name
6
+ t.string :version
7
+ t.datetime :last_heartbeat_at
8
+ t.string :host_name
9
+ t.string :label
10
+ end
11
+ end
12
+
13
+ end
@@ -0,0 +1,5 @@
1
+ sqlite3:
2
+ adapter: sqlite3
3
+ pool: 5
4
+ timeout: 15000
5
+ database: tmp/sqlite3.db
@@ -0,0 +1,23 @@
1
+ ActiveRecord::Schema.define(version: 0) do
2
+
3
+ create_table(:delayed_jobs, force: true) do |t|
4
+ t.integer :priority, default: 0
5
+ t.integer :attempts, default: 0
6
+ t.text :handler
7
+ t.text :last_error
8
+ t.datetime :run_at
9
+ t.datetime :locked_at
10
+ t.datetime :failed_at
11
+ t.string :locked_by
12
+ t.string :queue
13
+ t.timestamps
14
+ end
15
+
16
+ create_table(:delayed_workers, force: true) do |t|
17
+ t.string :name
18
+ t.string :version
19
+ t.datetime :last_heartbeat_at
20
+ t.string :host_name
21
+ t.string :label
22
+ end
23
+ end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+
3
+ describe Delayed::Heartbeat::DeleteWorkerResults do
4
+ let(:worker) { create_worker(name: 'my-worker') }
5
+ let(:job) { create_job(locked_by: worker.name) }
6
+ let(:results) { create_results([worker], [job]) }
7
+
8
+ describe "#workers" do
9
+ specify { expect(results.workers).to eq [worker] }
10
+ end
11
+
12
+ describe "#unlocked_jobs" do
13
+ specify { expect(results.unlocked_jobs).to eq [job] }
14
+ end
15
+
16
+ describe "#to_s" do
17
+ it "includes workers" do
18
+ results = create_results([worker], [])
19
+ expect(results.to_s).to include(worker.name)
20
+ end
21
+
22
+ it "includes jobs for known workers" do
23
+ results = create_results([worker], [job])
24
+ expect(results.to_s).to include(job.id.to_s)
25
+ end
26
+
27
+ it "includes jobs locked by an unknown worker" do
28
+ job = create_job(locked_by: 'unknown-worker')
29
+ results = create_results([], [job])
30
+ expect(results.to_s).to include(job.id.to_s)
31
+ expect(results.to_s).to include(job.locked_by)
32
+ end
33
+ end
34
+
35
+ def create_worker(attributes = {})
36
+ Delayed::Heartbeat::Worker.create!(attributes)
37
+ end
38
+
39
+ def create_job(attributes = {})
40
+ attributes = attributes.reverse_merge(payload_object: TestJob.new)
41
+ Delayed::Job.create!(attributes)
42
+ end
43
+
44
+ def create_results(workers, unlocked_jobs)
45
+ Delayed::Heartbeat::DeleteWorkerResults.new(workers, unlocked_jobs)
46
+ end
47
+ end
@@ -0,0 +1,81 @@
1
+ require 'spec_helper'
2
+
3
+ describe Delayed::Heartbeat::WorkerHeartbeat, cleaner_strategy: :truncation do
4
+ let(:worker_name) { 'Test Worker' }
5
+ let(:worker_model) { find_worker_model(worker_name) }
6
+ let(:heartbeat_config) do
7
+ Delayed::Heartbeat::Configuration.new(heartbeat_interval_seconds: 0.001)
8
+ end
9
+
10
+ before do
11
+ allow(Delayed::Heartbeat).to receive(:configuration).and_return(heartbeat_config)
12
+
13
+ Delayed::Job.create!(locked_by: worker_name, payload_object: TestJob.new)
14
+ @worker_heartbeat = start_heartbeat
15
+ end
16
+
17
+ after do
18
+ stop_heartbeat
19
+ end
20
+
21
+ it "creates a worker model" do
22
+ expect(worker_model).to be_present
23
+ end
24
+
25
+ it "updates the worker heartbeat" do
26
+ original_heartbeat = worker_model.last_heartbeat_at
27
+ Wait.for('worker heartbeat updated') do
28
+ worker_model.reload.last_heartbeat_at != original_heartbeat
29
+ end
30
+ end
31
+
32
+ context "when the heartbeat is stopped" do
33
+ let!(:job) { Delayed::Backend::ActiveRecord::Job.create!(locked_by: worker_model.name, locked_at: Time.now) }
34
+
35
+ before do
36
+ stop_heartbeat
37
+ end
38
+
39
+ it "destroys the worker model" do
40
+ expect(find_worker_model(worker_name)).not_to be_present
41
+ end
42
+ end
43
+
44
+ context "when the heartbeat times out" do
45
+ let(:heartbeat_config) do
46
+ # Create a configuration where heartbeat is updated less frequently than the timeout
47
+ Delayed::Heartbeat::Configuration.new(heartbeat_interval_seconds: 0.001,
48
+ heartbeat_timeout_seconds: 0.00000001,
49
+ worker_termination_enabled: true)
50
+ end
51
+
52
+ before do
53
+ wait_for_heartbeat_thread_stopped
54
+ end
55
+
56
+ it "aborts the process" do
57
+ expect(@worker_heartbeat).to have_received(:exit).with(false)
58
+ end
59
+ end
60
+
61
+ def start_heartbeat
62
+ Delayed::Heartbeat::WorkerHeartbeat.new(worker_name) do |heartbeat|
63
+ allow(heartbeat).to receive(:exit)
64
+ end
65
+ end
66
+
67
+ def stop_heartbeat
68
+ @worker_heartbeat.stop
69
+ wait_for_heartbeat_thread_stopped
70
+ end
71
+
72
+ def wait_for_heartbeat_thread_stopped
73
+ Wait.for('worker thread stopped') do
74
+ !@worker_heartbeat.alive?
75
+ end
76
+ end
77
+
78
+ def find_worker_model(worker_name)
79
+ Delayed::Heartbeat::Worker.where(name: worker_name).first
80
+ end
81
+ end
@@ -0,0 +1,82 @@
1
+ require 'spec_helper'
2
+
3
+ describe Delayed::Heartbeat do
4
+ let(:current_version) { 'current version' }
5
+ let!(:active_worker) { create_worker_model(name: 'active_worker', version: current_version) }
6
+
7
+ let!(:active_job) do
8
+ Delayed::Job.create!(locked_by: active_worker.name,
9
+ locked_at: active_worker.last_heartbeat_at,
10
+ payload_object: TestJob.new)
11
+ end
12
+
13
+ let!(:orphaned_job) do
14
+ Delayed::Job.create!(locked_by: dead_worker.name,
15
+ locked_at: dead_worker.last_heartbeat_at,
16
+ payload_object: TestJob.new)
17
+ end
18
+
19
+ shared_examples "it destroys a worker and unlocks its jobs" do |expected_orphaned_job_attempts: nil|
20
+ specify { expect(active_worker).not_to have_been_destroyed }
21
+ specify { expect(active_job.reload.locked_by).not_to be_nil }
22
+ specify { expect(active_job.reload.locked_at).not_to be_nil }
23
+ specify { expect(active_job.reload.attempts).to eq(0) }
24
+ specify { expect(active_job.reload.failed_at).to be_nil }
25
+
26
+ specify { expect(dead_worker).to have_been_destroyed }
27
+ specify { expect(orphaned_job.reload.locked_by).to be_nil }
28
+ specify { expect(orphaned_job.reload.locked_at).to be_nil }
29
+ specify { expect(orphaned_job.reload.attempts).to eq(expected_orphaned_job_attempts) }
30
+ specify { expect(orphaned_job.reload.failed_at).to be_nil }
31
+ end
32
+
33
+ describe ".delete_timed_out_workers" do
34
+ let!(:dead_worker) do
35
+ create_worker_model(name: 'dead_worker',
36
+ last_heartbeat_at: Time.now - Delayed::Heartbeat.configuration.heartbeat_timeout_seconds - 1)
37
+ end
38
+
39
+ let(:max_attempts) { 5 }
40
+
41
+ let!(:failed_orphaned_job) do
42
+ Delayed::Job.create!(locked_by: dead_worker.name, locked_at: dead_worker.last_heartbeat_at,
43
+ payload_object: TestJobWithCallbacks.new) do |job|
44
+ job.attempts = max_attempts - 1
45
+ end
46
+ end
47
+
48
+ before do
49
+ TestJobWithCallbacks.clear
50
+ @old_attempts = Delayed::Worker.max_attempts
51
+ Delayed::Worker.max_attempts = max_attempts
52
+
53
+ Delayed::Heartbeat.delete_timed_out_workers
54
+ end
55
+
56
+ after do
57
+ Delayed::Worker.max_attempts = @old_attempts
58
+ end
59
+
60
+ specify { expect(failed_orphaned_job.reload.failed_at).to be_present }
61
+ specify { expect(TestJobWithCallbacks.called_callbacks).to eq [:failure] }
62
+
63
+ it_behaves_like "it destroys a worker and unlocks its jobs", expected_orphaned_job_attempts: 1
64
+ end
65
+
66
+ describe ".delete_workers_with_different_version" do
67
+ let!(:dead_worker) { create_worker_model(name: 'old_worker', version: 'old version') }
68
+
69
+ before do
70
+ Delayed::Heartbeat.delete_workers_with_different_version(current_version)
71
+ end
72
+
73
+ it_behaves_like "it destroys a worker and unlocks its jobs", expected_orphaned_job_attempts: 0
74
+ end
75
+
76
+ def create_worker_model(attributes)
77
+ Delayed::Heartbeat::Worker.create!(attributes)
78
+ end
79
+
80
+ end
81
+
82
+
@@ -0,0 +1,56 @@
1
+ $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
2
+
3
+ require 'simplecov'
4
+ require 'coveralls'
5
+
6
+ SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
7
+ SimpleCov::Formatter::HTMLFormatter,
8
+ Coveralls::SimpleCov::Formatter
9
+ ]
10
+ SimpleCov.start do
11
+ add_filter 'spec'
12
+ end
13
+
14
+ require 'database_cleaner'
15
+ require 'delayed_job_heartbeat_plugin'
16
+ require 'yaml'
17
+ require 'timecop'
18
+
19
+ spec_dir = File.dirname(__FILE__)
20
+ Dir["#{spec_dir}/support/**/*.rb"].sort.each { |f| require f }
21
+
22
+ FileUtils.makedirs('log')
23
+ FileUtils.makedirs('tmp')
24
+
25
+ Delayed::Worker.read_ahead = 1
26
+ Delayed::Worker.destroy_failed_jobs = false
27
+
28
+ Delayed::Worker.logger = Logger.new('log/test.log')
29
+ Delayed::Worker.logger.level = Logger::DEBUG
30
+ ActiveRecord::Base.logger = Delayed::Worker.logger
31
+ ActiveRecord::Migration.verbose = false
32
+
33
+ db_adapter = ENV.fetch('ADAPTER', 'sqlite3')
34
+ config = YAML.load(File.read('spec/db/database.yml'))
35
+ ActiveRecord::Base.establish_connection(config[db_adapter])
36
+ require 'db/schema'
37
+
38
+ RSpec.configure do |config|
39
+ config.order = 'random'
40
+
41
+ config.before(:suite) do
42
+ DatabaseCleaner.clean_with(:truncation)
43
+ end
44
+
45
+ config.before(:each) do |example|
46
+ DatabaseCleaner.strategy = example.metadata.fetch(:cleaner_strategy, :transaction)
47
+ end
48
+
49
+ config.before(:each) do
50
+ DatabaseCleaner.start
51
+ end
52
+
53
+ config.after(:each) do
54
+ DatabaseCleaner.clean
55
+ end
56
+ end
@@ -0,0 +1,15 @@
1
+ # encoding: UTF-8
2
+
3
+ RSpec::Matchers.define :have_been_destroyed do
4
+ match do |actual|
5
+ !actual.class.where(id: actual.id).exists?
6
+ end
7
+
8
+ description do
9
+ "model should have been destroyed"
10
+ end
11
+
12
+ failure_message do |actual|
13
+ "expected #{actual.class}(id: #{actual.id}) to have been destroyed"
14
+ end
15
+ end
@@ -0,0 +1,5 @@
1
+ class TestJob
2
+ def perform
3
+
4
+ end
5
+ end
@@ -0,0 +1,16 @@
1
+ class TestJobWithCallbacks
2
+ cattr_accessor :called_callbacks
3
+ self.called_callbacks = []
4
+
5
+ def self.clear
6
+ called_callbacks.clear
7
+ end
8
+
9
+ def failure(*)
10
+ self.class.called_callbacks << :failure
11
+ end
12
+
13
+ def perform
14
+ self.class.called_callbacks << :perform
15
+ end
16
+ end
@@ -0,0 +1,13 @@
1
+ module Wait
2
+ def self.for(condition_name, max_wait_time: 5, polling_interval: 0.001)
3
+ wait_until = Time.now + max_wait_time.seconds
4
+ loop do
5
+ return if yield
6
+ if Time.now > wait_until
7
+ raise "Condition not met: #{condition_name}"
8
+ else
9
+ sleep(polling_interval)
10
+ end
11
+ end
12
+ end
13
+ end
metadata ADDED
@@ -0,0 +1,232 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: delayed_job_heartbeat_plugin
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Joel Turkel
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2015-11-06 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: delayed_job
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - '>='
18
+ - !ruby/object:Gem::Version
19
+ version: 4.1.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: 4.1.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: delayed_job_active_record
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - '>='
32
+ - !ruby/object:Gem::Version
33
+ version: 4.1.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: 4.1.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: activerecord
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - '>='
46
+ - !ruby/object:Gem::Version
47
+ version: '3.2'
48
+ - - <
49
+ - !ruby/object:Gem::Version
50
+ version: '4.3'
51
+ type: :development
52
+ prerelease: false
53
+ version_requirements: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - '>='
56
+ - !ruby/object:Gem::Version
57
+ version: '3.2'
58
+ - - <
59
+ - !ruby/object:Gem::Version
60
+ version: '4.3'
61
+ - !ruby/object:Gem::Dependency
62
+ name: coveralls
63
+ requirement: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - '>='
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ type: :development
69
+ prerelease: false
70
+ version_requirements: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - '>='
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ - !ruby/object:Gem::Dependency
76
+ name: database_cleaner
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - '>='
80
+ - !ruby/object:Gem::Version
81
+ version: '1.2'
82
+ type: :development
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - '>='
87
+ - !ruby/object:Gem::Version
88
+ version: '1.2'
89
+ - !ruby/object:Gem::Dependency
90
+ name: rake
91
+ requirement: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - '>='
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ type: :development
97
+ prerelease: false
98
+ version_requirements: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - '>='
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ - !ruby/object:Gem::Dependency
104
+ name: rspec
105
+ requirement: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - '='
108
+ - !ruby/object:Gem::Version
109
+ version: 3.3.0
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - '='
115
+ - !ruby/object:Gem::Version
116
+ version: 3.3.0
117
+ - !ruby/object:Gem::Dependency
118
+ name: simplecov
119
+ requirement: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ~>
122
+ - !ruby/object:Gem::Version
123
+ version: 0.7.1
124
+ type: :development
125
+ prerelease: false
126
+ version_requirements: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ~>
129
+ - !ruby/object:Gem::Version
130
+ version: 0.7.1
131
+ - !ruby/object:Gem::Dependency
132
+ name: timecop
133
+ requirement: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - '>='
136
+ - !ruby/object:Gem::Version
137
+ version: '0'
138
+ type: :development
139
+ prerelease: false
140
+ version_requirements: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - '>='
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ - !ruby/object:Gem::Dependency
146
+ name: sqlite3
147
+ requirement: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - '>='
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
152
+ type: :development
153
+ prerelease: false
154
+ version_requirements: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - '>='
157
+ - !ruby/object:Gem::Version
158
+ version: '0'
159
+ description:
160
+ email:
161
+ - jturkel@salsify.com
162
+ executables: []
163
+ extensions: []
164
+ extra_rdoc_files: []
165
+ files:
166
+ - .gitignore
167
+ - .rspec
168
+ - .travis.yml
169
+ - Gemfile
170
+ - LICENSE.txt
171
+ - README.md
172
+ - Rakefile
173
+ - bin/setup
174
+ - delayed_job_heartbeat_plugin.gemspec
175
+ - lib/delayed/heartbeat.rb
176
+ - lib/delayed/heartbeat/compatibility.rb
177
+ - lib/delayed/heartbeat/configuration.rb
178
+ - lib/delayed/heartbeat/delete_worker_results.rb
179
+ - lib/delayed/heartbeat/plugin.rb
180
+ - lib/delayed/heartbeat/railtie.rb
181
+ - lib/delayed/heartbeat/tasks.rb
182
+ - lib/delayed/heartbeat/version.rb
183
+ - lib/delayed/heartbeat/worker.rb
184
+ - lib/delayed/heartbeat/worker_heartbeat.rb
185
+ - lib/delayed_job_heartbeat_plugin.rb
186
+ - lib/generators/delayed_job_heartbeat_plugin/install_generator.rb
187
+ - lib/generators/delayed_job_heartbeat_plugin/templates/migration.rb
188
+ - spec/db/database.yml
189
+ - spec/db/schema.rb
190
+ - spec/delayed/heartbeat/delete_worker_results_spec.rb
191
+ - spec/delayed/heartbeat/worker_heartbeat_spec.rb
192
+ - spec/delayed/heartbeat_spec.rb
193
+ - spec/spec_helper.rb
194
+ - spec/support/destroyed_model.rb
195
+ - spec/support/test_job.rb
196
+ - spec/support/test_job_with_callbacks.rb
197
+ - spec/support/wait.rb
198
+ homepage: https://github.com/salsify/delayed_job_heartbeat_plugin
199
+ licenses:
200
+ - MIT
201
+ metadata: {}
202
+ post_install_message:
203
+ rdoc_options: []
204
+ require_paths:
205
+ - lib
206
+ required_ruby_version: !ruby/object:Gem::Requirement
207
+ requirements:
208
+ - - '>='
209
+ - !ruby/object:Gem::Version
210
+ version: '2.0'
211
+ required_rubygems_version: !ruby/object:Gem::Requirement
212
+ requirements:
213
+ - - '>='
214
+ - !ruby/object:Gem::Version
215
+ version: '0'
216
+ requirements: []
217
+ rubyforge_project:
218
+ rubygems_version: 2.2.2
219
+ signing_key:
220
+ specification_version: 4
221
+ summary: Delayed::Job plugin to unlock jobs from dead workers
222
+ test_files:
223
+ - spec/db/database.yml
224
+ - spec/db/schema.rb
225
+ - spec/delayed/heartbeat/delete_worker_results_spec.rb
226
+ - spec/delayed/heartbeat/worker_heartbeat_spec.rb
227
+ - spec/delayed/heartbeat_spec.rb
228
+ - spec/spec_helper.rb
229
+ - spec/support/destroyed_model.rb
230
+ - spec/support/test_job.rb
231
+ - spec/support/test_job_with_callbacks.rb
232
+ - spec/support/wait.rb