Stop incrementing counter caches on association assignation#14849
Stop incrementing counter caches on association assignation#14849byroot wants to merge 2 commits intorails:masterfrom
Conversation
|
Oh and yes I added |
|
I will pull your branch to learn more and see what I can help |
|
@tenderlove @rafaelfranca: I suppose you were busy because of Rails conf, any chance you could review this PR next week? |
There was a problem hiding this comment.
Nowhere. It's dead code, I'll get rid of it, sorry for that.
|
@jeremy thanks a lot for this very good code review, and sorry for the dumb mistakes I'll fix that ASAP. In the meantime any idea on how I should take care of the failing test case? (i.e. the Limitations section of the PR) |
|
Agree that test case doesn't even make sense. Also, major 👍❤️👍 for fixing has_many as well 😁 |
Great, but what should I do? Just deleting it? |
There was a problem hiding this comment.
@jeremy So now I pre-update all foreign keys in separate UPDATE statements so I can bail out if it has already been updated.
Note that it only prevent race conditions where the same UPDATE is performed multiple times concurrently.
If the updates are different then the the counter will be desynchronized, the only way to prevent that is to lock and reload the record.
So it's definitely more work for the DB as discussed here: #14735 (comment)
Are you ok with that solution?
|
@jeremy ping? |
|
Looking good @byroot. Could use a second pair of eyes to review the concept and implementation more closely as well. cc @matthewd @eileencodes @tenderlove? |
There was a problem hiding this comment.
Personally I don't like when assignment happens in the conditional like this — I think it can be confusing to read and I always have to think twice about what the code is doing. Not sure what Rails prefers though 😄
This isn't a deal breaker for me but a personal preference.
There was a problem hiding this comment.
It's a fairly common pattern in Ruby community (and C).
As for it's usage throughout Rails code base:
$ ag --nogroup 'if \w+ =' | wc -l
516
There was a problem hiding this comment.
Actually, my regexp was wrong, it's less than that:
$ ag --nogroup 'if \w+ =\s' | wc -l
162
There was a problem hiding this comment.
I know it's pretty common usage but like I said it's a personal preference but not a dealbreaker. 😸
There was a problem hiding this comment.
also sorry that I posted the first message 3 times - github got weird 😳
There was a problem hiding this comment.
Isn't this essentially checking the existence of the "klass" variable twice? previous_klass checks it with a try, and now we check it again here? Can we do the check once?
It's a very bad time to perform the increment because the foreign_key change is not persisted yet and it happen outside the save transaction, so if the update fail the increment is not rollbacked.
|
I rebased master and I'm now puzzled by a broken test concerning optimistic locking and polymorphic associations with counter cache: def test_polymorphic_destroy_with_dependencies_and_lock_version
car = Car.create!
assert_difference 'car.wheels.count' do
car.wheels << Wheel.create!
end
assert_difference 'car.wheels.count', -1 do
car.destroy
end
assert car.destroyed?
endOn master (0.1ms) SAVEPOINT active_record_1
SQL (0.4ms) INSERT INTO "cars" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2014-08-03 16:07:21.105885"], ["updated_at", "2014-08-03 16:07:21.105885"]]
(0.0ms) RELEASE SAVEPOINT active_record_1
(0.1ms) SELECT COUNT(*) FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.0ms) SAVEPOINT active_record_1
SQL (0.8ms) INSERT INTO "wheels" DEFAULT VALUES
(0.1ms) RELEASE SAVEPOINT active_record_1
(0.0ms) SAVEPOINT active_record_1
SQL (0.2ms) UPDATE "wheels" SET "wheelable_id" = ?, "wheelable_type" = ? WHERE "wheels"."id" = 1 [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.1ms) RELEASE SAVEPOINT active_record_1
(0.1ms) SELECT COUNT(*) FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.1ms) SELECT COUNT(*) FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.1ms) SAVEPOINT active_record_1
FunkyBulb Load (0.2ms) SELECT "bulbs".* FROM "bulbs" WHERE "bulbs"."name" = ? AND "bulbs"."car_id" = ? [["name", "defaulty"], ["car_id", 1]]
FailedBulb Load (0.1ms) SELECT "bulbs".* FROM "bulbs" WHERE "bulbs"."name" = ? AND "bulbs"."car_id" = ? [["name", "defaulty"], ["car_id", 1]]
Engine Load (0.1ms) SELECT "engines".* FROM "engines" WHERE "engines"."car_id" = ? [["car_id", 1]]
Wheel Load (0.1ms) SELECT "wheels".* FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
SQL (0.1ms) DELETE FROM "wheels" WHERE "wheels"."id" = ? [["id", 1]]
SQL (0.1ms) DELETE FROM "cars" WHERE "cars"."id" = ? AND "cars"."lock_version" = ? [["id", 1], ["lock_version", 0]]
(0.0ms) RELEASE SAVEPOINT active_record_1
(0.1ms) SELECT COUNT(*) FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]But in my branch it is, which bump the lock version and make the test fail: (0.0ms) SAVEPOINT active_record_1
SQL (0.3ms) INSERT INTO "cars" ("created_at", "updated_at") VALUES (?, ?) [["created_at", "2014-08-03 16:10:52.696002"], ["updated_at", "2014-08-03 16:10:52.696002"]]
(0.0ms) RELEASE SAVEPOINT active_record_1
(0.1ms) SELECT COUNT(*) FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.0ms) SAVEPOINT active_record_1
SQL (1.1ms) INSERT INTO "wheels" DEFAULT VALUES
(0.1ms) RELEASE SAVEPOINT active_record_1
(0.0ms) SAVEPOINT active_record_1
SQL (0.3ms) UPDATE "wheels" SET "wheelable_id" = 1 WHERE "wheels"."id" = ? AND "wheels"."wheelable_id" IS NULL [["id", 1]]
SQL (0.2ms) UPDATE "cars" SET "wheels_count" = COALESCE("wheels_count", 0) + 1, "lock_version" = COALESCE("lock_version", 0) + 1 WHERE "cars"."id" = ? [["id", 1]]
SQL (0.1ms) UPDATE "wheels" SET "wheelable_id" = ?, "wheelable_type" = ? WHERE "wheels"."id" = 1 [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.0ms) RELEASE SAVEPOINT active_record_1
(0.1ms) SELECT COUNT(*) FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.1ms) SELECT COUNT(*) FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
(0.0ms) SAVEPOINT active_record_1
FunkyBulb Load (0.1ms) SELECT "bulbs".* FROM "bulbs" WHERE "bulbs"."name" = ? AND "bulbs"."car_id" = ? [["name", "defaulty"], ["car_id", 1]]
FailedBulb Load (0.1ms) SELECT "bulbs".* FROM "bulbs" WHERE "bulbs"."name" = ? AND "bulbs"."car_id" = ? [["name", "defaulty"], ["car_id", 1]]
Engine Load (0.1ms) SELECT "engines".* FROM "engines" WHERE "engines"."car_id" = ? [["car_id", 1]]
Wheel Load (0.1ms) SELECT "wheels".* FROM "wheels" WHERE "wheels"."wheelable_id" = ? AND "wheels"."wheelable_type" = ? [["wheelable_id", 1], ["wheelable_type", "Car"]]
SQL (0.1ms) DELETE FROM "wheels" WHERE "wheels"."id" = ? [["id", 1]]
SQL (0.1ms) DELETE FROM "cars" WHERE "cars"."id" = ? AND "cars"."lock_version" = ? [["id", 1], ["lock_version", 0]]So I tend to think the bug is on master, since the @tenderlove what do you think? |
|
Just wondering if this bug is still out there. I believe it is and I've been using the counter culture gem instead in my rails4 app. I am wondering if perhaps none of this logic (aside from the code to show the cached count) should even be part of the rails core since it does seem so hard to get right. A concern I would have about completely stripping it out and just letting third party gems extend the association to redefine |
|
The bug is still there. The race condition on destroy have been fixed, but it can still happen on update. It's indeed very hard to get right, or at least without big drawbacks like generating more queries, and I definitely recommend against updating a foreign key that had a counter cache. |
This is a 4th attempt to make counter cache transactional completely. Past attempts: rails#9236, rails#14849, rails#23357. All existing counter cache issues (increment/decrement twice, lost increment) are caused due to updating counter cache on the outside of the record saving transaction by assigning belongs_to record, even though assigning that doesn't cause the record saving. We have the `@_after_replace_counter_called` guard condition to mitigate double increment/decrement issues, but we can't completely prevent that inconsistency as long as updating counter cache on the outside of the transaction, since saving the record is not always happened after that. We already have handling counter cache after create/update/destroy, https://github.com/rails/rails/blob/1b90f614b1b3d06b7f02a8b9ea6cd84f15d58643/activerecord/lib/active_record/counter_cache.rb#L162-L189 https://github.com/rails/rails/blob/1b90f614b1b3d06b7f02a8b9ea6cd84f15d58643/activerecord/lib/active_record/associations/builder/belongs_to.rb#L33-L59 so just removing assigning logic on the belongs_to association makes counter cache transactional completely. Closes rails#14849. Closes rails#23357. Closes rails#31493. Closes rails#31494. Closes rails#32372. Closes rails#33113. Closes rails#33117 Closes rails#33129. Closes rails#33458.
Followup of #14765 and #14735
Similar in some ways to #9236
Fixes #13304 among others
Rationale
It's a very bad time to perform the increment because the foreign_key
change is not persisted yet and it happen outside the save transaction,
so if the update fail the increment is not rollbacked.
Limitation
I can't make
BelongsToAssociationsTest#test_custom_counter_cachework, becauseReplybelongs toTopictwice, so ActiveRecord think it's 2 different counters.I'm not sure what to do with that. I think it just don't make any sense. Maybe we could prevent targeting the same counter from two associations.
Further work
The same issue lies in
has_manyassociation the counters are decremented outside any transaction beside no record have been actually deleted. I'll see what I can do about that in a further PR if you don't mind.@tenderlove @arthurnn @rafaelfranca thoughts?
/cc @jasl