Skip to content

Commit c8ec79d

Browse files
authored
Merge pull request #39 from salsify/ms/pimcore-3160
* Move on cancellation logic to after failure hook. * Make delayed job group plugin incompatible with DelayedJob option `destroy_failed_jobs`. * Wrap job group cancellation with lock to prevent multiple on cancellation jobs from being enqueued.
2 parents b930b0f + 525a00d commit c8ec79d

File tree

11 files changed

+93
-8
lines changed

11 files changed

+93
-8
lines changed

Appraisals

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ appraise 'rails-7.0' do
44
gem 'activerecord', '~> 7.0.8'
55
gem 'activesupport', '~> 7.0.8'
66
gem 'sqlite3', '~> 1.7'
7+
gem 'concurrent-ruby', '1.3.4'
78
end
89

910
appraise 'rails-7.1' do

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 0.13.0
4+
- Moves `on_cancellation` logic from the before delayed job lifecycle hook to the after hook.
5+
- Gem will now fail to load if `Delayed::Worker.destroy_failed_jobs` is set to true.
6+
- Wrapped the job group cancel hook in a lock to prevent concurrently failing jobs from enqueueing
7+
multiple on cancellation jobs.
8+
39
## 0.12.0
410
- Add support for Rails 8.0.
511
- Drop support for Rails 6.1

gemfiles/rails_7.0.gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ source "https://rubygems.org"
55
gem "activerecord", "~> 7.0.8"
66
gem "activesupport", "~> 7.0.8"
77
gem "sqlite3", "~> 1.7"
8+
gem 'concurrent-ruby', '1.3.4'
89

910
gemspec path: "../"

lib/delayed/job_groups/errors.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# frozen_string_literal: true
2+
3+
module Delayed
4+
module JobGroups
5+
ConfigurationError = Class.new(StandardError)
6+
7+
class IncompatibleWithDelayedJobError < ConfigurationError
8+
DEFAULT_MESSAGE = 'DelayedJobGroupsPlugin is incompatible with Delayed::Job ' \
9+
'when `destroy_failed_jobs` is set to `true`'
10+
11+
def initialize(msg = DEFAULT_MESSAGE)
12+
super(msg)
13+
end
14+
end
15+
end
16+
end

lib/delayed/job_groups/job_group.rb

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,18 @@ def unblock
6262
end
6363

6464
def cancel
65-
Delayed::Job.enqueue(on_cancellation_job, on_cancellation_job_options || {}) if on_cancellation_job
66-
destroy
65+
with_lock do
66+
return if destroyed?
67+
68+
Delayed::Job.enqueue(on_cancellation_job, on_cancellation_job_options || {}) if on_cancellation_job
69+
destroy
70+
end
71+
rescue ActiveRecord::RecordNotFound
72+
# The first failing job to attempt cancelling the job group will enqueue the
73+
# on cancellation job and destroy the group. Any other concurrently failing job
74+
# in the same group can therefore silently return if the job group has already
75+
# been destroyed.
76+
nil
6777
end
6878

6979
def check_for_completion(skip_pending_jobs_check: false)

lib/delayed/job_groups/plugin.rb

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ def job.max_attempts
1717
end
1818
end
1919

20-
lifecycle.before(:failure) do |_worker, job|
20+
# In order to allow individual jobs in the JobGroup to perform cleanup activities upon
21+
# job failure, a JobGroups on cancellation job must be enqueued in the after failure
22+
# delayed job lifecycle hook. If both were to be performed in the same hook, the job's
23+
# failure hook and the on cancellation job may run at the same time since hook execution
24+
# order is never guaranteed. This is particular important if the on cancellation job
25+
# depends on state set in the failure hook of an individual job.
26+
lifecycle.after(:failure) do |_worker, job|
2127
# If a job in the job group fails, then cancel the whole job group.
2228
# Need to check that the job group is present since another
2329
# job may have concurrently cancelled it.

lib/delayed/job_groups/railtie.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ module Delayed
44
module JobGroups
55
class Railtie < ::Rails::Railtie
66
config.after_initialize do
7+
8+
# On cancellation checks are performed in the after failure delayed job lifecycle, however
9+
# https://github.com/collectiveidea/delayed_job/blob/master/lib/delayed/worker.rb#L268 may
10+
# delete jobs before the hook runs. This could cause a successful job in the same group to
11+
# complete the group instead of the group being cancelled. Therefore, we must ensure that
12+
# the Delayed::Worker.destroy_failed_jobs is set to false, guaranteeing that the group is
13+
# never empty if failure occurs.
14+
raise Delayed::JobGroups::IncompatibleWithDelayedJobError if Delayed::Worker.destroy_failed_jobs
15+
716
Delayed::Backend::ActiveRecord::Job.include(Delayed::JobGroups::JobExtensions)
817
end
918
end

lib/delayed/job_groups/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
module Delayed
44
module JobGroups
5-
VERSION = '0.12.0'
5+
VERSION = '0.13.0'
66
end
77
end

lib/delayed_job_groups_plugin.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
require 'active_record'
55
require 'delayed_job'
66
require 'delayed_job_active_record'
7+
require 'delayed/job_groups/errors'
78
require 'delayed/job_groups/compatibility'
89
require 'delayed/job_groups/complete_stuck_job_groups_job'
910
require 'delayed/job_groups/job_extensions'
@@ -18,6 +19,7 @@
1819
else
1920
# Do the same as in the railtie
2021
Delayed::Backend::ActiveRecord::Job.include(Delayed::JobGroups::JobExtensions)
22+
raise Delayed::JobGroups::IncompatibleWithDelayedJobError if Delayed::Worker.destroy_failed_jobs
2123
end
2224

2325
Delayed::Worker.plugins << Delayed::JobGroups::Plugin

spec/delayed/job_groups/job_group_spec.rb

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,11 +299,28 @@
299299
end
300300

301301
describe "#cancel" do
302+
subject(:job_group) do
303+
create(
304+
:job_group,
305+
on_completion_job: on_completion_job,
306+
on_completion_job_options: on_completion_job_options,
307+
on_cancellation_job: on_cancellation_job,
308+
on_cancellation_job_options: on_cancellation_job_options,
309+
blocked: blocked
310+
)
311+
end
312+
302313
let!(:queued_job) { Delayed::Job.create!(job_group_id: job_group.id) }
303314
let!(:running_job) { Delayed::Job.create!(job_group_id: job_group.id, locked_at: Time.now, locked_by: 'test') }
315+
let(:before_hook) {}
316+
let(:on_cancellation_job) { 'dummy job' }
317+
let(:on_cancellation_job_options) do
318+
{ foo: 'bar' }
319+
end
320+
let(:cancel) { true }
304321

305322
before do
306-
job_group.cancel
323+
job_group.cancel if cancel
307324
end
308325

309326
it "destroys the job group" do
@@ -317,6 +334,21 @@
317334
it "does not destroy running jobs" do
318335
expect(running_job).not_to have_been_destroyed
319336
end
337+
338+
context "when already cancelled" do
339+
let(:cancel) { false }
340+
341+
it "skips cancel block if already cancelled" do
342+
other = Delayed::JobGroups::JobGroup.find(job_group.id)
343+
job_group.cancel
344+
345+
other.cancel
346+
other.cancel
347+
348+
expect(Delayed::Job)
349+
.to have_received(:enqueue).with(on_cancellation_job, on_cancellation_job_options).once
350+
end
351+
end
320352
end
321353

322354
describe "#failure_cancels_group?" do

0 commit comments

Comments
 (0)