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
29 changes: 27 additions & 2 deletions src/BuildingBlocks/Caching/CacheServiceExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
namespace FSH.Framework.Caching;
namespace FSH.Framework.Caching;

/// <summary>
/// Extension methods for <see cref="ICacheService"/> providing cache-aside pattern implementations.
/// </summary>
public static class CacheServiceExtensions
{
/// <summary>
/// Gets an item from cache, or sets it using the provided callback if not found.
/// Implements the cache-aside pattern synchronously.
/// </summary>
/// <typeparam name="T">The type of the cached item.</typeparam>
/// <param name="cache">The cache service instance.</param>
/// <param name="key">The unique cache key.</param>
/// <param name="getItemCallback">A callback function to retrieve the item if not in cache.</param>
/// <param name="slidingExpiration">Optional sliding expiration for the cached item.</param>
/// <returns>The cached item or the newly retrieved and cached item.</returns>
public static T? GetOrSet<T>(this ICacheService cache, string key, Func<T?> getItemCallback, TimeSpan? slidingExpiration = null)
{
ArgumentNullException.ThrowIfNull(cache);
Expand All @@ -23,6 +37,17 @@ public static class CacheServiceExtensions
return value;
}

/// <summary>
/// Asynchronously gets an item from cache, or sets it using the provided task if not found.
/// Implements the cache-aside pattern asynchronously.
/// </summary>
/// <typeparam name="T">The type of the cached item.</typeparam>
/// <param name="cache">The cache service instance.</param>
/// <param name="key">The unique cache key.</param>
/// <param name="task">An async function to retrieve the item if not in cache.</param>
/// <param name="slidingExpiration">Optional sliding expiration for the cached item.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>The cached item or the newly retrieved and cached item.</returns>
public static async Task<T?> GetOrSetAsync<T>(this ICacheService cache, string key, Func<Task<T>> task, TimeSpan? slidingExpiration = null, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(cache);
Expand All @@ -44,4 +69,4 @@ public static class CacheServiceExtensions

return value;
}
}
}
36 changes: 35 additions & 1 deletion src/BuildingBlocks/Caching/DistributedCacheService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Text;
using System.Text.Json;

namespace FSH.Framework.Caching;

/// <summary>
/// Implementation of <see cref="ICacheService"/> using distributed cache (Redis or in-memory).
/// Provides JSON serialization for cached objects with configurable expiration policies.
/// </summary>
public sealed class DistributedCacheService : ICacheService
{
private static readonly Encoding Utf8 = Encoding.UTF8;
Expand All @@ -14,6 +19,12 @@ public sealed class DistributedCacheService : ICacheService
private readonly ILogger<DistributedCacheService> _logger;
private readonly CachingOptions _opts;

/// <summary>
/// Initializes a new instance of <see cref="DistributedCacheService"/>.
/// </summary>
/// <param name="cache">The underlying distributed cache implementation.</param>
/// <param name="logger">Logger for cache operations.</param>
/// <param name="opts">Caching configuration options.</param>
public DistributedCacheService(
IDistributedCache cache,
ILogger<DistributedCacheService> logger,
Expand All @@ -26,6 +37,7 @@ public DistributedCacheService(
_opts = opts.Value;
}

/// <inheritdoc />
public async Task<T?> GetItemAsync<T>(string key, CancellationToken ct = default)
{
key = Normalize(key);
Expand All @@ -42,6 +54,7 @@ public DistributedCacheService(
}
}

/// <inheritdoc />
public async Task SetItemAsync<T>(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default)
{
key = Normalize(key);
Expand All @@ -57,6 +70,7 @@ public async Task SetItemAsync<T>(string key, T value, TimeSpan? sliding = defau
}
}

/// <inheritdoc />
public async Task RemoveItemAsync(string key, CancellationToken ct = default)
{
key = Normalize(key);
Expand All @@ -65,6 +79,7 @@ public async Task RemoveItemAsync(string key, CancellationToken ct = default)
{ _logger.LogWarning(ex, "Cache remove failed for {Key}", key); }
}

/// <inheritdoc />
public async Task RefreshItemAsync(string key, CancellationToken ct = default)
{
key = Normalize(key);
Expand All @@ -76,11 +91,24 @@ public async Task RefreshItemAsync(string key, CancellationToken ct = default)
catch (Exception ex) when (ex is not OperationCanceledException)
{ _logger.LogWarning(ex, "Cache refresh failed for {Key}", key); }
}

/// <inheritdoc />
public T? GetItem<T>(string key) => GetItemAsync<T>(key).GetAwaiter().GetResult();

/// <inheritdoc />
public void SetItem<T>(string key, T value, TimeSpan? sliding = default) => SetItemAsync(key, value, sliding).GetAwaiter().GetResult();

/// <inheritdoc />
public void RemoveItem(string key) => RemoveItemAsync(key).GetAwaiter().GetResult();

/// <inheritdoc />
public void RefreshItem(string key) => RefreshItemAsync(key).GetAwaiter().GetResult();

/// <summary>
/// Builds cache entry options with configured expiration settings.
/// </summary>
/// <param name="sliding">Optional sliding expiration override.</param>
/// <returns>Configured cache entry options.</returns>
private DistributedCacheEntryOptions BuildEntryOptions(TimeSpan? sliding)
{
var o = new DistributedCacheEntryOptions();
Expand All @@ -96,6 +124,12 @@ private DistributedCacheEntryOptions BuildEntryOptions(TimeSpan? sliding)
return o;
}

/// <summary>
/// Normalizes the cache key by applying the configured prefix.
/// </summary>
/// <param name="key">The original cache key.</param>
/// <returns>The normalized key with prefix applied.</returns>
/// <exception cref="ArgumentNullException">Thrown when key is null or whitespace.</exception>
private string Normalize(string key)
{
if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key));
Expand Down
19 changes: 17 additions & 2 deletions src/BuildingBlocks/Caching/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,27 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StackExchange.Redis;

namespace FSH.Framework.Caching;

/// <summary>
/// Extension methods for registering caching services in the dependency injection container.
/// </summary>
public static class Extensions
{
/// <summary>
/// Adds FullStackHero caching services to the service collection.
/// Configures a hybrid L1/L2 cache with in-memory (L1) and Redis or distributed memory (L2).
/// </summary>
/// <param name="services">The service collection to add caching services to.</param>
/// <param name="configuration">The application configuration containing caching options.</param>
/// <returns>The service collection for chaining.</returns>
/// <remarks>
/// If Redis connection string is configured in <see cref="CachingOptions"/>, Redis is used for L2 cache.
/// Otherwise, falls back to in-memory distributed cache for L2.
/// The <see cref="HybridCacheService"/> is registered to provide both sync and async cache operations.
/// </remarks>
public static IServiceCollection AddHeroCaching(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(configuration);
Expand Down Expand Up @@ -47,4 +62,4 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services

return services;
}
}
}
53 changes: 53 additions & 0 deletions src/BuildingBlocks/Caching/HybridCacheService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@

