master_slave_adapter 0.2.0 → 1.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
data/.travis.yml CHANGED
@@ -1,4 +1,5 @@
1
1
  language: ruby
2
2
  rvm:
3
+ - 1.8.7
3
4
  - 1.9.2
4
5
  - 1.9.3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,14 @@
1
+ # 1.0.0 (not released yet)
2
+
3
+ * Add support for unavailable master connection
4
+ * Fallback to slave connection if possible
5
+ * Restrict the public interface. Removed the following methods:
6
+ * all class methods from ActiveRecord::ConnectionAdapters::MasterSlaveAdapter
7
+ * #current_connection=
8
+ * #current_clock=
9
+ * #slave_consistent?
10
+ * Fix 1.8.7 compliance
11
+
1
12
  # 0.2.0 (April 2, 2012)
2
13
 
3
14
  * Add support for ActiveRecord's query cache
data/TODO.txt CHANGED
@@ -1,8 +1,18 @@
1
1
  Read only mode
2
2
  --------------
3
3
 
4
- - Add better fallback to connection_for_read
4
+ - Write tests
5
+ - Clock.parse
6
+ - connection_error (integration test)
7
+ - integration tests
8
+ - with_consistency accepts string clock
9
+ - raise MasterUnavailable in all cases
10
+ - connection stack usage
5
11
 
