Strategies for defensive Rails migrations
I tend to be quite lazy when writing Rails migrations. I was painfully reminded of this recently when I tried to deploy a client project, and ran into some sticky issues. I've always been somewhat aware that there was more I should be doing, but never really took the time to actually improve my migrations. I will often write code like this without a second thought:
def up
add_column :requests, :approved, default: false, null: false
Request.find_each do |request|
request.approved = true
request.save!
end
end
This migration is problematic if an exception is thrown inside the find_each
block because the approved
column does not get removed even though the migration gets rolled back. The next time you attempt to run the migration (say, after you fixed whatever issue caused the original exception), it will always fail on the first line because it will try to add a column that already exists.
That is a very bad situation to have when deploying to production. Even though the find_each
block is unlikely to cause issues, you may have later migrations that won't be run, causing your code and your database to be out of sync for an extended period of time.
Luckily, when I got burned on my deploy, it was on a staging server. However, it woke me up enough to start looking into ways to write more defensive migrations.
I'll outline a few techniques below:
Techniques for safe, atomic migrations
1. Write a separate migration for each operation
Separating each operation into their own migrations is the safest solution. However, it's always tempting to keep related changes together, so I rarely do this myself. Plus, in a Rails app, it feels unnecessarily complex
2. Wrap the entire migration in a transaction
I believe your ability to do this depends on what database you're using, as it has to support nested transactions (Rails already runs each individual operation in a transaction).
3. Rescue exceptions and manually roll back earlier operations in the rescue block
Rescuing exceptions is a good technique if you don't have too many operations.
4. Guard your operations with conditionals
I wasn't aware of this, but ActiveRecord migrations provide methods such as column_exists
, index_exists?
, etc. that can be especially handy when you are unsure about whether later operations in the migration might cause exceptions.
5. Anticipate stale / incomplete data in production
Often migrations that run fine in development and maybe event staging fail when run in production. In many cases, you are not working with a production data snapshot – and even if you are, it might not be up to date enough to reflect exactly what happens in production.
Depending on your tolerance for downtime, running every migration against an up to date production snapshot might be worthwhile, but in most cases it's probably not necessary.
What I'd advocate instead is to make your migrations tolerant to variations in available data. As an example, lets say you have a migration like this:
def up
User.find_each do |user|
user.city_name = user.address.city
user.save!
end
end
In development, all of your users may have associated addresses. In production, unless your database ensures an address relation, it is entirely possible that some users may not have addresses. Rather than even making that assumption, it would be better to just assume that not all users have addresses:
def up
User.find_each do |user|
if user.address
user.city_name = user.address.city
user.save!
else
# log the user's id or notify your error tracker
end
end
end
6. Define ActiveRecord models inline in your migrations instead of referencing your application models
In Rails it is pretty standard practice to use ActiveRecord models in migrations to perform data operations. However, there is no guarantee that the state of your models will stay the same between when you write your migrations and when they are run in production.
To protect against this, you can actually define a new ActiveRecord model inside your migration that only gets used for that migration:
class SomeMigration < ActiveRecord::Migration
class User < ActiveRecord::Base
end
def up
User.find_each do |user|
# ...
end
end
end
Even if you have a User
model, this will actually reference SomeMigration::User
(since classes are also modules in Ruby) and your migration will run no matter what you've done to your User model (even if you deleted it!)
My advice to you is to not wait until something goes wrong with your migrations to start experimenting with more defensive techniques. Your future self will thank you.