Skip to content

Commit 02f6c29

Browse files
authored
Merge pull request rails#51453 from fatkodima/active-counter-caches
Add the ability to ignore counter cache columns while they are backfilling
2 parents 4a7c86a + e79455f commit 02f6c29

File tree

12 files changed

+134
-14
lines changed

12 files changed

+134
-14
lines changed

activerecord/CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,27 @@
1+
* Add the ability to ignore counter cache columns until they are backfilled
2+
3+
Starting to use counter caches on existing large tables can be troublesome, because the column
4+
values must be backfilled separately of the column addition (to not lock the table for too long)
5+
and before the use of `:counter_cache` (otherwise methods like `size`/`any?`/etc, which use
6+
counter caches internally, can produce incorrect results). People usually use database triggers
7+
or callbacks on child associations while backfilling before introducing a counter cache
8+
configuration to the association.
9+
10+
Now, to safely backfill the column, while keeping the column updated with child records added/removed, use:
11+
12+
```ruby
13+
class Comment < ApplicationRecord
14+
belongs_to :post, counter_cache: { active: false }
15+
end
16+
```
17+
18+
While the counter cache is not "active", the methods like `size`/`any?`/etc will not use it,
19+
but get the results directly from the database. After the counter cache column is backfilled, simply
20+
remove the `{ active: false }` part from the counter cache definition, and it will now be used by the
21+
mentioned methods.
22+
23+
*fatkodima*
24+
125
* Retry known idempotent SELECT queries on connection-related exceptions
226

327
SELECT queries we construct by walking the Arel tree and / or with known model attributes

activerecord/lib/active_record/associations.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1819,6 +1819,16 @@ def has_one(name, scope = nil, **options)
18191819
# return the count cached, see note below). You can also specify a custom counter
18201820
# cache column by providing a column name instead of a +true+/+false+ value to this
18211821
# option (e.g., <tt>counter_cache: :my_custom_counter</tt>.)
1822+
#
1823+
# Starting to use counter caches on existing large tables can be troublesome, because the column
1824+
# values must be backfilled separately of the column addition (to not lock the table for too long)
1825+
# and before the use of +:counter_cache+ (otherwise methods like +size+/+any?+/etc, which use
1826+
# counter caches internally, can produce incorrect results). To safely backfill the values while keeping
1827+
# counter cache columns updated with the child records creation/removal and to avoid the mentioned methods
1828+
# use the possibly incorrect counter cache column values and always get the results from the database,
1829+
# use <tt>counter_cache: { active: false }</tt>.
1830+
# If you also need to specify a custom column name, use <tt>counter_cache: { active: false, column: :my_custom_counter }</tt>.
1831+
#
18221832
# Note: Specifying a counter cache will add it to that model's list of readonly attributes
18231833
# using +attr_readonly+.
18241834
# [+:polymorphic+]

activerecord/lib/active_record/associations/collection_association.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ def size
228228
# loaded and you are going to fetch the records anyway it is better to
229229
# check <tt>collection.length.zero?</tt>.
230230
def empty?
231-
if loaded? || @association_ids || reflection.has_cached_counter?
231+
if loaded? || @association_ids || reflection.has_active_cached_counter?
232232
size.zero?
233233
else
234234
target.empty? && !scope.exists?

activerecord/lib/active_record/associations/has_many_association.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def insert_record(record, validate = true, raise = false)
7878
# If the collection is empty the target is set to an empty array and
7979
# the loaded flag is set to true as well.
8080
def count_records
81-
count = if reflection.has_cached_counter?
81+
count = if reflection.has_active_cached_counter?
8282
owner.read_attribute(reflection.counter_cache_column).to_i
8383
else
8484
scope.count(:all)

activerecord/lib/active_record/reflection.rb

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,16 @@ def constraints
236236
end
237237

238238
def counter_cache_column
239-
@counter_cache_column ||= if belongs_to?
240-
if options[:counter_cache] == true
241-
-"#{active_record.name.demodulize.underscore.pluralize}_count"
242-
elsif options[:counter_cache]
243-
-options[:counter_cache].to_s
239+
@counter_cache_column ||= begin
240+
counter_cache = options[:counter_cache]
241+
242+
if belongs_to?
243+
if counter_cache
244+
counter_cache[:column] || -"#{active_record.name.demodulize.underscore.pluralize}_count"
245+
end
246+
else
247+
-((counter_cache && -counter_cache[:column]) || "#{name}_count")
244248
end
245-
else
246-
-(options[:counter_cache]&.to_s || "#{name}_count")
247249
end
248250
end
249251

@@ -292,7 +294,7 @@ def inverse_updates_counter_in_memory?
292294
inverse_of && inverse_which_updates_counter_cache == inverse_of
293295
end
294296

295-
# Returns whether a counter cache should be used for this association.
297+
# Returns whether this association has a counter cache.
296298
#
297299
# The counter_cache option must be given on either the owner or inverse
298300
# association, and the column must be present on the owner.
@@ -302,6 +304,17 @@ def has_cached_counter?
302304
active_record.has_attribute?(counter_cache_column)
303305
end
304306

