lhm-shopify 3.4.2 → 3.5.3

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/test.yml +24 -15
  3. data/.gitignore +1 -6
  4. data/Appraisals +24 -0
  5. data/CHANGELOG.md +15 -0
  6. data/Gemfile.lock +66 -0
  7. data/README.md +53 -4
  8. data/Rakefile +10 -0
  9. data/dev.yml +28 -6
  10. data/docker-compose.yml +58 -0
  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 -11
  21. data/lib/lhm/chunk_insert.rb +4 -10
  22. data/lib/lhm/chunker.rb +7 -8
  23. data/lib/lhm/cleanup/current.rb +3 -10
  24. data/lib/lhm/connection.rb +109 -0
  25. data/lib/lhm/entangler.rb +4 -12
  26. data/lib/lhm/invoker.rb +3 -3
  27. data/lib/lhm/proxysql_helper.rb +10 -0
  28. data/lib/lhm/sql_retry.rb +132 -9
  29. data/lib/lhm/throttler/slave_lag.rb +19 -2
  30. data/lib/lhm/version.rb +1 -1
  31. data/lib/lhm.rb +25 -9
  32. data/scripts/helpers/wait-for-dbs.sh +21 -0
  33. data/scripts/mysql/reader/create_replication.sql +10 -0
  34. data/scripts/mysql/writer/create_test_db.sql +1 -0
  35. data/scripts/mysql/writer/create_users.sql +6 -0
  36. data/scripts/proxysql/proxysql.cnf +117 -0
  37. data/spec/integration/atomic_switcher_spec.rb +38 -13
  38. data/spec/integration/chunk_insert_spec.rb +2 -1
  39. data/spec/integration/chunker_spec.rb +1 -1
  40. data/spec/integration/database.yml +25 -0
  41. data/spec/integration/entangler_spec.rb +3 -1
  42. data/spec/integration/integration_helper.rb +24 -9
  43. data/spec/integration/lhm_spec.rb +75 -0
  44. data/spec/integration/proxysql_spec.rb +34 -0
  45. data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
  46. data/spec/integration/sql_retry/lock_wait_spec.rb +8 -6
  47. data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +17 -4
  48. data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
  49. data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +108 -0
  50. data/spec/integration/toxiproxy_helper.rb +40 -0
  51. data/spec/test_helper.rb +21 -0
  52. data/spec/unit/chunk_insert_spec.rb +7 -2
  53. data/spec/unit/chunker_spec.rb +46 -42
  54. data/spec/unit/connection_spec.rb +86 -0
  55. data/spec/unit/entangler_spec.rb +51 -10
  56. data/spec/unit/lhm_spec.rb +17 -0
  57. data/spec/unit/throttler/slave_lag_spec.rb +13 -8
  58. metadata +85 -14
  59. data/bin/.gitkeep +0 -0
  60. data/dbdeployer/config.json +0 -32
  61. data/dbdeployer/install.sh +0 -64
  62. data/gemfiles/ar-2.3_mysql.gemfile +0 -6
  63. data/gemfiles/ar-3.2_mysql.gemfile +0 -5
  64. data/gemfiles/ar-3.2_mysql2.gemfile +0 -5
  65. data/gemfiles/ar-4.0_mysql2.gemfile +0 -5
  66. data/gemfiles/ar-4.1_mysql2.gemfile +0 -5
  67. data/gemfiles/ar-4.2_mysql2.gemfile +0 -5
  68. 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,17 +16,12 @@ module Lhm
16
16
 
17
17
  attr_reader :connection
18
18
 
19
- def initialize(migration, connection = nil, options = {})
19
+ def initialize(migration, connection = nil, options={})
20
20
  @migration = migration
21
21
  @connection = connection
22
22
  @origin = migration.origin
23
23
  @destination = migration.destination
24
- @retry_helper = SqlRetry.new(
25
- @connection,
26
- {
27
- log_prefix: "AtomicSwitcher"
28
- }.merge!(options.fetch(:retriable, {}))
29
- )
24
+ @retry_options = options[:retriable] || {}
30
25
  end
31
26
 
32
27
  def atomic_switch
@@ -36,7 +31,7 @@ module Lhm
36
31
 
37
32
  def validate
38
33
  unless @connection.data_source_exists?(@origin.name) &&
39
- @connection.data_source_exists?(@destination.name)
34
+ @connection.data_source_exists?(@destination.name)
40
35
  error "`#{ @origin.name }` and `#{ @destination.name }` must exist"
41
36
  end
42
37
  end
@@ -44,9 +39,7 @@ module Lhm
44
39
  private
45
40
 
46
41
  def execute
47
- @retry_helper.with_retries do |retriable_connection|
48
- retriable_connection.execute atomic_switch
49
- end
42
+ @connection.execute(atomic_switch, should_retry: true, retry_options: @retry_options)
50
43
  end
51
44
  end
52
45
  end
@@ -1,24 +1,18 @@
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
+ def initialize(migration, connection, lowest, highest, retry_options = {})
6
7
  @migration = migration
7
8
  @connection = connection
8
9
  @lowest = lowest
9
10
  @highest = highest
10
- @retry_helper = SqlRetry.new(
11
- @connection,
12
- {
13
- log_prefix: "Chunker Insert"
14
- }.merge!(options.fetch(:retriable, {}))
15
- )
11
+ @retry_options = retry_options
16
12
  end
17
13
 
18
14
  def insert_and_return_count_of_rows_created
19
- @retry_helper.with_retries do |retriable_connection|
20
- retriable_connection.update sql
21
- end
15
+ @connection.update(sql, should_retry: true, retry_options: @retry_options)
22
16
  end
23
17
 
24
18
  def sql
data/lib/lhm/chunker.rb CHANGED
@@ -28,11 +28,12 @@ module Lhm
28
28
  @start = @chunk_finder.start
29
29
  @limit = @chunk_finder.limit
30
30
  @printer = options[:printer] || Printer::Percentage.new
31
+ @retry_options = options[:retriable] || {}
31
32
  @retry_helper = SqlRetry.new(
32
33
  @connection,
33
- {
34
+ retry_options: {
34
35
  log_prefix: "Chunker"
35
- }.merge!(options.fetch(:retriable, {}))
36
+ }.merge!(@retry_options)
36
37
  )
37
38
  end
38
39
 
@@ -46,7 +47,7 @@ module Lhm
46
47
  top = upper_id(@next_to_insert, stride)
47
48
  verify_can_run
48
49
 
49
- affected_rows = ChunkInsert.new(@migration, @connection, bottom, top, @options).insert_and_return_count_of_rows_created
50
+ affected_rows = ChunkInsert.new(@migration, @connection, bottom, top, @retry_options).insert_and_return_count_of_rows_created
50
51
  expected_rows = top - bottom + 1
51
52
 
52
53
  # Only log the chunker progress every 5 minutes instead of every iteration
@@ -78,7 +79,7 @@ module Lhm
78
79
  private
79
80
 
80
81
  def raise_on_non_pk_duplicate_warning
81
- @connection.query("show warnings").each do |level, code, message|
82
+ @connection.execute("show warnings", should_retry: true, retry_options: @retry_options).each do |level, code, message|
82
83
  unless message.match?(/Duplicate entry .+ for key 'PRIMARY'/)
83
84
  m = "Unexpected warning found for inserted row: #{message}"
84
85
  Lhm.logger.warn(m)
@@ -93,16 +94,14 @@ module Lhm
93
94
 
94
95
  def verify_can_run
95
96
  return unless @verifier
96
- @retry_helper.with_retries do |retriable_connection|
97
+ @retry_helper.with_retries(@retry_options) do |retriable_connection|
97
98
  raise "Verification failed, aborting early" if !@verifier.call(retriable_connection)
98
99
  end
99
100
  end
100
101
 
101
102
  def upper_id(next_id, stride)
102
103
  sql = "select id from `#{ @migration.origin_name }` where id >= #{ next_id } order by id limit 1 offset #{ stride - 1}"
103
- top = @retry_helper.with_retries do |retriable_connection|
104
- retriable_connection.select_value(sql)
105
- end
104
+ top = @connection.select_value(sql, should_retry: true, retry_options: @retry_options)
106
105
 
107
106
  [top ? top.to_i : @limit, @limit].min
108
107
  end
@@ -4,17 +4,12 @@ require 'lhm/sql_retry'
4
4
  module Lhm
5
5
  module Cleanup
6
6
  class Current
7
- def initialize(run, origin_table_name, connection, options = {})
7
+ def initialize(run, origin_table_name, connection, options={})
8
8
  @run = run
9
9
  @table_name = TableName.new(origin_table_name)
10
10
  @connection = connection
11
11
  @ddls = []
12
- @retry_helper = SqlRetry.new(
13
- @connection,
14
- {
15
- log_prefix: "Cleanup::Current"
16
- }.merge!(options.fetch(:retriable, {}))
17
- )
12
+ @retry_config = options[:retriable] || {}
18
13
  end
19
14
 
20
15
  attr_reader :run, :connection, :ddls
@@ -59,9 +54,7 @@ module Lhm
59
54
 
60
55
  def execute_ddls
61
56
  ddls.each do |ddl|
62
- @retry_helper.with_retries do |retriable_connection|
63
- retriable_connection.execute(ddl)
64
- end
57
+ @connection.execute(ddl, should_retry: true, retry_options: @retry_config)
65
58
  end
66
59
  Lhm.logger.info("Dropped triggers on #{@lhm_triggers_for_origin.join(', ')}")
67
60
  Lhm.logger.info("Dropped tables #{@lhm_triggers_for_origin.join(', ')}")
@@ -0,0 +1,109 @@
1
+ require 'delegate'
2
+ require 'lhm/sql_retry'
3
+
4
+ module Lhm
5
+ # Lhm::Connection inherits from SingleDelegator. It will forward any unknown method calls to the ActiveRecord
6
+ # connection.
7
+ class Connection < SimpleDelegator
8
+
9
+ def initialize(connection:, default_log_prefix: nil, options: {})
10
+ @default_log_prefix = default_log_prefix
11
+ @sql_retry = Lhm::SqlRetry.new(
12
+ connection,
13
+ retry_options: options[:retriable] || {},
14
+ reconnect_with_consistent_host: options[:reconnect_with_consistent_host] || false
15
+ )
16
+
17
+ # Creates delegation for the ActiveRecord Connection
18
+ super(connection)
19
+ end
20
+
21
+ def ar_connection
22
+ # Get object from the simple delegator
23
+ __getobj__
24
+ end
25
+
26
+ def ar_connection=(connection)
27
+ raise Lhm::Error.new("Lhm::Connection requires an active record connection to operate") if connection.nil?
28
+
29
+ @sql_retry.connection = connection
30
+ # Sets connection as the delegated object
31
+ __setobj__(connection)
32
+ end
33
+
34
+ def process_connection_options(options)
35
+ # If any other flags are added. Add the "processing" here
36
+ @sql_retry.reconnect_with_consistent_host = options[:reconnect_with_consistent_host] || false
37
+ end
38
+
39
+ def execute(query, should_retry: false, retry_options: {})
40
+ if should_retry
41
+ exec_with_retries(:execute, query, retry_options)
42
+ else
43
+ exec(:execute, query)
44
+ end
45
+ end
46
+
47
+ def update(query, should_retry: false, retry_options: {})
48
+ if should_retry
49
+ exec_with_retries(:update, query, retry_options)
50
+ else
51
+ exec(:update, query)
52
+ end
53
+ end
54
+
55
+ def select_value(query, should_retry: false, retry_options: {})
56
+ if should_retry
57
+ exec_with_retries(:select_value, query, retry_options)
58
+ else
59
+ exec(:select_value, query)
60
+ end
61
+ end
62
+
63
+ def select_values(query, should_retry: false, retry_options: {})
64
+ if should_retry
65
+ exec_with_retries(:select_values, query, retry_options)
66
+ else
67
+ exec(:select_values, query)
68
+ end
69
+ end
70
+
71
+ def select_one(query, should_retry: false, retry_options: {})
72
+ if should_retry
73
+ exec_with_retries(:select_one, query, retry_options)
74
+ else
75
+ exec(:select_one, query)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def exec(method, sql)
82
+ ar_connection.public_send(method, Lhm::ProxySQLHelper.tagged(sql))
83
+ end
84
+
85
+ def exec_with_retries(method, sql, retry_options = {})
86
+ retry_options[:log_prefix] ||= file
87
+ @sql_retry.with_retries(retry_options) do |conn|
88
+ conn.public_send(method, Lhm::ProxySQLHelper.tagged(sql))
89
+ end
90
+ end
91
+
92
+ # Returns camelized file name of caller (e.g. chunk_insert.rb -> ChunkInsert)
93
+ def file
94
+ # Find calling file and extract name
95
+ /[\/]*(\w+).rb:\d+:in/.match(relevant_caller)
96
+ name = $1&.camelize || "Connection"
97
+ "#{name}"
98
+ end
99
+
100
+ def relevant_caller
101
+ lhm_stack = caller.select { |x| x.include?("/lhm") }
102
+ first_candidate_index = lhm_stack.find_index { |line| !line.include?(__FILE__) }
103
+
104
+ # Find the file that called the `#execute` (fallbacks to current file)
105
+ return lhm_stack.first unless first_candidate_index
106
+ lhm_stack.at(first_candidate_index)
107
+ end
108
+ end
109
+ end
data/lib/lhm/entangler.rb CHANGED
@@ -4,6 +4,7 @@
4
4
  require 'lhm/command'
5
5
  require 'lhm/sql_helper'
6
6
  require 'lhm/sql_retry'
7
+ require 'lhm/connection'
7
8
 
8
9
  module Lhm
9
10
  class Entangler
@@ -19,12 +20,7 @@ module Lhm
19
20
  @origin = migration.origin
20
21
  @destination = migration.destination
21
22
  @connection = connection
22
- @retry_helper = SqlRetry.new(
23
- @connection,
24
- {
25
- log_prefix: "Entangler"
26
- }.merge!(options.fetch(:retriable, {}))
27
- )
23
+ @retry_options = options[:retriable] || {}
28
24
  end
29
25
 
30
26
  def entangle
@@ -90,18 +86,14 @@ module Lhm
90
86
 
91
87
  def before
92
88
  entangle.each do |stmt|
93
- @retry_helper.with_retries do |retriable_connection|
94
- retriable_connection.execute(stmt)
95
- end
89
+ @connection.execute(stmt, should_retry: true, retry_options: @retry_options)
96
90
  end
97
91
  Lhm.logger.info("Created triggers on #{@origin.name}")
98
92
  end
99
93
 
100
94
  def after
101
95
  untangle.each do |stmt|
102
- @retry_helper.with_retries do |retriable_connection|
103
- retriable_connection.execute(stmt)
104
- end
96
+ @connection.execute(stmt, should_retry: true, retry_options: @retry_options)
105
97
  end
106
98
  Lhm.logger.info("Dropped triggers on #{@origin.name}")
107
99
  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
 
@@ -56,7 +56,7 @@ module Lhm
56
56
  Chunker.new(migration, @connection, options).run
57
57
  raise "Required triggers do not exist" unless triggers_still_exist?(@connection, entangler)
58
58
  if options[:atomic_switch]
59
- AtomicSwitcher.new(migration, @connection, options).run
59
+ AtomicSwitcher.new(migration, @connection).run
60
60
  else
61
61
  LockedSwitcher.new(migration, @connection).run
62
62
  end
@@ -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,22 +16,119 @@ 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
- @log_prefix = options.delete(:log_prefix)
21
- @retry_config = default_retry_config.dup.merge!(options)
33
+ @log_prefix = retry_options.delete(:log_prefix)
34
+ @global_retry_config = default_retry_config.dup.merge!(retry_options)
35
+ if (@reconnect_with_consistent_host = reconnect_with_consistent_host)
36
+ @initial_hostname = hostname
37
+ @initial_server_id = server_id
38
+ end
22
39
  end
23
40
 
24
- def with_retries
41
+ # Complete explanation of algorithm: https://github.com/Shopify/lhm/pull/112
42
+ def with_retries(retry_config = {})
43
+ @log_prefix = retry_config.delete(:log_prefix)
44
+
45
+ retry_config = @global_retry_config.dup.merge!(retry_config)
46
+
25
47
  Retriable.retriable(retry_config) do
26
- yield(@connection)
48
+ # Using begin -> rescue -> end for Ruby 2.4 compatibility
49
+ begin
50
+ if @reconnect_with_consistent_host
51
+ raise Lhm::Error.new("Could not reconnected to initial MySQL host. 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
+ reconnect_with_host_check! if @reconnect_with_consistent_host
61
+ end
27
62
  end
28
63
  end
29
64
 
30
- attr_reader :retry_config
65
+ attr_reader :global_retry_config
66
+ attr_accessor :connection, :reconnect_with_consistent_host
31
67
 
32
68
  private
33
69
 
70
+ def hostname
71
+ mysql_single_value(MYSQL_VAR_NAMES[:hostname])
72
+ end
73
+
74
+ def server_id
75
+ mysql_single_value(MYSQL_VAR_NAMES[:server_id])
76
+ end
77
+
78
+ def cloudsql?
79
+ mysql_single_value(MYSQL_VAR_NAMES[:version_comment]).include?(CLOUDSQL_VERSION_COMMENT)
80
+ end
81
+
82
+ def mysql_single_value(name)
83
+ query = Lhm::ProxySQLHelper.tagged("SELECT #{name} LIMIT 1")
84
+
85
+ @connection.execute(query).to_a.first.tap do |record|
86
+ return record&.first
87
+ end
88
+ end
89
+
90
+ def same_host_as_initial?
91
+ return @initial_server_id == server_id if cloudsql?
92
+ @initial_hostname == hostname
93
+ end
94
+
95
+ def log_with_prefix(message, level = :info)
96
+ message.prepend("[#{@log_prefix}] ") if @log_prefix
97
+ Lhm.logger.public_send(level, message)
98
+ end
99
+
100
+ def reconnect_with_host_check!
101
+ log_with_prefix("Lost connection to MySQL, will retry to connect to same host")
102
+ begin
103
+ Retriable.retriable(host_retry_config) do
104
+ # tries to reconnect. On failure will trigger a retry
105
+ @connection.reconnect!
106
+
107
+ if same_host_as_initial?
108
+ # This is not an actual error, but controlled way to get the parent `Retriable.retriable` to retry
109
+ # the statement that failed (since the Retriable gem only retries on errors).
110
+ raise ReconnectToHostSuccessful.new("LHM successfully reconnected to initial host: #{@initial_hostname} (server_id: #{@initial_server_id})")
111
+ else
112
+ # New Master --> abort LHM (reconnecting will not change anything)
113
+ 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}).")
114
+ end
115
+ end
116
+ rescue StandardError => e
117
+ # The parent Retriable.retriable is configured to retry if it encounters an error with the success message.
118
+ # Therefore, if the connection is re-established successfully AND the host is the same, LHM can retry the query
119
+ # that originally failed.
120
+ raise e if reconnect_successful?(e)
121
+ # If the connection was not successful, the parent retriable will raise any error that originated from the
122
+ # `@connection.reconnect!`
123
+ # Therefore, this error will cause the LHM to abort
124
+ raise Lhm::Error.new("LHM tried the reconnection procedure but failed. Latest error: #{e.message}")
125
+ end
126
+ end
127
+
128
+ def reconnect_successful?(e)
129
+ e.is_a?(ReconnectToHostSuccessful)
130
+ end
131
+
34
132
  # For a full list of configuration options see https://github.com/kamui/retriable
35
133
  def default_retry_config
36
134
  {
@@ -43,6 +141,11 @@ module Lhm
43
141
  /Lost connection to MySQL server during query/,
44
142
  /Max connect timeout reached/,
45
143
  /Unknown MySQL server host/,
144
+ /connection is locked to hostgroup/,
145
+ /The MySQL server is running with the --read-only option so it cannot execute this statement/,
146
+ ],
147
+ ReconnectToHostSuccessful => [
148
+ /#{RECONNECT_SUCCESSFUL_MESSAGE}/
46
149
  ]
47
150
  },
48
151
  multiplier: 1, # each successive interval grows by this factor
@@ -51,9 +154,29 @@ module Lhm
51
154
  rand_factor: 0, # percentage to randomize the next retry interval time
52
155
  max_elapsed_time: Float::INFINITY, # max total time in seconds that code is allowed to keep being retried
53
156
  on_retry: Proc.new do |exception, try_number, total_elapsed_time, next_interval|
54
- log = "#{exception.class}: '#{exception.message}' - #{try_number} tries in #{total_elapsed_time} seconds and #{next_interval} seconds until the next try."
55
- log.prepend("[#{@log_prefix}] ") if @log_prefix
56
- Lhm.logger.info(log)
157
+ if reconnect_successful?(exception)
158
+ log_with_prefix("#{exception.message} -- triggering retry", :info)
159
+ else
160
+ log_with_prefix("#{exception.class}: '#{exception.message}' - #{try_number} tries in #{total_elapsed_time} seconds and #{next_interval} seconds until the next try.", :error)
161
+ end
162
+ end
163
+ }.freeze
164
+ end
165
+
166
+ def host_retry_config
167
+ {
168
+ on: {
169
+ StandardError => [
170
+ /Lost connection to MySQL server at 'reading initial communication packet'/
171
+ ]
172
+ },
173
+ multiplier: 1, # each successive interval grows by this factor
174
+ base_interval: 0.25, # the initial interval in seconds between tries.
175
+ tries: 20, # Number of attempts to make at running your code block (includes initial attempt).
176
+ rand_factor: 0, # percentage to randomize the next retry interval time
177
+ max_elapsed_time: Float::INFINITY, # max total time in seconds that code is allowed to keep being retried
178
+ on_retry: Proc.new do |exception, try_number, total_elapsed_time, next_interval|
179
+ log_with_prefix("#{exception.class}: '#{exception.message}' - #{try_number} tries in #{total_elapsed_time} seconds and #{next_interval} seconds until the next try.", :error)
57
180
  end
58
181
  }.freeze
