When updating a mature Rails project to use v6.1 defaults, we encountered an unexpected stack level too deep error. Investigation revealed that naming one of our class methods :default_role was a bad idea.

Introduction

We have been prepping a legacy Rails project for upgrade to Rails v7. As part of this work we want to switch to using the Rails v6.1 framework defaults, including autoloading via zeitwerk. During this process we hit an unexpected error when we tried to interact with our Role model:

    
> Role.where(name: 'basic')
  …/gems/activesupport-6.1.7.3/lib/active_support/core_ext/string/inflections.rb:61:in `singularize`: 
  stack level too deep (SystemStackError)
> Role.count
  …/gems/activerecord-6.1.7.3/lib/active_record/attributes.rb:250:in `load_schema!`: 
  stack level too deep (SystemStackError)
    
  

What the hell? The model itself was pretty simple; just a HABTM association with the User model and a few convenience methods on the class:

    
class Role < ApplicationRecord
  has_and_belongs_to_many :users

  def self.admin_role_names
    Rails.configuration.admin_role_names.values
  end

  def self.default_role
    where(name: 'basic').first
  end

  …
end
    
  

So what gives? After mistakenly blaming zeitwerk autoloading, then a bit more head-scratching we finally came across this change in ActiveRecord::Core, which adds the class attribute, default_role, to all ActiveRecord models:

    
module ActiveRecord
  module Core
    extend ActiveSupport::Concern
    …
    class_attribute :default_role, instance_writer: false

    class_attribute :default_shard, instance_writer: false

    mattr_accessor :legacy_connection_handling, instance_writer: false, default: true
    …
    
  

The change relates to multi-database support, wherein AR models can connect to different databases, depending whether they are assuming a role of :reading or :writing.

Before the switch to Rails v6.1 defaults, if we use some introspection we can see the that this method existed on other AR models, (e.g. User), with the implementation coming from ActiveRecord::Base. However, our Role class was still pulling the method definition from our custom class method:

    
> Role.method(:default_role).owner
=> Class:Role(id: integer, name: string, created_at: datetime, updated_at: datetime)
> User.method(:default_role).owner
=> Class:ActiveRecord::Base
    
  

This meant that our Role was behaving as we were expecting, based on our experience on earlier Rails versions. That being said, had we tried to make use of the Rails v6.1 multi-database role handling then we, presumably, would have run into some problems!

Switching to Rails v6.1 defaults meant that the ActiveRecord::Core implementation is referenced when a given AR model is loaded. This poses no problem for most of our domain models, but when we come to load our Role model the default_role class attribute is referenced within ActiveRecord::Core, this calls our custom (unrelated) default_role class method which tries to run a database query on the Role class. So Rails again tries to load the class, and round in circles we go. Thus, any attempt by Rails to load the Role model triggers the stack level too deep error.

Why did this only hit us when we switched to Rails v6.1 defaults?

Looking into Rails v6.1 defaults we see that, as part of the changes to connection handling, a config flag was introduced for legacy_connection_handling. Prior to our changes, this flag was evaluting to true. Under this condition the ActiveRecord::Core implementation will not reference the default_role class attribute during model loading, and the problem does not surface. However, under the new Rails v6.1 framework defaults the legacy_connection_handling flag evaluates to false and the default_role class attribute is referenced during model loading and our custom implementation wreaks havoc.

So it would seem that if we set this flag to true our application should revert to the old connection handling regime, solving the problem with the default_role method. We apply the flag in our config/application.rb as follows:

    
require_relative 'boot'
require 'rails/all'

Bundler.require(:default, Rails.env)

module Foo
  class Application < Rails::Application
    config.load_defaults 6.1
    config.active_record.legacy_connection_handling = true
    config.active_record.belongs_to_required_by_default = false
    …
    
  

Booting our console we can see that once again the Role.default_role method is being pulled from the Role class, as expected:

    
> Role.method(:default_role).owner
=> Class:Role(id: integer, name: string, created_at: datetime, updated_at: datetime)
    
  

This is one way to solve our problem, but it requires that we override the framework defaults for this setting. That is not something we really want to be carrying around with us for the longer term. So we review the project and realize that our Role.default_role method only has a few call sites across the application; so the better solution in our case (and in most cases) is to just rename our custom method to avoid the conflict with the new ActiveRecord::Core class attribute. In our case Role.default will do the same job and is arguably a little more fluent.

    
class Role < ApplicationRecord
  …
  def self.default
    where(name: 'basic').first
  end
  …
end
    
  

Summary

Upgrading to Rails v6.1 defaults led to a stack level too deep error when accessing our Role model. The problem was the existence of a custom class method, unfortunately named Role.default_role. In Rails v6 ActiveRecord::Core introduced a class attribute of the same name, relating to multi-database support. The impact of this collision can be averted by setting the framework flag legacy_connection_handling to true. However, to avoid overriding framework defaults, our preferred solution was to rename our custom class method, and avoid the naming collision altogether.

References

  1. Rails docs on migrating to zeitwerk
  2. The ActiveRecord commit that introduces default_role
  3. Migrating from legacy_connection_handling in Rails 6.1

Comments

There are no existing comments

Got your own view or feedback? Share it with us below …

×

Subscribe

Join our mailing list to hear when new content is published to the VectorLogic blog.
We promise not to spam you, and you can unsubscribe at any time.