lhm-shopify 3.3.5

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 (94) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +34 -0
  3. data/.gitignore +17 -0
  4. data/.rubocop.yml +183 -0
  5. data/.travis.yml +21 -0
  6. data/CHANGELOG.md +216 -0
  7. data/Gemfile +5 -0
  8. data/LICENSE +27 -0
  9. data/README.md +284 -0
  10. data/Rakefile +22 -0
  11. data/bin/.gitkeep +0 -0
  12. data/dbdeployer/config.json +32 -0
  13. data/dbdeployer/install.sh +64 -0
  14. data/dev.yml +20 -0
  15. data/gemfiles/ar-2.3_mysql.gemfile +6 -0
  16. data/gemfiles/ar-3.2_mysql.gemfile +5 -0
  17. data/gemfiles/ar-3.2_mysql2.gemfile +5 -0
  18. data/gemfiles/ar-4.0_mysql2.gemfile +5 -0
  19. data/gemfiles/ar-4.1_mysql2.gemfile +5 -0
  20. data/gemfiles/ar-4.2_mysql2.gemfile +5 -0
  21. data/gemfiles/ar-5.0_mysql2.gemfile +5 -0
  22. data/lhm.gemspec +34 -0
  23. data/lib/lhm.rb +131 -0
  24. data/lib/lhm/atomic_switcher.rb +52 -0
  25. data/lib/lhm/chunk_finder.rb +32 -0
  26. data/lib/lhm/chunk_insert.rb +51 -0
  27. data/lib/lhm/chunker.rb +87 -0
  28. data/lib/lhm/cleanup/current.rb +74 -0
  29. data/lib/lhm/command.rb +48 -0
  30. data/lib/lhm/entangler.rb +117 -0
  31. data/lib/lhm/intersection.rb +51 -0
  32. data/lib/lhm/invoker.rb +98 -0
  33. data/lib/lhm/locked_switcher.rb +74 -0
  34. data/lib/lhm/migration.rb +43 -0
  35. data/lib/lhm/migrator.rb +237 -0
  36. data/lib/lhm/printer.rb +59 -0
  37. data/lib/lhm/railtie.rb +9 -0
  38. data/lib/lhm/sql_helper.rb +77 -0
  39. data/lib/lhm/sql_retry.rb +61 -0
  40. data/lib/lhm/table.rb +121 -0
  41. data/lib/lhm/table_name.rb +23 -0
  42. data/lib/lhm/test_support.rb +35 -0
  43. data/lib/lhm/throttler.rb +36 -0
  44. data/lib/lhm/throttler/slave_lag.rb +145 -0
  45. data/lib/lhm/throttler/threads_running.rb +53 -0
  46. data/lib/lhm/throttler/time.rb +29 -0
  47. data/lib/lhm/timestamp.rb +11 -0
  48. data/lib/lhm/version.rb +6 -0
  49. data/shipit.rubygems.yml +0 -0
  50. data/spec/.lhm.example +4 -0
  51. data/spec/README.md +58 -0
  52. data/spec/fixtures/bigint_table.ddl +4 -0
  53. data/spec/fixtures/composite_primary_key.ddl +7 -0
  54. data/spec/fixtures/custom_primary_key.ddl +6 -0
  55. data/spec/fixtures/destination.ddl +6 -0
  56. data/spec/fixtures/lines.ddl +7 -0
  57. data/spec/fixtures/origin.ddl +6 -0
  58. data/spec/fixtures/permissions.ddl +5 -0
  59. data/spec/fixtures/small_table.ddl +4 -0
  60. data/spec/fixtures/tracks.ddl +5 -0
  61. data/spec/fixtures/users.ddl +14 -0
  62. data/spec/fixtures/wo_id_int_column.ddl +6 -0
  63. data/spec/integration/atomic_switcher_spec.rb +93 -0
  64. data/spec/integration/chunk_insert_spec.rb +29 -0
  65. data/spec/integration/chunker_spec.rb +185 -0
  66. data/spec/integration/cleanup_spec.rb +136 -0
  67. data/spec/integration/entangler_spec.rb +66 -0
  68. data/spec/integration/integration_helper.rb +237 -0
  69. data/spec/integration/invoker_spec.rb +33 -0
  70. data/spec/integration/lhm_spec.rb +585 -0
  71. data/spec/integration/lock_wait_timeout_spec.rb +30 -0
  72. data/spec/integration/locked_switcher_spec.rb +50 -0
  73. data/spec/integration/sql_retry/lock_wait_spec.rb +125 -0
  74. data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +101 -0
  75. data/spec/integration/table_spec.rb +91 -0
  76. data/spec/test_helper.rb +32 -0
  77. data/spec/unit/atomic_switcher_spec.rb +31 -0
  78. data/spec/unit/chunk_finder_spec.rb +73 -0
  79. data/spec/unit/chunk_insert_spec.rb +44 -0
  80. data/spec/unit/chunker_spec.rb +166 -0
  81. data/spec/unit/entangler_spec.rb +124 -0
  82. data/spec/unit/intersection_spec.rb +51 -0
  83. data/spec/unit/lhm_spec.rb +29 -0
  84. data/spec/unit/locked_switcher_spec.rb +51 -0
  85. data/spec/unit/migrator_spec.rb +146 -0
  86. data/spec/unit/printer_spec.rb +97 -0
  87. data/spec/unit/sql_helper_spec.rb +32 -0
  88. data/spec/unit/table_name_spec.rb +39 -0
  89. data/spec/unit/table_spec.rb +47 -0
  90. data/spec/unit/throttler/slave_lag_spec.rb +317 -0
  91. data/spec/unit/throttler/threads_running_spec.rb +64 -0
  92. data/spec/unit/throttler_spec.rb +124 -0
  93. data/spec/unit/unit_helper.rb +13 -0
  94. metadata +239 -0
