master_slave_adapter 1.0.0.beta1 → 1.0.0.beta2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,48 @@
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
+ def select_hash(conn, sql)
22
+ conn.select_one(sql)
23
+ end
24
+
25
+ CONNECTION_ERRORS = {
26
+ 2002 => "query: not connected", # CR_CONNECTION_ERROR
27
+ 2003 => "Can't connect to MySQL server on", # CR_CONN_HOST_ERROR
28
+ 2006 => "MySQL server has gone away", # CR_SERVER_GONE_ERROR
29
+ 2013 => "Lost connection to MySQL server during query", # CR_SERVER_LOST
30
+ -1 => "closed MySQL connection", # defined by Mysql2
31
+ }
32
+
33
+ def connection_error?(exception)
34
+ case exception
35
+ when ActiveRecord::StatementInvalid
36
+ CONNECTION_ERRORS.values.any? do |description|
37
+ exception.message.start_with?("Mysql2::Error: #{description}")
38
+ end
39
+ when Mysql2::Error
40
+ CONNECTION_ERRORS.keys.include?(exception.errno)
41
+ else
42
+ false
43
+ end
44
+ end
45
+
46
+ end
47
+ end
48
+ end
@@ -1,7 +1,7 @@
1
- require 'active_record'
2
1
  require 'active_record/connection_adapters/master_slave_adapter'
3
- require 'active_record/connection_adapters/master_slave_adapter/clock'
2
+ require 'active_record/connection_adapters/master_slave_adapter/shared_mysql_adapter_behavior'
4
3
  require 'active_record/connection_adapters/mysql_adapter'
4
+ require 'mysql'
5
5
 
6
6
  module ActiveRecord
7
7
  class Base
@@ -11,51 +11,31 @@ module ActiveRecord
11
11
  end
12
12
 
13
13
  module ConnectionAdapters
14
- class MysqlMasterSlaveAdapter < MasterSlaveAdapter::Base
15
- CONNECTION_ERRORS = [
16
- Mysql::Error::CR_CONNECTION_ERROR, # query: not connected
17
- Mysql::Error::CR_CONN_HOST_ERROR, # Can't connect to MySQL server on '%s' (%d)
18
- Mysql::Error::CR_SERVER_GONE_ERROR, # MySQL server has gone away
19
- Mysql::Error::CR_SERVER_LOST, # Lost connection to MySQL server during query
20
- ]
14
+ class MysqlMasterSlaveAdapter < AbstractAdapter
15
+ include MasterSlaveAdapter
16
+ include SharedMysqlAdapterBehavior
21
17
 
22
- def with_consistency(clock)
23
- clock =
24
- case clock
25
- when Clock then clock
26
- when String then Clock.parse(clock)
27
- when nil then Clock.zero
28
- end
29
-
30
- super(clock)
31
- end
18
+ private
32
19
 
33
- # TODO: only do the actual conenction specific things here
34
- def master_clock
35
- conn = master_connection
36
- if status = conn.uncached { conn.select_one("SHOW MASTER STATUS") }
37
- Clock.new(status['File'], status['Position'])
38
- else
39
- Clock.infinity
20
+ if MysqlAdapter.instance_methods.map(&:to_sym).include?(:exec_without_stmt)
21
+ # The MysqlAdapter in ActiveRecord > v3.1 uses prepared statements which
22
+ # don't return any results for queries like "SHOW MASTER/SLAVE STATUS",
23
+ # so we have to use normal queries here.
24
+ def select_hash(conn, sql)
25
+ conn.exec_without_stmt(sql).first
40
26
  end
41
- rescue MasterUnavailable
42
- Clock.zero
43
- rescue
44
- Clock.infinity
45
- end
46
-
47
- # TODO: only do the actual conenction specific things here
48
- def slave_clock(conn)
49
- if status = conn.uncached { conn.select_one("SHOW SLAVE STATUS") }
50
- Clock.new(status['Relay_Master_Log_File'], status['Exec_Master_Log_Pos'])
51
- else
52
- Clock.zero
27
+ else
28
+ def select_hash(conn, sql)
29
+ conn.select_one(sql)
53
30
  end
