├── tests
├── System
│ ├── appsettings.json
│ ├── Benchmark
│ │ ├── SetBenchmark.cs
│ │ ├── BenchmarkBase.cs
│ │ └── GetBenchmark.cs
│ ├── L1L2RedisCache.Tests.System.csproj
│ ├── ReliabilityTests.cs
│ ├── PerformanceTests.cs
│ └── MessagingTests.cs
└── Unit
│ ├── L1L2RedisCache.Tests.Unit.csproj
│ └── L1L2RedisCacheTests.cs
├── .devcontainer
├── docker-compose.yml
└── devcontainer.json
├── .github
└── workflows
│ ├── build.yml
│ └── publish.yml
├── src
├── CacheMessage.cs
├── Messaging
│ ├── OnMessageEventArgs.cs
│ ├── NopMessagePublisher.cs
│ ├── IMessagePublisher.cs
│ ├── IMessageSubscriber.cs
│ ├── DefaultMessagePublisher.cs
│ ├── KeyspaceMessageSubscriber.cs
│ ├── DefaultMessageSubscriber.cs
│ └── KeyeventMessageSubscriber.cs
├── Configuration
│ ├── IMessagingConfigurationVerifier.cs
│ ├── MessagingConfigurationVerifier.cs
│ └── ServiceCollectionExtensions.cs
├── L1L2RedisCacheLoggerExtensions.cs
├── MessagingType.cs
├── L1L2RedisCache.csproj
├── L1L2RedisCacheOptions.cs
├── HashEntryArrayExtensions.cs
└── L1L2RedisCache.cs
├── .vscode
└── tasks.json
├── .editorconfig
├── LICENSE
├── README.md
├── L1L2RedisCache.sln
└── .gitignore
/tests/System/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "L1L2RedisCache":
3 | {
4 | "Configuration": "redis",
5 | "InstanceName": "L1L2RedisCache:Test:"
6 | }
7 | }
--------------------------------------------------------------------------------
/.devcontainer/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | devcontainer:
3 | image: mcr.microsoft.com/devcontainers/dotnet:9.0-bookworm
4 | volumes:
5 | - ..:/workspace:cached
6 | command: sleep infinity
7 | redis:
8 | image: redis
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on: push
4 |
5 | jobs:
6 | build:
7 | runs-on: ubuntu-latest
8 | steps:
9 | - uses: actions/checkout@v4
10 | - name: Test
11 | uses: devcontainers/ci@v0.3
12 | with:
13 | push: never
14 | runCmd: dotnet test
15 |
--------------------------------------------------------------------------------
/src/CacheMessage.cs:
--------------------------------------------------------------------------------
1 | namespace L1L2RedisCache;
2 |
3 | ///
4 | /// A Redis pub/sub message indicating a cache value has changed.
5 | ///
6 | public class CacheMessage
7 | {
8 | ///
9 | /// The cache key of the value that has changed.
10 | ///
11 | public string Key { get; set; } = default!;
12 |
13 | ///
14 | /// The unique publisher identifier of the cache that changed the value.
15 | ///
16 | public Guid PublisherId { get; set; }
17 | }
18 |
--------------------------------------------------------------------------------
/src/Messaging/OnMessageEventArgs.cs:
--------------------------------------------------------------------------------
1 | namespace L1L2RedisCache;
2 |
3 | ///
4 | /// Supplies information about a message event from an IMessageSubscriber.
5 | ///
6 | ///
7 | /// Initializes a new instance of OnMessageEventArgs.
8 | ///
9 | public class OnMessageEventArgs(
10 | string key) :
11 | EventArgs
12 | {
13 |
14 | ///
15 | /// The cache key pertaining to the message event.
16 | ///
17 | public string Key { get; set; } = key;
18 | }
19 |
--------------------------------------------------------------------------------
/src/Messaging/NopMessagePublisher.cs:
--------------------------------------------------------------------------------
1 | using StackExchange.Redis;
2 |
3 | namespace L1L2RedisCache;
4 |
5 | internal sealed class NopMessagePublisher :
6 | IMessagePublisher
7 | {
8 | public void Publish(
9 | IConnectionMultiplexer connectionMultiplexer,
10 | string key) { }
11 |
12 | public Task PublishAsync(
13 | IConnectionMultiplexer connectionMultiplexer,
14 | string key,
15 | CancellationToken cancellationToken = default)
16 | {
17 | return Task.CompletedTask;
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks":
4 | [
5 | {
6 | "label": "build",
7 | "command": "dotnet",
8 | "type": "shell",
9 | "args":
10 | [
11 | "build",
12 | "/property:GenerateFullPaths=true",
13 | "/consoleloggerparameters:NoSummary"
14 | ],
15 | "group":
16 | {
17 | "isDefault": true,
18 | "kind": "build",
19 | },
20 | "presentation":
21 | {
22 | "reveal": "silent"
23 | },
24 | "problemMatcher": "$msCompile"
25 | }
26 | ]
27 | }
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | {
2 | "dockerComposeFile": "docker-compose.yml",
3 | "customizations":
4 | {
5 | "vscode":
6 | {
7 | "extensions":
8 | [
9 | "ms-dotnettools.csdevkit",
10 | "ue.alphabetical-sorter"
11 | ],
12 | "settings":
13 | {
14 | "remote.autoForwardPorts": false
15 | }
16 | }
17 | },
18 | "forwardPorts":
19 | [
20 | "redis:6379"
21 | ],
22 | "name": "L1L2RedisCache",
23 | "postCreateCommand": "dotnet dev-certs https",
24 | "remoteUser": "root",
25 | "service": "devcontainer",
26 | "shutdownAction": "stopCompose",
27 | "workspaceFolder": "/workspace"
28 | }
29 |
--------------------------------------------------------------------------------
/src/Configuration/IMessagingConfigurationVerifier.cs:
--------------------------------------------------------------------------------
1 | using StackExchange.Redis;
2 |
3 | namespace L1L2RedisCache;
4 |
5 | ///
6 | /// Verifies Redis configuration settings.
7 | ///
8 | public interface IMessagingConfigurationVerifier
9 | {
10 | ///
11 | /// Verifies Redis configuration values.
12 | ///
13 | /// The StackExchange.Redis.IDatabase for configuration values.
14 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled.
15 | Task VerifyConfigurationAsync(
16 | IDatabase database,
17 | CancellationToken cancellationToken = default);
18 | }
19 |
--------------------------------------------------------------------------------
/src/L1L2RedisCacheLoggerExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Logging;
2 |
3 | namespace L1L2RedisCache;
4 |
5 | internal static partial class L1L2RedisCacheLoggerExtensions
6 | {
7 | [LoggerMessage(
8 | Level = LogLevel.Error,
9 | Message = "Redis notify-keyspace-events config is invalid for MessagingType {MessagingType}")]
10 | public static partial void MessagingConfigurationInvalid(
11 | this ILogger logger,
12 | MessagingType messagingType,
13 | Exception? exception = null);
14 |
15 | [LoggerMessage(
16 | Level = LogLevel.Error,
17 | Message = "Failed to initialize subscriber; retrying in {SubscriberRetryDelay}")]
18 | public static partial void SubscriberFailed(
19 | this ILogger logger,
20 | TimeSpan subscriberRetryDelay,
21 | Exception? exception = null);
22 | }
23 |
--------------------------------------------------------------------------------
/tests/Unit/L1L2RedisCache.Tests.Unit.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | latest-recommended
4 | true
5 | enable
6 | false
7 | enable
8 | net9.0
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/tests/System/Benchmark/SetBenchmark.cs:
--------------------------------------------------------------------------------
1 | using BenchmarkDotNet.Attributes;
2 | using Microsoft.Extensions.Caching.Distributed;
3 |
4 | namespace L1L2RedisCache.Tests.System;
5 |
6 | [SimpleJob]
7 | public class SetBenchmark : BenchmarkBase
8 | {
9 | [Benchmark]
10 | public void L1L2Set()
11 | {
12 | for (var iteration = 1;
13 | iteration <= Iterations;
14 | iteration++)
15 | {
16 | L1L2Cache!.SetString(
17 | $"Set:{iteration}",
18 | "Value",
19 | DistributedCacheEntryOptions);
20 | }
21 | }
22 |
23 | [Benchmark]
24 | public void L2Set()
25 | {
26 | for (var iteration = 1;
27 | iteration <= Iterations;
28 | iteration++)
29 | {
30 | L2Cache!.SetString(
31 | $"Set:{iteration}",
32 | "Value",
33 | DistributedCacheEntryOptions);
34 | }
35 | }
36 | }
--------------------------------------------------------------------------------
/.github/workflows/publish.yml:
--------------------------------------------------------------------------------
1 | name: Publish
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - name: Test
14 | uses: devcontainers/ci@v0.3
15 | with:
16 | push: never
17 | runCmd: dotnet test
18 | publish:
19 | runs-on: ubuntu-latest
20 | steps:
21 | - uses: actions/checkout@v4
22 | - name: Version
23 | run: echo "VERSION=${GITHUB_REF:10}+${GITHUB_SHA::8}" >> $GITHUB_ENV
24 | - name: Setup
25 | uses: actions/setup-dotnet@v4
26 | with:
27 | dotnet-version: 9
28 | - name: Build
29 | run: dotnet build -c Release
30 | - name: Publish
31 | run: dotnet nuget push "**/*.nupkg" -k ${{ secrets.NUGET_KEY }} -n -s https://api.nuget.org/v3/index.json --skip-duplicate
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | [*.{cs,vb}]
2 |
3 | dotnet_diagnostic.severity = warning
4 | dotnet_diagnostic.CS1591.severity = none
5 | dotnet_diagnostic.IDE0001.severity = warning
6 | dotnet_diagnostic.IDE0007.severity = warning
7 | dotnet_diagnostic.IDE0008.severity = none
8 | dotnet_diagnostic.IDE0010.severity = none
9 | dotnet_diagnostic.IDE0028.severity = warning
10 | dotnet_diagnostic.IDE0055.severity = none
11 | dotnet_diagnostic.IDE0058.severity = none
12 | dotnet_diagnostic.IDE0090.severity = warning
13 | dotnet_diagnostic.IDE0160.severity = none
14 | dotnet_diagnostic.IDE0161.severity = warning
15 | dotnet_diagnostic.IDE0290.severity = warning
16 | dotnet_diagnostic.IDE0300.severity = warning
17 | dotnet_diagnostic.IDE0301.severity = warning
18 | dotnet_diagnostic.IDE0303.severity = warning
19 | dotnet_diagnostic.IDE0305.severity = warning
20 |
21 | csharp_space_between_square_brackets = true
22 | csharp_style_var_elsewhere = true:warning
23 | csharp_style_var_for_built_in_types = true:warning
24 | csharp_style_var_when_type_is_apparent = true:warning
25 | dotnet_style_namespace_match_folder = false
26 | dotnet_style_prefer_conditional_expression_over_return = false
27 |
--------------------------------------------------------------------------------
/src/MessagingType.cs:
--------------------------------------------------------------------------------
1 | namespace L1L2RedisCache;
2 |
3 | ///
4 | /// The type of messaging system to use for L1 memory cache eviction.
5 | ///
6 | public enum MessagingType
7 | {
8 | ///
9 | /// Use standard L1L2RedisCache pub/sub messages for L1 memory cache eviction. The Redis server requires no additional configuration.
10 | ///
11 | Default = 0,
12 |
13 | ///
14 | /// Use keyevent notifications for L1 memory cache eviction instead of standard L1L2 pub/sub messages. The Redis server must have keyevent notifications enabled with at least ghE parameters.
15 | ///
16 | KeyeventNotifications = 1,
17 |
18 | ///
19 | /// Use keyspace notifications for L1 memory cache eviction instead of standard L1L2 pub/sub messages. The Redis server must have keyevent notifications enabled with at least ghK parameters.
20 | ///
21 | KeyspaceNotifications = 2,
22 | }
23 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/src/L1L2RedisCache.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | latest-recommended
4 | true
5 | true
6 | true
7 | enable
8 | latest
9 | CA1724;CA1812;SYSLIB1006;
10 | enable
11 | README.md
12 | git
13 | https://github.com/null-d3v/L1L2RedisCache.git
14 | netstandard2.1
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/Messaging/IMessagePublisher.cs:
--------------------------------------------------------------------------------
1 | using StackExchange.Redis;
2 |
3 | namespace L1L2RedisCache;
4 |
5 | ///
6 | /// Publishes messages to other L1L2RedisCache instances indicating cache values have changed.
7 | ///
8 | public interface IMessagePublisher
9 | {
10 | ///
11 | /// Publishes a message indicating a cache value has changed.
12 | ///
13 | /// The StackExchange.Redis.IConnectionMultiplexer for publishing.
14 | /// The cache key of the value that has changed.
15 | void Publish(
16 | IConnectionMultiplexer connectionMultiplexer,
17 | string key);
18 |
19 | ///
20 | /// Publishes a message indicating a cache value has changed.
21 | ///
22 | /// The StackExchange.Redis.IConnectionMultiplexer for publishing.
23 | /// The cache key of the value that has changed.
24 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled.
25 | /// The System.Threading.Tasks.Task that represents the asynchronous operation.
26 | Task PublishAsync(
27 | IConnectionMultiplexer connectionMultiplexer,
28 | string key,
29 | CancellationToken cancellationToken = default);
30 | }
31 |
--------------------------------------------------------------------------------
/tests/System/L1L2RedisCache.Tests.System.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 | latest-recommended
4 | true
5 | enable
6 | false
7 | enable
8 | net9.0
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/L1L2RedisCacheOptions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.StackExchangeRedis;
2 | using Microsoft.Extensions.Options;
3 |
4 | namespace L1L2RedisCache;
5 |
6 | ///
7 | /// Configuration options for L1L2RedisCache.
8 | ///
9 | public sealed class L1L2RedisCacheOptions :
10 | RedisCacheOptions, IOptions
11 | {
12 | ///
13 | /// Initializes a new instance of L1L2RedisCacheOptions.
14 | ///
15 | public L1L2RedisCacheOptions() : base() { }
16 |
17 | ///
18 | /// Unique identifier for the operating instance.
19 | ///
20 | public Guid Id { get; } = Guid.NewGuid();
21 |
22 | ///
23 | /// The pub/sub channel name.
24 | ///
25 | public string Channel => $"{KeyPrefix}Channel";
26 |
27 | ///
28 | /// A prefix to be applied to all cache keys.
29 | ///
30 | public string KeyPrefix => InstanceName ?? string.Empty;
31 |
32 | ///
33 | /// A prefix to be applied to all L1 lock cache keys.
34 | ///
35 | public string LockKeyPrefix => $"{KeyPrefix}{Id}";
36 |
37 | ///
38 | /// The type of messaging to use for L1 memory cache eviction.
39 | ///
40 | public MessagingType MessagingType { get; set; } =
41 | MessagingType.Default;
42 |
43 | ///
44 | /// The duration of time to delay before retrying subscriber intialization.
45 | ///
46 | public TimeSpan SubscriberRetryDelay { get; set; } =
47 | TimeSpan.FromSeconds(5);
48 |
49 | L1L2RedisCacheOptions IOptions.Value => this;
50 | }
51 |
--------------------------------------------------------------------------------
/tests/System/Benchmark/BenchmarkBase.cs:
--------------------------------------------------------------------------------
1 | using BenchmarkDotNet.Attributes;
2 | using Microsoft.Extensions.Caching.Distributed;
3 | using Microsoft.Extensions.Caching.Memory;
4 | using Microsoft.Extensions.Configuration;
5 | using Microsoft.Extensions.DependencyInjection;
6 |
7 | namespace L1L2RedisCache.Tests.System;
8 |
9 | public abstract class BenchmarkBase
10 | {
11 | [Params(100)]
12 | public int Iterations { get; set; }
13 |
14 | protected DistributedCacheEntryOptions DistributedCacheEntryOptions { get; set; } =
15 | new DistributedCacheEntryOptions
16 | {
17 | AbsoluteExpirationRelativeToNow =
18 | TimeSpan.FromHours(1),
19 | };
20 | protected IMemoryCache? L1Cache { get; set; }
21 | protected IDistributedCache? L1L2Cache { get; set; }
22 | protected IDistributedCache? L2Cache { get; set; }
23 |
24 | [GlobalSetup]
25 | public void GlobalSetup()
26 | {
27 | var configuration = new ConfigurationBuilder()
28 | .AddJsonFile("appsettings.json")
29 | .AddEnvironmentVariables()
30 | .Build();
31 |
32 | var services = new ServiceCollection();
33 | services.AddSingleton(configuration);
34 | services.AddL1L2RedisCache(options =>
35 | {
36 | configuration.Bind("L1L2RedisCache", options);
37 | });
38 | var serviceProvider = services
39 | .BuildServiceProvider();
40 |
41 | L1Cache = serviceProvider
42 | .GetRequiredService();
43 | L1L2Cache = serviceProvider
44 | .GetRequiredService();
45 | L2Cache = serviceProvider
46 | .GetRequiredService>()
47 | .Invoke();
48 | }
49 | }
--------------------------------------------------------------------------------
/src/HashEntryArrayExtensions.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.Distributed;
2 | using StackExchange.Redis;
3 |
4 | namespace L1L2RedisCache;
5 |
6 | internal static class HashEntryArrayExtensions
7 | {
8 | private const string AbsoluteExpirationKey = "absexp";
9 | private const string DataKey = "data";
10 | private const long NotPresent = -1;
11 | private const string SlidingExpirationKey = "sldexp";
12 |
13 | internal static DistributedCacheEntryOptions GetDistributedCacheEntryOptions(
14 | this HashEntry[] hashEntries)
15 | {
16 | var distributedCacheEntryOptions = new DistributedCacheEntryOptions();
17 |
18 | var absoluteExpirationHashEntry = hashEntries.FirstOrDefault(
19 | hashEntry => hashEntry.Name == AbsoluteExpirationKey);
20 | if (absoluteExpirationHashEntry.Value.HasValue &&
21 | absoluteExpirationHashEntry.Value != NotPresent)
22 | {
23 | distributedCacheEntryOptions.AbsoluteExpiration = new DateTimeOffset(
24 | (long)absoluteExpirationHashEntry.Value, TimeSpan.Zero);
25 | }
26 |
27 | var slidingExpirationHashEntry = hashEntries.FirstOrDefault(
28 | hashEntry => hashEntry.Name == SlidingExpirationKey);
29 | if (slidingExpirationHashEntry.Value.HasValue &&
30 | slidingExpirationHashEntry.Value != NotPresent)
31 | {
32 | distributedCacheEntryOptions.SlidingExpiration = new TimeSpan(
33 | (long)slidingExpirationHashEntry.Value);
34 | }
35 |
36 | return distributedCacheEntryOptions;
37 | }
38 |
39 | internal static RedisValue GetRedisValue(
40 | this HashEntry[] hashEntries)
41 | {
42 | var dataHashEntry = hashEntries.FirstOrDefault(
43 | hashEntry => hashEntry.Name == DataKey);
44 |
45 | return dataHashEntry.Value;
46 | }
47 | }
--------------------------------------------------------------------------------
/tests/System/ReliabilityTests.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.Distributed;
2 | using Microsoft.Extensions.Configuration;
3 | using Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.VisualStudio.TestTools.UnitTesting;
5 | using StackExchange.Redis;
6 |
7 | namespace L1L2RedisCache.Tests.System;
8 |
9 | [TestClass]
10 | public class ReliabilityTests
11 | {
12 | public ReliabilityTests()
13 | {
14 | Configuration = new ConfigurationBuilder()
15 | .AddJsonFile("appsettings.json")
16 | .AddEnvironmentVariables()
17 | .Build();
18 | EventTimeout = TimeSpan.FromSeconds(5);
19 | }
20 |
21 | public IConfiguration Configuration { get; }
22 | public TimeSpan EventTimeout { get; }
23 |
24 | [TestMethod]
25 | public void InitializeBadConnectionTest()
26 | {
27 | var services = new ServiceCollection();
28 | services.AddSingleton(Configuration);
29 | services.AddL1L2RedisCache(options =>
30 | {
31 | Configuration.Bind("L1L2RedisCache", options);
32 | options.Configuration = "localhost:80";
33 | });
34 | using var serviceProvider = services
35 | .BuildServiceProvider();
36 |
37 | var messageSubscriber = serviceProvider
38 | .GetRequiredService();
39 | using var subscribeAutoResetEvent = new AutoResetEvent(false);
40 | messageSubscriber.OnSubscribe += (sender, e) =>
41 | {
42 | subscribeAutoResetEvent.Set();
43 | };
44 |
45 | var l1L2Cache = serviceProvider
46 | .GetRequiredService();
47 |
48 | Assert.IsFalse(
49 | subscribeAutoResetEvent
50 | .WaitOne(EventTimeout));
51 | Assert.ThrowsExceptionAsync(
52 | () => l1L2Cache
53 | .GetStringAsync(string.Empty));
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/Messaging/IMessageSubscriber.cs:
--------------------------------------------------------------------------------
1 | using StackExchange.Redis;
2 |
3 | namespace L1L2RedisCache;
4 |
5 | ///
6 | /// Subscribes to messages published by other L1L2RedisCache instances indicating cache values have changed.
7 | ///
8 | public interface IMessageSubscriber
9 | {
10 | ///
11 | /// An event that is raised when a message is recieved.
12 | ///
13 | EventHandler? OnMessage { get; set; }
14 |
15 | ///
16 | /// An event that is raised when a subscription is created.
17 | ///
18 | EventHandler? OnSubscribe { get; set; }
19 |
20 | ///
21 | /// Subscribes to messages indicating cache values have changed.
22 | ///
23 | /// The StackExchange.Redis.IConnectionMultiplexer for subscribing.
24 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled.
25 | /// The System.Threading.Tasks.Task that represents the asynchronous operation.
26 | Task SubscribeAsync(
27 | IConnectionMultiplexer connectionMultiplexer,
28 | CancellationToken cancellationToken = default);
29 |
30 | ///
31 | /// Unsubscribes to messages indicating cache values have changed.
32 | ///
33 | /// The StackExchange.Redis.IConnectionMultiplexer for subscribing.
34 | /// Optional. The System.Threading.CancellationToken used to propagate notifications that the operation should be canceled.
35 | /// The System.Threading.Tasks.Task that represents the asynchronous operation.
36 | Task UnsubscribeAsync(
37 | IConnectionMultiplexer connectionMultiplexer,
38 | CancellationToken cancellationToken = default);
39 | }
40 |
--------------------------------------------------------------------------------
/src/Configuration/MessagingConfigurationVerifier.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Options;
2 | using StackExchange.Redis;
3 |
4 | namespace L1L2RedisCache;
5 |
6 | internal sealed class MessagingConfigurationVerifier(
7 | IOptions l1L2RedisCacheOptionsOptionsAccessor) :
8 | IMessagingConfigurationVerifier
9 | {
10 | private const string config = "notify-keyspace-events";
11 |
12 | static MessagingConfigurationVerifier()
13 | {
14 | NotifyKeyspaceEventsConfig = new Dictionary
15 | {
16 | { MessagingType.Default, string.Empty },
17 | { MessagingType.KeyeventNotifications, "ghE" },
18 | { MessagingType.KeyspaceNotifications, "ghK" },
19 | };
20 | }
21 |
22 | internal static IDictionary NotifyKeyspaceEventsConfig { get; }
23 |
24 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; } =
25 | l1L2RedisCacheOptionsOptionsAccessor.Value;
26 |
27 | public async Task VerifyConfigurationAsync(
28 | IDatabase database,
29 | CancellationToken _ = default)
30 | {
31 | var isVerified = NotifyKeyspaceEventsConfig
32 | .TryGetValue(
33 | L1L2RedisCacheOptions.MessagingType,
34 | out var expectedValues);
35 |
36 | var configValue = (await database
37 | .ExecuteAsync(
38 | "config",
39 | "get",
40 | config)
41 | .ConfigureAwait(false))
42 | .ToDictionary()[config]
43 | .ToString();
44 |
45 | if (expectedValues != null)
46 | {
47 | foreach (var expectedValue in expectedValues)
48 | {
49 | if (configValue?.Contains(
50 | expectedValue,
51 | StringComparison.Ordinal) != true)
52 | {
53 | isVerified = false;
54 | }
55 | }
56 | }
57 |
58 | return isVerified;
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/Messaging/DefaultMessagePublisher.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Options;
2 | using StackExchange.Redis;
3 | using System.Text.Json;
4 |
5 | namespace L1L2RedisCache;
6 |
7 | internal sealed class DefaultMessagePublisher(
8 | IOptions jsonSerializerOptionsAccessor,
9 | IOptions l1L2RedisCacheOptionsOptionsAccessor) :
10 | IMessagePublisher
11 | {
12 | public JsonSerializerOptions JsonSerializerOptions { get; set; } =
13 | jsonSerializerOptionsAccessor.Value;
14 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } =
15 | l1L2RedisCacheOptionsOptionsAccessor.Value;
16 |
17 | public void Publish(
18 | IConnectionMultiplexer connectionMultiplexer,
19 | string key)
20 | {
21 | connectionMultiplexer
22 | .GetSubscriber()
23 | .Publish(
24 | new RedisChannel(
25 | L1L2RedisCacheOptions.Channel,
26 | RedisChannel.PatternMode.Literal),
27 | JsonSerializer.Serialize(
28 | new CacheMessage
29 | {
30 | Key = key,
31 | PublisherId = L1L2RedisCacheOptions.Id,
32 | },
33 | JsonSerializerOptions));
34 | }
35 |
36 | public async Task PublishAsync(
37 | IConnectionMultiplexer connectionMultiplexer,
38 | string key,
39 | CancellationToken cancellationToken = default)
40 | {
41 | await connectionMultiplexer
42 | .GetSubscriber()
43 | .PublishAsync(
44 | new RedisChannel(
45 | L1L2RedisCacheOptions.Channel,
46 | RedisChannel.PatternMode.Literal),
47 | JsonSerializer.Serialize(
48 | new CacheMessage
49 | {
50 | Key = key,
51 | PublisherId = L1L2RedisCacheOptions.Id,
52 | },
53 | JsonSerializerOptions))
54 | .ConfigureAwait(false);
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/tests/System/Benchmark/GetBenchmark.cs:
--------------------------------------------------------------------------------
1 | using BenchmarkDotNet.Attributes;
2 | using Microsoft.Extensions.Caching.Distributed;
3 | using Microsoft.Extensions.Caching.Memory;
4 |
5 | namespace L1L2RedisCache.Tests.System;
6 |
7 | public class GetBenchmark : BenchmarkBase
8 | {
9 | public new void GlobalSetup()
10 | {
11 | base.GlobalSetup();
12 |
13 | for (var iteration = 1;
14 | iteration <= Iterations;
15 | iteration++)
16 | {
17 | L1L2Cache!.SetString(
18 | $"Get:{iteration}",
19 | "Value",
20 | DistributedCacheEntryOptions);
21 | L1L2Cache!.SetString(
22 | $"GetPropagation:{iteration}",
23 | "Value",
24 | DistributedCacheEntryOptions);
25 | }
26 | }
27 |
28 | [IterationSetup]
29 | public void IterationSetup()
30 | {
31 | for (var iteration = 1;
32 | iteration <= Iterations;
33 | iteration++)
34 | {
35 | L1Cache!.Remove(
36 | $"GetPropagation:{iteration}");
37 | }
38 | }
39 |
40 | [Benchmark]
41 | public void L1Get()
42 | {
43 | for (var iteration = 1;
44 | iteration <= Iterations;
45 | iteration++)
46 | {
47 | L1Cache!.Get(
48 | $"Get:{iteration}");
49 | }
50 | }
51 |
52 | [Benchmark]
53 | public void L1L2Get()
54 | {
55 | for (var iteration = 1;
56 | iteration <= Iterations;
57 | iteration++)
58 | {
59 | L1L2Cache!.GetString(
60 | $"Get:{iteration}");
61 | }
62 | }
63 |
64 | [Benchmark]
65 | public void L1L2GetPropagation()
66 | {
67 | for (var iteration = 1;
68 | iteration <= Iterations;
69 | iteration++)
70 | {
71 | L1L2Cache!.GetString(
72 | $"GetPropagation:{iteration}");
73 | }
74 | }
75 |
76 | [Benchmark]
77 | public void L2Get()
78 | {
79 | for (var iteration = 1;
80 | iteration <= Iterations;
81 | iteration++)
82 | {
83 | L2Cache!.GetString(
84 | $"Get:{iteration}");
85 | }
86 | }
87 | }
--------------------------------------------------------------------------------
/src/Messaging/KeyspaceMessageSubscriber.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.Memory;
2 | using Microsoft.Extensions.Options;
3 | using StackExchange.Redis;
4 |
5 | namespace L1L2RedisCache;
6 |
7 | internal class KeyspaceMessageSubscriber(
8 | IMemoryCache l1Cache,
9 | IOptions l1L2RedisCacheOptionsOptionsAccessor) :
10 | IMessageSubscriber
11 | {
12 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } =
13 | l1L2RedisCacheOptionsOptionsAccessor.Value;
14 | public IMemoryCache L1Cache { get; set; } =
15 | l1Cache;
16 | public EventHandler? OnMessage { get; set; }
17 | public EventHandler? OnSubscribe { get; set; }
18 |
19 | public async Task SubscribeAsync(
20 | IConnectionMultiplexer connectionMultiplexer,
21 | CancellationToken cancellationToken = default)
22 | {
23 | await connectionMultiplexer
24 | .GetSubscriber()
25 | .SubscribeAsync(
26 | new RedisChannel(
27 | "__keyspace@*__:*",
28 | RedisChannel.PatternMode.Pattern),
29 | ProcessMessage)
30 | .ConfigureAwait(false);
31 |
32 | OnSubscribe?.Invoke(
33 | this,
34 | EventArgs.Empty);
35 | }
36 |
37 | public async Task UnsubscribeAsync(
38 | IConnectionMultiplexer connectionMultiplexer,
39 | CancellationToken cancellationToken = default)
40 | {
41 | await connectionMultiplexer
42 | .GetSubscriber()
43 | .UnsubscribeAsync(
44 | new RedisChannel(
45 | "__keyspace@*__:*",
46 | RedisChannel.PatternMode.Pattern))
47 | .ConfigureAwait(false);
48 | }
49 |
50 | internal void ProcessMessage(
51 | RedisChannel channel,
52 | RedisValue message)
53 | {
54 | if (message == "del" ||
55 | message == "hset")
56 | {
57 | var keyPrefixIndex = channel.ToString().IndexOf(
58 | L1L2RedisCacheOptions.KeyPrefix,
59 | StringComparison.Ordinal);
60 | if (keyPrefixIndex != -1)
61 | {
62 | var key = channel.ToString()[
63 | (keyPrefixIndex + L1L2RedisCacheOptions.KeyPrefix.Length)..];
64 | L1Cache.Remove(
65 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}");
66 | L1Cache.Remove(
67 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}");
68 |
69 | OnMessage?.Invoke(
70 | this,
71 | new OnMessageEventArgs(key));
72 | }
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/Messaging/DefaultMessageSubscriber.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.Memory;
2 | using Microsoft.Extensions.Options;
3 | using StackExchange.Redis;
4 | using System.Text.Json;
5 |
6 | namespace L1L2RedisCache;
7 |
8 | internal class DefaultMessageSubscriber(
9 | IOptions jsonSerializerOptionsAcccessor,
10 | IMemoryCache l1Cache,
11 | IOptions l1L2RedisCacheOptionsOptionsAccessor) :
12 | IMessageSubscriber
13 | {
14 | public JsonSerializerOptions JsonSerializerOptions { get; set; } =
15 | jsonSerializerOptionsAcccessor.Value;
16 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } =
17 | l1L2RedisCacheOptionsOptionsAccessor.Value;
18 | public IMemoryCache L1Cache { get; set; } =
19 | l1Cache;
20 | public EventHandler? OnMessage { get; set; }
21 | public EventHandler? OnSubscribe { get; set; }
22 |
23 | public async Task SubscribeAsync(
24 | IConnectionMultiplexer connectionMultiplexer,
25 | CancellationToken cancellationToken = default)
26 | {
27 | await connectionMultiplexer
28 | .GetSubscriber()
29 | .SubscribeAsync(
30 | new RedisChannel(
31 | L1L2RedisCacheOptions.Channel,
32 | RedisChannel.PatternMode.Literal),
33 | ProcessMessage)
34 | .ConfigureAwait(false);
35 |
36 | OnSubscribe?.Invoke(
37 | this,
38 | EventArgs.Empty);
39 | }
40 |
41 | public async Task UnsubscribeAsync(
42 | IConnectionMultiplexer connectionMultiplexer,
43 | CancellationToken cancellationToken = default)
44 | {
45 | await connectionMultiplexer
46 | .GetSubscriber()
47 | .UnsubscribeAsync(
48 | new RedisChannel(
49 | L1L2RedisCacheOptions.Channel,
50 | RedisChannel.PatternMode.Literal))
51 | .ConfigureAwait(false);
52 | }
53 |
54 | internal void ProcessMessage(
55 | RedisChannel channel,
56 | RedisValue message)
57 | {
58 | var cacheMessage = JsonSerializer
59 | .Deserialize(
60 | message.ToString(),
61 | JsonSerializerOptions);
62 | if (cacheMessage != null &&
63 | cacheMessage.PublisherId != L1L2RedisCacheOptions.Id)
64 | {
65 | L1Cache.Remove(
66 | $"{L1L2RedisCacheOptions.KeyPrefix}{cacheMessage.Key}");
67 | L1Cache.Remove(
68 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{cacheMessage.Key}");
69 |
70 | OnMessage?.Invoke(
71 | this,
72 | new OnMessageEventArgs(cacheMessage.Key));
73 | }
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/tests/System/PerformanceTests.cs:
--------------------------------------------------------------------------------
1 | using BenchmarkDotNet.Configs;
2 | using BenchmarkDotNet.Extensions;
3 | using BenchmarkDotNet.Running;
4 | using Microsoft.VisualStudio.TestTools.UnitTesting;
5 |
6 | namespace L1L2RedisCache.Tests.System;
7 |
8 | [TestClass]
9 | public class PerformanceTests
10 | {
11 | private IConfig Config { get; } =
12 | DefaultConfig.Instance.WithOptions(
13 | ConfigOptions.DisableOptimizationsValidator);
14 |
15 | [TestMethod]
16 | public void GetPerformanceTest()
17 | {
18 | var benchmarkSummary = BenchmarkRunner
19 | .Run(Config);
20 |
21 | Assert.IsTrue(
22 | benchmarkSummary.Reports.All(
23 | r => r.Success));
24 |
25 | var l1GetReport = benchmarkSummary
26 | .GetReportFor(
27 | gB => gB.L1Get());
28 | var l1L2GetReport = benchmarkSummary
29 | .GetReportFor(
30 | gB => gB.L1L2Get());
31 | var l1L2GetPropagationReport = benchmarkSummary
32 | .GetReportFor(
33 | gB => gB.L1L2GetPropagation());
34 | var l2GetReport = benchmarkSummary
35 | .GetReportFor(
36 | gB => gB.L2Get());
37 |
38 | var l2GetVsL1L2GetRatio =
39 | l2GetReport.ResultStatistics?.Median /
40 | l1L2GetReport.ResultStatistics?.Median;
41 | Assert.IsTrue(
42 | l2GetVsL1L2GetRatio > 100,
43 | $"L1L2Cache Get must perform significantly better (> 100) than RedisCache Get: {l2GetVsL1L2GetRatio}");
44 |
45 | var l1L2GetPropagationVsl2GetRatio =
46 | l2GetReport.ResultStatistics?.Median /
47 | l1L2GetPropagationReport.ResultStatistics?.Median;
48 | Assert.IsTrue(
49 | l1L2GetPropagationVsl2GetRatio > 3,
50 | $"L1L2Cache Get must perform better (> 3) than to RedisCache Get: {l1L2GetPropagationVsl2GetRatio}");
51 | }
52 |
53 | [TestMethod]
54 | public void SetPerformanceTest()
55 | {
56 | var benchmarkSummary = BenchmarkRunner
57 | .Run(Config);
58 |
59 | Assert.IsTrue(
60 | benchmarkSummary.Reports.All(
61 | r => r.Success));
62 |
63 | var l1L2SetReport = benchmarkSummary
64 | .GetReportFor(
65 | gB => gB.L1L2Set());
66 | var l2SetReport = benchmarkSummary
67 | .GetReportFor(
68 | gB => gB.L2Set());
69 |
70 | var l1L2SetVsl2SetRatio =
71 | l1L2SetReport.ResultStatistics?.Median /
72 | l2SetReport.ResultStatistics?.Median;
73 | Assert.IsTrue(
74 | l1L2SetVsl2SetRatio < 3,
75 | $"L1L2Cache Set cannot perform significantly worse (< 3) than RedisCache Set: {l1L2SetVsl2SetRatio}");
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/Messaging/KeyeventMessageSubscriber.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.Memory;
2 | using Microsoft.Extensions.Options;
3 | using StackExchange.Redis;
4 |
5 | namespace L1L2RedisCache;
6 |
7 | internal class KeyeventMessageSubscriber(
8 | IMemoryCache l1Cache,
9 | IOptions l1L2RedisCacheOptionsOptionsAccessor) :
10 | IMessageSubscriber
11 | {
12 | public L1L2RedisCacheOptions L1L2RedisCacheOptions { get; set; } =
13 | l1L2RedisCacheOptionsOptionsAccessor.Value;
14 | public IMemoryCache L1Cache { get; set; } =
15 | l1Cache;
16 | public EventHandler? OnMessage { get; set; }
17 | public EventHandler? OnSubscribe { get; set; }
18 |
19 | public async Task SubscribeAsync(
20 | IConnectionMultiplexer connectionMultiplexer,
21 | CancellationToken cancellationToken = default)
22 | {
23 | await connectionMultiplexer
24 | .GetSubscriber()
25 | .SubscribeAsync(
26 | new RedisChannel(
27 | "__keyevent@*__:del",
28 | RedisChannel.PatternMode.Pattern),
29 | ProcessMessage)
30 | .ConfigureAwait(false);
31 |
32 | await connectionMultiplexer
33 | .GetSubscriber()
34 | .SubscribeAsync(
35 | new RedisChannel(
36 | "__keyevent@*__:hset",
37 | RedisChannel.PatternMode.Pattern),
38 | ProcessMessage)
39 | .ConfigureAwait(false);
40 |
41 | OnSubscribe?.Invoke(
42 | this,
43 | EventArgs.Empty);
44 | }
45 |
46 | public async Task UnsubscribeAsync(
47 | IConnectionMultiplexer connectionMultiplexer,
48 | CancellationToken cancellationToken = default)
49 | {
50 | await connectionMultiplexer
51 | .GetSubscriber()
52 | .UnsubscribeAsync(
53 | new RedisChannel(
54 | "__keyevent@*__:del",
55 | RedisChannel.PatternMode.Pattern))
56 | .ConfigureAwait(false);
57 |
58 | await connectionMultiplexer
59 | .GetSubscriber()
60 | .UnsubscribeAsync(
61 | new RedisChannel(
62 | "__keyevent@*__:hset",
63 | RedisChannel.PatternMode.Pattern))
64 | .ConfigureAwait(false);
65 | }
66 |
67 | internal void ProcessMessage(
68 | RedisChannel channel,
69 | RedisValue message)
70 | {
71 | if (message.StartsWith(
72 | L1L2RedisCacheOptions.KeyPrefix))
73 | {
74 | var key = message
75 | .ToString()[L1L2RedisCacheOptions.KeyPrefix.Length..];
76 | L1Cache.Remove(
77 | $"{L1L2RedisCacheOptions.KeyPrefix}{key}");
78 | L1Cache.Remove(
79 | $"{L1L2RedisCacheOptions.LockKeyPrefix}{key}");
80 |
81 | OnMessage?.Invoke(
82 | this,
83 | new OnMessageEventArgs(key));
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # L1L2RedisCache
2 |
3 | `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).
4 |
5 | `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.
6 |
7 | 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.
8 |
9 | ## Configuration
10 |
11 | It is intended that L1L12RedisCache be used as an `IDistributedCache` implementation.
12 |
13 | `L1L2RedisCache` can be registered during startup with the following `IServiceCollection` extension method:
14 |
15 | ```
16 | services.AddL1L2RedisCache(options =>
17 | {
18 | options.Configuration = "localhost";
19 | options.InstanceName = "Namespace:Prefix:";
20 | });
21 | ```
22 |
23 | `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:
24 |
25 | ### MessagingType
26 |
27 | The type of messaging system to use for L1 memory cache eviction.
28 |
29 | | MessagingType | Description | Suggestion |
30 | | - | - | - |
31 | | `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. |
32 | | `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`. |
33 | | `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`. |
34 |
35 | ## Performance
36 |
37 | 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.
38 |
39 | ## Considerations
40 |
41 | 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.
42 |
--------------------------------------------------------------------------------
/L1L2RedisCache.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio 15
4 | VisualStudioVersion = 15.0.26124.0
5 | MinimumVisualStudioVersion = 15.0.26124.0
6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache", "src\L1L2RedisCache.csproj", "{71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}"
7 | EndProject
8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{79FE29CD-A4E5-46BB-9FC8-5EC921CFE5F3}"
9 | EndProject
10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache.Tests.Unit", "tests\Unit\L1L2RedisCache.Tests.Unit.csproj", "{8791FCF7-078D-44A5-AC59-C7C2CE469D3F}"
11 | EndProject
12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "L1L2RedisCache.Tests.System", "tests\System\L1L2RedisCache.Tests.System.csproj", "{6A825E82-5BF4-43A0-BA08-9CB000FB232A}"
13 | EndProject
14 | Global
15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
16 | Debug|Any CPU = Debug|Any CPU
17 | Debug|x64 = Debug|x64
18 | Debug|x86 = Debug|x86
19 | Release|Any CPU = Release|Any CPU
20 | Release|x64 = Release|x64
21 | Release|x86 = Release|x86
22 | EndGlobalSection
23 | GlobalSection(SolutionProperties) = preSolution
24 | HideSolutionNode = FALSE
25 | EndGlobalSection
26 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
27 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
28 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|Any CPU.Build.0 = Debug|Any CPU
29 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x64.ActiveCfg = Debug|Any CPU
30 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x64.Build.0 = Debug|Any CPU
31 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x86.ActiveCfg = Debug|Any CPU
32 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Debug|x86.Build.0 = Debug|Any CPU
33 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|Any CPU.ActiveCfg = Release|Any CPU
34 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|Any CPU.Build.0 = Release|Any CPU
35 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x64.ActiveCfg = Release|Any CPU
36 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x64.Build.0 = Release|Any CPU
37 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x86.ActiveCfg = Release|Any CPU
38 | {71A8453B-1A5E-49ED-A9A2-17B7DC9A7407}.Release|x86.Build.0 = Release|Any CPU
39 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
40 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
41 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x64.ActiveCfg = Debug|Any CPU
42 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x64.Build.0 = Debug|Any CPU
43 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x86.ActiveCfg = Debug|Any CPU
44 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Debug|x86.Build.0 = Debug|Any CPU
45 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
46 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|Any CPU.Build.0 = Release|Any CPU
47 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x64.ActiveCfg = Release|Any CPU
48 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x64.Build.0 = Release|Any CPU
49 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x86.ActiveCfg = Release|Any CPU
50 | {8791FCF7-078D-44A5-AC59-C7C2CE469D3F}.Release|x86.Build.0 = Release|Any CPU
51 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
52 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|Any CPU.Build.0 = Debug|Any CPU
53 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x64.ActiveCfg = Debug|Any CPU
54 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x64.Build.0 = Debug|Any CPU
55 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x86.ActiveCfg = Debug|Any CPU
56 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Debug|x86.Build.0 = Debug|Any CPU
57 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|Any CPU.ActiveCfg = Release|Any CPU
58 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|Any CPU.Build.0 = Release|Any CPU
59 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x64.ActiveCfg = Release|Any CPU
60 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x64.Build.0 = Release|Any CPU
61 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x86.ActiveCfg = Release|Any CPU
62 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A}.Release|x86.Build.0 = Release|Any CPU
63 | EndGlobalSection
64 | GlobalSection(NestedProjects) = preSolution
65 | {6A825E82-5BF4-43A0-BA08-9CB000FB232A} = {79FE29CD-A4E5-46BB-9FC8-5EC921CFE5F3}
66 | EndGlobalSection
67 | EndGlobal
68 |
--------------------------------------------------------------------------------
/src/Configuration/ServiceCollectionExtensions.cs:
--------------------------------------------------------------------------------
1 | using L1L2RedisCache;
2 | using Microsoft.Extensions.Caching.Distributed;
3 | using Microsoft.Extensions.Caching.StackExchangeRedis;
4 | using Microsoft.Extensions.Options;
5 | using StackExchange.Redis;
6 |
7 | namespace Microsoft.Extensions.DependencyInjection;
8 |
9 | ///
10 | /// Extension methods for setting up L1L2RedisCache related services in an Microsoft.Extensions.DependencyInjection.IServiceCollection.
11 | ///
12 | public static class ServiceCollectionExtensions
13 | {
14 | ///
15 | /// Adds L1L2RedisCache distributed caching services to the specified IServiceCollection.
16 | ///
17 | /// The IServiceCollection so that additional calls can be chained.
18 | public static IServiceCollection AddL1L2RedisCache(
19 | this IServiceCollection services,
20 | Action setupAction)
21 | {
22 | if (setupAction == null)
23 | {
24 | throw new ArgumentNullException(
25 | nameof(setupAction));
26 | }
27 |
28 | var l1L2RedisCacheOptions = new L1L2RedisCacheOptions();
29 | setupAction.Invoke(l1L2RedisCacheOptions);
30 |
31 | services.AddOptions();
32 | services.Configure(setupAction);
33 | services.Configure(
34 | (options) =>
35 | {
36 | if (options.ConnectionMultiplexerFactory == null)
37 | {
38 | if (options.ConfigurationOptions != null)
39 | {
40 | options.ConnectionMultiplexerFactory = () =>
41 | Task.FromResult(
42 | ConnectionMultiplexer.Connect(
43 | options.ConfigurationOptions) as IConnectionMultiplexer);
44 | }
45 | else if (!string.IsNullOrEmpty(options.Configuration))
46 | {
47 | options.ConnectionMultiplexerFactory = () =>
48 | Task.FromResult(
49 | ConnectionMultiplexer.Connect(
50 | options.Configuration) as IConnectionMultiplexer);
51 | }
52 | }
53 | });
54 | services.AddMemoryCache();
55 | services.AddSingleton(
56 | serviceProvider => new Func(
57 | () => new RedisCache(
58 | serviceProvider.GetRequiredService>())));
59 | services.AddSingleton();
60 | services.AddSingleton();
61 |
62 | services.AddSingleton();
63 | services.AddSingleton();
64 | services.AddSingleton(
65 | serviceProvider =>
66 | {
67 | var options = serviceProvider
68 | .GetRequiredService>()
69 | .Value;
70 |
71 | return options.MessagingType switch
72 | {
73 | MessagingType.Default =>
74 | serviceProvider.GetRequiredService(),
75 | _ =>
76 | serviceProvider.GetRequiredService(),
77 | };
78 | });
79 |
80 | services.AddSingleton();
81 | services.AddSingleton();
82 | services.AddSingleton();
83 | services.AddSingleton(
84 | serviceProvider =>
85 | {
86 | var options = serviceProvider
87 | .GetRequiredService>()
88 | .Value;
89 |
90 | return options.MessagingType switch
91 | {
92 | MessagingType.Default =>
93 | serviceProvider.GetRequiredService(),
94 | MessagingType.KeyeventNotifications =>
95 | serviceProvider.GetRequiredService(),
96 | MessagingType.KeyspaceNotifications =>
97 | serviceProvider.GetRequiredService(),
98 | _ => throw new NotImplementedException(),
99 | };
100 | });
101 |
102 | return services;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/tests/System/MessagingTests.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.Distributed;
2 | using Microsoft.Extensions.Configuration;
3 | using Microsoft.Extensions.DependencyInjection;
4 | using Microsoft.Extensions.Options;
5 | using Microsoft.VisualStudio.TestTools.UnitTesting;
6 |
7 | namespace L1L2RedisCache.Tests.System;
8 |
9 | [TestClass]
10 | public class MessagingTests
11 | {
12 | public MessagingTests()
13 | {
14 | Configuration = new ConfigurationBuilder()
15 | .AddJsonFile("appsettings.json")
16 | .AddEnvironmentVariables()
17 | .Build();
18 | EventTimeout = TimeSpan.FromSeconds(5);
19 | }
20 |
21 | public IConfiguration Configuration { get; }
22 | public TimeSpan EventTimeout { get; }
23 |
24 | [DataRow(100, MessagingType.Default)]
25 | [DataRow(100, MessagingType.KeyeventNotifications)]
26 | [DataRow(100, MessagingType.KeyspaceNotifications)]
27 | [TestMethod]
28 | public async Task MessagingTypeTest(
29 | int iterations,
30 | MessagingType messagingType)
31 | {
32 | var primaryServices = new ServiceCollection();
33 | primaryServices.AddSingleton(Configuration);
34 | primaryServices.AddL1L2RedisCache(options =>
35 | {
36 | Configuration.Bind("L1L2RedisCache", options);
37 | options.MessagingType = messagingType;
38 | });
39 | using var primaryServiceProvider = primaryServices
40 | .BuildServiceProvider();
41 |
42 | var primaryL1L2Cache = primaryServiceProvider
43 | .GetRequiredService();
44 | var primaryL1L2CacheOptions = primaryServiceProvider
45 | .GetRequiredService>()
46 | .Value;
47 |
48 | await SetAndVerifyConfigurationAsync(
49 | primaryServiceProvider,
50 | messagingType)
51 | .ConfigureAwait(false);
52 |
53 | var secondaryServices = new ServiceCollection();
54 | secondaryServices.AddSingleton(Configuration);
55 | secondaryServices.AddL1L2RedisCache(options =>
56 | {
57 | Configuration.Bind("L1L2RedisCache", options);
58 | options.MessagingType = messagingType;
59 | });
60 | using var secondaryServiceProvider = secondaryServices
61 | .BuildServiceProvider();
62 |
63 | var secondaryMessageSubscriber = secondaryServiceProvider
64 | .GetRequiredService();
65 | using var messageAutoResetEvent = new AutoResetEvent(false);
66 | using var subscribeAutoResetEvent = new AutoResetEvent(false);
67 | secondaryMessageSubscriber.OnMessage += (sender, e) =>
68 | {
69 | messageAutoResetEvent.Set();
70 | };
71 | secondaryMessageSubscriber.OnSubscribe += (sender, e) =>
72 | {
73 | subscribeAutoResetEvent.Set();
74 | };
75 |
76 | var secondaryL1L2Cache = secondaryServiceProvider
77 | .GetRequiredService();
78 |
79 | Assert.IsTrue(
80 | subscribeAutoResetEvent
81 | .WaitOne(EventTimeout));
82 |
83 | for (var iteration = 0; iteration < iterations; iteration++)
84 | {
85 | var key = Guid.NewGuid().ToString();
86 | var value = Guid.NewGuid().ToString();
87 |
88 | // L1 population via L2
89 | await primaryL1L2Cache
90 | .SetStringAsync(
91 | key, value)
92 | .ConfigureAwait(false);
93 | Assert.IsTrue(
94 | messageAutoResetEvent
95 | .WaitOne(EventTimeout));
96 | Assert.AreEqual(
97 | value,
98 | await secondaryL1L2Cache
99 | .GetStringAsync(key)
100 | .ConfigureAwait(false));
101 |
102 | // L1 eviction via set
103 | // L1 population via L2
104 | await primaryL1L2Cache
105 | .SetStringAsync(
106 | key, value)
107 | .ConfigureAwait(false);
108 | Assert.IsTrue(
109 | messageAutoResetEvent
110 | .WaitOne(EventTimeout));
111 | Assert.AreEqual(
112 | value,
113 | await secondaryL1L2Cache
114 | .GetStringAsync(key)
115 | .ConfigureAwait(false));
116 |
117 | // L1 eviction via remove
118 | await primaryL1L2Cache
119 | .RemoveAsync(key)
120 | .ConfigureAwait(false);
121 | Assert.IsTrue(
122 | messageAutoResetEvent
123 | .WaitOne(EventTimeout));
124 | Assert.IsNull(
125 | await secondaryL1L2Cache
126 | .GetStringAsync(key)
127 | .ConfigureAwait(false));
128 | }
129 | }
130 |
131 | private static async Task SetAndVerifyConfigurationAsync(
132 | IServiceProvider serviceProvider,
133 | MessagingType messagingType)
134 | {
135 | var l1L2Cache = serviceProvider
136 | .GetRequiredService() as L1L2RedisCache;
137 |
138 | await l1L2Cache!.Database.Value
139 | .ExecuteAsync(
140 | "config",
141 | "set",
142 | "notify-keyspace-events",
143 | MessagingConfigurationVerifier
144 | .NotifyKeyspaceEventsConfig[messagingType])
145 | .ConfigureAwait(false);
146 |
147 | var configurationVerifier = serviceProvider
148 | .GetRequiredService();
149 | Assert.IsTrue(
150 | await configurationVerifier
151 | .VerifyConfigurationAsync(
152 | l1L2Cache.Database.Value)
153 | .ConfigureAwait(false));
154 | }
155 | }
156 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ## Ignore Visual Studio temporary files, build results, and
2 | ## files generated by popular Visual Studio add-ons.
3 | ##
4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
5 |
6 | # User-specific files
7 | *.rsuser
8 | *.suo
9 | *.user
10 | *.userosscache
11 | *.sln.docstates
12 |
13 | # User-specific files (MonoDevelop/Xamarin Studio)
14 | *.userprefs
15 |
16 | # Build results
17 | [Dd]ebug/
18 | [Dd]ebugPublic/
19 | [Rr]elease/
20 | [Rr]eleases/
21 | x64/
22 | x86/
23 | bld/
24 | [Bb]in/
25 | [Oo]bj/
26 | [Ll]og/
27 |
28 | # Visual Studio 2015/2017 cache/options directory
29 | .vs/
30 | # Uncomment if you have tasks that create the project's static files in wwwroot
31 | #wwwroot/
32 |
33 | # Visual Studio 2017 auto generated files
34 | Generated\ Files/
35 |
36 | # MSTest test Results
37 | [Tt]est[Rr]esult*/
38 | [Bb]uild[Ll]og.*
39 |
40 | # NUNIT
41 | *.VisualState.xml
42 | TestResult.xml
43 |
44 | # Build Results of an ATL Project
45 | [Dd]ebugPS/
46 | [Rr]eleasePS/
47 | dlldata.c
48 |
49 | # Benchmark Results
50 | BenchmarkDotNet.Artifacts/
51 |
52 | # .NET Core
53 | project.lock.json
54 | project.fragment.lock.json
55 | artifacts/
56 |
57 | # StyleCop
58 | StyleCopReport.xml
59 |
60 | # Files built by Visual Studio
61 | *_i.c
62 | *_p.c
63 | *_h.h
64 | *.ilk
65 | *.meta
66 | *.obj
67 | *.iobj
68 | *.pch
69 | *.pdb
70 | *.ipdb
71 | *.pgc
72 | *.pgd
73 | *.rsp
74 | *.sbr
75 | *.tlb
76 | *.tli
77 | *.tlh
78 | *.tmp
79 | *.tmp_proj
80 | *_wpftmp.csproj
81 | *.log
82 | *.vspscc
83 | *.vssscc
84 | .builds
85 | *.pidb
86 | *.svclog
87 | *.scc
88 |
89 | # Chutzpah Test files
90 | _Chutzpah*
91 |
92 | # Visual C++ cache files
93 | ipch/
94 | *.aps
95 | *.ncb
96 | *.opendb
97 | *.opensdf
98 | *.sdf
99 | *.cachefile
100 | *.VC.db
101 | *.VC.VC.opendb
102 |
103 | # Visual Studio profiler
104 | *.psess
105 | *.vsp
106 | *.vspx
107 | *.sap
108 |
109 | # Visual Studio Trace Files
110 | *.e2e
111 |
112 | # TFS 2012 Local Workspace
113 | $tf/
114 |
115 | # Guidance Automation Toolkit
116 | *.gpState
117 |
118 | # ReSharper is a .NET coding add-in
119 | _ReSharper*/
120 | *.[Rr]e[Ss]harper
121 | *.DotSettings.user
122 |
123 | # JustCode is a .NET coding add-in
124 | .JustCode
125 |
126 | # TeamCity is a build add-in
127 | _TeamCity*
128 |
129 | # DotCover is a Code Coverage Tool
130 | *.dotCover
131 |
132 | # AxoCover is a Code Coverage Tool
133 | .axoCover/*
134 | !.axoCover/settings.json
135 |
136 | # Visual Studio code coverage results
137 | *.coverage
138 | *.coveragexml
139 |
140 | # NCrunch
141 | _NCrunch_*
142 | .*crunch*.local.xml
143 | nCrunchTemp_*
144 |
145 | # MightyMoose
146 | *.mm.*
147 | AutoTest.Net/
148 |
149 | # Web workbench (sass)
150 | .sass-cache/
151 |
152 | # Installshield output folder
153 | [Ee]xpress/
154 |
155 | # DocProject is a documentation generator add-in
156 | DocProject/buildhelp/
157 | DocProject/Help/*.HxT
158 | DocProject/Help/*.HxC
159 | DocProject/Help/*.hhc
160 | DocProject/Help/*.hhk
161 | DocProject/Help/*.hhp
162 | DocProject/Help/Html2
163 | DocProject/Help/html
164 |
165 | # Click-Once directory
166 | publish/
167 |
168 | # Publish Web Output
169 | *.[Pp]ublish.xml
170 | *.azurePubxml
171 | # Note: Comment the next line if you want to checkin your web deploy settings,
172 | # but database connection strings (with potential passwords) will be unencrypted
173 | *.pubxml
174 | *.publishproj
175 |
176 | # Microsoft Azure Web App publish settings. Comment the next line if you want to
177 | # checkin your Azure Web App publish settings, but sensitive information contained
178 | # in these scripts will be unencrypted
179 | PublishScripts/
180 |
181 | # NuGet Packages
182 | *.nupkg
183 | # The packages folder can be ignored because of Package Restore
184 | **/[Pp]ackages/*
185 | # except build/, which is used as an MSBuild target.
186 | !**/[Pp]ackages/build/
187 | # Uncomment if necessary however generally it will be regenerated when needed
188 | #!**/[Pp]ackages/repositories.config
189 | # NuGet v3's project.json files produces more ignorable files
190 | *.nuget.props
191 | *.nuget.targets
192 |
193 | # Microsoft Azure Build Output
194 | csx/
195 | *.build.csdef
196 |
197 | # Microsoft Azure Emulator
198 | ecf/
199 | rcf/
200 |
201 | # Windows Store app package directories and files
202 | AppPackages/
203 | BundleArtifacts/
204 | Package.StoreAssociation.xml
205 | _pkginfo.txt
206 | *.appx
207 |
208 | # Visual Studio cache files
209 | # files ending in .cache can be ignored
210 | *.[Cc]ache
211 | # but keep track of directories ending in .cache
212 | !*.[Cc]ache/
213 |
214 | # Others
215 | ClientBin/
216 | ~$*
217 | *~
218 | *.dbmdl
219 | *.dbproj.schemaview
220 | *.jfm
221 | *.pfx
222 | *.publishsettings
223 | orleans.codegen.cs
224 |
225 | # Including strong name files can present a security risk
226 | # (https://github.com/github/gitignore/pull/2483#issue-259490424)
227 | #*.snk
228 |
229 | # Since there are multiple workflows, uncomment next line to ignore bower_components
230 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
231 | #bower_components/
232 |
233 | # RIA/Silverlight projects
234 | Generated_Code/
235 |
236 | # Backup & report files from converting an old project file
237 | # to a newer Visual Studio version. Backup files are not needed,
238 | # because we have git ;-)
239 | _UpgradeReport_Files/
240 | Backup*/
241 | UpgradeLog*.XML
242 | UpgradeLog*.htm
243 | ServiceFabricBackup/
244 | *.rptproj.bak
245 |
246 | # SQL Server files
247 | *.mdf
248 | *.ldf
249 | *.ndf
250 |
251 | # Business Intelligence projects
252 | *.rdl.data
253 | *.bim.layout
254 | *.bim_*.settings
255 | *.rptproj.rsuser
256 |
257 | # Microsoft Fakes
258 | FakesAssemblies/
259 |
260 | # GhostDoc plugin setting file
261 | *.GhostDoc.xml
262 |
263 | # Node.js Tools for Visual Studio
264 | .ntvs_analysis.dat
265 | node_modules/
266 |
267 | # Visual Studio 6 build log
268 | *.plg
269 |
270 | # Visual Studio 6 workspace options file
271 | *.opt
272 |
273 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
274 | *.vbw
275 |
276 | # Visual Studio LightSwitch build output
277 | **/*.HTMLClient/GeneratedArtifacts
278 | **/*.DesktopClient/GeneratedArtifacts
279 | **/*.DesktopClient/ModelManifest.xml
280 | **/*.Server/GeneratedArtifacts
281 | **/*.Server/ModelManifest.xml
282 | _Pvt_Extensions
283 |
284 | # Paket dependency manager
285 | .paket/paket.exe
286 | paket-files/
287 |
288 | # FAKE - F# Make
289 | .fake/
290 |
291 | # JetBrains Rider
292 | .idea/
293 | *.sln.iml
294 |
295 | # CodeRush personal settings
296 | .cr/personal
297 |
298 | # Python Tools for Visual Studio (PTVS)
299 | __pycache__/
300 | *.pyc
301 |
302 | # Cake - Uncomment if you are using it
303 | # tools/**
304 | # !tools/packages.config
305 |
306 | # Tabs Studio
307 | *.tss
308 |
309 | # Telerik's JustMock configuration file
310 | *.jmconfig
311 |
312 | # BizTalk build output
313 | *.btp.cs
314 | *.btm.cs
315 | *.odx.cs
316 | *.xsd.cs
317 |
318 | # OpenCover UI analysis results
319 | OpenCover/
320 |
321 | # Azure Stream Analytics local run output
322 | ASALocalRun/
323 |
324 | # MSBuild Binary and Structured Log
325 | *.binlog
326 |
327 | # NVidia Nsight GPU debugger configuration file
328 | *.nvuser
329 |
330 | # MFractors (Xamarin productivity tool) working folder
331 | .mfractor/
332 |
333 | # Local History for Visual Studio
334 | .localhistory/
335 |
336 | .mono/
--------------------------------------------------------------------------------
/tests/Unit/L1L2RedisCacheTests.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.Caching.Distributed;
2 | using Microsoft.Extensions.Caching.Memory;
3 | using Microsoft.Extensions.Options;
4 | using Microsoft.VisualStudio.TestTools.UnitTesting;
5 | using NSubstitute;
6 | using StackExchange.Redis;
7 |
8 | namespace L1L2RedisCache.Tests.Unit;
9 |
10 | [TestClass]
11 | public class L1L2RedisCacheTests
12 | {
13 | public L1L2RedisCacheTests()
14 | {
15 | L1Cache = new MemoryCache(
16 | Options.Create(new MemoryCacheOptions()));
17 |
18 | L2Cache = new MemoryDistributedCache(
19 | Options.Create(
20 | new MemoryDistributedCacheOptions()));
21 |
22 | L1L2RedisCacheOptions = Options
23 | .Create(
24 | new L1L2RedisCacheOptions
25 | {
26 | InstanceName = "L1L2RedisCache:Test:",
27 | })
28 | .Value;
29 |
30 | var database = Substitute
31 | .For();
32 | database
33 | .HashGetAll(
34 | Arg.Any(),
35 | Arg.Any())
36 | .Returns(
37 | args =>
38 | {
39 | var key = ((RedisKey)args[0]).ToString()[
40 | (L1L2RedisCacheOptions?.InstanceName?.Length ?? 0)..];
41 | var value = L2Cache.Get(key);
42 | return
43 | [
44 | new HashEntry("data", value),
45 | ];
46 | });
47 | database
48 | .HashGetAllAsync(
49 | Arg.Any(),
50 | Arg.Any())
51 | .Returns(
52 | async args =>
53 | {
54 | var key = ((RedisKey)args[0]).ToString()[
55 | (L1L2RedisCacheOptions?.InstanceName?.Length ?? 0)..];
56 | var value = await L2Cache
57 | .GetAsync(key)
58 | .ConfigureAwait(false);
59 | return
60 | [
61 | new HashEntry("data", value),
62 | ];
63 | });
64 | database
65 | .KeyExists(
66 | Arg.Any(),
67 | Arg.Any())
68 | .Returns(
69 | args =>
70 | {
71 | return L2Cache.Get(
72 | ((RedisKey)args[0]).ToString()) != null;
73 | });
74 | database
75 | .KeyExistsAsync(
76 | Arg.Any(),
77 | Arg.Any())
78 | .Returns(
79 | async args =>
80 | {
81 | var key = ((RedisKey)args[0]).ToString()[
82 | (L1L2RedisCacheOptions.InstanceName?.Length ?? 0)..];
83 | return await L2Cache
84 | .GetAsync(key)
85 | .ConfigureAwait(false) != null;
86 | });
87 |
88 | var connectionMultiplexer = Substitute
89 | .For();
90 | connectionMultiplexer
91 | .GetDatabase(
92 | Arg.Any(),
93 | Arg.Any