lhm-shopify 3.5.0 → 3.5.4

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +17 -4
  3. data/.gitignore +0 -2
  4. data/Appraisals +24 -0
  5. data/CHANGELOG.md +23 -0
  6. data/Gemfile.lock +66 -0
  7. data/README.md +53 -0
  8. data/Rakefile +6 -5
  9. data/dev.yml +18 -3
  10. data/docker-compose.yml +15 -3
  11. data/gemfiles/activerecord_5.2.gemfile +9 -0
  12. data/gemfiles/activerecord_5.2.gemfile.lock +65 -0
  13. data/gemfiles/activerecord_6.0.gemfile +7 -0
  14. data/gemfiles/activerecord_6.0.gemfile.lock +67 -0
  15. data/gemfiles/activerecord_6.1.gemfile +7 -0
  16. data/gemfiles/activerecord_6.1.gemfile.lock +66 -0
  17. data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
  18. data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +64 -0
  19. data/lhm.gemspec +7 -3
  20. data/lib/lhm/atomic_switcher.rb +4 -3
  21. data/lib/lhm/chunk_insert.rb +7 -3
  22. data/lib/lhm/chunker.rb +6 -6
  23. data/lib/lhm/cleanup/current.rb +4 -1
  24. data/lib/lhm/connection.rb +66 -19
  25. data/lib/lhm/entangler.rb +5 -4
  26. data/lib/lhm/invoker.rb +5 -3
  27. data/lib/lhm/locked_switcher.rb +2 -0
  28. data/lib/lhm/proxysql_helper.rb +10 -0
  29. data/lib/lhm/sql_retry.rb +135 -11
  30. data/lib/lhm/throttler/slave_lag.rb +19 -2
  31. data/lib/lhm/version.rb +1 -1
  32. data/lib/lhm.rb +32 -12
  33. data/scripts/mysql/writer/create_users.sql +3 -0
  34. data/spec/integration/atomic_switcher_spec.rb +38 -10
  35. data/spec/integration/chunk_insert_spec.rb +2 -1
  36. data/spec/integration/chunker_spec.rb +8 -6
  37. data/spec/integration/database.yml +10 -0
  38. data/spec/integration/entangler_spec.rb +3 -1
  39. data/spec/integration/integration_helper.rb +20 -4
  40. data/spec/integration/lhm_spec.rb +75 -0
  41. data/spec/integration/proxysql_spec.rb +34 -0
  42. data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
  43. data/spec/integration/sql_retry/lock_wait_spec.rb +8 -6
  44. data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +19 -9
  45. data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
  46. data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +108 -0
  47. data/spec/integration/toxiproxy_helper.rb +40 -0
  48. data/spec/test_helper.rb +21 -0
  49. data/spec/unit/chunk_insert_spec.rb +7 -2
  50. data/spec/unit/chunker_spec.rb +46 -42
  51. data/spec/unit/connection_spec.rb +51 -8
  52. data/spec/unit/entangler_spec.rb +71 -19
  53. data/spec/unit/lhm_spec.rb +17 -0
  54. data/spec/unit/throttler/slave_lag_spec.rb +14 -9
  55. metadata +76 -11
  56. data/gemfiles/ar-2.3_mysql.gemfile +0 -6
  57. data/gemfiles/ar-3.2_mysql.gemfile +0 -5
  58. data/gemfiles/ar-3.2_mysql2.gemfile +0 -5
  59. data/gemfiles/ar-4.0_mysql2.gemfile +0 -5
  60. data/gemfiles/ar-4.1_mysql2.gemfile +0 -5
  61. data/gemfiles/ar-4.2_mysql2.gemfile +0 -5
  62. data/gemfiles/ar-5.0_mysql2.gemfile +0 -5
data/lhm.gemspec CHANGED
@@ -1,6 +1,6 @@
1
- # -*- encoding: utf-8 -*-
1
+ # frozen_string_literal: true
2
2
 
3
- lib = File.expand_path('../lib', __FILE__)
3
+ lib = File.expand_path('lib', __dir__)
4
4
  $:.unshift(lib) unless $:.include?(lib)
