Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Run transactional callbacks on instances most likely to match DB state #45280

Conversation

cbothner
Copy link
Contributor

@cbothner cbothner commented Jun 6, 2022

When multiple instances of the same ActiveRecord model are used to persist changes within a transaction, Rails always runs after_commit callbacks on the first to save. This results in very unintuitive behavior:

Product.transaction do
  Product.find(id).update!(title: "T-Shirt")
  Product.find(id).update!(description: "A cool T-shirt")
end
# When the transaction commits, `after_update_commit` callbacks are fired
# on the first instance, which does not have the new description.

Product.transaction do
  Product.find(id).update!(title: "T-Shirt")
  Product.find(id).destroy!
end
# When the transaction commits, `after_update_commit` callbacks are fired,
# even though the product is destroyed.

This PR implements a change to instead fire transactional callbacks on instances with internal state most likely to match the committed database state.

  • In general, run transactional callbacks on the last instance to save a given record within the transaction.
  • There are two exceptions:
    • If the record is created within the transaction, then updated by another instance, after_create_commit callbacks will be run on the second instance. This is instead of the after_update_commit callbacks that would naively be run based on that instance’s state.
    • If the record is destroyed within the transaction, then after_destroy_commit callbacks will be fired on the last destroyed instance, even if a stale instance subsequently performed an update (which will have affected 0 rows).

Please see the added test cases for a more detailed statement of behavior.

Copy link
Member

@rafaelfranca rafaelfranca left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me. Let's add the flag to allow people to opt-in to it, but still makes this a framework default

@cbothner
Copy link
Contributor Author

cbothner commented Jun 9, 2022

@rafaelfranca I added the configuration flag, updated the framework defaults, adding documentation for the new flag, and added a changelog entry.

I considered whether a deprecation warning was necessary but based on the example of other such framework defaults I decided not to. We aren't really deprecating the old behavior if there's a configuration flag that lets an app developer opt in or out as they please. Let me know what you think.

CI is red but as far as I can tell it appears just to be flakiness.

Comment on lines 21 to 23
- If the record is created within the transaction, then
`after_create_commit` callbacks will be fired on the last instance to
save, even if that instance performed an update.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this an exception? It is running the callback on the last instance to save.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exceptional behavior is that we're running after_create_commit, not after_update_commit on that instance, even though from its perspective it just issued an update

https://github.com/rails/rails/blob/ad54b7a6733373b5426ceed6c4663f99e9781dbb/activerecord/test/cases/transaction_callbacks_test.rb#L791-L802

If you don't feel like that detail needs to be called out, then I'm happy to remove or reword.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, got it, that is not clear in the message. Can we update the message to show that difference?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I updated it. Is that more clear?

activerecord/CHANGELOG.md Outdated Show resolved Hide resolved
activerecord/test/cases/test_case.rb Outdated Show resolved Hide resolved
@cbothner cbothner force-pushed the run-transactional-callbacks-on-instances-most-likely-to-match-database-state branch from e296c89 to a7ae66f Compare June 10, 2022 19:31
@cbothner cbothner force-pushed the run-transactional-callbacks-on-instances-most-likely-to-match-database-state branch from a7ae66f to 936a862 Compare June 14, 2022 17:34
@rafaelfranca rafaelfranca merged commit 4962e03 into rails:main Jun 14, 2022
@cbothner cbothner deleted the run-transactional-callbacks-on-instances-most-likely-to-match-database-state branch June 14, 2022 20:17
yahonda added a commit to yahonda/rails that referenced this pull request Jun 26, 2022
Follow up rails#45280
There are couple of pull requests that remove these trailing spaces.
Changing the trailing whitespaces is not related to them.
@kshnurov
Copy link

@cbothner @rafaelfranca this change silently breaks ActiveModel::Dirty for after_commit callbacks. saved_changes, saved_change_to_x? and etc are pretty common in after_commit.

Depending on what else is happening in the transaction they'll either work as expected or will be completely ignored since the last object won't have the expected changes. A nightmare to debug and the only way to fix is to disable this behavior.

@cbothner
Copy link
Contributor Author

@kshnurov Anything that relies on instance state is unreliable when two instances are being used to write to the same row in the same transaction. No matter which instance we choose for callbacks, we'll miss something. Between the two options, I believe this is the better choice. Missing dirty state from the first instance to save does break callbacks that are conditional on saved_changes, but we provide the right column data to callbacks that don't rely on dirty state. Missing database state from the second to save breaks all callbacks that rely on that state, and sometimes results in the wrong lifecycle event being fired.

Really I think we should all try not to get into situations where we have multiple instances representing the same row. Passing around one instance will ensure no state is lost, and as a bonus it will save unnecessary SELECT statements.

@kshnurov
Copy link

@cbothner well, both are bad, but the stale state is an expected behaviour described in the docs. This PR didn't update them, which makes things even worse - people will expect it to work as before.

This one is a breaking change in Rails 7.1 that leads to silent hard-to-find bugs.
Fixing something while breaking the other thing is not a good solution.
Stale state is known, expected and could be easily fixed by a simple .reload. I don't see a workaround for this one though.

@zzak
Copy link
Member

zzak commented Dec 31, 2023

@kshnurov Sorry to dismiss your comments here, but people make mistakes and responding to a PR that was merged over 18 months ago probably won't get the feedback you're looking for. I suggest if you can create a reproduction to open a new issue. 🙏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants