incrdecr_cached_counts 0.1.0 → 0.5.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 +5 -5
- data/.circleci/config.yml +47 -0
- data/README.rdoc +2 -0
- data/incrdecr_cached_counts.gemspec +1 -3
- data/lib/cached_counts.rb +67 -40
- data/lib/cached_counts/query_context.rb +21 -0
- data/lib/cached_counts/version.rb +1 -1
- data/spec/spec_helper.rb +2 -9
- metadata +7 -40
- data/.circleci-matrix.yml +0 -8
- data/.travis.yml +0 -15
- data/circle.yml +0 -10
- data/lib/cached_counts/connection_for.rb +0 -14
- data/lib/cached_counts/dalli_check.rb +0 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 5c0a0f2090aabd3b286436ad4bce2dca587f003cb5c19307be1d7974c84894ab
|
4
|
+
data.tar.gz: 546850171cb6d859149155acd0a6c3cc4697702306c7e7d7015c6a6e86bc4066
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: dc5aa5c3e0977528853821aa6d11484f1bfccbb8c435c003ea4c90856cac06933448c52707884dd182a5727f04c701ff5a5bf21d8454b695637a62fd0860ed72
|
7
|
+
data.tar.gz: 7ce3ec421d5bea4a5f6e1a5a80c341fcb603f8288a13751058afb9ced3aea58ceae75668a42522132d3f3246e5955a60a16ecf80f6b9a1b6d6017a1286e9f9b1
|
@@ -0,0 +1,47 @@
|
|
1
|
+
version: 2.1
|
2
|
+
|
3
|
+
jobs:
|
4
|
+
build:
|
5
|
+
docker:
|
6
|
+
- image: cimg/ruby:2.7-node # use a tailored CircleCI docker image.
|
7
|
+
steps:
|
8
|
+
- checkout
|
9
|
+
|
10
|
+
- run: bundle config set path 'vendor/bundle'
|
11
|
+
- run: sudo apt-get update
|
12
|
+
- run: sudo apt install libsqlite3-dev
|
13
|
+
|
14
|
+
# There's probably a better way of doing this that avoids the repetition and manual
|
15
|
+
# deletion of Gemfile.lock.
|
16
|
+
- restore_cache:
|
17
|
+
key: rails-5.2.4.4-{{ checksum "Gemfile" }}
|
18
|
+
- run:
|
19
|
+
name: bundle install
|
20
|
+
command: bundle install
|
21
|
+
environment:
|
22
|
+
ACTIVERECORD_VERSION: 5.2.4.4
|
23
|
+
- save_cache:
|
24
|
+
key: rails-5.2.4.4-{{ checksum "Gemfile" }}
|
25
|
+
paths:
|
26
|
+
- vendor/bundle
|
27
|
+
- run:
|
28
|
+
name: Test with Rails 5.2
|
29
|
+
command: bundle exec rspec
|
30
|
+
|
31
|
+
- run: rm Gemfile.lock
|
32
|
+
- restore_cache:
|
33
|
+
key: rails-6.0.3.4-{{ checksum "Gemfile" }}
|
34
|
+
- run:
|
35
|
+
name: bundle install
|
36
|
+
command: bundle install --path vendor/bundle
|
37
|
+
environment:
|
38
|
+
ACTIVERECORD_VERSION: 6.0.3.4
|
39
|
+
- save_cache:
|
40
|
+
key: rails-6.0.3.4-{{ checksum "Gemfile" }}
|
41
|
+
paths:
|
42
|
+
- vendor/bundle
|
43
|
+
- restore_cache:
|
44
|
+
key: rails-6.0.3.4-{{ checksum "Gemfile" }}
|
45
|
+
- run:
|
46
|
+
name: Test with Rails 6.0
|
47
|
+
command: bundle exec rspec
|
data/README.rdoc
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
= CachedCounts
|
2
2
|
|
3
|
+
{<img src="http://circleci.com/gh/academia-edu/cached_counts.svg?style=svg" />}[https://circleci.com/gh/academia-edu/cached_counts]
|
4
|
+
|
3
5
|
A replacement for Rails' counter caches, using memcached.
|
4
6
|
|
5
7
|
Caches counts of models in a scope or association in memcached, and keeps the
|
@@ -19,13 +19,11 @@ Gem::Specification.new do |s|
|
|
19
19
|
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
20
20
|
s.require_paths = ["lib"]
|
21
21
|
|
22
|
-
s.add_dependency "rails", ">=
|
22
|
+
s.add_dependency "rails", ">= 5.2"
|
23
23
|
s.add_dependency "dalli"
|
24
24
|
|
25
25
|
s.add_development_dependency "sqlite3"
|
26
26
|
s.add_development_dependency "rspec"
|
27
27
|
s.add_development_dependency "database_cleaner"
|
28
|
-
s.add_development_dependency "test_after_commit"
|
29
|
-
s.add_development_dependency "after_commit_exception_notification"
|
30
28
|
s.add_development_dependency "rake"
|
31
29
|
end
|
data/lib/cached_counts.rb
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
require 'cached_counts/logger'
|
2
|
-
require 'cached_counts/
|
3
|
-
require 'cached_counts/connection_for'
|
2
|
+
require 'cached_counts/query_context'
|
4
3
|
|
5
4
|
module CachedCounts
|
6
5
|
extend ActiveSupport::Concern
|
@@ -39,11 +38,13 @@ module CachedCounts
|
|
39
38
|
# @option options [Integer, #to_s] :version
|
40
39
|
# Cache version - bump if you change the definition of a count.
|
41
40
|
#
|
42
|
-
# @option options [
|
43
|
-
#
|
44
|
-
#
|
45
|
-
#
|
46
|
-
#
|
41
|
+
# @option options [Proc] :race_condition_fallback
|
42
|
+
# Fallback to the result of this proc if the cache is empty, while
|
43
|
+
# loading the actual value from the db. Works similarly to
|
44
|
+
# +race_condition_ttl+ but for empty caches rather than expired values.
|
45
|
+
# Meant to prevent a thundering-herd scenario, if for example a
|
46
|
+
# memcached instance goes away. Can be nil; defaults to using a value
|
47
|
+
# grabbed from the cache or DB at startup.
|
47
48
|
#
|
48
49
|
def caches_count_where(attribute_name, options = {})
|
49
50
|
# Delay actual run to work around circular dependencies
|
@@ -134,6 +135,14 @@ module CachedCounts
|
|
134
135
|
version = options.fetch :version, 1
|
135
136
|
key = scope_count_key(attribute_name, version)
|
136
137
|
|
138
|
+
unless options.has_key?(:race_condition_fallback)
|
139
|
+
options[:race_condition_fallback] = default_race_condition_fallback_proc(
|
140
|
+
key,
|
141
|
+
relation,
|
142
|
+
options
|
143
|
+
)
|
144
|
+
end
|
145
|
+
|
137
146
|
[attribute_name, *Array(options[:alias])].each do |attr_name|
|
138
147
|
add_count_attribute_methods(
|
139
148
|
attr_name,
|
@@ -146,6 +155,23 @@ module CachedCounts
|
|
146
155
|
end
|
147
156
|
end
|
148
157
|
|
158
|
+
def default_race_condition_fallback_proc(key, relation, options)
|
159
|
+
fallback = Rails.cache.read(key)
|
160
|
+
fallback = fallback.value if fallback.is_a?(ActiveSupport::Cache::Entry)
|
161
|
+
|
162
|
+
if fallback.nil?
|
163
|
+
begin
|
164
|
+
fallback = relation.count
|
165
|
+
rescue ActiveRecord::StatementInvalid => e
|
166
|
+
fallback = 0
|
167
|
+
end
|
168
|
+
|
169
|
+
Rails.cache.write key, fallback, expires_in: options.fetch(:expires_in, 1.week), raw: true
|
170
|
+
end
|
171
|
+
|
172
|
+
-> { fallback }
|
173
|
+
end
|
174
|
+
|
149
175
|
def define_association_count_attribute(attribute_name, association, options)
|
150
176
|
options = options.dup
|
151
177
|
|
@@ -158,6 +184,18 @@ module CachedCounts
|
|
158
184
|
association_count_key(id, attribute_name, version)
|
159
185
|
end
|
160
186
|
|
187
|
+
# Try to fetch values for ids from the cache. If it's a miss return the default value
|
188
|
+
define_singleton_method "try_#{attr_name}_counts_for" do |ids, default=nil|
|
189
|
+
raw_result = Rails.cache.read_multi(*ids.map{|id| association_count_key(id, attribute_name, version)})
|
190
|
+
|
191
|
+
result = {}
|
192
|
+
ids.each do |id|
|
193
|
+
result[id] = raw_result[association_count_key(id, attribute_name, version)]&.to_i || default
|
194
|
+
end
|
195
|
+
|
196
|
+
result
|
197
|
+
end
|
198
|
+
|
161
199
|
define_singleton_method "#{attr_name}_count_for" do |id|
|
162
200
|
new({id: id}, without_protection: true).send("#{attr_name}_count")
|
163
201
|
end
|
@@ -198,12 +236,7 @@ module CachedCounts
|
|
198
236
|
|
199
237
|
def add_count_attribute_methods(attribute_name, key_getter, relation_getter, define_with, counted_class, options)
|
200
238
|
expires_in = options.fetch :expires_in, 1.week
|
201
|
-
|
202
|
-
value_updater_lambda = options.fetch :value_updater_lambda, default_value_updater_lambda
|
203
|
-
# TODO: need a good strategy to figure this value out
|
204
|
-
# As a fallback value is used, we immediately fire the calculation for the real value
|
205
|
-
# Is it reasonable to wait as long as it takes to calcualte it? Is there an alternative?
|
206
|
-
fallback_expiry_seconds = expires_in
|
239
|
+
race_condition_fallback = options.fetch :race_condition_fallback, nil
|
207
240
|
|
208
241
|
key_method = "#{attribute_name}_count_key"
|
209
242
|
|
@@ -213,23 +246,31 @@ module CachedCounts
|
|
213
246
|
val = Rails.cache.fetch(
|
214
247
|
send(key_method),
|
215
248
|
expires_in: expires_in,
|
216
|
-
race_condition_ttl:
|
249
|
+
race_condition_ttl: 30.seconds,
|
217
250
|
raw: true # Necessary for incrementing to work correctly
|
218
251
|
) do
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
send(key_method)
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
252
|
+
if race_condition_fallback
|
253
|
+
# Ensure that other reads find something in the cache, but
|
254
|
+
# continue calculating here because the default is likely inaccurate.
|
255
|
+
fallback_value = instance_exec &race_condition_fallback
|
256
|
+
CachedCounts.logger.warn "Setting #{fallback_value} as race_condition_fallback for #{send(key_method)}"
|
257
|
+
Rails.cache.write(
|
258
|
+
send(key_method),
|
259
|
+
fallback_value.to_i,
|
260
|
+
expires_in: 30.seconds,
|
261
|
+
raw: true
|
262
|
+
)
|
263
|
+
end
|
264
|
+
|
228
265
|
relation = instance_exec(&relation_getter)
|
229
266
|
relation = relation.reorder('')
|
230
267
|
relation.select_values = ['count(*)']
|
231
|
-
|
268
|
+
|
269
|
+
CachedCounts.query_context.call(counted_class) do
|
270
|
+
counted_class.connection.select_value(relation.to_sql).to_i
|
271
|
+
end
|
232
272
|
end
|
273
|
+
|
233
274
|
if val.is_a?(ActiveSupport::Cache::Entry)
|
234
275
|
val.value.to_i
|
235
276
|
else
|
@@ -251,22 +292,8 @@ module CachedCounts
|
|
251
292
|
end
|
252
293
|
end
|
253
294
|
|
254
|
-
def default_value_updater_lambda()
|
255
|
-
lambda { |counted_class, relation_sql, cache_key, expires_in|
|
256
|
-
conn = CachedCounts.connection_for(counted_class)
|
257
|
-
value = conn.select_value(relation_sql).to_i
|
258
|
-
Rails.cache.write(
|
259
|
-
cache_key,
|
260
|
-
value,
|
261
|
-
expires_in: expires_in,
|
262
|
-
raw: true
|
263
|
-
)
|
264
|
-
return value
|
265
|
-
}
|
266
|
-
end
|
267
|
-
|
268
295
|
def add_counting_hooks(attribute_name, key_getter, counted_class, options)
|
269
|
-
increment_hook = "increment_#{attribute_name}_count"
|
296
|
+
increment_hook = "increment_#{attribute_name}_count".to_sym
|
270
297
|
counted_class.send :define_method, increment_hook do
|
271
298
|
if (key = instance_exec &key_getter)
|
272
299
|
Rails.cache.increment(
|
@@ -277,7 +304,7 @@ module CachedCounts
|
|
277
304
|
end
|
278
305
|
end
|
279
306
|
|
280
|
-
decrement_hook = "decrement_#{attribute_name}_count"
|
307
|
+
decrement_hook = "decrement_#{attribute_name}_count".to_sym
|
281
308
|
counted_class.send :define_method, decrement_hook do
|
282
309
|
if (key = instance_exec &key_getter)
|
283
310
|
Rails.cache.decrement(
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module CachedCounts
|
2
|
+
class << self
|
3
|
+
# Optional configuration: Set a proc which takes as arguments (1) the class
|
4
|
+
# we are counting and (2) the block that runs the count query. The
|
5
|
+
# `query_context` block must call the block that's passed as an argument.
|
6
|
+
#
|
7
|
+
# This is useful for replication, e.g.,
|
8
|
+
#
|
9
|
+
# CachedCounts.query_context = proc do |klass, &run_query|
|
10
|
+
# role = klass == User ? :reading : :writing
|
11
|
+
# ActiveRecord::Base.connected_to(role: role) do
|
12
|
+
# run_query.call
|
13
|
+
# end
|
14
|
+
# end
|
15
|
+
attr_writer :query_context
|
16
|
+
|
17
|
+
def query_context
|
18
|
+
@query_context ||= proc { |_klass, &block| block.call }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
data/spec/spec_helper.rb
CHANGED
@@ -3,7 +3,7 @@ require 'bundler/setup'
|
|
3
3
|
require 'pry'
|
4
4
|
require 'active_record'
|
5
5
|
require 'rails'
|
6
|
-
require 'active_support/cache/
|
6
|
+
require 'active_support/cache/mem_cache_store'
|
7
7
|
require 'cached_counts'
|
8
8
|
|
9
9
|
ActiveRecord::Base.configurations = YAML::load_file('spec/database.yml')
|
@@ -13,7 +13,7 @@ if ActiveRecord::Base.respond_to?(:raise_in_transactional_callbacks)
|
|
13
13
|
ActiveRecord::Base.raise_in_transactional_callbacks = true
|
14
14
|
end
|
15
15
|
|
16
|
-
Rails.cache = ActiveSupport::Cache::
|
16
|
+
Rails.cache = ActiveSupport::Cache::MemCacheStore.new("localhost")
|
17
17
|
|
18
18
|
RSpec.configure do |config|
|
19
19
|
config.before(:each) do
|
@@ -25,13 +25,6 @@ end
|
|
25
25
|
require_relative './fixtures.rb'
|
26
26
|
require 'database_cleaner'
|
27
27
|
|
28
|
-
if Rails.version.to_f < 5.0
|
29
|
-
require 'test_after_commit'
|
30
|
-
end
|
31
|
-
if Rails.version.to_f < 4.2
|
32
|
-
require 'after_commit_exception_notification'
|
33
|
-
end
|
34
|
-
|
35
28
|
RSpec.configure do |config|
|
36
29
|
config.before(:suite) do
|
37
30
|
DatabaseCleaner.strategy = :transaction
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: incrdecr_cached_counts
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.5.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- David Judd
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date:
|
11
|
+
date: 2021-01-13 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: rails
|
@@ -16,14 +16,14 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: '
|
19
|
+
version: '5.2'
|
20
20
|
type: :runtime
|
21
21
|
prerelease: false
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
23
23
|
requirements:
|
24
24
|
- - ">="
|
25
25
|
- !ruby/object:Gem::Version
|
26
|
-
version: '
|
26
|
+
version: '5.2'
|
27
27
|
- !ruby/object:Gem::Dependency
|
28
28
|
name: dalli
|
29
29
|
requirement: !ruby/object:Gem::Requirement
|
@@ -80,34 +80,6 @@ dependencies:
|
|
80
80
|
- - ">="
|
81
81
|
- !ruby/object:Gem::Version
|
82
82
|
version: '0'
|
83
|
-
- !ruby/object:Gem::Dependency
|
84
|
-
name: test_after_commit
|
85
|
-
requirement: !ruby/object:Gem::Requirement
|
86
|
-
requirements:
|
87
|
-
- - ">="
|
88
|
-
- !ruby/object:Gem::Version
|
89
|
-
version: '0'
|
90
|
-
type: :development
|
91
|
-
prerelease: false
|
92
|
-
version_requirements: !ruby/object:Gem::Requirement
|
93
|
-
requirements:
|
94
|
-
- - ">="
|
95
|
-
- !ruby/object:Gem::Version
|
96
|
-
version: '0'
|
97
|
-
- !ruby/object:Gem::Dependency
|
98
|
-
name: after_commit_exception_notification
|
99
|
-
requirement: !ruby/object:Gem::Requirement
|
100
|
-
requirements:
|
101
|
-
- - ">="
|
102
|
-
- !ruby/object:Gem::Version
|
103
|
-
version: '0'
|
104
|
-
type: :development
|
105
|
-
prerelease: false
|
106
|
-
version_requirements: !ruby/object:Gem::Requirement
|
107
|
-
requirements:
|
108
|
-
- - ">="
|
109
|
-
- !ruby/object:Gem::Version
|
110
|
-
version: '0'
|
111
83
|
- !ruby/object:Gem::Dependency
|
112
84
|
name: rake
|
113
85
|
requirement: !ruby/object:Gem::Requirement
|
@@ -130,20 +102,17 @@ executables: []
|
|
130
102
|
extensions: []
|
131
103
|
extra_rdoc_files: []
|
132
104
|
files:
|
133
|
-
- ".circleci
|
105
|
+
- ".circleci/config.yml"
|
134
106
|
- ".gitignore"
|
135
|
-
- ".travis.yml"
|
136
107
|
- ".yardopts"
|
137
108
|
- Gemfile
|
138
109
|
- MIT-LICENSE
|
139
110
|
- README.rdoc
|
140
111
|
- Rakefile
|
141
|
-
- circle.yml
|
142
112
|
- incrdecr_cached_counts.gemspec
|
143
113
|
- lib/cached_counts.rb
|
144
|
-
- lib/cached_counts/connection_for.rb
|
145
|
-
- lib/cached_counts/dalli_check.rb
|
146
114
|
- lib/cached_counts/logger.rb
|
115
|
+
- lib/cached_counts/query_context.rb
|
147
116
|
- lib/cached_counts/railtie.rb
|
148
117
|
- lib/cached_counts/version.rb
|
149
118
|
- spec/caches_count_of_spec.rb
|
@@ -174,10 +143,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
174
143
|
- !ruby/object:Gem::Version
|
175
144
|
version: '0'
|
176
145
|
requirements: []
|
177
|
-
|
178
|
-
rubygems_version: 2.5.1
|
146
|
+
rubygems_version: 3.1.4
|
179
147
|
signing_key:
|
180
148
|
specification_version: 4
|
181
149
|
summary: A replacement for Rails' counter caches using memcached (via Dalli)
|
182
150
|
test_files: []
|
183
|
-
has_rdoc:
|
data/.circleci-matrix.yml
DELETED
data/.travis.yml
DELETED
@@ -1,15 +0,0 @@
|
|
1
|
-
sudo: false # use newer travis infrastructure
|
2
|
-
language: ruby
|
3
|
-
rvm:
|
4
|
-
- "2.1.8"
|
5
|
-
- "2.2.4"
|
6
|
-
- "2.3.0"
|
7
|
-
env:
|
8
|
-
- "ACTIVERECORD_VERSION=4.2.6"
|
9
|
-
- "ACTIVERECORD_VERSION=4.1.15"
|
10
|
-
- "ACTIVERECORD_VERSION=4.0.13"
|
11
|
-
- "ACTIVERECORD_VERSION=master"
|
12
|
-
matrix:
|
13
|
-
allow_failures:
|
14
|
-
# Master may or may not pass
|
15
|
-
- env: "ACTIVERECORD_VERSION=master"
|
data/circle.yml
DELETED
@@ -1,14 +0,0 @@
|
|
1
|
-
module CachedCounts
|
2
|
-
class << self
|
3
|
-
# Optional configuration: set a proc which takes the class we are counting
|
4
|
-
# and returns a connection. Useful with replication, e.g. with Octopus:
|
5
|
-
#
|
6
|
-
# `CachedCounts.connection_for = proc { |klass| klass.using(:read_slave).connection }`
|
7
|
-
attr_writer :connection_for
|
8
|
-
|
9
|
-
def connection_for(counted_class)
|
10
|
-
@connection_for ||= proc { |klass| klass.connection }
|
11
|
-
@connection_for[counted_class]
|
12
|
-
end
|
13
|
-
end
|
14
|
-
end
|