lhm-teak 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +43 -0
  3. data/.gitignore +12 -0
  4. data/.rubocop.yml +183 -0
  5. data/.travis.yml +21 -0
  6. data/Appraisals +24 -0
  7. data/CHANGELOG.md +254 -0
  8. data/Gemfile +5 -0
  9. data/Gemfile.lock +67 -0
  10. data/LICENSE +27 -0
  11. data/README.md +335 -0
  12. data/Rakefile +33 -0
  13. data/dev.yml +45 -0
  14. data/docker-compose.yml +60 -0
  15. data/gemfiles/activerecord_5.2.gemfile +9 -0
  16. data/gemfiles/activerecord_5.2.gemfile.lock +66 -0
  17. data/gemfiles/activerecord_6.0.gemfile +7 -0
  18. data/gemfiles/activerecord_6.0.gemfile.lock +68 -0
  19. data/gemfiles/activerecord_6.1.gemfile +7 -0
  20. data/gemfiles/activerecord_6.1.gemfile.lock +67 -0
  21. data/gemfiles/activerecord_7.0.0.alpha2.gemfile +7 -0
  22. data/gemfiles/activerecord_7.0.0.alpha2.gemfile.lock +65 -0
  23. data/lhm.gemspec +38 -0
  24. data/lib/lhm/atomic_switcher.rb +46 -0
  25. data/lib/lhm/chunk_finder.rb +62 -0
  26. data/lib/lhm/chunk_insert.rb +61 -0
  27. data/lib/lhm/chunker.rb +95 -0
  28. data/lib/lhm/cleanup/current.rb +71 -0
  29. data/lib/lhm/command.rb +48 -0
  30. data/lib/lhm/connection.rb +108 -0
  31. data/lib/lhm/entangler.rb +112 -0
  32. data/lib/lhm/intersection.rb +51 -0
  33. data/lib/lhm/invoker.rb +100 -0
  34. data/lib/lhm/locked_switcher.rb +76 -0
  35. data/lib/lhm/migration.rb +51 -0
  36. data/lib/lhm/migrator.rb +244 -0
  37. data/lib/lhm/printer.rb +63 -0
  38. data/lib/lhm/proxysql_helper.rb +10 -0
  39. data/lib/lhm/railtie.rb +9 -0
  40. data/lib/lhm/sql_helper.rb +77 -0
  41. data/lib/lhm/sql_retry.rb +180 -0
  42. data/lib/lhm/table.rb +121 -0
  43. data/lib/lhm/table_name.rb +23 -0
  44. data/lib/lhm/test_support.rb +35 -0
  45. data/lib/lhm/throttler/slave_lag.rb +162 -0
  46. data/lib/lhm/throttler/threads_running.rb +53 -0
  47. data/lib/lhm/throttler/time.rb +29 -0
  48. data/lib/lhm/throttler.rb +36 -0
  49. data/lib/lhm/timestamp.rb +11 -0
  50. data/lib/lhm/version.rb +6 -0
  51. data/lib/lhm-shopify.rb +1 -0
  52. data/lib/lhm.rb +156 -0
  53. data/scripts/helpers/wait-for-dbs.sh +21 -0
  54. data/scripts/mysql/reader/create_replication.sql +10 -0
  55. data/scripts/mysql/writer/create_test_db.sql +1 -0
  56. data/scripts/mysql/writer/create_users.sql +6 -0
  57. data/scripts/proxysql/proxysql.cnf +117 -0
  58. data/shipit.rubygems.yml +0 -0
  59. data/spec/.lhm.example +4 -0
  60. data/spec/README.md +58 -0
  61. data/spec/fixtures/bigint_table.ddl +4 -0
  62. data/spec/fixtures/composite_primary_key.ddl +6 -0
  63. data/spec/fixtures/composite_primary_key_dest.ddl +6 -0
  64. data/spec/fixtures/custom_primary_key.ddl +6 -0
  65. data/spec/fixtures/custom_primary_key_dest.ddl +6 -0
  66. data/spec/fixtures/destination.ddl +6 -0
  67. data/spec/fixtures/lines.ddl +7 -0
  68. data/spec/fixtures/origin.ddl +6 -0
  69. data/spec/fixtures/permissions.ddl +5 -0
  70. data/spec/fixtures/small_table.ddl +4 -0
  71. data/spec/fixtures/tracks.ddl +5 -0
  72. data/spec/fixtures/users.ddl +14 -0
  73. data/spec/fixtures/wo_id_int_column.ddl +6 -0
  74. data/spec/integration/atomic_switcher_spec.rb +129 -0
  75. data/spec/integration/chunk_insert_spec.rb +30 -0
  76. data/spec/integration/chunker_spec.rb +269 -0
  77. data/spec/integration/cleanup_spec.rb +147 -0
  78. data/spec/integration/database.yml +25 -0
  79. data/spec/integration/entangler_spec.rb +68 -0
  80. data/spec/integration/integration_helper.rb +252 -0
  81. data/spec/integration/invoker_spec.rb +33 -0
  82. data/spec/integration/lhm_spec.rb +659 -0
  83. data/spec/integration/lock_wait_timeout_spec.rb +30 -0
  84. data/spec/integration/locked_switcher_spec.rb +50 -0
  85. data/spec/integration/proxysql_spec.rb +34 -0
  86. data/spec/integration/sql_retry/db_connection_helper.rb +52 -0
  87. data/spec/integration/sql_retry/lock_wait_spec.rb +127 -0
  88. data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +114 -0
  89. data/spec/integration/sql_retry/proxysql_helper.rb +22 -0
  90. data/spec/integration/sql_retry/retry_with_proxysql_spec.rb +109 -0
  91. data/spec/integration/table_spec.rb +83 -0
  92. data/spec/integration/toxiproxy_helper.rb +40 -0
  93. data/spec/test_helper.rb +69 -0
  94. data/spec/unit/atomic_switcher_spec.rb +29 -0
  95. data/spec/unit/chunk_finder_spec.rb +73 -0
  96. data/spec/unit/chunk_insert_spec.rb +67 -0
  97. data/spec/unit/chunker_spec.rb +176 -0
  98. data/spec/unit/connection_spec.rb +111 -0
  99. data/spec/unit/entangler_spec.rb +187 -0
  100. data/spec/unit/intersection_spec.rb +51 -0
  101. data/spec/unit/lhm_spec.rb +46 -0
  102. data/spec/unit/locked_switcher_spec.rb +46 -0
  103. data/spec/unit/migrator_spec.rb +144 -0
  104. data/spec/unit/printer_spec.rb +85 -0
  105. data/spec/unit/sql_helper_spec.rb +28 -0
  106. data/spec/unit/table_name_spec.rb +39 -0
  107. data/spec/unit/table_spec.rb +47 -0
  108. data/spec/unit/throttler/slave_lag_spec.rb +322 -0
  109. data/spec/unit/throttler/threads_running_spec.rb +64 -0
  110. data/spec/unit/throttler_spec.rb +124 -0
  111. data/spec/unit/unit_helper.rb +26 -0
  112. metadata +366 -0
