rails: Counter cache does not work well with optimistic locking

Counter cache and optimistic locking don’t play well together. The example below illustrated the problem:

require 'active_record'
require 'minitest/autorun'
require 'sqlite3'
require 'logger'

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection 'sqlite3::memory:'

ActiveRecord::Schema.define do
  create_table :people do |t|
    t.string :name
    t.integer :lock_version, null: false, default: 0
    t.integer :vehicles_count, null: false, default: 0
    t.integer :jobs_count, null: false, default: 0
    t.timestamps
  end

  create_table :jobs do |t|
    t.belongs_to :person
  end

  create_table :vehicles do |t|
    t.belongs_to :owner, polymorphic: true
  end
end

class Person < ActiveRecord::Base
  has_many :jobs,                 dependent: :destroy, counter_cache: true
  has_many :vehicles, as: :owner, dependent: :destroy, counter_cache: true
end

class Job < ActiveRecord::Base
  belongs_to :person, counter_cache: true
end

class Vehicle < ActiveRecord::Base
  belongs_to :owner, polymorphic: true, counter_cache: true
end

class BugTest < Minitest::Test
  def test_locking_simple_non_polymorphic
    person = Person.create!
    person.jobs << Job.create!

    # Fails on the next expression. jobs#<< updates the lock_version in the
    # database, but not in the loaded object. Raises StaleObjectError.

    person.destroy
  end

  def test_locking_simple_polymorphic
    person = Person.create!
    person.vehicles << Vehicle.create!

    # Should fail on the next expression if counter_cache worked for
    # polymorphic relationships. It doesn't at the time of writing so the
    # test passes. Fails on top of #16445

    person.destroy
  end
end

Note that one of the tests passes, because counter cache does not work on polymorphic associations. If that is fixed (#16445), both tests will fail.

As far as I can tell, the expected behaviour is for counter cache to bump the lock_version. It does so in the database, but in some situations it fails to update it in the loaded object. I’ve created a gist with test cases that tests this behaviour in detail. All of the tests currently fail.

One way to get the tests in the gist to pass is to add touch: true to both belongs_to relationships (after applying #16445 and #16448, which fix some bugs). touch: true seems to do a good job of updating lock_version in the loaded model when necessary.

I see three possible ways to resolve this issue:

  1. Don’t bump lock_version for counter_cache. This makes some sense to me – the version is not bumped if there is no counter_cache and I don’t see why adding a cache should change this. The user can always intentionally get it by passing touch: true.
  2. Have the counter cache do what touch: true does. I gave this a shot, but couldn’t get it to work in all cases. It might introduce some duplication between counter_cache and touch. It makes the counter_cache code very aware of (and non-orthogonal to) the optimistic locking code.
  3. Always set touch: true in the association when there is a counter cache. It’s a cheap solution 😄 . Not sure how it can be implemented, but it’s seems to be the simplest thing that will (1) preserve the behaviour and (2) fix the issue. Also – no duplication.

There might be other possibilities that I haven’t thought of.

My personal favourite is the first one. It gives the user the most options and it doesn’t require changing the counter_cache too much. Not changing the lock_version behaviour also makes sense to me. However, I understand it might not be the desired solution.

Thoughts?

/cc @senny

P.S.: Whichever of the three (or more) options makes the most sense, I’d love to try implementing it 😄

About this issue

  • Original URL
  • State: closed
  • Created 10 years ago
  • Comments: 21 (4 by maintainers)

Most upvoted comments

Unless I’m missing something, at first glance I believe there’s a straightforward solution: Bump up an ActiveRecord instance’s lock version after incrementing/decrementing a cache_counter column, such that the ActiveRecord instance’s lock version remains in parity with the DB.

I’m going to think about this a little more before submitting a PR, but intend to submit a patch within the next week or so.

First draft/proof of concept: https://github.com/alipman88/rails/commit/01b3c76aeca815e33608941fa3b0a8d16161ff35

(I think the actual fix may end up living somewhere else.)

I’ve created a new minimal reproduction script based on @yan-hoose’s comment https://github.com/rails/rails/issues/16449#issuecomment-521215501.

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  # Activate the gem you are reporting the issue against.
  gem "activerecord", "6.0.3"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# 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 :posts, force: true do |t|
    t.string :title
    t.integer :lock_version, default: 0
    t.integer :comments_count, default: 0
  end

  create_table :comments, force: true do |t|
    t.integer :post_id
  end
end

class Post < ActiveRecord::Base
  has_many :comments
end

class Comment < ActiveRecord::Base
  belongs_to :post, counter_cache: true
end

class BugTest < Minitest::Test
  def test_the_bug
    post = Post.create!

    # Updates the lock_version in the DB but not on the Ruby object
    post.comments.create!

    # Raises an ActiveRecord::StaleObjectError
    post.update!(title: "foo")

    assert_equal "foo", post.title
  end
end

@tbcooney I don’t think there’s any official workaround at the moment, so do whatever works for you. 👍

Another option, if it’s possible in your app, might be to re-order the operations. For example, change from:

post.comments.create!
post.update!(title: "foo")  # raises ActiveRecord::StaleObjectError

To:

post.update!(title: "foo")
post.comments.create!  # nothing raised