sbader-lhm 1.1.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 (53) hide show
  1. data/.gitignore +6 -0
  2. data/.travis.yml +10 -0
  3. data/CHANGELOG.md +99 -0
  4. data/LICENSE +27 -0
  5. data/README.md +146 -0
  6. data/Rakefile +20 -0
  7. data/bin/lhm-kill-queue +172 -0
  8. data/bin/lhm-spec-clobber.sh +36 -0
  9. data/bin/lhm-spec-grants.sh +25 -0
  10. data/bin/lhm-spec-setup-cluster.sh +67 -0
  11. data/bin/lhm-test-all.sh +10 -0
  12. data/gemfiles/ar-2.3_mysql.gemfile +5 -0
  13. data/gemfiles/ar-3.2_mysql.gemfile +5 -0
  14. data/gemfiles/ar-3.2_mysql2.gemfile +5 -0
  15. data/lhm.gemspec +27 -0
  16. data/lib/lhm.rb +45 -0
  17. data/lib/lhm/atomic_switcher.rb +49 -0
  18. data/lib/lhm/chunker.rb +114 -0
  19. data/lib/lhm/command.rb +46 -0
  20. data/lib/lhm/entangler.rb +98 -0
  21. data/lib/lhm/intersection.rb +63 -0
  22. data/lib/lhm/invoker.rb +49 -0
  23. data/lib/lhm/locked_switcher.rb +71 -0
  24. data/lib/lhm/migration.rb +30 -0
  25. data/lib/lhm/migrator.rb +219 -0
  26. data/lib/lhm/sql_helper.rb +85 -0
  27. data/lib/lhm/table.rb +97 -0
  28. data/lib/lhm/version.rb +6 -0
  29. data/spec/.lhm.example +4 -0
  30. data/spec/README.md +51 -0
  31. data/spec/bootstrap.rb +13 -0
  32. data/spec/fixtures/destination.ddl +6 -0
  33. data/spec/fixtures/origin.ddl +6 -0
  34. data/spec/fixtures/small_table.ddl +4 -0
  35. data/spec/fixtures/users.ddl +12 -0
  36. data/spec/integration/atomic_switcher_spec.rb +42 -0
  37. data/spec/integration/chunker_spec.rb +32 -0
  38. data/spec/integration/entangler_spec.rb +66 -0
  39. data/spec/integration/integration_helper.rb +140 -0
  40. data/spec/integration/lhm_spec.rb +204 -0
  41. data/spec/integration/locked_switcher_spec.rb +42 -0
  42. data/spec/integration/table_spec.rb +48 -0
  43. data/spec/unit/atomic_switcher_spec.rb +31 -0
  44. data/spec/unit/chunker_spec.rb +111 -0
  45. data/spec/unit/entangler_spec.rb +76 -0
  46. data/spec/unit/intersection_spec.rb +39 -0
  47. data/spec/unit/locked_switcher_spec.rb +51 -0
  48. data/spec/unit/migration_spec.rb +23 -0
  49. data/spec/unit/migrator_spec.rb +134 -0
  50. data/spec/unit/sql_helper_spec.rb +32 -0
  51. data/spec/unit/table_spec.rb +34 -0
  52. data/spec/unit/unit_helper.rb +14 -0
  53. metadata +173 -0
