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

Explicit transactions not rolling back when validation fails on nested attributes #14698

Open
lcreid opened this Issue Apr 11, 2014 · 12 comments

Comments

Projects
None yet
7 participants
@lcreid

lcreid commented Apr 11, 2014

In certain situations, when a validation fails on a model referenced in nested attributes, when the update attributes is executed inside an explicit transaction, the transaction is not rolled back, and the database is left in a state that's different than it was before the transaction.

Example:

In the controller:

    User.transaction do
      user.update(email: nil, user_setting_attributes: {favourite_color: 'red'})
    end

Note that user.update! does not cause the issue.

The models:

class User < ActiveRecord::Base
  has_one :user_setting
  accepts_nested_attributes_for :user_setting
  validates_presence_of :email
end

class UserSetting < ActiveRecord::Base
  belongs_to :user
end

A complete file that shows a case that causes, and some cases that are similar but don't cause the issue:

unless File.exist?('Gemfile')
  File.write('Gemfile', <<-GEMFILE)
source 'https://rubygems.org'
gem 'activerecord', '4.1.0'
gem 'sqlite3'
GEMFILE

  system 'bundle'
end

require 'bundler'
Bundler.setup(:default)

# Activate the gem you are reporting the issue against.
require 'active_record'
require 'minitest/autorun'
require 'logger'

# Ensure backward compatibility with Minitest 4
Minitest::Test = MiniTest::Unit::TestCase unless defined?(Minitest::Test)

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:')
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :users do |t|
    t.string :email
  end

  create_table :user_settings do |t|
    t.integer :user_id
    t.string :favourite_color
  end
end

class User < ActiveRecord::Base
  has_one :user_setting
  accepts_nested_attributes_for :user_setting
  validates_presence_of :email
end

class UserSetting < ActiveRecord::Base
  belongs_to :user
end

class BugTest < Minitest::Test
  def test_update_without_transaction
    user = User.create!(email: 'godfrey@example.com', user_setting_attributes: {favourite_color: 'orange'})
    assert_equal 'orange', user.user_setting.favourite_color

    assert ! user.update(email: nil, user_setting_attributes: {favourite_color: 'red'})

    user.reload

    assert user.user_setting.present?
    assert_equal 'orange', user.user_setting.favourite_color
  end

  def test_update_with_transaction
    user = User.create!(email: 'godfrey@example.com', user_setting_attributes: {favourite_color: 'orange'})
    assert_equal 'orange', user.user_setting.favourite_color

    User.transaction do
      assert ! user.update(email: nil, user_setting_attributes: {favourite_color: 'red'})
    end

    user.reload

    assert user.user_setting.present?
    assert_equal 'orange', user.user_setting.favourite_color
  end

  def test_update_without_transaction!
    user = User.create!(email: 'godfrey@example.com', user_setting_attributes: {favourite_color: 'orange'})
    assert_equal 'orange', user.user_setting.favourite_color

    assert_raises(ActiveRecord::RecordInvalid) do
      user.update!(email: nil, user_setting_attributes: {favourite_color: 'red'})
    end

    user.reload

    assert user.user_setting.present?
    assert_equal 'orange', user.user_setting.favourite_color
  end

  def test_update_with_transaction!
    user = User.create!(email: 'godfrey@example.com', user_setting_attributes: {favourite_color: 'orange'})
    assert_equal 'orange', user.user_setting.favourite_color

    assert_raises(ActiveRecord::RecordInvalid) do
      User.transaction do
        assert ! user.update!(email: nil, user_setting_attributes: {favourite_color: 'red'})
      end
    end

    user.reload

    assert user.user_setting.present?
    assert_equal 'orange', user.user_setting.favourite_color
  end
end

If the above is in a file called repro.rb, then:

ruby repro.rb

should produce output ending in:

4 runs, 15 assertions, 1 failures, 0 errors, 0 skips

The issues has been observed in 4.1 master and 4.0 master. I haven't yet checked how far back it might go.

The issue was shown to my by @chancancode . I'm documenting the issue here without much further research yet because I didn't want it to get dropped.

@chancancode

This comment has been minimized.

Show comment
Hide comment
@chancancode

chancancode Apr 13, 2014

Member