@@ -0,0 +1,7 @@
1
+ # This file was generated by Appraisal
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gem "activerecord", "7.0.0.alpha2"
6
+
7
+ gemspec path: "../"
@@ -0,0 +1,65 @@
1
+ PATH
2
+ remote: ..
3
+ specs:
4
+ lhm-shopify (3.5.5)
5
+ retriable (>= 3.0.0)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ activemodel (7.0.0.alpha2)
11
+ activesupport (= 7.0.0.alpha2)
12
+ activerecord (7.0.0.alpha2)
13
+ activemodel (= 7.0.0.alpha2)
14
+ activesupport (= 7.0.0.alpha2)
15
+ activesupport (7.0.0.alpha2)
16
+ concurrent-ruby (~> 1.0, >= 1.0.2)
17
+ i18n (>= 1.6, < 2)
18
+ minitest (>= 5.1)
19
+ tzinfo (~> 2.0)
20
+ after_do (0.4.0)
21
+ appraisal (2.4.1)
22
+ bundler
23
+ rake
24
+ thor (>= 0.14.0)
25
+ byebug (11.1.3)
26
+ concurrent-ruby (1.1.9)
27
+ docile (1.4.0)
28
+ i18n (1.8.11)
29
+ concurrent-ruby (~> 1.0)
30
+ minitest (5.14.4)
31
+ mocha (1.13.0)
32
+ mysql2 (0.5.3)
33
+ rake (13.0.6)
34
+ retriable (3.1.2)
35
+ simplecov (0.21.2)
36
+ docile (~> 1.1)
37
+ simplecov-html (~> 0.11)
38
+ simplecov_json_formatter (~> 0.1)
39
+ simplecov-html (0.12.3)
40
+ simplecov_json_formatter (0.1.3)
41
+ thor (1.1.0)
42
+ toxiproxy (2.0.0)
43
+ tzinfo (2.0.4)
44
+ concurrent-ruby (~> 1.0)
45
+
46
+ PLATFORMS
47
+ arm64-darwin-21
48
+ x86_64-darwin-20
49
+ x86_64-linux
50
+
51
+ DEPENDENCIES
52
+ activerecord (= 7.0.0.alpha2)
53
+ after_do
54
+ appraisal
55
+ byebug
56
+ lhm-shopify!
57
+ minitest
58
+ mocha
59
+ mysql2
60
+ rake
61
+ simplecov
62
+ toxiproxy
63
+
64
+ BUNDLED WITH
65
+ 2.2.22
data/lhm.gemspec ADDED
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ lib = File.expand_path('lib', __dir__)
4
+ $:.unshift(lib) unless $:.include?(lib)
5
+
6
+ require 'lhm/version'
7
+
8
+ Gem::Specification.new do |s|
9
+ s.name = 'lhm-teak'
10
+ s.version = Lhm::VERSION
11
+ s.licenses = ['BSD-3-Clause']
12
+ s.platform = Gem::Platform::RUBY
13
+ s.authors = ['SoundCloud', 'Shopify', 'Rany Keddo', 'Tobias Bielohlawek', 'Tobias Schmidt', 'Teak.io']
14
+ s.email = %q{team@teak.io}
15
+ s.summary = %q{online schema changer for mysql}
16
+ s.description = %q{Migrate large tables without downtime by copying to a temporary table in chunks. The old table is not dropped. Instead, it is moved to timestamp_table_name for verification.}
17
+ s.homepage = %q{http://github.com/GoCarrot/lhm}
18
+ s.files = `git ls-files`.split("\n")
19
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
20
+ s.require_paths = ['lib']
21
+ s.executables = []
22
+ s.metadata['allowed_push_host'] = "https://rubygems.org"
23
+
24
+ s.required_ruby_version = '>= 2.3.0'
25
+
26
+ s.add_dependency 'retriable', '>= 3.0.0'
27
+
28
+ s.add_development_dependency 'activerecord'
29
+ s.add_development_dependency 'minitest'
30
+ s.add_development_dependency 'mocha'
31
+ s.add_development_dependency 'after_do'
32
+ s.add_development_dependency 'rake'
33
+ s.add_development_dependency 'mysql2'
34
+ s.add_development_dependency 'simplecov'
35
+ s.add_development_dependency 'toxiproxy'
36
+ s.add_development_dependency 'appraisal'
37
+ s.add_development_dependency 'byebug'
38
+ end
@@ -0,0 +1,46 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/command'
5
+ require 'lhm/migration'
6
+ require 'lhm/sql_retry'
7
+
8
+ module Lhm
9
+ # Switches origin with destination table using an atomic rename.
10
+ #
11
+ # It should only be used if the MySQL server version is not affected by the
12
+ # bin log affecting bug #39675. This can be verified using
13
+ # Lhm::SqlHelper.supports_atomic_switch?.
14
+ class AtomicSwitcher
15
+ include Command
16
+
17
+ attr_reader :connection
18
+
19
+ LOG_PREFIX = "AtomicSwitcher"
20
+
21
+ def initialize(migration, connection = nil)
22
+ @migration = migration
23
+ @connection = connection
24
+ @origin = migration.origin
25
+ @destination = migration.destination
26
+ end
27
+
28
+ def atomic_switch
29
+ "rename table `#{ @origin.name }` to `#{ @migration.archive_name }`, " \
30
+ "`#{ @destination.name }` to `#{ @origin.name }`"
31
+ end
32
+
33
+ def validate
34
+ unless @connection.data_source_exists?(@origin.name) &&
35
+ @connection.data_source_exists?(@destination.name)
36
+ error "`#{ @origin.name }` and `#{ @destination.name }` must exist"
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def execute
43
+ @connection.execute(atomic_switch, should_retry: true, log_prefix: LOG_PREFIX)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,62 @@
1
+ module Lhm
2
+ class ChunkFinder
3
+ LOG_PREFIX = "Chunker"
4
+
5
+ def initialize(migration, connection = nil, options = {})
6
+ @migration = migration
7
+ @connection = connection
8
+ @start = options[:start] || select_start_from_db
9
+ @limit = options[:limit] || select_limit_from_db
10
+ @throttler = options[:throttler]
11
+ @processed_rows = 0
12
+ end
13
+
14
+ def table_empty?
15
+ start.nil? && limit.nil?
16
+ end
17
+
18
+ def validate
19
+ if start > limit
20
+ raise ArgumentError, "impossible chunk options (limit (#{limit.inspect} must be greater than start (#{start.inspect})"
21
+ end
22
+ end
23
+
24
+ def each_chunk
25
+ next_id = @start
26
+ @processed_rows = 0
27
+ while next_id <= @limit
28
+ top = upper_id(next_id)
29
+ @processed_rows += @throttler.stride
30
+ yield ChunkInsert.new(@migration, @connection, next_id, top)
31
+ next_id = top + 1
32
+ end
33
+ end
34
+
35
+ def max_rows
36
+ @limit - @start + 1
37
+ end
38
+
39
+ def processed_rows
40
+ @processed_rows
41
+ end
42
+
43
+ private
44
+
45
+ attr_reader :start, :limit
46
+
47
+ def select_start_from_db
48
+ @connection.select_value("select min(id) from `#{ @migration.origin_name }`")
49
+ end
50
+
51
+ def select_limit_from_db
52
+ @connection.select_value("select max(id) from `#{ @migration.origin_name }`")
53
+ end
54
+
55
+ def upper_id(next_id)
56
+ sql = "select id from `#{ @migration.origin_name }` where id >= #{ next_id } order by id limit 1 offset #{ @throttler.stride - 1}"
57
+ top = @connection.select_value(sql, should_retry: true, log_prefix: LOG_PREFIX)
58
+
59
+ [top ? top.to_i : @limit, @limit].min
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,61 @@
1
+ require 'lhm/sql_retry'
2
+ require 'lhm/proxysql_helper'
3
+
4
+ module Lhm
5
+ class ChunkInsert
6
+
7
+ LOG_PREFIX = "ChunkInsert"
8
+
9
+ def initialize(migration, connection, lowest, highest, retry_options = {})
10
+ @migration = migration
11
+ @connection = connection
12
+ @lowest = lowest
13
+ @highest = highest
14
+ @retry_options = retry_options
15
+ end
16
+
17
+ def insert_and_return_count_of_rows_created
18
+ @connection.update(sql, should_retry: true, log_prefix: LOG_PREFIX)
19
+ end
20
+
21
+ def bottom
22
+ @lowest
23
+ end
24
+
25
+ def top
26
+ @highest
27
+ end
28
+
29
+ def expected_rows
30
+ top - bottom + 1
31
+ end
32
+
33
+ private
34
+
35
+ def sql
36
+ "insert ignore into `#{ @migration.destination_name }` (#{ @migration.destination_columns }) " \
37
+ "select #{ @migration.origin_columns } from `#{ @migration.origin_name }` " \
38
+ "#{ conditions } `#{ @migration.origin_name }`.`id` between #{ @lowest } and #{ @highest }"
39
+ end
40
+
41
+ # XXX this is extremely brittle and doesn't work when filter contains more
42
+ # than one SQL clause, e.g. "where ... group by foo". Before making any
43
+ # more changes here, please consider either:
44
+ #
45
+ # 1. Letting users only specify part of defined clauses (i.e. don't allow
46
+ # `filter` on Migrator to accept both WHERE and INNER JOIN
47
+ # 2. Changing query building so that it uses structured data rather than
48
+ # strings until the last possible moment.
49
+ def conditions
50
+ if @migration.conditions
51
+ @migration.conditions.
52
+ # strip ending paren
53
+ sub(/\)\Z/, '').
54
+ # put any where conditions in parens
55
+ sub(/where\s(\w.*)\Z/, 'where (\\1)') + ' and'
56
+ else
57
+ 'where'
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,95 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+ require 'lhm/command'
4
+ require 'lhm/sql_helper'
5
+ require 'lhm/printer'
6
+ require 'lhm/chunk_insert'
7
+ require 'lhm/chunk_finder'
8
+
9
+ module Lhm
10
+ class Chunker
11
+ include Command
12
+ include SqlHelper
13
+
14
+ attr_reader :connection
15
+
16
+ LOG_PREFIX = "Chunker"
17
+
18
+ # Copy from origin to destination in chunks of size `stride`.
19
+ # Use the `throttler` class to sleep between each stride.
20
+ def initialize(migration, connection = nil, options = {})
21
+ @migration = migration
22
+ @connection = connection
23
+ @chunk_finder = options.fetch(:chuck_finder, ChunkFinder).new(migration, connection, options)
24
+ @options = options
25
+ @raise_on_warnings = options.fetch(:raise_on_warnings, false)
26
+ @verifier = options[:verifier]
27
+ if @throttler = options[:throttler]
28
+ @throttler.connection = @connection if @throttler.respond_to?(:connection=)
29
+ end
30
+ @printer = options[:printer] || Printer::Percentage.new
31
+ @retry_options = options[:retriable] || {}
32
+ @retry_helper = SqlRetry.new(
33
+ @connection,
34
+ retry_options: @retry_options
35
+ )
36
+ end
37
+
38
+ def execute
39
+ @start_time = Time.now
40
+
41
+ return if @chunk_finder.table_empty?
42
+ @chunk_finder.each_chunk do |chunk|
43
+ verify_can_run
44
+
45
+ affected_rows = chunk.insert_and_return_count_of_rows_created
46
+
47
+ # Only log the chunker progress every 5 minutes instead of every iteration
48
+ current_time = Time.now
49
+ if current_time - @start_time > (5 * 60)
50
+ Lhm.logger.info("Inserted #{affected_rows} rows into the destination table from #{chunk.bottom} to #{chunk.top}")
51
+ @start_time = current_time
52
+ end
53
+
54
+ if affected_rows < chunk.expected_rows
55
+ raise_on_non_pk_duplicate_warning
56
+ end
57
+
58
+ if @throttler && affected_rows > 0
59
+ @throttler.run
60
+ end
61
+
62
+ @printer.notify(@chunk_finder.processed_rows, @chunk_finder.max_rows)
63
+ end
64
+ @printer.end
65
+ rescue => e
66
+ @printer.exception(e) if @printer.respond_to?(:exception)
67
+ raise
68
+ end
69
+
70
+ private
71
+
72
+ def raise_on_non_pk_duplicate_warning
73
+ @connection.execute("show warnings", should_retry: true, log_prefix: LOG_PREFIX).each do |level, code, message|
74
+ unless message.match?(/Duplicate entry .+ for key 'PRIMARY'/)
75
+ m = "Unexpected warning found for inserted row: #{message}"
76
+ Lhm.logger.warn(m)
77
+ raise Error.new(m) if @raise_on_warnings
78
+ end
79
+ end
80
+ end
81
+
82
+ def verify_can_run
83
+ return unless @verifier
84
+ @retry_helper.with_retries(log_prefix: LOG_PREFIX) do |retriable_connection|
85
+ raise "Verification failed, aborting early" if !@verifier.call(retriable_connection)
86
+ end
87
+ end
88
+
89
+ def validate
90
+ return if @chunk_finder.table_empty?
91
+ @chunk_finder.validate
92
+ end
93
+
94
+ end
95
+ end
@@ -0,0 +1,71 @@
1
+ require 'lhm/timestamp'
2
+ require 'lhm/sql_retry'
3
+
4
+ module Lhm
5
+ module Cleanup
6
+ class Current
7
+
8
+ LOG_PREFIX = "Current"
9
+
10
+ def initialize(run, origin_table_name, connection, options={})
11
+ @run = run
12
+ @table_name = TableName.new(origin_table_name)
13
+ @connection = connection
14
+ @ddls = []
15
+ @retry_config = options[:retriable] || {}
16
+ end
17
+
18
+ attr_reader :run, :connection, :ddls
19
+
20
+ def execute
21
+ build_statements_for_drop_lhm_triggers_for_origin
22
+ build_statements_for_rename_lhmn_tables_for_origin
23
+ if run
24
+ execute_ddls
25
+ else
26
+ report_ddls
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def build_statements_for_drop_lhm_triggers_for_origin
33
+ lhm_triggers_for_origin.each do |trigger|
34
+ @ddls << "drop trigger if exists #{trigger}"
35
+ end
36
+ end
37
+
38
+ def lhm_triggers_for_origin
39
+ @lhm_triggers_for_origin ||= all_triggers_for_origin.select { |name| name =~ /^lhmt/ }
40
+ end
41
+
42
+ def all_triggers_for_origin
43
+ @all_triggers_for_origin ||= connection.select_values("show triggers like '%#{@table_name.original}'").collect do |trigger|
44
+ trigger.respond_to?(:trigger) ? trigger.trigger : trigger
45
+ end
46
+ end
47
+
48
+ def build_statements_for_rename_lhmn_tables_for_origin
49
+ lhmn_tables_for_origin.each do |table|
50
+ @ddls << "rename table #{table} to #{@table_name.failed}"
51
+ end
52
+ end
53
+
54
+ def lhmn_tables_for_origin
55
+ @lhmn_tables_for_origin ||= connection.select_values("show tables like '#{@table_name.new}'")
56
+ end
57
+
58
+ def execute_ddls
59
+ ddls.each do |ddl|
60
+ @connection.execute(ddl, should_retry: true, log_prefix: LOG_PREFIX)
61
+ end
62
+ Lhm.logger.info("Dropped triggers on #{@lhm_triggers_for_origin.join(', ')}")
63
+ Lhm.logger.info("Dropped tables #{@lhm_triggers_for_origin.join(', ')}")
64
+ end
65
+
66
+ def report_ddls
67
+ Lhm.logger.info("The following DDLs would be executed: #{ddls}")
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,48 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ module Lhm
5
+ class Error < StandardError
6
+ end
7
+
8
+ module Command
9
+ def run(&block)
10
+ Lhm.logger.info "Starting run of class=#{self.class}"
11
+ validate
12
+
13
+ if block_given?
14
+ before
15
+ block.call(self)
16
+ after
17
+ else
18
+ execute
19
+ end
20
+ rescue => e
21
+ Lhm.logger.error "Error in class=#{self.class}, reverting. exception=#{e.class} message=#{e.message}"
22
+ revert
23
+ raise
24
+ end
25
+
26
+ private
27
+
28
+ def validate
29
+ end
30
+
31
+ def revert
32
+ end
33
+
34
+ def execute
35
+ raise NotImplementedError.new(self.class.name)
36
+ end
37
+
38
+ def before
39
+ end
40
+
41
+ def after
42
+ end
43
+
44
+ def error(msg)
45
+ raise Error.new(msg)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,108 @@
1
+ require 'delegate'
2
+ require 'forwardable'
3
+ require 'lhm/sql_retry'
4
+
5
+ module Lhm
6
+ # Lhm::Connection inherits from SingleDelegator. It will forward any unknown method calls to the ActiveRecord
7
+ # connection.
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__
16
+
17
+ def initialize(connection:, options: {})
18
+ @sql_retry = Lhm::SqlRetry.new(
19
+ connection,
20
+ retry_options: options[:retriable] || {},
21
+ reconnect_with_consistent_host: options[:reconnect_with_consistent_host] || false
22
+ )
23
+
24
+ # Creates delegation for the ActiveRecord Connection
25
+ super(connection)
26
+ end
27
+
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)
34
+ end
35
+
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
44
+ end
45
+
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
76
+ end
77
+
78
+ private
79
+
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))
88
+ end
89
+ end
90
+
91
+ # Returns camelized file name of caller (e.g. chunk_insert.rb -> ChunkInsert)
92
+ def file
93
+ # Find calling file and extract name
94
+ /[\/]*(\w+).rb:\d+:in/.match(relevant_caller)
95
+ name = $1&.camelize || "Connection"
96
+ "#{name}"
97
+ end
98
+
99
+ def relevant_caller
100
+ lhm_stack = caller.select { |x| x.include?("/lhm") }
101
+ first_candidate_index = lhm_stack.find_index { |line| !line.include?(__FILE__) }
102
+
103
+ # Find the file that called the `#execute` (fallbacks to current file)
104
+ return lhm_stack.first unless first_candidate_index
105
+ lhm_stack.at(first_candidate_index)
106
+ end
107
+ end
108
+ end