custom_counter_cache 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +85 -0
- data/README.rdoc +208 -34
- data/lib/custom_counter_cache/dispatcher.rb +101 -0
- data/lib/custom_counter_cache/model.rb +188 -62
- data/lib/custom_counter_cache/railtie.rb +3 -0
- data/lib/custom_counter_cache/recount_job.rb +16 -0
- data/lib/custom_counter_cache/version.rb +1 -1
- data/lib/custom_counter_cache.rb +35 -1
- data/lib/tasks/custom_counter_cache.rake +12 -0
- metadata +59 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 56544a2488e48dc35c3ad64e7eb7cb0bc2c0be1bc3f5aa8ee5a52a349de2fcb8
|
|
4
|
+
data.tar.gz: 5d40812f87a2d9ce720e77558ac1cec17c81a607170d2a9e690b56bcc9481e29
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 287db2bc86cca62e838cc32849eb1bb43fe66f9d40e771981bb56df221617e6bdf4392950808cafa031afb8a90fe9682ca68053bc0867ad5df802a76ca47f073
|
|
7
|
+
data.tar.gz: e096528b08c44673508784b4faa7bdaa500d5f5f30b5844d4d2a68a1dcf77ff241df30d4805ad58eb22f126f6fb821fc9214c5114fdf4ee520eca44e15ee3981
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0
|
|
4
|
+
|
|
5
|
+
### Breaking
|
|
6
|
+
|
|
7
|
+
- Requires Ruby >= 3.3 and Rails (Active Record) >= 8.0.
|
|
8
|
+
- `:if`/`:unless` on `update_counter_cache` now only gate the create and update callbacks; a
|
|
9
|
+
destroy always recounts. Use `except: [:destroy]` to skip it.
|
|
10
|
+
- Column counters are written with `update_column`: recounts no longer save other unsaved
|
|
11
|
+
changes on the owner, touch its `updated_at`, run its callbacks or bump `lock_version`.
|
|
12
|
+
- `update_counter_cache` raises `ArgumentError` for associations other than `belongs_to`
|
|
13
|
+
(they have never worked).
|
|
14
|
+
- Every model that calls `define_counter_cache` gets the `counters` association, not only models
|
|
15
|
+
with a counter that has no column.
|
|
16
|
+
- Defining counters on a table that doesn't exist no longer silently defines nothing.
|
|
17
|
+
- Recounts triggered by `update_counter_cache` callbacks run after the saving transaction commits
|
|
18
|
+
instead of inside it: once per owner and counter per transaction, not at all on rollback, and
|
|
19
|
+
under a short lock on the owner's row. Counting inside the transaction couldn't see a concurrent
|
|
20
|
+
save's uncommitted child, so concurrent saves to the same owner left a stale count (on
|
|
21
|
+
PostgreSQL, 399 of 400 concurrent test runs). The counter now changes at commit rather than at
|
|
22
|
+
save; call `update_<name>` directly to recount immediately inside a transaction. Rails'
|
|
23
|
+
transactional tests commit each save within the test transaction, so tests see the recount.
|
|
24
|
+
- A counter stored in the counters table raises `ArgumentError` for a value that isn't a whole
|
|
25
|
+
number (e.g. `2.5`, `'many'`, infinity) instead of silently truncating it with `to_i`. `nil`
|
|
26
|
+
still means 0, and whole values like `4.0` or `BigDecimal('4')` are still accepted. Use a column
|
|
27
|
+
of a suitable type or `store: :cache` for fractional values.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- Whether a counter uses a column or the counters table is decided when it's read or written, so
|
|
32
|
+
loading models never queries the database, and a column added after the model has loaded is
|
|
33
|
+
picked up. The Heroku `DATABASE_URL` workaround is gone.
|
|
34
|
+
- Counter rows are deleted on destroy only when the model has a counter without a column, so
|
|
35
|
+
column-only models never touch the counters table.
|
|
36
|
+
- A recount that raises after commit is passed to `ActiveSupport.error_reporter.unexpected` (with
|
|
37
|
+
the owner and counter as context) and logged, instead of propagating: the save has already
|
|
38
|
+
committed, and one failure no longer skips the other recounts queued for that commit. In
|
|
39
|
+
development and test Rails' debug mode still raises it. Direct `update_<name>` calls,
|
|
40
|
+
`recount_counter_caches` and `CustomCounterCache::RecountJob` still raise.
|
|
41
|
+
- Paranoia's `restore` now recounts the owner for callbacks declared after `acts_as_paranoid`; it
|
|
42
|
+
didn't before. Add `except: [:restore]` to keep the old behavior.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `CustomCounterCache.counter_class_name` (default `'Counter'`) sets the model behind the
|
|
47
|
+
counters table.
|
|
48
|
+
- `define_counter_cache :name, touch: true` (or `touch: :column`) sets a timestamp on the owner
|
|
49
|
+
whenever the counter is recounted. A column counter is written in the same UPDATE, and no
|
|
50
|
+
callbacks run.
|
|
51
|
+
- `define_counter_cache :name, store: :cache, expires_in:` keeps a counter in
|
|
52
|
+
`CustomCounterCache.cache_store` (defaults to `Rails.cache`). It is computed on a miss and
|
|
53
|
+
deleted after commit when a child changes, instead of being recounted. Destroying the owner
|
|
54
|
+
deletes its keys.
|
|
55
|
+
- `update_counter_cache ..., on_change: [:attr]` recounts on update only when a listed attribute
|
|
56
|
+
or the association's key columns changed. Create and destroy always recount.
|
|
57
|
+
- `update_counter_cache [:article, :user], :comments_count` follows a path of `belongs_to`
|
|
58
|
+
associations and recounts both the old and the new owner at the end of it (grandparent counters).
|
|
59
|
+
- `update_counter_cache ..., recount: :later` recounts in `CustomCounterCache::RecountJob`
|
|
60
|
+
(Active Job), enqueued after commit, once per owner and counter per transaction.
|
|
61
|
+
- `CustomCounterCache.batch { }` recounts each owner and counter once after the block ends.
|
|
62
|
+
- `CustomCounterCache.skip { }` drops recounts inside the block.
|
|
63
|
+
- `Model.recount_counter_caches(*names, scope:, batch_size:)` recounts every record (or a scope) in
|
|
64
|
+
batches, repairing drift from `update_all`, `delete_all` and imports.
|
|
65
|
+
- The `custom_counter_cache:recount` rake task (`MODEL=`, `COUNTERS=`, `BATCH_SIZE=`) wraps
|
|
66
|
+
`recount_counter_caches`.
|
|
67
|
+
- Composite primary keys: owners and `belongs_to` associations with composite keys work with
|
|
68
|
+
column and cache counters, reassignment (including a change to only some key columns),
|
|
69
|
+
`on_change:`, `recount: :later` and `recount_counter_caches`. The counters table can't hold a
|
|
70
|
+
composite key, so a counter there raises `ArgumentError` naming the alternatives.
|
|
71
|
+
- `only:` and `except:` on `update_counter_cache` accept `:restore`, the event fired by Paranoia's
|
|
72
|
+
`restore`.
|
|
73
|
+
|
|
74
|
+
### Upgrading
|
|
75
|
+
|
|
76
|
+
- If an `:if`/`:unless` condition was meant to skip destroys, add `except: [:destroy]`.
|
|
77
|
+
- If you relied on a recount updating the owner's `updated_at`, pass `touch: true` to
|
|
78
|
+
`define_counter_cache`. Owner callbacks no longer run on a recount; call them explicitly if needed.
|
|
79
|
+
- If a migration adds a counter column and backfills it, call `reset_column_information` on the
|
|
80
|
+
model before backfilling.
|
|
81
|
+
- Counters now change when the saving transaction commits. Code that saves a child and reads the
|
|
82
|
+
owner's counter inside the same transaction should call `update_<name>` first.
|
|
83
|
+
- A counters-table counter whose block can return a fractional value (an average, a sum of
|
|
84
|
+
decimals) now raises: move it to a column of a suitable type or to `store: :cache`.
|
|
85
|
+
- On a model with a string or UUID primary key, the counters table's `countable_id` must be that
|
|
86
|
+
type (`t.references :countable, polymorphic: true, type: :uuid`).
|
|
87
|
+
|
|
3
88
|
## 0.3.2
|
|
4
89
|
|
|
5
90
|
### Fixed
|
data/README.rdoc
CHANGED
|
@@ -6,8 +6,8 @@ This is a simple approach to creating a custom counter cache in Rails that can b
|
|
|
6
6
|
|
|
7
7
|
=== Requirements
|
|
8
8
|
|
|
9
|
-
* Ruby >= 3.
|
|
10
|
-
* Rails (Active Record) >=
|
|
9
|
+
* Ruby >= 3.3
|
|
10
|
+
* Rails (Active Record) >= 8.0
|
|
11
11
|
|
|
12
12
|
CI covers the Ruby and Rails versions that are not yet end-of-life.
|
|
13
13
|
|
|
@@ -17,38 +17,180 @@ Add the following to your Gemfile:
|
|
|
17
17
|
|
|
18
18
|
gem 'custom_counter_cache'
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
== Defining a counter
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
This is the block that will be used to calculate the value for the counter cache. It will be called by other models through their association via an after_save or after_destroy callback.
|
|
22
|
+
The block calculates the counter's value from its owner. It is called whenever a model that
|
|
23
|
+
declares +update_counter_cache+ (see below) changes.
|
|
25
24
|
|
|
26
25
|
include CustomCounterCache::Model
|
|
27
26
|
define_counter_cache :articles_count do |user|
|
|
28
27
|
user.articles.where(state: 'published').count
|
|
29
28
|
end
|
|
30
29
|
|
|
31
|
-
|
|
30
|
+
Pass <tt>touch: true</tt> (or a column name, e.g. <tt>touch: :counted_at</tt>) to also set that
|
|
31
|
+
timestamp, <tt>updated_at</tt> for +true+, on every recount. A column counter is written in the same
|
|
32
|
+
UPDATE. No callbacks run.
|
|
33
|
+
|
|
34
|
+
Where the value is kept depends on the counter: a column of the same name, a row in a shared
|
|
35
|
+
counters table, or a cache store (<tt>store: :cache</tt>). See Choosing storage below.
|
|
32
36
|
|
|
33
|
-
|
|
37
|
+
== Triggering recounts
|
|
38
|
+
|
|
39
|
+
Declare which models change the counter with +update_counter_cache+, naming a +belongs_to+
|
|
40
|
+
association and the counter on its owner. It defines the after_create, after_update and
|
|
41
|
+
after_destroy callbacks (plus after_restore for Paranoia, see Soft deletes).
|
|
34
42
|
|
|
35
43
|
include CustomCounterCache::Model
|
|
36
|
-
update_counter_cache :user, :articles_count, if: -> (article) { article.
|
|
44
|
+
update_counter_cache :user, :articles_count, if: -> (article) { article.saved_change_to_state? }
|
|
45
|
+
|
|
46
|
+
These callbacks can be added to any number of models that might need to change the counter.
|
|
47
|
+
When a record moves to another owner, both the old and the new owner are recounted.
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
|
|
51
|
+
* <tt>:if</tt>, <tt>:unless</tt>: limit when the create and update callbacks run. The callbacks
|
|
52
|
+
run after the record is saved, so use <tt>saved_change_to_state?</tt> rather than
|
|
53
|
+
<tt>state_changed?</tt>, which is always false by then. They don't apply to destroy or restore:
|
|
54
|
+
those have no saved changes, and an extra recount is never wrong.
|
|
55
|
+
* <tt>on_change:</tt>: an attribute or an array. Recount on update only when one of them changed;
|
|
56
|
+
prefer it to +:if+ for plain attribute checks. Create and destroy always recount. It combines
|
|
57
|
+
with +:if+ and +:unless+ (both must allow the update).
|
|
58
|
+
* <tt>only:</tt>, <tt>except:</tt>: limit the events, any of <tt>:create</tt>, <tt>:update</tt>,
|
|
59
|
+
<tt>:destroy</tt> and <tt>:restore</tt>, e.g. <tt>only: [:create, :destroy]</tt> or
|
|
60
|
+
<tt>except: [:destroy]</tt>.
|
|
61
|
+
* <tt>:prepend</tt>: prepend the callbacks instead of appending them.
|
|
62
|
+
* <tt>recount: :later</tt>: recount in a background job instead of right after commit. See
|
|
63
|
+
When recounts run.
|
|
64
|
+
|
|
65
|
+
For example, to recount only when the state changes:
|
|
66
|
+
|
|
67
|
+
update_counter_cache :user, :articles_count, on_change: [:state]
|
|
68
|
+
|
|
69
|
+
The association's key columns (plus the type column for a polymorphic one) are always watched
|
|
70
|
+
by +on_change+, so moving the record to another owner recounts both owners.
|
|
71
|
+
|
|
72
|
+
== Grandparent counters
|
|
73
|
+
|
|
74
|
+
Pass a path of +belongs_to+ associations to recount an owner further up. For a
|
|
75
|
+
<tt>comments_count</tt> on User across all of a user's articles:
|
|
76
|
+
|
|
77
|
+
class User < ApplicationRecord
|
|
78
|
+
include CustomCounterCache::Model
|
|
79
|
+
has_many :articles
|
|
80
|
+
has_many :comments, through: :articles
|
|
81
|
+
define_counter_cache(:comments_count) { |user| user.comments.count }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Comment < ApplicationRecord
|
|
85
|
+
include CustomCounterCache::Model
|
|
86
|
+
belongs_to :article
|
|
87
|
+
update_counter_cache [:article, :user], :comments_count
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
class Article < ApplicationRecord
|
|
91
|
+
include CustomCounterCache::Model
|
|
92
|
+
belongs_to :user
|
|
93
|
+
update_counter_cache :user, :comments_count # an article moving users changes both counts
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
Moving a comment recounts the old and the new user, once if they're the same user. The second
|
|
97
|
+
declaration is needed because an Article changing user also changes both users' counts, and only
|
|
98
|
+
Article's own callbacks see that. A step after a polymorphic one is skipped for records whose
|
|
99
|
+
class has no such association; any other missing or non-+belongs_to+ step raises ArgumentError.
|
|
100
|
+
|
|
101
|
+
== Soft deletes
|
|
102
|
+
|
|
103
|
+
Discard soft-deletes with an ordinary update, so the callbacks already fire: filter with
|
|
104
|
+
<tt>.kept</tt> in the counter block, and if you use <tt>on_change:</tt> include
|
|
105
|
+
<tt>:discarded_at</tt> in it, or discarding won't recount.
|
|
106
|
+
|
|
107
|
+
define_counter_cache(:pages_count) { |notebook| notebook.pages.kept.count }
|
|
108
|
+
update_counter_cache :notebook, :pages_count, on_change: [:state, :discarded_at]
|
|
109
|
+
|
|
110
|
+
Paranoia's +destroy+ runs the destroy callbacks, and +restore+ is recounted too, provided
|
|
111
|
+
+acts_as_paranoid+ is declared before +update_counter_cache+. Skip it with <tt>except: [:restore]</tt>
|
|
112
|
+
(<tt>:restore</tt> is also accepted by <tt>only:</tt>).
|
|
113
|
+
|
|
114
|
+
class Parcel < ApplicationRecord
|
|
115
|
+
acts_as_paranoid
|
|
116
|
+
belongs_to :crate
|
|
117
|
+
update_counter_cache :crate, :parcels_count
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
== Batching and skipping
|
|
121
|
+
|
|
122
|
+
Every triggering save recounts its owner. For bulk work, batch the recounts so each owner and
|
|
123
|
+
counter is recounted once, when the block ends (or when its transaction commits):
|
|
124
|
+
|
|
125
|
+
CustomCounterCache.batch do
|
|
126
|
+
rows.each { |row| article.comments.create!(row) } # one recount of article.comments_count
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
Batches nest (the outermost one flushes) and still flush if the block raises, since a recount
|
|
130
|
+
reflects whatever is in the database. To skip recounts entirely, e.g. for an import you'll
|
|
131
|
+
recount afterwards:
|
|
132
|
+
|
|
133
|
+
CustomCounterCache.skip { import_comments }
|
|
37
134
|
|
|
38
|
-
|
|
39
|
-
<tt>state_changed?</tt> (which is always false by then). The :if condition also applies to
|
|
40
|
-
after_destroy, where there are no saved changes, hence the <tt>destroyed?</tt> check.
|
|
135
|
+
Both are per thread (and per fiber).
|
|
41
136
|
|
|
42
|
-
|
|
43
|
-
and <tt>:prepend</tt>.
|
|
137
|
+
== When recounts run
|
|
44
138
|
|
|
45
|
-
|
|
139
|
+
Recounts triggered by these callbacks run after the saving transaction commits, once per owner
|
|
140
|
+
and counter per transaction, and not at all if it rolls back. Each takes a short lock on the
|
|
141
|
+
owner's row while it counts, so two saves committing at once can't leave a stale value: the
|
|
142
|
+
later recount always counts after the earlier commit. (Counting inside the saving transaction
|
|
143
|
+
can't see a concurrent save's uncommitted child, so one of them would overwrite the other.)
|
|
46
144
|
|
|
47
|
-
|
|
145
|
+
The counter therefore changes when the transaction commits, not when the child is saved. Inside
|
|
146
|
+
a transaction, call the update method yourself if you need the new value straight away:
|
|
48
147
|
|
|
49
|
-
|
|
148
|
+
Article.transaction do
|
|
149
|
+
user.articles.create!(attrs)
|
|
150
|
+
user.update_articles_count # recounts now; update_* and recount_counter_caches never wait
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
If a recount raises after commit, the save has already succeeded and the count can be rebuilt, so
|
|
154
|
+
the error doesn't propagate to the caller or stop the other recounts queued for that commit. It's
|
|
155
|
+
passed to <tt>Rails.error.unexpected</tt>, which raises in development and test (with
|
|
156
|
+
<tt>consider_all_requests_local</tt>, as by default) and in production reports it to your error
|
|
157
|
+
tracker with the owner and counter in the context. It's also logged. Calling
|
|
158
|
+
<tt>update_<name></tt> or <tt>recount_counter_caches</tt> yourself raises as usual, and so does
|
|
159
|
+
<tt>CustomCounterCache::RecountJob</tt>, so your queue's retries apply.
|
|
160
|
+
|
|
161
|
+
To recount in a background job instead, enqueued after commit:
|
|
162
|
+
|
|
163
|
+
update_counter_cache :user, :articles_count, recount: :later # CustomCounterCache::RecountJob (Active Job)
|
|
164
|
+
|
|
165
|
+
To pick a queue, <tt>CustomCounterCache::RecountJob.queue_as :low</tt>. Rails' transactional
|
|
166
|
+
tests commit each save within the test transaction, so recounts happen there as they do in
|
|
167
|
+
production.
|
|
168
|
+
|
|
169
|
+
== Choosing storage
|
|
170
|
+
|
|
171
|
+
[Column] Add a column with the counter's name. Fastest to read, and you can sort and
|
|
172
|
+
filter by it in SQL. Needs a migration per counter.
|
|
173
|
+
[Counters table] Used automatically when there's no column. One shared table, no migration per
|
|
174
|
+
counter; preload with <tt>includes(:counters)</tt>. See The counters table.
|
|
175
|
+
[Cache] <tt>define_counter_cache :x, store: :cache, expires_in: 12.hours</tt>. Kept in
|
|
176
|
+
<tt>CustomCounterCache.cache_store</tt> (defaults to Rails.cache: Redis,
|
|
177
|
+
Memcached, Solid Cache...). Reads compute on a miss; a child change deletes the
|
|
178
|
+
key after commit instead of recounting, so writes are cheap and never lock the
|
|
179
|
+
owner's row. Destroying the owner deletes its keys. Not visible to SQL, and a
|
|
180
|
+
delete racing a concurrent read can leave a stale value for up to +expires_in+.
|
|
181
|
+
|
|
182
|
+
Column or counters table is decided when the counter is read or written, not when the model
|
|
183
|
+
loads, so defining counters never touches the database, and a column added later is picked up.
|
|
50
184
|
|
|
51
|
-
|
|
185
|
+
To use a column, add one:
|
|
186
|
+
|
|
187
|
+
def change
|
|
188
|
+
add_column :users, :articles_count, :integer, default: 0, null: false
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
== The counters table
|
|
192
|
+
|
|
193
|
+
To store counters in a single shared table instead, use this migration:
|
|
52
194
|
|
|
53
195
|
create_table :counters do |t|
|
|
54
196
|
t.references :countable, polymorphic: true
|
|
@@ -58,6 +200,17 @@ If you would like to store all of your counter caches in a single table, you can
|
|
|
58
200
|
end
|
|
59
201
|
add_index :counters, [ :countable_id, :countable_type, :key ], unique: true
|
|
60
202
|
|
|
203
|
+
The table holds whole numbers. A block result of +nil+ is stored as 0, and whole values such as
|
|
204
|
+
<tt>4.0</tt> or <tt>BigDecimal('4')</tt> are converted, but a fractional result such as an average
|
|
205
|
+
raises ArgumentError rather than being truncated: give that counter a column of a suitable type
|
|
206
|
+
(e.g. decimal) or use <tt>store: :cache</tt>.
|
|
207
|
+
|
|
208
|
+
+countable_id+ must match your models' primary key type: for string or UUID keys, use
|
|
209
|
+
<tt>t.references :countable, polymorphic: true, type: :uuid</tt> (or <tt>:string</tt>). A model
|
|
210
|
+
with a composite primary key can't use this table, since +countable_id+ holds one value: give its
|
|
211
|
+
counters a column or <tt>store: :cache</tt> (it raises ArgumentError otherwise). Composite keys
|
|
212
|
+
work everywhere else, including composite foreign keys on the +belongs_to+ side.
|
|
213
|
+
|
|
61
214
|
Here is the example model to go with:
|
|
62
215
|
|
|
63
216
|
class Counter < ActiveRecord::Base
|
|
@@ -65,23 +218,44 @@ Here is the example model to go with:
|
|
|
65
218
|
validates :countable, presence: true
|
|
66
219
|
end
|
|
67
220
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
destroyed, also destroy the record it belongs to" -- the opposite of what you
|
|
71
|
-
want, and it creates a destroy loop: destroying a countable record cascades
|
|
72
|
-
into destroying its Counters (via the <tt>has_many :counters, dependent:
|
|
73
|
-
:delete_all</tt> this gem defines), and a wrongly-configured +belongs_to+ would
|
|
74
|
-
then cascade back into destroying the same countable record again. The
|
|
75
|
-
countable side's <tt>dependent: :delete_all</tt> already handles cleanup and
|
|
76
|
-
skips Counter's own callbacks entirely, so no +dependent+ option belongs on
|
|
77
|
-
the +belongs_to+ side at all.
|
|
221
|
+
To use a different model name (for example if +Counter+ is already taken), set
|
|
222
|
+
<tt>CustomCounterCache.counter_class_name</tt> (see Configuration).
|
|
78
223
|
|
|
79
|
-
|
|
224
|
+
When a record is destroyed, its counter rows are removed with a single DELETE, without loading
|
|
225
|
+
them or running Counter's callbacks. Do not add <tt>dependent: :destroy</tt> to the +belongs_to+
|
|
226
|
+
above: on a +belongs_to+ it means "destroying this Counter also destroys its owner".
|
|
80
227
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
228
|
+
== Backfilling and repairing
|
|
229
|
+
|
|
230
|
+
To backfill your counters, or repair them later, recount every record from the console or a migration:
|
|
231
|
+
|
|
232
|
+
User.recount_counter_caches
|
|
233
|
+
|
|
234
|
+
or only some counters and records:
|
|
235
|
+
|
|
236
|
+
User.recount_counter_caches(:articles_count, scope: User.where(id: 1..1000), batch_size: 500)
|
|
237
|
+
|
|
238
|
+
It returns the number of records processed. The same is available as a rake task:
|
|
239
|
+
|
|
240
|
+
bin/rails custom_counter_cache:recount MODEL=User COUNTERS=articles_count
|
|
241
|
+
|
|
242
|
+
+COUNTERS+ (comma-separated, default all) and +BATCH_SIZE+ (default 1000) are optional.
|
|
243
|
+
|
|
244
|
+
Callbacks can't see +update_all+, +delete_all+, +insert_all+ or SQL imports, so counts drift after
|
|
245
|
+
them. Run the recount periodically, or after an import, to repair that. It calls +update_<name>+
|
|
246
|
+
directly, so it also works inside <tt>CustomCounterCache.skip { }</tt>.
|
|
247
|
+
|
|
248
|
+
In a migration that also adds the column, call <tt>User.reset_column_information</tt> first so the
|
|
249
|
+
backfill writes to the new column.
|
|
250
|
+
|
|
251
|
+
== Configuration
|
|
252
|
+
|
|
253
|
+
Set these in an initializer (<tt>config/initializers/custom_counter_cache.rb</tt>):
|
|
84
254
|
|
|
85
|
-
|
|
255
|
+
* <tt>CustomCounterCache.counter_class_name</tt>: the model behind the counters table. Default
|
|
256
|
+
<tt>'Counter'</tt>. Must be set before your models load.
|
|
257
|
+
* <tt>CustomCounterCache.cache_store</tt>: the store for <tt>store: :cache</tt> counters. Defaults
|
|
258
|
+
to Rails.cache.
|
|
86
259
|
|
|
87
|
-
|
|
260
|
+
CustomCounterCache.counter_class_name = 'CounterCache'
|
|
261
|
+
CustomCounterCache.cache_store = ActiveSupport::Cache::MemoryStore.new
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require 'active_support/isolated_execution_state'
|
|
2
|
+
|
|
3
|
+
module CustomCounterCache
|
|
4
|
+
# Recounts each owner and counter once after the outermost batch ends (after commit, if inside a transaction).
|
|
5
|
+
def self.batch(&block)
|
|
6
|
+
Dispatcher.batch(&block)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
# Drops recounts inside the block, e.g. for an import followed by recount_counter_caches.
|
|
10
|
+
def self.skip(&block)
|
|
11
|
+
Dispatcher.skip(&block)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Single path from update_counter_cache's callbacks to an owner's recount.
|
|
15
|
+
module Dispatcher
|
|
16
|
+
# Always after commit: inside the saving transaction a recount can't see a concurrent save's
|
|
17
|
+
# uncommitted child, and the later of the two overwrites the count with a stale one.
|
|
18
|
+
def self.recount(owner, name, timing = :after_commit)
|
|
19
|
+
return if state[:skip]
|
|
20
|
+
# A cached counter is invalidated after commit, never recounted in place.
|
|
21
|
+
timing = :invalidate if owner.class.custom_counter_cache_storage(name) == :cache
|
|
22
|
+
# Keep the first instance seen, so loaded counters on it stay current.
|
|
23
|
+
return state[:batch][[owner.class, owner.id, name]] ||= [owner, timing] if state[:batch]
|
|
24
|
+
defer(owner, name, timing)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Serializes recounts of one owner on its row, so each counts after any concurrent commit.
|
|
28
|
+
# Locks by query rather than with_lock, which would reload the owner and drop its unsaved changes.
|
|
29
|
+
def self.locked_recount(owner, name)
|
|
30
|
+
klass = owner.class
|
|
31
|
+
klass.transaction do
|
|
32
|
+
# Owner deleted meanwhile: nothing left to recount.
|
|
33
|
+
next unless klass.where(CustomCounterCache.key_conditions(klass.primary_key, owner.id)).lock.pick(*Array(klass.primary_key).first(1))
|
|
34
|
+
owner.public_send("update_#{name}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.batch
|
|
39
|
+
outermost = state[:batch].nil?
|
|
40
|
+
state[:batch] ||= {}
|
|
41
|
+
yield
|
|
42
|
+
ensure
|
|
43
|
+
# A recount reflects whatever is in the database, so flushing after an exception is still correct.
|
|
44
|
+
if outermost
|
|
45
|
+
pending = state.delete(:batch)
|
|
46
|
+
pending.each { |(_, _, name), (owner, timing)| recount(owner, name, timing) }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.skip
|
|
51
|
+
skipping = state[:skip]
|
|
52
|
+
state[:skip] = true
|
|
53
|
+
yield
|
|
54
|
+
ensure
|
|
55
|
+
state[:skip] = skipping
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Once per owner and counter per transaction. A recount reads committed state, so running it late is still correct.
|
|
59
|
+
def self.defer(owner, name, timing)
|
|
60
|
+
key = [owner.class, owner.id, name, timing]
|
|
61
|
+
deferred = state[:deferred] ||= {}
|
|
62
|
+
return if deferred.key?(key)
|
|
63
|
+
|
|
64
|
+
transaction = owner.class.current_transaction
|
|
65
|
+
deferred[key] = true if transaction.open?
|
|
66
|
+
# A rolled-back savepoint drops its after_commit, so forget the key and let a later save schedule it again.
|
|
67
|
+
transaction.after_rollback { deferred.delete(key) }
|
|
68
|
+
transaction.after_commit do
|
|
69
|
+
deferred.delete(key)
|
|
70
|
+
perform(owner, name, timing)
|
|
71
|
+
rescue StandardError => error
|
|
72
|
+
failed(error, owner, name)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The save has committed and the count can be rebuilt, so don't make the save look failed or
|
|
77
|
+
# stop the other recounts queued for this commit. unexpected still raises in development and test.
|
|
78
|
+
def self.failed(error, owner, name)
|
|
79
|
+
context = { owner: owner.class.name, owner_id: owner.id, counter: name.to_s }
|
|
80
|
+
owner.class.logger&.error("custom_counter_cache: recount of #{context[:owner]} #{context[:owner_id].inspect} " \
|
|
81
|
+
"#{context[:counter]} failed: #{error.class}: #{error.message}")
|
|
82
|
+
ActiveSupport.error_reporter.unexpected(error, context: context, source: 'custom_counter_cache')
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.perform(owner, name, timing)
|
|
86
|
+
case timing
|
|
87
|
+
when :later then RecountJob.perform_later(owner.class.name, owner.id, name.to_s)
|
|
88
|
+
when :invalidate
|
|
89
|
+
CustomCounterCache.cache_store.delete(CustomCounterCache.cache_key(owner, name))
|
|
90
|
+
touch_column = owner.class.custom_counter_cache_touch_column(name)
|
|
91
|
+
# A cascade destroy invalidates after the owner is gone; there's nothing left to touch.
|
|
92
|
+
owner.update_column(touch_column, Time.current) if touch_column && !owner.destroyed?
|
|
93
|
+
else locked_recount(owner, name)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.state
|
|
98
|
+
ActiveSupport::IsolatedExecutionState[:custom_counter_cache] ||= {}
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -3,90 +3,125 @@ require 'active_support/concern'
|
|
|
3
3
|
module CustomCounterCache::Model
|
|
4
4
|
extend ActiveSupport::Concern
|
|
5
5
|
|
|
6
|
+
included do
|
|
7
|
+
class_attribute :custom_counter_cache_names, instance_accessor: false, default: []
|
|
8
|
+
# Per counter name; a new merged hash is assigned so subclasses never mutate their parent's.
|
|
9
|
+
class_attribute :custom_counter_cache_options, instance_accessor: false, default: {}
|
|
10
|
+
end
|
|
11
|
+
|
|
6
12
|
module ClassMethods
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
# Column or counters row is decided on each call, not here, so defining never queries the database.
|
|
14
|
+
def define_counter_cache(cache_column, touch: nil, store: nil, expires_in: nil, &block)
|
|
15
|
+
raise ArgumentError, "store: must be :cache or omitted" unless store.nil? || store == :cache
|
|
16
|
+
name = cache_column.to_s
|
|
17
|
+
define_counters_association
|
|
18
|
+
define_cache_cleanup if store == :cache
|
|
19
|
+
self.custom_counter_cache_names += [name]
|
|
20
|
+
self.custom_counter_cache_options = custom_counter_cache_options.merge(name => { touch: touch, store: store, expires_in: expires_in })
|
|
21
|
+
|
|
22
|
+
custom_counter_cache_methods.module_eval do
|
|
23
|
+
define_method(name) do
|
|
24
|
+
case self.class.custom_counter_cache_storage(name)
|
|
25
|
+
when :column then super()
|
|
26
|
+
when :cache
|
|
27
|
+
CustomCounterCache.cache_store.fetch(CustomCounterCache.cache_key(self, name), expires_in: expires_in) { block.call(self) }
|
|
18
28
|
else
|
|
19
|
-
|
|
29
|
+
# Once loaded (e.g. includes(:counters)), a missing row means 0, not a query.
|
|
30
|
+
counter = counters.loaded? ? counters.detect { |c| c.key == name } : counters.find_by(key: name)
|
|
31
|
+
counter.try(:value).to_i
|
|
20
32
|
end
|
|
21
33
|
end
|
|
22
|
-
|
|
34
|
+
|
|
35
|
+
define_method("#{name}=") do |count|
|
|
36
|
+
case self.class.custom_counter_cache_storage(name)
|
|
37
|
+
when :column then return super(count)
|
|
38
|
+
when :cache then return CustomCounterCache.cache_store.write(CustomCounterCache.cache_key(self, name), count, expires_in: expires_in)
|
|
39
|
+
end
|
|
40
|
+
count = CustomCounterCache::Model.whole_number!(self, name, count)
|
|
23
41
|
# Update the loaded Counter itself, or the reader keeps returning its stale value.
|
|
24
|
-
counter = counters.loaded? ? counters.detect { |c| c.key ==
|
|
42
|
+
counter = counters.loaded? ? counters.detect { |c| c.key == name } : counters.find_by(key: name)
|
|
25
43
|
if counter
|
|
26
|
-
counter.update_attribute :value, count
|
|
44
|
+
counter.update_attribute :value, count
|
|
27
45
|
else
|
|
28
46
|
begin
|
|
29
47
|
# Savepoint: on PostgreSQL a failed INSERT would otherwise abort the caller's whole transaction.
|
|
30
|
-
self.class.transaction(requires_new: true) { counters.create key:
|
|
48
|
+
self.class.transaction(requires_new: true) { counters.create key: name, value: count }
|
|
31
49
|
rescue ActiveRecord::RecordNotUnique
|
|
32
50
|
# Lost a create race. A locking read sees the winner's row even under REPEATABLE READ (MySQL).
|
|
33
51
|
counters.reset
|
|
34
|
-
counters.lock.find_by!(key:
|
|
52
|
+
counters.lock.find_by!(key: name).update_attribute :value, count
|
|
35
53
|
end
|
|
36
54
|
end
|
|
37
55
|
end
|
|
38
|
-
end
|
|
39
56
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
57
|
+
define_method("update_#{name}") do
|
|
58
|
+
touch_column = self.class.custom_counter_cache_touch_column(name)
|
|
59
|
+
value = block.call(self)
|
|
60
|
+
if self.class.custom_counter_cache_storage(name) == :column
|
|
61
|
+
# One statement, and no callbacks: update_columns, not touch.
|
|
62
|
+
touch_column ? update_columns(name => value, touch_column => Time.current) : update_column(name, value)
|
|
63
|
+
else
|
|
64
|
+
send "#{name}=", value
|
|
65
|
+
update_column touch_column, Time.current if touch_column
|
|
66
|
+
end
|
|
46
67
|
end
|
|
47
68
|
end
|
|
69
|
+
end
|
|
48
70
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
71
|
+
# :cache when declared; otherwise a column if one exists, else a row in the counters table.
|
|
72
|
+
def custom_counter_cache_storage(name) # :nodoc:
|
|
73
|
+
return :cache if custom_counter_cache_options.dig(name.to_s, :store) == :cache
|
|
74
|
+
return :column if column_names.include?(name.to_s)
|
|
75
|
+
# The counters table's single countable_id can't hold a composite key.
|
|
76
|
+
if composite_primary_key?
|
|
77
|
+
raise ArgumentError, "#{self} has a composite primary key, so counter #{name} needs a column or store: :cache"
|
|
78
|
+
end
|
|
79
|
+
:counters
|
|
52
80
|
end
|
|
53
81
|
|
|
54
|
-
def
|
|
55
|
-
|
|
82
|
+
def custom_counter_cache_touch_column(name) # :nodoc:
|
|
83
|
+
touch = custom_counter_cache_options.dig(name.to_s, :touch)
|
|
84
|
+
touch == true ? 'updated_at' : (touch.to_s if touch)
|
|
85
|
+
end
|
|
56
86
|
|
|
57
|
-
|
|
87
|
+
# Repairs drift no callback can see (update_all, delete_all, imports). Returns the records processed.
|
|
88
|
+
def recount_counter_caches(*names, scope: all, batch_size: 1000)
|
|
89
|
+
names = names.empty? ? custom_counter_cache_names : names.map(&:to_s)
|
|
90
|
+
unknown = names - custom_counter_cache_names
|
|
91
|
+
raise ArgumentError, "#{self} has no counter cache named #{unknown.join(', ')}" if unknown.any?
|
|
92
|
+
|
|
93
|
+
# Calls update_<name> directly, so CustomCounterCache.skip and .batch don't affect it.
|
|
94
|
+
# Preload :counters, which the virtual counter writer uses once loaded, to avoid a query per record.
|
|
95
|
+
scope = scope.includes(:counters) if names.any? { |name| custom_counter_cache_storage(name) == :counters }
|
|
96
|
+
processed = 0
|
|
97
|
+
scope.find_each(batch_size: batch_size) do |record|
|
|
98
|
+
names.each { |name| record.public_send("update_#{name}") }
|
|
99
|
+
processed += 1
|
|
100
|
+
end
|
|
101
|
+
processed
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# association may be a belongs_to path, e.g. [:article, :user], to recount a grandparent.
|
|
105
|
+
def update_counter_cache(association, cache_column, options = {})
|
|
106
|
+
path = Array(association).map(&:to_sym)
|
|
107
|
+
association = path.first
|
|
58
108
|
cache_column = cache_column.to_sym
|
|
59
|
-
method_name = "callback_#{
|
|
109
|
+
method_name = "callback_#{path.join('_')}_#{cache_column}".to_sym
|
|
60
110
|
reflection = reflect_on_association(association)
|
|
61
|
-
raise ArgumentError, "#{self} must declare belongs_to :#{association} before update_counter_cache" unless reflection
|
|
111
|
+
raise ArgumentError, "#{self} must declare belongs_to :#{association} before update_counter_cache" unless reflection&.belongs_to?
|
|
62
112
|
foreign_key = reflection.foreign_key
|
|
113
|
+
rest = path.drop(1)
|
|
114
|
+
path_checked = false
|
|
115
|
+
timing = CustomCounterCache.check_timing!(options.fetch(:recount, :after_commit))
|
|
63
116
|
|
|
64
|
-
# define callback
|
|
65
117
|
define_method method_name do
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
# safe_: the stored type may name a class that has since been renamed or removed.
|
|
74
|
-
old_klass = old_type&.safe_constantize
|
|
75
|
-
if ( old_klass && old_id && record = old_klass.find_by(reflection.association_primary_key(old_klass) => old_id) )
|
|
76
|
-
record.send("update_#{cache_column}")
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
else
|
|
80
|
-
if saved_change_to_attribute?(foreign_key)
|
|
81
|
-
old_id = attribute_before_last_save(foreign_key)
|
|
82
|
-
if ( old_id && record = reflection.klass.find_by(reflection.association_primary_key => old_id) )
|
|
83
|
-
record.send("update_#{cache_column}")
|
|
84
|
-
end
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
# update new association
|
|
88
|
-
if ( record = send(association) )
|
|
89
|
-
record.send("update_#{cache_column}")
|
|
118
|
+
# Later steps live on classes that may not be loaded at declaration time, so check them on first use.
|
|
119
|
+
path_checked ||= CustomCounterCache::Model.check_path!(reflection, rest)
|
|
120
|
+
owners = [CustomCounterCache::Model.previous_owner(self, reflection), public_send(association)]
|
|
121
|
+
owners = owners.map { |record| CustomCounterCache::Model.follow_path(record, rest) }
|
|
122
|
+
# Moving between two records under the same grandparent must recount it once, not twice.
|
|
123
|
+
owners.compact.uniq { |owner| [owner.class, owner.id] }.each do |owner|
|
|
124
|
+
CustomCounterCache::Dispatcher.recount(owner, cache_column, timing)
|
|
90
125
|
end
|
|
91
126
|
end
|
|
92
127
|
|
|
@@ -97,13 +132,104 @@ module CustomCounterCache::Model
|
|
|
97
132
|
|
|
98
133
|
# set callbacks
|
|
99
134
|
callback_opts = options.slice(:if, :unless, :prepend)
|
|
135
|
+
update_opts = callback_opts
|
|
136
|
+
if options[:on_change]
|
|
137
|
+
# The keys are always watched so a reassignment recounts the old owner too.
|
|
138
|
+
watched = Array(options[:on_change]).map(&:to_s) + Array(foreign_key).map(&:to_s)
|
|
139
|
+
watched << reflection.foreign_type.to_s if reflection.polymorphic?
|
|
140
|
+
changed = ->(record) { watched.any? { |attribute| record.saved_change_to_attribute?(attribute) } }
|
|
141
|
+
update_opts = callback_opts.merge(if: Array(options[:if]) + [changed])
|
|
142
|
+
end
|
|
143
|
+
# Create isn't gated by :on_change: column defaults aren't saved changes, so new records would be missed.
|
|
100
144
|
after_create method_name, **callback_opts unless skip_callback.call(:create, options)
|
|
101
|
-
after_update method_name, **
|
|
102
|
-
|
|
145
|
+
after_update method_name, **update_opts unless skip_callback.call(:update, options)
|
|
146
|
+
# Not :if/:unless: they test saved changes, which a destroy lacks. An extra recount is never wrong.
|
|
147
|
+
after_destroy method_name, **options.slice(:prepend) unless skip_callback.call(:destroy, options)
|
|
148
|
+
# Paranoia's restore skips update callbacks; it defines :restore only once acts_as_paranoid has run.
|
|
149
|
+
after_restore method_name, **options.slice(:prepend) if respond_to?(:after_restore) && !skip_callback.call(:restore, options)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# Included after Active Record's generated attribute methods, so super reaches a real column's accessor.
|
|
155
|
+
def custom_counter_cache_methods
|
|
156
|
+
@custom_counter_cache_methods ||= Module.new.tap { |mod| include mod }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def define_cache_cleanup
|
|
160
|
+
return if @custom_counter_cache_cleanup
|
|
161
|
+
@custom_counter_cache_cleanup = true
|
|
162
|
+
after_destroy_commit do
|
|
163
|
+
self.class.custom_counter_cache_names.each do |name|
|
|
164
|
+
next unless self.class.custom_counter_cache_storage(name) == :cache
|
|
165
|
+
CustomCounterCache.cache_store.delete(CustomCounterCache.cache_key(self, name))
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def define_counters_association
|
|
171
|
+
return if reflect_on_association(:counters)
|
|
172
|
+
has_many :counters, as: :countable, class_name: CustomCounterCache.counter_class_name
|
|
173
|
+
# Not dependent: :delete_all, which would query the counters table even when every counter is a column.
|
|
174
|
+
before_destroy do
|
|
175
|
+
in_table = self.class.custom_counter_cache_names.any? { |name| self.class.custom_counter_cache_storage(name) == :counters }
|
|
176
|
+
counters.delete_all(:delete_all) if in_table
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The counters table holds integers. Converting 2.5 to 2 would store a wrong count without a word, so refuse.
|
|
182
|
+
def self.whole_number!(record, name, value)
|
|
183
|
+
number =
|
|
184
|
+
case value
|
|
185
|
+
when nil then 0
|
|
186
|
+
when Integer then value
|
|
187
|
+
when String then Integer(value, 10, exception: false)
|
|
188
|
+
when Numeric then value.to_i if (!value.respond_to?(:finite?) || value.finite?) && value == value.to_i
|
|
189
|
+
end
|
|
190
|
+
return number if number
|
|
191
|
+
raise ArgumentError, "#{record.class}##{name} is stored in the counters table, which holds whole numbers; got " \
|
|
192
|
+
"#{value.inspect}. Give it a column of a suitable type, or use store: :cache"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Checks each step up to the first polymorphic one, past which the class varies per record.
|
|
196
|
+
def self.check_path!(reflection, steps)
|
|
197
|
+
steps.each do |step|
|
|
198
|
+
return true if reflection.polymorphic?
|
|
199
|
+
owner_class = reflection.klass
|
|
200
|
+
reflection = owner_class.reflect_on_association(step)
|
|
201
|
+
raise ArgumentError, "#{owner_class} must declare belongs_to :#{step} for update_counter_cache" unless reflection&.belongs_to?
|
|
202
|
+
end
|
|
203
|
+
true
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def self.follow_path(record, steps)
|
|
207
|
+
steps.reduce(record) do |current, step|
|
|
208
|
+
break unless current
|
|
209
|
+
reflection = current.class.reflect_on_association(step)
|
|
210
|
+
# Only reachable past a polymorphic step: this type has no such parent, so there's nothing to recount.
|
|
211
|
+
break unless reflection
|
|
212
|
+
raise ArgumentError, "#{current.class} must declare belongs_to :#{step} for update_counter_cache" unless reflection.belongs_to?
|
|
213
|
+
current.public_send(step)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# The owner record belonged to before its last save moved it, or nil if it didn't move.
|
|
218
|
+
# Keys may be composite: only some of their columns may have changed.
|
|
219
|
+
def self.previous_owner(record, reflection)
|
|
220
|
+
keys = Array(reflection.foreign_key).map(&:to_s)
|
|
221
|
+
keys += [reflection.foreign_type] if reflection.polymorphic?
|
|
222
|
+
return unless keys.any? { |key| record.saved_change_to_attribute?(key) }
|
|
223
|
+
|
|
224
|
+
old_values = keys.map { |key| record.saved_change_to_attribute?(key) ? record.attribute_before_last_save(key) : record[key] }
|
|
225
|
+
return if old_values.any?(&:nil?)
|
|
103
226
|
|
|
104
|
-
|
|
105
|
-
#
|
|
106
|
-
|
|
227
|
+
if reflection.polymorphic?
|
|
228
|
+
# safe_: the stored type may name a class that has since been renamed or removed.
|
|
229
|
+
old_klass = old_values.pop.safe_constantize
|
|
230
|
+
old_klass&.find_by(CustomCounterCache.key_conditions(reflection.association_primary_key(old_klass), old_values))
|
|
231
|
+
else
|
|
232
|
+
reflection.klass.find_by(CustomCounterCache.key_conditions(reflection.association_primary_key, old_values))
|
|
107
233
|
end
|
|
108
234
|
end
|
|
109
235
|
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
begin
|
|
2
|
+
require 'active_job'
|
|
3
|
+
rescue LoadError
|
|
4
|
+
raise LoadError, "custom_counter_cache: recount: :later needs Active Job; add 'activejob' to your Gemfile"
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
module CustomCounterCache
|
|
8
|
+
# Enqueued by update_counter_cache ..., recount: :later. Takes plain values, not the record, so no GlobalID is needed.
|
|
9
|
+
class RecountJob < ActiveJob::Base
|
|
10
|
+
def perform(class_name, id, name)
|
|
11
|
+
klass = class_name.safe_constantize
|
|
12
|
+
owner = klass&.find_by(CustomCounterCache.key_conditions(klass.primary_key, id))
|
|
13
|
+
Dispatcher.locked_recount(owner, name) if owner
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/custom_counter_cache.rb
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
require 'active_support'
|
|
2
|
+
require 'active_support/core_ext/module/attribute_accessors'
|
|
3
|
+
|
|
1
4
|
module CustomCounterCache
|
|
2
|
-
|
|
5
|
+
# Model holding counters that have no column. Read when a model defines its first counter, so set it before models load.
|
|
6
|
+
mattr_accessor :counter_class_name, default: 'Counter'
|
|
7
|
+
mattr_writer :cache_store, default: nil
|
|
8
|
+
|
|
9
|
+
# Where store: :cache counters live: the configured store, else Rails.cache.
|
|
10
|
+
def self.cache_store
|
|
11
|
+
store = @@cache_store || (::Rails.cache if defined?(::Rails) && ::Rails.respond_to?(:cache))
|
|
12
|
+
store or raise ArgumentError, 'store: :cache needs a cache: set CustomCounterCache.cache_store (Rails.cache is used when available)'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.cache_key(owner, name)
|
|
16
|
+
"custom_counter_cache/v1/#{owner.class.polymorphic_name}/#{Array(owner.id).join('-')}/#{name}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Conditions matching columns to values; each may be a single key or a composite one.
|
|
20
|
+
def self.key_conditions(columns, values) # :nodoc:
|
|
21
|
+
Array(columns).map(&:to_s).zip(Array(values)).to_h
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Callback recounts always run after commit; :later moves them into a job.
|
|
25
|
+
TIMINGS = %i[after_commit later].freeze
|
|
26
|
+
|
|
27
|
+
def self.check_timing!(timing) # :nodoc:
|
|
28
|
+
raise ArgumentError, "recount: must be one of #{TIMINGS.join(', ')}" unless TIMINGS.include?(timing)
|
|
29
|
+
# Fail at class load, not on the first save, if Active Job is missing.
|
|
30
|
+
require 'custom_counter_cache/recount_job' if timing == :later
|
|
31
|
+
timing
|
|
32
|
+
end
|
|
3
33
|
end
|
|
34
|
+
|
|
35
|
+
require 'custom_counter_cache/dispatcher'
|
|
36
|
+
require 'custom_counter_cache/model'
|
|
37
|
+
require 'custom_counter_cache/railtie' if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
namespace :custom_counter_cache do
|
|
2
|
+
desc 'Recount counter caches: MODEL=User [COUNTERS=articles_count,comments_count] [BATCH_SIZE=1000]'
|
|
3
|
+
task recount: :environment do
|
|
4
|
+
abort 'MODEL is required, e.g. MODEL=User' if ENV['MODEL'].to_s.empty?
|
|
5
|
+
model = ENV['MODEL'].safe_constantize
|
|
6
|
+
abort "#{ENV['MODEL']} is not a model with counter caches" unless model.respond_to?(:recount_counter_caches)
|
|
7
|
+
|
|
8
|
+
names = ENV['COUNTERS'].to_s.split(',').map(&:strip).reject(&:empty?)
|
|
9
|
+
batch_size = Integer(ENV.fetch('BATCH_SIZE', 1000))
|
|
10
|
+
puts "Recounted #{model.recount_counter_caches(*names, batch_size: batch_size)} #{model} records"
|
|
11
|
+
end
|
|
12
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: custom_counter_cache
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Cedric Howe
|
|
@@ -15,7 +15,7 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
18
|
+
version: '8.0'
|
|
19
19
|
- - "<"
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: '9.0'
|
|
@@ -25,7 +25,7 @@ dependencies:
|
|
|
25
25
|
requirements:
|
|
26
26
|
- - ">="
|
|
27
27
|
- !ruby/object:Gem::Version
|
|
28
|
-
version: '
|
|
28
|
+
version: '8.0'
|
|
29
29
|
- - "<"
|
|
30
30
|
- !ruby/object:Gem::Version
|
|
31
31
|
version: '9.0'
|
|
@@ -35,7 +35,7 @@ dependencies:
|
|
|
35
35
|
requirements:
|
|
36
36
|
- - ">="
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
|
-
version: '
|
|
38
|
+
version: '8.0'
|
|
39
39
|
- - "<"
|
|
40
40
|
- !ruby/object:Gem::Version
|
|
41
41
|
version: '9.0'
|
|
@@ -45,10 +45,44 @@ dependencies:
|
|
|
45
45
|
requirements:
|
|
46
46
|
- - ">="
|
|
47
47
|
- !ruby/object:Gem::Version
|
|
48
|
-
version: '
|
|
48
|
+
version: '8.0'
|
|
49
49
|
- - "<"
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
51
|
version: '9.0'
|
|
52
|
+
- !ruby/object:Gem::Dependency
|
|
53
|
+
name: activejob
|
|
54
|
+
requirement: !ruby/object:Gem::Requirement
|
|
55
|
+
requirements:
|
|
56
|
+
- - ">="
|
|
57
|
+
- !ruby/object:Gem::Version
|
|
58
|
+
version: '8.0'
|
|
59
|
+
- - "<"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '9.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '8.0'
|
|
69
|
+
- - "<"
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '9.0'
|
|
72
|
+
- !ruby/object:Gem::Dependency
|
|
73
|
+
name: discard
|
|
74
|
+
requirement: !ruby/object:Gem::Requirement
|
|
75
|
+
requirements:
|
|
76
|
+
- - "~>"
|
|
77
|
+
- !ruby/object:Gem::Version
|
|
78
|
+
version: '2.0'
|
|
79
|
+
type: :development
|
|
80
|
+
prerelease: false
|
|
81
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
82
|
+
requirements:
|
|
83
|
+
- - "~>"
|
|
84
|
+
- !ruby/object:Gem::Version
|
|
85
|
+
version: '2.0'
|
|
52
86
|
- !ruby/object:Gem::Dependency
|
|
53
87
|
name: minitest
|
|
54
88
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -69,6 +103,20 @@ dependencies:
|
|
|
69
103
|
- - "<"
|
|
70
104
|
- !ruby/object:Gem::Version
|
|
71
105
|
version: '7'
|
|
106
|
+
- !ruby/object:Gem::Dependency
|
|
107
|
+
name: paranoia
|
|
108
|
+
requirement: !ruby/object:Gem::Requirement
|
|
109
|
+
requirements:
|
|
110
|
+
- - "~>"
|
|
111
|
+
- !ruby/object:Gem::Version
|
|
112
|
+
version: '3.1'
|
|
113
|
+
type: :development
|
|
114
|
+
prerelease: false
|
|
115
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
116
|
+
requirements:
|
|
117
|
+
- - "~>"
|
|
118
|
+
- !ruby/object:Gem::Version
|
|
119
|
+
version: '3.1'
|
|
72
120
|
- !ruby/object:Gem::Dependency
|
|
73
121
|
name: rake
|
|
74
122
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -109,14 +157,19 @@ files:
|
|
|
109
157
|
- LICENSE
|
|
110
158
|
- README.rdoc
|
|
111
159
|
- lib/custom_counter_cache.rb
|
|
160
|
+
- lib/custom_counter_cache/dispatcher.rb
|
|
112
161
|
- lib/custom_counter_cache/model.rb
|
|
162
|
+
- lib/custom_counter_cache/railtie.rb
|
|
163
|
+
- lib/custom_counter_cache/recount_job.rb
|
|
113
164
|
- lib/custom_counter_cache/version.rb
|
|
165
|
+
- lib/tasks/custom_counter_cache.rake
|
|
114
166
|
homepage: https://github.com/cedric/custom_counter_cache
|
|
115
167
|
licenses:
|
|
116
168
|
- MIT
|
|
117
169
|
metadata:
|
|
118
170
|
source_code_uri: https://github.com/cedric/custom_counter_cache
|
|
119
171
|
changelog_uri: https://github.com/cedric/custom_counter_cache/blob/main/CHANGELOG.md
|
|
172
|
+
rubygems_mfa_required: 'true'
|
|
120
173
|
rdoc_options: []
|
|
121
174
|
require_paths:
|
|
122
175
|
- lib
|
|
@@ -124,7 +177,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
124
177
|
requirements:
|
|
125
178
|
- - ">="
|
|
126
179
|
- !ruby/object:Gem::Version
|
|
127
|
-
version: '3.
|
|
180
|
+
version: '3.3'
|
|
128
181
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
129
182
|
requirements:
|
|
130
183
|
- - ">="
|