diff --git a/Hyperbee.Pipeline.sln b/Hyperbee.Pipeline.sln
index 59b906c..e68526a 100644
--- a/Hyperbee.Pipeline.sln
+++ b/Hyperbee.Pipeline.sln
@@ -34,9 +34,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hyperbee.Pipeline.Tests", "
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "docs", "docs", "{884A8242-351E-4363-9B34-E8C202CF7787}"
ProjectSection(SolutionItems) = preProject
+ docs\childPipeline.md = docs\childPipeline.md
+ docs\dependencyInjection.md = docs\dependencyInjection.md
+ docs\execution.md = docs\execution.md
docs\middleware.md = docs\middleware.md
+ docs\todo.md = docs\todo.md
EndProjectSection
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hyperbee.Pipeline.Caching", "src\Hyperbee.Pipline.Caching\Hyperbee.Pipeline.Caching.csproj", "{833A7497-542F-4B88-A76A-DA520E000F6F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hyperbee.Pipeline.Caching.Tests", "test\Hyperbee.PipelineCaching.Tests\Hyperbee.Pipeline.Caching.Tests.csproj", "{B7E5FBB3-AF2A-4E48-8E6A-10887DC6C4C0}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -51,6 +59,14 @@ Global
{17DA1657-DF82-440F-B1F1-D888BFA9626B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{17DA1657-DF82-440F-B1F1-D888BFA9626B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{17DA1657-DF82-440F-B1F1-D888BFA9626B}.Release|Any CPU.Build.0 = Release|Any CPU
+ {833A7497-542F-4B88-A76A-DA520E000F6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {833A7497-542F-4B88-A76A-DA520E000F6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {833A7497-542F-4B88-A76A-DA520E000F6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {833A7497-542F-4B88-A76A-DA520E000F6F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B7E5FBB3-AF2A-4E48-8E6A-10887DC6C4C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B7E5FBB3-AF2A-4E48-8E6A-10887DC6C4C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B7E5FBB3-AF2A-4E48-8E6A-10887DC6C4C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B7E5FBB3-AF2A-4E48-8E6A-10887DC6C4C0}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -60,6 +76,7 @@ Global
{4DBDB7F5-3F66-4572-80B5-3322449C77A4} = {1FA7CE2A-C9DA-4DC3-A242-5A7EAF8EE4FC}
{17DA1657-DF82-440F-B1F1-D888BFA9626B} = {F9B24CD9-E06B-4834-84CB-8C29E5F10BE0}
{884A8242-351E-4363-9B34-E8C202CF7787} = {870D9301-BE3D-44EA-BF9C-FCC2E87FE4CD}
+ {B7E5FBB3-AF2A-4E48-8E6A-10887DC6C4C0} = {F9B24CD9-E06B-4834-84CB-8C29E5F10BE0}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {32874F5B-B467-4F28-A8E2-82C2536FB228}
diff --git a/src/Hyperbee.Pipeline/Context/PipelineContextFactory.cs b/src/Hyperbee.Pipeline/Context/PipelineContextFactory.cs
index 411136d..fd7a83b 100644
--- a/src/Hyperbee.Pipeline/Context/PipelineContextFactory.cs
+++ b/src/Hyperbee.Pipeline/Context/PipelineContextFactory.cs
@@ -26,9 +26,13 @@ public IPipelineContext Create( ILogger logger )
public static IPipelineContextFactory Instance { get; private set; }
- public static IPipelineContextFactory CreateFactory( IServiceProvider serviceProvider = null )
+ public static IPipelineContextFactory CreateFactory( IServiceProvider serviceProvider = null, bool resetFactory = false )
{
- // get-or-create
+ if ( resetFactory )
+ {
+ return Instance = new PipelineContextFactory( serviceProvider );
+ }
+
return Instance ??= new PipelineContextFactory( serviceProvider );
}
}
diff --git a/src/Hyperbee.Pipline.Caching/Hyperbee.Pipeline.Caching.csproj b/src/Hyperbee.Pipline.Caching/Hyperbee.Pipeline.Caching.csproj
new file mode 100644
index 0000000..746088f
--- /dev/null
+++ b/src/Hyperbee.Pipline.Caching/Hyperbee.Pipeline.Caching.csproj
@@ -0,0 +1,45 @@
+
+
+
+ net8.0
+ enable
+ true
+
+ Stillpoint Software, Inc.
+ README.md
+ pipeline;caching
+ icon.png
+ https://github.com/Stillpoint-Software/Hyperbee.Pipeline/
+ https://github.com/Stillpoint-Software/hyperbee.pipeline/releases/latest
+ net8.0
+ LICENSE
+ Stillpoint Software, Inc.
+ Hyperbee Piplines Caching
+ Caching for Hyperbee.Pipelines async pipelines
+ https://github.com/Stillpoint-Software/Hyperbee.Pipeline
+ git
+ https://github.com/Stillpoint-Software/hyperbee.pipeline/releases/latest
+
+
+
+
+
+
+
+
+
+ True
+ \
+
+
+ True
+ \
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
diff --git a/src/Hyperbee.Pipline.Caching/PipelineCacheExtensions.cs b/src/Hyperbee.Pipline.Caching/PipelineCacheExtensions.cs
new file mode 100644
index 0000000..29c7aac
--- /dev/null
+++ b/src/Hyperbee.Pipline.Caching/PipelineCacheExtensions.cs
@@ -0,0 +1,136 @@
+using Hyperbee.Pipeline.Extensions.Implementation;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Hyperbee.Pipeline.Caching;
+
+public static class PipelineCacheExtensions
+{
+ public static IPipelineBuilder PipeCache(
+ this IPipelineBuilder builder,
+ Func, IPipelineBuilder> nestedBuilder,
+ Func optionsFunc = null )
+ {
+ ArgumentNullException.ThrowIfNull( nestedBuilder );
+
+ var block = PipelineFactory.Start();
+ var function = nestedBuilder( block ).GetPipelineFunction();
+
+ return builder.PipeCacheAsync( function.Function, optionsFunc );
+ }
+
+ public static IPipelineBuilder PipeCacheAsync(
+ this IPipelineBuilder builder,
+ Func, IPipelineBuilder> nestedBuilder,
+ Func optionsFunc = null )
+ {
+ ArgumentNullException.ThrowIfNull( nestedBuilder );
+
+ var block = PipelineFactory.Start();
+ var function = nestedBuilder( block ).GetPipelineFunction();
+
+ return builder.PipeCacheAsync( function.Function, optionsFunc );
+ }
+
+ public static IPipelineBuilder PipeCache(
+ this IPipelineBuilder builder,
+ Function next,
+ Func optionsFunc = null )
+ {
+
+ // default to using input as key
+ optionsFunc ??= ( output, options ) =>
+ {
+ options.Key = output;
+ return options;
+ };
+
+
+ return builder.Pipe( ( context, argument ) =>
+ {
+ var cache = context
+ .ServiceProvider
+ .GetService();
+
+ if ( cache == null )
+ {
+ context.Logger?.LogWarning( "Cache not configured." );
+ return next( context, argument );
+ }
+
+ var defaultCacheOption = context
+ .ServiceProvider
+ .GetService>();
+
+ var cacheOption = optionsFunc( argument, defaultCacheOption?.Value ?? new PipelineMemoryCacheOptions() );
+
+ if ( cacheOption?.Key != null )
+ {
+ return cache.GetOrCreate( cacheOption.Key, entry =>
+ {
+ context.Logger?.LogDebug( "Creating cache entry for {Key} not configured", cacheOption.Key );
+ entry.SetOptions( cacheOption );
+ return next( context, argument );
+ } ) ?? default;
+ }
+
+ context.Logger?.LogError( "Cache entries must have a valid key." );
+ context.Exception = new InvalidOperationException( "Cache entries must have a valid key." );
+ context.CancelAfter();
+ return default;
+
+ } );
+ }
+
+ public static IPipelineBuilder PipeCacheAsync(
+ this IPipelineBuilder builder,
+ FunctionAsync next,
+ Func optionsFunc = null )
+ {
+
+ // default to using input as key
+ optionsFunc ??= ( output, options ) =>
+ {
+ options.Key = output;
+ return options;
+ };
+
+
+ return builder.PipeAsync( async ( context, argument ) =>
+ {
+ var cache = context
+ .ServiceProvider
+ .GetService();
+
+ if ( cache == null )
+ {
+ context.Logger?.LogWarning( "Cache not configured." );
+ return await next( context, argument );
+ }
+
+ var defaultCacheOption = context
+ .ServiceProvider
+ .GetService>();
+
+ var cacheOption = optionsFunc( argument, defaultCacheOption?.Value ?? new PipelineMemoryCacheOptions() );
+
+ if ( cacheOption?.Key != null )
+ {
+ return await cache.GetOrCreateAsync( cacheOption.Key, entry =>
+ {
+ context.Logger?.LogDebug( "Creating cache entry for {Key} not configured", cacheOption.Key );
+ entry.SetOptions( cacheOption );
+ return next( context, argument );
+ } ) ?? default;
+ }
+
+ context.Logger?.LogError( "Cache entries must have a valid key." );
+ context.Exception = new InvalidOperationException( "Cache entries must have a valid key." );
+ context.CancelAfter();
+ return default;
+
+ } );
+ }
+}
diff --git a/src/Hyperbee.Pipline.Caching/PipelineMemoryCacheOptions.cs b/src/Hyperbee.Pipline.Caching/PipelineMemoryCacheOptions.cs
new file mode 100644
index 0000000..f7d3694
--- /dev/null
+++ b/src/Hyperbee.Pipline.Caching/PipelineMemoryCacheOptions.cs
@@ -0,0 +1,10 @@
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Options;
+
+namespace Hyperbee.Pipeline.Caching;
+
+public class PipelineMemoryCacheOptions : MemoryCacheEntryOptions, IOptions
+{
+ public object Key { get; set; }
+ public PipelineMemoryCacheOptions Value => this;
+}
diff --git a/src/Hyperbee.Pipline.Caching/README.md b/src/Hyperbee.Pipline.Caching/README.md
new file mode 100644
index 0000000..816c7c5
--- /dev/null
+++ b/src/Hyperbee.Pipline.Caching/README.md
@@ -0,0 +1,76 @@
+# Hyperbee.Pipeline.Caching
+
+The `Hyperbee.Pipeline.Caching` library is a set of extentsions to `Hyperbee.Pipeline` that adds support for caching within a pipeline using `Microsoft.Extensions.Caching` libraries.
+
+## Examples
+For simple pipelines the previous step's return value can be used as the key:
+
+```csharp
+// Takes a string and returns a number
+var command = PipelineFactory
+ .Start()
+ .PipeCacheAsync( CharacterCountAsync )
+ .Build();
+
+var result = await command( factory.Create( logger ), "test" );
+
+Assert.AreEqual( 4, result );
+```
+
+Or for more complex the options callback can be used to customize how the results will be cached.
+
+```csharp
+// Takes a string and returns a number
+var command = PipelineFactory
+ .Start()
+ .PipeCacheAsync( CharacterCountAsync,
+ ( input, options ) =>
+ {
+ options.Key = $"custom/{input}";
+ options.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours( 1 );
+ return options;
+ } )
+ .Build();
+
+var result = await command( factory.Create( logger ), "test" );
+
+Assert.AreEqual( 4, result );
+```
+
+When a set of steps should cache the overload that takes a builder can be used. In this case the inital input will be used at the key and the final step of the nested pipeline will be cached
+
+```csharp
+// Takes a string and returns a number
+var command = PipelineFactory
+ .Start()
+ .PipeCacheAsync( b => b
+ .PipeAsync( CharacterCountAsync )
+ .Pipe( (ctx, arg) => arg + 100 ))
+ .Build();
+
+var result = await command( factory.Create( logger ), "test" );
+
+Assert.AreEqual( 104, result );
+```
+
+## Dependacy Injection
+
+Because this uses the existing DI built into pipelines, caching can be configured with an existing cache:
+
+```csharp
+// Add Memory Cache
+services.AddMemoryCache();
+
+// Share with the pipelines
+services.AddPipeline( includeAllServices: true );
+```
+
+Or defined seperately as part of the container use for the pipelines:
+
+```csharp
+services.AddPipeline( (factoryServices, rootProvider) =>
+{
+ factoryServices.AddMemoryCache();
+ factoryServices.AddPipelineDefaultCacheSettings( absoluteExpirationRelativeToNow: TimeSpan.FromHours( 1 ) )
+} );
+```
\ No newline at end of file
diff --git a/src/Hyperbee.Pipline.Caching/ServiceCollectionExtensions.cs b/src/Hyperbee.Pipline.Caching/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3a45df4
--- /dev/null
+++ b/src/Hyperbee.Pipline.Caching/ServiceCollectionExtensions.cs
@@ -0,0 +1,26 @@
+using Hyperbee.Pipeline.Caching;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
+
+// ReSharper disable CheckNamespace
+namespace Hyperbee.Pipeline;
+
+public static class ServiceCollectionExtensions
+{
+ public static IServiceCollection AddPipelineDefaultCacheSettings(
+ this IServiceCollection services,
+ DateTimeOffset? absoluteExpiration = null,
+ TimeSpan? absoluteExpirationRelativeToNow = null,
+ CacheItemPriority priority = CacheItemPriority.Normal,
+ PostEvictionCallbackRegistration callbackRegistration = null
+ )
+ {
+ return services.AddTransient( ( _ ) => new PipelineMemoryCacheOptions
+ {
+ AbsoluteExpiration = absoluteExpiration,
+ AbsoluteExpirationRelativeToNow = absoluteExpirationRelativeToNow,
+ Priority = priority,
+ PostEvictionCallbacks = { callbackRegistration }
+ } );
+ }
+}
diff --git a/test/Hyperbee.PipelineCaching.Tests/Hyperbee.Pipeline.Caching.Tests.csproj b/test/Hyperbee.PipelineCaching.Tests/Hyperbee.Pipeline.Caching.Tests.csproj
new file mode 100644
index 0000000..541ce0e
--- /dev/null
+++ b/test/Hyperbee.PipelineCaching.Tests/Hyperbee.Pipeline.Caching.Tests.csproj
@@ -0,0 +1,30 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/Hyperbee.PipelineCaching.Tests/PipelineCachingTests.cs b/test/Hyperbee.PipelineCaching.Tests/PipelineCachingTests.cs
new file mode 100644
index 0000000..c73c4a3
--- /dev/null
+++ b/test/Hyperbee.PipelineCaching.Tests/PipelineCachingTests.cs
@@ -0,0 +1,238 @@
+using System.ComponentModel.Design;
+using Hyperbee.Pipeline.Context;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Internal;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+
+namespace Hyperbee.Pipeline.Caching.Tests;
+
+[TestClass]
+public class PipelineCachingTests
+{
+
+ [TestMethod]
+ public async Task Should_ReturnDifferentResults_WhenUsingDefaultKeys()
+ {
+ // Arrange
+ var command = PipelineFactory
+ .Start()
+ .PipeCacheAsync( ComplexAsync )
+ .Build();
+
+ var factory = CreateContextFactory();
+ var logger = Substitute.For();
+
+ // Act
+ var result1 = await command( factory.Create( logger ), "test" );
+ var result2 = await command( factory.Create( logger ), "test" );
+ var result3 = await command( factory.Create( logger ), "test more" );
+
+ // Assert
+ Assert.AreEqual( 4, result1 );
+ Assert.AreEqual( 4, result2 );
+ Assert.AreEqual( 9, result3 );
+ }
+
+ [TestMethod]
+ public async Task Should_ReturnDifferentResults_WhenUsingDefaultKeys_NonAsync()
+ {
+ // Arrange
+ var command = PipelineFactory
+ .Start()
+ .PipeCache( Complex )
+ .Build();
+
+ var factory = CreateContextFactory();
+ var logger = Substitute.For();
+
+ // Act
+ var result1 = await command( factory.Create( logger ), "test" );
+ var result2 = await command( factory.Create( logger ), "test" );
+ var result3 = await command( factory.Create( logger ), "test more" );
+
+ // Assert
+ Assert.AreEqual( 4, result1 );
+ Assert.AreEqual( 4, result2 );
+ Assert.AreEqual( 9, result3 );
+ }
+
+ [TestMethod]
+ public async Task Should_ReturnDifferentResults_WhenUsingDefaultKeys_WithNestedAsyncPipeline()
+ {
+ // Arrange
+ var command = PipelineFactory
+ .Start()
+ .PipeCacheAsync( b => b
+ .PipeAsync( ComplexAsync )
+ .Pipe( ( ctx, arg ) => arg + 100 ) )
+ .Build();
+
+ var factory = CreateContextFactory();
+ var logger = Substitute.For();
+
+ // Act
+ var result1 = await command( factory.Create( logger ), "test async" );
+ var result2 = await command( factory.Create( logger ), "test async" );
+ var result3 = await command( factory.Create( logger ), "test async more" );
+
+ // Assert
+ Assert.AreEqual( 110, result1 );
+ Assert.AreEqual( 110, result2 );
+ Assert.AreEqual( 115, result3 );
+ }
+
+ [TestMethod]
+ public async Task Should_ReturnDifferentResults_WhenUsingDefaultKeys_WithNestedPipeline()
+ {
+ // Arrange
+ var command = PipelineFactory
+ .Start()
+ .PipeCache( b => b
+ .Pipe( Complex )
+ .Pipe( ( ctx, arg ) => arg + 100 ) )
+ .Build();
+
+ var factory = CreateContextFactory();
+ var logger = Substitute.For();
+
+ // Act
+ var result1 = await command( factory.Create( logger ), "test" );
+ var result2 = await command( factory.Create( logger ), "test" );
+ var result3 = await command( factory.Create( logger ), "test more" );
+
+ // Assert
+ Assert.AreEqual( 104, result1 );
+ Assert.AreEqual( 104, result2 );
+ Assert.AreEqual( 109, result3 );
+ }
+
+ [TestMethod]
+ public async Task Should_ReturnDifferentResults_WhenUsingCustomKeys()
+ {
+ // Arrange
+ var command = PipelineFactory
+ .Start<(string Tenant, string Value)>()
+ .PipeCacheAsync( ComplexAsync,
+ ( input, options ) =>
+ {
+ options.Key = $"custom/{input.Tenant}/{input.Value}";
+ options.AbsoluteExpiration = DateTimeOffset.Now + TimeSpan.FromMilliseconds( 200 );
+ return options;
+ } )
+ .Build();
+
+ var factory = CreateContextFactory();
+ var logger = Substitute.For();
+
+ // Act
+ var result1 = await command( factory.Create( logger ), ("CompanyA", "test company") );
+ var result2 = await command( factory.Create( logger ), ("OrganizationB", "test organization") );
+
+ // Assert
+ Assert.AreEqual( 12, result1 );
+ Assert.AreEqual( 17, result2 );
+ }
+
+ [TestMethod]
+ public async Task Should_ReturnDifferentResults_WhenCacheExpires()
+ {
+ // Arrange
+ var startTime = DateTimeOffset.UtcNow;
+ var expireTime = startTime + TimeSpan.FromMinutes( 10 );
+
+ var clock = new TestSystemClock { UtcNow = startTime };
+ var command = PipelineFactory
+ .Start()
+ .PipeCacheAsync( ComplexAsync,
+ ( input, options ) =>
+ {
+ options.Key = "TESTING_EXPIRING";
+ options.SetAbsoluteExpiration( expireTime );
+ options.RegisterPostEvictionCallback( ( key, value, reason, state ) =>
+ {
+ Console.WriteLine( $"{key}, {value}, {reason}, {state}" );
+ } );
+ return options;
+ } )
+ .Build();
+
+
+ var factory = CreateContextFactory( clock );
+ var logger = Substitute.For();
+
+ // Act
+ var result1 = await command( factory.Create( logger ), "testing" );
+ var result2 = await command( factory.Create( logger ), "testing more" );
+
+ clock.UtcNow += TimeSpan.FromMinutes( 20 ); // Fast-forward time by 20 minutes
+
+ var result3 = await command( factory.Create( logger ), "testing more again" );
+
+ // Assert
+ Assert.AreEqual( 7, result1 );
+ Assert.AreEqual( 7, result2 );
+ Assert.AreEqual( 18, result3 );
+ }
+
+ [TestMethod]
+ public async Task Should_ReturnSameResults_WhenUsingSameKeys()
+ {
+ // Arrange
+ var command = PipelineFactory
+ .Start()
+ .PipeCacheAsync( ComplexAsync,
+ ( _, options ) =>
+ {
+ options.Key = "USING_SAME_KEY";
+ return options;
+ } )
+ .Build();
+
+ var factory = CreateContextFactory();
+ var logger = Substitute.For();
+
+ // Act
+ var result1 = await command( factory.Create( logger ), "a" );
+ var result2 = await command( factory.Create( logger ), "bc" );
+
+ // Assert
+ Assert.AreEqual( 1, result1 );
+ Assert.AreEqual( 1, result2 ); // same as previous
+ }
+
+ private static Task ComplexAsync( IPipelineContext context, string argument ) =>
+ Task.FromResult( argument.Length );
+
+ private static Task ComplexAsync( IPipelineContext context, (string Tenant, string Value) argument ) =>
+ Task.FromResult( argument.Value.Length );
+
+ private static int Complex( IPipelineContext context, string argument ) => argument.Length;
+
+ private static IPipelineContextFactory CreateContextFactory( ISystemClock? clock = null )
+ {
+ clock ??= new TestSystemClock { UtcNow = DateTimeOffset.UtcNow };
+ var container = new ServiceContainer();
+
+ container.AddService(
+ typeof( IMemoryCache ),
+ new MemoryCache( new MemoryCacheOptions
+ {
+ Clock = clock,
+ ExpirationScanFrequency = TimeSpan.FromMilliseconds( 100 ),
+ TrackLinkedCacheEntries = false
+ } ) );
+
+ container.AddService(
+ typeof( PipelineMemoryCacheOptions ),
+ new PipelineMemoryCacheOptions()
+ );
+
+ return PipelineContextFactory.CreateFactory( container, true );
+ }
+
+ public class TestSystemClock : ISystemClock
+ {
+ public DateTimeOffset UtcNow { get; set; }
+ }
+}