54
- rescue
55
- Clock.zero
56
31
  end
57
32
 
58
- private
33
+ CONNECTION_ERRORS = [
34
+ Mysql::Error::CR_CONNECTION_ERROR, # query: not connected
35
+ Mysql::Error::CR_CONN_HOST_ERROR, # Can't connect to MySQL server on '%s' (%d)
36
+ Mysql::Error::CR_SERVER_GONE_ERROR, # MySQL server has gone away
37
+ Mysql::Error::CR_SERVER_LOST, # Lost connection to MySQL server during query
38
+ ]
59
39
 
60
40
  def connection_error?(exception)
61
41
  case exception
@@ -67,6 +47,7 @@ module ActiveRecord
67
47
  false
68
48
  end
69
49
  end
50
+
70
51
  end
71
52
  end
72
53
  end
@@ -20,8 +20,8 @@ Gem::Specification.new do |s|
20
20
  s.required_ruby_version = '>= 1.8.7'
21
21
  s.required_rubygems_version = '>= 1.3.7'
22
22
 
23
- s.add_development_dependency 'rspec'
24
- s.add_development_dependency 'rake'
23
+ s.add_dependency 'activerecord', ['>= 2.3.9', '<= 4.0']
25
24
 
26
- s.add_dependency 'activerecord', '~> 2.3.9'
25
+ s.add_development_dependency 'rake'
26
+ s.add_development_dependency 'rspec'
27
27
  end
