The mysterious litany of require_dependecy calls

… and check why 5600+ Rails engineers read also this

The mysterious litany of require_dependecy calls

One of the challenges we faced when working on a huge legacy app tech stack upgrade was switching from the obsolete classic autoloader to the modern one, Zeitwerk.

It is optional starting from Rails 6 but gets mandatory in Rails 7.

Once, we were on Rails 6 and managed to apply most of the new framework defaults, we decided it was high time to switch to Zeitwerk.

…This is where the story begins…

Spending a lot of time with this codebase we came across one very large initializer with above 300 of require_dependency calls.

The first red flag was that all the files listed in the initializer were located under autoloaded directories.

The official Rails documentation clearly states:

All known use cases of require_dependency have been eliminated with Zeitwerk. You should grep the project and delete them.

But first, we wanted to make sure that we understood why this file was even there and what is the story behind it. It started with an ominous comment:

# Pre-loading all the Reporting modules, otherwise we
# might get uninitialized constant errors (typically on rake tasks)

Pretty scary, right? Yeah, I thought so too. Who wants to introduce NameErrors in production? Not me, for sure.

In fact, we managed to find some traces of those errors in Sentry, but couldn’t reproduce them locally. We started digging deeper and looked at the differences between these environments.

  • In production.rb we had eager loading enabled which is totally standard for performance-oriented environments. However, it was found out that this setting does not affect rake tasks. Rake tasks, even though run in a production environment, similarly to the development environment, do not eager-load the codebase.

  • Production pods were run on some Debian-based Linux distribution, while our local development environment was macOS. We found out that the file system on macOS is case-insensitive by default, while on Linux it is case-sensitive.

We have also noticed that files listed in the mysterious initializer had unusual capitalization in their paths. Example: lib/report/PL/X123/products

Classic autoloader

With a classic autoloader, and eager loading disabled, it goes from a const name to a file name by calling Report::PL::X123.to_s.underscore which results in report/pl/x123/products.

This magic happens in the Module#const_missing method invoked each time a reference is made to an undefined constant (analogous to the well-known method_missing callback). Standard Ruby implementation of this method raises an error, but Rails overrides it and tries to locate the file in one of the autoloaded directories.

However, there was no such file like report/pl/x123/products.rb from the case-sensitive file system perspective and that’s the clue why NameErrors were spotted in production unless we eagerly loaded the whole codebase at boot time (in case of eager loading being enabled, Rails loads all files in the eager_load_paths during boot).

case-insensitive file system (development - macOS)

❯ ls lib/report/PL/X123/products.rb

❯ ls lib/report/pl/x123/products.rb

case-sensitive file system (production - linux)

$ ls lib/report/PL/X123/products.rb

$ ls lib/report/pl/x123/products.rb
ls: cannot access 'lib/report/pl/x123/products.rb': No such file or directory

How things changed with Zeitwerk

Zeitwerk autoloader works in the opposite way.

It goes from a file name to a const name by listing files from the autoloaded directories and calling .delete_suffix!(".rb").camelize on each of them. It takes inflection rules into account, resulting in Report::PL::X123::Products no matter whether file system is case-sensitive or not.

It utilizes Module#autoload built-in Ruby feature to specify the file where the constant should be loaded from:

# at boot time
autoload :Report, Rails.root.join('lib/report')
# on first Report reference
Report.autoload :PL, Rails.root.join('lib/report/pl')
# on first Report::PL reference
Report::PL.autoload :X123, Rails.root.join('lib/report/PL/x123')
# on first Report::PL::X123 reference
Report::PL::X123.autoload :Products, Rails.root.join('lib/report/PL/X123/products.rb')

It simply says:

When you encounter Report::PL::X123::Products and it will be missed in a constant table, load lib/report/PL/X123/products.rb

Knowing that we felt fully confident to remove the initializer with its mysterious require_dependency litany and switch to Zeitwerk. It went very smoothly and NameErrors never appeared again.

Anyway, from now on, I will always be suspicious when I see capitalized file names in the project tree.

You might also like