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