incrdecr_cached_counts 0.1.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 933d0e94e08544064f20c36691f4c82fc581a843
4
- data.tar.gz: d60e51a4e1f2f3be7329f41999199ee04c471675
2
+ SHA256:
3
+ metadata.gz: 5c0a0f2090aabd3b286436ad4bce2dca587f003cb5c19307be1d7974c84894ab
4
+ data.tar.gz: 546850171cb6d859149155acd0a6c3cc4697702306c7e7d7015c6a6e86bc4066
5
5
  SHA512:
6
- metadata.gz: 0e83facc3fb470a922d4dbbca8fbefdfe525a8a03016a49a9e3f244b97f8ca46e4c77a1d46143fd9ad84046c98d5bc1d9c46ab50fbd861f58c15d618947e9c7b
7
- data.tar.gz: c7fb4d8e58784e72ac21884abb76412050a18c6e55520d470b65fc336c49336728ff2677bf2f6b5760c2c6a5bba2ed8683ff44280ebd1967e4982b1125b80352
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
@@ -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", ">= 4.0"
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
@@ -1,6 +1,5 @@
1
1
  require 'cached_counts/logger'
2
- require 'cached_counts/dalli_check'
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 [Lambda] :default_value_lambda
43
- # Uses this value when the cache is empty, while it is being populated
44
- #
45
- # @option options [Lambda] :value_updater_lambda
46
- # Override logic for updating a cache value - e.g. to perform background updates
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
- default_value_lambda = options.fetch :default_value_lambda, -> {0}
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: fallback_expiry_seconds,
249
+ race_condition_ttl: 30.seconds,
217
250
  raw: true # Necessary for incrementing to work correctly
218
251
  ) do
219
- # Write down the default value first so subsequent reads don't repeat calculations
220
- # TODO: multiple requests can potentially get to this point and start multiple update operations? Is that a problem?
221
- default_value = instance_exec &default_value_lambda
222
- Rails.cache.write(
223
- send(key_method),
224
- default_value,
225
- expires_in: fallback_expiry_seconds,
226
- raw: true
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
- value_updater_lambda.call(counted_class, relation.to_sql, send(key_method), expires_in) || default_value
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
@@ -1,3 +1,3 @@
1
1
  module CachedCounts
2
- VERSION = "0.1.0"
2
+ VERSION = "0.5.0"
3
3
  end
@@ -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/dalli_store'
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::DalliStore.new("localhost")
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.1.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: 2016-12-06 00:00:00.000000000 Z
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: '4.0'
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: '4.0'
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-matrix.yml"
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
- rubyforge_project:
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:
@@ -1,8 +0,0 @@
1
- env:
2
- - ACTIVERECORD_VERSION=4.2.6
3
- - ACTIVERECORD_VERSION=4.1.15
4
- - ACTIVERECORD_VERSION=4.0.13
5
-
6
- command:
7
- - rm -f Gemfile.lock && ACTIVERECORD_VERSION=$ACTIVERECORD_VERSION bundle
8
- - bundle exec rake
@@ -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,10 +0,0 @@
1
- machine:
2
- ruby:
3
- version: 2.2.3
4
-
5
- dependencies:
6
- pre:
7
- - curl -fsSL https://git.io/v2Ifn | bash
8
-
9
- database:
10
- override:
@@ -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
@@ -1,5 +0,0 @@
1
- if defined?(Rails)
2
- ActiveSupport.on_load :cached_counts do
3
- raise "CachedCounts depends on Dalli!" unless Rails.cache.respond_to?(:dalli)
4
- end
5
- end