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:

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.

Null / zero / false are valid cached values. All operations use 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

ScenarioRecommended provider
Single-process desktop or CLI appInMemory
ASP.NET Core app with existing MemoryCacheMemoryCache
Load-balanced web app (shared cache)Redis
Hot local reads + shared invalidationHybrid (L1=InMemory, L2=Redis)
Tests / minimal setupSimple (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

PropertyDefaultDescription
DefaultExpiry10 minFallback TTL used when no per-call expiry is supplied
MaxItems10 000Item cap for in-memory providers; triggers LRU eviction when reached
CleanupInterval5 minHow often the cleanup timer scans for and removes expired entries
EnableStatisticstrueToggle hit/miss/memory counters; disable for minimal overhead
KeyPrefix"beep:"String prepended to every key — prevents collisions across components
EnableCompressionfalseCompress values exceeding CompressionThreshold
CompressionThreshold1 024 bytesMinimum value size (bytes) before compression is applied
SerializationFormatJsonSerialization format for stored values: Json, MessagePack, Binary
Redis—Redis-specific sub-configuration (see below)
MemoryCache—IMemoryCache-specific sub-configuration (see below)

RedisConfiguration

PropertyDefaultDescription
ConnectionString"localhost:6379"Redis endpoint (StackExchange.Redis format)
Database0Redis logical database index
ConnectTimeout5 sMaximum connection establishment time
UseConnectionMultiplexingtrueReuse a single multiplexed connection for all calls
RetryPolicy3 retries, 100 ms base, 5 s max, exponentialRetry policy applied on transient Redis errors

MemoryCacheConfiguration

PropertyDefaultDescription
SizeLimit100 MBHard memory ceiling for IMemoryCache
CompactionPercentage5 %Fraction of entries removed when SizeLimit is reached
ExpirationScanFrequency1 minHow 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 / PropertySignatureDescription
NamestringProvider display name
IsAvailableboolCurrent health — false triggers fallback
StatisticsCacheStatisticsLive 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
ExistsAsyncTask<bool>(key, ct)Presence check — does not return the value
RemoveAsyncTask<bool>(key, ct)Delete a single entry; returns true if found
ClearAsyncTask<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
RefreshAsyncTask<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

MethodDescription
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:

TTLMeaning
Soft TTLHow long a value is considered fresh. Within this window every read returns the cached value without any background work.
Hard TTLAbsolute 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");
Tag index entries are stored with a 30-day TTL in the same provider. Each tag entry is a 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

MethodDescription
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

MemberDescription
StatisticsLive CacheStatistics from the underlying provider
IsAvailabletrue 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
Always dispose a 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)

PropertyTypeDescription
HitslongNumber of successful cache lookups
MisseslongNumber of lookups that returned no entry
HitRatiodoubleHits / (Hits + Misses) × 100
ItemCountlongCurrent number of entries in the provider
MemoryUsagelongEstimated memory consumption (bytes)
ExpiredItemslongEntries removed by TTL expiration
EvictedItemslongEntries removed by LRU or memory-pressure eviction

CacheManagerStatistics (combined)

PropertyDescription
PrimaryProviderCacheStatistics for the primary
FallbackProviderCacheStatistics for the fallback
PrimaryProviderNameDisplay name (e.g. "InMemory", "Redis")
PrimaryProviderAvailabletrue when primary is reachable
CombinedHitRatioHits / (Hits + Misses) across both providers
TotalItemCountSum of item counts across both providers
TotalMemoryUsageSum of memory usage across both providers
TotalExpiredItemsSum of expired entries across both providers
TotalEvictedItemsSum 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);