5
5
 
6
6
  require 'lhm/version'
@@ -25,10 +25,14 @@ Gem::Specification.new do |s|
25
25
 
26
26
  s.add_dependency 'retriable', '>= 3.0.0'
27
27
 
28
+ s.add_development_dependency 'activerecord'
28
29
  s.add_development_dependency 'minitest'
29
30
  s.add_development_dependency 'mocha'
31
+ s.add_development_dependency 'after_do'
30
32
  s.add_development_dependency 'rake'
31
- s.add_development_dependency 'activerecord'
32
33
  s.add_development_dependency 'mysql2'
33
34
  s.add_development_dependency 'simplecov'
35
+ s.add_development_dependency 'toxiproxy'
36
+ s.add_development_dependency 'appraisal'
37
+ s.add_development_dependency 'byebug'
34
38
  end
@@ -16,12 +16,13 @@ module Lhm
16
16
 
17
17
  attr_reader :connection
18
18
 
19
- def initialize(migration, connection = nil, options={})
19
+ LOG_PREFIX = "AtomicSwitcher"
20
+
21
+ def initialize(migration, connection = nil)
20
22
  @migration = migration
21
23
  @connection = connection
22
24
  @origin = migration.origin
23
25
  @destination = migration.destination
24
- @retry_options = options[:retriable] || {}
25
26
  end
26
27
 
27
28
  def atomic_switch
@@ -39,7 +40,7 @@ module Lhm
39
40
  private
40
41
 
41
42
  def execute
42
- @connection.execute(atomic_switch, @retry_options)
43
+ @connection.execute(atomic_switch, should_retry: true, log_prefix: LOG_PREFIX)
43
44
  end
44
45
  end
45
46
  end
@@ -1,17 +1,21 @@
1
1
  require 'lhm/sql_retry'
2
+ require 'lhm/proxysql_helper'
2
3
 
3
4
  module Lhm
4
5
  class ChunkInsert
5
- def initialize(migration, connection, lowest, highest, options = {})
6
+
7
+ LOG_PREFIX = "ChunkInsert"
8
+
9
+ def initialize(migration, connection, lowest, highest, retry_options = {})
6
10
  @migration = migration
7
11
  @connection = connection
8
12
  @lowest = lowest
9
13
  @highest = highest
10
- @retry_options = options[:retriable] || {}
14
+ @retry_options = retry_options
11
15
  end
12
16
 
13
17
  def insert_and_return_count_of_rows_created
14
- @connection.update(sql, @retry_options)
18
+ @connection.update(sql, should_retry: true, log_prefix: LOG_PREFIX)
15
19
  end
16
20
 
17
21
  def sql
data/lib/lhm/chunker.rb CHANGED
@@ -13,6 +13,8 @@ module Lhm
13
13
 
14
14
  attr_reader :connection
15
15
 
16
+ LOG_PREFIX = "Chunker"
17
+
16
18
  # Copy from origin to destination in chunks of size `stride`.
17
19
  # Use the `throttler` class to sleep between each stride.
18
20
  def initialize(migration, connection = nil, options = {})
@@ -31,9 +33,7 @@ module Lhm
31
33
  @retry_options = options[:retriable] || {}
32
34
  @retry_helper = SqlRetry.new(
33
35
  @connection,
34
- {
35
- log_prefix: "Chunker"
36
- }.merge!(@retry_options)
36
+ retry_options: @retry_options
37
37
  )
38
38
  end
39
39
 
@@ -79,7 +79,7 @@ module Lhm
79
79
  private
80
80
 
81
81
  def raise_on_non_pk_duplicate_warning
82
- @connection.execute("show warnings", @retry_options).each do |level, code, message|
82
+ @connection.execute("show warnings", should_retry: true, log_prefix: LOG_PREFIX).each do |level, code, message|
83
83
  unless message.match?(/Duplicate entry .+ for key 'PRIMARY'/)
84
84
  m = "Unexpected warning found for inserted row: #{message}"
85
85
  Lhm.logger.warn(m)
@@ -94,14 +94,14 @@ module Lhm
94
94
 
95
95
  def verify_can_run
