Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,8 @@ public void Set<T>(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))
Expand All @@ -128,7 +129,7 @@ public void Set<T>(CacheKey key, T value, CacheEntryOptions? options = null)
return;
}

var distributedOptions = ToDistributedOptions(entryOptions);
var distributedOptions = ToDistributedOptions(entryOptions, relativeExpiration);
_distributedCache.Set(key.FullKey, payload, distributedOptions);
}

Expand All @@ -142,7 +143,8 @@ public Task SetAsync<T>(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))
Expand All @@ -151,7 +153,7 @@ public Task SetAsync<T>(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);
}

Expand Down Expand Up @@ -270,24 +272,24 @@ 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<T> CreateEnvelope<T>(T value, CacheEntryOptions options)
private DistributedStoredEntry<T> CreateEnvelope<T>(T value, CacheEntryOptions options, TimeSpan? relativeExpiration)
{
var createdAt = _timeProvider.GetUtcNow();
DateTimeOffset? expiresAt = null;
if (options.AbsoluteExpiration.HasValue)
{
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<T>(value, createdAt, expiresAt, options, _serializer.ContentType);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,18 +96,37 @@ public void Set<T>(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);
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -300,6 +314,11 @@ private bool ExceedsSizeLimit(byte[] payload, CacheEntryOptions entryOptions)
return typed;
}

if (stored.Payload is null)
{
return default;
}

try
{
return _serializer.Deserialize<T>(stored.Payload);
Expand Down Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -54,21 +54,24 @@ public static IServiceCollection AddCleanArchitectureCaching(
var options = sp.GetRequiredService<IOptions<CachingOptions>>().Value;
var serializers = sp.GetServices<ICacheSerializer>();
var distributedCache = sp.GetService<IDistributedCache>();
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<DistributedCacheAdapter>(sp, serializers);
}

Expand Down
3 changes: 2 additions & 1 deletion src/CleanArchitecture.Extensions.Caching/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,19 @@ public void Throws_when_distributed_backend_selected_without_distributed_cache()
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<ICache>());
}

[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<InvalidOperationException>(() => provider.GetRequiredService<ICache>());
}

private sealed class FakeDistributedCache : IDistributedCache
{
public byte[]? Get(string key) => null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,23 @@ public void Throws_when_preferred_serializer_is_missing()
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<ICache>());
}

[Fact]
public void Stores_non_serializable_values_without_throwing()
{
using var provider = BuildProvider();
var cache = provider.GetRequiredService<ICache>();
var keyFactory = provider.GetRequiredService<ICacheKeyFactory>();
var key = keyFactory.Create("Resource", keyFactory.CreateHash(new { id = 9 }));
var value = new SelfReferencing();

cache.Set(key, value);

var cached = cache.Get<SelfReferencing>(key);

Assert.NotNull(cached);
Assert.Same(value, cached!.Value);
}

private sealed class TestCacheSerializer : ICacheSerializer
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
Expand All @@ -131,4 +148,9 @@ private sealed class TestCacheSerializer : ICacheSerializer

public T? Deserialize<T>(ReadOnlySpan<byte> payload) => JsonSerializer.Deserialize<T>(payload, SerializerOptions);
}

private sealed class SelfReferencing
{
public SelfReferencing Self => this;
}
}
Loading