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,85 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ module Lhm
5
+ module SqlHelper
6
+ extend self
7
+
8
+ def annotation
9
+ "/* large hadron migration */"
10
+ end
11
+
12
+ def idx_name(table_name, cols)
13
+ column_names = column_definition(cols).map(&:first)
14
+ "index_#{ table_name }_on_#{ column_names.join("_and_") }"
15
+ end
16
+
17
+ def idx_spec(cols)
18
+ column_definition(cols).map do |name, length|
19
+ "`#{ name }`#{ length }"
20
+ end.join(', ')
21
+ end
22
+
23
+ def table?(table_name)
24
+ connection.table_exists?(table_name)
25
+ end
26
+
27
+ def sql(statements)
28
+ [statements].flatten.each do |statement|
29
+ connection.execute(tagged(statement))
30
+ end
31
+ rescue ActiveRecord::StatementInvalid => e
32
+ error e.message
33
+ end
34
+
35
+ def update(statements)
36
+ [statements].flatten.inject(0) do |memo, statement|
37
+ memo += connection.update(tagged(statement))
38
+ end
39
+ rescue ActiveRecord::StatementInvalid => e
40
+ error e.message
41
+ end
42
+
43
+ def version_string
44
+ connection.select_one("show variables like 'version'")["Value"]
45
+ end
46
+
47
+ private
48
+
49
+ def tagged(statement)
50
+ "#{ statement } #{ SqlHelper.annotation }"
51
+ end
52
+
53
+ def column_definition(cols)
54
+ Array(cols).map do |column|
55
+ column.to_s.match(/`?([^\(]+)`?(\([^\)]+\))?/).captures
56
+ end
57
+ end
58
+
59
+ # Older versions of MySQL contain an atomic rename bug affecting bin
60
+ # log order. Affected versions extracted from bug report:
61
+ #
62
+ # http://bugs.mysql.com/bug.php?id=39675
63
+ #
64
+ # More Info: http://dev.mysql.com/doc/refman/5.5/en/metadata-locking.html
65
+ def supports_atomic_switch?
66
+ major, minor, tiny = version_string.split('.').map(&:to_i)
67
+
68
+ case major
69
+ when 4 then return false if minor and minor < 2
70
+ when 5
71
+ case minor
72
+ when 0 then return false if tiny and tiny < 52
73
+ when 1 then return false
74
+ when 4 then return false if tiny and tiny < 4
75
+ when 5 then return false if tiny and tiny < 3
76
+ end
77
+ when 6
78
+ case minor
79
+ when 0 then return false if tiny and tiny < 11
80
+ end
81
+ end
82
+ return true
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,97 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/sql_helper'
5
+
6
+ module Lhm
7
+ class Table
8
+ attr_reader :name, :columns, :indices, :pk, :ddl
9
+
10
+ def initialize(name, pk = "id", ddl = nil)
11
+ @name = name
12
+ @columns = {}
13
+ @indices = {}
14
+ @pk = pk
15
+ @ddl = ddl
16
+ end
17
+
18
+ def satisfies_primary_key?
19
+ @pk == "id"
20
+ end
21
+
22
+ def destination_name
23
+ "lhmn_#{ @name }"
24
+ end
25
+
26
+ def self.parse(table_name, connection)
27
+ Parser.new(table_name, connection).parse
28
+ end
29
+
30
+ class Parser
31
+ include SqlHelper
32
+
33
+ def initialize(table_name, connection)
34
+ @table_name = table_name.to_s
35
+ @schema_name = connection.current_database
36
+ @connection = connection
37
+ end
38
+
39
+ def ddl
40
+ sql = "show create table `#{ @table_name }`"
41
+ specification = nil
42
+ @connection.execute(sql).each { |row| specification = row.last }
43
+ specification
44
+ end
45
+
46
+ def parse
47
+ schema = read_information_schema
48
+
49
+ Table.new(@table_name, extract_primary_key(schema), ddl).tap do |table|
50
+ schema.each do |defn|
51
+ table.columns[defn["COLUMN_NAME"]] = {
52
+ :type => defn["COLUMN_TYPE"],
53
+ :is_nullable => defn["IS_NULLABLE"],
54
+ :column_default => defn["COLUMN_DEFAULT"]
55
+ }
56
+ end
57
+
58
+ extract_indices(read_indices).each do |idx, columns|
59
+ table.indices[idx] = columns
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def read_information_schema
67
+ @connection.select_all %Q{
68
+ select *
69
+ from information_schema.columns
70
+ where table_name = "#{ @table_name }"
71
+ and table_schema = "#{ @schema_name }"
72
+ }
73
+ end
74
+
75
+ def read_indices
76
+ @connection.select_all %Q{
77
+ show indexes from `#{ @schema_name }`.`#{ @table_name }`
78
+ where key_name != "PRIMARY"
79
+ }
80
+ end
81
+
82
+ def extract_indices(indices)
83
+ indices.map { |row| [row["Key_name"], row["Column_name"]] }.
84
+ inject(Hash.new { |h, k| h[k] = []}) do |memo, (idx, column)|
85
+ memo[idx] << column
86
+ memo
87
+ end
88
+ end
89
+
90
+ def extract_primary_key(schema)
91
+ cols = schema.select { |defn| defn["COLUMN_KEY"] == "PRI" }
92
+ keys = cols.map { |defn| defn["COLUMN_NAME"] }
93
+ keys.length == 1 ? keys.first : keys
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,6 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ module Lhm
5
+ VERSION = "1.1.0"
6
+ end
@@ -0,0 +1,4 @@
1
+ mysqldir=/usr/local/mysql
2
+ basedir=/opt/lhm-cluster
3
+ master_port=3306
4
+ slave_port=3307
@@ -0,0 +1,51 @@
1
+ Preparing for master slave integration tests
2
+ --------------------------------------------
3
+
4
+ # configuration
5
+
6
+ create ~/.lhm:
7
+
8
+ mysqldir=/usr/local/mysql
9
+ basedir=/opt/lhm-cluster
10
+ master_port=3306
11
+ slave_port=3307
12
+
13
+ mysqldir specifies the location of your mysql install. basedir is the
14
+ directory master and slave databases will get installed into.
15
+
16
+ # setup
17
+
18
+ You can set the integration specs up to run against a master slave setup by
19
+ running the included `bin/lhm-spec-clobber.sh` script. this deletes the configured
20
+ lhm master slave setup and reinstalls and configures a master slave setup.
21
+
22
+ Follow the manual instructions if you want more control over this process.
23
+
24
+ # manual setup
25
+
26
+ ## set up instances
27
+
28
+ bin/lhm-spec-setup-cluster.sh
29
+
30
+ ## start instances
31
+
32
+ basedir=/opt/lhm-luster
33
+ mysqld --defaults-file="$basedir/master/my.cnf"
34
+ mysqld --defaults-file="$basedir/slave/my.cnf"
35
+
36
+ ## run the grants
37
+
38
+ bin/lhm-spec-grants.sh
39
+
40
+ ## run specs
41
+
42
+ To run specs in slave mode, set the SLAVE=1 when running tests:
43
+
44
+ MASTER_SLAVE=1 rake specs
45
+
46
+ # connecting
47
+
48
+ you can connect by running (with the respective ports):
49
+
50
+ mysql --protocol=TCP -p3307
51
+
@@ -0,0 +1,13 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'minitest/spec'
5
+ require 'minitest/autorun'
6
+ require 'minitest/mock'
7
+ require "pathname"
8
+
9
+ $project = Pathname.new(File.dirname(__FILE__) + '/..').cleanpath
10
+ $spec = $project.join("spec")
11
+ $fixtures = $spec.join("fixtures")
12
+
13
+ $: << $project.join("lib").to_s
@@ -0,0 +1,6 @@
1
+ CREATE TABLE `destination` (
2
+ `id` int(11) NOT NULL AUTO_INCREMENT,
3
+ `destination` int(11) DEFAULT NULL,
4
+ `common` varchar(255) DEFAULT NULL,
5
+ PRIMARY KEY (`id`)
6
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8
@@ -0,0 +1,6 @@
1
+ CREATE TABLE `origin` (
2
+ `id` int(11) NOT NULL AUTO_INCREMENT,
3
+ `origin` int(11) DEFAULT NULL,
4
+ `common` varchar(255) DEFAULT NULL,
5
+ PRIMARY KEY (`id`)
6
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8
@@ -0,0 +1,4 @@
1
+ CREATE TABLE `small_table` (
2
+ `id` INT(11),
3
+ PRIMARY KEY (`id`)
4
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8
@@ -0,0 +1,12 @@
1
+ CREATE TABLE `users` (
2
+ `id` int(11) NOT NULL AUTO_INCREMENT,
3
+ `reference` int(11) DEFAULT NULL,
4
+ `username` varchar(255) DEFAULT NULL,
5
+ `group` varchar(255) DEFAULT NULL,
6
+ `created_at` datetime DEFAULT NULL,
7
+ `comment` varchar(20) DEFAULT NULL,
8
+ `description` text,
9
+ PRIMARY KEY (`id`),
10
+ UNIQUE KEY `index_users_on_reference` (`reference`),
11
+ KEY `index_users_on_username_and_created_at` (`username`,`created_at`)
12
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8
@@ -0,0 +1,42 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require File.expand_path(File.dirname(__FILE__)) + '/integration_helper'
5
+
6
+ require 'lhm/table'
7
+ require 'lhm/migration'
8
+ require 'lhm/atomic_switcher'
9
+
10
+ describe Lhm::AtomicSwitcher do
11
+ include IntegrationHelper
12
+
13
+ before(:each) { connect_master! }
14
+
15
+ describe "switching" do
16
+ before(:each) do
17
+ @origin = table_create("origin")
18
+ @destination = table_create("destination")
19
+ @migration = Lhm::Migration.new(@origin, @destination)
20
+ end
21
+
22
+ it "rename origin to archive" do
23
+ switcher = Lhm::AtomicSwitcher.new(@migration, connection)
24
+ switcher.run
25
+
26
+ slave do
27
+ table_exists?(@origin).must_equal true
28
+ table_read(@migration.archive_name).columns.keys.must_include "origin"
29
+ end
30
+ end
31
+
32
+ it "rename destination to origin" do
33
+ switcher = Lhm::AtomicSwitcher.new(@migration, connection)
34
+ switcher.run
35
+
36
+ slave do
37
+ table_exists?(@destination).must_equal false
38
+ table_read(@origin.name).columns.keys.must_include "destination"
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,32 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require File.expand_path(File.dirname(__FILE__)) + '/integration_helper'
5
+
6
+ require 'lhm'
7
+ require 'lhm/table'
8
+ require 'lhm/migration'
9
+
10
+ describe Lhm::Chunker do
11
+ include IntegrationHelper
12
+
13
+ before(:each) { connect_master! }
14
+
15
+ describe "copying" do
16
+ before(:each) do
17
+ @origin = table_create(:origin)
18
+ @destination = table_create(:destination)
19
+ @migration = Lhm::Migration.new(@origin, @destination)
20
+ end
21
+
22
+ it "should copy 23 rows from origin to destination" do
23
+ 23.times { |n| execute("insert into origin set id = '#{ n * n + 23 }'") }
24
+
25
+ Lhm::Chunker.new(@migration, connection, { :stride => 100 }).run
26
+
27
+ slave do
28
+ count_all(@destination.name).must_equal(23)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,66 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require File.expand_path(File.dirname(__FILE__)) + '/integration_helper'
5
+
6
+ require 'lhm/table'
7
+ require 'lhm/migration'
8
+ require 'lhm/entangler'
9
+
10
+ describe Lhm::Entangler do
11
+ include IntegrationHelper
12
+
13
+ before(:each) { connect_master! }
14
+
15
+ describe "entanglement" do
16
+ before(:each) do
17
+ @origin = table_create("origin")
18
+ @destination = table_create("destination")
19
+ @migration = Lhm::Migration.new(@origin, @destination)
20
+ @entangler = Lhm::Entangler.new(@migration, connection)
21
+ end
22
+
23
+ it "should replay inserts from origin into destination" do
24
+ @entangler.run do |entangler|
25
+ execute("insert into origin (common) values ('inserted')")
26
+ end
27
+
28
+ slave do
29
+ count(:destination, "common", "inserted").must_equal(1)
30
+ end
31
+ end
32
+
33
+ it "should replay deletes from origin into destination" do
34
+ execute("insert into origin (common) values ('inserted')")
35
+
36
+ @entangler.run do |entangler|
37
+ execute("delete from origin where common = 'inserted'")
38
+ end
39
+
40
+ slave do
41
+ count(:destination, "common", "inserted").must_equal(0)
42
+ end
43
+ end
44
+
45
+ it "should replay updates from origin into destination" do
46
+ @entangler.run do |entangler|
47
+ execute("insert into origin (common) values ('inserted')")
48
+ execute("update origin set common = 'updated'")
49
+ end
50
+
51
+ slave do
52
+ count(:destination, "common", "updated").must_equal(1)
53
+ end
54
+ end
55
+
56
+ it "should remove entanglement" do
57
+ @entangler.run {}
58
+
59
+ execute("insert into origin (common) values ('inserted')")
60
+
61
+ slave do
62
+ count(:destination, "common", "inserted").must_equal(0)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,140 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require File.expand_path(File.dirname(__FILE__)) + "/../bootstrap"
5
+
6
+ require 'active_record'
7
+ begin
8
+ require 'mysql2'
9
+ rescue LoadError
10
+ require 'mysql'
11
+ end
12
+ require 'lhm/table'
13
+ require 'lhm/sql_helper'
14
+
15
+ module IntegrationHelper
16
+ #
17
+ # Connectivity
18
+ #
19
+
20
+ def connection
21
+ ActiveRecord::Base.connection
22
+ end
23
+
24
+ def connect_master!
25
+ connect!(3306)
26
+ end
27
+
28
+ def connect_slave!
29
+ connect!(3307)
30
+ end
31
+
32
+ def connect!(port)
33
+ ActiveRecord::Base.establish_connection(
34
+ :adapter => defined?(Mysql2) ? 'mysql2' : 'mysql',
35
+ :host => '127.0.0.1',
36
+ :database => 'lhm',
37
+ :username => '',
38
+ :port => port
39
+ )
40
+ end
41
+
42
+ def select_one(*args)
43
+ connection.select_one(*args)
44
+ end
45
+
46
+ def select_value(*args)
47
+ connection.select_value(*args)
48
+ end
49
+
50
+ def execute(*args)
51
+ retries = 10
52
+ begin
53
+ connection.execute(*args)
54
+ rescue ActiveRecord::StatementInvalid => e
55
+ if (retries -= 1) > 0 && e.message =~ /Table '.*?' doesn't exist/
56
+ sleep 0.1
57
+ retry
58
+ else
59
+ raise
60
+ end
61
+ end
62
+ end
63
+
64
+ def slave(&block)
65
+ if master_slave_mode?
66
+ connect_slave!
67
+
68
+ # need to wait for the slave to catch up. a better method would be to
69
+ # check the master binlog position and wait for the slave to catch up
70
+ # to that position.
71
+ sleep 1
72
+ end
73
+
74
+ yield block
75
+
76
+ if master_slave_mode?
77
+ connect_master!
78
+ end
79
+ end
80
+
81
+ #
82
+ # Test Data
83
+ #
84
+
85
+ def fixture(name)
86
+ File.read($fixtures.join("#{ name }.ddl"))
87
+ end
88
+
89
+ def table_create(fixture_name)
90
+ execute "drop table if exists `#{ fixture_name }`"
91
+ execute fixture(fixture_name)
92
+ table_read(fixture_name)
93
+ end
94
+
95
+ def table_read(fixture_name)
96
+ Lhm::Table.parse(fixture_name, connection)
97
+ end
98
+
99
+ def table_exists?(table)
100
+ connection.table_exists?(table.name)
101
+ end
102
+
103
+ #
104
+ # Database Helpers
105
+ #
106
+
107
+ def count(table, column, value)
108
+ query = "select count(*) from #{ table } where #{ column } = '#{ value }'"
109
+ select_value(query).to_i
110
+ end
111
+
112
+ def count_all(table)
113
+ query = "select count(*) from #{ table }"
114
+ select_value(query).to_i
115
+ end
116
+
117
+ def index_on_columns?(table_name, cols, type = :non_unique)
118
+ key_name = Lhm::SqlHelper.idx_name(table_name, cols)
119
+
120
+ index?(table_name, key_name, type)
121
+ end
122
+
123
+ def index?(table_name, key_name, type = :non_unique)
124
+ non_unique = type == :non_unique ? 1 : 0
125
+
126
+ !!select_one(%Q<
127
+ show indexes in #{ table_name }
128
+ where key_name = '#{ key_name }'
129
+ and non_unique = #{ non_unique }
130
+ >)
131
+ end
132
+
133
+ #
134
+ # Environment
135
+ #
136
+
137
+ def master_slave_mode?
138
+ !!ENV["MASTER_SLAVE"]
139
+ end
140
+ end