slave_pools 0.1.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,19 @@
1
+ module SlavePoolsModule
2
+ module ObserverExtensions
3
+ def self.included(base)
4
+ base.alias_method_chain :update, :masterdb
5
+ end
6
+
7
+ # Send observed_method(object) if the method exists.
8
+ # currently replicating the update method instead of using the aliased method call to update_without_master
9
+ def update_with_masterdb(observed_method, object, &block) #:nodoc:
10
+ if object.class.connection.respond_to?(:with_master)
11
+ object.class.connection.with_master do
12
+ send(observed_method, object, &block) if respond_to?(observed_method) && !disabled_for?(object)
13
+ end
14
+ else
15
+ send(observed_method, object, &block) if respond_to?(observed_method) && !disabled_for?(object)
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ module SlavePoolsModule
2
+ # Implements the methods expected by the QueryCache module
3
+ module QueryCacheCompat
4
+
5
+ def select_all(*a, &b)
6
+ arel, name, binds = a
7
+ if query_cache_enabled && !locked?(arel)
8
+ # FIXME this still hits the +select_all+ method in AR connection's
9
+ # query_cache.rb. It'd be nice if we could avoid it somehow so
10
+ # +select_all+ and then +to_sql+ aren't called redundantly.
11
+ sql = to_sql(arel, binds)
12
+ @master.connection.send(:cache_sql, sql, binds) {send_to_current(:select_all, *[sql, name, binds], &b)}
13
+ else
14
+ send_to_current(:select_all, *a, &b)
15
+ end
16
+ end
17
+
18
+ def insert(*a, &b)
19
+ @master.connection.clear_query_cache if query_cache_enabled
20
+ send_to_master(:insert, *a, &b)
21
+ end
22
+
23
+ def update(*a, &b)
24
+ @master.connection.clear_query_cache if query_cache_enabled
25
+ send_to_master(:update, *a, &b)
26
+ end
27
+
28
+ def delete(*a, &b)
29
+ @master.connection.clear_query_cache if query_cache_enabled
30
+ send_to_master(:delete, *a, &b)
31
+ end
32
+
33
+ # Rails 3.2 changed query cacheing a little and affected slave_pools like this:
34
+ #
35
+ # * ActiveRecord::Base.cache sets @query_cache_enabled for current connection
36
+ # * ActiveRecord::QueryCache middleware (in call()) that Rails uses sets
37
+ # @query_cache_enabled directly on ActiveRecord::Base.connection
38
+ # (which could be master at that point)
39
+ #
40
+ # :`( So, let's just use the master connection for all query cacheing.
41
+ def query_cache_enabled
42
+ @master.connection.query_cache_enabled
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,29 @@
1
+ module SlavePoolsModule
2
+ class SlavePool
3
+
4
+ attr_accessor :name, :slaves,:pool_size, :current_index
5
+
6
+ def initialize(name, slaves)
7
+ @name = name
8
+ @slaves = slaves
9
+ @pool_size = @slaves.length
10
+ @current_index = 0
11
+ end
12
+
13
+ def current
14
+ @slaves[@current_index]
15
+ end
16
+
17
+ def next
18
+ next_index! if @pool_size != 1
19
+ current
20
+ end
21
+
22
+ protected
23
+
24
+ def next_index!
25
+ @current_index = (@current_index + 1) % @pool_size
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = %q{slave_pools}
5
+ s.version = "0.1.0"
6
+
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 1.2") if s.respond_to? :required_rubygems_version=
8
+ s.authors = ["Dan Drabik"]
9
+ s.description = "Connection proxy for ActiveRecord for single master / multiple slave database groups"
10
+ s.email = "dan@kickstarter.com"
11
+ s.extra_rdoc_files = ["LICENSE", "README.rdoc"]
12
+ s.files = ["lib/slave_pools.rb", "lib/slave_pools/active_record_extensions.rb", "lib/slave_pools/connection_proxy.rb", "lib/slave_pools/observer_extensions.rb", "lib/slave_pools/query_cache_compat.rb", "lib/slave_pools/slave_pool.rb", "LICENSE", "README.rdoc", "spec/config/database.yml", "spec/connection_proxy_spec.rb", "spec/slave_pool_spec.rb","spec/slave_pools_spec.rb", "spec/spec_helper.rb", "slave_pools.gemspec"]
13
+ s.has_rdoc = true
14
+ s.homepage = "https://github.com/kickstarter/slave_pools"
15
+ s.rdoc_options = ["--line-numbers", "--inline-source", "--title", "slave_pools", "--main", "README.rdoc"]
16
+ s.require_paths = ["lib"]
17
+ s.rubygems_version = %q{1.3.1}
18
+ s.summary = "Connection proxy for ActiveRecord for single master / multiple slave database groups"
19
+
20
+ s.add_dependency('activerecord', ["~> 3.2.12"])
21
+ s.add_development_dependency('mysql2', ["~> 0.3.11"])
22
+ s.add_development_dependency('rspec')
23
+ s.add_development_dependency('rake')
24
+ end
@@ -0,0 +1,43 @@
1
+ login: &login
2
+ adapter: mysql2
3
+ username: root
4
+ password:
5
+ host: localhost
6
+ encoding: utf8
7
+ read_timeout: 1
8
+
9
+ readonly_login: &readonly_login
10
+ adapter: mysql2
11
+ username: read_only
12
+ password: readme
13
+ host: localhost
14
+ encoding: utf8
15
+ read_timeout: 1
16
+
17
+ test:
18
+ database: test_db
19
+ <<: *login
20
+
21
+ test_pool_secondary_name_db1:
22
+ database: test_db
23
+ <<: *readonly_login
24
+
25
+ test_pool_secondary_name_db2:
26
+ database: test_db
27
+ <<: *readonly_login
28
+
29
+ test_pool_secondary_name_db3:
30
+ database: test_db
31
+ <<: *readonly_login
32
+
33
+ test_pool_default_name_db1:
34
+ database: test_db
35
+ <<: *readonly_login
36
+
37
+ test_pool_default_name_db2:
38
+ database: test_db
39
+ <<: *readonly_login
40
+
41
+ test_pool_default_name_fake_db:
42
+ database: fake_db_that_doesnt_exist #do not create this db, used to test the connection
43
+ <<: *readonly_login
@@ -0,0 +1,330 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
2
+
3
+ describe SlavePools do
4
+
5
+ before(:each) do
6
+ ActiveRecord::Base.configurations = SLAVE_POOLS_SPEC_CONFIG
7
+ ActiveRecord::Base.establish_connection :test
8
+ ActiveRecord::Migration.verbose = false
9
+ ActiveRecord::Migration.create_table(:master_models, :force => true) {}
10
+ class MasterModel < ActiveRecord::Base; end
11
+ ActiveRecord::Migration.create_table(:foo_models, :force => true) {|t| t.string :bar}
12
+ class FooModel < ActiveRecord::Base; end
13
+ @sql = 'SELECT NOW()'
14
+ end
15
+
16
+ describe "standard setup" do
17
+ before(:each) do
18
+ SlavePoolsModule::ConnectionProxy.master_models = ['MasterModel']
19
+ SlavePoolsModule::ConnectionProxy.setup!
20
+ @proxy = ActiveRecord::Base.connection_proxy
21
+ @slave_pool_hash = @proxy.slave_pools
22
+ @slave_pool_array = @slave_pool_hash.values
23
+ @master = @proxy.master.retrieve_connection
24
+ # creates instance variables (@default_slave1, etc.) for each slave based on the order they appear in the slave_pool
25
+ # ruby 1.8.7 doesn't support ordered hashes, so we assign numbers to the slaves this way, and not the order in the yml file
26
+ # to prevent @default_slave1 from being different on different systems
27
+ ['default', 'secondary'].each do |pool_name|
28
+ @slave_pool_hash[pool_name.to_sym].slaves.each_with_index do |slave, i|
29
+ instance_variable_set("@#{pool_name}_slave#{i + 1}", slave.retrieve_connection)
30
+ end
31
+ end
32
+ end
33
+
34
+ it 'AR::B should respond to #connection_proxy' do
35
+ ActiveRecord::Base.connection_proxy.should be_kind_of(SlavePoolsModule::ConnectionProxy)
36
+ end
37
+
38
+ it 'FooModel#connection should return an instance of SlavePools::ConnectionProxy' do
39
+ FooModel.connection.should be_kind_of(SlavePoolsModule::ConnectionProxy)
40
+ end
41
+
42
+ it 'MasterModel#connection should not return an instance of SlavePools::ConnectionProxy' do
43
+ MasterModel.connection.should_not be_kind_of(SlavePoolsModule::ConnectionProxy)
44
+ end
45
+
46
+ it "should generate classes for each entry in the database.yml" do
47
+ defined?(SlavePoolsModule::DefaultDb1).should_not be_nil
48
+ defined?(SlavePoolsModule::DefaultDb2).should_not be_nil
49
+ defined?(SlavePoolsModule::SecondaryDb1).should_not be_nil
50
+ defined?(SlavePoolsModule::SecondaryDb2).should_not be_nil
51
+ defined?(SlavePoolsModule::SecondaryDb3).should_not be_nil
52
+ end
53
+
54
+ it "should not generate classes for an invalid DB in the database.yml" do
55
+ defined?(SlavePoolsModule::DefaultFakeDb).should be_nil
56
+ end
57
+
58
+ it 'should handle nested with_master-blocks correctly' do
59
+ @proxy.current.should_not == @proxy.master
60
+ @proxy.with_master do
61
+ @proxy.current.should == @proxy.master
62
+ @proxy.with_master do
63
+ @proxy.current.should == @proxy.master
64
+ @proxy.with_master do
65
+ @proxy.current.should == @proxy.master
66
+ end
67
+ @proxy.current.should == @proxy.master
68
+ end
69
+ @proxy.current.should == @proxy.master
70
+ end
71
+ @proxy.current.should_not == @proxy.master
72
+ end
73
+
74
+ it 'should perform transactions on the master' do
75
+ @master.should_receive(:select_all).exactly(5)
76
+ @default_slave1.should_receive(:select_all).exactly(0)
77
+ ActiveRecord::Base.transaction({}) do
78
+ 5.times {@proxy.select_all(@sql)}
79
+ end
80
+ end
81
+
82
+ it 'should perform transactions on the master, and selects outside of transaction on the slave' do
83
+ @default_slave1.should_receive(:select_all).exactly(2) # before and after the transaction go to slaves
84
+ @master.should_receive(:select_all).exactly(5)
85
+ @proxy.select_all(@sql)
86
+ ActiveRecord::Base.transaction do
87
+ 5.times {@proxy.select_all(@sql)}
88
+ end
89
+ @proxy.select_all(@sql)
90
+ end
91
+
92
+ it 'should not switch to the next reader on selects' do
93
+ @default_slave1.should_receive(:select_one).exactly(6)
94
+ @default_slave2.should_receive(:select_one).exactly(0)
95
+ 6.times { @proxy.select_one(@sql) }
96
+ end
97
+
98
+ it '#next_slave! should switch to the next slave' do
99
+ @default_slave1.should_receive(:select_one).exactly(3)
100
+ @default_slave2.should_receive(:select_one).exactly(7)
101
+ 3.times { @proxy.select_one(@sql) }
102
+ @proxy.next_slave!
103
+ 7.times { @proxy.select_one(@sql) }
104
+ end
105
+
106
+ it 'should switch if next reader is explicitly called' do
107
+ @default_slave1.should_receive(:select_one).exactly(3)
108
+ @default_slave2.should_receive(:select_one).exactly(3)
109
+ 6.times do
110
+ @proxy.select_one(@sql)
111
+ @proxy.next_slave!
112
+ end
113
+ end
114
+
115
+ it 'should not switch to the next reader when whithin a with_master-block' do
116
+ @master.should_receive(:select_one).twice
117
+ @default_slave1.should_not_receive(:select_one)
118
+ @default_slave2.should_not_receive(:select_one)
119
+ @proxy.with_master do
120
+ 2.times { @proxy.select_one(@sql) }
121
+ end
122
+ end
123
+
124
+ it 'should send dangerous methods to the master' do
125
+ meths = [:insert, :update, :delete, :execute]
126
+ meths.each do |meth|
127
+ @default_slave1.stub!(meth).and_raise(RuntimeError)
128
+ @master.should_receive(meth).and_return(true)
129
+ @proxy.send(meth, @sql)
130
+ end
131
+ end
132
+
133
+ it "should send writes to the master, even if current gets called for a write" do
134
+ @proxy.instance_variable_set("@master_depth", 0)
135
+ @master.should_receive(:update).and_return(true)
136
+ @proxy.send(:send_to_current, :update, @sql, {})
137
+ end
138
+
139
+ it "should not allow master depth to get below 0" do
140
+ @proxy.instance_variable_set("@master_depth", -500)
141
+ @proxy.instance_variable_get("@master_depth").should == -500
142
+ @proxy.with_master {@sql}
143
+ @proxy.instance_variable_get("@master_depth").should == 0
144
+ end
145
+
146
+ it 'should dynamically generate safe methods' do
147
+ @proxy.should_not respond_to(:select_value)
148
+ @proxy.select_value(@sql)
149
+ @proxy.should respond_to(:select_value)
150
+ end
151
+
152
+ it 'should cache queries using select_all' do
153
+ ActiveRecord::Base.cache do
154
+ # next_slave will be called and switch to the SlaveDatabase2
155
+ @default_slave1.should_receive(:select_all).exactly(1).and_return([])
156
+ @default_slave2.should_not_receive(:select_all)
157
+ @master.should_not_receive(:select_all)
158
+ 3.times { @proxy.select_all(@sql) }
159
+ @master.query_cache.keys.size.should == 1
160
+ end
161
+ end
162
+
163
+ it 'should invalidate the cache on insert, delete and update' do
164
+ ActiveRecord::Base.cache do
165
+ meths = [:insert, :update, :delete, :insert, :update]
166
+ meths.each do |meth|
167
+ @master.should_receive(meth).and_return(true)
168
+ end
169
+ @default_slave1.should_receive(:select_all).exactly(5).and_return([])
170
+ @default_slave2.should_receive(:select_all).exactly(0)
171
+ 5.times do |i|
172
+ @proxy.select_all(@sql)
173
+ @proxy.select_all(@sql)
174
+ @master.query_cache.keys.size.should == 1
175
+ @proxy.send(meths[i])
176
+ @master.query_cache.keys.size.should == 0
177
+ end
178
+ end
179
+ end
180
+
181
+ describe "using querycache middleware" do
182
+ it 'should cache queries using select_all' do
183
+ mw = ActiveRecord::QueryCache.new lambda { |env|
184
+ @default_slave1.should_receive(:select_all).exactly(1).and_return([])
185
+ @default_slave2.should_not_receive(:select_all)
186
+ @master.should_not_receive(:select_all)
187
+ 3.times { @proxy.select_all(@sql) }
188
+ @proxy.next_slave!
189
+ 3.times { @proxy.select_all(@sql) }
190
+ @proxy.next_slave!
191
+ 3.times { @proxy.select_all(@sql)}
192
+ @master.query_cache.keys.size.should == 1
193
+ }
194
+ mw.call({})
195
+ end
196
+
197
+ it 'should invalidate the cache on insert, delete and update' do
198
+ mw = ActiveRecord::QueryCache.new lambda { |env|
199
+ meths = [:insert, :update, :delete, :insert, :update]
200
+ meths.each do |meth|
201
+ @master.should_receive(meth).and_return(true)
202
+ end
203
+ @default_slave1.should_receive(:select_all).exactly(5).and_return([])
204
+ @default_slave2.should_receive(:select_all).exactly(0)
205
+ 5.times do |i|
206
+ @proxy.select_all(@sql)
207
+ @proxy.select_all(@sql)
208
+ @master.query_cache.keys.size.should == 1
209
+ @proxy.send(meths[i])
210
+ @master.query_cache.keys.size.should == 0
211
+ end
212
+ }
213
+ mw.call({})
214
+ end
215
+ end
216
+
217
+ it 'should NOT rescue a non Mysql2::Error' do
218
+ @default_slave1.should_receive(:select_all).once.and_raise(RuntimeError.new('some error'))
219
+ @default_slave2.should_not_receive(:select_all)
220
+ @master.should_not_receive(:select_all)
221
+ lambda { @proxy.select_all(@sql) }.should raise_error
222
+ end
223
+
224
+ it 'should rescue a Mysql::Error fall back to the master' do
225
+ @default_slave1.should_receive(:select_all).once.and_raise(Mysql2::Error.new('connection error'))
226
+ @default_slave2.should_not_receive(:select_all)
227
+ @master.should_receive(:select_all).and_return(true)
228
+ lambda { @proxy.select_all(@sql) }.should_not raise_error
229
+ end
230
+
231
+ it 'should re-raise a Mysql::Error from a query timeout and not fall back to master' do
232
+ @default_slave1.should_receive(:select_all).once.and_raise(Mysql2::Error.new('Timeout waiting for a response from the last query. (waited 5 seconds)'))
233
+ @default_slave2.should_not_receive(:select_all)
234
+ @master.should_not_receive(:select_all)
235
+ lambda { @proxy.select_all(@sql) }.should raise_error
236
+ end
237
+
238
+ it 'should try to reconnect the master connection after the master has failed' do
239
+ @master.should_receive(:update).and_raise(RuntimeError)
240
+ lambda { @proxy.update(@sql) }.should raise_error
241
+ @master.should_receive(:reconnect!).and_return(true)
242
+ @master.should_receive(:insert).and_return(1)
243
+ @proxy.insert(@sql)
244
+ end
245
+
246
+ it 'should reload models from the master' do
247
+ foo = FooModel.create!(:bar => 'baz')
248
+ foo.bar = "not_saved"
249
+ @default_slave1.should_not_receive(:select_all)
250
+ @default_slave2.should_not_receive(:select_all)
251
+ foo.reload
252
+ # we didn't stub @master#select_all here, check that we actually hit the db
253
+ foo.bar.should == 'baz'
254
+ end
255
+
256
+ context "Using with_pool call" do
257
+
258
+ it "should switch to default pool if an invalid pool is specified" do
259
+ @default_slave1.should_receive(:select_one).exactly(3)
260
+ @secondary_slave1.should_not_receive(:select_one)
261
+ @secondary_slave2.should_not_receive(:select_one)
262
+ @secondary_slave3.should_not_receive(:select_one)
263
+ @proxy.with_pool('sfdsfsdf') do
264
+ 3.times {@proxy.select_one(@sql)}
265
+ end
266
+ end
267
+
268
+ it "should switch to default pool if an no pool is specified" do
269
+ @default_slave1.should_receive(:select_one).exactly(1)
270
+ @proxy.with_pool do
271
+ @proxy.select_one(@sql)
272
+ end
273
+ end
274
+
275
+ it "should use a different pool if specified" do
276
+ @default_slave1.should_not_receive(:select_one)
277
+ @secondary_slave1.should_receive(:select_one).exactly(3)
278
+ @secondary_slave2.should_not_receive(:select_one)
279
+ @secondary_slave3.should_not_receive(:select_one)
280
+ @proxy.with_pool('secondary') do
281
+ 3.times {@proxy.select_one(@sql)}
282
+ end
283
+ end
284
+
285
+ it "should different pool should use next_slave! to advance to the next DB" do
286
+ @default_slave1.should_not_receive(:select_one)
287
+ @secondary_slave1.should_receive(:select_one).exactly(2)
288
+ @secondary_slave2.should_receive(:select_one).exactly(1)
289
+ @secondary_slave3.should_receive(:select_one).exactly(1)
290
+ @proxy.with_pool('secondary') do
291
+ 4.times do
292
+ @proxy.select_one(@sql)
293
+ @proxy.next_slave!
294
+ end
295
+ end
296
+ end
297
+
298
+ it "should switch to master if with_master is specified in an inner block" do
299
+ @master.should_receive(:select_one).exactly(5)
300
+ @default_slave1.should_receive(:select_one).exactly(0)
301
+ @secondary_slave1.should_receive(:select_one).exactly(0)
302
+ @secondary_slave2.should_receive(:select_one).exactly(0)
303
+ @secondary_slave3.should_receive(:select_one).exactly(0)
304
+ @proxy.with_pool('secondary') do
305
+ @proxy.with_master do
306
+ 5.times do
307
+ @proxy.select_one(@sql)
308
+ @proxy.next_slave!
309
+ end
310
+ end
311
+ end
312
+ end
313
+
314
+ it "should switch to master if with_master is specified in an outer block (with master needs to trump with_pool)" do
315
+ @secondary_slave1.should_receive(:select_one).exactly(0)
316
+ @secondary_slave2.should_receive(:select_one).exactly(0)
317
+ @secondary_slave3.should_receive(:select_one).exactly(0)
318
+ @proxy.with_master do
319
+ @proxy.with_pool('secondary') do
320
+ 5.times do
321
+ @proxy.select_one(@sql)
322
+ @proxy.next_slave!
323
+ end
324
+ end
325
+ end
326
+ end
327
+ end
328
+ end
329
+ end
330
+