Skip to content

Commit afa0198

Browse files
committed
Fix ActiveJob::EnqueueAfterTransactionCommit API
Fix: rails#51426 (comment) `perform_later` is supposed to return the Job instance on success, and `false` on error. When the `enqueue` is automatically delayed, it's of course impossible to predict if the actual queueing will succeed, but for backward compatibility reasons, it's best to assume it will. If necessary, you can hold onto the job instance and check for `#successfully_enqueued?` after the transaction has completed.
1 parent 61d78da commit afa0198

File tree

7 files changed

+185
-24
lines changed

7 files changed

+185
-24
lines changed

activejob/lib/active_job/enqueue_after_transaction_commit.rb

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,11 @@ module EnqueueAfterTransactionCommit # :nodoc:
1616
# - `:never` forces the job to be queued immediately.
1717
# - `:default` lets the queue adapter define the behavior (recommended).
1818
class_attribute :enqueue_after_transaction_commit, instance_accessor: false, instance_predicate: false, default: :never
19+
end
1920

20-
around_enqueue do |job, block|
21-
after_transaction = case job.class.enqueue_after_transaction_commit
21+
private
22+
def raw_enqueue
23+
after_transaction = case self.class.enqueue_after_transaction_commit
2224
when :always
2325
true
2426
when :never
@@ -28,11 +30,15 @@ module EnqueueAfterTransactionCommit # :nodoc:
2830
end
2931

3032
if after_transaction
31-
ActiveRecord.after_all_transactions_commit(&block)
33+
self.successfully_enqueued = true
34+
ActiveRecord.after_all_transactions_commit do
35+
self.successfully_enqueued = false
36+
super
37+
end
38+
self
3239
else
33-
block.call
40+
super
3441
end
3542
end
36-
end
3743
end
3844
end

activejob/lib/active_job/enqueuing.rb

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,17 @@ module ClassMethods
5050
# custom serializers.
5151
#
5252
# Returns an instance of the job class queued with arguments available in
53-
# Job#arguments or false if the enqueue did not succeed.
53+
# Job#arguments or +false+ if the enqueue did not succeed.
5454
#
5555
# After the attempted enqueue, the job will be yielded to an optional block.
5656
#
5757
# If Active Job is used conjointly with Active Record, and #perform_later is called
5858
# inside an Active Record transaction, then the enqueue is implicitly deferred to after
59-
# the transaction is committed, or droped if it's rolled back. This behavior can
60-
# be changed on a per job basis:
59+
# the transaction is committed, or droped if it's rolled back. In such case #perform_later
60+
# will return the job instance like if it was successfully enqueued, but will still return
61+
# +false+ if a callback prevented the job from being enqueued.
62+
#
63+
# This behavior can be changed on a per job basis:
6164
#
6265
# class NotificationJob < ApplicationJob
6366
# self.enqueue_after_transaction_commit = false
@@ -98,6 +101,18 @@ def enqueue(options = {})
98101
self.successfully_enqueued = false
99102

100103
run_callbacks :enqueue do
104+
raw_enqueue
105+
end
106+
107+
if successfully_enqueued?
108+
self
109+
else
110+
false
111+
end
112+
end
113+
114+
private
115+
def raw_enqueue
101116
if scheduled_at
102117
queue_adapter.enqueue_at self, scheduled_at.to_f
103118
else
@@ -108,12 +123,5 @@ def enqueue(options = {})
108123
rescue EnqueueError => e
109124
self.enqueue_error = e
110125
end
111-
112-
if successfully_enqueued?
113-
self
114-
else
115-
false
116-
end
117-
end
118126
end
119127
end
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# frozen_string_literal: true
2+
3+
require "helper"
4+
require "jobs/enqueue_error_job"
5+
6+
class EnqueueAfterTransactionCommitTest < ActiveSupport::TestCase
7+
class FakeActiveRecord
8+
attr_reader :calls
9+
10+
def initialize(should_yield = true)
11+
@calls = 0
12+
@yield = should_yield
13+
@callbacks = []
14+
end
15+
16+
def after_all_transactions_commit(&block)
17+
@calls += 1
18+
if @yield
19+
yield
20+
else
21+
@callbacks << block
22+
end
23+
end
24+
25+
def run_after_commit_callbacks
26+
callbacks, @callbacks = @callbacks, []
27+
callbacks.each(&:call)
28+
end
29+
end
30+
31+
class EnqueueAfterCommitJob < ActiveJob::Base
32+
self.enqueue_after_transaction_commit = :always
33+
34+
def perform
35+
# noop
36+
end
37+
end
38+
39+
class ErrorEnqueueAfterCommitJob < EnqueueErrorJob
40+
class EnqueueErrorAdapter
41+
def enqueue_after_transaction_commit?
42+
true
43+
end
44+
45+
def enqueue(...)
46+
raise ActiveJob::EnqueueError, "There was an error enqueuing the job"
47+
end
48+
49+
def enqueue_at(...)
50+
raise ActiveJob::EnqueueError, "There was an error enqueuing the job"
51+
end
52+
end
53+
54+
self.queue_adapter = EnqueueErrorAdapter.new
55+
self.enqueue_after_transaction_commit = :always
56+
57+
def perform
58+
# noop
59+
end
60+
end
61+
62+
test "#perform_later wait for transactions to complete before enqueuing the job" do
63+
fake_active_record = FakeActiveRecord.new
64+
stub_const(Object, :ActiveRecord, fake_active_record, exists: false) do
65+
assert_difference -> { fake_active_record.calls }, +1 do
66+
EnqueueAfterCommitJob.perform_later
67+
end
68+
end
69+
end
70+
71+
test "#perform_later returns the Job instance even if it's delayed by `after_all_transactions_commit`" do
72+
fake_active_record = FakeActiveRecord.new(false)
73+
stub_const(Object, :ActiveRecord, fake_active_record, exists: false) do
74+
job = EnqueueAfterCommitJob.perform_later
75+
assert_instance_of EnqueueAfterCommitJob, job
76+
assert_predicate job, :successfully_enqueued?
77+
end
78+
end
79+
80+
test "#perform_later yields the enqueued Job instance even if it's delayed by `after_all_transactions_commit`" do
81+
fake_active_record = FakeActiveRecord.new(false)
82+
stub_const(Object, :ActiveRecord, fake_active_record, exists: false) do
83+
called = false
84+
job = EnqueueAfterCommitJob.perform_later do |yielded_job|
85+
called = true
86+
assert_instance_of EnqueueAfterCommitJob, yielded_job
87+
end
88+
assert called, "#perform_later yielded the job"
89+
assert_instance_of EnqueueAfterCommitJob, job
90+
assert_predicate job, :successfully_enqueued?
91+
end
92+
end
93+
94+
test "#perform_later assumes successful enqueue, but update status later" do
95+
fake_active_record = FakeActiveRecord.new(false)
96+
stub_const(Object, :ActiveRecord, fake_active_record, exists: false) do
97+
job = ErrorEnqueueAfterCommitJob.perform_later
98+
assert_instance_of ErrorEnqueueAfterCommitJob, job
99+
assert_predicate job, :successfully_enqueued?
100+
101+
fake_active_record.run_after_commit_callbacks
102+
assert_not_predicate job, :successfully_enqueued?
103+
end
104+
end
105+
end