96
96
  return unless @verifier
97
- @retry_helper.with_retries(@retry_options) do |retriable_connection|
97
+ @retry_helper.with_retries(log_prefix: LOG_PREFIX) do |retriable_connection|
98
98
  raise "Verification failed, aborting early" if !@verifier.call(retriable_connection)
99
99
  end
100
100
  end
101
101
 
102
102
  def upper_id(next_id, stride)
103
103
  sql = "select id from `#{ @migration.origin_name }` where id >= #{ next_id } order by id limit 1 offset #{ stride - 1}"
104
- top = @connection.select_value(sql, @retry_options)
104
+ top = @connection.select_value(sql, should_retry: true, log_prefix: LOG_PREFIX)
105
105
 
106
106
  [top ? top.to_i : @limit, @limit].min
107
107
  end
@@ -4,6 +4,9 @@ require 'lhm/sql_retry'
4
4
  module Lhm
5
5
  module Cleanup
6
6
  class Current
7
+
8
+ LOG_PREFIX = "Current"
9
+
7
10
  def initialize(run, origin_table_name, connection, options={})
8
11
  @run = run
9
12
  @table_name = TableName.new(origin_table_name)
@@ -54,7 +57,7 @@ module Lhm
54
57
 
55
58
  def execute_ddls
56
59
  ddls.each do |ddl|
57
- @connection.execute(ddl, @retry_config)
60
+ @connection.execute(ddl, should_retry: true, log_prefix: LOG_PREFIX)
58
61
  end
59
62
  Lhm.logger.info("Dropped triggers on #{@lhm_triggers_for_origin.join(', ')}")
60
63
  Lhm.logger.info("Dropped tables #{@lhm_triggers_for_origin.join(', ')}")
@@ -1,43 +1,90 @@
1
1
  require 'delegate'
2
+ require 'forwardable'
3
+ require 'lhm/sql_retry'
2
4
 
3
5
  module Lhm
4
- class Connection < SimpleDelegator
5
-
6
6
  # Lhm::Connection inherits from SingleDelegator. It will forward any unknown method calls to the ActiveRecord
7
7
  # connection.
8
- alias connection __getobj__
9
- alias connection= __setobj__
8
+ class Connection < SimpleDelegator
9
+ extend Forwardable
10
+
11
+ # Will delegate the following function to @sql_retry object, while leaving them accessible from the Lhm::Connection
12
+ # object
13
+ def_delegators :@sql_retry, :reconnect_with_consistent_host, :reconnect_with_consistent_host=, :retry_config=
14
+
15
+ alias ar_connection __getobj__
10
16
 
11
- def initialize(connection:, default_log_prefix: nil, retry_options: {})
12
- @default_log_prefix = default_log_prefix
13
- @retry_options = retry_options || default_retry_config
17
+ def initialize(connection:, options: {})
14
18
  @sql_retry = Lhm::SqlRetry.new(
15
19
  connection,
16
- retry_options,
20
+ retry_options: options[:retriable] || {},
21
+ reconnect_with_consistent_host: options[:reconnect_with_consistent_host] || false
17
22
  )
18
23
 
19
24
  # Creates delegation for the ActiveRecord Connection
20
25
  super(connection)
21
26
  end
22
27
 
23
- def execute(query, retry_options = {})
24
- exec_with_retries(:execute, query, retry_options)
28
+ def ar_connection=(connection)
29
+ raise Lhm::Error.new("Lhm::Connection requires an active record connection to operate") if connection.nil?
30
+
31
+ @sql_retry.connection = connection
32
+ # Sets connection as the delegated object
33
+ __setobj__(connection)
25
34
  end
26
35
 
27
- def update(query, retry_options = {})
28
- exec_with_retries(:update, query, retry_options)
36
+ # ActiveRecord::Base overridden methods to incorporate custom retry logic
37
+ # All other methods will be delegated
38
+ def execute(query, should_retry: false, log_prefix: nil)
39
+ if should_retry
40
+ exec_with_retries(:execute, query, log_prefix)
41
+ else
42
+ exec(:execute, query)
43
+ end
29
44
  end
