Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/docs/how-to/run-conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,13 @@ public class DeployModule : Module<None>
- `[RunIfAny<T1, T2>]` runs when at least one condition is `true`.

Multiple condition attributes are evaluated in this order: `SkipIf`, `RunIfAll`, then
`RunIfAny`. Attribute conditions run during module discovery and register a skipped result
directly. Fluent `.WithSkipWhen(...)` conditions run later in the execution pipeline and invoke
the skipped hooks and lifecycle notifications.
`RunIfAny`. Attribute conditions and fluent `.WithSkipWhen(...)` conditions run in the same
execution pipeline after dependency waiting. Both invoke skipped hooks and lifecycle
notifications.

Fluent dependencies are validated before execution conditions are evaluated. Every dependency
declared with `DependsOn<T>()` must therefore be registered, even when an attribute condition
will skip the consuming module on the current platform or environment.

Built-in platform conditions include `OnLinux`, `OnWindows`, and `OnMacOS`:

Expand Down
36 changes: 24 additions & 12 deletions docs/docs/how-to/skipping.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ sidebar_position: 7

The recommended way to configure module skipping is through the `Configure()` method with the fluent builder API:

Attribute conditions (`[SkipIf<T>]`, `[RunIfAll<T>]`, and `[RunIfAny<T>]`) remain supported,
but they run during module discovery, before dependency waiting. An ignored module receives a
skipped result directly. Fluent `.WithSkipWhen(...)` conditions run in the execution pipeline,
where skipped hooks and lifecycle notifications are invoked. Use the fluent API when consumers
depend on those notifications.
Attribute conditions (`[SkipIf<T>]`, `[RunIfAll<T>]`, and `[RunIfAny<T>]`) remain supported.
Attribute and fluent conditions run in the same execution pipeline after dependency waiting, so
both invoke skipped hooks and lifecycle notifications.

### Simple Condition

Expand Down Expand Up @@ -103,12 +101,11 @@ public class MyModule : Module<CommandResult>

## Combining with Other Behaviors

Repeated skip conditions use AND-to-skip semantics. They run in registration order, and the module
is skipped only when every condition returns `SkipDecision.Skip`. A `SkipDecision.DoNotSkip`
result stops evaluation and keeps the module eligible to run. When every condition skips, their
reasons are combined.
Repeated `WithSkipWhen` conditions use OR-to-skip semantics, matching repeated `[SkipIf<T>]`
attributes. They run in registration order, and evaluation stops when any condition returns
`SkipDecision.Skip`.

For example, this module skips cleanup only for CI builds that are not on the main branch:
For example, this module skips cleanup for either CI builds or non-main branches:

```csharp
public class CleanupModule : Module<CommandResult>
Expand All @@ -127,8 +124,23 @@ public class CleanupModule : Module<CommandResult>
}
```

When any one of several independent predicates should be enough to skip, combine them in a single
condition and return `SkipDecision.Skip` when their OR expression is true.
When every condition must match before the module is skipped, group them explicitly with
`WithSkipWhenAll`:

```csharp
protected override ModuleConfiguration Configure() => ModuleConfiguration.Create()
.WithSkipWhenAll(
_ => Environment.GetEnvironmentVariable("CI") == "true"
? SkipDecision.Skip("Running in CI")
: SkipDecision.DoNotSkip,
_ => Environment.GetEnvironmentVariable("DEPLOY_ENV") != "production"
? SkipDecision.Skip("Not deploying to production")
: SkipDecision.DoNotSkip)
.Build();
```

Conditions inside a `WithSkipWhenAll` group use AND-to-skip semantics and combine their reasons.
The group composes with other skip conditions using OR-to-skip semantics.

## History
If a module was skipped, you can attempt to find its history from a previous run. See [History](storing-and-retrieving-results)
Expand Down
75 changes: 57 additions & 18 deletions src/ModularPipelines/Attributes/OperatingSystemConditions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,43 +29,42 @@ internal static class OperatingSystemConditions
/// </summary>
public static IReadOnlyList<string> GetTargets(IConditionAttribute attribute)
{
if (attribute.Logic != ConditionLogic.All)
{
return [];
}
var supportedOperatingSystems = GetSupportedOperatingSystems(attribute);

var conditionTypes = attribute.GetType().GetGenericArguments();
if (conditionTypes.Length == 0)
if (supportedOperatingSystems is null || supportedOperatingSystems.Count == 0)
{
return [];
}

return [CreateCapability(supportedOperatingSystems)];
}

