Skip to content

Cache compiled factory-delegate generators to avoid recompilation across scopes (#1461)#1491

Merged
tillig merged 5 commits into
developfrom
feature/issue-1461
Jun 16, 2026
Merged

Cache compiled factory-delegate generators to avoid recompilation across scopes (#1461)#1491
tillig merged 5 commits into
developfrom
feature/issue-1461

Conversation

@tillig

@tillig tillig commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

Fixes #1461. Resolving a factory delegate (Func<T>, Func<X,T>, custom delegates) in a child lifetime scope — where the product T is registered in the parent — caused Autofac to recompile the delegate's expression tree on every child scope creation. GeneratedFactoryRegistrationSource.RegistrationsFor runs again for each new child scope and constructs a new FactoryGenerator, whose constructor unconditionally called Expression.Lambda(...).Compile(). Because each produced registration gets a fresh Guid, the InstancePerLifetimeScope share cache never hit across sibling scopes, so structurally identical delegates were recompiled repeatedly. (Despite the title, this is not IStartable-specific — any Func<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.

  • Cache key: (delegateType, effectiveParameterMapping) — fully determines the structure of the compiled lambda. Adaptive is resolved to ByType/ByName before keying, so it never appears in the key.
  • Context kept out of the cached delegate: the previously baked-in Expression.Constant values (service, productRegistration) are now Expression.Parameters supplied at invocation time, so the cached delegate carries no instance state and is safe to share across registrations and scopes. The per-FactoryGenerator closure supplies the instance-specific service/productRegistration at call time.
  • Two separate caches for the two constructor overloads (resolve-via-ResolveService vs resolve-via-ResolveComponent) prevent any cross-overload collision.
  • The Issue Func<T,U,...> should not be resolvable if any parameter types are duplicated #269 duplicate-parameter-type guard is preserved in both compile paths.

Cache lifetime / collectible-assembly unload

The caches live in the existing ReflectionCacheSet infrastructure (via two InternalReflectionCaches properties), exactly like every other Type-keyed cache in Autofac — so they participate in ReflectionCacheSet.Clear() and Clear(predicate). This means types from a collectible AssemblyLoadContext are 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 existing ReflectionCacheDictionary/ReflectionCacheTupleDictionary require MemberInfo keys; ParameterMapping is an enum). Its Clear(predicate) matches on the Type component of the key. Usage = All because 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:

  • The issue's LifetimeWithConsumer repro across sibling scopes.
  • Generator reuse: resolving the same Func<T> from sibling scopes returns the same cached compiled delegate (Assert.Same on the specific key's value — robust to parallel tests, no global-count dependence).
  • Collision matrix: different product types for the same delegate type each resolve their own product; ByType and ByName parameter mapping; InstancePerDependency / InstancePerLifetimeScope / SingleInstance semantics preserved; per-scope context binding.
  • Unload clearing: a fresh ReflectionCacheSet proves the new cache participates in Clear() and in Clear(predicate) (selective assembly-unload eviction on the Type component) — without mutating the process-global ReflectionCacheSet.Shared.

Zero warnings; full Autofac.Test passes on net8.0 and net10.0; format clean.

Benchmark

Adds GeneratedFactoryChildScopeBenchmark (bench/Autofac.Benchmarks/, registered in BenchmarkSet.All) modeling the exact issue scenario: a component registered in a child scope that depends on Func<T> where T is registered in the parent, resolved across many child scopes. Run against a pre-fix baseline:

dotnet run -c Release --project bench/Autofac.Benchmarks -- --baseline-version 9.1.0 --filter *GeneratedFactoryChildScopeBenchmark*

Measured (Source = this fix vs Package-9.1.0 = pre-fix):

ScopeCount 9.1.0 Mean Fixed Mean Ratio 9.1.0 Alloc Fixed Alloc Alloc Ratio
100 15,315 µs 546 µs 0.04 (~25× faster) 3.25 MB 2.12 MB 0.65
1000 153,554 µs 5,495 µs 0.04 (~25× faster) 32.49 MB 21.21 MB 0.65

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.

tillig added 3 commits June 16, 2026 08:58
…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

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.10345% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.07%. Comparing base (0e724c2) to head (a5c3de1).
⚠️ Report is 3 commits behind head on develop.

Files with missing lines Patch % Lines
...ac/Features/GeneratedFactories/FactoryGenerator.cs 93.84% 3 Missing and 1 partial ⚠️
...c/Util/Cache/ReflectionCacheTypeKeyedDictionary.cs 80.00% 1 Missing and 1 partial ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tillig added 2 commits June 16, 2026 12:05
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
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.

IStartable in a child lifetime scope causes recompilation of factory delegates

1 participant