stack level too deeperror. Investigation revealed that naming one of our class methods
:default_rolewas a bad idea.
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.where(name: 'basic') …/gems/activesupport-126.96.36.199/lib/active_support/core_ext/string/inflections.rb:61:in `singularize`: stack level too deep (SystemStackError) > Role.count …/gems/activerecord-188.8.131.52/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
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
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
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
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
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
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,
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
However, to avoid overriding framework defaults, our preferred solution was to rename our custom class method, and avoid the naming collision altogether.