30
45
 
31
- def select_value(query, retry_options = {})
32
- exec_with_retries(:select_value, query, retry_options)
46
+ def update(query, should_retry: false, log_prefix: nil)
47
+ if should_retry
48
+ exec_with_retries(:update, query, log_prefix)
49
+ else
50
+ exec(:update, query)
51
+ end
52
+ end
53
+
54
+ def select_value(query, should_retry: false, log_prefix: nil)
55
+ if should_retry
56
+ exec_with_retries(:select_value, query, log_prefix)
57
+ else
58
+ exec(:select_value, query)
59
+ end
60
+ end
61
+
62
+ def select_values(query, should_retry: false, log_prefix: nil)
63
+ if should_retry
64
+ exec_with_retries(:select_values, query, log_prefix)
65
+ else
66
+ exec(:select_values, query)
67
+ end
68
+ end
69
+
70
+ def select_one(query, should_retry: false, log_prefix: nil)
71
+ if should_retry
72
+ exec_with_retries(:select_one, query, log_prefix)
73
+ else
74
+ exec(:select_one, query)
75
+ end
33
76
  end
34
77
 
35
78
  private
36
79
 
37
- def exec_with_retries(method, sql, retry_options = {})
38
- retry_options[:log_prefix] ||= file
39
- @sql_retry.with_retries(retry_options) do |conn|
40
- conn.public_send(method, sql)
80
+ def exec(method, sql)
81
+ ar_connection.public_send(method, Lhm::ProxySQLHelper.tagged(sql))
82
+ end
83
+
84
+ def exec_with_retries(method, sql, log_prefix=nil)
85
+ effective_log_prefix = log_prefix || file
86
+ @sql_retry.with_retries(log_prefix: effective_log_prefix) do |conn|
87
+ conn.public_send(method, Lhm::ProxySQLHelper.tagged(sql))
41
88
  end
42
89
  end
43
90
 
@@ -51,7 +98,7 @@ module Lhm
51
98
 
52
99
  def relevant_caller
53
100
  lhm_stack = caller.select { |x| x.include?("/lhm") }
54
- first_candidate_index = lhm_stack.find_index {|line| !line.include?(__FILE__)}
101
+ first_candidate_index = lhm_stack.find_index { |line| !line.include?(__FILE__) }
55
102
 
56
103
  # Find the file that called the `#execute` (fallbacks to current file)
57
104
  return lhm_stack.first unless first_candidate_index
data/lib/lhm/entangler.rb CHANGED
@@ -13,14 +13,15 @@ module Lhm
13
13
 
14
14
  attr_reader :connection
15
15
 
16
+ LOG_PREFIX = "Entangler"
17
+
16
18
  # Creates entanglement between two tables. All creates, updates and deletes
17
19
  # to origin will be repeated on the destination table.
18
- def initialize(migration, connection = nil, options = {})
20
+ def initialize(migration, connection = nil)
19
21
  @intersection = migration.intersection
20
22
  @origin = migration.origin
21
23
  @destination = migration.destination
22
24
  @connection = connection
23
- @retry_options = options[:retriable] || {}
24
25
  end
25
26
 
26
27
  def entangle
@@ -86,14 +87,14 @@ module Lhm
86
87
 
87
88
  def before
88
89
  entangle.each do |stmt|
89
- @connection.execute(stmt, @retry_options)
90
+ @connection.execute(stmt, should_retry: true, log_prefix: LOG_PREFIX)
90
91
  end
91
92
  Lhm.logger.info("Created triggers on #{@origin.name}")
92
93
  end
93
94
 
94
95
  def after
95
96
  untangle.each do |stmt|
96
- @connection.execute(stmt, @retry_options)
97
+ @connection.execute(stmt, should_retry: true, log_prefix: LOG_PREFIX)
97
98
  end
98
99
  Lhm.logger.info("Dropped triggers on #{@origin.name}")
99
100
  end
data/lib/lhm/invoker.rb CHANGED
@@ -16,8 +16,8 @@ module Lhm
16
16
  class Invoker
17
17
  include SqlHelper
18
18
  LOCK_WAIT_TIMEOUT_DELTA = 10
