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
Make ActiveRecord::Rollback bubble to outer transactions #44518
base: main
Are you sure you want to change the base?
Conversation
Thanks for looking at this.. the fact Rollback can be silently swallowed by a block that isn't actually going to rollback has long been a concern to me. I've actually been digging into transaction behaviour recently, with one side-goal being to come at this from the other direction, and hopefully move to However, surprising (and IMO unreasonable) as it is, given how long this has been around, it seems safe to imagine that someone has a vital workflow that's [perhaps unknowingly] depending on this historical behaviour. What do you think of putting in a deprecation warning for a version first? For now we could use the same conditional, but then instead of re-raising, just warn that we will re-raise in future. In line with my planned change, we could introduce a new better name for |
A deprecation seems sensible given it has been here forever and I'm happy to hear you also want to remove the behaviour 😄 . Given the test failures it would have to handle existing rails code somehow where the rollback is expected to be swallowed. If I change this to a deprecation warning and fix the rails code so they somehow don't warn would that fit your suggestion? |
I think so! For the tests, we'll probably need to individually consider whether the test is covering the behaviour as documented [that we're deprecating] -- in which case the test would remain, but now also assert an expected deprecation; or whether it's best fixed by changing it to use a real transaction, or does specifically want the current behaviour [unlike the deprecation case, this would be "will still want to work like this even afterwards"]. |
Thanks, looking at the failures most of them in the rails codebase should still work, for example saving a model with an associations, very roughly rails is often doing something like: class OtherThing
def save
raise ActiveRecord::Rollback if invalid?
end
end
class Thing
has_many :other_things
def save
transaction do
other_things.each(&:save)
raise RecordNotSaved unless other_things.all?(&:persisted?)
end
end
end I think the easiest thing would be to add an option to the transaction to say that you want it to swallow |
My first instinct is that those should be upgraded to (#44526 is partly building toward a |
That would cause many more rails transactions to use nested transactions when I don't think that is wanted. This issue is motivated for us because our postgresqls database was running very slow due to I like the idea of only using a real nested transaction if it is needed but I also don't want to introduce more nested transactions in general. I was thinking of def transaction(swallow_rollback_errors: false)
...
rescue ActiveRecord::Rollback
if !swallow_rollback_errors && !requires_new && transaction_open?
ActiveSupport::Deprecation.warn(<<-MSG.squish)
An ActiveRecord::Rollback error is being raised inside a nested transaction without `requires_new: true`
which will not rollback the outer transaction. Please use `swallow_rollback_errors: true`
if you want this behaviour. In future rails versions ActiveRecord::Rollback will be re-rasied
so the outer transaction will be rolled back also.
MSG
end
end |
Thanks, that's interesting context on wanting to avoid too many savepoints. It sounds like there might be future improvements in Postgres to make it less of a danger-zone, but even assuming they merge, they're still a ways off. I suggest we add a new Then we add the deprecation warning much as you describe for existing callers. They can avoid the deprecation warning by choosing either I'd prefer not to add a |
52590bb
to
da90b27
Compare
Hi @matthewd, is this what you were suggesting? |
Hey @nikolai-b, sorry to keep you waiting, and thanks for the ping. I'd felt that there was a surprising quantity of def transaction(requires_new: nil, isolation: nil, joinable: true, join_existing: nil, &block)
if !requires_new && current_transaction.joinable?
if isolation
raise ActiveRecord::TransactionIsolationError, "cannot set isolation when joining a transaction"
end
did_join = true
yield
else
transaction_manager.within_new_transaction(isolation: isolation, joinable: joinable, &block)
end
rescue ActiveRecord::Rollback
if did_join
if join_existing
raise
else
# Deprecation Plan: Default requires_new to !join_existing. Always raise if did_join.
ActiveSupport::Deprecation.warn(<<-MSG.squish)
ActiveRecord::Rollback was raised inside a `transaction` block that has not created a separate
transaction. This interrupts the inner block, but does not rollback its changes.
In Rails 7.2, `transaction` will always create a nested database transaction by default, and
ActiveRecord::Rollback will have full effect.
To opt in to the future behavior, use `transaction(requires_new: true)`.
Alternatively, use `transaction(join_existing: true)` to re-use the parent transaction, but
re-raise ActiveRecord::Rollback up to the parent.
MSG
end
else
# rollback is silently swallowed; within_new_transaction has already done the work
end
end (This is where I admit I've only written the above in my browser, and not confirmed it actually works as expected 🙈, but...) I believe that should behave the same in all of our interesting cases, but should also mean most of the other tests stop needing I did also make a substantive change to the deprecation message: despite the considerations you raised around why one might wish to avoid savepoints, for the average user I think it's most important our API does what its name implies -- here, " The new re-raise behaviour of |
…escued With join_existing: true then ActiveRecord::Rollback will re-raise till it reaches a subtransaction or the top transaction. The reason to stop at subtransaction transactions is that they are actual nested transactions so are the smallest unit the database can rollback. If an ActiveRecord::Rollback is raised inside a nested transaction then show a deprecation unless: join_existing: true or a subtransaction was created
da90b27
to
8aeed1d
Compare
Eeek, I understand you want to make the |
Does your application have that many nested explicit |
I did a quick patch of Rails' transaction method so I could give you some lots of examples from our codebase but it is hard because rails uses nested transactions so much, e.g. rails/activerecord/lib/active_record/associations/collection_association.rb Lines 234 to 243 in 75e6c0a
calls rails/activerecord/lib/active_record/associations/collection_association.rb Lines 119 to 125 in 75e6c0a
or rails/activerecord/lib/active_record/persistence.rb Lines 797 to 804 in 75e6c0a
where you can see update! calling save! and both create transactions:
rails/activerecord/lib/active_record/transactions.rb Lines 301 to 303 in 75e6c0a
The same is true for us. We often do a few small things in a transaction and sometimes also group these small operations together. So far this hasn't been an issue because |
Hmm, fair enough. As long as you never Where the caller is prepared to commit to not badly rescuing an inner exception, |
To be clear are your saying that my proposal in the PR is equivalent to using savepoints (so long as the user doesn't I think that is valid 😄 but it would still make our database unusable and a believe it would do the same for others. I don't have a good solution as if savepoint worked well I'd be in favour of using it. Can Rails at least wait till there is a version of postgresql out that handles savepoints better before using savepoints more heavily? It looks like there are some patches that might improve things. To be sure you are saying the difference between my proposal (using def do_something_else
ActiveRecord::Base.transaction do
thing.create!
raise ActiveRecord::Rollback
end
end
ActiveRecord::Base.transaction do
do_something_else
rescue ActiveRecord::Rollback
end with savepoints thing would not be persisted, with |
Yes. I think it's a bit clearer if we side-step the complication of
My hope is to pretty much only affect explicit calls to And at this stage it's only a statement of future intent: the message says "in Rails 7.2", for which we have no scheduled release date, but extrapolating from past releases is a good two years away. That doesn't leave a huge amount of time for Postgres to get ahead of us, because any possible change needs to go through their own release process, but it's very possible it'll be solved by then. And if it's still in the pipeline, you can add I have to infer your database (and Gitlab's, etc) is in some way an outlier... savepoints are a long established and widely used technology in Postgres, so it just seems implausible that they're "never use this if you want your database to work"-level broken for everyone everywhere, and yet no-one noticed until mid last year. I'm hopeful for an upstream solution to ensure it's always performant, but I don't want to leave the surprising behaviour to trip new users for longer than the already-several-year lead time we're facing today. |
Hi. I have a question related to the using If a nested transaction is created with We could use a For demonstration: class User < ApplicationRecord
after_commit :emit_change_message
def emit_change_mess
puts "Changing the user's name: #{first_name} #{last_name}"
# Log changes to kafka
...
end
end
User.create(first_name: "John", last_name: "Doe")
ActiveRecord::Base.transaction(joinable: false, requires_new: true) do
User.last.update(first_name: "Transaction 1") # triggers +after_commit+
ActiveRecord::Base.transaction(joinable: false, requires_new: true) do
User.last.update(last_name: "Transaction 2") # triggers +after_commit+
raise ActiveRecord::Rollback
end
end
# => Changing user's name: first_name: Transaction 1, last_name: Doe
# => Changing user's name: first_name: Transaction 1, last_name: Transaction 2
User.last.first_name
# => "John"
User.last.last_name
# => "Doe" In the above example, the changes are not saved in the database as expected. However, the With the proposed changes in this PR with |
create a separate transaction. This interrupts the inner block but does not rollback | ||
its changes. | ||
|
||
In future rails versions ActiveRecord::Rollback will be re-rasied so the outer |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- Typo:
re-rasied
->re-raised
😄
Summary
Re-raise the error till it reaches a savepoint or the top transaction.
The reason to stop at savepoint / requires_new transactions is that
they are actual nested transactions so are the smallest unit the
database can rollback.
Other Information
This is a breaking change but the current behaviour is documented as
being "surprising".