307+
# Returns whether this association has a counter cache and its column values were backfilled
308+
# (and so it is used internally by methods like +size+/+any?+/etc).
309+
def has_active_cached_counter?
310+
return false unless has_cached_counter?
311+
312+
counter_cache = options[:counter_cache] ||
313+
(inverse_which_updates_counter_cache && inverse_which_updates_counter_cache.options[:counter_cache])
314+
315+
counter_cache[:active] != false
316+
end
317+
305318
def counter_must_be_updated_by_has_many?
306319
!inverse_updates_counter_in_memory? && has_cached_counter?
307320
end
@@ -378,7 +391,7 @@ def initialize(name, scope, options, active_record)
378391
super()
379392
@name = name
380393
@scope = scope
381-
@options = options
394+
@options = normalize_options(options)
382395
@active_record = active_record
383396
@klass = options[:anonymous_class]
384397
@plural_name = active_record.pluralize_table_names ?
@@ -434,6 +447,26 @@ def scope_for(relation, owner = nil)
434447
def derive_class_name
435448
name.to_s.camelize
436449
end
450+
451+
def normalize_options(options)
452+
counter_cache = options.delete(:counter_cache)
453+
454+
if counter_cache
455+
active = true
456+
457+
case counter_cache
458+
when String, Symbol
459+
column = -counter_cache.to_s
460+
when Hash
461+
active = counter_cache.fetch(:active, true)
462+
column = counter_cache[:column]&.to_s
463+
end
464+
465+
options[:counter_cache] = { active: active, column: column }
466+
end
467+
468+
options
469+
end
437470
end
438471

439472
# Holds all the metadata about an aggregation as it was specified in the

activerecord/test/cases/counter_cache_test.rb

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require "models/aircraft"
99
require "models/wheel"
1010
require "models/engine"
11+
require "models/tyre"
1112
require "models/reply"
1213
require "models/category"
1314
require "models/categorization"
@@ -443,6 +444,43 @@ class ::SpecialReply < ::Reply
443444
assert_not Car.counter_cache_column?("cars_count")
444445
end
445446

447+
test "inactive conter cache" do
448+
car = Car.new
449+
car.bulbs = [Bulb.new, Bulb.new]
450+
car.save!
451+
452+
assert_equal 2, car.bulbs_count
453+
car.reload
454+
455+
assert_queries_count(5) do
456+
assert_equal 2, car.bulbs.size
457+
assert_equal 2, car.bulbs.count
458+
assert_not_predicate car.bulbs, :empty?
459+
assert_predicate car.bulbs, :any?
460+
assert_not_predicate car.bulbs, :none?
461+
end
462+
end
463+
464+
test "active conter cache" do
465+
car = Car.new
466+
car.tyres = [Tyre.new, Tyre.new]
467+
car.save!
468+
469+
assert_equal 2, car.custom_tyres_count
470+
car.reload
471+
472+
assert_no_queries do
473+
assert_equal 2, car.tyres.size
474+
assert_not_predicate car.tyres, :empty?
475+
assert_predicate car.tyres, :any?
476+
assert_not_predicate car.tyres, :none?
477+
end
478+
479+
assert_queries_count(1) do
480+
assert_equal 2, car.tyres.count
481+
end
482+
end
483+
446484
private
447485
def assert_touching(record, *attributes)
448486
record.update_columns attributes.index_with(5.minutes.ago)

activerecord/test/fixtures/cars.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@ honda:
22
id: 1
33
name: honda
44
engines_count: 0
5+
bulbs_count: 0
6+
custom_tyres_count: 0
57
person_id: 1
68

79
zyke:
810
id: 2
911
name: zyke
1012
engines_count: 0
13+
bulbs_count: 0
14+
custom_tyres_count: 0
1115
person_id: 2

activerecord/test/models/bulb.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
class Bulb < ActiveRecord::Base
44
default_scope { where(name: "defaulty") }
5-
belongs_to :car, touch: true
5+
belongs_to :car, touch: true, counter_cache: { active: false }
66
scope :awesome, -> { where(frickinawesome: true) }
77

88
attr_reader :scope_after_initialize, :attributes_after_initialize, :count_after_create

activerecord/test/models/car.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Car < ActiveRecord::Base
1414

1515
has_one :bulb
1616

17-
has_many :tyres
17+
has_many :tyres, counter_cache: :custom_tyres_count
1818
has_many :engines, dependent: :destroy, inverse_of: :my_car
1919
has_many :wheels, as: :wheelable, dependent: :destroy
2020

activerecord/test/models/tyre.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# frozen_string_literal: true
22

33
class Tyre < ActiveRecord::Base
4-
belongs_to :car
4+
belongs_to :car, counter_cache: { active: true, column: :custom_tyres_count }
55

66
def self.custom_find(id)
77
find(id)

0 commit comments

Comments
 (0)