capacitor 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
@@ -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
@@ -0,0 +1,9 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in capacitor.gemspec
4
+ gemspec
5
+
6
+ group :development do
7
+ gem 'guard-rspec'
8
+ end
9
+
@@ -0,0 +1,7 @@
1
+ guard 'rspec', all_on_start: false, all_after_pass: false do
2
+ watch(%r{^spec/.+_spec\.rb$})
3
+ watch('spec/spec_helper.rb') { "spec" }
4
+
5
+ watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
6
+ watch(%r{^spec/support/(.+)\.rb$}) { "spec" }
7
+ end
@@ -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.
@@ -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
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env rake
2
+ require "bundler/gem_tasks"
3
+
4
+ task :default => [:spec]
5
+
6
+ require 'rspec/core/rake_task'
7
+ desc "Run specs"
8
+ RSpec::Core::RakeTask.new do |t|
9
+ t.pattern = 'spec/**/*_spec.rb'
10
+ end
@@ -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
@@ -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,13 @@
1
+ require 'thor'
2
+
3
+ module Capacitor
4
+ class CLI < Thor
5
+ include Thor::Actions
6
+
7
+ desc 'start', 'Run the capacitor'
8
+
9
+ def start
10
+ Capacitor.run
11
+ end
12
+ end
13
+ 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,7 @@
1
+ module Capacitor
2
+ class Railtie < ::Rails::Railtie
3
+ rake_tasks do
4
+ load File.expand_path('../tasks.rb', __FILE__)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,5 @@
1
+ namespace :capacitor do
2
+ task :run => :environment do
3
+ Capacitor.run
4
+ end
5
+ 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,3 @@
1
+ module Capacitor
2
+ VERSION = "1.0.0"
3
+ 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
@@ -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: