master_slave_adapter 0.2.0 → 1.0.0.beta1

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