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
41 changes: 41 additions & 0 deletions framework/SimpleModule.Core/Caching/CacheEntryOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
namespace SimpleModule.Core.Caching;

/// <summary>
/// Implementation-agnostic options describing how an entry should be retained in the cache.
/// </summary>
public sealed class CacheEntryOptions
{
/// <summary>
/// Lifetime relative to the time the entry is written. Mutually exclusive with
/// <see cref="AbsoluteExpiration"/>.
/// </summary>
public TimeSpan? AbsoluteExpirationRelativeToNow { get; init; }

/// <summary>
/// An absolute point in time at which the entry expires. Mutually exclusive with
/// <see cref="AbsoluteExpirationRelativeToNow"/>.
/// </summary>
public DateTimeOffset? AbsoluteExpiration { get; init; }

/// <summary>
/// Sliding expiration window. The entry is evicted if it is not accessed within this window.
/// </summary>
public TimeSpan? SlidingExpiration { get; init; }

/// <summary>
/// Optional size hint, used by stores that enforce a size limit.
/// </summary>
public long? Size { get; init; }

/// <summary>
/// Creates options that expire after the supplied duration.
/// </summary>
public static CacheEntryOptions Expires(TimeSpan duration) =>
new() { AbsoluteExpirationRelativeToNow = duration };

/// <summary>
/// Creates options with a sliding expiration window.
/// </summary>
public static CacheEntryOptions Sliding(TimeSpan window) =>
new() { SlidingExpiration = window };
}
19 changes: 19 additions & 0 deletions framework/SimpleModule.Core/Caching/CacheKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace SimpleModule.Core.Caching;

/// <summary>
/// Helpers for composing consistent cache keys.
/// </summary>
public static class CacheKey
{
/// <summary>
/// Joins the supplied parts with <c>:</c>, skipping null or empty segments.
/// </summary>
/// <example>
/// <code>CacheKey.Compose("settings", scope.ToString(), userId, key);</code>
/// </example>
public static string Compose(params string?[] parts)
{
ArgumentNullException.ThrowIfNull(parts);
return string.Join(':', parts.Where(p => !string.IsNullOrEmpty(p)));
}
}
24 changes: 24 additions & 0 deletions framework/SimpleModule.Core/Caching/CacheResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
namespace SimpleModule.Core.Caching;

/// <summary>
/// Result of a cache lookup. Distinguishes a miss from a hit that contains a <see langword="null"/>
/// value (negative caching).
/// </summary>
/// <typeparam name="T">The cached value type.</typeparam>
public readonly record struct CacheResult<T>(bool Hit, T? Value);

/// <summary>
/// Non-generic helpers for constructing <see cref="CacheResult{T}"/> values.
/// </summary>
public static class CacheResult
{
/// <summary>
/// Creates a miss result for type <typeparamref name="T"/>.
/// </summary>
public static CacheResult<T> Miss<T>() => default;

/// <summary>
/// Creates a hit result with the supplied value (which may be <see langword="null"/>).
/// </summary>
public static CacheResult<T> Hit<T>(T? value) => new(true, value);
}
40 changes: 40 additions & 0 deletions framework/SimpleModule.Core/Caching/CacheStoreExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
namespace SimpleModule.Core.Caching;

/// <summary>
/// Convenience extensions over <see cref="ICacheStore"/>.
/// </summary>
public static class CacheStoreExtensions
{
/// <summary>
/// Returns a view over the store where every key is automatically prefixed with
/// <paramref name="prefix"/> (joined with <c>:</c>). Useful for module- or tenant-scoped
/// cache namespacing without forcing every call site to remember the prefix.
/// </summary>
public static ICacheStore WithPrefix(this ICacheStore store, string prefix)
{
ArgumentNullException.ThrowIfNull(store);
return new PrefixedCacheStore(store, prefix);
}

/// <summary>
/// Synchronous-style helper for the common pattern <c>var v = await cache.GetOrCreateAsync(...)</c>
/// where the factory is itself synchronous.
/// </summary>
public static ValueTask<T?> GetOrCreateAsync<T>(
this ICacheStore store,
string key,
Func<T?> factory,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default
)
{
ArgumentNullException.ThrowIfNull(store);
ArgumentNullException.ThrowIfNull(factory);
return store.GetOrCreateAsync(
key,
_ => new ValueTask<T?>(factory()),
options,
cancellationToken
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;

namespace SimpleModule.Core.Caching;

/// <summary>
/// DI registration for the unified SimpleModule caching abstraction.
/// </summary>
public static class CachingServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="ICacheStore"/> with the default in-process
/// <see cref="MemoryCacheStore"/> implementation, along with the underlying
/// <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/>. Safe to call
/// multiple times — registrations are added with <c>TryAdd</c>.
/// </summary>
public static IServiceCollection AddSimpleModuleCaching(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddMemoryCache();
services.TryAddSingleton<ICacheStore, MemoryCacheStore>();
return services;
}
}
55 changes: 55 additions & 0 deletions framework/SimpleModule.Core/Caching/ICacheStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace SimpleModule.Core.Caching;

/// <summary>
/// Unified caching abstraction used across SimpleModule modules.
/// </summary>
/// <remarks>
/// The default registration is an in-process <c>MemoryCacheStore</c> backed by
/// <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/>. The interface is intentionally
/// async-first so that distributed implementations (Redis, etc.) can be plugged in without
/// changing call sites.
/// </remarks>
public interface ICacheStore
{
/// <summary>
/// Looks up an entry. Returns a <see cref="CacheResult{T}"/> that distinguishes a miss from a
/// hit containing a <see langword="null"/> value (negative caching).
/// </summary>
ValueTask<CacheResult<T>> TryGetAsync<T>(
string key,
CancellationToken cancellationToken = default
);

/// <summary>
/// Writes an entry, replacing any existing value for <paramref name="key"/>.
/// </summary>
ValueTask SetAsync<T>(
string key,
T? value,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default
);

/// <summary>
/// Returns the cached value for <paramref name="key"/>, invoking <paramref name="factory"/>
/// to populate the cache on a miss. Implementations must guard against cache stampedes —
/// concurrent callers for the same key see <paramref name="factory"/> invoked at most once.
/// </summary>
ValueTask<T?> GetOrCreateAsync<T>(
string key,
Func<CancellationToken, ValueTask<T?>> factory,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default
);

/// <summary>
/// Removes a single entry. No-op if the key is absent.
/// </summary>
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);

/// <summary>
/// Removes every entry whose key starts with <paramref name="prefix"/>. Useful for
/// invalidating a logical group (e.g., all entries for a user, tenant, or module).
/// </summary>
ValueTask RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
}
Loading
Loading