delayed_job_groups_plugin 0.1.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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 2a4de75ba53fbd7b7777b5c65ccdbb29a509cf51
4
+ data.tar.gz: 611191660f2dcf4b942efe7916006e1f710a8e29
5
+ SHA512:
6
+ metadata.gz: 0894bd53ffa33e924e1319034f462f7cd2e4b6e2b0ca39bc9cb4209dbdcf89cd5f36cfcca63012639685a05e86aa4402b97608faa672debc4607b6c96b55f106
7
+ data.tar.gz: b301f22a1c2334058f8d6f3b4c463e5be9cc7aa252d60a713ef79d328a2ea0692e610fd123e1fb367bf8cd4d215c0f0d5e34350fb5df26605fe262ccef6b3ad9
data/.gitignore ADDED
@@ -0,0 +1,20 @@
1
+ *.gem
2
+ *.iml
3
+ *.rbc
4
+ .bundle
5
+ .config
6
+ .idea/
7
+ .yardoc
8
+ Gemfile.lock
9
+ InstalledFiles
10
+ _yardoc
11
+ coverage
12
+ doc/
13
+ lib/bundler/man
14
+ log/
15
+ pkg
16
+ rdoc
17
+ spec/reports
18
+ test/tmp
19
+ test/version_tmp
20
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --color
2
+ --format progress
data/.travis.yml ADDED
@@ -0,0 +1,10 @@
1
+ language: ruby
2
+ env:
3
+ matrix:
4
+ - RAILS_VERSION="~> 3.2.0" JRUBY_OPTS="$JRUBY_OPTS --debug"
5
+ - RAILS_VERSION="~> 4.0.0" JRUBY_OPTS="$JRUBY_OPTS --debug"
6
+ rvm:
7
+ - 1.9.3
8
+ - 2.0.0
9
+ - 2.1.0
10
+ - jruby-19mode
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2014 Joel Turkel
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # Delayed Job Groups
2
+ [![Build Status](https://secure.travis-ci.org/salsify/delayed_job_groups_plugin.png?branch=master)][travis]
3
+ [![Code Climate](https://codeclimate.com/github/salsify/delayed_job_groups_plugin.png)][codeclimate]
4
+ [![Coverage Status](https://coveralls.io/repos/salsify/delayed_job_groups_plugin/badge.png)][coveralls]
5
+
6
+ [travis]: http://travis-ci.org/salsify/delayed_job_groups_plugin
7
+ [codeclimate]: https://codeclimate.com/github/salsify/delayed_job_groups_plugin
8
+ [coveralls]: https://coveralls.io/r/salsify/delayed_job_groups_plugin
9
+
10
+ A [Delayed Job](https://github.com/collectiveidea/delayed_job) plugin that adds job groups supporting:
11
+
12
+ * Canceling all jobs in a job group
13
+ * Canceling the job group when a job in the job group fails
14
+ * Blocking and unblocking all jobs in a job group
15
+ * Running additional processing after all jobs in a job group complete or a job group fails
16
+
17
+ ## Installation
18
+
19
+ Add this line to your application's Gemfile:
20
+
21
+ gem 'delayed_job_groups_plugin'
22
+
23
+ And then execute:
24
+
25
+ $ bundle install
26
+
27
+ Or install it yourself as:
28
+
29
+ $ gem install delayed_job_groups_plugin
30
+
31
+ Run the required database migrations:
32
+
33
+ $ rails generate delayed_job_groups_plugin:install
34
+ $ rake db:migrate
35
+
36
+ ## Usage
37
+
38
+ Creating a job group and queueing some jobs:
39
+
40
+ ```ruby
41
+ job_group = Delayed::JobGroups::JobGroup.create!
42
+
43
+ # JobGroup#enqueue has the same signature as Delayed::Job.enqueue
44
+ # i.e. it takes a job and an optional hash of options.
45
+ job_group.enqueue(MyJob.new('some arg'), queue: 'general')
46
+ job_group.enqueue(MyJob.new('some other arg'), queue: 'general', priority: 10)
47
+
48
+ # Tell the JobGroup we're done queueing jobs
49
+ job_group.mark_queueing_complete
50
+ ```
51
+
52
+ Registering a job to run after all jobs in the job group have completed:
53
+
54
+ ```ruby
55
+ # We can optionally pass options that will be used when queueing the on completion job
56
+ job_group = Delayed::JobGroups::JobGroup.create!(on_completion_job: MyCompletionJob.new,
57
+ on_completion_job_options: { queue: 'general' })
58
+ ```
59
+
60
+ Registering a job to run if the job group is canceled or fails:
61
+
62
+ ```ruby
63
+ # We can optionally pass options that will be used when queueing the on cancellation job
64
+ job_group = Delayed::JobGroups::JobGroup.create!(on_cancellation_job: MyCancellationJob.new,
65
+ on_cancellation_job_options: { queue: 'general' })
66
+ ```
67
+
68
+ Block and unblock jobs in a job group:
69
+
70
+ ```ruby
71
+ # Construct the JobGroup in a blocked state
72
+ job_group = Delayed::JobGroups::JobGroup.create!(blocked: true)
73
+ job_group.enqueue(MyJob.new('some arg'), queue: 'general')
74
+ job_group.mark_queueing_complete
75
+
76
+ # Do more stuff...
77
+
78
+ # Unblock the JobGroup so its jobs can run
79
+ job_group.unblock
80
+ ```
81
+
82
+ Cancel a job group:
83
+
84
+ ```ruby
85
+ job_group = Delayed::JobGroups::JobGroup.create!
86
+
87
+ # Do more stuff...
88
+
89
+ job_group.cancel
90
+ ```
91
+
92
+ ## Supported Platforms
93
+
94
+ * Only the Delayed Job Active Record backend is supported.
95
+ * Tested with Rails 3.2 and 4.0.
96
+ * Tested with MRI 1.9.3, 2.0.0, 2.1.0 and JRuby in 1.9 mode.
97
+
98
+ ## Contributing
99
+
100
+ 1. Fork it
101
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
102
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
103
+ 4. Push to the branch (`git push origin my-new-feature`)
104
+ 5. Create new Pull Request
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ require 'bundler/setup'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'rspec/core/rake_task'
5
+ RSpec::Core::RakeTask.new(:spec) do |task|
6
+ task.verbose = false
7
+ end
8
+
9
+ task default: :spec
@@ -0,0 +1,37 @@
1
+ # encoding: UTF-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'delayed/job_groups/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'delayed_job_groups_plugin'
8
+ spec.version = Delayed::JobGroups::VERSION
9
+ spec.authors = ['Joel Turkel']
10
+ spec.email = ['jturkel@salsify.com']
11
+ spec.description = %q{Aggregates Delayed::Job jobs into groups with group level management and lifecycle callbacks}
12
+ spec.summary = %q{Delayed::Job job groups plugin}
13
+ spec.homepage = 'https://github.com/salsify/delayed_job_groups_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.add_dependency 'delayed_job', '>= 3.0'
21
+ spec.add_dependency 'delayed_job_active_record', '>= 0.4'
22
+
23
+ spec.add_development_dependency 'activerecord', ENV.fetch('RAILS_VERSION', ['>= 3.2', '< 4.1'])
24
+ spec.add_development_dependency 'coveralls'
25
+ spec.add_development_dependency 'database_cleaner', '>= 1.2'
26
+ spec.add_development_dependency 'rake'
27
+ spec.add_development_dependency 'rspec', '>= 2.14'
28
+ spec.add_development_dependency 'simplecov', '~> 0.7.1'
29
+
30
+ if RUBY_PLATFORM == 'java'
31
+ spec.add_development_dependency 'jdbc-sqlite3'
32
+ spec.add_development_dependency 'activerecord-jdbcsqlite3-adapter'
33
+ else
34
+ spec.add_development_dependency 'sqlite3'
35
+ end
36
+
37
+ end
@@ -0,0 +1,16 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'active_support/version'
4
+ require 'active_record/version'
5
+
6
+ module Delayed
7
+ module JobGroups
8
+ module Compatibility
9
+
10
+ def self.mass_assignment_security_enabled?
11
+ ::ActiveRecord::VERSION::MAJOR < 4 || defined?(::ActiveRecord::MassAssignmentSecurity)
12
+ end
13
+
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,30 @@
1
+ # encoding: UTF-8
2
+
3
+ module Delayed
4
+ module JobGroups
5
+ module JobExtensions
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ if Delayed::JobGroups::Compatibility.mass_assignment_security_enabled?
10
+ attr_accessible :job_group_id, :blocked
11
+ end
12
+
13
+ belongs_to :job_group, class_name: 'Delayed::JobGroups::JobGroup'
14
+
15
+ class << self
16
+
17
+ # Patch ready_to_run to exclude blocked jobs
18
+ def ready_to_run_with_blocked_filtering(worker_name, max_run_time)
19
+ ready_to_run_without_blocked_filtering(worker_name, max_run_time).where(blocked: false)
20
+ end
21
+ alias_method_chain :ready_to_run, :blocked_filtering
22
+ end
23
+ end
24
+
25
+ def in_job_group?
26
+ job_group_id.present?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,99 @@
1
+ # encoding: UTF-8
2
+
3
+ module Delayed
4
+ module JobGroups
5
+ class JobGroup < ActiveRecord::Base
6
+
7
+ self.table_name = "#{ActiveRecord::Base.table_name_prefix}delayed_job_groups"
8
+
9
+ if Delayed::JobGroups::Compatibility.mass_assignment_security_enabled?
10
+ attr_accessible :on_completion_job, :on_completion_job_options, :blocked, :on_cancellation_job,
11
+ :on_cancellation_job_options
12
+ end
13
+
14
+ serialize :on_completion_job
15
+ serialize :on_completion_job_options, Hash
16
+ serialize :on_cancellation_job
17
+ serialize :on_cancellation_job_options, Hash
18
+
19
+ validates :queueing_complete, :blocked, inclusion: [true, false]
20
+
21
+ if ActiveRecord::VERSION::MAJOR >= 4
22
+ has_many :active_jobs, -> { where(failed_at: nil) }, class_name: Job
23
+ else
24
+ has_many :active_jobs, class_name: Job, conditions: {failed_at: nil}
25
+ end
26
+
27
+
28
+ # Only delete dependent jobs that are unlocked so we can determine if there are in-flight jobs
29
+ # for canceled job groups
30
+ if ActiveRecord::VERSION::MAJOR >= 4
31
+ has_many :queued_jobs, -> { where(failed_at: nil, locked_by: nil) }, class_name: Job,
32
+ dependent: :delete_all
33
+ else
34
+ has_many :queued_jobs, class_name: Job, conditions: {failed_at: nil, locked_by: nil},
35
+ dependent: :delete_all
36
+ end
37
+
38
+ def mark_queueing_complete
39
+ with_lock do
40
+ raise 'JobGroup has already completed queueing' if queueing_complete?
41
+ update_column(:queueing_complete, true)
42
+ complete if ready_for_completion?
43
+ end
44
+ end
45
+
46
+ def enqueue(job, options = {})
47
+ options = options.merge(job_group_id: id)
48
+ options[:blocked] = blocked?
49
+ Delayed::Job.enqueue(job, options)
50
+ end
51
+
52
+ def unblock
53
+ return unless blocked?
54
+
55
+ with_lock do
56
+ update_column(:blocked, false)
57
+ active_jobs.update_all(blocked: false)
58
+ complete if ready_for_completion?
59
+ end
60
+ end
61
+
62
+ def cancel
63
+ Delayed::Job.enqueue(on_cancellation_job, on_cancellation_job_options || {}) if on_cancellation_job
64
+ destroy
65
+ end
66
+
67
+ def self.check_for_completion(job_group_id)
68
+ # Optimization to avoid loading and locking the JobGroup when the group
69
+ # still has pending jobs
70
+ return if has_pending_jobs?(job_group_id)
71
+
72
+ transaction do
73
+ # The first completed job to notice the job group's queue count has dropped to
74
+ # zero will queue the job group's completion job and destroy the job group so
75
+ # other jobs need to handle the job group having been destroyed already.
76
+ job_group = where(id: job_group_id).lock(true).first
77
+ job_group.send(:complete) if job_group && job_group.send(:ready_for_completion?)
78
+ end
79
+ end
80
+
81
+ def self.has_pending_jobs?(job_group_ids)
82
+ job_group_ids = Array(job_group_ids)
83
+ return false if job_group_ids.empty?
84
+ Delayed::Job.where(job_group_id: job_group_ids, failed_at: nil).exists?
85
+ end
86
+
87
+ private
88
+
89
+ def ready_for_completion?
90
+ queueing_complete? && !JobGroup.has_pending_jobs?(id) && !blocked?
91
+ end
92
+
93
+ def complete
94
+ Delayed::Job.enqueue(on_completion_job, on_completion_job_options || {}) if on_completion_job
95
+ destroy
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,65 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'delayed_job'
4
+ require 'set'
5
+
6
+ module Delayed
7
+ module JobGroups
8
+ class Plugin < Delayed::Plugin
9
+
10
+ # Delayed job callbacks will be registered in a global Delayed::Lifecycle every time a
11
+ # Delayed::Worker is created. This creates problems in test runs that create
12
+ # multiple workers because we register the callbacks multiple times on the same
13
+ # global Lifecycle.
14
+ def self.callbacks(&block)
15
+ registered_lifecycles = Set.new
16
+ super do |lifecycle|
17
+ if registered_lifecycles.add?(lifecycle.object_id)
18
+ block.call(lifecycle)
19
+ end
20
+ end
21
+ end
22
+
23
+ callbacks do |lifecycle|
24
+ lifecycle.before(:error) do |worker, job|
25
+ # If the job group has been cancelled then don't let the job be retried
26
+ if job.in_job_group? && job_group_cancelled?(job.job_group_id)
27
+ def job.max_attempts
28
+ 1
29
+ end
30
+ end
31
+ end
32
+
33
+ lifecycle.before(:failure) do |worker, job|
34
+ # If a job in the job group fails, then cancel the whole job group.
35
+ # Need to check that the job group is present since another
36
+ # job may have concurrently cancelled it.
37
+ if job.in_job_group? && job.job_group
38
+ job.job_group.cancel
39
+ end
40
+ end
41
+
42
+ lifecycle.after(:perform) do |worker, job|
43
+ # Make sure we only check to see if the job group is complete
44
+ # if the job succeeded
45
+ if job.in_job_group? && job_completed?(job)
46
+ JobGroup.check_for_completion(job.job_group_id)
47
+ end
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def self.job_group_cancelled?(job_group_id)
54
+ !JobGroup.exists?(job_group_id)
55
+ end
56
+
57
+ def self.job_completed?(job)
58
+ # Delayed job will already have marked the job for destruction
59
+ # if it has completed
60
+ job.destroyed?
61
+ end
62
+ end
63
+ end
64
+ end
65
+
@@ -0,0 +1,7 @@
1
+ # encoding: UTF-8
2
+
3
+ module Delayed
4
+ module JobGroups
5
+ VERSION = '0.1.0'
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'active_support'
4
+ require 'active_record'
5
+ require 'delayed_job'
6
+ require 'delayed_job_active_record'
7
+ require 'delayed/job_groups/compatibility'
8
+ require 'delayed/job_groups/job_extensions'
9
+ require 'delayed/job_groups/job_group'
10
+ require 'delayed/job_groups/plugin'
11
+ require 'delayed/job_groups/version'
12
+
13
+ Delayed::Backend::ActiveRecord::Job.send(:include, Delayed::JobGroups::JobExtensions)
14
+
15
+ Delayed::Worker.plugins << Delayed::JobGroups::Plugin
@@ -0,0 +1,22 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/migration'
5
+ require 'rails/generators/active_record'
6
+
7
+ module DelayedJobGroupsPlugin
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include Rails::Generators::Migration
10
+
11
+ self.source_paths << File.join(File.dirname(__FILE__), 'templates')
12
+
13
+ def create_migration_file
14
+ migration_template('migration.rb', 'db/migrate/create_delayed_job_groups.rb')
15
+ end
16
+
17
+ def self.next_migration_number(dirname)
18
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
19
+ end
20
+
21
+ end
22
+ end
@@ -0,0 +1,46 @@
1
+ # encoding: UTF-8
2
+
3
+ class CreateDelayedJobGroups < ActiveRecord::Migration
4
+
5
+ def up
6
+ add_column(:delayed_jobs, :blocked, :boolean, default: false, null: false)
7
+ add_column(:delayed_jobs, :job_group_id, :integer)
8
+ add_index(:delayed_jobs, :job_group_id)
9
+
10
+ if partial_indexes_supported?
11
+ remove_index(:delayed_jobs, name: :delayed_jobs_priority)
12
+ execute <<-SQL
13
+ CREATE INDEX delayed_jobs_priority
14
+ ON delayed_jobs(priority, run_at)
15
+ WHERE failed_at IS NULL AND blocked = FALSE
16
+ SQL
17
+ end
18
+
19
+ create_table(:delayed_job_groups) do |t|
20
+ t.text :on_completion_job
21
+ t.text :on_completion_job_options
22
+ t.text :on_cancellation_job
23
+ t.text :on_cancellation_job_options
24
+ t.boolean :queueing_complete, default: false, null: false
25
+ t.boolean :blocked, default: false, null: false
26
+ end
27
+ end
28
+
29
+ def down
30
+ remove_columns(:delayed_jobs, :blocked, :job_group_id)
31
+
32
+ if partial_indexes_supported?
33
+ execute <<-SQL
34
+ CREATE INDEX delayed_jobs_priority
35
+ ON delayed_jobs(priority, run_at)
36
+ WHERE failed_at IS NULL
37
+ SQL
38
+ end
39
+
40
+ drop_table(:delayed_job_groups)
41
+ end
42
+
43
+ def partial_indexes_supported?
44
+ connection.adapter_name == 'PostgreSQL'
45
+ end
46
+ end
@@ -0,0 +1,3 @@
1
+ sqlite3:
2
+ adapter: sqlite3
3
+ database: ":memory:"
data/spec/db/schema.rb ADDED
@@ -0,0 +1,29 @@
1
+ # encoding: UTF-8
2
+
3
+ ActiveRecord::Schema.define(:version => 0) do
4
+
5
+ create_table(:delayed_jobs, force: true) do |t|
6
+ t.integer :priority, default: 0
7
+ t.integer :attempts, default: 0
8
+ t.text :handler
9
+ t.text :last_error
10
+ t.datetime :run_at
11
+ t.datetime :locked_at
12
+ t.datetime :failed_at
13
+ t.string :locked_by
14
+ t.string :queue
15
+ t.timestamps
16
+ t.boolean :blocked, default: false, null: false
17
+ t.integer :job_group_id
18
+ end
19
+ add_index(:delayed_jobs, :job_group_id)
20
+
21
+ create_table(:delayed_job_groups, force: true) do |t|
22
+ t.text :on_completion_job
23
+ t.text :on_completion_job_options
24
+ t.text :on_cancellation_job
25
+ t.text :on_cancellation_job_options
26
+ t.boolean :queueing_complete, default: false, null: false
27
+ t.boolean :blocked, default: false, null: false
28
+ end
29
+ end
@@ -0,0 +1,223 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Delayed::JobGroups::JobGroup do
6
+
7
+ let(:blocked) { false }
8
+ let(:on_completion_job) { 'dummy on completion job' }
9
+ let(:on_completion_job_options) do
10
+ { foo: 'bar' }
11
+ end
12
+ let(:current_time) { Time.utc(2013) }
13
+
14
+ subject(:job_group) do
15
+ Delayed::JobGroups::JobGroup.create!(on_completion_job: on_completion_job,
16
+ on_completion_job_options: on_completion_job_options,
17
+ blocked: blocked)
18
+ end
19
+
20
+ before do
21
+ Time.stub(:now).and_return(current_time)
22
+ Delayed::Job.stub(:enqueue)
23
+ end
24
+
25
+ shared_examples "the job group was completed" do
26
+ it "queues the completion job" do
27
+ Delayed::Job.should have_received(:enqueue).with(on_completion_job, on_completion_job_options)
28
+ end
29
+
30
+ it "destroys the job group" do
31
+ job_group.should have_been_destroyed
32
+ end
33
+ end
34
+
35
+ shared_examples "the job group was not completed" do
36
+ it "does not queue the completion job" do
37
+ Delayed::Job.should_not have_received(:enqueue)
38
+ end
39
+
40
+ it "does not destroy the job group" do
41
+ job_group.should_not have_been_destroyed
42
+ end
43
+ end
44
+
45
+ describe "#mark_queueing_complete" do
46
+
47
+ context "when no jobs exist" do
48
+ before { job_group.mark_queueing_complete }
49
+
50
+ it { should be_queueing_complete }
51
+ it_behaves_like "the job group was completed"
52
+ end
53
+
54
+ context "when no jobs exist but the job group is blocked" do
55
+ let(:blocked) { true }
56
+ before { job_group.mark_queueing_complete }
57
+
58
+ it { should be_queueing_complete }
59
+ it_behaves_like "the job group was not completed"
60
+ end
61
+
62
+ context "when active jobs exist" do
63
+ before do
64
+ Delayed::Job.create!(job_group_id: job_group.id)
65
+ job_group.mark_queueing_complete
66
+ end
67
+
68
+ it { should be_queueing_complete }
69
+ it_behaves_like "the job group was not completed"
70
+ end
71
+ end
72
+
73
+ describe ".check_for_completion" do
74
+ let!(:job) { Delayed::Job.create!(job_group_id: job_group.id) }
75
+
76
+ before do
77
+ job_group.mark_queueing_complete
78
+ end
79
+
80
+ shared_context "complete job and check job group complete" do
81
+ before do
82
+ job.destroy
83
+ Delayed::JobGroups::JobGroup.check_for_completion(job_group.id)
84
+ end
85
+ end
86
+
87
+ context "when no jobs exist" do
88
+ include_context "complete job and check job group complete"
89
+
90
+ it_behaves_like "the job group was completed"
91
+ end
92
+
93
+ context "when active jobs exist" do
94
+ before do
95
+ Delayed::JobGroups::JobGroup.check_for_completion(job_group.id)
96
+ end
97
+
98
+ it_behaves_like "the job group was not completed"
99
+ end
100
+
101
+ context "when on failed jobs exist" do
102
+ before do
103
+ job.update_attributes!(failed_at: Time.now)
104
+ Delayed::JobGroups::JobGroup.check_for_completion(job_group.id)
105
+ end
106
+
107
+ it_behaves_like "the job group was completed"
108
+ end
109
+
110
+ context "when there are no on_completion_job_options" do
111
+ let(:on_completion_job_options) { nil }
112
+
113
+ include_context "complete job and check job group complete"
114
+
115
+ it "queues the completion job with empty options" do
116
+ Delayed::Job.should have_received(:enqueue).with(on_completion_job, {})
117
+ end
118
+
119
+ it "destroys the job group" do
120
+ job_group.should have_been_destroyed
121
+ end
122
+ end
123
+
124
+ context "when there is no on_completion_job" do
125
+ let(:on_completion_job) { nil }
126
+
127
+ include_context "complete job and check job group complete"
128
+
129
+ it "doesn't queues the non-existent completion job" do
130
+ Delayed::Job.should_not have_received(:enqueue)
131
+ end
132
+
133
+ it "destroys the job group" do
134
+ job_group.should have_been_destroyed
135
+ end
136
+ end
137
+ end
138
+
139
+ describe "#enqueue" do
140
+ let(:job) { 'dummy job' }
141
+
142
+ before do
143
+ job_group.enqueue(job)
144
+ end
145
+
146
+ shared_examples "it enqueues the job in the correct blocked state" do
147
+ it "enqueues the job in the same blocked state as the job group" do
148
+ Delayed::Job.should have_received(:enqueue).with(job, job_group_id: job_group.id, blocked: blocked)
149
+ end
150
+ end
151
+
152
+ it_behaves_like "it enqueues the job in the correct blocked state"
153
+
154
+ context "when the job_group is blocked" do
155
+ let(:blocked) { true }
156
+
157
+ it_behaves_like "it enqueues the job in the correct blocked state"
158
+ end
159
+ end
160
+
161
+ describe "#unblock" do
162
+
163
+ context "when the JobGroup isn't blocked" do
164
+ before do
165
+ job_group.unblock
166
+ end
167
+
168
+ its(:blocked?) { should be_false }
169
+ end
170
+
171
+ context "when the JobGroup is blocked" do
172
+ let(:blocked) { true }
173
+
174
+ context "when there are pending jobs" do
175
+ let!(:job) { Delayed::Job.create!(job_group_id: job_group.id, blocked: true) }
176
+
177
+ before do
178
+ job_group.mark_queueing_complete
179
+ job_group.unblock
180
+ end
181
+
182
+ its(:blocked?) { should be_false }
183
+
184
+ it "sets the job's run_at to the current time" do
185
+ job.reload.run_at.should eq current_time
186
+ end
187
+
188
+ it_behaves_like "the job group was not completed"
189
+ end
190
+
191
+ describe "when there are no pending jobs" do
192
+ before do
193
+ job_group.mark_queueing_complete
194
+ job_group.unblock
195
+ end
196
+
197
+ its(:blocked?) { should be_false }
198
+ it_behaves_like "the job group was completed"
199
+ end
200
+ end
201
+ end
202
+
203
+ describe "#cancel" do
204
+ let!(:queued_job) { Delayed::Job.create!(job_group_id: job_group.id) }
205
+ let!(:running_job) { Delayed::Job.create!(job_group_id: job_group.id, locked_at: Time.now, locked_by: 'test') }
206
+
207
+ before do
208
+ job_group.cancel
209
+ end
210
+
211
+ it "destroys the job group" do
212
+ job_group.should have_been_destroyed
213
+ end
214
+
215
+ it "destroys queued jobs" do
216
+ queued_job.should have_been_destroyed
217
+ end
218
+
219
+ it "does not destroy running jobs" do
220
+ running_job.should_not have_been_destroyed
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,255 @@
1
+ # encoding: UTF-8
2
+
3
+ require 'spec_helper'
4
+
5
+ describe Delayed::JobGroups::Plugin do
6
+
7
+ before(:all) do
8
+ @old_max_attempts = Delayed::Worker.max_attempts
9
+ Delayed::Worker.max_attempts = 2
10
+ end
11
+
12
+ after(:all) do
13
+ Delayed::Worker.max_attempts = @old_max_attempts
14
+ end
15
+
16
+ before(:each) do
17
+ CompletionJob.invoked = false
18
+ CancellationJob.invoked = false
19
+ end
20
+
21
+ let!(:job_group) { Delayed::JobGroups::JobGroup.create!(on_completion_job: CompletionJob.new) }
22
+
23
+ it "runs the completion job after completing other jobs" do
24
+ job_group.enqueue(NoOpJob.new)
25
+ job_group.enqueue(NoOpJob.new)
26
+ job_group.mark_queueing_complete
27
+ job_group_count.should eq 1
28
+ queued_job_count.should eq 2
29
+
30
+ # Run our first job
31
+ Delayed::Worker.new.work_off(1)
32
+ CompletionJob.invoked.should be_false
33
+ job_group_count.should eq 1
34
+ queued_job_count.should eq 1
35
+
36
+ # Run our second job which should enqueue the completion job
37
+ Delayed::Worker.new.work_off(1)
38
+ CompletionJob.invoked.should be_false
39
+ job_group_count.should eq 0
40
+ queued_job_count.should eq 1
41
+
42
+ # Now we should run the completion job
43
+ Delayed::Worker.new.work_off(1)
44
+ CompletionJob.invoked.should be_true
45
+ queued_job_count.should eq 0
46
+ end
47
+
48
+ it "only runs the completion job after queueing is completed" do
49
+ job_group.enqueue(NoOpJob.new)
50
+ job_group.enqueue(NoOpJob.new)
51
+ job_group_count.should eq 1
52
+ queued_job_count.should eq 2
53
+
54
+ # Run our first job
55
+ Delayed::Worker.new.work_off(1)
56
+ CompletionJob.invoked.should be_false
57
+ job_group_count.should eq 1
58
+ queued_job_count.should eq 1
59
+
60
+ # Run our second job
61
+ Delayed::Worker.new.work_off(1)
62
+ CompletionJob.invoked.should be_false
63
+ job_group_count.should eq 1
64
+ queued_job_count.should eq 0
65
+
66
+ # Mark queueing complete which should queue the completion job
67
+ job_group.mark_queueing_complete
68
+ job_group_count.should eq 0
69
+ queued_job_count.should eq 1
70
+
71
+ # Now we should run the completion job
72
+ Delayed::Worker.new.work_off(1)
73
+ CompletionJob.invoked.should be_true
74
+ queued_job_count.should eq 0
75
+ end
76
+
77
+ it "cancels the job group if a job fails" do
78
+ Delayed::Worker.max_attempts = 1
79
+
80
+ job_group.enqueue(FailingJob.new)
81
+ job_group.enqueue(NoOpJob.new)
82
+ job_group.mark_queueing_complete
83
+ queued_job_count.should eq 2
84
+ job_group_count.should eq 1
85
+
86
+ # Run the job which should fail and cancel the JobGroup
87
+ Delayed::Worker.new.work_off(1)
88
+ CompletionJob.invoked.should be_false
89
+ failed_job_count.should eq 1
90
+ queued_job_count.should eq 0
91
+ job_group_count.should eq 0
92
+ end
93
+
94
+ it "doesn't retry failed jobs if the job group has been canceled" do
95
+ job_group.cancel
96
+ Delayed::Job.enqueue(FailingJob.new, job_group_id: job_group.id)
97
+ queued_job_count.should eq 1
98
+
99
+ # Run the job which should fail and should not queue a retry
100
+ Delayed::Worker.new.work_off(1)
101
+ failed_job_count.should eq 1
102
+ queued_job_count.should eq 0
103
+ end
104
+
105
+ it "doesn't run jobs until they're unblocked" do
106
+ job_group.blocked = true
107
+ job_group.save!
108
+
109
+ job_group.enqueue(NoOpJob.new)
110
+ job_group.enqueue(NoOpJob.new)
111
+ job_group.mark_queueing_complete
112
+ Delayed::Job.count.should eq 2
113
+
114
+ # No jobs should run because they're blocked
115
+ (successes, failures) = Delayed::Worker.new.work_off
116
+ successes.should eq 0
117
+ failures.should eq 0
118
+ Delayed::Job.count.should eq 2
119
+
120
+ job_group.unblock
121
+
122
+ # Run our first job
123
+ Delayed::Worker.new.work_off(1)
124
+ CompletionJob.invoked.should be_false
125
+ job_group_count.should eq 1
126
+ Delayed::Job.count.should eq 1
127
+
128
+ # Run our second job which should enqueue the completion job
129
+ Delayed::Worker.new.work_off(1)
130
+ CompletionJob.invoked.should be_false
131
+ job_group_count.should eq 0
132
+ Delayed::Job.count.should eq 1
133
+
134
+ # Now we should run the completion job
135
+ Delayed::Worker.new.work_off(1)
136
+ CompletionJob.invoked.should be_true
137
+ Delayed::Job.count.should eq 0
138
+ end
139
+
140
+ context "when a cancellation job is provided" do
141
+ let!(:job_group) do
142
+ Delayed::JobGroups::JobGroup.create!(on_completion_job: CompletionJob.new,
143
+ on_cancellation_job: CancellationJob.new)
144
+ end
145
+
146
+ it "runs the cancellation job after a job error causes cancellation" do
147
+ Delayed::Worker.max_attempts = 1
148
+
149
+ job_group.enqueue(FailingJob.new)
150
+ job_group.enqueue(NoOpJob.new)
151
+ job_group.mark_queueing_complete
152
+ queued_job_count.should eq 2
153
+ job_group_count.should eq 1
154
+
155
+ # Run the job which should fail and cancel the JobGroup
156
+ Delayed::Worker.new.work_off(1)
157
+ CompletionJob.invoked.should be_false
158
+ CancellationJob.invoked.should be_false
159
+ failed_job_count.should eq 1
160
+
161
+ queued_job_count.should eq 1
162
+ job_group_count.should eq 0
163
+
164
+ # Now we should run the cancellation job
165
+ Delayed::Worker.new.work_off(1)
166
+ CompletionJob.invoked.should be_false
167
+ CancellationJob.invoked.should be_true
168
+ queued_job_count.should eq 0
169
+ end
170
+
171
+ it "runs the cancellation job after the job group is cancelled" do
172
+ job_group.enqueue(NoOpJob.new)
173
+ job_group.enqueue(FailingJob.new)
174
+ job_group.mark_queueing_complete
175
+ job_group.cancel
176
+
177
+ #cancellation job should be queued
178
+ queued_job_count.should eq 1
179
+ CancellationJob.invoked.should be_false
180
+
181
+ # Run the cancellation job
182
+ Delayed::Worker.new.work_off(1)
183
+ CancellationJob.invoked.should be_true
184
+ queued_job_count.should eq 0
185
+ end
186
+ end
187
+
188
+ context "when a no completion job is provided" do
189
+ let!(:job_group) { Delayed::JobGroups::JobGroup.create! }
190
+
191
+ it "doesn't queue a non-existent completion job" do
192
+ job_group.enqueue(NoOpJob.new)
193
+ job_group.enqueue(NoOpJob.new)
194
+ job_group.mark_queueing_complete
195
+ job_group_count.should eq 1
196
+ queued_job_count.should eq 2
197
+ failed_job_count.should eq 0
198
+
199
+ # Run our first job
200
+ Delayed::Worker.new.work_off(1)
201
+ job_group_count.should eq 1
202
+ queued_job_count.should eq 1
203
+ failed_job_count.should eq 0
204
+
205
+ # Run our second job which should delete the job group
206
+ Delayed::Worker.new.work_off(1)
207
+ job_group_count.should eq 0
208
+ queued_job_count.should eq 0
209
+ failed_job_count.should eq 0
210
+ end
211
+ end
212
+
213
+ class FailingJob
214
+
215
+ def perform
216
+ raise 'Test failure'
217
+ end
218
+
219
+ end
220
+
221
+ class NoOpJob
222
+
223
+ def perform
224
+
225
+ end
226
+ end
227
+
228
+ class CompletionJob
229
+ cattr_accessor :invoked
230
+
231
+ def perform
232
+ CompletionJob.invoked = true
233
+ end
234
+ end
235
+
236
+ class CancellationJob
237
+ cattr_accessor :invoked
238
+
239
+ def perform
240
+ CancellationJob.invoked = true
241
+ end
242
+ end
243
+
244
+ def job_group_count
245
+ Delayed::JobGroups::JobGroup.count
246
+ end
247
+
248
+ def queued_job_count
249
+ Delayed::Job.where(failed_at: nil).count
250
+ end
251
+
252
+ def failed_job_count
253
+ Delayed::Job.where('failed_at IS NOT NULL').count
254
+ end
255
+ end
@@ -0,0 +1,55 @@
1
+ # encoding: UTF-8
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_groups_plugin'
16
+ require 'yaml'
17
+
18
+ spec_dir = File.dirname(__FILE__)
19
+ Dir["#{spec_dir}/support/**/*.rb"].sort.each { |f| require f }
20
+
21
+ FileUtils.makedirs('log')
22
+
23
+ Delayed::Worker.read_ahead = 1
24
+ Delayed::Worker.destroy_failed_jobs = false
25
+
26
+ Delayed::Worker.logger = Logger.new('log/test.log')
27
+ Delayed::Worker.logger.level = Logger::DEBUG
28
+ ActiveRecord::Base.logger = Delayed::Worker.logger
29
+ ActiveRecord::Migration.verbose = false
30
+
31
+ db_adapter = ENV.fetch('ADAPTER', 'sqlite3')
32
+ config = YAML.load(File.read('spec/db/database.yml'))
33
+ ActiveRecord::Base.establish_connection(config[db_adapter])
34
+ require 'db/schema'
35
+
36
+ RSpec.configure do |config|
37
+ config.treat_symbols_as_metadata_keys_with_true_values = true
38
+ config.order = 'random'
39
+
40
+ config.before(:suite) do
41
+ DatabaseCleaner.clean_with(:truncation)
42
+ end
43
+
44
+ config.before(:each) do
45
+ DatabaseCleaner.strategy = :transaction
46
+ end
47
+
48
+ config.before(:each) do
49
+ DatabaseCleaner.start
50
+ end
51
+
52
+ config.after(:each) do
53
+ DatabaseCleaner.clean
54
+ end
55
+ 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_for_should do |actual|
13
+ "expected #{actual.class}(id: #{actual.id}) to have been destroyed"
14
+ end
15
+ end
metadata ADDED
@@ -0,0 +1,205 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: delayed_job_groups_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: 2014-02-23 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: '3.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - '>='
25
+ - !ruby/object:Gem::Version
26
+ version: '3.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: '0.4'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - '>='
39
+ - !ruby/object:Gem::Version
40
+ version: '0.4'
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.1'
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.1'
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: '2.14'
110
+ type: :development
111
+ prerelease: false
112
+ version_requirements: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - '>='
115
+ - !ruby/object:Gem::Version
116
+ version: '2.14'
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: sqlite3
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
+ description: Aggregates Delayed::Job jobs into groups with group level management
146
+ and lifecycle callbacks
147
+ email:
148
+ - jturkel@salsify.com
149
+ executables: []
150
+ extensions: []
151
+ extra_rdoc_files: []
152
+ files:
153
+ - .gitignore
154
+ - .rspec
155
+ - .travis.yml
156
+ - Gemfile
157
+ - LICENSE.txt
158
+ - README.md
159
+ - Rakefile
160
+ - delayed_job_groups.gemspec
161
+ - lib/delayed/job_groups/compatibility.rb
162
+ - lib/delayed/job_groups/job_extensions.rb
163
+ - lib/delayed/job_groups/job_group.rb
164
+ - lib/delayed/job_groups/plugin.rb
165
+ - lib/delayed/job_groups/version.rb
166
+ - lib/delayed_job_groups_plugin.rb
167
+ - lib/generators/delayed_job_groups_plugin/install_generator.rb
168
+ - lib/generators/delayed_job_groups_plugin/templates/migration.rb
169
+ - spec/db/database.yml
170
+ - spec/db/schema.rb
171
+ - spec/delayed/job_groups/job_group_spec.rb
172
+ - spec/delayed/job_groups/plugin_spec.rb
173
+ - spec/spec_helper.rb
174
+ - spec/support/destroyed_model.rb
175
+ homepage: https://github.com/salsify/delayed_job_groups_plugin
176
+ licenses:
177
+ - MIT
178
+ metadata: {}
179
+ post_install_message:
180
+ rdoc_options: []
181
+ require_paths:
182
+ - lib
183
+ required_ruby_version: !ruby/object:Gem::Requirement
184
+ requirements:
185
+ - - '>='
186
+ - !ruby/object:Gem::Version
187
+ version: '0'
188
+ required_rubygems_version: !ruby/object:Gem::Requirement
189
+ requirements:
190
+ - - '>='
191
+ - !ruby/object:Gem::Version
192
+ version: '0'
193
+ requirements: []
194
+ rubyforge_project:
195
+ rubygems_version: 2.0.14
196
+ signing_key:
197
+ specification_version: 4
198
+ summary: Delayed::Job job groups plugin
199
+ test_files:
200
+ - spec/db/database.yml
201
+ - spec/db/schema.rb
202
+ - spec/delayed/job_groups/job_group_spec.rb
203
+ - spec/delayed/job_groups/plugin_spec.rb
204
+ - spec/spec_helper.rb
205
+ - spec/support/destroyed_model.rb