Skip to content

Cache a compiled snapshot per factory and traits - #1851

Draft
neilvcarvalho wants to merge 1 commit into
nc/evaluator-flagsfrom
nc/compiled-factory
Draft

neilvcarvalho wants to merge 1 commit into
nc/evaluator-flagsfrom
nc/compiled-factory

Conversation

@neilvcarvalho

@neilvcarvalho neilvcarvalho commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Third step of the sequence of refactors toward a compiled, non-cloning core. Stacked on #1848 (step 2) and #1847 (step 1); merge those first.

What changed

Every run that passed traits used to clone the Factory, clone its Definition, append the traits to the clone and compile it again, which defined a fresh anonymous Evaluator subclass on every call. CallbacksObserver existed to deduplicate callbacks at run time, and FactoryBot.modify after a factory's first use was ignored because the memoized attributes were kept.

  • CompiledFactory (new) is a snapshot of the build class, evaluator class, callbacks, constructor and to_create for one factory and one list of trait names, and it owns run.
  • Factory#compile_with_traits computes the snapshot from the existing Definition and Trait objects by folding over the parent's snapshot, resolving the requested traits in the factory's scope through the new Definition#lookup_traits, and applying them after the factory's own attributes. The ordering rules from the acceptance suite (callbacks, to_create, initialize_with, trait precedence) are reproduced by the same fold.
  • Internal.compiled_factory caches the snapshot under [factory.name, trait_names] in the configuration. define and modify clear the cache; reload already resets the configuration. Factory#compiled(trait_names) is the accessor.
  • FactoryRunner#run asks for the snapshot instead of calling factory.with_traits(traits).run. Factory#run runs the snapshot without traits, which keeps the existing unit specs working.
  • Evaluation takes the callback list and notifies it directly. The same Callback object now reaches a run through every path, so deduplication is callbacks.uniq at snapshot time.
  • Definition#attributes no longer memoizes, and DeclarationList drops its memo when a declaration is added, so a modify after first use recomputes the next snapshot.

Deleted

  • lib/factory_bot/callbacks_observer.rb and its require.
  • Factory#with_traits, Factory#initialize_copy, the memoized evaluator_class and attributes on Factory, and the hierarchy-era callbacks, compiled_constructor and compiled_to_create methods, which now live on the snapshot.
  • Definition#initialize_copy, Definition#append_traits, the additional-trait handling and the attribute memo.
  • NullFactory#attributes and NullFactory#evaluator_class; the root of the chain now answers compiled with a snapshot built from Internal.

Deviation from the plan

Trait#clone and DefinitionProxy#__fetch_or_register_sequence stay. The plan lists them under this step, but the acceptance suite pins that a parent trait which names another trait must pick up a child factory's redefinition of that trait (spec/acceptance/traits_spec.rb, "calls the correct trait when child built first"). Today that works because each child factory gets its own clone of the parent's traits, so trait bodies resolve names in a per-factory copy. Resolving trait names in the scope of the factory being built is step 5 in the plan, and until then the clone is what provides the scoping. The clone now happens once per child factory at compile time rather than on every run, and the URI based sequence reuse exists only so those clones share the parent's inline sequences. Both can go with step 5.

Behavior

No acceptance spec or feature was edited. The one visible difference, which the plan calls out as intended: FactoryBot.modify after a factory has already been used now applies to subsequent builds. On main the modification is silently ignored. I verified this directly, along with snapshot reuse across runs with the same traits, callbacks running once when reachable through several trait paths, and a global callback added by a later define taking effect.

Verification

All on Ruby 4.0.1.

  • bundle exec standardrb: no offenses.
  • bundle exec rake all_specs (default Gemfile): 332 unit examples, 445 acceptance examples, 4 Cucumber scenarios, all passing.
  • BUNDLE_GEMFILE=gemfiles/7.0.gemfile bundle exec rake all_specs: same counts, all passing.
  • BUNDLE_GEMFILE=gemfiles/main.gemfile bundle exec rake all_specs: same counts, all passing.

Unit spec changes: the strategy shared examples now stub factory.compiled and assert run on the snapshot, the NullFactory spec covers compiled, and Internal gains two examples for the cache key and for clearing on define and modify.

Every run that passed traits cloned the Factory, cloned its Definition,
appended the traits to the clone and compiled it again, which defined
a fresh anonymous Evaluator subclass each time. CallbacksObserver
existed to deduplicate callbacks at run time because cloned traits
could present the same callback through several objects, and
FactoryBot.modify after a factory's first use was ignored because the
memoized attributes were kept.

Introduce CompiledFactory, a snapshot of the build class, evaluator
class, callbacks, constructor and to_create for one factory and one
list of trait names. Factory#compile_with_traits computes it from the
existing Definition and Trait objects by folding over the parent's
snapshot, resolving the requested traits in the factory's scope and
applying them after the factory's own attributes. Internal caches the
snapshot under [factory.name, trait_names] in the configuration, and
define, modify and reload clear the cache. FactoryRunner asks for the
snapshot instead of cloning, and Factory#run runs the snapshot without
traits.

Nothing is cloned per run any more, so Factory#with_traits,
Factory#initialize_copy, Definition#initialize_copy and the additional
traits on Definition go. The same Callback object now reaches a run
through every path, so CallbacksObserver becomes callbacks.uniq and
Evaluation notifies the callbacks directly. Definition#attributes no
longer memoizes and DeclarationList drops its memo when a declaration
is added, so a modify after first use recomputes the next snapshot.

Trait#clone stays, now used once per child factory at compile time to
inherit the parent's traits, because a parent trait that names a trait
the child redefines must resolve it in the child's scope, and that
scoping is a later step. For the same reason inline sequences keep the
URI based reuse in DefinitionProxy.

Behavior is unchanged for every acceptance example. The one visible
difference is that FactoryBot.modify after a factory has already been
used now applies to subsequent builds instead of being ignored.

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