Repository navigation
Cache a compiled snapshot per factory and traits - #1851
Draft
neilvcarvalho wants to merge 1 commit into
Draft
neilvcarvalho wants to merge 1 commit into
neilvcarvalho wants to merge 1 commit into
Conversation
neilvcarvalho
added this pull request to stack #1849
October 6, 2026 15:08
This was referenced Oct 6, 2026
neilvcarvalho
force-pushed
the
nc/compiled-factory
branch
from
October 6, 2026 15:31
26a25c8 to
c881903
Compare
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.
neilvcarvalho
force-pushed
the
nc/compiled-factory
branch
from
October 13, 2026 13:15
c881903 to
81662d8
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 itsDefinition, append the traits to the clone and compile it again, which defined a fresh anonymousEvaluatorsubclass on every call.CallbacksObserverexisted to deduplicate callbacks at run time, andFactoryBot.modifyafter 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 andto_createfor one factory and one list of trait names, and it ownsrun.Factory#compile_with_traitscomputes the snapshot from the existingDefinitionandTraitobjects by folding over the parent's snapshot, resolving the requested traits in the factory's scope through the newDefinition#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_factorycaches the snapshot under[factory.name, trait_names]in the configuration.defineandmodifyclear the cache;reloadalready resets the configuration.Factory#compiled(trait_names)is the accessor.FactoryRunner#runasks for the snapshot instead of callingfactory.with_traits(traits).run.Factory#runruns the snapshot without traits, which keeps the existing unit specs working.Evaluationtakes the callback list and notifies it directly. The sameCallbackobject now reaches a run through every path, so deduplication iscallbacks.uniqat snapshot time.Definition#attributesno longer memoizes, andDeclarationListdrops its memo when a declaration is added, so amodifyafter first use recomputes the next snapshot.Deleted
lib/factory_bot/callbacks_observer.rband its require.Factory#with_traits,Factory#initialize_copy, the memoizedevaluator_classandattributesonFactory, and thehierarchy-eracallbacks,compiled_constructorandcompiled_to_createmethods, which now live on the snapshot.Definition#initialize_copy,Definition#append_traits, the additional-trait handling and the attribute memo.NullFactory#attributesandNullFactory#evaluator_class; the root of the chain now answerscompiledwith a snapshot built fromInternal.Deviation from the plan
Trait#cloneandDefinitionProxy#__fetch_or_register_sequencestay. 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.modifyafter a factory has already been used now applies to subsequent builds. Onmainthe 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 laterdefinetaking 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.compiledand assertrunon the snapshot, theNullFactoryspec coverscompiled, andInternalgains two examples for the cache key and for clearing ondefineandmodify.