sbader-lhm 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,98 @@
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 Entangler
9
+ include Command
10
+ include SqlHelper
11
+
12
+ attr_reader :connection
13
+
14
+ # Creates entanglement between two tables. All creates, updates and deletes
15
+ # to origin will be repeated on the destination table.
16
+ def initialize(migration, connection = nil)
17
+ @common = migration.intersection
18
+ @origin = migration.origin
19
+ @destination = migration.destination
20
+ @connection = connection
21
+ end
22
+
23
+ def entangle
24
+ [
25
+ create_delete_trigger,
26
+ create_insert_trigger,
27
+ create_update_trigger
28
+ ]
29
+ end
30
+
31
+ def untangle
32
+ [
33
+ "drop trigger if exists `#{ trigger(:del) }`",
34
+ "drop trigger if exists `#{ trigger(:ins) }`",
35
+ "drop trigger if exists `#{ trigger(:upd) }`"
36
+ ]
37
+ end
38
+
39
+ def create_insert_trigger
40
+ strip %Q{
41
+ create trigger `#{ trigger(:ins) }`
42
+ after insert on `#{ @origin.name }` for each row
43
+ replace into `#{ @destination.name }` (#{ @common.combined_joined }) #{ SqlHelper.annotation }
44
+ values (#{ @common.combined_typed("NEW") })
45
+ }
46
+ end
47
+
48
+ def create_update_trigger
49
+ strip %Q{
50
+ create trigger `#{ trigger(:upd) }`
51
+ after update on `#{ @origin.name }` for each row
52
+ replace into `#{ @destination.name }` (#{ @common.joined }) #{ SqlHelper.annotation }
53
+ values (#{ @common.typed("NEW") })
54
+ }
55
+ end
56
+
57
+ def create_delete_trigger
58
+ strip %Q{
59
+ create trigger `#{ trigger(:del) }`
60
+ after delete on `#{ @origin.name }` for each row
61
+ delete ignore from `#{ @destination.name }` #{ SqlHelper.annotation }
62
+ where `#{ @destination.name }`.`id` = OLD.`id`
63
+ }
64
+ end
65
+
66
+ def trigger(type)
67
+ "lhmt_#{ type }_#{ @origin.name }"
68
+ end
69
+
70
+ def validate
71
+ unless table?(@origin.name)
72
+ error("#{ @origin.name } does not exist")
73
+ end
74
+
75
+ unless table?(@destination.name)
76
+ error("#{ @destination.name } does not exist")
77
+ end
78
+ end
79
+
80
+ def before
81
+ sql(entangle)
82
+ end
83
+
84
+ def after
85
+ sql(untangle)
86
+ end
87
+
88
+ def revert
89
+ after
90
+ end
91
+
92
+ private
93
+
94
+ def strip(sql)
95
+ sql.strip.gsub(/\n */, "\n")
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,63 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ module Lhm
5
+ # Determine and format columns common to origin and destination.
6
+ class Intersection
7
+ def initialize(origin, destination, insert_trigger_additions)
8
+ @origin = origin
9
+ @destination = destination
10
+ @insert_trigger_additions = insert_trigger_additions
11
+ end
12
+
13
+ def common
14
+ (@origin.columns.keys & @destination.columns.keys).sort
15
+ end
16
+
17
+ def insert
18
+ @insert_trigger_additions.keys
19
+ end
20
+
21
+ def escaped_insert
22
+ insert.map { |name| tick(name) }
23
+ end
24
+
25
+ def combined_joined
26
+ (escaped + escaped_insert).join(", ")
27
+ end
28
+
29
+ def combined_typed(type)
30
+ (common.map { |name| qualified(name, type) } + @insert_trigger_additions.values.map { |name| parenthesize(name) }).join(", ")
31
+ end
32
+
33
+ def escaped
34
+ common.map { |name| tick(name) }
35
+ end
36
+
37
+ def joined
38
+ escaped.join(", ")
39
+ end
40
+
41
+ def typed_unjoined(type)
42
+ common.map { |name| qualified(name, type) }
43
+ end
44
+
45
+ def typed(type)
46
+ common.map { |name| qualified(name, type) }.join(", ")
47
+ end
48
+
49
+ private
50
+
51
+ def qualified(name, type)
52
+ "#{ type }.`#{ name }`"
53
+ end
54
+
55
+ def tick(name)
56
+ "`#{ name }`"
57
+ end
58
+
59
+ def parenthesize(name)
60
+ "(#{ name })"
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,49 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/chunker'
5
+ require 'lhm/entangler'
6
+ require 'lhm/atomic_switcher'
7
+ require 'lhm/locked_switcher'
8
+ require 'lhm/migrator'
9
+
10
+ module Lhm
11
+ # Copies an origin table to an altered destination table. Live activity is
12
+ # synchronized into the destination table using triggers.
13
+ #
14
+ # Once the origin and destination tables have converged, origin is archived
15
+ # and replaced by destination.
16
+ class Invoker
17
+ include SqlHelper
18
+
19
+ attr_reader :migrator, :connection
20
+
21
+ def initialize(origin, connection)
22
+ @connection = connection
23
+ @migrator = Migrator.new(origin, connection)
24
+ end
25
+
26
+ def run(options = {})
27
+ if !options.include?(:atomic_switch)
28
+ if supports_atomic_switch?
29
+ options[:atomic_switch] = true
30
+ else
31
+ raise Error.new(
32
+ "Using mysql #{version_string}. You must explicitly set " +
33
+ "options[:atomic_switch] (re SqlHelper#supports_atomic_switch?)")
34
+ end
35
+ end
36
+
37
+ migration = @migrator.run
38
+
39
+ Entangler.new(migration, @connection).run do
40
+ Chunker.new(migration, @connection, options).run
41
+ if options[:atomic_switch]
42
+ AtomicSwitcher.new(migration, @connection).run
43
+ else
44
+ LockedSwitcher.new(migration, @connection).run
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,71 @@
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 nonatomically using a locked write.
10
+ # LockedSwitcher adopts the Facebook strategy, with the following caveat:
11
+ #
12
+ # "Since alter table causes an implicit commit in innodb, innodb locks get
13
+ # released after the first alter table. So any transaction that sneaks in
14
+ # after the first alter table and before the second alter table gets
15
+ # a 'table not found' error. The second alter table is expected to be very
16
+ # fast though because copytable is not visible to other transactions and so
17
+ # there is no need to wait."
18
+ #
19
+ class LockedSwitcher
20
+ include Command
21
+ include SqlHelper
22
+
23
+ attr_reader :connection
24
+
25
+ def initialize(migration, connection = nil)
26
+ @migration = migration
27
+ @connection = connection
28
+ @origin = migration.origin
29
+ @destination = migration.destination
30
+ end
31
+
32
+ def statements
33
+ uncommitted { switch }
34
+ end
35
+
36
+ def switch
37
+ [
38
+ "lock table `#{ @origin.name }` write, `#{ @destination.name }` write",
39
+ "alter table `#{ @origin.name }` rename `#{ @migration.archive_name }`",
40
+ "alter table `#{ @destination.name }` rename `#{ @origin.name }`",
41
+ "commit",
42
+ "unlock tables"
43
+ ]
44
+ end
45
+
46
+ def uncommitted(&block)
47
+ [
48
+ "set @lhm_auto_commit = @@session.autocommit",
49
+ "set session autocommit = 0",
50
+ yield,
51
+ "set session autocommit = @lhm_auto_commit"
52
+ ].flatten
53
+ end
54
+
55
+ def validate
56
+ unless table?(@origin.name) && table?(@destination.name)
57
+ error "`#{ @origin.name }` and `#{ @destination.name }` must exist"
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ def revert
64
+ sql "unlock tables"
65
+ end
66
+
67
+ def execute
68
+ sql statements
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,30 @@
1
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek, Tobias
2
+ # Schmidt
3
+
4
+ require 'lhm/intersection'
5
+
6
+ module Lhm
7
+ class Migration
8
+ attr_reader :origin, :destination, :insert_joins
9
+
10
+ def initialize(origin, destination, time = Time.now, insert_trigger_additions, insert_joins)
11
+ @origin = origin
12
+ @destination = destination
13
+ @start = time
14
+ @insert_trigger_additions = insert_trigger_additions
15
+ @insert_joins = insert_joins
16
+ end
17
+
18
+ def archive_name
19
+ "lhma_#{ startstamp }_#{ @origin.name }"
20
+ end
21
+
22
+ def intersection
23
+ Intersection.new(@origin, @destination, @insert_trigger_additions)
24
+ end
25
+
26
+ def startstamp
27
+ @start.strftime "%Y_%m_%d_%H_%M_%S_#{ "%03d" % (@start.usec / 1000) }"
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,219 @@
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
+ require 'lhm/table'
8
+
9
+ module Lhm
10
+ # Copies existing schema and applies changes using alter on the empty table.
11
+ # `run` returns a Migration which can be used for the remaining process.
12
+ class Migrator
13
+ include Command
14
+ include SqlHelper
15
+
16
+ attr_reader :name, :statements, :connection
17
+
18
+ def initialize(table, connection = nil)
19
+ @connection = connection
20
+ @origin = table
21
+ @name = table.destination_name
22
+ @statements = []
23
+ @insert_trigger_additions = {}
24
+ @insert_joins = []
25
+ end
26
+
27
+ # Alter a table with a custom statement
28
+ #
29
+ # @example
30
+ #
31
+ # Lhm.change_table(:users) do |m|
32
+ # m.ddl("ALTER TABLE #{m.name} ADD COLUMN age INT(11)")
33
+ # end
34
+ #
35
+ # @param [String] statement SQL alter statement
36
+ # @note
37
+ #
38
+ # Don't write the table name directly into the statement. Use the #name
39
+ # getter instead, because the alter statement will be executed against a
40
+ # temporary table.
41
+ #
42
+ def ddl(statement)
43
+ statements << statement
44
+ end
45
+
46
+ # Adds joins to the chunked insert. Helpful if you would need to do an update
47
+ # after the change_table
48
+ #
49
+ # @example
50
+ #
51
+ # Lhm.change_table(:users) do |m|
52
+ # m.add_column(:comment, "VARCHAR(12) DEFAULT '0'")
53
+ # m.join_on_insert(:people, :description, :comment, "people.user_id = users.id")
54
+ # end
55
+ #
56
+ # @param [String] table Table to join to
57
+ # @param [String] origin_field Column in the origin (joined) table
58
+ # @param [String] destination_field Column in the destination table
59
+ # @param [String] statement Valid sql join statement
60
+ def join_on_insert(table, origin_field, destination_field, statement)
61
+ @insert_joins << { :table => table, :origin_field => origin_field, :destination_field => destination_field, :statement => statement }
62
+ end
63
+
64
+ # Adds additional columns to the trigger that is created for inserts.
65
+ #
66
+ # @example
67
+ #
68
+ # Lhm.change_table(:users) do |m|
69
+ # m.add_column(:comment, "VARCHAR(12) DEFAULT '0'")
70
+ # m.insert_trigger(:comment, "SELECT comment FROM people WHERE NEW.id = people.id")
71
+ # end
72
+ #
73
+ # @param [String] key Column to insert value
74
+ # @param [String] statement Valid sql query, can use NEW to reference the row in trigger
75
+ def insert_trigger(key, statement)
76
+ @insert_trigger_additions[key] = statement
77
+ end
78
+
79
+ # Add a column to a table
80
+ #
81
+ # @example
82
+ #
83
+ # Lhm.change_table(:users) do |m|
84
+ # m.add_column(:comment, "VARCHAR(12) DEFAULT '0'")
85
+ # end
86
+ #
87
+ # @param [String] name Name of the column to add
88
+ # @param [String] definition Valid SQL column definition
89
+ def add_column(name, definition)
90
+ ddl("alter table `%s` add column `%s` %s" % [@name, name, definition])
91
+ end
92
+
93
+ # Change an existing column to a new definition
94
+ #
95
+ # @example
96
+ #
97
+ # Lhm.change_table(:users) do |m|
98
+ # m.change_column(:comment, "VARCHAR(12) DEFAULT '0' NOT NULL")
99
+ # end
100
+ #
101
+ # @param [String] name Name of the column to change
102
+ # @param [String] definition Valid SQL column definition
103
+ def change_column(name, definition)
104
+ ddl("alter table `%s` modify column `%s` %s" % [@name, name, definition])
105
+ end
106
+
107
+ # Remove a column from a table
108
+ #
109
+ # @example
110
+ #
111
+ # Lhm.change_table(:users) do |m|
112
+ # m.remove_column(:comment)
113
+ # end
114
+ #
115
+ # @param [String] name Name of the column to delete
116
+ def remove_column(name)
117
+ ddl("alter table `%s` drop `%s`" % [@name, name])
118
+ end
119
+
120
+ # Add an index to a table
121
+ #
122
+ # @example
123
+ #
124
+ # Lhm.change_table(:users) do |m|
125
+ # m.add_index(:comment)
126
+ # m.add_index([:username, :created_at])
127
+ # m.add_index("comment(10)")
128
+ # end
129
+ #
130
+ # @param [String, Symbol, Array<String, Symbol>] columns
131
+ # A column name given as String or Symbol. An Array of Strings or Symbols
132
+ # for compound indexes. It's possible to pass a length limit.
133
+ # @param [String, Symbol] index_name
134
+ # Optional name of the index to be created
135
+ def add_index(columns, index_name = nil)
136
+ ddl(index_ddl(columns, false, index_name))
137
+ end
138
+
139
+ # Add a unique index to a table
140
+ #
141
+ # @example
142
+ #
143
+ # Lhm.change_table(:users) do |m|
144
+ # m.add_unique_index(:comment)
145
+ # m.add_unique_index([:username, :created_at])
146
+ # m.add_unique_index("comment(10)")
147
+ # end
148
+ #
149
+ # @param [String, Symbol, Array<String, Symbol>] columns
150
+ # A column name given as String or Symbol. An Array of Strings or Symbols
151
+ # for compound indexes. It's possible to pass a length limit.
152
+ # @param [String, Symbol] index_name
153
+ # Optional name of the index to be created
154
+ def add_unique_index(columns, index_name = nil)
155
+ ddl(index_ddl(columns, true, index_name))
156
+ end
157
+
158
+ # Remove an index from a table
159
+ #
160
+ # @example
161
+ #
162
+ # Lhm.change_table(:users) do |m|
163
+ # m.remove_index(:comment)
164
+ # m.remove_index([:username, :created_at])
165
+ # end
166
+ #
167
+ # @param [String, Symbol, Array<String, Symbol>] columns
168
+ # A column name given as String or Symbol. An Array of Strings or Symbols
169
+ # for compound indexes.
170
+ # @param [String, Symbol] index_name
171
+ # Optional name of the index to be removed
172
+ def remove_index(columns, index_name = nil)
173
+ index_name ||= idx_name(@origin.name, columns)
174
+ ddl("drop index `%s` on `%s`" % [index_name, @name])
175
+ end
176
+
177
+ private
178
+
179
+ def validate
180
+ unless table?(@origin.name)
181
+ error("could not find origin table #{ @origin.name }")
182
+ end
183
+
184
+ unless @origin.satisfies_primary_key?
185
+ error("origin does not satisfy primary key requirements")
186
+ end
187
+
188
+ dest = @origin.destination_name
189
+
190
+ if table?(dest)
191
+ error("#{ dest } should not exist; not cleaned up from previous run?")
192
+ end
193
+ end
194
+
195
+ def execute
196
+ destination_create
197
+ sql(@statements)
198
+ Migration.new(@origin, destination_read, @insert_trigger_additions, @insert_joins)
199
+ end
200
+
201
+ def destination_create
202
+ original = "CREATE TABLE `#{ @origin.name }`"
203
+ replacement = "CREATE TABLE `#{ @origin.destination_name }`"
204
+
205
+ sql(@origin.ddl.gsub(original, replacement))
206
+ end
207
+
208
+ def destination_read
209
+ Table.parse(@origin.destination_name, connection)
210
+ end
211
+
212
+ def index_ddl(cols, unique = nil, index_name = nil)
213
+ type = unique ? "unique index" : "index"
214
+ index_name ||= idx_name(@origin.name, cols)
215
+ parts = [type, index_name, @name, idx_spec(cols)]
216
+ "create %s `%s` on `%s` (%s)" % parts
217
+ end
218
+ end
219
+ end