activejob/test/helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,5 @@ def adapter_is?(*adapter_class_symbols)
2323
end
2424

2525
require_relative "../../tools/test_common"
26+
27+
ActiveJob::Base.include(ActiveJob::EnqueueAfterTransactionCommit)

activesupport/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* `stub_const` now accepts a `exists: false` parameter to allow stubbing missing constants.
2+
3+
*Jean Boussier*
4+
15
* Make ActiveSupport::BacktraceCleaner copy filters and silencers on dup and clone
26

37
Previously the copy would still share the internal silencers and filters array,

activesupport/lib/active_support/testing/constant_stubbing.rb

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,39 @@ module ConstantStubbing
1515
# Using this method rather than forcing <tt>World::List::Import::LARGE_IMPORT_THRESHOLD = 5000</tt> prevents
1616
# warnings from being thrown, and ensures that the old value is returned after the test has completed.
1717
#
18+
# If the constant doesn't already exists, but you need it set for the duration of the block
19+
# you can do so by passing `exists: false`.
20+
#
21+
# stub_const(object, :SOME_CONST, 1, exists: false) do
22+
# assert_equal 1, SOME_CONST
23+
# end
24+
#
1825
# Note: Stubbing a const will stub it across all threads. So if you have concurrent threads
1926
# (like separate test suites running in parallel) that all depend on the same constant, it's possible
2027
# divergent stubbing will trample on each other.
21-
def stub_const(mod, constant, new_value)
22-
old_value = mod.const_get(constant, false)
23-
mod.send(:remove_const, constant)
24-
mod.const_set(constant, new_value)
25-
yield
26-
ensure
27-
mod.send(:remove_const, constant)
28-
mod.const_set(constant, old_value)
28+
def stub_const(mod, constant, new_value, exists: true)
29+
if exists
30+
begin
31+
old_value = mod.const_get(constant, false)
32+
mod.send(:remove_const, constant)
33+
mod.const_set(constant, new_value)
34+
yield
35+
ensure
36+
mod.send(:remove_const, constant)
37+
mod.const_set(constant, old_value)
38+
end
39+
else
40+
if mod.const_defined?(constant)
41+
raise NameError, "already defined constant #{constant} in #{mod.name}"
42+
end
43+
44+
begin
45+
mod.const_set(constant, new_value)
46+
yield
47+
ensure
48+
mod.send(:remove_const, constant)
49+
end
50+
end
2951
end
3052
end
3153
end

activesupport/test/test_case_test.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,7 @@ class TestConstStubbing < ActiveSupport::TestCase
580580
assert_equal 1, ConstStubbable::CONSTANT
581581
end
582582

583-
test "trying to stub a constant that does not exist in the receiver raises NameError" do
583+
test "stubbing a constant that does not exist in the receiver raises NameError" do
584584
assert_raises(NameError) do
585585
stub_const(ConstStubbable, :NOT_A_CONSTANT, 1) { }
586586
end
@@ -589,4 +589,18 @@ class TestConstStubbing < ActiveSupport::TestCase
589589
stub_const(SubclassOfConstStubbable, :CONSTANT, 1) { }
590590
end
591591
end
592+
593+
test "stubbing a constant that does not exist can be done with `exists: false`" do
594+
stub_const(ConstStubbable, :NOT_A_CONSTANT, 1, exists: false) do
595+
assert_equal 1, ConstStubbable::NOT_A_CONSTANT
596+
end
597+
598+
assert_raises(NameError) do
599+
ConstStubbable::NOT_A_CONSTANT
600+
end
601+
602+
assert_raises(NameError) do
603+
stub_const(Object, :ConstStubbable, 1, exists: false)
604+
end
605+
end
592606
end

0 commit comments

Comments
 (0)