My Journey Upgrading to Ruby 3.0: Strategies and Insights

My Journey Upgrading to Ruby 3.0: Strategies and Insights

In this blog post, I detail my journey upgrading a client’s Ruby from version 2.7 to 3.0. While some of the approaches I took may be tailored to their specific needs and might not directly apply to your situation, they offer insights into one possible path for upgrading Ruby, particularly in scenarios with limited test coverage.

Managing deprecations

The first thing we did was handle all the Ruby 3.0 related deprecations. Check out our Fix Sneaky ArgumentErrors When Upgrading Ruby opens a new window blog post to learn more about Ruby 3.0 deprecations. In most cases, the tricky part is not fixing those deprecations but finding the places to fix those deprecations.

Test cases

One of the ways to find all the places to fix the deprecations is to rely on the test cases. It is always very helpful when you have very good test coverage, you can make changes to the application with a high level of confidence. When you run the test cases, it gives you all the places the deprecations occurred and you can fix the code.

Low test coverage

But what if the application does not have a high test coverage, just like the application I was upgrading. We discussed with the client that one of the approaches to find all the places where we need to make changes to handle the deprecated behaviour is to depend on their manual QA checks. So we asked their team to run the manual QA checks and send us the logs, so we can filter the deprecations. I think this approach makes sense when the application is small in nature as it does not have a lot of features, and all critical paths are part of your manual QA checks.

In this case, we realized this approach may not work for us as the QA team was swamped with work and there is still scope for edge cases being missed.

So we took another approach, we wrote a module that will monitor the production logs of the application while the application runs on Ruby 2.7 and will push events to a 3rd party exception tracker. We found out about this approach through Andrew Markle’s Upgrading to Ruby 3 in small steps opens a new window article.

This is the script we ended up running in production:

# frozen_string_literal: true

# This module's purpose is to push any ruby warnings up to our
# ErrorMonitoring stack, so that we can have visibility on all Ruby # and gem warnings happening in production.
# This needs to be loaded early in the boot process so that we can
# catch any warnings
# from any bundled gems.

class RubyWarning < StandardError; end

module ErrorMonitoring
  module WarningHandlers
    module Ruby
      def warn(message)
        allowed_keywords = ENV["ALLOWED_EXCEPTION_KEYWORDS"]
        if allowed_keywords&.split(",")&.any? { |word| message.include?(word) }
          exception = ::RubyWarning.new(message)
          Bugsnag.notify(exception) do |event|
            event.severity = "warning"
            event.grouping_hash = message
          end
        end
        super
      end
    end
  end
end

# Enable Ruby deprecation warnings
Warning[:deprecated] = true
unless Rails.env.test?
  Warning.singleton_class.prepend(ErrorMonitoring::WarningHandlers::Ruby)
end

Since we were interested in deprecation warnings related to Ruby 3.0 only, we filtered all the deprecations by looking for only certain keywords that are of interest to us. We configured all such keywords in an environment variable called ALLOWED_EXCEPTION_KEYWORDS, to make it easy for us to configure new keywords without having to create a new pull request. We set this on production:

ALLOWED_EXCEPTION_KEYWORDS = ["kwargs", "keyword", "keyword argument", "missing keyword", "missing", "positional"]

We let this run for 2 weeks in production while constantly deploying fixes for the deprecations that were reported. By the end of week 2, the deprecation warnings related to Ruby 3.0 stopped coming up as events in our tracker.

Updating Gems

After the deprecations were handled, the next step was to update the gems. One of the approaches to handle this step is to dual boot the application. We have written a few articles about dual boot. You can read them here opens a new window .

In this specific project, we decided to not dual boot early on. The reason was pretty clear, the client told us that they are not going to take the dual boot approach to production. And they also told us that they would prefer to review the pull requests without dual booting.

So we decided to create a new Ruby 3.0 branch that was off their main branch, and all our future work related to the Ruby 3 upgrade was being pushed to this branch.

Fixing the test failures

After successfully upgrading the gems, we configured CI to execute tests on Ruby 3.0. Having addressed all instances of deprecated code usage, any spec failures encountered were exclusively due to non-backward compatible changes between Ruby 2.7 and 3.0.

Running the tests provided valuable insights into the extent of spec failures stemming from the Ruby upgrade and an estimation of the time required to rectify these issues. Once we achieved a passing CI, we were on the verge of completing the upgrade.

However, one persistent challenge was maintaining synchronization between our Ruby 3 upgrade branch and the client’s main branch. Daily rebasing of our upgrade branch with the main branch often resulted in new test failures, impacting our timelines. This problem worsened due to frequent changes made to the main branch. In such scenarios, prioritizing the upgrade project becomes crucial to avoid constantly playing catch-up with other changes in the application.

Crossing the finish line

The remaining tasks involved addressing all code review suggestions, facilitating QA for our pull requests, and resolving any bugs identified during the QA process.

Once all of these tasks were addressed, we had successfully upgraded their application from Ruby 2.7 to Ruby 3.0.

Running an unsupported version of Ruby in production and need to upgrade? Send us a message! opens a new window !

Get the book