Skip to content

Commit e37c200

Browse files
thomhurstclaude
andauthored
perf: Add cancellation support and short-circuit to ModuleConditionHandler (#1720)
- 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 <noreply@anthropic.com>
1 parent a61655d commit e37c200

2 files changed

Lines changed: 22 additions & 23 deletions

File tree

src/ModularPipelines/Engine/IModuleConditionHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@ namespace ModularPipelines.Engine;
55

66
internal interface IModuleConditionHandler
77
{
8-
Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module);
8+
Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module, CancellationToken cancellationToken = default);
99
}

src/ModularPipelines/Engine/ModuleConditionHandler.cs

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using System.Reflection;
2-
using EnumerableAsyncProcessor.Extensions;
32
using Microsoft.Extensions.Options;
43
using ModularPipelines.Attributes;
54
using ModularPipelines.Context;
@@ -20,7 +19,7 @@ public ModuleConditionHandler(IOptions<PipelineOptions> pipelineOptions, IPipeli
2019
_pipelineContextProvider = pipelineContextProvider;
2120
}
2221

23-
public async Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module)
22+
public async Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module, CancellationToken cancellationToken = default)
2423
{
2524
var moduleType = module.GetType();
2625

@@ -34,7 +33,7 @@ public ModuleConditionHandler(IOptions<PipelineOptions> pipelineOptions, IPipeli
3433
return (true, SkipDecision.Skip("The module was not in a runnable category"));
3534
}
3635

37-
var conditionResult = await IsRunnableCondition(moduleType).ConfigureAwait(false);
36+
var conditionResult = await IsRunnableCondition(moduleType, cancellationToken).ConfigureAwait(false);
3837
if (!conditionResult.IsRunnable)
3938
{
4039
return (true, conditionResult.SkipDecision);
@@ -71,43 +70,43 @@ private bool IsIgnoreCategory(Type moduleType)
7170
return category != null && ignoreCategories.Contains(category.Category);
7271
}
7372

74-
private async Task<(bool IsRunnable, SkipDecision? SkipDecision)> IsRunnableCondition(Type moduleType)
73+
private async Task<(bool IsRunnable, SkipDecision? SkipDecision)> IsRunnableCondition(Type moduleType, CancellationToken cancellationToken)
7574
{
7675
var mandatoryRunConditionAttributes = moduleType.GetCustomAttributes<MandatoryRunConditionAttribute>(true).ToList();
7776
var runConditionAttributes = moduleType.GetCustomAttributes<RunConditionAttribute>(true).Except(mandatoryRunConditionAttributes).ToList();
7877

7978
// Get a context for condition evaluation
8079
var pipelineContext = _pipelineContextProvider.GetModuleContext();
8180

82-
var mandatoryConditionResults = await mandatoryRunConditionAttributes.ToAsyncProcessorBuilder()
83-
.SelectAsync(async runConditionAttribute => new RunnableConditionMet(await runConditionAttribute.Condition(pipelineContext).ConfigureAwait(false), runConditionAttribute))
84-
.ProcessInParallel();
85-
86-
var mandatoryCondition = mandatoryConditionResults.FirstOrDefault(result => !result.ConditionMet);
87-
88-
if (mandatoryCondition != null)
81+
// Evaluate mandatory conditions sequentially with short-circuit on first failure
82+
foreach (var attr in mandatoryRunConditionAttributes)
8983
{
90-
return (false, SkipDecision.Skip($"A condition to run this module has not been met - {mandatoryCondition.RunConditionAttribute.GetType().Name}"));
84+
cancellationToken.ThrowIfCancellationRequested();
85+
86+
var conditionMet = await attr.Condition(pipelineContext).ConfigureAwait(false);
87+
if (!conditionMet)
88+
{
89+
return (false, SkipDecision.Skip($"A condition to run this module has not been met - {attr.GetType().Name}"));
90+
}
9191
}
9292

9393
if (!runConditionAttributes.Any())
9494
{
9595
return (true, null);
9696
}
9797

98-
var conditionResults = await runConditionAttributes.ToAsyncProcessorBuilder()
99-
.SelectAsync(async runConditionAttribute => new RunnableConditionMet(await runConditionAttribute.Condition(pipelineContext).ConfigureAwait(false), runConditionAttribute))
100-
.ProcessInParallel();
101-
102-
var runnableCondition = conditionResults.FirstOrDefault(result => result.ConditionMet);
103-
104-
if (runnableCondition != null)
98+
// Evaluate non-mandatory conditions sequentially with short-circuit on first success
99+
foreach (var attr in runConditionAttributes)
105100
{
106-
return (true, null);
101+
cancellationToken.ThrowIfCancellationRequested();
102+
103+
var conditionMet = await attr.Condition(pipelineContext).ConfigureAwait(false);
104+
if (conditionMet)
105+
{
106+
return (true, null);
107+
}
107108
}
108109

109110
return (false, SkipDecision.Skip($"No run conditions were met: {string.Join(", ", runConditionAttributes.Select(x => x.GetType().Name.Replace("Attribute", string.Empty, StringComparison.OrdinalIgnoreCase)))}"));
110111
}
111-
112-
private record RunnableConditionMet(bool ConditionMet, RunConditionAttribute RunConditionAttribute);
113112
}

0 commit comments

Comments
 (0)