-
Notifications
You must be signed in to change notification settings - Fork 0
Advanced Recipes
Isolate cache entries per tenant by implementing IKeyContextProvider. Each tenant's data is stored under a separate key segment and cannot bleed into another tenant's cache.
public class TenantKeyContextProvider : IKeyContextProvider
{
private readonly IHttpContextAccessor _accessor;
public TenantKeyContextProvider(IHttpContextAccessor accessor)
=> _accessor = accessor;
public Task<string?> GetContextSegmentAsync(HttpContext context)
{
// Read tenant from JWT claim, header, or route value
var tenantId =
context.User.FindFirst("tenant_id")?.Value
?? context.Request.Headers["X-Tenant-Id"].FirstOrDefault()
?? context.GetRouteValue("tenantId")?.ToString();
return Task.FromResult(tenantId is not null ? $"t={tenantId}" : null);
}
}Register:
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<IKeyContextProvider, TenantKeyContextProvider>();
builder.Services.AddCacheWeave(options => { ... });Resulting key:
products:list:v1:t=acme:page=1
products:list:v1:t=globex:page=1
To evict all entries for a specific tenant, use InvalidateByPrefixAsync with the tenant segment:
await cache.InvalidateByPrefixAsync("products:list:v1:t=acme");For multi-instance deployments, replace the default in-process SemaphoreSlim with a Redis-backed distributed lock using RedLock.net:
using RedLockNet;
using RedLockNet.SERedis;
using RedLockNet.SERedis.Configuration;
public class RedisStampedeProtector : ICacheStampedeProtector
{
private readonly IDistributedLockFactory _locks;
private readonly ICacheProvider _cache;
public RedisStampedeProtector(IDistributedLockFactory locks, ICacheProvider cache)
{
_locks = locks;
_cache = cache;
}
public async Task<T?> GetOrSetAsync<T>(
string key,
Func<Task<T?>> factory,
TimeSpan? expiry,
CancellationToken ct = default)
{
// Fast path — no lock needed on hit
var hit = await _cache.GetAsync<T>(key, ct);
if (hit is not null) return hit;
await using var redLock = await _locks.CreateLockAsync(
resource: $"lock:{key}",
expiryTime: TimeSpan.FromSeconds(30),
waitTime: TimeSpan.FromSeconds(10),
retryTime: TimeSpan.FromMilliseconds(200),
cancellationToken: ct);
if (!redLock.IsAcquired)
return await factory(); // safe degradation
// Re-check after acquiring lock
hit = await _cache.GetAsync<T>(key, ct);
if (hit is not null) return hit;
var value = await factory();
if (value is not null)
await _cache.SetAsync(key, value, expiry, ct);
return value;
}
}Register:
var multiplexers = new List<RedLockMultiplexer>
{
new(ConnectionMultiplexer.Connect("localhost:6379"))
};
builder.Services.AddSingleton<IDistributedLockFactory>(
RedLockFactory.Create(multiplexers));
// Must be registered before AddCacheWeave
builder.Services.AddSingleton<ICacheStampedeProtector, RedisStampedeProtector>();
builder.Services.AddCacheWeave(options => { ... });
builder.Services.AddCacheWeaveRedis("localhost:6379");Replace GZip with Brotli for better compression ratios on large JSON payloads:
using System.IO.Compression;
using System.Text;
public class BrotliCacheCompressor : ICacheCompressor
{
private readonly CompressionLevel _level;
public BrotliCacheCompressor(CompressionLevel level = CompressionLevel.Fastest)
=> _level = level;
public byte[] Compress(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
using var output = new MemoryStream();
using (var brotli = new BrotliStream(output, _level))
brotli.Write(bytes, 0, bytes.Length);
return output.ToArray();
}
public string Decompress(byte[] data)
{
using var input = new MemoryStream(data);
using var brotli = new BrotliStream(input, CompressionMode.Decompress);
using var output = new MemoryStream();
brotli.CopyTo(output);
return Encoding.UTF8.GetString(output.ToArray());
}
}Register before AddCacheWeave:
builder.Services.AddSingleton<ICacheCompressor>(
new BrotliCacheCompressor(CompressionLevel.Optimal));
builder.Services.AddCacheWeave(options =>
{
options.EnableCompression = true;
});Avoid hardcoding ExpirySeconds in attributes by reading TTLs from appsettings.json via a custom ICacheKeyBuilder:
public class ConfigurableTtlKeyBuilder : ICacheKeyBuilder
{
private readonly DefaultCacheKeyBuilder _inner;
private readonly IConfiguration _config;
public ConfigurableTtlKeyBuilder(DefaultCacheKeyBuilder inner, IConfiguration config)
{
_inner = inner;
_config = config;
}
public string Build(
string? baseKey,
CacheWeaveOptions options,
IEnumerable<KeyValuePair<string, StringValues>>? queryParams,
string? contextSegment,
string? bodyHash = null)
=> _inner.Build(baseKey, options, queryParams, contextSegment, bodyHash);
public TimeSpan? GetExpiry(string key)
{
var seconds = _config.GetValue<int?>($"CacheWeave:Ttls:{key}");
return seconds.HasValue ? TimeSpan.FromSeconds(seconds.Value) : null;
}
}{
"CacheWeave": {
"Ttls": {
"products:list": 300,
"products:detail": 600,
"dashboard:stats": 60
}
}
}Pre-populate hot keys during application startup using IHostedService:
public class CacheWarmupService : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
public CacheWarmupService(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
public async Task StartAsync(CancellationToken ct)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var cache = scope.ServiceProvider.GetRequiredService<ICacheWeaveService>();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// Warm the first page of products
await cache.GetOrSetAsync<List<ProductDto>>(
"products:list:v1:page=1:pageSize=20",
factory: () => db.Products
.OrderBy(p => p.Name)
.Take(20)
.Select(p => new ProductDto(p))
.ToListAsync(ct),
expiry: TimeSpan.FromMinutes(5),
ct: ct);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
// Register
builder.Services.AddHostedService<CacheWarmupService>();Add a request header or query param to bypass the cache for debugging or admin purposes:
public class BypassCacheKeyContextProvider : IKeyContextProvider
{
public Task<string?> GetContextSegmentAsync(HttpContext context)
{
// If the bypass header is present, append a unique nonce to the key
// so the request always misses the cache
if (context.Request.Headers.ContainsKey("X-Cache-Bypass"))
{
var nonce = Guid.NewGuid().ToString("N");
return Task.FromResult<string?>($"bypass={nonce}");
}
return Task.FromResult<string?>(null);
}
}Any request with X-Cache-Bypass: 1 will always miss the cache and never write to it (the nonce makes the key unique, and the response is too short-lived to be useful).
Restrict this header to admin roles in your authorization middleware to prevent abuse.