CacheManager & ICacheProvider
Pluggable multi-backend caching subsystem with stale-while-revalidate, scoped caches, tag-based invalidation, batch operations, and SLO-ready statistics.
On This Page
Overview
The BeepDM caching subsystem is built around two complementary surfaces:
-
CacheManager— a static, globally-shared cache with pluggable primary + fallback providers, stale-while-revalidate (SWR), batch operations, tag groups, and centralized statistics. -
CacheScope— an isolated, per-component instance cache obtained viaCacheManager.CreateScope(). Each scope owns its own provider, statistics, and key namespace. Used byProxyDataSourcefor per-datasource caches.
Pluggable Providers
Five built-in providers: SimpleCacheProvider, InMemoryCacheProvider (LRU), MemoryCacheProvider (IMemoryCache), RedisCacheProvider, HybridCacheProvider (L1+L2+L3). Set any custom ICacheProvider implementation.
Stale-While-Revalidate
Dual-TTL pattern: entries are served immediately (even stale) while a background task refreshes them. One stampede-guard per key prevents multiple parallel refreshes.
Scoped Caches
CacheScope gives each component (e.g., a ProxyDataSource) an isolated provider with its own statistics and namespaced keys.
Tag Invalidation
Group cache entries under string tags at write time; invalidate the entire group with a single RemoveByTagAsync("tag") call.
Batch Operations
GetManyAsync and SetManyAsync fetch or store dictionaries of key-value pairs in one call, minimising round-trips on distributed backends.
SLO-Ready Statistics
Per-provider hit/miss, hit ratio, item count, memory usage, expired/evicted counters. Combined stats available via CacheManagerStatistics.
ExistsAsync for hit detection, so null, 0,
false, and empty collections are stored and returned correctly — they are
never treated as cache misses.
Cache Providers
All providers implement ICacheProvider and are located in Caching/Providers/.
| Class | CacheProviderType |
Backing store | Best for |
|---|---|---|---|
SimpleCacheProvider |
Simple |
ConcurrentDictionary |
Unit tests, light workloads, default fallback |
InMemoryCacheProvider |
InMemory |
ConcurrentDictionary + LinkedList (LRU) |
High-throughput single-process caching; O(1) access and LRU eviction; optional compression |
MemoryCacheProvider |
MemoryCache |
Microsoft.Extensions.Caching.Memory.IMemoryCache |
ASP.NET Core / IHost environments that already run MemoryCache; size-limited via SizeLimit |
RedisCacheProvider |
Redis |
Redis (standalone, Sentinel, or Cluster) | Multi-instance / distributed caching; persistence; cross-process invalidation |
HybridCacheProvider |
Hybrid |
L1 (InMemory) + L2 (Redis/MemoryCache) + optional L3 (persistent) | Maximum performance with shared consistency; automatic promotion / demotion across tiers |
Provider selection guide
| Scenario | Recommended provider |
|---|---|
| Single-process desktop or CLI app | InMemory |
| ASP.NET Core app with existing MemoryCache | MemoryCache |
| Load-balanced web app (shared cache) | Redis |
| Hot local reads + shared invalidation | Hybrid (L1=InMemory, L2=Redis) |
| Tests / minimal setup | Simple (default) |
Custom provider
public class MyCustomProvider : ICacheProvider
{
public string Name => "MyCustom";
public bool IsAvailable => true;
public CacheStatistics Statistics { get; } = new CacheStatistics();
// Implement GetAsync, SetAsync, RemoveAsync, ExistsAsync, ClearAsync,
// GetManyAsync, SetManyAsync, RefreshAsync, Dispose ...
}
// Register globally
CacheManager.SetProvider(new MyCustomProvider());
CacheConfiguration
Passed to CacheManager.Initialize() or CacheManager.CreateScope().
Top-level properties
| Property | Default | Description |
|---|---|---|
DefaultExpiry | 10 min | Fallback TTL used when no per-call expiry is supplied |
MaxItems | 10 000 | Item cap for in-memory providers; triggers LRU eviction when reached |
CleanupInterval | 5 min | How often the cleanup timer scans for and removes expired entries |
EnableStatistics | true | Toggle hit/miss/memory counters; disable for minimal overhead |
KeyPrefix | "beep:" | String prepended to every key — prevents collisions across components |
EnableCompression | false | Compress values exceeding CompressionThreshold |
CompressionThreshold | 1 024 bytes | Minimum value size (bytes) before compression is applied |
SerializationFormat | Json | Serialization format for stored values: Json, MessagePack, Binary |
Redis | — | Redis-specific sub-configuration (see below) |
MemoryCache | — | IMemoryCache-specific sub-configuration (see below) |
RedisConfiguration
| Property | Default | Description |
|---|---|---|
ConnectionString | "localhost:6379" | Redis endpoint (StackExchange.Redis format) |
Database | 0 | Redis logical database index |
ConnectTimeout | 5 s | Maximum connection establishment time |
UseConnectionMultiplexing | true | Reuse a single multiplexed connection for all calls |
RetryPolicy | 3 retries, 100 ms base, 5 s max, exponential | Retry policy applied on transient Redis errors |
MemoryCacheConfiguration
| Property | Default | Description |
|---|---|---|
SizeLimit | 100 MB | Hard memory ceiling for IMemoryCache |
CompactionPercentage | 5 % | Fraction of entries removed when SizeLimit is reached |
ExpirationScanFrequency | 1 min | How often IMemoryCache scans for expired entries |
var config = new CacheConfiguration
{
DefaultExpiry = TimeSpan.FromMinutes(30),
MaxItems = 5_000,
CleanupInterval = TimeSpan.FromMinutes(2),
KeyPrefix = "myapp:",
EnableCompression = true,
CompressionThreshold = 1_024,
Redis = new RedisConfiguration
{
ConnectionString = "redis-host:6379,password=secret",
Database = 1,
RetryPolicy = new RetryPolicyConfiguration { MaxRetries = 5 }
}
};
ICacheProvider Interface
All providers implement ICacheProvider : IDisposable.
The interface is fully async to avoid blocking on I/O-bound operations.
| Method / Property | Signature | Description |
|---|---|---|
Name | string | Provider display name |
IsAvailable | bool | Current health — false triggers fallback |
Statistics | CacheStatistics | Live counters for this provider instance |
GetAsync<T> | Task<T>(key, ct) | Fetch a value; returns default(T) on miss (use ExistsAsync to distinguish miss from cached default) |
SetAsync<T> | Task<bool>(key, value, expiry?, ct) | Store a value with optional TTL override |
ExistsAsync | Task<bool>(key, ct) | Presence check — does not return the value |
RemoveAsync | Task<bool>(key, ct) | Delete a single entry; returns true if found |
ClearAsync | Task<long>(pattern?, ct) | Remove all entries, or only those whose key matches pattern; returns removed count |
GetManyAsync<T> | Task<Dictionary<string,T>>(keys, ct) | Fetch multiple keys in one call; missing keys are omitted from result |
SetManyAsync<T> | Task<long>(dict, expiry?, ct) | Store multiple entries; returns count of successfully stored items |
RefreshAsync | Task<bool>(key, expiry, ct) | Extend the TTL of an existing entry without changing its value |
CacheManager — Initialization
CacheManager is a static class. It must be initialised once before use.
Subsequent calls to Initialize are no-ops (idempotent).
Initialize
public static void Initialize(
CacheConfiguration configuration = null,
CacheProviderType primaryProvider = CacheProviderType.InMemory,
CacheProviderType? fallbackProvider = null)
Registers the primary provider and an optional explicit fallback. If fallbackProvider
is null, a SimpleCacheProvider is used as the automatic fallback so
cache operations always succeed even when the primary is unavailable.
SetProvider
public static void SetProvider(ICacheProvider provider, ICacheProvider fallbackProvider = null)
Replace the primary (and optionally the fallback) provider at runtime. The replaced provider is disposed. Useful for tests or to swap from in-memory to Redis after startup.
Quick-start examples
// Default — SimpleCacheProvider (no config needed)
CacheManager.Initialize();
// InMemory primary, default Simple fallback
CacheManager.Initialize(
new CacheConfiguration { MaxItems = 5_000, DefaultExpiry = TimeSpan.FromMinutes(30) },
CacheProviderType.InMemory);
// Redis primary + InMemory fallback
CacheManager.Initialize(
new CacheConfiguration { Redis = new RedisConfiguration { ConnectionString = "redis:6379" } },
primaryProvider : CacheProviderType.Redis,
fallbackProvider : CacheProviderType.InMemory);
// Inject a custom provider
CacheManager.SetProvider(new MyCustomProvider());
CacheManager — Core Operations
| Method | Description |
|---|---|
GetAsync<T>(key, ct) | Fetch by key. Tries primary, falls back automatically on unavailability. |
SetAsync<T>(key, value, expiry?, ct) | Store a value. Returns true on success. |
ExistsAsync(key, ct) | Presence check — the canonical way to distinguish a cached null from a miss. |
RemoveAsync(key, ct) | Delete one entry. |
ClearAsync(pattern?, ct) | Remove all entries, or only those matching a glob-style pattern (provider-dependent). |
GetManyAsync<T>(keys, ct) | Batch fetch — returns only keys that exist. |
SetManyAsync<T>(dict, expiry?, ct) | Batch store. |
RefreshAsync(key, expiry, ct) | Extend TTL without touching the value. |
GetOrCreate<T>(key, factory, expiry?) | Synchronous fetch-or-create. |
GetOrCreateAsync<T>(key, factory, expiry?, ct) | Async fetch-or-create (miss = invoke factory, store, return). |
GetOrCreateWithStaleAsync<T>(key, factory, softTtl, hardTtl, ct) | Stale-while-revalidate fetch-or-create (see below). |
GetStatistics() | Returns a CacheManagerStatistics snapshot for both providers. |
CreateScope(providerType, config) | Create an isolated CacheScope with its own provider. |
Fallback behaviour
On every read (GetAsync, ExistsAsync, GetManyAsync)
the manager tries the primary first. If the primary's IsAvailable is false
or the call throws, the operation is retried transparently against the fallback provider.
Writes go to the primary only; the fallback is read-only when the primary is down.
Stale-While-Revalidate
The stale-while-revalidate (SWR) pattern uses two TTLs per entry to decouple freshness from availability:
| TTL | Meaning |
|---|---|
| Soft TTL | How long a value is considered fresh. Within this window every read returns the cached value without any background work. |
| Hard TTL | Absolute lifetime in the cache store. Once soft TTL expires but hard TTL has not, the stale value is served immediately while the cache is silently refreshed in the background. After the hard TTL the entry is gone and the next caller blocks on the factory. |
time ─────────────────────────────────────────────────────────────────────►
│◄──── soft TTL ────►│◄──── stale window (hard−soft) ────►│ expired │
│ return cached │ return stale + trigger background │ block │
Stampede guard: A ConcurrentDictionary<string,int> (_swrInFlight)
ensures only one background refresh fires per key at a time. Additional callers during an in-flight
refresh all receive the stale value immediately.
CacheManager.GetOrCreateWithStaleAsync
var data = await CacheManager.GetOrCreateWithStaleAsync(
key : "report:weekly",
factory : () => ReportService.GenerateAsync(),
softTtl : TimeSpan.FromMinutes(5),
hardTtl : TimeSpan.FromMinutes(60));
// Callers within 5 min → instant
// Callers 5-60 min later → instant (stale) + silent background refresh
// After 60 min → blocks once to regenerate
CacheScope.GetOrCreateAsync (scoped SWR)
using var scope = CacheManager.CreateScope(CacheProviderType.InMemory, config);
var rows = await scope.GetOrCreateAsync(
key : "orders:open",
factory : () => db.GetOpenOrdersAsync(),
softTtl : TimeSpan.FromSeconds(30),
hardTtl : TimeSpan.FromMinutes(5));
Extension Operations
Additional static methods on CacheManager from CacheManager.Extensions.cs.
Tagged cache entries
Group related entries under one or more string tags to mass-invalidate them together.
// Store with tags
await CacheManager.SetWithTagsAsync(
key : "user:123:profile",
value : userProfile,
tags : new[] { "user:123", "profiles" },
expiry: TimeSpan.FromHours(1));
await CacheManager.SetWithTagsAsync(
key : "user:123:orders",
value : orderList,
tags : new[] { "user:123", "orders" });
// Invalidate everything tagged "user:123" (removes both entries above)
long removed = await CacheManager.RemoveByTagAsync("user:123");
Console.WriteLine($"Removed {removed} entries");
HashSet<string> of all keys associated with that tag. The tag index itself
is cleaned up by RemoveByTagAsync after the grouped keys are removed.
Conditional writes
// Store only if the key does not already exist
bool didSet = await CacheManager.SetIfNotExistsAsync("lock:export-job", true, TimeSpan.FromMinutes(5));
if (!didSet)
Console.WriteLine("Job already running — skip duplicate");
Full extension method list
| Method | Description |
|---|---|
SetWithTagsAsync<T>(key, value, tags, expiry?, ct) | Store a value and associate it with one or more tags |
RemoveByTagAsync(tag, ct) | Remove all entries belonging to the specified tag |
SetIfNotExistsAsync<T>(key, value, expiry?, ct) | Conditional set — only writes if the key is absent (useful for distributed locks) |
CacheScope
A CacheScope is a disposable, instance-based cache backed by its own
ICacheProvider. It is the recommended way to give a component or service
its own isolated cache without polluting the global CacheManager state.
Creating a scope
// Factory on CacheManager
CacheScope scope = CacheManager.CreateScope(
CacheProviderType.InMemory,
new CacheConfiguration
{
MaxItems = 200,
DefaultExpiry = TimeSpan.FromMinutes(10),
KeyPrefix = "proxy:orders-db:"
});
CacheScope API
| Member | Description |
|---|---|
Statistics | Live CacheStatistics from the underlying provider |
IsAvailable | true when the provider is healthy and the scope has not been disposed |
GetAsync<T>(key, ct) | Fetch from this scope's provider |
SetAsync<T>(key, value, expiry?, ct) | Store in this scope's provider |
ExistsAsync(key, ct) | Presence check |
RemoveAsync(key, ct) | Delete one entry |
ClearAsync(pattern?, ct) | Remove all (or pattern-matched) entries |
GetOrCreateAsync<T>(key, factory, softTtl, hardTtl, ct) | SWR fetch-or-create |
Dispose() | Disposes the underlying provider; subsequent calls throw ObjectDisposedException |
CacheScope when the owning component is torn down.
The underlying provider holds a background cleanup timer and (for Redis) a network
connection that must be released.
Statistics & Diagnostics
CacheStatistics (per provider)
| Property | Type | Description |
|---|---|---|
Hits | long | Number of successful cache lookups |
Misses | long | Number of lookups that returned no entry |
HitRatio | double | Hits / (Hits + Misses) × 100 |
ItemCount | long | Current number of entries in the provider |
MemoryUsage | long | Estimated memory consumption (bytes) |
ExpiredItems | long | Entries removed by TTL expiration |
EvictedItems | long | Entries removed by LRU or memory-pressure eviction |
CacheManagerStatistics (combined)
| Property | Description |
|---|---|
PrimaryProvider | CacheStatistics for the primary |
FallbackProvider | CacheStatistics for the fallback |
PrimaryProviderName | Display name (e.g. "InMemory", "Redis") |
PrimaryProviderAvailable | true when primary is reachable |
CombinedHitRatio | Hits / (Hits + Misses) across both providers |
TotalItemCount | Sum of item counts across both providers |
TotalMemoryUsage | Sum of memory usage across both providers |
TotalExpiredItems | Sum of expired entries across both providers |
TotalEvictedItems | Sum of evicted entries across both providers |
Inspecting statistics
var stats = CacheManager.GetStatistics();
Console.WriteLine($"Primary: {stats.PrimaryProviderName} available={stats.PrimaryProviderAvailable}");
Console.WriteLine($" Hits: {stats.PrimaryProvider?.Hits}");
Console.WriteLine($" Misses: {stats.PrimaryProvider?.Misses}");
Console.WriteLine($" HitRatio:{stats.PrimaryProvider?.HitRatio:F2}%");
Console.WriteLine($" Items: {stats.TotalItemCount}");
Console.WriteLine($" Memory: {stats.TotalMemoryUsage / 1024.0:F1} KB");
Console.WriteLine($" Combined hit ratio: {stats.CombinedHitRatio:F2}%");
Usage Examples
Example 1 — Basic store and retrieve
CacheManager.Initialize(); // SimpleCacheProvider by default
await CacheManager.SetAsync("user:123", new UserDto { Name = "Alice", Age = 30 });
await CacheManager.SetAsync("product:456", new ProductDto { Name = "Widget", Price = 9.99m },
expiry: TimeSpan.FromMinutes(5));
if (await CacheManager.ExistsAsync("user:123"))
{
var user = await CacheManager.GetAsync<UserDto>("user:123");
Console.WriteLine(user.Name);
}
await CacheManager.RemoveAsync("user:123");
Example 2 — GetOrCreate pattern
// Async factory is called only on a cache miss
var report = await CacheManager.GetOrCreateAsync(
key : "report:monthly",
factory: async () => await ReportService.GenerateMonthlyAsync(),
expiry : TimeSpan.FromHours(1));
// Synchronous overload
var config = CacheManager.GetOrCreate("app:config", () => ConfigLoader.Load());
Example 3 — Stale-while-revalidate
// Callers never wait longer than a millisecond after the first population.
// Background refresh ensures data stays fresh without blocking.
var dashboard = await CacheManager.GetOrCreateWithStaleAsync(
key : "dashboard:stats",
factory : async () => await StatsService.ComputeAsync(),
softTtl : TimeSpan.FromMinutes(2),
hardTtl : TimeSpan.FromMinutes(30));
Example 4 — Batch operations
// Set 1 000 items in one call
var batch = Enumerable.Range(1, 1_000)
.ToDictionary(i => $"item:{i}", i => new ItemDto { Id = i });
long stored = await CacheManager.SetManyAsync(batch, TimeSpan.FromMinutes(15));
Console.WriteLine($"Stored {stored} items");
// Retrieve a subset
var keys = new[] { "item:1", "item:42", "item:999" };
var found = await CacheManager.GetManyAsync<ItemDto>(keys);
Console.WriteLine($"Found {found.Count} / {keys.Length}");
Example 5 — Tag-based invalidation
// Populate user-scoped entries
await CacheManager.SetWithTagsAsync("user:7:profile", profile, tags: new[] { "user:7" });
await CacheManager.SetWithTagsAsync("user:7:orders", orders, tags: new[] { "user:7", "orders" });
await CacheManager.SetWithTagsAsync("user:7:prefs", prefs, tags: new[] { "user:7" });
// User logs out / account deleted — invalidate everything for user 7
long cleared = await CacheManager.RemoveByTagAsync("user:7");
Console.WriteLine($"Cleared {cleared} entries for user 7");
Example 6 — Redis primary with InMemory fallback
CacheManager.Initialize(
new CacheConfiguration
{
DefaultExpiry = TimeSpan.FromMinutes(20),
KeyPrefix = "orders:",
Redis = new RedisConfiguration
{
ConnectionString = "redis-prod:6379,password=s3cr3t",
ConnectTimeout = TimeSpan.FromSeconds(3),
RetryPolicy = new RetryPolicyConfiguration { MaxRetries = 3 }
}
},
primaryProvider : CacheProviderType.Redis,
fallbackProvider : CacheProviderType.InMemory);
// If Redis is down, the InMemory fallback is used automatically
var order = await CacheManager.GetOrCreateAsync("order:88", () => db.GetOrderAsync(88));
Example 7 — Scoped cache for a component
public class OrderRepository : IDisposable
{
private readonly IDatabase _db;
private readonly CacheScope _cache;
public OrderRepository(IDatabase db)
{
_db = db;
_cache = CacheManager.CreateScope(
CacheProviderType.InMemory,
new CacheConfiguration { MaxItems = 500, KeyPrefix = "orders:" });
}
public async Task<IReadOnlyList<Order>> GetOpenOrdersAsync()
=> await _cache.GetOrCreateAsync(
key : "open",
factory : () => _db.QueryAsync<Order>("SELECT * FROM Orders WHERE Status='Open'"),
softTtl : TimeSpan.FromSeconds(30),
hardTtl : TimeSpan.FromMinutes(5));
public async Task InvalidateAsync() => await _cache.ClearAsync();
public void Dispose() => _cache.Dispose();
}
Example 8 — Monitoring statistics
// Periodic health check
var stats = CacheManager.GetStatistics();
if (stats.PrimaryProvider?.HitRatio < 50)
Console.WriteLine("Warning: cache hit ratio below 50% — consider increasing MaxItems");
if (!stats.PrimaryProviderAvailable)
Console.WriteLine("Warning: primary cache provider is down — running on fallback");
Console.WriteLine($"Total memory: {stats.TotalMemoryUsage / 1024:F0} KB");
Console.WriteLine($"Evictions: {stats.TotalEvictedItems}");
Example 9 — Custom provider injection
// Use a custom provider (e.g. Garnet, Memcached, Cosmos DB cache)
var provider = new MemcachedCacheProvider("memcached-host:11211");
CacheManager.SetProvider(provider, fallbackProvider: new SimpleCacheProvider());
// Or replace just for one component via scope
using var scope = new CacheScope(provider);