Skip to content

Commit 3bcd167

Browse files
committed
Run after_commit callbacks defined on models in the correct order
1 parent f014802 commit 3bcd167

File tree

9 files changed

+181
-27
lines changed

9 files changed

+181
-27
lines changed

activerecord/CHANGELOG.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,22 @@
1+
* `after_commit` callbacks defined on models now execute in the correct order.
2+
3+
```ruby
4+
class User < ActiveRecord::Base
5+
after_commit { puts("this gets called first") }
6+
after_commit { puts("this gets called second") }
7+
end
8+
```
9+
10+
Previously, the callbacks executed in the reverse order. To opt in to the new behaviour:
11+
12+
```ruby
13+
config.active_record.run_after_transaction_callbacks_in_order_defined = true
14+
```
15+
16+
This is the default for new apps.
17+
18+
*Alex Ghiculescu*
19+
120
* Infer `foreign_key` when `inverse_of` is present on `has_one` and `has_many` associations.
221

322
```ruby

activerecord/lib/active_record.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,9 @@ def self.global_executor_concurrency # :nodoc:
313313
singleton_class.attr_accessor :before_committed_on_all_records
314314
self.before_committed_on_all_records = false
315315

316+
singleton_class.attr_accessor :run_after_transaction_callbacks_in_order_defined
317+
self.run_after_transaction_callbacks_in_order_defined = false
318+
316319
##
317320
# :singleton-method:
318321
# Specify a threshold for the size of query result sets. If the number of

activerecord/lib/active_record/callbacks.rb

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -234,30 +234,16 @@ module ActiveRecord
234234
# end
235235
#
236236
# In this case the +log_children+ is executed before +do_something_else+.
237-
# The same applies to all non-transactional callbacks.
237+
# This applies to all non-transactional callbacks, and to +before_commit+.
238238
#
239-
# As seen below, in case there are multiple transactional callbacks the order
240-
# is reversed.
239+
# For transactional +after_+ callbacks (+after_commit+, +after_rollback+, etc), the order
240+
# can be set via configuration.
241241
#
242-
# For example:
243-
#
244-
# class Topic < ActiveRecord::Base
245-
# has_many :children
246-
#
247-
# after_commit :log_children
248-
# after_commit :do_something_else
249-
#
250-
# private
251-
# def log_children
252-
# # Child processing
253-
# end
254-
#
255-
# def do_something_else
256-
# # Something else
257-
# end
258-
# end
242+
# config.active_record.run_after_transaction_callbacks_in_order_defined = false
259243
#
260-
# In this case the +do_something_else+ is executed before +log_children+.
244+
# If +true+ (the default from Rails 7.1), callbacks are executed in the order they
245+
# are defined, just like the example above. If +false+, the order is reversed, so
246+
# +do_something_else+ is executed before +log_children+.
261247
#
262248
# == \Transactions
263249
#

activerecord/lib/active_record/transactions.rb

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,43 +230,51 @@ def before_commit(*args, &block) # :nodoc:
230230
# after_commit :do_bar_baz, on: [:update, :destroy]
231231
#
232232
def after_commit(*args, &block)
233-
set_options_for_callbacks!(args)
233+
set_options_for_callbacks!(args, prepend_option)
234234
set_callback(:commit, :after, *args, &block)
235235
end
236236

237237
# Shortcut for <tt>after_commit :hook, on: [ :create, :update ]</tt>.
238238
def after_save_commit(*args, &block)
239-
set_options_for_callbacks!(args, on: [ :create, :update ])
239+
set_options_for_callbacks!(args, on: [ :create, :update ], **prepend_option)
240240
set_callback(:commit, :after, *args, &block)
241241
end
242242

243243
# Shortcut for <tt>after_commit :hook, on: :create</tt>.
244244
def after_create_commit(*args, &block)
245-
set_options_for_callbacks!(args, on: :create)
245+
set_options_for_callbacks!(args, on: :create, **prepend_option)
246246
set_callback(:commit, :after, *args, &block)
247247
end
248248

249249
# Shortcut for <tt>after_commit :hook, on: :update</tt>.
250250
def after_update_commit(*args, &block)
251-
set_options_for_callbacks!(args, on: :update)
251+
set_options_for_callbacks!(args, on: :update, **prepend_option)
252252
set_callback(:commit, :after, *args, &block)
253253
end
254254

255255
# Shortcut for <tt>after_commit :hook, on: :destroy</tt>.
256256
def after_destroy_commit(*args, &block)
257-
set_options_for_callbacks!(args, on: :destroy)
257+
set_options_for_callbacks!(args, on: :destroy, **prepend_option)
258258
set_callback(:commit, :after, *args, &block)
259259
end
260260

261261
# This callback is called after a create, update, or destroy are rolled back.
262262
#
263263
# Please check the documentation of #after_commit for options.
264264
def after_rollback(*args, &block)
265-
set_options_for_callbacks!(args)
265+
set_options_for_callbacks!(args, prepend_option)
266266
set_callback(:rollback, :after, *args, &block)
267267
end
268268

269269
private
270+
def prepend_option
271+
if ActiveRecord.run_after_transaction_callbacks_in_order_defined
272+
{ prepend: true }
273+
else
274+
{}
275+
end
276+
end
277+
270278
def set_options_for_callbacks!(args, enforced_options = {})
271279
options = args.extract_options!.merge!(enforced_options)
272280
args << options

activerecord/test/cases/transaction_callbacks_test.rb

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,99 @@ def test_before_commit_update_in_same_transaction
640640
end
641641
end
642642

643+
class CallbackOrderTest < ActiveRecord::TestCase
644+
self.use_transactional_tests = false
645+
646+
module Behaviour
647+
extend ActiveSupport::Concern
648+
649+
included do
650+
self.table_name = :topics
651+
652+
after_commit { |record| record.history << 3 }
653+
after_commit { |record| record.history << 4 }
654+
after_save_commit { |record| record.history << "save" }
655+
after_create_commit { |record| record.history << "create" }
656+
after_update_commit { |record| record.history << "update" }
657+
after_destroy_commit { |record| record.history << "destroy" }
658+
659+
after_rollback { |record| record.history << "rollback1" }
660+
after_rollback { |record| record.history << "rollback2" }
661+
662+
before_commit { |record| record.history << 1 }
663+
before_commit { |record| record.history << 2 }
664+
end
665+
666+
def clear_history
667+
@history = []
668+
end
669+
670+
def history
671+
@history ||= []
672+
end
673+
end
674+
675+
run_after_transaction_callbacks_in_order_defined_was = ActiveRecord.run_after_transaction_callbacks_in_order_defined
676+
ActiveRecord.run_after_transaction_callbacks_in_order_defined = true
677+
class TopicWithCallbacksWithSpecificOrderWithSettingTrue < ActiveRecord::Base
678+
include Behaviour
679+
end
680+
ActiveRecord.run_after_transaction_callbacks_in_order_defined = run_after_transaction_callbacks_in_order_defined_was
681+
682+
run_after_transaction_callbacks_in_order_defined_was = ActiveRecord.run_after_transaction_callbacks_in_order_defined
683+
ActiveRecord.run_after_transaction_callbacks_in_order_defined = false
684+
class TopicWithCallbacksWithSpecificOrderWithSettingFalse < ActiveRecord::Base
685+
include Behaviour
686+
end
687+
ActiveRecord.run_after_transaction_callbacks_in_order_defined = run_after_transaction_callbacks_in_order_defined_was
688+
689+
def test_callbacks_run_in_order_defined_in_model_if_using_run_after_transaction_callbacks_in_order_defined
690+
topic = TopicWithCallbacksWithSpecificOrderWithSettingTrue.new
691+
topic.save
692+
assert_equal [1, 2, 3, 4, "save", "create"], topic.history
693+
694+
topic.clear_history
695+
topic.approved = true
696+
topic.save
697+
assert_equal [1, 2, 3, 4, "save", "update"], topic.history
698+
699+
topic.clear_history
700+
topic.transaction do
701+
topic.approved = false
702+
topic.save
703+
raise ActiveRecord::Rollback
704+
end
705+
assert_equal ["rollback1", "rollback2"], topic.history
706+
707+
topic.clear_history
708+
topic.destroy
709+
assert_equal [1, 2, 3, 4, "destroy"], topic.history
710+
end
711+
712+
def test_callbacks_run_in_order_defined_in_model_if_not_using_run_after_transaction_callbacks_in_order_defined
713+
topic = TopicWithCallbacksWithSpecificOrderWithSettingFalse.new
714+
topic.save
715+
assert_equal [1, 2, "create", "save", 4, 3], topic.history
716+
717+
topic.clear_history
718+
topic.approved = true
719+
topic.save
720+
assert_equal [1, 2, "update", "save", 4, 3], topic.history
721+
722+
topic.clear_history
723+
topic.transaction do
724+
topic.approved = false
725+
topic.save
726+
raise ActiveRecord::Rollback
727+
end
728+
assert_equal ["rollback2", "rollback1"], topic.history
729+
730+
topic.clear_history
731+
topic.destroy
732+
assert_equal [1, 2, "destroy", 4, 3], topic.history
733+
end
734+
end
735+
643736
class CallbacksOnDestroyUpdateActionRaceTest < ActiveRecord::TestCase
644737
self.use_transactional_tests = false
645738

guides/source/configuring.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ Below are the default values associated with each target version. In cases of co
7171
- [`config.active_record.marshalling_format_version`](#config-active-record-marshalling-format-version): `7.1`
7272
- [`config.active_record.query_log_tags_format`](#config-active-record-query-log-tags-format): `:sqlcommenter`
7373
- [`config.active_record.raise_on_assign_to_attr_readonly`](#config-active-record-raise-on-assign-to-attr-readonly): `true`
74+
- [`config.active_record.run_after_transaction_callbacks_in_order_defined`](#config-active-record-run-after-transaction-callbacks-in-order-defined): `true`
7475
- [`config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction`](#config-active-record-run-commit-callbacks-on-first-saved-instances-in-transaction): `false`
7576
- [`config.active_record.sqlite3_adapter_strict_strings_by_default`](#config-active-record-sqlite3-adapter-strict-strings-by-default): `true`
7677
- [`config.active_support.default_message_encryptor_serializer`](#config-active-support-default-message-encryptor-serializer): `:json`
@@ -1303,6 +1304,19 @@ The default value depends on the `config.load_defaults` target version:
13031304
| (original) | `YAML` |
13041305
| 7.1 | `nil` |
13051306
1307+
#### `config.active_record.run_after_transaction_callbacks_in_order_defined`
1308+
1309+
If true, `after_commit` callbacks are executed in the order they are defined in a model. If false, they are executed in reverse order.
1310+
1311+
All other callbacks are always executed in the order they are defined in a model (unless you use `prepend: true`).
1312+
1313+
The default value depends on the `config.load_defaults` target version:
1314+
1315+
| Starting with version | The default value is |
1316+
| --------------------- | -------------------- |
1317+
| (original) | `false` |
1318+
| 7.1 | `true` |
1319+
13061320
#### `config.active_record.query_log_tags_enabled`
13071321
13081322
Specifies whether or not to enable adapter-level query comments. Defaults to

railties/lib/rails/application/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,7 @@ def load_defaults(target_version)
284284
active_record.default_column_serializer = nil
285285
active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA256
286286
active_record.marshalling_format_version = 7.1
287+
active_record.run_after_transaction_callbacks_in_order_defined = true
287288
end
288289

289290
if respond_to?(:action_dispatch)

railties/lib/rails/generators/rails/app/templates/config/initializers/new_framework_defaults_7_1.rb.tt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,8 @@
161161
# leave this optimization off on the first deploy, then enable it on a
162162
# subsequent deploy.
163163
# Rails.application.config.active_record.marshalling_format_version = 7.1
164+
165+
# Run `after_commit` and `after_*_commit` callbacks in the order they are defined in a model.
166+
# This matches the behaviour of all other callbacks.
167+
# In previous versions of Rails, they ran in the inverse order.
168+
# Rails.application.config.active_record.run_after_transaction_callbacks_in_order_defined = true

railties/test/application/configuration_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4346,6 +4346,31 @@ def new(app); self; end
43464346
end
43474347
end
43484348

4349+
test "run_after_transaction_callbacks_in_order_defined is true in new apps" do
4350+
app "development"
4351+
4352+
assert_equal true, ActiveRecord.run_after_transaction_callbacks_in_order_defined
4353+
end
4354+
4355+
test "run_after_transaction_callbacks_in_order_defined is false in upgrading apps" do
4356+
remove_from_config '.*config\.load_defaults.*\n'
4357+
add_to_config 'config.load_defaults "7.0"'
4358+
app "development"
4359+
4360+
assert_equal false, ActiveRecord.run_after_transaction_callbacks_in_order_defined
4361+
end
4362+
4363+
test "run_after_transaction_callbacks_in_order_defined can be set via framework defaults" do
4364+
remove_from_config '.*config\.load_defaults.*\n'
4365+
add_to_config 'config.load_defaults "7.0"'
4366+
app_file "config/initializers/new_framework_defaults_7_1.rb", <<-RUBY
4367+
Rails.application.config.active_record.run_after_transaction_callbacks_in_order_defined = true
4368+
RUBY
4369+
app "development"
4370+
4371+
assert_equal true, ActiveRecord.run_after_transaction_callbacks_in_order_defined
4372+
end
4373+
43494374
private
43504375
def set_custom_config(contents, config_source = "custom".inspect)
43514376
app_file "config/custom.yml", contents

0 commit comments

Comments
 (0)