The mysterious litany of
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
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.
production.rbwe 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.
With a classic autoloader, and eager loading disabled, it goes from a const name to a file name by
Report::PL::X123.to_s.underscore which results in
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
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
.delete_suffix!(".rb").camelize on each of them.
It takes inflection rules into account, resulting
Report::PL::X123::Products no matter whether file system is case-sensitive or not.
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::Productsand it will be missed in a constant table, load
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.