namespace FSH.Framework.Caching;

/// <summary>
/// A hybrid cache implementation combining L1 (in-memory) and L2 (distributed) caching.
/// Provides fast local access with distributed cache backup for multi-instance scenarios.
/// </summary>
/// <remarks>
/// The hybrid approach uses memory cache for fast L1 access and automatically populates
/// it from the L2 distributed cache on cache misses. Write operations update both caches.
/// Memory cache uses 80% of the distributed cache sliding expiration for faster refresh.
/// </remarks>
public sealed class HybridCacheService : ICacheService
{
private static readonly Encoding Utf8 = Encoding.UTF8;
Expand All @@ -17,6 +26,13 @@ public sealed class HybridCacheService : ICacheService
private readonly ILogger<HybridCacheService> _logger;
private readonly CachingOptions _opts;

/// <summary>
/// Initializes a new instance of <see cref="HybridCacheService"/>.
/// </summary>
/// <param name="memoryCache">The L1 in-memory cache.</param>
/// <param name="distributedCache">The L2 distributed cache (Redis or memory-based).</param>
/// <param name="logger">Logger for cache operations.</param>
/// <param name="opts">Caching configuration options.</param>
public HybridCacheService(
IMemoryCache memoryCache,
IDistributedCache distributedCache,
Expand All @@ -31,6 +47,11 @@ public HybridCacheService(
_opts = opts.Value;
}

/// <inheritdoc />
/// <remarks>
/// First checks L1 memory cache, then falls back to L2 distributed cache.
/// If found in L2, the item is automatically populated into L1 for subsequent fast access.
/// </remarks>
public async Task<T?> GetItemAsync<T>(string key, CancellationToken ct = default)
{
key = Normalize(key);
Expand Down Expand Up @@ -66,6 +87,10 @@ public HybridCacheService(
}
}

/// <inheritdoc />
/// <remarks>
/// Writes to both L1 memory cache and L2 distributed cache simultaneously.
/// </remarks>
public async Task SetItemAsync<T>(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default)
{
key = Normalize(key);
Expand All @@ -86,6 +111,10 @@ public async Task SetItemAsync<T>(string key, T value, TimeSpan? sliding = defau
}
}

/// <inheritdoc />
/// <remarks>
/// Removes from both L1 memory cache and L2 distributed cache.
/// </remarks>
public async Task RemoveItemAsync(string key, CancellationToken ct = default)
{
key = Normalize(key);
Expand All @@ -102,6 +131,7 @@ public async Task RemoveItemAsync(string key, CancellationToken ct = default)
}
}

/// <inheritdoc />
public async Task RefreshItemAsync(string key, CancellationToken ct = default)
{
key = Normalize(key);
Expand All @@ -116,11 +146,23 @@ public async Task RefreshItemAsync(string key, CancellationToken ct = default)
}
}

