cached_counter 0.0.1
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 +7 -0
- data/.gitignore +15 -0
- data/.rspec +2 -0
- data/.travis.yml +22 -0
- data/Gemfile +17 -0
- data/LICENSE.txt +22 -0
- data/README.md +77 -0
- data/Rakefile +2 -0
- data/cached_counter.gemspec +23 -0
- data/lib/cached_counter.rb +314 -0
- data/lib/cached_counter/version.rb +3 -0
- data/spec/cached_counter_spec.rb +120 -0
- data/spec/setup/articles_with_delayed_jobs.rb +35 -0
- data/spec/setup/sqlite.rb +10 -0
- data/spec/setup/undefine.rb +7 -0
- data/spec/spec_helper.rb +40 -0
- metadata +95 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: d8e2382b12fc86f19c6dd734dfa801ff77f1e84d
|
4
|
+
data.tar.gz: f961dfb495177005b7f59e88e446bcc84dcf1588
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 86a9b2d91155c1b4fea65fee55d0e5815d9869081cb737b993235dd4193625356ddbe687f04a1f0574b547eaaa722a5c6da9de0ad1001832b744379e1a0dbe4d
|
7
|
+
data.tar.gz: 8fc6a71789bd035a13c6e632041ebbfceb6ec852826194c977f09f3bd351179f3c6f30087adfd49eaaeef6088cdfa3a7d7d3ba3236fe9ddedf64b858b6536556
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
bundler_args: "--without development"
|
2
|
+
language: ruby
|
3
|
+
rvm:
|
4
|
+
- 2.1.2
|
5
|
+
- 2.1.3
|
6
|
+
- ruby-head
|
7
|
+
gemfile:
|
8
|
+
- Gemfile
|
9
|
+
matrix:
|
10
|
+
allow_failures:
|
11
|
+
- rvm: ruby-head
|
12
|
+
fast_finish: true
|
13
|
+
services: memcache
|
14
|
+
before_install: gem install bundler
|
15
|
+
before_script:
|
16
|
+
- echo $PWD
|
17
|
+
- memcached -p 11212 -d
|
18
|
+
script:
|
19
|
+
- bundle exec rspec
|
20
|
+
notifications:
|
21
|
+
slack:
|
22
|
+
secure: aVCX6aZuIS4QWS2GPaAvMulnUm3L5IBtIQCRI+BMQSZcvKKwidKz/jjCaVUSK1kyjQRIBh61B/SykMTZKtxi7HV7CMklpCXJGZeJdwhDRK1ZnfjvkO0AayMYYJ0NwzHLvHaOGCxyZIvQqUT8S6HwUVpxg4wjjMzLefqCx2c/8Ek=
|
data/Gemfile
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
source 'https://rubygems.org'
|
2
|
+
|
3
|
+
# Specify your gem's dependencies in elasticsearch-model-extensions.gemspec
|
4
|
+
gemspec path: File.dirname(__FILE__)
|
5
|
+
|
6
|
+
group :test do
|
7
|
+
gem 'rspec', '~> 3.1.0'
|
8
|
+
gem 'database_cleaner'
|
9
|
+
gem 'coveralls', require: false
|
10
|
+
end
|
11
|
+
|
12
|
+
group :test, :development do
|
13
|
+
gem 'activerecord', '~> 3.2'
|
14
|
+
gem 'sqlite3'
|
15
|
+
gem 'delayed_job_active_record', '~> 4.0.1'
|
16
|
+
gem 'dalli', '~> 2.7.2'
|
17
|
+
end
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2015 Yusuke KUOKA
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
# CachedCounter
|
2
|
+
|
3
|
+
[](https://travis-ci.org/crowdworks/cached_counter)
|
4
|
+
[](https://coveralls.io/r/crowdworks/cached_counter?branch=master)
|
5
|
+
[](https://codeclimate.com/github/crowdworks/cached_counter)
|
6
|
+
|
7
|
+
Cache Counter is an instantaneous but lock-friendly implementation of the counter.
|
8
|
+
|
9
|
+
It allows to increment/decrement/get counts primarily saved in the database in a faster way.
|
10
|
+
Utilizing the cache, it can be updated without big row-locks like the ActiveRecord's update_counters,
|
11
|
+
with instantaneous and consistency unlike the updated_counters within delayed, background jobs.
|
12
|
+
|
13
|
+
## Installation
|
14
|
+
|
15
|
+
Add this line to your application's Gemfile:
|
16
|
+
|
17
|
+
```ruby
|
18
|
+
gem 'cached_counter'
|
19
|
+
```
|
20
|
+
|
21
|
+
And then execute:
|
22
|
+
|
23
|
+
$ bundle
|
24
|
+
|
25
|
+
Or install it yourself as:
|
26
|
+
|
27
|
+
$ gem install cached_counter
|
28
|
+
|
29
|
+
## Usage
|
30
|
+
|
31
|
+
```ruby
|
32
|
+
# When you use the counter backed by Memcached via Dalli
|
33
|
+
require 'dalli'
|
34
|
+
require' cache_store'
|
35
|
+
|
36
|
+
# Give the Cached Counter the name of the Cache Store and options you want to pass.
|
37
|
+
# The built-in DalliCacheStore is named `:dalli` and accepts the `:hosts` option which is an array of Memcached hosts
|
38
|
+
# you want Dalli connect to.
|
39
|
+
CachedCounter.cache_store :dalli, hosts: %w| 127.0.0.1 |
|
40
|
+
|
41
|
+
# We have an integer column named `num_read` in the `articles` table.
|
42
|
+
article = Article.first
|
43
|
+
|
44
|
+
# Give Cached Counter the record which has an attribute containing the initial value of the counter.
|
45
|
+
cached_counter = CachedCounter.create(record: article, attribute: :num_read)
|
46
|
+
|
47
|
+
article.num_read
|
48
|
+
#=> 0
|
49
|
+
cached_counter.value
|
50
|
+
#=> 0
|
51
|
+
|
52
|
+
# The value of the counter is updated immediately,
|
53
|
+
# and a background job is scheduled to eventually update the value of the counter persisted in the database.
|
54
|
+
cached_counter.increment
|
55
|
+
|
56
|
+
# You can see the incremented value in the cache immediately
|
57
|
+
cached_counter.value
|
58
|
+
#=> 1
|
59
|
+
|
60
|
+
# But not yet in the database
|
61
|
+
article.reload.num_read
|
62
|
+
#=> 0
|
63
|
+
|
64
|
+
# When the scheduled background job is finished,
|
65
|
+
# you can also see the incremented value in the database, too.
|
66
|
+
article.reload.num_read
|
67
|
+
#=> 1
|
68
|
+
```
|
69
|
+
|
70
|
+
|
71
|
+
## Contributing
|
72
|
+
|
73
|
+
1. Fork it ( https://github.com/crowdworks/cached_counter/fork )
|
74
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
75
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
76
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
77
|
+
5. Create a new Pull Request
|
data/Rakefile
ADDED
@@ -0,0 +1,23 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'cached_counter/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "cached_counter"
|
8
|
+
spec.version = CachedCounter::VERSION
|
9
|
+
spec.authors = ["Yusuke KUOKA"]
|
10
|
+
spec.email = ["ykuoka@gmail.com"]
|
11
|
+
spec.summary = %q{An instantaneous but lock-friendly implementation of the counter}
|
12
|
+
spec.description = %q{Cached Counter allows to increment/decrement/get counts primarily saved in the database in a faster way. Utilizing the cache, it can be updated without big row-locks like the ActiveRecord's update_counters, with instantaneous and consistency unlike the updated_counters within delayed, background jobs}
|
13
|
+
spec.homepage = "https://github.com/crowdworks/cached_counter"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_development_dependency "bundler", "~> 1.7"
|
22
|
+
spec.add_development_dependency "rake", "~> 10.0"
|
23
|
+
end
|
@@ -0,0 +1,314 @@
|
|
1
|
+
require "cached_counter/version"
|
2
|
+
|
3
|
+
class CachedCounter
|
4
|
+
attr_reader :model_class, :attribute, :id
|
5
|
+
|
6
|
+
class ConcurrentCacheWriteError < StandardError; end
|
7
|
+
|
8
|
+
# @param [#call] cache_key The proc which takes the single argument of the class inheriting ActiveRecord::Base and returns a cache key
|
9
|
+
# @return [#call]
|
10
|
+
def self.cache_key(cache_key=nil)
|
11
|
+
if cache_key
|
12
|
+
@cache_key = cache_key
|
13
|
+
else
|
14
|
+
@cache_key ||= -> c { "#{c.model_class.model_name.cache_key}/#{c.attribute}/cached_counter/#{c.id}" }
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
# @param [#call] cache_store
|
19
|
+
# @return [CacheStore::Base]
|
20
|
+
def self.cache_store(cache_store=nil, *args, **options)
|
21
|
+
if cache_store
|
22
|
+
@cache_store = builder_for_cache_store(cache_store, *args, **options)
|
23
|
+
else
|
24
|
+
@cache_store
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
# @param [Proc]
|
29
|
+
def self.builder_for_cache_store(cache_store, *args, **options)
|
30
|
+
case cache_store
|
31
|
+
when Symbol
|
32
|
+
klass = cache_store_class_for_symbol(cache_store)
|
33
|
+
-> { klass.create(*args, **options) }
|
34
|
+
when Class
|
35
|
+
-> { cache_store.create(*args, **options)}
|
36
|
+
when Proc
|
37
|
+
cache_store
|
38
|
+
else
|
39
|
+
-> { cache_store }
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
# @param [Symbol] symbol
|
44
|
+
def self.cache_store_class_for_symbol(symbol)
|
45
|
+
const_get CacheStore.name + '::' + symbol.to_s.split('_').map(&:capitalize).join + 'CacheStore'
|
46
|
+
end
|
47
|
+
|
48
|
+
# @param [ActiveRecord::Base] record
|
49
|
+
# @param [Symbol] attribute
|
50
|
+
def self.create(record:, attribute:, cache_store: nil)
|
51
|
+
new(model_class: record.class, id: record.id, attribute: attribute, cache_store: cache_store)
|
52
|
+
end
|
53
|
+
|
54
|
+
def initialize(model_class:, id:, attribute:, cache_store: nil)
|
55
|
+
@model_class = model_class
|
56
|
+
@attribute = attribute
|
57
|
+
@id = id
|
58
|
+
@cache_store = cache_store
|
59
|
+
end
|
60
|
+
|
61
|
+
# Increment the specified attribute of the record utilizing the cache not to lock the table row as long as possible
|
62
|
+
def increment
|
63
|
+
cache_updated_successfully =
|
64
|
+
with_cache_store do |store|
|
65
|
+
begin
|
66
|
+
store.incr(cache_key) ||
|
67
|
+
# When the key doesn't exit in the cache because of cache expirations/clean-ups/restarts
|
68
|
+
store.add(cache_key, value_in_db + 1) ||
|
69
|
+
# In rare cases, the value for the key is updated by other client and we have to fail immediately, not to
|
70
|
+
# run into race-conditions.
|
71
|
+
raise(ConcurrentCacheWriteError, "Failing not to enter a race condition while writing a value for the key #{cache_key}")
|
72
|
+
rescue store.error_class => e
|
73
|
+
false
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
begin
|
78
|
+
if cache_updated_successfully
|
79
|
+
# When this database transaction failed afterward, we have to rollback the incrementation by decrementing
|
80
|
+
# the value in the cached.
|
81
|
+
# Without the rollback, we'll fall into an inconsistent state between the database and the cache.
|
82
|
+
on_error_rollback_by(:decrement_in_cache)
|
83
|
+
|
84
|
+
# As we have successfully incremented the value in the cache, we can rely on the cache in order to
|
85
|
+
# get/show the latest value.
|
86
|
+
# Therefore, we have no need to update the database record in realtime and we can achieve
|
87
|
+
# incrementing the counter with a little row-lock.
|
88
|
+
increment_in_db_later
|
89
|
+
else
|
90
|
+
# The cache service seems to be down, but we don't want to stop the application service.
|
91
|
+
# That's why we fall back to increment the value in the database which requires a bigger row-lock.
|
92
|
+
increment_in_db
|
93
|
+
end
|
94
|
+
rescue => e
|
95
|
+
raise e
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def value
|
100
|
+
begin
|
101
|
+
cache_store.get(cache_key).try(&:to_i)
|
102
|
+
rescue cache_store.error_class => e
|
103
|
+
nil
|
104
|
+
end || value_in_db
|
105
|
+
end
|
106
|
+
|
107
|
+
# Decrement the specified attribute of the record utilizing the cache not to lock the table row as long as possible
|
108
|
+
def decrement
|
109
|
+
cache_updated_successfully =
|
110
|
+
with_cache_store do |store|
|
111
|
+
begin
|
112
|
+
store.decr(cache_key) ||
|
113
|
+
store.add(cache_key, value_in_db - 1) ||
|
114
|
+
raise(ConcurrentCacheWriteError, "Failing not to enter a race condition while writing a value for the key #{cache_key}")
|
115
|
+
rescue store.error_class => e
|
116
|
+
false
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
begin
|
121
|
+
if cache_updated_successfully
|
122
|
+
on_error_rollback_by(:increment_in_cache)
|
123
|
+
|
124
|
+
decrement_in_db_later
|
125
|
+
else
|
126
|
+
decrement_in_db
|
127
|
+
end
|
128
|
+
rescue => e
|
129
|
+
raise e
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def value_in_db
|
134
|
+
@model_class.find(@id).send(@attribute)
|
135
|
+
end
|
136
|
+
|
137
|
+
def invalidate_cache
|
138
|
+
cache_store.delete(cache_key)
|
139
|
+
end
|
140
|
+
|
141
|
+
def increment_in_cache
|
142
|
+
with_cache_store do |d|
|
143
|
+
d.incr(cache_key)
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
def increment_in_db
|
148
|
+
@model_class.increment_counter(@attribute, @id)
|
149
|
+
end
|
150
|
+
|
151
|
+
def increment_in_db_later
|
152
|
+
call_method_later(:increment_in_db)
|
153
|
+
end
|
154
|
+
|
155
|
+
def decrement_in_cache
|
156
|
+
with_cache_store do |d|
|
157
|
+
d.decr(cache_key)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def decrement_in_db
|
162
|
+
@model_class.decrement_counter(@attribute, @id)
|
163
|
+
end
|
164
|
+
|
165
|
+
def decrement_in_db_later
|
166
|
+
call_method_later(:decrement_in_db)
|
167
|
+
end
|
168
|
+
|
169
|
+
# @return [String]
|
170
|
+
def cache_key
|
171
|
+
self.class.cache_key.call(self)
|
172
|
+
end
|
173
|
+
|
174
|
+
private
|
175
|
+
|
176
|
+
# @return [CacheStore::Base]
|
177
|
+
def cache_store
|
178
|
+
@cache_store ||= self.class.cache_store.call
|
179
|
+
end
|
180
|
+
|
181
|
+
def with_cache_store
|
182
|
+
yield cache_store
|
183
|
+
end
|
184
|
+
|
185
|
+
# @param [Symbol] method
|
186
|
+
def call_method_later(method)
|
187
|
+
CachedCounter::RetriedJob.new(
|
188
|
+
model_class: @model_class,
|
189
|
+
id: @id,
|
190
|
+
attribute: @attribute,
|
191
|
+
method: method
|
192
|
+
).enqueue!
|
193
|
+
end
|
194
|
+
|
195
|
+
# @param [Symbol] method
|
196
|
+
def on_error_rollback_by(method)
|
197
|
+
listener = CachedCounter::RollbackByMethodCallListener.new(
|
198
|
+
model_class: @model_class,
|
199
|
+
id: @id,
|
200
|
+
attribute: @attribute,
|
201
|
+
method: method
|
202
|
+
)
|
203
|
+
|
204
|
+
# @see https://github.com/rails/rails/blob/v3.2.18/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L242
|
205
|
+
# @see https://github.com/rails/rails/blob/v4.1.4/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L248
|
206
|
+
@model_class.connection.add_transaction_record(listener)
|
207
|
+
end
|
208
|
+
|
209
|
+
class RetriedJob
|
210
|
+
def initialize(model_class:, id:, attribute:, method:)
|
211
|
+
@model_class = model_class
|
212
|
+
@id = id
|
213
|
+
@attribute = attribute
|
214
|
+
@method = method
|
215
|
+
end
|
216
|
+
|
217
|
+
def perform
|
218
|
+
CachedCounter.new(model_class: @model_class, id: @id, attribute: @attribute).send(@method)
|
219
|
+
end
|
220
|
+
|
221
|
+
def max_attempts
|
222
|
+
10
|
223
|
+
end
|
224
|
+
|
225
|
+
def enqueue!
|
226
|
+
Delayed::Job.enqueue self
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
class RollbackByMethodCallListener
|
231
|
+
def initialize(model_class:, id:, attribute:, method:)
|
232
|
+
@model_class = model_class
|
233
|
+
@id = id
|
234
|
+
@attribute = attribute
|
235
|
+
@method = method
|
236
|
+
end
|
237
|
+
|
238
|
+
# called only when Rails is 4+
|
239
|
+
# Without this method implemented, you will see `undefined method `has_transactional_callbacks?' for #<CachedCounter::RollbackByMethodCallListener:0x007f5af749cd80>`
|
240
|
+
# when the listener is passed to `#add_transaction_record`
|
241
|
+
# @see Rails 4: https://github.com/rails/rails/blob/v4.1.4/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L125
|
242
|
+
def has_transactional_callbacks?
|
243
|
+
true
|
244
|
+
end
|
245
|
+
|
246
|
+
# @see Rails 3: https://github.com/rails/rails/blob/v3.2.18/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L372
|
247
|
+
# @see Rails 4: https://github.com/rails/rails/blob/v4.1.4/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L147
|
248
|
+
def committed!
|
249
|
+
nil
|
250
|
+
end
|
251
|
+
|
252
|
+
# @see ActiveRecord::Transactions::rolledback!
|
253
|
+
# @see Rails 3: https://github.com/rails/rails/blob/v3.2.18/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb#L357
|
254
|
+
# @see Rails 4: https://github.com/rails/rails/blob/v4.1.4/activerecord/lib/active_record/connection_adapters/abstract/transaction.rb#L136
|
255
|
+
def rolledback!(force_restore_state = false)
|
256
|
+
CachedCounter.new(model_class: @model_class, id: @id, attribute: @attribute).send(@method)
|
257
|
+
end
|
258
|
+
end
|
259
|
+
|
260
|
+
module CacheStore
|
261
|
+
class Base
|
262
|
+
def incr(key); fail_with_not_implemented_error 'incr' end
|
263
|
+
def decr(key); fail_with_not_implemented_error 'decr' end
|
264
|
+
def add(key, value); fail_with_not_implemented_error 'add' end
|
265
|
+
def delete(key); fail_with_not_implemented_error 'delete' end
|
266
|
+
def error_class; fail_with_not_implemented_error 'incr' end
|
267
|
+
|
268
|
+
private
|
269
|
+
|
270
|
+
def fail_with_not_implemented_error(method_name)
|
271
|
+
fail ::CacheStore::Base::NotImplementedError, "#{self.class.name}##{method_name} must be implemented."
|
272
|
+
end
|
273
|
+
|
274
|
+
class NotImplementedError < StandardError; end
|
275
|
+
end
|
276
|
+
|
277
|
+
class DalliCacheStore < Base
|
278
|
+
# @param [Dalli::Client] dalli_client
|
279
|
+
def initialize(dalli_client)
|
280
|
+
@dalli_client = dalli_client
|
281
|
+
end
|
282
|
+
|
283
|
+
def self.create(options={})
|
284
|
+
opts = options.dup
|
285
|
+
hosts = opts.delete(:hosts)
|
286
|
+
new(Dalli::Client.new(hosts, opts))
|
287
|
+
end
|
288
|
+
|
289
|
+
def incr(key)
|
290
|
+
@dalli_client.incr(key)
|
291
|
+
end
|
292
|
+
|
293
|
+
def decr(key)
|
294
|
+
@dalli_client.decr(key)
|
295
|
+
end
|
296
|
+
|
297
|
+
def add(key, value)
|
298
|
+
@dalli_client.add(key, value, 0, raw: true)
|
299
|
+
end
|
300
|
+
|
301
|
+
def get(key)
|
302
|
+
@dalli_client.get(key)
|
303
|
+
end
|
304
|
+
|
305
|
+
def delete(key)
|
306
|
+
@dalli_client.delete(key)
|
307
|
+
end
|
308
|
+
|
309
|
+
def error_class
|
310
|
+
Dalli::RingError
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
require 'dalli'
|
4
|
+
|
5
|
+
require 'cached_counter'
|
6
|
+
|
7
|
+
RSpec.describe CachedCounter do
|
8
|
+
before(:each) do
|
9
|
+
load 'setup/articles_with_delayed_jobs.rb'
|
10
|
+
|
11
|
+
CachedCounter.cache_store :dalli, hosts: %w| 127.0.0.1 |
|
12
|
+
end
|
13
|
+
|
14
|
+
after :each do
|
15
|
+
ActiveRecord::Schema.define(:version => 2) do
|
16
|
+
drop_table :delayed_jobs
|
17
|
+
drop_table :articles
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
let(:cache_store) { CachedCounter.cache_store.call }
|
22
|
+
let(:cached_counter) { CachedCounter.create(record: record, attribute: :num_read, cache_store: cache_store) }
|
23
|
+
let(:record) { Article.first }
|
24
|
+
|
25
|
+
subject { cached_counter }
|
26
|
+
|
27
|
+
before do
|
28
|
+
cached_counter.invalidate_cache
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '#increment' do
|
32
|
+
subject { -> { cached_counter.increment } }
|
33
|
+
|
34
|
+
it { is_expected.to change { cached_counter.value }.by(1) }
|
35
|
+
it { is_expected.not_to change { record.num_read} }
|
36
|
+
it { is_expected.to change { record.reload.num_read }.by(1) }
|
37
|
+
|
38
|
+
context 'when the surrounding transaction rolled-back' do
|
39
|
+
subject { -> { transaction }}
|
40
|
+
|
41
|
+
let(:transaction) {
|
42
|
+
begin
|
43
|
+
Article.transaction do
|
44
|
+
cached_counter.increment
|
45
|
+
|
46
|
+
fail 'simulated error'
|
47
|
+
end
|
48
|
+
rescue
|
49
|
+
;
|
50
|
+
end
|
51
|
+
}
|
52
|
+
|
53
|
+
it { is_expected.not_to change { cached_counter.value } }
|
54
|
+
it { is_expected.not_to change { record.num_read } }
|
55
|
+
it { is_expected.not_to change { record.reload.num_read } }
|
56
|
+
end
|
57
|
+
|
58
|
+
context 'when the cache is updated concurrently' do
|
59
|
+
before do
|
60
|
+
expect(cache_store).to receive(:incr).and_return(false)
|
61
|
+
expect(cache_store).to receive(:add).and_return(false)
|
62
|
+
end
|
63
|
+
|
64
|
+
subject { -> { cached_counter.increment } }
|
65
|
+
|
66
|
+
it { is_expected.to raise_error(CachedCounter::ConcurrentCacheWriteError)}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
describe '#decrement' do
|
71
|
+
subject { -> { cached_counter.decrement } }
|
72
|
+
|
73
|
+
it { is_expected.to change { cached_counter.value }.by(-1) }
|
74
|
+
it { is_expected.not_to change { record.num_read} }
|
75
|
+
it { is_expected.to change { record.reload.num_read }.by(-1) }
|
76
|
+
|
77
|
+
context 'when the surrounding transaction rolled-back' do
|
78
|
+
subject { -> { transaction }}
|
79
|
+
|
80
|
+
let(:transaction) {
|
81
|
+
begin
|
82
|
+
Article.transaction do
|
83
|
+
cached_counter.decrement
|
84
|
+
|
85
|
+
fail 'simulated error'
|
86
|
+
end
|
87
|
+
rescue
|
88
|
+
;
|
89
|
+
end
|
90
|
+
}
|
91
|
+
|
92
|
+
it { is_expected.not_to change { cached_counter.value } }
|
93
|
+
it { is_expected.not_to change { record.num_read } }
|
94
|
+
it { is_expected.not_to change { record.reload.num_read } }
|
95
|
+
end
|
96
|
+
|
97
|
+
context 'when the cache is updated concurrently' do
|
98
|
+
before do
|
99
|
+
expect(cache_store).to receive(:decr).and_return(false)
|
100
|
+
expect(cache_store).to receive(:add).and_return(false)
|
101
|
+
end
|
102
|
+
|
103
|
+
subject { -> { cached_counter.decrement } }
|
104
|
+
|
105
|
+
it { is_expected.to raise_error(CachedCounter::ConcurrentCacheWriteError)}
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
context 'when the cache_store is not specified' do
|
110
|
+
let(:cache_store) { nil }
|
111
|
+
|
112
|
+
describe '#increment' do
|
113
|
+
subject { -> { cached_counter.increment } }
|
114
|
+
|
115
|
+
it { is_expected.to change { cached_counter.value }.by(1) }
|
116
|
+
it { is_expected.not_to change { record.num_read} }
|
117
|
+
it { is_expected.to change { record.reload.num_read }.by(1) }
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
load 'setup/undefine.rb'
|
2
|
+
|
3
|
+
require 'delayed_job_active_record'
|
4
|
+
|
5
|
+
ActiveRecord::Schema.define(:version => 1) do
|
6
|
+
create_table :articles do |t|
|
7
|
+
t.string :title
|
8
|
+
t.integer :num_read, :default => 0
|
9
|
+
t.datetime :created_at, :default => 'NOW()'
|
10
|
+
end
|
11
|
+
|
12
|
+
create_table :delayed_jobs, :force => true do |table|
|
13
|
+
table.integer :priority, :default => 0
|
14
|
+
table.integer :attempts, :default => 0
|
15
|
+
table.text :handler
|
16
|
+
table.text :last_error
|
17
|
+
table.datetime :run_at
|
18
|
+
table.datetime :locked_at
|
19
|
+
table.datetime :failed_at
|
20
|
+
table.string :locked_by
|
21
|
+
table.string :queue
|
22
|
+
table.timestamps
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
Delayed::Worker.delay_jobs = false
|
27
|
+
|
28
|
+
class ::Article < ActiveRecord::Base
|
29
|
+
end
|
30
|
+
|
31
|
+
Article.delete_all
|
32
|
+
|
33
|
+
::Article.create! title: 'Test', num_read: 1
|
34
|
+
::Article.create! title: 'Testing Coding'
|
35
|
+
::Article.create! title: 'Coding'
|
@@ -0,0 +1,10 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
ActiveRecord::Base.establish_connection(:adapter => 'sqlite3', :database => "test.db")
|
5
|
+
logger = ::Logger.new(STDERR)
|
6
|
+
logger.formatter = lambda { |s, d, p, m| "\e[2;36m#{m}\e[0m\n" }
|
7
|
+
ActiveRecord::Base.logger = logger unless ENV['QUIET']
|
8
|
+
|
9
|
+
ActiveRecord::LogSubscriber.colorize_logging = false
|
10
|
+
ActiveRecord::Migration.verbose = false
|
@@ -0,0 +1,7 @@
|
|
1
|
+
# Required to clear classes defined in `load 'setup/*.rb'` to make subsequent `load 'setup/*.rb'` to work correctly.
|
2
|
+
%i| Article Comment AuthorProfile Author Tag Book |.each do |class_name|
|
3
|
+
if Object.const_defined?(class_name)
|
4
|
+
Object.send(:remove_const, class_name)
|
5
|
+
STDERR.puts "Undefined #{class_name}"
|
6
|
+
end
|
7
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'coveralls'
|
2
|
+
Coveralls.wear!
|
3
|
+
|
4
|
+
RSpec.configure do |config|
|
5
|
+
config.expect_with :rspec do |expectations|
|
6
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
7
|
+
end
|
8
|
+
|
9
|
+
config.mock_with :rspec do |mocks|
|
10
|
+
mocks.verify_partial_doubles = true
|
11
|
+
end
|
12
|
+
|
13
|
+
config.profile_examples = 10
|
14
|
+
|
15
|
+
config.order = :random
|
16
|
+
|
17
|
+
Kernel.srand config.seed
|
18
|
+
|
19
|
+
config.before :suite do
|
20
|
+
require 'setup/sqlite.rb'
|
21
|
+
|
22
|
+
require 'database_cleaner'
|
23
|
+
|
24
|
+
# https://github.com/DatabaseCleaner/database_cleaner#additional-activerecord-options-for-truncation
|
25
|
+
DatabaseCleaner.clean_with :deletion, cache_tables: false
|
26
|
+
DatabaseCleaner.strategy = :deletion
|
27
|
+
end
|
28
|
+
|
29
|
+
config.around(:each) do |example|
|
30
|
+
DatabaseCleaner.cleaning do
|
31
|
+
example.run
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
config.before(:each) do |s|
|
36
|
+
md = s.metadata
|
37
|
+
x = md[:example_group]
|
38
|
+
STDERR.puts "==>>> #{x[:file_path]}:#{x[:line_number]} #{md[:description_args]}"
|
39
|
+
end
|
40
|
+
end
|
metadata
ADDED
@@ -0,0 +1,95 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: cached_counter
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Yusuke KUOKA
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2015-01-30 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: bundler
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.7'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.7'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rake
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '10.0'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '10.0'
|
41
|
+
description: Cached Counter allows to increment/decrement/get counts primarily saved
|
42
|
+
in the database in a faster way. Utilizing the cache, it can be updated without
|
43
|
+
big row-locks like the ActiveRecord's update_counters, with instantaneous and consistency
|
44
|
+
unlike the updated_counters within delayed, background jobs
|
45
|
+
email:
|
46
|
+
- ykuoka@gmail.com
|
47
|
+
executables: []
|
48
|
+
extensions: []
|
49
|
+
extra_rdoc_files: []
|
50
|
+
files:
|
51
|
+
- ".gitignore"
|
52
|
+
- ".rspec"
|
53
|
+
- ".travis.yml"
|
54
|
+
- Gemfile
|
55
|
+
- LICENSE.txt
|
56
|
+
- README.md
|
57
|
+
- Rakefile
|
58
|
+
- cached_counter.gemspec
|
59
|
+
- lib/cached_counter.rb
|
60
|
+
- lib/cached_counter/version.rb
|
61
|
+
- spec/cached_counter_spec.rb
|
62
|
+
- spec/setup/articles_with_delayed_jobs.rb
|
63
|
+
- spec/setup/sqlite.rb
|
64
|
+
- spec/setup/undefine.rb
|
65
|
+
- spec/spec_helper.rb
|
66
|
+
homepage: https://github.com/crowdworks/cached_counter
|
67
|
+
licenses:
|
68
|
+
- MIT
|
69
|
+
metadata: {}
|
70
|
+
post_install_message:
|
71
|
+
rdoc_options: []
|
72
|
+
require_paths:
|
73
|
+
- lib
|
74
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
75
|
+
requirements:
|
76
|
+
- - ">="
|
77
|
+
- !ruby/object:Gem::Version
|
78
|
+
version: '0'
|
79
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
requirements: []
|
85
|
+
rubyforge_project:
|
86
|
+
rubygems_version: 2.2.2
|
87
|
+
signing_key:
|
88
|
+
specification_version: 4
|
89
|
+
summary: An instantaneous but lock-friendly implementation of the counter
|
90
|
+
test_files:
|
91
|
+
- spec/cached_counter_spec.rb
|
92
|
+
- spec/setup/articles_with_delayed_jobs.rb
|
93
|
+
- spec/setup/sqlite.rb
|
94
|
+
- spec/setup/undefine.rb
|
95
|
+
- spec/spec_helper.rb
|