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
72 changes: 52 additions & 20 deletions docs/docs/pipeline-runners.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,69 @@ title: Pipeline Runners

A pipeline runner is responsible for executing a pipeline by resolving and invoking each registered step in order. It handles lazy instantiation, short-circuiting, cancellation, and exceptions during execution.

## Runner Interfaces and Defaults
## Default Runner Interfaces and Implementation

All runners implement the `IPipelineRunner<TContext, TStepInterface>` interface, where `TStepInterface` must implement `IPipelineStep<TContext>`. In most cases, `TStepInterface` is a custom interface used to identify which steps belong to a specific pipeline.
All runners implement the `IPipelineRunner<TContext, TStepInterface>` interface, where `TStepInterface` must implement `IPipelineStep<TContext>`. `TStepInterface` is typically used to distinguish steps when multiple pipelines share the same context type. If your application uses only one pipeline per context, you can use the simplified `IPipelineRunner<TContext>` interface, which is easier to register and resolve from the dependency injection container.

If you're not using a custom step interface, you can use the convenience interface `IPipelineRunner<TContext>`. This is just a shorthand for `IPipelineRunner<TContext, IPipelineStep<TContext>>`.
PipeForge provides default implementations for both interfaces. You only need a custom runner if you want to override execution behavior or manually manage dependency resolution.

```csharp title="IPipelineRunner.cs"
IPipelineRunner<TContext, TStepInterface>
where T : class
where TStepInterface : IPipelineStep<TContext>
{ }
## Custom Runner Interfaces

IPipelineRunner<TContext> : IPipelineRunner<TContext, IPipelineStep<TContext>>
where TContext : class
{ }
When using custom step interfaces (e.g. for multiple pipelines with the same context), you can define a corresponding runner interface without implementing it—PipeForge will generate the implementation automatically in supported environments. This simplifies DI registration and resolution by avoiding generic type repetition.

```csharp title="Create the interface"
public interface ISampleContextRunner
: IPipelineRunner<SampleContext, ISampleContextStep> { }
```

```csharp title="Register the interface"
builder.Services.AddPipeline<SampleContext, ISampleContextStep, ISampleContextRunner>();
```

PipeForge provides default runner implementations for `IPipelineRunner<TContext, TStepInterface>` and `IPipelineRunner<TContext>`, so you don't need to write a custom runner unless you want to customize behavior or manage dependency resolution differently.
```csharp title="Resolve the interface"
public class MyService
{
private readonly ISampleContextRunner _runner;

If you're using custom step interfaces (e.g. to support multiple pipelines), it can be helpful to define a matching custom runner. PipeForge provides a base class, `PipelineRunner<TContext, TStepInterface>`, to make this easy:
public MyService(ISampleContextRunner runner)
{
_runner = runner;
}
}
```

```csharp
public interface ISampleContextRunner
: IPipelineRunner<SampleContext, ISampleContextStep> { }
:::tip

In .NET 5.0 and later (excluding AOT), PipeForge generates the runner implementation automatically—you only need to define the interface.

:::

## Custom Runner Implementation

The following table summarizes when you need to provide a **concrete implementation**:

| Target Environment | Interface Only | Concrete Implementation Required |
| ------------------ | -------------- | -------------------------------- |
| .NET 5.0 or higher | ✅ | ❌ |
| Native AOT | ❌ | ✅ |
| .NET Standard 2.0 | ❌ | ✅ |

If you’re targeting .NET Standard 2.0 or Native AOT, you must provide a concrete implementation. PipeForge makes this easy by providing a base class, `PipelineRunner<TContext, TStepInterface>`:

```csharp title="Custom interface implementation"
public class SampleContextRunner
: PipelineRunner<SampleContext, ISampleContextStep>, ISampleContextRunner { }
: PipelineRunner<SampleContext, ISampleContextStep>, ISampleContextRunner
{
public SampleContextRunner(IServiceProvider serviceProvider) : base(serviceProvider)
{
}
}
```

This approach allows you to resolve your runner via `ISampleContextRunner`, rather than repeating the full generic signature. When using PipeForge's registration extensions, your custom runner will be registered automatically - no manual wiring required.
This approach allows you to register and resolve your runner via `ISampleContextRunner`, same as before.

:::tip

## Custom Runners
To customize behavior (e.g. logging, diagnostics, instrumentation), implement `IPipelineRunner<TContext, TStepInterface>` directly or subclass `PipelineRunner<TContext, TStepInterface>`.

If you need to extend the default behavior (e.g. to add logging, diagnostics, or custom instrumentation), you can create your own runner by implementing `IPipelineRunner<TContext, TStepInterface>` directly, and optionally extending `PipelineRunner<TContext, TStepInterface>`.
:::
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ public void RegisterRunner_ReturnsFalse_WhenRunnerIsAlreadyRegisters()

result.ShouldBeFalse();
}

#if NETSTANDARD2_0
[Fact]
public void RegisterRunner_Throws_WhenNoRunnerImplementationFound()
{
Expand All @@ -110,4 +110,28 @@ public void RegisterRunner_Throws_WhenNoRunnerImplementationFound()

ex.Message.ShouldBe(string.Format(PipeForge.PipelineRegistration.MessageRunnerImplementationNotFound, runnerTypeName));
}
#else
[Fact]
public void RegisterRunner_GeneratesRunner_WhenNoRunnerImplementationFound()
{
var runnerTypeName = typeof(INotImplementedRunner).FullName ?? typeof(INotImplementedRunner).Name;
var services = new ServiceCollection();

services.RegisterRunner<SampleContext, ISampleContextStep, INotImplementedRunner>(Assemblies, ServiceLifetime.Transient, null);

var provider = services.BuildServiceProvider();
var runner = provider.GetService<INotImplementedRunner>();

runner.ShouldNotBeNull();
var type = runner.GetType();

type.ShouldNotBeNull();
type.IsAbstract.ShouldBeFalse();
type.IsInterface.ShouldBeFalse();
type.IsGenericType.ShouldBeFalse();

typeof(INotImplementedRunner).IsAssignableFrom(type).ShouldBeTrue();
typeof(PipelineRunner<SampleContext, ISampleContextStep>).IsAssignableFrom(type).ShouldBeTrue();
}
#endif
}
28 changes: 28 additions & 0 deletions src/PipeForge.Tests/PipelineRunnerFactoryTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using PipeForge.Tests.Steps;

namespace PipeForge.Tests;

public class PipelineRunnerFactoryTests
{
private readonly Assembly[] _assemblies = [typeof(PipelineRunnerFactoryTests).Assembly];
private readonly ILogger _logger = new LoggerFactory().CreateLogger("Tests");


public interface IGeneratedRunner : IPipelineRunner<SampleContext, ISampleContextStep> { }

[Fact]
public void Factory_Creates_Type_That_Implements_Interface_And_Inherits_Base()
{
var type = PipelineRunnerFactory.CreatePipelineRunner<SampleContext, ISampleContextStep, IGeneratedRunner>();

type.ShouldNotBeNull();
type.IsAbstract.ShouldBeFalse();
type.IsInterface.ShouldBeFalse();
type.IsGenericType.ShouldBeFalse();

typeof(IGeneratedRunner).IsAssignableFrom(type).ShouldBeTrue();
typeof(PipelineRunner<SampleContext, ISampleContextStep>).IsAssignableFrom(type).ShouldBeTrue();
}
}
26 changes: 26 additions & 0 deletions src/PipeForge/PipelineRegistration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ internal static class PipelineRegistration
internal static readonly string MessageNoStepsFound = "No pipeline steps found for {0} in the specified assemblies.";
internal static readonly string MessageNumberStepsFound = "Discovered and registered {0} pipeline steps for {1}.";
internal static readonly string MessageRunnerAlreadyRegistered = "Pipeline runner for {0} already registered. Skipping step registration.";
internal static readonly string MessageRunnerDynamicCodeNotSupported = "Dynamic code generation is not supported on this runtime.";
internal static readonly string MessageRunnerFailedDynamicCreation = "Failed to create dynamic pipeline runner for {0}.";
internal static readonly string MessageRunnerImplementationNotFound = "No concrete implementation found for pipeline runner interface '{0}'. If you are using a custom runner interface, you must also provide an implementation for it.";
internal static readonly string MessageRunnerRegistration = "Registering pipeline runner implementation {0} for interface {1} with {2} lifetime";
internal static readonly string MessageStepAlreadyRegistered = "Pipeline step '{0}' is already registered. Pipeline steps must be uniquely registered.";
Expand Down Expand Up @@ -110,9 +112,33 @@ public static bool RegisterRunner<TContext, TStepInterface, TRunnerInterface>(
return true;
}

