replica_pools 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 87d48e4179aaed026e490fc8449b9b9b68f288cc
4
+ data.tar.gz: 4892116bdad80f053398ca6939104ce7f2de74a6
5
+ SHA512:
6
+ metadata.gz: d98d3a9401f909fffdfad0eb313c1735c50d016d5bcd54186d61459ad20be23c5d2c83d97a451e1fbb2e39c11449058239790584c88e10f7088b11c06924c75a
7
+ data.tar.gz: ff7aaeb624ca01d426b6938778500e12677bde3a9ed627b37cea77eaf1da7ebf8f42f17c7ae37e7b9b033b93ff03dfb608e5148fcf8667c42c77f4051a0a34b3
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ The MIT license:
2
+ Copyright (c) 2012 Kickstarter
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in
12
+ all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
20
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # ReplicaPools
2
+
3
+ Easy Single Leader / Multiple Replica Setup for use in Ruby/Rails projects
4
+
5
+ [![Build
6
+ Status](https://travis-ci.org/kickstarter/replica_pools.png?branch=owningit)](https://travis-ci.org/kickstarter/replica_pools)
7
+
8
+ ## Overview
9
+
10
+ ReplicaPools replaces ActiveRecord's connection with a proxy that routes database interactions to the proper connection. Safe (whitelisted) methods may go to the current replica, and all other methods go to the leader connection.
11
+
12
+ ReplicaPools also provides helpers so you can customize your replica strategy. You can organize replicas into pools and cycle through them (e.g. in a before_filter). You can make the connection default to the leader, or the default replica pool, and then use block helpers to temporarily change the behavior (e.g. in an around_filter).
13
+
14
+ * Uses a naming convention in database.yml to designate replica pools.
15
+ * Defaults to a given replica pool, but may also be configured to default to leader.
16
+ * Routes database interactions (queries) to the right connection
17
+ * Whitelisted queries go to the current connection (might be a replica).
18
+ * All queries inside a transaction run on leader.
19
+ * All other queries are also sent to the leader connection.
20
+ * Supports ActiveRecord's in-memory query caching.
21
+ * Helper methods can be used to easily load balance replicas, route traffic to different replica pools, or run directly against leader. (examples below)
22
+
23
+ ## Not Supported
24
+
25
+ * Sharding.
26
+ * Automatic load balancing strategies.
27
+ * Replica weights. You can accomplish this in your own load balancing strategy.
28
+ * Whitelisting models that always use leader.
29
+ * Blacklisting poorly performing replicas. This could cause load spikes on your leader. Whatever provisions your database.yml should make this choice.
30
+
31
+ ## Installation and Setup
32
+
33
+ Add to your Gemfile:
34
+
35
+ gem 'replica_pools'
36
+
37
+ ### Adding Replicas
38
+
39
+ Add entries to your database.yml in the form of `<environment>_pool_<pool_name>_name_<db_name>`
40
+
41
+ For example:
42
+
43
+ # Leader connection for production environment
44
+ production:
45
+ adapter: mysql
46
+ database: myapp_production
47
+ username: root
48
+ password:
49
+ host: localhost
50
+
51
+ # Default pool for production environment
52
+ production_pool_default_name_replica1:
53
+ adapter: mysql
54
+ database: replica_db1
55
+ username: root
56
+ password:
57
+ host: 10.0.0.2
58
+ production_pool_default_name_replica2:
59
+ ...
60
+
61
+ # Special pool for production environment
62
+ production_pool_admin_name_replica1:
63
+ ...
64
+ production_pool_admin_name_another_replica:
65
+ ...
66
+
67
+ ### Simulating Replicas
68
+
69
+ If you don't have any replicas (e.g. in your development environment), ReplicaPools will create a default pool containing only leader. But if you want to mimic your production environment more closely you can create a read-only mysql user and use it like a replica.
70
+
71
+ # Development connection
72
+ development: &dev
73
+ adapter: mysql
74
+ database: myapp_development
75
+ username: root
76
+ password:
77
+ host: localhost
78
+
79
+ development_pool_default_name_replica1:
80
+ username: readonly
81
+ <<: &dev
82
+
83
+ Don't do this in your test environment if you use transactional tests though! The replica connections won't be able to see any fixtures or factory data.
84
+
85
+ ### Configuring
86
+
87
+ Add a `config/initializers/replica_pools.rb` if you want to change config settings:
88
+
89
+ ReplicaPools.config.defaults_to_leader = true
90
+
91
+ ## Usage
92
+
93
+ Toggle to next replica:
94
+
95
+ ReplicaPools.next_replica!
96
+
97
+ Specify a pool besides the default:
98
+
99
+ ReplicaPools.with_pool('other_pool') { #do stuff }
100
+
101
+ Specifically use the leader for a call:
102
+
103
+ ReplicaPools.with_leader { #do stuff }
104
+
105
+ ### Load Balancing
106
+
107
+ If you have multiple replicas in a pool and you'd like to load balance requests between them, you can easily accomplish this with a `before_filter`:
108
+
109
+ class ApplicationController < ActionController::Base
110
+ after_filter :switch_to_next_replica
111
+
112
+ protected
113
+
114
+ def switch_to_next_replica
115
+ ReplicaPools.next_replica!
116
+ end
117
+ end
118
+
119
+ ### Specialty Pools
120
+
121
+ If you have specialized replica pools and would like to use them for different controllers or actions, you can use an `around_filter`:
122
+
123
+ class ApplicationController < ActionController::Base
124
+ around_filter :use_special_replicas
125
+
126
+ protected
127
+
128
+ def use_special_replicas
129
+ ReplicaPools.with_pool('special'){ yield }
130
+ end
131
+ end
132
+
133
+ ### Replica Lag
134
+
135
+ By default, writes are sent to the leader and reads are sent to replicas. But replicas might lag behind the leader by seconds or even minutes. So if you write to leader during a request you probably want to read from leader in that request as well. You may even want to read from the leader on the _next_ request, to cover redirects.
136
+
137
+ Here's one way to accomplish that:
138
+
139
+ class ApplicationController < ActionController::Base
140
+
141
+ around_filter :stick_to_leader_for_updates
142
+ around_filter :use_leader_for_redirect #goes with above
143
+
144
+ def stick_to_leader_for_updates
145
+ if request.get?
146
+ yield
147
+ else
148
+ ReplicaPools.with_leader { yield }
149
+ session[:stick_to_leader] = 1
150
+ end
151
+ end
152
+
153
+ def use_leader_for_redirect
154
+ if session[:stick_to_leader]
155
+ session[:stick_to_leader] = nil
156
+ ReplicaPools.with_leader { yield }
157
+ else
158
+ yield
159
+ end
160
+ end
161
+ end
162
+
163
+ ## Running specs
164
+
165
+ If you haven't already, install the rspec gem, then set up your database
166
+ with a test database and a read_only user.
167
+
168
+ To match spec/config/database.yml, you can run:
169
+
170
+ rake bootstrap
171
+
172
+ From the plugin directory, run:
173
+
174
+ rspec spec
175
+
176
+ ## Authors
177
+
178
+ Author: Dan Drabik, Lance Ivy
179
+
180
+ Copyright (c) 2012-2013, Kickstarter
181
+
182
+ Released under the MIT license
183
+
184
+ ## See also
185
+
186
+ ### MultiDb
187
+
188
+ The project is based on:
189
+
190
+ * https://github.com/schoefmax/multi_db
191
+
192
+ ### Masochism
193
+
194
+ The original leader/replica plugin:
195
+
196
+ * http://github.com/technoweenie/masochism
@@ -0,0 +1,63 @@
1
+ require 'active_record'
2
+ require 'replica_pools/config'
3
+ require 'replica_pools/pool'
4
+ require 'replica_pools/pools'
5
+ require 'replica_pools/active_record_extensions'
6
+ require 'replica_pools/hijack'
7
+ require 'replica_pools/query_cache'
8
+ require 'replica_pools/connection_proxy'
9
+
10
+ require 'replica_pools/engine' if defined? Rails
11
+ ActiveRecord::Base.send :include, ReplicaPools::ActiveRecordExtensions
12
+
13
+ module ReplicaPools
14
+ class << self
15
+
16
+ def config
17
+ @config ||= ReplicaPools::Config.new
18
+ end
19
+
20
+ def setup!
21
+ ConnectionProxy.generate_safe_delegations
22
+
23
+ ActiveRecord::Base.send(:extend, ReplicaPools::Hijack)
24
+
25
+ log :info, "Proxy loaded with: #{pools.keys.join(', ')}"
26
+ end
27
+
28
+ def proxy
29
+ Thread.current[:replica_pools_proxy] ||= ReplicaPools::ConnectionProxy.new(
30
+ ActiveRecord::Base,
31
+ ReplicaPools.pools
32
+ )
33
+ end
34
+
35
+ def current
36
+ proxy.current
37
+ end
38
+
39
+ def next_replica!
40
+ proxy.next_replica!
41
+ end
42
+
43
+ def with_pool(*a)
44
+ proxy.with_pool(*a){ yield }
45
+ end
46
+
47
+ def with_leader
48
+ proxy.with_leader{ yield }
49
+ end
50
+
51
+ def pools
52
+ Thread.current[:replica_pools] ||= ReplicaPools::Pools.new
53
+ end
54
+
55
+ def log(level, message)
56
+ logger.send(level, "[ReplicaPools] #{message}")
57
+ end
58
+
59
+ def logger
60
+ ActiveRecord::Base.logger
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,28 @@
1
+ module ReplicaPools
2
+ module ActiveRecordExtensions
3
+ def self.included(base)
4
+ base.send :extend, ClassMethods
5
+ end
6
+
7
+ def reload(options = nil)
8
+ self.class.connection_proxy.with_leader { super }
9
+ end
10
+
11
+ module ClassMethods
12
+ def connection_proxy
13
+ ReplicaPools.proxy
14
+ end
15
+
16
+ # Make sure transactions run on leader
17
+ # Even if they're initiated from ActiveRecord::Base
18
+ # (which doesn't have our hijack).
19
+ def transaction(options = {}, &block)
20
+ if self.connection.kind_of?(ConnectionProxy)
21
+ super
22
+ else
23
+ self.connection_proxy.with_leader { super }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,22 @@
1
+ module ReplicaPools
2
+ class Config
3
+ # The current environment. Normally set to Rails.env, but
4
+ # will default to 'development' outside of Rails apps.
5
+ attr_accessor :environment
6
+
7
+ # When true, all queries will go to leader unless wrapped in with_pool{}.
8
+ # When false, all safe queries will go to the current replica unless wrapped in with_leader{}.
9
+ # Defaults to false.
10
+ attr_accessor :defaults_to_leader
11
+
12
+ # The list of methods considered safe to send to a readonly connection.
13
+ # Defaults are based on Rails version.
14
+ attr_accessor :safe_methods
15
+
16
+ def initialize
17
+ @environment = 'development'
18
+ @defaults_to_leader = false
19
+ @safe_methods = []
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,125 @@
1
+ require 'active_record/connection_adapters/abstract/query_cache'
2
+ require 'set'
3
+
4
+ module ReplicaPools
5
+ class ConnectionProxy
6
+ include ReplicaPools::QueryCache
7
+
8
+ attr_accessor :leader
9
+ attr_accessor :leader_depth, :current, :current_pool, :replica_pools
10
+
11
+ class << self
12
+ def generate_safe_delegations
13
+ ReplicaPools.config.safe_methods.each do |method|
14
+ generate_safe_delegation(method) unless instance_methods.include?(method)
15
+ end
16
+ end
17
+
18
+ protected
19
+
20
+ def generate_safe_delegation(method)
21
+ class_eval <<-END, __FILE__, __LINE__ + 1
22
+ def #{method}(*args, &block)
23
+ route_to(current, :#{method}, *args, &block)
24
+ end
25
+ END
26
+ end
27
+ end
28
+
29
+ def initialize(leader, pools)
30
+ @leader = leader
31
+ @replica_pools = pools
32
+ @leader_depth = 0
33
+ @current_pool = default_pool
34
+
35
+ if ReplicaPools.config.defaults_to_leader
36
+ @current = leader
37
+ else
38
+ @current = current_replica
39
+ end
40
+
41
+ # this ivar is for ConnectionAdapter compatibility
42
+ # some gems (e.g. newrelic_rpm) will actually use
43
+ # instance_variable_get(:@config) to find it.
44
+ @config = current.connection_config
45
+ end
46
+
47
+ def with_pool(pool_name = 'default')
48
+ last_conn, last_pool = self.current, self.current_pool
49
+ self.current_pool = replica_pools[pool_name.to_sym] || default_pool
50
+ self.current = current_replica unless within_leader_block?
51
+ yield
52
+ ensure
53
+ self.current_pool = last_pool
54
+ self.current = last_conn
55
+ end
56
+
57
+ def with_leader
58
+ last_conn = self.current
59
+ self.current = leader
60
+ self.leader_depth += 1
61
+ yield
62
+ ensure
63
+ self.leader_depth = [leader_depth - 1, 0].max
64
+ self.current = last_conn
65
+ end
66
+
67
+ def transaction(*args, &block)
68
+ with_leader { leader.transaction(*args, &block) }
69
+ end
70
+
71
+ def next_replica!
72
+ return if within_leader_block?
73
+ self.current = current_pool.next
74
+ end
75
+
76
+ def current_replica
77
+ current_pool.current
78
+ end
79
+
80
+ protected
81
+
82
+ def default_pool
83
+ replica_pools[:default] || replica_pools.values.first
84
+ end
85
+
86
+ # Proxies any unknown methods to leader.
87
+ # Safe methods have been generated during `setup!`.
88
+ # Creates a method to speed up subsequent calls.
89
+ def method_missing(method, *args, &block)
90
+ generate_unsafe_delegation(method)
91
+ send(method, *args, &block)
92
+ end
93
+
94
+ def within_leader_block?
95
+ leader_depth > 0
96
+ end
97
+
98
+ def generate_unsafe_delegation(method)
99
+ self.class_eval <<-END, __FILE__, __LINE__ + 1
100
+ def #{method}(*args, &block)
101
+ route_to(leader, :#{method}, *args, &block)
102
+ end
103
+ END
104
+ end
105
+
106
+ def route_to(conn, method, *args, &block)
107
+ conn.retrieve_connection.send(method, *args, &block)
108
+ rescue => e
109
+ ReplicaPools.log :error, "Error during ##{method}: #{e}"
110
+ log_proxy_state
111
+
112
+ current.retrieve_connection.verify! # may reconnect
113
+ raise e
114
+ end
115
+
116
+ private
117
+
118
+ def log_proxy_state
119
+ ReplicaPools.log :error, "Current Connection: #{current}"
120
+ ReplicaPools.log :error, "Current Pool Name: #{current_pool.name}"
121
+ ReplicaPools.log :error, "Current Pool Members: #{current_pool.replicas}"
122
+ ReplicaPools.log :error, "leader Depth: #{leader_depth}"
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,30 @@
1
+ require 'rails/engine'
2
+
3
+ module ReplicaPools
4
+ class Engine < Rails::Engine
5
+ initializer 'replica_pools.defaults' do
6
+ ReplicaPools.config.environment = Rails.env
7
+
8
+ ReplicaPools.config.safe_methods =
9
+ if ActiveRecord::VERSION::MAJOR == 3
10
+ [
11
+ :select_all, :select_one, :select_value, :select_values,
12
+ :select_rows, :select, :verify!, :raw_connection, :active?, :reconnect!,
13
+ :disconnect!, :reset_runtime, :log, :log_info
14
+ ]
15
+ elsif ActiveRecord::VERSION::MAJOR == 4
16
+ [
17
+ :select_all, :select_one, :select_value, :select_values,
18
+ :select_rows, :select, :verify!, :raw_connection, :active?, :reconnect!,
19
+ :disconnect!, :reset_runtime, :log
20
+ ]
21
+ else
22
+ warn "Unsupported ActiveRecord version #{ActiveRecord.version}. Please whitelist the safe methods."
23
+ end
24
+ end
25
+
26
+ config.after_initialize do
27
+ ReplicaPools.setup!
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ module ReplicaPools
2
+ # The hijack is added to ActiveRecord::Base but only applies to
3
+ # its descendants. The Base.connection is left in place.
4
+ module Hijack
5
+ def self.extended(base)
6
+ # hijack models that have already been loaded
7
+ base.send(:descendants).each do |child|
8
+ child.hijack_connection
9
+ end
10
+ end
11
+
12
+ # hijack models that get loaded later
13
+ def inherited(child)
14
+ super
15
+ child.hijack_connection
16
+ end
17
+
18
+ def hijack_connection
19
+ class << self
20
+ alias_method :connection, :connection_proxy
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ module ReplicaPools
2
+ class Pool
3
+ attr_reader :name, :replicas, :current, :size
4
+
5
+ def initialize(name, connections)
6
+ @name = name
7
+ @replicas = connections
8
+ @size = connections.size
9
+ self.reset
10
+ end
11
+
12
+ def reset
13
+ @cycle = replicas.cycle
14
+ self.next
15
+ end
16
+
17
+ def next
18
+ @current = @cycle.next
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,52 @@
1
+ require 'delegate'
2
+
3
+ module ReplicaPools
4
+ class Pools < ::SimpleDelegator
5
+ include Enumerable
6
+
7
+ def initialize
8
+ pools = {}
9
+ pool_configurations.group_by{|_, name, _| name }.each do |name, set|
10
+ pools[name.to_sym] = ReplicaPools::Pool.new(
11
+ name,
12
+ set.map{ |conn_name, _, replica_name|
13
+ connection_class(name, replica_name, conn_name)
14
+ }
15
+ )
16
+ end
17
+
18
+ if pools.empty?
19
+ ReplicaPools.log :info, "No pools found for #{ReplicaPools.config.environment}. Loading a default pool with leader instead."
20
+ pools[:default] = ReplicaPools::Pool.new('default', [ActiveRecord::Base])
21
+ end
22
+
23
+ super pools
24
+ end
25
+
26
+ private
27
+
28
+ # finds valid pool configs
29
+ def pool_configurations
30
+ ActiveRecord::Base.configurations.map do |name, config|
31
+ next unless name.to_s =~ /#{ReplicaPools.config.environment}_pool_(.*)_name_(.*)/
32
+ [name, $1, $2]
33
+ end.compact
34
+ end
35
+
36
+ # generates a unique ActiveRecord::Base subclass for a single replica
37
+ def connection_class(pool_name, replica_name, connection_name)
38
+ class_name = "#{pool_name.camelize}#{replica_name.camelize}"
39
+
40
+ ReplicaPools.module_eval %Q{
41
+ class #{class_name} < ActiveRecord::Base
42
+ self.abstract_class = true
43
+ establish_connection :#{connection_name}
44
+ def self.connection_config
45
+ configurations[#{connection_name.to_s.inspect}]
46
+ end
47
+ end
48
+ }, __FILE__, __LINE__
49
+ ReplicaPools.const_get(class_name)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,35 @@
1
+ module ReplicaPools
2
+ # duck-types with ActiveRecord::ConnectionAdapters::QueryCache
3
+ # but relies on ActiveRecord::Base.query_cache for state so we
4
+ # don't fragment the cache across multiple connections
5
+ #
6
+ # we could use more of ActiveRecord's QueryCache if it only
7
+ # used accessors for its internal ivars.
8
+ module QueryCache
9
+ query_cache_methods = ActiveRecord::ConnectionAdapters::QueryCache.instance_methods(false)
10
+
11
+ # these methods can all use the leader connection
12
+ (query_cache_methods - [:select_all]).each do |method_name|
13
+ module_eval <<-END, __FILE__, __LINE__ + 1
14
+ def #{method_name}(*a, &b)
15
+ ActiveRecord::Base.connection.#{method_name}(*a, &b)
16
+ end
17
+ END
18
+ end
19
+
20
+ # select_all is trickier. it needs to use the leader
21
+ # connection for cache logic, but ultimately pass its query
22
+ # through to whatever connection is current.
23
+ def select_all(arel, name = nil, binds = [])
24
+ if query_cache_enabled && !locked?(arel)
25
+ sql = to_sql(arel, binds)
26
+ cache_sql(sql, binds) { route_to(current, :select_all, sql, name, binds) }
27
+ else
28
+ route_to(current, :select_all, arel, name, binds)
29
+ end
30
+ end
31
+
32
+ # these can use the unsafe delegation built into ConnectionProxy
33
+ # [:insert, :update, :delete]
34
+ end
35
+ end
@@ -0,0 +1,3 @@
1
+ module ReplicaPools
2
+ VERSION = "1.3.0"
3
+ end
@@ -0,0 +1,2 @@
1
+ class TestModel < ActiveRecord::Base
2
+ end
@@ -0,0 +1,238 @@
1
+ require_relative 'spec_helper'
2
+ require_relative 'config/test_model'
3
+
4
+ describe ReplicaPools do
5
+
6
+ before(:each) do
7
+ @sql = 'SELECT NOW()'
8
+
9
+ @proxy = ReplicaPools.proxy
10
+ @leader = @proxy.leader.retrieve_connection
11
+
12
+ reset_proxy(@proxy)
13
+ create_replica_aliases(@proxy)
14
+ end
15
+
16
+ it 'AR::B should respond to #connection_proxy' do
17
+ ActiveRecord::Base.should respond_to(:connection_proxy)
18
+ ActiveRecord::Base.connection_proxy.should be_kind_of(ReplicaPools::ConnectionProxy)
19
+ end
20
+
21
+ it 'TestModel#connection should return an instance of ReplicaPools::ConnectionProxy' do
22
+ TestModel.connection.should be_kind_of(ReplicaPools::ConnectionProxy)
23
+ end
24
+
25
+ it "should generate classes for each entry in the database.yml" do
26
+ defined?(ReplicaPools::DefaultDb1).should_not be_nil
27
+ defined?(ReplicaPools::DefaultDb2).should_not be_nil
28
+ defined?(ReplicaPools::SecondaryDb1).should_not be_nil
29
+ defined?(ReplicaPools::SecondaryDb2).should_not be_nil
30
+ defined?(ReplicaPools::SecondaryDb3).should_not be_nil
31
+ end
32
+
33
+ context "with_leader" do
34
+ it 'should revert to previous replica connection' do
35
+ @proxy.current = @proxy.current_replica
36
+ @proxy.with_leader do
37
+ @proxy.current.should equal(@proxy.leader)
38
+ end
39
+ @proxy.current.name.should eq('ReplicaPools::DefaultDb1')
40
+ end
41
+
42
+ it 'should revert to previous leader connection' do
43
+ @proxy.current = @proxy.leader
44
+ @proxy.with_leader do
45
+ @proxy.current.should equal(@proxy.leader)
46
+ end
47
+ @proxy.current.should equal(@proxy.leader)
48
+ end
49
+
50
+ it 'should know when in block' do
51
+ @proxy.send(:within_leader_block?).should_not be
52
+ @proxy.with_leader do
53
+ @proxy.send(:within_leader_block?).should be
54
+ end
55
+ @proxy.send(:within_leader_block?).should_not be
56
+ end
57
+ end
58
+
59
+ context "transaction" do
60
+ it 'should send all to leader' do
61
+ @leader.should_receive(:select_all).exactly(1)
62
+ @default_replica1.should_receive(:select_all).exactly(0)
63
+
64
+ TestModel.transaction do
65
+ @proxy.select_all(@sql)
66
+ end
67
+ end
68
+
69
+ it 'should send all to leader even if transactions begins on AR::Base' do
70
+ @leader.should_receive(:select_all).exactly(1)
71
+ @default_replica1.should_receive(:select_all).exactly(0)
72
+
73
+ ActiveRecord::Base.transaction do
74
+ @proxy.select_all(@sql)
75
+ end
76
+ end
77
+ end
78
+
79
+ it 'should perform transactions on the leader, and selects outside of transaction on the replica' do
80
+ @default_replica1.should_receive(:select_all).exactly(2) # before and after the transaction go to replicas
81
+ @leader.should_receive(:select_all).exactly(5)
82
+ @proxy.select_all(@sql)
83
+ ActiveRecord::Base.transaction do
84
+ 5.times {@proxy.select_all(@sql)}
85
+ end
86
+ @proxy.select_all(@sql)
87
+ end
88
+
89
+ it 'should not switch replicas automatically on selects' do
90
+ @default_replica1.should_receive(:select_one).exactly(6)
91
+ @default_replica2.should_receive(:select_one).exactly(0)
92
+ 6.times { @proxy.select_one(@sql) }
93
+ end
94
+
95
+ context "next_replica!" do
96
+ it 'should switch to the next replica' do
97
+ @default_replica1.should_receive(:select_one).exactly(1)
98
+ @default_replica2.should_receive(:select_one).exactly(1)
99
+
100
+ @proxy.select_one(@sql)
101
+ @proxy.next_replica!
102
+ @proxy.select_one(@sql)
103
+ end
104
+
105
+ it 'should not switch when in a with_leader-block' do
106
+ @leader.should_receive(:select_one).exactly(2)
107
+ @default_replica1.should_not_receive(:select_one)
108
+ @default_replica2.should_not_receive(:select_one)
109
+
110
+ @proxy.with_leader do
111
+ @proxy.select_one(@sql)
112
+ @proxy.next_replica!
113
+ @proxy.select_one(@sql)
114
+ end
115
+ end
116
+ end
117
+
118
+ it 'should send dangerous methods to the leader' do
119
+ meths = [:insert, :update, :delete, :execute]
120
+ meths.each do |meth|
121
+ @default_replica1.stub(meth).and_raise(RuntimeError)
122
+ @leader.should_receive(meth).and_return(true)
123
+ @proxy.send(meth, @sql)
124
+ end
125
+ end
126
+
127
+ it "should not allow leader depth to get below 0" do
128
+ @proxy.instance_variable_set("@leader_depth", -500)
129
+ @proxy.instance_variable_get("@leader_depth").should == -500
130
+ @proxy.with_leader {@sql}
131
+ @proxy.instance_variable_get("@leader_depth").should == 0
132
+ end
133
+
134
+ it 'should pre-generate safe methods' do
135
+ @proxy.should respond_to(:select_value)
136
+ end
137
+
138
+ it 'should dynamically generate unsafe methods' do
139
+ @leader.should_receive(:unsafe).and_return(true)
140
+
141
+ @proxy.should_not respond_to(:unsafe)
142
+ @proxy.unsafe(@sql)
143
+ @proxy.should respond_to(:unsafe)
144
+ end
145
+
146
+ it 'should not replay errors on leader' do
147
+ @default_replica1.should_receive(:select_all).once.and_raise(ArgumentError.new('random message'))
148
+ @default_replica2.should_not_receive(:select_all)
149
+ @leader.should_not_receive(:select_all)
150
+ lambda { @proxy.select_all(@sql) }.should raise_error(ArgumentError)
151
+ end
152
+
153
+ it 'should reload models from the leader' do
154
+ foo = TestModel.create!
155
+ @leader.should_receive(:select_all).and_return(ActiveRecord::Result.new(["id"], ["1"]))
156
+ @default_replica1.should_not_receive(:select_all)
157
+ @default_replica2.should_not_receive(:select_all)
158
+ foo.reload
159
+ end
160
+
161
+ context "with_pool" do
162
+
163
+ it "should switch to the named pool" do
164
+ @proxy.with_pool('secondary') do
165
+ @proxy.current_pool.name.should eq('secondary')
166
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb1')
167
+ end
168
+ end
169
+
170
+ it "should switch to default pool if an unknown pool is specified" do
171
+ @proxy.with_pool('unknown') do
172
+ @proxy.current_pool.name.should eq('default')
173
+ @proxy.current.name.should eq('ReplicaPools::DefaultDb1')
174
+ end
175
+ end
176
+
177
+ it "should switch to default pool if no pool is specified" do
178
+ @proxy.with_pool do
179
+ @proxy.current_pool.name.should eq('default')
180
+ @proxy.current.name.should eq('ReplicaPools::DefaultDb1')
181
+ end
182
+ end
183
+
184
+ it "should cycle replicas only within the pool" do
185
+ @proxy.with_pool('secondary') do
186
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb1')
187
+ @proxy.next_replica!
188
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb2')
189
+ @proxy.next_replica!
190
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb3')
191
+ @proxy.next_replica!
192
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb1')
193
+ end
194
+ end
195
+
196
+ it "should allow switching back to leader" do
197
+ @proxy.with_pool('secondary') do
198
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb1')
199
+ @proxy.with_leader do
200
+ @proxy.current.name.should eq('ActiveRecord::Base')
201
+ end
202
+ end
203
+ end
204
+
205
+ it "should not switch to pool when nested inside with_leader" do
206
+ @proxy.current.name.should eq('ReplicaPools::DefaultDb1')
207
+ @proxy.with_leader do
208
+ @proxy.with_pool('secondary') do
209
+ @proxy.current.name.should eq('ActiveRecord::Base')
210
+ end
211
+ end
212
+ end
213
+
214
+ it "should switch back to previous pool and replica" do
215
+ @proxy.next_replica!
216
+
217
+ @proxy.current_pool.name.should eq('default')
218
+ @proxy.current.name.should eq('ReplicaPools::DefaultDb2')
219
+
220
+ @proxy.with_pool('secondary') do
221
+ @proxy.current_pool.name.should eq('secondary')
222
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb1')
223
+
224
+ @proxy.with_pool('default') do
225
+ @proxy.current_pool.name.should eq('default')
226
+ @proxy.current.name.should eq('ReplicaPools::DefaultDb2')
227
+ end
228
+
229
+ @proxy.current_pool.name.should eq('secondary')
230
+ @proxy.current.name.should eq('ReplicaPools::SecondaryDb1')
231
+ end
232
+
233
+ @proxy.current_pool.name.should eq('default')
234
+ @proxy.current.name.should eq('ReplicaPools::DefaultDb2')
235
+ end
236
+ end
237
+ end
238
+
data/spec/pool_spec.rb ADDED
@@ -0,0 +1,35 @@
1
+ require_relative 'spec_helper'
2
+
3
+ describe ReplicaPools::Pool do
4
+
5
+ context "Multiple replicas" do
6
+ before do
7
+ @replicas = ["db1", "db2", "db3"]
8
+ @replica_pool = ReplicaPools::Pool.new("name", @replicas.clone)
9
+ end
10
+
11
+ specify {@replica_pool.size.should == 3}
12
+
13
+ it "should return items in a round robin fashion" do
14
+ @replica_pool.current.should == @replicas[0]
15
+ @replica_pool.next.should == @replicas[1]
16
+ @replica_pool.next.should == @replicas[2]
17
+ @replica_pool.next.should == @replicas[0]
18
+ end
19
+ end
20
+
21
+ context "Single replica" do
22
+ before do
23
+ @replicas = ["db1"]
24
+ @replica_pool = ReplicaPools::Pool.new("name", @replicas.clone)
25
+ end
26
+
27
+ specify {@replica_pool.size.should == 1}
28
+
29
+ it "should return items in a round robin fashion" do
30
+ @replica_pool.current.should == @replicas[0]
31
+ @replica_pool.next.should == @replicas[0]
32
+ end
33
+ end
34
+ end
35
+
@@ -0,0 +1,86 @@
1
+ require 'rack'
2
+ require_relative 'spec_helper'
3
+
4
+ describe ReplicaPools::QueryCache do
5
+ before(:each) do
6
+ @sql = 'SELECT NOW()'
7
+
8
+ @proxy = ReplicaPools.proxy
9
+ @leader = @proxy.leader.retrieve_connection
10
+
11
+ @leader.clear_query_cache
12
+
13
+ reset_proxy(@proxy)
14
+ create_replica_aliases(@proxy)
15
+ end
16
+
17
+ it 'should cache queries using select_all' do
18
+ ActiveRecord::Base.cache do
19
+ # next_replica will be called and switch to the replicaDatabase2
20
+ @default_replica1.should_receive(:select_all).exactly(1).and_return([])
21
+ @default_replica2.should_not_receive(:select_all)
22
+ @leader.should_not_receive(:select_all)
23
+ 3.times { @proxy.select_all(@sql) }
24
+ @leader.query_cache.keys.size.should == 1
25
+ end
26
+ end
27
+
28
+ it 'should invalidate the cache on insert, delete and update' do
29
+ ActiveRecord::Base.cache do
30
+ meths = [:insert, :update, :delete, :insert, :update]
31
+ meths.each do |meth|
32
+ @leader.should_receive("exec_#{meth}").and_return(true)
33
+ end
34
+
35
+ @default_replica1.should_receive(:select_all).exactly(5).and_return([])
36
+ @default_replica2.should_receive(:select_all).exactly(0)
37
+ 5.times do |i|
38
+ @proxy.select_all(@sql)
39
+ @proxy.select_all(@sql)
40
+ @leader.query_cache.keys.size.should == 1
41
+ @proxy.send(meths[i], '')
42
+ @leader.query_cache.keys.size.should == 0
43
+ end
44
+ end
45
+ end
46
+
47
+ describe "using querycache middleware" do
48
+ it 'should cache queries using select_all' do
49
+ mw = ActiveRecord::QueryCache.new lambda { |env|
50
+ @default_replica1.should_receive(:select_all).exactly(1).and_return([])
51
+ @default_replica2.should_not_receive(:select_all)
52
+ @leader.should_not_receive(:select_all)
53
+ 3.times { @proxy.select_all(@sql) }
54
+ @proxy.next_replica!
55
+ 3.times { @proxy.select_all(@sql) }
56
+ @proxy.next_replica!
57
+ 3.times { @proxy.select_all(@sql)}
58
+ @leader.query_cache.keys.size.should == 1
59
+ [200, {}, nil]
60
+ }
61
+ mw.call({})
62
+ end
63
+
64
+ it 'should invalidate the cache on insert, delete and update' do
65
+ mw = ActiveRecord::QueryCache.new lambda { |env|
66
+ meths = [:insert, :update, :delete, :insert, :update]
67
+ meths.each do |meth|
68
+ @leader.should_receive("exec_#{meth}").and_return(true)
69
+ end
70
+
71
+ @default_replica1.should_receive(:select_all).exactly(5).and_return([])
72
+ @default_replica2.should_receive(:select_all).exactly(0)
73
+ 5.times do |i|
74
+ @proxy.select_all(@sql)
75
+ @proxy.select_all(@sql)
76
+ @leader.query_cache.keys.size.should == 1
77
+ @proxy.send(meths[i], '')
78
+ @leader.query_cache.keys.size.should == 0
79
+ end
80
+ [200, {}, nil]
81
+ }
82
+ mw.call({})
83
+ end
84
+ end
85
+
86
+ end
@@ -0,0 +1,31 @@
1
+ require_relative 'spec_helper'
2
+
3
+ describe ReplicaPools do
4
+
5
+ before(:each) do
6
+ ReplicaPools.pools.each{|_, pool| pool.reset }
7
+ @proxy = ReplicaPools.proxy
8
+ end
9
+
10
+ it 'should delegate next_replica! call to connection proxy' do
11
+ @proxy.should_receive(:next_replica!).exactly(1)
12
+ ReplicaPools.next_replica!
13
+ end
14
+
15
+ it 'should delegate with_pool call to connection proxy' do
16
+ @proxy.should_receive(:with_pool).exactly(1)
17
+ ReplicaPools.with_pool('test')
18
+ end
19
+
20
+ it 'should delegate with_leader call to connection proxy' do
21
+ @proxy.should_receive(:with_leader).exactly(1)
22
+ ReplicaPools.with_leader
23
+ end
24
+
25
+ it 'should delegate current call to connection proxy' do
26
+ @proxy.should_receive(:current).exactly(1)
27
+ ReplicaPools.current
28
+ end
29
+
30
+ end
31
+
@@ -0,0 +1,44 @@
1
+ require 'rubygems'
2
+ require 'bundler/setup'
3
+ require 'logger'
4
+
5
+ module Rails
6
+ def self.env
7
+ ActiveSupport::StringInquirer.new("test")
8
+ end
9
+ end
10
+
11
+ require 'active_record'
12
+ spec_dir = File.dirname(__FILE__)
13
+ ActiveRecord::Base.logger = Logger.new(spec_dir + "/debug.log")
14
+ ActiveRecord::Base.configurations = YAML::load(File.open(spec_dir + '/config/database.yml'))
15
+
16
+ ActiveRecord::Base.establish_connection :test
17
+ ActiveRecord::Migration.verbose = false
18
+ ActiveRecord::Migration.create_table(:test_models, :force => true) {}
19
+
20
+ require 'replica_pools'
21
+ ReplicaPools::Engine.initializers.each(&:run)
22
+ ActiveSupport.run_load_hooks(:after_initialize, ReplicaPools::Engine)
23
+
24
+ module ReplicaPools::Testing
25
+ # Creates aliases for the replica connections in each pool
26
+ # for easy reference in tests.
27
+ def create_replica_aliases(proxy)
28
+ proxy.replica_pools.each do |name, pool|
29
+ pool.replicas.each.with_index do |replica, i|
30
+ instance_variable_set("@#{name}_replica#{i + 1}", replica.retrieve_connection)
31
+ end
32
+ end
33
+ end
34
+
35
+ def reset_proxy(proxy)
36
+ proxy.replica_pools.each{|_, pool| pool.reset }
37
+ proxy.current_pool = proxy.replica_pools[:default]
38
+ proxy.current = proxy.current_replica
39
+ end
40
+ end
41
+
42
+ RSpec.configure do |c|
43
+ c.include ReplicaPools::Testing
44
+ end
metadata ADDED
@@ -0,0 +1,153 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: replica_pools
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Dan Drabik
8
+ - Lance Ivy
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2015-02-12 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: activerecord
16
+ requirement: !ruby/object:Gem::Requirement
17
+ requirements:
18
+ - - '>='
19
+ - !ruby/object:Gem::Version
20
+ version: 3.2.12
21
+ type: :runtime
22
+ prerelease: false
23
+ version_requirements: !ruby/object:Gem::Requirement
24
+ requirements:
25
+ - - '>='
26
+ - !ruby/object:Gem::Version
27
+ version: 3.2.12
28
+ - !ruby/object:Gem::Dependency
29
+ name: mysql2
30
+ requirement: !ruby/object:Gem::Requirement
31
+ requirements:
32
+ - - ~>
33
+ - !ruby/object:Gem::Version
34
+ version: 0.3.11
35
+ type: :development
36
+ prerelease: false
37
+ version_requirements: !ruby/object:Gem::Requirement
38
+ requirements:
39
+ - - ~>
40
+ - !ruby/object:Gem::Version
41
+ version: 0.3.11
42
+ - !ruby/object:Gem::Dependency
43
+ name: rack
44
+ requirement: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - '>='
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ type: :development
50
+ prerelease: false
51
+ version_requirements: !ruby/object:Gem::Requirement
52
+ requirements:
53
+ - - '>='
54
+ - !ruby/object:Gem::Version
55
+ version: '0'
56
+ - !ruby/object:Gem::Dependency
57
+ name: rspec
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - '>='
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ - !ruby/object:Gem::Dependency
71
+ name: rake
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - '>='
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - '>='
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: rails
86
+ requirement: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - '>='
89
+ - !ruby/object:Gem::Version
90
+ version: '0'
91
+ type: :development
92
+ prerelease: false
93
+ version_requirements: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - '>='
96
+ - !ruby/object:Gem::Version
97
+ version: '0'
98
+ description: Connection proxy for ActiveRecord for leader / replica setups.
99
+ email: dan@kickstarter.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - lib/replica_pools/active_record_extensions.rb
105
+ - lib/replica_pools/config.rb
106
+ - lib/replica_pools/connection_proxy.rb
107
+ - lib/replica_pools/engine.rb
108
+ - lib/replica_pools/hijack.rb
109
+ - lib/replica_pools/pool.rb
110
+ - lib/replica_pools/pools.rb
111
+ - lib/replica_pools/query_cache.rb
112
+ - lib/replica_pools/version.rb
113
+ - lib/replica_pools.rb
114
+ - LICENSE
115
+ - README.md
116
+ - spec/config/test_model.rb
117
+ - spec/connection_proxy_spec.rb
118
+ - spec/pool_spec.rb
119
+ - spec/query_cache_spec.rb
120
+ - spec/slave_pools_spec.rb
121
+ - spec/spec_helper.rb
122
+ homepage: https://github.com/kickstarter/replica_pools
123
+ licenses:
124
+ - MIT
125
+ metadata: {}
126
+ post_install_message:
127
+ rdoc_options: []
128
+ require_paths:
129
+ - lib
130
+ required_ruby_version: !ruby/object:Gem::Requirement
131
+ requirements:
132
+ - - '>='
133
+ - !ruby/object:Gem::Version
134
+ version: '0'
135
+ required_rubygems_version: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - '>='
138
+ - !ruby/object:Gem::Version
139
+ version: '1.2'
140
+ requirements: []
141
+ rubyforge_project:
142
+ rubygems_version: 2.0.14
143
+ signing_key:
144
+ specification_version: 4
145
+ summary: Connection proxy for ActiveRecord for leader / replica setups.
146
+ test_files:
147
+ - spec/config/test_model.rb
148
+ - spec/connection_proxy_spec.rb
149
+ - spec/pool_spec.rb
150
+ - spec/query_cache_spec.rb
151
+ - spec/slave_pools_spec.rb
152
+ - spec/spec_helper.rb
153
+ has_rdoc: