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; } + } +}