├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Rakefile ├── Readme.md ├── lib ├── active_record │ └── connection_adapters │ │ ├── master_slave_adapter.rb │ │ ├── master_slave_adapter │ │ ├── circuit_breaker.rb │ │ ├── clock.rb │ │ ├── shared_mysql_adapter_behavior.rb │ │ └── version.rb │ │ ├── mysql2_master_slave_adapter.rb │ │ └── mysql_master_slave_adapter.rb └── master_slave_adapter.rb ├── master_slave_adapter.gemspec └── spec ├── all.sh ├── common ├── circuit_breaker_spec.rb ├── master_slave_adapter_spec.rb ├── mysql2_master_slave_adapter_spec.rb ├── mysql_master_slave_adapter_spec.rb └── support │ ├── connection_setup_helper.rb │ └── mysql_consistency_examples.rb ├── gemfiles ├── activerecord2.3 ├── activerecord3.0 └── activerecord3.2 └── integration ├── mysql2_master_slave_adapter_spec.rb ├── mysql_master_slave_adapter_spec.rb └── support ├── mysql_setup_helper.rb └── shared_mysql_examples.rb /.gitignore: -------------------------------------------------------------------------------- 1 | tags 2 | test/* 3 | pkg/* 4 | .rvmrc 5 | spec/gemfiles/*.lock 6 | spec/integration/mysql 7 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.8.7 4 | - 1.9.2 5 | - 1.9.3 6 | gemfile: 7 | - spec/gemfiles/activerecord2.3 8 | - spec/gemfiles/activerecord3.0 9 | - spec/gemfiles/activerecord3.2 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.1.2 (November 26, 2012) 2 | 3 | * Avoid trying to connect to master twice if unavailable 4 | 5 | # 1.1.1 (November 17, 2012) 6 | 7 | * [BUGFIX] Fix activerecord 3.2 compatibility 8 | * Fix setup of mysql integration servers 9 | 10 | # 1.1.0 (November 15, 2012) 11 | 12 | * [BUGFIX] Don't raise MasterUnavailable if a slave is unavailable 13 | 14 | # 1.0.0 (July 24, 2012) 15 | 16 | * Add support for unavailable master connection 17 | * Restrict the public interface. Removed the following methods: 18 | * all class methods from ActiveRecord::ConnectionAdapters::MasterSlaveAdapter 19 | * #current_connection= 20 | * #current_clock= 21 | * #slave_consistent? 22 | * ActiveRecord::Base.on_commit and ActiveRecord::Base.on_rollback 23 | * Fix 1.8.7 compliance 24 | * Fix bug which led to infinitely connection stack growth 25 | * Add ActiveRecord 3.x compatibility 26 | * Add support for Mysql2 27 | 28 | # 0.2.0 (April 2, 2012) 29 | 30 | * Add support for ActiveRecord's query cache 31 | 32 | # 0.1.10 (March 06, 2012) 33 | 34 | * Delegate #visitor to master connection 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Maurício Linhares, 2 | Torsten Curdt, 3 | Kim Altintop, 4 | Omid Aladini, 5 | Tiago Loureiro, 6 | Tobias Schmidt, 7 | SoundCloud Ltd 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'rspec/core/rake_task' 3 | 4 | Bundler::GemHelper.install_tasks 5 | 6 | class MasterSlaveAdapterRSpecTask < RSpec::Core::RakeTask 7 | attr_accessor :exclude 8 | 9 | private 10 | 11 | def files_to_run 12 | FileList[ pattern ].exclude(exclude) 13 | end 14 | end 15 | 16 | def mysql2_adapter_available? 17 | require 'active_record/connection_adapters/mysql2_adapter' 18 | true 19 | rescue LoadError 20 | false 21 | rescue 22 | true 23 | end 24 | 25 | desc 'Default: Run specs' 26 | task :default => :spec 27 | 28 | desc 'Run specs' 29 | task :spec => ['spec:common', 'spec:integration'] 30 | 31 | namespace :spec do 32 | desc 'Run common specs' 33 | MasterSlaveAdapterRSpecTask.new(:common) do |task| 34 | task.pattern = './spec/common/*_spec.rb' 35 | task.exclude = /mysql2/ unless mysql2_adapter_available? 36 | task.verbose = false 37 | end 38 | 39 | desc 'Run integration specs' 40 | task :integration => ['spec:integration:check', 'spec:integration:all'] 41 | 42 | namespace :integration do 43 | desc 'Check requirements' 44 | task :check do 45 | [:mysql, :mysqld, :mysql_install_db].each do |executable| 46 | unless system("which #{executable} > /dev/null") 47 | raise "Can't run integration tests. #{executable} is not available in $PATH" 48 | end 49 | end 50 | end 51 | 52 | desc 'Run all integration specs' 53 | MasterSlaveAdapterRSpecTask.new(:all) do |task| 54 | task.pattern = './spec/integration/*_spec.rb' 55 | task.exclude = /mysql2/ unless mysql2_adapter_available? 56 | task.verbose = false 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Replication Aware Master Slave Adapter [![Build Status](https://secure.travis-ci.org/soundcloud/master_slave_adapter.png)][6] 2 | 3 | Improved version of the [master_slave_adapter plugin][1], packaged as a gem. 4 | 5 | ## Features 6 | 7 | 1. automatic selection of master or slave connection: `with_consistency` 8 | 2. manual selection of master or slave connection: `with_master`, `with_slave` 9 | 3. handles master unavailable scenarios gracefully 10 | 4. transaction callbacks: `on_commit`, `on_rollback` 11 | 5. also: 12 | * support for multiple slaves 13 | * (partial) support for [database_cleaner][2] 14 | 15 | ### Automatic Selection of Master or Slave 16 | 17 | The adapter will run all reads against a slave database, unless a) the read is inside an open transaction or b) the 18 | adapter determines that the slave lags behind the master _relative to the last write_. For this to work, an initial 19 | initial consistency requirement, a Clock, must be passed to the adapter. Based on this clock value, the adapter 20 | determines if a (randomly chosen) slave meets this requirement. If not, all statements are executed against master, 21 | otherwise, the slave connection is used until either a transaction is opened or a write occurs. After a successful write 22 | or transaction, the adapter determines a new consistency requirement, which is returned and can be used for subsequent 23 | operations. Note that after a write or transaction, the adapter keeps using the master connection. 24 | 25 | As an example, a Rails application could run the following function as an `around_filter`: 26 | 27 | ```ruby 28 | def with_consistency_filter 29 | if logged_in? 30 | clock = cached_clock_for(current_user) 31 | 32 | new_clock = ActiveRecord::Base.with_consistency(clock) do 33 | # inside the controller, ActiveRecord models can be used just as normal. 34 | # The adapter will take care of choosing the right connection. 35 | yield 36 | end 37 | 38 | [ new_clock, clock ].compact.max.tap do |c| 39 | cache_clock_for(current_user, c) 40 | end if new_clock != clock 41 | else 42 | # anonymous users will have to wait until the slaves have caught up 43 | with_slave { yield } 44 | end 45 | end 46 | ``` 47 | 48 | Note that we use the last seen consistency for a given user as reference point. This will give the user a recent view of the data, 49 | possibly reading from master, and if no write occurs inside the `with_consistency` block, we have a reasonable value to 50 | cache and reuse on subsequent requests. 51 | If no cached clock is available, this indicates that no particular consistency is required. Any slave connection will do. 52 | Since `with_consistency` blocks can be nested, the controller code could later decide to require a more recent view on 53 | the data. 54 | 55 | _See also this [blog post][3] for a more detailed explanation._ 56 | 57 | ### Manual Selection of Master or Slave 58 | 59 | The original functionality of the adapter has been preserved: 60 | 61 | ```ruby 62 | ActiveRecord::Base.with_master do 63 | # everything inside here will go to master 64 | end 65 | 66 | ActiveRecord::Base.with_slave do 67 | # everything inside here will go to one of the slaves 68 | # opening a transaction or writing will switch to master 69 | # for the rest of the block 70 | end 71 | ``` 72 | 73 | `with_master`, `with_slave` as well as `with_consistency` can be nested deliberately. 74 | 75 | ### Handles master unavailable scenarios gracefully 76 | 77 | Due to scenarios when the master is possibly down (e.g., maintenance), we try 78 | to delegate as much as possible to the active slaves. In order to accomplish 79 | this we have added the following functionalities. 80 | 81 | * We ignore errors while connecting to the master server. 82 | * ActiveRecord::MasterUnavailable exceptions are raised in cases when we need to use 83 | a master connection, but the server is unavailable. This exception is propagated 84 | to the application. 85 | * We have introduced the circuit breaker pattern in the master reconnect logic 86 | to prevent excessive reconnection attempts. We block any queries which require 87 | a master connection for a given timeout (by default, 30 seconds). After the 88 | timeout has expired, any attempt of using the master connection will trigger 89 | a reconnection. 90 | * The master slave adapter is still usable for any queries that require only 91 | slave connections. 92 | 93 | ### Transaction Callbacks 94 | 95 | This feature was originally developed at [SoundCloud][4] for the standard `MysqlAdapter`. It allows arbitrary blocks of 96 | code to be deferred for execution until the next transaction completes (or rolls back). 97 | 98 | ```irb 99 | irb> ActiveRecord::Base.on_commit { puts "COMMITTED!" } 100 | irb> ActiveRecord::Base.on_rollback { puts "ROLLED BACK!" } 101 | irb> ActiveRecord::Base.connection.transaction do 102 | irb* # ... 103 | irb> end 104 | COMMITTED! 105 | => nil 106 | irb> ActiveRecord::Base.connection.transaction do 107 | irb* # ... 108 | irb* raise "failed operation" 109 | irb> end 110 | ROLLED BACK! 111 | # stack trace omitted 112 | => nil 113 | ``` 114 | 115 | Note that a transaction callback will be fired only *once*, so you might want to do: 116 | 117 | ```ruby 118 | class MyModel 119 | after_save do 120 | connection.on_commit do 121 | # ... 122 | end 123 | end 124 | end 125 | ``` 126 | 127 | ### Support for Multiple Slaves 128 | 129 | The adapter keeps a list of slave connections (see *Configuration*) and chooses randomly between them. The selection is 130 | made at the beginning of a `with_slave` or `with_consistency` block and doesn't change until the block returns. Hence, a 131 | nested `with_slave` or `with_consistency` might run against a different slave. 132 | 133 | ### Database Cleaner 134 | 135 | At [SoundCloud][4], we're using [database_cleaner][2]'s 'truncation strategy' to wipe the database between [cucumber][5] 136 | 'feature's. As our cucumber suite proved valuable while testing the `with_consistency` feature, we had to support 137 | `truncate_table` as an `ActiveRecord::Base.connection` instance method. We might add other strategies if there's enough 138 | interest. 139 | 140 | ## Requirements 141 | 142 | MasterSlaveAdapter requires ActiveRecord with a version >= 2.3, is compatible 143 | with at least Ruby 1.8.7, 1.9.2, 1.9.3 and comes with built-in support for mysql 144 | and mysql2 libraries. 145 | 146 | You can check the versions it's tested against at [Travis CI](http://travis-ci.org/#!/soundcloud/master_slave_adapter). 147 | 148 | ## Installation 149 | 150 | Using plain rubygems: 151 | 152 | $ gem install master_slave_adapter 153 | 154 | Using bundler, just include it in your Gemfile: 155 | 156 | gem 'master_slave_adapter' 157 | 158 | ## Configuration 159 | 160 | Example configuration for the development environment in `database.yml`: 161 | 162 | ```yaml 163 | development: 164 | adapter: master_slave # use master_slave adapter 165 | connection_adapter: mysql # actual adapter to use (only mysql is supported atm) 166 | disable_connection_test: false # when an instance is checked out from the connection pool, 167 | # we check if the connections are still alive, reconnecting if necessary 168 | 169 | # these values are picked up as defaults in the 'master' and 'slaves' sections: 170 | database: aweapp_development 171 | username: aweappuser 172 | password: s3cr3t 173 | 174 | master: 175 | host: masterhost 176 | username: readwrite_user # override default value 177 | 178 | slaves: 179 | - host: slave01 180 | - host: slave02 181 | ``` 182 | 183 | ## Testing 184 | 185 | You can execute all tests against your current ruby version via: 186 | 187 | rake spec 188 | 189 | In case you have `rvm` installed, you can test against 1.8.7, 1.9.2 and 1.9.3 as well as ActiveRecord 2 and 3 via: 190 | 191 | bash spec/all.sh 192 | 193 | ## Credits 194 | 195 | * Maurício Lenhares - _original master_slave_adapter plugin_ 196 | * Torsten Curdt - _with_consistency, maintainership & open source licenses_ 197 | * Sean Treadway - _chief everything & transaction callbacks_ 198 | * Kim Altintop - _strong lax monoidal endofunctors_ 199 | * Omid Aladini - _chief operator & everything else_ 200 | * Tiago Loureiro - _review expert & master unavailable handling_ 201 | * Tobias Schmidt - _typo master & activerecord ranter_ 202 | 203 | 204 | [1]: https://github.com/mauricio/master_slave_adapter 205 | [2]: https://github.com/bmabey/database_cleaner 206 | [3]: http://www.yourdailygeekery.com/2011/06/14/master-slave-consistency.html 207 | [4]: http://backstage.soundcloud.com 208 | [5]: http://cukes.info 209 | [6]: http://travis-ci.org/soundcloud/master_slave_adapter -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/master_slave_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'active_record/connection_adapters/abstract_adapter' 3 | require 'active_record/connection_adapters/master_slave_adapter/circuit_breaker' 4 | 5 | module ActiveRecord 6 | class MasterUnavailable < ConnectionNotEstablished; end 7 | 8 | class Base 9 | class << self 10 | def with_consistency(clock, &blk) 11 | if connection.respond_to? :with_consistency 12 | connection.with_consistency(clock, &blk) 13 | else 14 | yield 15 | nil 16 | end 17 | end 18 | 19 | def with_master(&blk) 20 | if connection.respond_to? :with_master 21 | connection.with_master(&blk) 22 | else 23 | yield 24 | end 25 | end 26 | 27 | def with_slave(&blk) 28 | if connection.respond_to? :with_slave 29 | connection.with_slave(&blk) 30 | else 31 | yield 32 | end 33 | end 34 | 35 | def master_slave_connection(config) 36 | config = massage(config) 37 | adapter = config.fetch(:connection_adapter) 38 | name = "#{adapter}_master_slave" 39 | 40 | load_adapter(name) 41 | send(:"#{name}_connection", config) 42 | end 43 | 44 | private 45 | 46 | def massage(config) 47 | config = config.symbolize_keys 48 | skip = [ :adapter, :connection_adapter, :master, :slaves ] 49 | defaults = config. 50 | reject { |k,_| skip.include?(k) }. 51 | merge(:adapter => config.fetch(:connection_adapter)) 52 | ([config.fetch(:master)] + config.fetch(:slaves, [])).map do |cfg| 53 | cfg.symbolize_keys!.reverse_merge!(defaults) 54 | end 55 | config 56 | end 57 | 58 | def load_adapter(adapter_name) 59 | unless respond_to?("#{adapter_name}_connection") 60 | begin 61 | require "active_record/connection_adapters/#{adapter_name}_adapter" 62 | rescue LoadError 63 | begin 64 | require 'rubygems' 65 | gem "activerecord-#{adapter_name}-adapter" 66 | require "active_record/connection_adapters/#{adapter_name}_adapter" 67 | rescue LoadError 68 | raise %Q{Please install the #{adapter_name} adapter: 69 | `gem install activerecord-#{adapter_name}-adapter` (#{$!})} 70 | end 71 | end 72 | end 73 | end 74 | end 75 | end 76 | 77 | module ConnectionAdapters 78 | class AbstractAdapter 79 | if instance_methods.map(&:to_sym).include?(:log_info) 80 | # ActiveRecord v2.x 81 | alias_method :orig_log_info, :log_info 82 | def log_info(sql, name, ms) 83 | orig_log_info(sql, "[#{connection_info}] #{name || 'SQL'}", ms) 84 | end 85 | else 86 | # ActiveRecord v3.x 87 | alias_method :orig_log, :log 88 | def log(sql, name = 'SQL', *args, &block) 89 | orig_log(sql, "[#{connection_info}] #{name || 'SQL'}", *args, &block) 90 | end 91 | end 92 | 93 | private 94 | def connection_info 95 | @connection_info ||= @config.values_at(:name, :host, :port).compact.join(':') 96 | end 97 | end 98 | 99 | module MasterSlaveAdapter 100 | def initialize(config, logger) 101 | super(nil, logger) 102 | 103 | @config = config 104 | @connections = {} 105 | @connections[:master] = connect_to_master 106 | @connections[:slaves] = @config.fetch(:slaves).map { |cfg| connect(cfg, :slave) } 107 | @last_seen_slave_clocks = {} 108 | @disable_connection_test = @config[:disable_connection_test] == 'true' 109 | @circuit = CircuitBreaker.new(logger) 110 | 111 | self.current_connection = slave_connection! 112 | end 113 | 114 | # MASTER SLAVE ADAPTER INTERFACE ======================================== 115 | 116 | def with_master 117 | with(master_connection) { yield } 118 | end 119 | 120 | def with_slave 121 | with(slave_connection!) { yield } 122 | end 123 | 124 | def with_consistency(clock) 125 | if clock.nil? 126 | raise ArgumentError, "consistency must be a valid comparable value" 127 | end 128 | 129 | # try random slave, else fall back to master 130 | slave = slave_connection! 131 | conn = 132 | if !open_transaction? && slave_consistent?(slave, clock) 133 | slave 134 | else 135 | master_connection 136 | end 137 | 138 | with(conn) { yield } 139 | 140 | current_clock || clock 141 | end 142 | 143 | def on_commit(&blk) 144 | on_commit_callbacks.push blk 145 | end 146 | 147 | def on_rollback(&blk) 148 | on_rollback_callbacks.push blk 149 | end 150 | 151 | # ADAPTER INTERFACE OVERRIDES =========================================== 152 | 153 | def insert(*args) 154 | on_write { |conn| conn.insert(*args) } 155 | end 156 | 157 | def update(*args) 158 | on_write { |conn| conn.update(*args) } 159 | end 160 | 161 | def delete(*args) 162 | on_write { |conn| conn.delete(*args) } 163 | end 164 | 165 | def execute(*args) 166 | on_write { |conn| conn.execute(*args) } 167 | end 168 | 169 | def commit_db_transaction 170 | on_write { |conn| conn.commit_db_transaction } 171 | on_commit_callbacks.shift.call(current_clock) until on_commit_callbacks.blank? 172 | end 173 | 174 | def rollback_db_transaction 175 | on_commit_callbacks.clear 176 | with(master_connection) { |conn| conn.rollback_db_transaction } 177 | on_rollback_callbacks.shift.call until on_rollback_callbacks.blank? 178 | end 179 | 180 | def active? 181 | return true if @disable_connection_test 182 | connections.map { |c| c.active? }.all? 183 | end 184 | 185 | def reconnect! 186 | connections.each { |c| c.reconnect! } 187 | end 188 | 189 | def disconnect! 190 | connections.each { |c| c.disconnect! } 191 | end 192 | 193 | def reset! 194 | connections.each { |c| c.reset! } 195 | end 196 | 197 | def cache(&blk) 198 | connections.inject(blk) do |block, connection| 199 | lambda { connection.cache(&block) } 200 | end.call 201 | end 202 | 203 | def uncached(&blk) 204 | connections.inject(blk) do |block, connection| 205 | lambda { connection.uncached(&block) } 206 | end.call 207 | end 208 | 209 | def clear_query_cache 210 | connections.each { |connection| connection.clear_query_cache } 211 | end 212 | 213 | def outside_transaction? 214 | nil 215 | end 216 | 217 | # ADAPTER INTERFACE DELEGATES =========================================== 218 | 219 | def self.rescued_delegate(*methods) 220 | options = methods.pop 221 | to = options[:to] 222 | 223 | file, line = caller.first.split(':', 2) 224 | line = line.to_i 225 | 226 | methods.each do |method| 227 | module_eval(<<-EOS, file, line) 228 | def #{method}(*args, &block) 229 | begin 230 | #{to}.__send__(:#{method}, *args, &block) 231 | rescue ActiveRecord::StatementInvalid => error 232 | handle_error(#{to}, error) 233 | end 234 | end 235 | EOS 236 | end 237 | end 238 | class << self; private :rescued_delegate; end 239 | 240 | # === must go to master 241 | rescued_delegate :adapter_name, 242 | :supports_migrations?, 243 | :supports_primary_key?, 244 | :supports_savepoints?, 245 | :native_database_types, 246 | :raw_connection, 247 | :open_transactions, 248 | :increment_open_transactions, 249 | :decrement_open_transactions, 250 | :transaction_joinable=, 251 | :create_savepoint, 252 | :rollback_to_savepoint, 253 | :release_savepoint, 254 | :current_savepoint_name, 255 | :begin_db_transaction, 256 | :add_limit!, 257 | :default_sequence_name, 258 | :reset_sequence!, 259 | :insert_fixture, 260 | :empty_insert_statement, 261 | :case_sensitive_equality_operator, 262 | :limited_update_conditions, 263 | :insert_sql, 264 | :update_sql, 265 | :delete_sql, 266 | :visitor, 267 | :to => :master_connection 268 | # schema statements 269 | rescued_delegate :table_exists?, 270 | :column_exists?, 271 | :index_name_exists?, 272 | :create_table, 273 | :change_table, 274 | :rename_table, 275 | :drop_table, 276 | :add_column, 277 | :remove_column, 278 | :remove_columns, 279 | :change_column, 280 | :change_column_default, 281 | :rename_column, 282 | :add_index, 283 | :remove_index, 284 | :remove_index!, 285 | :rename_index, 286 | :index_name, 287 | :index_exists?, 288 | :structure_dump, 289 | :dump_schema_information, 290 | :initialize_schema_migrations_table, 291 | :assume_migrated_upto_version, 292 | :type_to_sql, 293 | :add_column_options!, 294 | :distinct, 295 | :add_order_by_for_association_limiting!, 296 | :add_timestamps, 297 | :remove_timestamps, 298 | :to => :master_connection 299 | # no clear interface contract: 300 | rescued_delegate :tables, # commented in SchemaStatements 301 | :truncate_table, # monkeypatching database_cleaner gem 302 | :primary_key, # is Base#primary_key meant to be the contract? 303 | :to => :master_connection 304 | # No need to be so picky about these methods 305 | rescued_delegate :add_limit_offset!, # DatabaseStatements 306 | :add_lock!, #DatabaseStatements 307 | :columns, 308 | :table_alias_for, 309 | :to => :prefer_master_connection 310 | 311 | # === determine read connection 312 | rescued_delegate :select_all, 313 | :select_one, 314 | :select_rows, 315 | :select_value, 316 | :select_values, 317 | :to => :connection_for_read 318 | 319 | # === doesn't really matter, but must be handled by underlying adapter 320 | rescued_delegate *(ActiveRecord::ConnectionAdapters::Quoting.instance_methods + [{ 321 | :to => :current_connection }]) 322 | # issue #4: current_database is not supported by all adapters, though 323 | rescued_delegate :current_database, :to => :current_connection 324 | 325 | # ok, we might have missed more 326 | def method_missing(name, *args, &blk) 327 | master_connection.send(name.to_sym, *args, &blk).tap do 328 | @logger.try(:warn, %Q{ 329 | You called the unsupported method '#{name}' on #{self.class.name}. 330 | In order to help us improve master_slave_adapter, please report this 331 | to: https://github.com/soundcloud/master_slave_adapter/issues 332 | 333 | Thank you. 334 | }) 335 | end 336 | rescue ActiveRecord::StatementInvalid => exception 337 | handle_error(master_connection, exception) 338 | end 339 | 340 | # UTIL ================================================================== 341 | 342 | def master_connection 343 | if circuit.tripped? 344 | raise MasterUnavailable 345 | end 346 | 347 | @connections[:master] ||= connect_to_master 348 | if @connections[:master] 349 | circuit.success! 350 | @connections[:master] 351 | else 352 | circuit.fail! 353 | raise MasterUnavailable 354 | end 355 | end 356 | 357 | def master_available? 358 | !@connections[:master].nil? 359 | end 360 | 361 | # Returns a random slave connection 362 | # Note: the method is not referentially transparent, hence the bang 363 | def slave_connection! 364 | @connections[:slaves].sample 365 | end 366 | 367 | def connections 368 | @connections.values.flatten.compact 369 | end 370 | 371 | def current_connection 372 | connection_stack.first 373 | end 374 | 375 | def current_clock 376 | @master_slave_clock 377 | end 378 | 379 | def master_clock 380 | raise NotImplementedError 381 | end 382 | 383 | def slave_clock(conn) 384 | raise NotImplementedError 385 | end 386 | 387 | protected 388 | 389 | def open_transaction? 390 | master_available? ? (master_connection.open_transactions > 0) : false 391 | end 392 | 393 | def connection_for_read 394 | open_transaction? ? master_connection : current_connection 395 | end 396 | 397 | def prefer_master_connection 398 | master_available? ? master_connection : slave_connection! 399 | end 400 | 401 | def master_connection?(connection) 402 | @connections[:master] == connection 403 | end 404 | 405 | def reset_master_connection 406 | @connections[:master] = nil 407 | end 408 | 409 | def slave_consistent?(conn, clock) 410 | if @last_seen_slave_clocks[conn].try(:>=, clock) 411 | true 412 | elsif (slave_clk = slave_clock(conn)) 413 | @last_seen_slave_clocks[conn] = clock 414 | slave_clk >= clock 415 | else 416 | false 417 | end 418 | end 419 | 420 | def current_clock=(clock) 421 | @master_slave_clock = clock 422 | end 423 | 424 | def connection_stack 425 | @master_slave_connection ||= [] 426 | end 427 | 428 | def current_connection=(conn) 429 | connection_stack.unshift(conn) 430 | end 431 | 432 | def on_write 433 | with(master_connection) do |conn| 434 | yield(conn).tap do 435 | unless open_transaction? 436 | master_clk = master_clock 437 | unless current_clock.try(:>=, master_clk) 438 | self.current_clock = master_clk 439 | end 440 | 441 | # keep using master after write 442 | connection_stack.replace([ conn ]) 443 | end 444 | end 445 | end 446 | end 447 | 448 | def with(connection) 449 | self.current_connection = connection 450 | yield(connection).tap { connection_stack.shift if connection_stack.size > 1 } 451 | rescue ActiveRecord::StatementInvalid => exception 452 | handle_error(connection, exception) 453 | end 454 | 455 | def connect(cfg, name) 456 | adapter_method = "#{cfg.fetch(:adapter)}_connection".to_sym 457 | ActiveRecord::Base.send(adapter_method, { :name => name }.merge(cfg)) 458 | end 459 | 460 | def connect_to_master 461 | connect(@config.fetch(:master), :master) 462 | rescue => exception 463 | if connection_error?(exception) 464 | @logger.try(:warn, "Can't connect to master. #{exception.message}") 465 | nil 466 | else 467 | raise 468 | end 469 | end 470 | 471 | def on_commit_callbacks 472 | @on_commit_callbacks ||= [] 473 | end 474 | 475 | def on_rollback_callbacks 476 | @on_rollback_callbacks ||= [] 477 | end 478 | 479 | def connection_error?(exception) 480 | raise NotImplementedError 481 | end 482 | 483 | def handle_error(connection, exception) 484 | if master_connection?(connection) && connection_error?(exception) 485 | reset_master_connection 486 | raise MasterUnavailable 487 | else 488 | raise exception 489 | end 490 | end 491 | 492 | def circuit 493 | @circuit 494 | end 495 | end 496 | end 497 | end 498 | -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/master_slave_adapter/circuit_breaker.rb: -------------------------------------------------------------------------------- 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 62 | -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/master_slave_adapter/clock.rb: -------------------------------------------------------------------------------- 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 | def self.parse(string) 34 | new(*string.split('@')) 35 | rescue 36 | nil 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/master_slave_adapter/shared_mysql_adapter_behavior.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/connection_adapters/master_slave_adapter/clock' 2 | 3 | module ActiveRecord 4 | module ConnectionAdapters 5 | module MasterSlaveAdapter 6 | module SharedMysqlAdapterBehavior 7 | def with_consistency(clock) 8 | clock = 9 | case clock 10 | when Clock then clock 11 | when String then Clock.parse(clock) 12 | when nil then Clock.zero 13 | end 14 | 15 | super(clock) 16 | end 17 | 18 | def master_clock 19 | conn = master_connection 20 | if status = conn.uncached { conn.select_one("SHOW MASTER STATUS") } 21 | Clock.new(status['File'], status['Position']) 22 | else 23 | Clock.infinity 24 | end 25 | rescue MasterUnavailable 26 | Clock.zero 27 | rescue ActiveRecordError 28 | Clock.infinity 29 | end 30 | 31 | def slave_clock(conn) 32 | if status = conn.uncached { conn.select_one("SHOW SLAVE STATUS") } 33 | Clock.new(status['Relay_Master_Log_File'], status['Exec_Master_Log_Pos']) 34 | else 35 | Clock.zero 36 | end 37 | rescue ActiveRecordError 38 | Clock.zero 39 | end 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/master_slave_adapter/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module ConnectionAdapters 3 | module MasterSlaveAdapter 4 | VERSION = "1.1.2" 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/mysql2_master_slave_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/connection_adapters/master_slave_adapter' 2 | require 'active_record/connection_adapters/master_slave_adapter/clock' 3 | require 'active_record/connection_adapters/master_slave_adapter/shared_mysql_adapter_behavior' 4 | require 'active_record/connection_adapters/mysql2_adapter' 5 | require 'mysql2' 6 | 7 | module ActiveRecord 8 | class Base 9 | def self.mysql2_master_slave_connection(config) 10 | ConnectionAdapters::Mysql2MasterSlaveAdapter.new(config, logger) 11 | end 12 | end 13 | 14 | module ConnectionAdapters 15 | class Mysql2MasterSlaveAdapter < AbstractAdapter 16 | include MasterSlaveAdapter 17 | include SharedMysqlAdapterBehavior 18 | 19 | private 20 | 21 | CONNECTION_ERRORS = { 22 | 2002 => "query: not connected", # CR_CONNECTION_ERROR 23 | 2003 => "Can't connect to MySQL server on", # CR_CONN_HOST_ERROR 24 | 2006 => "MySQL server has gone away", # CR_SERVER_GONE_ERROR 25 | 2013 => "Lost connection to MySQL server during query", # CR_SERVER_LOST 26 | -1 => "closed MySQL connection", # defined by Mysql2 27 | } 28 | 29 | def connection_error?(exception) 30 | case exception 31 | when ActiveRecord::StatementInvalid 32 | CONNECTION_ERRORS.values.any? do |description| 33 | exception.message.start_with?("Mysql2::Error: #{description}") 34 | end 35 | when Mysql2::Error 36 | CONNECTION_ERRORS.keys.include?(exception.errno) 37 | else 38 | false 39 | end 40 | end 41 | 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/mysql_master_slave_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/connection_adapters/master_slave_adapter' 2 | require 'active_record/connection_adapters/master_slave_adapter/shared_mysql_adapter_behavior' 3 | require 'active_record/connection_adapters/mysql_adapter' 4 | require 'mysql' 5 | 6 | module ActiveRecord 7 | class Base 8 | def self.mysql_master_slave_connection(config) 9 | ConnectionAdapters::MysqlMasterSlaveAdapter.new(config, logger) 10 | end 11 | end 12 | 13 | module ConnectionAdapters 14 | class MysqlMasterSlaveAdapter < AbstractAdapter 15 | include MasterSlaveAdapter 16 | include SharedMysqlAdapterBehavior 17 | 18 | private 19 | 20 | CONNECTION_ERRORS = [ 21 | Mysql::Error::CR_CONNECTION_ERROR, # query: not connected 22 | Mysql::Error::CR_CONN_HOST_ERROR, # Can't connect to MySQL server on '%s' (%d) 23 | Mysql::Error::CR_SERVER_GONE_ERROR, # MySQL server has gone away 24 | Mysql::Error::CR_SERVER_LOST, # Lost connection to MySQL server during query 25 | ] 26 | 27 | def connection_error?(exception) 28 | case exception 29 | when ActiveRecord::StatementInvalid 30 | CONNECTION_ERRORS.include?(current_connection.raw_connection.errno) 31 | when Mysql::Error 32 | CONNECTION_ERRORS.include?(exception.errno) 33 | else 34 | false 35 | end 36 | end 37 | 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/master_slave_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/connection_adapters/master_slave_adapter' 2 | -------------------------------------------------------------------------------- /master_slave_adapter.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("../lib", __FILE__) 2 | 3 | require 'active_record/connection_adapters/master_slave_adapter/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'master_slave_adapter' 7 | s.version = ActiveRecord::ConnectionAdapters::MasterSlaveAdapter::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = [ 'Mauricio Linhares', 'Torsten Curdt', 'Kim Altintop', 'Omid Aladini', 'Tiago Loureiro', 'Tobias Schmidt', 'SoundCloud' ] 10 | s.email = %q{tiago@soundcloud.com ts@soundcloud.com} 11 | s.homepage = 'http://github.com/soundcloud/master_slave_adapter' 12 | s.summary = %q{Replication Aware Master/Slave Database Adapter for ActiveRecord} 13 | s.description = %q{(MySQL) Replication Aware Master/Slave Database Adapter for ActiveRecord} 14 | 15 | s.files = `git ls-files`.split("\n") 16 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 17 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 18 | s.require_path = 'lib' 19 | 20 | s.required_ruby_version = '>= 1.8.7' 21 | s.required_rubygems_version = '>= 1.3.7' 22 | 23 | s.add_dependency 'activerecord', ['>= 2.3.9', '< 4.0'] 24 | 25 | s.add_development_dependency 'rake' 26 | s.add_development_dependency 'rspec' 27 | end 28 | -------------------------------------------------------------------------------- /spec/all.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | source `which rvm | sed 's/rvm\/bin/rvm\/scripts/'` 4 | 5 | for ruby in 1.8.7 1.9.2 1.9.3; do 6 | rvm use $ruby 7 | for gemfile in spec/gemfiles/*; do 8 | if [[ "$gemfile" =~ \.lock ]]; then 9 | continue 10 | fi 11 | 12 | BUNDLE_GEMFILE=$gemfile bundle install --quiet 13 | BUNDLE_GEMFILE=$gemfile bundle exec rake spec 14 | done 15 | done 16 | -------------------------------------------------------------------------------- /spec/common/circuit_breaker_spec.rb: -------------------------------------------------------------------------------- 1 | $: << File.expand_path(File.join(File.dirname( __FILE__ ), '..', '..', 'lib')) 2 | 3 | require 'rspec' 4 | require 'active_record/connection_adapters/master_slave_adapter/circuit_breaker' 5 | 6 | describe ActiveRecord::ConnectionAdapters::MasterSlaveAdapter::CircuitBreaker do 7 | let(:logger) { nil } 8 | let(:failure_threshold) { 5 } 9 | let(:timeout) { 10 } 10 | 11 | subject { described_class.new(logger, failure_threshold, timeout) } 12 | 13 | it 'should not be tripped by default' do 14 | should_not be_tripped 15 | end 16 | 17 | context "after single failure" do 18 | before { subject.fail! } 19 | 20 | it 'should remain untripped' do 21 | should_not be_tripped 22 | end 23 | end 24 | 25 | context "after failure threshold is reached" do 26 | before { failure_threshold.times { subject.fail! } } 27 | 28 | it { should be_tripped } 29 | 30 | context "and timeout exceeded" do 31 | before do 32 | now = Time.now 33 | Time.stub(:now).and_return(now + timeout) 34 | subject.tripped? # side effect :/ 35 | end 36 | 37 | it { should_not be_tripped } 38 | 39 | context "after single failure" do 40 | before { subject.fail! } 41 | 42 | it { should be_tripped } 43 | end 44 | 45 | context "after single success" do 46 | before { subject.success! } 47 | 48 | it { should_not be_tripped } 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/common/master_slave_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | $: << File.expand_path(File.join(File.dirname( __FILE__ ), '..', '..', 'lib')) 2 | 3 | require 'rspec' 4 | require 'common/support/connection_setup_helper' 5 | 6 | module ActiveRecord 7 | class Base 8 | cattr_accessor :master_mock, :slave_mock 9 | 10 | def self.test_connection(config) 11 | config[:database] == 'slave' ? slave_mock : master_mock 12 | end 13 | 14 | def self.test_master_slave_connection(config) 15 | ConnectionAdapters::TestMasterSlaveAdapter.new(config, logger) 16 | end 17 | end 18 | 19 | module ConnectionAdapters 20 | class TestMasterSlaveAdapter < AbstractAdapter 21 | include MasterSlaveAdapter 22 | 23 | def master_clock 24 | end 25 | 26 | def slave_clock(connection) 27 | end 28 | 29 | def connection_error?(exception) 30 | end 31 | end 32 | end 33 | end 34 | 35 | describe ActiveRecord::ConnectionAdapters::MasterSlaveAdapter do 36 | include_context 'connection setup' 37 | let(:connection_adapter) { 'test' } 38 | 39 | describe 'common configuration' do 40 | it "should call 'columns' on master" do 41 | master_connection.should_receive(:columns) 42 | adapter_connection.columns 43 | end 44 | 45 | SelectMethods.each do |method| 46 | it "should send the method '#{method}' to the slave connection" do 47 | master_connection.stub!( :open_transactions ).and_return( 0 ) 48 | slave_connection.should_receive( method ).with('testing').and_return( true ) 49 | adapter_connection.send( method, 'testing' ) 50 | end 51 | 52 | it "should send the method '#{method}' to the master connection if with_master was specified" do 53 | master_connection.should_receive( method ).with('testing').and_return( true ) 54 | ActiveRecord::Base.with_master do 55 | adapter_connection.send( method, 'testing' ) 56 | end 57 | end 58 | 59 | it "should send the method '#{method}' to the slave connection if with_slave was specified" do 60 | slave_connection.should_receive( method ).with('testing').and_return( true ) 61 | ActiveRecord::Base.with_slave do 62 | adapter_connection.send( method, 'testing' ) 63 | end 64 | end 65 | 66 | context "given there are open transactions" do 67 | it "should send the method '#{method}' to the master connection" do 68 | master_connection.stub!( :open_transactions ).and_return( 1 ) 69 | master_connection.should_receive( method ).with('testing').and_return( true ) 70 | 71 | adapter_connection.send( method, 'testing' ) 72 | end 73 | 74 | it "should send the method '#{method}' to the master connection, even in with_slave" do 75 | master_connection.stub!( :open_transactions ).and_return( 1 ) 76 | master_connection.should_receive( method ).with('testing').and_return( true ) 77 | 78 | ActiveRecord::Base.with_slave do 79 | adapter_connection.send( method, 'testing' ) 80 | end 81 | end 82 | 83 | it "raises MasterUnavailable if master is not available" do 84 | adapter_connection.stub(:connection_error?).and_return(true) 85 | master_connection.stub(:open_transactions).and_return(1) 86 | master_connection.should_receive(method).with('testing').and_raise(ActiveRecord::StatementInvalid) 87 | 88 | expect do 89 | adapter_connection.send(method, 'testing') 90 | end.to raise_error(ActiveRecord::MasterUnavailable) 91 | end 92 | end 93 | 94 | context 'given slave is not available' do 95 | it 'raises statement invalid exception' do 96 | adapter_connection.stub(:connection_error?).and_return(true) 97 | slave_connection.should_receive(method).with('testing').and_raise(ActiveRecord::StatementInvalid) 98 | 99 | expect do 100 | ActiveRecord::Base.with_slave do 101 | adapter_connection.send(method, 'testing') 102 | end 103 | end.to raise_error(ActiveRecord::StatementInvalid) 104 | end 105 | end 106 | end # /SelectMethods.each 107 | 108 | SchemaStatements.each do |method| 109 | it "should send the method '#{method}' from ActiveRecord::ConnectionAdapters::SchemaStatements to the master" do 110 | master_connection.should_receive( method ).and_return( true ) 111 | adapter_connection.send( method ) 112 | end 113 | 114 | it "should raise MasterSlaveAdapter if master is not available" do 115 | adapter_connection.stub(:connection_error?).and_return(true) 116 | master_connection.should_receive(method).and_raise(ActiveRecord::StatementInvalid) 117 | 118 | expect do 119 | adapter_connection.send(method) 120 | end.to raise_error(ActiveRecord::MasterUnavailable) 121 | end 122 | end 123 | 124 | it "should call #visitor on master connection" do 125 | master_connection.should_receive(:visitor) 126 | adapter_connection.visitor 127 | end 128 | 129 | it 'should be a master slave connection' do 130 | adapter_connection.class.should == ActiveRecord::ConnectionAdapters::TestMasterSlaveAdapter 131 | end 132 | 133 | it 'should have a master connection' do 134 | adapter_connection.master_connection.should == master_connection 135 | end 136 | 137 | it 'should have a slave connection' do 138 | adapter_connection.slave_connection!.should == slave_connection 139 | end 140 | end 141 | 142 | describe "connection testing" do 143 | before do 144 | master_connection.unstub(:active?) 145 | slave_connection.unstub(:active?) 146 | end 147 | 148 | context "disabled" do 149 | let(:database_setup) do 150 | default_database_setup.merge(:disable_connection_test => 'true') 151 | end 152 | 153 | it "should not perform the testing" do 154 | master_connection.should_not_receive(:active?) 155 | slave_connection.should_not_receive(:active?) 156 | 157 | adapter_connection.active?.should == true 158 | end 159 | end 160 | 161 | context "enabled" do 162 | it "should perform the testing" do 163 | # twice == one during connection + one on explicit #active? call 164 | master_connection.should_receive(:active?).twice.and_return(true) 165 | slave_connection.should_receive(:active?).twice.and_return(true) 166 | 167 | adapter_connection.active?.should == true 168 | end 169 | end 170 | end 171 | 172 | describe 'with connection eager loading enabled' do 173 | it 'should eager load the connections' do 174 | adapter_connection.connections.should include(master_connection, slave_connection) 175 | end 176 | end 177 | 178 | describe "transaction callbacks" do 179 | def run_tx 180 | adapter_connection. 181 | should_receive('master_clock'). 182 | and_return(1) 183 | %w(begin_db_transaction 184 | commit_db_transaction 185 | increment_open_transactions 186 | decrement_open_transactions).each do |txstmt| 187 | master_connection. 188 | should_receive(txstmt).exactly(1).times 189 | end 190 | master_connection. 191 | should_receive('open_transactions').exactly(4).times. 192 | and_return(0, 1, 0, 0) 193 | 194 | master_connection. 195 | should_receive('update').with('testing'). 196 | and_return(true) 197 | 198 | ActiveRecord::Base.transaction do 199 | adapter_connection.send('update', 'testing') 200 | end 201 | end 202 | 203 | def fail_tx 204 | %w(begin_db_transaction 205 | rollback_db_transaction 206 | increment_open_transactions 207 | decrement_open_transactions).each do |txstmt| 208 | master_connection. 209 | should_receive(txstmt).exactly(1).times 210 | end 211 | 212 | master_connection. 213 | should_receive('open_transactions').exactly(3).times. 214 | and_return(0, 1, 0) 215 | master_connection. 216 | should_receive('update').with('testing'). 217 | and_return(true) 218 | 219 | ActiveRecord::Base.transaction do 220 | adapter_connection.send('update', 'testing') 221 | raise "rollback" 222 | end 223 | rescue 224 | nil 225 | end 226 | 227 | context "on commit" do 228 | it "on_commit callback should be called" do 229 | x = false 230 | adapter_connection.on_commit { x = true } 231 | lambda { run_tx }.should change { x }.to(true) 232 | end 233 | 234 | it "on_rollback callback should not be called" do 235 | x = false 236 | adapter_connection.on_rollback { x = true } 237 | lambda { run_tx }.should_not change { x } 238 | end 239 | end 240 | 241 | context "on rollback" do 242 | it "on_commit callback should not be called" do 243 | x = false 244 | adapter_connection.on_commit { x = true } 245 | lambda { fail_tx }.should_not change { x } 246 | end 247 | 248 | it "on_rollback callback should be called" do 249 | x = false 250 | adapter_connection.on_rollback { x = true } 251 | lambda { fail_tx }.should change { x }.to(true) 252 | end 253 | end 254 | end 255 | 256 | describe "query cache" do 257 | describe "#cache" do 258 | it "activities query caching on all connections" do 259 | master_connection.should_receive(:cache).and_yield 260 | slave_connection.should_receive(:cache).and_yield 261 | master_connection.should_not_receive(:select_value) 262 | slave_connection.should_receive(:select_value) 263 | 264 | adapter_connection.cache do 265 | adapter_connection.select_value("SELECT 42") 266 | end 267 | end 268 | end 269 | 270 | describe "#uncached" do 271 | it "deactivates query caching on all connections" do 272 | master_connection.should_receive(:uncached).and_yield 273 | slave_connection.should_receive(:uncached).and_yield 274 | master_connection.should_not_receive(:select_value) 275 | slave_connection.should_receive(:select_value) 276 | 277 | adapter_connection.uncached do 278 | adapter_connection.select_value("SELECT 42") 279 | end 280 | end 281 | end 282 | 283 | describe "#clear_query_cache" do 284 | it "clears the query cache on all connections" do 285 | master_connection.should_receive(:clear_query_cache) 286 | slave_connection.should_receive(:clear_query_cache) 287 | 288 | adapter_connection.clear_query_cache 289 | end 290 | end 291 | end 292 | 293 | describe "connection stack" do 294 | it "should start with the slave connection on top" do 295 | adapter_connection.current_connection.should == slave_connection 296 | end 297 | 298 | it "should keep the current connection on top" do 299 | ActiveRecord::Base.with_master do 300 | adapter_connection.current_connection.should == master_connection 301 | ActiveRecord::Base.with_slave do 302 | adapter_connection.current_connection.should == slave_connection 303 | ActiveRecord::Base.with_master do 304 | adapter_connection.current_connection.should == master_connection 305 | end 306 | adapter_connection.current_connection.should == slave_connection 307 | end 308 | adapter_connection.current_connection.should == master_connection 309 | end 310 | adapter_connection.current_connection.should == slave_connection 311 | end 312 | 313 | it "should continue to use master connection after a write" do 314 | master_connection.should_receive(:execute).with("INSERT 42") 315 | 316 | ActiveRecord::Base.with_slave do 317 | adapter_connection.current_connection.should == slave_connection 318 | ActiveRecord::Base.with_master do 319 | adapter_connection.current_connection.should == master_connection 320 | ActiveRecord::Base.with_slave do 321 | adapter_connection.current_connection.should == slave_connection 322 | adapter_connection.execute("INSERT 42") 323 | adapter_connection.current_connection.should == master_connection 324 | end 325 | adapter_connection.current_connection.should == master_connection 326 | end 327 | adapter_connection.current_connection.should == master_connection 328 | end 329 | adapter_connection.current_connection.should == master_connection 330 | end 331 | end 332 | end 333 | -------------------------------------------------------------------------------- /spec/common/mysql2_master_slave_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | $: << File.expand_path(File.join(File.dirname( __FILE__ ), '..', '..', 'lib')) 2 | 3 | require 'rspec' 4 | require 'common/support/connection_setup_helper' 5 | require 'common/support/mysql_consistency_examples' 6 | require 'active_record/connection_adapters/mysql2_master_slave_adapter' 7 | 8 | module ActiveRecord 9 | class Base 10 | cattr_accessor :master_mock, :slave_mock 11 | 12 | def self.mysql2_connection(config) 13 | config[:database] == 'slave' ? slave_mock : master_mock 14 | end 15 | end 16 | end 17 | 18 | describe ActiveRecord::ConnectionAdapters::Mysql2MasterSlaveAdapter do 19 | include_context 'connection setup' 20 | let(:connection_adapter) { 'mysql2' } 21 | 22 | it_should_behave_like 'mysql consistency' 23 | 24 | describe "connection error detection" do 25 | { 26 | 2002 => "query: not connected", 27 | 2003 => "Can't connect to MySQL server on 'localhost' (3306)", 28 | 2006 => "MySQL server has gone away", 29 | 2013 => "Lost connection to MySQL server during query", 30 | }.each do |errno, description| 31 | it "raises MasterUnavailable for '#{description}' during query execution" do 32 | master_connection.stub_chain(:raw_connection, :errno).and_return(errno) 33 | master_connection.should_receive(:insert).and_raise(ActiveRecord::StatementInvalid.new("Mysql2::Error: #{description}: INSERT 42")) 34 | 35 | expect do 36 | adapter_connection.insert("INSERT 42") 37 | end.to raise_error(ActiveRecord::MasterUnavailable) 38 | end 39 | 40 | it "doesn't raise anything for '#{description}' during connection" do 41 | error = Mysql2::Error.new(description) 42 | error.stub(:errno).and_return(errno) 43 | ActiveRecord::Base.should_receive(:master_mock).and_raise(error) 44 | 45 | expect do 46 | ActiveRecord::Base.connection_handler.clear_all_connections! 47 | ActiveRecord::Base.connection 48 | end.to_not raise_error 49 | end 50 | end 51 | 52 | it "raises MasterUnavailable for 'closed MySQL connection' during query execution" do 53 | master_connection.should_receive(:insert).and_raise(ActiveRecord::StatementInvalid.new("Mysql2::Error: closed MySQL connection: INSERT 42")) 54 | 55 | expect do 56 | adapter_connection.insert("INSERT 42") 57 | end.to raise_error(ActiveRecord::MasterUnavailable) 58 | end 59 | 60 | it "raises StatementInvalid for other errors" do 61 | master_connection.should_receive(:insert).and_raise(ActiveRecord::StatementInvalid.new("Mysql2::Error: Query execution was interrupted: INSERT 42")) 62 | 63 | expect do 64 | adapter_connection.insert("INSERT 42") 65 | end.to raise_error(ActiveRecord::StatementInvalid) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/common/mysql_master_slave_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | $: << File.expand_path(File.join(File.dirname( __FILE__ ), '..', '..', 'lib')) 2 | 3 | require 'rspec' 4 | require 'common/support/connection_setup_helper' 5 | require 'common/support/mysql_consistency_examples' 6 | require 'active_record/connection_adapters/mysql_master_slave_adapter' 7 | 8 | module ActiveRecord 9 | class Base 10 | cattr_accessor :master_mock, :slave_mock 11 | 12 | def self.mysql_connection(config) 13 | config[:database] == 'slave' ? slave_mock : master_mock 14 | end 15 | end 16 | end 17 | 18 | describe ActiveRecord::ConnectionAdapters::MysqlMasterSlaveAdapter do 19 | include_context 'connection setup' 20 | let(:connection_adapter) { 'mysql' } 21 | 22 | it_should_behave_like 'mysql consistency' 23 | 24 | describe "connection error detection" do 25 | { 26 | Mysql::Error::CR_CONNECTION_ERROR => "query: not connected", 27 | Mysql::Error::CR_CONN_HOST_ERROR => "Can't connect to MySQL server on 'localhost' (3306)", 28 | Mysql::Error::CR_SERVER_GONE_ERROR => "MySQL server has gone away", 29 | Mysql::Error::CR_SERVER_LOST => "Lost connection to MySQL server during query", 30 | }.each do |errno, description| 31 | it "raises MasterUnavailable for '#{description}' during query execution" do 32 | master_connection.stub_chain(:raw_connection, :errno).and_return(errno) 33 | master_connection.should_receive(:insert).and_raise(ActiveRecord::StatementInvalid.new("Mysql::Error: #{description}: INSERT 42")) 34 | 35 | expect do 36 | adapter_connection.insert("INSERT 42") 37 | end.to raise_error(ActiveRecord::MasterUnavailable) 38 | end 39 | 40 | it "doesn't raise anything for '#{description}' during connection" do 41 | error = Mysql::Error.new(description) 42 | error.stub(:errno).and_return(errno) 43 | ActiveRecord::Base.should_receive(:master_mock).and_raise(error) 44 | 45 | expect do 46 | ActiveRecord::Base.connection_handler.clear_all_connections! 47 | ActiveRecord::Base.connection 48 | end.to_not raise_error 49 | end 50 | end 51 | 52 | it "raises StatementInvalid for other errors" do 53 | error = ActiveRecord::StatementInvalid.new("Mysql::Error: Query execution was interrupted: INSERT 42") 54 | master_connection.stub_chain(:raw_connection, :errno).and_return(Mysql::Error::ER_QUERY_INTERRUPTED) 55 | master_connection.should_receive(:insert).and_raise(error) 56 | 57 | expect do 58 | adapter_connection.insert("INSERT 42") 59 | end.to raise_error(ActiveRecord::StatementInvalid) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/common/support/connection_setup_helper.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/connection_adapters/master_slave_adapter' 2 | 3 | SchemaStatements = ActiveRecord::ConnectionAdapters::SchemaStatements.public_instance_methods.map(&:to_sym) 4 | SelectMethods = [ :select_all, :select_one, :select_rows, :select_value, :select_values ] 5 | 6 | shared_context 'connection setup' do 7 | let(:default_database_setup) do 8 | { 9 | :adapter => 'master_slave', 10 | :username => 'root', 11 | :database => 'slave', 12 | :connection_adapter => connection_adapter, 13 | :master => { :username => 'root', :database => 'master' }, 14 | :slaves => [{ :database => 'slave' }], 15 | } 16 | end 17 | 18 | let(:database_setup) do 19 | default_database_setup 20 | end 21 | 22 | let(:mocked_methods) do 23 | { 24 | :reconnect! => true, 25 | :disconnect! => true, 26 | :active? => true, 27 | } 28 | end 29 | 30 | let(:master_connection) do 31 | stubs = mocked_methods.merge(:open_transactions => 0) 32 | mock('master connection', stubs).tap do |conn| 33 | conn.stub(:uncached).and_yield 34 | end 35 | end 36 | 37 | let(:slave_connection) do 38 | mock('slave connection', mocked_methods).tap do |conn| 39 | conn.stub(:uncached).and_yield 40 | end 41 | end 42 | 43 | before do 44 | ActiveRecord::Base.master_mock = master_connection 45 | ActiveRecord::Base.slave_mock = slave_connection 46 | ActiveRecord::Base.establish_connection(database_setup) 47 | end 48 | 49 | after do 50 | ActiveRecord::Base.connection_handler.clear_all_connections! 51 | end 52 | 53 | def adapter_connection 54 | ActiveRecord::Base.connection 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/common/support/mysql_consistency_examples.rb: -------------------------------------------------------------------------------- 1 | require 'active_record/connection_adapters/master_slave_adapter/clock' 2 | 3 | Clock = ActiveRecord::ConnectionAdapters::MasterSlaveAdapter::Clock 4 | 5 | shared_examples_for 'mysql consistency' do 6 | def zero 7 | Clock.zero 8 | end 9 | 10 | def master_position(pos) 11 | Clock.new('', pos) 12 | end 13 | 14 | def should_report_clock(pos, connection, log_file, log_pos, sql) 15 | pos = Array(pos) 16 | values = pos.map { |p| { log_file => '', log_pos => p } } 17 | 18 | connection. 19 | should_receive(:select_one).exactly(pos.length).times. 20 | with(sql). 21 | and_return(*values) 22 | end 23 | 24 | def slave_should_report_clock(pos) 25 | should_report_clock(pos, slave_connection, 'Relay_Master_Log_File', 'Exec_Master_Log_Pos', 'SHOW SLAVE STATUS') 26 | end 27 | 28 | def master_should_report_clock(pos) 29 | should_report_clock(pos, master_connection, 'File', 'Position', 'SHOW MASTER STATUS') 30 | end 31 | 32 | SelectMethods.each do |method| 33 | it "should send the method '#{method}' to the slave if nil is given" do 34 | slave_should_report_clock(0) 35 | slave_connection.should_receive(method).with('testing').and_return(true) 36 | new_clock = ActiveRecord::Base.with_consistency(nil) do 37 | adapter_connection.send(method, 'testing') 38 | end 39 | new_clock.should be_a(Clock) 40 | new_clock.should equal(zero) 41 | end 42 | 43 | it "should send the method '#{method}' to the slave if clock.zero is given" do 44 | slave_should_report_clock(0) 45 | slave_connection.should_receive(method).with('testing').and_return(true) 46 | old_clock = zero 47 | new_clock = ActiveRecord::Base.with_consistency(old_clock) do 48 | adapter_connection.send(method, 'testing') 49 | end 50 | new_clock.should be_a(Clock) 51 | new_clock.should equal(old_clock) 52 | end 53 | 54 | it "should send the method '#{method}' to the master if slave hasn't cought up to required clock yet" do 55 | slave_should_report_clock(0) 56 | master_connection.should_receive(method).with('testing').and_return(true) 57 | old_clock = master_position(1) 58 | new_clock = ActiveRecord::Base.with_consistency(old_clock) do 59 | adapter_connection.send(method, 'testing' ) 60 | end 61 | new_clock.should be_a(Clock) 62 | new_clock.should equal(old_clock) 63 | end 64 | 65 | it "should send the method '#{method}' to the master connection if there are open transactions" do 66 | master_connection.stub!(:open_transactions).and_return(1) 67 | master_connection.should_receive(method).with('testing').and_return(true) 68 | old_clock = zero 69 | new_clock = ActiveRecord::Base.with_consistency(old_clock) do 70 | adapter_connection.send(method, 'testing') 71 | end 72 | new_clock.should be_a(Clock) 73 | new_clock.should equal(zero) 74 | end 75 | 76 | it "should send the method '#{method}' to the master after a write operation" do 77 | slave_should_report_clock(0) 78 | master_should_report_clock(2) 79 | slave_connection.should_receive(method).with('testing').and_return(true) 80 | master_connection.should_receive(:update).with('testing').and_return(true) 81 | master_connection.should_receive(method).with('testing').and_return(true) 82 | old_clock = zero 83 | new_clock = ActiveRecord::Base.with_consistency(old_clock) do 84 | adapter_connection.send(method, 'testing') # slave 85 | adapter_connection.send(:update, 'testing') # master 86 | adapter_connection.send(method, 'testing') # master 87 | end 88 | new_clock.should be_a(Clock) 89 | new_clock.should > old_clock 90 | end 91 | end 92 | 93 | it "should update the clock after a transaction" do 94 | slave_should_report_clock(0) 95 | master_should_report_clock([0, 1, 1]) 96 | 97 | slave_connection. 98 | should_receive(:select_all).exactly(1).times.with('testing'). 99 | and_return(true) 100 | 101 | master_connection. 102 | should_receive(:update).exactly(3).times.with('testing'). 103 | and_return(true) 104 | master_connection. 105 | should_receive(:select_all).exactly(5).times.with('testing'). 106 | and_return(true) 107 | %w(begin_db_transaction 108 | commit_db_transaction 109 | increment_open_transactions 110 | decrement_open_transactions).each do |txstmt| 111 | master_connection.should_receive(txstmt).exactly(1).times 112 | end 113 | 114 | master_connection. 115 | should_receive('open_transactions').exactly(13).times. 116 | and_return( 117 | # adapter: with_consistency, select_all, update, select_all 118 | 0, 0, 0, 0, 119 | # connection: transaction 120 | 0, 121 | # adapter: select_all, update, select_all, commit_db_transaction 122 | 1, 1, 1, 0, 123 | # connection: transaction (ensure) 124 | 0, 125 | # adapter: select_all, update, select_all 126 | 0, 0, 0 127 | ) 128 | 129 | old_clock = zero 130 | new_clock = ActiveRecord::Base.with_consistency(old_clock) do 131 | adapter_connection.send(:select_all, 'testing') # slave s=0 m=0 132 | adapter_connection.send(:update, 'testing') # master s=0 m=1 133 | adapter_connection.send(:select_all, 'testing') # master s=0 m=1 134 | 135 | ActiveRecord::Base.transaction do 136 | adapter_connection.send(:select_all, 'testing') # master s=0 m=1 137 | adapter_connection.send(:update, 'testing') # master s=0 m=1 138 | adapter_connection.send(:select_all, 'testing') # master s=0 m=1 139 | end 140 | 141 | adapter_connection.send(:select_all, 'testing') # master s=0 m=2 142 | adapter_connection.send(:update, 'testing') # master s=0 m=3 143 | adapter_connection.send(:select_all, 'testing') # master s=0 m=3 144 | end 145 | 146 | new_clock.should > old_clock 147 | end 148 | 149 | context "with nested with_consistency" do 150 | it "should return the same clock if not writing and no lag" do 151 | slave_should_report_clock(0) 152 | slave_connection. 153 | should_receive(:select_one).exactly(3).times.with('testing'). 154 | and_return(true) 155 | 156 | old_clock = zero 157 | new_clock = ActiveRecord::Base.with_consistency(old_clock) do 158 | adapter_connection.send(:select_one, 'testing') 159 | ActiveRecord::Base.with_consistency(old_clock) do 160 | adapter_connection.send(:select_one, 'testing') 161 | end 162 | adapter_connection.send(:select_one, 'testing') 163 | end 164 | new_clock.should equal(old_clock) 165 | end 166 | 167 | it "requesting a newer clock should return a new clock" do 168 | adapter_connection. 169 | should_receive('slave_consistent?').exactly(2).times. 170 | and_return(true, false) 171 | slave_connection. 172 | should_receive(:select_all).exactly(2).times.with('testing'). 173 | and_return(true) 174 | master_connection. 175 | should_receive(:select_all).exactly(1).times.with('testing'). 176 | and_return(true) 177 | 178 | start_clock = zero 179 | inner_clock = zero 180 | outer_clock = ActiveRecord::Base.with_consistency(start_clock) do 181 | adapter_connection.send(:select_all, 'testing') # slave 182 | inner_clock = ActiveRecord::Base.with_consistency(master_position(1)) do 183 | adapter_connection.send(:select_all, 'testing') # master 184 | end 185 | adapter_connection.send(:select_all, 'testing') # slave 186 | end 187 | 188 | start_clock.should equal(outer_clock) 189 | inner_clock.should > start_clock 190 | end 191 | end 192 | 193 | it "should do the right thing when nested inside with_master" do 194 | slave_should_report_clock(0) 195 | slave_connection.should_receive(:select_all).exactly(1).times.with('testing').and_return(true) 196 | master_connection.should_receive(:select_all).exactly(2).times.with('testing').and_return(true) 197 | ActiveRecord::Base.with_master do 198 | adapter_connection.send(:select_all, 'testing') # master 199 | ActiveRecord::Base.with_consistency(zero) do 200 | adapter_connection.send(:select_all, 'testing') # slave 201 | end 202 | adapter_connection.send(:select_all, 'testing') # master 203 | end 204 | end 205 | 206 | it "should do the right thing when nested inside with_slave" do 207 | slave_should_report_clock(0) 208 | slave_connection.should_receive(:select_all).exactly(3).times.with('testing').and_return(true) 209 | ActiveRecord::Base.with_slave do 210 | adapter_connection.send(:select_all, 'testing') # slave 211 | ActiveRecord::Base.with_consistency(zero) do 212 | adapter_connection.send(:select_all, 'testing') # slave 213 | end 214 | adapter_connection.send(:select_all, 'testing') # slave 215 | end 216 | end 217 | 218 | it "should do the right thing when wrapping with_master" do 219 | slave_should_report_clock(0) 220 | slave_connection.should_receive(:select_all).exactly(2).times.with('testing').and_return(true) 221 | master_connection.should_receive(:select_all).exactly(1).times.with('testing').and_return(true) 222 | ActiveRecord::Base.with_consistency(zero) do 223 | adapter_connection.send(:select_all, 'testing') # slave 224 | ActiveRecord::Base.with_master do 225 | adapter_connection.send(:select_all, 'testing') # master 226 | end 227 | adapter_connection.send(:select_all, 'testing') # slave 228 | end 229 | end 230 | 231 | it "should do the right thing when wrapping with_slave" do 232 | slave_should_report_clock(0) 233 | slave_connection.should_receive(:select_all).exactly(1).times.with('testing').and_return(true) 234 | master_connection.should_receive(:select_all).exactly(2).times.with('testing').and_return(true) 235 | ActiveRecord::Base.with_consistency(master_position(1)) do 236 | adapter_connection.send(:select_all, 'testing') # master 237 | ActiveRecord::Base.with_slave do 238 | adapter_connection.send(:select_all, 'testing') # slave 239 | end 240 | adapter_connection.send(:select_all, 'testing') # master 241 | end 242 | end 243 | 244 | it "should accept clock as string" do 245 | slave_should_report_clock(0) 246 | slave_connection.should_receive(:select_all).with('testing') 247 | 248 | ActiveRecord::Base.with_consistency("@0") do 249 | adapter_connection.send(:select_all, 'testing') 250 | end 251 | end 252 | end 253 | -------------------------------------------------------------------------------- /spec/gemfiles/activerecord2.3: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem "mysql", "~> 2.8.1" 4 | gem "activerecord", "~> 2.3.14" 5 | gemspec :path=>"../../" 6 | -------------------------------------------------------------------------------- /spec/gemfiles/activerecord3.0: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem "mysql", "~> 2.8.1" 4 | gem "activerecord", "~> 3.0.0" 5 | gemspec :path=>"../../" 6 | -------------------------------------------------------------------------------- /spec/gemfiles/activerecord3.2: -------------------------------------------------------------------------------- 1 | source :rubygems 2 | 3 | gem "mysql", "~> 2.8.1" 4 | gem "mysql2", "~> 0.3.11" 5 | gem "activerecord", "~> 3.2.3" 6 | gemspec :path=>"../../" 7 | -------------------------------------------------------------------------------- /spec/integration/mysql2_master_slave_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | $: << File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'lib')) 2 | 3 | require 'rspec' 4 | require 'master_slave_adapter' 5 | require 'integration/support/shared_mysql_examples' 6 | 7 | describe "ActiveRecord::ConnectionAdapters::Mysql2MasterSlaveAdapter" do 8 | let(:connection_adapter) { 'mysql2' } 9 | 10 | it_should_behave_like "a MySQL MasterSlaveAdapter" 11 | end 12 | -------------------------------------------------------------------------------- /spec/integration/mysql_master_slave_adapter_spec.rb: -------------------------------------------------------------------------------- 1 | $: << File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'lib')) 2 | 3 | require 'rspec' 4 | require 'master_slave_adapter' 5 | require 'integration/support/shared_mysql_examples' 6 | 7 | describe "ActiveRecord::ConnectionAdapters::MysqlMasterSlaveAdapter" do 8 | let(:connection_adapter) { 'mysql' } 9 | 10 | it_should_behave_like "a MySQL MasterSlaveAdapter" 11 | end 12 | -------------------------------------------------------------------------------- /spec/integration/support/mysql_setup_helper.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | require 'timeout' 3 | 4 | module MysqlSetupHelper 5 | MASTER_ID = "1" 6 | MASTER_PORT = 3310 7 | SLAVE_ID = "2" 8 | SLAVE_PORT = 3311 9 | TEST_TABLE = "master_slave_adapter.master_slave_test" 10 | 11 | def port(identifier) 12 | case identifier 13 | when :master then MASTER_PORT 14 | when :slave then SLAVE_PORT 15 | end 16 | end 17 | 18 | def server_id(identifier) 19 | case identifier 20 | when :master then MASTER_ID 21 | when :slave then SLAVE_ID 22 | end 23 | end 24 | 25 | def start_replication 26 | execute(:slave, "start slave") 27 | end 28 | 29 | def stop_replication 30 | execute(:slave, "stop slave") 31 | end 32 | 33 | def move_master_clock 34 | execute(:master, "insert into #{TEST_TABLE} (message) VALUES ('test')") 35 | end 36 | 37 | def wait_for_replication_sync 38 | Timeout.timeout(5) do 39 | until slave_status == master_status; end 40 | end 41 | rescue Timeout::Error 42 | raise "Replication synchronization failed" 43 | end 44 | 45 | def configure 46 | execute(:master, <<-EOS) 47 | SET sql_log_bin = 0; 48 | create user 'slave'@'localhost' identified by 'slave'; 49 | grant replication slave on *.* to 'slave'@'localhost'; 50 | create database master_slave_adapter; 51 | SET sql_log_bin = 1; 52 | EOS 53 | 54 | execute(:slave, <<-EOS) 55 | change master to master_user = 'slave', 56 | master_password = 'slave', 57 | master_port = #{port(:master)}, 58 | master_host = 'localhost'; 59 | create database master_slave_adapter; 60 | EOS 61 | 62 | execute(:master, <<-EOS) 63 | CREATE TABLE #{TEST_TABLE} ( 64 | id int(11) NOT NULL AUTO_INCREMENT, 65 | message text COLLATE utf8_unicode_ci, 66 | created_at datetime DEFAULT NULL, 67 | PRIMARY KEY (id) 68 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci; 69 | EOS 70 | end 71 | 72 | def setup 73 | [:master, :slave].each do |name| 74 | path = location(name) 75 | config_path = File.join(path, "my.cnf") 76 | base_dir = File.dirname(File.dirname(`which mysql_install_db`)) 77 | 78 | FileUtils.rm_rf(path) 79 | FileUtils.mkdir_p(path) 80 | File.open(config_path, "w") { |file| file << config(name) } 81 | 82 | `mysql_install_db --defaults-file='#{config_path}' --basedir='#{base_dir}' --user=''` 83 | end 84 | end 85 | 86 | def start_master 87 | start(:master) 88 | end 89 | 90 | def stop_master 91 | stop(:master) 92 | end 93 | 94 | def start_slave 95 | start(:slave) 96 | end 97 | 98 | def stop_slave 99 | stop(:slave) 100 | end 101 | 102 | private 103 | 104 | def slave_status 105 | status(:slave).values_at(9, 21) 106 | end 107 | 108 | def master_status 109 | status(:master).values_at(0, 1) 110 | end 111 | 112 | def status(name) 113 | `mysql --protocol=TCP -P#{port(name)} -uroot -N -s -e 'show #{name} status'`.strip.split("\t") 114 | end 115 | 116 | def execute(host, statement = '') 117 | system(%{mysql --protocol=TCP -P#{port(host)} -uroot -e "#{statement}"}) 118 | end 119 | 120 | def start(name) 121 | $pipes ||= {} 122 | $pipes[name] = IO.popen("mysqld --defaults-file='#{location(name)}/my.cnf'") 123 | wait_for_database_boot(name) 124 | end 125 | 126 | def stop(name) 127 | pipe = $pipes[name] 128 | Process.kill("KILL", pipe.pid) 129 | Process.wait(pipe.pid, Process::WNOHANG) 130 | 131 | # Ruby 1.8.7 doesn't support IO.popen([cmd, [arg, ]]) syntax, and passing 132 | # the command line as string wraps the process in a shell. The IO#pid method 133 | # will then only return the pid of the wrapping shell process, which is not 134 | # what we need here. 135 | mysqld_pid = `ps a | grep 'mysqld.*#{location(name)}/my.cnf' | grep -v grep | awk '{print $1}'`.to_i 136 | Process.kill("KILL", mysqld_pid) unless mysqld_pid.zero? 137 | ensure 138 | pipe.close unless pipe.closed? 139 | end 140 | 141 | def started?(host) 142 | system(%{mysql --protocol=TCP -P#{port(host)} -uroot -e '' 2> /dev/null}) 143 | end 144 | 145 | def wait_for_database_boot(host) 146 | Timeout.timeout(5) do 147 | until started?(host); sleep(0.1); end 148 | end 149 | rescue Timeout::Error 150 | raise "Couldn't connect to MySQL in time" 151 | end 152 | 153 | def location(name) 154 | File.expand_path(File.join("..", "mysql", name.to_s), File.dirname(__FILE__)) 155 | end 156 | 157 | def config(name) 158 | path = location(name) 159 | 160 | <<-EOS 161 | [mysqld] 162 | pid-file = #{path}/mysqld.pid 163 | socket = #{path}/mysqld.sock 164 | port = #{port(name)} 165 | log-error = #{path}/error.log 166 | datadir = #{path}/data 167 | log-bin = #{name}-bin 168 | log-bin-index = #{name}-bin.index 169 | server-id = #{server_id(name)} 170 | lower_case_table_names = 1 171 | sql-mode = '' 172 | replicate-ignore-db = mysql 173 | EOS 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /spec/integration/support/shared_mysql_examples.rb: -------------------------------------------------------------------------------- 1 | require 'integration/support/mysql_setup_helper' 2 | 3 | shared_examples_for "a MySQL MasterSlaveAdapter" do 4 | include MysqlSetupHelper 5 | 6 | let(:configuration) do 7 | { 8 | :adapter => 'master_slave', 9 | :connection_adapter => connection_adapter, 10 | :username => 'root', 11 | :database => 'master_slave_adapter', 12 | :master => { 13 | :host => '127.0.0.1', 14 | :port => port(:master), 15 | }, 16 | :slaves => [{ 17 | :host => '127.0.0.1', 18 | :port => port(:slave), 19 | }], 20 | } 21 | end 22 | 23 | let(:test_table) { MysqlSetupHelper::TEST_TABLE } 24 | 25 | let(:logger) { nil } 26 | 27 | def connection 28 | ActiveRecord::Base.connection 29 | end 30 | 31 | def should_read_from(host) 32 | server = server_id(host) 33 | query = "SELECT @@Server_id as Value" 34 | 35 | connection.select_all(query).first["Value"].to_s.should == server 36 | connection.select_one(query)["Value"].to_s.should == server 37 | connection.select_rows(query).first.first.to_s.should == server 38 | connection.select_value(query).to_s.should == server 39 | connection.select_values(query).first.to_s.should == server 40 | end 41 | 42 | before(:all) do 43 | setup 44 | start_master 45 | start_slave 46 | configure 47 | start_replication 48 | end 49 | 50 | after(:all) do 51 | stop_master 52 | stop_slave 53 | end 54 | 55 | before do 56 | ActiveRecord::Base.establish_connection(configuration) 57 | ActiveRecord::Base.logger = logger 58 | ActiveRecord::Base.connection.should be_active 59 | end 60 | 61 | it "connects to the database" do 62 | expect { ActiveRecord::Base.connection }.to_not raise_error 63 | end 64 | 65 | context "given a debug logger" do 66 | let(:logger) do 67 | logger = [] 68 | def logger.debug(*args) 69 | push(args.join) 70 | end 71 | def logger.debug? 72 | true 73 | end 74 | 75 | logger 76 | end 77 | 78 | it "logs the connection info" do 79 | ActiveRecord::Base.connection.select_value("SELECT 42") 80 | 81 | logger.last.should =~ /\[slave:127.0.0.1:3311\] SQL .*SELECT 42/ 82 | end 83 | end 84 | 85 | context "when asked for master" do 86 | it "reads from master" do 87 | ActiveRecord::Base.with_master do 88 | should_read_from :master 89 | end 90 | end 91 | end 92 | 93 | context "when asked for slave" do 94 | it "reads from slave" do 95 | ActiveRecord::Base.with_slave do 96 | should_read_from :slave 97 | end 98 | end 99 | end 100 | 101 | context "when asked for consistency" do 102 | context "given slave is fully synced" do 103 | before do 104 | wait_for_replication_sync 105 | end 106 | 107 | it "reads from slave" do 108 | ActiveRecord::Base.with_consistency(connection.master_clock) do 109 | should_read_from :slave 110 | end 111 | end 112 | end 113 | 114 | context "given slave lags behind" do 115 | before do 116 | stop_replication 117 | move_master_clock 118 | end 119 | 120 | after do 121 | start_replication 122 | end 123 | 124 | it "reads from master" do 125 | ActiveRecord::Base.with_consistency(connection.master_clock) do 126 | should_read_from :master 127 | end 128 | end 129 | 130 | context "and slave catches up" do 131 | before do 132 | start_replication 133 | wait_for_replication_sync 134 | end 135 | 136 | it "reads from slave" do 137 | ActiveRecord::Base.with_consistency(connection.master_clock) do 138 | should_read_from :slave 139 | end 140 | end 141 | end 142 | end 143 | 144 | context "given we always wait for slave to catch up and be consistent" do 145 | before do 146 | start_replication 147 | end 148 | 149 | it "should always read from slave" do 150 | wait_for_replication_sync 151 | ActiveRecord::Base.with_consistency(connection.master_clock) do 152 | should_read_from :slave 153 | end 154 | move_master_clock 155 | wait_for_replication_sync 156 | ActiveRecord::Base.with_consistency(connection.master_clock) do 157 | should_read_from :slave 158 | end 159 | end 160 | end 161 | end 162 | 163 | context "given master goes away in between queries" do 164 | let(:query) { "INSERT INTO #{test_table} (message) VALUES ('test')" } 165 | 166 | after do 167 | start_master 168 | end 169 | 170 | it "raises a MasterUnavailable exception" do 171 | expect do 172 | ActiveRecord::Base.connection.insert(query) 173 | end.to_not raise_error 174 | 175 | stop_master 176 | 177 | expect do 178 | ActiveRecord::Base.connection.insert(query) 179 | end.to raise_error(ActiveRecord::MasterUnavailable) 180 | end 181 | end 182 | 183 | context "given master is not available" do 184 | before do 185 | stop_master 186 | end 187 | 188 | after do 189 | start_master 190 | end 191 | 192 | context "when asked for master" do 193 | it "fails" do 194 | expect do 195 | ActiveRecord::Base.with_master { should_read_from :master } 196 | end.to raise_error(ActiveRecord::MasterUnavailable) 197 | end 198 | end 199 | 200 | context "when asked for slave" do 201 | it "reads from slave" do 202 | ActiveRecord::Base.with_slave do 203 | should_read_from :slave 204 | end 205 | end 206 | end 207 | end 208 | 209 | context "given slave is not available" do 210 | before do 211 | stop_slave 212 | end 213 | 214 | after do 215 | start_slave 216 | end 217 | 218 | context "when asked for slave" do 219 | it "fails" do 220 | expect do 221 | ActiveRecord::Base.with_slave { should_read_from :slave } 222 | end.to raise_error(ActiveRecord::StatementInvalid) 223 | end 224 | end 225 | 226 | end 227 | 228 | end 229 | --------------------------------------------------------------------------------