Fabian Lindfors

Zero-downtime schema migrations for Ruby on Rails

I’ve been working on a tool called Reshape which aims to automate zero-downtime schema migrations for Postgres. The main thing I aspire for with Reshape is a great developer experience, hopefully as good as other schema migration tools which don’t do zero-downtime. Because of the way Reshape works, it requires some slight changes to your application so lately I’ve been focused on improving that experience with helper libraries.

In this post, we’ll see how easily Reshape can be integrated with Rails and the great advantages it provides compared to standard Active Record migrations. If you’d like an introduction to Reshape first, I recommend reading the introductory blog post.

Setting up a Rails project

To illustrate Rails and Reshape together, we will set up a simple blog by loosely following the official getting started guide. First off, we need to create a new Rails project:

rails new reshape_test --database=postgresql
$ cd reshape_test
$ bin/rails server

Next, we create a controller to handle our blog articles. To keep things simple, we’ll only support listing articles and creating them.

$ bin/rails generate controller Articles index new create --skip-routes

And then we add some routes to config/routes.rb for our new controller as well:

Rails.application.routes.draw do
  get "/articles", to: "articles#index"
  get "/articles/new", to: "articles#new"
  post "/articles", to: "articles#create"
end

Creating a model and our first migration

Here comes the fun part! We’ll need a model for our articles and if we tell Rails to generate one for us, it will also generate a Active Record migration. We want to use Reshape for migrations though so let’s create a model without a migration to start:

$ bin/rails generate model Article title:string body:text --no-migration

Now we need to create a corresponding Reshape migration to set up the table for our articles. We’ll store our migration files in migrations/ and prefix the file names with incrementing numbers to keep them sorted:

# migrations/1_create_articles_table.toml

[[actions]]
type = "create_table"
name = "articles"
primary_key = ["id"]

	[[actions.columns]]
	name = "id"
	type = "INTEGER"
	generated = "ALWAYS AS IDENTITY"

	[[actions.columns]]
	name = "title"
	type = "TEXT"

	[[actions.columns]]
	name = "body"
	type = "TEXT"

	[[actions.columns]]
	name = "created_at"
	type = "TIMESTAMP WITHOUT TIMEZONE"

	[[actions.columns]]
	name = "updated_at"
	type = "TIMESTAMP WITHOUT TIMEZONE"

Make sure you have Reshape installed and then run the following to apply the migration.

$ reshape migration start --complete

And now for the secret sauce. Reshape works by encapsulating your tables in views and having your application interacting with these views instead. During a migration, Reshape will ensure both the old and new schema are available at the same time through different views, allowing you to roll out your application gradually with no downtime. This requires that the application specifies which schema it supports using what’s called a search path, which is easily done with the Ruby helper library. Let’s add it to our Gemfile:

gem "reshape_helper", "~> 0.2.0"

And then we update our config/database.yml to set the correct search path:

default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  schema_search_path: <%= ReshapeHelper::search_path %>

That’s all it takes to integrate Reshape!

Using our model

Next, we want to actually make use of our new model. We need to add a few things to our controller:

# app/controllers/articles_controller.rb

class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end

  def new
    @article = Article.new
  end

  def create
    @article = Article.new(article_params)

    if @article.save
      redirect_to @article
    else
      render :new, status: :unprocessable_entity
    end
  end

  private
    def article_params
      params.require(:article).permit(:title, :body)
    end
end

And finally we need to set up our views as well:

<%# app/views/articles/new.html.erb %>

<h1>New Article</h1>

<%= form_with model: @article do |form| %>
  <div>
    <%= form.label :title %><br>
    <%= form.text_field :title %>
  </div>

  <div>
    <%= form.label :body %><br>
    <%= form.text_area :body %>
  </div>

  <div>
    <%= form.submit %>
  </div>
<% end %>


<%# app/views/articles/index.html.erb %>

<h1>Articles</h1>

<ul>
  <% @articles.each do |article| %>
    <li>
      <strong><%= article.title %></strong>: <%= article.body %>
    </li>
  <% end %>
</ul>

This leaves us with a fully functional, albeit slightly bare-bones, blog, ready to be deployed for the world to enjoy!

When things inevitably change

With the blog deployed, it’s about time we start bikeshedding code quality. After an intense nitpicking session, we decide that we need to rename the title column to heading. With normal Active Record migrations this would be hard to do without downtime. Because our currently deployed application expects it to be called title, there will inevitably be some downtime when rolling out the new one. If we want to avoid that, we would need to: create a new column with the new name, backfill all the values, write to both columns for new articles, deploy, stop writing to both columns, and then finally remove the old column.

Phew, that’s a lot of work! Luckily we are using Reshape which will automate it all for us. We just need to create a new migration:

# migrations/2_rename_title_to_heading.toml

[[actions]]
type = "alter_column"
table = "articles"
column = "title"

	[actions.changes]
	name = "heading"

And update our application to use the new name for the field. In our case we only need to update the view:

<%# app/views/articles/index.html.erb %>

<h1>Articles</h1>

<ul>
  <% @articles.each do |article| %>
    <li>
      <strong><%= article.heading %></strong>: <%= article.body %>
    </li>
  <% end %>
</ul>

Now we are ready to deploy which means doing the following:

  1. Run reshape migration start to apply the new migrations
  2. Gradually roll out our application changes. Both the old and the new schema are available at the same time so there will be no downtime!
  3. Once fully deployed, run reshape migration complete to remove the old schema

That’s it! We managed to change the name of a table column and update our application with a single deployment and no downtime. No manual work was necessary, Reshape handled all the tricky and annoying bits.

Wrapping up

Changing the name of a column is one of the simpler schema changes one could make, but Reshape supports a whole lot more, check out the documentation for some examples. Reshape also doesn’t need to be added to a project from the start, it can just as easily be added to an existing Rails application with no downtime.

Schema changes are a natural part of all development involving databases but the developer experience around them is abysmal. At least for me, improving the experience is a great boon for productivity. If you’d like to chat about Reshape or schema migrations in general, feel free to reach out!