lhm-teak 3.6.0
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 +7 -0
- data/.github/workflows/test.yml +43 -0
- data/.gitignore +12 -0
- data/.rubocop.yml +183 -0
- data/.travis.yml +21 -0
- data/Appraisals +24 -0
- data/CHANGELOG.md +254 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +67 -0
- data/LICENSE +27 -0
- data/README.md +335 -0
- data/Rakefile +33 -0
- data/dev.yml +45 -0
- data/docker-compose.yml +60 -0
- data/gemfiles/activerecord_5.2.gemfile +9 -0
- data/gemfiles/activerecord_5.2.gemfile.lock +66 -0
- data/gemfiles/activerecord_6.0.gemfile +7 -0
- data/gemfiles/activerecord_6.0.gemfile.lock +68 -0
- data/gemfiles/activerecord_6.1.gemfile +7 -0
- data/gemfiles/activerecord_6.1.gemfile.lock +67 -0
- data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
- data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +65 -0
- data/lhm.gemspec +38 -0
- data/lib/lhm/atomic_switcher.rb +46 -0
- data/lib/lhm/chunk_finder.rb +62 -0
- data/lib/lhm/chunk_insert.rb +61 -0
- data/lib/lhm/chunker.rb +95 -0
- data/lib/lhm/cleanup/current.rb +71 -0
- data/lib/lhm/command.rb +48 -0
- data/lib/lhm/connection.rb +108 -0
- data/lib/lhm/entangler.rb +112 -0
- data/lib/lhm/intersection.rb +51 -0
- data/lib/lhm/invoker.rb +100 -0
- data/lib/lhm/locked_switcher.rb +76 -0
- data/lib/lhm/migration.rb +51 -0
- data/lib/lhm/migrator.rb +244 -0
- data/lib/lhm/printer.rb +63 -0
- data/lib/lhm/proxysql_helper.rb +10 -0
- data/lib/lhm/railtie.rb +9 -0
- data/lib/lhm/sql_helper.rb +77 -0
- data/lib/lhm/sql_retry.rb +180 -0
- data/lib/lhm/table.rb +121 -0
- data/lib/lhm/table_name.rb +23 -0
- data/lib/lhm/test_support.rb +35 -0
- data/lib/lhm/throttler/slave_lag.rb +162 -0
- data/lib/lhm/throttler/threads_running.rb +53 -0
- data/lib/lhm/throttler/time.rb +29 -0
- data/lib/lhm/throttler.rb +36 -0
- data/lib/lhm/timestamp.rb +11 -0
- data/lib/lhm/version.rb +6 -0
- data/lib/lhm-shopify.rb +1 -0
- data/lib/lhm.rb +156 -0
- data/scripts/helpers/wait-for-dbs.sh +21 -0
- data/scripts/mysql/reader/create_replication.sql +10 -0
- data/scripts/mysql/writer/create_test_db.sql +1 -0
- data/scripts/mysql/writer/create_users.sql +6 -0
- data/scripts/proxysql/proxysql.cnf +117 -0
- data/shipit.rubygems.yml +0 -0
- data/spec/.lhm.example +4 -0
- data/spec/README.md +58 -0
- data/spec/fixtures/bigint_table.ddl +4 -0
- data/spec/fixtures/composite_primary_key.ddl +6 -0
- data/spec/fixtures/composite_primary_key_dest.ddl +6 -0
- data/spec/fixtures/custom_primary_key.ddl +6 -0
- data/spec/fixtures/custom_primary_key_dest.ddl +6 -0
- data/spec/fixtures/destination.ddl +6 -0
- data/spec/fixtures/lines.ddl +7 -0
- data/spec/fixtures/origin.ddl +6 -0
- data/spec/fixtures/permissions.ddl +5 -0
- data/spec/fixtures/small_table.ddl +4 -0
- data/spec/fixtures/tracks.ddl +5 -0
- data/spec/fixtures/users.ddl +14 -0
- data/spec/fixtures/wo_id_int_column.ddl +6 -0
- data/spec/integration/atomic_switcher_spec.rb +129 -0
- data/spec/integration/chunk_insert_spec.rb +30 -0
- data/spec/integration/chunker_spec.rb +269 -0
- data/spec/integration/cleanup_spec.rb +147 -0
- data/spec/integration/database.yml +25 -0
- data/spec/integration/entangler_spec.rb +68 -0
- data/spec/integration/integration_helper.rb +252 -0
- data/spec/integration/invoker_spec.rb +33 -0
- data/spec/integration/lhm_spec.rb +659 -0
- data/spec/integration/lock_wait_timeout_spec.rb +30 -0
- data/spec/integration/locked_switcher_spec.rb +50 -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 +127 -0
- data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +114 -0
- data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
- data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +109 -0
- data/spec/integration/table_spec.rb +83 -0
- data/spec/integration/toxiproxy_helper.rb +40 -0
- data/spec/test_helper.rb +69 -0
- data/spec/unit/atomic_switcher_spec.rb +29 -0
- data/spec/unit/chunk_finder_spec.rb +73 -0
- data/spec/unit/chunk_insert_spec.rb +67 -0
- data/spec/unit/chunker_spec.rb +176 -0
- data/spec/unit/connection_spec.rb +111 -0
- data/spec/unit/entangler_spec.rb +187 -0
- data/spec/unit/intersection_spec.rb +51 -0
- data/spec/unit/lhm_spec.rb +46 -0
- data/spec/unit/locked_switcher_spec.rb +46 -0
- data/spec/unit/migrator_spec.rb +144 -0
- data/spec/unit/printer_spec.rb +85 -0
- data/spec/unit/sql_helper_spec.rb +28 -0
- data/spec/unit/table_name_spec.rb +39 -0
- data/spec/unit/table_spec.rb +47 -0
- data/spec/unit/throttler/slave_lag_spec.rb +322 -0
- data/spec/unit/throttler/threads_running_spec.rb +64 -0
- data/spec/unit/throttler_spec.rb +124 -0
- data/spec/unit/unit_helper.rb +26 -0
- metadata +366 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require File.expand_path(File.dirname(__FILE__)) + '/integration_helper'
|
|
5
|
+
|
|
6
|
+
require 'lhm/table'
|
|
7
|
+
require 'lhm/migration'
|
|
8
|
+
require 'lhm/locked_switcher'
|
|
9
|
+
|
|
10
|
+
describe Lhm::LockedSwitcher do
|
|
11
|
+
include IntegrationHelper
|
|
12
|
+
|
|
13
|
+
before(:each) do
|
|
14
|
+
connect_master!
|
|
15
|
+
@old_logger = Lhm.logger
|
|
16
|
+
Lhm.logger = Logger.new('/dev/null')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
after(:each) do
|
|
20
|
+
Lhm.logger = @old_logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe 'switching' do
|
|
24
|
+
before(:each) do
|
|
25
|
+
@origin = table_create('origin')
|
|
26
|
+
@destination = table_create('destination')
|
|
27
|
+
@migration = Lhm::Migration.new(@origin, @destination)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'rename origin to archive' do
|
|
31
|
+
switcher = Lhm::LockedSwitcher.new(@migration, connection)
|
|
32
|
+
switcher.run
|
|
33
|
+
|
|
34
|
+
slave do
|
|
35
|
+
value(data_source_exists?(@origin)).must_equal true
|
|
36
|
+
value(table_read(@migration.archive_name).columns.keys).must_include 'origin'
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'rename destination to origin' do
|
|
41
|
+
switcher = Lhm::LockedSwitcher.new(@migration, connection)
|
|
42
|
+
switcher.run
|
|
43
|
+
|
|
44
|
+
slave do
|
|
45
|
+
value(data_source_exists?(@destination)).must_equal false
|
|
46
|
+
value(table_read(@origin.name).columns.keys).must_include 'destination'
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
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
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require 'minitest/autorun'
|
|
2
|
+
require 'mysql2'
|
|
3
|
+
require 'integration/sql_retry/lock_wait_timeout_test_helper'
|
|
4
|
+
require 'lhm'
|
|
5
|
+
|
|
6
|
+
describe Lhm::SqlRetry do
|
|
7
|
+
before(:each) do
|
|
8
|
+
@old_logger = Lhm.logger
|
|
9
|
+
@logger = StringIO.new
|
|
10
|
+
Lhm.logger = Logger.new(@logger)
|
|
11
|
+
|
|
12
|
+
@helper = LockWaitTimeoutTestHelper.new(
|
|
13
|
+
lock_duration: 5,
|
|
14
|
+
innodb_lock_wait_timeout: 2
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
@helper.create_table_to_lock
|
|
18
|
+
|
|
19
|
+
# Start a thread to hold a lock on the table
|
|
20
|
+
@locked_record_id = @helper.hold_lock
|
|
21
|
+
|
|
22
|
+
# Assert our pre-conditions
|
|
23
|
+
assert_equal 2, @helper.record_count
|
|
24
|
+
|
|
25
|
+
Mysql2::Client.any_instance.stubs(:active?).returns(true)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
after(:each) do
|
|
29
|
+
# Restore default logger
|
|
30
|
+
Lhm.logger = @old_logger
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# This is the control test case. It shows that when Lhm::SqlRetry is not used,
|
|
34
|
+
# a lock wait timeout exceeded error is raised.
|
|
35
|
+
it "does nothing to prevent exceptions, when not used" do
|
|
36
|
+
puts ""
|
|
37
|
+
puts "***The output you see below is OK so long as the test passes.***"
|
|
38
|
+
puts "*" * 64
|
|
39
|
+
# Start a thread to retry, once the lock is held, execute the block
|
|
40
|
+
@helper.with_waiting_lock do |waiting_connection|
|
|
41
|
+
@helper.insert_records_at_ids(waiting_connection, [@locked_record_id])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
exception = assert_raises { @helper.trigger_wait_lock }
|
|
45
|
+
|
|
46
|
+
assert_match /Lock wait timeout exceeded; try restarting transaction/, exception.message
|
|
47
|
+
assert_equal Mysql2::Error::TimeoutError, exception.class
|
|
48
|
+
|
|
49
|
+
assert_equal 2, @helper.record_count # no records inserted
|
|
50
|
+
puts "*" * 64
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# This is test demonstrating the happy path: a well configured retry
|
|
54
|
+
# tuned to the locks it encounters.
|
|
55
|
+
it "successfully executes the SQL despite the errors encountered" do
|
|
56
|
+
# Start a thread to retry, once the lock is held, execute the block
|
|
57
|
+
@helper.with_waiting_lock do |waiting_connection|
|
|
58
|
+
sql_retry = Lhm::SqlRetry.new(waiting_connection, retry_options: {
|
|
59
|
+
base_interval: 0.2, # first retry after 200ms
|
|
60
|
+
multiplier: 1, # subsequent retries wait 1x longer than first retry (no change)
|
|
61
|
+
tries: 3, # we only need 3 tries (including the first) for the scenario described below
|
|
62
|
+
rand_factor: 0 # do not introduce randomness to wait timer
|
|
63
|
+
}, reconnect_with_consistent_host: false)
|
|
64
|
+
|
|
65
|
+
# RetryTestHelper is configured to hold lock for 5 seconds and timeout after 2 seconds.
|
|
66
|
+
# Therefore the sequence of events will be:
|
|
67
|
+
# 0s: first insert query is started while lock is held
|
|
68
|
+
# 2s: first timeout error will occur, SqlRetry is configured to wait 200ms after this
|
|
69
|
+
# 2.2s: second insert query is started while lock is held
|
|
70
|
+
# 4.2s: second timeout error will occur, SqlRetry is configured to wait 200ms after this
|
|
71
|
+
# 4.4s: third insert query is started while lock is held
|
|
72
|
+
# 5s: lock is released, insert successful no further retries needed
|
|
73
|
+
sql_retry.with_retries do |retriable_connection|
|
|
74
|
+
@helper.insert_records_at_ids(retriable_connection, [@locked_record_id])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
@helper.trigger_wait_lock
|
|
79
|
+
|
|
80
|
+
assert_equal 3, @helper.record_count # records inserted successfully despite lock
|
|
81
|
+
|
|
82
|
+
logs = @logger.string.split("\n")
|
|
83
|
+
assert_equal 2, logs.length
|
|
84
|
+
|
|
85
|
+
assert logs.first.include?("Mysql2::Error::TimeoutError: 'Lock wait timeout exceeded; try restarting transaction' - 1 tries")
|
|
86
|
+
assert logs.first.include?("0.2 seconds until the next try")
|
|
87
|
+
|
|
88
|
+
assert logs.last.include?("Mysql2::Error::TimeoutError: 'Lock wait timeout exceeded; try restarting transaction' - 2 tries")
|
|
89
|
+
assert logs.last.include?("0.2 seconds until the next try")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# This is test demonstrating the sad configuration path: it shows
|
|
93
|
+
# that when the retries are not tuned to the locks encountered,
|
|
94
|
+
# retries are not effective.
|
|
95
|
+
it "fails to retry enough to overcome the timeout" do
|
|
96
|
+
puts ""
|
|
97
|
+
puts "***The output you see below is OK so long as the test passes.***"
|
|
98
|
+
puts "*" * 64
|
|
99
|
+
# Start a thread to retry, once the lock is held, execute the block
|
|
100
|
+
@helper.with_waiting_lock do |waiting_connection|
|
|
101
|
+
sql_retry = Lhm::SqlRetry.new(waiting_connection, retry_options: {
|
|
102
|
+
base_interval: 0.2, # first retry after 200ms
|
|
103
|
+
multiplier: 1, # subsequent retries wait 1x longer than first retry (no change)
|
|
104
|
+
tries: 2, # we need 3 tries (including the first) for the scenario described below, but we only get two...we will fail
|
|
105
|
+
rand_factor: 0 # do not introduce randomness to wait timer
|
|
106
|
+
}, reconnect_with_consistent_host: false)
|
|
107
|
+
|
|
108
|
+
# RetryTestHelper is configured to hold lock for 5 seconds and timeout after 2 seconds.
|
|
109
|
+
# Therefore the sequence of events will be:
|
|
110
|
+
# 0s: first insert query is started while lock is held
|
|
111
|
+
# 2s: first timeout error will occur, SqlRetry is configured to wait 200ms after this
|
|
112
|
+
# 2.2s: second insert query is started while lock is held
|
|
113
|
+
# 4.2s: second timeout error will occur, SqlRetry is configured to only try twice, so we fail here
|
|
114
|
+
sql_retry.with_retries do |retriable_connection|
|
|
115
|
+
@helper.insert_records_at_ids(retriable_connection, [@locked_record_id])
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
exception = assert_raises { @helper.trigger_wait_lock }
|
|
120
|
+
|
|
121
|
+
assert_match /Lock wait timeout exceeded; try restarting transaction/, exception.message
|
|
122
|
+
assert_equal Mysql2::Error::TimeoutError, exception.class
|
|
123
|
+
|
|
124
|
+
assert_equal 2, @helper.record_count # no records inserted
|
|
125
|
+
puts "*" * 64
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
require 'integration/integration_helper'
|
|
2
|
+
|
|
3
|
+
class LockWaitTimeoutTestHelper
|
|
4
|
+
|
|
5
|
+
def initialize(lock_duration:, innodb_lock_wait_timeout:)
|
|
6
|
+
# This connection will be used exclusively to setup the test,
|
|
7
|
+
# assert pre-conditions and assert post-conditions.
|
|
8
|
+
# We choose to use a `Mysql2::Client` connection instead of
|
|
9
|
+
# `ActiveRecord::Base.establish_connection` because of AR's connection
|
|
10
|
+
# pool which forces thread syncronization. In this test,
|
|
11
|
+
# we want to intentionally create a lock to test retries,
|
|
12
|
+
# so that is an anti-feature.
|
|
13
|
+
@main_conn = new_mysql_connection
|
|
14
|
+
|
|
15
|
+
@lock_duration = lock_duration
|
|
16
|
+
|
|
17
|
+
# While implementing this, I discovered that MySQL seems to have an off-by-one
|
|
18
|
+
# bug with the innodb_lock_wait_timeout. If you ask it to wait 2 seconds, it will wait 3.
|
|
19
|
+
# In order to avoid surprisingly the user, let's account for that here, but also
|
|
20
|
+
# guard against a case where we go below 1, the minimum value.
|
|
21
|
+
raise ArgumentError, "innodb_lock_wait_timeout must be greater than or equal to 2" unless innodb_lock_wait_timeout >= 2
|
|
22
|
+
raise ArgumentError, "innodb_lock_wait_timeout must be an integer" if innodb_lock_wait_timeout.class != Integer
|
|
23
|
+
@innodb_lock_wait_timeout = innodb_lock_wait_timeout - 1
|
|
24
|
+
|
|
25
|
+
@threads = []
|
|
26
|
+
@queue = Queue.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_table_to_lock(connection = main_conn)
|
|
30
|
+
connection.query("DROP TABLE IF EXISTS #{test_table_name};")
|
|
31
|
+
connection.query("CREATE TABLE #{test_table_name} (id INT, PRIMARY KEY (id)) ENGINE=InnoDB;")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def hold_lock(seconds = lock_duration, queue = @queue)
|
|
35
|
+
# We are intentionally choosing to create a gap in the between the IDs to
|
|
36
|
+
# create a gap lock.
|
|
37
|
+
insert_records_at_ids(main_conn, [1001,1003])
|
|
38
|
+
locked_id = 1002
|
|
39
|
+
|
|
40
|
+
# This is the locking thread. It creates gap lock. It must be created first.
|
|
41
|
+
@threads << Thread.new do
|
|
42
|
+
conn = new_mysql_connection
|
|
43
|
+
conn.query("START TRANSACTION;")
|
|
44
|
+
conn.query("DELETE FROM #{test_table_name} WHERE id=#{locked_id}") # we now have the lock
|
|
45
|
+
queue.push(true) # this will signal the waiting thread to unblock, now that the lock is held
|
|
46
|
+
sleep seconds # hold the lock, while the waiting thread is waiting/retrying
|
|
47
|
+
conn.query("ROLLBACK;") # release the lock
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
return locked_id
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def record_count(connection = main_conn)
|
|
54
|
+
response = connection.query("SELECT COUNT(id) FROM #{test_table_name}")
|
|
55
|
+
response.first.values.first
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def with_waiting_lock(lock_time = @lock_duration, queue = @queue)
|
|
59
|
+
@threads << Thread.new do
|
|
60
|
+
conn = new_mysql_connection
|
|
61
|
+
conn.query("SET SESSION innodb_lock_wait_timeout = #{innodb_lock_wait_timeout}") # set timeout to be less than lock_time, so the timeout will happen
|
|
62
|
+
queue.pop # this will block until the lock thread establishes lock
|
|
63
|
+
yield(conn) # invoke the code that should retry while lock is held
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def trigger_wait_lock
|
|
68
|
+
@threads.each(&:join)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def insert_records_at_ids(connection, ids)
|
|
72
|
+
ids.each do |id|
|
|
73
|
+
mysql_exec(connection, "INSERT INTO #{test_table_name} (id) VALUES (#{id})")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
attr_reader :main_conn, :lock_duration, :innodb_lock_wait_timeout
|
|
80
|
+
|
|
81
|
+
def new_mysql_connection
|
|
82
|
+
Mysql2::Client.new(
|
|
83
|
+
host: '127.0.0.1',
|
|
84
|
+
username: db_config['master']['user'],
|
|
85
|
+
password: db_config['master']['password'],
|
|
86
|
+
port: db_config['master']['port'],
|
|
87
|
+
database: test_db_name
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_db_name
|
|
92
|
+
@test_db_name ||= "test"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def db_config
|
|
96
|
+
@db_config ||= YAML.load_file(File.expand_path(File.dirname(__FILE__)) + '/../database.yml')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def test_table_name
|
|
100
|
+
@test_table_name ||= "lock_wait"
|
|
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
|
|
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,109 @@
|
|
|
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. Aborting/, 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
|
+
Lhm::SqlRetry.any_instance.stubs(:server_id).returns(1).then.returns(2)
|
|
65
|
+
|
|
66
|
+
# Need new instance for stub to take into effect
|
|
67
|
+
lhm_retry = Lhm::SqlRetry.new(@connection, retry_options: {},
|
|
68
|
+
reconnect_with_consistent_host: true)
|
|
69
|
+
|
|
70
|
+
e = assert_raises Lhm::Error do
|
|
71
|
+
#Creating a network blip
|
|
72
|
+
ToxiproxyHelper.with_kill_and_restart(:mysql_proxysql, 2.seconds) do
|
|
73
|
+
lhm_retry.with_retries do |retriable_connection|
|
|
74
|
+
retriable_connection.execute("INSERT INTO #{DBConnectionHelper.test_table_name} (id) VALUES (2000)")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
assert_equal e.class, Lhm::Error
|
|
80
|
+
assert_match(/LHM tried the reconnection procedure but failed. Aborting/, e.message)
|
|
81
|
+
|
|
82
|
+
logs = @logger.string.split("\n")
|
|
83
|
+
|
|
84
|
+
assert logs.first.include?("Lost connection to MySQL, will retry to connect to same host")
|
|
85
|
+
assert logs.last.include?("Reconnected to wrong host. Started migration on: mysql-1 (server_id: 1), but reconnected to: mysql-2 (server_id: 2).")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it "Will abort if failover happens (mimicked with proxySQL)" do
|
|
89
|
+
e = assert_raises Lhm::Error do
|
|
90
|
+
#Creates a failover by switching the target hostgroup for the #hostname
|
|
91
|
+
ProxySQLHelper.with_lhm_hostgroup_flip do
|
|
92
|
+
#Creating a network blip
|
|
93
|
+
ToxiproxyHelper.with_kill_and_restart(:mysql_proxysql, 2.seconds) do
|
|
94
|
+
@lhm_retry.with_retries do |retriable_connection|
|
|
95
|
+
retriable_connection.execute("INSERT INTO #{DBConnectionHelper.test_table_name} (id) VALUES (2000)")
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
assert_equal e.class, Lhm::Error
|
|
102
|
+
assert_match(/LHM tried the reconnection procedure but failed. Aborting/, e.message)
|
|
103
|
+
|
|
104
|
+
logs = @logger.string.split("\n")
|
|
105
|
+
|
|
106
|
+
assert logs.first.include?("Lost connection to MySQL, will retry to connect to same host")
|
|
107
|
+
assert logs.last.include?("Reconnected to wrong host. Started migration on: mysql-1 (server_id: 1), but reconnected to: mysql-2 (server_id: 2).")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require File.expand_path(File.dirname(__FILE__)) + '/integration_helper'
|
|
5
|
+
require 'lhm/table'
|
|
6
|
+
|
|
7
|
+
describe Lhm::Table do
|
|
8
|
+
include IntegrationHelper
|
|
9
|
+
|
|
10
|
+
describe 'id numeric column requirement' do
|
|
11
|
+
describe 'when met' do
|
|
12
|
+
before(:each) do
|
|
13
|
+
connect_master!
|
|
14
|
+
@table = table_create(:custom_primary_key)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'should parse primary key' do
|
|
18
|
+
value(@table.pk).must_equal('pk')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'should parse indices' do
|
|
22
|
+
value(@table.indices['index_custom_primary_key_on_id']).must_equal(['id'])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'should parse columns' do
|
|
26
|
+
value(@table.columns['id'][:type]).must_match(/(bigint|int)\(\d+\)/)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'should return true for method that should be renamed' do
|
|
30
|
+
value(@table.satisfies_id_column_requirement?).must_equal true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'should support bigint tables' do
|
|
34
|
+
@table = table_create(:bigint_table)
|
|
35
|
+
value(@table.satisfies_id_column_requirement?).must_equal true
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe 'when not met' do
|
|
40
|
+
before(:each) do
|
|
41
|
+
connect_master!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'should return false for a non-int id column' do
|
|
45
|
+
@table = table_create(:wo_id_int_column)
|
|
46
|
+
value(@table.satisfies_id_column_requirement?).must_equal false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe Lhm::Table::Parser do
|
|
52
|
+
describe 'create table parsing' do
|
|
53
|
+
before(:each) do
|
|
54
|
+
connect_master!
|
|
55
|
+
@table = table_create(:users)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'should parse table name in show create table' do
|
|
59
|
+
value(@table.name).must_equal('users')
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'should parse primary key' do
|
|
63
|
+
value(@table.pk).must_equal('id')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'should parse column type in show create table' do
|
|
67
|
+
value(@table.columns['username'][:type]).must_equal('varchar(255)')
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'should parse column metadata' do
|
|
71
|
+
assert_nil @table.columns['username'][:column_default]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'should parse indices' do
|
|
75
|
+
value(@table.indices['index_users_on_username_and_created_at']).must_equal(['username', 'created_at'])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'should parse index' do
|
|
79
|
+
value(@table.indices['index_users_on_reference']).must_equal(['reference'])
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
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
|