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.
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
- Rails docs on migrating to zeitwerk
-
The ActiveRecord commit that introduces
default_role
-
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 …