data/spec/all.sh ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+
3
+ source ~/.rvm/scripts/rvm
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
@@ -0,0 +1,5 @@
1
+ source :rubygems
2
+
3
+ gem "mysql", "~> 2.8.1"
4
+ gem "activerecord", "~> 2.3.14"
5
+ gemspec :path=>"../../"
@@ -0,0 +1,5 @@
1
+ source :rubygems
2
+
3
+ gem "mysql", "~> 2.8.1"
4
+ gem "activerecord", "~> 3.0.0"
5
+ gemspec :path=>"../../"
@@ -0,0 +1,6 @@
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=>"../../"
@@ -0,0 +1,174 @@
1
+ require 'fileutils'
2
+ require 'timeout'
3
+
4
+ module MysqlHelper
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
+ data_path = File.join(path, "data")
77
+ base_dir = File.dirname(File.dirname(`which mysql_install_db`))
78
+
79
+ FileUtils.rm_rf(path)
80
+ FileUtils.mkdir_p(path)
81
+ File.open(config_path, "w") { |file| file << config(name) }
82
+
83
+ `mysql_install_db --basedir='#{base_dir}' --datadir='#{data_path}'`
84
+ end
85
+ end
86
+
87
+ def start_master
88
+ start(:master)
89
+ end
90
+
91
+ def stop_master
92
+ stop(:master)
93
+ end
94
+
95
+ def start_slave
96
+ start(:slave)
97
+ end
98
+
99
+ def stop_slave
100
+ stop(:slave)
101
+ end
102
+
103
+ private
104
+
105
+ def slave_status
106
+ status(:slave).values_at(9, 21)
107
+ end
108
+
109
+ def master_status
110
+ status(:master).values_at(0, 1)
111
+ end
112
+
113
+ def status(name)
114
+ `mysql --protocol=TCP -P#{port(name)} -uroot -N -s -e 'show #{name} status'`.strip.split("\t")
115
+ end
116
+
117
+ def execute(host, statement = '')
118
+ system(%{mysql --protocol=TCP -P#{port(host)} -uroot -e "#{statement}"})
119
+ end
120
+
121
+ def start(name)
122
+ $pipes ||= {}
123
+ $pipes[name] = IO.popen("mysqld --defaults-file='#{location(name)}/my.cnf'")
124
+ wait_for_database_boot(name)
125
+ end
126
+
127
+ def stop(name)
128
+ pipe = $pipes[name]
129
+ Process.kill("KILL", pipe.pid)
130
+ Process.wait(pipe.pid, Process::WNOHANG)
131
+
132
+ # Ruby 1.8.7 doesn't support IO.popen([cmd, [arg, ]]) syntax, and passing
133
+ # the command line as string wraps the process in a shell. The IO#pid method
134
+ # will then only return the pid of the wrapping shell process, which is not
135
+ # what we need here.
136
+ mysqld_pid = `ps a | grep 'mysqld.*#{location(name)}/my.cnf' | grep -v grep | awk '{print $1}'`.to_i
137
+ Process.kill("KILL", mysqld_pid) unless mysqld_pid.zero?
138
+ ensure
139
+ pipe.close unless pipe.closed?
140
+ end
141
+
142
+ def started?(host)
143
+ system(%{mysql --protocol=TCP -P#{port(host)} -uroot -e '' 2> /dev/null})
144
+ end
145
+
146
+ def wait_for_database_boot(host)
147
+ Timeout.timeout(5) do
148
+ until started?(host); sleep(0.1); end
149
+ end
150
+ rescue Timeout::Error
151
+ raise "Couldn't connect to MySQL in time"
152
+ end
153
+
154
+ def location(name)
155
+ File.expand_path(File.join("..", "mysql", name.to_s), File.dirname(__FILE__))
156
+ end
157
+
158
+ def config(name)
159
+ path = location(name)
160
+
161
+ <<-EOS
162
+ [mysqld]
163
+ pid-file = #{path}/mysqld.pid
164
+ socket = #{path}/mysqld.sock
165
+ port = #{port(name)}
166
+ log-error = #{path}/error.log
167
+ datadir = #{path}/data
168
+ log-bin = #{name}-bin
169
+ log-bin-index = #{name}-bin.index
170
+ server-id = #{server_id(name)}
171
+ lower_case_table_names = 1
172
+ EOS
173
+ end
174
+ end
@@ -0,0 +1,212 @@
1
+ require 'integration/helpers/mysql_helper'
2
+
3
+ shared_examples_for "a MySQL MasterSlaveAdapter" do
4
+ include MysqlHelper
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) { MysqlHelper::TEST_TABLE }
24
+
25
+ def connection
26
+ ActiveRecord::Base.connection
27
+ end
28
+
29
+ def should_read_from(host)
30
+ server = server_id(host)
31
+ query = "SELECT @@Server_id as Value"
32
+
33
+ connection.select_all(query).first["Value"].to_s.should == server
34
+ connection.select_one(query)["Value"].to_s.should == server
35
+ connection.select_rows(query).first.first.to_s.should == server
36
+ connection.select_value(query).to_s.should == server
37
+ connection.select_values(query).first.to_s.should == server
38
+ end
39
+
40
+ before(:all) do
41
+ setup
42
+ start_master
43
+ start_slave
44
+ configure
45
+ start_replication
46
+ end
47
+
48
+ after(:all) do
49
+ stop_master
50
+ stop_slave
51
+ end
52
+
53
+ before do
54
+ ActiveRecord::Base.establish_connection(configuration)
55
+ end
56
+
57
+ it "connects to the database" do
58
+ expect { ActiveRecord::Base.connection }.to_not raise_error
59
+ end
60
+
61
+ context "given a debug logger" do
62
+ let(:debug_logger) do
63
+ logger = []
64
+ def logger.debug(*args)
65
+ push(args.join)
66
+ end
67
+ def logger.debug?
68
+ true
69
+ end
70
+
71
+ logger
72
+ end
73
+
74
+ before do
75
+ ActiveRecord::Base.logger = debug_logger
76
+ end
77
+
78
+ after do
79
+ ActiveRecord::Base.logger = nil
80
+ end
81
+
82
+ it "logs the connection info" do
83
+ ActiveRecord::Base.connection.select_value("SELECT 42")
84
+
85
+ debug_logger.last.should =~ /\[slave:127.0.0.1:3311\] SQL .*SELECT 42/
86
+ end
87
+ end
88
+
89
+ context "when asked for master" do
90
+ it "reads from master" do
91
+ ActiveRecord::Base.with_master do
92
+ should_read_from :master
93
+ end
94
+ end
95
+ end
96
+
97
+ context "when asked for slave" do
98
+ it "reads from slave" do
99
+ ActiveRecord::Base.with_slave do
100
+ should_read_from :slave
101
+ end
102
+ end
103
+ end
104
+
105
+ context "when asked for consistency" do
106
+ context "given slave is fully synced" do
107
+ before do
108
+ wait_for_replication_sync
109
+ end
110
+
111
+ it "reads from slave" do
112
+ ActiveRecord::Base.with_consistency(connection.master_clock) do
113
+ should_read_from :slave
114
+ end
115
+ end
116
+ end
117
+
118
+ context "given slave lags behind" do
119
+ before do
120
+ stop_replication
121
+ move_master_clock
122
+ end
123
+
124
+ after do
125
+ start_replication
126
+ end
127
+
128
+ it "reads from master" do
129
+ ActiveRecord::Base.with_consistency(connection.master_clock) do
130
+ should_read_from :master
131
+ end
132
+ end
133
+
134
+ context "and slave catches up" do
135
+ before do
136
+ start_replication
137
+ wait_for_replication_sync
138
+ end
139
+
140
+ it "reads from slave" do
141
+ ActiveRecord::Base.with_consistency(connection.master_clock) do
142
+ should_read_from :slave
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ context "given we always wait for slave to catch up and be consistent" do
149
+ before do
150
+ start_replication
151
+ end
152
+
153
+ it "should always read from slave" do
154
+ wait_for_replication_sync
155
+ ActiveRecord::Base.with_consistency(connection.master_clock) do
156
+ should_read_from :slave
157
+ end
158
+ move_master_clock
159
+ wait_for_replication_sync
160
+ ActiveRecord::Base.with_consistency(connection.master_clock) do
161
+ should_read_from :slave
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ context "given master goes away in between queries" do
168
+ let(:query) { "INSERT INTO #{test_table} (message) VALUES ('test')" }
169
+
170
+ after do
171
+ start_master
172
+ end
173
+
174
+ it "raises a MasterUnavailable exception" do
175
+ expect do
176
+ ActiveRecord::Base.connection.insert(query)
177
+ end.to_not raise_error
178
+
179
+ stop_master
180
+
181
+ expect do
182
+ ActiveRecord::Base.connection.insert(query)
183
+ end.to raise_error(ActiveRecord::MasterUnavailable)
184
+ end
185
+ end
186
+
187
+ context "given master is not available" do
188
+ before(:all) do
189
+ stop_master
190
+ end
191
+
192
+ after(:all) do
193
+ start_master
194
+ end
195
+
196
+ context "when asked for master" do
197
+ it "fails" do
198
+ expect do
199
+ ActiveRecord::Base.with_master { should_read_from :master }
200
+ end.to raise_error(ActiveRecord::MasterUnavailable)
201
+ end
202
+ end
203
+
204
+ context "when asked for slave" do
205
+ it "reads from slave" do
206
+ ActiveRecord::Base.with_slave do
207
+ should_read_from :slave
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,11 @@
1
+ $: << File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'lib'))
2
+
3
+ require 'rspec'
4
+ require 'master_slave_adapter'
5
+ require 'integration/helpers/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
@@ -0,0 +1,11 @@
1
+ $: << File.expand_path(File.join(File.dirname(__FILE__), '..', '..', 'lib'))
2
+
3
+ require 'rspec'
4
+ require 'master_slave_adapter'
5
+ require 'integration/helpers/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