lhm-teak 3.6.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +43 -0
  3. data/.gitignore +12 -0
  4. data/.rubocop.yml +183 -0
  5. data/.travis.yml +21 -0
  6. data/Appraisals +24 -0
  7. data/CHANGELOG.md +254 -0
  8. data/Gemfile +5 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +27 -0
  11. data/README.md +335 -0
  12. data/Rakefile +33 -0
  13. data/dev.yml +45 -0
  14. data/docker-compose.yml +60 -0
  15. data/gemfiles/activerecord_5.2.gemfile +9 -0
  16. data/gemfiles/activerecord_5.2.gemfile.lock +66 -0
  17. data/gemfiles/activerecord_6.0.gemfile +7 -0
  18. data/gemfiles/activerecord_6.0.gemfile.lock +68 -0
  19. data/gemfiles/activerecord_6.1.gemfile +7 -0
  20. data/gemfiles/activerecord_6.1.gemfile.lock +67 -0
  21. data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
  22. data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +65 -0
  23. data/lhm.gemspec +38 -0
  24. data/lib/lhm/atomic_switcher.rb +46 -0
  25. data/lib/lhm/chunk_finder.rb +62 -0
  26. data/lib/lhm/chunk_insert.rb +61 -0
  27. data/lib/lhm/chunker.rb +95 -0
  28. data/lib/lhm/cleanup/current.rb +71 -0
  29. data/lib/lhm/command.rb +48 -0
  30. data/lib/lhm/connection.rb +108 -0
  31. data/lib/lhm/entangler.rb +112 -0
  32. data/lib/lhm/intersection.rb +51 -0
  33. data/lib/lhm/invoker.rb +100 -0
  34. data/lib/lhm/locked_switcher.rb +76 -0
  35. data/lib/lhm/migration.rb +51 -0
  36. data/lib/lhm/migrator.rb +244 -0
  37. data/lib/lhm/printer.rb +63 -0
  38. data/lib/lhm/proxysql_helper.rb +10 -0
  39. data/lib/lhm/railtie.rb +9 -0
  40. data/lib/lhm/sql_helper.rb +77 -0
  41. data/lib/lhm/sql_retry.rb +180 -0
  42. data/lib/lhm/table.rb +121 -0
  43. data/lib/lhm/table_name.rb +23 -0
  44. data/lib/lhm/test_support.rb +35 -0
  45. data/lib/lhm/throttler/slave_lag.rb +162 -0
  46. data/lib/lhm/throttler/threads_running.rb +53 -0
  47. data/lib/lhm/throttler/time.rb +29 -0
  48. data/lib/lhm/throttler.rb +36 -0
  49. data/lib/lhm/timestamp.rb +11 -0
  50. data/lib/lhm/version.rb +6 -0
  51. data/lib/lhm-shopify.rb +1 -0
  52. data/lib/lhm.rb +156 -0
  53. data/scripts/helpers/wait-for-dbs.sh +21 -0
  54. data/scripts/mysql/reader/create_replication.sql +10 -0
  55. data/scripts/mysql/writer/create_test_db.sql +1 -0
  56. data/scripts/mysql/writer/create_users.sql +6 -0
  57. data/scripts/proxysql/proxysql.cnf +117 -0
  58. data/shipit.rubygems.yml +0 -0
  59. data/spec/.lhm.example +4 -0
  60. data/spec/README.md +58 -0
  61. data/spec/fixtures/bigint_table.ddl +4 -0
  62. data/spec/fixtures/composite_primary_key.ddl +6 -0
  63. data/spec/fixtures/composite_primary_key_dest.ddl +6 -0
  64. data/spec/fixtures/custom_primary_key.ddl +6 -0
  65. data/spec/fixtures/custom_primary_key_dest.ddl +6 -0
  66. data/spec/fixtures/destination.ddl +6 -0
  67. data/spec/fixtures/lines.ddl +7 -0
  68. data/spec/fixtures/origin.ddl +6 -0
  69. data/spec/fixtures/permissions.ddl +5 -0
  70. data/spec/fixtures/small_table.ddl +4 -0
  71. data/spec/fixtures/tracks.ddl +5 -0
  72. data/spec/fixtures/users.ddl +14 -0
  73. data/spec/fixtures/wo_id_int_column.ddl +6 -0
  74. data/spec/integration/atomic_switcher_spec.rb +129 -0
  75. data/spec/integration/chunk_insert_spec.rb +30 -0
  76. data/spec/integration/chunker_spec.rb +269 -0
  77. data/spec/integration/cleanup_spec.rb +147 -0
  78. data/spec/integration/database.yml +25 -0
  79. data/spec/integration/entangler_spec.rb +68 -0
  80. data/spec/integration/integration_helper.rb +252 -0
  81. data/spec/integration/invoker_spec.rb +33 -0
  82. data/spec/integration/lhm_spec.rb +659 -0
  83. data/spec/integration/lock_wait_timeout_spec.rb +30 -0
  84. data/spec/integration/locked_switcher_spec.rb +50 -0
  85. data/spec/integration/proxysql_spec.rb +34 -0
  86. data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
  87. data/spec/integration/sql_retry/lock_wait_spec.rb +127 -0
  88. data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +114 -0
  89. data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
  90. data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +109 -0
  91. data/spec/integration/table_spec.rb +83 -0
  92. data/spec/integration/toxiproxy_helper.rb +40 -0
  93. data/spec/test_helper.rb +69 -0
  94. data/spec/unit/atomic_switcher_spec.rb +29 -0
  95. data/spec/unit/chunk_finder_spec.rb +73 -0
  96. data/spec/unit/chunk_insert_spec.rb +67 -0
  97. data/spec/unit/chunker_spec.rb +176 -0
  98. data/spec/unit/connection_spec.rb +111 -0
  99. data/spec/unit/entangler_spec.rb +187 -0
  100. data/spec/unit/intersection_spec.rb +51 -0
  101. data/spec/unit/lhm_spec.rb +46 -0
  102. data/spec/unit/locked_switcher_spec.rb +46 -0
  103. data/spec/unit/migrator_spec.rb +144 -0
  104. data/spec/unit/printer_spec.rb +85 -0
  105. data/spec/unit/sql_helper_spec.rb +28 -0
  106. data/spec/unit/table_name_spec.rb +39 -0
  107. data/spec/unit/table_spec.rb +47 -0
  108. data/spec/unit/throttler/slave_lag_spec.rb +322 -0
  109. data/spec/unit/throttler/threads_running_spec.rb +64 -0
  110. data/spec/unit/throttler_spec.rb +124 -0
  111. data/spec/unit/unit_helper.rb +26 -0
  112. 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