5

I am in the process of upgrading a ~2 year old Rails app from 6.1 to 7.0.1

I have an STI setup where Pursuit is the main class and it has several other types as descendants:

# app/models/pursuit.rb
require 'sti_preload'

class Pursuit < ApplicationRecord
# ...
end

Descendant classes all look like this:

# app/models/pur_issue.rb
class PurIssue < Pursuit
# ...
end

--

# app/models/pur_goal.rb
class PurGoal < Pursuit
# ...
end

--

# app/models/pur_tracker.rb
class PurTracker < Pursuit
# ...
end

I have been using STI preload snippet as recommended by the Ruby Guides, and all worked well under Rails 6.0 and 6.1.

But now that I am upgrading to Rails 7.0.1, I suddenly get an "Uninitialized Constant" error for only the PurIssue subclass whereas all the other subclasses load fine:

Showing /Users/me/gits/rffvp/app/views/pursuits/_stats.html.slim where line #1 raised:

uninitialized constant PurIssue

Extracted source (around line #33):

33 types_in_db.each do |type|
34   logger.debug("Preloading STI type #{type}")
35   type.constantize
36 end
37 logger.debug("Types in database #{types_in_db}")


Trace of template inclusion: #<ActionView::Template app/views/layouts/_footer.html.slim locals=[]>, #<ActionView::Template app/views/layouts/application.html.slim locals=[]>

Rails.root: /Users/me/gits/rffvp
Application Trace | Framework Trace | Full Trace
lib/sti_preload.rb:33:in `block in preload_sti'
lib/sti_preload.rb:31:in `each'
lib/sti_preload.rb:31:in `preload_sti'
lib/sti_preload.rb:13:in `descendants'
app/models/pursuit.rb:71:in `<class:Pursuit>'
app/models/pursuit.rb:54:in `<main>'
app/models/pur_issue.rb:52:in `<main>'
app/views/pursuits/_stats.html.slim:1
app/views/layouts/_footer.html.slim:14
app/views/layouts/application.html.slim:13

I can't seem to figure out why PurIssue will not load anymore whereas all the other subclasses will. This is breaking my entire app since PurIssues are the most important data points.

Can someone point me to a Rails configuration change between 6.0, 6.1 and 7.0 that might cause this different behavior?

# lib/sti_preload.rb
module StiPreload
  unless Rails.application.config.eager_load
    extend ActiveSupport::Concern

    included do
      cattr_accessor :preloaded, instance_accessor: false
    end

    class_methods do
      def descendants
        preload_sti unless preloaded
        super
      end

      # Constantizes all types present in the database. There might be more on
      # disk, but that does not matter in practice as far as the STI API is
      # concerned.
      #
      # Assumes store_full_sti_class is true, the default.
      def preload_sti
        types_in_db = \
          base_class
          .unscoped
          .select(inheritance_column)
          .distinct
          .pluck(inheritance_column)
          .compact

        types_in_db.each do |type|
          logger.debug("Preloading STI type #{type}")
          type.constantize
        end
        logger.debug("Types in database #{types_in_db}")

        self.preloaded = true
      end
    end
  end
end

Zeitwerk shows the same error by the way:

$ rails zeitwerk:check        
Hold on, I am eager loading the application.
rails aborted!
NameError: uninitialized constant PurIssue
/Users/me/gits/rffvp/lib/sti_preload.rb:33:in `block in preload_sti'
/Users/me/gits/rffvp/lib/sti_preload.rb:31:in `each'
/Users/me/gits/rffvp/lib/sti_preload.rb:31:in `preload_sti'
/Users/me/gits/rffvp/lib/sti_preload.rb:13:in `descendants'
/Users/me/gits/rffvp/app/models/concerns/validateable.rb:10:in `block in <module:Validateable>'
/Users/me/gits/rffvp/app/models/pursuit.rb:68:in `include'
/Users/me/gits/rffvp/app/models/pursuit.rb:68:in `<class:Pursuit>'
/Users/me/gits/rffvp/app/models/pursuit.rb:54:in `<main>'
/Users/me/gits/rffvp/app/models/pur_issue.rb:1:in `<main>'
Tasks: TOP => zeitwerk:check
(See full trace by running task with --trace)
siros
  • 51
  • 3
  • i tried to reproduce your problem and found out that it looks like your problem relative to the order of the classes loading process, in your case: when the class `PurIssue` is loading, the class `Pursuit` is loaded so your code `preload_sti` is triggered which reference to the constant `PurIssue` (which is loading), as a result the error `uninitialized constant` be throwed. I still not figure out the solution, but you could try a workaround by changing the name of the class `PurIssue` into `Issue` which will be loaded before `Pursuit`. – Lam Phan Jan 24 '22 at 04:51
  • This is indeed a problem with Rails 7 and that (rails-guide recommended) StiPreload class, it's not compatible with Rails 7. Contributors are currently trying to work out a replacement/improvement at https://github.com/rails/rails/issues/45307 , but as I write this it's still in progress. But there are some ideas there that you can use to try something using an initializer and to_prepare and an explicit list of classes. – jrochkind Dec 01 '22 at 18:13

1 Answers1

3

I saw the same thing trying to do an upgrade to rails 7. I believe it originates with this change: https://github.com/rails/rails/commit/ffae3bd8d69f9ed1ae185e960d7a38ec17118a4d

Effectively the change to invoke Class.descendants directly in the internal association callback methods exposes a more longstanding implicit issue with invoking Class.descendants at all during the autoload, as the StiPreload implementation may result in a problem where it circularly attempts to constantize the original class being autoloaded. I've added an issue in the Rails repo here https://github.com/rails/rails/issues/44252

Dave W
  • 31
  • 1
  • Thanks for pinpointing the exact underlying problem @dave-w! Very insightful. So would there be a quick workaround in the StiPreload code that I could employ to go around the circularity problem, maybe a way to eager-load the parent class first? Or is my best workaround to switch back to a pre 3.1 version of Ruby, since the change seems to have originalted with that version? – siros Jan 25 '22 at 23:54
  • Sure thing - I did a workaround of detaching the preload from descendants and invoking it manually on the parent classes in an initializer. We were seeing an additional issue with our associations being lost when triggering the StiPreload things from the `descendants` method, so eager loading the parent classes early wasn't enough for us – Dave W Jan 26 '22 at 01:58
  • Just confirming that the issue occurs on Rails 7.0.1 running under Ruby 2.7.5. I'll see if higher Ruby versions make a difference although I don't expect they will. – siros Jan 26 '22 at 02:09
  • I tested your repository to showcase the issue and it does pass 'rails zeitwerk:check' whereas my problem triggers the "uninitialized constant" issue with 'rails zeitwerk:check'. Not sure if this is relevant to resolving the issue? – siros Jan 26 '22 at 02:30
  • If you run the zeitwerk check in my repo with the environment variable set you should see the uninitialized constant error: `SHOWCASE_BUG=1 bin/rails zeitwerk:check` . The repro is just showing that triggering the call to `descendants` causes the issue. Calling `.descendants` on the class happens normally when adding associations in rails 7 after the commit linked in the answer. – Dave W Jan 26 '22 at 03:24
  • I just ran into this too, I've filed it as an issue on Rails: https://github.com/rails/rails/issues/46625 I do not know what to do about this -- is it not possible to safely do single-table inheritance in Rails 7? Anyone figured anything out? Dave W, can you give more info on your solution, code example? (it is not a ruby 3.1 problem -- I am getting it with ruby 3 too, that's a wrong direction) – jrochkind Dec 01 '22 at 16:30