capacitor 1.0.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 +7 -0
- data/.gitignore +17 -0
- data/.travis.yml +10 -0
- data/Gemfile +9 -0
- data/Guardfile +7 -0
- data/LICENSE.txt +22 -0
- data/README.md +68 -0
- data/Rakefile +10 -0
- data/capacitor.gemspec +29 -0
- data/lib/capacitor.rb +42 -0
- data/lib/capacitor/cli.rb +13 -0
- data/lib/capacitor/commands_fetcher.rb +86 -0
- data/lib/capacitor/counter_cache.rb +54 -0
- data/lib/capacitor/railtie.rb +7 -0
- data/lib/capacitor/tasks.rb +5 -0
- data/lib/capacitor/updater.rb +37 -0
- data/lib/capacitor/version.rb +3 -0
- data/lib/capacitor/watcher.rb +133 -0
- data/spec/capacitor/commands_fetcher_spec.rb +72 -0
- data/spec/capacitor/counter_cache_spec.rb +35 -0
- data/spec/capacitor/updater_spec.rb +42 -0
- data/spec/capacitor/watcher_spec.rb +25 -0
- data/spec/spec_helper.rb +27 -0
- metadata +188 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
!binary "U0hBMQ==":
|
3
|
+
metadata.gz: 6dde6d41727f6f92057212601934f5909528fa82
|
4
|
+
data.tar.gz: 30aa52c8a78d564be08dc3adf12d52b8e72e4239
|
5
|
+
!binary "U0hBNTEy":
|
6
|
+
metadata.gz: 7edadb27faa727afd913a5b689a718ac4d9147b3b811467ac9e9623899a4829a9752dc5afac072ab030135bdc5208e6dda41c5bd3bd959f88c107fc68eaed3ae
|
7
|
+
data.tar.gz: 9a412459d55b87a2a33d2fabc46a5d5f0f5e8a3b0f5945f048e9d9f9c0c7848f1d7202564bddcc142ad160cbc96a44f13bf578f83fc735d1381ba1077514e686
|
data/.gitignore
ADDED
data/.travis.yml
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
language: ruby
|
2
|
+
rvm:
|
3
|
+
- 1.9.3
|
4
|
+
- 2.0.0
|
5
|
+
script: bundle exec rake
|
6
|
+
|
7
|
+
notifications:
|
8
|
+
email: false
|
9
|
+
hipchat:
|
10
|
+
- secure: "D/ppHWW3ZnaTSq/mtlZx+Vg96QksJN0igrpJVw808ERLX1WUyF44Vwo4ED8D\nbpppQBjhoUqbDM0mgD3Hb6b3mo//n84WXqzV5pfRnLLQM/9y7dzQcYers++9\ncwQwJw7WBu7J/m2yTV7qL4O6yPNB9YlYqIbum1jNbM5oKjksfTw="
|
data/Gemfile
ADDED
data/Guardfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Jesse Montrose
|
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,68 @@
|
|
1
|
+
# Capacitor
|
2
|
+
|
3
|
+
Keeping a counter cache field of a foreign table relationship can significantly reduce the DB cost of reading those counts later. But what if your traffic patterns have you choking on a pile of counter updates to the same row?
|
4
|
+
|
5
|
+
Instead of making ActiveRecord calls to change a counter field, write them to `Capacitor`. They'll get summarized in a `redis` hash, with a separate process batch-retrieving and writing to `ActiveRecord`. Being single-threaded, the writing process avoids row lock collisions, and absorbs traffic spikes by coalescing changes to the same row into one DB write.
|
6
|
+
|
7
|
+
You get the high writing capacity of `redis`, the safety of your primary DB remaining the source-of-truth for the counts, and near-realtime counter field reads that don't require `COUNT(*)` queries.
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile:
|
12
|
+
|
13
|
+
gem 'capacitor'
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install capacitor
|
22
|
+
|
23
|
+
Also, both the injecting and listening ends of Capacitor will instantiate a bare `Redis.new` and require the appropriate `redis` environment variables.
|
24
|
+
|
25
|
+
## Usage
|
26
|
+
|
27
|
+
Increment and decrement changes flow in one direction:
|
28
|
+
|
29
|
+
ActiveRecord write > Capacitor Injector > redis > Capacitor Listener > DB
|
30
|
+
|
31
|
+
### ActiveRecord write
|
32
|
+
|
33
|
+
To buffer the 'users_count' field of your 'Widget' ActiveRecord model, add a method that calls `Capacitor.enqueue_count_change` with the `classname`, `object_id`, and a positive or negative increment.
|
34
|
+
|
35
|
+
def enqueue_users_count_change(delta)
|
36
|
+
Capacitor.enqueue_count_change 'Widget', widget_id, :users_count, delta
|
37
|
+
end
|
38
|
+
|
39
|
+
### Capacitor Injector
|
40
|
+
|
41
|
+
Writes go to `redis`, incrementing or decrementing the `capacitor:incoming_hash[classname:object_id:fieldname]` key. The same value is pushed onto `capacitor.incoming_signal_list` to wake up the `Capacitor Listener`.
|
42
|
+
|
43
|
+
### redis
|
44
|
+
|
45
|
+
Changes build indefinitely in `capacitor:incoming_hash`. `Capacitor` tries to avoid losing changes one time by moving improperly-processed batches (such as a crashing server) to `capacitor:retry_hash`. After a second failure, batches are moved to `capacitor:failure:<timestamp>` and that failure key is added to `capacitor:failed_hash_keys`.
|
46
|
+
|
47
|
+
### Capacitor Listener
|
48
|
+
|
49
|
+
In order to ensure changes get written, you'll need to keep a listener running, such as in your `Procfile`:
|
50
|
+
|
51
|
+
capacitor: bundle exec rails runner "capacitor.run"
|
52
|
+
|
53
|
+
`Capacitor.loop_forever` blocks indefinitely on `capacitor.incoming_signal_list` waiting for counter changes.
|
54
|
+
|
55
|
+
On waking, it sets aside the `capacitor:incoming_hash` to allow a new batch to start.
|
56
|
+
|
57
|
+
### DB
|
58
|
+
|
59
|
+
Once a batch is set aside, `Capacitor Listener` loops over the `classname:object_id:fieldname` keys, trying to instantiate the `ActiveRecord` models and update their counts.
|
60
|
+
|
61
|
+
|
62
|
+
## Contributing
|
63
|
+
|
64
|
+
1. Fork it
|
65
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
66
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
67
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
68
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/capacitor.gemspec
ADDED
@@ -0,0 +1,29 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'capacitor/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "capacitor"
|
8
|
+
spec.version = Capacitor::VERSION
|
9
|
+
spec.authors = ["Jesse Montrose"]
|
10
|
+
spec.email = ["jesse@ninth.org"]
|
11
|
+
spec.description = %q{Instead of making ActiveRecord calls to change a counter field, write them to capacitor. They'll get summarized in a redis hash, with a separate process batch-retrieving and writing to ActiveRecord. Being single-threaded, the writing process avoids row lock collisions, and absorbs traffic spikes by coalescing changes to the same row into one DB write.}
|
12
|
+
spec.summary = %q{Buffered ActiveRecord counter writes through redis.}
|
13
|
+
spec.homepage = ""
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
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
|
+
spec.add_dependency "formatted-metrics", "~> 1.0"
|
21
|
+
spec.add_dependency "redis-namespace", "~> 1.0"
|
22
|
+
spec.add_dependency "activesupport", "~> 3.2"
|
23
|
+
|
24
|
+
spec.add_development_dependency "activerecord", "~> 3.2"
|
25
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
26
|
+
spec.add_development_dependency "rake"
|
27
|
+
spec.add_development_dependency "redis"
|
28
|
+
spec.add_development_dependency "rspec"
|
29
|
+
end
|
data/lib/capacitor.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'redis-namespace'
|
3
|
+
require 'logger'
|
4
|
+
require 'capacitor/railtie' if defined?(::Rails)
|
5
|
+
|
6
|
+
module Capacitor
|
7
|
+
autoload :CounterCache, 'capacitor/counter_cache'
|
8
|
+
autoload :CommandsFetcher, 'capacitor/commands_fetcher'
|
9
|
+
autoload :Watcher, 'capacitor/watcher'
|
10
|
+
autoload :Updater, 'capacitor/updater'
|
11
|
+
|
12
|
+
class << self
|
13
|
+
attr_writer :logger
|
14
|
+
|
15
|
+
def logger
|
16
|
+
@logger ||= const_defined?(:Rails) ? Rails.logger : Logger.new(STDOUT)
|
17
|
+
end
|
18
|
+
|
19
|
+
def log_level=(level)
|
20
|
+
return unless level
|
21
|
+
begin
|
22
|
+
level = Logger.const_get level.upcase if level.is_a?(String)
|
23
|
+
logger.level = level
|
24
|
+
rescue Exception => e
|
25
|
+
logger.error "Unable to set log level to #{level} - #{e}"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def redis=(redis)
|
30
|
+
@redis = Redis::Namespace.new :capacitor, redis: redis
|
31
|
+
end
|
32
|
+
|
33
|
+
def redis
|
34
|
+
@redis ||= Redis::Namespace.new :capacitor, redis: Redis.current
|
35
|
+
end
|
36
|
+
|
37
|
+
def run
|
38
|
+
STDOUT.sync = true
|
39
|
+
Watcher.run
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,86 @@
|
|
1
|
+
module Capacitor
|
2
|
+
|
3
|
+
class CommandsFetcher
|
4
|
+
|
5
|
+
def fetch
|
6
|
+
new.retrieve_batch
|
7
|
+
end
|
8
|
+
|
9
|
+
def blocking_timeout
|
10
|
+
60
|
11
|
+
end
|
12
|
+
|
13
|
+
def redis
|
14
|
+
Capacitor.redis
|
15
|
+
end
|
16
|
+
|
17
|
+
def logger
|
18
|
+
Capacitor.logger
|
19
|
+
end
|
20
|
+
|
21
|
+
def incoming_signal_list
|
22
|
+
redis.blpop "incoming_signal_list", blocking_timeout
|
23
|
+
end
|
24
|
+
|
25
|
+
def block_on_incoming_signal_list
|
26
|
+
false until incoming_signal_list
|
27
|
+
flush_incoming_signal_list
|
28
|
+
end
|
29
|
+
|
30
|
+
def flush_incoming_signal_list
|
31
|
+
redis.del "incoming_signal_list"
|
32
|
+
end
|
33
|
+
|
34
|
+
def retrieve_existing_batch
|
35
|
+
batch = redis.hgetall "processing_hash"
|
36
|
+
if !batch.empty?
|
37
|
+
redis.rename "processing_hash", "retry_hash"
|
38
|
+
logger.error "processing_hash moved to retry_hash"
|
39
|
+
batch
|
40
|
+
else
|
41
|
+
nil
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def retrieve_current_batch
|
46
|
+
begin
|
47
|
+
result = redis.rename "incoming_hash", "processing_hash"
|
48
|
+
rescue Exception => e
|
49
|
+
# This means we got a signal without getting data, which is
|
50
|
+
# probably okay due to the harmless race condition, but might
|
51
|
+
# warrant investigation later, so let's log it and move on.
|
52
|
+
logger.warn "empty incoming_hash in retrieve_batch"
|
53
|
+
return {}
|
54
|
+
end
|
55
|
+
redis.hgetall "processing_hash"
|
56
|
+
end
|
57
|
+
|
58
|
+
# When things are working well
|
59
|
+
# :incoming_hash -> :processing_hash -> flush()
|
60
|
+
#
|
61
|
+
# If a batch fails once
|
62
|
+
# :processing_hash -> :retry_hash -> flush()
|
63
|
+
#
|
64
|
+
# If a batch fails again
|
65
|
+
# :retry_hash -> :failed_hash_keys -> flush()
|
66
|
+
# then start over with :incoming_hash
|
67
|
+
def retrieve_batch
|
68
|
+
flush_retried_batch
|
69
|
+
return retrieve_existing_batch || retrieve_current_batch
|
70
|
+
end
|
71
|
+
|
72
|
+
def flush_batch
|
73
|
+
# Safely processed now, kill the batch in redis
|
74
|
+
redis.del "processing_hash", "retry_hash"
|
75
|
+
end
|
76
|
+
|
77
|
+
def flush_retried_batch
|
78
|
+
if redis.hlen("retry_hash") > 0
|
79
|
+
failure = 'failure:' + Time.new.utc.to_f.to_s
|
80
|
+
redis.rename "retry_hash", failure
|
81
|
+
redis.lpush "failed_hash_keys", failure
|
82
|
+
logger.error "retry_hash moved to #{failure}"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
module Capacitor
|
2
|
+
# Public: The public interface to incrementing and decrementing the counter cache
|
3
|
+
#
|
4
|
+
# klass - ActiveRecord class
|
5
|
+
# id - record id
|
6
|
+
# column - counter column symbol
|
7
|
+
class CounterCache
|
8
|
+
def initialize(klass, id, column)
|
9
|
+
@classname = klass.to_s
|
10
|
+
@id = id.to_s
|
11
|
+
@column = column
|
12
|
+
end
|
13
|
+
|
14
|
+
# Public: increment `column` by 1
|
15
|
+
#
|
16
|
+
# Returns: nothing
|
17
|
+
def increment
|
18
|
+
enqueue_count_change 1
|
19
|
+
end
|
20
|
+
|
21
|
+
# Public: decrement `column` by 1
|
22
|
+
#
|
23
|
+
# Returns: nothing
|
24
|
+
def decrement
|
25
|
+
enqueue_count_change -1
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
attr_accessor :classname, :id, :column
|
31
|
+
|
32
|
+
def redis
|
33
|
+
Capacitor.redis
|
34
|
+
end
|
35
|
+
|
36
|
+
def logger
|
37
|
+
Capacitor.logger
|
38
|
+
end
|
39
|
+
|
40
|
+
def enqueue_count_change(delta)
|
41
|
+
responses = redis.pipelined do
|
42
|
+
redis.hincrby "incoming_hash", counter_id, delta
|
43
|
+
redis.lpush "incoming_signal_list", counter_id
|
44
|
+
redis.get "log_level"
|
45
|
+
end
|
46
|
+
Capacitor.log_level= responses.last
|
47
|
+
logger.debug "enqueue_count_change #{counter_id} #{delta}"
|
48
|
+
end
|
49
|
+
|
50
|
+
def counter_id
|
51
|
+
[classname, id, column.to_s].join(':')
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module Capacitor
|
2
|
+
class Updater
|
3
|
+
attr_reader :model, :id, :field, :count_delta, :counter_id
|
4
|
+
|
5
|
+
def initialize(counter_id, count_delta)
|
6
|
+
@count_delta = count_delta.to_i
|
7
|
+
@counter_id = counter_id
|
8
|
+
@model, @id, @field = self.class.parse_counter_id(counter_id)
|
9
|
+
end
|
10
|
+
|
11
|
+
# Public: Updates the counter with the new count delta
|
12
|
+
#
|
13
|
+
# If count_delta is zero, does nothing
|
14
|
+
def update
|
15
|
+
return if count_delta.zero?
|
16
|
+
model.update_counters(id, field => count_delta)
|
17
|
+
end
|
18
|
+
|
19
|
+
# Public: Returns the counter value from the database
|
20
|
+
def old_count
|
21
|
+
model.find(id)[field]
|
22
|
+
end
|
23
|
+
|
24
|
+
# Public: Returns a string of useful debug info
|
25
|
+
def inspect
|
26
|
+
"counter_id=#{counter_id} old_count=#{old_count} count_delta=#{count_delta}"
|
27
|
+
end
|
28
|
+
|
29
|
+
# Internal: Expect a counter_id in the form: classname:object_id:field_name
|
30
|
+
#
|
31
|
+
# Returns: model, object_id, :field
|
32
|
+
def self.parse_counter_id(counter_id)
|
33
|
+
classname, object_id, field_name = counter_id.split(':')
|
34
|
+
[classname.constantize, object_id.to_i, field_name.to_sym]
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,133 @@
|
|
1
|
+
require "active_support/core_ext/string/inflections"
|
2
|
+
require 'formatted-metrics'
|
3
|
+
|
4
|
+
module Capacitor
|
5
|
+
class Watcher
|
6
|
+
|
7
|
+
def loop_forever
|
8
|
+
logger.info "Capacitor listening..."
|
9
|
+
redis.set "capacitor_start", Time.new.to_s
|
10
|
+
|
11
|
+
loop do
|
12
|
+
wait_for_batch
|
13
|
+
loop_once
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
def wait_for_batch
|
18
|
+
commands_fetcher.block_on_incoming_signal_list
|
19
|
+
end
|
20
|
+
|
21
|
+
def loop_once
|
22
|
+
@working = true
|
23
|
+
start_time = Time.new
|
24
|
+
Capacitor.log_level= log_level
|
25
|
+
counts = commands_fetcher.retrieve_batch
|
26
|
+
with_pause
|
27
|
+
process_batch counts
|
28
|
+
commands_fetcher.flush_batch
|
29
|
+
|
30
|
+
instrument "capacitor.loop.time", Time.new - start_time, units:'seconds'
|
31
|
+
instrument "capacitor.loop.object_counters", counts.length
|
32
|
+
|
33
|
+
shut_down! if shut_down?
|
34
|
+
ensure
|
35
|
+
@working = false
|
36
|
+
end
|
37
|
+
|
38
|
+
def with_pause
|
39
|
+
yield if block_given?
|
40
|
+
|
41
|
+
if time = pause_time
|
42
|
+
logger.debug "Capacitor pausing for #{time}s"
|
43
|
+
sleep time
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def pause_time
|
48
|
+
time = redis.get "pause_time"
|
49
|
+
time ? time.to_f : nil
|
50
|
+
end
|
51
|
+
|
52
|
+
def log_level
|
53
|
+
redis.get "log_level"
|
54
|
+
end
|
55
|
+
|
56
|
+
def commands_fetcher
|
57
|
+
@commands_fetcher ||= CommandsFetcher.new
|
58
|
+
end
|
59
|
+
|
60
|
+
def self.run
|
61
|
+
watcher = new
|
62
|
+
|
63
|
+
%w(INT TERM).each do |signal|
|
64
|
+
trap signal do
|
65
|
+
watcher.handle_signal(signal)
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
watcher.loop_forever
|
70
|
+
end
|
71
|
+
|
72
|
+
def logger
|
73
|
+
Capacitor.logger
|
74
|
+
end
|
75
|
+
|
76
|
+
# Public: update_counters on models
|
77
|
+
#
|
78
|
+
# counts - {'classname:id:field_name' => increment, ...}
|
79
|
+
#
|
80
|
+
# Returns: nothing
|
81
|
+
def process_batch(counts)
|
82
|
+
counts.each do |counter_id, count_delta|
|
83
|
+
process(counter_id, count_delta)
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# Public: Updates a counter on one model
|
88
|
+
def process(counter_id, count_delta)
|
89
|
+
updater = Updater.new(counter_id, count_delta)
|
90
|
+
logger.debug updater.inspect if logger.debug?
|
91
|
+
updater.update
|
92
|
+
rescue Exception => e
|
93
|
+
logger.error "#{counter_id} exception: #{e}"
|
94
|
+
end
|
95
|
+
|
96
|
+
def handle_signal(signal)
|
97
|
+
case signal
|
98
|
+
when 'INT', 'TERM'
|
99
|
+
working? ? shut_down : shut_down!
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def working?
|
106
|
+
@working
|
107
|
+
end
|
108
|
+
|
109
|
+
def shut_down
|
110
|
+
@shut_down = true
|
111
|
+
end
|
112
|
+
|
113
|
+
def shut_down!
|
114
|
+
exit(0)
|
115
|
+
end
|
116
|
+
|
117
|
+
def shut_down?
|
118
|
+
@shut_down
|
119
|
+
end
|
120
|
+
|
121
|
+
def delay_warning_threshold
|
122
|
+
0.003
|
123
|
+
end
|
124
|
+
|
125
|
+
def redis
|
126
|
+
Capacitor.redis
|
127
|
+
end
|
128
|
+
|
129
|
+
def instrument(*args, &block)
|
130
|
+
Metrics.instrument *args, &block
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Capacitor::CommandsFetcher do
|
4
|
+
subject (:commands_fetcher) { described_class.new }
|
5
|
+
|
6
|
+
describe '#incoming_signal_list' do
|
7
|
+
context 'when incoming_signal_list has items' do
|
8
|
+
subject { commands_fetcher.incoming_signal_list }
|
9
|
+
before {
|
10
|
+
redis.lpush "incoming_signal_list", "abc"
|
11
|
+
commands_fetcher.stub blocking_timeout: 1
|
12
|
+
}
|
13
|
+
it { should_not be_nil }
|
14
|
+
end
|
15
|
+
context 'when incoming_signal_list is empty' do
|
16
|
+
subject { commands_fetcher.incoming_signal_list }
|
17
|
+
before { commands_fetcher.stub blocking_timeout: 1 }
|
18
|
+
it { should be_nil }
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
describe '#retrieve_existing_batch' do
|
23
|
+
context 'when unprocessed batch waiting' do
|
24
|
+
subject {
|
25
|
+
commands_fetcher.retrieve_batch
|
26
|
+
# Normally, the retrieved batch would get flushed before a new retrieval
|
27
|
+
commands_fetcher.retrieve_existing_batch
|
28
|
+
}
|
29
|
+
before { redis.hincrby "incoming_hash", "Post:123:users_count", 1 }
|
30
|
+
it { should eq("Post:123:users_count" => "1") }
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe '#retrieve_batch' do
|
35
|
+
context 'when normal batch waiting' do
|
36
|
+
subject { commands_fetcher.retrieve_batch }
|
37
|
+
before { redis.hincrby "incoming_hash", "Post:123:users_count", 1 }
|
38
|
+
it { should eq("Post:123:users_count" => "1") }
|
39
|
+
end
|
40
|
+
context 'when unprocessed batch fails' do
|
41
|
+
subject {
|
42
|
+
commands_fetcher.retrieve_batch
|
43
|
+
commands_fetcher.retrieve_batch
|
44
|
+
commands_fetcher.retrieve_batch
|
45
|
+
}
|
46
|
+
before { redis.hincrby "incoming_hash", "Post:123:users_count", 1 }
|
47
|
+
it { should eq({}) }
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
|
52
|
+
describe '#flush_batch' do
|
53
|
+
before {
|
54
|
+
redis.hincrby "incoming_hash", "Post:123:users_count", 1
|
55
|
+
commands_fetcher.retrieve_batch
|
56
|
+
}
|
57
|
+
it 'flushes redis.processing_hash' do
|
58
|
+
expect { commands_fetcher.flush_batch }.to change { redis.hlen "processing_hash" }.from(1).to(0)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
describe '#flush_retried_batch' do
|
63
|
+
before {
|
64
|
+
redis.hincrby "incoming_hash", "Post:123:users_count", 1
|
65
|
+
commands_fetcher.retrieve_batch
|
66
|
+
commands_fetcher.retrieve_batch
|
67
|
+
}
|
68
|
+
it 'flushes redis.retry_hash' do
|
69
|
+
expect { commands_fetcher.flush_retried_batch }.to change { redis.hlen "retry_hash" }.from(1).to(0)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,35 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Capacitor::CounterCache do
|
4
|
+
let (:klass) { Post }
|
5
|
+
let (:id) { 123 }
|
6
|
+
let (:column) { :view_count }
|
7
|
+
subject (:counter_cache) { described_class.new klass, id, column }
|
8
|
+
|
9
|
+
describe '#increment' do
|
10
|
+
it 'adds one element to hash key in redis' do
|
11
|
+
expect { counter_cache.increment }.to change { redis.hlen('incoming_hash') }.from(0).to(1)
|
12
|
+
end
|
13
|
+
it 'adds one element to signal key in redis' do
|
14
|
+
expect { counter_cache.increment }.to change { redis.llen('incoming_signal_list') }.from(0).to(1)
|
15
|
+
end
|
16
|
+
it 'increments 123 Post key by 1' do
|
17
|
+
expect { counter_cache.increment }.to change {
|
18
|
+
redis.hget('incoming_hash', 'Post:123:view_count')
|
19
|
+
}.from(nil).to("1")
|
20
|
+
end
|
21
|
+
end
|
22
|
+
describe '#decrement' do
|
23
|
+
it 'adds one element to hash key in redis' do
|
24
|
+
expect { counter_cache.decrement }.to change { redis.hlen('incoming_hash') }.from(0).to(1)
|
25
|
+
end
|
26
|
+
it 'adds one element to signal key in redis' do
|
27
|
+
expect { counter_cache.decrement }.to change { redis.llen('incoming_signal_list') }.from(0).to(1)
|
28
|
+
end
|
29
|
+
it 'decrements 123 Post key by 1' do
|
30
|
+
expect { counter_cache.decrement }.to change {
|
31
|
+
redis.hget('incoming_hash', 'Post:123:view_count')
|
32
|
+
}.from(nil).to("-1")
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Capacitor::Updater do
|
4
|
+
let (:model) { Post }
|
5
|
+
let (:id) { 123 }
|
6
|
+
let (:field) { :view_count }
|
7
|
+
let (:count_delta) { 13 }
|
8
|
+
let (:counter_id) { 'Post:123:view_count' }
|
9
|
+
subject (:updater) { described_class.new( counter_id, count_delta ) }
|
10
|
+
|
11
|
+
describe '#attributes' do
|
12
|
+
its(:model) { should == model }
|
13
|
+
its(:id) { should == id }
|
14
|
+
its(:field) { should == field }
|
15
|
+
its(:counter_id) { should == 'Post:123:view_count' }
|
16
|
+
its(:count_delta) { should == 13 }
|
17
|
+
end
|
18
|
+
|
19
|
+
describe '#update' do
|
20
|
+
it 'should call #update_counters on the model' do
|
21
|
+
model.should_receive(:update_counters).with(id, field => count_delta)
|
22
|
+
updater.update
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
describe '#inspect' do
|
27
|
+
before { updater.should_receive(:old_count).and_return(1) }
|
28
|
+
its(:inspect) { should == 'counter_id=Post:123:view_count old_count=1 count_delta=13' }
|
29
|
+
end
|
30
|
+
|
31
|
+
describe '.parse_counter_id' do
|
32
|
+
context 'with counter_id Post:123:users_count' do
|
33
|
+
subject { described_class.parse_counter_id 'Post:123:users_count' }
|
34
|
+
it { should eq([Post, 123, :users_count]) }
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'fails to parse counter_id MissingClassname:123:users_count' do
|
38
|
+
expect { described_class.parse_counter_id 'MissingClassname:123:users_count' }.to raise_error(NameError)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Capacitor::Watcher do
|
4
|
+
subject (:watcher) { described_class.new }
|
5
|
+
|
6
|
+
describe '#process_batch' do
|
7
|
+
it 'updates counters' do
|
8
|
+
Post.should_receive(:update_counters).with(123, {:users_count=>1})
|
9
|
+
watcher.process_batch({'Post:123:users_count' => 1})
|
10
|
+
end
|
11
|
+
it 'logs invalid model classnames' do
|
12
|
+
watcher.logger.should_receive(:error).with("MissingClassname:123:users_count exception: uninitialized constant MissingClassname")
|
13
|
+
watcher.process_batch({'MissingClassname:123:users_count' => 1})
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
describe '#loop_once' do
|
18
|
+
it 'updates counters' do
|
19
|
+
Post.should_receive(:update_counters).with(123, {:users_count=>1})
|
20
|
+
redis.hincrby "incoming_hash", "Post:123:users_count", 1
|
21
|
+
redis.lpush "incoming_signal_list", "abc"
|
22
|
+
watcher.loop_once
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
require 'bundler/setup'
|
2
|
+
Bundler.require
|
3
|
+
|
4
|
+
def redis
|
5
|
+
Capacitor.redis
|
6
|
+
end
|
7
|
+
|
8
|
+
RSpec.configure do |config|
|
9
|
+
config.before do
|
10
|
+
redis.flushdb
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
Capacitor.logger = Logger.new nil
|
15
|
+
|
16
|
+
class Post
|
17
|
+
def self.update_counters(*)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.find(*)
|
21
|
+
new
|
22
|
+
end
|
23
|
+
|
24
|
+
def [](*)
|
25
|
+
1
|
26
|
+
end
|
27
|
+
end
|
metadata
ADDED
@@ -0,0 +1,188 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: capacitor
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Jesse Montrose
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-09-30 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: formatted-metrics
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ~>
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ~>
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: redis-namespace
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ~>
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ~>
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: activesupport
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ~>
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '3.2'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ~>
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '3.2'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: activerecord
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ~>
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.2'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ~>
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.2'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: bundler
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ~>
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '1.3'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ~>
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '1.3'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: rake
|
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: redis
|
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
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: rspec
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ! '>='
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ! '>='
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description: Instead of making ActiveRecord calls to change a counter field, write
|
126
|
+
them to capacitor. They'll get summarized in a redis hash, with a separate process
|
127
|
+
batch-retrieving and writing to ActiveRecord. Being single-threaded, the writing
|
128
|
+
process avoids row lock collisions, and absorbs traffic spikes by coalescing changes
|
129
|
+
to the same row into one DB write.
|
130
|
+
email:
|
131
|
+
- jesse@ninth.org
|
132
|
+
executables: []
|
133
|
+
extensions: []
|
134
|
+
extra_rdoc_files: []
|
135
|
+
files:
|
136
|
+
- .gitignore
|
137
|
+
- .travis.yml
|
138
|
+
- Gemfile
|
139
|
+
- Guardfile
|
140
|
+
- LICENSE.txt
|
141
|
+
- README.md
|
142
|
+
- Rakefile
|
143
|
+
- capacitor.gemspec
|
144
|
+
- lib/capacitor.rb
|
145
|
+
- lib/capacitor/cli.rb
|
146
|
+
- lib/capacitor/commands_fetcher.rb
|
147
|
+
- lib/capacitor/counter_cache.rb
|
148
|
+
- lib/capacitor/railtie.rb
|
149
|
+
- lib/capacitor/tasks.rb
|
150
|
+
- lib/capacitor/updater.rb
|
151
|
+
- lib/capacitor/version.rb
|
152
|
+
- lib/capacitor/watcher.rb
|
153
|
+
- spec/capacitor/commands_fetcher_spec.rb
|
154
|
+
- spec/capacitor/counter_cache_spec.rb
|
155
|
+
- spec/capacitor/updater_spec.rb
|
156
|
+
- spec/capacitor/watcher_spec.rb
|
157
|
+
- spec/spec_helper.rb
|
158
|
+
homepage: ''
|
159
|
+
licenses:
|
160
|
+
- MIT
|
161
|
+
metadata: {}
|
162
|
+
post_install_message:
|
163
|
+
rdoc_options: []
|
164
|
+
require_paths:
|
165
|
+
- lib
|
166
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
167
|
+
requirements:
|
168
|
+
- - ! '>='
|
169
|
+
- !ruby/object:Gem::Version
|
170
|
+
version: '0'
|
171
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
172
|
+
requirements:
|
173
|
+
- - ! '>='
|
174
|
+
- !ruby/object:Gem::Version
|
175
|
+
version: '0'
|
176
|
+
requirements: []
|
177
|
+
rubyforge_project:
|
178
|
+
rubygems_version: 2.0.3
|
179
|
+
signing_key:
|
180
|
+
specification_version: 4
|
181
|
+
summary: Buffered ActiveRecord counter writes through redis.
|
182
|
+
test_files:
|
183
|
+
- spec/capacitor/commands_fetcher_spec.rb
|
184
|
+
- spec/capacitor/counter_cache_spec.rb
|
185
|
+
- spec/capacitor/updater_spec.rb
|
186
|
+
- spec/capacitor/watcher_spec.rb
|
187
|
+
- spec/spec_helper.rb
|
188
|
+
has_rdoc:
|