zd 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.md +220 -0
- data/lib/zd.rb +159 -0
- data/lib/zd/version.rb +3 -0
- metadata +62 -0
data/README.md
ADDED
@@ -0,0 +1,220 @@
|
|
1
|
+
# ZD
|
2
|
+
|
3
|
+
ZD is a zero-downtime data migration framework that sits on top of the data store (or stores) of your choice. It implements zero-downtime by putting your data through a series of states:
|
4
|
+
|
5
|
+
1. **Unrun**:
|
6
|
+
The migration is implemented, but has not been run yet.
|
7
|
+
2. **Prepared**:
|
8
|
+
Any necessary creation of a "space" for the migrated data has been done. Typically not needed for schema-less stores. Your application code now writes to both the old and new data structures.
|
9
|
+
3. **Migrated**:
|
10
|
+
All pre-existing data has been copied and/or mutated in to the new locations while continuing to exist as-is in the old locations. The code base is still operating off of the old locations (and new data continues to be written to both locations).
|
11
|
+
4. **Switched**:
|
12
|
+
The code base now uses the new locations and ignores the old locations. Data is still written to both old and new locations. This is the point at which you would want to test the system to make sure the migration worked as expected. If something isn't right, the migration can still be rolled all the way back to the `unrun` state.
|
13
|
+
5. **Completed**:
|
14
|
+
The migration has been verified to be working, and new data is no longer written to the old locations. Migration-specific code can be stripped out of the code base now. Once a migration is `completed`, it cannot be rolled back. Any references to the migration in the codebase will generate a warning.
|
15
|
+
6. **Destroyed**:
|
16
|
+
The old locations for data have been removed from the data store. Any references to the migration in the codebase will raise an error.
|
17
|
+
|
18
|
+
## Installation
|
19
|
+
|
20
|
+
Just add zd to your Gemfile:
|
21
|
+
|
22
|
+
gem 'zd'
|
23
|
+
|
24
|
+
And `bundle install`.
|
25
|
+
|
26
|
+
## Usage
|
27
|
+
|
28
|
+
To show how ZD works, lets walk through a simple example. Lets say you have a Person class, which used to store separate first names and last names. You've since expanded internationally and realized what a bad idea this is in general, and so now you need to fix your mistake down to the data level *without* taking your application down (though rolling restarts are OK). Your Person class looks like this initially:
|
29
|
+
|
30
|
+
class Person
|
31
|
+
include AwesomeDB
|
32
|
+
|
33
|
+
def first_name
|
34
|
+
read(:first_name)
|
35
|
+
end
|
36
|
+
|
37
|
+
def last_name
|
38
|
+
read(:last_name)
|
39
|
+
end
|
40
|
+
|
41
|
+
def first_name=(value)
|
42
|
+
write(:first_name, value)
|
43
|
+
end
|
44
|
+
|
45
|
+
def last_name=(value)
|
46
|
+
write(:last_name, value)
|
47
|
+
end
|
48
|
+
|
49
|
+
def name
|
50
|
+
[first_name, last_name].compact.join(" ")
|
51
|
+
end
|
52
|
+
|
53
|
+
def name=(value)
|
54
|
+
first_name, *rest = value.split(/\s+/)
|
55
|
+
write(:first_name, first_name)
|
56
|
+
write(:last_name, rest.join(" "))
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
(The `read` and `write` methods are made-up access methods for the made-up AwesomeDB data store.)
|
61
|
+
|
62
|
+
Currently you still have code using both the `name` and `first_name`/`last_name`, but you're slowly cleaning it up. The key thing is that all the methods on the class continue to obey their contract throughout the data migration.
|
63
|
+
|
64
|
+
To get started you'll want to generate a new migration with `zd new <name>`. Migrations go in the db/migrate folder in your project, and use a timestamped filename (similar to ActiveRecord migrations). A fresh migration looks something like this:
|
65
|
+
|
66
|
+
class Migrations::MergeFirstAndLastName < ZD::Migration
|
67
|
+
register! depends_on: :nothing
|
68
|
+
|
69
|
+
def prepare
|
70
|
+
end
|
71
|
+
|
72
|
+
def migrate
|
73
|
+
end
|
74
|
+
|
75
|
+
def destroy
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
|
80
|
+
And here is what it might look like after the migration is filled out:
|
81
|
+
|
82
|
+
class Migrations::MergeFirstAndLastName < ZD::Migration
|
83
|
+
register! depends_on: :nothing
|
84
|
+
|
85
|
+
def prepare
|
86
|
+
Person.add_field :name
|
87
|
+
end
|
88
|
+
|
89
|
+
def migrate
|
90
|
+
Person.each do |person|
|
91
|
+
person.name = [person.first_name, person.last_name].compact.join(" ")
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
def destroy
|
96
|
+
Person.remove_field :first_name
|
97
|
+
Person.remove_field :last_name
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
|
102
|
+
The `Person.add_field` and `Person.remove_field` methods are made up; you would just use whatever your data store provides (if necessary; many schemaless datastores won't even need the `prepare` step).
|
103
|
+
|
104
|
+
This is all well and good, but how does the model handle the fact that the data format is shifting around underneath it? ZD provides state-based methods that can be used to mark when which code should be run:
|
105
|
+
|
106
|
+
class Person
|
107
|
+
include AwesomeDB
|
108
|
+
|
109
|
+
def first_name
|
110
|
+
ZD[:merge_first_and_last_name].HANDLE do |m|
|
111
|
+
m.UNTIL_SWITCHED{read(:first_name)}
|
112
|
+
m.ONCE_SWITCHED{@first_name ||= name.split(/\s+/).first}
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def last_name
|
117
|
+
ZD[:merge_first_and_last_name].HANDLE do |m|
|
118
|
+
m.UNTIL_SWITCHED{read(:last_name)}
|
119
|
+
m.ONCE_SWITCHED{@last_name ||= name.split(/\s+/)[1..-1].join(" ")}
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def first_name=(value)
|
124
|
+
ZD[:merge_first_and_last_name].HANDLE do |m|
|
125
|
+
m.ONCE_PREPARED{write(:name, [value, last_name].compact.join(" "))}
|
126
|
+
m.UNTIL_COMPLETED{write(:first_name, value)}
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def last_name=(value)
|
131
|
+
ZD[:merge_first_and_last_name].HANDLE do |m|
|
132
|
+
m.ONCE_PREPARED{write(:name, [first_name, value].compact.join(" "))}
|
133
|
+
m.UNTIL_COMPLETED{write(:last_name, value)}
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def name
|
138
|
+
ZD[:merge_first_and_last_name].HANDLE do |m|
|
139
|
+
m.UNTIL_SWITCHED{return [first_name, last_name].compact.join(" ")}
|
140
|
+
m.ONCE_SWITCHED{read(:name)}
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def name=(value)
|
145
|
+
ZD[:merge_first_and_last_name].HANDLE do |m|
|
146
|
+
m.ONCE_PREPARED{write(:name, value)}
|
147
|
+
m.UNTIL_COMPLETED do
|
148
|
+
first_name, *rest = value.split(/\s+/)
|
149
|
+
write(:first_name, first_name)
|
150
|
+
write(:last_name, rest.join(" "))
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
|
157
|
+
The first thing you're probably thinking after seeing that is, "Who hit my code with the ugly stick!?!" But that's actually a feature of ZD: migration-specific code sticks out like a sore thumb so that there will be lots of motivation to strip it out once the migration is complete. Migration code should be robust but temporary.
|
158
|
+
|
159
|
+
Once your migration and migration-specific code is in place, you can start walking your data through the migration states using `zd`:
|
160
|
+
|
161
|
+
$ zd prepare
|
162
|
+
|
163
|
+
All migrations in the `unrun` state will be transitioned to the `prepared` state via the `prepare` action.
|
164
|
+
|
165
|
+
$ zd migrate
|
166
|
+
|
167
|
+
All migrations in the `prepared` state will be transitioned to the `migrated` state via the `migrate` action.
|
168
|
+
|
169
|
+
$ zd switch
|
170
|
+
|
171
|
+
All migrations in the `migrated` state will flip over to `switched`. This triggers all code to start using the new code paths.
|
172
|
+
|
173
|
+
This is the point at which you should verify that your migrations have been successful and all the new code is working as expected in production. Getting back to the old state is as easy as `zd switchoff [name]`.
|
174
|
+
|
175
|
+
$ zd complete
|
176
|
+
|
177
|
+
All migrations in the `switched` state will flip over to `completed`. This triggers all code to stop writing to old locations, and puts you past the point of no return for an easy rollback. Once you get here, it's time to go through your codebase and rip out the migration-specific code blocks, just leaving the code that deals with the new data structure.
|
178
|
+
|
179
|
+
$ zd destroy
|
180
|
+
|
181
|
+
All migrations in the `completed` state will be transitioned to the `destroyed` state via the `destroy` action. Typically this is the point at which old data gets cleaned up. Note that once your migration gets to this state, continued references to it in your code will raise an error.
|
182
|
+
|
183
|
+
And that's all there is to it! You can either leave old migration files from db/migrate, or delete them once you're done with them - the overhead for each one is very small. Oh, and here's what the Person class looks like once you're done:
|
184
|
+
|
185
|
+
class Person
|
186
|
+
include AwesomeDB
|
187
|
+
|
188
|
+
def first_name
|
189
|
+
@first_name ||= name.split(/\s+/).first
|
190
|
+
end
|
191
|
+
|
192
|
+
def last_name
|
193
|
+
@last_name ||= name.split(/\s+/)[1..-1].join(" ")
|
194
|
+
end
|
195
|
+
|
196
|
+
def first_name=(value)
|
197
|
+
write(:name, [value, last_name].compact.join(" "))
|
198
|
+
end
|
199
|
+
|
200
|
+
def last_name=(value)
|
201
|
+
write(:name, [first_name, value].compact.join(" "))
|
202
|
+
end
|
203
|
+
|
204
|
+
def name
|
205
|
+
read(:name)
|
206
|
+
end
|
207
|
+
|
208
|
+
def name=(value)
|
209
|
+
write(:name, value)
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
No more ugly!
|
214
|
+
|
215
|
+
|
216
|
+
## State Tracking
|
217
|
+
|
218
|
+
## Dependencies
|
219
|
+
|
220
|
+
## Philosophy
|
data/lib/zd.rb
ADDED
@@ -0,0 +1,159 @@
|
|
1
|
+
require "active_support/core_ext/string"
|
2
|
+
|
3
|
+
module ZD
|
4
|
+
def self.[](name)
|
5
|
+
migrations[name]
|
6
|
+
end
|
7
|
+
|
8
|
+
def HANDLE(name)
|
9
|
+
yield(self[name])
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.load!(path)
|
13
|
+
Dir["#{path}/*.rb"].each do |e|
|
14
|
+
if block_given?
|
15
|
+
yield(e)
|
16
|
+
else
|
17
|
+
require e
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.migrations
|
23
|
+
@migrations ||= {}
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.register_migration(name, migration, dependencies)
|
27
|
+
if dependencies.empty?
|
28
|
+
raise ArgumentError, "You must declare migration dependencies (or pass :nothing)"
|
29
|
+
end
|
30
|
+
migrations[name] = migration
|
31
|
+
dependencies.each do |d|
|
32
|
+
add_dependency(name, d)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def self.add_dependency(migration, depends_on)
|
37
|
+
raise ArgumentError, "Dependency #{depends_on} not found for #{migration}" unless(depends_on == :nothing || self[depends_on])
|
38
|
+
dependencies[depends_on] << migration
|
39
|
+
end
|
40
|
+
|
41
|
+
def self.dependencies
|
42
|
+
@dependencies ||= Hash.new{|h,k| h[k] = []}
|
43
|
+
end
|
44
|
+
|
45
|
+
class Migration
|
46
|
+
def self.register!(options={})
|
47
|
+
migration_name = (options[:name] || name.split(/::/).last.underscore.to_sym)
|
48
|
+
initial_state = (options[:initial_state] || :destroyed)
|
49
|
+
initial_state = initial_state.call if initial_state.respond_to?(:call)
|
50
|
+
ZD.register_migration(migration_name, new(initial_state), Array(options[:depends_on]))
|
51
|
+
end
|
52
|
+
|
53
|
+
STATES = [:unrun, :prepared, :migrated, :switched, :completed, :destroyed]
|
54
|
+
TRANSITIONS = {
|
55
|
+
unrun: :prepare,
|
56
|
+
prepared: :migrate,
|
57
|
+
migrated: :switch,
|
58
|
+
switched: :complete,
|
59
|
+
completed: :destroy,
|
60
|
+
}
|
61
|
+
|
62
|
+
attr_reader :state
|
63
|
+
def initialize(initial_state)
|
64
|
+
self.state = initial_state
|
65
|
+
end
|
66
|
+
|
67
|
+
def state=(new_state)
|
68
|
+
check_state(new_state)
|
69
|
+
@state = new_state
|
70
|
+
end
|
71
|
+
|
72
|
+
def check_state(state)
|
73
|
+
raise ArgumentError, "Unknown state #{state}" unless STATES.include?(state)
|
74
|
+
end
|
75
|
+
|
76
|
+
def migrate_to(new_state, silent=false)
|
77
|
+
@silent = silent
|
78
|
+
check_state(new_state)
|
79
|
+
currently = STATES.index(state)
|
80
|
+
target = STATES.index(new_state)
|
81
|
+
to_run = STATES[(currently)..(target-1)]
|
82
|
+
to_run.each do |s|
|
83
|
+
action_name = TRANSITIONS[s]
|
84
|
+
puts "Transitioning from #{s} via #{action_name}..."
|
85
|
+
send(action_name) if action_name && respond_to?(action_name)
|
86
|
+
self.state = s
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
def already?(in_state)
|
91
|
+
STATES.index(@state) >= STATES.index(in_state)
|
92
|
+
end
|
93
|
+
|
94
|
+
def HANDLE
|
95
|
+
yield self
|
96
|
+
end
|
97
|
+
|
98
|
+
def UNTIL_PREPARED
|
99
|
+
unless already?(:prepared)
|
100
|
+
yield
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
def ONCE_PREPARED
|
105
|
+
if already?(:prepared)
|
106
|
+
yield
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
def UNTIL_SWITCHED
|
111
|
+
unless already?(:switched)
|
112
|
+
yield
|
113
|
+
end
|
114
|
+
end
|
115
|
+
|
116
|
+
def ONCE_SWITCHED
|
117
|
+
if already?(:switched)
|
118
|
+
yield
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
def UNTIL_COMPLETED
|
123
|
+
unless already?(:completed)
|
124
|
+
yield
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def ONCE_COMPLETED
|
129
|
+
if already?(:completed)
|
130
|
+
yield
|
131
|
+
end
|
132
|
+
end
|
133
|
+
|
134
|
+
def ONCE_DESTROYED
|
135
|
+
if already?(:destroyed)
|
136
|
+
yield
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
def TEST_ONCE_SWITCHED
|
141
|
+
old_state = state
|
142
|
+
migrate_to(:switched, true)
|
143
|
+
yield
|
144
|
+
ensure
|
145
|
+
self.state = old_state
|
146
|
+
end
|
147
|
+
|
148
|
+
def puts(v="\n")
|
149
|
+
super(v) unless @silent
|
150
|
+
end
|
151
|
+
|
152
|
+
def print(v)
|
153
|
+
super(v) unless @silent
|
154
|
+
end
|
155
|
+
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
module Migrations; end
|
data/lib/zd/version.rb
ADDED
metadata
ADDED
@@ -0,0 +1,62 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: zd
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Nathaniel Talbott
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2011-12-23 00:00:00.000000000Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activesupport
|
16
|
+
requirement: &70132156417960 !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ~>
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '3.0'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: *70132156417960
|
25
|
+
description: ZD implements zero-downtime migrations as a managed series of state changes,
|
26
|
+
with hooks for you to manage your code and data throughout the migration.
|
27
|
+
email:
|
28
|
+
- nathaniel@talbott.ws
|
29
|
+
executables: []
|
30
|
+
extensions: []
|
31
|
+
extra_rdoc_files: []
|
32
|
+
files:
|
33
|
+
- README.md
|
34
|
+
- lib/zd.rb
|
35
|
+
- lib/zd/version.rb
|
36
|
+
homepage: http://github.com/ntalbott/zd
|
37
|
+
licenses: []
|
38
|
+
post_install_message:
|
39
|
+
rdoc_options: []
|
40
|
+
require_paths:
|
41
|
+
- lib
|
42
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
43
|
+
none: false
|
44
|
+
requirements:
|
45
|
+
- - ! '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 1.9.2
|
48
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: 1.5.2
|
54
|
+
requirements: []
|
55
|
+
rubyforge_project:
|
56
|
+
rubygems_version: 1.8.10
|
57
|
+
signing_key:
|
58
|
+
specification_version: 3
|
59
|
+
summary: A zero-downtime data migration framework that sits on top of the data store
|
60
|
+
(or stores) of your choice.
|
61
|
+
test_files: []
|
62
|
+
has_rdoc:
|