lhm-shopify 3.3.5

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