@@ -0,0 +1,6 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "mysql", "~> 2.8.1"
4
+ gem "activerecord", "~> 2.3.18"
5
+ gem "iconv", "~> 1.0.4"
6
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "mysql", "~> 2.8.1"
4
+ gem "activerecord", "~> 3.2.2"
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "mysql2", "~> 0.3.11"
4
+ gem "activerecord", "~> 3.2.2"
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "mysql2", "~> 0.3.17"
4
+ gem "activerecord", "~> 4.0.13"
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "mysql2", "~> 0.3.17"
4
+ gem "activerecord", "~> 4.1.9"
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "mysql2", "~> 0.3.17"
4
+ gem "activerecord", "~> 4.2.0"
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ gem "mysql2", "~> 0.4.5"
4
+ gem "activerecord", "~> 5.0.2"
5
+ gemspec :path=>"../"
data/lhm.gemspec ADDED
@@ -0,0 +1,34 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ lib = File.expand_path('../lib', __FILE__)
4
+ $:.unshift(lib) unless $:.include?(lib)
5
+
6
+ require 'lhm/version'
7
+
8
+ Gem::Specification.new do |s|
9
+ s.name = 'lhm-shopify'
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']
14
+ s.email = %q{database-engineering@shopify.com}
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/shopify/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 'minitest'
29
+ s.add_development_dependency 'mocha'
30
+ s.add_development_dependency 'rake'
31
+ s.add_development_dependency 'activerecord'
32
+ s.add_development_dependency 'mysql2'
33
+ s.add_development_dependency 'simplecov'
34
+ end
data/lib/lhm.rb ADDED
@@ -0,0 +1,131 @@
1
+ # Copyright (c) 2011 - 2013, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/table_name'
5
+ require 'lhm/table'
6
+ require 'lhm/invoker'
7
+ require 'lhm/throttler'
8
+ require 'lhm/version'
9
+ require 'lhm/cleanup/current'
10
+ require 'lhm/sql_retry'
11
+ require 'lhm/test_support'
12
+ require 'lhm/railtie' if defined?(Rails::Railtie)
13
+ require 'logger'
14
+
15
+ # Large hadron migrator - online schema change tool
16
+ #
17
+ # @example
18
+ #
19
+ # Lhm.change_table(:users) do |m|
20
+ # m.add_column(:arbitrary, "INT(12)")
21
+ # m.add_index([:arbitrary, :created_at])
22
+ # m.ddl("alter table %s add column flag tinyint(1)" % m.name)
23
+ # end
24
+ #
25
+ module Lhm
26
+ extend Throttler
27
+ extend self
28
+
29
+ DEFAULT_LOGGER_OPTIONS = { level: Logger::INFO, file: STDOUT }
30
+
31
+ # Alters a table with the changes described in the block
32
+ #
33
+ # @param [String, Symbol] table_name Name of the table
34
+ # @param [Hash] options Optional options to alter the chunk / switch behavior
35
+ # @option options [Integer] :stride
36
+ # Size of a chunk (defaults to: 2,000)
37
+ # @option options [Integer] :throttle
38
+ # Time to wait between chunks in milliseconds (defaults to: 100)
39
+ # @option options [Integer] :start
40
+ # Primary Key position at which to start copying chunks
41
+ # @option options [Integer] :limit
42
+ # Primary Key position at which to stop copying chunks
43
+ # @option options [Boolean] :atomic_switch
44
+ # Use atomic switch to rename tables (defaults to: true)
45
+ # If using a version of mysql affected by atomic switch bug, LHM forces user
46
+ # to set this option (see SqlHelper#supports_atomic_switch?)
47
+ # @yield [Migrator] Yielded Migrator object records the changes
48
+ # @return [Boolean] Returns true if the migration finishes
49
+ # @raise [Error] Raises Lhm::Error in case of a error and aborts the migration
50
+ def change_table(table_name, options = {}, &block)
51
+ origin = Table.parse(table_name, connection)
52
+ invoker = Invoker.new(origin, connection)
53
+ block.call(invoker.migrator)
54
+ invoker.run(options)
55
+ true
56
+ end
57
+
58
+ # Cleanup tables and triggers
59
+ #
60
+ # @param [Boolean] run execute now or just display information
61
+ # @param [Hash] options Optional options to alter cleanup behaviour
62
+ # @option options [Time] :until
63
+ # Filter to only remove tables up to specified time (defaults to: nil)
64
+ def cleanup(run = false, options = {})
65
+ lhm_tables = connection.select_values('show tables').select { |name| name =~ /^lhm(a|n)_/ }
66
+ if options[:until]
67
+ lhm_tables.select! do |table|
68
+ table_date_time = Time.strptime(table, 'lhma_%Y_%m_%d_%H_%M_%S')
69
+ table_date_time <= options[:until]
70
+ end
71
+ end
72
+
73
+ lhm_triggers = connection.select_values('show triggers').collect do |trigger|
74
+ trigger.respond_to?(:trigger) ? trigger.trigger : trigger
75
+ end.select { |name| name =~ /^lhmt/ }
76
+
77
+ drop_tables_and_triggers(run, lhm_triggers, lhm_tables)
78
+ end
79
+
80
+ def cleanup_current_run(run, table_name, options = {})
81
+ Lhm::Cleanup::Current.new(run, table_name, connection, options).execute
82
+ end
83
+
84
+ def setup(connection)
85
+ @@connection = connection
86
+ end
87
+
88
+ def connection
89
+ @@connection ||=
90
+ begin
91
+ raise 'Please call Lhm.setup' unless defined?(ActiveRecord)
92
+ ActiveRecord::Base.connection
93
+ end
94
+ end
95
+
96
+ def self.logger=(new_logger)
97
+ @@logger = new_logger
98
+ end
99
+
100
+ def self.logger
101
+ @@logger ||=
102
+ begin
103
+ logger = Logger.new(DEFAULT_LOGGER_OPTIONS[:file])
104
+ logger.level = DEFAULT_LOGGER_OPTIONS[:level]
105
+ logger.formatter = nil
106
+ logger
107
+ end
108
+ end
109
+
110
+ private
111
+
112
+ def drop_tables_and_triggers(run = false, triggers, tables)
113
+ if run
114
+ triggers.each do |trigger|
115
+ connection.execute("drop trigger if exists #{trigger}")
116
+ end
117
+ tables.each do |table|
118
+ connection.execute("drop table if exists #{table}")
119
+ end
120
+ true
121
+ elsif tables.empty? && triggers.empty?
122
+ puts 'Everything is clean. Nothing to do.'
123
+ true
124
+ else
125
+ puts "Would drop LHM backup tables: #{tables.join(', ')}."
126
+ puts "Would drop LHM triggers: #{triggers.join(', ')}."
127
+ puts 'Run with Lhm.cleanup(true) to drop all LHM triggers and tables, or Lhm.cleanup_current_run(true, table_name) to clean up a specific LHM.'
128
+ false
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,52 @@
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
+ def initialize(migration, connection = nil, options = {})
20
+ @migration = migration
21
+ @connection = connection
22
+ @origin = migration.origin
23
+ @destination = migration.destination
24
+ @retry_helper = SqlRetry.new(
25
+ @connection,
26
+ {
27
+ log_prefix: "AtomicSwitcher"
28
+ }.merge!(options.fetch(:retriable, {}))
29
+ )
30
+ end
31
+
32
+ def atomic_switch
33
+ "rename table `#{ @origin.name }` to `#{ @migration.archive_name }`, " \
34
+ "`#{ @destination.name }` to `#{ @origin.name }`"
35
+ end
36
+
37
+ def validate
38
+ unless @connection.data_source_exists?(@origin.name) &&
39
+ @connection.data_source_exists?(@destination.name)
40
+ error "`#{ @origin.name }` and `#{ @destination.name }` must exist"
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def execute
47
+ @retry_helper.with_retries do |retriable_connection|
48
+ retriable_connection.execute atomic_switch
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,32 @@
1
+ module Lhm
2
+ class ChunkFinder
3
+ def initialize(migration, connection = nil, options = {})
4
+ @migration = migration
5
+ @connection = connection
6
+ @start = options[:start] || select_start_from_db
7
+ @limit = options[:limit] || select_limit_from_db
8
+ end
9
+
10
+ attr_accessor :start, :limit
11
+
12
+ def table_empty?
13
+ start.nil? && limit.nil?
14
+ end
15
+
16
+ def validate
17
+ if start > limit
18
+ raise ArgumentError, "impossible chunk options (limit (#{limit.inspect} must be greater than start (#{start.inspect})"
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def select_start_from_db
25
+ @connection.select_value("select min(id) from `#{ @migration.origin_name }`")
26
+ end
27
+
28
+ def select_limit_from_db
29
+ @connection.select_value("select max(id) from `#{ @migration.origin_name }`")
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,51 @@
1
+ require 'lhm/sql_retry'
2
+
3
+ module Lhm
4
+ class ChunkInsert
5
+ def initialize(migration, connection, lowest, highest, options = {})
6
+ @migration = migration
7
+ @connection = connection
8
+ @lowest = lowest
9
+ @highest = highest
10
+ @retry_helper = SqlRetry.new(
11
+ @connection,
12
+ {
13
+ log_prefix: "Chunker Insert"
14
+ }.merge!(options.fetch(:retriable, {}))
15
+ )
16
+ end
17
+
18
+ def insert_and_return_count_of_rows_created
19
+ @retry_helper.with_retries do |retriable_connection|
20
+ retriable_connection.update sql
21
+ end
22
+ end
23
+
24
+ def sql
25
+ "insert ignore into `#{ @migration.destination_name }` (#{ @migration.destination_columns }) " \
26
+ "select #{ @migration.origin_columns } from `#{ @migration.origin_name }` " \
27
+ "#{ conditions } `#{ @migration.origin_name }`.`id` between #{ @lowest } and #{ @highest }"
28
+ end
29
+
30
+ private
31
+ # XXX this is extremely brittle and doesn't work when filter contains more
32
+ # than one SQL clause, e.g. "where ... group by foo". Before making any
33
+ # more changes here, please consider either:
34
+ #
35
+ # 1. Letting users only specify part of defined clauses (i.e. don't allow
36
+ # `filter` on Migrator to accept both WHERE and INNER JOIN
37
+ # 2. Changing query building so that it uses structured data rather than
38
+ # strings until the last possible moment.
39
+ def conditions
40
+ if @migration.conditions
41
+ @migration.conditions.
42
+ # strip ending paren
43
+ sub(/\)\Z/, '').
44
+ # put any where conditions in parens
45
+ sub(/where\s(\w.*)\Z/, 'where (\\1)') + ' and'
46
+ else
47
+ 'where'
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,87 @@
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
+ # Copy from origin to destination in chunks of size `stride`.
17
+ # Use the `throttler` class to sleep between each stride.
18
+ def initialize(migration, connection = nil, options = {})
19
+ @migration = migration
20
+ @connection = connection
21
+ @chunk_finder = ChunkFinder.new(migration, connection, options)
22
+ @options = options
23
+ @verifier = options[:verifier]
24
+ if @throttler = options[:throttler]
25
+ @throttler.connection = @connection if @throttler.respond_to?(:connection=)
26
+ end
27
+ @start = @chunk_finder.start
28
+ @limit = @chunk_finder.limit
29
+ @printer = options[:printer] || Printer::Percentage.new
30
+ @retry_helper = SqlRetry.new(
31
+ @connection,
32
+ {
33
+ log_prefix: "Chunker"
34
+ }.merge!(options.fetch(:retriable, {}))
35
+ )
36
+ end
37
+
38
+ def execute
39
+ return if @chunk_finder.table_empty?
40
+ @next_to_insert = @start
41
+ while @next_to_insert <= @limit || (@start == @limit)
42
+ stride = @throttler.stride
43
+ top = upper_id(@next_to_insert, stride)
44
+ verify_can_run
45
+
46
+ affected_rows = ChunkInsert.new(@migration, @connection, bottom, top, @options).insert_and_return_count_of_rows_created
47
+ if @throttler && affected_rows > 0
48
+ @throttler.run
49
+ end
50
+ @printer.notify(bottom, @limit)
51
+ @next_to_insert = top + 1
52
+ break if @start == @limit
53
+ end
54
+ @printer.end
55
+ rescue => e
56
+ @printer.exception(e) if @printer.respond_to?(:exception)
57
+ raise
58
+ end
59
+
60
+ private
61
+
62
+ def bottom
63
+ @next_to_insert
64
+ end
65
+
66
+ def verify_can_run
67
+ return unless @verifier
68
+ @retry_helper.with_retries do |retriable_connection|
69
+ raise "Verification failed, aborting early" if !@verifier.call(retriable_connection)
70
+ end
71
+ end
72
+
73
+ def upper_id(next_id, stride)
74
+ sql = "select id from `#{ @migration.origin_name }` where id >= #{ next_id } order by id limit 1 offset #{ stride - 1}"
75
+ top = @retry_helper.with_retries do |retriable_connection|
76
+ retriable_connection.select_value(sql)
77
+ end
78
+
79
+ [top ? top.to_i : @limit, @limit].min
80
+ end
81
+
82
+ def validate
83
+ return if @chunk_finder.table_empty?
84
+ @chunk_finder.validate
85
+ end
86
+ end
87
+ end