19
- INNODB_LOCK_WAIT_TIMEOUT_MAX=1073741824.freeze # https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout
20
- LOCK_WAIT_TIMEOUT_MAX=31536000.freeze # https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html
19
+ INNODB_LOCK_WAIT_TIMEOUT_MAX = 1073741824.freeze # https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout
20
+ LOCK_WAIT_TIMEOUT_MAX = 31536000.freeze # https://dev.mysql.com/doc/refman/5.7/en/server-system-variables.html
21
21
 
22
22
  attr_reader :migrator, :connection
23
23
 
@@ -49,7 +49,7 @@ module Lhm
49
49
  normalize_options(options)
50
50
  set_session_lock_wait_timeouts
51
51
  migration = @migrator.run
52
- entangler = Entangler.new(migration, @connection, options)
52
+ entangler = Entangler.new(migration, @connection)
53
53
 
54
54
  entangler.run do
55
55
  options[:verifier] ||= Proc.new { |conn| triggers_still_exist?(conn, entangler) }
@@ -90,6 +90,8 @@ module Lhm
90
90
  options[:throttler] = Lhm.throttler
91
91
  end
92
92
 
93
+ Lhm.connection.retry_config = options[:retriable] || {}
94
+
93
95
  rescue => e
94
96
  Lhm.logger.error "LHM run failed with exception=#{e.class} message=#{e.message}"
95
97
  raise
@@ -22,6 +22,8 @@ module Lhm
22
22
 
23
23
  attr_reader :connection
24
24
 
25
+ LOG_PREFIX = "LockedSwitcher"
26
+
25
27
  def initialize(migration, connection = nil)
26
28
  @migration = migration
27
29
  @connection = connection
@@ -0,0 +1,10 @@
1
+ module Lhm
2
+ module ProxySQLHelper
3
+ extend self
4
+ ANNOTATION = "/*maintenance:lhm*/"
5
+
6
+ def tagged(sql)
7
+ "#{sql} #{ANNOTATION}"
8
+ end
9
+ end
10
+ end
data/lib/lhm/sql_retry.rb CHANGED
@@ -1,5 +1,6 @@
1
1
  require 'retriable'
2
2
  require 'lhm/sql_helper'
3
+ require 'lhm/proxysql_helper'
3
4
 
4
5
  module Lhm
5
6
  # SqlRetry standardizes the interface for retry behavior in components like
@@ -15,26 +16,123 @@ module Lhm
15
16
  # https://github.com/kamui/retriable. Additionally, a "log_prefix" option,
16
17
  # which is unique to SqlRetry can be used to prefix log output.
17
18
  class SqlRetry
18
- def initialize(connection, options = {})
19
+ RECONNECT_SUCCESSFUL_MESSAGE = "LHM successfully reconnected to initial host:"
20
+ CLOUDSQL_VERSION_COMMENT = "(Google)"
21
+
22
+ MYSQL_VAR_NAMES = {
23
+ hostname: "@@global.hostname",
24
+ server_id: "@@global.server_id",
25
+ version_comment: "@@version_comment",
26
+ }
27
+
28
+ # This internal error is used to trigger retries from the parent Retriable.retriable in #with_retries
29
+ class ReconnectToHostSuccessful < Lhm::Error; end
30
+
31
+ def initialize(connection, retry_options: {}, reconnect_with_consistent_host: false)
19
32
  @connection = connection
20
- @global_retry_config = default_retry_config.dup.merge!(options)
33
+ self.retry_config = retry_options
34
+ self.reconnect_with_consistent_host = reconnect_with_consistent_host
21
35
  end
22
36
 
