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.
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