Ah. Thanks for looking into this @lcreid. I dug a bit deeper and found the documentation I was looking for the other day. It turns out that this is indeed expected and documented behaviour (see the example in the docs I linked). And it also turns out that I have been bitten by this more than once (see #12944, also #4566), so clearly this is super confusing 😅

I'll look into the reason behind this (performance maybe?) and see if anything can be changed here in Rails 5.0, either make requires_new the default or perhaps make inner transaction blocks propagate the exceptions.

Closing for now, but thanks again for looking into this with me!

Member

chancancode commented Apr 13, 2014

Ah. Thanks for looking into this @lcreid. I dug a bit deeper and found the documentation I was looking for the other day. It turns out that this is indeed expected and documented behaviour (see the example in the docs I linked). And it also turns out that I have been bitten by this more than once (see #12944, also #4566), so clearly this is super confusing 😅

I'll look into the reason behind this (performance maybe?) and see if anything can be changed here in Rails 5.0, either make requires_new the default or perhaps make inner transaction blocks propagate the exceptions.

Closing for now, but thanks again for looking into this with me!

@chancancode

This comment has been minimized.

Show comment
Hide comment
@chancancode

chancancode Apr 15, 2014

Member

I'm actually a bit suspicious of the correctness of the documentation I linked, I think this might have been valid – I'll try to get a more definitive answer

Member

chancancode commented Apr 15, 2014

I'm actually a bit suspicious of the correctness of the documentation I linked, I think this might have been valid – I'll try to get a more definitive answer

@matthewd

This comment has been minimized.

Show comment
Hide comment
@matthewd

matthewd Apr 15, 2014

Member

@chancancode FWIW, I agree... that sounds more like it's documenting existing behaviour, rather than intended.

Member

matthewd commented Apr 15, 2014

@chancancode FWIW, I agree... that sounds more like it's documenting existing behaviour, rather than intended.

@chancancode

This comment has been minimized.

Show comment
Hide comment
@chancancode

chancancode Apr 15, 2014

Member

So it all started with this docrails commit – 53bbbcc, which the documented (but perhaps buggy) behaviour at that time to what we have now.

@evtuhovich, I know this is a long time ago, but do you remember the back story for this change? Have you confirmed that the behaviour you documented is intended? Thanks in advance 😀

Member

chancancode commented Apr 15, 2014

So it all started with this docrails commit – 53bbbcc, which the documented (but perhaps buggy) behaviour at that time to what we have now.

@evtuhovich, I know this is a long time ago, but do you remember the back story for this change? Have you confirmed that the behaviour you documented is intended? Thanks in advance 😀

@evtuhovich

This comment has been minimized.

Show comment
Hide comment
@evtuhovich

evtuhovich Apr 16, 2014

Contributor

I remember this bug, i spent one day to find a solution.

This was default behaviour for nested transaction, because not all databases support it. I only found this and document. So, if you want consistent nested transaction, always use Model.transaction(:requires_new => true)

Contributor

evtuhovich commented Apr 16, 2014

I remember this bug, i spent one day to find a solution.

This was default behaviour for nested transaction, because not all databases support it. I only found this and document. So, if you want consistent nested transaction, always use Model.transaction(:requires_new => true)

@chancancode

This comment has been minimized.

Show comment
Hide comment
@chancancode

chancancode Apr 16, 2014

Member

I believe the intention was that if you don't use requires_new it should rollback the outermost block, whereas if you do it will rollback that specific block - that was the originally documented behavior. Have you verified with others that the behavior you documented is intened (as opposed to just a buggy implementation)?

Member

chancancode commented Apr 16, 2014

I believe the intention was that if you don't use requires_new it should rollback the outermost block, whereas if you do it will rollback that specific block - that was the originally documented behavior. Have you verified with others that the behavior you documented is intened (as opposed to just a buggy implementation)?

@evtuhovich

This comment has been minimized.

Show comment
Hide comment
@evtuhovich

evtuhovich Apr 16, 2014

Contributor

I also was sure, that rollback should rollback outremost block. But this is wrong. When you rollback inner transaction nothing happens, outer transaction don't get rollback and continue its execution.

And this is why a spent a day to understand that problem in my project.

Contributor

evtuhovich commented Apr 16, 2014

I also was sure, that rollback should rollback outremost block. But this is wrong. When you rollback inner transaction nothing happens, outer transaction don't get rollback and continue its execution.

And this is why a spent a day to understand that problem in my project.

@chancancode

This comment has been minimized.

Show comment
Hide comment
@chancancode

chancancode Apr 16, 2014

Member

Right, if I understand you correctly, you are saying the documentation (at the time) didn't agree with the actual behaviour of the code (at the time), correct? But in that case, it could be either the code or the documentation that is wrong. In this case I suspect it's the former – which is why I asked if you have confirmed this behaviour is desirable (for example, filed a bug on this and was told by a core team member that the docs are wrong).

I'm trying to find those back stories to support this doc change, because it appears otherwise that the code should have been fixed instead.

Member

chancancode commented Apr 16, 2014

Right, if I understand you correctly, you are saying the documentation (at the time) didn't agree with the actual behaviour of the code (at the time), correct? But in that case, it could be either the code or the documentation that is wrong. In this case I suspect it's the former – which is why I asked if you have confirmed this behaviour is desirable (for example, filed a bug on this and was told by a core team member that the docs are wrong).

I'm trying to find those back stories to support this doc change, because it appears otherwise that the code should have been fixed instead.

@evtuhovich

This comment has been minimized.

Show comment
Hide comment
@evtuhovich

evtuhovich Apr 16, 2014

Contributor

I don't think, this is desireble behaviour, i've just documented the state of code at that time, and i hope, this helps other people.

And i sure the code should be fixed, of course.

Contributor

evtuhovich commented Apr 16, 2014

I don't think, this is desireble behaviour, i've just documented the state of code at that time, and i hope, this helps other people.

And i sure the code should be fixed, of course.

@chancancode

This comment has been minimized.

Show comment
Hide comment
@chancancode

chancancode Apr 16, 2014

Member

Thanks for the update! 😄

For the future, please file a bug report for these kind of things – the documentation should reflect how the code should behave, so we shouldn't change the documentation when the code misbehave. This will cause other people to make incorrect inference about the code and result in things like issues incorrectly being closed, or worse – more code being changed to align itself with the buggy behaviour.

Member

chancancode commented Apr 16, 2014

Thanks for the update! 😄

For the future, please file a bug report for these kind of things – the documentation should reflect how the code should behave, so we shouldn't change the documentation when the code misbehave. This will cause other people to make incorrect inference about the code and result in things like issues incorrectly being closed, or worse – more code being changed to align itself with the buggy behaviour.

@chancancode chancancode reopened this Apr 16, 2014

@chancancode

This comment has been minimized.

Show comment
Hide comment
@chancancode
Member

chancancode commented Apr 16, 2014

cc @fxn

chancancode added a commit to chancancode/rails that referenced this issue May 7, 2014

Fixed "fake" nested transactions swallowing manual rollbacks
Previously, `ActiveRecord::Rollback` are silently swallowed by all transaction
blocks. The intention behind this is that these special "exceptions" are only
used to signal a rollback request to Active Record, thus they should be
discarded after the rollback request is handled.

However, this is incorrect for nested transactions. By default, nested
transaction blocks are effectively a no-op – they simply become part of the
parent transaction†. To achieve this, the correct behaviour is to "bubble up"
the `ActiveRecord::Rollback` "exception", which allow the first parent
transaction block with a real database transaction to correctly handle the
rollback.

The documentation has been changed to reflect this. This effectively rolls
back 53bbbcc.

Fixes #3455, #14698.

† The reason that nested transactions defaults to no-op is that savepoints are
  not supported in all databases.

chancancode added a commit to chancancode/rails that referenced this issue May 7, 2014

Fixed "fake" nested transactions swallowing manual rollbacks
Previously, `ActiveRecord::Rollback` are silently swallowed by all transaction
blocks. The intention behind this is that these special "exceptions" are only
used to signal a rollback request to Active Record, thus they should be
discarded after the rollback request is handled.

However, this is incorrect for nested transactions. By default, nested
transaction blocks are effectively a no-op – they simply become part of the
parent transaction†. To achieve this, the correct behaviour is to "bubble up"
the `ActiveRecord::Rollback` "exception", which allow the first parent
transaction block with a real database transaction to correctly handle the
rollback.

The documentation has been changed to reflect this. This effectively rolls
back 53bbbcc.

Fixes #3455, #14698.

† The reason that nested transactions defaults to no-op is that savepoints are
  not supported in all databases.

chancancode added a commit to chancancode/rails that referenced this issue May 8, 2014

Fixed "fake" nested transactions swallowing manual rollbacks
Previously, `ActiveRecord::Rollback` are silently swallowed by all transaction
blocks. The intention behind this is that these special "exceptions" are only
used to signal a rollback request to Active Record, thus they should be
discarded after the rollback request is handled.

However, this is incorrect for nested transactions. By default, nested
transaction blocks are effectively a no-op – they simply become part of the
parent transaction†. To achieve this, the correct behaviour is to "bubble up"
the `ActiveRecord::Rollback` "exception", which allow the first parent
transaction block with a real database transaction to correctly handle the
rollback.

The documentation has been changed to reflect this. This effectively rolls
back 53bbbcc.

Fixes #3455, #14698.

† The reason that nested transactions defaults to no-op is that savepoints are
  not supported in all databases.

*Godfrey Chan*

@chancancode chancancode added JRuby and removed JRuby labels Jun 26, 2014

@chancancode chancancode added this to the 4.2.0 milestone Jul 2, 2014

@chancancode chancancode self-assigned this Jul 2, 2014

@chancancode chancancode removed this from the 4.2.0 milestone Aug 12, 2014

@rails-bot rails-bot added the stale label Nov 19, 2014

@rails-bot

This comment has been minimized.

Show comment
Hide comment
@rails-bot

rails-bot Nov 19, 2014

This issue has been automatically marked as stale because it has not been commented on for at least
three months.

The resources of the Rails team are limited, and so we are asking for your help.

If you can still reproduce this error on the 4-1-stable, 4-0-stable branches or on master,
please reply with all of the information you have about it in order to keep the issue open.

Thank you for all your contributions.

rails-bot commented Nov 19, 2014

This issue has been automatically marked as stale because it has not been commented on for at least
three months.

The resources of the Rails team are limited, and so we are asking for your help.

If you can still reproduce this error on the 4-1-stable, 4-0-stable branches or on master,
please reply with all of the information you have about it in order to keep the issue open.

Thank you for all your contributions.

chancancode added a commit that referenced this issue Jan 10, 2015

Fixed "fake" nested transactions swallowing manual rollbacks
Previously, `ActiveRecord::Rollback` are silently swallowed by all transaction
blocks. The intention behind this is that these special "exceptions" are only
used to signal a rollback request to Active Record, thus they should be
discarded after the rollback request is handled.

However, this is incorrect for nested transactions. By default, nested
transaction blocks are effectively a no-op – they simply become part of the
parent transaction†. To achieve this, the correct behaviour is to "bubble up"
the `ActiveRecord::Rollback` "exception", which allow the first parent
transaction block with a real database transaction to correctly handle the
rollback.

The documentation has been changed to reflect this. This effectively rolls
back 53bbbcc.

Fixes #3455, #14698.

† The reason that nested transactions defaults to no-op is that savepoints are
  not supported in all databases.

*Godfrey Chan*

Conflicts:
	activerecord/CHANGELOG.md
	activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb

chancancode added a commit that referenced this issue Jan 10, 2015

Fixed "fake" nested transactions swallowing manual rollbacks
Previously, `ActiveRecord::Rollback` are silently swallowed by all transaction
blocks. The intention behind this is that these special "exceptions" are only
used to signal a rollback request to Active Record, thus they should be
discarded after the rollback request is handled.

However, this is incorrect for nested transactions. By default, nested
transaction blocks are effectively a no-op – they simply become part of the
parent transaction†. To achieve this, the correct behaviour is to "bubble up"
the `ActiveRecord::Rollback` "exception", which allow the first parent
transaction block with a real database transaction to correctly handle the
rollback.

The documentation has been changed to reflect this. This effectively rolls
back 53bbbcc.

Fixes #3455, #14698.

† The reason that nested transactions defaults to no-op is that savepoints are
  not supported in all databases.

*Godfrey Chan*

Conflicts:
	activerecord/CHANGELOG.md
	activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment