lhm-shopify 3.4.2 → 3.5.3

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