/// <inheritdoc />
public T? GetItem<T>(string key) => GetItemAsync<T>(key).GetAwaiter().GetResult();

/// <inheritdoc />
public void SetItem<T>(string key, T value, TimeSpan? sliding = default) => SetItemAsync(key, value, sliding).GetAwaiter().GetResult();

/// <inheritdoc />
public void RemoveItem(string key) => RemoveItemAsync(key).GetAwaiter().GetResult();

/// <inheritdoc />
public void RefreshItem(string key) => RefreshItemAsync(key).GetAwaiter().GetResult();

/// <summary>
/// Builds distributed cache entry options with configured expiration settings.
/// </summary>
/// <param name="sliding">Optional sliding expiration override.</param>
/// <returns>Configured distributed cache entry options.</returns>
private DistributedCacheEntryOptions BuildDistributedEntryOptions(TimeSpan? sliding)
{
var o = new DistributedCacheEntryOptions();
Expand All @@ -136,6 +178,11 @@ private DistributedCacheEntryOptions BuildDistributedEntryOptions(TimeSpan? slid
return o;
}

/// <summary>
/// Gets memory cache expiration options, set to 80% of distributed cache expiration
/// for faster refresh cycles.
/// </summary>
/// <returns>Memory cache entry options with sliding expiration.</returns>
private MemoryCacheEntryOptions GetMemoryCacheExpiration()
{
var options = new MemoryCacheEntryOptions();
Expand All @@ -147,6 +194,12 @@ private MemoryCacheEntryOptions GetMemoryCacheExpiration()
return options;
}

/// <summary>
/// Normalizes the cache key by applying the configured prefix.
/// </summary>
/// <param name="key">The original cache key.</param>
/// <returns>The normalized key with prefix applied.</returns>
/// <exception cref="ArgumentNullException">Thrown when key is null or whitespace.</exception>
private string Normalize(string key)
{
if (string.IsNullOrWhiteSpace(key)) throw new ArgumentNullException(nameof(key));
Expand Down
62 changes: 60 additions & 2 deletions src/BuildingBlocks/Caching/ICacheService.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,70 @@
namespace FSH.Framework.Caching;
namespace FSH.Framework.Caching;

/// <summary>
/// Provides caching operations for storing and retrieving items from cache.
/// Supports both synchronous and asynchronous operations.
/// </summary>
public interface ICacheService
{
/// <summary>
/// Asynchronously retrieves an item from the cache.
/// </summary>
/// <typeparam name="T">The type of the cached item.</typeparam>
/// <param name="key">The unique cache key.</param>
/// <param name="ct">Cancellation token for the operation.</param>
/// <returns>The cached item if found; otherwise, null.</returns>
Task<T?> GetItemAsync<T>(string key, CancellationToken ct = default);

/// <summary>
/// Asynchronously stores an item in the cache.
/// </summary>
/// <typeparam name="T">The type of the item to cache.</typeparam>
/// <param name="key">The unique cache key.</param>
/// <param name="value">The value to cache.</param>
/// <param name="sliding">Optional sliding expiration. Uses default if not specified.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task SetItemAsync<T>(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default);

/// <summary>
/// Asynchronously removes an item from the cache.
/// </summary>
/// <param name="key">The unique cache key to remove.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task RemoveItemAsync(string key, CancellationToken ct = default);

/// <summary>
/// Asynchronously refreshes the sliding expiration of a cached item.
/// </summary>
/// <param name="key">The unique cache key to refresh.</param>
/// <param name="ct">Cancellation token for the operation.</param>
Task RefreshItemAsync(string key, CancellationToken ct = default);

/// <summary>
/// Retrieves an item from the cache synchronously.
/// </summary>
/// <typeparam name="T">The type of the cached item.</typeparam>
/// <param name="key">The unique cache key.</param>
/// <returns>The cached item if found; otherwise, null.</returns>
T? GetItem<T>(string key);

/// <summary>
/// Stores an item in the cache synchronously.
/// </summary>
/// <typeparam name="T">The type of the item to cache.</typeparam>
/// <param name="key">The unique cache key.</param>
/// <param name="value">The value to cache.</param>
/// <param name="sliding">Optional sliding expiration. Uses default if not specified.</param>
void SetItem<T>(string key, T value, TimeSpan? sliding = default);

/// <summary>
/// Removes an item from the cache synchronously.
/// </summary>
/// <param name="key">The unique cache key to remove.</param>
void RemoveItem(string key);

/// <summary>
/// Refreshes the sliding expiration of a cached item synchronously.
/// </summary>
/// <param name="key">The unique cache key to refresh.</param>
void RefreshItem(string key);
}
}
Loading
Loading