Skip to content

Advanced Recipes

Aghogho Bernard edited this page May 6, 2026 · 1 revision

Advanced Recipes

Tenant Isolation

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");

Distributed Stampede Lock (Redis)

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");

Custom Compressor (Brotli)

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;
});

Per-Endpoint Expiry from Configuration

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
    }
  }
}

Warming the Cache on Startup

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>();

Bypassing the Cache for Specific Requests

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.

Clone this wiki locally