@@ -0,0 +1,36 @@
1
+ #!/bin/sh
2
+
3
+ set -e
4
+ set -u
5
+
6
+ source ~/.lhm
7
+
8
+ lhmkill() {
9
+ ps -ef | gsed -n "/[m]ysqld.*lhm-cluster/p" | awk '{ print $2 }' | xargs kill
10
+ sleep 5
11
+ }
12
+
13
+ echo stopping other running mysql instance
14
+ launchctl remove com.mysql.mysqld || { echo launchctl did not remove mysqld; }
15
+ "$mysqldir"/bin/mysqladmin shutdown || { echo mysqladmin did not shut down anything; }
16
+
17
+ echo killing lhm-cluster
18
+ lhmkill
19
+
20
+ echo removing $basedir
21
+ rm -rf "$basedir"
22
+
23
+ echo setting up cluster
24
+ bin/lhm-spec-setup-cluster.sh
25
+
26
+ echo staring instances
27
+ "$mysqldir"/bin/mysqld --defaults-file="$basedir/master/my.cnf" 2>&1 >$basedir/master/lhm.log &
28
+ "$mysqldir"/bin/mysqld --defaults-file="$basedir/slave/my.cnf" 2>&1 >$basedir/slave/lhm.log &
29
+ sleep 5
30
+
31
+ echo running grants
32
+ bin/lhm-spec-grants.sh
33
+
34
+ trap lhmkill SIGTERM SIGINT
35
+
36
+ wait
@@ -0,0 +1,25 @@
1
+ #!/bin/sh
2
+
3
+ source ~/.lhm
4
+
5
+ master() { "$mysqldir"/bin/mysql --protocol=TCP -P $master_port -uroot; }
6
+ slave() { "$mysqldir"/bin/mysql --protocol=TCP -P $slave_port -uroot; }
7
+
8
+ # set up master
9
+
10
+ echo "create user 'slave'@'localhost' identified by 'slave'" | master
11
+ echo "grant replication slave on *.* to 'slave'@'localhost'" | master
12
+
13
+ # set up slave
14
+
15
+ echo "change master to master_user = 'slave', master_password = 'slave', master_port = 3306, master_host = 'localhost'" | slave
16
+ echo "start slave" | slave
17
+ echo "show slave status \G" | slave
18
+
19
+ # setup for test
20
+
21
+ echo "grant all privileges on *.* to ''@'localhost'" | master
22
+ echo "grant all privileges on *.* to ''@'localhost'" | slave
23
+
24
+ echo "create database lhm" | master
25
+ echo "create database if not exists lhm" | slave
@@ -0,0 +1,67 @@
1
+ #!/bin/sh
2
+
3
+ #
4
+ # Set up master slave cluster for lhm specs
5
+ #
6
+
7
+ set -e
8
+ set -u
9
+
10
+ source ~/.lhm
11
+
12
+ #
13
+ # Main
14
+ #
15
+
16
+ install_bin="$(echo ./*/mysql_install_db)"
17
+
18
+ mkdir -p "$basedir/master/data" "$basedir/slave/data"
19
+
20
+ cat <<-CNF > $basedir/master/my.cnf
21
+ [mysqld]
22
+ pid-file = $basedir/master/mysqld.pid
23
+ socket = $basedir/master/mysqld.sock
24
+ port = $master_port
25
+ log_output = FILE
26
+ log-error = $basedir/master/error.log
27
+ datadir = $basedir/master/data
28
+ log-bin = master-bin
29
+ log-bin-index = master-bin.index
30
+ server-id = 1
31
+ CNF
32
+
33
+ cat <<-CNF > $basedir/slave/my.cnf
34
+ [mysqld]
35
+ pid-file = $basedir/slave/mysqld.pid
36
+ socket = $basedir/slave/mysqld.sock
37
+ port = $slave_port
38
+ log_output = FILE
39
+ log-error = $basedir/slave/error.log
40
+ datadir = $basedir/slave/data
41
+ relay-log = slave-relay-bin
42
+ relay-log-index = slave-relay-bin.index
43
+ server-id = 2
44
+
45
+ # replication (optional filters)
46
+
47
+ # replicate-do-table = lhm.users
48
+ # replicate-do-table = lhm.lhmn_users
49
+ # replicate-wild-do-table = lhm.lhma_%_users
50
+
51
+ # replicate-do-table = lhm.origin
52
+ # replicate-do-table = lhm.lhmn_origin
53
+ # replicate-wild-do-table = lhm.lhma_%_origin
54
+
55
+ # replicate-do-table = lhm.destination
56
+ # replicate-do-table = lhm.lhmn_destination
57
+ # replicate-wild-do-table = lhm.lhma_%_destination
58
+ CNF
59
+
60
+ # build system tables
61
+
62
+ (
63
+ cd "$mysqldir"
64
+ $install_bin --datadir="$basedir/master/data"
65
+ $install_bin --datadir="$basedir/slave/data"
66
+
67
+ )
@@ -0,0 +1,10 @@
1
+ #!/bin/sh
2
+
3
+ for gemfile in gemfiles/*.gemfile
4
+ do
5
+ if !(BUNDLE_GEMFILE=$gemfile bundle install &&
6
+ BUNDLE_GEMFILE=$gemfile bundle exec rake)
7
+ then
8
+ exit 1
9
+ fi
10
+ done
@@ -0,0 +1,5 @@
1
+ source :rubygems
2
+
3
+ gem "mysql", "~> 2.8.1"
4
+ gem "activerecord", "~> 2.3.14"
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source :rubygems
2
+
3
+ gem "mysql", "~> 2.8.1"
4
+ gem "activerecord", "~> 3.2.2"
5
+ gemspec :path=>"../"
@@ -0,0 +1,5 @@
1
+ source :rubygems
2
+
3
+ gem "mysql2", "~> 0.3.11"
4
+ gem "activerecord", "~> 3.2.2"
5
+ gemspec :path=>"../"
@@ -0,0 +1,27 @@
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 = "sbader-lhm"
10
+ s.version = Lhm::VERSION
11
+ s.platform = Gem::Platform::RUBY
12
+ s.authors = ["SoundCloud", "Rany Keddo", "Tobias Bielohlawek", "Tobias Schmidt"]
13
+ s.email = %q{rany@soundcloud.com, tobi@soundcloud.com, ts@soundcloud.com}
14
+ s.summary = %q{online schema changer for mysql}
15
+ 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.}
16
+ s.homepage = %q{http://github.com/soundcloud/large-hadron-migrator}
17
+ s.files = `git ls-files`.split("\n")
18
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
19
+ s.require_paths = ["lib"]
20
+ s.executables = ["lhm-kill-queue"]
21
+
22
+ s.add_development_dependency "minitest", "= 2.10.0"
23
+ s.add_development_dependency "rake"
24
+
25
+ s.add_dependency "activerecord"
26
+ end
27
+
@@ -0,0 +1,45 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'active_record'
5
+ require 'lhm/table'
6
+ require 'lhm/invoker'
7
+ require 'lhm/version'
8
+
9
+ # Large hadron migrator - online schema change tool
10
+ #
11
+ # @example
12
+ #
13
+ # Lhm.change_table(:users) do |m|
14
+ # m.add_column(:arbitrary, "INT(12)")
15
+ # m.add_index([:arbitrary, :created_at])
16
+ # m.ddl("alter table %s add column flag tinyint(1)" % m.name)
17
+ # end
18
+ #
19
+ module Lhm
20
+
21
+ # Alters a table with the changes described in the block
22
+ #
23
+ # @param [String, Symbol] table_name Name of the table
24
+ # @param [Hash] options Optional options to alter the chunk / switch behavior
25
+ # @option options [Fixnum] :stride
26
+ # Size of a chunk (defaults to: 40,000)
27
+ # @option options [Fixnum] :throttle
28
+ # Time to wait between chunks in milliseconds (defaults to: 100)
29
+ # @option options [Boolean] :atomic_switch
30
+ # Use atomic switch to rename tables (defaults to: true)
31
+ # If using a version of mysql affected by atomic switch bug, LHM forces user
32
+ # to set this option (see SqlHelper#supports_atomic_switch?)
33
+ # @yield [Migrator] Yielded Migrator object records the changes
34
+ # @return [Boolean] Returns true if the migration finishes
35
+ # @raise [Error] Raises Lhm::Error in case of a error and aborts the migration
36
+ def self.change_table(table_name, options = {}, &block)
37
+ connection = ActiveRecord::Base.connection
38
+ origin = Table.parse(table_name, connection)
39
+ invoker = Invoker.new(origin, connection)
40
+ block.call(invoker.migrator)
41
+ invoker.run(options)
42
+
43
+ true
44
+ end
45
+ end
@@ -0,0 +1,49 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/command'
5
+ require 'lhm/migration'
6
+ require 'lhm/sql_helper'
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
+ include SqlHelper
17
+
18
+ attr_reader :connection
19
+
20
+ def initialize(migration, connection = nil)
21
+ @migration = migration
22
+ @connection = connection
23
+ @origin = migration.origin
24
+ @destination = migration.destination
25
+ end
26
+
27
+ def statements
28
+ atomic_switch
29
+ end
30
+
31
+ def atomic_switch
32
+ [
33
+ "rename table `#{ @origin.name }` to `#{ @migration.archive_name }`, " +
34
+ "`#{ @destination.name }` to `#{ @origin.name }`"
35
+ ]
36
+ end
37
+
38
+ def validate
39
+ unless table?(@origin.name) && table?(@destination.name)
40
+ error "`#{ @origin.name }` and `#{ @destination.name }` must exist"
41
+ end
42
+ end
43
+
44
+ private
45
+ def execute
46
+ sql statements
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,114 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/command'
5
+ require 'lhm/sql_helper'
6
+
7
+ module Lhm
8
+ class Chunker
9
+ include Command
10
+ include SqlHelper
11
+
12
+ attr_reader :connection
13
+
14
+ # Copy from origin to destination in chunks of size `stride`. Sleeps for
15
+ # `throttle` milliseconds between each stride.
16
+ def initialize(migration, connection = nil, options = {})
17
+ @migration = migration
18
+ @connection = connection
19
+ @stride = options[:stride] || 40_000
20
+ @throttle = options[:throttle] || 100
21
+ @start = options[:start] || select_start
22
+ @limit = options[:limit] || select_limit
23
+ end
24
+
25
+ # Copies chunks of size `stride`, starting from `start` up to id `limit`.
26
+ def up_to(&block)
27
+ 1.upto(traversable_chunks_size) do |n|
28
+ yield(bottom(n), top(n))
29
+ end
30
+ end
31
+
32
+ def traversable_chunks_size
33
+ @limit && @start ? ((@limit - @start + 1) / @stride.to_f).ceil : 0
34
+ end
35
+
36
+ def bottom(chunk)
37
+ (chunk - 1) * @stride + @start
38
+ end
39
+
40
+ def top(chunk)
41
+ [chunk * @stride + @start - 1, @limit].min
42
+ end
43
+
44
+ def copy(lowest, highest)
45
+ "insert ignore into `#{ destination_name }` (#{ columns }) " +
46
+ "select #{ columns_with_joins } from `#{ origin_name }` " +
47
+ "#{ joins } " +
48
+ "where #{origin_name}.`id` between #{ lowest } and #{ highest }"
49
+ end
50
+
51
+ def joins
52
+ @migration.insert_joins.map{|j| "join #{ j[:table] } on #{ j[:statement] }"}.join(" ")
53
+ end
54
+
55
+ def join_fields_typed
56
+ @migration.insert_joins.map {|j| "#{j[:table]}.`#{j[:origin_field]}`"}
57
+ end
58
+
59
+ def join_fields
60
+ @migration.insert_joins.map {|j| "`#{ j[:destination_field] }`"}
61
+ end
62
+
63
+ def select_start
64
+ start = connection.select_value("select min(id) from #{ origin_name }")
65
+ start ? start.to_i : nil
66
+ end
67
+
68
+ def select_limit
69
+ limit = connection.select_value("select max(id) from #{ origin_name }")
70
+ limit ? limit.to_i : nil
71
+ end
72
+
73
+ def throttle_seconds
74
+ @throttle / 1000.0
75
+ end
76
+
77
+ private
78
+
79
+ def destination_name
80
+ @migration.destination.name
81
+ end
82
+
83
+ def origin_name
84
+ @migration.origin.name
85
+ end
86
+
87
+ def columns
88
+ @columns ||= (@migration.intersection.escaped + join_fields).join(", ")
89
+ end
90
+
91
+ def columns_with_joins
92
+ @columns_with_joins ||= (@migration.intersection.typed_unjoined(origin_name) + join_fields_typed).join(", ")
93
+ end
94
+
95
+ def validate
96
+ if @start && @limit && @start > @limit
97
+ error("impossible chunk options (limit must be greater than start)")
98
+ end
99
+ end
100
+
101
+ def execute
102
+ up_to do |lowest, highest|
103
+ affected_rows = update(copy(lowest, highest))
104
+
105
+ if affected_rows > 0
106
+ sleep(throttle_seconds)
107
+ end
108
+
109
+ print "."
110
+ end
111
+ print "\n"
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,46 @@
1
+ # Copyright (c) 2011, 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
+ validate
11
+
12
+ if(block_given?)
13
+ before
14
+ block.call(self)
15
+ after
16
+ else
17
+ execute
18
+ end
19
+ rescue
20
+ revert
21
+ raise
22
+ end
23
+
24
+ private
25
+
26
+ def validate
27
+ end
28
+
29
+ def revert
30
+ end
31
+
32
+ def execute
33
+ raise NotImplementedError.new(self.class.name)
34
+ end
35
+
36
+ def before
37
+ end
38
+
39
+ def after
40
+ end
41
+
42
+ def error(msg)
43
+ raise Error.new(msg)
44
+ end
45
+ end
46
+ end