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
data/lib/lhm/railtie.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
module Lhm
|
|
5
|
+
module SqlHelper
|
|
6
|
+
extend self
|
|
7
|
+
|
|
8
|
+
def annotation
|
|
9
|
+
'/* large hadron migration */'
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def idx_name(table_name, cols)
|
|
13
|
+
column_names = column_definition(cols).map(&:first)
|
|
14
|
+
"index_#{ table_name }_on_#{ column_names.join('_and_') }"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def idx_spec(cols)
|
|
18
|
+
column_definition(cols).map do |name, length|
|
|
19
|
+
"`#{ name }`#{ length }"
|
|
20
|
+
end.join(', ')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def version_string
|
|
24
|
+
row = connection.select_one("show variables like 'version'")
|
|
25
|
+
value = struct_key(row, 'Value')
|
|
26
|
+
row[value]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def tagged(statement)
|
|
30
|
+
"#{ statement } #{ SqlHelper.annotation }"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def column_definition(cols)
|
|
36
|
+
Array(cols).map do |column|
|
|
37
|
+
column.to_s.match(/`?([^\(]+)`?(\([^\)]+\))?/).captures
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Older versions of MySQL contain an atomic rename bug affecting bin
|
|
42
|
+
# log order. Affected versions extracted from bug report:
|
|
43
|
+
#
|
|
44
|
+
# http://bugs.mysql.com/bug.php?id=39675
|
|
45
|
+
#
|
|
46
|
+
# More Info: http://dev.mysql.com/doc/refman/5.5/en/metadata-locking.html
|
|
47
|
+
def supports_atomic_switch?
|
|
48
|
+
major, minor, tiny = version_string.split('.').map(&:to_i)
|
|
49
|
+
|
|
50
|
+
case major
|
|
51
|
+
when 4 then return false if minor and minor < 2
|
|
52
|
+
when 5
|
|
53
|
+
case minor
|
|
54
|
+
when 0 then return false if tiny and tiny < 52
|
|
55
|
+
when 1 then return false
|
|
56
|
+
when 4 then return false if tiny and tiny < 4
|
|
57
|
+
when 5 then return false if tiny and tiny < 3
|
|
58
|
+
end
|
|
59
|
+
when 6
|
|
60
|
+
case minor
|
|
61
|
+
when 0 then return false if tiny and tiny < 11
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def struct_key(struct, key)
|
|
68
|
+
keys = if struct.is_a? Hash
|
|
69
|
+
struct.keys
|
|
70
|
+
else
|
|
71
|
+
struct.members
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
keys.find { |k| k.to_s.downcase == key.to_s.downcase }
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
require 'retriable'
|
|
2
|
+
require 'lhm/sql_helper'
|
|
3
|
+
require 'lhm/proxysql_helper'
|
|
4
|
+
|
|
5
|
+
module Lhm
|
|
6
|
+
# SqlRetry standardizes the interface for retry behavior in components like
|
|
7
|
+
# Entangler, AtomicSwitcher, ChunkerInsert.
|
|
8
|
+
#
|
|
9
|
+
# By default if an error includes the message "Lock wait timeout exceeded", or
|
|
10
|
+
# "Deadlock found when trying to get lock", SqlRetry will retry again
|
|
11
|
+
# once the MySQL client returns control to the caller, plus one second.
|
|
12
|
+
# It will retry a total of 10 times and output to the logger a description
|
|
13
|
+
# of the retry with error information, retry count, and elapsed time.
|
|
14
|
+
#
|
|
15
|
+
# This behavior can be modified by passing `options` that are documented in
|
|
16
|
+
# https://github.com/kamui/retriable. Additionally, a "log_prefix" option,
|
|
17
|
+
# which is unique to SqlRetry can be used to prefix log output.
|
|
18
|
+
class SqlRetry
|
|
19
|
+
RECONNECT_SUCCESSFUL_MESSAGE = "LHM successfully reconnected to initial host:"
|
|
20
|
+
CLOUDSQL_VERSION_COMMENT = "(Google)"
|
|
21
|
+
# Will retry for 120 seconds (approximately, since connecting takes time).
|
|
22
|
+
RECONNECT_RETRY_MAX_ITERATION = 120
|
|
23
|
+
RECONNECT_RETRY_INTERVAL = 1
|
|
24
|
+
# Will abort the LHM if it had to reconnect more than 25 times in a single run (indicator that there might be
|
|
25
|
+
# something wrong with the network and would be better to run the LHM at a later time).
|
|
26
|
+
RECONNECTION_MAXIMUM = 25
|
|
27
|
+
|
|
28
|
+
MYSQL_VAR_NAMES = {
|
|
29
|
+
hostname: "@@global.hostname",
|
|
30
|
+
server_id: "@@global.server_id",
|
|
31
|
+
version_comment: "@@version_comment",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def initialize(connection, retry_options: {}, reconnect_with_consistent_host: false)
|
|
35
|
+
@connection = connection
|
|
36
|
+
self.retry_config = retry_options
|
|
37
|
+
self.reconnect_with_consistent_host = reconnect_with_consistent_host
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Complete explanation of algorithm: https://github.com/Shopify/lhm/pull/112
|
|
41
|
+
def with_retries(log_prefix: nil)
|
|
42
|
+
@log_prefix = log_prefix || "" # No prefix. Just logs
|
|
43
|
+
|
|
44
|
+
# Amount of time LHM had to reconnect. Aborting if more than RECONNECTION_MAXIMUM
|
|
45
|
+
reconnection_counter = 0
|
|
46
|
+
|
|
47
|
+
Retriable.retriable(@retry_config) do
|
|
48
|
+
# Using begin -> rescue -> end for Ruby 2.4 compatibility
|
|
49
|
+
begin
|
|
50
|
+
if @reconnect_with_consistent_host
|
|
51
|
+
raise Lhm::Error.new("MySQL host has changed since the start of the LHM. Aborting to avoid data-loss") unless same_host_as_initial?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
yield(@connection)
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
# Not all errors should trigger a reconnect. Some errors such be raised and abort the LHM (such as reconnecting to the wrong host).
|
|
57
|
+
# The error will be raised the connection is still active (i.e. no need to reconnect) or if the connection is
|
|
58
|
+
# dead (i.e. not active) and @reconnect_with_host is false (i.e. instructed not to reconnect)
|
|
59
|
+
raise e if @connection.active? || (!@connection.active? && !@reconnect_with_consistent_host)
|
|
60
|
+
|
|
61
|
+
# Lhm could be stuck in a weird state where it loses connection, reconnects and re looses-connection instantly
|
|
62
|
+
# after, creating an infinite loop (because of the usage of `retry`). Hence, abort after 25 reconnections
|
|
63
|
+
raise Lhm::Error.new("LHM reached host reconnection max of #{RECONNECTION_MAXIMUM} times. " \
|
|
64
|
+
"Please try again later.") if reconnection_counter > RECONNECTION_MAXIMUM
|
|
65
|
+
|
|
66
|
+
reconnection_counter += 1
|
|
67
|
+
if reconnect_with_host_check!
|
|
68
|
+
retry
|
|
69
|
+
else
|
|
70
|
+
raise Lhm::Error.new("LHM tried the reconnection procedure but failed. Aborting")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Both attributes will have defined setters
|
|
77
|
+
attr_reader :retry_config, :reconnect_with_consistent_host
|
|
78
|
+
attr_accessor :connection
|
|
79
|
+
|
|
80
|
+
def retry_config=(retry_options)
|
|
81
|
+
@retry_config = default_retry_config.dup.merge!(retry_options)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reconnect_with_consistent_host=(reconnect)
|
|
85
|
+
if (@reconnect_with_consistent_host = reconnect)
|
|
86
|
+
@initial_hostname = hostname
|
|
87
|
+
@initial_server_id = server_id
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def hostname
|
|
94
|
+
mysql_single_value(MYSQL_VAR_NAMES[:hostname])
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def server_id
|
|
98
|
+
mysql_single_value(MYSQL_VAR_NAMES[:server_id])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def cloudsql?
|
|
102
|
+
mysql_single_value(MYSQL_VAR_NAMES[:version_comment]).include?(CLOUDSQL_VERSION_COMMENT)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def mysql_single_value(name)
|
|
106
|
+
query = Lhm::ProxySQLHelper.tagged("SELECT #{name} LIMIT 1")
|
|
107
|
+
|
|
108
|
+
@connection.execute(query).to_a.first.tap do |record|
|
|
109
|
+
return record&.first
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def same_host_as_initial?
|
|
114
|
+
return @initial_server_id == server_id if cloudsql?
|
|
115
|
+
@initial_hostname == hostname
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def log_with_prefix(message, level = :info)
|
|
119
|
+
message.prepend("[#{@log_prefix}] ") if @log_prefix
|
|
120
|
+
Lhm.logger.public_send(level, message)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def reconnect_with_host_check!
|
|
124
|
+
log_with_prefix("Lost connection to MySQL, will retry to connect to same host")
|
|
125
|
+
|
|
126
|
+
RECONNECT_RETRY_MAX_ITERATION.times do
|
|
127
|
+
begin
|
|
128
|
+
sleep(RECONNECT_RETRY_INTERVAL)
|
|
129
|
+
|
|
130
|
+
# tries to reconnect. On failure will trigger a retry
|
|
131
|
+
@connection.reconnect!
|
|
132
|
+
|
|
133
|
+
if same_host_as_initial?
|
|
134
|
+
# This is not an actual error, but controlled way to get the parent `Retriable.retriable` to retry
|
|
135
|
+
# the statement that failed (since the Retriable gem only retries on errors).
|
|
136
|
+
log_with_prefix("LHM successfully reconnected to initial host: #{@initial_hostname} (server_id: #{@initial_server_id})")
|
|
137
|
+
return true
|
|
138
|
+
else
|
|
139
|
+
# New Master --> abort LHM (reconnecting will not change anything)
|
|
140
|
+
log_with_prefix("Reconnected to wrong host. Started migration on: #{@initial_hostname} (server_id: #{@initial_server_id}), but reconnected to: #{hostname} (server_id: #{server_id}).", :error)
|
|
141
|
+
return false
|
|
142
|
+
end
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
# Retry if ActiveRecord cannot reach host
|
|
145
|
+
next if /Lost connection to MySQL server at 'reading initial communication packet'/ === e.message
|
|
146
|
+
log_with_prefix("Encountered error: [#{e.class}] #{e.message}. Will stop reconnection procedure.", :info)
|
|
147
|
+
return false
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# For a full list of configuration options see https://github.com/kamui/retriable
|
|
154
|
+
def default_retry_config
|
|
155
|
+
{
|
|
156
|
+
on: {
|
|
157
|
+
StandardError => [
|
|
158
|
+
/Lock wait timeout exceeded/,
|
|
159
|
+
/Timeout waiting for a response from the last query/,
|
|
160
|
+
/Deadlock found when trying to get lock/,
|
|
161
|
+
/Query execution was interrupted/,
|
|
162
|
+
/Lost connection to MySQL server during query/,
|
|
163
|
+
/Max connect timeout reached/,
|
|
164
|
+
/Unknown MySQL server host/,
|
|
165
|
+
/connection is locked to hostgroup/,
|
|
166
|
+
/The MySQL server is running with the --read-only option so it cannot execute this statement/,
|
|
167
|
+
]
|
|
168
|
+
},
|
|
169
|
+
multiplier: 1, # each successive interval grows by this factor
|
|
170
|
+
base_interval: 1, # the initial interval in seconds between tries.
|
|
171
|
+
tries: 20, # Number of attempts to make at running your code block (includes initial attempt).
|
|
172
|
+
rand_factor: 0, # percentage to randomize the next retry interval time
|
|
173
|
+
max_elapsed_time: Float::INFINITY, # max total time in seconds that code is allowed to keep being retried
|
|
174
|
+
on_retry: Proc.new do |exception, try_number, total_elapsed_time, next_interval|
|
|
175
|
+
log_with_prefix("#{exception.class}: '#{exception.message}' - #{try_number} tries in #{total_elapsed_time} seconds and #{next_interval} seconds until the next try.", :error)
|
|
176
|
+
end
|
|
177
|
+
}.freeze
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
data/lib/lhm/table.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
|
|
2
|
+
# Schmidt
|
|
3
|
+
|
|
4
|
+
require 'lhm/sql_helper'
|
|
5
|
+
|
|
6
|
+
module Lhm
|
|
7
|
+
class Table
|
|
8
|
+
attr_reader :name, :columns, :indices, :pk, :ddl
|
|
9
|
+
|
|
10
|
+
def initialize(name, pk = 'id', ddl = nil)
|
|
11
|
+
@name = name
|
|
12
|
+
@table_name = TableName.new(name)
|
|
13
|
+
@columns = {}
|
|
14
|
+
@indices = {}
|
|
15
|
+
@pk = pk
|
|
16
|
+
@ddl = ddl
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def satisfies_id_column_requirement?
|
|
20
|
+
!!((id = columns['id']) &&
|
|
21
|
+
id[:type] =~ /(bigint|int)(\(\d+\))?/)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def destination_name
|
|
25
|
+
@destination_name ||= @table_name.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.parse(table_name, connection)
|
|
29
|
+
Parser.new(table_name, connection).parse
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class Parser
|
|
33
|
+
include SqlHelper
|
|
34
|
+
|
|
35
|
+
def initialize(table_name, connection)
|
|
36
|
+
@table_name = table_name.to_s
|
|
37
|
+
@schema_name = connection.current_database
|
|
38
|
+
@connection = connection
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ddl
|
|
42
|
+
sql = "show create table `#{ @table_name }`"
|
|
43
|
+
specification = nil
|
|
44
|
+
@connection.execute(sql).each { |row| specification = row.last }
|
|
45
|
+
specification
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def parse
|
|
49
|
+
schema = read_information_schema
|
|
50
|
+
|
|
51
|
+
Table.new(@table_name, extract_primary_key(schema), ddl).tap do |table|
|
|
52
|
+
schema.each do |defn|
|
|
53
|
+
column_name = struct_key(defn, 'COLUMN_NAME')
|
|
54
|
+
column_type = struct_key(defn, 'COLUMN_TYPE')
|
|
55
|
+
is_nullable = struct_key(defn, 'IS_NULLABLE')
|
|
56
|
+
column_default = struct_key(defn, 'COLUMN_DEFAULT')
|
|
57
|
+
comment = struct_key(defn, 'COLUMN_COMMENT')
|
|
58
|
+
collate = struct_key(defn, 'COLLATION_NAME')
|
|
59
|
+
|
|
60
|
+
table.columns[defn[column_name]] = {
|
|
61
|
+
:type => defn[column_type],
|
|
62
|
+
:is_nullable => defn[is_nullable],
|
|
63
|
+
:column_default => defn[column_default],
|
|
64
|
+
:comment => defn[comment],
|
|
65
|
+
:collate => defn[collate],
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
extract_indices(read_indices).each do |idx, columns|
|
|
70
|
+
table.indices[idx] = columns
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def read_information_schema
|
|
78
|
+
@connection.select_all %Q{
|
|
79
|
+
select *
|
|
80
|
+
from information_schema.columns
|
|
81
|
+
where table_name = '#{ @table_name }'
|
|
82
|
+
and table_schema = '#{ @schema_name }'
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def read_indices
|
|
87
|
+
@connection.select_all %Q{
|
|
88
|
+
show indexes from `#{ @schema_name }`.`#{ @table_name }`
|
|
89
|
+
where key_name != 'PRIMARY'
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def extract_indices(indices)
|
|
94
|
+
indices.
|
|
95
|
+
map do |row|
|
|
96
|
+
key_name = struct_key(row, 'Key_name')
|
|
97
|
+
column_name = struct_key(row, 'COLUMN_NAME')
|
|
98
|
+
[row[key_name], row[column_name]]
|
|
99
|
+
end.
|
|
100
|
+
inject(Hash.new { |h, k| h[k] = [] }) do |memo, (idx, column)|
|
|
101
|
+
memo[idx] << column
|
|
102
|
+
memo
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_primary_key(schema)
|
|
107
|
+
cols = schema.select do |defn|
|
|
108
|
+
column_key = struct_key(defn, 'COLUMN_KEY')
|
|
109
|
+
defn[column_key] == 'PRI'
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
keys = cols.map do |defn|
|
|
113
|
+
column_name = struct_key(defn, 'COLUMN_NAME')
|
|
114
|
+
defn[column_name]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
keys.length == 1 ? keys.first : keys
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Lhm
|
|
2
|
+
class TableName
|
|
3
|
+
def initialize(original, time = Time.now)
|
|
4
|
+
@original = original
|
|
5
|
+
@time = time
|
|
6
|
+
@timestamp = Timestamp.new(time)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
attr_reader :original
|
|
10
|
+
|
|
11
|
+
def archived
|
|
12
|
+
"lhma_#{@timestamp}_#{@original}"[0...64]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def failed
|
|
16
|
+
archived[0...57] + "_failed"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def new
|
|
20
|
+
"lhmn_#{@original}"[0...64]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
module Lhm
|
|
3
|
+
module TestMigrator
|
|
4
|
+
def initialize(*)
|
|
5
|
+
super
|
|
6
|
+
@name = @origin.name
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def execute
|
|
10
|
+
@statements.each do |stmt|
|
|
11
|
+
@connection.execute(tagged(stmt))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
module TestInvoker
|
|
17
|
+
def run(options = {})
|
|
18
|
+
normalize_options(options)
|
|
19
|
+
set_session_lock_wait_timeouts
|
|
20
|
+
@migrator.run
|
|
21
|
+
rescue => e
|
|
22
|
+
Lhm.logger.error("LHM run failed with exception=#{e.class} message=#{e.message}")
|
|
23
|
+
raise
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Patch LHM to execute ALTER TABLE directly on original tables,
|
|
28
|
+
# without the online migration dance.
|
|
29
|
+
# This mode is designed for local/CI environments where we can speed
|
|
30
|
+
# things up by not invoking "real" LHM logic.
|
|
31
|
+
def self.execute_inline!
|
|
32
|
+
Lhm::Migrator.prepend(TestMigrator)
|
|
33
|
+
Lhm::Invoker.prepend(TestInvoker)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
module Lhm
|
|
2
|
+
module Throttler
|
|
3
|
+
|
|
4
|
+
def self.format_hosts(hosts)
|
|
5
|
+
formatted_hosts = []
|
|
6
|
+
hosts.each do |host|
|
|
7
|
+
if host && !host.match(/localhost/) && !host.match(/127.0.0.1/)
|
|
8
|
+
formatted_hosts << host.partition(':')[0]
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
formatted_hosts
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class SlaveLag
|
|
15
|
+
include Command
|
|
16
|
+
|
|
17
|
+
INITIAL_TIMEOUT = 0.1
|
|
18
|
+
DEFAULT_STRIDE = 2_000
|
|
19
|
+
DEFAULT_MAX_ALLOWED_LAG = 10
|
|
20
|
+
|
|
21
|
+
MAX_TIMEOUT = INITIAL_TIMEOUT * 1024
|
|
22
|
+
|
|
23
|
+
attr_accessor :timeout_seconds, :allowed_lag, :stride, :connection
|
|
24
|
+
|
|
25
|
+
def initialize(options = {})
|
|
26
|
+
@timeout_seconds = INITIAL_TIMEOUT
|
|
27
|
+
@stride = options[:stride] || DEFAULT_STRIDE
|
|
28
|
+
@allowed_lag = options[:allowed_lag] || DEFAULT_MAX_ALLOWED_LAG
|
|
29
|
+
@slaves = {}
|
|
30
|
+
@get_config = options[:current_config]
|
|
31
|
+
@check_only = options[:check_only]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def execute
|
|
35
|
+
sleep(throttle_seconds)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def throttle_seconds
|
|
41
|
+
lag = max_current_slave_lag
|
|
42
|
+
|
|
43
|
+
if lag > @allowed_lag && @timeout_seconds < MAX_TIMEOUT
|
|
44
|
+
Lhm.logger.info("Increasing timeout between strides from #{@timeout_seconds} to #{@timeout_seconds * 2} because #{lag} seconds of slave lag detected is greater than the maximum of #{@allowed_lag} seconds allowed.")
|
|
45
|
+
@timeout_seconds = @timeout_seconds * 2
|
|
46
|
+
elsif lag <= @allowed_lag && @timeout_seconds > INITIAL_TIMEOUT
|
|
47
|
+
Lhm.logger.info("Decreasing timeout between strides from #{@timeout_seconds} to #{@timeout_seconds / 2} because #{lag} seconds of slave lag detected is less than or equal to the #{@allowed_lag} seconds allowed.")
|
|
48
|
+
@timeout_seconds = @timeout_seconds / 2
|
|
49
|
+
else
|
|
50
|
+
@timeout_seconds
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def slaves
|
|
55
|
+
@slaves[@connection] ||= get_slaves
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def get_slaves
|
|
59
|
+
slaves = []
|
|
60
|
+
if @check_only.nil? or !@check_only.respond_to?(:call)
|
|
61
|
+
slave_hosts = master_slave_hosts
|
|
62
|
+
while slave_hosts.any? do
|
|
63
|
+
host = slave_hosts.pop
|
|
64
|
+
slave = Slave.new(host, @get_config)
|
|
65
|
+
if !slaves.map(&:host).include?(host) && slave.connection
|
|
66
|
+
slaves << slave
|
|
67
|
+
slave_hosts.concat(slave.slave_hosts)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
else
|
|
71
|
+
slave_config = @check_only.call
|
|
72
|
+
slaves << Slave.new(slave_config['host'], @get_config)
|
|
73
|
+
end
|
|
74
|
+
slaves
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def master_slave_hosts
|
|
78
|
+
Throttler.format_hosts(@connection.select_values(Slave::SQL_SELECT_SLAVE_HOSTS))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def max_current_slave_lag
|
|
82
|
+
max = slaves.map { |slave| slave.lag }.push(0).max
|
|
83
|
+
Lhm.logger.info "Max current slave lag: #{max}"
|
|
84
|
+
max
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
class Slave
|
|
89
|
+
SQL_SELECT_SLAVE_HOSTS = "SELECT host FROM information_schema.processlist WHERE command LIKE 'Binlog Dump%'"
|
|
90
|
+
SQL_SELECT_MAX_SLAVE_LAG = 'SHOW SLAVE STATUS'
|
|
91
|
+
|
|
92
|
+
attr_reader :host, :connection
|
|
93
|
+
|
|
94
|
+
def initialize(host, connection_config = nil)
|
|
95
|
+
@host = host
|
|
96
|
+
@connection_config = prepare_connection_config(connection_config)
|
|
97
|
+
@connection = client(@connection_config)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def slave_hosts
|
|
101
|
+
Throttler.format_hosts(query_connection(SQL_SELECT_SLAVE_HOSTS, 'host'))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def lag
|
|
105
|
+
query_connection(SQL_SELECT_MAX_SLAVE_LAG, 'Seconds_Behind_Master').first.to_i
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def client(config)
|
|
111
|
+
begin
|
|
112
|
+
Lhm.logger.info "Connecting to #{@host} on database: #{config[:database]}"
|
|
113
|
+
Mysql2::Client.new(config)
|
|
114
|
+
rescue Mysql2::Error => e
|
|
115
|
+
Lhm.logger.info "Error connecting to #{@host}: #{e}"
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def prepare_connection_config(config_proc)
|
|
121
|
+
config = if config_proc
|
|
122
|
+
if config_proc.respond_to?(:call) # if we get a proc
|
|
123
|
+
config_proc.call
|
|
124
|
+
else
|
|
125
|
+
raise ArgumentError, "Expected #{config_proc.inspect} to respond to `call`"
|
|
126
|
+
end
|
|
127
|
+
else
|
|
128
|
+
db_config
|
|
129
|
+
end
|
|
130
|
+
config.deep_symbolize_keys!
|
|
131
|
+
config[:host] = @host
|
|
132
|
+
config
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def query_connection(query, result)
|
|
136
|
+
begin
|
|
137
|
+
@connection.query(query).map { |row| row[result] }
|
|
138
|
+
rescue Mysql2::Error => e
|
|
139
|
+
Lhm.logger.info "Unable to connect and/or query #{host}: #{e}"
|
|
140
|
+
[nil]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
def db_config
|
|
147
|
+
if ar_supports_db_config?
|
|
148
|
+
ActiveRecord::Base.connection_pool.db_config.configuration_hash.dup
|
|
149
|
+
else
|
|
150
|
+
ActiveRecord::Base.connection_pool.spec.config.dup
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ar_supports_db_config?
|
|
155
|
+
# https://api.rubyonrails.org/v6.0/classes/ActiveRecord/ConnectionAdapters/ConnectionPool.html <-- has spec
|
|
156
|
+
# vs
|
|
157
|
+
# https://api.rubyonrails.org/v6.1/classes/ActiveRecord/ConnectionAdapters/ConnectionPool.html <-- has db_config
|
|
158
|
+
ActiveRecord::VERSION::MAJOR > 6 || ActiveRecord::VERSION::MAJOR == 6 && ActiveRecord::VERSION::MINOR >= 1
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Lhm
|
|
2
|
+
module Throttler
|
|
3
|
+
class ThreadsRunning
|
|
4
|
+
include Command
|
|
5
|
+
|
|
6
|
+
DEFAULT_INITIAL_TIMEOUT = 0.1
|
|
7
|
+
DEFAULT_HEALTHY_RANGE = (0..50)
|
|
8
|
+
|
|
9
|
+
attr_accessor :timeout_seconds, :healthy_range, :connection
|
|
10
|
+
attr_reader :max_timeout_seconds, :initial_timeout_seconds
|
|
11
|
+
|
|
12
|
+
def initialize(options = {})
|
|
13
|
+
@initial_timeout_seconds = options[:initial_timeout] || DEFAULT_INITIAL_TIMEOUT
|
|
14
|
+
@max_timeout_seconds = options[:max_timeout] || (@initial_timeout_seconds * 1024)
|
|
15
|
+
@timeout_seconds = @initial_timeout_seconds
|
|
16
|
+
@healthy_range = options[:healthy_range] || DEFAULT_HEALTHY_RANGE
|
|
17
|
+
@connection = options[:connection]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def threads_running
|
|
21
|
+
query = <<~SQL.squish
|
|
22
|
+
SELECT COUNT(*) as Threads_running
|
|
23
|
+
FROM (
|
|
24
|
+
SELECT 1 FROM performance_schema.threads
|
|
25
|
+
WHERE NAME='thread/sql/one_connection'
|
|
26
|
+
AND PROCESSLIST_STATE IS NOT NULL
|
|
27
|
+
LIMIT #{@healthy_range.max + 1}
|
|
28
|
+
) AS LIM
|
|
29
|
+
SQL
|
|
30
|
+
|
|
31
|
+
@connection.select_value(query)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def throttle_seconds
|
|
35
|
+
current_threads_running = threads_running
|
|
36
|
+
|
|
37
|
+
if !healthy_range.cover?(current_threads_running) && @timeout_seconds < @max_timeout_seconds
|
|
38
|
+
Lhm.logger.info("Increasing timeout between strides from #{@timeout_seconds} to #{@timeout_seconds * 2} because threads running is greater than the maximum of #{@healthy_range.max} allowed.")
|
|
39
|
+
@timeout_seconds = @timeout_seconds * 2
|
|
40
|
+
elsif healthy_range.cover?(current_threads_running) && @timeout_seconds > @initial_timeout_seconds
|
|
41
|
+
Lhm.logger.info("Decreasing timeout between strides from #{@timeout_seconds} to #{@timeout_seconds / 2} because threads running is less than the maximum of #{@healthy_range.max} allowed.")
|
|
42
|
+
@timeout_seconds = @timeout_seconds / 2
|
|
43
|
+
else
|
|
44
|
+
@timeout_seconds
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def execute
|
|
49
|
+
sleep throttle_seconds
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Lhm
|
|
2
|
+
module Throttler
|
|
3
|
+
class Time
|
|
4
|
+
include Command
|
|
5
|
+
|
|
6
|
+
DEFAULT_TIMEOUT = 0.1
|
|
7
|
+
DEFAULT_STRIDE = 2_000
|
|
8
|
+
|
|
9
|
+
attr_accessor :timeout_seconds
|
|
10
|
+
attr_accessor :stride
|
|
11
|
+
|
|
12
|
+
def initialize(options = {})
|
|
13
|
+
@timeout_seconds = options[:delay] || DEFAULT_TIMEOUT
|
|
14
|
+
@stride = options[:stride] || DEFAULT_STRIDE
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def execute
|
|
18
|
+
sleep timeout_seconds
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class LegacyTime < Time
|
|
23
|
+
def initialize(timeout, stride)
|
|
24
|
+
@timeout_seconds = timeout / 1000.0
|
|
25
|
+
@stride = stride
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|