23
- def with_retries(retry_config = {})
24
- cnf = @global_retry_config.dup.merge!(retry_config)
25
- @log_prefix = cnf.delete(:log_prefix) || "SQL Retry"
26
- Retriable.retriable(cnf) do
27
- yield(@connection)
37
+ # Complete explanation of algorithm: https://github.com/Shopify/lhm/pull/112
38
+ def with_retries(log_prefix: nil)
39
+ @log_prefix = log_prefix || "" # No prefix. Just logs
40
+
41
+ Retriable.retriable(@retry_config) do
42
+ # Using begin -> rescue -> end for Ruby 2.4 compatibility
43
+ begin
44
+ if @reconnect_with_consistent_host
45
+ raise Lhm::Error.new("Could not reconnected to initial MySQL host. Aborting to avoid data-loss") unless same_host_as_initial?
46
+ end
47
+
48
+ yield(@connection)
49
+ rescue StandardError => e
50
+ # Not all errors should trigger a reconnect. Some errors such be raised and abort the LHM (such as reconnecting to the wrong host).
51
+ # The error will be raised the connection is still active (i.e. no need to reconnect) or if the connection is
52
+ # dead (i.e. not active) and @reconnect_with_host is false (i.e. instructed not to reconnect)
53
+ raise e if @connection.active? || (!@connection.active? && !@reconnect_with_consistent_host)
54
+ reconnect_with_host_check! if @reconnect_with_consistent_host
55
+ end
28
56
  end
29
57
  end
30
58
 
31
- attr_reader :global_retry_config
59
+ # Both attributes will have defined setters
60
+ attr_reader :retry_config, :reconnect_with_consistent_host
61
+ attr_accessor :connection
62
+
63
+ def retry_config=(retry_options)
64
+ @retry_config = default_retry_config.dup.merge!(retry_options)
65
+ end
66
+
67
+ def reconnect_with_consistent_host=(reconnect)
68
+ if (@reconnect_with_consistent_host = reconnect)
69
+ @initial_hostname = hostname
70
+ @initial_server_id = server_id
71
+ end
72
+ end
32
73
 
33
74
  private
34
75
 
76
+ def hostname
77
+ mysql_single_value(MYSQL_VAR_NAMES[:hostname])
78
+ end
79
+
80
+ def server_id
81
+ mysql_single_value(MYSQL_VAR_NAMES[:server_id])
82
+ end
83
+
84
+ def cloudsql?
85
+ mysql_single_value(MYSQL_VAR_NAMES[:version_comment]).include?(CLOUDSQL_VERSION_COMMENT)
86
+ end
87
+
88
+ def mysql_single_value(name)
89
+ query = Lhm::ProxySQLHelper.tagged("SELECT #{name} LIMIT 1")
90
+
91
+ @connection.execute(query).to_a.first.tap do |record|
92
+ return record&.first
93
+ end
94
+ end
95
+
96
+ def same_host_as_initial?
97
+ return @initial_server_id == server_id if cloudsql?
98
+ @initial_hostname == hostname
99
+ end
100
+
35
101
  def log_with_prefix(message, level = :info)
36
102
  message.prepend("[#{@log_prefix}] ") if @log_prefix
37
- Lhm.logger.send(level, message)
103
+ Lhm.logger.public_send(level, message)
104
+ end
105
+
106
+ def reconnect_with_host_check!
107
+ log_with_prefix("Lost connection to MySQL, will retry to connect to same host")
108
+ begin
109
+ Retriable.retriable(host_retry_config) do
110
+ # tries to reconnect. On failure will trigger a retry
111
+ @connection.reconnect!
112
+
113
+ if same_host_as_initial?
114
+ # This is not an actual error, but controlled way to get the parent `Retriable.retriable` to retry
115
+ # the statement that failed (since the Retriable gem only retries on errors).
116
+ raise ReconnectToHostSuccessful.new("LHM successfully reconnected to initial host: #{@initial_hostname} (server_id: #{@initial_server_id})")
117
+ else
118
+ # New Master --> abort LHM (reconnecting will not change anything)
119
+ raise Lhm::Error.new("Reconnected to wrong host. Started migration on: #{@initial_hostname} (server_id: #{@initial_server_id}), but reconnected to: #{hostname} (server_id: #{@initial_server_id}).")
120
+ end
121
+ end
122
+ rescue StandardError => e
123
+ # The parent Retriable.retriable is configured to retry if it encounters an error with the success message.
124
+ # Therefore, if the connection is re-established successfully AND the host is the same, LHM can retry the query
125
+ # that originally failed.
126
+ raise e if reconnect_successful?(e)
127
+ # If the connection was not successful, the parent retriable will raise any error that originated from the
128
+ # `@connection.reconnect!`
129
+ # Therefore, this error will cause the LHM to abort
130
+ raise Lhm::Error.new("LHM tried the reconnection procedure but failed. Latest error: #{e.message}")
131
+ end
132
+ end
133
+
134
+ def reconnect_successful?(e)
135
+ e.is_a?(ReconnectToHostSuccessful)
38
136
  end
