lhm-shopify 3.5.0 → 3.5.4

Sign up to get free protection for your applications and to get access to all the features.
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';