#if NET5_0_OR_GREATER
// 4. Attempt to create a dynamic runner using the factory.
try
{
if (!System.Runtime.CompilerServices.RuntimeFeature.IsDynamicCodeSupported)
throw new PlatformNotSupportedException(MessageRunnerDynamicCodeNotSupported);

var proxy = PipelineRunnerFactory.CreatePipelineRunner<TContext, TStepInterface, TRunnerInterface>();
logger?.LogDebug(MessageRunnerRegistration, proxy.GetTypeName(), runnerType.GetTypeName(), lifetime.ToString());
services.TryAdd(ServiceDescriptor.Describe(runnerType, proxy, lifetime));
return true;
}
catch (PlatformNotSupportedException ex)
{
logger?.LogError(ex, MessageRunnerDynamicCodeNotSupported, runnerType.GetTypeName());
throw;
}
catch (Exception ex)
{
logger?.LogError(ex, MessageRunnerFailedDynamicCreation, runnerType.GetTypeName());
throw;
}
#else
// 4. Throw an exception when no implementation found, and the default is not valid
logger?.LogWarning(MessageRunnerImplementationNotFound, runnerType.GetTypeName());
throw new InvalidOperationException(string.Format(MessageRunnerImplementationNotFound, runnerType.GetTypeName()));
#endif
}

public static void RegisterStep(
Expand Down
55 changes: 55 additions & 0 deletions src/PipeForge/PipelineRunnerFactory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#if NET5_0_OR_GREATER
using System.Reflection;
using System.Reflection.Emit;

namespace PipeForge;

internal static class PipelineRunnerFactory
{
private static readonly AssemblyBuilder _assemblyBuilder = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("PipeForge.DynamicPipelines"), AssemblyBuilderAccess.Run);

private static readonly ModuleBuilder _moduleBuilder = _assemblyBuilder.DefineDynamicModule("MainModule");

private static readonly Type[] _parameterTypes = [typeof(IServiceProvider)];

public static Type CreatePipelineRunner<TContext, TStepInterface, TRunnerInterface>()
where TContext : class
where TStepInterface : IPipelineStep<TContext>
where TRunnerInterface : IPipelineRunner<TContext, TStepInterface>
{
if (!typeof(TRunnerInterface).IsInterface)
throw new InvalidOperationException("TRunnerInterface must be an interface.");

var typeBuilder = _moduleBuilder.DefineType(
$"PipeForge.DynamicPipelines.{typeof(TRunnerInterface).Name}_Proxy",
TypeAttributes.Public | TypeAttributes.Sealed | TypeAttributes.Class,
typeof(PipelineRunner<TContext, TStepInterface>));

typeBuilder.AddInterfaceImplementation(typeof(TRunnerInterface));

// Implement constructor(s) from base class
ImplementConstructor(typeBuilder, typeof(PipelineRunner<TContext, TStepInterface>));

return typeBuilder.CreateTypeInfo()!;
}

private static void ImplementConstructor(TypeBuilder typeBuilder, Type baseType)
{
var baseCtor = baseType.GetConstructor(_parameterTypes)
?? throw new InvalidOperationException("Expected base constructor with IServiceProvider parameter.");

var ctorBuilder = typeBuilder.DefineConstructor(
MethodAttributes.Public,
CallingConventions.Standard,
_parameterTypes);

var il = ctorBuilder.GetILGenerator();

// Call base constructor
il.Emit(OpCodes.Ldarg_0); // this
il.Emit(OpCodes.Ldarg_1); // IServiceProvider
il.Emit(OpCodes.Call, baseCtor);
il.Emit(OpCodes.Ret);
}
}
#endif
2 changes: 1 addition & 1 deletion src/PipeForge/version.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "2.0.0",
"version": "2.1.0",
"publicReleaseRefSpec": [
"^refs/heads/main$",
"^refs/heads/v\\d+(?:\\.\\d+)?$"
Expand Down