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,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