59
182
  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.4.2'
5
+ VERSION = '3.5.3'
6
6
  end
data/lib/lhm.rb CHANGED
@@ -8,6 +8,8 @@ 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'
12
+ require 'lhm/connection'
11
13
  require 'lhm/test_support'
12
14
  require 'lhm/railtie' if defined?(Rails::Railtie)
13
15
  require 'logger'
@@ -26,7 +28,7 @@ module Lhm
26
28
  extend Throttler
27
29
  extend self
28
30
 
29
- DEFAULT_LOGGER_OPTIONS = { level: Logger::INFO, file: STDOUT }
31
+ DEFAULT_LOGGER_OPTIONS = { level: Logger::INFO, file: STDOUT }
30
32
 
31
33
  # Alters a table with the changes described in the block
32
34
  #
@@ -81,16 +83,30 @@ module Lhm
81
83
  Lhm::Cleanup::Current.new(run, table_name, connection, options).execute
82
84
  end
83
85
 
84
- def setup(connection)
85
- @@connection = connection
86
+ # Setups DB connection
87
+ #
88
+ # @param [ActiveRecord::Base] connection ActiveRecord Connection
89
+ # @param [Hash] connection_options Optional options (defaults to: empty hash)
90
+ # @option connection_options [Boolean] :reconnect_with_consistent_host
91
+ # Active / Deactivate ProxySQL-aware reconnection procedure (default to: false)
92
+ def setup(connection, connection_options = {})
93
+ @@connection = Connection.new(connection: connection, options: connection_options)
86
94
  end
87
95
 
88
- def connection
89
- @@connection ||=
90
- begin
91
- raise 'Please call Lhm.setup' unless defined?(ActiveRecord)
92
- ActiveRecord::Base.connection
93
- end
96
+ # Setups DB connection
97
+ #
98
+ # @param [Hash] connection_options Optional options (defaults to: empty hash)
99
+ # @option connection_options [Boolean] :reconnect_with_consistent_host
100
+ # Active / Deactivate ProxySQL-aware reconnection procedure (default to: false)
101
+ def connection(connection_options = nil)
102
+ @@connection ||= begin
103
+ raise 'Please call Lhm.setup' unless defined?(ActiveRecord)
104
+ @@connection = Connection.new(connection: ActiveRecord::Base.connection, options: connection_options || {})
105
+ end
106
+
107
+ @@connection.process_connection_options(connection_options) unless connection_options.nil?
108
+
109
+ @@connection
94
110
  end
95
111
 
96
112
  def self.logger=(new_logger)
@@ -0,0 +1,21 @@
1
+ #!/bin/bash
2
+ # Wait for writer
3
+ echo "Waiting for MySQL-1: "
4
+ while ! (mysqladmin ping --host="127.0.0.1" --port=33006 --user=root --password=password --silent 2> /dev/null); do
5
+ echo -ne "."
6
+ sleep 1
7
+ done
8
+ # Wait for reader
9
+ echo "Waiting for MySQL-2: "
10
+ while ! (mysqladmin ping --host="127.0.0.1" --port=33007 --user=root --password=password --silent 2> /dev/null); do
11
+ echo -ne "."
12
+ sleep 1
13
+ done
14
+ # Wait for proxysql
15
+ echo "Waiting for ProxySQL:"
16
+ while ! (mysqladmin ping --host="127.0.0.1" --port=33005 --user=root --password=password --silent 2> /dev/null); do
17
+ echo -ne "."
18
+ sleep 1
19
+ done
20
+
21
+ echo "All DBs are ready"
@@ -0,0 +1,10 @@
1
+ STOP SLAVE;
2
+ CHANGE MASTER TO
3
+ MASTER_HOST='mysql-1',
4
+ MASTER_AUTO_POSITION=1,
5
+ MASTER_USER='replication',
6
+ MASTER_PASSWORD='password',
7
+ MASTER_CONNECT_RETRY=1,
8
+ MASTER_RETRY_COUNT=300; -- 5 minutes
9
+
10
+ start slave;
@@ -0,0 +1 @@
1
+ CREATE DATABASE test;