Skip to content

Materialize trigger collections to eliminate ConcatIterator CPU waste#215

Open
PhenX wants to merge 3 commits intomasterfrom
fix/concat-iterator-perf
Open

Materialize trigger collections to eliminate ConcatIterator CPU waste#215
PhenX wants to merge 3 commits intomasterfrom
fix/concat-iterator-perf

Conversation

@PhenX
Copy link
Member

@PhenX PhenX commented Mar 21, 2026

…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:
image

Setup:
image

After:

Runtime:
image

Setup:
image

After, v2:

Runtime:
image

Setup:
image

…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)
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> to List<T> and switch WithAdditional* methods to List.Add().
  • Cache GetServiceProviderHashCode() result and use List.Count / count-based short-circuiting in ShouldUseSameServiceProvider().
  • Avoid LINQ .Count() in debug info population.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +128 to +129
private List<(object typeOrInstance, ServiceLifetime lifetime)>? _triggers;
private List<Type> _triggerTypes;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
clone._triggerTypes = clone._triggerTypes.Concat(triggerTypesEnumerable);
}

clone._triggerTypes ??= new List<Type>();
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_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.

Suggested change
clone._triggerTypes ??= new List<Type>();

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 294 to 297
if (!TypeIsValidTrigger(triggerType))
{
throw new ArgumentException("A trigger needs to implement either or both IBeforeSaveChangeTrigger or IAfterSaveChangeTriggerHandler", nameof(triggerType));
}
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +37 to +41
// Only register types that actually implement a known trigger interface
if (TriggerTypeHelper.GetTriggerInterfaces(assemblyType).Length > 0)
{
builder.AddTrigger(assemblyType, lifetime);
}
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// Only register types that actually implement a known trigger interface
if (TriggerTypeHelper.GetTriggerInterfaces(assemblyType).Length > 0)
{
builder.AddTrigger(assemblyType, lifetime);
}
builder.AddTrigger(assemblyType, lifetime);

Copilot uses AI. Check for mistakes.
Comment on lines +16 to +42
// 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),
};
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
else if (_triggerTypes != null)
{
return _triggerTypes.Any(triggerType => TypeHelpers.FindGenericInterfaces(type, triggerType) != null);
return _triggerTypes.Any(triggerType => TypeHelpers.FindGenericInterfaces(type, triggerType).Any());
Copy link

Copilot AI Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)
));

Copilot uses AI. Check for mistakes.
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.

3 participants