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,9 @@
1
+ module Lhm
2
+ class Railtie < Rails::Railtie
3
+ initializer "lhm.test_setup" do
4
+ if Rails.env.test? || Rails.env.development?
5
+ Lhm.execute_inline!
6
+ end
7
+ end
8
+ end
9
+ end
@@ -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