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.
- checksums.yaml +7 -0
- data/.github/workflows/test.yml +34 -0
- data/.gitignore +17 -0
- data/.rubocop.yml +183 -0
- data/.travis.yml +21 -0
- data/CHANGELOG.md +216 -0
- data/Gemfile +5 -0
- data/LICENSE +27 -0
- data/README.md +284 -0
- data/Rakefile +22 -0
- data/bin/.gitkeep +0 -0
- data/dbdeployer/config.json +32 -0
- data/dbdeployer/install.sh +64 -0
- data/dev.yml +20 -0
- data/gemfiles/ar-2.3_mysql.gemfile +6 -0
- data/gemfiles/ar-3.2_mysql.gemfile +5 -0
- data/gemfiles/ar-3.2_mysql2.gemfile +5 -0
- data/gemfiles/ar-4.0_mysql2.gemfile +5 -0
- data/gemfiles/ar-4.1_mysql2.gemfile +5 -0
- data/gemfiles/ar-4.2_mysql2.gemfile +5 -0
- data/gemfiles/ar-5.0_mysql2.gemfile +5 -0
- data/lhm.gemspec +34 -0
- data/lib/lhm.rb +131 -0
- data/lib/lhm/atomic_switcher.rb +52 -0
- data/lib/lhm/chunk_finder.rb +32 -0
- data/lib/lhm/chunk_insert.rb +51 -0
- data/lib/lhm/chunker.rb +87 -0
- data/lib/lhm/cleanup/current.rb +74 -0
- data/lib/lhm/command.rb +48 -0
- data/lib/lhm/entangler.rb +117 -0
- data/lib/lhm/intersection.rb +51 -0
- data/lib/lhm/invoker.rb +98 -0
- data/lib/lhm/locked_switcher.rb +74 -0
- data/lib/lhm/migration.rb +43 -0
- data/lib/lhm/migrator.rb +237 -0
- data/lib/lhm/printer.rb +59 -0
- data/lib/lhm/railtie.rb +9 -0
- data/lib/lhm/sql_helper.rb +77 -0
- data/lib/lhm/sql_retry.rb +61 -0
- data/lib/lhm/table.rb +121 -0
- data/lib/lhm/table_name.rb +23 -0
- data/lib/lhm/test_support.rb +35 -0
- data/lib/lhm/throttler.rb +36 -0
- data/lib/lhm/throttler/slave_lag.rb +145 -0
- data/lib/lhm/throttler/threads_running.rb +53 -0
- data/lib/lhm/throttler/time.rb +29 -0
- data/lib/lhm/timestamp.rb +11 -0
- data/lib/lhm/version.rb +6 -0
- data/shipit.rubygems.yml +0 -0
- data/spec/.lhm.example +4 -0
- data/spec/README.md +58 -0
- data/spec/fixtures/bigint_table.ddl +4 -0
- data/spec/fixtures/composite_primary_key.ddl +7 -0
- data/spec/fixtures/custom_primary_key.ddl +6 -0
- data/spec/fixtures/destination.ddl +6 -0
- data/spec/fixtures/lines.ddl +7 -0
- data/spec/fixtures/origin.ddl +6 -0
- data/spec/fixtures/permissions.ddl +5 -0
- data/spec/fixtures/small_table.ddl +4 -0
- data/spec/fixtures/tracks.ddl +5 -0
- data/spec/fixtures/users.ddl +14 -0
- data/spec/fixtures/wo_id_int_column.ddl +6 -0
- data/spec/integration/atomic_switcher_spec.rb +93 -0
- data/spec/integration/chunk_insert_spec.rb +29 -0
- data/spec/integration/chunker_spec.rb +185 -0
- data/spec/integration/cleanup_spec.rb +136 -0
- data/spec/integration/entangler_spec.rb +66 -0
- data/spec/integration/integration_helper.rb +237 -0
- data/spec/integration/invoker_spec.rb +33 -0
- data/spec/integration/lhm_spec.rb +585 -0
- data/spec/integration/lock_wait_timeout_spec.rb +30 -0
- data/spec/integration/locked_switcher_spec.rb +50 -0
- data/spec/integration/sql_retry/lock_wait_spec.rb +125 -0
- data/spec/integration/sql_retry/lock_wait_timeout_test_helper.rb +101 -0
- data/spec/integration/table_spec.rb +91 -0
- data/spec/test_helper.rb +32 -0
- data/spec/unit/atomic_switcher_spec.rb +31 -0
- data/spec/unit/chunk_finder_spec.rb +73 -0
- data/spec/unit/chunk_insert_spec.rb +44 -0
- data/spec/unit/chunker_spec.rb +166 -0
- data/spec/unit/entangler_spec.rb +124 -0
- data/spec/unit/intersection_spec.rb +51 -0
- data/spec/unit/lhm_spec.rb +29 -0
- data/spec/unit/locked_switcher_spec.rb +51 -0
- data/spec/unit/migrator_spec.rb +146 -0
- data/spec/unit/printer_spec.rb +97 -0
- data/spec/unit/sql_helper_spec.rb +32 -0
- data/spec/unit/table_name_spec.rb +39 -0
- data/spec/unit/table_spec.rb +47 -0
- data/spec/unit/throttler/slave_lag_spec.rb +317 -0
- data/spec/unit/throttler/threads_running_spec.rb +64 -0
- data/spec/unit/throttler_spec.rb +124 -0
- data/spec/unit/unit_helper.rb +13 -0
- metadata +239 -0
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
|
data/lib/lhm/chunker.rb
ADDED
@@ -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
|