6
- - write tests
7
- - extract adapter specific code
8
- - make everything nice
12
+ - Check
13
+ - thread safety
14
+ - AR reconnect behavior / connection check
15
+ - replication in other databases
16
+
17
+ - restructure MasterSlaveAdapter with proper namespaces
18
+ - make clock private
@@ -0,0 +1,61 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module MasterSlaveAdapter
4
+ class CircuitBreaker
5
+ def initialize(logger = nil, failure_threshold = 5, timeout = 30)
6
+ @logger = logger
7
+ @failure_count = 0
8
+ @failure_threshold = failure_threshold
9
+ @timeout = timeout
10
+ @state = :closed
11
+ end
12
+
13
+ def tripped?
14
+ if open? && timeout_exceeded?
15
+ change_state_to :half_open
16
+ end
17
+
18
+ open?
19
+ end
20
+
21
+ def success!
22
+ if !closed?
23
+ @failure_count = 0
24
+ change_state_to :closed
25
+ end
26
+ end
27
+
28
+ def fail!
29
+ @failure_count += 1
30
+ if !open? && @failure_count >= @failure_threshold
31
+ @opened_at = Time.now
32
+ change_state_to :open
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def open?
39
+ :open == @state
40
+ end
41
+
42
+ def half_open?
43
+ :half_open == @state
44
+ end
45
+
46
+ def closed?
47
+ :closed == @state
48
+ end
49
+
50
+ def timeout_exceeded?
51
+ (Time.now - @opened_at) >= @timeout
52
+ end
53
+
54
+ def change_state_to(state)
55
+ @state = state
56
+ @logger && @logger.warn("circuit is now #{state}")
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,42 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module MasterSlaveAdapter
4
+ class Clock
5
+ include Comparable
6
+ attr_reader :file, :position
7
+
8
+ def initialize(file, position)
9
+ raise ArgumentError, "file and postion may not be nil" if file.nil? || position.nil?
10
+ @file, @position = file, position.to_i
11
+ end
12
+
13
+ def <=>(other)
14
+ @file == other.file ? @position <=> other.position : @file <=> other.file
15
+ end
16
+
17
+ def to_s
18
+ [ @file, @position ].join('@')
19
+ end
20
+
21
+ def infinity?
22
+ self == self.class.infinity
23
+ end
24
+
25
+ def self.zero
26
+ @zero ||= Clock.new('', 0)
27
+ end
28
+
29
+ def self.infinity
30
+ @infinity ||= Clock.new('', Float::MAX.to_i)
31
+ end
32
+
33
+ # TODO: tests
34
+ def self.parse(string)
35
+ new(*string.split('@'))
36
+ rescue
37
+ nil
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,7 @@
1
+ module ActiveRecord
2
+ module ConnectionAdapters
3
+ module MasterSlaveAdapter
4
+ VERSION = "1.0.0.beta1"
5
+ end
6
+ end
7
+ end
@@ -1 +1,501 @@
1
- require 'master_slave_adapter'
1
+ require 'active_record'
2
+ require 'active_record/connection_adapters/master_slave_adapter/circuit_breaker'
3
+
4
+ module ActiveRecord
5
+ class MasterUnavailable < ConnectionNotEstablished; end
6
+
7
+ class Base
8
+ class << self
9
+ def with_consistency(clock, &blk)
10
+ if connection.respond_to? :with_consistency
11
+ connection.with_consistency(clock, &blk)
12
+ else
13
+ yield
14
+ nil
15
+ end
16
+ end
17
+
18
+ def with_master(&blk)
19
+ if connection.respond_to? :with_master
20
+ connection.with_master(&blk)
21
+ else
22
+ yield
23
+ end
24
+ end
25
+
26
+ def with_slave(&blk)
27
+ if connection.respond_to? :with_slave
28
+ connection.with_slave(&blk)
29
+ else
30
+ yield
31
+ end
32
+ end
33
+
34
+ def on_commit(&blk)
35
+ connection.on_commit(&blk) if connection.respond_to? :on_commit
36
+ end
37
+
38
+ def on_rollback(&blk)
39
+ connection.on_rollback(&blk) if connection.respond_to? :on_rollback
40
+ end
41
+
42
+ def master_slave_connection(config)
43
+ config = massage(config)
44
+ name = config.fetch(:connection_adapter)
45
+ adapter = "#{name.classify}MasterSlaveAdapter"
46
+
47
+ load_adapter("#{name}_master_slave")
48
+ ConnectionAdapters.const_get(adapter).new(config, logger)
49
+ end
50
+
51
+ private
52
+
53
+ def massage(config)
54
+ config = config.symbolize_keys
55
+ skip = [ :adapter, :connection_adapter, :master, :slaves ]
56
+ defaults = config.
57
+ reject { |k,_| skip.include?(k) }.
58
+ merge(:adapter => config.fetch(:connection_adapter))
59
+ ([config.fetch(:master)] + config.fetch(:slaves, [])).map do |cfg|
60
+ cfg.symbolize_keys!.reverse_merge!(defaults)
61
+ end
62
+ config
63
+ end
64
+
65
+ def load_adapter(adapter_name)
66
+ unless respond_to?("#{adapter_name}_connection")
67
+ begin
68
+ require "active_record/connection_adapters/#{adapter_name}_adapter"
69
+ rescue LoadError
70
+ begin
71
+ require 'rubygems'
72
+ gem "activerecord-#{adapter_name}-adapter"
73
+ require "active_record/connection_adapters/#{adapter_name}_adapter"
74
+ rescue LoadError
75
+ raise %Q{Please install the #{adapter_name} adapter:
76
+ `gem install activerecord-#{adapter_name}-adapter` (#{$!})"}
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ module ConnectionAdapters
85
+ class AbstractAdapter
86
+ alias_method :orig_log_info, :log_info
87
+ def log_info(sql, name, ms)
88
+ connection_name =
89
+ [ @config[:name], @config[:host], @config[:port] ].compact.join(":")
90
+ orig_log_info sql, "[#{connection_name}] #{name || 'SQL'}", ms
91
+ end
92
+ end
93
+
94
+ module MasterSlaveAdapter
95
+ class Base < AbstractAdapter
96
+ def initialize(config, logger)
97
+ super(nil, logger)
98
+
99
+ @config = config
100
+
101
+ @connections = {}
102
+ @connections[:master] = connect_to_master
103
+ @connections[:slaves] = @config.fetch(:slaves).map { |cfg| connect(cfg, :slave) }
104
+
105
+ @disable_connection_test = @config[:disable_connection_test] == 'true'
106
+ @circuit = CircuitBreaker.new(logger)
107
+
108
+ self.current_connection = slave_connection!
109
+ end
110
+
111
+ # MASTER SLAVE ADAPTER INTERFACE ========================================
112
+
113
+ def with_master
114
+ with(master_connection) { yield }
115
+ end
116
+
117
+ def with_slave
118
+ with(slave_connection!) { yield }
119
+ end
120
+
121
+ def with_consistency(clock)
122
+ if clock.nil?
123
+ raise ArgumentError, "consistency must be a valid comparable value"
124
+ end
125
+
126
+ # try random slave, else fall back to master
127
+ slave = slave_connection!
128
+ conn =
129
+ if !open_transaction? && slave_consistent?(slave, clock)
130
+ slave
131
+ else
132
+ master_connection
133
+ end
134
+
135
+ with(conn) { yield }
136
+
137
+ current_clock || clock
138
+ end
139
+
140
+ def on_commit(&blk)
141
+ on_commit_callbacks.push blk
142
+ end
143
+
144
+ def on_rollback(&blk)
145
+ on_rollback_callbacks.push blk
146
+ end
147
+
148
+ # ADAPTER INTERFACE OVERRIDES ===========================================
149
+
150
+ def insert(*args)
151
+ on_write { |conn| conn.insert(*args) }
152
+ end
153
+
154
+ def update(*args)
155
+ on_write { |conn| conn.update(*args) }
156
+ end
157
+
158
+ def delete(*args)
159
+ on_write { |conn| conn.delete(*args) }
160
+ end
161
+
162
+ def commit_db_transaction
163
+ on_write { |conn| conn.commit_db_transaction }
164
+ on_commit_callbacks.shift.call(current_clock) until on_commit_callbacks.blank?
165
+ end
166
+
167
+ def rollback_db_transaction
168
+ on_commit_callbacks.clear
169
+ with(master_connection) { |conn| conn.rollback_db_transaction }
170
+ on_rollback_callbacks.shift.call until on_rollback_callbacks.blank?
171
+ end
172
+
173
+ def active?
174
+ return true if @disable_connection_test
175
+ connections.map { |c| c.active? }.all?
176
+ end
177
+
178
+ def reconnect!
179
+ connections.each { |c| c.reconnect! }
180
+ end
181
+
182
+ def disconnect!
183
+ connections.each { |c| c.disconnect! }
184
+ end
185
+
186
+ def reset!
187
+ connections.each { |c| c.reset! }
188
+ end
189
+
190
+ def cache(&blk)
191
+ connections.inject(blk) do |block, connection|
192
+ lambda { connection.cache(&block) }
193
+ end.call
194
+ end
195
+
196
+ def uncached(&blk)
197
+ connections.inject(blk) do |block, connection|
198
+ lambda { connection.uncached(&block) }
199
+ end.call
200
+ end
201
+
202
+ def clear_query_cache
203
+ connections.each { |connection| connection.clear_query_cache }
204
+ end
205
+
206
+ # Someone calling execute directly on the connection is likely to be a
207
+ # write, respectively some DDL statement. People really shouldn't do that,
208
+ # but let's delegate this to master, just to be sure.
209
+ def execute(*args)
210
+ on_write { |conn| conn.execute(*args) }
211
+ end
212
+
213
+ # ADAPTER INTERFACE DELEGATES ===========================================
214
+
215
+ def self.rescued_delegate(*methods)
216
+ options = methods.pop
217
+ to = options[:to]
218
+
219
+ file, line = caller.first.split(':', 2)
220
+ line = line.to_i
221
+
222
+ methods.each do |method|
223
+ module_eval(<<-EOS, file, line)
224
+ def #{method}(*args, &block)
225
+ begin
226
+ #{to}.__send__(:#{method}, *args, &block)
227
+ rescue => exception
228
+ if master_connection?(#{to}) && connection_error?(exception)
229
+ reset_master_connection
230
+ raise MasterUnavailable
231
+ else
232
+ raise
233
+ end
234
+ end
235
+ end
236
+ EOS
237
+ end
238
+ end
239
+ class << self; private :rescued_delegate; end
240
+
241
+ # === must go to master
242
+ rescued_delegate :adapter_name,
243
+ :supports_migrations?,
244
+ :supports_primary_key?,
245
+ :supports_savepoints?,
246
+ :native_database_types,
247
+ :raw_connection,
248
+ :open_transactions,
249
+ :increment_open_transactions,
250
+ :decrement_open_transactions,
251
+ :transaction_joinable=,
252
+ :create_savepoint,
253
+ :rollback_to_savepoint,
254
+ :release_savepoint,
255
+ :current_savepoint_name,
256
+ :begin_db_transaction,
257
+ :outside_transaction?,
258
+ :add_limit!,
259
+ :default_sequence_name,
260
+ :reset_sequence!,
261
+ :insert_fixture,
262
+ :empty_insert_statement,
263
+ :case_sensitive_equality_operator,
264
+ :limited_update_conditions,
265
+ :insert_sql,
266
+ :update_sql,
267
+ :delete_sql,
268
+ :to => :master_connection
269
+ # schema statements
270
+ rescued_delegate :table_exists?,
271
+ :create_table,
272
+ :change_table,
273
+ :rename_table,
274
+ :drop_table,
275
+ :add_column,
276
+ :remove_column,
277
+ :remove_columns,
278
+ :change_column,
279
+ :change_column_default,
280
+ :rename_column,
281
+ :add_index,
282
+ :remove_index,
283
+ :remove_index!,
284
+ :rename_index,
285
+ :index_name,
286
+ :index_exists?,
287
+ :structure_dump,
288
+ :dump_schema_information,
289
+ :initialize_schema_migrations_table,
290
+ :assume_migrated_upto_version,
291
+ :type_to_sql,
292
+ :add_column_options!,
293
+ :distinct,
294
+ :add_order_by_for_association_limiting!,
295
+ :add_timestamps,
296
+ :remove_timestamps,
297
+ :to => :master_connection
298
+ # ActiveRecord 3.0
299
+ rescued_delegate :visitor,
300
+ :to => :master_connection
301
+ # no clear interface contract:
302
+ rescued_delegate :tables, # commented in SchemaStatements
303
+ :truncate_table, # monkeypatching database_cleaner gem
304
+ :primary_key, # is Base#primary_key meant to be the contract?
305
+ :to => :master_connection
306
+ # No need to be so picky about these methods
307
+ rescued_delegate :add_limit_offset!, # DatabaseStatements
308
+ :add_lock!, #DatabaseStatements
309
+ :columns,
310
+ :table_alias_for,
311
+ :to => :prefer_master_connection
312
+
313
+ # ok, we might have missed more
314
+ def method_missing(name, *args, &blk)
315
+ master_connection.send(name.to_sym, *args, &blk).tap do
316
+ @logger.try(:warn, %Q{
317
+ You called the unsupported method '#{name}' on #{self.class.name}.
318
+ In order to help us improve master_slave_adapter, please report this
319
+ to: https://github.com/soundcloud/master_slave_adapter/issues
320
+
321
+ Thank you.
322
+ })
323
+ end
324
+ rescue => exception
325
+ if connection_error?(exception)
326
+ reset_master_connection
327
+ raise MasterUnavailable
328
+ else
329
+ raise
330
+ end
331
+ end
332
+
333
+ # === determine read connection
334
+ rescued_delegate :select_all,
335
+ :select_one,
336
+ :select_rows,
337
+ :select_value,
338
+ :select_values,
339
+ :to => :connection_for_read
340
+
341
+ def connection_for_read
342
+ open_transaction? ? master_connection : current_connection
343
+ end
344
+ private :connection_for_read
345
+
346
+ # === doesn't really matter, but must be handled by underlying adapter
347
+ rescued_delegate *(ActiveRecord::ConnectionAdapters::Quoting.instance_methods + [{
348
+ :to => :current_connection }])
349
+ # issue #4: current_database is not supported by all adapters, though
350
+ rescued_delegate :current_database, :to => :current_connection
351
+
352
+ # UTIL ==================================================================
353
+
354
+ def master_connection
355
+ if circuit.tripped?
356
+ raise MasterUnavailable
357
+ end
358
+
359
+ @connections[:master] ||= connect_to_master
360
+ if @connections[:master]
361
+ circuit.success!
362
+ @connections[:master]
363
+ else
364
+ circuit.fail!
365
+ raise MasterUnavailable
366
+ end
367
+ end
368
+
369
+ def master_available?
370
+ !@connections[:master].nil?
371
+ end
372
+
373
+ # Returns a random slave connection
374
+ # Note: the method is not referentially transparent, hence the bang
375
+ def slave_connection!
376
+ @connections[:slaves].sample
377
+ end
378
+
379
+ def connections
380
+ @connections.values.flatten.compact
381
+ end
382
+
383
+ def current_connection
384
+ connection_stack.first
385
+ end
386
+
387
+ def current_clock
388
+ @master_slave_clock
389
+ end
390
+
391
+ def master_clock
392
+ raise NotImplementedError
393
+ end
394
+
395
+ def slave_clock(conn)
396
+ raise NotImplementedError
397
+ end
398
+
399
+ protected
400
+
401
+ def prefer_master_connection
402
+ master_available? ? master_connection : slave_connection!
403
+ end
404
+
405
+ def master_connection?(connection)
406
+ @connections[:master] == connection
407
+ end
408
+
409
+ def reset_master_connection
410
+ @connections[:master] = nil
411
+ end
412
+
413
+ def current_clock=(clock)
414
+ @master_slave_clock = clock
415
+ end
416
+
417
+ def slave_consistent?(conn, clock)
418
+ if (last_seen_clock = get_last_seen_slave_clock(conn))
419
+ last_seen_clock >= clock
420
+ elsif (slave_clk = slave_clock(conn))
421
+ set_last_seen_slave_clock(conn, slave_clk)
422
+ slave_clk >= clock
423
+ else
424
+ false
425
+ end
426
+ end
427
+
428
+ def on_write
429
+ with(master_connection) do |conn|
430
+ yield(conn).tap do
431
+ unless open_transaction?
432
+ if mc = master_clock
433
+ self.current_clock = mc unless current_clock.try(:>=, mc)
434
+ end
435
+ # keep using master after write
436
+ connection_stack.replace([ conn ])
437
+ end
438
+ end
439
+ end
440
+ end
441
+
442
+ def with(conn)
443
+ self.current_connection = conn
444
+ yield(conn).tap { connection_stack.shift if connection_stack.size > 1 }
445
+ end
446
+
447
+ private
448
+
449
+ def connect(cfg, name)
450
+ adapter_method = "#{cfg.fetch(:adapter)}_connection".to_sym
451
+ ActiveRecord::Base.send(adapter_method, { :name => name }.merge(cfg))
452
+ end
453
+
454
+ def open_transaction?
455
+ master_available? ? (master_connection.open_transactions > 0) : false
456
+ end
457
+
458
+ def connection_stack
459
+ @master_slave_connection ||= []
460
+ end
461
+
462
+ def current_connection=(conn)
463
+ connection_stack.unshift(conn)
464
+ end
465
+
466
+ def on_commit_callbacks
467
+ Thread.current[:on_commit_callbacks] ||= []
468
+ end
469
+
470
+ def on_rollback_callbacks
471
+ Thread.current[:on_rollback_callbacks] ||= []
472
+ end
473
+
474
+ def get_last_seen_slave_clock(conn)
475
+ conn.instance_variable_get(:@last_seen_slave_clock)
476
+ end
477
+
478
+ def set_last_seen_slave_clock(conn, clock)
479
+ last_seen = get_last_seen_slave_clock(conn)
480
+ if last_seen.nil? || last_seen < clock
481
+ conn.instance_variable_set(:@last_seen_slave_clock, clock)
482
+ end
483
+ end
484
+
485
+ def connect_to_master
486
+ connect(@config.fetch(:master), :master)
487
+ rescue => exception
488
+ connection_error?(exception) ? nil : raise
489
+ end
490
+
491
+ def connection_error?(exception)
492
+ raise NotImplementedError
493
+ end
494
+
495
+ def circuit
496
+ @circuit
497
+ end
498
+ end
499
+ end
500
+ end
501
+ end