Skip to content

Narrow constant discovery in the AASM and ActiveRecordRelations compilers - #2722

Open
dduugg wants to merge 1 commit into
Shopify:mainfrom
dduugg:dduugg/narrow-dsl-constant-discovery
Open

dduugg wants to merge 1 commit into
Shopify:mainfrom
dduugg:dduugg/narrow-dsl-constant-discovery

Conversation

@dduugg

@dduugg dduugg commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Motivation

tapioca dsl SomeConstant is meant to keep constant discovery proportional to what was asked for. Tapioca::Dsl::Compiler's private helpers all short-circuit to the requested set when one is present:

def descendants_of(klass)
  if @@requested_constants.any?
    T.cast(@@requested_constants.select { |k| k < klass && !k.singleton_class? }, ...)
  else
    super
  end
end

def all_modules
  @all_modules ||= if @@requested_constants.any?
    @@requested_constants.grep(Module)
  else
    ObjectSpace.each_object(Module).reject { |mod| deprecated_constant_proxy?(mod) }.to_a
  end.freeze
end

That narrowing only takes effect for compilers that route through those helpers, because Pipeline#gather_constants merges every active compiler's processable_constants and only then intersects with the requested set:

active_compilers.each do |compiler|
  constants.merge(compiler.processable_constants)
end
...
unless requested_constants.empty? && requested_paths.empty?
  constants &= requested_constants

Two bundled compilers bypass the helpers, so they enumerate the whole application's loaded classes on every invocation even when a single constant was requested:

  • ActiveRecordRelations calls ActiveRecord::Base.descendants directly. It is the only ActiveRecord::Base compiler that does; ActiveRecordColumns, ActiveRecordScope, and ActiveRecordAssociations all use descendants_of(::ActiveRecord::Base).reject(&:abstract_class?).
  • AASM walks ObjectSpace.each_object(::AASM::ClassMethods) directly.

One caveat on how I'd frame this, since I would rather under-claim than over-claim: this surfaced while investigating a tapioca dsl <Constant> run on a large Rails monolith that took far longer than expected, and I have not finished attributing that runtime. load_application passes eager_load: @requested_constants.empty? && @requested_paths.empty?, so with constants requested the application is not eager loaded and both of these walks should be enumerating comparatively small sets. This may therefore not be the main cost in that case. What it does fix is unambiguous, though: after it, no bundled compiler does whole-application discovery when specific constants were requested.

Implementation

ActiveRecordRelations now matches its three sibling ActiveRecord::Base compilers.

AASM uses all_classes.grep(::AASM::ClassMethods), the idiom ActiveModelSecurePassword and ActiveModelAttributes already use for the same "classes that extend this module" shape. Things worth a reviewer's eye:

  • Enumerable#grep(mod) applies mod === obj, the same test ObjectSpace.each_object(mod) applies, so the gathered set is unchanged for classes.
  • Narrowing from all objects to classes is safe here: AASM::ClassMethods is only ever extended into classes, and Pipeline#run already drops non-modules via select { |c| Module === c }.
  • Routing through all_classes additionally skips the Rails 8.1+ DeprecatedConstantProxy wrappers that all_modules filters out to avoid emitting deprecation warnings during discovery.

Tests

No new tests. Both compilers' specs already have initialize blocks asserting gathered_constants, which is exactly the discovery behaviour these changes touch, and they pass unchanged:

10 tests, 32 assertions, 0 failures, 0 errors, 0 skips

I considered adding a test that asserts the narrowing itself and decided against it for now: DslSpec's gathered_constants helper does not thread requested_constants through, so it would mean extending that helper, and the property is not universal across compilers (several legitimately return fixed constants such as [::Time] or [ActiveSupport::TestCase]). Happy to add one in whatever shape you'd prefer.

…lers

Compiler#descendants_of, #all_classes, and #all_modules short-circuit to
@@requested_constants when any are requested, but Pipeline#gather_constants
merges every compiler's processable_constants before intersecting with the
requested set. So the narrowing only applies to compilers that route through
those helpers.

These two did not. ActiveRecordRelations called ActiveRecord::Base.descendants
directly, the only ActiveRecord::Base compiler to do so; the other three use
descendants_of(::ActiveRecord::Base).reject(&:abstract_class?). AASM walked
ObjectSpace.each_object(::AASM::ClassMethods) directly, where
all_classes.grep(mod) is the established equivalent.

grep applies mod === obj, the same test each_object(mod) applies, so the
gathered set is unchanged. Restricting to classes is safe because
AASM::ClassMethods is only extended into classes and Pipeline#run already
drops non-modules.
@dduugg
dduugg requested a review from a team as a code owner September 25, 2026 03:53

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant