Rebase Capital

Upgrading WebTranslateIt from Rails 3.2 to Rails 7

How a team of two took a large Rails app from Rails 3.2 and Ruby 2.7 to Rails 7 and Ruby 3.1 in four months.

About a year ago, WebTranslateIt ran on Rails 3.2 LTS and Ruby 2.7. It now runs on vanilla Rails 7 and Ruby 3.1. The app started life in 2008 as a Rails 2 app. Time flies.

WebTranslateIt is a Rails app with a large codebase, serving about 35 requests per second on average.

Rails LTS is a maintained fork of Rails sold by Makandra, with continuous security fixes. They also ship patched versions of some Rails dependencies, rake among them. It let us stay on an old version of Rails indefinitely and safely, but staying was never the plan. We wanted the newer features and the performance improvements. We recommend Rails LTS all the same.

First, why we were running something so old. For years, WebTranslateIt had a development team of one: me. I tried on and off for two years to get from Rails 3.2 to Rails 4, and it was simply too hard. Running a business meant shipping the features that paid for it, so the upgrade kept getting postponed.

We are two now. James, a freelance Rails developer, took the project on. He is experienced and knows the framework deeply, and that is why we moved as fast as we did. Here is how we went from Rails 3.2 to 7.0, and Ruby 2.7 to 3.1, in four months.

Preparation

We started by improving test coverage. Upgrading across a gap that size without good coverage is asking for trouble. We spent a few months writing unit tests over the important parts of the code, plus feature tests with Capybara.

We added RuboCop and fixed the offenses. Normalizing the code and catching problems early, many of them correctable automatically, helped enormously. We shipped all of it before touching Rails.

Strong parameters was one of the headline Rails 4 features, so we added the strong_parameters gem on Rails 3 and made the app ready ahead of time.

Before each upgrade we read the Rails upgrade guide and the upgrade notes on fastruby.io. Both were a great help.

Rails 4

Rails 4 was the hardest of the upgrades, despite everything we had done in advance. A lot changed in the framework. Rather than recount all of it, here are three things worth knowing if you are facing the same jump.

Go from latest to latest

We started by moving from Rails 3.2 LTS to vanilla 4.0, and hit a wall. Rails 3.2 LTS let us run Ruby 2.7; vanilla 4.0 did not, so we had to downgrade every dependency to a version old enough to match. Many of those older versions had different public APIs, which meant reimplementing our own code against them. Dependency hell.

Then, having finished, we could not release it. Vanilla 4.0 carries known security issues, so shipping it would have been a step backwards.

So we carried on to vanilla 4.1, then 4.2, then 4.2 LTS. Rails 4.2 LTS supports Ruby 2.7, so we could finally upgrade all those dependencies again, and undo the API changes we had just made.

It was a lot of wasted work. In hindsight, and contrary to what the Rails upgrade guide advises, we should have gone straight from 3.2 LTS to 4.2 LTS and skipped the detour entirely. We learned the lesson and did exactly that for every later version, and it went much faster.

Backport what you can

We made one commit for every small change needed to run on the next version of Rails. Plenty of those changes work fine on the current version too.

Whenever that was the case, we cherry-picked the backportable commits into a pull request against main, checked everything still worked, and deployed it. Then we rebased main onto the Rails 4 branch. The goal was to keep the Rails 4 pull request as small as possible.

Have a way back

We also made sure the upgraded app was easy to revert. When a release was ready, we tested the water by deploying the branch without merging it:

cap production deploy BRANCH=rails4

and going back to Rails 3 was one command:

cap production deploy

We deployed to staging first, every time. A small changeset plus a one-command rollback is what let us deploy with confidence.

Rails 4 went out on May 4. Two months from Rails 3.2 to 4.2, with no disruption.

Rails 5

James started on Rails 5 the day Rails 4 shipped, this time targeting the LTS release, which made it much easier.

Meanwhile I had maintenance to do:

  • read the logs for deprecation warnings and fix them
  • let Dependabot upgrade our dependencies. There was a lot to get through, since many gems had dropped Rails 3 support. We rebased them onto main one at a time and released them gradually.
  • point RuboCop at Rails 4 and work through the new offenses, again in small releases.

All of it was rebased onto the Rails 5 branch, which helped that upgrade along. Team work.

Rails 5 went out on June 5, almost exactly a month after Rails 4.

Serialization

Rails 5 added support for Postgres JSONB columns, so we converted every field that had been serialized as YAML since Rails 2. Plural forms on translations were one of them. Migrating 30 million translations took a week, and we found a way to do it without any disruption. That deserves a post of its own.

Rails 6

We went straight on to Rails 6. After 3 to 4 and 4 to 5, this was a small gap. Rails 6.1 was still supported at the time, so we went to vanilla 6.1. It felt good to be back among supported releases.

Rails 6 went out on June 13, twelve days after Rails 5. It went that fast because by then we were practiced at this, and RuboCop had kept the code in good shape. There was not much to change.

Rails 6 introduced framework defaults, so we spent the next few days turning them on one at a time and releasing each one. As usual, we watched for deprecation warnings and let Dependabot do its work.

Ruby 3.0, then 3.1

Rails 6 supports Ruby 3.0, so we upgraded and released on June 14, the day after Rails 6.

Then we tried Ruby 3.1 on Rails 6.1, and it worked. We upgraded on June 23, with no changes at all.

Rails 7

I had not even released Rails 6 when James asked on Slack: “Guess how many failing specs we have on Rails 7?” He was about to go on holiday and wanted the upgrade finished. We had one failing spec. The Rails 7 release was ready before Rails 6 had shipped.

Rails 7 went out on June 28. We spent the following days enabling the new defaults one by one, clearing deprecation warnings and fixing RuboCop offenses.

What we learned

Rails 3.2 to Rails 7 in about four months, with a team of two. I will not pretend it was relaxing. The holiday afterwards was well earned.

Rails upgrades have gotten much easier over the years. There are fewer breaking changes, and the framework defaults files let you turn new behavior on one piece at a time.

Upgrade stories usually leave out the infrastructure work, and that was the larger part for us. Changing how we serialized data was a big migration in its own right, because of the size of the database. We also adopted a couple of abandoned gems: we now maintain version_fu and payday so they work on Ruby 3.1 and Rails 7.

The app uses far less memory now, thanks to Ruby 3.1 and to the dependencies we could finally upgrade. It is faster and more reliable than it has ever been.