lhm-shopify 3.5.0 → 3.5.4
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.
- checksums.yaml +4 -4
- data/.github/workflows/test.yml +17 -4
- data/.gitignore +0 -2
- data/Appraisals +24 -0
- data/CHANGELOG.md +23 -0
- data/Gemfile.lock +66 -0
- data/README.md +53 -0
- data/Rakefile +6 -5
- data/dev.yml +18 -3
- data/docker-compose.yml +15 -3
- data/gemfiles/activerecord_5.2.gemfile +9 -0
- data/gemfiles/activerecord_5.2.gemfile.lock +65 -0
- data/gemfiles/activerecord_6.0.gemfile +7 -0
- data/gemfiles/activerecord_6.0.gemfile.lock +67 -0
- data/gemfiles/activerecord_6.1.gemfile +7 -0
- data/gemfiles/activerecord_6.1.gemfile.lock +66 -0
- data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
- data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +64 -0
- data/lhm.gemspec +7 -3
- data/lib/lhm/atomic_switcher.rb +4 -3
- data/lib/lhm/chunk_insert.rb +7 -3
- data/lib/lhm/chunker.rb +6 -6
- data/lib/lhm/cleanup/current.rb +4 -1
- data/lib/lhm/connection.rb +66 -19
- data/lib/lhm/entangler.rb +5 -4
- data/lib/lhm/invoker.rb +5 -3
- data/lib/lhm/locked_switcher.rb +2 -0
- data/lib/lhm/proxysql_helper.rb +10 -0
- data/lib/lhm/sql_retry.rb +135 -11
- data/lib/lhm/throttler/slave_lag.rb +19 -2
- data/lib/lhm/version.rb +1 -1
- data/lib/lhm.rb +32 -12
- data/scripts/mysql/writer/create_users.sql +3 -0
- data/spec/integration/atomic_switcher_spec.rb +38 -10
- data/spec/integration/chunk_insert_spec.rb +2 -1
- data/spec/integration/chunker_spec.rb +8 -6
- data/spec/integration/database.yml +10 -0
- data/spec/integration/entangler_spec.rb +3 -1
- data/spec/integration/integration_helper.rb +20 -4
- data/spec/integration/lhm_spec.rb +75 -0
- data/spec/integration/proxysql_spec.rb +34 -0
- data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
- data/spec/integration/sql_retry/lock_wait_spec.rb +8 -6
- data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +19 -9
- data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
- data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +108 -0
- data/spec/integration/toxiproxy_helper.rb +40 -0
- data/spec/test_helper.rb +21 -0
- data/spec/unit/chunk_insert_spec.rb +7 -2
- data/spec/unit/chunker_spec.rb +46 -42
- data/spec/unit/connection_spec.rb +51 -8
- data/spec/unit/entangler_spec.rb +71 -19
- data/spec/unit/lhm_spec.rb +17 -0
- data/spec/unit/throttler/slave_lag_spec.rb +14 -9
- metadata +76 -11
- data/gemfiles/ar-2.3_mysql.gemfile +0 -6
- data/gemfiles/ar-3.2_mysql.gemfile +0 -5
- data/gemfiles/ar-3.2_mysql2.gemfile +0 -5
- data/gemfiles/ar-4.0_mysql2.gemfile +0 -5
- data/gemfiles/ar-4.1_mysql2.gemfile +0 -5
- data/gemfiles/ar-4.2_mysql2.gemfile +0 -5
- data/gemfiles/ar-5.0_mysql2.gemfile +0 -5
@@ -16,9 +16,9 @@ describe Lhm::AtomicSwitcher do
|
|
16
16
|
describe 'switching' do
|
17
17
|
before(:each) do
|
18
18
|
Thread.abort_on_exception = true
|
19
|
-
@origin
|
19
|
+
@origin = table_create('origin')
|
20
20
|
@destination = table_create('destination')
|
21
|
-
@migration
|
21
|
+
@migration = Lhm::Migration.new(@origin, @destination)
|
22
22
|
@logs = StringIO.new
|
23
23
|
Lhm.logger = Logger.new(@logs)
|
24
24
|
@connection.execute('SET GLOBAL innodb_lock_wait_timeout=3')
|
@@ -32,11 +32,22 @@ describe Lhm::AtomicSwitcher do
|
|
32
32
|
it 'should retry and log on lock wait timeouts' do
|
33
33
|
ar_connection = mock()
|
34
34
|
ar_connection.stubs(:data_source_exists?).returns(true)
|
35
|
-
ar_connection.stubs(:
|
35
|
+
ar_connection.stubs(:active?).returns(true)
|
36
|
+
ar_connection.stubs(:execute).returns([["dummy"]], [["dummy"]], [["dummy"]])
|
37
|
+
.then
|
38
|
+
.raises(ActiveRecord::StatementInvalid, 'Lock wait timeout exceeded; try restarting transaction.')
|
39
|
+
.then
|
40
|
+
.returns([["dummy"]]) # Matches initial host -> triggers retry
|
41
|
+
|
42
|
+
connection = Lhm::Connection.new(connection: ar_connection, options: {
|
43
|
+
reconnect_with_consistent_host: true,
|
44
|
+
retriable: {
|
45
|
+
tries: 3,
|
46
|
+
base_interval: 0
|
47
|
+
}
|
48
|
+
})
|
36
49
|
|
37
|
-
|
38
|
-
|
39
|
-
switcher = Lhm::AtomicSwitcher.new(@migration, connection, retriable: {base_interval: 0})
|
50
|
+
switcher = Lhm::AtomicSwitcher.new(@migration, connection)
|
40
51
|
|
41
52
|
assert switcher.run
|
42
53
|
|
@@ -50,11 +61,28 @@ describe Lhm::AtomicSwitcher do
|
|
50
61
|
it 'should give up on lock wait timeouts after a configured number of tries' do
|
51
62
|
ar_connection = mock()
|
52
63
|
ar_connection.stubs(:data_source_exists?).returns(true)
|
53
|
-
ar_connection.stubs(:
|
64
|
+
ar_connection.stubs(:active?).returns(true)
|
65
|
+
ar_connection.stubs(:execute).returns([["dummy"]], [["dummy"]], [["dummy"]])
|
66
|
+
.then
|
67
|
+
.raises(ActiveRecord::StatementInvalid, 'Lock wait timeout exceeded; try restarting transaction.')
|
68
|
+
.then
|
69
|
+
.returns([["dummy"]]) # triggers retry 1
|
70
|
+
.then
|
71
|
+
.raises(ActiveRecord::StatementInvalid, 'Lock wait timeout exceeded; try restarting transaction.')
|
72
|
+
.then
|
73
|
+
.returns([["dummy"]]) # triggers retry 2
|
74
|
+
.then
|
75
|
+
.raises(ActiveRecord::StatementInvalid, 'Lock wait timeout exceeded; try restarting transaction.') # triggers retry 2
|
76
|
+
|
77
|
+
connection = Lhm::Connection.new(connection: ar_connection, options: {
|
78
|
+
reconnect_with_consistent_host: true,
|
79
|
+
retriable: {
|
80
|
+
tries: 2,
|
81
|
+
base_interval: 0
|
82
|
+
}
|
83
|
+
})
|
54
84
|
|
55
|
-
|
56
|
-
|
57
|
-
switcher = Lhm::AtomicSwitcher.new(@migration, connection, retriable: {tries: 2, base_interval: 0})
|
85
|
+
switcher = Lhm::AtomicSwitcher.new(@migration, connection)
|
58
86
|
|
59
87
|
assert_raises(ActiveRecord::StatementInvalid) { switcher.run }
|
60
88
|
end
|
@@ -11,7 +11,8 @@ describe Lhm::ChunkInsert do
|
|
11
11
|
@destination = table_create(:destination)
|
12
12
|
@migration = Lhm::Migration.new(@origin, @destination)
|
13
13
|
execute("insert into origin set id = 1001")
|
14
|
-
@
|
14
|
+
@connection = Lhm::Connection.new(connection: connection)
|
15
|
+
@instance = Lhm::ChunkInsert.new(@migration, @connection, 1001, 1001)
|
15
16
|
end
|
16
17
|
|
17
18
|
it "returns the count" do
|
@@ -173,6 +173,9 @@ describe Lhm::Chunker do
|
|
173
173
|
printer.expect(:notify, :return_value, [Integer, Integer])
|
174
174
|
printer.expect(:end, :return_value, [])
|
175
175
|
|
176
|
+
Lhm::Throttler::Slave.any_instance.stubs(:slave_hosts).returns(['127.0.0.1'])
|
177
|
+
Lhm::Throttler::SlaveLag.any_instance.stubs(:master_slave_hosts).returns(['127.0.0.1'])
|
178
|
+
|
176
179
|
Lhm::Chunker.new(
|
177
180
|
@migration, connection, { throttler: Lhm::Throttler::SlaveLag.new(stride: 100), printer: printer }
|
178
181
|
).run
|
@@ -215,17 +218,16 @@ describe Lhm::Chunker do
|
|
215
218
|
printer.expects(:verify)
|
216
219
|
printer.expects(:end)
|
217
220
|
|
218
|
-
|
221
|
+
Lhm::Throttler::Slave.any_instance.stubs(:slave_hosts).returns(['127.0.0.1'])
|
222
|
+
Lhm::Throttler::SlaveLag.any_instance.stubs(:master_slave_hosts).returns(['127.0.0.1'])
|
219
223
|
|
220
|
-
|
221
|
-
['127.0.0.1']
|
222
|
-
end
|
224
|
+
throttler = Lhm::Throttler::SlaveLag.new(stride: 10, allowed_lag: 0)
|
223
225
|
|
224
226
|
if master_slave_mode?
|
225
227
|
def throttler.slave_connection(slave)
|
226
|
-
config = ActiveRecord::Base.connection_pool.
|
228
|
+
config = ActiveRecord::Base.connection_pool.db_config.configuration_hash.dup
|
227
229
|
config[:host] = slave
|
228
|
-
config[:port] =
|
230
|
+
config[:port] = 33007
|
229
231
|
ActiveRecord::Base.send('mysql2_connection', config)
|
230
232
|
end
|
231
233
|
end
|
@@ -6,6 +6,7 @@ require File.expand_path(File.dirname(__FILE__)) + '/integration_helper'
|
|
6
6
|
require 'lhm/table'
|
7
7
|
require 'lhm/migration'
|
8
8
|
require 'lhm/entangler'
|
9
|
+
require 'lhm/connection'
|
9
10
|
|
10
11
|
describe Lhm::Entangler do
|
11
12
|
include IntegrationHelper
|
@@ -17,7 +18,8 @@ describe Lhm::Entangler do
|
|
17
18
|
@origin = table_create('origin')
|
18
19
|
@destination = table_create('destination')
|
19
20
|
@migration = Lhm::Migration.new(@origin, @destination)
|
20
|
-
@
|
21
|
+
@connection = Lhm::Connection.new(connection: connection)
|
22
|
+
@entangler = Lhm::Entangler.new(@migration, @connection)
|
21
23
|
end
|
22
24
|
|
23
25
|
it 'should replay inserts from origin into destination' do
|
@@ -35,6 +35,15 @@ module IntegrationHelper
|
|
35
35
|
@connection
|
36
36
|
end
|
37
37
|
|
38
|
+
def connect_proxysql!
|
39
|
+
connect!(
|
40
|
+
'127.0.0.1',
|
41
|
+
$db_config['proxysql']['port'],
|
42
|
+
$db_config['proxysql']['user'],
|
43
|
+
$db_config['proxysql']['password'],
|
44
|
+
)
|
45
|
+
end
|
46
|
+
|
38
47
|
def connect_master!
|
39
48
|
connect!(
|
40
49
|
'127.0.0.1',
|
@@ -53,14 +62,21 @@ module IntegrationHelper
|
|
53
62
|
)
|
54
63
|
end
|
55
64
|
|
65
|
+
def connect_master_with_toxiproxy!
|
66
|
+
connect!(
|
67
|
+
'127.0.0.1',
|
68
|
+
$db_config['master_toxic']['port'],
|
69
|
+
$db_config['master_toxic']['user'],
|
70
|
+
$db_config['master_toxic']['password'])
|
71
|
+
end
|
72
|
+
|
56
73
|
def connect!(hostname, port, user, password)
|
57
|
-
|
58
|
-
Lhm.setup(adapter)
|
74
|
+
Lhm.setup(ar_conn(hostname, port, user, password))
|
59
75
|
unless defined?(@@cleaned_up)
|
60
76
|
Lhm.cleanup(true)
|
61
77
|
@@cleaned_up = true
|
62
78
|
end
|
63
|
-
@connection =
|
79
|
+
@connection = Lhm.connection
|
64
80
|
end
|
65
81
|
|
66
82
|
def ar_conn(host, port, user, password)
|
@@ -119,7 +135,7 @@ module IntegrationHelper
|
|
119
135
|
# Helps testing behaviour when another client locks the db
|
120
136
|
def start_locking_thread(lock_for, queue, locking_query)
|
121
137
|
Thread.new do
|
122
|
-
conn =
|
138
|
+
conn = new_mysql_connection
|
123
139
|
conn.query('BEGIN')
|
124
140
|
conn.query(locking_query)
|
125
141
|
queue.push(true)
|
@@ -2,6 +2,7 @@
|
|
2
2
|
# Schmidt
|
3
3
|
|
4
4
|
require File.expand_path(File.dirname(__FILE__)) + '/integration_helper'
|
5
|
+
require 'integration/toxiproxy_helper'
|
5
6
|
|
6
7
|
describe Lhm do
|
7
8
|
include IntegrationHelper
|
@@ -581,5 +582,79 @@ describe Lhm do
|
|
581
582
|
end
|
582
583
|
end
|
583
584
|
end
|
585
|
+
|
586
|
+
describe 'connection' do
|
587
|
+
include ToxiproxyHelper
|
588
|
+
|
589
|
+
before(:each) do
|
590
|
+
@logs = StringIO.new
|
591
|
+
Lhm.logger = Logger.new(@logs)
|
592
|
+
end
|
593
|
+
|
594
|
+
it " should not try to reconnect if reconnect_with_consistent_host is not provided" do
|
595
|
+
connect_master_with_toxiproxy!
|
596
|
+
|
597
|
+
table_create(:users)
|
598
|
+
100.times { |n| execute("insert into users set reference = '#{ n }'") }
|
599
|
+
|
600
|
+
assert_raises ActiveRecord::StatementInvalid do
|
601
|
+
Toxiproxy[:mysql_master].down do
|
602
|
+
Lhm.change_table(:users, :atomic_switch => false) do |t|
|
603
|
+
t.ddl("ALTER TABLE #{t.name} CHANGE id id bigint (20) NOT NULL")
|
604
|
+
t.ddl("ALTER TABLE #{t.name} DROP PRIMARY KEY, ADD PRIMARY KEY (username, id)")
|
605
|
+
t.ddl("ALTER TABLE #{t.name} ADD INDEX (id)")
|
606
|
+
t.ddl("ALTER TABLE #{t.name} CHANGE id id bigint (20) NOT NULL AUTO_INCREMENT")
|
607
|
+
end
|
608
|
+
end
|
609
|
+
end
|
610
|
+
end
|
611
|
+
|
612
|
+
it "should reconnect if reconnect_with_consistent_host is true" do
|
613
|
+
connect_master_with_toxiproxy!
|
614
|
+
mysql_disabled = false
|
615
|
+
|
616
|
+
table_create(:users)
|
617
|
+
100.times { |n| execute("insert into users set reference = '#{ n }'") }
|
618
|
+
|
619
|
+
# Redeclare Lhm::ChunkInsert to use Hook to disable MySQL writer for 3 seconds before first insert
|
620
|
+
Lhm::ChunkInsert.class_eval do
|
621
|
+
extend AfterDo
|
622
|
+
|
623
|
+
before(:insert_and_return_count_of_rows_created) do
|
624
|
+
unless mysql_disabled
|
625
|
+
mysql_disabled = true
|
626
|
+
Thread.new do
|
627
|
+
Toxiproxy[:mysql_master].down do
|
628
|
+
sleep 3
|
629
|
+
end
|
630
|
+
end
|
631
|
+
end
|
632
|
+
end
|
633
|
+
|
634
|
+
# Need to call `#method_added` manually to have the hooks take into effect
|
635
|
+
method_added(:insert_and_return_count_of_rows_created)
|
636
|
+
end
|
637
|
+
|
638
|
+
Lhm.change_table(:users, atomic_switch: false, reconnect_with_consistent_host: true) do |t|
|
639
|
+
t.ddl("ALTER TABLE #{t.name} CHANGE id id bigint (20) NOT NULL")
|
640
|
+
t.ddl("ALTER TABLE #{t.name} DROP PRIMARY KEY, ADD PRIMARY KEY (username, id)")
|
641
|
+
t.ddl("ALTER TABLE #{t.name} ADD INDEX (id)")
|
642
|
+
t.ddl("ALTER TABLE #{t.name} CHANGE id id bigint (20) NOT NULL AUTO_INCREMENT")
|
643
|
+
end
|
644
|
+
|
645
|
+
log_lines = @logs.string.split("\n")
|
646
|
+
|
647
|
+
assert log_lines.one?{ |line| line.include?("Lost connection to MySQL, will retry to connect to same host")}
|
648
|
+
assert log_lines.any?{ |line| line.include?("Lost connection to MySQL server at 'reading initial communication packet'")}
|
649
|
+
assert log_lines.one?{ |line| line.include?("LHM successfully reconnected to initial host")}
|
650
|
+
assert log_lines.one?{ |line| line.include?("100% complete")}
|
651
|
+
|
652
|
+
Lhm::ChunkInsert.remove_all_callbacks
|
653
|
+
|
654
|
+
slave do
|
655
|
+
value(count_all(:users)).must_equal(100)
|
656
|
+
end
|
657
|
+
end
|
658
|
+
end
|
584
659
|
end
|
585
660
|
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
describe "ProxySQL integration" do
|
2
|
+
it "Should contact the writer" do
|
3
|
+
conn = Mysql2::Client.new(
|
4
|
+
host: '127.0.0.1',
|
5
|
+
username: "writer",
|
6
|
+
password: "password",
|
7
|
+
port: "33005",
|
8
|
+
)
|
9
|
+
|
10
|
+
assert_equal conn.query("SELECT @@global.hostname as host").each.first["host"], "mysql-1"
|
11
|
+
end
|
12
|
+
|
13
|
+
it "Should contact the reader" do
|
14
|
+
conn = Mysql2::Client.new(
|
15
|
+
host: '127.0.0.1',
|
16
|
+
username: "reader",
|
17
|
+
password: "password",
|
18
|
+
port: "33005",
|
19
|
+
)
|
20
|
+
|
21
|
+
assert_equal conn.query("SELECT @@global.hostname as host").each.first["host"], "mysql-2"
|
22
|
+
end
|
23
|
+
|
24
|
+
it "Should override default hostgroup from user if rule matches" do
|
25
|
+
conn = Mysql2::Client.new(
|
26
|
+
host: '127.0.0.1',
|
27
|
+
username: "reader",
|
28
|
+
password: "password",
|
29
|
+
port: "33005",
|
30
|
+
)
|
31
|
+
|
32
|
+
assert_equal conn.query("SELECT @@global.hostname as host #{Lhm::ProxySQLHelper::ANNOTATION}").each.first["host"], "mysql-1"
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'mysql2'
|
3
|
+
|
4
|
+
class DBConnectionHelper
|
5
|
+
|
6
|
+
DATABASE_CONFIG_FILE = "database.yml"
|
7
|
+
|
8
|
+
class << self
|
9
|
+
def db_config
|
10
|
+
@db_config ||= YAML.load_file(File.expand_path(File.dirname(__FILE__)) + "/../#{DATABASE_CONFIG_FILE}")
|
11
|
+
end
|
12
|
+
|
13
|
+
def new_mysql_connection(role = :master, with_data = false, toxic = false)
|
14
|
+
|
15
|
+
key = role.to_s + toxic_postfix(toxic)
|
16
|
+
|
17
|
+
conn = ActiveRecord::Base.establish_connection(
|
18
|
+
:host => '127.0.0.1',
|
19
|
+
:adapter => "mysql2",
|
20
|
+
:username => db_config[key]['user'],
|
21
|
+
:password => db_config[key]['password'],
|
22
|
+
:database => test_db_name,
|
23
|
+
:port => db_config[key]['port']
|
24
|
+
)
|
25
|
+
conn = conn.connection
|
26
|
+
init_with_dummy_data(conn) if with_data
|
27
|
+
conn
|
28
|
+
end
|
29
|
+
|
30
|
+
def toxic_postfix(toxic)
|
31
|
+
toxic ? "_toxic" : ""
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_db_name
|
35
|
+
@test_db_name ||= "test"
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_table_name
|
39
|
+
@test_table_name ||= "test"
|
40
|
+
end
|
41
|
+
|
42
|
+
def init_with_dummy_data(conn)
|
43
|
+
conn.execute("DROP TABLE IF EXISTS #{test_table_name} ")
|
44
|
+
conn.execute("CREATE TABLE #{test_table_name} (id int)")
|
45
|
+
|
46
|
+
1.upto(9) do |i|
|
47
|
+
query = "INSERT INTO #{test_table_name} (id) VALUE (#{i})"
|
48
|
+
conn.execute(query)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
@@ -21,6 +21,8 @@ describe Lhm::SqlRetry do
|
|
21
21
|
|
22
22
|
# Assert our pre-conditions
|
23
23
|
assert_equal 2, @helper.record_count
|
24
|
+
|
25
|
+
Mysql2::Client.any_instance.stubs(:active?).returns(true)
|
24
26
|
end
|
25
27
|
|
26
28
|
after(:each) do
|
@@ -41,7 +43,7 @@ describe Lhm::SqlRetry do
|
|
41
43
|
|
42
44
|
exception = assert_raises { @helper.trigger_wait_lock }
|
43
45
|
|
44
|
-
|
46
|
+
assert_match /Lock wait timeout exceeded; try restarting transaction/, exception.message
|
45
47
|
assert_equal Mysql2::Error::TimeoutError, exception.class
|
46
48
|
|
47
49
|
assert_equal 2, @helper.record_count # no records inserted
|
@@ -53,12 +55,12 @@ describe Lhm::SqlRetry do
|
|
53
55
|
it "successfully executes the SQL despite the errors encountered" do
|
54
56
|
# Start a thread to retry, once the lock is held, execute the block
|
55
57
|
@helper.with_waiting_lock do |waiting_connection|
|
56
|
-
sql_retry = Lhm::SqlRetry.new(waiting_connection, {
|
58
|
+
sql_retry = Lhm::SqlRetry.new(waiting_connection, retry_options: {
|
57
59
|
base_interval: 0.2, # first retry after 200ms
|
58
60
|
multiplier: 1, # subsequent retries wait 1x longer than first retry (no change)
|
59
61
|
tries: 3, # we only need 3 tries (including the first) for the scenario described below
|
60
62
|
rand_factor: 0 # do not introduce randomness to wait timer
|
61
|
-
})
|
63
|
+
}, reconnect_with_consistent_host: false)
|
62
64
|
|
63
65
|
# RetryTestHelper is configured to hold lock for 5 seconds and timeout after 2 seconds.
|
64
66
|
# Therefore the sequence of events will be:
|
@@ -96,12 +98,12 @@ describe Lhm::SqlRetry do
|
|
96
98
|
puts "*" * 64
|
97
99
|
# Start a thread to retry, once the lock is held, execute the block
|
98
100
|
@helper.with_waiting_lock do |waiting_connection|
|
99
|
-
sql_retry = Lhm::SqlRetry.new(waiting_connection, {
|
101
|
+
sql_retry = Lhm::SqlRetry.new(waiting_connection, retry_options: {
|
100
102
|
base_interval: 0.2, # first retry after 200ms
|
101
103
|
multiplier: 1, # subsequent retries wait 1x longer than first retry (no change)
|
102
104
|
tries: 2, # we need 3 tries (including the first) for the scenario described below, but we only get two...we will fail
|
103
105
|
rand_factor: 0 # do not introduce randomness to wait timer
|
104
|
-
})
|
106
|
+
}, reconnect_with_consistent_host: false)
|
105
107
|
|
106
108
|
# RetryTestHelper is configured to hold lock for 5 seconds and timeout after 2 seconds.
|
107
109
|
# Therefore the sequence of events will be:
|
@@ -116,7 +118,7 @@ describe Lhm::SqlRetry do
|
|
116
118
|
|
117
119
|
exception = assert_raises { @helper.trigger_wait_lock }
|
118
120
|
|
119
|
-
|
121
|
+
assert_match /Lock wait timeout exceeded; try restarting transaction/, exception.message
|
120
122
|
assert_equal Mysql2::Error::TimeoutError, exception.class
|
121
123
|
|
122
124
|
assert_equal 2, @helper.record_count # no records inserted
|
@@ -1,5 +1,7 @@
|
|
1
|
-
require '
|
1
|
+
require 'integration/integration_helper'
|
2
|
+
|
2
3
|
class LockWaitTimeoutTestHelper
|
4
|
+
|
3
5
|
def initialize(lock_duration:, innodb_lock_wait_timeout:)
|
4
6
|
# This connection will be used exclusively to setup the test,
|
5
7
|
# assert pre-conditions and assert post-conditions.
|
@@ -68,7 +70,7 @@ class LockWaitTimeoutTestHelper
|
|
68
70
|
|
69
71
|
def insert_records_at_ids(connection, ids)
|
70
72
|
ids.each do |id|
|
71
|
-
connection
|
73
|
+
mysql_exec(connection, "INSERT INTO #{test_table_name} (id) VALUES (#{id})")
|
72
74
|
end
|
73
75
|
end
|
74
76
|
|
@@ -77,17 +79,13 @@ class LockWaitTimeoutTestHelper
|
|
77
79
|
attr_reader :main_conn, :lock_duration, :innodb_lock_wait_timeout
|
78
80
|
|
79
81
|
def new_mysql_connection
|
80
|
-
|
82
|
+
Mysql2::Client.new(
|
81
83
|
host: '127.0.0.1',
|
82
84
|
username: db_config['master']['user'],
|
83
85
|
password: db_config['master']['password'],
|
84
|
-
port: db_config['master']['port']
|
86
|
+
port: db_config['master']['port'],
|
87
|
+
database: test_db_name
|
85
88
|
)
|
86
|
-
|
87
|
-
# For some reasons sometimes the database does not exist
|
88
|
-
client.query("CREATE DATABASE IF NOT EXISTS #{test_db_name}")
|
89
|
-
client.select_db(test_db_name)
|
90
|
-
client
|
91
89
|
end
|
92
90
|
|
93
91
|
def test_db_name
|
@@ -101,4 +99,16 @@ class LockWaitTimeoutTestHelper
|
|
101
99
|
def test_table_name
|
102
100
|
@test_table_name ||= "lock_wait"
|
103
101
|
end
|
102
|
+
|
103
|
+
private
|
104
|
+
|
105
|
+
def mysql_exec(connection, statement)
|
106
|
+
if connection.class == Mysql2::Client
|
107
|
+
connection.query(statement)
|
108
|
+
elsif connection.class.to_s.include?("ActiveRecord")
|
109
|
+
connection.execute(statement)
|
110
|
+
else
|
111
|
+
raise StandardError.new("Unrecognized MySQL client")
|
112
|
+
end
|
113
|
+
end
|
104
114
|
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class ProxySQLHelper
|
2
|
+
class << self
|
3
|
+
# Flips the destination hostgroup for /maintenance:lhm/ from 0 (i.e. writer) to 1 (i.e. reader)
|
4
|
+
def with_lhm_hostgroup_flip
|
5
|
+
conn = Mysql2::Client.new(
|
6
|
+
host: '127.0.0.1',
|
7
|
+
username: "remote-admin",
|
8
|
+
password: "password",
|
9
|
+
port: "6032",
|
10
|
+
)
|
11
|
+
|
12
|
+
begin
|
13
|
+
conn.query("UPDATE mysql_query_rules SET destination_hostgroup=1 WHERE match_pattern=\"maintenance:lhm\"")
|
14
|
+
conn.query("LOAD MYSQL QUERY RULES TO RUNTIME;")
|
15
|
+
yield
|
16
|
+
ensure
|
17
|
+
conn.query("UPDATE mysql_query_rules SET destination_hostgroup=0 WHERE match_pattern=\"maintenance:lhm\"")
|
18
|
+
conn.query("LOAD MYSQL QUERY RULES TO RUNTIME;")
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,108 @@
|
|
1
|
+
require 'minitest/autorun'
|
2
|
+
require 'mysql2'
|
3
|
+
require 'lhm'
|
4
|
+
require 'toxiproxy'
|
5
|
+
|
6
|
+
require 'integration/sql_retry/lock_wait_timeout_test_helper'
|
7
|
+
require 'integration/sql_retry/db_connection_helper'
|
8
|
+
require 'integration/sql_retry/proxysql_helper'
|
9
|
+
require 'integration/toxiproxy_helper'
|
10
|
+
|
11
|
+
describe Lhm::SqlRetry, "ProxiSQL tests for LHM retry" do
|
12
|
+
include ToxiproxyHelper
|
13
|
+
|
14
|
+
before(:each) do
|
15
|
+
@old_logger = Lhm.logger
|
16
|
+
@logger = StringIO.new
|
17
|
+
Lhm.logger = Logger.new(@logger)
|
18
|
+
|
19
|
+
@connection = DBConnectionHelper::new_mysql_connection(:proxysql, true, true)
|
20
|
+
|
21
|
+
@lhm_retry = Lhm::SqlRetry.new(@connection, retry_options: {},
|
22
|
+
reconnect_with_consistent_host: true)
|
23
|
+
end
|
24
|
+
|
25
|
+
after(:each) do
|
26
|
+
# Restore default logger
|
27
|
+
Lhm.logger = @old_logger
|
28
|
+
end
|
29
|
+
|
30
|
+
it "Will abort if service is down" do
|
31
|
+
|
32
|
+
e = assert_raises Lhm::Error do
|
33
|
+
#Service down
|
34
|
+
Toxiproxy[:mysql_proxysql].down do
|
35
|
+
@lhm_retry.with_retries do |retriable_connection|
|
36
|
+
retriable_connection.execute("INSERT INTO #{DBConnectionHelper.test_table_name} (id) VALUES (2000)")
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
assert_equal Lhm::Error, e.class
|
41
|
+
assert_match(/LHM tried the reconnection procedure but failed. Latest error:/, e.message)
|
42
|
+
end
|
43
|
+
|
44
|
+
it "Will retry until connection is achieved" do
|
45
|
+
|
46
|
+
#Creating a network blip
|
47
|
+
ToxiproxyHelper.with_kill_and_restart(:mysql_proxysql, 2.seconds) do
|
48
|
+
@lhm_retry.with_retries do |retriable_connection|
|
49
|
+
retriable_connection.execute("INSERT INTO #{DBConnectionHelper.test_table_name} (id) VALUES (2000)")
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
assert_equal @connection.execute("Select * from #{DBConnectionHelper.test_table_name} WHERE id=2000").to_a.first.first, 2000
|
54
|
+
|
55
|
+
logs = @logger.string.split("\n")
|
56
|
+
|
57
|
+
assert logs.first.include?("Lost connection to MySQL, will retry to connect to same host")
|
58
|
+
assert logs.last.include?("LHM successfully reconnected to initial host")
|
59
|
+
end
|
60
|
+
|
61
|
+
it "Will abort if new writer is not same host" do
|
62
|
+
# The hostname will be constant before the blip
|
63
|
+
Lhm::SqlRetry.any_instance.stubs(:hostname).returns("mysql-1").then.returns("mysql-2")
|
64
|
+
|
65
|
+
# Need new instance for stub to take into effect
|
66
|
+
lhm_retry = Lhm::SqlRetry.new(@connection, retry_options: {},
|
67
|
+
reconnect_with_consistent_host: true)
|
68
|
+
|
69
|
+
e = assert_raises Lhm::Error do
|
70
|
+
#Creating a network blip
|
71
|
+
ToxiproxyHelper.with_kill_and_restart(:mysql_proxysql, 2.seconds) do
|
72
|
+
lhm_retry.with_retries do |retriable_connection|
|
73
|
+
retriable_connection.execute("INSERT INTO #{DBConnectionHelper.test_table_name} (id) VALUES (2000)")
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
assert_equal e.class, Lhm::Error
|
79
|
+
assert_match(/LHM tried the reconnection procedure but failed. Latest error: Reconnected to wrong host/, e.message)
|
80
|
+
|
81
|
+
logs = @logger.string.split("\n")
|
82
|
+
|
83
|
+
assert logs.first.include?("Lost connection to MySQL, will retry to connect to same host")
|
84
|
+
assert logs.last.include?("Lost connection to MySQL server at 'reading initial communication packet")
|
85
|
+
end
|
86
|
+
|
87
|
+
it "Will abort if failover happens (mimicked with proxySQL)" do
|
88
|
+
e = assert_raises Lhm::Error do
|
89
|
+
#Creates a failover by switching the target hostgroup for the #hostname
|
90
|
+
ProxySQLHelper.with_lhm_hostgroup_flip do
|
91
|
+
#Creating a network blip
|
92
|
+
ToxiproxyHelper.with_kill_and_restart(:mysql_proxysql, 2.seconds) do
|
93
|
+
@lhm_retry.with_retries do |retriable_connection|
|
94
|
+
retriable_connection.execute("INSERT INTO #{DBConnectionHelper.test_table_name} (id) VALUES (2000)")
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
assert_equal e.class, Lhm::Error
|
101
|
+
assert_match(/LHM tried the reconnection procedure but failed. Latest error: Reconnected to wrong host/, e.message)
|
102
|
+
|
103
|
+
logs = @logger.string.split("\n")
|
104
|
+
|
105
|
+
assert logs.first.include?("Lost connection to MySQL, will retry to connect to same host")
|
106
|
+
assert logs.last.include?("Lost connection to MySQL server at 'reading initial communication packet")
|
107
|
+
end
|
108
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
require 'toxiproxy'
|
3
|
+
|
4
|
+
module ToxiproxyHelper
|
5
|
+
class << self
|
6
|
+
|
7
|
+
def included(base)
|
8
|
+
Toxiproxy.reset
|
9
|
+
|
10
|
+
# listen on localhost, but toxiproxy is in a container itself, thus the upstream uses the Docker-Compose DNS
|
11
|
+
Toxiproxy.populate(
|
12
|
+
[
|
13
|
+
{
|
14
|
+
name: 'mysql_master',
|
15
|
+
listen: '0.0.0.0:22220',
|
16
|
+
upstream: 'mysql-1:3306'
|
17
|
+
},
|
18
|
+
{
|
19
|
+
name: 'mysql_proxysql',
|
20
|
+
listen: '0.0.0.0:22222',
|
21
|
+
upstream: 'proxysql:3306'
|
22
|
+
}
|
23
|
+
])
|
24
|
+
end
|
25
|
+
|
26
|
+
def with_kill_and_restart(target, restart_after)
|
27
|
+
thread = Thread.new do
|
28
|
+
sleep(restart_after) unless restart_after.nil?
|
29
|
+
Toxiproxy[target].enable
|
30
|
+
end
|
31
|
+
|
32
|
+
Toxiproxy[target].disable
|
33
|
+
|
34
|
+
yield
|
35
|
+
|
36
|
+
ensure
|
37
|
+
thread.join
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|