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.
@@ -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: