Skip to content
Open
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
16 changes: 12 additions & 4 deletions src/BuildingBlocks/Caching/CacheKeys.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ namespace FSH.Framework.Caching;

/// <summary>
/// Cache key conventions and tag constants used across the FullStackHero starter kit.
/// Keys should be tenant-scoped where applicable; tags enable bulk invalidation via
/// <see cref="Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveByTagAsync(string, System.Threading.CancellationToken)"/>.
/// Tenant scoping is applied automatically by <see cref="ITenantCacheService"/> —
/// keys returned by these methods are the <em>logical</em> (un-prefixed) key.
/// Tags enable bulk invalidation via HybridCache's tag system.
/// </summary>
public static class CacheKeys
{
Expand Down Expand Up @@ -35,8 +36,15 @@ public static class Tags
/// <summary>Key for the system-wide default theme.</summary>
public const string DefaultTheme = "theme:default";

/// <summary>Key for an idempotency replay entry, scoped by tenant.</summary>
public static string IdempotencyEntry(string tenantId, string key) => $"idem:t:{tenantId}:{key}";
/// <summary>Logical key for an idempotency replay entry (no tenant prefix — applied by ITenantCacheService).</summary>
public static string IdempotencyEntry(string key) => $"idem:{key}";

/// <summary>
/// Fully-qualified idempotency key including the tenant segment, used when reading
/// directly from <see cref="Microsoft.Extensions.Caching.Distributed.IDistributedCache"/>
/// to match the key written by <see cref="ITenantCacheService"/> (which uses prefix <c>t:{tenantId}:</c>).
/// </summary>
public static string IdempotencyEntryFull(string tenantId, string key) => $"t:{tenantId}:idem:{key}";

/// <summary>
/// Key for the impersonation-grant revocation marker, indexed by JWT id.
Expand Down
9 changes: 8 additions & 1 deletion src/BuildingBlocks/Caching/Caching.csproj
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<RootNamespace>FSH.Framework.Caching</RootNamespace>
<AssemblyName>FSH.Framework.Caching</AssemblyName>
<PackageId>FullStackHero.Framework.Caching</PackageId>
</PropertyGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Caching.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" />
Expand All @@ -17,6 +23,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Options" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Finbuckle.MultiTenant.Abstractions" />
</ItemGroup>

<ItemGroup>
Expand Down
16 changes: 16 additions & 0 deletions src/BuildingBlocks/Caching/Extensions.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using Finbuckle.MultiTenant.Abstractions;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -85,6 +86,21 @@ public static IServiceCollection AddHeroCaching(this IServiceCollection services
// factory that builds the inner via the original descriptor and returns our wrapper.
DecorateHybridCache(services);

// Register the tenant-scoped cache service. Scoped lifetime because the tenant context
// (and therefore the key prefix) is per-request. Module code must inject
// ITenantCacheService — injecting HybridCache directly is prohibited in module assemblies
// and enforced by architecture tests.
services.AddScoped<ITenantCacheService>(sp =>
new TenantHybridCache(
sp.GetRequiredService<HybridCache>(),
sp.GetRequiredService<IMultiTenantContextAccessor>()));

// Register the global (cross-tenant) cache service. Singleton lifetime because it
// carries no per-request state. Use this ONLY for data shared across all tenants,
// e.g. system defaults, global lookup tables, shared configuration.
services.AddSingleton<IGlobalCacheService>(sp =>
new GlobalHybridCache(sp.GetRequiredService<HybridCache>()));

return services;
}

Expand Down
46 changes: 46 additions & 0 deletions src/BuildingBlocks/Caching/GlobalHybridCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using Microsoft.Extensions.Caching.Hybrid;

namespace FSH.Framework.Caching;

/// <summary>
/// <see cref="IGlobalCacheService"/> implementation that delegates directly to
/// <see cref="HybridCache"/> without any tenant scoping.
/// Registered as <c>Singleton</c> — safe because no per-request state is involved.
/// </summary>
internal sealed class GlobalHybridCache : IGlobalCacheService
{
private readonly HybridCache _cache;

public GlobalHybridCache(HybridCache cache)
{
ArgumentNullException.ThrowIfNull(cache);
_cache = cache;
}

/// <inheritdoc/>
public ValueTask<T> GetOrCreateAsync<TState, T>(
string key,
TState state,
Func<TState, CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default)
=> _cache.GetOrCreateAsync(key, state, factory, options, tags, cancellationToken);

/// <inheritdoc/>
public ValueTask SetAsync<T>(
string key,
T value,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default)
=> _cache.SetAsync(key, value, options, tags, cancellationToken);

/// <inheritdoc/>
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
=> _cache.RemoveAsync(key, cancellationToken);

/// <inheritdoc/>
public ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default)
=> _cache.RemoveByTagAsync(tag, cancellationToken);
}
38 changes: 38 additions & 0 deletions src/BuildingBlocks/Caching/IGlobalCacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using Microsoft.Extensions.Caching.Hybrid;

namespace FSH.Framework.Caching;

/// <summary>
/// Cross-tenant cache service for entries that are intentionally shared across all tenants.
/// Examples: system defaults, global configuration, shared lookup tables.
/// </summary>
/// <remarks>
/// Use this service ONLY for data that is genuinely identical for every tenant.
/// For any per-tenant data use <see cref="ITenantCacheService"/> instead.
/// Registered as <c>Singleton</c> because there is no per-request tenant context involved.
/// </remarks>
public interface IGlobalCacheService
{
/// <summary>Gets or creates a cross-tenant cache entry.</summary>
ValueTask<T> GetOrCreateAsync<TState, T>(
string key,
TState state,
Func<TState, CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default);

/// <summary>Sets a cross-tenant cache entry.</summary>
ValueTask SetAsync<T>(
string key,
T value,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default);

/// <summary>Removes a cross-tenant cache entry by key.</summary>
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);

/// <summary>Removes all cross-tenant entries tagged with the given tag.</summary>
ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default);
}
54 changes: 54 additions & 0 deletions src/BuildingBlocks/Caching/ITenantCacheService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Microsoft.Extensions.Caching.Hybrid;

namespace FSH.Framework.Caching;

/// <summary>
/// Tenant-scoped wrapper over <see cref="HybridCache"/>.
/// All keys and tags are automatically prefixed with the current tenant identifier,
/// preventing cross-tenant cache collisions without requiring callers to manage
/// the prefix themselves.
/// </summary>
/// <remarks>
/// Register as <c>Scoped</c> because the tenant context (and therefore the prefix)
/// changes per HTTP request. Inject <see cref="ITenantCacheService"/> in module
/// code — do NOT inject <see cref="HybridCache"/> directly from business modules.
/// </remarks>
public interface ITenantCacheService
{
/// <summary>
/// Gets an existing cache entry or creates a new one using the provided factory,
/// automatically scoping the key and tags to the current tenant.
/// </summary>
ValueTask<T> GetOrCreateAsync<TState, T>(
string key,
TState state,
Func<TState, CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Sets a cache entry for the given key, scoped to the current tenant.
/// </summary>
ValueTask SetAsync<T>(
string key,
T value,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Removes a cache entry by key, scoped to the current tenant.
/// </summary>
ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);

/// <summary>
/// Removes all cache entries carrying the given tag, scoped to the current tenant.
/// </summary>
ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default);

/// <summary>
/// Removes all cache entries carrying any of the given tags, scoped to the current tenant.
/// </summary>
ValueTask RemoveByTagAsync(IEnumerable<string> tags, CancellationToken cancellationToken = default);
}
137 changes: 137 additions & 0 deletions src/BuildingBlocks/Caching/TenantHybridCache.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
using Finbuckle.MultiTenant;
using Finbuckle.MultiTenant.Abstractions;
using Microsoft.Extensions.Caching.Hybrid;

namespace FSH.Framework.Caching;

/// <summary>
/// <see cref="ITenantCacheService"/> implementation that wraps <see cref="HybridCache"/>
/// and automatically scopes every key and tag with the active tenant identifier.
/// </summary>
/// <remarks>
/// This class must be registered as <c>Scoped</c> so that each HTTP request gets
/// a fresh instance bound to the correct tenant context. The underlying
/// <see cref="HybridCache"/> remains <c>Singleton</c> and is shared across tenants —
/// only the key/tag prefixing is tenant-specific.
/// </remarks>
internal sealed class TenantHybridCache : ITenantCacheService
{
private readonly HybridCache _cache;
private readonly IMultiTenantContextAccessor _tenantAccessor;

public TenantHybridCache(HybridCache cache, IMultiTenantContextAccessor tenantAccessor)
{
ArgumentNullException.ThrowIfNull(cache);
ArgumentNullException.ThrowIfNull(tenantAccessor);
_cache = cache;
_tenantAccessor = tenantAccessor;
}

/// <summary>
/// Internal factory for test projects (via InternalsVisibleTo). Prefer DI for production code.
/// </summary>
internal static ITenantCacheService Create(HybridCache cache, IMultiTenantContextAccessor accessor)
=> new TenantHybridCache(cache, accessor);

// -----------------------------------------------------------------------
// Key/tag scoping helpers
// -----------------------------------------------------------------------

private string GetTenantId()
{
var tenantId = _tenantAccessor.MultiTenantContext?.TenantInfo?.Id;
if (string.IsNullOrEmpty(tenantId))
{
throw new InvalidOperationException(
"No active tenant context. TenantHybridCache requires a resolved tenant. " +
"Ensure the request passes through the Finbuckle middleware before the cache is accessed.");
}

return tenantId;
}

/// <summary>Scopes a logical key to the current tenant: <c>t:{tenantId}:{key}</c>.</summary>
private string ScopeKey(string key) => $"t:{GetTenantId()}:{key}";

/// <summary>
/// Returns the full tag set for a cache entry:
/// <list type="bullet">
/// <item><c>tenant:{tenantId}</c> — whole-tenant purge tag (used by <see cref="CacheKeys.Tags.Tenant"/>).</item>
/// <item><c>t:{tenantId}:{callerTag}</c> for every caller-supplied tag — scoped so
/// <see cref="RemoveByTagAsync(string, CancellationToken)"/> can look up the same
/// prefixed tag and actually find the stored entries.</item>
/// </list>
/// Without the per-tag prefix the SET and REMOVE paths would use different tag strings,
/// making all tag-based invalidation a silent no-op.
/// </summary>
private static IEnumerable<string> ScopeTags(string tenantId, IEnumerable<string>? callerTags)
{
// Whole-tenant purge tag — lets callers blow away an entire tenant's cache.
yield return CacheKeys.Tags.Tenant(tenantId);

if (callerTags is null) yield break;

// Per-tag scoped form — must match the lookup key built in RemoveByTagAsync.
foreach (var tag in callerTags)
yield return $"t:{tenantId}:{tag}";
}

// -----------------------------------------------------------------------
// ITenantCacheService implementation
// -----------------------------------------------------------------------

/// <inheritdoc/>
public ValueTask<T> GetOrCreateAsync<TState, T>(
string key,
TState state,
Func<TState, CancellationToken, ValueTask<T>> factory,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
return _cache.GetOrCreateAsync(
ScopeKey(key),
state,
factory,
options,
ScopeTags(tenantId, tags),
cancellationToken);
}

/// <inheritdoc/>
public ValueTask SetAsync<T>(
string key,
T value,
HybridCacheEntryOptions? options = null,
IEnumerable<string>? tags = null,
CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
return _cache.SetAsync(
ScopeKey(key),
value,
options,
ScopeTags(tenantId, tags),
cancellationToken);
}

/// <inheritdoc/>
public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
=> _cache.RemoveAsync(ScopeKey(key), cancellationToken);

/// <inheritdoc/>
public ValueTask RemoveByTagAsync(string tag, CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
// Scope the tag so only entries for this tenant are evicted.
return _cache.RemoveByTagAsync($"t:{tenantId}:{tag}", cancellationToken);
}

/// <inheritdoc/>
public ValueTask RemoveByTagAsync(IEnumerable<string> tags, CancellationToken cancellationToken = default)
{
var tenantId = GetTenantId();
return _cache.RemoveByTagAsync(tags.Select(t => $"t:{tenantId}:{t}"), cancellationToken);
}
}
Loading
Loading