diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d9739aa..321f27c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,7 +20,6 @@ "redis:6379" ], "name": "L1L2RedisCache", - "postCreateCommand": "dotnet dev-certs https", "remoteUser": "root", "service": "devcontainer", "shutdownAction": "stopCompose", diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 22ed388..c2a0419 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,6 +1,6 @@ services: devcontainer: - image: mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm + image: mcr.microsoft.com/devcontainers/dotnet:dev-10.0 volumes: - ..:/workspace:cached command: sleep infinity diff --git a/.editorconfig b/.editorconfig index 0a85bc9..188d3c3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -3,6 +3,7 @@ dotnet_diagnostic.severity = warning dotnet_diagnostic.CS1591.severity = none dotnet_diagnostic.IDE0001.severity = warning +dotnet_diagnostic.IDE0005.severity = warning dotnet_diagnostic.IDE0007.severity = warning dotnet_diagnostic.IDE0008.severity = none dotnet_diagnostic.IDE0010.severity = none @@ -18,6 +19,9 @@ dotnet_diagnostic.IDE0301.severity = warning dotnet_diagnostic.IDE0303.severity = warning dotnet_diagnostic.IDE0305.severity = warning +dotnet_diagnostic.MSTEST0016.severity = warning +dotnet_diagnostic.MSTEST0049.severity = warning + csharp_space_between_square_brackets = true csharp_style_var_elsewhere = true:warning csharp_style_var_for_built_in_types = true:warning diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2125666 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 872bec3..8a0df48 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,4 +11,4 @@ jobs: uses: devcontainers/ci@v0.3 with: push: never - runCmd: dotnet test + runCmd: dotnet test --max-parallel-test-modules 1 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c15b68e..64b74db 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: uses: devcontainers/ci@v0.3 with: push: never - runCmd: dotnet test + runCmd: dotnet test --max-parallel-test-modules 1 publish: runs-on: ubuntu-latest steps: @@ -24,8 +24,8 @@ jobs: - name: Setup uses: actions/setup-dotnet@v4 with: - dotnet-version: 9 - - name: Build - run: dotnet build -c Release + dotnet-version: 10 + - name: Pack + run: dotnet pack -c Release - name: Publish run: dotnet nuget push "**/*.nupkg" -k ${{ secrets.NUGET_KEY }} -n -s https://api.nuget.org/v3/index.json --skip-duplicate \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3736cc0 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.2.0", + "configurations": + [ + { + "name": "MessagingRedisCache Lab", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/labs/MessagingRedisCache/bin/Debug/net10.0/MessagingRedisCache.Lab.dll", + "args": [], + "cwd": "${workspaceFolder}/labs/MessagingRedisCache", + "stopAtEntry": false, + "env": + { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + ] +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..8da37cb --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,12 @@ + + + latest-all + true + true + enable + latest + enable + git + https://github.com/null-d3v/L1L2RedisCache.git + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..5b1563a --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,28 @@ + + + true + true + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/L1L2RedisCache.sln b/L1L2RedisCache.sln deleted file mode 100644 index 7c884b3..0000000 --- a/L1L2RedisCache.sln +++ /dev/null @@ -1,67 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26124.0 -MinimumVisualStudioVersion = 15.0.26124.0 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache", "src\L1L2RedisCache.csproj", "{71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}" -EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{79FE29CD-A4E5-46BB-9FC8-5EC921CFE5F3}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache.Tests.Unit", "tests\Unit\L1L2RedisCache.Tests.Unit.csproj", "{8791FCF7-078D-44A5-AC59-C7C2CE469D3F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache.Tests.System", "tests\System\L1L2RedisCache.Tests.System.csproj", "{6A825E82-5BF4-43A0-BA08-9CB000FB232A}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Debug|x64 = Debug|x64 - Debug|x86 = Debug|x86 - Release|Any CPU = Release|Any CPU - Release|x64 = Release|x64 - Release|x86 = Release|x86 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|Any CPU.Build.0 = Debug|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x64.ActiveCfg = Debug|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x64.Build.0 = Debug|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x86.ActiveCfg = Debug|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x86.Build.0 = Debug|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|Any CPU.ActiveCfg = Release|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|Any CPU.Build.0 = Release|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x64.ActiveCfg = Release|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x64.Build.0 = Release|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x86.ActiveCfg = Release|Any CPU - {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x86.Build.0 = Release|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x64.ActiveCfg = Debug|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x64.Build.0 = Debug|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x86.ActiveCfg = Debug|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x86.Build.0 = Debug|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|Any CPU.Build.0 = Release|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x64.ActiveCfg = Release|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x64.Build.0 = Release|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x86.ActiveCfg = Release|Any CPU - {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x86.Build.0 = Release|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|Any CPU.Build.0 = Debug|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x64.ActiveCfg = Debug|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x64.Build.0 = Debug|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x86.ActiveCfg = Debug|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x86.Build.0 = Debug|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|Any CPU.ActiveCfg = Release|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|Any CPU.Build.0 = Release|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x64.ActiveCfg = Release|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x64.Build.0 = Release|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x86.ActiveCfg = Release|Any CPU - {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x86.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {6A825E82-5BF4-43A0-BA08-9CB000FB232A} = {79FE29CD-A4E5-46BB-9FC8-5EC921CFE5F3} - EndGlobalSection -EndGlobal diff --git a/L1L2RedisCache.slnx b/L1L2RedisCache.slnx new file mode 100644 index 0000000..a2d6194 --- /dev/null +++ b/L1L2RedisCache.slnx @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md index a16af3e..2cf6879 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,7 @@ -# L1L2RedisCache - -`L1L2RedisCache` is an implementation of [`IDistributedCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IDistributedCache.cs) with a strong focus on performance. It leverages [`IMemoryCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs) as a level 1 cache and [`RedisCache`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCache.cs) as a level 2 cache, with level 1 evictions being managed via [Redis pub/sub](https://redis.io/topics/pubsub). - -`L1L2RedisCache` is heavily inspired by development insights provided over the past several years by [StackOverflow](https://stackoverflow.com/). It attempts to simplify those concepts into a highly accessible `IDistributedCache` implementation that is more performant. - -I expect to gracefully decomission this project when [`StackExchange.Redis`](https://github.com/StackExchange/StackExchange.Redis) has [client-side caching](https://redis.io/docs/latest/develop/use/client-side-caching/) support. - -## Configuration - -It is intended that L1L12RedisCache be used as an `IDistributedCache` implementation. - -`L1L2RedisCache` can be registered during startup with the following `IServiceCollection` extension method: +# MessagingRedisCache -``` -services.AddL1L2RedisCache(options => -{ - options.Configuration = "localhost"; - options.InstanceName = "Namespace:Prefix:"; -}); -``` +[`MessagingRedisCache`](/src/MessagingRedisCache/README.md) is an implementation of [`IDistributedCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IDistributedCache.cs) using [`RedisCache`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCache.cs) as a base implementation. `MessagingRedisCache` will utilize [Redis pub/sub](https://redis.io/topics/pubsub) to ensure that memory cache entries can be synchronized in a distributed system. This makes it a viable backing store for [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid), where it can evict `IMemoryCache` entries in distributed systems. -`L1L2RedisCache` options are an extension of the standard `RedisCache` [`RedisCacheOptions`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCacheOptions.cs). The following additional customizations are supported: - -### MessagingType - -The type of messaging system to use for L1 memory cache eviction. - -| MessagingType | Description | Suggestion | -| - | - | - | -| `Default` | Use standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages for L1 memory cache eviction. | Default behavior. The Redis server requires no additional configuration. | -| `KeyeventNotifications` | Use [keyevent notifications](https://redis.io/topics/notifications) for L1 memory eviction instead of standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyevent notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghE` configuration and the majority of keys in the server are managed by `L1L2RedisCache`. | -| `KeyspaceNotifications` | Use [keyspace notifications](https://redis.io/topics/notifications) for L1 memory eviction instead of standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyspace notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghK` configuration and the majority of keys in the server are managed by `L1L2RedisCache`. | - -## Performance - -L1L2RedisCache will generally outperform `RedisCache`, especially in cases of high volume or large cache entries. As entries are opportunistically pulled from memory instead of Redis, costs of latency, network, and Redis operations are avoided. Respective performance gains will rely heavily on the impact of afforementioned factors. - -## Considerations +# L1L2RedisCache -Due to the complex nature of a distributed L1 memory cache, cache entries with sliding expirations are only stored in L2 (Redis). These entries will show no performance improvement over the standard `RedisCache`, but incur no performance penalty. +[`L1L2RedisCache`](/src/L1L2RedisCache/README.md) is an implementation of [`IDistributedCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IDistributedCache.cs), leveraging [`IMemoryCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs) as a level 1 cache and [`MessagingRedisCache`](/src/MessagingRedisCache/README.md) as a level 2 cache. Level 1 evictions are managed via `MessagingRedisCache`'s [Redis pub/sub](https://redis.io/topics/pubsub). \ No newline at end of file diff --git a/global.json b/global.json new file mode 100644 index 0000000..7facb0a --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "test": + { + "runner": "Microsoft.Testing.Platform" + } +} \ No newline at end of file diff --git a/labs/MessagingRedisCache/MessagingRedisCache.Lab.csproj b/labs/MessagingRedisCache/MessagingRedisCache.Lab.csproj new file mode 100644 index 0000000..d194bf6 --- /dev/null +++ b/labs/MessagingRedisCache/MessagingRedisCache.Lab.csproj @@ -0,0 +1,17 @@ + + + EXTEXP0018; + Exe + net10.0 + + + + + + + + + + + + diff --git a/labs/MessagingRedisCache/Program.cs b/labs/MessagingRedisCache/Program.cs new file mode 100644 index 0000000..d82aa5b --- /dev/null +++ b/labs/MessagingRedisCache/Program.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; + +var services = new ServiceCollection(); +services.AddHybridCache(); +services.AddMessagingRedisCache(options => +{ + options.Configuration = "redis"; + options.InstanceName = "MessagingRedisCache:Test:"; +}); +var serviceProvider = services.BuildServiceProvider(); + +var hybridCache = serviceProvider + .GetRequiredService(); + +var key = Guid.NewGuid().ToString(); +var value = Guid.NewGuid().ToString(); + +await hybridCache + .SetAsync( + key, + value, + new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromHours(1), + }) + .ConfigureAwait(false); + +var testValue = await hybridCache + .GetOrCreateAsync( + key, + cancellationToken => + ValueTask.FromResult(null)) + .ConfigureAwait(false); diff --git a/src/Configuration/ServiceCollectionExtensions.cs b/src/Configuration/ServiceCollectionExtensions.cs deleted file mode 100644 index a3989b1..0000000 --- a/src/Configuration/ServiceCollectionExtensions.cs +++ /dev/null @@ -1,104 +0,0 @@ -using L1L2RedisCache; -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.StackExchangeRedis; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace Microsoft.Extensions.DependencyInjection; - -/// -/// Extension methods for setting up L1L2RedisCache related services in an Microsoft.Extensions.DependencyInjection.IServiceCollection. -/// -public static class ServiceCollectionExtensions -{ - /// - /// Adds L1L2RedisCache distributed caching services to the specified IServiceCollection. - /// - /// The IServiceCollection so that additional calls can be chained. - public static IServiceCollection AddL1L2RedisCache( - this IServiceCollection services, - Action setupAction) - { - if (setupAction == null) - { - throw new ArgumentNullException( - nameof(setupAction)); - } - - var l1L2RedisCacheOptions = new L1L2RedisCacheOptions(); - setupAction.Invoke(l1L2RedisCacheOptions); - - services.AddOptions(); - services.Configure(setupAction); - services.Configure( - (options) => - { - if (options.ConnectionMultiplexerFactory == null) - { - if (options.ConfigurationOptions != null) - { - options.ConnectionMultiplexerFactory = () => - Task.FromResult( - ConnectionMultiplexer.Connect( - options.ConfigurationOptions) as IConnectionMultiplexer); - } - else if (!string.IsNullOrEmpty(options.Configuration)) - { - options.ConnectionMultiplexerFactory = () => - Task.FromResult( - ConnectionMultiplexer.Connect( - options.Configuration) as IConnectionMultiplexer); - } - } - }); - services.AddMemoryCache(); - services.AddSingleton( - serviceProvider => new Func( - () => new RedisCache( - serviceProvider.GetRequiredService>()))); - services.AddSingleton(); - services.AddSingleton(); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton( - serviceProvider => - { - var options = serviceProvider - .GetRequiredService>() - .Value; - - return options.MessagingType switch - { - MessagingType.Default => - serviceProvider.GetRequiredService(), - _ => - serviceProvider.GetRequiredService(), - }; - }); - - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton( - serviceProvider => - { - var options = serviceProvider - .GetRequiredService>() - .Value; - - return options.MessagingType switch - { - MessagingType.Default => - serviceProvider.GetRequiredService(), - MessagingType.KeyeventNotifications => - serviceProvider.GetRequiredService(), - MessagingType.KeyspaceNotifications => - serviceProvider.GetRequiredService(), - _ => throw new NotImplementedException(), - }; - }); - - return services; - } -} diff --git a/src/HashEntryArrayExtensions.cs b/src/HashEntryArrayExtensions.cs deleted file mode 100644 index d3ad2ff..0000000 --- a/src/HashEntryArrayExtensions.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using StackExchange.Redis; - -namespace L1L2RedisCache; - -internal static class HashEntryArrayExtensions -{ - private const string AbsoluteExpirationKey = "absexp"; - private const string DataKey = "data"; - private const long NotPresent = -1; - private const string SlidingExpirationKey = "sldexp"; - - internal static DistributedCacheEntryOptions GetDistributedCacheEntryOptions( - this HashEntry[] hashEntries) - { - var distributedCacheEntryOptions = new DistributedCacheEntryOptions(); - - var absoluteExpirationHashEntry = hashEntries.FirstOrDefault( - hashEntry => hashEntry.Name == AbsoluteExpirationKey); - if (absoluteExpirationHashEntry.Value.HasValue && - absoluteExpirationHashEntry.Value != NotPresent) - { - distributedCacheEntryOptions.AbsoluteExpiration = new DateTimeOffset( - (long)absoluteExpirationHashEntry.Value, TimeSpan.Zero); - } - - var slidingExpirationHashEntry = hashEntries.FirstOrDefault( - hashEntry => hashEntry.Name == SlidingExpirationKey); - if (slidingExpirationHashEntry.Value.HasValue && - slidingExpirationHashEntry.Value != NotPresent) - { - distributedCacheEntryOptions.SlidingExpiration = new TimeSpan( - (long)slidingExpirationHashEntry.Value); - } - - return distributedCacheEntryOptions; - } - - internal static RedisValue GetRedisValue( - this HashEntry[] hashEntries) - { - var dataHashEntry = hashEntries.FirstOrDefault( - hashEntry => hashEntry.Name == DataKey); - - return dataHashEntry.Value; - } -} \ No newline at end of file diff --git a/src/L1L2RedisCache.cs b/src/L1L2RedisCache.cs deleted file mode 100644 index 22bc1d2..0000000 --- a/src/L1L2RedisCache.cs +++ /dev/null @@ -1,574 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace L1L2RedisCache; - -/// -/// A distributed cache implementation using both memory and Redis. -/// -public sealed class L1L2RedisCache : - IDisposable, - IDistributedCache -{ - /// - /// Initializes a new instance of L1L2RedisCache. - /// - public L1L2RedisCache( - IMemoryCache l1Cache, - IOptions l1l2RedisCacheOptionsAccessor, - Func l2CacheAccessor, - IMessagePublisher messagePublisher, - IMessageSubscriber messageSubscriber, - IMessagingConfigurationVerifier messagingConfigurationVerifier, - ILogger? logger = null) - { - L1Cache = l1Cache ?? - throw new ArgumentNullException( - nameof(l1Cache)); - L1L2RedisCacheOptions = l1l2RedisCacheOptionsAccessor?.Value ?? - throw new ArgumentNullException( - nameof(l1l2RedisCacheOptionsAccessor)); - L2Cache = l2CacheAccessor?.Invoke() ?? - throw new ArgumentNullException( - nameof(l2CacheAccessor)); - Logger = logger ?? - NullLogger.Instance; - MessagePublisher = messagePublisher ?? - throw new ArgumentNullException( - nameof(messagePublisher)); - MessageSubscriber = messageSubscriber ?? - throw new ArgumentNullException( - nameof(messageSubscriber)); - MessagingConfigurationVerifier = messagingConfigurationVerifier ?? - throw new ArgumentNullException( - nameof(l1l2RedisCacheOptionsAccessor)); - - Database = new Lazy(() => - L1L2RedisCacheOptions - .ConnectionMultiplexerFactory!() - .GetAwaiter() - .GetResult() - .GetDatabase( - L1L2RedisCacheOptions - .ConfigurationOptions? - .DefaultDatabase ?? -1)); - - SubscribeCancellationTokenSource = new CancellationTokenSource(); - _ = SubscribeAsync( - SubscribeCancellationTokenSource.Token); - } - - private static SemaphoreSlim KeySemaphore { get; } = - new SemaphoreSlim(1, 1); - - /// - /// The StackExchange.Redis.IDatabase for the . - /// - public Lazy Database { get; } - - /// - /// The IMemoryCache for L1. - /// - public IMemoryCache L1Cache { get; } - - /// - /// Configuration options. - /// - public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; } - - /// - /// The IDistributedCache for L2. - /// - public IDistributedCache L2Cache { get; } - - /// - /// Optional. The logger. - /// - public ILogger Logger { get; } - - /// - /// The pub/sub publisher. - /// - public IMessagePublisher MessagePublisher { get; } - - /// - /// The pub/sub subscriber. - /// - public IMessageSubscriber MessageSubscriber { get; } - - /// - /// The messaging configuration verifier. - /// - public IMessagingConfigurationVerifier MessagingConfigurationVerifier { get; } - - private bool IsDisposed { get; set; } - private CancellationTokenSource SubscribeCancellationTokenSource { get; set; } - - /// - /// Releases all resources used by the current instance. - /// - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// - /// Gets a value with the given key. - /// - /// A string identifying the requested value. - /// The located value or null. - public byte[]? Get(string key) - { - var value = L1Cache.Get( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}") as byte[]; - - if (value == null) - { - if (Database.Value.KeyExists( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}")) - { - var semaphore = GetOrCreateLock( - key, - null); - semaphore.Wait(); - try - { - var hashEntries = GetHashEntries(key); - var distributedCacheEntryOptions = hashEntries - .GetDistributedCacheEntryOptions(); - value = hashEntries.GetRedisValue(); - - SetMemoryCache( - key, - value!, - distributedCacheEntryOptions); - SetLock( - key, - semaphore, - distributedCacheEntryOptions); - } - finally - { - semaphore.Release(); - } - } - } - - return value; - } - - /// - /// Gets a value with the given key. - /// - /// A string identifying the requested value. - /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. - /// The System.Threading.Tasks.Task that represents the asynchronous operation, containing the located value or null. - public async Task GetAsync( - string key, - CancellationToken token = default) - { - var value = L1Cache.Get( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}") as byte[]; - - if (value == null) - { - if (await Database.Value - .KeyExistsAsync( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}") - .ConfigureAwait(false)) - { - var semaphore = await GetOrCreateLockAsync( - key, - null, - token) - .ConfigureAwait(false); - await semaphore - .WaitAsync(token) - .ConfigureAwait(false); - try - { - var hashEntries = await GetHashEntriesAsync(key) - .ConfigureAwait(false); - var distributedCacheEntryOptions = hashEntries - .GetDistributedCacheEntryOptions(); - value = hashEntries.GetRedisValue(); - - SetMemoryCache( - key, - value!, - distributedCacheEntryOptions); - SetLock( - key, - semaphore, - distributedCacheEntryOptions); - } - finally - { - semaphore.Release(); - } - } - } - - return value; - } - - /// - /// Refreshes a value in the cache based on its key, resetting its sliding expiration timeout (if any). - /// - /// A string identifying the requested value. - public void Refresh(string key) - { - L2Cache.Refresh(key); - } - - /// - /// Refreshes a value in the cache based on its key, resetting its sliding expiration timeout (if any). - /// - /// A string identifying the requested value. - /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. - /// The System.Threading.Tasks.Task that represents the asynchronous operation. - public async Task RefreshAsync( - string key, - CancellationToken token = default) - { - await L2Cache - .RefreshAsync(key, token) - .ConfigureAwait(false); - } - - /// - /// Removes the value with the given key. - /// - /// A string identifying the requested value. - public void Remove(string key) - { - var semaphore = GetOrCreateLock(key, null); - semaphore.Wait(); - try - { - L2Cache.Remove(key); - L1Cache.Remove( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); - MessagePublisher.Publish( - Database.Value.Multiplexer, - key); - L1Cache.Remove( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); - } - finally - { - semaphore.Release(); - } - } - - /// - /// Removes the value with the given key. - /// - /// A string identifying the requested value. - /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. - /// The System.Threading.Tasks.Task that represents the asynchronous operation. - public async Task RemoveAsync( - string key, - CancellationToken token = default) - { - var semaphore = await GetOrCreateLockAsync( - key, - null, - token) - .ConfigureAwait(false); - await semaphore - .WaitAsync(token) - .ConfigureAwait(false); - try - { - await L2Cache - .RemoveAsync(key, token) - .ConfigureAwait(false); - L1Cache.Remove( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); - await MessagePublisher - .PublishAsync( - Database.Value.Multiplexer, - key, - token) - .ConfigureAwait(false); - L1Cache.Remove( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); - } - finally - { - semaphore.Release(); - } - } - - /// - /// Sets a value with the given key. - /// - /// A string identifying the requested value. - /// The value to set in the cache. - /// The cache options for the value. - public void Set( - string key, - byte[] value, - DistributedCacheEntryOptions options) - { - var semaphore = GetOrCreateLock( - key, options); - semaphore.Wait(); - try - { - L2Cache.Set( - key, value, options); - SetMemoryCache( - key, value, options); - MessagePublisher.Publish( - Database.Value.Multiplexer, - key); - } - finally - { - semaphore.Release(); - } - } - - /// - /// Sets a value with the given key. - /// - /// A string identifying the requested value. - /// The value to set in the cache. - /// The cache options for the value. - /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled. - /// The System.Threading.Tasks.Task that represents the asynchronous operation. - public async Task SetAsync( - string key, - byte[] value, - DistributedCacheEntryOptions options, - CancellationToken token = default) - { - var semaphore = await GetOrCreateLockAsync( - key, - options, - token) - .ConfigureAwait(false); - await semaphore - .WaitAsync(token) - .ConfigureAwait(false); - try - { - await L2Cache - .SetAsync( - key, - value, - options, - token) - .ConfigureAwait(false); - SetMemoryCache( - key, value, options); - await MessagePublisher - .PublishAsync( - Database.Value.Multiplexer, - key, - token) - .ConfigureAwait(false); - } - finally - { - semaphore.Release(); - } - } - - private void Dispose(bool isDisposing) - { - if (IsDisposed) - { - return; - } - - if (isDisposing) - { - SubscribeCancellationTokenSource.Dispose(); - } - - IsDisposed = true; - } - - private HashEntry[] GetHashEntries(string key) - { - var hashEntries = Array.Empty(); - - try - { - hashEntries = Database.Value.HashGetAll( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); - } - catch (RedisServerException) { } - - return hashEntries; - } - - private async Task GetHashEntriesAsync(string key) - { - var hashEntries = Array.Empty(); - - try - { - hashEntries = await Database.Value - .HashGetAllAsync( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}") - .ConfigureAwait(false); - } - catch (RedisServerException) { } - - return hashEntries; - } - - private SemaphoreSlim GetOrCreateLock( - string key, - DistributedCacheEntryOptions? distributedCacheEntryOptions) - { - KeySemaphore.Wait(); - try - { - return L1Cache.GetOrCreate( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}", - cacheEntry => - { - cacheEntry.AbsoluteExpiration = - distributedCacheEntryOptions?.AbsoluteExpiration; - cacheEntry.AbsoluteExpirationRelativeToNow = - distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow; - cacheEntry.SlidingExpiration = - distributedCacheEntryOptions?.SlidingExpiration; - return new SemaphoreSlim(1, 1); - }) ?? - new SemaphoreSlim(1, 1); - } - finally - { - KeySemaphore.Release(); - } - } - - private async Task GetOrCreateLockAsync( - string key, - DistributedCacheEntryOptions? distributedCacheEntryOptions, - CancellationToken cancellationToken = default) - { - await KeySemaphore - .WaitAsync(cancellationToken) - .ConfigureAwait(false); - try - { - return await L1Cache - .GetOrCreateAsync( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}", - cacheEntry => - { - cacheEntry.AbsoluteExpiration = - distributedCacheEntryOptions?.AbsoluteExpiration; - cacheEntry.AbsoluteExpirationRelativeToNow = - distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow; - cacheEntry.SlidingExpiration = - distributedCacheEntryOptions?.SlidingExpiration; - return Task.FromResult(new SemaphoreSlim(1, 1)); - }) - .ConfigureAwait(false) ?? - new SemaphoreSlim(1, 1); - } - finally - { - KeySemaphore.Release(); - } - } - - private SemaphoreSlim SetLock( - string key, - SemaphoreSlim semaphore, - DistributedCacheEntryOptions distributedCacheEntryOptions) - { - var memoryCacheEntryOptions = new MemoryCacheEntryOptions - { - AbsoluteExpiration = - distributedCacheEntryOptions?.AbsoluteExpiration, - AbsoluteExpirationRelativeToNow = - distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow, - SlidingExpiration = - distributedCacheEntryOptions?.SlidingExpiration, - }; - - return L1Cache.Set( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}", - semaphore, - memoryCacheEntryOptions); - } - - private void SetMemoryCache( - string key, - byte[] value, - DistributedCacheEntryOptions distributedCacheEntryOptions) - { - var memoryCacheEntryOptions = new MemoryCacheEntryOptions - { - AbsoluteExpiration = - distributedCacheEntryOptions?.AbsoluteExpiration, - AbsoluteExpirationRelativeToNow = - distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow, - SlidingExpiration = - distributedCacheEntryOptions?.SlidingExpiration, - }; - - if (!memoryCacheEntryOptions.SlidingExpiration.HasValue) - { - L1Cache.Set( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}", - value, - memoryCacheEntryOptions); - } - } - - private async Task SubscribeAsync( - CancellationToken cancellationToken = default) - { - while (true) - { - try - { - if (!await MessagingConfigurationVerifier - .VerifyConfigurationAsync( - Database.Value, - cancellationToken) - .ConfigureAwait(false)) - { - Logger.MessagingConfigurationInvalid( - L1L2RedisCacheOptions.MessagingType); - } - - await MessageSubscriber - .SubscribeAsync( - Database.Value.Multiplexer, - cancellationToken) - .ConfigureAwait(false); - break; - } - catch (RedisConnectionException redisConnectionException) - { - Logger.SubscriberFailed( - L1L2RedisCacheOptions - .SubscriberRetryDelay, - redisConnectionException); - - await Task - .Delay( - L1L2RedisCacheOptions - .SubscriberRetryDelay, - cancellationToken) - .ConfigureAwait(false); - } - } - } -} diff --git a/src/L1L2RedisCache.csproj b/src/L1L2RedisCache.csproj deleted file mode 100644 index 637ecfc..0000000 --- a/src/L1L2RedisCache.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - latest-recommended - true - true - true - enable - latest - CA1724;CA1812;SYSLIB1006; - enable - README.md - git - https://github.com/null-d3v/L1L2RedisCache.git - netstandard2.1 - - - - - - - - - - - - - - - diff --git a/src/L1L2RedisCache/Configuration/ServiceCollectionExtensions.cs b/src/L1L2RedisCache/Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..5664eb7 --- /dev/null +++ b/src/L1L2RedisCache/Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using MessagingRedisCache; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up L1L2RedisCache related services in an Microsoft.Extensions.DependencyInjection.IServiceCollection. +/// +public static class ServiceCollectionExtensions +{ + extension( + IServiceCollection services) + { + /// + /// Adds L1L2RedisCache distributed caching services to the specified IServiceCollection. + /// + /// The IServiceCollection so that additional calls can be chained. + public IServiceCollection AddL1L2RedisCache( + Action setupAction) + { + ArgumentNullException.ThrowIfNull(setupAction); + + services + .AddMessagingRedisCache(setupAction) + .AddMemoryCacheSubscriber(); + services.AddSingleton(); + services.AddMemoryCache(); + + return services; + } + } +} \ No newline at end of file diff --git a/src/L1L2RedisCache/HashEntryArrayExtensions.cs b/src/L1L2RedisCache/HashEntryArrayExtensions.cs new file mode 100644 index 0000000..ca087c6 --- /dev/null +++ b/src/L1L2RedisCache/HashEntryArrayExtensions.cs @@ -0,0 +1,49 @@ +using Microsoft.Extensions.Caching.Distributed; +using StackExchange.Redis; + +namespace L1L2RedisCache; + +internal static class HashEntryArrayExtensions +{ + private const string AbsoluteExpirationKey = "absexp"; + private const string DataKey = "data"; + private const long NotPresent = -1; + private const string SlidingExpirationKey = "sldexp"; + + extension( + HashEntry[] hashEntries) + { + internal DistributedCacheEntryOptions GetDistributedCacheEntryOptions() + { + var distributedCacheEntryOptions = new DistributedCacheEntryOptions(); + + var absoluteExpirationHashEntry = hashEntries.FirstOrDefault( + hashEntry => hashEntry.Name == AbsoluteExpirationKey); + if (absoluteExpirationHashEntry.Value.HasValue && + absoluteExpirationHashEntry.Value != NotPresent) + { + distributedCacheEntryOptions.AbsoluteExpiration = new DateTimeOffset( + (long)absoluteExpirationHashEntry.Value, TimeSpan.Zero); + } + + var slidingExpirationHashEntry = hashEntries.FirstOrDefault( + hashEntry => hashEntry.Name == SlidingExpirationKey); + if (slidingExpirationHashEntry.Value.HasValue && + slidingExpirationHashEntry.Value != NotPresent) + { + distributedCacheEntryOptions.SlidingExpiration = new TimeSpan( + (long)slidingExpirationHashEntry.Value); + } + + return distributedCacheEntryOptions; + } + + internal RedisValue GetRedisValue() + { + var dataHashEntry = hashEntries.FirstOrDefault( + hashEntry => hashEntry.Name == DataKey); + + return dataHashEntry.Value; + } + } +} \ No newline at end of file diff --git a/src/L1L2RedisCache/L1L2RedisCache.cs b/src/L1L2RedisCache/L1L2RedisCache.cs new file mode 100644 index 0000000..807ccb8 --- /dev/null +++ b/src/L1L2RedisCache/L1L2RedisCache.cs @@ -0,0 +1,369 @@ +using MessagingRedisCache; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using System.Diagnostics.CodeAnalysis; + +namespace L1L2RedisCache; + +/// +/// A distributed cache implementation using both memory and Redis. +/// +public class L1L2RedisCache( + IBufferDistributedCache bufferDistributedCache, + IMemoryCache l1Cache, + IMessagePublisher messagePublisher, + IMessageSubscriber messageSubscriber, + IMessagingConfigurationVerifier messagingConfigurationVerifier, + IOptions messagingRedisCacheOptionsAccessor, + ILogger? logger = null) : + MessagingRedisCache.MessagingRedisCache( + bufferDistributedCache, + messagePublisher, + messageSubscriber, + messagingConfigurationVerifier, + messagingRedisCacheOptionsAccessor, + logger), + IDistributedCache +{ + /// + /// The IMemoryCache for L1. + /// + public IMemoryCache L1Cache { get; } = + l1Cache ?? + throw new ArgumentNullException( + nameof(l1Cache)); + + private SemaphoreSlim KeySemaphore { get; } = + new SemaphoreSlim(1, 1); + + /// + [SuppressMessage("Reliability", "CA2000")] + public new byte[]? Get(string key) + { + var value = L1Cache.Get(key) as byte[]; + + if (value == null) + { + if (Database.Value.KeyExists( + $"{MessagingRedisCacheOptions.InstanceName}{key}")) + { + var semaphore = GetOrCreateKeyLock( + key, + null); + semaphore.Wait(); + try + { + var hashEntries = GetHashEntries(key); + var distributedCacheEntryOptions = hashEntries + .GetDistributedCacheEntryOptions(); + value = hashEntries.GetRedisValue(); + + SetMemoryCache( + key, + value!, + distributedCacheEntryOptions); + SetKeyLock( + key, + semaphore, + distributedCacheEntryOptions); + } + finally + { + semaphore.Release(); + } + } + } + + return value; + } + + /// + public new async Task GetAsync( + string key, + CancellationToken token = default) + { + var value = L1Cache.Get(key) as byte[]; + + if (value == null) + { + if (await Database.Value + .KeyExistsAsync( + $"{MessagingRedisCacheOptions.InstanceName}{key}") + .ConfigureAwait(false)) + { + var semaphore = await GetOrCreateKeyLockAsync( + key, + null, + token) + .ConfigureAwait(false); + await semaphore + .WaitAsync(token) + .ConfigureAwait(false); + try + { + var hashEntries = await GetHashEntriesAsync(key) + .ConfigureAwait(false); + var distributedCacheEntryOptions = hashEntries + .GetDistributedCacheEntryOptions(); + value = hashEntries.GetRedisValue(); + + SetMemoryCache( + key, + value!, + distributedCacheEntryOptions); + SetKeyLock( + key, + semaphore, + distributedCacheEntryOptions); + } + finally + { + semaphore.Release(); + } + } + } + + return value; + } + + /// + [SuppressMessage("Reliability", "CA2000")] + public new void Remove(string key) + { + var semaphore = GetOrCreateKeyLock(key, null); + semaphore.Wait(); + try + { + base.Remove(key); + L1Cache.Remove(key); + L1Cache.Remove( + $"{MessagingRedisCacheOptions.Id}{key}"); + } + finally + { + semaphore.Release(); + } + } + + /// + public new async Task RemoveAsync( + string key, + CancellationToken token = default) + { + var semaphore = await GetOrCreateKeyLockAsync( + key, + null, + token) + .ConfigureAwait(false); + await semaphore + .WaitAsync(token) + .ConfigureAwait(false); + try + { + await base + .RemoveAsync(key, token) + .ConfigureAwait(false); + L1Cache.Remove(key); + L1Cache.Remove( + $"{MessagingRedisCacheOptions.Id}{key}"); + } + finally + { + semaphore.Release(); + } + } + + /// + [SuppressMessage("Reliability", "CA2000")] + public new void Set( + string key, + byte[] value, + DistributedCacheEntryOptions options) + { + var semaphore = GetOrCreateKeyLock( + key, options); + semaphore.Wait(); + try + { + base.Set( + key, value, options); + SetMemoryCache( + key, value, options); + } + finally + { + semaphore.Release(); + } + } + + /// + public new async Task SetAsync( + string key, + byte[] value, + DistributedCacheEntryOptions options, + CancellationToken token = default) + { + var semaphore = await GetOrCreateKeyLockAsync( + key, + options, + token) + .ConfigureAwait(false); + await semaphore + .WaitAsync(token) + .ConfigureAwait(false); + try + { + await base + .SetAsync( + key, + value, + options, + token) + .ConfigureAwait(false); + SetMemoryCache( + key, value, options); + } + finally + { + semaphore.Release(); + } + } + + private HashEntry[] GetHashEntries(string key) + { + var hashEntries = Array.Empty(); + + try + { + hashEntries = Database.Value.HashGetAll( + $"{MessagingRedisCacheOptions.InstanceName}{key}"); + } + catch (RedisServerException) { } + + return hashEntries; + } + + private async Task GetHashEntriesAsync(string key) + { + var hashEntries = Array.Empty(); + + try + { + hashEntries = await Database.Value + .HashGetAllAsync( + $"{MessagingRedisCacheOptions.InstanceName}{key}") + .ConfigureAwait(false); + } + catch (RedisServerException) { } + + return hashEntries; + } + + private SemaphoreSlim GetOrCreateKeyLock( + string key, + DistributedCacheEntryOptions? distributedCacheEntryOptions) + { + KeySemaphore.Wait(); + try + { + return L1Cache.GetOrCreate( + $"{MessagingRedisCacheOptions.Id}{key}", + cacheEntry => + { + cacheEntry.AbsoluteExpiration = + distributedCacheEntryOptions?.AbsoluteExpiration; + cacheEntry.AbsoluteExpirationRelativeToNow = + distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow; + cacheEntry.SlidingExpiration = + distributedCacheEntryOptions?.SlidingExpiration; + return new SemaphoreSlim(1, 1); + }) ?? + new SemaphoreSlim(1, 1); + } + finally + { + KeySemaphore.Release(); + } + } + + [SuppressMessage("Reliability", "CA2000")] + private async Task GetOrCreateKeyLockAsync( + string key, + DistributedCacheEntryOptions? distributedCacheEntryOptions, + CancellationToken cancellationToken = default) + { + await KeySemaphore + .WaitAsync(cancellationToken) + .ConfigureAwait(false); + try + { + return await L1Cache + .GetOrCreateAsync( + $"{MessagingRedisCacheOptions.Id}{key}", + cacheEntry => + { + cacheEntry.AbsoluteExpiration = + distributedCacheEntryOptions?.AbsoluteExpiration; + cacheEntry.AbsoluteExpirationRelativeToNow = + distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow; + cacheEntry.SlidingExpiration = + distributedCacheEntryOptions?.SlidingExpiration; + return Task.FromResult(new SemaphoreSlim(1, 1)); + }) + .ConfigureAwait(false) ?? + new SemaphoreSlim(1, 1); + } + finally + { + KeySemaphore.Release(); + } + } + + private SemaphoreSlim SetKeyLock( + string key, + SemaphoreSlim semaphore, + DistributedCacheEntryOptions distributedCacheEntryOptions) + { + var memoryCacheEntryOptions = new MemoryCacheEntryOptions + { + AbsoluteExpiration = + distributedCacheEntryOptions?.AbsoluteExpiration, + AbsoluteExpirationRelativeToNow = + distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow, + SlidingExpiration = + distributedCacheEntryOptions?.SlidingExpiration, + }; + + return L1Cache.Set( + $"{MessagingRedisCacheOptions.Id}{key}", + semaphore, + memoryCacheEntryOptions); + } + + private void SetMemoryCache( + string key, + byte[] value, + DistributedCacheEntryOptions distributedCacheEntryOptions) + { + var memoryCacheEntryOptions = new MemoryCacheEntryOptions + { + AbsoluteExpiration = + distributedCacheEntryOptions?.AbsoluteExpiration, + AbsoluteExpirationRelativeToNow = + distributedCacheEntryOptions?.AbsoluteExpirationRelativeToNow, + SlidingExpiration = + distributedCacheEntryOptions?.SlidingExpiration, + }; + + if (!memoryCacheEntryOptions.SlidingExpiration.HasValue) + { + L1Cache.Set( + key, + value, + memoryCacheEntryOptions); + } + } +} \ No newline at end of file diff --git a/src/L1L2RedisCache/L1L2RedisCache.csproj b/src/L1L2RedisCache/L1L2RedisCache.csproj new file mode 100644 index 0000000..c5031a0 --- /dev/null +++ b/src/L1L2RedisCache/L1L2RedisCache.csproj @@ -0,0 +1,24 @@ + + + true + CA1724; + README.md + true + net8.0;net9.0;net10.0 + + + + + + + + + + + + + + + + + diff --git a/src/L1L2RedisCache/README.md b/src/L1L2RedisCache/README.md new file mode 100644 index 0000000..50e4020 --- /dev/null +++ b/src/L1L2RedisCache/README.md @@ -0,0 +1,43 @@ +# L1L2RedisCache + +`L1L2RedisCache` is an implementation of [`IDistributedCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IDistributedCache.cs), leveraging [`IMemoryCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IMemoryCache.cs) as a level 1 cache and [`MessagingRedisCache`](../MessagingRedisCache/README.md) as a level 2 cache. Level 1 evictions are managed via `MessagingRedisCache`'s [Redis pub/sub](https://redis.io/topics/pubsub). + +`L1L2RedisCache` is heavily inspired by development insights provided over the past several years by [StackOverflow](https://stackoverflow.com/). It attempts to simplify those concepts into a highly accessible `IDistributedCache` implementation that is more performant than a baseline `RedisCache`. + +Using a [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) combined with [`MessagingRedisCache`](../MessagingRedisCache/README.md) is a viable alternative to this project. It is possible that with increased functionality of `HybridCache` that this project will no longer be necessary. + +## Configuration + +It is intended that L1L12RedisCache be used as an `IDistributedCache` implementation. + +`L1L2RedisCache` can be registered during startup with the following `IServiceCollection` extension method: + +``` +services.AddL1L2RedisCache(options => +{ + options.Configuration = "localhost"; + options.InstanceName = "Namespace:Prefix:"; +}); +``` + +## MessagingRedisCacheOptions + +`L1L2RedisCache` uses `MessagingRedisCacheOptions` which extend the standard `RedisCache` [`RedisCacheOptions`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCacheOptions.cs). The following additional customizations are supported: + +### MessagingType + +The type of messaging system to use for L1 memory cache eviction. + +| MessagingType | Description | Suggestion | +| - | - | - | +| `Default` | Use standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages for L1 memory cache eviction. | Default behavior. The Redis server requires no additional configuration. | +| `KeyeventNotifications` | Use [keyevent notifications](https://redis.io/topics/notifications) for L1 memory eviction instead of standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyevent notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghE` configuration and the majority of keys in the server are managed by `L1L2RedisCache`. | +| `KeyspaceNotifications` | Use [keyspace notifications](https://redis.io/topics/notifications) for L1 memory eviction instead of standard `L1L2RedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyspace notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghK` configuration and the majority of keys in the server are managed by `L1L2RedisCache`. | + +## Performance + +L1L2RedisCache will generally outperform a baseline `RedisCache`, particularly in cases of high volume or large cache entries. As entries are opportunistically pulled from memory instead of Redis, where costs of latency, network, and Redis operations are avoided. Respective performance gains will rely heavily on the impact of the afforementioned factors. + +## Considerations + +Due to the complex nature of a distributed L1 memory cache, cache entries with sliding expirations are only stored in L2 (Redis). These entries will show no performance improvement over the standard `RedisCache`, but incur no performance penalty. diff --git a/src/L1L2RedisCacheOptions.cs b/src/L1L2RedisCacheOptions.cs deleted file mode 100644 index 230b178..0000000 --- a/src/L1L2RedisCacheOptions.cs +++ /dev/null @@ -1,50 +0,0 @@ -using Microsoft.Extensions.Caching.StackExchangeRedis; -using Microsoft.Extensions.Options; - -namespace L1L2RedisCache; - -/// -/// Configuration options for L1L2RedisCache. -/// -public sealed class L1L2RedisCacheOptions : - RedisCacheOptions, IOptions -{ - /// - /// Initializes a new instance of L1L2RedisCacheOptions. - /// - public L1L2RedisCacheOptions() : base() { } - - /// - /// Unique identifier for the operating instance. - /// - public Guid Id { get; } = Guid.NewGuid(); - - /// - /// The pub/sub channel name. - /// - public string Channel => $"{KeyPrefix}Channel"; - - /// - /// A prefix to be applied to all cache keys. - /// - public string KeyPrefix => InstanceName ?? string.Empty; - - /// - /// A prefix to be applied to all L1 lock cache keys. - /// - public string LockKeyPrefix => $"{KeyPrefix}{Id}"; - - /// - /// The type of messaging to use for L1 memory cache eviction. - /// - public MessagingType MessagingType { get; set; } = - MessagingType.Default; - - /// - /// The duration of time to delay before retrying subscriber intialization. - /// - public TimeSpan SubscriberRetryDelay { get; set; } = - TimeSpan.FromSeconds(5); - - L1L2RedisCacheOptions IOptions.Value => this; -} diff --git a/src/Messaging/DefaultMessagePublisher.cs b/src/Messaging/DefaultMessagePublisher.cs deleted file mode 100644 index f0c81c0..0000000 --- a/src/Messaging/DefaultMessagePublisher.cs +++ /dev/null @@ -1,56 +0,0 @@ -using Microsoft.Extensions.Options; -using StackExchange.Redis; -using System.Text.Json; - -namespace L1L2RedisCache; - -internal sealed class DefaultMessagePublisher( - IOptions jsonSerializerOptionsAccessor, - IOptions l1L2RedisCacheOptionsOptionsAccessor) : - IMessagePublisher -{ - public JsonSerializerOptions JsonSerializerOptions { get; set; } = - jsonSerializerOptionsAccessor.Value; - public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = - l1L2RedisCacheOptionsOptionsAccessor.Value; - - public void Publish( - IConnectionMultiplexer connectionMultiplexer, - string key) - { - connectionMultiplexer - .GetSubscriber() - .Publish( - new RedisChannel( - L1L2RedisCacheOptions.Channel, - RedisChannel.PatternMode.Literal), - JsonSerializer.Serialize( - new CacheMessage - { - Key = key, - PublisherId = L1L2RedisCacheOptions.Id, - }, - JsonSerializerOptions)); - } - - public async Task PublishAsync( - IConnectionMultiplexer connectionMultiplexer, - string key, - CancellationToken cancellationToken = default) - { - await connectionMultiplexer - .GetSubscriber() - .PublishAsync( - new RedisChannel( - L1L2RedisCacheOptions.Channel, - RedisChannel.PatternMode.Literal), - JsonSerializer.Serialize( - new CacheMessage - { - Key = key, - PublisherId = L1L2RedisCacheOptions.Id, - }, - JsonSerializerOptions)) - .ConfigureAwait(false); - } -} diff --git a/src/Messaging/DefaultMessageSubscriber.cs b/src/Messaging/DefaultMessageSubscriber.cs deleted file mode 100644 index 7453eb1..0000000 --- a/src/Messaging/DefaultMessageSubscriber.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using StackExchange.Redis; -using System.Text.Json; - -namespace L1L2RedisCache; - -internal class DefaultMessageSubscriber( - IOptions jsonSerializerOptionsAcccessor, - IMemoryCache l1Cache, - IOptions l1L2RedisCacheOptionsOptionsAccessor) : - IMessageSubscriber -{ - public JsonSerializerOptions JsonSerializerOptions { get; set; } = - jsonSerializerOptionsAcccessor.Value; - public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = - l1L2RedisCacheOptionsOptionsAccessor.Value; - public IMemoryCache L1Cache { get; set; } = - l1Cache; - public EventHandler? OnMessage { get; set; } - public EventHandler? OnSubscribe { get; set; } - - public async Task SubscribeAsync( - IConnectionMultiplexer connectionMultiplexer, - CancellationToken cancellationToken = default) - { - await connectionMultiplexer - .GetSubscriber() - .SubscribeAsync( - new RedisChannel( - L1L2RedisCacheOptions.Channel, - RedisChannel.PatternMode.Literal), - ProcessMessage) - .ConfigureAwait(false); - - OnSubscribe?.Invoke( - this, - EventArgs.Empty); - } - - public async Task UnsubscribeAsync( - IConnectionMultiplexer connectionMultiplexer, - CancellationToken cancellationToken = default) - { - await connectionMultiplexer - .GetSubscriber() - .UnsubscribeAsync( - new RedisChannel( - L1L2RedisCacheOptions.Channel, - RedisChannel.PatternMode.Literal)) - .ConfigureAwait(false); - } - - internal void ProcessMessage( - RedisChannel channel, - RedisValue message) - { - var cacheMessage = JsonSerializer - .Deserialize( - message.ToString(), - JsonSerializerOptions); - if (cacheMessage != null && - cacheMessage.PublisherId != L1L2RedisCacheOptions.Id) - { - L1Cache.Remove( - $"{L1L2RedisCacheOptions.KeyPrefix}{cacheMessage.Key}"); - L1Cache.Remove( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{cacheMessage.Key}"); - - OnMessage?.Invoke( - this, - new OnMessageEventArgs(cacheMessage.Key)); - } - } -} diff --git a/src/Messaging/KeyeventMessageSubscriber.cs b/src/Messaging/KeyeventMessageSubscriber.cs deleted file mode 100644 index c80b6a7..0000000 --- a/src/Messaging/KeyeventMessageSubscriber.cs +++ /dev/null @@ -1,86 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace L1L2RedisCache; - -internal class KeyeventMessageSubscriber( - IMemoryCache l1Cache, - IOptions l1L2RedisCacheOptionsOptionsAccessor) : - IMessageSubscriber -{ - public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = - l1L2RedisCacheOptionsOptionsAccessor.Value; - public IMemoryCache L1Cache { get; set; } = - l1Cache; - public EventHandler? OnMessage { get; set; } - public EventHandler? OnSubscribe { get; set; } - - public async Task SubscribeAsync( - IConnectionMultiplexer connectionMultiplexer, - CancellationToken cancellationToken = default) - { - await connectionMultiplexer - .GetSubscriber() - .SubscribeAsync( - new RedisChannel( - "__keyevent@*__:del", - RedisChannel.PatternMode.Pattern), - ProcessMessage) - .ConfigureAwait(false); - - await connectionMultiplexer - .GetSubscriber() - .SubscribeAsync( - new RedisChannel( - "__keyevent@*__:hset", - RedisChannel.PatternMode.Pattern), - ProcessMessage) - .ConfigureAwait(false); - - OnSubscribe?.Invoke( - this, - EventArgs.Empty); - } - - public async Task UnsubscribeAsync( - IConnectionMultiplexer connectionMultiplexer, - CancellationToken cancellationToken = default) - { - await connectionMultiplexer - .GetSubscriber() - .UnsubscribeAsync( - new RedisChannel( - "__keyevent@*__:del", - RedisChannel.PatternMode.Pattern)) - .ConfigureAwait(false); - - await connectionMultiplexer - .GetSubscriber() - .UnsubscribeAsync( - new RedisChannel( - "__keyevent@*__:hset", - RedisChannel.PatternMode.Pattern)) - .ConfigureAwait(false); - } - - internal void ProcessMessage( - RedisChannel channel, - RedisValue message) - { - if (message.StartsWith( - L1L2RedisCacheOptions.KeyPrefix)) - { - var key = message - .ToString()[L1L2RedisCacheOptions.KeyPrefix.Length..]; - L1Cache.Remove( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); - L1Cache.Remove( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); - - OnMessage?.Invoke( - this, - new OnMessageEventArgs(key)); - } - } -} diff --git a/src/Messaging/KeyspaceMessageSubscriber.cs b/src/Messaging/KeyspaceMessageSubscriber.cs deleted file mode 100644 index a98edbf..0000000 --- a/src/Messaging/KeyspaceMessageSubscriber.cs +++ /dev/null @@ -1,75 +0,0 @@ -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using StackExchange.Redis; - -namespace L1L2RedisCache; - -internal class KeyspaceMessageSubscriber( - IMemoryCache l1Cache, - IOptions l1L2RedisCacheOptionsOptionsAccessor) : - IMessageSubscriber -{ - public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } = - l1L2RedisCacheOptionsOptionsAccessor.Value; - public IMemoryCache L1Cache { get; set; } = - l1Cache; - public EventHandler? OnMessage { get; set; } - public EventHandler? OnSubscribe { get; set; } - - public async Task SubscribeAsync( - IConnectionMultiplexer connectionMultiplexer, - CancellationToken cancellationToken = default) - { - await connectionMultiplexer - .GetSubscriber() - .SubscribeAsync( - new RedisChannel( - "__keyspace@*__:*", - RedisChannel.PatternMode.Pattern), - ProcessMessage) - .ConfigureAwait(false); - - OnSubscribe?.Invoke( - this, - EventArgs.Empty); - } - - public async Task UnsubscribeAsync( - IConnectionMultiplexer connectionMultiplexer, - CancellationToken cancellationToken = default) - { - await connectionMultiplexer - .GetSubscriber() - .UnsubscribeAsync( - new RedisChannel( - "__keyspace@*__:*", - RedisChannel.PatternMode.Pattern)) - .ConfigureAwait(false); - } - - internal void ProcessMessage( - RedisChannel channel, - RedisValue message) - { - if (message == "del" || - message == "hset") - { - var keyPrefixIndex = channel.ToString().IndexOf( - L1L2RedisCacheOptions.KeyPrefix, - StringComparison.Ordinal); - if (keyPrefixIndex != -1) - { - var key = channel.ToString()[ - (keyPrefixIndex + L1L2RedisCacheOptions.KeyPrefix.Length)..]; - L1Cache.Remove( - $"{L1L2RedisCacheOptions.KeyPrefix}{key}"); - L1Cache.Remove( - $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}"); - - OnMessage?.Invoke( - this, - new OnMessageEventArgs(key)); - } - } - } -} diff --git a/src/Messaging/OnMessageEventArgs.cs b/src/Messaging/OnMessageEventArgs.cs deleted file mode 100644 index 1e47b63..0000000 --- a/src/Messaging/OnMessageEventArgs.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace L1L2RedisCache; - -/// -/// Supplies information about a message event from an IMessageSubscriber. -/// -/// -/// Initializes a new instance of OnMessageEventArgs. -/// -public class OnMessageEventArgs( - string key) : - EventArgs -{ - - /// - /// The cache key pertaining to the message event. - /// - public string Key { get; set; } = key; -} diff --git a/src/CacheMessage.cs b/src/MessagingRedisCache/CacheMessage.cs similarity index 92% rename from src/CacheMessage.cs rename to src/MessagingRedisCache/CacheMessage.cs index 2b80666..0c4a5c2 100644 --- a/src/CacheMessage.cs +++ b/src/MessagingRedisCache/CacheMessage.cs @@ -1,4 +1,4 @@ -namespace L1L2RedisCache; +namespace MessagingRedisCache; /// /// A Redis pub/sub message indicating a cache value has changed. diff --git a/src/Configuration/IMessagingConfigurationVerifier.cs b/src/MessagingRedisCache/Configuration/IMessagingConfigurationVerifier.cs similarity index 95% rename from src/Configuration/IMessagingConfigurationVerifier.cs rename to src/MessagingRedisCache/Configuration/IMessagingConfigurationVerifier.cs index 53dadab..3f15506 100644 --- a/src/Configuration/IMessagingConfigurationVerifier.cs +++ b/src/MessagingRedisCache/Configuration/IMessagingConfigurationVerifier.cs @@ -1,6 +1,6 @@ using StackExchange.Redis; -namespace L1L2RedisCache; +namespace MessagingRedisCache; /// /// Verifies Redis configuration settings. diff --git a/src/MessagingRedisCache/Configuration/IMessagingRedisCacheBuilder.cs b/src/MessagingRedisCache/Configuration/IMessagingRedisCacheBuilder.cs new file mode 100644 index 0000000..cdf810b --- /dev/null +++ b/src/MessagingRedisCache/Configuration/IMessagingRedisCacheBuilder.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MessagingRedisCache; + +/// +/// A builder abstraction for configuring a messaging Redis cache. +/// +public interface IMessagingRedisCacheBuilder +{ + IServiceCollection Services { get; } +} \ No newline at end of file diff --git a/src/Configuration/MessagingConfigurationVerifier.cs b/src/MessagingRedisCache/Configuration/MessagingConfigurationVerifier.cs similarity index 71% rename from src/Configuration/MessagingConfigurationVerifier.cs rename to src/MessagingRedisCache/Configuration/MessagingConfigurationVerifier.cs index 4e2cf1e..1291c48 100644 --- a/src/Configuration/MessagingConfigurationVerifier.cs +++ b/src/MessagingRedisCache/Configuration/MessagingConfigurationVerifier.cs @@ -1,10 +1,10 @@ using Microsoft.Extensions.Options; using StackExchange.Redis; -namespace L1L2RedisCache; +namespace MessagingRedisCache; -internal sealed class MessagingConfigurationVerifier( - IOptions l1L2RedisCacheOptionsOptionsAccessor) : +public sealed class MessagingConfigurationVerifier( + IOptions messagingRedisCacheOptionsAccessor) : IMessagingConfigurationVerifier { private const string config = "notify-keyspace-events"; @@ -19,18 +19,20 @@ static MessagingConfigurationVerifier() }; } - internal static IDictionary NotifyKeyspaceEventsConfig { get; } + public static IDictionary NotifyKeyspaceEventsConfig { get; } - public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; } = - l1L2RedisCacheOptionsOptionsAccessor.Value; + public MessagingRedisCacheOptions MessagingRedisCacheOptions { get; } = + messagingRedisCacheOptionsAccessor.Value; public async Task VerifyConfigurationAsync( IDatabase database, - CancellationToken _ = default) + CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(database); + var isVerified = NotifyKeyspaceEventsConfig .TryGetValue( - L1L2RedisCacheOptions.MessagingType, + MessagingRedisCacheOptions.MessagingType, out var expectedValues); var configValue = (await database diff --git a/src/MessagingRedisCache/Configuration/MessagingRedisCacheBuilder.cs b/src/MessagingRedisCache/Configuration/MessagingRedisCacheBuilder.cs new file mode 100644 index 0000000..2a37955 --- /dev/null +++ b/src/MessagingRedisCache/Configuration/MessagingRedisCacheBuilder.cs @@ -0,0 +1,11 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace MessagingRedisCache; + +internal class MessagingRedisCacheBuilder( + IServiceCollection services) : + IMessagingRedisCacheBuilder +{ + public IServiceCollection Services { get; } = + services; +} \ No newline at end of file diff --git a/src/MessagingRedisCache/Configuration/MessagingRedisCacheBuilderExtensions.cs b/src/MessagingRedisCache/Configuration/MessagingRedisCacheBuilderExtensions.cs new file mode 100644 index 0000000..acab99a --- /dev/null +++ b/src/MessagingRedisCache/Configuration/MessagingRedisCacheBuilderExtensions.cs @@ -0,0 +1,42 @@ +using MessagingRedisCache; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up messaging Redis cache related services. +/// +public static class MessagingRedisCacheBuilderExtensions +{ + extension( + IMessagingRedisCacheBuilder builder) + { + public IMessagingRedisCacheBuilder AddMemoryCacheSubscriber() + { + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.AddSingleton( + serviceProvider => + { + var options = serviceProvider + .GetRequiredService>() + .Value; + + return options.MessagingType switch + { + MessagingType.Default => + serviceProvider.GetRequiredService(), + MessagingType.KeyeventNotifications => + serviceProvider.GetRequiredService(), + MessagingType.KeyspaceNotifications => + serviceProvider.GetRequiredService(), + _ => throw new NotImplementedException(), + }; + }); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/MessagingRedisCache/Configuration/ServiceCollectionExtensions.cs b/src/MessagingRedisCache/Configuration/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..225065d --- /dev/null +++ b/src/MessagingRedisCache/Configuration/ServiceCollectionExtensions.cs @@ -0,0 +1,89 @@ +using MessagingRedisCache; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up messaging Redis cache related services in an Microsoft.Extensions.DependencyInjection.IServiceCollection. +/// +public static class ServiceCollectionExtensions +{ + extension( + IServiceCollection services) + { + /// + /// Adds messaging Redis caching services to the specified IServiceCollection. + /// + /// A IMessagingRedisCacheBuilder so that additional calls can be chained. + [SuppressMessage("Performance", "CA1849")] + public IMessagingRedisCacheBuilder AddMessagingRedisCache( + Action setupAction) + { + ArgumentNullException.ThrowIfNull(setupAction); + + services.AddOptions(); + services.Configure(setupAction); + services.Configure( + options => + { + if (options.ConnectionMultiplexerFactory == null) + { + if (options.ConfigurationOptions != null) + { + options.ConnectionMultiplexerFactory = () => + Task.FromResult( + ConnectionMultiplexer.Connect( + options.ConfigurationOptions) as IConnectionMultiplexer); + } + else if (!string.IsNullOrEmpty(options.Configuration)) + { + options.ConnectionMultiplexerFactory = () => + Task.FromResult( + ConnectionMultiplexer.Connect( + options.Configuration) as IConnectionMultiplexer); + } + } + }); + services.AddSingleton( + serviceProvider => + new RedisCache( + serviceProvider + .GetRequiredService>())); + services.AddSingleton( + serviceProvider => new Func( + () => new RedisCache( + serviceProvider.GetRequiredService>()))); + services.TryAddSingleton(); + services.TryAddSingleton(); + + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton( + serviceProvider => + { + var options = serviceProvider + .GetRequiredService>() + .Value; + + return options.MessagingType switch + { + MessagingType.Default => + serviceProvider.GetRequiredService(), + MessagingType.KeyeventNotifications or MessagingType.KeyspaceNotifications => + serviceProvider.GetRequiredService(), + _ => + throw new NotImplementedException(), + }; + }); + + services.TryAddSingleton(); + + return new MessagingRedisCacheBuilder(services); + } + } +} diff --git a/src/MessagingRedisCache/Messaging/DefaultMessagePublisher.cs b/src/MessagingRedisCache/Messaging/DefaultMessagePublisher.cs new file mode 100644 index 0000000..5c0d8f5 --- /dev/null +++ b/src/MessagingRedisCache/Messaging/DefaultMessagePublisher.cs @@ -0,0 +1,53 @@ +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using System.Text.Json; + +namespace MessagingRedisCache; + +internal sealed class DefaultMessagePublisher( + IOptions messagingRedisCacheOptionsAccessor) : + IMessagePublisher +{ + public MessagingRedisCacheOptions MessagingRedisCacheOptions { get; set; } = + messagingRedisCacheOptionsAccessor.Value; + + public void Publish( + IConnectionMultiplexer connectionMultiplexer, + RedisKey key) + { + connectionMultiplexer + .GetSubscriber() + .Publish( + new RedisChannel( + MessagingRedisCacheOptions.Channel, + RedisChannel.PatternMode.Literal), + JsonSerializer.Serialize( + new CacheMessage + { + Key = key.ToString(), + PublisherId = MessagingRedisCacheOptions.Id, + }, + SourceGenerationContext.Default.CacheMessage)); + } + + public async Task PublishAsync( + IConnectionMultiplexer connectionMultiplexer, + RedisKey key, + CancellationToken cancellationToken = default) + { + await connectionMultiplexer + .GetSubscriber() + .PublishAsync( + new RedisChannel( + MessagingRedisCacheOptions.Channel, + RedisChannel.PatternMode.Literal), + JsonSerializer.Serialize( + new CacheMessage + { + Key = key.ToString(), + PublisherId = MessagingRedisCacheOptions.Id, + }, + SourceGenerationContext.Default.CacheMessage)) + .ConfigureAwait(false); + } +} diff --git a/src/MessagingRedisCache/Messaging/DefaultMessageSubscriber.cs b/src/MessagingRedisCache/Messaging/DefaultMessageSubscriber.cs new file mode 100644 index 0000000..8f2d43d --- /dev/null +++ b/src/MessagingRedisCache/Messaging/DefaultMessageSubscriber.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using System.Text.Json; + +namespace MessagingRedisCache; + +internal class DefaultMessageSubscriber( + IMemoryCache memoryCache, + IOptions messagingRedisCacheOptionsAccessor) : + IMessageSubscriber +{ + public IMemoryCache MemoryCache { get; set; } = + memoryCache; + public MessagingRedisCacheOptions MessagingRedisCacheOptions { get; set; } = + messagingRedisCacheOptionsAccessor.Value; + + public async Task SubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + (await connectionMultiplexer + .GetSubscriber() + .SubscribeAsync( + new RedisChannel( + MessagingRedisCacheOptions.Channel, + RedisChannel.PatternMode.Literal)) + .ConfigureAwait(false)) + .OnMessage(ProcessMessageAsync); + + await MessagingRedisCacheOptions + .Events + .OnSubscribe + .Invoke() + .ConfigureAwait(false); + } + + public async Task UnsubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + await connectionMultiplexer + .GetSubscriber() + .UnsubscribeAsync( + new RedisChannel( + MessagingRedisCacheOptions.Channel, + RedisChannel.PatternMode.Literal)) + .ConfigureAwait(false); + } + + internal async Task ProcessMessageAsync( + ChannelMessage channelMessage) + { + var cacheMessage = JsonSerializer + .Deserialize( + channelMessage.Message.ToString(), + SourceGenerationContext.Default.CacheMessage); + if (cacheMessage != null && + cacheMessage.PublisherId != MessagingRedisCacheOptions.Id) + { + MemoryCache.Remove( + cacheMessage.Key); + + await MessagingRedisCacheOptions + .Events + .OnMessageRecieved + .Invoke(channelMessage) + .ConfigureAwait(false); + } + } +} diff --git a/src/Messaging/IMessagePublisher.cs b/src/MessagingRedisCache/Messaging/IMessagePublisher.cs similarity index 94% rename from src/Messaging/IMessagePublisher.cs rename to src/MessagingRedisCache/Messaging/IMessagePublisher.cs index d0b5458..a8266e6 100644 --- a/src/Messaging/IMessagePublisher.cs +++ b/src/MessagingRedisCache/Messaging/IMessagePublisher.cs @@ -1,6 +1,6 @@ using StackExchange.Redis; -namespace L1L2RedisCache; +namespace MessagingRedisCache; /// /// Publishes messages to other L1L2RedisCache instances indicating cache values have changed. @@ -14,7 +14,7 @@ public interface IMessagePublisher /// The cache key of the value that has changed. void Publish( IConnectionMultiplexer connectionMultiplexer, - string key); + RedisKey key); /// /// Publishes a message indicating a cache value has changed. @@ -25,6 +25,6 @@ void Publish( /// The System.Threading.Tasks.Task that represents the asynchronous operation. Task PublishAsync( IConnectionMultiplexer connectionMultiplexer, - string key, + RedisKey key, CancellationToken cancellationToken = default); } diff --git a/src/Messaging/IMessageSubscriber.cs b/src/MessagingRedisCache/Messaging/IMessageSubscriber.cs similarity index 81% rename from src/Messaging/IMessageSubscriber.cs rename to src/MessagingRedisCache/Messaging/IMessageSubscriber.cs index 069eb9a..f4c6ca4 100644 --- a/src/Messaging/IMessageSubscriber.cs +++ b/src/MessagingRedisCache/Messaging/IMessageSubscriber.cs @@ -1,22 +1,12 @@ using StackExchange.Redis; -namespace L1L2RedisCache; +namespace MessagingRedisCache; /// /// Subscribes to messages published by other L1L2RedisCache instances indicating cache values have changed. /// public interface IMessageSubscriber { - /// - /// An event that is raised when a message is recieved. - /// - EventHandler? OnMessage { get; set; } - - /// - /// An event that is raised when a subscription is created. - /// - EventHandler? OnSubscribe { get; set; } - /// /// Subscribes to messages indicating cache values have changed. /// diff --git a/src/MessagingRedisCache/Messaging/KeyeventMessageSubscriber.cs b/src/MessagingRedisCache/Messaging/KeyeventMessageSubscriber.cs new file mode 100644 index 0000000..e511feb --- /dev/null +++ b/src/MessagingRedisCache/Messaging/KeyeventMessageSubscriber.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using StackExchange.Redis; + +namespace MessagingRedisCache; + +internal class KeyeventMessageSubscriber( + IMemoryCache memoryCache, + IOptions messagingRedisCacheOptionsAccessor) : + IMessageSubscriber +{ + public IMemoryCache MemoryCache { get; set; } = + memoryCache; + public MessagingRedisCacheOptions MessagingRedisCacheOptions { get; set; } = + messagingRedisCacheOptionsAccessor.Value; + + public async Task SubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + (await connectionMultiplexer + .GetSubscriber() + .SubscribeAsync( + new RedisChannel( + "__keyevent@*__:del", + RedisChannel.PatternMode.Pattern)) + .ConfigureAwait(false)) + .OnMessage(ProcessMessageAsync); + + (await connectionMultiplexer + .GetSubscriber() + .SubscribeAsync( + new RedisChannel( + "__keyevent@*__:hset", + RedisChannel.PatternMode.Pattern)) + .ConfigureAwait(false)) + .OnMessage(ProcessMessageAsync); + + await MessagingRedisCacheOptions + .Events + .OnSubscribe + .Invoke() + .ConfigureAwait(false); + } + + public async Task UnsubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + await connectionMultiplexer + .GetSubscriber() + .UnsubscribeAsync( + new RedisChannel( + "__keyevent@*__:del", + RedisChannel.PatternMode.Pattern)) + .ConfigureAwait(false); + + await connectionMultiplexer + .GetSubscriber() + .UnsubscribeAsync( + new RedisChannel( + "__keyevent@*__:hset", + RedisChannel.PatternMode.Pattern)) + .ConfigureAwait(false); + } + + internal async Task ProcessMessageAsync( + ChannelMessage channelMessage) + { + if (string.IsNullOrEmpty( + MessagingRedisCacheOptions.InstanceName) || + channelMessage.Message.StartsWith( + MessagingRedisCacheOptions.InstanceName)) + { + var key = channelMessage.Message + .ToString()[ (MessagingRedisCacheOptions.InstanceName?.Length ?? 0).. ]; + MemoryCache.Remove( + key); + + await MessagingRedisCacheOptions + .Events + .OnMessageRecieved + .Invoke(channelMessage) + .ConfigureAwait(false); + } + } +} diff --git a/src/MessagingRedisCache/Messaging/KeyspaceMessageSubscriber.cs b/src/MessagingRedisCache/Messaging/KeyspaceMessageSubscriber.cs new file mode 100644 index 0000000..dfa11b3 --- /dev/null +++ b/src/MessagingRedisCache/Messaging/KeyspaceMessageSubscriber.cs @@ -0,0 +1,73 @@ +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using System.Text.RegularExpressions; + +namespace MessagingRedisCache; + +internal class KeyspaceMessageSubscriber( + IMemoryCache memoryCache, + IOptions messagingRedisCacheOptionsAccessor) : + IMessageSubscriber +{ + public IMemoryCache MemoryCache { get; set; } = + memoryCache; + public MessagingRedisCacheOptions MessagingRedisCacheOptions { get; set; } = + messagingRedisCacheOptionsAccessor.Value; + + private Regex ChannelPrefixRegex { get; } = new( + @$"__keyspace@\d*__:{messagingRedisCacheOptionsAccessor.Value.InstanceName}"); + + public async Task SubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + (await connectionMultiplexer + .GetSubscriber() + .SubscribeAsync( + new RedisChannel( + $"__keyspace@*__:{MessagingRedisCacheOptions.InstanceName}*", + RedisChannel.PatternMode.Pattern)) + .ConfigureAwait(false)) + .OnMessage(ProcessMessageAsync); + + await MessagingRedisCacheOptions + .Events + .OnSubscribe + .Invoke() + .ConfigureAwait(false); + } + + public async Task UnsubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + await connectionMultiplexer + .GetSubscriber() + .UnsubscribeAsync( + new RedisChannel( + $"__keyspace@*__:{MessagingRedisCacheOptions.InstanceName}*", + RedisChannel.PatternMode.Pattern)) + .ConfigureAwait(false); + } + + internal async Task ProcessMessageAsync( + ChannelMessage channelMessage) + { + if (channelMessage.Message == "del" || + channelMessage.Message == "hset") + { + var key = ChannelPrefixRegex.Replace( + channelMessage.Channel.ToString(), + string.Empty); + MemoryCache.Remove( + key); + + await MessagingRedisCacheOptions + .Events + .OnMessageRecieved + .Invoke(channelMessage) + .ConfigureAwait(false); + } + } +} diff --git a/src/Messaging/NopMessagePublisher.cs b/src/MessagingRedisCache/Messaging/NopMessagePublisher.cs similarity index 82% rename from src/Messaging/NopMessagePublisher.cs rename to src/MessagingRedisCache/Messaging/NopMessagePublisher.cs index 7d9fe4e..767bba1 100644 --- a/src/Messaging/NopMessagePublisher.cs +++ b/src/MessagingRedisCache/Messaging/NopMessagePublisher.cs @@ -1,17 +1,17 @@ using StackExchange.Redis; -namespace L1L2RedisCache; +namespace MessagingRedisCache; internal sealed class NopMessagePublisher : IMessagePublisher { public void Publish( IConnectionMultiplexer connectionMultiplexer, - string key) { } + RedisKey key) { } public Task PublishAsync( IConnectionMultiplexer connectionMultiplexer, - string key, + RedisKey key, CancellationToken cancellationToken = default) { return Task.CompletedTask; diff --git a/src/MessagingRedisCache/Messaging/NopMessageSubscriber.cs b/src/MessagingRedisCache/Messaging/NopMessageSubscriber.cs new file mode 100644 index 0000000..61f9450 --- /dev/null +++ b/src/MessagingRedisCache/Messaging/NopMessageSubscriber.cs @@ -0,0 +1,21 @@ +using StackExchange.Redis; + +namespace MessagingRedisCache; + +internal sealed class NopMessageSubscriber : + IMessageSubscriber +{ + public Task SubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task UnsubscribeAsync( + IConnectionMultiplexer connectionMultiplexer, + CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } +} diff --git a/src/MessagingRedisCache/MessagingRedisCache.cs b/src/MessagingRedisCache/MessagingRedisCache.cs new file mode 100644 index 0000000..1868fa8 --- /dev/null +++ b/src/MessagingRedisCache/MessagingRedisCache.cs @@ -0,0 +1,327 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using StackExchange.Redis; +using System.Buffers; + +namespace MessagingRedisCache; + +/// +/// A Redis distributed cache implementation that uses pub/sub. +/// +public class MessagingRedisCache : + IBufferDistributedCache, + IDisposable +{ + /// + /// Initializes a new instance of MessagingRedisCache. + /// + public MessagingRedisCache( + IBufferDistributedCache bufferDistributedCache, + IMessagePublisher messagePublisher, + IMessageSubscriber messageSubscriber, + IMessagingConfigurationVerifier messagingConfigurationVerifier, + IOptions messagingRedisCacheOptionsAccessor, + ILogger? logger = null) + { + BufferDistributedCache = bufferDistributedCache ?? + throw new ArgumentNullException( + nameof(bufferDistributedCache)); + Logger = logger ?? + NullLogger.Instance; + MessagePublisher = messagePublisher ?? + throw new ArgumentNullException( + nameof(messagePublisher)); + MessageSubscriber = messageSubscriber ?? + throw new ArgumentNullException( + nameof(messageSubscriber)); + MessagingConfigurationVerifier = messagingConfigurationVerifier ?? + throw new ArgumentNullException( + nameof(messagingRedisCacheOptionsAccessor)); + MessagingRedisCacheOptions = messagingRedisCacheOptionsAccessor?.Value ?? + throw new ArgumentNullException( + nameof(messagingRedisCacheOptionsAccessor)); + + Database = new Lazy(() => + MessagingRedisCacheOptions + .ConnectionMultiplexerFactory!() + .GetAwaiter() + .GetResult() + .GetDatabase( + MessagingRedisCacheOptions + .ConfigurationOptions? + .DefaultDatabase ?? -1)); + + SubscribeCancellationTokenSource = new CancellationTokenSource(); + _ = SubscribeAsync( + SubscribeCancellationTokenSource.Token); + } + + /// + /// The backing IBufferDistributedCache, implemented by . + /// + public IBufferDistributedCache BufferDistributedCache { get; } + + /// + /// The StackExchange.Redis.IDatabase for the . + /// + public Lazy Database { get; } + + /// + /// Optional. The logger. + /// + public ILogger Logger { get; init; } + + /// + /// The pub/sub publisher. + /// + public IMessagePublisher MessagePublisher { get; } + + /// + /// The pub/sub subscriber. + /// + public IMessageSubscriber MessageSubscriber { get; } + + /// + /// The messaging configuration verifier. + /// + public IMessagingConfigurationVerifier MessagingConfigurationVerifier { get; } + + /// + /// Configuration options. + /// + public MessagingRedisCacheOptions MessagingRedisCacheOptions { get; } + + private bool IsDisposed { get; set; } + private CancellationTokenSource SubscribeCancellationTokenSource { get; set; } + + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public byte[]? Get(string key) + { + return BufferDistributedCache + .Get(key); + } + + /// + public async Task GetAsync( + string key, + CancellationToken token = default) + { + return await BufferDistributedCache + .GetAsync( + key, + token) + .ConfigureAwait(false); + } + + /// + public void Refresh(string key) + { + BufferDistributedCache + .Refresh(key); + } + + /// + public async Task RefreshAsync( + string key, + CancellationToken token = default) + { + await BufferDistributedCache + .RefreshAsync( + key, + token) + .ConfigureAwait(false); + } + + /// + public void Remove(string key) + { + BufferDistributedCache + .Remove(key); + MessagePublisher.Publish( + Database.Value.Multiplexer, + key); + } + + /// + public async Task RemoveAsync( + string key, + CancellationToken token = default) + { + await BufferDistributedCache + .RemoveAsync( + key, + token) + .ConfigureAwait(false); + await MessagePublisher + .PublishAsync( + Database.Value.Multiplexer, + key, + token) + .ConfigureAwait(false); + } + + /// + public void Set( + string key, + byte[] value, + DistributedCacheEntryOptions options) + { + BufferDistributedCache.Set( + key, + value, + options); + MessagePublisher.Publish( + Database.Value.Multiplexer, + key); + } + + /// + public void Set( + string key, + ReadOnlySequence value, + DistributedCacheEntryOptions options) + { + BufferDistributedCache.Set( + key, + value, + options); + MessagePublisher.Publish( + Database.Value.Multiplexer, + key); + } + + /// + public async Task SetAsync( + string key, + byte[] value, + DistributedCacheEntryOptions options, + CancellationToken token = default) + { + await BufferDistributedCache + .SetAsync( + key, + value, + options, + token) + .ConfigureAwait(false); + await MessagePublisher + .PublishAsync( + Database.Value.Multiplexer, + key, + token) + .ConfigureAwait(false); + } + + /// + public async ValueTask SetAsync( + string key, + ReadOnlySequence value, + DistributedCacheEntryOptions options, + CancellationToken token = default) + { + await BufferDistributedCache + .SetAsync( + key, + value, + options, + token) + .ConfigureAwait(false); + await MessagePublisher + .PublishAsync( + Database.Value.Multiplexer, + key, + token) + .ConfigureAwait(false); + } + + /// + public bool TryGet( + string key, + IBufferWriter destination) + { + return BufferDistributedCache + .TryGet( + key, + destination); + } + + /// + public async ValueTask TryGetAsync( + string key, + IBufferWriter destination, + CancellationToken token = default) + { + return await BufferDistributedCache + .TryGetAsync( + key, + destination, + token) + .ConfigureAwait(false); + } + + protected virtual void Dispose(bool isDisposing) + { + if (IsDisposed) + { + return; + } + + if (isDisposing) + { + SubscribeCancellationTokenSource.Dispose(); + } + + IsDisposed = true; + } + + private async Task SubscribeAsync( + CancellationToken cancellationToken = default) + { + while (true) + { + try + { + if (!await MessagingConfigurationVerifier + .VerifyConfigurationAsync( + Database.Value, + cancellationToken) + .ConfigureAwait(false)) + { + Logger.MessagingConfigurationInvalid( + MessagingRedisCacheOptions.MessagingType); + } + + await MessageSubscriber + .SubscribeAsync( + Database.Value.Multiplexer, + cancellationToken) + .ConfigureAwait(false); + break; + } + catch (RedisConnectionException redisConnectionException) + { + Logger.SubscriberFailed( + MessagingRedisCacheOptions + .SubscriberRetryDelay, + redisConnectionException); + + await Task + .Delay( + MessagingRedisCacheOptions + .SubscriberRetryDelay, + cancellationToken) + .ConfigureAwait(false); + } + } + } +} diff --git a/src/MessagingRedisCache/MessagingRedisCache.csproj b/src/MessagingRedisCache/MessagingRedisCache.csproj new file mode 100644 index 0000000..fab2027 --- /dev/null +++ b/src/MessagingRedisCache/MessagingRedisCache.csproj @@ -0,0 +1,21 @@ + + + true + CA1724; + README.md + true + net8.0;net9.0;net10.0 + + + + + + + + + + + + + + diff --git a/src/MessagingRedisCache/MessagingRedisCacheEvents.cs b/src/MessagingRedisCache/MessagingRedisCacheEvents.cs new file mode 100644 index 0000000..313a68c --- /dev/null +++ b/src/MessagingRedisCache/MessagingRedisCacheEvents.cs @@ -0,0 +1,21 @@ +using StackExchange.Redis; + +namespace MessagingRedisCache; + +/// +/// Specifies which events the MessagingRedisCache invokes. +/// +public class MessagingRedisCacheEvents +{ + /// + /// Invoked when a message is recieved. + /// + public Func OnMessageRecieved { get; set; } = + channelMessage => Task.CompletedTask; + + /// + /// Invoked when a message subscription is established. + /// + public Func OnSubscribe { get; set; } = + () => Task.CompletedTask; +} diff --git a/src/L1L2RedisCacheLoggerExtensions.cs b/src/MessagingRedisCache/MessagingRedisCacheLoggerExtensions.cs similarity index 75% rename from src/L1L2RedisCacheLoggerExtensions.cs rename to src/MessagingRedisCache/MessagingRedisCacheLoggerExtensions.cs index cd66f2e..da0942f 100644 --- a/src/L1L2RedisCacheLoggerExtensions.cs +++ b/src/MessagingRedisCache/MessagingRedisCacheLoggerExtensions.cs @@ -1,14 +1,14 @@ using Microsoft.Extensions.Logging; -namespace L1L2RedisCache; +namespace MessagingRedisCache; -internal static partial class L1L2RedisCacheLoggerExtensions +internal static partial class MessagingRedisCacheLoggerExtensions { [LoggerMessage( Level = LogLevel.Error, Message = "Redis notify-keyspace-events config is invalid for MessagingType {MessagingType}")] public static partial void MessagingConfigurationInvalid( - this ILogger logger, + this ILogger logger, MessagingType messagingType, Exception? exception = null); @@ -16,7 +16,7 @@ public static partial void MessagingConfigurationInvalid( Level = LogLevel.Error, Message = "Failed to initialize subscriber; retrying in {SubscriberRetryDelay}")] public static partial void SubscriberFailed( - this ILogger logger, + this ILogger logger, TimeSpan subscriberRetryDelay, Exception? exception = null); } diff --git a/src/MessagingRedisCache/MessagingRedisCacheOptions.cs b/src/MessagingRedisCache/MessagingRedisCacheOptions.cs new file mode 100644 index 0000000..9b70944 --- /dev/null +++ b/src/MessagingRedisCache/MessagingRedisCacheOptions.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Caching.StackExchangeRedis; + +namespace MessagingRedisCache; + +/// +/// Configuration options for MessagingRedisCache. +/// +public class MessagingRedisCacheOptions : + RedisCacheOptions +{ + /// + /// The pub/sub channel name. + /// + public string Channel => + $"{InstanceName}{nameof(MessagingRedisCache)}"; + + /// + /// Specifies which events the MessagingRedisCache invokes. + /// + public MessagingRedisCacheEvents Events { get; set; } = + new MessagingRedisCacheEvents(); + + /// + /// Unique identifier for the operating instance. + /// + public Guid Id { get; } = + Guid.NewGuid(); + + /// + /// The type of messaging to use for L1 memory cache eviction. + /// + public MessagingType MessagingType { get; set; } = + MessagingType.Default; + + /// + /// The duration of time to delay before retrying subscriber intialization. + /// + public TimeSpan SubscriberRetryDelay { get; set; } = + TimeSpan.FromSeconds(5); +} diff --git a/src/MessagingType.cs b/src/MessagingRedisCache/MessagingType.cs similarity index 80% rename from src/MessagingType.cs rename to src/MessagingRedisCache/MessagingType.cs index 97bd3e2..15f33de 100644 --- a/src/MessagingType.cs +++ b/src/MessagingRedisCache/MessagingType.cs @@ -1,4 +1,4 @@ -namespace L1L2RedisCache; +namespace MessagingRedisCache; /// /// The type of messaging system to use for L1 memory cache eviction. @@ -6,7 +6,7 @@ public enum MessagingType { /// - /// Use standard L1L2RedisCache pub/sub messages for L1 memory cache eviction. The Redis server requires no additional configuration. + /// Use standard MessagingRedisCache pub/sub messages for L1 memory cache eviction. The Redis server requires no additional configuration. /// Default = 0, diff --git a/src/MessagingRedisCache/README.md b/src/MessagingRedisCache/README.md new file mode 100644 index 0000000..1373d51 --- /dev/null +++ b/src/MessagingRedisCache/README.md @@ -0,0 +1,53 @@ +# MessagingRedisCache + +`MessagingRedisCache` is an implementation of [`IDistributedCache`](https://github.com/dotnet/runtime/blob/main/src/libraries/Microsoft.Extensions.Caching.Abstractions/src/IDistributedCache.cs) using [`RedisCache`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCache.cs) as a base implementation. `MessagingRedisCache` will utilize [Redis pub/sub](https://redis.io/topics/pubsub) to ensure that memory cache entries can be synchronized in a distributed system. This makes it a viable backing store for [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid), where it can evict `IMemoryCache` entries in distributed systems. + +All changes to entries in `MessagingRedisCache` by way of removal or updates will result in a Redis message being published. The default implementation is to directly publish these messages in an established channel. Alternatively, [keyevent notifications](https://redis.io/topics/notifications) or [keyspace notifications](https://redis.io/topics/notifications) can be used if the Redis server is configured for them. + +I expect to gracefully decomission this project when [`StackExchange.Redis`](https://github.com/StackExchange/StackExchange.Redis) has [client-side caching](https://redis.io/docs/latest/develop/use/client-side-caching/) support or if [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) similarly implements client-side eviction. + +## Configuration + +It is intended that `MessagingRedisCache` be used in conjunction with another kind of layered caching solution, specificially [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) or [`L1L2RedisCache`](../L1L2RedisCache/README.md). Without any kind of layered cache solution, `MessagingRedisCache` will only publish Redis pub/sub messages for another consumer unconfigured by this project. + +`MessagingRedisCache` can be registered during startup with the following `IServiceCollection` extension method: + +``` +services + .AddMessagingRedisCache(options => + { + options.Configuration = "redis"; + options.InstanceName = "Namespace:Prefix:"; + }); +``` + +### Message Subscription + +A message subscriber can be registered on the `IMessagingRedisCacheBuilder` which will automatically evict cache entries from an `IMemoryCache` registered on the `IServiceCollection`: + +``` +services + .AddMessagingRedisCache(options => + { + options.Configuration = "redis"; + options.InstanceName = "Namespace:Prefix:"; + }) + .AddMemoryCacheSubscriber(); +``` + +This should be done explicitly when using [`HybridCache`](https://learn.microsoft.com/en-us/aspnet/core/performance/caching/hybrid) and is done automatically when using [`L1L2RedisCache`](../L1L2RedisCache/README.md). + +## MessagingRedisCacheOptions + +`MessagingRedisCacheOptions` are an extension of the standard [`RedisCacheOptions`](https://github.com/dotnet/aspnetcore/blob/main/src/Caching/StackExchangeRedis/src/RedisCacheOptions.cs). The following additional customizations are supported: + +### MessagingType + +The type of message publishing system to use: + +| MessagingType | Description | Suggestion | +| - | - | - | +| `Default` | Publish standard `MessagingRedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. | Default behavior. The Redis server requires no additional configuration. | +| `KeyeventNotifications` | Rely on [keyevent notifications](https://redis.io/topics/notifications) for message publishing instead of standard `MessagingRedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyevent notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghE` configuration and the majority of keys in the server are managed by `MessagingRedisCache`. | +| `KeyspaceNotifications` | Rely on [keyspace notifications](https://redis.io/topics/notifications) for message publishing instead of standard `MessagingRedisCache` [pub/sub](https://redis.io/topics/pubsub) messages. The Redis server must have keyspace notifications enabled. | This is only advisable if the Redis server is already using [keyevent notifications](https://redis.io/topics/notifications) with at least a `ghK` configuration and the majority of keys in the server are managed by `MessagingRedisCache`. | + diff --git a/src/MessagingRedisCache/SourceGenerationContext.cs b/src/MessagingRedisCache/SourceGenerationContext.cs new file mode 100644 index 0000000..ce46601 --- /dev/null +++ b/src/MessagingRedisCache/SourceGenerationContext.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace MessagingRedisCache; + +[JsonSerializable(typeof(CacheMessage))] +[JsonSerializable(typeof(Guid))] +[JsonSerializable(typeof(string))] +internal partial class SourceGenerationContext : + JsonSerializerContext +{ +} \ No newline at end of file diff --git a/tests/System/Benchmark/BenchmarkBase.cs b/tests/L1L2RedisCache/System/Benchmark/BenchmarkBase.cs similarity index 84% rename from tests/System/Benchmark/BenchmarkBase.cs rename to tests/L1L2RedisCache/System/Benchmark/BenchmarkBase.cs index 3cdd3b6..879a9d3 100644 --- a/tests/System/Benchmark/BenchmarkBase.cs +++ b/tests/L1L2RedisCache/System/Benchmark/BenchmarkBase.cs @@ -1,8 +1,11 @@ using BenchmarkDotNet.Attributes; +using MessagingRedisCache; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.StackExchangeRedis; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; namespace L1L2RedisCache.Tests.System; @@ -10,7 +13,7 @@ public abstract class BenchmarkBase { [Params(100)] public int Iterations { get; set; } - + protected DistributedCacheEntryOptions DistributedCacheEntryOptions { get; set; } = new DistributedCacheEntryOptions { @@ -26,7 +29,6 @@ public void GlobalSetup() { var configuration = new ConfigurationBuilder() .AddJsonFile("appsettings.json") - .AddEnvironmentVariables() .Build(); var services = new ServiceCollection(); @@ -42,8 +44,8 @@ public void GlobalSetup() .GetRequiredService(); L1L2Cache = serviceProvider .GetRequiredService(); - L2Cache = serviceProvider - .GetRequiredService>() - .Invoke(); + L2Cache = new RedisCache( + serviceProvider + .GetRequiredService>()); } } \ No newline at end of file diff --git a/tests/System/Benchmark/GetBenchmark.cs b/tests/L1L2RedisCache/System/Benchmark/GetBenchmark.cs similarity index 98% rename from tests/System/Benchmark/GetBenchmark.cs rename to tests/L1L2RedisCache/System/Benchmark/GetBenchmark.cs index f2cf0c8..fbf4ade 100644 --- a/tests/System/Benchmark/GetBenchmark.cs +++ b/tests/L1L2RedisCache/System/Benchmark/GetBenchmark.cs @@ -44,7 +44,7 @@ public void L1Get() iteration <= Iterations; iteration++) { - L1Cache!.Get( + L1Cache!.Get( $"Get:{iteration}"); } } diff --git a/tests/System/Benchmark/SetBenchmark.cs b/tests/L1L2RedisCache/System/Benchmark/SetBenchmark.cs similarity index 100% rename from tests/System/Benchmark/SetBenchmark.cs rename to tests/L1L2RedisCache/System/Benchmark/SetBenchmark.cs diff --git a/tests/L1L2RedisCache/System/L1L2RedisCache.Tests.System.csproj b/tests/L1L2RedisCache/System/L1L2RedisCache.Tests.System.csproj new file mode 100644 index 0000000..2331458 --- /dev/null +++ b/tests/L1L2RedisCache/System/L1L2RedisCache.Tests.System.csproj @@ -0,0 +1,35 @@ + + + true + false + true + CA1515; + Exe + net10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/System/MessagingTests.cs b/tests/L1L2RedisCache/System/MessagingTests.cs similarity index 62% rename from tests/System/MessagingTests.cs rename to tests/L1L2RedisCache/System/MessagingTests.cs index 7ab75da..041bcc1 100644 --- a/tests/System/MessagingTests.cs +++ b/tests/L1L2RedisCache/System/MessagingTests.cs @@ -1,25 +1,25 @@ +using MessagingRedisCache; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Text; namespace L1L2RedisCache.Tests.System; [TestClass] -public class MessagingTests +public class MessagingTests( + TestContext testContext) { - public MessagingTests() - { - Configuration = new ConfigurationBuilder() + public IConfiguration Configuration { get; } = + new ConfigurationBuilder() .AddJsonFile("appsettings.json") - .AddEnvironmentVariables() .Build(); - EventTimeout = TimeSpan.FromSeconds(5); - } - - public IConfiguration Configuration { get; } - public TimeSpan EventTimeout { get; } + public TimeSpan EventTimeout { get; } = + TimeSpan.FromSeconds(5); + public TestContext TestContext { get; set; } = + testContext; [DataRow(100, MessagingType.Default)] [DataRow(100, MessagingType.KeyeventNotifications)] @@ -29,11 +29,21 @@ public async Task MessagingTypeTest( int iterations, MessagingType messagingType) { + using var messageAutoResetEvent = + new AutoResetEvent(false); + using var subscribeAutoResetEvent = + new AutoResetEvent(false); + var primaryServices = new ServiceCollection(); primaryServices.AddSingleton(Configuration); primaryServices.AddL1L2RedisCache(options => { Configuration.Bind("L1L2RedisCache", options); + options.Events.OnSubscribe = () => + { + subscribeAutoResetEvent.Set(); + return Task.CompletedTask; + }; options.MessagingType = messagingType; }); using var primaryServiceProvider = primaryServices @@ -41,8 +51,8 @@ public async Task MessagingTypeTest( var primaryL1L2Cache = primaryServiceProvider .GetRequiredService(); - var primaryL1L2CacheOptions = primaryServiceProvider - .GetRequiredService>() + var primaryMessagingRedisCacheOptions = primaryServiceProvider + .GetRequiredService>() .Value; await SetAndVerifyConfigurationAsync( @@ -55,24 +65,18 @@ await SetAndVerifyConfigurationAsync( secondaryServices.AddL1L2RedisCache(options => { Configuration.Bind("L1L2RedisCache", options); + options.Events.OnMessageRecieved = channelMessage => + { + messageAutoResetEvent.Set(); + return Task.CompletedTask; + }; options.MessagingType = messagingType; }); using var secondaryServiceProvider = secondaryServices .BuildServiceProvider(); - var secondaryMessageSubscriber = secondaryServiceProvider - .GetRequiredService(); - using var messageAutoResetEvent = new AutoResetEvent(false); - using var subscribeAutoResetEvent = new AutoResetEvent(false); - secondaryMessageSubscriber.OnMessage += (sender, e) => - { - messageAutoResetEvent.Set(); - }; - secondaryMessageSubscriber.OnSubscribe += (sender, e) => - { - subscribeAutoResetEvent.Set(); - }; - + var secondaryL1Cache = secondaryServiceProvider + .GetRequiredService(); var secondaryL1L2Cache = secondaryServiceProvider .GetRequiredService(); @@ -88,42 +92,69 @@ await SetAndVerifyConfigurationAsync( // L1 population via L2 await primaryL1L2Cache .SetStringAsync( - key, value) + key, + value, + token: TestContext.CancellationToken) .ConfigureAwait(false); Assert.IsTrue( messageAutoResetEvent .WaitOne(EventTimeout)); + Assert.IsNull( + secondaryL1Cache + .Get(key)); Assert.AreEqual( value, await secondaryL1L2Cache - .GetStringAsync(key) + .GetStringAsync( + key, + token: TestContext.CancellationToken) .ConfigureAwait(false)); + Assert.IsTrue( + Encoding.ASCII.GetBytes(value).SequenceEqual( + secondaryL1Cache.Get(key) ?? [ ])); // L1 eviction via set // L1 population via L2 await primaryL1L2Cache .SetStringAsync( - key, value) + key, + value, + token: TestContext.CancellationToken) .ConfigureAwait(false); Assert.IsTrue( messageAutoResetEvent .WaitOne(EventTimeout)); + Assert.IsNull( + secondaryL1Cache + .Get(key)); Assert.AreEqual( value, await secondaryL1L2Cache - .GetStringAsync(key) + .GetStringAsync( + key, + token: TestContext.CancellationToken) .ConfigureAwait(false)); + Assert.IsTrue( + Encoding.ASCII.GetBytes(value).SequenceEqual( + secondaryL1Cache.Get(key) ?? [ ])); // L1 eviction via remove await primaryL1L2Cache - .RemoveAsync(key) + .RemoveAsync( + key, + token: TestContext.CancellationToken) .ConfigureAwait(false); Assert.IsTrue( messageAutoResetEvent .WaitOne(EventTimeout)); + Assert.IsNull( + secondaryL1Cache + .Get(key)); Assert.IsNull( await secondaryL1L2Cache - .GetStringAsync(key) + .GetStringAsync( + key, + token: TestContext.CancellationToken) .ConfigureAwait(false)); } } @@ -152,4 +183,4 @@ await configurationVerifier l1L2Cache.Database.Value) .ConfigureAwait(false)); } -} +} \ No newline at end of file diff --git a/tests/System/PerformanceTests.cs b/tests/L1L2RedisCache/System/PerformanceTests.cs similarity index 67% rename from tests/System/PerformanceTests.cs rename to tests/L1L2RedisCache/System/PerformanceTests.cs index 167eafa..762814e 100644 --- a/tests/System/PerformanceTests.cs +++ b/tests/L1L2RedisCache/System/PerformanceTests.cs @@ -1,7 +1,6 @@ using BenchmarkDotNet.Configs; using BenchmarkDotNet.Extensions; using BenchmarkDotNet.Running; -using Microsoft.VisualStudio.TestTools.UnitTesting; namespace L1L2RedisCache.Tests.System; @@ -37,17 +36,19 @@ public void GetPerformanceTest() var l2GetVsL1L2GetRatio = l2GetReport.ResultStatistics?.Median / - l1L2GetReport.ResultStatistics?.Median; - Assert.IsTrue( - l2GetVsL1L2GetRatio > 100, - $"L1L2Cache Get must perform significantly better (> 100) than RedisCache Get: {l2GetVsL1L2GetRatio}"); + l1L2GetReport.ResultStatistics?.Median ?? 0; + Assert.IsGreaterThan( + 100, + l2GetVsL1L2GetRatio, + $"L1L2RedisCache Get must perform significantly better (> 100) than RedisCache Get: {l2GetVsL1L2GetRatio}"); var l1L2GetPropagationVsl2GetRatio = - l2GetReport.ResultStatistics?.Median / - l1L2GetPropagationReport.ResultStatistics?.Median; - Assert.IsTrue( - l1L2GetPropagationVsl2GetRatio > 3, - $"L1L2Cache Get must perform better (> 3) than to RedisCache Get: {l1L2GetPropagationVsl2GetRatio}"); + l1L2GetPropagationReport.ResultStatistics?.Median / + l2GetReport.ResultStatistics?.Median ?? 0; + Assert.IsLessThan( + 3, + l1L2GetPropagationVsl2GetRatio, + $"L1L2RedisCache GetPropagation cannot perform significantly worse (< 3) than RedisCache Get: {l1L2GetPropagationVsl2GetRatio}"); } [TestMethod] @@ -69,9 +70,10 @@ public void SetPerformanceTest() var l1L2SetVsl2SetRatio = l1L2SetReport.ResultStatistics?.Median / - l2SetReport.ResultStatistics?.Median; - Assert.IsTrue( - l1L2SetVsl2SetRatio < 3, - $"L1L2Cache Set cannot perform significantly worse (< 3) than RedisCache Set: {l1L2SetVsl2SetRatio}"); + l2SetReport.ResultStatistics?.Median ?? 0; + Assert.IsLessThan( + 3, + l1L2SetVsl2SetRatio, + $"L1L2RedisCache Set cannot perform significantly worse (< 3) than RedisCache Set: {l1L2SetVsl2SetRatio}"); } -} +} \ No newline at end of file diff --git a/tests/System/ReliabilityTests.cs b/tests/L1L2RedisCache/System/ReliabilityTests.cs similarity index 55% rename from tests/System/ReliabilityTests.cs rename to tests/L1L2RedisCache/System/ReliabilityTests.cs index 7885020..75e1397 100644 --- a/tests/System/ReliabilityTests.cs +++ b/tests/L1L2RedisCache/System/ReliabilityTests.cs @@ -1,55 +1,54 @@ using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Microsoft.VisualStudio.TestTools.UnitTesting; using StackExchange.Redis; namespace L1L2RedisCache.Tests.System; [TestClass] -public class ReliabilityTests +public class ReliabilityTests( + TestContext testContext) { - public ReliabilityTests() - { - Configuration = new ConfigurationBuilder() + public IConfiguration Configuration { get; } = + new ConfigurationBuilder() .AddJsonFile("appsettings.json") - .AddEnvironmentVariables() .Build(); - EventTimeout = TimeSpan.FromSeconds(5); - } - - public IConfiguration Configuration { get; } - public TimeSpan EventTimeout { get; } + public TimeSpan EventTimeout { get; } = + TimeSpan.FromSeconds(5); + public TestContext TestContext { get; set; } = + testContext; [TestMethod] public void InitializeBadConnectionTest() { + using var subscribeAutoResetEvent = + new AutoResetEvent(false); + var services = new ServiceCollection(); services.AddSingleton(Configuration); services.AddL1L2RedisCache(options => { Configuration.Bind("L1L2RedisCache", options); + options.Events.OnSubscribe = () => + { + subscribeAutoResetEvent.Set(); + return Task.CompletedTask; + }; options.Configuration = "localhost:80"; }); using var serviceProvider = services .BuildServiceProvider(); - var messageSubscriber = serviceProvider - .GetRequiredService(); - using var subscribeAutoResetEvent = new AutoResetEvent(false); - messageSubscriber.OnSubscribe += (sender, e) => - { - subscribeAutoResetEvent.Set(); - }; - var l1L2Cache = serviceProvider .GetRequiredService(); Assert.IsFalse( subscribeAutoResetEvent .WaitOne(EventTimeout)); - Assert.ThrowsExceptionAsync( + Assert.ThrowsAsync( () => l1L2Cache - .GetStringAsync(string.Empty)); + .GetStringAsync( + string.Empty, + token: TestContext.CancellationToken)); } -} +} \ No newline at end of file diff --git a/tests/System/appsettings.json b/tests/L1L2RedisCache/System/appsettings.json similarity index 57% rename from tests/System/appsettings.json rename to tests/L1L2RedisCache/System/appsettings.json index 80298ea..22f31f2 100644 --- a/tests/System/appsettings.json +++ b/tests/L1L2RedisCache/System/appsettings.json @@ -2,6 +2,6 @@ "L1L2RedisCache": { "Configuration": "redis", - "InstanceName": "L1L2RedisCache:Test:" + "InstanceName": "MessagingRedisCache:Test:" } } \ No newline at end of file diff --git a/tests/MessagingRedisCache/System/DefaultMessagingTests.cs b/tests/MessagingRedisCache/System/DefaultMessagingTests.cs new file mode 100644 index 0000000..f000e95 --- /dev/null +++ b/tests/MessagingRedisCache/System/DefaultMessagingTests.cs @@ -0,0 +1,15 @@ +namespace MessagingRedisCache.Tests.System; + +[TestClass] +public class DefaultMessagingTests( + TestContext testContext) : + MessagingTestsBase( + MessagingType.Default, + testContext) +{ + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + } +} diff --git a/tests/MessagingRedisCache/System/HybridCacheTests.cs b/tests/MessagingRedisCache/System/HybridCacheTests.cs new file mode 100644 index 0000000..61668a9 --- /dev/null +++ b/tests/MessagingRedisCache/System/HybridCacheTests.cs @@ -0,0 +1,114 @@ +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; + +namespace MessagingRedisCache.Tests.System; + +[TestClass] +public class HybridCacheTests : + TestsBase +{ + public HybridCacheTests( + TestContext testContext) : + base( + MessagingType.Default, + testContext) + { + PrimaryServices.AddHybridCache(); + SecondaryServices.AddHybridCache(); + + SecondaryOptionsConfigureAction = options => + { + options.Events.OnMessageRecieved = channelMessage => + { + MessageAutoResetEvent.Set(); + return Task.CompletedTask; + }; + }; + } + + public HybridCacheEntryOptions HybridCacheEntryOptions { get; } = + new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromHours(1), + }; + public AutoResetEvent MessageAutoResetEvent { get; } = + new AutoResetEvent(false); + public HybridCache PrimaryHybridCache => + PrimaryServiceProvider + .GetRequiredService(); + public HybridCache SecondaryHybridCache => + SecondaryServiceProvider + .GetRequiredService(); + + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + } + + [TestMethod] + public async Task GetOrCreateAsyncTest() + { + await SetAndVerifyConfigurationAsync() + .ConfigureAwait(false); + + for (var iteration = 0; iteration < Iterations; iteration++) + { + var key = Guid.NewGuid().ToString(); + var value = Guid.NewGuid().ToString(); + + await PrimaryHybridCache + .SetAsync( + key, + value, + HybridCacheEntryOptions, + cancellationToken: TestContext.CancellationToken) + .ConfigureAwait(false); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + Assert.AreEqual( + value, + await PrimaryHybridCache + .GetOrCreateAsync( + key, + cancellationToken => + ValueTask.FromResult(null), + HybridCacheEntryOptions, + cancellationToken: TestContext.CancellationToken) + .ConfigureAwait(false)); + Assert.IsNull( + SecondaryMemoryCache + .Get(key)); + Assert.AreEqual( + value, + await SecondaryHybridCache + .GetOrCreateAsync( + key, + cancellationToken => + ValueTask.FromResult(null), + HybridCacheEntryOptions, + cancellationToken: TestContext.CancellationToken) + .ConfigureAwait(false)); + Assert.IsNotNull( + SecondaryMemoryCache + .Get(key)); + + await PrimaryHybridCache + .RemoveAsync( + key, + cancellationToken: TestContext.CancellationToken) + .ConfigureAwait(false); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + Assert.IsNull( + PrimaryMemoryCache + .Get(key)); + Assert.IsNull( + SecondaryMemoryCache + .Get(key)); + } + } +} diff --git a/tests/MessagingRedisCache/System/KeyeventMessagingTests.cs b/tests/MessagingRedisCache/System/KeyeventMessagingTests.cs new file mode 100644 index 0000000..59ff993 --- /dev/null +++ b/tests/MessagingRedisCache/System/KeyeventMessagingTests.cs @@ -0,0 +1,15 @@ +namespace MessagingRedisCache.Tests.System; + +[TestClass] +public class KeyeventMessagingTests( + TestContext testContext) : + MessagingTestsBase( + MessagingType.KeyeventNotifications, + testContext) +{ + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + } +} diff --git a/tests/MessagingRedisCache/System/KeyspaceMessagingTests.cs b/tests/MessagingRedisCache/System/KeyspaceMessagingTests.cs new file mode 100644 index 0000000..eace898 --- /dev/null +++ b/tests/MessagingRedisCache/System/KeyspaceMessagingTests.cs @@ -0,0 +1,15 @@ +namespace MessagingRedisCache.Tests.System; + +[TestClass] +public class KeyspaceMessagingTests( + TestContext testContext) : + MessagingTestsBase( + MessagingType.KeyspaceNotifications, + testContext) +{ + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + } +} diff --git a/tests/MessagingRedisCache/System/MessagingRedisCache.Tests.System.csproj b/tests/MessagingRedisCache/System/MessagingRedisCache.Tests.System.csproj new file mode 100644 index 0000000..4eaf598 --- /dev/null +++ b/tests/MessagingRedisCache/System/MessagingRedisCache.Tests.System.csproj @@ -0,0 +1,35 @@ + + + true + false + true + CA1515; + Exe + net10.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/MessagingRedisCache/System/MessagingTestsBase.cs b/tests/MessagingRedisCache/System/MessagingTestsBase.cs new file mode 100644 index 0000000..2a931d7 --- /dev/null +++ b/tests/MessagingRedisCache/System/MessagingTestsBase.cs @@ -0,0 +1,168 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using System.Diagnostics.CodeAnalysis; +using System.Text; + +namespace MessagingRedisCache.Tests.System; + +[TestClass] +public abstract class MessagingTestsBase : + TestsBase +{ + protected MessagingTestsBase( + MessagingType messagingType, + TestContext testContext) : + base( + messagingType, + testContext) + { + SecondaryOptionsConfigureAction = options => + { + options.Events.OnMessageRecieved = channelMessage => + { + MessageAutoResetEvent.Set(); + return Task.CompletedTask; + }; + }; + } + + public AutoResetEvent MessageAutoResetEvent { get; } = + new AutoResetEvent(false); + + [TestInitialize] + public override void TestInitialize() + { + base.TestInitialize(); + } + + [TestMethod] + public async Task RemoveAsyncTest() + { + await SetAndVerifyConfigurationAsync() + .ConfigureAwait(false); + + for (var iteration = 0; iteration < Iterations; iteration++) + { + var key = Guid.NewGuid().ToString(); + var value = Guid.NewGuid().ToString(); + + await PrimaryDistributedCache + .SetStringAsync( + key, + value, + token: TestContext.CancellationToken) + .ConfigureAwait(false); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + + SecondaryMemoryCache + .Set(key, value); + await PrimaryDistributedCache + .RemoveAsync( + key, + token: TestContext.CancellationToken) + .ConfigureAwait(false); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + Assert.IsNull( + SecondaryMemoryCache + .Get(key)); + } + } + + [TestMethod] + [SuppressMessage("Performance", "CA1849")] + public async Task RemoveTest() + { + await SetAndVerifyConfigurationAsync() + .ConfigureAwait(false); + + for (var iteration = 0; iteration < Iterations; iteration++) + { + var key = Guid.NewGuid().ToString(); + var value = Guid.NewGuid().ToString(); + + await PrimaryDistributedCache + .SetStringAsync( + key, + value, + token: TestContext.CancellationToken) + .ConfigureAwait(false); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + + SecondaryMemoryCache + .Set(key, value); + PrimaryDistributedCache + .Remove( + key); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + Assert.IsNull( + SecondaryMemoryCache + .Get(key)); + } + } + + [TestMethod] + public async Task SetAsyncTest() + { + await SetAndVerifyConfigurationAsync() + .ConfigureAwait(false); + + for (var iteration = 0; iteration < Iterations; iteration++) + { + var key = Guid.NewGuid().ToString(); + var value = Guid.NewGuid().ToString(); + + SecondaryMemoryCache + .Set(key, value); + await PrimaryDistributedCache + .SetStringAsync( + key, + value, + DistributedCacheEntryOptions, + token: TestContext.CancellationToken) + .ConfigureAwait(false); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + Assert.IsNull( + SecondaryMemoryCache + .Get(key)); + } + } + + [TestMethod] + [SuppressMessage("Performance", "CA1849")] + public async Task SetTest() + { + await SetAndVerifyConfigurationAsync() + .ConfigureAwait(false); + + for (var iteration = 0; iteration < Iterations; iteration++) + { + var key = Guid.NewGuid().ToString(); + var value = Guid.NewGuid().ToString(); + + SecondaryMemoryCache + .Set(key, value); + PrimaryDistributedCache + .Set( + key, + Encoding.ASCII.GetBytes(value), + DistributedCacheEntryOptions); + Assert.IsTrue( + MessageAutoResetEvent + .WaitOne(EventTimeout)); + Assert.IsNull( + SecondaryMemoryCache + .Get(key)); + } + } +} diff --git a/tests/MessagingRedisCache/System/TestsBase.cs b/tests/MessagingRedisCache/System/TestsBase.cs new file mode 100644 index 0000000..eb611fa --- /dev/null +++ b/tests/MessagingRedisCache/System/TestsBase.cs @@ -0,0 +1,121 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace MessagingRedisCache.Tests.System; + +public abstract class TestsBase +{ + internal TestsBase( + MessagingType messagingType, + TestContext testContext) + { + MessagingType = messagingType; + TestContext = testContext; + + PrimaryServices = new ServiceCollection(); + PrimaryServices.AddSingleton(Configuration); + PrimaryServices.AddMemoryCache(); + PrimaryServices + .AddMessagingRedisCache(options => + { + Configuration.Bind("MessagingRedisCache", options); + options.MessagingType = MessagingType; + }) + .AddMemoryCacheSubscriber(); + + SecondaryServices = new ServiceCollection(); + SecondaryServices.AddSingleton(Configuration); + SecondaryServices.AddMemoryCache(); + SecondaryServices + .AddMessagingRedisCache(options => + { + Configuration.Bind("MessagingRedisCache", options); + options.MessagingType = MessagingType; + }) + .AddMemoryCacheSubscriber(); + } + + public virtual Action? PrimaryOptionsConfigureAction { get; set; } + public virtual Action? SecondaryOptionsConfigureAction { get; set; } + + public IConfiguration Configuration { get; } = + new ConfigurationBuilder() + .AddJsonFile("appsettings.json") + .Build(); + public DistributedCacheEntryOptions DistributedCacheEntryOptions { get; } = + new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = + TimeSpan.FromHours(1), + }; + public TimeSpan EventTimeout { get; } = + TimeSpan.FromSeconds(10); + public int Iterations { get; } = 100; + public MessagingType MessagingType { get; } + public IDistributedCache PrimaryDistributedCache { get; private set; } = default!; + public IMemoryCache PrimaryMemoryCache { get; private set; } = default!; + public IServiceProvider PrimaryServiceProvider { get; private set; } = default!; + public IDistributedCache SecondaryDistributedCache { get; private set; } = default!; + public IMemoryCache SecondaryMemoryCache { get; private set; } = default!; + public IServiceProvider SecondaryServiceProvider { get; private set; } = default!; + public TestContext TestContext { get; } + + protected IServiceCollection PrimaryServices { get; private set; } = default!; + protected IServiceCollection SecondaryServices { get; private set; } = default!; + + [TestInitialize] + public virtual void TestInitialize() + { + if (PrimaryOptionsConfigureAction != null) + { + PrimaryServices + .Configure( + PrimaryOptionsConfigureAction); + } + PrimaryServiceProvider = PrimaryServices + .BuildServiceProvider(); + PrimaryDistributedCache = PrimaryServiceProvider + .GetRequiredService(); + PrimaryMemoryCache = PrimaryServiceProvider + .GetRequiredService(); + + if (SecondaryOptionsConfigureAction != null) + { + SecondaryServices + .Configure( + SecondaryOptionsConfigureAction); + } + SecondaryServiceProvider = SecondaryServices + .BuildServiceProvider(); + SecondaryDistributedCache = SecondaryServiceProvider + .GetRequiredService(); + SecondaryMemoryCache = SecondaryServiceProvider + .GetRequiredService(); + } + + public async Task SetAndVerifyConfigurationAsync() + { + var messagingRedisCache = PrimaryServiceProvider + .GetRequiredService() as MessagingRedisCache; + + await messagingRedisCache!.Database.Value + .ExecuteAsync( + "config", + "set", + "notify-keyspace-events", + MessagingConfigurationVerifier + .NotifyKeyspaceEventsConfig[MessagingType]) + .ConfigureAwait(false); + + var configurationVerifier = PrimaryServiceProvider + .GetRequiredService(); + Assert.IsTrue( + await configurationVerifier + .VerifyConfigurationAsync( + messagingRedisCache.Database.Value, + cancellationToken: TestContext.CancellationToken) + .ConfigureAwait(false)); + } +} diff --git a/tests/MessagingRedisCache/System/appsettings.json b/tests/MessagingRedisCache/System/appsettings.json new file mode 100644 index 0000000..aec495d --- /dev/null +++ b/tests/MessagingRedisCache/System/appsettings.json @@ -0,0 +1,7 @@ +{ + "MessagingRedisCache": + { + "Configuration": "redis", + "InstanceName": "MessagingRedisCache:Test:" + } +} \ No newline at end of file diff --git a/tests/MessagingRedisCache/Unit/MessagingRedisCache.Tests.Unit.csproj b/tests/MessagingRedisCache/Unit/MessagingRedisCache.Tests.Unit.csproj new file mode 100644 index 0000000..baa77f2 --- /dev/null +++ b/tests/MessagingRedisCache/Unit/MessagingRedisCache.Tests.Unit.csproj @@ -0,0 +1,28 @@ + + + true + false + true + CA1515; + Exe + net10.0 + + + + + + + + + + + + + + + + + + + + diff --git a/tests/MessagingRedisCache/Unit/MessagingRedisCacheTests.cs b/tests/MessagingRedisCache/Unit/MessagingRedisCacheTests.cs new file mode 100644 index 0000000..087f8bb --- /dev/null +++ b/tests/MessagingRedisCache/Unit/MessagingRedisCacheTests.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; +using NSubstitute; +using StackExchange.Redis; + +namespace MessagingRedisCache.Tests.Unit; + +[TestClass] +public class MessagingRedisCacheTests +{ + public MessagingRedisCacheTests( + TestContext testContext) + { + TestContext = testContext; + + MessagingRedisCacheOptions = new MessagingRedisCacheOptions + { + InstanceName = "L1L2RedisCache:Test:", + }; + + var database = Substitute + .For(); + ConnectionMultiplexer = Substitute + .For(); + ConnectionMultiplexer + .GetDatabase( + Arg.Any(), + Arg.Any()) + .Returns(database); + + MessagingRedisCacheOptions.ConnectionMultiplexerFactory = + () => Task.FromResult(ConnectionMultiplexer); + + MessagePublisher = Substitute + .For(); + MessagePublisher + .Publish( + Arg.Any(), + Arg.Any()); + MessagePublisher + .PublishAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + + MessageSubscriber = Substitute + .For(); + MessageSubscriber + .SubscribeAsync( + Arg.Any(), + Arg.Any()); + + var messagingConfigurationVerifier = Substitute + .For(); + messagingConfigurationVerifier + .VerifyConfigurationAsync( + Arg.Any(), + Arg.Any()); + + var bufferDistributedCache = Substitute + .For(); + MessagingRedisCache = new MessagingRedisCache( + bufferDistributedCache, + MessagePublisher, + MessageSubscriber, + messagingConfigurationVerifier, + Options.Create(MessagingRedisCacheOptions)); + } + + public IConnectionMultiplexer ConnectionMultiplexer { get; } + public IMessagePublisher MessagePublisher { get; } + public IMessageSubscriber MessageSubscriber { get; } + public MessagingRedisCacheOptions MessagingRedisCacheOptions { get; } + public MessagingRedisCache MessagingRedisCache { get; } + public TestContext TestContext { get; set; } + + [TestMethod] + public async Task RemoveAsyncTest() + { + var key = "key"; + + await MessagingRedisCache + .RemoveAsync( + key, + token: TestContext.CancellationToken) + .ConfigureAwait(false); + + await MessagePublisher + .Received() + .PublishAsync( + Arg.Any(), + key, + cancellationToken: TestContext.CancellationToken) + .ConfigureAwait(false); + } + + [TestMethod] + public void RemoveTest() + { + var key = "key"; + + MessagingRedisCache + .Remove(key); + + MessagePublisher + .Received() + .Publish( + Arg.Any(), + key); + } + + [TestMethod] + public async Task SetAsyncTest() + { + var key = "key"; + var value = " "u8.ToArray(); + + await MessagingRedisCache + .SetAsync( + key, + value, + token: TestContext.CancellationToken) + .ConfigureAwait(false); + + await MessagePublisher + .Received() + .PublishAsync( + Arg.Any(), + key, + cancellationToken: TestContext.CancellationToken) + .ConfigureAwait(false); + } + + [TestMethod] + public void SetTest() + { + var key = "key"; + var value = " "u8.ToArray(); + + MessagingRedisCache + .Set(key, value); + + MessagePublisher + .Received() + .Publish( + Arg.Any(), + key); + } +} diff --git a/tests/System/L1L2RedisCache.Tests.System.csproj b/tests/System/L1L2RedisCache.Tests.System.csproj deleted file mode 100644 index 1d820c2..0000000 --- a/tests/System/L1L2RedisCache.Tests.System.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - latest-recommended - true - enable - false - enable - net9.0 - - - - - - - - - - - - - - - - - - - - diff --git a/tests/Unit/L1L2RedisCache.Tests.Unit.csproj b/tests/Unit/L1L2RedisCache.Tests.Unit.csproj deleted file mode 100644 index 0a49c3d..0000000 --- a/tests/Unit/L1L2RedisCache.Tests.Unit.csproj +++ /dev/null @@ -1,20 +0,0 @@ - - - latest-recommended - true - enable - false - enable - net9.0 - - - - - - - - - - - - diff --git a/tests/Unit/L1L2RedisCacheTests.cs b/tests/Unit/L1L2RedisCacheTests.cs deleted file mode 100644 index 4f114fe..0000000 --- a/tests/Unit/L1L2RedisCacheTests.cs +++ /dev/null @@ -1,282 +0,0 @@ -using Microsoft.Extensions.Caching.Distributed; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; -using Microsoft.VisualStudio.TestTools.UnitTesting; -using NSubstitute; -using StackExchange.Redis; - -namespace L1L2RedisCache.Tests.Unit; - -[TestClass] -public class L1L2RedisCacheTests -{ - public L1L2RedisCacheTests() - { - L1Cache = new MemoryCache( - Options.Create(new MemoryCacheOptions())); - - L2Cache = new MemoryDistributedCache( - Options.Create( - new MemoryDistributedCacheOptions())); - - L1L2RedisCacheOptions = Options - .Create( - new L1L2RedisCacheOptions - { - InstanceName = "L1L2RedisCache:Test:", - }) - .Value; - - var database = Substitute - .For(); - database - .HashGetAll( - Arg.Any(), - Arg.Any()) - .Returns( - args => - { - var key = ((RedisKey)args[0]).ToString()[ - (L1L2RedisCacheOptions?.InstanceName?.Length ?? 0)..]; - var value = L2Cache.Get(key); - return - [ - new HashEntry("data", value), - ]; - }); - database - .HashGetAllAsync( - Arg.Any(), - Arg.Any()) - .Returns( - async args => - { - var key = ((RedisKey)args[0]).ToString()[ - (L1L2RedisCacheOptions?.InstanceName?.Length ?? 0)..]; - var value = await L2Cache - .GetAsync(key) - .ConfigureAwait(false); - return - [ - new HashEntry("data", value), - ]; - }); - database - .KeyExists( - Arg.Any(), - Arg.Any()) - .Returns( - args => - { - return L2Cache.Get( - ((RedisKey)args[0]).ToString()) != null; - }); - database - .KeyExistsAsync( - Arg.Any(), - Arg.Any()) - .Returns( - async args => - { - var key = ((RedisKey)args[0]).ToString()[ - (L1L2RedisCacheOptions.InstanceName?.Length ?? 0)..]; - return await L2Cache - .GetAsync(key) - .ConfigureAwait(false) != null; - }); - - var connectionMultiplexer = Substitute - .For(); - connectionMultiplexer - .GetDatabase( - Arg.Any(), - Arg.Any()) - .Returns(database); - - L1L2RedisCacheOptions.ConnectionMultiplexerFactory = - () => Task.FromResult(connectionMultiplexer); - - var messagePublisher = Substitute - .For(); - messagePublisher - .Publish( - Arg.Any(), - Arg.Any()); - messagePublisher - .PublishAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()); - - var messageSubscriber = Substitute - .For(); - messageSubscriber - .SubscribeAsync( - Arg.Any(), - Arg.Any()); - - var messagingConfigurationVerifier = Substitute - .For(); - messagingConfigurationVerifier - .VerifyConfigurationAsync( - Arg.Any(), - Arg.Any()); - - L1L2Cache = new L1L2RedisCache( - L1Cache, - L1L2RedisCacheOptions, - new Func(() => L2Cache), - messagePublisher, - messageSubscriber, - messagingConfigurationVerifier); - } - - public IMemoryCache L1Cache { get; } - public IDistributedCache L1L2Cache { get; } - public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; } - public IDistributedCache L2Cache { get; } - - [TestMethod] - public async Task GetPropagationTest() - { - var key = "key"; - var value = " "u8.ToArray(); - - var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; - - await L2Cache - .SetAsync(key, value) - .ConfigureAwait(false); - - Assert.IsNull( - L1Cache.Get(prefixedKey)); - Assert.AreEqual( - value, - await L1L2Cache - .GetAsync(key) - .ConfigureAwait(false)); - Assert.AreEqual( - value, - L1Cache.Get(prefixedKey)); - } - - [TestMethod] - public void SetTest() - { - var key = "key"; - var value = " "u8.ToArray(); - - var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; - - L1L2Cache.Set(key, value); - - Assert.AreEqual( - value, - L1L2Cache.Get(key)); - Assert.AreEqual( - value, - L1Cache.Get(prefixedKey)); - Assert.AreEqual( - value, - L2Cache.Get(key)); - } - - [TestMethod] - public void SetRemoveTest() - { - var key = "key"; - var value = " "u8.ToArray(); - - var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; - - L1L2Cache.Set(key, value); - - Assert.AreEqual( - value, - L1L2Cache.Get(key)); - Assert.AreEqual( - value, - L1Cache.Get(prefixedKey)); - Assert.AreEqual( - value, - L2Cache.Get(key)); - - L1L2Cache.Remove(key); - - Assert.IsNull( - L1L2Cache.Get(key)); - Assert.IsNull( - L1Cache.Get(prefixedKey)); - Assert.IsNull( - L2Cache.Get(key)); - } - - [TestMethod] - public async Task SetAsyncTest() - { - var key = "key"; - var value = " "u8.ToArray(); - - var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; - - await L1L2Cache - .SetAsync(key, value) - .ConfigureAwait(false); - - Assert.AreEqual( - value, - await L1L2Cache - .GetAsync(key) - .ConfigureAwait(false)); - Assert.AreEqual( - value, - L1Cache.Get(prefixedKey)); - Assert.AreEqual( - value, - await L2Cache - .GetAsync(key) - .ConfigureAwait(false)); - } - - [TestMethod] - public async Task SetAsyncRemoveAsyncTest() - { - var key = "key"; - var value = " "u8.ToArray(); - - var prefixedKey = $"{L1L2RedisCacheOptions.KeyPrefix}{key}"; - - await L1L2Cache - .SetAsync(key, value) - .ConfigureAwait(false); - - Assert.AreEqual( - value, - await L1L2Cache - .GetAsync(key) - .ConfigureAwait(false)); - Assert.AreEqual( - value, - L1Cache.Get(prefixedKey)); - Assert.AreEqual( - value, - await L2Cache - .GetAsync(key) - .ConfigureAwait(false)); - - await L1L2Cache - .RemoveAsync(key) - .ConfigureAwait(false); - - Assert.IsNull( - await L1L2Cache - .GetAsync(key) - .ConfigureAwait(false)); - Assert.IsNull( - L1Cache.Get(prefixedKey)); - Assert.IsNull( - await L2Cache - .GetAsync(key) - .ConfigureAwait(false)); - } -}