large-hadron-migrator 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ .rvmrc
data/CHANGES.markdown ADDED
File without changes
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ Copyright (c) 2011, SoundCloud, Rany Keddo and Tobias Bielohlawek
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5
+
6
+ - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7
+ - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+ - Neither the name of the SoundCloud nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
9
+
10
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
data/README.markdown ADDED
@@ -0,0 +1,237 @@
1
+ # Large Hadron Migrator
2
+
3
+ Rails style database migrations are a useful way to evolve your data schema in
4
+ an agile manner. Most Rails projects start like this, and at first, making
5
+ changes is fast and easy.
6
+
7
+ That is until your tables grow to millions of records. At this point, the
8
+ locking nature of `ALTER TABLE` may take your site down for an hour our more
9
+ while critical tables are migrated. In order to avoid this, developers begin
10
+ to design around the problem by introducing join tables or moving the data
11
+ into another layer. Development gets less and less agile as tables grow and
12
+ grow. To make the problem worse, adding or changing indices to optimize data
13
+ access becomes just as difficult.
14
+
15
+ > Side effects may include black holes and universe implosion.
16
+
17
+ There are few things that can be done at the server or engine level. It is
18
+ possible to change default values in an `ALTER TABLE` without locking the table.
19
+ The InnoDB Plugin provides facilities for online index creation, which is
20
+ great if you are using this engine, but only solves half the problem.
21
+
22
+ At SoundCloud we started having migration pains quite a while ago, and after
23
+ looking around for third party solutions [0] [1] [2], we decided to create our
24
+ own. We called it Large Hadron Migrator, and it is a gem for online
25
+ ActiveRecord migrations.
26
+
27
+ ![LHC](http://farm4.static.flickr.com/3093/2844971993_17f2ddf2a8_z.jpg)
28
+
29
+ [The Large Hadron collider at CERN](http://en.wikipedia.org/wiki/Large_Hadron_Collider)
30
+
31
+ ## The idea
32
+
33
+ The basic idea is to perform the migration online while the system is live,
34
+ without locking the table. Similar to OAK (online alter table) [2] and the
35
+ facebook tool [0], we use a copy table, triggers and a journal table.
36
+
37
+ We copy successive ranges of data from the original table to a copy table and
38
+ then rename both at the end. Since `UPDATE`, `DELETE` and `CREATE` statements
39
+ continue to hit the original table while doing this, we add tiggers to capture
40
+ these changes into a journal table.
41
+
42
+ At the end of the copying process, the journal table is replayed so that none
43
+ of these intervening mutations are lost.
44
+
45
+ The Large Hadron is a test driven Ruby solution which can easily be dropped
46
+ into an ActiveRecord migration. It presumes a single auto incremented
47
+ numerical primary key called id as per the Rails convention. Unlike the
48
+ twitter solution [1], it does not require the presence of an indexed
49
+ `updated_at` column.
50
+
51
+ ## Usage
52
+
53
+ Large Hadron Migration is currently implemented as a Rails ActiveRecord
54
+ Migration.
55
+
56
+ class AddIndexToEmails < LargeHadronMigration
57
+ def self.up
58
+ large_hadron_migrate :emails, :wait => 0.2 do |table_name|
59
+ execute %Q{
60
+ alter table %s
61
+ add index index_emails_on_hashed_address (hashed_address)
62
+ } % table_name
63
+ end
64
+ end
65
+ end
66
+
67
+ ## Migration phases
68
+
69
+ LHM runs through the following phases during a migration.
70
+
71
+ ### Get the maximum primary key value for the table
72
+
73
+ When starting the migration, we remember the last insert id on the original
74
+ table. When the original table is copied into the new table, we stop at this
75
+ id. The rest of the records will be found in the journal table - see below.
76
+
77
+ ### Create new table and journal table
78
+
79
+ The two tables are cloned using `SHOW CREATE TABLE`. The journal table has an
80
+ extra action field (update, delete, insert).
81
+
82
+ ### Activate journalling with triggers
83
+
84
+ Triggers are created for each of the action types 'create', 'update' and
85
+ 'delete'. Triggers are responsible for filling the journal table.
86
+
87
+ Because the journal table has the same primary key as the original table,
88
+ there can only ever be one version of the record in the journal table.
89
+
90
+ If the journalling trigger hits an already persisted record, it will be
91
+ replaced with the latest data and action. `ON DUPLICATE KEY` comes in handy
92
+ here. This insures that all journal records will be consistent with the
93
+ original table.
94
+
95
+ ### Perform alter statement on new table
96
+
97
+ The user supplied `ALTER TABLE` statement(s) or index changes are applied to the
98
+ new table. Our tests using InnodDB showed this to be faster than adding the
99
+ indexes at the end of the copying process.
100
+
101
+ ### Copy in chunks up to max primary key value to new table
102
+
103
+ Currently InnoDB aquires a read lock on the source rows in `INSERT INTO...
104
+ SELECT`. LHM reads 35K ranges and pauses for a specified number of milliseconds
105
+ so that contention can be minimized.
106
+
107
+ ### Switch new and original table names and remove triggers
108
+
109
+ The orignal and copy table are now atomically switched with `RENAME TABLE
110
+ original TO archive_original, copy_table TO original`. The triggers are removed
111
+ so that journalling stops and all mutations and reads now go against the
112
+ original table.
113
+
114
+ ### Replay journal: insert, update, deletes
115
+
116
+ Because the chunked copy stops at the intial maximum id, we can simply replay
117
+ all inserts in the journal table without worrying about collisions.
118
+
119
+ Updates and deletes are then replayed.
120
+
121
+ ## Potential issues
122
+
123
+ Locks could be avoided during the copy phase by loading records into an
124
+ outfile and then reading them back into the copy table. The facebook solution
125
+ does this and reads in 500000 rows and is faster for this reason. We plan to
126
+ add this optimization to LHM.
127
+
128
+ Data is eventually consistent while replaying the journal, so there may be
129
+ delays while the journal is replayed. The journal is replayed in a single
130
+ pass, so this will be quite short compared to the copy phase. The
131
+ inconsistency during replay is similar in effect to a slave which is slightly
132
+ behind master.
133
+
134
+ There is also caveat with the current journalling scheme; stale journal
135
+ 'update' entries are still replayed. Imagine an update to the a record in the
136
+ migrated table while the journal is replaying. The journal may already contain
137
+ an update for this record, which becomes stale now. When it is replayed, the
138
+ second change will be lost. So if a record is updated twice, once before and
139
+ once during the replay window, the second update will be lost.
140
+
141
+ There are several ways this edge case could be resolved. One way would be to
142
+ add an UPDATE trigger to the main table, and delete corresponding records from
143
+ the journal while replaying. This would ensure that the journal does not
144
+ contain stale update entries.
145
+
146
+ ## Near disaster at the collider
147
+
148
+ Having scratched our itch, we went ahead and got ready to roll schema and
149
+ index changes that would impact hundreds of millions of records across many
150
+ tables. There was a backlog of changes we rolled out in one go.
151
+
152
+ At the time, our MySQL slaves were regularly struggling with their replication
153
+ thread. They were often far behind master. Some of the changes were designed
154
+ to relieve this situation. Because of the slave lag, we configured the LHM to
155
+ add a bit more wait time between chunks, which made the total migration time
156
+ quite long. After running some rehersals, we agreed on the settings and rolled
157
+ out to live, expecting 5 - 7 hours to complete the migrations.
158
+
159
+ ![LHC](http://farm2.static.flickr.com/1391/958035425_abb70e79b1.jpg)
160
+
161
+ Several hours into the migration, a critical fix had to be deployed to the
162
+ site. We rolled out the fix and restarted the app servers in mid migration.
163
+ This was not a good idea.
164
+
165
+ TL;DR: Never restart during migrations when removing columns with large
166
+ hadron. You can restart while adding migrations as long as active record reads
167
+ column definitions from the slave.
168
+
169
+ The information below is only relevant if you want to restart your app servers
170
+ while migrating in a master slave setup.
171
+
172
+ ### When adding a column
173
+
174
+ 1. Migration running on master; no effect, nothing has changed.
175
+ 2. Tables switched on master. slave out of sync, migration running.
176
+ a. Given the app server reads columns from slave on restart, nothing
177
+ happens.
178
+ b. Given the app server reads columns from master on restart, bad
179
+ shitz happen, ie: queries are built with new columns, ie on :include
180
+ the explicit column list will be built (rather than *) for the
181
+ included table. Since it does not exist on the slave, queries will
182
+ break here.
183
+
184
+ 3. Tables switched on slave
185
+ - Same as 2b. Just do a cap deploy instead of cap deploy:restart.
186
+
187
+ ### When removing a column
188
+
189
+ 1. Migration running on master; no effect, nothing has changed.
190
+ 2. Tables switched on master. slave out of sync, migration running.
191
+ a. Given the app server reads columns from slave on restart
192
+ - Writes against master will fail due to the additional column.
193
+ - Reads will succeed against slaves, but not master.
194
+
195
+ b. Given the app server reads columns from master on restart:
196
+ - Writes against master might succeed. Old code referencing
197
+ removed columns will fail.
198
+ - Reads might or might not succeed, for the same reason.
199
+
200
+ ## Todos
201
+
202
+ Load data into outfile instead of `INSERT INTO... SELECT`. Avoid contention and
203
+ increase speed.
204
+
205
+ Handle invalidation of 'update' entries in journal while replaying. Avoid
206
+ stale update replays.
207
+
208
+ Some other optimizations:
209
+
210
+ Deletions create gaps in the primary key id integer column. LHM has no
211
+ problems with this, but the chunked copy could be sped up by factoring this
212
+ in. Currently a copy range may be completely empty, but there will still be
213
+ a `INSERT INTO... SELECT`.
214
+
215
+ Records inserted after the last insert id is retrieved and before the triggers
216
+ are created are currently lost. The table should be briefly locked while id is
217
+ read and triggers are applied.
218
+
219
+ ## Contributing
220
+
221
+ We'll check out your contribution if you:
222
+
223
+ - Provide a comprehensive suite of tests for your fork.
224
+ - Have a clear and documented rationale for your changes.
225
+ - Package these up in a pull request.
226
+
227
+ We'll do our best to help you out with any contribution issues you may have.
228
+
229
+ ## License
230
+
231
+ The license is included as LICENSE in this directory.
232
+
233
+ ## Footnotes
234
+
235
+ [0]: http://www.facebook.com/note.php?note\_id=430801045932 "Facebook"
236
+ [1]: https://github.com/freels/table\_migrator "Twitter"
237
+ [2]: http://openarkkit.googlecode.com "OAK online alter table"
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
3
+
4
+ require 'spec/rake/spectask'
5
+ Spec::Rake::SpecTask.new
data/VERSION ADDED
@@ -0,0 +1 @@
1
+ 0.1.2
@@ -0,0 +1,24 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "large-hadron-migrator"
5
+ s.version = File.read("VERSION").to_s
6
+ s.platform = Gem::Platform::RUBY
7
+ s.authors = ["SoundCloud", "Rany Keddo", "Tobias Bielohlawek"]
8
+ s.email = %q{rany@soundcloud.com, tobi@soundcloud.com}
9
+ s.summary = %q{online schema changer for mysql}
10
+ 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.}
11
+ s.homepage = %q{http://github.com/soundcloud/large-hadron-migrator}
12
+ s.files = `git ls-files`.split("\n")
13
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
15
+ s.require_paths = ["lib"]
16
+
17
+ ['activerecord ~>2.3.8', 'activesupport ~>2.3.8', 'mysql =2.8.1'].each do |gem|
18
+ s.add_dependency *gem.split(' ')
19
+ end
20
+
21
+ ['rspec =1.3.1'].each do |gem|
22
+ s.add_development_dependency *gem.split(' ')
23
+ end
24
+ end
@@ -0,0 +1,379 @@
1
+ require 'benchmark'
2
+
3
+ #
4
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek
5
+ #
6
+ # Migrate large tables without downtime by copying to a temporary table in
7
+ # chunks. The old table is not dropped. Instead, it is moved to
8
+ # timestamp_table_name for verification.
9
+ #
10
+ # WARNING:
11
+ # - this is an unlocked online operation. updates will probably become
12
+ # inconsistent during migration.
13
+ # - may cause the universe to implode.
14
+ #
15
+ # USAGE:
16
+ #
17
+ # class AddIndexToEmails < LargeHadronMigration
18
+ # def self.up
19
+ # large_hadron_migrate :emails, :wait => 0.2 do |table_name|
20
+ # execute %Q{
21
+ # alter table %s
22
+ # add index index_emails_on_hashed_address (hashed_address)
23
+ # } % table_name
24
+ # end
25
+ # end
26
+ # end
27
+ #
28
+ # How to deploy large hadrons with capistrano
29
+ # -------------------------------------------
30
+ #
31
+ # 1. Run cap deploy:update_code. The new release directory is not symlinked,
32
+ # so that restarts will not load the new code.
33
+ #
34
+ # 2. Run rake db:migrate from the new release directory on an appserver,
35
+ # preferably in a screen session.
36
+ #
37
+ # 3. Wait for migrations to sync to all slaves then cap deploy.
38
+ #
39
+ # Restarting before step 2 is done
40
+ # --------------------------------
41
+ #
42
+ # - When adding a column
43
+ #
44
+ # 1. Migration running on master; no effect, nothing has changed.
45
+ # 2. Tables switched on master. slave out of sync, migration running.
46
+ # a. Given the app server reads columns from slave on restart, nothing
47
+ # happens.
48
+ # b. Given the app server reads columns from master on restart, bad
49
+ # shitz happen, ie: queries are built with new columns, ie on :include
50
+ # the explicit column list will be built (rather than *) for the
51
+ # included table. Since it does not exist on the slave, queries will
52
+ # break here.
53
+ #
54
+ # 3. Tables switched on slave
55
+ # - Same as 2b. Just do a cap deploy instead of cap deploy:restart.
56
+ #
57
+ # - When removing a column
58
+ #
59
+ # 1. Migration running on master; no effect, nothing has changed.
60
+ # 2. Tables switched on master. slave out of sync, migration running.
61
+ # a. Given the app server reads columns from slave on restart
62
+ # - Writes against master will fail due to the additional column.
63
+ # - Reads will succeed against slaves, but not master.
64
+ #
65
+ # b. Given the app server reads columns from master on restart:
66
+ # - Writes against master might succeed. Old code referencing
67
+ # removed columns will fail.
68
+ # - Reads might or might not succeed, for the same reason.
69
+ #
70
+ # tl;dr: Never restart during migrations when removing columns with large
71
+ # hadron. You can restart while adding migrations as long as active record
72
+ # reads column definitions from the slave.
73
+ #
74
+ # Pushing out hotfixes while migrating in step 2
75
+ # ----------------------------------------------
76
+ #
77
+ # - Check out the currently running (old) code ref.
78
+ # - Branch from this, make your changes, push it up
79
+ # - Deploy this version.
80
+ #
81
+ # Deploying the new version will hurt your head. Don't do it.
82
+ #
83
+ class LargeHadronMigration < ActiveRecord::Migration
84
+
85
+ # id_window must be larger than the number of inserts
86
+ # added to the journal table. if this is not the case,
87
+ # inserts will be lost in the replay phase.
88
+ def self.large_hadron_migrate(curr_table, *args, &block)
89
+ opts = args.extract_options!.reverse_merge :wait => 0.5,
90
+ :chunk_size => 35_000,
91
+ :id_window => 11_000
92
+
93
+ curr_table = curr_table.to_s
94
+ chunk_size = opts[:chunk_size].to_i
95
+
96
+ # we are in dev/test mode - so speed it up
97
+ chunk_size = 10_000_000.to_i if Rails.env.development? or Rails.env.test?
98
+ wait = opts[:wait].to_f
99
+ id_window = opts[:id_window]
100
+
101
+ raise "chunk_size must be >= 1" unless chunk_size >= 1
102
+
103
+ new_table = "new_#{curr_table}"
104
+ old_table = "%s_#{curr_table}" % Time.now.strftime("%Y_%m_%d_%H_%M_%S_%3N")
105
+ journal_table = "#{old_table}_changes"
106
+
107
+ last_insert_id = last_insert_id(curr_table)
108
+ say "last inserted id in #{curr_table}: #{last_insert_id}"
109
+
110
+ begin
111
+ # clean tables. old tables are never deleted to guard against rollbacks.
112
+ execute %Q/drop table if exists %s/ % new_table
113
+
114
+ clone_table(curr_table, new_table, id_window)
115
+ clone_table_for_changes(curr_table, journal_table)
116
+
117
+ # add triggers
118
+ add_trigger_on_action(curr_table, journal_table, "insert")
119
+ add_trigger_on_action(curr_table, journal_table, "update")
120
+ add_trigger_on_action(curr_table, journal_table, "delete")
121
+
122
+ # alter new table
123
+ default_values = {}
124
+ yield new_table, default_values
125
+
126
+ insertion_columns = prepare_insertion_columns(new_table, curr_table, default_values)
127
+ raise "insertion_columns empty" if insertion_columns.empty?
128
+
129
+ chunked_insert \
130
+ last_insert_id,
131
+ chunk_size,
132
+ new_table,
133
+ insertion_columns,
134
+ curr_table,
135
+ wait
136
+
137
+ rename_tables curr_table => old_table, new_table => curr_table
138
+ cleanup(curr_table)
139
+
140
+ # replay changes from the changes jornal
141
+ replay_insert_changes(curr_table, journal_table, chunk_size, wait)
142
+ replay_update_changes(curr_table, journal_table, chunk_size, wait)
143
+ replay_delete_changes(curr_table, journal_table)
144
+
145
+ old_table
146
+ ensure
147
+ cleanup(curr_table)
148
+ end
149
+ end
150
+
151
+ def self.prepare_insertion_columns(new_table, table, default_values = {})
152
+ {}.tap do |columns|
153
+ (common_columns(new_table, table) | default_values.keys).each do |column|
154
+ columns[tick(column)] = default_values[column] || tick(column)
155
+ end
156
+ end
157
+ end
158
+
159
+ def self.chunked_insert(last_insert_id, chunk_size, new_table, insertion_columns, curr_table, wait, where = "")
160
+ # do the inserts in chunks. helps to reduce io contention and keeps the
161
+ # undo log small.
162
+ chunks = (last_insert_id / chunk_size.to_f).ceil
163
+ times = []
164
+ (1..chunks).each do |chunk|
165
+
166
+ times << Benchmark.measure do
167
+ execute "start transaction"
168
+
169
+ execute %Q{
170
+ insert into %s
171
+ (%s)
172
+ select %s
173
+ from %s
174
+ where (id between %d and %d) %s
175
+
176
+ } % [
177
+ new_table,
178
+ insertion_columns.keys.join(","),
179
+ insertion_columns.values.join(","),
180
+ curr_table,
181
+ ((chunk - 1) * chunk_size) + 1,
182
+ [chunk * chunk_size, last_insert_id].min,
183
+ where
184
+ ]
185
+ execute "COMMIT"
186
+ end
187
+
188
+ say_remaining_estimate(times, chunks, chunk, wait)
189
+
190
+ # larger values trade greater inconsistency for less io
191
+ sleep wait
192
+ end
193
+ end
194
+
195
+ def self.chunked_update(last_insert_id, chunk_size, new_table, insertion_columns, curr_table, wait, where = "")
196
+ # do the inserts in chunks. helps to reduce io contention and keeps the
197
+ # undo log small.
198
+ chunks = (last_insert_id / chunk_size.to_f).ceil
199
+ times = []
200
+ (1..chunks).each do |chunk|
201
+
202
+ times << Benchmark.measure do
203
+ execute "start transaction"
204
+
205
+ execute %Q{
206
+ update %s as t1
207
+ join %s as t2 on t1.id = t2.id
208
+ set %s
209
+ where (t2.id between %d and %d) %s
210
+ } % [
211
+ new_table,
212
+ curr_table,
213
+ insertion_columns.keys.map { |keys| "t1.#{keys} = t2.#{keys}"}.join(","),
214
+ ((chunk - 1) * chunk_size) + 1,
215
+ [chunk * chunk_size, last_insert_id].min,
216
+ where
217
+ ]
218
+ execute "COMMIT"
219
+ end
220
+
221
+ say_remaining_estimate(times, chunks, chunk, wait)
222
+
223
+ # larger values trade greater inconsistency for less io
224
+ sleep wait
225
+ end
226
+ end
227
+
228
+ def self.last_insert_id(curr_table)
229
+ with_master do
230
+ connection.select_value("select max(id) from %s" % curr_table).to_i
231
+ end
232
+ end
233
+
234
+ def self.table_column_names(table_name)
235
+ with_master do
236
+ connection.select_values %Q{
237
+ select column_name
238
+ from information_schema.columns
239
+ where table_name = "%s"
240
+ and table_schema = "%s"
241
+
242
+ } % [table_name, connection.current_database]
243
+ end
244
+ end
245
+
246
+ def self.with_master
247
+ if ActiveRecord::Base.respond_to? :with_master
248
+ ActiveRecord::Base.with_master do
249
+ yield
250
+ end
251
+ else
252
+ yield
253
+ end
254
+ end
255
+
256
+ def self.clone_table(source, dest, window = 0)
257
+ execute schema_sql(source, dest, window)
258
+ end
259
+
260
+ def self.common_columns(t1, t2)
261
+ table_column_names(t1) & table_column_names(t2)
262
+ end
263
+
264
+ def self.clone_table_for_changes(table, journal_table)
265
+ clone_table(table, journal_table)
266
+ execute %Q{
267
+ alter table %s
268
+ add column hadron_action varchar(15);
269
+ } % journal_table
270
+ end
271
+
272
+ def self.rename_tables(tables = {})
273
+ execute "rename table %s" % tables.map{ |old_table, new_table| "#{old_table} to #{new_table}" }.join(', ')
274
+ end
275
+
276
+ def self.add_trigger_on_action(table, journal_table, action)
277
+ columns = table_column_names(table)
278
+ table_alias = (action == 'delete') ? 'OLD' : 'NEW'
279
+ fallback = (action == 'delete') ? "`hadron_action` = 'delete'" : columns.map { |c| "#{tick(c)} = #{table_alias}.#{tick(c)}" }.join(",")
280
+
281
+ execute %Q{
282
+ create trigger %s
283
+ after #{action} on %s for each row
284
+ begin
285
+ insert into %s (%s, `hadron_action`)
286
+ values (%s, '#{ action }')
287
+ ON DUPLICATE KEY UPDATE %s;
288
+ end
289
+ } % [trigger_name(action, table),
290
+ table,
291
+ journal_table,
292
+ columns.map { |c| tick(c) }.join(","),
293
+ columns.map { |c| "#{table_alias}.#{tick(c)}" }.join(","),
294
+ fallback
295
+ ]
296
+ end
297
+
298
+ def self.delete_trigger_on_action(table, action)
299
+ execute "drop trigger if exists %s" % trigger_name(action, table)
300
+ end
301
+
302
+ def self.trigger_name(action, table)
303
+ tick("after_#{action}_#{table}")
304
+ end
305
+
306
+ def self.cleanup(table)
307
+ delete_trigger_on_action(table, "insert")
308
+ delete_trigger_on_action(table, "update")
309
+ delete_trigger_on_action(table, "delete")
310
+ end
311
+
312
+ def self.say_remaining_estimate(times, chunks, chunk, wait)
313
+ avg = times.inject(0) { |s, t| s += t.real } / times.size.to_f
314
+ remaining = chunks - chunk
315
+ say "%d more chunks to go, estimated end: %s" % [
316
+ remaining,
317
+ Time.now + (remaining * (avg + wait))
318
+ ]
319
+ end
320
+
321
+ def self.replay_insert_changes(table, journal_table, chunk_size = 10000, wait = 0.2)
322
+ last_insert_id = last_insert_id(journal_table)
323
+ columns = prepare_insertion_columns(table, journal_table)
324
+
325
+ chunked_insert \
326
+ last_insert_id,
327
+ chunk_size,
328
+ table,
329
+ columns,
330
+ journal_table,
331
+ wait,
332
+ "AND hadron_action = 'insert'"
333
+ end
334
+
335
+ def self.replay_delete_changes(table, journal_table)
336
+ execute %Q{
337
+ delete from #{table} where id in (
338
+ select id from #{journal_table} where hadron_action = 'delete'
339
+ )
340
+ }
341
+ end
342
+
343
+ def self.replay_update_changes(table, journal_table, chunk_size = 10000, wait = 0.2)
344
+ last_insert_id = last_insert_id(journal_table)
345
+ columns = prepare_insertion_columns(table, journal_table)
346
+
347
+ chunked_update \
348
+ last_insert_id,
349
+ chunk_size,
350
+ table,
351
+ columns,
352
+ journal_table,
353
+ wait,
354
+ "AND hadron_action = 'update'"
355
+ end
356
+
357
+ #
358
+ # use show create instead of create table like. there was some weird
359
+ # behavior with the latter where the auto_increment of the source table
360
+ # got modified when updating the destination.
361
+ #
362
+ def self.schema_sql(source, dest, window)
363
+ show_create(source).tap do |schema|
364
+ schema.gsub!(/auto_increment=(\d+)/i) do
365
+ "auto_increment=#{ $1.to_i + window }"
366
+ end
367
+
368
+ schema.gsub!('CREATE TABLE `%s`' % source, 'CREATE TABLE `%s`' % dest)
369
+ end
370
+ end
371
+
372
+ def self.show_create(t1)
373
+ (execute "show create table %s" % t1).fetch_row.last
374
+ end
375
+
376
+ def self.tick(col)
377
+ "`#{ col }`"
378
+ end
379
+ end
@@ -0,0 +1,370 @@
1
+ #
2
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek
3
+ #
4
+
5
+ require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
6
+
7
+ require "migrate/add_new_column"
8
+
9
+ describe "LargeHadronMigration", "integration" do
10
+ include SpecHelper
11
+
12
+ before(:each) { recreate }
13
+
14
+ it "should add new column" do
15
+
16
+ table("addscolumn") do |t|
17
+ t.string :title
18
+ t.integer :rating
19
+ t.timestamps
20
+ end
21
+
22
+ truthiness_column "addscolumn", "title", "varchar"
23
+ truthiness_column "addscolumn", "rating", "int"
24
+ truthiness_column "addscolumn", "created_at", "datetime"
25
+ truthiness_column "addscolumn", "updated_at", "datetime"
26
+
27
+ ghost = AddNewColumn.up
28
+
29
+ truthiness_column "addscolumn", "title", "varchar"
30
+ truthiness_column "addscolumn", "rating", "int"
31
+ truthiness_column "addscolumn", "spam", "tinyint"
32
+ truthiness_column "addscolumn", "created_at", "datetime"
33
+ truthiness_column "addscolumn", "updated_at", "datetime"
34
+ end
35
+
36
+ it "should have same row data" do
37
+ table "addscolumn" do |t|
38
+ t.string :text
39
+ t.integer :number
40
+ t.timestamps
41
+ end
42
+
43
+ 1200.times do |i|
44
+ random_string = (0...rand(25)).map{65.+(rand(25)).chr}.join
45
+ sql "INSERT INTO `addscolumn` SET
46
+ `id` = #{i+1},
47
+ `text` = '#{random_string}',
48
+ `number` = '#{rand(255)}',
49
+ `updated_at` = NOW(),
50
+ `created_at` = NOW()"
51
+ end
52
+
53
+ ghost = AddNewColumn.up
54
+
55
+ truthiness_rows "addscolumn", ghost
56
+ end
57
+ end
58
+
59
+
60
+ describe "LargeHadronMigration", "rename" do
61
+ include SpecHelper
62
+
63
+ before(:each) do
64
+ recreate
65
+ end
66
+
67
+ it "should rename multiple tables" do
68
+ table "renameme" do |t|
69
+ t.string :text
70
+ end
71
+
72
+ table "renamemetoo" do |t|
73
+ t.integer :number
74
+ end
75
+
76
+ LargeHadronMigration.rename_tables("renameme" => "renameme_new", "renamemetoo" => "renameme")
77
+
78
+ truthiness_column "renameme", "number", "int"
79
+ truthiness_column "renameme_new", "text", "varchar"
80
+ end
81
+
82
+ end
83
+
84
+ describe "LargeHadronMigration", "triggers" do
85
+ include SpecHelper
86
+
87
+ before(:each) do
88
+ recreate
89
+
90
+ table "triggerme" do |t|
91
+ t.string :text
92
+ t.integer :number
93
+ t.timestamps
94
+ end
95
+
96
+ LargeHadronMigration.clone_table_for_changes \
97
+ "triggerme",
98
+ "triggerme_changes"
99
+ end
100
+
101
+ it "should create a table for triggered changes" do
102
+ truthiness_column "triggerme_changes", "hadron_action", "varchar"
103
+ end
104
+
105
+ it "should trigger on insert" do
106
+ LargeHadronMigration.add_trigger_on_action \
107
+ "triggerme",
108
+ "triggerme_changes",
109
+ "insert"
110
+
111
+ # test
112
+ sql("insert into triggerme values (111, 'hallo', 5, NOW(), NOW())")
113
+ sql("select * from triggerme_changes where id = 111").tap do |res|
114
+ res.fetch_hash.tap do |row|
115
+ row['hadron_action'].should == 'insert'
116
+ row['text'].should == 'hallo'
117
+ end
118
+ end
119
+ end
120
+
121
+ it "should trigger on update" do
122
+
123
+ # setup
124
+ sql "insert into triggerme values (111, 'hallo', 5, NOW(), NOW())"
125
+ LargeHadronMigration.add_trigger_on_action \
126
+ "triggerme",
127
+ "triggerme_changes",
128
+ "update"
129
+
130
+ # test
131
+ sql("update triggerme set text = 'goodbye' where id = '111'")
132
+ sql("select * from triggerme_changes where id = 111").tap do |res|
133
+ res.fetch_hash.tap do |row|
134
+ row['hadron_action'].should == 'update'
135
+ row['text'].should == 'goodbye'
136
+ end
137
+ end
138
+ end
139
+
140
+ it "should trigger on delete" do
141
+
142
+ # setup
143
+ sql "insert into triggerme values (111, 'hallo', 5, NOW(), NOW())"
144
+ LargeHadronMigration.add_trigger_on_action \
145
+ "triggerme",
146
+ "triggerme_changes",
147
+ "delete"
148
+
149
+ # test
150
+ sql("delete from triggerme where id = '111'")
151
+ sql("select * from triggerme_changes where id = 111").tap do |res|
152
+ res.fetch_hash.tap do |row|
153
+ row['hadron_action'].should == 'delete'
154
+ row['text'].should == 'hallo'
155
+ end
156
+ end
157
+ end
158
+
159
+ it "should trigger on create and update" do
160
+ LargeHadronMigration.add_trigger_on_action \
161
+ "triggerme",
162
+ "triggerme_changes",
163
+ "insert"
164
+
165
+ LargeHadronMigration.add_trigger_on_action \
166
+ "triggerme",
167
+ "triggerme_changes",
168
+ "update"
169
+
170
+ # test
171
+ sql "insert into triggerme values (111, 'hallo', 5, NOW(), NOW())"
172
+ sql("update triggerme set text = 'goodbye' where id = '111'")
173
+
174
+ sql("select count(*) AS cnt from triggerme_changes where id = 111").tap do |res|
175
+ res.fetch_hash.tap do |row|
176
+ row['cnt'].should == '1'
177
+ end
178
+ end
179
+ end
180
+
181
+ it "should trigger on multiple update" do
182
+ sql "insert into triggerme values (111, 'hallo', 5, NOW(), NOW())"
183
+ LargeHadronMigration.add_trigger_on_action \
184
+ "triggerme",
185
+ "triggerme_changes",
186
+ "update"
187
+
188
+ # test
189
+ sql("update triggerme set text = 'goodbye' where id = '111'")
190
+ sql("update triggerme set text = 'hallo again' where id = '111'")
191
+
192
+ sql("select count(*) AS cnt from triggerme_changes where id = 111").tap do |res|
193
+ res.fetch_hash.tap do |row|
194
+ row['cnt'].should == '1'
195
+ end
196
+ end
197
+ end
198
+
199
+ it "should trigger on inser, update and delete" do
200
+ LargeHadronMigration.add_trigger_on_action \
201
+ "triggerme",
202
+ "triggerme_changes",
203
+ "insert"
204
+
205
+ LargeHadronMigration.add_trigger_on_action \
206
+ "triggerme",
207
+ "triggerme_changes",
208
+ "update"
209
+
210
+ LargeHadronMigration.add_trigger_on_action \
211
+ "triggerme",
212
+ "triggerme_changes",
213
+ "delete"
214
+
215
+ # test
216
+ sql "insert into triggerme values (111, 'hallo', 5, NOW(), NOW())"
217
+ sql("update triggerme set text = 'goodbye' where id = '111'")
218
+ sql("delete from triggerme where id = '111'")
219
+
220
+ sql("select count(*) AS cnt from triggerme_changes where id = 111").tap do |res|
221
+ res.fetch_hash.tap do |row|
222
+ row['cnt'].should == '1'
223
+ end
224
+ end
225
+ end
226
+
227
+ it "should cleanup triggers" do
228
+ %w(insert update delete).each do |action|
229
+ LargeHadronMigration.add_trigger_on_action \
230
+ "triggerme",
231
+ "triggerme_changes",
232
+ action
233
+ end
234
+
235
+ LargeHadronMigration.cleanup "triggerme"
236
+
237
+ # test
238
+ sql("insert into triggerme values (111, 'hallo', 5, NOW(), NOW())")
239
+ sql("update triggerme set text = 'goodbye' where id = '111'")
240
+ sql("delete from triggerme where id = '111'")
241
+
242
+ sql("select count(*) AS cnt from triggerme_changes where id = 111").tap do |res|
243
+ res.fetch_hash.tap do |row|
244
+ row['cnt'].should == '0'
245
+ end
246
+ end
247
+ end
248
+
249
+ end
250
+
251
+ describe "LargeHadronMigration", "replaying changes" do
252
+ include SpecHelper
253
+
254
+ before(:each) do
255
+ recreate
256
+
257
+ table "source" do |t|
258
+ t.string :text
259
+ t.integer :number
260
+ t.timestamps
261
+ end
262
+
263
+ table "source_changes" do |t|
264
+ t.string :text
265
+ t.integer :number
266
+ t.string :hadron_action
267
+ t.timestamps
268
+ end
269
+ end
270
+
271
+ it "should replay inserts" do
272
+ sql %Q{
273
+ insert into source (id, text, number, created_at, updated_at)
274
+ values (1, 'hallo', 5, NOW(), NOW())
275
+ }
276
+
277
+ sql %Q{
278
+ insert into source_changes (id, text, number, created_at, updated_at, hadron_action)
279
+ values (2, 'goodbye', 5, NOW(), NOW(), 'insert')
280
+ }
281
+
282
+ sql %Q{
283
+ insert into source_changes (id, text, number, created_at, updated_at, hadron_action)
284
+ values (3, 'goodbye', 5, NOW(), NOW(), 'delete')
285
+ }
286
+
287
+ LargeHadronMigration.replay_insert_changes("source", "source_changes")
288
+
289
+ sql("select * from source where id = 2").tap do |res|
290
+ res.fetch_hash.tap do |row|
291
+ row['text'].should == 'goodbye'
292
+ end
293
+ end
294
+
295
+ sql("select count(*) as cnt from source where id = 3").tap do |res|
296
+ res.fetch_hash.tap do |row|
297
+ row['cnt'].should == '0'
298
+ end
299
+ end
300
+ end
301
+
302
+
303
+ it "should replay updates" do
304
+ sql %Q{
305
+ insert into source (id, text, number, created_at, updated_at)
306
+ values (1, 'hallo', 5, NOW(), NOW())
307
+ }
308
+
309
+ sql %Q{
310
+ insert into source_changes (id, text, number, created_at, updated_at, hadron_action)
311
+ values (1, 'goodbye', 5, NOW(), NOW(), 'update')
312
+ }
313
+
314
+ LargeHadronMigration.replay_update_changes("source", "source_changes")
315
+
316
+ sql("select * from source where id = 1").tap do |res|
317
+ res.fetch_hash.tap do |row|
318
+ row['text'].should == 'goodbye'
319
+ end
320
+ end
321
+ end
322
+
323
+ it "should replay deletes" do
324
+ sql %Q{
325
+ insert into source (id, text, number, created_at, updated_at)
326
+ values (1, 'hallo', 5, NOW(), NOW()),
327
+ (2, 'schmu', 5, NOW(), NOW())
328
+ }
329
+
330
+ sql %Q{
331
+ insert into source_changes (id, text, number, created_at, updated_at, hadron_action)
332
+ values (1, 'goodbye', 5, NOW(), NOW(), 'delete')
333
+ }
334
+
335
+ LargeHadronMigration.replay_delete_changes("source", "source_changes")
336
+
337
+ sql("select count(*) as cnt from source").tap do |res|
338
+ res.fetch_hash.tap do |row|
339
+ row['cnt'].should == '1'
340
+ end
341
+ end
342
+ end
343
+
344
+ end
345
+
346
+ describe "LargeHadronMigration", "units" do
347
+ include SpecHelper
348
+
349
+ it "should return correct schema" do
350
+ recreate
351
+ table "source" do |t|
352
+ t.string :text
353
+ t.integer :number
354
+ t.timestamps
355
+ end
356
+
357
+ sql %Q{
358
+ insert into source (id, text, number, created_at, updated_at)
359
+ values (1, 'hallo', 5, NOW(), NOW()),
360
+ (2, 'schmu', 5, NOW(), NOW())
361
+ }
362
+
363
+ schema = LargeHadronMigration.schema_sql("source", "source_changes", 1000)
364
+
365
+ schema.should_not include('`source`')
366
+ schema.should include('`source_changes`')
367
+ schema.should include('1003')
368
+ end
369
+ end
370
+
@@ -0,0 +1,13 @@
1
+ #
2
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek
3
+ #
4
+
5
+ class AddNewColumn < LargeHadronMigration
6
+ def self.up
7
+ large_hadron_migrate "addscolumn", :chunk_size => 100 do |table_name|
8
+ execute %Q{
9
+ alter table %s add column spam tinyint(1)
10
+ } % table_name
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,114 @@
1
+ #
2
+ # Copyright (c) 2011, SoundCloud Ltd., Rany Keddo, Tobias Bielohlawek
3
+ #
4
+
5
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
6
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
7
+
8
+ require 'active_record'
9
+ require 'large_hadron_migration'
10
+ require 'spec'
11
+ require 'spec/autorun'
12
+
13
+ ActiveRecord::Base.establish_connection(
14
+ :adapter => 'mysql',
15
+ :database => 'large_hadron_migration',
16
+ :username => 'root',
17
+ :password => '',
18
+ :host => 'localhost'
19
+ )
20
+
21
+ module SpecHelper
22
+ def connection
23
+ ActiveRecord::Base.connection
24
+ end
25
+
26
+ def sql(args)
27
+ connection.execute(args)
28
+ end
29
+
30
+ def recreate
31
+ sql "drop database large_hadron_migration"
32
+ sql "create database large_hadron_migration character set = 'UTF8'"
33
+
34
+ ActiveRecord::Base.connection.reconnect!
35
+ end
36
+
37
+ def flunk(msg)
38
+ raise Spec::Expectations::ExpectationNotMetError.new(msg)
39
+ end
40
+
41
+ def table(name)
42
+ ActiveRecord::Schema.define do
43
+ create_table(name) do |t|
44
+ yield t
45
+ end
46
+ end
47
+
48
+ name
49
+ end
50
+
51
+ #
52
+ # can't be arsed with rspec matchers
53
+ #
54
+
55
+ def truthiness_column(table, name, type)
56
+ results = connection.select_values %Q{
57
+ select column_name
58
+ from information_schema.columns
59
+ where table_name = "%s"
60
+ and table_schema = "%s"
61
+ and column_name = "%s"
62
+ and data_type = "%s"
63
+ } % [table, connection.current_database, name, type]
64
+
65
+ if results.empty?
66
+ flunk "truthiness column not defined as: %s:%s:%s" % [
67
+ table,
68
+ name,
69
+ type
70
+ ]
71
+ end
72
+ end
73
+
74
+ def truthiness_rows(table_name1, table_name2, offset = 0, limit = 1000)
75
+ res_1 = sql("SELECT * FROM #{table_name1} ORDER BY id ASC LIMIT #{limit} OFFSET #{offset}")
76
+ res_2 = sql("SELECT * FROM #{table_name2} ORDER BY id ASC LIMIT #{limit} OFFSET #{offset}")
77
+
78
+ limit.times do |i|
79
+ res_1_hash = res_1.fetch_hash
80
+ res_2_hash = res_2.fetch_hash
81
+
82
+ if res_1_hash.nil? || res_2_hash.nil?
83
+ flunk("truthiness rows failed: Expected #{limit} rows, but only #{i} found")
84
+ end
85
+
86
+ res_1_hash.keys.each do |key|
87
+ flunk("truthiness rows failed: #{key} is not same") unless res_1_hash[key] == res_2_hash[key]
88
+ end
89
+ end
90
+
91
+ end
92
+
93
+ end
94
+
95
+ # Mock Rails Environment
96
+ class Rails
97
+ class << self
98
+ def env
99
+ self
100
+ end
101
+
102
+ def development?
103
+ false
104
+ end
105
+
106
+ def production?
107
+ true
108
+ end
109
+
110
+ def test?
111
+ false
112
+ end
113
+ end
114
+ end
metadata ADDED
@@ -0,0 +1,139 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: large-hadron-migrator
3
+ version: !ruby/object:Gem::Version
4
+ prerelease: false
5
+ segments:
6
+ - 0
7
+ - 1
8
+ - 2
9
+ version: 0.1.2
10
+ platform: ruby
11
+ authors:
12
+ - SoundCloud
13
+ - Rany Keddo
14
+ - Tobias Bielohlawek
15
+ autorequire:
16
+ bindir: bin
17
+ cert_chain: []
18
+
19
+ date: 2011-05-04 00:00:00 +02:00
20
+ default_executable:
21
+ dependencies:
22
+ - !ruby/object:Gem::Dependency
23
+ name: activerecord
24
+ prerelease: false
25
+ requirement: &id001 !ruby/object:Gem::Requirement
26
+ none: false
27
+ requirements:
28
+ - - ~>
29
+ - !ruby/object:Gem::Version
30
+ segments:
31
+ - 2
32
+ - 3
33
+ - 8
34
+ version: 2.3.8
35
+ type: :runtime
36
+ version_requirements: *id001
37
+ - !ruby/object:Gem::Dependency
38
+ name: activesupport
39
+ prerelease: false
40
+ requirement: &id002 !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ~>
44
+ - !ruby/object:Gem::Version
45
+ segments:
46
+ - 2
47
+ - 3
48
+ - 8
49
+ version: 2.3.8
50
+ type: :runtime
51
+ version_requirements: *id002
52
+ - !ruby/object:Gem::Dependency
53
+ name: mysql
54
+ prerelease: false
55
+ requirement: &id003 !ruby/object:Gem::Requirement
56
+ none: false
57
+ requirements:
58
+ - - "="
59
+ - !ruby/object:Gem::Version
60
+ segments:
61
+ - 2
62
+ - 8
63
+ - 1
64
+ version: 2.8.1
65
+ type: :runtime
66
+ version_requirements: *id003
67
+ - !ruby/object:Gem::Dependency
68
+ name: rspec
69
+ prerelease: false
70
+ requirement: &id004 !ruby/object:Gem::Requirement
71
+ none: false
72
+ requirements:
73
+ - - "="
74
+ - !ruby/object:Gem::Version
75
+ segments:
76
+ - 1
77
+ - 3
78
+ - 1
79
+ version: 1.3.1
80
+ type: :development
81
+ version_requirements: *id004
82
+ description: 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.
83
+ email: rany@soundcloud.com, tobi@soundcloud.com
84
+ executables: []
85
+
86
+ extensions: []
87
+
88
+ extra_rdoc_files: []
89
+
90
+ files:
91
+ - .gitignore
92
+ - CHANGES.markdown
93
+ - Gemfile
94
+ - Gemfile.lock
95
+ - LICENSE
96
+ - README.markdown
97
+ - Rakefile
98
+ - VERSION
99
+ - large-hadron-migrator.gemspec
100
+ - lib/large_hadron_migration.rb
101
+ - spec/large_hadron_migration_spec.rb
102
+ - spec/migrate/add_new_column.rb
103
+ - spec/spec_helper.rb
104
+ has_rdoc: true
105
+ homepage: http://github.com/soundcloud/large-hadron-migrator
106
+ licenses: []
107
+
108
+ post_install_message:
109
+ rdoc_options: []
110
+
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ none: false
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ segments:
119
+ - 0
120
+ version: "0"
121
+ required_rubygems_version: !ruby/object:Gem::Requirement
122
+ none: false
123
+ requirements:
124
+ - - ">="
125
+ - !ruby/object:Gem::Version
126
+ segments:
127
+ - 0
128
+ version: "0"
129
+ requirements: []
130
+
131
+ rubyforge_project:
132
+ rubygems_version: 1.3.7
133
+ signing_key:
134
+ specification_version: 3
135
+ summary: online schema changer for mysql
136
+ test_files:
137
+ - spec/large_hadron_migration_spec.rb
138
+ - spec/migrate/add_new_column.rb
139
+ - spec/spec_helper.rb