From b2dabc7b934a55205e0af3dc4e998ab20f22452d Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:57:27 +0000 Subject: [PATCH] perf: Add cancellation support and short-circuit to ModuleConditionHandler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add CancellationToken parameter to ShouldIgnore method - Change from parallel evaluation to sequential with short-circuit: - Mandatory conditions: stop on first failure - Non-mandatory conditions: stop on first success - Remove unused EnumerableAsyncProcessor dependency - Add cancellation checks between condition evaluations This reduces wasted work when conditions fail early and allows proper cancellation during condition evaluation. Fixes #1571 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../Engine/IModuleConditionHandler.cs | 2 +- .../Engine/ModuleConditionHandler.cs | 43 +++++++++---------- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/ModularPipelines/Engine/IModuleConditionHandler.cs b/src/ModularPipelines/Engine/IModuleConditionHandler.cs index 18d650d718..2e8e072e7f 100644 --- a/src/ModularPipelines/Engine/IModuleConditionHandler.cs +++ b/src/ModularPipelines/Engine/IModuleConditionHandler.cs @@ -5,5 +5,5 @@ namespace ModularPipelines.Engine; internal interface IModuleConditionHandler { - Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module); + Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module, CancellationToken cancellationToken = default); } \ No newline at end of file diff --git a/src/ModularPipelines/Engine/ModuleConditionHandler.cs b/src/ModularPipelines/Engine/ModuleConditionHandler.cs index 06c04075fe..2f40cca009 100644 --- a/src/ModularPipelines/Engine/ModuleConditionHandler.cs +++ b/src/ModularPipelines/Engine/ModuleConditionHandler.cs @@ -1,5 +1,4 @@ using System.Reflection; -using EnumerableAsyncProcessor.Extensions; using Microsoft.Extensions.Options; using ModularPipelines.Attributes; using ModularPipelines.Context; @@ -20,7 +19,7 @@ public ModuleConditionHandler(IOptions pipelineOptions, IPipeli _pipelineContextProvider = pipelineContextProvider; } - public async Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module) + public async Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module, CancellationToken cancellationToken = default) { var moduleType = module.GetType(); @@ -34,7 +33,7 @@ public ModuleConditionHandler(IOptions pipelineOptions, IPipeli return (true, SkipDecision.Skip("The module was not in a runnable category")); } - var conditionResult = await IsRunnableCondition(moduleType).ConfigureAwait(false); + var conditionResult = await IsRunnableCondition(moduleType, cancellationToken).ConfigureAwait(false); if (!conditionResult.IsRunnable) { return (true, conditionResult.SkipDecision); @@ -71,7 +70,7 @@ private bool IsIgnoreCategory(Type moduleType) return category != null && ignoreCategories.Contains(category.Category); } - private async Task<(bool IsRunnable, SkipDecision? SkipDecision)> IsRunnableCondition(Type moduleType) + private async Task<(bool IsRunnable, SkipDecision? SkipDecision)> IsRunnableCondition(Type moduleType, CancellationToken cancellationToken) { var mandatoryRunConditionAttributes = moduleType.GetCustomAttributes(true).ToList(); var runConditionAttributes = moduleType.GetCustomAttributes(true).Except(mandatoryRunConditionAttributes).ToList(); @@ -79,15 +78,16 @@ private bool IsIgnoreCategory(Type moduleType) // Get a context for condition evaluation var pipelineContext = _pipelineContextProvider.GetModuleContext(); - var mandatoryConditionResults = await mandatoryRunConditionAttributes.ToAsyncProcessorBuilder() - .SelectAsync(async runConditionAttribute => new RunnableConditionMet(await runConditionAttribute.Condition(pipelineContext).ConfigureAwait(false), runConditionAttribute)) - .ProcessInParallel(); - - var mandatoryCondition = mandatoryConditionResults.FirstOrDefault(result => !result.ConditionMet); - - if (mandatoryCondition != null) + // Evaluate mandatory conditions sequentially with short-circuit on first failure + foreach (var attr in mandatoryRunConditionAttributes) { - return (false, SkipDecision.Skip($"A condition to run this module has not been met - {mandatoryCondition.RunConditionAttribute.GetType().Name}")); + cancellationToken.ThrowIfCancellationRequested(); + + var conditionMet = await attr.Condition(pipelineContext).ConfigureAwait(false); + if (!conditionMet) + { + return (false, SkipDecision.Skip($"A condition to run this module has not been met - {attr.GetType().Name}")); + } } if (!runConditionAttributes.Any()) @@ -95,19 +95,18 @@ private bool IsIgnoreCategory(Type moduleType) return (true, null); } - var conditionResults = await runConditionAttributes.ToAsyncProcessorBuilder() - .SelectAsync(async runConditionAttribute => new RunnableConditionMet(await runConditionAttribute.Condition(pipelineContext).ConfigureAwait(false), runConditionAttribute)) - .ProcessInParallel(); - - var runnableCondition = conditionResults.FirstOrDefault(result => result.ConditionMet); - - if (runnableCondition != null) + // Evaluate non-mandatory conditions sequentially with short-circuit on first success + foreach (var attr in runConditionAttributes) { - return (true, null); + cancellationToken.ThrowIfCancellationRequested(); + + var conditionMet = await attr.Condition(pipelineContext).ConfigureAwait(false); + if (conditionMet) + { + return (true, null); + } } return (false, SkipDecision.Skip($"No run conditions were met: {string.Join(", ", runConditionAttributes.Select(x => x.GetType().Name.Replace("Attribute", string.Empty, StringComparison.OrdinalIgnoreCase)))}")); } - - private record RunnableConditionMet(bool ConditionMet, RunConditionAttribute RunConditionAttribute); } \ No newline at end of file