Cache compiled factory-delegate generators to avoid recompilation across scopes (#1461)#1491
Merged
Conversation
…ompile() calls for the same Func<T> delegate type across sibling lifetime scopes (#1461) Previously, GeneratedFactoryRegistrationSource.RegistrationsFor constructed a new FactoryGenerator for every child scope that needed a Func<T> whose product was registered in a parent scope. Each FactoryGenerator constructor called Expression.Lambda(...).Compile(), which is expensive, even when the resulting delegates were structurally identical (same delegateType + parameterMapping). The fix parameterises the compiled expression trees on 'service' and 'serviceRegistration' (passing those at invocation time via runtime Expression parameters rather than baking them in as Expression.Constant), then caches the compiled delegates in two process-static ConcurrentDictionary instances keyed on (delegateType, effectiveParameterMapping). The instance-specific closure over the concrete service/registration is thin and allocation-free (no additional heap object is needed beyond the FactoryGenerator itself).
…nfrastructure (#1461) The two compiled factory-delegate generator caches (previously raw static ConcurrentDictionary fields on FactoryGenerator) now live as named entries in ReflectionCacheSet.Shared.Internal via a new ReflectionCacheTypeKeyedDictionary<TDiscriminator,TValue> type, so they participate in ReflectionCacheSet.Clear() / Clear(predicate) calls. This prevents Type references from collectible AssemblyLoadContexts being pinned forever by these caches, fixing the memory-leak scenario described in #1461.
Two tests were interacting with the process-global ReflectionCacheSet.Shared in ways that are unsafe under xUnit's parallel test execution: - FactoryDelegate_CompiledGeneratorCache_ClearedByReflectionCacheSetClear called ReflectionCacheSet.Shared.Clear(), wiping the entire process-wide reflection cache that all other parallel tests depend on. Replaced with a fresh new ReflectionCacheSet() instance: directly adds an entry to the GeneratedFactoryServiceRegistrationGenerators cache, asserts NotEmpty, calls set.Clear(), asserts Empty. Proves the cache participates in clearing without any global side effects. - FactoryDelegate_CompiledGeneratorCached_SiblingScopes asserted .Count stability on the shared cache, which is perturbed by parallel test classes populating other entries. Replaced the count-equality assertion with an identity check: captures the cached delegate reference (TryGetValue) after scope1, then after scope2/scope3 asserts Assert.Same on the same key — proving no recompilation occurred for that specific key regardless of what other tests add to the global cache. Also adds FactoryDelegate_CompiledGeneratorCache_ClearedByReflectionCacheSetPredicateClear, a new test (also using a fresh set instance) that exercises the predicate-based Clear(predicate) path, proving that the Type component of the composite key drives assembly-unload selective eviction while unrelated keys are preserved.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## develop #1491 +/- ##
===========================================
+ Coverage 78.00% 78.07% +0.07%
===========================================
Files 217 218 +1
Lines 5897 5944 +47
Branches 1265 1273 +8
===========================================
+ Hits 4600 4641 +41
- Misses 759 763 +4
- Partials 538 540 +2 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Adds a BenchmarkDotNet benchmark that measures the cost of resolving Func<T> from repeated child lifetime scopes where T is registered in the root container. This is the scenario fixed in #1461 where the factory expression tree was recompiled on every child-scope resolution before the compiled-generator cache was introduced.
Previously the benchmark created child scopes with no child-local registrations, so the Func<Product> registration was shared from the root and FactoryGenerator was never invoked per-scope. The bug never fired, which is why the baseline comparison showed ~12% overhead (just cache-lookup cost) rather than a win. The corrected benchmark registers a Consumer(Func<Product>) in each child scope via the configuration action, exactly mirroring the LifetimeWithConsumer_SiblingScopes regression test. This forces the generated-factory registration to be created inside every child scope, triggering the expression-tree recompilation path that #1461 fixes. With this change, Source (fixed) is ~25x faster than Package-9.1.0 at ScopeCount=100 and ScopeCount=1000 (Ratio 0.04), with 35% less memory allocated, proving the benchmark correctly exercises the fix. Fixes #1461
This was referenced Jun 17, 2026
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.
Summary
Fixes #1461. Resolving a factory delegate (
Func<T>,Func<X,T>, custom delegates) in a child lifetime scope — where the productTis registered in the parent — caused Autofac to recompile the delegate's expression tree on every child scope creation.GeneratedFactoryRegistrationSource.RegistrationsForruns again for each new child scope and constructs a newFactoryGenerator, whose constructor unconditionally calledExpression.Lambda(...).Compile(). Because each produced registration gets a freshGuid, theInstancePerLifetimeScopeshare cache never hit across sibling scopes, so structurally identical delegates were recompiled repeatedly. (Despite the title, this is notIStartable-specific — anyFunc<T>consumer in a child scope reproduces it.)Fix
The compiled generator (the result of
Expression.Lambda(...).Compile()) is now cached and reused for structurally identical factory delegates instead of being recompiled per scope.(delegateType, effectiveParameterMapping)— fully determines the structure of the compiled lambda.Adaptiveis resolved toByType/ByNamebefore keying, so it never appears in the key.Expression.Constantvalues (service,productRegistration) are nowExpression.Parameters supplied at invocation time, so the cached delegate carries no instance state and is safe to share across registrations and scopes. The per-FactoryGeneratorclosure supplies the instance-specificservice/productRegistrationat call time.ResolveServicevs resolve-via-ResolveComponent) prevent any cross-overload collision.Cache lifetime / collectible-assembly unload
The caches live in the existing
ReflectionCacheSetinfrastructure (via twoInternalReflectionCachesproperties), exactly like every otherType-keyed cache in Autofac — so they participate inReflectionCacheSet.Clear()andClear(predicate). This means types from a collectibleAssemblyLoadContextare released on unload rather than being pinned forever by a process-static dictionary.A new internal cache type,
ReflectionCacheTypeKeyedDictionary<TDiscriminator, TValue>, holds the(Type, TDiscriminator)key shape (the existingReflectionCacheDictionary/ReflectionCacheTupleDictionaryrequireMemberInfokeys;ParameterMappingis an enum). ItsClear(predicate)matches on theTypecomponent of the key.Usage = Allbecause these generators are needed at resolve time and must survive container build.No public API surface growth beyond the internal cache type.
Tests
All tagged
#1461, two-segment scenario names, no AAA comments:LifetimeWithConsumerrepro across sibling scopes.Func<T>from sibling scopes returns the same cached compiled delegate (Assert.Sameon the specific key's value — robust to parallel tests, no global-count dependence).ByTypeandByNameparameter mapping;InstancePerDependency/InstancePerLifetimeScope/SingleInstancesemantics preserved; per-scope context binding.ReflectionCacheSetproves the new cache participates inClear()and inClear(predicate)(selective assembly-unload eviction on theTypecomponent) — without mutating the process-globalReflectionCacheSet.Shared.Zero warnings; full
Autofac.Testpasses on net8.0 and net10.0; format clean.Benchmark
Adds
GeneratedFactoryChildScopeBenchmark(bench/Autofac.Benchmarks/, registered inBenchmarkSet.All) modeling the exact issue scenario: a component registered in a child scope that depends onFunc<T>whereTis registered in the parent, resolved across many child scopes. Run against a pre-fix baseline:Measured (Source = this fix vs Package-9.1.0 = pre-fix):
The ~25× speedup and 35% allocation reduction come from eliminating the per-child-scope
Expression.Compile(). The benchmark is the permanent guard against regressing this.