/// <summary>
/// Returns whether all-platform attributes require mutually exclusive operating systems.
/// </summary>
public static bool HasImpossibleCombination(IEnumerable<IConditionAttribute> attributes)
{
HashSet<string>? supportedOperatingSystems = null;

foreach (var conditionType in conditionTypes)
foreach (var attribute in attributes)
{
var conditionOperatingSystems = GetSupportedOperatingSystems(conditionType);
if (conditionOperatingSystems is null)
var attributeOperatingSystems = GetSupportedOperatingSystems(attribute);
if (attributeOperatingSystems is null)
{
return [];
continue;
}

if (supportedOperatingSystems is null)
{
supportedOperatingSystems = conditionOperatingSystems;
supportedOperatingSystems = attributeOperatingSystems;
}
else
{
supportedOperatingSystems.IntersectWith(conditionOperatingSystems);
supportedOperatingSystems.IntersectWith(attributeOperatingSystems);
}
}

if (supportedOperatingSystems is null || supportedOperatingSystems.Count == 0)
{
return [];
}

return [CreateCapability(supportedOperatingSystems)];
return supportedOperatingSystems is { Count: 0 };
}

/// <summary>
Expand Down Expand Up @@ -98,6 +97,46 @@ public static IReadOnlyList<string> GetWorkerCapabilities(string operatingSystem
return capabilities;
}

[UnconditionalSuppressMessage(
"Trimming",
"IL2067",
Justification = "Condition types come from RunIfAll<T> generic arguments, whose new() constraint preserves a public parameterless constructor.")]
private static HashSet<string>? GetSupportedOperatingSystems(IConditionAttribute attribute)
{
if (attribute.Logic != ConditionLogic.All)
{
return null;
}

var conditionTypes = attribute.GetType().GetGenericArguments();
if (conditionTypes.Length == 0)
{
return null;
}

HashSet<string>? supportedOperatingSystems = null;

foreach (var conditionType in conditionTypes)
{
var conditionOperatingSystems = GetSupportedOperatingSystems(conditionType);
if (conditionOperatingSystems is null)
{
return null;
}

if (supportedOperatingSystems is null)
{
supportedOperatingSystems = conditionOperatingSystems;
}
else
{
supportedOperatingSystems.IntersectWith(conditionOperatingSystems);
}
}

return supportedOperatingSystems;
}

