Read-Only Mode For Better Rails Downtime

Recently I was looking to upgrade the Postgres version on an application I’ve been working on. This would require a small amount of downtime, likely about 10 minutes.

The default solution I’d reach for in these cases would be to go into Heroku’s maintenance mode, which serves an HTML maintenance page with a 503 Service Unavailable status code. This works but makes the application entirely unusable during the upgrade, and I was hoping to find a better solution. In this particular case, I also wanted to be able to provide JSON responses as the application mainly provides an API for a mobile app.

After exploring a handful of half-baked options, I settled on using a read-only connection to the database to still allow reads but prevent any writes from occurring. While using the read-only connection, the Postgres adapter will raise an error any time we attempt to change data in the database, but we can easily rescue this specific error and convert it to a user-facing notice. I felt a bit odd using exceptions as the core of this workflow, but in the end, it worked out really well, so I wanted to share the specifics.

It’s worth noting that this solution is particularly well suited to this specific application, which only provides an API and has very read-heavy usage, but I imagine it could be extended to work with other styles of app as well.

Configuring Rails to Use the Read-Only Connection

If present, Rails will use the connection string in a DATABASE_URL env var to connect to the database. Following the Connection Preference notes in the Rails guides, I realized that I could make this DATABASE_URL usage explicit and allow for a temporary override. To do this, I added an explicit url property for the production environment with desired connection preference:

# config/database.yml

production:
  <<: *default
  url: <%= ENV["DATABASE_URL_READ_ONLY"] || ENV["DATABASE_URL"] %>

With this in place, I can enable the read-only mode simply by setting the DATABASE_URL_READ_ONLY env var:

heroku config:set \
  DATABASE_URL_READ_ONLY='postgres://read_only_user:abc123...' \
  --remote production

Likewise, to disable the read-only mode, I can use:

heroku config:unset DATABASE_URL_READ_ONLY --remote production

Note: I was able to use Heroku’s Postgres Credentials interface to create the read-only user, but if you’re not working with Heroku you should be able to use these instructions to create your read-only user.

Error Handling

With other approaches I considered I found that I had to close off multiple different potential ways to issue writes to the database, but the read-only connection worked well to cut everything off in one change. That said, it was only half the solution, as I certainly didn’t want the errors making it to users.

Thankfully it was relatively straightforward to provide a centralized rescue that would allow me to handle all the errors. First, I created a module using Rails’s ActiveSupport::Concern functionality:

# app/controllers/concerns/read_only_controller_support.rb
module ReadOnlyControllerSupport
  extend ActiveSupport::Concern

  included do
    if ENV["DATABASE_URL_READ_ONLY"].present?
      rescue_from ActiveRecord::StatementInvalid do |error|
        if error.message.match?(/PG::InsufficientPrivilege/i)
          render(
            status: :service_unavailable,
            json: {
              info: "The app is currently in read-only maintenance mode. Please try again later.",
            },
          )
        else
          raise error
        end
      end
    end
  end
end

When included, this module will use Rails’s rescue_from method to capture potentially relevant errors, and then we do a quick check within that block to make sure we’re only capturing the relevant errors.

Note, the rescue_from logic is only enabled when the DATABASE_URL_READ_ONLY is set, so we’re able to reuse the existence of that variable as a way to scope this behavior.

I was then able to include that module in any relevant base controller:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include ReadOnlyControllerSupport
end

# app/controllers/api/base_controller.rb
class Api::BaseController < ActionController::Base
  include ReadOnlyControllerSupport
end

Non-API Error Handling

My initial use case for this read-only mode only needed to support API requests, but I could imagine extending it to HTML and form-based interfaces.

The first thing I would consider would be adding a sitewide banner that stated that we were in a read-only maintenance mode to alert users to the current status.

With that in place, I think we could extend the error handling in the ReadOnlyControllerSupport module to redirect the user back and display a relevant message:

rescue_from ActiveRecord::StatementInvalid do |error|
  if error.message.match?(/PG::InsufficientPrivilege/i)
    respond_to do |format|
      format.json do
        # JSON erorr message as shown above
      end

      format.html do
        redirect_back(
          fallback_location: root_path,
          alert: "The app is currently in read-only maintenance mode. Please try again later.",
        )
      end
    end
  else
    raise error
  end
end

Scheduler and Background Jobs

One additional consideration here would be around background jobs and scheduler processes. For background jobs things are relatively straightforward – we just need to scale our worker pool down to zero for the read-only period.

Scheduler processes are a little trickier as I didn’t have a mechanism for globally enabling or disabling them. With that in mind, I think the ideal solution would be to only ever have scheduler processes enqueue jobs but not actually do any work beyond that.

Migrations

The final sticking point we ran into was migrations. We have a release command defined in our Procfile that was configured to run rake db:migrate. Unfortunately, it turns out that even if no migrations run, Rails will still attempt to write to the ar_internal_metadata table as part of the db:migrate command, and Heroku will run the release command any time we change an env. In my initial attempt, Heroku failed when I attempted to set the DATABASE_URL_READ_ONLY as the associated release command hit the read-only error when running rake db:migrate.

To work around this I wrote a small script that first checks if there are any migrations that need to be run, and only if there are, then runs rake db:migrate:

#!/bin/bash

set -e

if bin/rails db:migrate:status | grep '^\s\+down\s'; then
  bin/rails db:migrate
fi

This script was added to the repo as bin/migrate-if-needed, and then we replaced our call to rake db:migrate with bin/migrate-if-needed

Update (Oct 14, 2020)

After sharing this post, a commenter on Hacker News pointed out the rails_failover gem that their team at Discourse maintains. It seems to offer similar functionality, but in a more robust and fully thought out way. Looks like a great option to implement this sort of system.

Solid code, the right features, and a strong team

I would love to help you build your platform, empower your team, and tackle everything in between. Let's talk!