Materialize trigger collections to eliminate ConcatIterator CPU waste#215
Materialize trigger collections to eliminate ConcatIterator CPU waste#215
Conversation
…r CPU waste Replace IEnumerable<T> fields with List<T> to avoid deeply nested ConcatIterator chains from repeated .Concat() calls. Each WithAdditionalTrigger call now uses List.Add() instead. Also cache the service provider hash code, use O(1) List.Count property instead of LINQ .Count(), and add count-based fast paths in ShouldUseSameServiceProvider to short-circuit before SequenceEqual. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> (cherry picked from commit 0cc6aa1)
There was a problem hiding this comment.
Pull request overview
This PR optimizes TriggersOptionExtension to avoid CPU overhead from repeatedly chaining IEnumerable.Concat() (creating deep ConcatIterator trees) by materializing trigger collections into List<T>, and adds a couple of small fast paths around service-provider caching comparisons.
Changes:
- Replace trigger registries from
IEnumerable<T>toList<T>and switchWithAdditional*methods toList.Add(). - Cache
GetServiceProviderHashCode()result and useList.Count/ count-based short-circuiting inShouldUseSameServiceProvider(). - Avoid LINQ
.Count()in debug info population.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/EntityFrameworkCore.Triggered/Infrastructure/Internal/TriggersOptionExtension.cs
Show resolved
Hide resolved
| private List<(object typeOrInstance, ServiceLifetime lifetime)>? _triggers; | ||
| private List<Type> _triggerTypes; |
There was a problem hiding this comment.
Changing _triggers to List<...> means the public Triggers property now returns a reference to a mutable internal collection (it’s exposed as IEnumerable, but consumers can still cast back to List and mutate). That can invalidate the new GetServiceProviderHashCode() cache and break the expectation that option extensions are effectively immutable. Consider returning a read-only wrapper (AsReadOnly()), an IReadOnlyList, or otherwise preventing external mutation.
| clone._triggerTypes = clone._triggerTypes.Concat(triggerTypesEnumerable); | ||
| } | ||
|
|
||
| clone._triggerTypes ??= new List<Type>(); |
There was a problem hiding this comment.
_triggerTypes is non-nullable and always initialized in constructors, so clone._triggerTypes ??= new List<Type>(); is redundant and suggests the field can be null. Either remove the null-coalescing assignment here, or (if null is a valid state) make _triggerTypes nullable consistently and handle that throughout.
| clone._triggerTypes ??= new List<Type>(); |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!TypeIsValidTrigger(triggerType)) | ||
| { | ||
| throw new ArgumentException("A trigger needs to implement either or both IBeforeSaveChangeTrigger or IAfterSaveChangeTriggerHandler", nameof(triggerType)); | ||
| } |
There was a problem hiding this comment.
The ArgumentException message thrown for invalid triggers references IBeforeSaveChangeTrigger/IAfterSaveChangeTriggerHandler, which don't appear to exist in this codebase and don't match the current lifecycle interface names. Since TypeIsValidTrigger is now stricter, this message is more likely to surface—please update it to mention the actual supported trigger interfaces (or a generic “known trigger interface” wording).
| // Only register types that actually implement a known trigger interface | ||
| if (TriggerTypeHelper.GetTriggerInterfaces(assemblyType).Length > 0) | ||
| { | ||
| builder.AddTrigger(assemblyType, lifetime); | ||
| } |
There was a problem hiding this comment.
AddAssemblyTriggers now filters types using TriggerTypeHelper.GetTriggerInterfaces(), which is a hard-coded list of “known” lifecycle interfaces. This means triggers for lifecycle interfaces added via AddTriggerType (e.g., transaction trigger interfaces from UseTransactionTriggers) will be silently skipped during assembly scanning, even though they’re valid trigger types once configured.
| // Only register types that actually implement a known trigger interface | |
| if (TriggerTypeHelper.GetTriggerInterfaces(assemblyType).Length > 0) | |
| { | |
| builder.AddTrigger(assemblyType, lifetime); | |
| } | |
| builder.AddTrigger(assemblyType, lifetime); |
| // Open generic trigger interfaces — add new generic lifecycle interfaces here | ||
| private readonly static HashSet<Type> _genericTriggerTypes = new HashSet<Type> | ||
| { | ||
| typeof(IBeforeSaveTrigger<>), | ||
| typeof(IBeforeSaveAsyncTrigger<>), | ||
| typeof(IAfterSaveTrigger<>), | ||
| typeof(IAfterSaveAsyncTrigger<>), | ||
| typeof(IAfterSaveFailedTrigger<>), | ||
| typeof(IAfterSaveFailedAsyncTrigger<>), | ||
| }; | ||
|
|
||
| // Non-generic lifecycle interfaces — add new non-generic lifecycle interfaces here | ||
| private readonly static HashSet<Type> _nonGenericTriggerTypes = new HashSet<Type> | ||
| { | ||
| typeof(IBeforeSaveStartingTrigger), | ||
| typeof(IBeforeSaveStartingAsyncTrigger), | ||
| typeof(IBeforeSaveCompletedTrigger), | ||
| typeof(IBeforeSaveCompletedAsyncTrigger), | ||
| typeof(IAfterSaveFailedStartingTrigger), | ||
| typeof(IAfterSaveFailedStartingAsyncTrigger), | ||
| typeof(IAfterSaveFailedCompletedTrigger), | ||
| typeof(IAfterSaveFailedCompletedAsyncTrigger), | ||
| typeof(IAfterSaveStartingTrigger), | ||
| typeof(IAfterSaveStartingAsyncTrigger), | ||
| typeof(IAfterSaveCompletedTrigger), | ||
| typeof(IAfterSaveCompletedAsyncTrigger), | ||
| }; |
There was a problem hiding this comment.
TriggerTypeHelper’s interface sets are hard-coded to the base save-change lifecycles. If a consumer adds additional lifecycle interfaces via options (e.g., EntityFrameworkCore.Triggered.Transactions lifecycles or other custom lifecycles), GetTriggerInterfaces will return an empty array and assembly scanning helpers will not register those triggers. Consider making the “known trigger types” list extensible (e.g., allow registering additional interface types) or avoid using this helper for options-builder scanning where the valid trigger-type list is configurable.
| else if (_triggerTypes != null) | ||
| { | ||
| return _triggerTypes.Any(triggerType => TypeHelpers.FindGenericInterfaces(type, triggerType) != null); | ||
| return _triggerTypes.Any(triggerType => TypeHelpers.FindGenericInterfaces(type, triggerType).Any()); |
There was a problem hiding this comment.
TypeIsValidTrigger now uses TypeHelpers.FindGenericInterfaces(...) for entries in _triggerTypes, but FindGenericInterfaces only matches generic interfaces. This makes any non-generic lifecycle trigger interfaces (e.g., IBeforeSaveStartingTrigger, IAfterSaveCompletedTrigger, etc.) fail validation and prevents registering those triggers via WithAdditionalTrigger/AddTrigger.
| return _triggerTypes.Any(triggerType => TypeHelpers.FindGenericInterfaces(type, triggerType).Any()); | |
| return _triggerTypes.Any(triggerType => | |
| triggerType != null && | |
| ( | |
| triggerType.IsGenericTypeDefinition | |
| ? TypeHelpers.FindGenericInterfaces(type, triggerType).Any() | |
| : triggerType.IsAssignableFrom(type) | |
| )); |
…r CPU waste
Replace IEnumerable fields with List to avoid deeply nested ConcatIterator chains from repeated .Concat() calls. Each WithAdditionalTrigger call now uses List.Add() instead. Also cache the service provider hash code, use O(1) List.Count property instead of LINQ .Count(), and add count-based fast paths in ShouldUseSameServiceProvider to short-circuit before SequenceEqual.
(cherry picked from commit 0cc6aa1)
Benchmarks
Before:
Runtime:

Setup:

After:
Runtime:

Setup:

After, v2:
Runtime:

Setup:
