master_slave_adapter 1.0.0.beta1 → 1.0.0.beta2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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