39
137
 
40
138
  # For a full list of configuration options see https://github.com/kamui/retriable
@@ -49,6 +147,11 @@ module Lhm
49
147
  /Lost connection to MySQL server during query/,
50
148
  /Max connect timeout reached/,
51
149
  /Unknown MySQL server host/,
150
+ /connection is locked to hostgroup/,
151
+ /The MySQL server is running with the --read-only option so it cannot execute this statement/,
152
+ ],
153
+ ReconnectToHostSuccessful => [
154
+ /#{RECONNECT_SUCCESSFUL_MESSAGE}/
52
155
  ]
53
156
  },
54
157
  multiplier: 1, # each successive interval grows by this factor
@@ -57,8 +160,29 @@ module Lhm
57
160
  rand_factor: 0, # percentage to randomize the next retry interval time
58
161
  max_elapsed_time: Float::INFINITY, # max total time in seconds that code is allowed to keep being retried
59
162
  on_retry: Proc.new do |exception, try_number, total_elapsed_time, next_interval|
60
- log = "#{exception.class}: '#{exception.message}' - #{try_number} tries in #{total_elapsed_time} seconds and #{next_interval} seconds until the next try."
61
- log_with_prefix(log, :info)
163
+ if reconnect_successful?(exception)
164
+ log_with_prefix("#{exception.message} -- triggering retry", :info)
165
+ else
166
+ log_with_prefix("#{exception.class}: '#{exception.message}' - #{try_number} tries in #{total_elapsed_time} seconds and #{next_interval} seconds until the next try.", :error)
167
+ end
168
+ end
169
+ }.freeze
170
+ end
171
+
172
+ def host_retry_config
173
+ {
174
+ on: {
175
+ StandardError => [
176
+ /Lost connection to MySQL server at 'reading initial communication packet'/
177
+ ]
178
+ },
179
+ multiplier: 1, # each successive interval grows by this factor
180
+ base_interval: 0.25, # the initial interval in seconds between tries.
181
+ tries: 20, # Number of attempts to make at running your code block (includes initial attempt).
182
+ rand_factor: 0, # percentage to randomize the next retry interval time
183
+ max_elapsed_time: Float::INFINITY, # max total time in seconds that code is allowed to keep being retried
184
+ on_retry: Proc.new do |exception, try_number, total_elapsed_time, next_interval|
185
+ log_with_prefix("#{exception.class}: '#{exception.message}' - #{try_number} tries in #{total_elapsed_time} seconds and #{next_interval} seconds until the next try.", :error)
62
186
  end
63
187
  }.freeze
64
188
  end
@@ -124,8 +124,8 @@ module Lhm
124
124
  else
125
125
  raise ArgumentError, "Expected #{config_proc.inspect} to respond to `call`"
126
126
  end
127
- else # otherwise default to ActiveRecord provided config
128
- ActiveRecord::Base.connection_pool.spec.config.dup
127
+ else
128
+ db_config
129
129
  end
130
130
  config.deep_symbolize_keys!
131
131
  config[:host] = @host
@@ -140,6 +140,23 @@ module Lhm
140
140
  [nil]
141
141
  end
142
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
143
160
  end
144
161
  end
145
162
  end
data/lib/lhm/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Schmidt
3
3
 
4
4
  module Lhm
5
- VERSION = '3.5.0'
5
+ VERSION = '3.5.4'
6
6
  end
data/lib/lhm.rb CHANGED
@@ -8,6 +8,7 @@ require 'lhm/throttler'
8
8
  require 'lhm/version'
9
9
  require 'lhm/cleanup/current'
10
10
  require 'lhm/sql_retry'
