Open richardboehme opened 2 years ago
Great report. I can replicate the issue, I can also confirm that if you add self.use_transactional_tests = false
to the tests then the issue goes away.
I added some logging to the test, here's the SQL being run in the failing test:
D, [2022-03-17T10:20:49.909316 #35545] DEBUG -- : TRANSACTION (0.0ms) begin transaction
D, [2022-03-17T10:20:49.914272 #35545] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.914573 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:20:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.915049 #35545] DEBUG -- : TestObject Create (0.3ms) INSERT INTO "test_objects" ("field", "timestamps", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["field", "test"], ["timestamps", nil], ["created_at", "2022-03-17 15:20:49.913840"], ["updated_at", "2022-03-17 15:20:49.913840"]]
D, [2022-03-17T10:20:49.915279 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:20:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.915450 #35545] DEBUG -- : TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.915616 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:20:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.916140 #35545] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.916419 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:23:in `block (2 levels) in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.916585 #35545] DEBUG -- : TestObject Update (0.1ms) UPDATE "test_objects" SET "field" = ?, "updated_at" = ? WHERE "test_objects"."id" = ? [["field", "test2"], ["updated_at", "2022-03-17 15:20:49.915813"], ["id", 980190967]]
D, [2022-03-17T10:20:49.916813 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:23:in `block (2 levels) in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.916971 #35545] DEBUG -- : TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.917137 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:22:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.917581 #35545] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.917855 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:26:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.917999 #35545] DEBUG -- : TestObject Update (0.1ms) UPDATE "test_objects" SET "updated_at" = ? WHERE "test_objects"."id" = ? [["updated_at", "2022-03-17 15:20:49.917213"], ["id", 980190967]]
D, [2022-03-17T10:20:49.918222 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:26:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.918361 #35545] DEBUG -- : TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.918516 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:26:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.919008 #35545] DEBUG -- : TestObject Load (0.1ms) SELECT "test_objects".* FROM "test_objects" WHERE "test_objects"."id" = ? LIMIT ? [["id", 980190967], ["LIMIT", 1]]
D, [2022-03-17T10:20:49.919211 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:28:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.919756 #35545] DEBUG -- : TRANSACTION (0.3ms) rollback transaction
F
Failure:
TestObjectTest#test_dirty_transactions_with_update [/Users/alex/Code/rails-dirty-transaction-update/test/models/test_object_test.rb:28]:
Expected: "test2"
Actual: "test"
And in the passing test:
D, [2022-03-17T10:20:49.920437 #35545] DEBUG -- : TRANSACTION (0.0ms) begin transaction
D, [2022-03-17T10:20:49.921111 #35545] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.921376 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:7:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.921774 #35545] DEBUG -- : TestObject Create (0.3ms) INSERT INTO "test_objects" ("field", "timestamps", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["field", "test"], ["timestamps", nil], ["created_at", "2022-03-17 15:20:49.920881"], ["updated_at", "2022-03-17 15:20:49.920881"]]
D, [2022-03-17T10:20:49.921997 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:7:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.933194 #35545] DEBUG -- : TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.933397 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:7:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.933877 #35545] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.934121 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:11:in `block (2 levels) in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.934296 #35545] DEBUG -- : TestObject Update (0.1ms) UPDATE "test_objects" SET "field" = ?, "updated_at" = ? WHERE "test_objects"."id" = ? [["field", "test2"], ["updated_at", "2022-03-17 15:20:49.933576"], ["id", 980190967]]
D, [2022-03-17T10:20:49.934487 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:11:in `block (2 levels) in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.934629 #35545] DEBUG -- : TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.934787 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:9:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.935228 #35545] DEBUG -- : TRANSACTION (0.0ms) SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.935486 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:14:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.935627 #35545] DEBUG -- : TestObject Update (0.0ms) UPDATE "test_objects" SET "field" = ?, "updated_at" = ? WHERE "test_objects"."id" = ? [["field", "test2"], ["updated_at", "2022-03-17 15:20:49.934894"], ["id", 980190967]]
D, [2022-03-17T10:20:49.935868 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:14:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.935988 #35545] DEBUG -- : TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
D, [2022-03-17T10:20:49.936133 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:14:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.936492 #35545] DEBUG -- : TestObject Load (0.0ms) SELECT "test_objects".* FROM "test_objects" WHERE "test_objects"."id" = ? LIMIT ? [["id", 980190967], ["LIMIT", 1]]
D, [2022-03-17T10:20:49.936716 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:16:in `block in <class:TestObjectTest>'
D, [2022-03-17T10:20:49.937185 #35545] DEBUG -- : TRANSACTION (0.3ms) rollback transaction
I think the key is this, in the passing test:
D, [2022-03-17T10:20:49.935627 #35545] DEBUG -- : TestObject Update (0.0ms) UPDATE "test_objects" SET "field" = ?, "updated_at" = ? WHERE "test_objects"."id" = ? [["field", "test2"], ["updated_at", "2022-03-17 15:20:49.934894"], ["id", 980190967]]
D, [2022-03-17T10:20:49.935868 #35545] DEBUG -- : ↳ test/models/test_object_test.rb:14:in `block in <class:TestObjectTest>'
Basically the "test2" change is being rolled back, but then when you do test.update(updated_at: Time.current)
it's being saved again. If you remove that line from both tests, they both end in the same state (field = "test").
This makes me wonder...
The test and the script should print the same values for the updated database field (test2).
Why do you expect this? The change to field
is rolled back. Is it because of the seemingly-unrelated change to updated_at
?
(I agree there's probably a bug here since the behaviour is different between test and the runner, I just want to make sure we're on the same page about what the expected behaviour is.)
Basically the "test2" change is being rolled back, but then when you do
test.update(updated_at: Time.current)
it's being saved again. If you remove that line from both tests, they both end in the same state (field = "test").This makes me wonder...
The test and the script should print the same values for the updated database field (test2).
Why do you expect this? The change to
field
is rolled back. Is it because of the seemingly-unrelated change toupdated_at
? (I agree there's probably a bug here since the behaviour is different between test and the runner, I just want to make sure we're on the same page about what the expected behaviour is.)
Thanks for looking into this!
As far as I know if we rollback a transaction the model itself is not being "rolled" back. This is why I assume that the model stays with field = "test2"
. It just was not updated in the database because of the rollback. The second update then implicitly updates the "field" column. This is exactly what happend in one of our code bases (by accident). I wanted to write a test for it and it magically passed even though I did not change the behavior. This is how I noticed, that there must be a difference between running this in a test and running it in dev/prod.
I agree it's kind of unexpected that the update of the updated_at
column would update the field
column as well but, looking at the implementation of update
, it kind of makes sense.
Ahh yep i suppose that makes sense. The object isn't being rolled back, but changes
is resetting.
class TestObjectTest < ActiveSupport::TestCase
test 'dirty transactions with save' do
test = TestObject.create(field: 'test')
ActiveRecord::Base.transaction do # Transaction by business logic
test.field = 'test2'
test.save
raise ActiveRecord::Rollback # something fails
end
assert_equal "test2", test.field
assert test.changes.key?("field")
test.update(updated_at: Time.current)
assert_equal "test2", test.reload.field
end
test 'dirty transactions with update' do
test = TestObject.create(field: 'test')
ActiveRecord::Base.transaction do # Transaction by business logic
test.update(field: 'test2')
raise ActiveRecord::Rollback # something fails
end
assert_equal "test2", test.field
assert test.changes.key?("field"), test.changes # fails here, changes is {}
test.update(updated_at: Time.current)
assert_equal "test2", test.reload.field
end
end
Yeah it seems like this is the problem... This is why the save called by the second update
does not pick it up.
A few more observations:
All the rollbacks go through here: https://github.com/rails/rails/blob/0169d15bc7ec4557971d6ac6120e48b2cac9c407/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L156
In test, full_rollback?
is false, because a SavepointTransaction
is being used, presumably because you're already inside a transaction (from the test) so the stack isn't empty: https://github.com/rails/rails/blob/0169d15bc7ec4557971d6ac6120e48b2cac9c407/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L303..L319
In the rails runner, full_rollback?
is true and a RealTransaction
is being used.
This matters because this force param eventually ends up here: https://github.com/rails/rails/blob/18707ab17fa492eb25ad2e8f9818a320dc20b823/activerecord/lib/active_record/transactions.rb#L398
And I think that's why changes
isn't being reset and is ending up as {}
- the mutation reset happens in that method.
Interesting... It uses the SavepointTransaction because DBMS like postgreSQL do not support nested transactions, right?
I wonder why it works with assign + save though. I guess the difference is, that update
calls with_transaction_returning_status
before assigning the attributes, while the other method will only call with_transaction_returning_status
when calling save
(after the attribute was already set).
With this information I could reproduce the problem outside of tests, using this script:
test = TestObject.create(field: 'bar')
test.transaction do
test.transaction(requires_new: true) do
test.update(field: 'foo')
raise ActiveRecord::Rollback
end
puts test.changes.key?("field")
end
We would expect test.changes.key?("field")
to be true but it returns false. I wonder how this can be fixed though...
This patch makes all your tests pass (both in test and via the runner): https://github.com/rails/rails/compare/main...ghiculescu:double-transaction-in-test
The issue is that remember_transaction_record_state
was being called twice on the update
path. Once from update
, once from save
. This means @_start_transaction_state[:level] += 1
was called twice, which then means that the check in restore_transaction_record_state
failed so it would never restore anything. Everything else in remember_transaction_record_state
is idempotent, so a better fix might be to make the incrementing thing also idempotent... I just don't see any neater ways to do it.
This patch makes all your tests pass (both in test and via the runner):
That's super cool! I already thought about this double transaction that we do in update
might be a problem. With your explanation this whole think starts to make a lot of sense!
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 7-0-stable
branch or on main
, please reply with all of the information you have about it in order to keep the issue open.
Thank you for all your contributions.
Steps to reproduce
rails test test/models/test_object_test.rb
rails runner test_transactions.rb
Note: I tried using the ActiveRecord bug report template but I could not get the test case to behave like a 'normal' Rails test case (that uses transactional tests).
Expected behavior
The test and the script should print the same values for the updated database field (
test2
). It should not matter whether we useupdate
or assign the attribute and usesave
.Actual behavior
The test will print
test
for the version that uses theupdate
method andtest2
for the version that uses an assign and a save. I'm not sure if this is an actual bug or expected behavior, but it took me a lot of time to figure out why my test case was not failing (but fails on prod/dev).I'm not into the internals of ActiveRecord but looking into the source of
update
I saw that there will be an additional transaction state remembered for the model (usingwith_transaction_returning_status
which callsremember_transaction_record_state
) when usingupdate
. Maybe this results in some invalid state if the update-transaction is shared with a business logic transaction above?System configuration
Rails version: 7.0.2.3
Ruby version: 2.7.0