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 +5 -0
- data/CHANGES.markdown +0 -0
- data/Gemfile +3 -0
- data/LICENSE +10 -0
- data/README.markdown +237 -0
- data/Rakefile +5 -0
- data/VERSION +1 -0
- data/large-hadron-migrator.gemspec +24 -0
- data/lib/large_hadron_migration.rb +379 -0
- data/spec/large_hadron_migration_spec.rb +370 -0
- data/spec/migrate/add_new_column.rb +13 -0
- data/spec/spec_helper.rb +114 -0
- metadata +139 -0
data/CHANGES.markdown
ADDED
File without changes
|
data/Gemfile
ADDED
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
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
|
data/spec/spec_helper.rb
ADDED
@@ -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
|