From be9ccd85de230804073de7a94ac7650a573788a3 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 27 Jan 2026 11:03:52 +0100 Subject: [PATCH 01/23] Add cache key versioning and environment prefix Introduces configurable cache key versions for invalidation. Adds optional environment prefix for cache key isolation in multi-environment setups. Updates `CreateCacheKey` to incorporate these new components. Includes new options for `MessagePackDistributedCache` and `RedisHybridCache`. --- .../DistributedCache.cs | 32 ++- .../MessagePackDistributedCache.cs | 9 + .../MessagePackDistributedCacheOptions.cs | 21 ++ ...tion.Extensions.Caching.RedisHybrid.csproj | 1 + .../RedisHybridCache.cs | 21 +- .../RedisHybridCacheOptions.cs | 34 +++ .../ServiceCollectionExtensions.cs | 19 ++ .../CacheKeyVersioningTests.cs | 256 ++++++++++++++++++ 8 files changed, 389 insertions(+), 4 deletions(-) create mode 100644 Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs create mode 100644 Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs index 612e345..1e51791 100644 --- a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs @@ -19,6 +19,18 @@ public abstract class DistributedCache : IDistributedCache /// private static string CacheIdName => typeof(TCacheId).Name; + /// + /// Gets the cache key version for invalidation purposes. + /// If null, version is not included in the cache key. + /// + protected virtual int? Version => null; + + /// + /// Gets the optional environment prefix for cache key isolation. + /// If null or empty, environment prefix is not included in the cache key. + /// + protected virtual string? EnvironmentPrefix => null; + /// public T? Get(TCacheId id) where T : class @@ -206,7 +218,7 @@ protected abstract Task SetCacheObjectAsync(string key, T value, CacheEntryOp /// The cache id. /// The key of the cache entry. /// The caching key. - private static string CreateCacheKey(TCacheId id, string? key = null) + private string CreateCacheKey(TCacheId id, string? key = null) { var cacheKey = id.ToString(); if (!string.IsNullOrWhiteSpace(key)) @@ -214,7 +226,23 @@ private static string CreateCacheKey(TCacheId id, string? key = null) cacheKey = $"{cacheKey}_{key}"; } - return $"{CacheIdName}:{cacheKey}"; + var fullKey = CacheIdName; + + // Add version if specified + if (this.Version.HasValue) + { + fullKey = $"{fullKey}:v{this.Version.Value}"; + } + + fullKey = $"{fullKey}:{cacheKey}"; + + // Add environment prefix if specified + if (!string.IsNullOrWhiteSpace(this.EnvironmentPrefix)) + { + fullKey = $"{this.EnvironmentPrefix}:{fullKey}"; + } + + return fullKey; } } } diff --git a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs index 997fd9e..7c58764 100644 --- a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs +++ b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs @@ -42,6 +42,9 @@ public MessagePackDistributedCache(IDistributedCache cache, IOptions + protected override int? Version { get; } + + /// + protected override string? EnvironmentPrefix { get; } + /// protected override T? GetCacheObject(string key) where T : class diff --git a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs index 7269bcc..02ff009 100644 --- a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs +++ b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs @@ -21,6 +21,27 @@ public class MessagePackDistributedCacheOptions : IOptions public bool RequireMessagePackObjectAnnotation { get; set; } + /// + /// Gets or sets the cache key version to use for cache invalidation. + /// If not set, version is not included in the cache key. + /// Changing this version will invalidate all existing cache keys with that version. + /// The version will be formatted as "v{number}" in the cache key (e.g., v1, v2). + /// + /// + /// The cache key version. Defaults to null (no version in cache key). + /// + public int? Version { get; set; } + + /// + /// Gets or sets the optional environment prefix for cache key isolation. + /// If not set, environment prefix is not included in the cache key. + /// Useful for multi-environment scenarios (e.g., "dev", "staging", "prod"). + /// + /// + /// The environment prefix. Defaults to null (no environment prefix in cache key). + /// + public string? EnvironmentPrefix { get; set; } + /// public MessagePackDistributedCacheOptions Value => this; } diff --git a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj index ecdd485..44f4054 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj +++ b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj @@ -9,6 +9,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs index 0bb4a47..c9513fd 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Foundatio.Caching; + using Microsoft.Extensions.Options; using Neolution.Extensions.Caching.Abstractions; /// @@ -25,11 +26,27 @@ public class RedisHybridCache : DistributedCache /// Initializes a new instance of the class. /// /// The cache client. - public RedisHybridCache(ICacheClient cacheClient) + /// The options accessor. + public RedisHybridCache(ICacheClient cacheClient, IOptions optionsAccessor) { - this.cacheClient = cacheClient; + this.cacheClient = cacheClient ?? throw new ArgumentNullException(nameof(cacheClient)); + + if (optionsAccessor == null) + { + throw new ArgumentNullException(nameof(optionsAccessor)); + } + + var options = optionsAccessor.Value; + this.Version = options.Version; + this.EnvironmentPrefix = options.EnvironmentPrefix; } + /// + protected override int? Version { get; } + + /// + protected override string? EnvironmentPrefix { get; } + /// protected override T? GetCacheObject(string key) where T : class diff --git a/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs new file mode 100644 index 0000000..673a974 --- /dev/null +++ b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs @@ -0,0 +1,34 @@ +namespace Neolution.Extensions.Caching.RedisHybrid +{ + using Microsoft.Extensions.Options; + + /// + /// The options for the Redis hybrid cache implementation. + /// + public class RedisHybridCacheOptions : IOptions + { + /// + /// Gets or sets the cache key version to use for cache invalidation. + /// If not set, version is not included in the cache key. + /// Changing this version will invalidate all existing cache keys with that version. + /// The version will be formatted as "v{number}" in the cache key (e.g., v1, v2). + /// + /// + /// The cache key version. Defaults to null (no version in cache key). + /// + public int? Version { get; set; } + + /// + /// Gets or sets the optional environment prefix for cache key isolation. + /// If not set, environment prefix is not included in the cache key. + /// Useful for multi-environment scenarios (e.g., "dev", "staging", "prod"). + /// + /// + /// The environment prefix. Defaults to null (no environment prefix in cache key). + /// + public string? EnvironmentPrefix { get; set; } + + /// + public RedisHybridCacheOptions Value => this; + } +} diff --git a/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs b/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs index 05222b3..c8ba9d7 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs @@ -1,6 +1,7 @@ // Namespace adjusted for easier recognition of these extension methods in composition roots. namespace Microsoft.Extensions.DependencyInjection { + using System; using Foundatio.Caching; using Microsoft.Extensions.Logging; using Neolution.Extensions.Caching.Abstractions; @@ -19,6 +20,22 @@ public static class ServiceCollectionExtensions /// The redis connection string. public static void AddRedisHybridCache(this IServiceCollection services, string redisConnectionString) { + services.AddRedisHybridCache(redisConnectionString, _ => { }); + } + + /// + /// Adds the Neolution default non-distributed memory caching implementation with configuration options. + /// + /// The services. + /// The redis connection string. + /// The setup action. + public static void AddRedisHybridCache(this IServiceCollection services, string redisConnectionString, Action configureOptions) + { + if (configureOptions == null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + services.AddSingleton(sp => ConnectionMultiplexer.Connect(redisConnectionString)); services.AddSingleton(sp => new RedisHybridCacheClient(new RedisHybridCacheClientOptions { @@ -26,6 +43,8 @@ public static void AddRedisHybridCache(this IServiceCollection services, string LoggerFactory = sp.GetService(), })); + services.AddOptions(); + services.Configure(configureOptions); services.AddSingleton(typeof(IDistributedCache<>), typeof(RedisHybridCache<>)); } } diff --git a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs new file mode 100644 index 0000000..63493f6 --- /dev/null +++ b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs @@ -0,0 +1,256 @@ +namespace Neolution.Extensions.Caching.UnitTests +{ + using System; + using Microsoft.Extensions.DependencyInjection; + using Neolution.Extensions.Caching.Abstractions; + using Neolution.Extensions.Caching.Distributed; + using Neolution.Extensions.Caching.RedisHybrid; + using Neolution.Extensions.Caching.UnitTests.Models; + using Shouldly; + using Xunit; + + #nullable enable + + /// + /// Tests for cache key versioning and environment isolation + /// + public class CacheKeyVersioningTests + { + /// + /// Tests backward compatibility - cache key without version by default + /// + [Fact] + public void MessagePackCacheKeyWithoutVersionByDefault() + { + // Arrange + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddMessagePackDistributedCache(); // No options - should not include version + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "Hello World!"; + + // Act + cache.Set(TestCacheId.Foobar, testValue); + + // Assert - Verify we can retrieve the value (maintains backward compatibility) + cache.Get(TestCacheId.Foobar).ShouldBe(testValue); + } + + /// + /// Tests if cache key includes version by default + /// + [Fact] + public void MessagePackCacheKeyIncludesDefaultVersion() + { + // Arrange + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddMessagePackDistributedCache(); + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "Hello World!"; + + // Act + cache.Set(TestCacheId.Foobar, testValue); + + // Assert - Verify we can retrieve the value (key format is correct) + cache.Get(TestCacheId.Foobar).ShouldBe(testValue); + } + + /// + /// Tests if cache key includes custom version + /// + [Fact] + public void MessagePackCacheKeyIncludesCustomVersion() + { + // Arrange + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddMessagePackDistributedCache(options => + { + options.Version = 2; + }); + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "Hello World!"; + + // Act + cache.Set(TestCacheId.Foobar, testValue); + + // Assert - Verify we can retrieve the value + cache.Get(TestCacheId.Foobar).ShouldBe(testValue); + } + + /// + /// Tests if different versions produce different cache keys + /// + [Fact] + public void DifferentVersionsProduceDifferentKeys() + { + // Arrange - Create two service providers with different versions + var servicesV1 = new ServiceCollection(); + servicesV1.AddDistributedMemoryCache(); + servicesV1.AddMessagePackDistributedCache(options => options.Version = 1); + + var servicesV2 = new ServiceCollection(); + servicesV2.AddDistributedMemoryCache(); + servicesV2.AddMessagePackDistributedCache(options => options.Version = 2); + + using var providerV1 = servicesV1.BuildServiceProvider(); + using var providerV2 = servicesV2.BuildServiceProvider(); + + var cacheV1 = providerV1.GetRequiredService>(); + var cacheV2 = providerV2.GetRequiredService>(); + + var valueV1 = "Value in V1"; + var valueV2 = "Value in V2"; + + // Act - Set values in both caches + cacheV1.Set(TestCacheId.Foobar, valueV1); + cacheV2.Set(TestCacheId.Foobar, valueV2); + + // Assert - Each cache should only see its own value + // Note: This test demonstrates isolation, but with separate providers they use separate memory caches + cacheV1.Get(TestCacheId.Foobar).ShouldBe(valueV1); + cacheV2.Get(TestCacheId.Foobar).ShouldBe(valueV2); + } + + /// + /// Tests if cache key includes environment prefix + /// + [Fact] + public void MessagePackCacheKeyIncludesEnvironmentPrefix() + { + // Arrange + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddMessagePackDistributedCache(options => + { + options.EnvironmentPrefix = "dev"; + }); + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "Hello World!"; + + // Act + cache.Set(TestCacheId.Foobar, testValue); + + // Assert - Verify we can retrieve the value + cache.Get(TestCacheId.Foobar).ShouldBe(testValue); + } + + /// + /// Tests if cache key includes both version and environment prefix + /// + [Fact] + public void MessagePackCacheKeyIncludesBothVersionAndEnvironment() + { + // Arrange + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddMessagePackDistributedCache(options => + { + options.Version = 2; + options.EnvironmentPrefix = "prod"; + }); + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "Hello World!"; + + // Act + cache.Set(TestCacheId.Foobar, testValue); + + // Assert - Verify we can retrieve the value + cache.Get(TestCacheId.Foobar).ShouldBe(testValue); + } + + /// + /// Tests if cache key with optional key parameter works correctly + /// + [Fact] + public void MessagePackCacheKeyWithOptionalKeyAndVersion() + { + // Arrange + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddMessagePackDistributedCache(options => + { + options.Version = 2; + options.EnvironmentPrefix = "staging"; + }); + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "User 123"; + var key = "123"; + + // Act + cache.Set(TestCacheId.Foobar, key, testValue); + + // Assert - Verify we can retrieve the value with the key + cache.Get(TestCacheId.Foobar, key).ShouldBe(testValue); + + // Assert - Verify the key without the optional parameter is different + cache.Get(TestCacheId.Foobar).ShouldBeNull(); + } + + /// + /// Tests if null or empty environment prefix is handled correctly + /// + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void NullOrEmptyEnvironmentPrefixIsHandledCorrectly(string? environmentPrefix) + { + // Arrange + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddMessagePackDistributedCache(options => + { + options.EnvironmentPrefix = environmentPrefix; + }); + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "Hello World!"; + + // Act + cache.Set(TestCacheId.Foobar, testValue); + + // Assert - Should work without environment prefix + cache.Get(TestCacheId.Foobar).ShouldBe(testValue); + } + + /// + /// Tests if RedisHybridCache key includes custom version + /// + [Fact(Skip = "Requires Redis connection - integration test")] + public void RedisHybridCacheKeyIncludesCustomVersion() + { + // Arrange + var services = new ServiceCollection(); + services.AddRedisHybridCache("localhost:6379", options => + { + options.Version = 2; + options.EnvironmentPrefix = "test"; + }); + + using var serviceProvider = services.BuildServiceProvider(); + var cache = serviceProvider.GetRequiredService>(); + var testValue = "Hello World!"; + + // Act + cache.Set(TestCacheId.Foobar, testValue); + + // Assert + cache.Get(TestCacheId.Foobar).ShouldBe(testValue); + } + } +} From 7ecc365c759bc2f08b8374be4f202d58d3a77840 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 27 Jan 2026 12:21:44 +0100 Subject: [PATCH 02/23] Enhances cache key generation and validation Introduces `CacheKeyAttribute` for explicit cache key strings on enum members, making them refactor-safe. Adds URL encoding for optional keys and optional length validation for distributed cache keys to improve compatibility and performance. Includes new unit tests for these features. --- .../CacheKeyAttribute.cs | 38 +++ .../DistributedCache.cs | 68 ++++- .../MemoryCache.cs | 1 + .../CacheKeyImprovementsTests.cs | 118 ++++++++ .../DistributedCacheKeyImprovementsTests.cs | 254 ++++++++++++++++++ .../Models/TestCacheId.cs | 14 + 6 files changed, 491 insertions(+), 2 deletions(-) create mode 100644 Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs create mode 100644 Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs create mode 100644 Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs diff --git a/Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs b/Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs new file mode 100644 index 0000000..1d6df4d --- /dev/null +++ b/Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs @@ -0,0 +1,38 @@ +namespace Neolution.Extensions.Caching.Abstractions +{ + using System; + + /// + /// Specifies the explicit cache key string to use for an enum value. + /// This makes cache keys refactor-safe by decoupling them from the enum member name. + /// + [AttributeUsage(AttributeTargets.Field, AllowMultiple = false)] + public sealed class CacheKeyAttribute : Attribute + { + /// + /// Initializes a new instance of the class. + /// + /// The explicit cache key string to use. + /// Thrown when key is null. + /// Thrown when key is empty or whitespace. + public CacheKeyAttribute(string key) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("Cache key cannot be empty or whitespace.", nameof(key)); + } + + this.Key = key; + } + + /// + /// Gets the explicit cache key string. + /// + public string Key { get; } + } +} diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs index 1e51791..943034f 100644 --- a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs @@ -1,6 +1,7 @@ namespace Neolution.Extensions.Caching.Abstractions { using System; + using System.Text; using System.Threading; using System.Threading.Tasks; @@ -11,6 +12,12 @@ public abstract class DistributedCache : IDistributedCache where TCacheId : struct, Enum { + /// + /// Maximum allowed cache key length in bytes (UTF-8 encoded). + /// This ensures performance and compatibility with limits of certain cache backends. + /// + private const int MaxCacheKeyBytes = 250; + /// /// Gets the name of the cache. /// @@ -19,6 +26,20 @@ public abstract class DistributedCache : IDistributedCache /// private static string CacheIdName => typeof(TCacheId).Name; + /// + /// Gets a value indicating whether optional cache keys should be URL-encoded. + /// Default is true for safe handling of special characters. + /// Set to false to maintain backwards compatibility with existing cache keys. + /// + protected virtual bool EnableKeyEncoding => true; + + /// + /// Gets a value indicating whether cache key length should be validated. + /// Default is true to ensure performance and compatibility with many cache backends. + /// Set to false to disable length validation if your cache backend supports longer keys. + /// + protected virtual bool EnableKeyLengthValidation => true; + /// /// Gets the cache key version for invalidation purposes. /// If null, version is not included in the cache key. @@ -212,6 +233,30 @@ protected abstract Task SetCacheObjectAsync(string key, T value, CacheEntryOp /// The . protected abstract Task RemoveCacheObjectAsync(string key, CancellationToken token); + /// + /// Gets the cache key string for an enum value. + /// If the enum value has a , uses the explicit key. + /// Otherwise, uses the enum member name (ToString()). + /// + /// The cache identifier enum value. + /// The cache key string to use. + private static string GetCacheKeyString(TCacheId id) + { + var enumType = typeof(TCacheId); + var memberName = id.ToString(); + var memberInfo = enumType.GetField(memberName); + + if (memberInfo == null) + { + return memberName; + } + + // Check for CacheKeyAttribute + var attribute = (CacheKeyAttribute?)Attribute.GetCustomAttribute(memberInfo, typeof(CacheKeyAttribute)); + + return attribute?.Key ?? memberName; + } + /// /// Creates the full key to use for the underlying cache implementation. /// @@ -220,10 +265,14 @@ protected abstract Task SetCacheObjectAsync(string key, T value, CacheEntryOp /// The caching key. private string CreateCacheKey(TCacheId id, string? key = null) { - var cacheKey = id.ToString(); + // Use attribute value if present, otherwise enum name + var cacheKey = GetCacheKeyString(id); + if (!string.IsNullOrWhiteSpace(key)) { - cacheKey = $"{cacheKey}_{key}"; + // URL-encode the key if enabled + var processedKey = this.EnableKeyEncoding ? Uri.EscapeDataString(key) : key; + cacheKey = $"{cacheKey}_{processedKey}"; } var fullKey = CacheIdName; @@ -242,6 +291,21 @@ private string CreateCacheKey(TCacheId id, string? key = null) fullKey = $"{this.EnvironmentPrefix}:{fullKey}"; } + // Optional validation - check total key length if enabled + if (this.EnableKeyLengthValidation) + { + var keyBytes = Encoding.UTF8.GetByteCount(fullKey); + + if (keyBytes > MaxCacheKeyBytes) + { + throw new ArgumentException( + $"Generated cache key exceeds maximum length of {MaxCacheKeyBytes} bytes. " + + $"Current key is {keyBytes} bytes: '{fullKey}'. " + + $"Consider using a shorter optional key or shorter enum names.", + nameof(key)); + } + } + return fullKey; } } diff --git a/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs b/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs index 25917da..5b16ca7 100644 --- a/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs @@ -102,6 +102,7 @@ public void Remove(TCacheId id, string key) private static string CreateCacheKey(TCacheId container, string? key = null) { var containerName = container.ToString(); + if (!string.IsNullOrWhiteSpace(key)) { containerName = $"{containerName}_{key}"; diff --git a/Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs b/Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs new file mode 100644 index 0000000..ec47b8f --- /dev/null +++ b/Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs @@ -0,0 +1,118 @@ +namespace Neolution.Extensions.Caching.UnitTests +{ + using System; + using Microsoft.Extensions.DependencyInjection; + using Neolution.Extensions.Caching.Abstractions; + using Neolution.Extensions.Caching.UnitTests.Models; + using Shouldly; + using Xunit; + + /// + /// Tests for cache key improvements (explicit string values for in-memory cache) + /// + public class CacheKeyImprovementsTests + { + /// + /// Tests that in-memory cache handles special characters without encoding + /// + [Fact] + public void MemoryCacheHandlesSpecialCharactersWithoutEncoding() + { + // Arrange + using var serviceProvider = CreateServiceCollection().BuildServiceProvider(); + var cache = GetCache(serviceProvider); + var key = "user:123 test@example.com"; + const string value = "test-value"; + + // Act + cache.Set(TestCacheId.Foobar, key, value); + var result = cache.Get(TestCacheId.Foobar, key); + + // Assert + result.ShouldBe(value); + } + + /// + /// Tests that in-memory cache handles unicode characters + /// + [Fact] + public void MemoryCacheHandlesUnicodeCharacters() + { + // Arrange + using var serviceProvider = CreateServiceCollection().BuildServiceProvider(); + var cache = GetCache(serviceProvider); + var key = "用户-123"; // Chinese characters + const string value = "test-value"; + + // Act + cache.Set(TestCacheId.Foobar, key, value); + var result = cache.Get(TestCacheId.Foobar, key); + + // Assert + result.ShouldBe(value); + } + + /// + /// Tests that in-memory cache handles URL-unsafe characters + /// + [Fact] + public void MemoryCacheHandlesUrlUnsafeCharacters() + { + // Arrange + using var serviceProvider = CreateServiceCollection().BuildServiceProvider(); + var cache = GetCache(serviceProvider); + var key = "key/with%special&chars?param=value"; + const string value = "test-value"; + + // Act + cache.Set(TestCacheId.Foobar, key, value); + var result = cache.Get(TestCacheId.Foobar, key); + + // Assert + result.ShouldBe(value); + } + + /// + /// Tests that in-memory cache accepts very long keys (no length restriction) + /// + [Fact] + public void MemoryCacheAcceptsVeryLongKeys() + { + // Arrange + using var serviceProvider = CreateServiceCollection().BuildServiceProvider(); + var cache = GetCache(serviceProvider); + // In-memory cache has no length restriction + var longKey = new string('x', 500); + const string value = "test-value"; + + // Act & Assert - Should not throw + Should.NotThrow(() => + { + cache.Set(TestCacheId.Foobar, longKey, value); + var result = cache.Get(TestCacheId.Foobar, longKey); + result.ShouldBe(value); + }); + } + + /// + /// Gets the cache. + /// + /// The service provider. + /// Get the cache service from the specified service provider. + private static IMemoryCache GetCache(IServiceProvider serviceProvider) + { + return serviceProvider.GetRequiredService>(); + } + + /// + /// Creates the service collection needed for these tests. + /// + /// The service collection. + private static ServiceCollection CreateServiceCollection() + { + var services = new ServiceCollection(); + services.AddInMemoryCache(); + return services; + } + } +} diff --git a/Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs b/Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs new file mode 100644 index 0000000..bebbda4 --- /dev/null +++ b/Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs @@ -0,0 +1,254 @@ +namespace Neolution.Extensions.Caching.UnitTests +{ + using System; + using Microsoft.Extensions.DependencyInjection; + using Neolution.Extensions.Caching.Abstractions; + using Neolution.Extensions.Caching.UnitTests.Models; + using Neolution.Extensions.Caching.UnitTests.TestData; + using Shouldly; + using Xunit; + + /// + /// Tests for cache key improvements in distributed cache (URL encoding, optional length validation, and explicit string values) + /// + public class DistributedCacheKeyImprovementsTests + { + /// + /// Tests that cache keys encode special characters properly + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyEncodesSpecialCharacters(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + var key = "user:123 test@example.com"; + const string value = "test-value"; + + // Act + cache.Set(TestCacheId.Foobar, key, value); + var result = cache.Get(TestCacheId.Foobar, key); + + // Assert + result.ShouldBe(value); + } + + /// + /// Tests that cache keys encode unicode characters properly + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyEncodesUnicodeCharacters(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + var key = "用户-123"; // Chinese characters + const string value = "test-value"; + + // Act + cache.Set(TestCacheId.Foobar, key, value); + var result = cache.Get(TestCacheId.Foobar, key); + + // Assert + result.ShouldBe(value); + } + + /// + /// Tests that cache keys encode URL-unsafe characters properly + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyEncodesUrlUnsafeCharacters(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + var key = "key/with%special&chars?param=value"; + const string value = "test-value"; + + // Act + cache.Set(TestCacheId.Foobar, key, value); + var result = cache.Get(TestCacheId.Foobar, key); + + // Assert + result.ShouldBe(value); + } + + /// + /// Tests that cache key throws exception when too long + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyThrowsExceptionWhenTooLong(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + // Create a very long key that exceeds 250 bytes + var longKey = new string('x', 300); + + // Act & Assert + var exception = Should.Throw(() => + { + cache.Set(TestCacheId.Foobar, longKey, "value"); + }); + + exception.Message.ShouldContain("exceeds maximum length"); + exception.Message.ShouldContain("250 bytes"); + } + + /// + /// Tests that cache key accepts maximum allowed length + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyAcceptsMaximumAllowedLength(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + // Test that we can use keys up to the limit + // Assuming enum name + structure is ~50 bytes, test with ~180 char key + var maxKey = new string('a', 180); + const string value = "test-value"; + + // Act & Assert + Should.NotThrow(() => + { + cache.Set(TestCacheId.Foobar, maxKey, value); + var result = cache.Get(TestCacheId.Foobar, maxKey); + result.ShouldBe(value); + }); + } + + /// + /// Tests that cache key validation accounts for unicode bytes + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyValidationAccountsForUnicodeBytes(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + // Unicode characters can be multiple bytes in UTF-8 + // 100 Chinese characters = ~300 bytes in UTF-8 + var unicodeKey = new string('中', 100); + + // Act & Assert + var exception = Should.Throw(() => + { + cache.Set(TestCacheId.Foobar, unicodeKey, "value"); + }); + + exception.Message.ShouldContain("exceeds maximum length"); + } + + /// + /// Tests that cache key uses explicit attribute value + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyUsesExplicitAttributeValue(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + const string value = "test-value"; + + // Act - Enum is "UserProfile" but attribute says "user-profile" + cache.Set(TestCacheId.UserProfile, value); + + // Assert - Should be retrievable + var result = cache.Get(TestCacheId.UserProfile); + result.ShouldBe(value); + } + + /// + /// Tests that cache key falls back to enum name when no attribute + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyFallsBackToEnumNameWhenNoAttribute(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + const string value = "test-value"; + + // Act - ProductCatalog has no attribute + cache.Set(TestCacheId.ProductCatalog, value); + + // Assert - Should still work + var result = cache.Get(TestCacheId.ProductCatalog); + result.ShouldBe(value); + } + + /// + /// Tests that cache key with attribute is refactor-safe + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyWithAttributeIsRefactorSafe(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + const string value = "test-value"; + + // This test documents the behavior: + // Even if we rename "UserProfile" to "User", + // the cache key remains "user-profile" due to the attribute + + // Act + cache.Set(TestCacheId.UserProfile, value); + + // Assert - The actual cache key should contain "user-profile", not "UserProfile" + var result = cache.Get(TestCacheId.UserProfile); + result.ShouldBe(value); + } + + /// + /// Tests that cache key with attribute and optional key works correctly + /// + /// The service collection. + [Theory] + [ClassData(typeof(ServiceCollectionTestDataCollection))] + public void CacheKeyWithAttributeAndOptionalKeyWorks(IServiceCollection serviceCollection) + { + // Arrange + using var serviceProvider = serviceCollection.BuildServiceProvider(); + var cache = GetCache(serviceProvider); + var optionalKey = "user-123"; + const string value = "test-value"; + + // Act + cache.Set(TestCacheId.UserProfile, optionalKey, value); + + // Assert + var result = cache.Get(TestCacheId.UserProfile, optionalKey); + result.ShouldBe(value); + } + + /// + /// Gets the cache. + /// + /// The service provider. + /// Get the cache service from the specified service provider. + private static IDistributedCache GetCache(IServiceProvider serviceProvider) + { + return serviceProvider.GetRequiredService>(); + } + } +} diff --git a/Neolution.Extensions.Caching.UnitTests/Models/TestCacheId.cs b/Neolution.Extensions.Caching.UnitTests/Models/TestCacheId.cs index 0239afa..ab27b2b 100644 --- a/Neolution.Extensions.Caching.UnitTests/Models/TestCacheId.cs +++ b/Neolution.Extensions.Caching.UnitTests/Models/TestCacheId.cs @@ -1,5 +1,7 @@ namespace Neolution.Extensions.Caching.UnitTests.Models { + using Neolution.Extensions.Caching.Abstractions; + /// /// The cache container /// @@ -8,11 +10,23 @@ public enum TestCacheId /// /// Test container item /// + [CacheKey("foobar")] Foobar = 0, /// /// The cache object with this identifier will never be refreshed. /// NonRefreshedFoobar = 1, + + /// + /// Test container for user profile with explicit cache key + /// + [CacheKey("user-profile")] + UserProfile = 2, + + /// + /// Test container for product catalog without explicit cache key + /// + ProductCatalog = 3, } } From da44c5af1d285d29b24890f2c18e2cce7e89991a Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 27 Jan 2026 16:09:01 +0100 Subject: [PATCH 03/23] Refactor distributed cache options Introduces a base options class for distributed caches to consolidate common settings. Updates `DistributedCache`, `MessagePackDistributedCache`, and `RedisHybridCache` to consume these options via `IOptions`, simplifying their constructors and internal logic. --- .../DistributedCache.cs | 39 ++-- .../DistributedCacheOptionsBase.cs | 48 +++++ ...ion.Extensions.Caching.Abstractions.csproj | 1 + .../MessagePackDistributedCache.cs | 9 +- .../MessagePackDistributedCacheOptions.cs | 43 ++--- .../ServiceCollectionExtensions.cs | 26 ++- .../ServiceCollectionExtensions.cs | 9 +- .../MsgPackSerializer.cs | 24 ++- .../RedisHybridCache.cs | 11 +- .../RedisHybridCacheOptions.cs | 31 +--- .../ServiceCollectionExtensions.cs | 44 +++-- .../Models/TestObject.cs | 18 ++ .../MsgPackSerializerTests.cs | 166 ++++++++++++++++++ .../RedisHybridCacheOptionsTests.cs | 116 ++++++++++++ .../RedisHybridCacheTests.cs | 12 +- 15 files changed, 476 insertions(+), 121 deletions(-) create mode 100644 Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs create mode 100644 Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs create mode 100644 Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs create mode 100644 Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs index 943034f..3220219 100644 --- a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; + using Microsoft.Extensions.Options; /// /// @@ -26,31 +27,49 @@ public abstract class DistributedCache : IDistributedCache /// private static string CacheIdName => typeof(TCacheId).Name; + private readonly bool enableKeyEncoding; + private readonly bool enableKeyLengthValidation; + private readonly int? version; + private readonly string? environmentPrefix; + + /// + /// Initializes a new instance of the class. + /// + /// The options accessor containing cache configuration. + /// Thrown when optionsAccessor is null. + protected DistributedCache(IOptions optionsAccessor) + { + if (optionsAccessor == null) + { + throw new ArgumentNullException(nameof(optionsAccessor)); + } + + var options = optionsAccessor.Value; + this.enableKeyEncoding = options.EnableKeyEncoding; + this.enableKeyLengthValidation = options.EnableKeyLengthValidation; + this.version = options.Version; + this.environmentPrefix = options.EnvironmentPrefix; + } + /// /// Gets a value indicating whether optional cache keys should be URL-encoded. - /// Default is true for safe handling of special characters. - /// Set to false to maintain backwards compatibility with existing cache keys. /// - protected virtual bool EnableKeyEncoding => true; + protected bool EnableKeyEncoding => this.enableKeyEncoding; /// /// Gets a value indicating whether cache key length should be validated. - /// Default is true to ensure performance and compatibility with many cache backends. - /// Set to false to disable length validation if your cache backend supports longer keys. /// - protected virtual bool EnableKeyLengthValidation => true; + protected bool EnableKeyLengthValidation => this.enableKeyLengthValidation; /// /// Gets the cache key version for invalidation purposes. - /// If null, version is not included in the cache key. /// - protected virtual int? Version => null; + protected int? Version => this.version; /// /// Gets the optional environment prefix for cache key isolation. - /// If null or empty, environment prefix is not included in the cache key. /// - protected virtual string? EnvironmentPrefix => null; + protected string? EnvironmentPrefix => this.environmentPrefix; /// public T? Get(TCacheId id) diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs new file mode 100644 index 0000000..2095a0b --- /dev/null +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs @@ -0,0 +1,48 @@ +namespace Neolution.Extensions.Caching.Abstractions +{ + /// + /// Base class for distributed cache configuration options. + /// Provides common configuration properties shared across all distributed cache implementations. + /// + public abstract class DistributedCacheOptionsBase + { + /// + /// Gets or sets the cache key version for invalidation purposes. + /// If null, version is not included in the cache key. + /// Changing this version will invalidate all existing cache entries. + /// The version will be formatted as "v{number}" in the cache key (e.g., v1, v2). + /// Default: null (no version in cache key). + /// + /// + /// options.Version = 2; // Cache key becomes: "MyCacheId:v2:UserProfile" + /// + public int? Version { get; set; } + + /// + /// Gets or sets the optional environment prefix for cache key isolation. + /// If null or empty, environment prefix is not included in the cache key. + /// Useful for multi-environment scenarios sharing the same cache backend. + /// Default: null (no environment prefix in cache key). + /// + /// + /// options.EnvironmentPrefix = "staging"; // Cache key becomes: "staging:MyCacheId:UserProfile" + /// + public string? EnvironmentPrefix { get; set; } + + /// + /// Gets or sets a value indicating whether optional cache keys should be URL-encoded. + /// URL encoding ensures safe handling of special characters in distributed cache backends. + /// Set to false to maintain backwards compatibility with existing cache keys. + /// Default: true. + /// + public bool EnableKeyEncoding { get; set; } = true; + + /// + /// Gets or sets a value indicating whether cache key length should be validated. + /// Default is true to ensure performance and compatibility with many cache backends. + /// Set to false to disable length validation if your cache backend supports longer keys. + /// Default: true. + /// + public bool EnableKeyLengthValidation { get; set; } = true; + } +} diff --git a/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj b/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj index fb98d7f..1778a2f 100644 --- a/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj +++ b/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj @@ -7,6 +7,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs index 7c58764..8f0c741 100644 --- a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs +++ b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCache.cs @@ -33,6 +33,7 @@ public class MessagePackDistributedCache : DistributedCache /// The cache. /// The options accessor. public MessagePackDistributedCache(IDistributedCache cache, IOptions optionsAccessor) + : base(optionsAccessor) { this.cache = cache ?? throw new ArgumentNullException(nameof(cache)); @@ -42,8 +43,6 @@ public MessagePackDistributedCache(IDistributedCache cache, IOptions - protected override int? Version { get; } - - /// - protected override string? EnvironmentPrefix { get; } - /// protected override T? GetCacheObject(string key) where T : class diff --git a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs index 02ff009..c6f9038 100644 --- a/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs +++ b/Neolution.Extensions.Caching.Distributed/MessagePackDistributedCacheOptions.cs @@ -1,48 +1,25 @@ namespace Neolution.Extensions.Caching.Distributed { - using Microsoft.Extensions.Options; + using Neolution.Extensions.Caching.Abstractions; /// - /// The options for the MessagePack distributed cache implementation. + /// Configuration options for MessagePack distributed cache. /// - public class MessagePackDistributedCacheOptions : IOptions + public class MessagePackDistributedCacheOptions : DistributedCacheOptionsBase { /// - /// Gets or sets a value indicating whether to disable compression, to not waste CPU resources if working with an in-memory cache backend. + /// Gets or sets a value indicating whether to disable compression. + /// Set to true to save CPU when using in-memory cache backends. + /// Default: false (compression enabled with LZ4). /// public bool DisableCompression { get; set; } /// - /// Gets or sets a value indicating whether to require annotation for serializable types. - /// Doing so would result in better overall serialization performance and smaller files. + /// Gets or sets a value indicating whether to require MessagePackObject attribute. + /// Setting this to true improves serialization performance but requires decorating + /// your classes with [MessagePackObject] attribute. + /// Default: false (uses contractless serialization). /// - /// - /// true to require annotation; otherwise, false. - /// public bool RequireMessagePackObjectAnnotation { get; set; } - - /// - /// Gets or sets the cache key version to use for cache invalidation. - /// If not set, version is not included in the cache key. - /// Changing this version will invalidate all existing cache keys with that version. - /// The version will be formatted as "v{number}" in the cache key (e.g., v1, v2). - /// - /// - /// The cache key version. Defaults to null (no version in cache key). - /// - public int? Version { get; set; } - - /// - /// Gets or sets the optional environment prefix for cache key isolation. - /// If not set, environment prefix is not included in the cache key. - /// Useful for multi-environment scenarios (e.g., "dev", "staging", "prod"). - /// - /// - /// The environment prefix. Defaults to null (no environment prefix in cache key). - /// - public string? EnvironmentPrefix { get; set; } - - /// - public MessagePackDistributedCacheOptions Value => this; } } diff --git a/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs b/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs index 1ff916d..0a70dec 100644 --- a/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs +++ b/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs @@ -11,20 +11,28 @@ namespace Microsoft.Extensions.DependencyInjection public static class ServiceCollectionExtensions { /// - /// Adds the distributed caching implementation that uses MessagePack to serialize and deserialize the values to cache. + /// Adds the distributed caching implementation that uses MessagePack for serialization. + /// Requires an + /// provider to be registered (e.g., Redis, SQL Server, Memory). /// - /// The services. - public static void AddMessagePackDistributedCache(this IServiceCollection services) + /// The service collection. + /// The service collection for fluent chaining. + public static IServiceCollection AddMessagePackDistributedCache(this IServiceCollection services) { - services.AddMessagePackDistributedCache(_ => { }); + return services.AddMessagePackDistributedCache(_ => { }); } /// - /// Adds the distributed caching implementation that uses MessagePack to serialize and deserialize the values to cache. Allows to configure options. + /// Adds the distributed caching implementation that uses MessagePack for serialization, + /// with custom configuration options. + /// Requires an + /// provider to be registered (e.g., Redis, SQL Server, Memory). /// - /// The services. - /// The setup action. - public static void AddMessagePackDistributedCache(this IServiceCollection services, Action configureOptions) + /// The service collection. + /// The action to configure cache options. + /// The service collection for fluent chaining. + /// Thrown when configureOptions is null. + public static IServiceCollection AddMessagePackDistributedCache(this IServiceCollection services, Action configureOptions) { if (configureOptions == null) { @@ -34,6 +42,8 @@ public static void AddMessagePackDistributedCache(this IServiceCollection servic services.AddOptions(); services.Configure(configureOptions); services.AddSingleton(typeof(IDistributedCache<>), typeof(MessagePackDistributedCache<>)); + + return services; } } } diff --git a/Neolution.Extensions.Caching.InMemory/ServiceCollectionExtensions.cs b/Neolution.Extensions.Caching.InMemory/ServiceCollectionExtensions.cs index cacca0c..f13ecf0 100644 --- a/Neolution.Extensions.Caching.InMemory/ServiceCollectionExtensions.cs +++ b/Neolution.Extensions.Caching.InMemory/ServiceCollectionExtensions.cs @@ -10,13 +10,16 @@ namespace Microsoft.Extensions.DependencyInjection public static class ServiceCollectionExtensions { /// - /// Adds the Neolution default non-distributed memory caching implementation. + /// Adds the in-memory caching implementation. + /// Uses Microsoft's internally. /// - /// The services. - public static void AddInMemoryCache(this IServiceCollection services) + /// The service collection. + /// The service collection for fluent chaining. + public static IServiceCollection AddInMemoryCache(this IServiceCollection services) { services.AddMemoryCache(); services.AddSingleton(typeof(IMemoryCache<>), typeof(InMemoryCache<>)); + return services; } } } diff --git a/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs b/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs index bff7b81..4c6e157 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs @@ -12,10 +12,28 @@ public class MsgPackSerializer : ISerializer { /// - /// The options + /// The MessagePack serializer options. /// - private readonly MessagePackSerializerOptions options = MessagePack.Resolvers.ContractlessStandardResolver.Options - .WithCompression(MessagePackCompression.Lz4BlockArray); + private readonly MessagePackSerializerOptions options; + + /// + /// Initializes a new instance of the class. + /// Compression is disabled by default to save CPU for in-memory scenarios. + /// + public MsgPackSerializer() + : this(false) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// If set to true, enables LZ4 compression. + public MsgPackSerializer(bool enableCompression) + { + this.options = MessagePack.Resolvers.ContractlessStandardResolver.Options + .WithCompression(enableCompression ? MessagePackCompression.Lz4BlockArray : MessagePackCompression.None); + } /// /// Deserializes the specified data. diff --git a/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs index c9513fd..660620b 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs @@ -28,6 +28,7 @@ public class RedisHybridCache : DistributedCache /// The cache client. /// The options accessor. public RedisHybridCache(ICacheClient cacheClient, IOptions optionsAccessor) + : base(optionsAccessor) { this.cacheClient = cacheClient ?? throw new ArgumentNullException(nameof(cacheClient)); @@ -35,18 +36,8 @@ public RedisHybridCache(ICacheClient cacheClient, IOptions - protected override int? Version { get; } - - /// - protected override string? EnvironmentPrefix { get; } - /// protected override T? GetCacheObject(string key) where T : class diff --git a/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs index 673a974..4488fa3 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCacheOptions.cs @@ -1,34 +1,17 @@ namespace Neolution.Extensions.Caching.RedisHybrid { - using Microsoft.Extensions.Options; + using Neolution.Extensions.Caching.Abstractions; /// - /// The options for the Redis hybrid cache implementation. + /// Configuration options for Redis hybrid cache (L1 + L2 caching). /// - public class RedisHybridCacheOptions : IOptions + public class RedisHybridCacheOptions : DistributedCacheOptionsBase { /// - /// Gets or sets the cache key version to use for cache invalidation. - /// If not set, version is not included in the cache key. - /// Changing this version will invalidate all existing cache keys with that version. - /// The version will be formatted as "v{number}" in the cache key (e.g., v1, v2). + /// Gets or sets a value indicating whether to enable compression for serialization. + /// Set to true to enable compression (LZ4) when bandwidth is a concern. + /// Default: false (compression disabled to save CPU cycles). /// - /// - /// The cache key version. Defaults to null (no version in cache key). - /// - public int? Version { get; set; } - - /// - /// Gets or sets the optional environment prefix for cache key isolation. - /// If not set, environment prefix is not included in the cache key. - /// Useful for multi-environment scenarios (e.g., "dev", "staging", "prod"). - /// - /// - /// The environment prefix. Defaults to null (no environment prefix in cache key). - /// - public string? EnvironmentPrefix { get; set; } - - /// - public RedisHybridCacheOptions Value => this; + public bool EnableCompression { get; set; } } } diff --git a/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs b/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs index c8ba9d7..3aff57e 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs @@ -14,22 +14,26 @@ namespace Microsoft.Extensions.DependencyInjection public static class ServiceCollectionExtensions { /// - /// Adds the Neolution default non-distributed memory caching implementation. + /// Adds the Redis hybrid cache implementation (L1 + L2 caching with message broker synchronization). /// - /// The services. - /// The redis connection string. - public static void AddRedisHybridCache(this IServiceCollection services, string redisConnectionString) + /// The service collection. + /// The Redis connection string. + /// The service collection for fluent chaining. + public static IServiceCollection AddRedisHybridCache(this IServiceCollection services, string redisConnectionString) { - services.AddRedisHybridCache(redisConnectionString, _ => { }); + return services.AddRedisHybridCache(redisConnectionString, _ => { }); } /// - /// Adds the Neolution default non-distributed memory caching implementation with configuration options. + /// Adds the Redis hybrid cache implementation (L1 + L2 caching with message broker synchronization), + /// with custom configuration options. /// - /// The services. - /// The redis connection string. - /// The setup action. - public static void AddRedisHybridCache(this IServiceCollection services, string redisConnectionString, Action configureOptions) + /// The service collection. + /// The Redis connection string. + /// The action to configure cache options. + /// The service collection for fluent chaining. + /// Thrown when configureOptions is null. + public static IServiceCollection AddRedisHybridCache(this IServiceCollection services, string redisConnectionString, Action configureOptions) { if (configureOptions == null) { @@ -37,15 +41,25 @@ public static void AddRedisHybridCache(this IServiceCollection services, string } services.AddSingleton(sp => ConnectionMultiplexer.Connect(redisConnectionString)); - services.AddSingleton(sp => new RedisHybridCacheClient(new RedisHybridCacheClientOptions - { - ConnectionMultiplexer = sp.GetService(), - LoggerFactory = sp.GetService(), - })); services.AddOptions(); services.Configure(configureOptions); + + services.AddSingleton(sp => + { + var options = sp.GetService>(); + var enableCompression = options?.Value?.EnableCompression ?? false; + + return new RedisHybridCacheClient(new RedisHybridCacheClientOptions + { + ConnectionMultiplexer = sp.GetService(), + LoggerFactory = sp.GetService(), + Serializer = new MsgPackSerializer(enableCompression), + }); + }); services.AddSingleton(typeof(IDistributedCache<>), typeof(RedisHybridCache<>)); + + return services; } } } diff --git a/Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs b/Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs new file mode 100644 index 0000000..7d67efc --- /dev/null +++ b/Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs @@ -0,0 +1,18 @@ +namespace Neolution.Extensions.Caching.UnitTests.Models +{ + /// + /// A simple test object for unit testing. + /// + public class TestObject + { + /// + /// Gets or sets the name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the value. + /// + public int Value { get; set; } + } +} diff --git a/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs b/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs new file mode 100644 index 0000000..1e4db9a --- /dev/null +++ b/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs @@ -0,0 +1,166 @@ +namespace Neolution.Extensions.Caching.UnitTests +{ + using System; + using System.IO; + using MessagePack; + using Neolution.Extensions.Caching.RedisHybrid; + using Neolution.Extensions.Caching.UnitTests.Models; + using Shouldly; + using Xunit; + + /// + /// Tests for the MsgPackSerializer compression configuration. + /// + public class MsgPackSerializerTests + { + /// + /// Tests that the parameterless constructor creates a serializer with compression disabled. + /// + [Fact] + public void ParameterlessConstructor_DisablesCompression_ByDefault() + { + // Arrange & Act + var serializer = new MsgPackSerializer(); + var testObject = new TestObject { Name = "Test", Value = 42 }; + + // Assert - serialize and verify it's not compressed (larger output) + using var stream = new MemoryStream(); + serializer.Serialize(testObject, stream); + var uncompressedSize = stream.Length; + + // Compressed data would be smaller for typical objects + // For this test, we just verify it works and produces data + uncompressedSize.ShouldBeGreaterThan(0); + } + + /// + /// Tests that explicitly disabling compression works correctly. + /// + [Fact] + public void Constructor_WithCompressionDisabled_ProducesLargerOutput() + { + // Arrange + var serializer = new MsgPackSerializer(enableCompression: false); + var largeObject = CreateLargeTestObject(); + + // Act + using var stream = new MemoryStream(); + serializer.Serialize(largeObject, stream); + + // Assert + stream.Length.ShouldBeGreaterThan(0); + } + + /// + /// Tests that enabling compression reduces the output size. + /// + [Fact] + public void Constructor_WithCompressionEnabled_ProducesSmallerOutput() + { + // Arrange + var uncompressedSerializer = new MsgPackSerializer(enableCompression: false); + var compressedSerializer = new MsgPackSerializer(enableCompression: true); + var largeObject = CreateLargeTestObject(); + + // Act + using var uncompressedStream = new MemoryStream(); + using var compressedStream = new MemoryStream(); + + uncompressedSerializer.Serialize(largeObject, uncompressedStream); + compressedSerializer.Serialize(largeObject, compressedStream); + + // Assert + compressedStream.Length.ShouldBeLessThan(uncompressedStream.Length); + } + + /// + /// Tests that serialized data can be deserialized correctly without compression. + /// + [Fact] + public void SerializeDeserialize_WithoutCompression_PreservesData() + { + // Arrange + var serializer = new MsgPackSerializer(enableCompression: false); + var original = new TestObject { Name = "Original", Value = 123 }; + + // Act + using var stream = new MemoryStream(); + serializer.Serialize(original, stream); + stream.Position = 0; + var deserialized = (TestObject)serializer.Deserialize(stream, typeof(TestObject)); + + // Assert + deserialized.Name.ShouldBe(original.Name); + deserialized.Value.ShouldBe(original.Value); + } + + /// + /// Tests that serialized data can be deserialized correctly with compression. + /// + [Fact] + public void SerializeDeserialize_WithCompression_PreservesData() + { + // Arrange + var serializer = new MsgPackSerializer(enableCompression: true); + var original = new TestObject { Name = "Compressed", Value = 456 }; + + // Act + using var stream = new MemoryStream(); + serializer.Serialize(original, stream); + stream.Position = 0; + var deserialized = (TestObject)serializer.Deserialize(stream, typeof(TestObject)); + + // Assert + deserialized.Name.ShouldBe(original.Name); + deserialized.Value.ShouldBe(original.Value); + } + + /// + /// Tests that null values are handled correctly. + /// + [Fact] + public void Serialize_NullValue_ThrowsArgumentNullException() + { + // Arrange + var serializer = new MsgPackSerializer(); + + // Act & Assert + Should.Throw(() => + { + using var stream = new MemoryStream(); + serializer.Serialize(null!, stream); + }); + } + + /// + /// Tests deserialization throws when result is null. + /// + [Fact] + public void Deserialize_InvalidData_ThrowsInvalidOperationException() + { + // Arrange + var serializer = new MsgPackSerializer(); + + // Act & Assert + Should.Throw(() => + { + using var stream = new MemoryStream(new byte[] { 0xC0 }); // MessagePack nil + serializer.Deserialize(stream, typeof(TestObject)); + }); + } + + /// + /// Creates a large test object with repetitive data that compresses well. + /// + /// A test object with lots of repetitive data. + private static TestObject CreateLargeTestObject() + { + var largeString = new string('A', 10000); // 10KB of 'A' characters - compresses well + return new TestObject + { + Name = largeString, + Value = 999 + }; + } + } +} diff --git a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs new file mode 100644 index 0000000..d28a961 --- /dev/null +++ b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs @@ -0,0 +1,116 @@ +namespace Neolution.Extensions.Caching.UnitTests +{ + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Options; + using Neolution.Extensions.Caching.RedisHybrid; + using Shouldly; + using Xunit; + + /// + /// Tests for the RedisHybridCacheOptions configuration. + /// + public class RedisHybridCacheOptionsTests + { + /// + /// Tests that EnableCompression defaults to false. + /// + [Fact] + public void EnableCompression_DefaultsToFalse() + { + // Arrange & Act + var options = new RedisHybridCacheOptions(); + + // Assert + options.EnableCompression.ShouldBeFalse(); + } + + /// + /// Tests that EnableCompression can be set to true. + /// + [Fact] + public void EnableCompression_CanBeSetToTrue() + { + // Arrange + var options = new RedisHybridCacheOptions + { + EnableCompression = true + }; + + // Act & Assert + options.EnableCompression.ShouldBeTrue(); + } + + /// + /// Tests that options can be configured via service collection. + /// + [Fact] + public void ServiceCollection_ConfiguresOptions_Correctly() + { + // Arrange + var services = new ServiceCollection(); + services.AddRedisHybridCache("localhost:6379", options => + { + options.EnableCompression = true; + options.Version = 2; + options.EnvironmentPrefix = "test"; + options.EnableKeyEncoding = false; + options.EnableKeyLengthValidation = false; + }); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + // Assert + options.Value.EnableCompression.ShouldBeTrue(); + options.Value.Version.ShouldBe(2); + options.Value.EnvironmentPrefix.ShouldBe("test"); + options.Value.EnableKeyEncoding.ShouldBeFalse(); + options.Value.EnableKeyLengthValidation.ShouldBeFalse(); + } + + /// + /// Tests that default service registration creates options with default values. + /// + [Fact] + public void ServiceCollection_DefaultConfiguration_UsesDefaultValues() + { + // Arrange + var services = new ServiceCollection(); + services.AddRedisHybridCache("localhost:6379"); + + // Act + using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + // Assert + options.Value.EnableCompression.ShouldBeFalse(); // Default is false + options.Value.Version.ShouldBeNull(); + options.Value.EnvironmentPrefix.ShouldBeNull(); + options.Value.EnableKeyEncoding.ShouldBeTrue(); // Default is true + options.Value.EnableKeyLengthValidation.ShouldBeTrue(); // Default is true + } + + /// + /// Tests that inherits properties from DistributedCacheOptionsBase. + /// + [Fact] + public void RedisHybridCacheOptions_InheritsFromBase() + { + // Arrange & Act + var options = new RedisHybridCacheOptions + { + Version = 5, + EnvironmentPrefix = "prod", + EnableKeyEncoding = false, + EnableKeyLengthValidation = false + }; + + // Assert + options.Version.ShouldBe(5); + options.EnvironmentPrefix.ShouldBe("prod"); + options.EnableKeyEncoding.ShouldBeFalse(); + options.EnableKeyLengthValidation.ShouldBeFalse(); + } + } +} diff --git a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs index 5b75b8b..4d6ed6d 100644 --- a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs @@ -125,14 +125,12 @@ private IServiceCollection CreateServiceCollection() var services = new ServiceCollection(); this.Log.MinimumLevel = LogLevel.Trace; services.AddSingleton(this.Log); - services.AddSingleton(sp => ConnectionMultiplexer.Connect("localhost")); - services.AddSingleton(sp => new RedisHybridCacheClient(new RedisHybridCacheClientOptions + services.AddRedisHybridCache("localhost", options => { - ConnectionMultiplexer = sp.GetService(), - LoggerFactory = sp.GetService(), - })); - - services.AddSingleton(typeof(IDistributedCache<>), typeof(RedisHybridCache<>)); + // Example: Enable compression if Redis bandwidth is a concern + options.EnableCompression = false; // Default is false for CPU optimization + options.Version = 1; + }); return services; } From a4ca780e3c368540e4fe1099bea57d1153f47441 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 27 Jan 2026 19:49:33 +0100 Subject: [PATCH 04/23] Rename MessagePack cache to Serialized cache Renames `AddMessagePackDistributedCache` to `AddSerializedDistributedCache` method. Introduces an `Obsolete` attribute to the old method, guiding users toward the new, more generic name. --- .../ServiceCollectionExtensions.cs | 56 +++++++++++++++++-- .../CacheKeyVersioningTests.cs | 18 +++--- .../ServiceCollectionTestDataCollection.cs | 6 +- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs b/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs index 0a70dec..9def3c2 100644 --- a/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs +++ b/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs @@ -2,6 +2,7 @@ namespace Microsoft.Extensions.DependencyInjection { using System; + using System.Linq; using Neolution.Extensions.Caching.Abstractions; using Neolution.Extensions.Caching.Distributed; @@ -11,20 +12,21 @@ namespace Microsoft.Extensions.DependencyInjection public static class ServiceCollectionExtensions { /// - /// Adds the distributed caching implementation that uses MessagePack for serialization. + /// Adds the serialized distributed caching implementation with MessagePack serialization. /// Requires an /// provider to be registered (e.g., Redis, SQL Server, Memory). /// /// The service collection. /// The service collection for fluent chaining. - public static IServiceCollection AddMessagePackDistributedCache(this IServiceCollection services) + /// Thrown when no IDistributedCache provider is registered. + public static IServiceCollection AddSerializedDistributedCache(this IServiceCollection services) { - return services.AddMessagePackDistributedCache(_ => { }); + return services.AddSerializedDistributedCache(_ => { }); } /// - /// Adds the distributed caching implementation that uses MessagePack for serialization, - /// with custom configuration options. + /// Adds the serialized distributed caching implementation with MessagePack serialization + /// and custom configuration options. /// Requires an /// provider to be registered (e.g., Redis, SQL Server, Memory). /// @@ -32,18 +34,60 @@ public static IServiceCollection AddMessagePackDistributedCache(this IServiceCol /// The action to configure cache options. /// The service collection for fluent chaining. /// Thrown when configureOptions is null. - public static IServiceCollection AddMessagePackDistributedCache(this IServiceCollection services, Action configureOptions) + /// Thrown when no IDistributedCache provider is registered. + public static IServiceCollection AddSerializedDistributedCache(this IServiceCollection services, Action configureOptions) { if (configureOptions == null) { throw new ArgumentNullException(nameof(configureOptions)); } + // Validate that an IDistributedCache provider has been registered + if (!services.Any(x => x.ServiceType == typeof(Microsoft.Extensions.Caching.Distributed.IDistributedCache))) + { + throw new InvalidOperationException( + """ + An IDistributedCache provider must be registered before calling AddSerializedDistributedCache(). + Register a provider such as Redis (AddStackExchangeRedisCache), SQL Server (AddDistributedSqlServerCache), + or Memory (AddDistributedMemoryCache) first. + """ + ); + } + services.AddOptions(); services.Configure(configureOptions); services.AddSingleton(typeof(IDistributedCache<>), typeof(MessagePackDistributedCache<>)); return services; } + + /// + /// Adds the distributed caching implementation that uses MessagePack for serialization. + /// Requires an + /// provider to be registered (e.g., Redis, SQL Server, Memory). + /// + /// The service collection. + /// The service collection for fluent chaining. + [Obsolete("Use AddSerializedDistributedCache() instead. This method will be removed in a future version.")] + public static IServiceCollection AddMessagePackDistributedCache(this IServiceCollection services) + { + return services.AddSerializedDistributedCache(); + } + + /// + /// Adds the distributed caching implementation that uses MessagePack for serialization, + /// with custom configuration options. + /// Requires an + /// provider to be registered (e.g., Redis, SQL Server, Memory). + /// + /// The service collection. + /// The action to configure cache options. + /// The service collection for fluent chaining. + /// Thrown when configureOptions is null. + [Obsolete("Use AddSerializedDistributedCache() instead. This method will be removed in a future version.")] + public static IServiceCollection AddMessagePackDistributedCache(this IServiceCollection services, Action configureOptions) + { + return services.AddSerializedDistributedCache(configureOptions); + } } } diff --git a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs index 63493f6..20f9fc5 100644 --- a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs @@ -25,7 +25,7 @@ public void MessagePackCacheKeyWithoutVersionByDefault() // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddMessagePackDistributedCache(); // No options - should not include version + services.AddSerializedDistributedCache(); // No options - should not include version using var serviceProvider = services.BuildServiceProvider(); var cache = serviceProvider.GetRequiredService>(); @@ -47,7 +47,7 @@ public void MessagePackCacheKeyIncludesDefaultVersion() // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddMessagePackDistributedCache(); + services.AddSerializedDistributedCache(); using var serviceProvider = services.BuildServiceProvider(); var cache = serviceProvider.GetRequiredService>(); @@ -69,7 +69,7 @@ public void MessagePackCacheKeyIncludesCustomVersion() // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddMessagePackDistributedCache(options => + services.AddSerializedDistributedCache(options => { options.Version = 2; }); @@ -94,11 +94,11 @@ public void DifferentVersionsProduceDifferentKeys() // Arrange - Create two service providers with different versions var servicesV1 = new ServiceCollection(); servicesV1.AddDistributedMemoryCache(); - servicesV1.AddMessagePackDistributedCache(options => options.Version = 1); + servicesV1.AddSerializedDistributedCache(options => options.Version = 1); var servicesV2 = new ServiceCollection(); servicesV2.AddDistributedMemoryCache(); - servicesV2.AddMessagePackDistributedCache(options => options.Version = 2); + servicesV2.AddSerializedDistributedCache(options => options.Version = 2); using var providerV1 = servicesV1.BuildServiceProvider(); using var providerV2 = servicesV2.BuildServiceProvider(); @@ -128,7 +128,7 @@ public void MessagePackCacheKeyIncludesEnvironmentPrefix() // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddMessagePackDistributedCache(options => + services.AddSerializedDistributedCache(options => { options.EnvironmentPrefix = "dev"; }); @@ -153,7 +153,7 @@ public void MessagePackCacheKeyIncludesBothVersionAndEnvironment() // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddMessagePackDistributedCache(options => + services.AddSerializedDistributedCache(options => { options.Version = 2; options.EnvironmentPrefix = "prod"; @@ -179,7 +179,7 @@ public void MessagePackCacheKeyWithOptionalKeyAndVersion() // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddMessagePackDistributedCache(options => + services.AddSerializedDistributedCache(options => { options.Version = 2; options.EnvironmentPrefix = "staging"; @@ -212,7 +212,7 @@ public void NullOrEmptyEnvironmentPrefixIsHandledCorrectly(string? environmentPr // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddMessagePackDistributedCache(options => + services.AddSerializedDistributedCache(options => { options.EnvironmentPrefix = environmentPrefix; }); diff --git a/Neolution.Extensions.Caching.UnitTests/TestData/ServiceCollectionTestDataCollection.cs b/Neolution.Extensions.Caching.UnitTests/TestData/ServiceCollectionTestDataCollection.cs index b44def8..995b724 100644 --- a/Neolution.Extensions.Caching.UnitTests/TestData/ServiceCollectionTestDataCollection.cs +++ b/Neolution.Extensions.Caching.UnitTests/TestData/ServiceCollectionTestDataCollection.cs @@ -5,7 +5,7 @@ using Microsoft.Extensions.DependencyInjection; /// - /// Test data to test different MessagePack cache configurations. + /// Test data to test different serialized cache configurations. /// public class ServiceCollectionTestDataCollection : IEnumerable { @@ -19,11 +19,11 @@ public IEnumerator GetEnumerator() switch (i) { case 0: - services.AddDistributedMemoryCache().AddMessagePackDistributedCache(); + services.AddDistributedMemoryCache().AddSerializedDistributedCache(); yield return new object[] { services }; break; case 1: - services.AddDistributedMemoryCache().AddMessagePackDistributedCache(options => { options.DisableCompression = true; }); + services.AddDistributedMemoryCache().AddSerializedDistributedCache(options => { options.DisableCompression = true; }); yield return new object[] { services }; break; } From 85372f704498a3e6c83aeb5222ed114fe73ac82e Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 27 Jan 2026 21:54:47 +0100 Subject: [PATCH 05/23] Revise README with documentation updates --- README.md | 457 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 445 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index e37e4b1..308f068 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,453 @@ # Introduction TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. -# Getting Started -TODO: Guide users through getting your code up and running on their own system. In this section you can talk about: -1. Installation process -2. Software dependencies -3. Latest releases -4. API references +A type-safe caching abstraction library for .NET that uses enum-based cache identifiers instead of magic strings. # Build and Test TODO: Describe and show how to build your code and run the tests. -# Contribute -TODO: Explain how other users and developers can contribute to make your code better. +- **Strongly-Typed Cache Keys**: Use enums instead of magic strings for cache identifiers, providing compile-time safety and IntelliSense support +- **Multiple Implementations**: + - **In-Memory Caching**: Standalone implementation for single-instance scenarios (uses `Microsoft.Extensions.Caching.Memory`) + - **Serialized Distributed Cache**: Wrapper that adds strongly-typed object serialization to any `IDistributedCache` provider (Redis, SQL Server, Memory, etc.) + - **Redis Hybrid Cache**: Standalone L1+L2 cache with automatic synchronization via pub/sub (uses `Foundatio.Caching.RedisHybridCacheClient`) +- **Consistent API**: Unified interface across all caching strategies +- **Async Support**: Full async/await support for distributed cache operations +- **Flexible Configuration**: Configurable expiration policies and serialization options +- **.NET Standard 2.0**: Compatible with .NET Core, .NET 5+, and .NET Framework -If you want to learn more about creating good readme files then refer the following [guidelines](https://docs.microsoft.com/en-us/azure/devops/repos/git/create-a-readme?view=azure-devops). You can also seek inspiration from the below readme files: -- [ASP.NET Core](https://github.com/aspnet/Home) -- [Visual Studio Code](https://github.com/Microsoft/vscode) -- [Chakra Core](https://github.com/Microsoft/ChakraCore) \ No newline at end of file +## Installation + +### NuGet Packages + +```bash +# For in-memory caching +dotnet add package Neolution.Extensions.Caching.InMemory + +# For serialized distributed caching (requires existing IDistributedCache provider) +dotnet add package Neolution.Extensions.Caching.Distributed + +# For Redis hybrid caching +dotnet add package Neolution.Extensions.Caching.RedisHybrid +``` + +## Getting Started + +### 1. Define Your Cache Identifiers + +Create an enum to represent your cache keys: + +```csharp +public enum MyCacheId +{ + UserProfile = 0, + ProductCatalog = 1, + SessionData = 2 +} +``` + +### 2. Register the Cache Service + +Choose the implementation that fits your needs: + +#### In-Memory Cache (Single Instance) + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + services.AddInMemoryCache(); +} +``` + +#### Serialized Distributed Cache (Wrapper) + +> **Note:** This is a **wrapper** that adds strongly-typed object serialization to any existing `IDistributedCache` provider. +> You must register a provider (Redis, SQL Server, Memory, etc.) **first**. + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + // First, register ANY IDistributedCache provider (e.g., Redis, SQL Server, Memory) + services.AddStackExchangeRedisCache(options => + { + options.Configuration = "localhost:6379"; + }); + // OR: services.AddDistributedSqlServerCache(...); + // OR: services.AddDistributedMemoryCache(); + + // Then add the serialized cache wrapper + services.AddSerializedDistributedCache(); +} +``` + +#### Redis Hybrid Cache + +```csharp +public void ConfigureServices(IServiceCollection services) +{ + // Basic configuration + services.AddRedisHybridCache("localhost:6379"); + + // OR with options + services.AddRedisHybridCache("localhost:6379", options => + { + options.EnableCompression = false; // Disable compression for in-memory optimization (default: false) + options.Version = 1; // Optional: Cache key version (default: null) + options.EnvironmentPrefix = "prod"; // Optional: Environment prefix (default: null) + options.EnableKeyEncoding = true; // URL-encode optional keys (default: true) + options.EnableKeyLengthValidation = true; // Validate key length (default: true) + }); +} +``` + +### 3. Use the Cache + +Inject the appropriate cache interface into your services: + +```csharp +public class UserService +{ + private readonly IMemoryCache _cache; + // OR: private readonly IDistributedCache _cache; + + public UserService(IMemoryCache cache) + { + _cache = cache; + } + + public UserProfile GetUserProfile(int userId) + { + // Try to get from cache + var cached = _cache.Get(MyCacheId.UserProfile, userId.ToString()); + if (cached != null) + return cached; + + // Not in cache, load from database + var profile = LoadFromDatabase(userId); + + // Store in cache with default expiration + _cache.Set(MyCacheId.UserProfile, userId.ToString(), profile); + + return profile; + } +} +``` + +## Usage Examples + +### Common Operations (All Implementations) + +All cache implementations support these basic operations: + +#### Basic Operations + +```csharp +// Store a value +_cache.Set(MyCacheId.UserProfile, user); + +// Store with composite key +_cache.Set(MyCacheId.UserProfile, userId.ToString(), user); + +// Retrieve a value +var user = _cache.Get(MyCacheId.UserProfile); +var user = _cache.Get(MyCacheId.UserProfile, userId.ToString()); + +// Remove a value +_cache.Remove(MyCacheId.UserProfile); +_cache.Remove(MyCacheId.UserProfile, userId.ToString()); +``` + +#### Cache with Expiration Options + +```csharp +var options = new CacheEntryOptions +{ + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30), + SlidingExpiration = TimeSpan.FromMinutes(5) +}; + +_cache.SetWithOptions(MyCacheId.ProductCatalog, product, options); +_cache.SetWithOptions(MyCacheId.ProductCatalog, productId.ToString(), product, options); +``` + +### Distributed Cache Features (Serialized Distributed & Redis Hybrid) + +The following features are available in **Serialized Distributed Cache** and **Redis Hybrid Cache** only, as they involve external storage systems. + +#### Async Operations + +```csharp +// Async operations +await _cache.SetAsync(MyCacheId.SessionData, sessionData); +var data = await _cache.GetAsync(MyCacheId.SessionData); +await _cache.RemoveAsync(MyCacheId.SessionData); + +// With options +var options = new CacheEntryOptions +{ + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) +}; +await _cache.SetWithOptionsAsync(MyCacheId.SessionData, sessionData, options); +``` + +#### Optional Key Encoding + +Optional keys are automatically URL-encoded by default using `Uri.EscapeDataString()` to safely handle special characters when storing keys in external systems: + +- Spaces, colons, and special characters: `user:123 test` → `user%3A123%20test` +- Unicode characters are preserved: `用户-123` → `%E7%94%A8%E6%88%B7-123` + +This ensures cache keys work reliably across all distributed cache backends (Redis, Memcached, SQL Server, etc.). + +**In-Memory Cache does not encode keys** since keys remain as strings in memory and never interact with external systems. + +**Configuration:** To disable URL encoding for cache keys, override the `EnableKeyEncoding` property: + +```csharp +public class MyDistributedCache : MessagePackDistributedCache +{ + protected override bool EnableKeyEncoding => false; // Disable URL encoding +} +``` + +#### Key Length Limits + +Generated cache keys are validated by default to not exceed **250 bytes** (UTF-8 encoded) to ensure compatibility with many popular cache backends (Memcached, Redis, SQL Server). + +**In-Memory Cache has no length restrictions.** + +**Configuration:** To disable length validation, override the `EnableKeyLengthValidation` property: + +```csharp +public class MyDistributedCache : MessagePackDistributedCache +{ + protected override bool EnableKeyLengthValidation => false; // Disable length validation +} +``` + +#### Cache Key Versioning and Environment Isolation + +**Version Management:** +The `Version` property (integer) allows you to invalidate all cached data when you need to force a cache refresh (e.g., after schema changes or bug fixes). + +```csharp +services.AddSerializedDistributedCache(options => +{ + options.Version = 2; +}); +``` + +**Environment Isolation:** +The `EnvironmentPrefix` property enables cache isolation across different environments sharing the same cache backend. + +```csharp +services.AddSerializedDistributedCache(options => +{ + options.EnvironmentPrefix = "staging"; // All cache keys will be prefixed with staging: +}); +``` + +By default, neither `Version` nor `EnvironmentPrefix` are set. + +## Best Practices + +### For All Implementations + +Keep optional keys short and meaningful to improve readability and maintainability: + +```csharp +// Good - use IDs or short identifiers +_cache.Set(MyCacheId.UserProfile, userId.ToString(), userData); +_cache.Set(MyCacheId.Product, sku, productData); +``` + +### For Distributed Implementations Only + +#### Refactor-Safe Cache Keys + +Since cache entries persist across application restarts and deployments, protect cache keys from breaking when refactoring enum names by using the `[CacheKey]` attribute: + +```csharp +using Neolution.Extensions.Caching.Abstractions; + +public enum MyCacheId +{ + // Explicit cache key - safe to rename enum value + [CacheKey("user-profile")] + UserProfile = 0, + + // Implicit - renaming this enum value will invalidate all cache entries + ProductCatalog = 1, +} +``` + +**Benefits:** +- Renaming `UserProfile` to `User` won't invalidate existing cache entries +- Cache keys become an explicit API contract +- Easier to maintain consistent naming across versions + +**Note:** In-Memory Cache ignores `[CacheKey]` attributes since the cache is cleared on restart anyway. + +## Configuration Options + +### Serialized Distributed Cache Options + +```csharp +services.AddSerializedDistributedCache(options => +{ + // Disable compression for in-memory backends to save CPU (default: false - compression enabled) + options.DisableCompression = true; + + // Require MessagePackObject attribute for better performance + // (requires decorating your classes with [MessagePackObject]) + options.RequireMessagePackObjectAnnotation = true; + + // Cache key version for invalidation (default: null - not included in key) + // Version is formatted as "v{number}" in cache key (e.g., v1, v2) + options.Version = 1; + + // Optional environment prefix for cache isolation (default: null - not included in key) + options.EnvironmentPrefix = "prod"; + + // URL-encode optional cache keys (default: true) + options.EnableKeyEncoding = true; + + // Validate cache key length (default: true - max 250 bytes) + options.EnableKeyLengthValidation = true; +}); +``` + +### Redis Hybrid Cache Options + +```csharp +services.AddRedisHybridCache("localhost:6379", options => +{ + // Enable compression for serialization (default: false - disabled for in-memory optimization) + // Set to true when bandwidth is more important than CPU usage + options.EnableCompression = false; + + // Cache key version for invalidation (default: null - not included in key) + // Version is formatted as "v{number}" in cache key (e.g., v1, v2) + options.Version = 1; + + // Optional environment prefix for cache isolation (default: null - not included in key) + options.EnvironmentPrefix = "prod"; + + // URL-encode optional cache keys (default: true) + options.EnableKeyEncoding = true; + + // Validate cache key length (default: true - max 250 bytes) + options.EnableKeyLengthValidation = true; +}); +``` + +### Cache Entry Options + +All cache implementations support the following expiration policies: + +| Property | Description | Supported Implementations | +|----------|-------------|---------------------------| +| `AbsoluteExpiration` | Fixed expiration date/time | MemoryCache, MessagePackDistributedCache | +| `AbsoluteExpirationRelativeToNow` | Expiration relative to current time | All implementations | +| `SlidingExpiration` | Reset expiration on access | MemoryCache, MessagePackDistributedCache | + +**Note**: RedisHybridCache only supports `AbsoluteExpirationRelativeToNow`. + +## Implementation Comparison + +| Feature | InMemory | Serialized Distributed | RedisHybrid | +|---------|----------|------------------------|-------------| +| **Type** | Standalone | **Wrapper** (requires provider) | Standalone | +| **Underlying Tech** | `IMemoryCache` | Any `IDistributedCache` | Foundatio.Redis | +| **Use Case** | Single instance | Any distributed backend | Multiple instances and Redis as backend | +| **Serialization** | None (in-memory objects) | MessagePack | MessagePack | +| **Compression** | N/A | LZ4 (enabled by default) | LZ4 (disabled by default) | +| **Performance** | Fastest | Depends on provider | Fast (L1 + L2 cache) | +| **Sync Across Servers** | No | Via cache backend | Via Redis pub/sub | +| **Async Support** | No | Yes | Yes | +| **Provider Examples** | N/A | Redis, SQL Server, Cosmos DB, Memory | Redis only | | + +### When to Use Each Implementation + +**Choose InMemory when:** +- Single-instance application +- Fastest performance is needed +- No cross-server synchronization required + +**Choose Redis Hybrid Cache when:** +- Multi-instance application with Redis as backend +- Fast performance with cross-server sync is needed +- Redis is your standard infrastructure + +**Choose Serialized Distributed Cache when:** +- You already have or need a specific `IDistributedCache` provider (SQL Server, Cosmos DB, NCache, etc.) +- You want flexibility to switch distributed cache providers without code changes + +## Architecture + +### Project Structure + +``` +Neolution.Extensions.Caching/ +├── Neolution.Extensions.Caching.Abstractions/ # Core interfaces and base classes +├── Neolution.Extensions.Caching.InMemory/ # In-memory implementation +├── Neolution.Extensions.Caching.Distributed/ # `IDistributedCache` wrapper +├── Neolution.Extensions.Caching.RedisHybrid/ # Redis hybrid cache +└── Neolution.Extensions.Caching.UnitTests/ # Unit tests +``` + +### Key Abstractions + +- **`IMemoryCache`**: Interface for memory caching operations +- **`IDistributedCache`**: Interface for distributed caching with sync/async operations +- **`MemoryCache`**: Abstract base class providing key generation and interface implementation +- **`DistributedCache`**: Abstract base class for distributed cache implementations +- **`CacheEntryOptions`**: Configuration for cache expiration policies + +## Build and Test + +### Prerequisites + +- .NET 6.0 SDK or later + +### Build + +```bash +dotnet restore +dotnet build --configuration Release +``` + +### Run Tests + +```bash +dotnet test +``` + +## Contributing + +Contributions are welcome! Please follow these guidelines: + +1. **Code Style**: Follow the existing code style and conventions +2. **Tests**: Add unit tests for new features or bug fixes +3. **Documentation**: Update XML documentation comments and README as needed +4. **Pull Requests**: Create a feature branch and submit a PR with a clear description + +### Development Setup + +1. Clone the repository +2. Open `Neolution.Extensions.Caching.sln` in Visual Studio or your preferred IDE +3. Install Redis locally for hybrid cache testing (optional) +4. Run `dotnet restore` to restore dependencies +5. Build and run tests + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Versioning + +This project uses [GitVersion](https://gitversion.net/) for semantic versioning with Continuous Delivery mode. + +## Support + +For issues, questions, or contributions, please use the GitHub issue tracker. \ No newline at end of file From 1082b30064444c18d5932da4b77582f2bc22d282 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 27 Jan 2026 23:07:55 +0100 Subject: [PATCH 06/23] Add v3 changelog and migration guide Introduces `CHANGELOG.md` to document all notable changes for v3, structured with Keep a Changelog. --- CHANGELOG.md | 38 +++++++ docs/MIGRATION_GUIDE_V3.md | 216 +++++++++++++++++++++++++++++++++++++ 2 files changed, 254 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 docs/MIGRATION_GUIDE_V3.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..087e5ce --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- `Version` property to distributed cache options for global cache invalidation. +- `EnvironmentPrefix` property to distributed cache options for multi-environment isolation. +- `[CacheKey]` attribute for refactor-safe cache keys. +- `EnableKeyEncoding` option for automatic URL encoding of optional cache keys (default: true for distributed caches). +- `EnableKeyLengthValidation` option to validate cache keys don't exceed 250 bytes (default: true for distributed caches). +- `EnableCompression` option to `RedisHybridCacheOptions` for controlling MessagePack compression. +- `DistributedCacheOptionsBase` base class for shared distributed cache configuration. +- Fluent API support - all service registration methods now return `IServiceCollection`. +- Validation that `IDistributedCache` provider is registered before calling `AddSerializedDistributedCache()`. +- `RedisHybridCacheOptions` class for Redis hybrid cache configuration. +- Comprehensive migration guide in [docs/MIGRATION_GUIDE_V3.md](docs/MIGRATION_GUIDE_V3.md). + +### Changed + +- Renamed `AddMessagePackDistributedCache()` to `AddSerializedDistributedCache()`. +- `MessagePackDistributedCacheOptions` now inherits from `DistributedCacheOptionsBase` instead of `IOptions`. +- All service registration extension methods now return `IServiceCollection` instead of `void`. +- Redis Hybrid Cache configuration now uses `RedisHybridCacheOptions` parameter instead of inline setup. + +### Deprecated + +- `AddMessagePackDistributedCache()` methods in favor of `AddSerializedDistributedCache()`. + +### Fixed + +- Error messages when `IDistributedCache` provider is not registered. +- Handling of special characters in cache keys via URL encoding. diff --git a/docs/MIGRATION_GUIDE_V3.md b/docs/MIGRATION_GUIDE_V3.md new file mode 100644 index 0000000..6a6db96 --- /dev/null +++ b/docs/MIGRATION_GUIDE_V3.md @@ -0,0 +1,216 @@ +# Migration Guide: v2.x to v3.0 + +Migration guide for **Neolution.Extensions.Caching v3.0**. + +## Breaking Changes + +### Options Pattern Correction + +Options classes now properly inherit from `DistributedCacheOptionsBase` instead of implementing `IOptions`. + +**Impact**: Only affects code that directly instantiates options classes (rare). Service registration is unaffected. + +### Method Rename: AddMessagePackDistributedCache → AddSerializedDistributedCache + +The registration method has been renamed for clarity. + +**v2.x**: +```csharp +services.AddMessagePackDistributedCache(); +``` + +**v3.0**: +```csharp +services.AddSerializedDistributedCache(); +``` + +The old method still works but is marked `[Obsolete]` and will be removed in a later release. + +### Provider Registration Validation + +`AddSerializedDistributedCache()` now validates that a cache provider is registered **before** the wrapper. + +**v2.x** - Failed silently at runtime: +```csharp +services.AddMessagePackDistributedCache(); // No error until you use it +``` + +**v3.0** - Fails immediately at registration: +```csharp +services.AddSerializedDistributedCache(); +// InvalidOperationException: "An IDistributedCache provider must be registered..." +``` + +**Required Order**: +```csharp +services.AddStackExchangeRedisCache(...); // Provider first +services.AddSerializedDistributedCache(); // Wrapper second +``` + +### Fluent API Return Types + +All extension methods now return `IServiceCollection` for chaining. + +```csharp +// v3.0 enables chaining +services.AddStackExchangeRedisCache(options => { ... }) + .AddSerializedDistributedCache() + .AddLogging(); +``` + +--- + +## New Configuration Options + +Version 3.0 exposes configuration properties that were previously hardcoded or inaccessible: + +### EnableKeyEncoding (Distributed Caches Only) + +Controls URL encoding of optional keys. **Default: `true`** + +```csharp +services.AddSerializedDistributedCache(options => +{ + options.EnableKeyEncoding = false; // Disable if you have legacy keys +}); +``` + +- **true**: `"user:123"` → `"user%3A123"` (safe for all backends) +- **false**: `"user:123"` → `"user:123"` (v2.x behavior) + +### EnableKeyLengthValidation (Distributed Caches Only) + +Validates keys don't exceed 250 bytes. **Default: `true`** + +```csharp +services.AddSerializedDistributedCache(options => +{ + options.EnableKeyLengthValidation = false; // If your backend supports long keys +}); +``` + +Prevents issues with Memcached and other backends with key length limits. + +--- + +## Migration Checklist + +### Step 1: Update Packages + +```bash +dotnet add package Neolution.Extensions.Caching.InMemory --version 3.0.0 +dotnet add package Neolution.Extensions.Caching.Distributed --version 3.0.0 +dotnet add package Neolution.Extensions.Caching.RedisHybrid --version 3.0.0 +``` + +### Step 2: Update Service Registration + +Replace deprecated method name (or ignore warning): + +```csharp +// Old (still works, but obsolete) +services.AddMessagePackDistributedCache(); + +// New (recommended) +services.AddSerializedDistributedCache(); +``` + +### Step 3: Verify Provider Registration Order + +Ensure provider comes **before** wrapper: + +```csharp +// Correct +services.AddStackExchangeRedisCache(options => { ... }); +services.AddSerializedDistributedCache(); + +// Wrong - throws InvalidOperationException +services.AddSerializedDistributedCache(); +services.AddStackExchangeRedisCache(options => { ... }); +``` + +### Step 4: Test Your Application + +- Verify cache operations work as expected +- Check logs for obsolete warnings +- Monitor cache hit rates + +--- + +## Optional: Leverage New Features + +### Cache Key Versioning + +Invalidate all cache entries by incrementing version: + +```csharp +services.AddSerializedDistributedCache(options => +{ + options.Version = 1; // Keys: "MyCacheId:v1:UserProfile" +}); + +// Later: increment to invalidate all entries +options.Version = 2; // Keys: "MyCacheId:v2:UserProfile" +``` + +**Default**: `null` (no version) - maintains v2.x compatibility. + +### Environment Isolation + +Prevent cache collisions when sharing backends: + +```csharp +services.AddSerializedDistributedCache(options => +{ + options.EnvironmentPrefix = "staging"; // Keys: "staging:MyCacheId:UserProfile" +}); +``` + +**Default**: `null` (no prefix) - maintains v2.x compatibility. + +### Refactor-Safe Keys + +Protect distributed cache keys from enum renames: + +```csharp +public enum MyCacheId +{ + [CacheKey("user-profile")] // Can rename enum without breaking cache + UserProfile = 0, + + ProductCatalog = 1 // Renaming breaks cache keys +} +``` + +**Note**: `[CacheKey]` only applies to distributed caches. In-memory cache ignores it. + +## FAQ + +### Will my existing cache entries still work? + +**Yes**, if you: +- Don't set `Version` (default) +- Don't set `EnvironmentPrefix` (default) +- Keep `EnableKeyEncoding = true` (default) + +Cache key format remains compatible with v2.x by default. + +### Will this affect performance? + +Minimal impact: +- URL encoding: Only on optional keys (negligible) +- Length validation: Simple string length check (negligible) +- Version/prefix: String concatenation (negligible) + +## Summary + +v3.0 improves configuration and error handling for distributed cache: + +- Better error messages (early validation) +- Exposed configuration properties +- Fluent API support +- Refactor-safe cache keys + +New features are opt-in. Existing cache entries remain compatible with default settings (no `Version`, no `EnvironmentPrefix`, `EnableKeyEncoding = true`). + +**Need Help?** Open an issue on GitHub. From 188dcce307f48d126833ffc4eceb8a8fd3c1827e Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 28 Jan 2026 00:12:41 +0100 Subject: [PATCH 07/23] CA fixes --- .../CacheKeyAttribute.cs | 9 +-- .../DistributedCache.cs | 59 ++++++++++++------- .../DistributedCacheOptionsBase.cs | 9 ++- .../ServiceCollectionExtensions.cs | 3 +- .../CacheKeyImprovementsTests.cs | 1 + .../CacheKeyVersioningTests.cs | 12 ++-- .../DistributedCacheKeyImprovementsTests.cs | 28 +-------- .../Models/TestObject.cs | 2 +- .../MsgPackSerializerTests.cs | 2 +- .../RedisHybridCacheOptionsTests.cs | 4 +- 10 files changed, 64 insertions(+), 65 deletions(-) diff --git a/Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs b/Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs index 1d6df4d..5d16739 100644 --- a/Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs +++ b/Neolution.Extensions.Caching.Abstractions/CacheKeyAttribute.cs @@ -1,4 +1,4 @@ -namespace Neolution.Extensions.Caching.Abstractions +namespace Neolution.Extensions.Caching.Abstractions { using System; @@ -17,14 +17,9 @@ public sealed class CacheKeyAttribute : Attribute /// Thrown when key is empty or whitespace. public CacheKeyAttribute(string key) { - if (key == null) - { - throw new ArgumentNullException(nameof(key)); - } - if (string.IsNullOrWhiteSpace(key)) { - throw new ArgumentException("Cache key cannot be empty or whitespace.", nameof(key)); + throw new ArgumentException("Cache key cannot be null, empty or whitespace.", nameof(key)); } this.Key = key; diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs index 3220219..3cca2a3 100644 --- a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs @@ -20,16 +20,23 @@ public abstract class DistributedCache : IDistributedCache private const int MaxCacheKeyBytes = 250; /// - /// Gets the name of the cache. + /// Indicates whether cache keys should be URL-encoded. /// - /// - /// The name of the cache. - /// - private static string CacheIdName => typeof(TCacheId).Name; - private readonly bool enableKeyEncoding; + + /// + /// Indicates whether cache key length validation is enabled. + /// private readonly bool enableKeyLengthValidation; + + /// + /// The cache key version for invalidation purposes. + /// private readonly int? version; + + /// + /// The environment prefix for cache key isolation. + /// private readonly string? environmentPrefix; /// @@ -71,11 +78,19 @@ protected DistributedCache(IOptions optionsAccessor /// protected string? EnvironmentPrefix => this.environmentPrefix; + /// + /// Gets the name of the cache. + /// + /// + /// The name of the cache. + /// + private static string CacheIdName => typeof(TCacheId).Name; + /// public T? Get(TCacheId id) where T : class { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); return this.GetCacheObject(cacheKey); } @@ -83,7 +98,7 @@ protected DistributedCache(IOptions optionsAccessor public T? Get(TCacheId id, string key) where T : class { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); return this.GetCacheObject(cacheKey); } @@ -91,7 +106,7 @@ protected DistributedCache(IOptions optionsAccessor public Task GetAsync(TCacheId id, CancellationToken token = default) where T : class { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); return this.GetCacheObjectAsync(cacheKey, token); } @@ -99,7 +114,7 @@ protected DistributedCache(IOptions optionsAccessor public Task GetAsync(TCacheId id, string key, CancellationToken token = default) where T : class { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); return this.GetCacheObjectAsync(cacheKey, token); } @@ -107,7 +122,7 @@ protected DistributedCache(IOptions optionsAccessor public void Set(TCacheId id, T value) where T : class { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); this.SetCacheObject(cacheKey, value, new CacheEntryOptions()); } @@ -115,7 +130,7 @@ public void Set(TCacheId id, T value) public void Set(TCacheId id, string key, T value) where T : class { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); this.SetCacheObject(cacheKey, value, new CacheEntryOptions()); } @@ -123,7 +138,7 @@ public void Set(TCacheId id, string key, T value) public Task SetAsync(TCacheId id, T value, CancellationToken token = default) where T : class { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); return this.SetCacheObjectAsync(cacheKey, value, new CacheEntryOptions(), token); } @@ -131,7 +146,7 @@ public Task SetAsync(TCacheId id, T value, CancellationToken token = default) public Task SetAsync(TCacheId id, string key, T value, CancellationToken token = default) where T : class { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); return this.SetCacheObjectAsync(cacheKey, value, new CacheEntryOptions(), token); } @@ -139,7 +154,7 @@ public Task SetAsync(TCacheId id, string key, T value, CancellationToken toke public void SetWithOptions(TCacheId id, T value, CacheEntryOptions? options) where T : class { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); this.SetCacheObject(cacheKey, value, options); } @@ -147,7 +162,7 @@ public void SetWithOptions(TCacheId id, T value, CacheEntryOptions? options) public void SetWithOptions(TCacheId id, string key, T value, CacheEntryOptions? options) where T : class { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); this.SetCacheObject(cacheKey, value, options); } @@ -155,7 +170,7 @@ public void SetWithOptions(TCacheId id, string key, T value, CacheEntryOption public Task SetWithOptionsAsync(TCacheId id, T value, CacheEntryOptions? options, CancellationToken token = default) where T : class { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); return this.SetCacheObjectAsync(cacheKey, value, options, token); } @@ -163,35 +178,35 @@ public Task SetWithOptionsAsync(TCacheId id, T value, CacheEntryOptions? opti public Task SetWithOptionsAsync(TCacheId id, string key, T value, CacheEntryOptions? options, CancellationToken token = default) where T : class { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); return this.SetCacheObjectAsync(cacheKey, value, options, token); } /// public void Remove(TCacheId id) { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); this.RemoveCacheObject(cacheKey); } /// public void Remove(TCacheId id, string key) { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); this.RemoveCacheObject(cacheKey); } /// public Task RemoveAsync(TCacheId id, CancellationToken token = default) { - var cacheKey = CreateCacheKey(id); + var cacheKey = this.CreateCacheKey(id); return this.RemoveCacheObjectAsync(cacheKey, token); } /// public Task RemoveAsync(TCacheId id, string key, CancellationToken token = default) { - var cacheKey = CreateCacheKey(id, key); + var cacheKey = this.CreateCacheKey(id, key); return this.RemoveCacheObjectAsync(cacheKey, token); } diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs index 2095a0b..c58a09b 100644 --- a/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs @@ -4,8 +4,15 @@ /// Base class for distributed cache configuration options. /// Provides common configuration properties shared across all distributed cache implementations. /// - public abstract class DistributedCacheOptionsBase + public class DistributedCacheOptionsBase { + /// + /// Initializes a new instance of the class. + /// + protected DistributedCacheOptionsBase() + { + } + /// /// Gets or sets the cache key version for invalidation purposes. /// If null, version is not included in the cache key. diff --git a/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs b/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs index 9def3c2..fec8bbb 100644 --- a/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs +++ b/Neolution.Extensions.Caching.Distributed/ServiceCollectionExtensions.cs @@ -50,8 +50,7 @@ public static IServiceCollection AddSerializedDistributedCache(this IServiceColl An IDistributedCache provider must be registered before calling AddSerializedDistributedCache(). Register a provider such as Redis (AddStackExchangeRedisCache), SQL Server (AddDistributedSqlServerCache), or Memory (AddDistributedMemoryCache) first. - """ - ); + """); } services.AddOptions(); diff --git a/Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs b/Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs index ec47b8f..bb5da8f 100644 --- a/Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/CacheKeyImprovementsTests.cs @@ -81,6 +81,7 @@ public void MemoryCacheAcceptsVeryLongKeys() // Arrange using var serviceProvider = CreateServiceCollection().BuildServiceProvider(); var cache = GetCache(serviceProvider); + // In-memory cache has no length restriction var longKey = new string('x', 500); const string value = "test-value"; diff --git a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs index 20f9fc5..f7398a2 100644 --- a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs @@ -39,15 +39,18 @@ public void MessagePackCacheKeyWithoutVersionByDefault() } /// - /// Tests if cache key includes version by default + /// Tests if cache key includes version when configured /// [Fact] - public void MessagePackCacheKeyIncludesDefaultVersion() + public void MessagePackCacheKeyIncludesConfiguredVersion() { // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddSerializedDistributedCache(); + services.AddSerializedDistributedCache(options => + { + options.Version = 1; + }); using var serviceProvider = services.BuildServiceProvider(); var cache = serviceProvider.GetRequiredService>(); @@ -56,7 +59,7 @@ public void MessagePackCacheKeyIncludesDefaultVersion() // Act cache.Set(TestCacheId.Foobar, testValue); - // Assert - Verify we can retrieve the value (key format is correct) + // Assert - Verify we can retrieve the value (version is included in key) cache.Get(TestCacheId.Foobar).ShouldBe(testValue); } @@ -203,6 +206,7 @@ public void MessagePackCacheKeyWithOptionalKeyAndVersion() /// /// Tests if null or empty environment prefix is handled correctly /// + /// The environment prefix to test. [Theory] [InlineData(null)] [InlineData("")] diff --git a/Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs b/Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs index bebbda4..0f053a2 100644 --- a/Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/DistributedCacheKeyImprovementsTests.cs @@ -90,6 +90,7 @@ public void CacheKeyThrowsExceptionWhenTooLong(IServiceCollection serviceCollect // Arrange using var serviceProvider = serviceCollection.BuildServiceProvider(); var cache = GetCache(serviceProvider); + // Create a very long key that exceeds 250 bytes var longKey = new string('x', 300); @@ -114,6 +115,7 @@ public void CacheKeyAcceptsMaximumAllowedLength(IServiceCollection serviceCollec // Arrange using var serviceProvider = serviceCollection.BuildServiceProvider(); var cache = GetCache(serviceProvider); + // Test that we can use keys up to the limit // Assuming enum name + structure is ~50 bytes, test with ~180 char key var maxKey = new string('a', 180); @@ -139,6 +141,7 @@ public void CacheKeyValidationAccountsForUnicodeBytes(IServiceCollection service // Arrange using var serviceProvider = serviceCollection.BuildServiceProvider(); var cache = GetCache(serviceProvider); + // Unicode characters can be multiple bytes in UTF-8 // 100 Chinese characters = ~300 bytes in UTF-8 var unicodeKey = new string('中', 100); @@ -194,31 +197,6 @@ public void CacheKeyFallsBackToEnumNameWhenNoAttribute(IServiceCollection servic result.ShouldBe(value); } - /// - /// Tests that cache key with attribute is refactor-safe - /// - /// The service collection. - [Theory] - [ClassData(typeof(ServiceCollectionTestDataCollection))] - public void CacheKeyWithAttributeIsRefactorSafe(IServiceCollection serviceCollection) - { - // Arrange - using var serviceProvider = serviceCollection.BuildServiceProvider(); - var cache = GetCache(serviceProvider); - const string value = "test-value"; - - // This test documents the behavior: - // Even if we rename "UserProfile" to "User", - // the cache key remains "user-profile" due to the attribute - - // Act - cache.Set(TestCacheId.UserProfile, value); - - // Assert - The actual cache key should contain "user-profile", not "UserProfile" - var result = cache.Get(TestCacheId.UserProfile); - result.ShouldBe(value); - } - /// /// Tests that cache key with attribute and optional key works correctly /// diff --git a/Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs b/Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs index 7d67efc..82022f8 100644 --- a/Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs +++ b/Neolution.Extensions.Caching.UnitTests/Models/TestObject.cs @@ -1,4 +1,4 @@ -namespace Neolution.Extensions.Caching.UnitTests.Models +namespace Neolution.Extensions.Caching.UnitTests.Models { /// /// A simple test object for unit testing. diff --git a/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs b/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs index 1e4db9a..841f1a5 100644 --- a/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs @@ -159,7 +159,7 @@ private static TestObject CreateLargeTestObject() return new TestObject { Name = largeString, - Value = 999 + Value = 999, }; } } diff --git a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs index d28a961..f3e5b7b 100644 --- a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs @@ -33,7 +33,7 @@ public void EnableCompression_CanBeSetToTrue() // Arrange var options = new RedisHybridCacheOptions { - EnableCompression = true + EnableCompression = true, }; // Act & Assert @@ -103,7 +103,7 @@ public void RedisHybridCacheOptions_InheritsFromBase() Version = 5, EnvironmentPrefix = "prod", EnableKeyEncoding = false, - EnableKeyLengthValidation = false + EnableKeyLengthValidation = false, }; // Assert From a47c9578172a1922496e17a03fdf434e125ae9bf Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 28 Jan 2026 00:17:03 +0100 Subject: [PATCH 08/23] Updates .NET to version 8.x Bumps the target .NET version in the build workflow from 6.x to 8.x, aligning with current development on the vNext branch. --- .github/workflows/dotnet.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index db5927d..12114ad 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -9,7 +9,7 @@ on: env: BUILD_CONFIGURATION: "Release" - DOTNET_VERSION: "6.x" + DOTNET_VERSION: "8.x" jobs: build: From 94eb57234a00e74a58bf82ab3fcc8d7b3cfa3178 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 28 Jan 2026 00:27:18 +0100 Subject: [PATCH 09/23] Updates target framework to .NET 8 --- .../Neolution.Extensions.Caching.UnitTests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj index 858824e..070b114 100644 --- a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj +++ b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj @@ -1,7 +1,7 @@ - net6.0 + net8.0 false latest From b5ce60ed025cbbacac8f0736d2a56904593003fb Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 28 Jan 2026 00:30:05 +0100 Subject: [PATCH 10/23] Updates .NET SDK to version 8.x --- .github/workflows/dotnet-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dotnet-publish.yml b/.github/workflows/dotnet-publish.yml index a61f01a..662d5bc 100644 --- a/.github/workflows/dotnet-publish.yml +++ b/.github/workflows/dotnet-publish.yml @@ -10,7 +10,7 @@ on: env: ARTIFACTS_FEED_URL: https://api.nuget.org/v3/index.json BUILD_CONFIGURATION: "Release" - DOTNET_VERSION: "6.x" + DOTNET_VERSION: "8.x" jobs: build-and-deploy: From 2e0e11a7f12114879f5eddae754b60e748ee584e Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 28 Jan 2026 20:49:18 +0100 Subject: [PATCH 11/23] Enhance Redis hybrid cache configuration --- .../ServiceCollectionExtensions.cs | 148 ++++++++++++++++-- README.md | 21 +-- 2 files changed, 148 insertions(+), 21 deletions(-) diff --git a/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs b/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs index 3aff57e..6383d9e 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs @@ -3,7 +3,9 @@ namespace Microsoft.Extensions.DependencyInjection { using System; using Foundatio.Caching; + using Foundatio.Serializer; using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; using Neolution.Extensions.Caching.Abstractions; using Neolution.Extensions.Caching.RedisHybrid; using StackExchange.Redis; @@ -19,8 +21,20 @@ public static class ServiceCollectionExtensions /// The service collection. /// The Redis connection string. /// The service collection for fluent chaining. + /// Thrown when services or redisConnectionString is null. + /// Thrown when redisConnectionString is empty or whitespace. public static IServiceCollection AddRedisHybridCache(this IServiceCollection services, string redisConnectionString) { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (string.IsNullOrWhiteSpace(redisConnectionString)) + { + throw new ArgumentException("Redis connection string cannot be null or empty.", nameof(redisConnectionString)); + } + return services.AddRedisHybridCache(redisConnectionString, _ => { }); } @@ -32,9 +46,20 @@ public static IServiceCollection AddRedisHybridCache(this IServiceCollection ser /// The Redis connection string. /// The action to configure cache options. /// The service collection for fluent chaining. - /// Thrown when configureOptions is null. + /// Thrown when services, redisConnectionString, or configureOptions is null. + /// Thrown when redisConnectionString is empty or whitespace. public static IServiceCollection AddRedisHybridCache(this IServiceCollection services, string redisConnectionString, Action configureOptions) { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (string.IsNullOrWhiteSpace(redisConnectionString)) + { + throw new ArgumentException("Redis connection string cannot be null or empty.", nameof(redisConnectionString)); + } + if (configureOptions == null) { throw new ArgumentNullException(nameof(configureOptions)); @@ -42,22 +67,123 @@ public static IServiceCollection AddRedisHybridCache(this IServiceCollection ser services.AddSingleton(sp => ConnectionMultiplexer.Connect(redisConnectionString)); + return services.AddRedisHybridCacheCore(configureOptions); + } + + /// + /// Adds the Redis hybrid cache implementation using an existing instance. + /// + /// The service collection. + /// The pre-created instance. + /// The service collection for fluent chaining. + /// Thrown when services or multiplexer is null. + /// + /// This overload is recommended when sharing the same Redis connection across multiple services + /// (e.g., Data Protection, Session State, SignalR backplane) to avoid creating multiple connections. + /// + public static IServiceCollection AddRedisHybridCache(this IServiceCollection services, IConnectionMultiplexer multiplexer) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (multiplexer == null) + { + throw new ArgumentNullException(nameof(multiplexer)); + } + + return services.AddRedisHybridCache(multiplexer, _ => { }); + } + + /// + /// Adds the Redis hybrid cache implementation using an existing instance. + /// + /// The service collection. + /// The pre-created instance. + /// The action to configure cache options. + /// The service collection for fluent chaining. + /// Thrown when services, multiplexer, or configureOptions is null. + /// + /// This overload is recommended when sharing the same Redis connection across multiple services + /// (e.g., Data Protection, Session State, SignalR backplane) to avoid creating multiple connections. + /// + public static IServiceCollection AddRedisHybridCache(this IServiceCollection services, IConnectionMultiplexer multiplexer, Action configureOptions) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (multiplexer == null) + { + throw new ArgumentNullException(nameof(multiplexer)); + } + + if (configureOptions == null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + // Register the provided multiplexer instance so other libraries (e.g. DataProtection) + // can reuse the same connection, then configure the cache. + services.AddSingleton(multiplexer); + return services.AddRedisHybridCacheCore(configureOptions); + } + + /// + /// Adds the Redis hybrid cache implementation (L1 + L2 caching with message broker synchronization), + /// using an already registered IConnectionMultiplexer. + /// + /// The service collection. + /// The action to configure cache options. + /// The service collection for fluent chaining. + /// Thrown when services or configureOptions is null. + /// + /// Use this overload when you need to share the same IConnectionMultiplexer instance + /// for other purposes (e.g., Data Protection keys). Register IConnectionMultiplexer before calling this method. + /// + public static IServiceCollection AddRedisHybridCache(this IServiceCollection services, Action configureOptions) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + if (configureOptions == null) + { + throw new ArgumentNullException(nameof(configureOptions)); + } + + return services.AddRedisHybridCacheCore(configureOptions); + } + + /// + /// Adds the Redis hybrid cache implementation core services and configuration. + /// + /// The service collection. + /// The action to configure cache options. + /// The service collection for fluent chaining. + private static IServiceCollection AddRedisHybridCacheCore(this IServiceCollection services, Action configureOptions) + { services.AddOptions(); services.Configure(configureOptions); - services.AddSingleton(sp => + services.AddSingleton(typeof(IDistributedCache<>), typeof(RedisHybridCache<>)); + + services.AddSingleton(sp => { - var options = sp.GetService>(); + var options = sp.GetService>(); var enableCompression = options?.Value?.EnableCompression ?? false; - - return new RedisHybridCacheClient(new RedisHybridCacheClientOptions - { - ConnectionMultiplexer = sp.GetService(), - LoggerFactory = sp.GetService(), - Serializer = new MsgPackSerializer(enableCompression), - }); + return new MsgPackSerializer(enableCompression); }); - services.AddSingleton(typeof(IDistributedCache<>), typeof(RedisHybridCache<>)); + + services.AddSingleton(sp => new RedisHybridCacheClient(new RedisHybridCacheClientOptions + { + ConnectionMultiplexer = sp.GetRequiredService(), + LoggerFactory = sp.GetService(), + Serializer = sp.GetRequiredService(), + })); return services; } diff --git a/README.md b/README.md index 308f068..5f790e8 100644 --- a/README.md +++ b/README.md @@ -85,18 +85,19 @@ public void ConfigureServices(IServiceCollection services) ```csharp public void ConfigureServices(IServiceCollection services) { - // Basic configuration + // Basic configuration with connection string services.AddRedisHybridCache("localhost:6379"); - // OR with options - services.AddRedisHybridCache("localhost:6379", options => - { - options.EnableCompression = false; // Disable compression for in-memory optimization (default: false) - options.Version = 1; // Optional: Cache key version (default: null) - options.EnvironmentPrefix = "prod"; // Optional: Environment prefix (default: null) - options.EnableKeyEncoding = true; // URL-encode optional keys (default: true) - options.EnableKeyLengthValidation = true; // Validate key length (default: true) - }); + // OR: Share connection with other Redis services (Data Protection, Session, SignalR, etc.) + var multiplexer = ConnectionMultiplexer.Connect("localhost:6379"); + services.AddSingleton(multiplexer); + + // Add cache using the shared connection + services.AddRedisHybridCache(multiplexer); + + // Use same shared connection for Data Protection Keys + services.AddDataProtection() + .PersistKeysToStackExchangeRedis(multiplexer, "DataProtection-Keys"); } ``` From 34292ecf68f42f6f4e8ec404d2216142e3aa348f Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 28 Jan 2026 21:31:58 +0100 Subject: [PATCH 12/23] Configures GitVersion for vNext branch --- GitVersion.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/GitVersion.yml b/GitVersion.yml index edd2ff1..ab3f601 100644 --- a/GitVersion.yml +++ b/GitVersion.yml @@ -1,5 +1,13 @@ mode: ContinuousDelivery +next-version: 3.0.0 branches: + vNext: + regex: ^vNext$ + mode: ContinuousDeployment + tag: vNext + increment: None + source-branches: ['main'] + is-release-branch: false feature: mode: ContinuousDeployment ignore: From 1b0798cad5d0ad21531ebeabc4b1c547c873b380 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Thu, 29 Jan 2026 12:00:39 +0100 Subject: [PATCH 13/23] Rename `Version` to `SchemaVersion` Clarifies the purpose of the property for cache invalidation strategies across the codebase, documentation, and tests. Reflects that the version relates to the cache's schema rather than a general software version. --- CHANGELOG.md | 2 +- .../DistributedCache.cs | 16 +++++----- .../DistributedCacheOptionsBase.cs | 14 ++++---- .../CacheKeyVersioningTests.cs | 32 +++++++++---------- .../RedisHybridCacheOptionsTests.cs | 10 +++--- .../RedisHybridCacheTests.cs | 2 +- README.md | 18 +++++------ docs/MIGRATION_GUIDE_V3.md | 12 +++---- 8 files changed, 53 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 087e5ce..7549a56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `Version` property to distributed cache options for global cache invalidation. +- `SchemaVersion` property to distributed cache options for global cache invalidation. - `EnvironmentPrefix` property to distributed cache options for multi-environment isolation. - `[CacheKey]` attribute for refactor-safe cache keys. - `EnableKeyEncoding` option for automatic URL encoding of optional cache keys (default: true for distributed caches). diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs index 3cca2a3..82efdcd 100644 --- a/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCache.cs @@ -30,9 +30,9 @@ public abstract class DistributedCache : IDistributedCache private readonly bool enableKeyLengthValidation; /// - /// The cache key version for invalidation purposes. + /// The cache schema version for invalidation purposes. /// - private readonly int? version; + private readonly int? schemaVersion; /// /// The environment prefix for cache key isolation. @@ -54,7 +54,7 @@ protected DistributedCache(IOptions optionsAccessor var options = optionsAccessor.Value; this.enableKeyEncoding = options.EnableKeyEncoding; this.enableKeyLengthValidation = options.EnableKeyLengthValidation; - this.version = options.Version; + this.schemaVersion = options.SchemaVersion; this.environmentPrefix = options.EnvironmentPrefix; } @@ -69,9 +69,9 @@ protected DistributedCache(IOptions optionsAccessor protected bool EnableKeyLengthValidation => this.enableKeyLengthValidation; /// - /// Gets the cache key version for invalidation purposes. + /// Gets the cache schema version for invalidation purposes. /// - protected int? Version => this.version; + protected int? SchemaVersion => this.schemaVersion; /// /// Gets the optional environment prefix for cache key isolation. @@ -311,10 +311,10 @@ private string CreateCacheKey(TCacheId id, string? key = null) var fullKey = CacheIdName; - // Add version if specified - if (this.Version.HasValue) + // Add schema version if specified + if (this.SchemaVersion.HasValue) { - fullKey = $"{fullKey}:v{this.Version.Value}"; + fullKey = $"{fullKey}:v{this.SchemaVersion.Value}"; } fullKey = $"{fullKey}:{cacheKey}"; diff --git a/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs b/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs index c58a09b..d44f7c2 100644 --- a/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs +++ b/Neolution.Extensions.Caching.Abstractions/DistributedCacheOptionsBase.cs @@ -14,16 +14,16 @@ protected DistributedCacheOptionsBase() } /// - /// Gets or sets the cache key version for invalidation purposes. - /// If null, version is not included in the cache key. - /// Changing this version will invalidate all existing cache entries. - /// The version will be formatted as "v{number}" in the cache key (e.g., v1, v2). - /// Default: null (no version in cache key). + /// Gets or sets the cache schema version for invalidation purposes. + /// If null, schema version is not included in the cache key. + /// Changing this schema version will invalidate all existing cache entries. + /// The schema version will be formatted as "v{number}" in the cache key (e.g., v1, v2). + /// Default: null (no schema version in cache key). /// /// - /// options.Version = 2; // Cache key becomes: "MyCacheId:v2:UserProfile" + /// options.SchemaVersion = 2; // Cache key becomes: "MyCacheId:v2:UserProfile" /// - public int? Version { get; set; } + public int? SchemaVersion { get; set; } /// /// Gets or sets the optional environment prefix for cache key isolation. diff --git a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs index f7398a2..20adb4d 100644 --- a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs @@ -17,7 +17,7 @@ public class CacheKeyVersioningTests { /// - /// Tests backward compatibility - cache key without version by default + /// Tests backward compatibility - cache key without schema version by default /// [Fact] public void MessagePackCacheKeyWithoutVersionByDefault() @@ -25,7 +25,7 @@ public void MessagePackCacheKeyWithoutVersionByDefault() // Arrange var services = new ServiceCollection(); services.AddDistributedMemoryCache(); - services.AddSerializedDistributedCache(); // No options - should not include version + services.AddSerializedDistributedCache(); // No options - should not include schema version using var serviceProvider = services.BuildServiceProvider(); var cache = serviceProvider.GetRequiredService>(); @@ -39,7 +39,7 @@ public void MessagePackCacheKeyWithoutVersionByDefault() } /// - /// Tests if cache key includes version when configured + /// Tests if cache key includes schema version when configured /// [Fact] public void MessagePackCacheKeyIncludesConfiguredVersion() @@ -49,7 +49,7 @@ public void MessagePackCacheKeyIncludesConfiguredVersion() services.AddDistributedMemoryCache(); services.AddSerializedDistributedCache(options => { - options.Version = 1; + options.SchemaVersion = 1; }); using var serviceProvider = services.BuildServiceProvider(); @@ -59,12 +59,12 @@ public void MessagePackCacheKeyIncludesConfiguredVersion() // Act cache.Set(TestCacheId.Foobar, testValue); - // Assert - Verify we can retrieve the value (version is included in key) + // Assert - Verify we can retrieve the value (schema version is included in key) cache.Get(TestCacheId.Foobar).ShouldBe(testValue); } /// - /// Tests if cache key includes custom version + /// Tests if cache key includes custom schema version /// [Fact] public void MessagePackCacheKeyIncludesCustomVersion() @@ -74,7 +74,7 @@ public void MessagePackCacheKeyIncludesCustomVersion() services.AddDistributedMemoryCache(); services.AddSerializedDistributedCache(options => { - options.Version = 2; + options.SchemaVersion = 2; }); using var serviceProvider = services.BuildServiceProvider(); @@ -89,19 +89,19 @@ public void MessagePackCacheKeyIncludesCustomVersion() } /// - /// Tests if different versions produce different cache keys + /// Tests if different schema versions produce different cache keys /// [Fact] public void DifferentVersionsProduceDifferentKeys() { - // Arrange - Create two service providers with different versions + // Arrange - Create two service providers with different schema versions var servicesV1 = new ServiceCollection(); servicesV1.AddDistributedMemoryCache(); - servicesV1.AddSerializedDistributedCache(options => options.Version = 1); + servicesV1.AddSerializedDistributedCache(options => options.SchemaVersion = 1); var servicesV2 = new ServiceCollection(); servicesV2.AddDistributedMemoryCache(); - servicesV2.AddSerializedDistributedCache(options => options.Version = 2); + servicesV2.AddSerializedDistributedCache(options => options.SchemaVersion = 2); using var providerV1 = servicesV1.BuildServiceProvider(); using var providerV2 = servicesV2.BuildServiceProvider(); @@ -148,7 +148,7 @@ public void MessagePackCacheKeyIncludesEnvironmentPrefix() } /// - /// Tests if cache key includes both version and environment prefix + /// Tests if cache key includes both schema version and environment prefix /// [Fact] public void MessagePackCacheKeyIncludesBothVersionAndEnvironment() @@ -158,7 +158,7 @@ public void MessagePackCacheKeyIncludesBothVersionAndEnvironment() services.AddDistributedMemoryCache(); services.AddSerializedDistributedCache(options => { - options.Version = 2; + options.SchemaVersion = 2; options.EnvironmentPrefix = "prod"; }); @@ -184,7 +184,7 @@ public void MessagePackCacheKeyWithOptionalKeyAndVersion() services.AddDistributedMemoryCache(); services.AddSerializedDistributedCache(options => { - options.Version = 2; + options.SchemaVersion = 2; options.EnvironmentPrefix = "staging"; }); @@ -233,7 +233,7 @@ public void NullOrEmptyEnvironmentPrefixIsHandledCorrectly(string? environmentPr } /// - /// Tests if RedisHybridCache key includes custom version + /// Tests if RedisHybridCache key includes custom schema version /// [Fact(Skip = "Requires Redis connection - integration test")] public void RedisHybridCacheKeyIncludesCustomVersion() @@ -242,7 +242,7 @@ public void RedisHybridCacheKeyIncludesCustomVersion() var services = new ServiceCollection(); services.AddRedisHybridCache("localhost:6379", options => { - options.Version = 2; + options.SchemaVersion = 2; options.EnvironmentPrefix = "test"; }); diff --git a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs index f3e5b7b..f97f2bc 100644 --- a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheOptionsTests.cs @@ -51,7 +51,7 @@ public void ServiceCollection_ConfiguresOptions_Correctly() services.AddRedisHybridCache("localhost:6379", options => { options.EnableCompression = true; - options.Version = 2; + options.SchemaVersion = 2; options.EnvironmentPrefix = "test"; options.EnableKeyEncoding = false; options.EnableKeyLengthValidation = false; @@ -63,7 +63,7 @@ public void ServiceCollection_ConfiguresOptions_Correctly() // Assert options.Value.EnableCompression.ShouldBeTrue(); - options.Value.Version.ShouldBe(2); + options.Value.SchemaVersion.ShouldBe(2); options.Value.EnvironmentPrefix.ShouldBe("test"); options.Value.EnableKeyEncoding.ShouldBeFalse(); options.Value.EnableKeyLengthValidation.ShouldBeFalse(); @@ -85,7 +85,7 @@ public void ServiceCollection_DefaultConfiguration_UsesDefaultValues() // Assert options.Value.EnableCompression.ShouldBeFalse(); // Default is false - options.Value.Version.ShouldBeNull(); + options.Value.SchemaVersion.ShouldBeNull(); options.Value.EnvironmentPrefix.ShouldBeNull(); options.Value.EnableKeyEncoding.ShouldBeTrue(); // Default is true options.Value.EnableKeyLengthValidation.ShouldBeTrue(); // Default is true @@ -100,14 +100,14 @@ public void RedisHybridCacheOptions_InheritsFromBase() // Arrange & Act var options = new RedisHybridCacheOptions { - Version = 5, + SchemaVersion = 5, EnvironmentPrefix = "prod", EnableKeyEncoding = false, EnableKeyLengthValidation = false, }; // Assert - options.Version.ShouldBe(5); + options.SchemaVersion.ShouldBe(5); options.EnvironmentPrefix.ShouldBe("prod"); options.EnableKeyEncoding.ShouldBeFalse(); options.EnableKeyLengthValidation.ShouldBeFalse(); diff --git a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs index 4d6ed6d..7d6ed07 100644 --- a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs @@ -129,7 +129,7 @@ private IServiceCollection CreateServiceCollection() { // Example: Enable compression if Redis bandwidth is a concern options.EnableCompression = false; // Default is false for CPU optimization - options.Version = 1; + options.SchemaVersion = 1; }); return services; diff --git a/README.md b/README.md index 5f790e8..de839fb 100644 --- a/README.md +++ b/README.md @@ -228,13 +228,13 @@ public class MyDistributedCache : MessagePackDistributedCache #### Cache Key Versioning and Environment Isolation -**Version Management:** -The `Version` property (integer) allows you to invalidate all cached data when you need to force a cache refresh (e.g., after schema changes or bug fixes). +**Schema Version Management:** +The `SchemaVersion` property (integer) allows you to invalidate all cached data when you need to force a cache refresh (e.g., after schema changes or bug fixes). ```csharp services.AddSerializedDistributedCache(options => { - options.Version = 2; + options.SchemaVersion = 2; }); ``` @@ -303,9 +303,9 @@ services.AddSerializedDistributedCache(options => // (requires decorating your classes with [MessagePackObject]) options.RequireMessagePackObjectAnnotation = true; - // Cache key version for invalidation (default: null - not included in key) - // Version is formatted as "v{number}" in cache key (e.g., v1, v2) - options.Version = 1; + // Cache schema version for invalidation (default: null - not included in key) + // Schema version is formatted as "v{number}" in cache key (e.g., v1, v2) + options.SchemaVersion = 1; // Optional environment prefix for cache isolation (default: null - not included in key) options.EnvironmentPrefix = "prod"; @@ -327,9 +327,9 @@ services.AddRedisHybridCache("localhost:6379", options => // Set to true when bandwidth is more important than CPU usage options.EnableCompression = false; - // Cache key version for invalidation (default: null - not included in key) - // Version is formatted as "v{number}" in cache key (e.g., v1, v2) - options.Version = 1; + // Cache schema version for invalidation (default: null - not included in key) + // Schema version is formatted as "v{number}" in cache key (e.g., v1, v2) + options.SchemaVersion = 1; // Optional environment prefix for cache isolation (default: null - not included in key) options.EnvironmentPrefix = "prod"; diff --git a/docs/MIGRATION_GUIDE_V3.md b/docs/MIGRATION_GUIDE_V3.md index 6a6db96..9ed054f 100644 --- a/docs/MIGRATION_GUIDE_V3.md +++ b/docs/MIGRATION_GUIDE_V3.md @@ -141,19 +141,19 @@ services.AddStackExchangeRedisCache(options => { ... }); ### Cache Key Versioning -Invalidate all cache entries by incrementing version: +Invalidate all cache entries by incrementing schema version: ```csharp services.AddSerializedDistributedCache(options => { - options.Version = 1; // Keys: "MyCacheId:v1:UserProfile" + options.SchemaVersion = 1; // Keys: "MyCacheId:v1:UserProfile" }); // Later: increment to invalidate all entries -options.Version = 2; // Keys: "MyCacheId:v2:UserProfile" +options.SchemaVersion = 2; // Keys: "MyCacheId:v2:UserProfile" ``` -**Default**: `null` (no version) - maintains v2.x compatibility. +**Default**: `null` (no schema version) - maintains v2.x compatibility. ### Environment Isolation @@ -189,7 +189,7 @@ public enum MyCacheId ### Will my existing cache entries still work? **Yes**, if you: -- Don't set `Version` (default) +- Don't set `SchemaVersion` (default) - Don't set `EnvironmentPrefix` (default) - Keep `EnableKeyEncoding = true` (default) @@ -211,6 +211,6 @@ v3.0 improves configuration and error handling for distributed cache: - Fluent API support - Refactor-safe cache keys -New features are opt-in. Existing cache entries remain compatible with default settings (no `Version`, no `EnvironmentPrefix`, `EnableKeyEncoding = true`). +New features are opt-in. Existing cache entries remain compatible with default settings (no `SchemaVersion`, no `EnvironmentPrefix`, `EnableKeyEncoding = true`). **Need Help?** Open an issue on GitHub. From 3b17dbf58f0fe1dfc685d90ad1a4db4a9a5a6112 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 25 Feb 2026 23:49:36 +0100 Subject: [PATCH 14/23] Upgrades dependencies and modernizes async test patterns - Replaces `MessagePack` with `Foundatio.MessagePack` and upgrades `Foundatio.Redis` to v12 - Updates test tooling (xunit, coverlet, Shouldly) to latest versions - Converts `.GetAwaiter().GetResult()` calls to proper `async/await` in tests - Cleans up README placeholder text --- ...tion.Extensions.Caching.Distributed.csproj | 2 +- .../MsgPackSerializer.cs | 6 +-- ...tion.Extensions.Caching.RedisHybrid.csproj | 4 +- .../DistributedCacheAsyncTests.cs | 43 +++++++++++-------- ...lution.Extensions.Caching.UnitTests.csproj | 17 +++----- .../RedisHybridCacheTests.cs | 32 ++++++++------ README.md | 6 +-- 7 files changed, 57 insertions(+), 53 deletions(-) diff --git a/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj b/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj index 446a296..333f7c8 100644 --- a/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj +++ b/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj @@ -7,7 +7,7 @@ - + diff --git a/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs b/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs index 4c6e157..d4fcb20 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs +++ b/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs @@ -43,8 +43,8 @@ public MsgPackSerializer(bool enableCompression) /// The deserialized object public object Deserialize(Stream data, Type objectType) { - return MessagePackSerializer.Deserialize(objectType, data, this.options) - ?? throw new InvalidOperationException($"Deserialization returned null for type '{objectType}'."); + return MessagePack.MessagePackSerializer.Deserialize(objectType, data, this.options) + ?? throw new InvalidOperationException($"Deserialization returned null for type '{objectType}'."); } /// @@ -59,7 +59,7 @@ public void Serialize(object value, Stream output) throw new ArgumentNullException(nameof(value)); } - MessagePackSerializer.Serialize(value.GetType(), output, value, this.options); + MessagePack.MessagePackSerializer.Serialize(value.GetType(), output, value, this.options); } } } diff --git a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj index 44f4054..b86cef1 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj +++ b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj @@ -7,8 +7,8 @@ - - + + all diff --git a/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs b/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs index d14f68b..aa0932f 100644 --- a/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs @@ -2,6 +2,7 @@ { using System; using System.Threading; + using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Neolution.Extensions.Caching.Abstractions; using Neolution.Extensions.Caching.UnitTests.Models; @@ -18,31 +19,33 @@ public class DistributedCacheAsyncTests /// Tests if created objects can be retrieved again from the cache. /// /// The service collection. + /// A representing the asynchronous unit test. [Theory] [ClassData(typeof(ServiceCollectionTestDataCollection))] - public void CreatedObjectCanBeRetrievedAgain(IServiceCollection services) + public async Task CreatedObjectCanBeRetrievedAgain(IServiceCollection services) { using var serviceProvider = services.BuildServiceProvider(); const string cacheObject = "Hello World!"; // Act var cache = GetCache(serviceProvider); - cache.SetAsync(TestCacheId.Foobar, cacheObject).GetAwaiter().GetResult(); - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult(); - cache.SetAsync(TestCacheId.Foobar, cacheObject).GetAwaiter().GetResult(); - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult(); + await cache.SetAsync(TestCacheId.Foobar, cacheObject); + await cache.GetAsync(TestCacheId.Foobar); + await cache.SetAsync(TestCacheId.Foobar, cacheObject); + await cache.GetAsync(TestCacheId.Foobar); // Assert - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult().ShouldBe(cacheObject); + (await cache.GetAsync(TestCacheId.Foobar)).ShouldBe(cacheObject); } /// /// Tests if created objects can be retrieved again from the cache. /// /// The service collection. + /// A representing the asynchronous unit test. [Theory] [ClassData(typeof(ServiceCollectionTestDataCollection))] - public void CreatedObjectWithKeyCanBeRetrievedAgain(IServiceCollection serviceCollection) + public async Task CreatedObjectWithKeyCanBeRetrievedAgain(IServiceCollection serviceCollection) { // Assign using var serviceProvider = serviceCollection.BuildServiceProvider(); @@ -52,19 +55,20 @@ public void CreatedObjectWithKeyCanBeRetrievedAgain(IServiceCollection serviceCo // Act var cache = GetCache(serviceProvider); - cache.SetAsync(TestCacheId.Foobar, key, cacheObject).GetAwaiter().GetResult(); + await cache.SetAsync(TestCacheId.Foobar, key, cacheObject); // Assert - cache.GetAsync(TestCacheId.Foobar, key).GetAwaiter().GetResult().ShouldBe(cacheObject); + (await cache.GetAsync(TestCacheId.Foobar, key)).ShouldBe(cacheObject); } /// /// Tests if removed object cannot be retrieved again from the cache. /// /// The service collection. + /// A representing the asynchronous unit test. [Theory] [ClassData(typeof(ServiceCollectionTestDataCollection))] - public void RemovedObjectCannotBeRetrievedAgain(IServiceCollection serviceCollection) + public async Task RemovedObjectCannotBeRetrievedAgain(IServiceCollection serviceCollection) { // Assign using var serviceProvider = serviceCollection.BuildServiceProvider(); @@ -72,20 +76,21 @@ public void RemovedObjectCannotBeRetrievedAgain(IServiceCollection serviceCollec // Act var cache = GetCache(serviceProvider); - cache.SetAsync(TestCacheId.Foobar, cacheObject).GetAwaiter().GetResult(); - cache.RemoveAsync(TestCacheId.Foobar).GetAwaiter().GetResult(); + await cache.SetAsync(TestCacheId.Foobar, cacheObject); + await cache.RemoveAsync(TestCacheId.Foobar); // Assert - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult().ShouldBeNull(); + (await cache.GetAsync(TestCacheId.Foobar)).ShouldBeNull(); } /// /// Tests if removed object cannot be retrieved again from the cache. /// /// The service collection. + /// A representing the asynchronous unit test. [Theory(Skip = "Refresh is removed from interface (no strong use-case)")] [ClassData(typeof(ServiceCollectionTestDataCollection))] - public void RefreshedObjectDidNotExpire(IServiceCollection serviceCollection) + public async Task RefreshedObjectDidNotExpire(IServiceCollection serviceCollection) { // Assign using var serviceProvider = serviceCollection.BuildServiceProvider(); @@ -99,21 +104,21 @@ public void RefreshedObjectDidNotExpire(IServiceCollection serviceCollection) var cache = GetCache(serviceProvider); // Add two objects to cache, both with a sliding expiration of 1000ms - cache.SetWithOptionsAsync(TestCacheId.Foobar, cacheObject, options).GetAwaiter().GetResult(); - cache.SetWithOptionsAsync(TestCacheId.NonRefreshedFoobar, cacheObject, options).GetAwaiter().GetResult(); + await cache.SetWithOptionsAsync(TestCacheId.Foobar, cacheObject, options); + await cache.SetWithOptionsAsync(TestCacheId.NonRefreshedFoobar, cacheObject, options); // After a wait of 500ms, refresh only one of the objects. Thread.Sleep(TimeSpan.FromMilliseconds(500)); - //cache.RefreshAsync(TestCacheId.Foobar).GetAwaiter().GetResult() + //await cache.RefreshAsync(TestCacheId.Foobar) // After a wait of 750ms, one of the objects should now be expired because its at least 1250ms (500+750) old at the moment. // But the refreshed object should still be valid for roughly another 250ms. Thread.Sleep(TimeSpan.FromMilliseconds(750)); // Assert - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult().ShouldBe(cacheObject); - cache.GetAsync(TestCacheId.NonRefreshedFoobar).GetAwaiter().GetResult().ShouldBeNull(); + (await cache.GetAsync(TestCacheId.Foobar)).ShouldBe(cacheObject); + (await cache.GetAsync(TestCacheId.NonRefreshedFoobar)).ShouldBeNull(); } /// diff --git a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj index 070b114..5d19ea2 100644 --- a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj +++ b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj @@ -7,27 +7,24 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + - - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs index 7d6ed07..a6fd444 100644 --- a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs @@ -1,6 +1,7 @@ namespace Neolution.Extensions.Caching.UnitTests { using System; + using System.Threading.Tasks; using Foundatio.Caching; using Foundatio.Xunit; using Microsoft.Extensions.DependencyInjection; @@ -30,8 +31,9 @@ public RedisHybridCacheTests(ITestOutputHelper outputHelper) /// /// Tests if created objects can be retrieved again from the cache. /// + /// A representing the asynchronous unit test. [Fact(Skip = "Activate as soon as we spin up a local Redis instance")] - public void CreatedObjectCanBeRetrievedAgain() + public async Task CreatedObjectCanBeRetrievedAgain() { // Assign var services = this.CreateServiceCollection(); @@ -45,31 +47,32 @@ public void CreatedObjectCanBeRetrievedAgain() var cache = GetCache(serviceProvider); logger.LogInformation("Before Setting Foobar"); - cache.SetAsync(TestCacheId.Foobar, cacheObject + 11).GetAwaiter().GetResult(); + await cache.SetAsync(TestCacheId.Foobar, cacheObject + 11); logger.LogInformation("After Setting Foobar"); logger.LogInformation("Before Getting Foobar"); - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult(); + await cache.GetAsync(TestCacheId.Foobar); logger.LogInformation("After Getting Foobar"); logger.LogInformation("Before ReSetting Foobar"); - cache.SetAsync(TestCacheId.Foobar, cacheObject).GetAwaiter().GetResult(); + await cache.SetAsync(TestCacheId.Foobar, cacheObject); logger.LogInformation("After ReSetting Foobar"); logger.LogInformation("Before ReGetting Foobar"); - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult(); + await cache.GetAsync(TestCacheId.Foobar); logger.LogInformation("After ReGetting Foobar"); // Assert logger.LogInformation("Next value should come from Cache"); - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult().ShouldBe(cacheObject); + (await cache.GetAsync(TestCacheId.Foobar)).ShouldBe(cacheObject); } /// /// Tests if created objects can be retrieved again from the cache. /// + /// A representing the asynchronous unit test. [Fact(Skip = "Activate as soon as we spin up a local Redis instance")] - public void CreatedObjectWithKeyCanBeRetrievedAgain() + public async Task CreatedObjectWithKeyCanBeRetrievedAgain() { // Assign var services = this.CreateServiceCollection(); @@ -80,17 +83,18 @@ public void CreatedObjectWithKeyCanBeRetrievedAgain() // Act var cache = GetCache(serviceProvider); - cache.SetAsync(TestCacheId.Foobar, key, cacheObject).GetAwaiter().GetResult(); + await cache.SetAsync(TestCacheId.Foobar, key, cacheObject); // Assert - cache.GetAsync(TestCacheId.Foobar, key).GetAwaiter().GetResult().ShouldBe(cacheObject); + (await cache.GetAsync(TestCacheId.Foobar, key)).ShouldBe(cacheObject); } /// /// Tests if removed object cannot be retrieved again from the cache. /// + /// A representing the asynchronous unit test. [Fact(Skip = "Activate as soon as we spin up a local Redis instance")] - public void RemovedObjectCannotBeRetrievedAgain() + public async Task RemovedObjectCannotBeRetrievedAgain() { // Assign var services = this.CreateServiceCollection(); @@ -99,11 +103,11 @@ public void RemovedObjectCannotBeRetrievedAgain() // Act var cache = GetCache(serviceProvider); - cache.SetAsync(TestCacheId.Foobar, cacheObject).GetAwaiter().GetResult(); - cache.RemoveAsync(TestCacheId.Foobar).GetAwaiter().GetResult(); + await cache.SetAsync(TestCacheId.Foobar, cacheObject); + await cache.RemoveAsync(TestCacheId.Foobar); // Assert - cache.GetAsync(TestCacheId.Foobar).GetAwaiter().GetResult().ShouldBeNull(); + (await cache.GetAsync(TestCacheId.Foobar)).ShouldBeNull(); } /// @@ -123,7 +127,7 @@ private static IDistributedCache GetCache(IServiceProvider serviceP private IServiceCollection CreateServiceCollection() { var services = new ServiceCollection(); - this.Log.MinimumLevel = LogLevel.Trace; + this.Log.DefaultLogLevel = LogLevel.Trace; services.AddSingleton(this.Log); services.AddRedisHybridCache("localhost", options => { diff --git a/README.md b/README.md index de839fb..d72f90d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ -# Introduction -TODO: Give a short introduction of your project. Let this section explain the objectives or the motivation behind this project. +# Introduction A type-safe caching abstraction library for .NET that uses enum-based cache identifiers instead of magic strings. # Build and Test -TODO: Describe and show how to build your code and run the tests. - **Strongly-Typed Cache Keys**: Use enums instead of magic strings for cache identifiers, providing compile-time safety and IntelliSense support - **Multiple Implementations**: @@ -451,4 +449,4 @@ This project uses [GitVersion](https://gitversion.net/) for semantic versioning ## Support -For issues, questions, or contributions, please use the GitHub issue tracker. \ No newline at end of file +For issues, questions, or contributions, please use the GitHub issue tracker. From c8484f6caaabc3327dbf5ec3c1507f695f489d6a Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Wed, 25 Feb 2026 23:58:46 +0100 Subject: [PATCH 15/23] Upgrades CodeAnalysis package to v3.2.2 - Bumps `Neolution.CodeAnalysis` from 2.6.1 to 3.2.2 across all projects - Replaces blocking `Thread.Sleep` calls with async `Task.Delay` to comply with new analyzer rules - Removes unused `using` directives flagged by updated ruleset - Converts sync test method to `async Task` to support the async delay --- .../Neolution.Extensions.Caching.Abstractions.csproj | 2 +- .../Neolution.Extensions.Caching.Distributed.csproj | 2 +- .../Neolution.Extensions.Caching.InMemory.csproj | 2 +- .../Neolution.Extensions.Caching.RedisHybrid.csproj | 2 +- .../CacheKeyVersioningTests.cs | 2 -- .../DistributedCacheAsyncTests.cs | 5 ++--- .../DistributedCacheTests.cs | 9 +++++---- .../MsgPackSerializerTests.cs | 1 - .../Neolution.Extensions.Caching.UnitTests.csproj | 2 +- .../RedisHybridCacheTests.cs | 2 -- 10 files changed, 12 insertions(+), 17 deletions(-) diff --git a/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj b/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj index 1778a2f..37e78c4 100644 --- a/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj +++ b/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj b/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj index 333f7c8..3fbc879 100644 --- a/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj +++ b/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj b/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj index 7276a0a..67e3d9c 100644 --- a/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj +++ b/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj @@ -8,7 +8,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj index b86cef1..22787c3 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj +++ b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj @@ -10,7 +10,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs index 20adb4d..a1d24d3 100644 --- a/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs @@ -3,8 +3,6 @@ using System; using Microsoft.Extensions.DependencyInjection; using Neolution.Extensions.Caching.Abstractions; - using Neolution.Extensions.Caching.Distributed; - using Neolution.Extensions.Caching.RedisHybrid; using Neolution.Extensions.Caching.UnitTests.Models; using Shouldly; using Xunit; diff --git a/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs b/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs index aa0932f..778547a 100644 --- a/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/DistributedCacheAsyncTests.cs @@ -1,7 +1,6 @@ namespace Neolution.Extensions.Caching.UnitTests { using System; - using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Neolution.Extensions.Caching.Abstractions; @@ -108,13 +107,13 @@ public async Task RefreshedObjectDidNotExpire(IServiceCollection serviceCollecti await cache.SetWithOptionsAsync(TestCacheId.NonRefreshedFoobar, cacheObject, options); // After a wait of 500ms, refresh only one of the objects. - Thread.Sleep(TimeSpan.FromMilliseconds(500)); + await Task.Delay(TimeSpan.FromMilliseconds(500)); //await cache.RefreshAsync(TestCacheId.Foobar) // After a wait of 750ms, one of the objects should now be expired because its at least 1250ms (500+750) old at the moment. // But the refreshed object should still be valid for roughly another 250ms. - Thread.Sleep(TimeSpan.FromMilliseconds(750)); + await Task.Delay(TimeSpan.FromMilliseconds(750)); // Assert (await cache.GetAsync(TestCacheId.Foobar)).ShouldBe(cacheObject); diff --git a/Neolution.Extensions.Caching.UnitTests/DistributedCacheTests.cs b/Neolution.Extensions.Caching.UnitTests/DistributedCacheTests.cs index b6dfbc6..e2b9377 100644 --- a/Neolution.Extensions.Caching.UnitTests/DistributedCacheTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/DistributedCacheTests.cs @@ -1,7 +1,7 @@ namespace Neolution.Extensions.Caching.UnitTests { using System; - using System.Threading; + using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Neolution.Extensions.Caching.Abstractions; using Neolution.Extensions.Caching.UnitTests.Models; @@ -81,9 +81,10 @@ public void RemovedObjectCannotBeRetrievedAgain(IServiceCollection serviceCollec /// Tests if removed object cannot be retrieved again from the cache. /// /// The service collection. + /// A representing the asynchronous unit test. [Theory(Skip = "Refresh() is removed from interface (no strong use-case)")] [ClassData(typeof(ServiceCollectionTestDataCollection))] - public void RefreshedObjectDidNotExpire(IServiceCollection serviceCollection) + public async Task RefreshedObjectDidNotExpire(IServiceCollection serviceCollection) { // Assign using var serviceProvider = serviceCollection.BuildServiceProvider(); @@ -101,13 +102,13 @@ public void RefreshedObjectDidNotExpire(IServiceCollection serviceCollection) cache.SetWithOptions(TestCacheId.NonRefreshedFoobar, cacheObject, options); // After a wait of 500ms, refresh only one of the objects. - Thread.Sleep(TimeSpan.FromMilliseconds(500)); + await Task.Delay(TimeSpan.FromMilliseconds(500)); //cache.Refresh(TestCacheId.Foobar) // After a wait of 750ms, one of the objects should now be expired because its at least 1250ms (500+750) old at the moment. // But the refreshed object should still be valid for roughly another 250ms. - Thread.Sleep(TimeSpan.FromMilliseconds(750)); + await Task.Delay(TimeSpan.FromMilliseconds(750)); // Assert cache.Get(TestCacheId.Foobar).ShouldBe(cacheObject); diff --git a/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs b/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs index 841f1a5..0101edf 100644 --- a/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs @@ -2,7 +2,6 @@ { using System; using System.IO; - using MessagePack; using Neolution.Extensions.Caching.RedisHybrid; using Neolution.Extensions.Caching.UnitTests.Models; using Shouldly; diff --git a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj index 5d19ea2..523bfbb 100644 --- a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj +++ b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs index a6fd444..7694b48 100644 --- a/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs +++ b/Neolution.Extensions.Caching.UnitTests/RedisHybridCacheTests.cs @@ -2,7 +2,6 @@ { using System; using System.Threading.Tasks; - using Foundatio.Caching; using Foundatio.Xunit; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -10,7 +9,6 @@ using Neolution.Extensions.Caching.RedisHybrid; using Neolution.Extensions.Caching.UnitTests.Models; using Shouldly; - using StackExchange.Redis; using Xunit; using Xunit.Abstractions; From 280a3dd3e5baec5b0f3463e9134600ccadd918fe Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Thu, 26 Feb 2026 00:03:14 +0100 Subject: [PATCH 16/23] Upgrades Microsoft.Extensions packages to .NET 8 - Bumps `Microsoft.Extensions.Options` from 6.0.0 to 8.0.2 across all projects - Bumps `Microsoft.Extensions.Caching.Abstractions` from 6.0.0 to 8.0.0 - Bumps `Microsoft.Extensions.Caching.Memory` from 6.0.2 to 8.0.1 --- .../Neolution.Extensions.Caching.Abstractions.csproj | 2 +- .../Neolution.Extensions.Caching.Distributed.csproj | 4 ++-- .../Neolution.Extensions.Caching.InMemory.csproj | 2 +- .../Neolution.Extensions.Caching.RedisHybrid.csproj | 2 +- .../Neolution.Extensions.Caching.UnitTests.csproj | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj b/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj index 37e78c4..dd9dc75 100644 --- a/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj +++ b/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj @@ -7,7 +7,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj b/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj index 3fbc879..534adb2 100644 --- a/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj +++ b/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj @@ -8,8 +8,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj b/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj index 67e3d9c..1387ce5 100644 --- a/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj +++ b/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj @@ -7,7 +7,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj index 22787c3..1e1fe08 100644 --- a/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj +++ b/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj @@ -9,7 +9,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj index 523bfbb..3f747e2 100644 --- a/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj +++ b/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj @@ -12,7 +12,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + all From a58e7ba10a6a68379098c7169ee8d3aa98da7fe2 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Thu, 26 Feb 2026 00:13:42 +0100 Subject: [PATCH 17/23] Makes cache Get methods return nullable types - Updates `Get` methods to return `T?` instead of `T`, better reflecting that a cache lookup can return nothing - Adds `where T : default` constraint to the concrete implementation to satisfy the nullable override --- Neolution.Extensions.Caching.Abstractions/IMemoryCache.cs | 4 ++-- Neolution.Extensions.Caching.Abstractions/MemoryCache.cs | 6 +++--- Neolution.Extensions.Caching.InMemory/InMemoryCache.cs | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/Neolution.Extensions.Caching.Abstractions/IMemoryCache.cs b/Neolution.Extensions.Caching.Abstractions/IMemoryCache.cs index 471cce0..d807e56 100644 --- a/Neolution.Extensions.Caching.Abstractions/IMemoryCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/IMemoryCache.cs @@ -20,7 +20,7 @@ public interface IMemoryCache /// The type of the cached object. /// The cache identifier. /// The object from cache. - T Get(TCacheId id); + T? Get(TCacheId id); /// /// Gets the item associated with this id and key if present. @@ -29,7 +29,7 @@ public interface IMemoryCache /// The cache identifier. /// An object identifying the requested entry. /// The object from cache. - T Get(TCacheId id, string key); + T? Get(TCacheId id, string key); /// /// Create or overwrite an entry in the cache. diff --git a/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs b/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs index 5b16ca7..f1a8bcb 100644 --- a/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs +++ b/Neolution.Extensions.Caching.Abstractions/MemoryCache.cs @@ -15,14 +15,14 @@ public abstract class MemoryCache : IMemoryCache private static string CacheIdName => typeof(TCacheId).Name; /// - public T Get(TCacheId id) + public T? Get(TCacheId id) { var cacheKey = CreateCacheKey(id); return this.GetCacheObject(cacheKey); } /// - public T Get(TCacheId id, string key) + public T? Get(TCacheId id, string key) { var cacheKey = CreateCacheKey(id, key); return this.GetCacheObject(cacheKey); @@ -76,7 +76,7 @@ public void Remove(TCacheId id, string key) /// The type of the object /// The key. /// The object from the cache - protected abstract T GetCacheObject(string key); + protected abstract T? GetCacheObject(string key); /// /// Sets the object in the cache. diff --git a/Neolution.Extensions.Caching.InMemory/InMemoryCache.cs b/Neolution.Extensions.Caching.InMemory/InMemoryCache.cs index db4a15b..a1d7919 100644 --- a/Neolution.Extensions.Caching.InMemory/InMemoryCache.cs +++ b/Neolution.Extensions.Caching.InMemory/InMemoryCache.cs @@ -26,7 +26,8 @@ public InMemoryCache(IMemoryCache cache) } /// - protected override T GetCacheObject(string key) + protected override T? GetCacheObject(string key) + where T : default { return this.cache.Get(key); } From d3da3ab83fcff942ce547afc358eb3da4ed432bf Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Thu, 28 May 2026 16:49:09 +0200 Subject: [PATCH 18/23] Enter prerelease mode (beta) for 3.0.0 --- .changeset/pre.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/pre.json diff --git a/.changeset/pre.json b/.changeset/pre.json new file mode 100644 index 0000000..86430c8 --- /dev/null +++ b/.changeset/pre.json @@ -0,0 +1,11 @@ +{ + "mode": "pre", + "tag": "beta", + "initialVersions": { + "@neolution-ch/neolution.extensions.caching.abstractions": "2.1.2", + "@neolution-ch/neolution.extensions.caching.distributed": "2.1.2", + "@neolution-ch/neolution.extensions.caching.inmemory": "2.1.2", + "@neolution-ch/neolution.extensions.caching.redishybrid": "2.1.2" + }, + "changesets": [] +} From ea0b59d0ffddcef80ce83b688dbf20ef04b55bcc Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Thu, 28 May 2026 17:06:20 +0200 Subject: [PATCH 19/23] Adds v3.0 changeset with breaking changes and migration guidance Marks all caching packages for a major release and documents the key API and registration changes for v3. Also lists the new opt-in key/versioning options and confirms v2.x cache entries remain readable with default settings. --- .changeset/hot-coins-build.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/hot-coins-build.md diff --git a/.changeset/hot-coins-build.md b/.changeset/hot-coins-build.md new file mode 100644 index 0000000..4ea23dc --- /dev/null +++ b/.changeset/hot-coins-build.md @@ -0,0 +1,20 @@ +--- +"@neolution-ch/neolution.extensions.caching.abstractions": major +"@neolution-ch/neolution.extensions.caching.distributed": major +"@neolution-ch/neolution.extensions.caching.redishybrid": major +"@neolution-ch/neolution.extensions.caching.inmemory": major +--- + +v3.0 release. See `docs/MIGRATION_GUIDE_V3.md` for the migration walkthrough. + +**Breaking:** +- Options classes inherit from `DistributedCacheOptionsBase` instead of implementing `IOptions`. +- `AddMessagePackDistributedCache()` renamed to `AddSerializedDistributedCache()` (old method kept as `[Obsolete]`). +- `AddSerializedDistributedCache()` now throws at registration time if no `IDistributedCache` provider is registered first. +- Extension methods return `IServiceCollection` for chaining. + +**New (opt-in):** +- `EnableKeyEncoding`, `EnableKeyLengthValidation`, `SchemaVersion`, `EnvironmentPrefix` configuration options. +- `[CacheKey("name")]` attribute on enum members for refactor-safe distributed cache keys. + +v2.x cache entries remain readable with default settings. From 5efb9d44f2ae9b4ab857d90d1db4e7350f9bbb52 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 4 Aug 2026 12:41:53 +0000 Subject: [PATCH 20/23] Targets net8.0 and net10.0 to clear MessagePack advisories Foundatio 12.x pinned MessagePack 3.1.4, which carries 12 advisories, three of them High: GHSA-hv8m-jj95-wg3x, GHSA-vh6j-jc39-fggf and GHSA-382j-8mxh-c7x2. Foundatio 13.0.2 brings MessagePack 3.1.7, the first version outside the affected range, but is .NET 8.0+ only. The four libraries and the test project now multi-target net8.0 and net10.0, and .NET Standard 2.0 is dropped. Foundatio.MessagePack, Foundatio.Redis and Foundatio.Xunit move to 13.0.2. Foundatio 13 annotates ISerializer for nullability, so MsgPackSerializer. Serialize takes object? and RedisHybridCacheClientOptions.LoggerFactory falls back to NullLoggerFactory.Instance. CI installs both the 10.x SDK (to build both target frameworks) and the 8.x runtime (to run the net8.0 test leg); the publish workflow moves to 10.x. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/cool-cycles-wear.md | 10 ++++++++ .github/workflows/dotnet.yml | 8 ++++--- .github/workflows/nuget-publish.yml | 2 +- README.md | 2 +- docs/MIGRATION_GUIDE_V3.md | 24 +++++++++++++++---- ...ion.Extensions.Caching.Abstractions.csproj | 2 +- ...tion.Extensions.Caching.Distributed.csproj | 4 ++-- ...olution.Extensions.Caching.InMemory.csproj | 2 +- .../MsgPackSerializer.cs | 2 +- ...tion.Extensions.Caching.RedisHybrid.csproj | 6 ++--- .../ServiceCollectionExtensions.cs | 3 ++- ...lution.Extensions.Caching.UnitTests.csproj | 4 ++-- 12 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 .changeset/cool-cycles-wear.md diff --git a/.changeset/cool-cycles-wear.md b/.changeset/cool-cycles-wear.md new file mode 100644 index 0000000..7c7d769 --- /dev/null +++ b/.changeset/cool-cycles-wear.md @@ -0,0 +1,10 @@ +--- +"@neolution-ch/neolution.extensions.caching.abstractions": major +"@neolution-ch/neolution.extensions.caching.distributed": major +"@neolution-ch/neolution.extensions.caching.redishybrid": major +"@neolution-ch/neolution.extensions.caching.inmemory": major +--- + +Target net8.0 and net10.0, and drop .NET Standard 2.0. Bump Foundatio to 13.0.2, which brings MessagePack 3.1.7 and fixes security advisories GHSA-hv8m-jj95-wg3x, GHSA-vh6j-jc39-fggf and GHSA-382j-8mxh-c7x2. + +Consumers must target .NET 8.0 or later; projects on .NET Framework, .NET Standard 2.0 or .NET 6.0/7.0 must stay on v2.x. See `docs/MIGRATION_GUIDE_V3.md`. diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 12114ad..be1f1f8 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -9,7 +9,6 @@ on: env: BUILD_CONFIGURATION: "Release" - DOTNET_VERSION: "8.x" jobs: build: @@ -20,9 +19,12 @@ jobs: - uses: actions/checkout@v3 - name: Setup .NET - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ env.DOTNET_VERSION }} + # 10.x builds both target frameworks; 8.x supplies the runtime the net8.0 test leg needs. + dotnet-version: | + 8.x + 10.x - name: Restore dependencies run: dotnet restore diff --git a/.github/workflows/nuget-publish.yml b/.github/workflows/nuget-publish.yml index 6cbfbd8..df91b12 100644 --- a/.github/workflows/nuget-publish.yml +++ b/.github/workflows/nuget-publish.yml @@ -17,7 +17,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: "8.x" + dotnet-version: "10.x" - name: Map tag to project id: map diff --git a/README.md b/README.md index d72f90d..3c3305d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A type-safe caching abstraction library for .NET that uses enum-based cache iden - **Consistent API**: Unified interface across all caching strategies - **Async Support**: Full async/await support for distributed cache operations - **Flexible Configuration**: Configurable expiration policies and serialization options -- **.NET Standard 2.0**: Compatible with .NET Core, .NET 5+, and .NET Framework +- **.NET 8.0 and .NET 10.0**: Multi-targeted; requires .NET 8.0 or later ## Installation diff --git a/docs/MIGRATION_GUIDE_V3.md b/docs/MIGRATION_GUIDE_V3.md index 9ed054f..c2f4a07 100644 --- a/docs/MIGRATION_GUIDE_V3.md +++ b/docs/MIGRATION_GUIDE_V3.md @@ -4,6 +4,12 @@ Migration guide for **Neolution.Extensions.Caching v3.0**. ## Breaking Changes +### Target Frameworks: .NET Standard 2.0 → .NET 8.0 and .NET 10.0 + +All four packages now multi-target **.NET 8.0** and **.NET 10.0**, and no longer ship a .NET Standard 2.0 build. + +**Impact**: Projects targeting .NET Framework, .NET Standard 2.0, or .NET 6.0/7.0 can no longer reference v3.0 and will fail to restore with `NU1202`. Your project must target .NET 8.0 or later. Stay on v2.x if you need .NET Standard 2.0. + ### Options Pattern Correction Options classes now properly inherit from `DistributedCacheOptionsBase` instead of implementing `IOptions`. @@ -95,7 +101,16 @@ Prevents issues with Memcached and other backends with key length limits. ## Migration Checklist -### Step 1: Update Packages +### Step 1: Retarget to .NET 8.0 or Later + +v3.0 requires .NET 8.0 or later: + +```xml +net8.0 + +``` + +### Step 2: Update Packages ```bash dotnet add package Neolution.Extensions.Caching.InMemory --version 3.0.0 @@ -103,7 +118,7 @@ dotnet add package Neolution.Extensions.Caching.Distributed --version 3.0.0 dotnet add package Neolution.Extensions.Caching.RedisHybrid --version 3.0.0 ``` -### Step 2: Update Service Registration +### Step 3: Update Service Registration Replace deprecated method name (or ignore warning): @@ -115,7 +130,7 @@ services.AddMessagePackDistributedCache(); services.AddSerializedDistributedCache(); ``` -### Step 3: Verify Provider Registration Order +### Step 4: Verify Provider Registration Order Ensure provider comes **before** wrapper: @@ -129,7 +144,7 @@ services.AddSerializedDistributedCache(); services.AddStackExchangeRedisCache(options => { ... }); ``` -### Step 4: Test Your Application +### Step 5: Test Your Application - Verify cache operations work as expected - Check logs for obsolete warnings @@ -206,6 +221,7 @@ Minimal impact: v3.0 improves configuration and error handling for distributed cache: +- .NET 8.0 and .NET 10.0 targets (.NET Standard 2.0 dropped) - Better error messages (early validation) - Exposed configuration properties - Fluent API support diff --git a/src/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj b/src/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj index dd9dc75..c2c2e4e 100644 --- a/src/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj +++ b/src/Neolution.Extensions.Caching.Abstractions/Neolution.Extensions.Caching.Abstractions.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + net8.0;net10.0 latest enable diff --git a/src/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj b/src/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj index 534adb2..0e029f4 100644 --- a/src/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj +++ b/src/Neolution.Extensions.Caching.Distributed/Neolution.Extensions.Caching.Distributed.csproj @@ -1,13 +1,13 @@ - netstandard2.0 + net8.0;net10.0 latest enable - + diff --git a/src/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj b/src/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj index 1387ce5..159cf3d 100644 --- a/src/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj +++ b/src/Neolution.Extensions.Caching.InMemory/Neolution.Extensions.Caching.InMemory.csproj @@ -1,7 +1,7 @@ - netstandard2.0 + net8.0;net10.0 latest enable diff --git a/src/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs b/src/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs index d4fcb20..a5707ce 100644 --- a/src/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs +++ b/src/Neolution.Extensions.Caching.RedisHybrid/MsgPackSerializer.cs @@ -52,7 +52,7 @@ public object Deserialize(Stream data, Type objectType) /// /// The value. /// The output. - public void Serialize(object value, Stream output) + public void Serialize(object? value, Stream output) { if (value is null) { diff --git a/src/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj b/src/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj index 1e1fe08..9791055 100644 --- a/src/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj +++ b/src/Neolution.Extensions.Caching.RedisHybrid/Neolution.Extensions.Caching.RedisHybrid.csproj @@ -1,14 +1,14 @@  - netstandard2.0 + net8.0;net10.0 latest enable - - + + all diff --git a/src/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs b/src/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs index 6383d9e..9fc182a 100644 --- a/src/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs +++ b/src/Neolution.Extensions.Caching.RedisHybrid/ServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ namespace Microsoft.Extensions.DependencyInjection using Foundatio.Caching; using Foundatio.Serializer; using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Neolution.Extensions.Caching.Abstractions; using Neolution.Extensions.Caching.RedisHybrid; @@ -181,7 +182,7 @@ private static IServiceCollection AddRedisHybridCacheCore(this IServiceCollectio services.AddSingleton(sp => new RedisHybridCacheClient(new RedisHybridCacheClientOptions { ConnectionMultiplexer = sp.GetRequiredService(), - LoggerFactory = sp.GetService(), + LoggerFactory = sp.GetService() ?? NullLoggerFactory.Instance, Serializer = sp.GetRequiredService(), })); diff --git a/tests/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj b/tests/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj index 27c093e..12d784f 100644 --- a/tests/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj +++ b/tests/Neolution.Extensions.Caching.UnitTests/Neolution.Extensions.Caching.UnitTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0;net10.0 false latest @@ -11,7 +11,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + From 7dfc76ab2a54ed58cd632ff62175d7597c08e4d7 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 4 Aug 2026 15:17:30 +0200 Subject: [PATCH 21/23] Cleans up release documentation and aligns tests with current runtime behavior Removes the legacy changelog in favor of changesets, updates release notes and README details for the v3 cache option naming and .NET 8 baseline, and tightens serializer expectations to the specific invalid operation exception now thrown. --- .changeset/hot-coins-build.md | 2 + CHANGELOG.md | 38 ------------------- README.md | 6 +-- .../RedisHybridCache.cs | 5 --- .../MsgPackSerializerTests.cs | 2 +- 5 files changed, 6 insertions(+), 47 deletions(-) delete mode 100644 CHANGELOG.md diff --git a/.changeset/hot-coins-build.md b/.changeset/hot-coins-build.md index 4ea23dc..904e643 100644 --- a/.changeset/hot-coins-build.md +++ b/.changeset/hot-coins-build.md @@ -11,10 +11,12 @@ v3.0 release. See `docs/MIGRATION_GUIDE_V3.md` for the migration walkthrough. - Options classes inherit from `DistributedCacheOptionsBase` instead of implementing `IOptions`. - `AddMessagePackDistributedCache()` renamed to `AddSerializedDistributedCache()` (old method kept as `[Obsolete]`). - `AddSerializedDistributedCache()` now throws at registration time if no `IDistributedCache` provider is registered first. +- Redis hybrid cache registration now takes a `RedisHybridCacheOptions` parameter instead of inline setup. - Extension methods return `IServiceCollection` for chaining. **New (opt-in):** - `EnableKeyEncoding`, `EnableKeyLengthValidation`, `SchemaVersion`, `EnvironmentPrefix` configuration options. +- `EnableCompression` on `RedisHybridCacheOptions` (controls MessagePack compression). - `[CacheKey("name")]` attribute on enum members for refactor-safe distributed cache keys. v2.x cache entries remain readable with default settings. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 7549a56..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,38 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- `SchemaVersion` property to distributed cache options for global cache invalidation. -- `EnvironmentPrefix` property to distributed cache options for multi-environment isolation. -- `[CacheKey]` attribute for refactor-safe cache keys. -- `EnableKeyEncoding` option for automatic URL encoding of optional cache keys (default: true for distributed caches). -- `EnableKeyLengthValidation` option to validate cache keys don't exceed 250 bytes (default: true for distributed caches). -- `EnableCompression` option to `RedisHybridCacheOptions` for controlling MessagePack compression. -- `DistributedCacheOptionsBase` base class for shared distributed cache configuration. -- Fluent API support - all service registration methods now return `IServiceCollection`. -- Validation that `IDistributedCache` provider is registered before calling `AddSerializedDistributedCache()`. -- `RedisHybridCacheOptions` class for Redis hybrid cache configuration. -- Comprehensive migration guide in [docs/MIGRATION_GUIDE_V3.md](docs/MIGRATION_GUIDE_V3.md). - -### Changed - -- Renamed `AddMessagePackDistributedCache()` to `AddSerializedDistributedCache()`. -- `MessagePackDistributedCacheOptions` now inherits from `DistributedCacheOptionsBase` instead of `IOptions`. -- All service registration extension methods now return `IServiceCollection` instead of `void`. -- Redis Hybrid Cache configuration now uses `RedisHybridCacheOptions` parameter instead of inline setup. - -### Deprecated - -- `AddMessagePackDistributedCache()` methods in favor of `AddSerializedDistributedCache()`. - -### Fixed - -- Error messages when `IDistributedCache` provider is not registered. -- Handling of special characters in cache keys via URL encoding. diff --git a/README.md b/README.md index d72f90d..aa64915 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ services.AddSerializedDistributedCache(options => }); ``` -By default, neither `Version` nor `EnvironmentPrefix` are set. +By default, neither `SchemaVersion` nor `EnvironmentPrefix` are set. ## Best Practices @@ -364,7 +364,7 @@ All cache implementations support the following expiration policies: | **Performance** | Fastest | Depends on provider | Fast (L1 + L2 cache) | | **Sync Across Servers** | No | Via cache backend | Via Redis pub/sub | | **Async Support** | No | Yes | Yes | -| **Provider Examples** | N/A | Redis, SQL Server, Cosmos DB, Memory | Redis only | | +| **Provider Examples** | N/A | Redis, SQL Server, Cosmos DB, Memory | Redis only | ### When to Use Each Implementation @@ -407,7 +407,7 @@ Neolution.Extensions.Caching/ ### Prerequisites -- .NET 6.0 SDK or later +- .NET 8.0 SDK or later ### Build diff --git a/src/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs b/src/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs index 660620b..f87f684 100644 --- a/src/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs +++ b/src/Neolution.Extensions.Caching.RedisHybrid/RedisHybridCache.cs @@ -31,11 +31,6 @@ public RedisHybridCache(ICacheClient cacheClient, IOptions diff --git a/tests/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs b/tests/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs index 0101edf..4090d50 100644 --- a/tests/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs +++ b/tests/Neolution.Extensions.Caching.UnitTests/MsgPackSerializerTests.cs @@ -141,7 +141,7 @@ public void Deserialize_InvalidData_ThrowsInvalidOperationException() var serializer = new MsgPackSerializer(); // Act & Assert - Should.Throw(() => + Should.Throw(() => { using var stream = new MemoryStream(new byte[] { 0xC0 }); // MessagePack nil serializer.Deserialize(stream, typeof(TestObject)); From cc945e69a76c6df16669abaa71ca846b6fe970cf Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 4 Aug 2026 13:28:10 +0000 Subject: [PATCH 22/23] Fixes README prerequisites and makes the schema version test meaningful The prerequisites still said ".NET 8.0 SDK or later", which reads as "8.0 is enough". Since the libraries multi-target net10.0, an 8.0 SDK cannot build them at all; the 8.0 runtime is only needed to run the net8.0 test leg. Now states both, matching what CI installs. DifferentVersionsProduceDifferentKeys built two providers each with its own AddDistributedMemoryCache, so the two caches never shared a store and the test passed regardless of whether the schema version reached the key. Both providers now share one MemoryDistributedCache: writing through the v1 cache must leave the v2 cache empty, and both entries must then coexist. Setting both providers to the same schema version fails the test, which it did not before. Addresses the remaining Copilot review comments on #8. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/shy-tools-see.md | 4 +++ README.md | 3 ++- .../CacheKeyVersioningTests.cs | 26 ++++++++++++++----- 3 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 .changeset/shy-tools-see.md diff --git a/.changeset/shy-tools-see.md b/.changeset/shy-tools-see.md new file mode 100644 index 0000000..dcb1cdf --- /dev/null +++ b/.changeset/shy-tools-see.md @@ -0,0 +1,4 @@ +--- +--- + +Docs: correct the SDK prerequisites in README for the net8.0/net10.0 multi-target. diff --git a/README.md b/README.md index 50d40db..8daa5ad 100644 --- a/README.md +++ b/README.md @@ -407,7 +407,8 @@ Neolution.Extensions.Caching/ ### Prerequisites -- .NET 8.0 SDK or later +- .NET 10.0 SDK — required to build the `net10.0` target (it also builds the `net8.0` one) +- .NET 8.0 runtime — required to run the `net8.0` test leg ### Build diff --git a/tests/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs b/tests/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs index a1d24d3..36aaeb7 100644 --- a/tests/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs +++ b/tests/Neolution.Extensions.Caching.UnitTests/CacheKeyVersioningTests.cs @@ -1,6 +1,7 @@ namespace Neolution.Extensions.Caching.UnitTests { using System; + using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Neolution.Extensions.Caching.Abstractions; using Neolution.Extensions.Caching.UnitTests.Models; @@ -87,18 +88,25 @@ public void MessagePackCacheKeyIncludesCustomVersion() } /// - /// Tests if different schema versions produce different cache keys + /// Tests if different schema versions produce different cache keys. + /// Both caches deliberately share one backing store, so a key collision between the two + /// schema versions would be observable instead of hidden behind separate memory caches. /// [Fact] public void DifferentVersionsProduceDifferentKeys() { - // Arrange - Create two service providers with different schema versions + // Arrange - One backing store, shared by two providers with different schema versions + var backingServices = new ServiceCollection(); + backingServices.AddDistributedMemoryCache(); + using var backingProvider = backingServices.BuildServiceProvider(); + var backingStore = backingProvider.GetRequiredService(); + var servicesV1 = new ServiceCollection(); - servicesV1.AddDistributedMemoryCache(); + servicesV1.AddSingleton(backingStore); servicesV1.AddSerializedDistributedCache(options => options.SchemaVersion = 1); var servicesV2 = new ServiceCollection(); - servicesV2.AddDistributedMemoryCache(); + servicesV2.AddSingleton(backingStore); servicesV2.AddSerializedDistributedCache(options => options.SchemaVersion = 2); using var providerV1 = servicesV1.BuildServiceProvider(); @@ -110,12 +118,16 @@ public void DifferentVersionsProduceDifferentKeys() var valueV1 = "Value in V1"; var valueV2 = "Value in V2"; - // Act - Set values in both caches + // Act - Write only through the v1 cache cacheV1.Set(TestCacheId.Foobar, valueV1); + + // Assert - The v2 cache must not see the v1 entry, because the key carries the schema version + cacheV2.Get(TestCacheId.Foobar).ShouldBeNull(); + + // Act - Write the same cache id through the v2 cache cacheV2.Set(TestCacheId.Foobar, valueV2); - // Assert - Each cache should only see its own value - // Note: This test demonstrates isolation, but with separate providers they use separate memory caches + // Assert - Both entries coexist in the one store, so the keys really are different cacheV1.Get(TestCacheId.Foobar).ShouldBe(valueV1); cacheV2.Get(TestCacheId.Foobar).ShouldBe(valueV2); } From 755ebeeb579583d62193fa98dbdb8ffc168898c6 Mon Sep 17 00:00:00 2001 From: Sandro Ciervo Date: Tue, 4 Aug 2026 13:59:59 +0000 Subject: [PATCH 23/23] Consolidates the v3 changeset and documents dependency and prerelease details Folds the MessagePack/Foundatio changeset into the v3 release changeset so the published release notes read as one entry instead of two, and corrects the text: the packages multi-target net8.0 and net10.0 rather than "targeting .NET 8", the netstandard2.0 drop is listed as a breaking change, and key encoding and key length validation are on by default rather than opt-in. Documents two things that were previously undocumented: MessagePack moves from 2.5.x to 3.1.7, which pulls consumers that reference it directly across a major version, and how prerelease mode differs from the regular release flow. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/cool-cycles-wear.md | 10 ---------- .changeset/hot-coins-build.md | 30 ++++++++++++++++++------------ docs/CHANGESETS.md | 6 ++++++ docs/MIGRATION_GUIDE_V3.md | 7 +++++++ 4 files changed, 31 insertions(+), 22 deletions(-) delete mode 100644 .changeset/cool-cycles-wear.md diff --git a/.changeset/cool-cycles-wear.md b/.changeset/cool-cycles-wear.md deleted file mode 100644 index 7c7d769..0000000 --- a/.changeset/cool-cycles-wear.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@neolution-ch/neolution.extensions.caching.abstractions": major -"@neolution-ch/neolution.extensions.caching.distributed": major -"@neolution-ch/neolution.extensions.caching.redishybrid": major -"@neolution-ch/neolution.extensions.caching.inmemory": major ---- - -Target net8.0 and net10.0, and drop .NET Standard 2.0. Bump Foundatio to 13.0.2, which brings MessagePack 3.1.7 and fixes security advisories GHSA-hv8m-jj95-wg3x, GHSA-vh6j-jc39-fggf and GHSA-382j-8mxh-c7x2. - -Consumers must target .NET 8.0 or later; projects on .NET Framework, .NET Standard 2.0 or .NET 6.0/7.0 must stay on v2.x. See `docs/MIGRATION_GUIDE_V3.md`. diff --git a/.changeset/hot-coins-build.md b/.changeset/hot-coins-build.md index 904e643..947c35f 100644 --- a/.changeset/hot-coins-build.md +++ b/.changeset/hot-coins-build.md @@ -5,18 +5,24 @@ "@neolution-ch/neolution.extensions.caching.inmemory": major --- -v3.0 release. See `docs/MIGRATION_GUIDE_V3.md` for the migration walkthrough. +Major version bump multi-targeting .NET 8 and .NET 10, with several breaking changes and new configuration options for distributed caches. -**Breaking:** -- Options classes inherit from `DistributedCacheOptionsBase` instead of implementing `IOptions`. -- `AddMessagePackDistributedCache()` renamed to `AddSerializedDistributedCache()` (old method kept as `[Obsolete]`). -- `AddSerializedDistributedCache()` now throws at registration time if no `IDistributedCache` provider is registered first. -- Redis hybrid cache registration now takes a `RedisHybridCacheOptions` parameter instead of inline setup. -- Extension methods return `IServiceCollection` for chaining. +**Breaking changes:** +- **.NET Standard 2.0 support dropped.** Packages now target `net8.0` and `net10.0`; projects on .NET Framework, .NET Standard 2.0 or .NET 6.0/7.0 must stay on v2.x +- `MessagePack` moves from 2.5.x to 3.1.7 (via `Foundatio` 13.0.2). If you reference `MessagePack` directly, NuGet unifies it to 3.x for your project too +- Options classes now inherit from a shared `DistributedCacheOptionsBase` instead of implementing `IOptions` +- `AddMessagePackDistributedCache()` renamed to `AddSerializedDistributedCache()` (old method kept as `[Obsolete]`) +- Registration now throws immediately if no `IDistributedCache` provider is registered first +- Redis hybrid cache registration now takes a `RedisHybridCacheOptions` parameter instead of inline setup +- All extension methods now return `IServiceCollection` for fluent chaining -**New (opt-in):** -- `EnableKeyEncoding`, `EnableKeyLengthValidation`, `SchemaVersion`, `EnvironmentPrefix` configuration options. -- `EnableCompression` on `RedisHybridCacheOptions` (controls MessagePack compression). -- `[CacheKey("name")]` attribute on enum members for refactor-safe distributed cache keys. +**New features:** +- `SchemaVersion` and `EnvironmentPrefix` options for cache invalidation and multi-environment isolation +- `[CacheKey]` attribute on enum members for refactor-safe distributed cache keys +- Configurable key encoding and key length validation (both enabled by default for distributed caches) +- Redis hybrid cache now exposes `RedisHybridCacheOptions` with compression control and shared multiplexer support -v2.x cache entries remain readable with default settings. +**Security:** +- `Foundatio` 13.0.2 brings `MessagePack` 3.1.7, fixing GHSA-hv8m-jj95-wg3x, GHSA-vh6j-jc39-fggf and GHSA-382j-8mxh-c7x2 (High) plus nine lower-severity advisories that affected the 3.1.4 pinned by `Foundatio` 12.x + +See `docs/MIGRATION_GUIDE_V3.md` for the full migration walkthrough. Existing v2.x cache entries remain readable with default settings. diff --git a/docs/CHANGESETS.md b/docs/CHANGESETS.md index c71644f..b59ef70 100644 --- a/docs/CHANGESETS.md +++ b/docs/CHANGESETS.md @@ -214,6 +214,12 @@ Once on `main`, every Version Packages PR bumps the beta counter. Exit with `npx Consumers on `dotnet add package Neolution.Extensions.Caching.Abstractions` keep getting the latest stable (e.g. `2.1.1`); they have to ask for the prerelease explicitly with `--prerelease` or `--version 3.0.0-beta.0`. +Differences from the [regular flow](#regular-release-flow): + +- Changeset files are kept until `pre exit` instead of being deleted by the Version Packages PR, so the stable release gets a complete changelog. +- `.changeset/pre.json` is CLI-owned state: it tracks which changesets have already shipped, plus an `initialVersions` anchor that keeps repeated bumps from compounding (`3.0.0-beta.0` → `beta.1`, never `4.0.0`). Never edit it by hand. +- If every pending changeset is empty, nothing is released at all — no Version PR, no publish. + ### Backporting on `release/vX.x` branches When `main` has moved on to a new major (e.g. v3) but a fix is needed for the old major (v2), use a `release/v2.x` branch. The Release workflow auto-creates `release/v(N-1).x` whenever a new major is published from `main` — see `scripts:` block in `release.yml`. For all other cases (manually creating it retroactively, when to use it, what a backport PR looks like), follow the [Backporting section of the canonical doc](https://github.com/neolution-ch/changeset-test/blob/main/docs/CHANGESETS.md#backporting--hotfixes-on-older-versions) — both `ci.yml` and `release.yml` already trigger on `release/**`. diff --git a/docs/MIGRATION_GUIDE_V3.md b/docs/MIGRATION_GUIDE_V3.md index c2f4a07..e6fe483 100644 --- a/docs/MIGRATION_GUIDE_V3.md +++ b/docs/MIGRATION_GUIDE_V3.md @@ -10,6 +10,12 @@ All four packages now multi-target **.NET 8.0** and **.NET 10.0**, and no longer **Impact**: Projects targeting .NET Framework, .NET Standard 2.0, or .NET 6.0/7.0 can no longer reference v3.0 and will fail to restore with `NU1202`. Your project must target .NET 8.0 or later. Stay on v2.x if you need .NET Standard 2.0. +### Dependency: MessagePack 2.5.x → 3.1.7 + +v2.x referenced `MessagePack` 2.5.x directly. v3.0 gets `MessagePack` 3.1.7 transitively through `Foundatio` 13.0.2, which also clears the advisories affecting 3.1.4 (GHSA-hv8m-jj95-wg3x, GHSA-vh6j-jc39-fggf, GHSA-382j-8mxh-c7x2). + +**Impact**: If your project references `MessagePack` itself, NuGet resolves the whole graph to 3.x, so your own serialization code is upgraded across a major version too. Review the [MessagePack v3 release notes](https://github.com/MessagePack-CSharp/MessagePack-CSharp/releases) if you use its API directly. Projects that only consume this library's cache interfaces are unaffected. + ### Options Pattern Correction Options classes now properly inherit from `DistributedCacheOptionsBase` instead of implementing `IOptions`. @@ -222,6 +228,7 @@ Minimal impact: v3.0 improves configuration and error handling for distributed cache: - .NET 8.0 and .NET 10.0 targets (.NET Standard 2.0 dropped) +- `MessagePack` 3.1.7 via `Foundatio` 13.0.2, clearing three High-severity advisories - Better error messages (early validation) - Exposed configuration properties - Fluent API support