[UnconditionalSuppressMessage(
"Trimming",
"IL2067",
Expand Down
89 changes: 80 additions & 9 deletions src/ModularPipelines/Configuration/ModuleConfigurationBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,13 @@ public sealed class ModuleConfigurationBuilder
/// <param name="condition">A function that receives the module context and returns a <see cref="SkipDecision"/>.</param>
/// <returns>This builder instance for method chaining.</returns>
/// <remarks>
/// Repeated conditions are evaluated in registration order and combined with AND-to-skip semantics:
/// the module is skipped only when every condition returns <see cref="SkipDecision.Skip(string?)"/>.
/// Any <see cref="SkipDecision.DoNotSkip"/> result keeps the module eligible to run.
/// To skip when any of several predicates matches, combine those predicates in one condition.
/// Repeated conditions use OR-to-skip semantics. Evaluation stops when a condition returns
/// <see cref="SkipDecision.Skip(string?)"/>.
/// </remarks>
public ModuleConfigurationBuilder WithSkipWhen(Func<IModuleContext, SkipDecision> condition)
{
ArgumentNullException.ThrowIfNull(condition);
_skipConditions.Add((context, _) => ValueTask.FromResult(condition(context)));
_skipConditions.Add(AdaptSkipCondition(condition));
return this;
}

Expand All @@ -79,10 +77,8 @@ public ModuleConfigurationBuilder WithSkipWhen(Func<IModuleContext, SkipDecision
/// <param name="condition">A function that receives the module context and cancellation token and returns a <see cref="SkipDecision"/>.</param>
/// <returns>This builder instance for method chaining.</returns>
/// <remarks>
/// Repeated conditions are evaluated in registration order and combined with AND-to-skip semantics:
/// the module is skipped only when every condition returns <see cref="SkipDecision.Skip(string?)"/>.
/// Any <see cref="SkipDecision.DoNotSkip"/> result keeps the module eligible to run.
/// To skip when any of several predicates matches, combine those predicates in one condition.
/// Repeated conditions use OR-to-skip semantics. Evaluation stops when a condition returns
/// <see cref="SkipDecision.Skip(string?)"/>.
/// </remarks>
public ModuleConfigurationBuilder WithSkipWhen(
Func<IModuleContext, CancellationToken, ValueTask<SkipDecision>> condition)
Expand All @@ -92,6 +88,43 @@ public ModuleConfigurationBuilder WithSkipWhen(
return this;
}

/// <summary>
/// Adds a synchronous group of conditions that must all return skip decisions to skip the module.
/// </summary>
/// <param name="conditions">Conditions evaluated in registration order with AND-to-skip semantics.</param>
/// <returns>This builder instance for method chaining.</returns>
/// <remarks>
/// Each call adds one AND group. The group composes with other skip conditions using OR-to-skip semantics.
/// </remarks>
public ModuleConfigurationBuilder WithSkipWhenAll(
params Func<IModuleContext, SkipDecision>[] conditions)
{
ArgumentNullException.ThrowIfNull(conditions);
ValidateSkipConditionGroup(conditions);

_skipConditions.Add(ComposeAllSkipConditions(
Array.ConvertAll(conditions, AdaptSkipCondition)));
return this;
}

/// <summary>
/// Adds an asynchronous group of conditions that must all return skip decisions to skip the module.
/// </summary>
/// <param name="conditions">Conditions evaluated in registration order with AND-to-skip semantics.</param>
/// <returns>This builder instance for method chaining.</returns>
/// <remarks>
/// Each call adds one AND group. The group composes with other skip conditions using OR-to-skip semantics.
/// </remarks>
public ModuleConfigurationBuilder WithSkipWhenAll(
params Func<IModuleContext, CancellationToken, ValueTask<SkipDecision>>[] conditions)
{
ArgumentNullException.ThrowIfNull(conditions);
ValidateSkipConditionGroup(conditions);

_skipConditions.Add(ComposeAllSkipConditions([.. conditions]));
return this;
}

#endregion

#region Scheduling and Metadata
Expand Down Expand Up @@ -371,6 +404,24 @@ internal ModuleConfigurationBuilder SetAdvancedRetryPolicy(Func<IModuleContext,
}

var conditions = _skipConditions.ToArray();
return async (context, cancellationToken) =>
{
foreach (var condition in conditions)
{
var decision = await condition(context, cancellationToken).ConfigureAwait(false);
if (decision.ShouldSkip)
{
return decision;
}
}

return SkipDecision.DoNotSkip;
};
}

private static Func<IModuleContext, CancellationToken, ValueTask<SkipDecision>> ComposeAllSkipConditions(
IReadOnlyList<Func<IModuleContext, CancellationToken, ValueTask<SkipDecision>>> conditions)
{
return async (context, cancellationToken) =>
{
List<string>? reasons = null;
Expand All @@ -393,6 +444,26 @@ internal ModuleConfigurationBuilder SetAdvancedRetryPolicy(Func<IModuleContext,
};
}

private static Func<IModuleContext, CancellationToken, ValueTask<SkipDecision>> AdaptSkipCondition(
Func<IModuleContext, SkipDecision> condition)
{
return (context, _) => ValueTask.FromResult(condition(context));
}

private static void ValidateSkipConditionGroup<TCondition>(TCondition[] conditions)
where TCondition : Delegate
{
if (conditions.Length == 0)
{
throw new ArgumentException("At least one skip condition is required.", nameof(conditions));
}

if (conditions.Any(static condition => condition is null))
{
throw new ArgumentException("Skip conditions cannot contain null values.", nameof(conditions));
}
}

private static void ValidateModuleType(Type moduleType)
{
ArgumentNullException.ThrowIfNull(moduleType);
Expand Down
6 changes: 5 additions & 1 deletion src/ModularPipelines/Engine/IModuleConditionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,9 @@ namespace ModularPipelines.Engine;

internal interface IModuleConditionHandler
{
Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnoreByCategory(
IModule module,
CancellationToken cancellationToken = default);

Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnore(IModule module, CancellationToken cancellationToken = default);
}
}
36 changes: 32 additions & 4 deletions src/ModularPipelines/Engine/ModuleConditionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,40 @@ public ModuleConditionHandler(
}
}

public Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> ShouldIgnoreByCategory(
IModule module,
CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var result = EvaluateCategoryConditions(module);
if (!result.ShouldIgnore
&& IsDistributedMaster()
&& OperatingSystemConditions.HasImpossibleCombination(GetConditionAttributes(module.GetType()).All))
{
result = (true, SkipDecision.Skip("Module requires mutually exclusive operating systems"));
}

return Task.FromResult(result);
}

private async Task<(bool ShouldIgnore, SkipDecision? SkipDecision)> EvaluateShouldIgnore(
IModule module,
CancellationToken cancellationToken)
{
var categoryResult = EvaluateCategoryConditions(module);
if (categoryResult.ShouldIgnore)
{
return categoryResult;
}

var moduleType = module.GetType();
var conditionResult = await IsRunnableCondition(moduleType, cancellationToken).ConfigureAwait(false);
return conditionResult.IsRunnable
? (false, null)
: (true, conditionResult.SkipDecision);
}

private (bool ShouldIgnore, SkipDecision? SkipDecision) EvaluateCategoryConditions(IModule module)
{
var moduleType = module.GetType();
_metadataRegistry.FinalizeMetadata(moduleType, module);
Expand All @@ -80,10 +111,7 @@ public ModuleConditionHandler(
return (true, SkipDecision.Skip("The module was not in a runnable category"));
}

var conditionResult = await IsRunnableCondition(moduleType, cancellationToken).ConfigureAwait(false);
return conditionResult.IsRunnable
? (false, null)
: (true, conditionResult.SkipDecision);
return (false, null);
}

private bool IsRunnableCategory(string? category)
Expand Down
Loading
Loading