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 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