From a3e6b45be18fda94929fd987cd73afe088f306f0 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Wed, 4 Feb 2026 12:38:49 +0300 Subject: [PATCH 1/2] fix: harden caching backends and entry metadata - prevent Backend.Distributed from accepting MemoryDistributedCache to avoid per-node behavior - align distributed entry ExpiresAt with jittered TTL - avoid eager serialization in memory adapter; skip store on serialization failure - add tests for backend guard and non-serializable memory entries --- .../Adapters/DistributedCacheAdapter.cs | 20 +++++----- .../Adapters/MemoryCacheAdapter.cs | 39 ++++++++++++++----- .../DependencyInjectionExtensions.cs | 15 ++++--- .../DependencyInjectionExtensionsTests.cs | 13 +++++++ .../MemoryCacheAdapterTests.cs | 22 +++++++++++ 5 files changed, 84 insertions(+), 25 deletions(-) diff --git a/src/CleanArchitecture.Extensions.Caching/Adapters/DistributedCacheAdapter.cs b/src/CleanArchitecture.Extensions.Caching/Adapters/DistributedCacheAdapter.cs index 31667e0..a4ff385 100644 --- a/src/CleanArchitecture.Extensions.Caching/Adapters/DistributedCacheAdapter.cs +++ b/src/CleanArchitecture.Extensions.Caching/Adapters/DistributedCacheAdapter.cs @@ -119,7 +119,8 @@ public void Set(CacheKey key, T value, CacheEntryOptions? options = null) } var entryOptions = ResolveEntryOptions(options); - var envelope = CreateEnvelope(value, entryOptions); + var relativeExpiration = ApplyJitter(entryOptions.AbsoluteExpirationRelativeToNow, _options.StampedePolicy?.Jitter); + var envelope = CreateEnvelope(value, entryOptions, relativeExpiration); var payload = _serializer.Serialize(envelope); if (ExceedsSizeLimit(payload)) @@ -128,7 +129,7 @@ public void Set(CacheKey key, T value, CacheEntryOptions? options = null) return; } - var distributedOptions = ToDistributedOptions(entryOptions); + var distributedOptions = ToDistributedOptions(entryOptions, relativeExpiration); _distributedCache.Set(key.FullKey, payload, distributedOptions); } @@ -142,7 +143,8 @@ public Task SetAsync(CacheKey key, T value, CacheEntryOptions? options = null } var entryOptions = ResolveEntryOptions(options); - var envelope = CreateEnvelope(value, entryOptions); + var relativeExpiration = ApplyJitter(entryOptions.AbsoluteExpirationRelativeToNow, _options.StampedePolicy?.Jitter); + var envelope = CreateEnvelope(value, entryOptions, relativeExpiration); var payload = _serializer.Serialize(envelope); if (ExceedsSizeLimit(payload)) @@ -151,7 +153,7 @@ public Task SetAsync(CacheKey key, T value, CacheEntryOptions? options = null return Task.CompletedTask; } - var distributedOptions = ToDistributedOptions(entryOptions); + var distributedOptions = ToDistributedOptions(entryOptions, relativeExpiration); return _distributedCache.SetAsync(key.FullKey, payload, distributedOptions, cancellationToken); } @@ -270,14 +272,14 @@ public Task RemoveAsync(CacheKey key, CancellationToken cancellationToken = defa private bool ExceedsSizeLimit(byte[] payload) => _options.MaxEntrySizeBytes.HasValue && payload.LongLength > _options.MaxEntrySizeBytes.Value; - private DistributedCacheEntryOptions ToDistributedOptions(CacheEntryOptions options) => new() + private static DistributedCacheEntryOptions ToDistributedOptions(CacheEntryOptions options, TimeSpan? relativeExpiration) => new() { AbsoluteExpiration = options.AbsoluteExpiration, - AbsoluteExpirationRelativeToNow = ApplyJitter(options.AbsoluteExpirationRelativeToNow, _options.StampedePolicy?.Jitter), + AbsoluteExpirationRelativeToNow = relativeExpiration, SlidingExpiration = options.SlidingExpiration }; - private DistributedStoredEntry CreateEnvelope(T value, CacheEntryOptions options) + private DistributedStoredEntry CreateEnvelope(T value, CacheEntryOptions options, TimeSpan? relativeExpiration) { var createdAt = _timeProvider.GetUtcNow(); DateTimeOffset? expiresAt = null; @@ -285,9 +287,9 @@ private DistributedStoredEntry CreateEnvelope(T value, CacheEntryOptions o { expiresAt = options.AbsoluteExpiration; } - else if (options.AbsoluteExpirationRelativeToNow.HasValue) + else if (relativeExpiration.HasValue) { - expiresAt = createdAt.Add(options.AbsoluteExpirationRelativeToNow.Value); + expiresAt = createdAt.Add(relativeExpiration.Value); } return new DistributedStoredEntry(value, createdAt, expiresAt, options, _serializer.ContentType); diff --git a/src/CleanArchitecture.Extensions.Caching/Adapters/MemoryCacheAdapter.cs b/src/CleanArchitecture.Extensions.Caching/Adapters/MemoryCacheAdapter.cs index e56fd10..f9a92cc 100644 --- a/src/CleanArchitecture.Extensions.Caching/Adapters/MemoryCacheAdapter.cs +++ b/src/CleanArchitecture.Extensions.Caching/Adapters/MemoryCacheAdapter.cs @@ -96,18 +96,37 @@ public void Set(CacheKey key, T value, CacheEntryOptions? options = null) } var entryOptions = ResolveEntryOptions(options); - var payload = _serializer.Serialize(value); - if (ExceedsSizeLimit(payload, entryOptions)) + if (ExceedsConfiguredSizeLimit(entryOptions)) { _logger.LogWarning("Cache entry for {Key} exceeded maximum size; skipping store.", key.FullKey); return; } + if (_options.MaxEntrySizeBytes.HasValue) + { + byte[] payload; + try + { + payload = _serializer.Serialize(value); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to serialize cache entry for {Key}; skipping store.", key.FullKey); + return; + } + + if (payload.LongLength > _options.MaxEntrySizeBytes.Value) + { + _logger.LogWarning("Cache entry for {Key} exceeded maximum size; skipping store.", key.FullKey); + return; + } + } + var memoryOptions = ToMemoryOptions(entryOptions); var createdAt = _timeProvider.GetUtcNow(); var expiresAt = ResolveExpiresAt(createdAt, memoryOptions); - var stored = new StoredCacheEntry(payload, _serializer.ContentType, createdAt, expiresAt, entryOptions, value); + var stored = new StoredCacheEntry(null, _serializer.ContentType, createdAt, expiresAt, entryOptions, value); _memoryCache.Set(key.FullKey, stored, memoryOptions); } @@ -263,13 +282,8 @@ private MemoryCacheEntryOptions ToMemoryOptions(CacheEntryOptions entryOptions) private CacheEntryOptions ResolveEntryOptions(CacheEntryOptions? options) => options ?? _options.DefaultEntryOptions ?? CacheEntryOptions.Default; - private bool ExceedsSizeLimit(byte[] payload, CacheEntryOptions entryOptions) + private bool ExceedsConfiguredSizeLimit(CacheEntryOptions entryOptions) { - if (_options.MaxEntrySizeBytes.HasValue && payload.LongLength > _options.MaxEntrySizeBytes.Value) - { - return true; - } - if (entryOptions.Size.HasValue && _options.MaxEntrySizeBytes.HasValue && entryOptions.Size.Value > _options.MaxEntrySizeBytes.Value) { return true; @@ -300,6 +314,11 @@ private bool ExceedsSizeLimit(byte[] payload, CacheEntryOptions entryOptions) return typed; } + if (stored.Payload is null) + { + return default; + } + try { return _serializer.Deserialize(stored.Payload); @@ -328,5 +347,5 @@ private bool ExceedsSizeLimit(byte[] payload, CacheEntryOptions entryOptions) return baseValue.Value + TimeSpan.FromMilliseconds(offsetMs); } - private sealed record StoredCacheEntry(byte[] Payload, string? ContentType, DateTimeOffset CreatedAt, DateTimeOffset? ExpiresAt, CacheEntryOptions Options, object? Value); + private sealed record StoredCacheEntry(byte[]? Payload, string? ContentType, DateTimeOffset CreatedAt, DateTimeOffset? ExpiresAt, CacheEntryOptions Options, object? Value); } diff --git a/src/CleanArchitecture.Extensions.Caching/DependencyInjectionExtensions.cs b/src/CleanArchitecture.Extensions.Caching/DependencyInjectionExtensions.cs index 185fdb2..86bb669 100644 --- a/src/CleanArchitecture.Extensions.Caching/DependencyInjectionExtensions.cs +++ b/src/CleanArchitecture.Extensions.Caching/DependencyInjectionExtensions.cs @@ -54,21 +54,24 @@ public static IServiceCollection AddCleanArchitectureCaching( var options = sp.GetRequiredService>().Value; var serializers = sp.GetServices(); var distributedCache = sp.GetService(); + var isMemoryDistributed = distributedCache is MemoryDistributedCache; + + if (options.Backend == CacheBackend.Distributed && (distributedCache is null || isMemoryDistributed)) + { + throw new InvalidOperationException( + "CachingOptions.Backend is set to Distributed but the registered IDistributedCache is missing or uses MemoryDistributedCache. " + + "Register a real distributed cache or set Backend to Memory/Auto."); + } var useDistributed = options.Backend switch { CacheBackend.Distributed => true, CacheBackend.Memory => false, - _ => distributedCache is not null && distributedCache is not MemoryDistributedCache + _ => distributedCache is not null && !isMemoryDistributed }; if (useDistributed) { - if (distributedCache is null) - { - throw new InvalidOperationException("CachingOptions.Backend is set to Distributed but no IDistributedCache is registered."); - } - return ActivatorUtilities.CreateInstance(sp, serializers); } diff --git a/tests/CleanArchitecture.Extensions.Caching.Tests/DependencyInjectionExtensionsTests.cs b/tests/CleanArchitecture.Extensions.Caching.Tests/DependencyInjectionExtensionsTests.cs index 38b55cf..f98c886 100644 --- a/tests/CleanArchitecture.Extensions.Caching.Tests/DependencyInjectionExtensionsTests.cs +++ b/tests/CleanArchitecture.Extensions.Caching.Tests/DependencyInjectionExtensionsTests.cs @@ -57,6 +57,19 @@ public void Throws_when_distributed_backend_selected_without_distributed_cache() Assert.Throws(() => provider.GetRequiredService()); } + [Fact] + public void Throws_when_distributed_backend_selected_with_memory_distributed_cache() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddCleanArchitectureCaching(options => options.Backend = CacheBackend.Distributed); + + using var provider = services.BuildServiceProvider(); + + Assert.Throws(() => provider.GetRequiredService()); + } + private sealed class FakeDistributedCache : IDistributedCache { public byte[]? Get(string key) => null; diff --git a/tests/CleanArchitecture.Extensions.Caching.Tests/MemoryCacheAdapterTests.cs b/tests/CleanArchitecture.Extensions.Caching.Tests/MemoryCacheAdapterTests.cs index 64ab8b9..7a33860 100644 --- a/tests/CleanArchitecture.Extensions.Caching.Tests/MemoryCacheAdapterTests.cs +++ b/tests/CleanArchitecture.Extensions.Caching.Tests/MemoryCacheAdapterTests.cs @@ -118,6 +118,23 @@ public void Throws_when_preferred_serializer_is_missing() Assert.Throws(() => provider.GetRequiredService()); } + [Fact] + public void Stores_non_serializable_values_without_throwing() + { + using var provider = BuildProvider(); + var cache = provider.GetRequiredService(); + var keyFactory = provider.GetRequiredService(); + var key = keyFactory.Create("Resource", keyFactory.CreateHash(new { id = 9 })); + var value = new SelfReferencing(); + + cache.Set(key, value); + + var cached = cache.Get(key); + + Assert.NotNull(cached); + Assert.Same(value, cached!.Value); + } + private sealed class TestCacheSerializer : ICacheSerializer { private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) @@ -131,4 +148,9 @@ private sealed class TestCacheSerializer : ICacheSerializer public T? Deserialize(ReadOnlySpan payload) => JsonSerializer.Deserialize(payload, SerializerOptions); } + + private sealed class SelfReferencing + { + public SelfReferencing Self => this; + } } From aaf2dd2c719acb06d01ff8d3d2e6d861b9fc96d6 Mon Sep 17 00:00:00 2001 From: Reza Heidari Date: Wed, 4 Feb 2026 12:39:05 +0300 Subject: [PATCH 2/2] docs: clarify distributed backend requirements - note that MemoryDistributedCache is treated as non-distributed --- src/CleanArchitecture.Extensions.Caching/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/CleanArchitecture.Extensions.Caching/README.md b/src/CleanArchitecture.Extensions.Caching/README.md index 064cda1..d5e56bc 100644 --- a/src/CleanArchitecture.Extensions.Caching/README.md +++ b/src/CleanArchitecture.Extensions.Caching/README.md @@ -29,7 +29,8 @@ public static void AddInfrastructureServices(this IHostApplicationBuilder builde { options.DefaultNamespace = "MyApp"; options.MaxEntrySizeBytes = 256 * 1024; - // Set Backend = CacheBackend.Distributed to force shared cache when IDistributedCache is configured. + // Set Backend = CacheBackend.Distributed to force shared cache when a real IDistributedCache (e.g., Redis) is configured. + // MemoryDistributedCache is treated as non-distributed; use Backend.Memory or Backend.Auto for single-node setups. }, queryOptions => { queryOptions.DefaultTtl = TimeSpan.FromMinutes(5);