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
Deprecate committing a transaction exited with return or throw #29333
Deprecate committing a transaction exited with return or throw #29333
Conversation
Thanks for the pull request, and welcome! The Rails team is excited to review your changes, and you should hear from @rafaelfranca (or someone else) soon. If any changes to this PR are deemed necessary, please add them as extra commits. This ensures that the reviewer can see what has changed since they last reviewed the code. Due to the way GitHub handles out-of-date commits, this should also make it reasonably obvious what issues have or haven't been addressed. Large or tricky changes may require several passes of review and changes. This repository is being automatically checked for code quality issues using Code Climate. You can see results for this analysis in the PR status below. Newly introduced issues should be fixed before a Pull Request is considered ready to review. Please see the contribution instructions for more information. |
We aren't going to add a deprecation to an existing release series; this needs to be directed to master.
That doesn't seem acceptable either. The reason for the previous change is that silently rolling back on a non-error |
d60726b
to
0f67779
Compare
Changed the base branch to master. Would it be more acceptable to raise an exception along with rolling back in the ensure block in the Rails release following the deprecation so it doesn't silently support either |
Yeah, while unfortunate, I think that would at least avoid nasty surprises. My preference would be to silently rollback on any throw (i.e., treating it like an exception), while silently committing if we're unwinding due to a return. That's basically the theory behind the current behaviour: no-one uses throw/catch, so a non-exception unwind is most likely a return. And that theory worked well for about 8 years. 🙈 Short of petitioning for a new language feature, the only option that comes to mind to distinguish them would be to monkey-patch I wonder how terrible that would actually be... |
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
5315007
to
7620dea
Compare
I changed the description of the future behaviour in the exception message: - in the next release of Rails it will instead rollback.
+ in the next release of Rails it will raise and rollback. and resolved merge conflicts |
This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. |
If a transaction is wrapped in a Timeout.timeout(duration) block, then the transaction will be committed when the transaction block is exited from the timeout, since it uses `throw`. Ruby code doesn't have a way to distinguish between a block being exited from a `return`, `break` or `throw`, so fixing this problem for the case of `throw` would require a backwards incompatible change for block exited with `return` or `break`. As such, the current behaviour so it can be changed in the future.
7620dea
to
c8fac62
Compare
We're working through this deprecation at GitHub and it feels like this is overreaching a bit. There are cases where you might want to return early and this deprecation reads like early returns will just start throwing exceptions in Rails 6.2. How are these changes intended to work with the following: with_lock do
return if some_crtieria_met?
# do work
end Will this start raising in 6.2? I've read this PR a few times and we're having trouble figuring out if our use case is intended to be unsupported or this is just a deprecation that's not applicable for us. I'm a little worried this is confusing for upgrades in other apps as well. |
That is an interesting use case, since in your early return example it doesn't really matter if the transaction is committed or rolled back since there hasn't been a write to the database. As a result, there is also no concern about ambiguity of whether the early return is supposed to mean the transaction should be committed or rolled back. I'm more concerned about the case where the application writes to the database and then does an early return with the intention of committing the transaction, since we can't support that without also committing the transaction in the case of a timeout from a To support your use case, perhaps we should use The other question is whether we should even bother to raise an exception in Rails 6.2 in place of the deprecation warning after rolling back the transaction. If we allow early returns out of a transaction without writes, then it would be simpler remove the write detection code and just always rollback from the transaction. The simpler behaviour would avoid the need to explain the special case to the user in documentation. It would also avoid translating a timeout into a different exception. I think the only advantage of raising an exception would be to make the breaking change more visible and thus easier to notice if the warning wasn't noticed, but the rollback from an early return seems pretty likely to be noticed from test suites if the deprecation isn't noticed. The ability to rollback the transaction through |
I think raising in 6.2 if there is ever a We're happy to ignore this deprecation as long as this won't start raising in 6.2. I don't know if using I'm not sure what the right path forward. This change, while fixing a real issue, can be confusing and fixes one case while introducing issues in another case. |
Yeah, I'm with you now about not raising in rails 6.2 If the deprecation warning is ignored because it isn't appropriate in some cases, then it won't be effective in letting developers know where to update their code to avoid being effected by the breaking change. I realize the I'll open a PR with my proposed path forward. |
So what now is the canonical way to return from the current context (let's assume you're nested inside several blocks so |
Is this a bug that the following code __silently__ rolls back any updates from the transaction without any exception? This is on Rails 7.0.2.4, also checked the current master:
|
@sam0x17 @majkelcc commenting on 2 year old PRs will likely not get the attention you need. If you have found a bug, please open a new issue with a reproduction script. Some newer changes have been made to transactions that were not caused by this PR. A bug in 7.x was recently fixed in #44955 which may be related to your current issues. |
@eileencodes thanks, I'll create a new bug report. It looks like my issue is caused by this PR - deprecation in Rails 6 turned into a rollback in Rails 7, but the fact that it does it silently is completely unexpected. |
This PR was just a deprecation. The intention was to have it raise in rails 7 so that it didn't rollback silently.
Extracting the contents of the transaction into its own method would make it easier to return out of those nested blocks while still committing. In that case, the return value returned from that extracted method might be needed to conditionally skip over any code that follows the transaction in that original method. |
That was my impression after reading this topic - created a bug report here #45017. |
being a bit of a necromant by reviving this thread, just wanted to share the syntax I use for this: object.transaction do
post = Post.find_by_id(123)
return unless post
do_stuff(post)
end into return unless object.transaction do
post = Post.find_by_id(123)
next unless post
do_stuff(post)
true
end this way you can avoid extra nested |
throw/catch is used for non-local control flow, not for exceptional situations. For exceptional situations, raise should be used instead. A timeout is an exceptional situation, so it should use raise, not throw/catch. Timeout's implementation that uses throw/catch internally causes serious problems. Consider the following code: ```ruby def handle_exceptions yield rescue Exception => exc handle_error # e.g. ROLLBACK for databases raise ensure handle_exit unless exc # e.g. COMMIT for databases end Timeout.timeout(1) do handle_exceptions do do_something end end ``` This kind of design ensures that all exceptions are handled as errors, and ensures that all exits (normal exit, early return, throw/catch) are not handled as errors. With Timeout's throw/catch implementation, this type of code does not work, since a timeout triggers the normal exit path. See rails/rails#29333 for an example of the damage Timeout's design has caused the Rails ecosystem. This switches Timeout.timeout to use raise/rescue internally. It adds a Timeout::ExitException subclass of exception for the internal raise/rescue, which Timeout.timeout will convert to Timeout::Error for backwards compatibility. Timeout::Error remains a subclass of RuntimeError. This is how timeout used to work in Ruby 2.0. It was changed in Ruby 2.1, after discussion in [Bug #8730] (commit 238c003 in the timeout repository). I think the change from using raise/rescue to using throw/catch has caused significant harm to the Ruby ecosystem at large, and reverting it is the most sensible choice. From the translation of [Bug #8730], it appears the issue was that someone could rescue Exception and not reraise the exception, causing timeout errors to be swallowed. However, such code is broken anyway. Using throw/catch causes far worse problems, because then it becomes impossible to differentiate between normal control flow and exceptional control flow. Also related to this is [Bug #11344], which changed how Thread.handle_interrupt interacted with Timeout.
throw/catch is used for non-local control flow, not for exceptional situations. For exceptional situations, raise should be used instead. A timeout is an exceptional situation, so it should use raise, not throw/catch. Timeout's implementation that uses throw/catch internally causes serious problems. Consider the following code: ```ruby def handle_exceptions yield rescue Exception => exc handle_error # e.g. ROLLBACK for databases raise ensure handle_exit unless exc # e.g. COMMIT for databases end Timeout.timeout(1) do handle_exceptions do do_something end end ``` This kind of design ensures that all exceptions are handled as errors, and ensures that all exits (normal exit, early return, throw/catch) are not handled as errors. With Timeout's throw/catch implementation, this type of code does not work, since a timeout triggers the normal exit path. See rails/rails#29333 for an example of the damage Timeout's design has caused the Rails ecosystem. This switches Timeout.timeout to use raise/rescue internally. It adds a Timeout::ExitException subclass of exception for the internal raise/rescue, which Timeout.timeout will convert to Timeout::Error for backwards compatibility. Timeout::Error remains a subclass of RuntimeError. This is how timeout used to work in Ruby 2.0. It was changed in Ruby 2.1, after discussion in [Bug #8730] (commit 238c003 in the timeout repository). I think the change from using raise/rescue to using throw/catch has caused significant harm to the Ruby ecosystem at large, and reverting it is the most sensible choice. From the translation of [Bug #8730], it appears the issue was that someone could rescue Exception and not reraise the exception, causing timeout errors to be swallowed. However, such code is broken anyway. Using throw/catch causes far worse problems, because then it becomes impossible to differentiate between normal control flow and exceptional control flow. Also related to this is [Bug #11344], which changed how Thread.handle_interrupt interacted with Timeout. Co-authored-by: Nobuyoshi Nakada <nobu@ruby-lang.org>
(ruby/timeout#30) throw/catch is used for non-local control flow, not for exceptional situations. For exceptional situations, raise should be used instead. A timeout is an exceptional situation, so it should use raise, not throw/catch. Timeout's implementation that uses throw/catch internally causes serious problems. Consider the following code: ```ruby def handle_exceptions yield rescue Exception => exc handle_error # e.g. ROLLBACK for databases raise ensure handle_exit unless exc # e.g. COMMIT for databases end Timeout.timeout(1) do handle_exceptions do do_something end end ``` This kind of design ensures that all exceptions are handled as errors, and ensures that all exits (normal exit, early return, throw/catch) are not handled as errors. With Timeout's throw/catch implementation, this type of code does not work, since a timeout triggers the normal exit path. See rails/rails#29333 for an example of the damage Timeout's design has caused the Rails ecosystem. This switches Timeout.timeout to use raise/rescue internally. It adds a Timeout::ExitException subclass of exception for the internal raise/rescue, which Timeout.timeout will convert to Timeout::Error for backwards compatibility. Timeout::Error remains a subclass of RuntimeError. This is how timeout used to work in Ruby 2.0. It was changed in Ruby 2.1, after discussion in [Bug #8730] (commit ruby/timeout@238c003c921e in the timeout repository). I think the change from using raise/rescue to using throw/catch has caused significant harm to the Ruby ecosystem at large, and reverting it is the most sensible choice. From the translation of [Bug #8730], it appears the issue was that someone could rescue Exception and not reraise the exception, causing timeout errors to be swallowed. However, such code is broken anyway. Using throw/catch causes far worse problems, because then it becomes impossible to differentiate between normal control flow and exceptional control flow. Also related to this is [Bug #11344], which changed how Thread.handle_interrupt interacted with Timeout. ruby/timeout@f16545abe6 Co-authored-by: Nobuyoshi Nakada <nobu@ruby-lang.org>
Fix: rails#45017 Ref: rails#29333 Ref: ruby/timeout#30 Historically only raised errors would trigger a rollback, but in Ruby `2.3`, the `timeout` library started using `throw` to interupt execution which had the adverse effect of committing open transactions. To solve this, in Active Record 6.1 the behavior was changed to instead rollback the transaction as it was safer than to potentially commit an incomplete transaction. Using `return`, `break` or `throw` inside a `transaction` block was essentially deprecated from Rails 6.1 onwards. However with the release of `timeout 0.4.0`, `Timeout.timeout` now raises an error again, and Active Record is able to return to its original, less surprising, behavior.
Problem
I found that we had transactions that were being committed because of the use of
Timeout.timeout(duration) do
wrapping database transactions. The reason why this was happening is that when Timeout.timeout isn't given a second argument (an exception class) then it usesthrow
andcatch
to exit the transaction block, after whichTimeout.timeout
will raise an exception. This why the documentation for Timeout.timeout saysThis can be reproduced using ruby code like
where the update will be committed even if the block gets a timeout exception.
The reason why this results in the transaction being committed is that fc83920 was added to commit a transaction when the transaction block is exited with
return
. Unfortunately, theensure
block can't distinguish between a block exited withreturn
,break
orthrow
, so we can't fix the problem withthrow
as used inTimeout.timeout
without a backwards incompatible change.Solution
Set a local variable
completed = true
after theyield
so that we can detect when theensure
block didn't complete, then use a deprecation warning to try to let developers know that rails will rollback instead of commit in this case in the future.That way we can fix this behaviour in the next release for the
Timeout.timeout
case. I will open a PR against master doing this.