11
+ require 'lhm/proxysql_helper'
11
12
  require 'lhm/connection'
12
13
  require 'lhm/test_support'
13
14
  require 'lhm/railtie' if defined?(Rails::Railtie)
@@ -27,7 +28,7 @@ module Lhm
27
28
  extend Throttler
28
29
  extend self
29
30
 
30
- DEFAULT_LOGGER_OPTIONS = { level: Logger::INFO, file: STDOUT }
31
+ DEFAULT_LOGGER_OPTIONS = { level: Logger::INFO, file: STDOUT }
31
32
 
32
33
  # Alters a table with the changes described in the block
33
34
  #
@@ -45,15 +46,19 @@ module Lhm
45
46
  # Use atomic switch to rename tables (defaults to: true)
46
47
  # If using a version of mysql affected by atomic switch bug, LHM forces user
47
48
  # to set this option (see SqlHelper#supports_atomic_switch?)
49
+ # @option options [Boolean] :reconnect_with_consistent_host
50
+ # Active / Deactivate ProxySQL-aware reconnection procedure (default to: false)
48
51
  # @yield [Migrator] Yielded Migrator object records the changes
49
52
  # @return [Boolean] Returns true if the migration finishes
50
53
  # @raise [Error] Raises Lhm::Error in case of a error and aborts the migration
51
54
  def change_table(table_name, options = {}, &block)
52
- origin = Table.parse(table_name, connection)
53
- invoker = Invoker.new(origin, connection)
54
- block.call(invoker.migrator)
55
- invoker.run(options)
56
- true
55
+ with_flags(options) do
56
+ origin = Table.parse(table_name, connection)
57
+ invoker = Invoker.new(origin, connection)
58
+ block.call(invoker.migrator)
59
+ invoker.run(options)
60
+ true
61
+ end
57
62
  end
58
63
 
59
64
  # Cleanup tables and triggers
@@ -82,16 +87,19 @@ module Lhm
82
87
  Lhm::Cleanup::Current.new(run, table_name, connection, options).execute
83
88
  end
84
89
 
90
+ # Setups DB connection
91
+ #
92
+ # @param [ActiveRecord::Base] connection ActiveRecord Connection
85
93
  def setup(connection)
86
- @@connection = Lhm::Connection.new(connection: connection)
94
+ @@connection = Connection.new(connection: connection)
87
95
  end
88
96
 
97
+ # Returns DB connection (or initializes it if not created yet)
89
98
  def connection
90
- @@connection ||=
91
- begin
92
- raise 'Please call Lhm.setup' unless defined?(ActiveRecord)
93
- ActiveRecord::Base.connection
94
- end
99
+ @@connection ||= begin
100
+ raise 'Please call Lhm.setup' unless defined?(ActiveRecord)
101
+ @@connection = Connection.new(connection: ActiveRecord::Base.connection)
102
+ end
95
103
  end
96
104
 
97
105
  def self.logger=(new_logger)
@@ -133,4 +141,16 @@ module Lhm
133
141
  false
134
142
  end
135
143
  end
144
+
145
+ def with_flags(options)
146
+ old_flags = {
147
+ reconnect_with_consistent_host: Lhm.connection.reconnect_with_consistent_host,
148
+ }
149
+
150
+ Lhm.connection.reconnect_with_consistent_host = options[:reconnect_with_consistent_host] || false
151
+
152
+ yield
153
+ ensure
154
+ Lhm.connection.reconnect_with_consistent_host = old_flags[:reconnect_with_consistent_host]
155
+ end
136
156
  end
@@ -1,3 +1,6 @@
1
1
  # Creates replication user in Writer
2
+ CREATE USER IF NOT EXISTS 'writer'@'%' IDENTIFIED BY 'password';
3
+ CREATE USER IF NOT EXISTS 'reader'@'%' IDENTIFIED BY 'password';
4
+
2
5
  CREATE USER IF NOT EXISTS 'replication'@'%' IDENTIFIED BY 'password';
3
6
  GRANT REPLICATION SLAVE ON *.* TO' replication'@'%' IDENTIFIED BY 'password';