Davasorus.Utility.DotNet.Cache
2026.2.3.1
dotnet add package Davasorus.Utility.DotNet.Cache --version 2026.2.3.1
NuGet\Install-Package Davasorus.Utility.DotNet.Cache -Version 2026.2.3.1
<PackageReference Include="Davasorus.Utility.DotNet.Cache" Version="2026.2.3.1" />
<PackageVersion Include="Davasorus.Utility.DotNet.Cache" Version="2026.2.3.1" />
<PackageReference Include="Davasorus.Utility.DotNet.Cache" />
paket add Davasorus.Utility.DotNet.Cache --version 2026.2.3.1
#r "nuget: Davasorus.Utility.DotNet.Cache, 2026.2.3.1"
#:package Davasorus.Utility.DotNet.Cache@2026.2.3.1
#addin nuget:?package=Davasorus.Utility.DotNet.Cache&version=2026.2.3.1
#tool nuget:?package=Davasorus.Utility.DotNet.Cache&version=2026.2.3.1
Davasorus.Utility.DotNet.Cache
A unified, high-performance caching library for .NET 8+ that provides a common interface for multiple cache backends: in-memory, SQLite, and SQL Server.
Features
- Unified Interface: Common
ICacheOperationsinterface across all backends - Multiple Backends: Choose between in-memory (fast), SQLite (persistent, file-based), or SQL Server (distributed)
- Tiered Cache (L1 + L2): Compose Memory (L1) with SQLite or SqlServer (L2) via
ITieredCacheServicefor hot-data-in-memory + durable-fallback semantics - Batch Operations: Get/set multiple items efficiently with
GetManyAsyncandSetManyAsync - Smart Caching: Built-in
GetOrSetAsyncprevents cache stampede - Statistics & Monitoring: Track cache performance with
ICacheStatistics - Key Pattern Operations: Search and remove by pattern with
ICacheKeyOperations - Service/Client Pattern: Scoped services with transient clients for dependency injection
- Expiration Support: Sliding and absolute expiration for all backends
- Type-Safe: Fully generic with strong typing
- Production Ready: Comprehensive test coverage (200+ tests)
- Async/Await: First-class async support with cancellation tokens
Installation
dotnet add package Davasorus.Utility.DotNet.Cache
When to use this package vs. .NET 9 HybridCache
.NET 9 introduced Microsoft.Extensions.Caching.Hybrid (HybridCache),
Microsoft's first-party tiered (L1 in-memory + L2 distributed) cache
abstraction. Our package partially overlaps with HybridCache but is
designed for different use cases. Pick whichever fits your scenario.
Comparison
| Concern | This package | Microsoft HybridCache (.NET 9+) |
|---|---|---|
| L1+L2 tiering | Yes (via ITieredCacheService) |
Yes (built-in) |
| Memory-only use case | Yes (lightweight) | Overkill |
| SQLite persistence | Yes | No (not a built-in L2 option) |
| SqlServer persistence | Yes | Yes (via IDistributedCache) |
| Tag-based invalidation | Yes (all three backends) | Yes (built-in) |
| Stale-while-revalidate | Yes | No |
| Custom serializer | Yes | Yes |
| Custom compression | Yes | No |
| Stampede prevention | Yes | Yes (built-in) |
| Cache-key fidelity (null-vs-missing distinction) | Yes (TryGetAsync) |
Limited |
| OTel instrumentation | First-class (operations/duration/errors/entries/evictions metrics + activity tags) | Limited |
| BenchmarkDotNet baseline | Yes (per-backend baseline included) | No |
Pick HybridCache when
- You want L1+L2 tiering (in-memory + distributed) with minimal config.
- You're building on .NET 9+ and want the framework-blessed cache abstraction.
- Your use case is well-served by Microsoft's defaults; you don't need custom compression, SWR, or detailed metrics.
Pick this package when
- You want SQLite persistence for desktop apps or single-server scenarios.
- You want L1+L2 tiering with SQLite or SqlServer as the durable
L2 —
ITieredCacheServiceships an opinionated Memory-L1 + SQLite/SqlServer-L2 composition with first-class telemetry. See the Tiered Cache (L1 + L2) section below. - You want stale-while-revalidate semantics on cache reads (return stale while refreshing in background).
- You want custom compression (Brotli, Gzip) for large payloads, especially on SQLite/SqlServer.
- You want detailed OpenTelemetry instrumentation out of the box —
cache.operations,cache.operation.duration,cache.errors,cache.entries,cache.evictionsmetrics plus rich activity tags. - You want tag-based invalidation with a simple, predictable API.
- You're on .NET 8 (HybridCache requires .NET 9+).
Can I use both?
Yes — they're not mutually exclusive. A common pattern:
// Use HybridCache for the request-path L1+L2 caching (Microsoft's strength):
services.AddHybridCache();
// Use this package for a separate persistent SQLite cache for offline data:
services.AddSqliteCacheServices();
The two services live side-by-side in DI; consumers inject whichever is appropriate for the scenario.
Tiered Cache (L1 + L2)
The tiered cache composes a fast in-memory L1 with a durable L2
(SQLite or SqlServer) behind a single ITieredCacheService interface.
Reads hit L1 first and fall back to L2 with promotion on miss; writes
go L2-first then L1 (eventual consistency with L2 as the durable
record of truth). Use it when you want a hot-data working set in
memory backed by a persistent store on a single machine (Memory + SQLite)
or across instances (Memory + SqlServer).
Registration
// Memory L1 + SQLite L2 (single-machine durable cache)
services.AddMemoryCacheServices();
services.AddSqliteCacheServices();
services.AddTieredCacheServices<ISqliteCacheService>();
// — OR —
// Memory L1 + SqlServer L2 (cross-instance distributed cache)
services.AddMemoryCacheServices();
services.AddSqlServerCacheServices();
services.AddTieredCacheServices<ISqlServerCacheService>();
Day-1 only the L1=Memory shape is supported via the AddTieredCacheServices<TL2>()
single-generic overload. SQLite-as-L1 or SqlServer-as-L1 is documented
as future work (see Future direction (tiered cache) below).
Calling AddTieredCacheServices twice on the same IServiceCollection
throws InvalidOperationException — multi-tier-per-host (e.g. one
SQLite tier and one SqlServer tier) isn't currently supported. If
you need that shape, consume the backend services (IMemoryCacheService,
ISqliteCacheService, ISqlServerCacheService) directly without the
tiered wrapper.
Read path
var svc = serviceProvider.GetRequiredService<ITieredCacheService>();
var user = await svc.GetAsync<User>("user:42");
- L1 (Memory) is consulted first. On hit, the value is returned.
- On L1 miss, L2 is consulted. On L2 hit, the value is promoted to
L1 with the L1 TTL bounded by
TieredCacheOptions.L1MaxTtl(defaults to 5 minutes when unset). - On both-tier miss, returns
null/(found: false, value: default).
L1MaxTtl also caps the L1 entry's TTL on direct writes — when
the consumer's sliding or absolute expiration on SetAsync would
exceed L1MaxTtl, the L1 entry is bounded at L1MaxTtl (L2 still
honors the full consumer-requested TTL). This keeps L1 a hot-data
working set even when L2 entries are long-lived.
The optional CheckL2OnL1Hit flag (default false) makes L1 hits also
consult L2 via ExistsAsync to detect L1-vs-L2 inconsistency. When the
L1 entry exists but L2 doesn't, the L1 entry is evicted and the read
reports a miss. Trades L2 round-trip latency on every L1 hit for
stronger consistency — useful when external processes can invalidate
L2 directly.
services.AddTieredCacheServices<ISqlServerCacheService>(opts =>
{
opts.CheckL2OnL1Hit = true;
});
Write path
await svc.SetAsync("user:42", user, new CacheOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
});
- L2 is written first — L2 is the durable record. If L2 throws, the write fails (exception propagates, L1 is not touched).
- L1 is written second. If L1 throws after L2 succeeded, the L1 failure is swallowed and logged (warning), any prior L1 entry for the key is evicted (so a stale L1 entry can't shadow the fresh L2 value), and the write reports success. L2 is authoritative — the next read will repromote from L2.
Pattern removal and tag invalidation
Both surfaces are L2-authoritative with best-effort L1 cleanup:
// Remove all keys matching a glob pattern. L2 is invalidated first
// (returns the affected key list via OUTPUT/RETURNING); each affected
// key is then removed from L1 with CancellationToken.None so cleanup
// runs even if the caller's token cancels mid-loop.
var removedKeys = await svc.RemoveByPatternWithKeysAsync("user:*");
// Same pattern for tag invalidation.
var affectedKeys = await svc.InvalidateByTagWithKeysAsync("session-tags");
Pruning passthrough
ITieredCacheService.PruneExpiredEntriesAsync prunes both tiers and
sums the counts. The DI registration removes the package-owned L1
pruner plus the selected L2 backend's pruner from the
ICachePruning enumerable and re-registers the tiered service in
their place, so the hosted CachePruningHostedService iterates one
tiered entry per configuration (prunes L1 and L2 internally) rather
than iterating the two backend pruners separately. Any third-party
ICachePruning registrations or other-backend pruners registered for
standalone use are preserved. Cooperative cancellation
(OperationCanceledException tied to the caller token) propagates
without inflating cache.errors.
Telemetry
The tiered service emits on its own Meter (Davasorus.Utility.Cache.tiered)
in addition to the per-backend meters:
| Instrument | Kind | Notable tags |
|---|---|---|
cache.operations |
Counter | backend=tiered, operation, result, cache.hit_tier=l1\|l2\|miss |
cache.errors |
Counter | backend=tiered, operation, exception.type |
cache.tier_promotions |
Counter | backend=tiered |
For batch reads (GetManyAsync), the tiered service delegates per-key
to TryGetAsync — emitting one cache.operations event per key with
that key's specific hit_tier. Dashboards see exact-tier visibility
for every key access rather than an aggregated "mixed" bucket.
The L1 and L2 backends emit on their own meters
(Davasorus.Utility.Cache.memory, .sqlite, .sqlserver) the same
way they do when used without the tiered wrapper. A single tiered
write therefore produces three cache.operations events: one on each
of memory / sqlite-or-sqlserver / tiered.
Caveats and limitations
- No cross-instance L1 invalidation. In multi-instance deployments
(N app instances each with their own L1 in front of one shared L2),
invalidations on instance A don't propagate to instances B, C, … L1
entries. Either enable
CheckL2OnL1Hit(doubles L1 read latency) or accept eventual consistency bounded by L1 TTL. - SWR is request-driven, not background-driven.
GetOrSetAsyncwithCacheOptions.Refreshset DOES trigger stale-while-revalidate refreshes on tiered reads — the L1-hit fast path checks the per-key L1 promotion timestamp (no L2 round-trip) and fires a background refresh viaTask.RunonCancellationToken.Nonewhen the entry is stale. What's missing vs. a fully L1-aware SWR is a background scanner — refreshes only fire when a caller actively reads the key. Quiet keys can stay stale until the next access. - Single tier per host.
AddTieredCacheServicesis single-call; multiple tiers in the same DI container would require enumerableITieredCacheServiceregistrations (future work). - Memory-only L1. SQLite-as-L1 (e.g. for "remote SQLite at L1 speed" patterns) is future work.
The umbrella design for the tiered cache lives in the package's planning workspace alongside the per-slice specs (not shipped in this README) and covers read/write semantics, telemetry conventions, DI rewiring, and the breaking-change inventory across slices in greater depth than this section.
Future direction (tiered cache)
Acknowledged limitations and known future-work items:
- Cross-instance L1 invalidation backplane. Redis pub/sub or SQL Server Service Broker to broadcast invalidations to peer L1s.
- Background-driven SWR. Today SWR fires only when a caller reads a stale-but-cached key (request-driven). A future enhancement would add a background scanner so quiet keys can refresh without a foreground read.
- 3-tier (Memory + SQLite + SqlServer). Generic
TieredCacheService<TL1, TL2, TL3>shape for Memory → SQLite → SqlServer hierarchies. - Non-Memory L1. SQLite-as-L1 or SqlServer-as-L1 wiring.
Day-1 only ships Memory-as-L1 DI helpers — the single-generic
AddTieredCacheServices<TL2>()overload composes a fixedIMemoryCacheServiceL1 with the selected L2. A future slice would add a 2-generic overload selecting both L1 and L2.
Bridging to HybridCache
We don't implement HybridCache's API or provide adapters to use our
backends as HybridCache's L2. The two abstractions stay side-by-side
in DI; consumers pick whichever fits the scenario per the comparison
table above. If you have a specific integration use case, please open
an issue — that demand signal would justify a future spec.
Upgrade note — SqlServer schema change
Starting in version 2026.2.x (this slice's release; exact version stamped by CI), the SqlServer
cache backend owns its own database schema instead of wrapping
Microsoft.Extensions.Caching.SqlServer. The default table name has changed:
| Before | After |
|---|---|
[cache].[CacheData] |
[cache].[CacheDataV2] |
Action required on upgrade:
- If you were using the cache only for ephemeral data (the normal case),
no action is needed — old cache entries simply become unreachable and the
new table is created automatically on first call to
InitializeAsync. - If you had a SQL Agent job or other background process running against
the old
[cache].[CacheData]table (for example, the eviction job fromMicrosoft.Extensions.Caching.SqlServer), it is no longer needed — the cache now manages expiration internally (lazy on read in this slice, with an explicitPruneExpiredEntriesAsyncplanned in a follow-up slice). - The
IDistributedCacheservice is no longer registered byAddSqlServerCacheServices. If your code resolvesIDistributedCachefrom DI (rather thanISqlServerCacheService), registerMicrosoft.Extensions.Caching.SqlServerdirectly alongside this package.
Consumers can keep the old table name by passing it explicitly to
InitializeAsync(connectionString, "cache", "CacheData") — but the package
will create the V2 schema in that table on first run, which may conflict
with leftover columns from the previous shape. Recommended: let the package
create CacheDataV2 and drop the old table once verified empty.
Quick Start
Memory Cache (In-Memory)
Perfect for high-performance, non-persistent caching.
Note: Memory cache stores references, not copies. Values you retrieve from
MemoryCacheServiceare the same instances stored. Do not mutate retrieved objects — that mutation is visible to all subsequent readers of the same key. If you need copy semantics, clone the object after retrieval. (This matches the behavior ofIMemoryCache,ConcurrentDictionary, and other in-process .NET caches.)
using Davasorus.Utility.DotNet.Cache.Configuration;
// Recommended: Use extension methods
services.AddMemoryCacheServices();
// Or with fluent configuration for batch operations
services.AddMemoryCacheServices(cache => cache
.WithParallelBatching()
.WithMaxDegreeOfParallelism(4)
.WithBatchSize(100));
// Usage
public class MyService
{
private readonly IMemoryCacheService _cache;
public MyService(IMemoryCacheService cache)
{
_cache = cache;
}
public async Task<User> GetUserAsync(int userId)
{
var key = $"user:{userId}";
// Get or create with factory pattern
// Factory receives the cache key and a CancellationToken
return await _cache.GetOrSetAsync(key, async (_, ct) =>
{
// This only runs if cache miss
return await _database.GetUserAsync(userId, ct);
}, new CacheOptions
{
SlidingExpiration = TimeSpan.FromMinutes(15)
});
}
}
SQLite Cache (File-Based Persistence)
Ideal for persistent caching in desktop apps or services without database infrastructure.
using Davasorus.Utility.DotNet.Cache.Configuration;
// Recommended: Use extension methods
services.AddSqliteCacheServices();
// Or with fluent configuration
services.AddSqliteCacheServices(cache => cache
.DisableParallelBatching()
.WithBatchSize(50));
// Initialize at startup
var cache = serviceProvider.GetRequiredService<ISqliteCacheService>();
await cache.InitializeAsync("./cache/app-cache.db");
// Usage
public class ProductService
{
private readonly ISqliteCacheService _cache;
public ProductService(ISqliteCacheService cache)
{
_cache = cache;
}
public async Task<List<Product>> GetProductsAsync(string category)
{
var key = $"products:{category}";
var cached = await _cache.GetAsync<List<Product>>(key);
if (cached != null)
return cached;
var products = await _database.GetProductsByCategoryAsync(category);
await _cache.SetAsync(key, products, new CacheOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
});
return products;
}
// Database maintenance
public async Task PerformMaintenanceAsync()
{
// Remove expired entries
var removed = await _cache.PruneExpiredEntriesAsync();
Console.WriteLine($"Removed {removed} expired entries");
// Compact database
await _cache.CompactDatabaseAsync();
// Get statistics
var stats = await _cache.GetStatisticsAsync();
Console.WriteLine($"Cache size: {stats.SizeInBytes / 1024}KB");
}
}
SQL Server Cache (Distributed)
Best for distributed applications requiring shared cache across multiple servers.
using Davasorus.Utility.DotNet.Cache.Configuration;
// Recommended: Use extension methods
services.AddSqlServerCacheServices();
// Or with fluent configuration
services.AddSqlServerCacheServices(cache => cache
.WithSqlBatchSize(500)
.WithBatchSize(100));
// Initialize at startup. The schema and table are created automatically
// if they don't already exist — no separate tooling needed.
var cache = serviceProvider.GetRequiredService<ISqlServerCacheService>();
await cache.InitializeAsync(
connectionString: "Server=localhost;Database=AppCache;...",
schemaName: "cache",
tableName: "CacheDataV2" // default; pass a different name to override
);
// AddSqlServerCacheServices registers pkg #14 (Davasorus.Utility.DotNet.SQL)
// internally. Your app must also have logging configured (Host.CreateDefaultBuilder
// does this automatically) and register Davasorus.Utility.DotNet.SQS via
// AddSqsLogging(...) — pkg #14 takes a required ISqsService dependency.
// Usage
public class SessionService
{
private readonly ISqlServerCacheService _cache;
public SessionService(ISqlServerCacheService cache)
{
_cache = cache;
}
public async Task<Session> GetSessionAsync(string sessionId)
{
var key = $"session:{sessionId}";
return await _cache.GetAsync<Session>(key);
}
public async Task SaveSessionAsync(string sessionId, Session session)
{
var key = $"session:{sessionId}";
await _cache.SetAsync(key, session, new CacheOptions
{
// Session expires after 20 minutes of inactivity
SlidingExpiration = TimeSpan.FromMinutes(20)
});
}
}
Cross-process stampede protection
When you run the same cache backend from multiple processes (e.g., web app instances behind a load balancer), the per-process stampede prevention isn't enough — each process could independently invoke the factory for the same key during a cold start. The SqlServer backend uses sp_getapplock as an outer ring around the per-process lock so that, even across processes, only one factory invocation per key runs at a time.
The feature is enabled by default. Disable it for single-instance scenarios that don't need the extra DB round-trip:
services.AddSqlServerCacheServices(cache => cache
.WithoutCrossProcessLock()); // single-instance only — skip sp_getapplock
Tune the acquisition timeout (default 30s) if your factory is unusually fast or slow:
services.AddSqlServerCacheServices(cache => cache
.WithCrossProcessLockTimeout(5000)); // 5s — for snappy factories that should never wait long
Failure policy: if sp_getapplock can't be acquired within the configured timeout (or returns a deadlock-victim code), the call falls back to in-process-only protection with a Warning log and the activity tag cache.lock_scope="in_process_fallback" plus cache.lock_fallback_reason={timeout|deadlock|cancelled|exception}. The factory still runs; just without the cross-process guarantee for that one call. Rising fallback rate (visible on the cache.lock_acquisitions{result="fallback"} counter) is the signal to size up the connection pool or raise the timeout.
Operational note: sp_getapplock with @LockOwner='Transaction' holds a SQL connection for the duration of the factory. Size your connection pool to at least the expected number of concurrent unique cache-miss keys per process. The per-process semaphore deduplicates same-key contention within a process, so this is bounded by the number of distinct slow factories running in parallel.
Per-instance isolation (advanced): if you run multiple intentionally-isolated cache services in the same process (e.g., per-tenant cache pools), set distinct LockKeyNamespace values so the in-process lock dictionaries don't dedupe across instances:
services.AddSqlServerCacheServices(cache => cache
.WithLockKeyNamespace("tenant-a")); // tenant-A cache won't share in-process locks with tenant-B
API Reference
Common Interface (ICacheOperations)
All cache services implement this unified interface:
public interface ICacheOperations : IAsyncDisposable
{
// Get single value from cache
Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default);
// Get with explicit hit/miss disambiguation (distinguishes key-missing from stored-null)
Task<(bool found, T? value)> TryGetAsync<T>(
string key,
CancellationToken cancellationToken = default
);
// Get multiple values in one operation
Task<Dictionary<string, T?>> GetManyAsync<T>(
IEnumerable<string> keys,
CancellationToken cancellationToken = default
);
// Get from cache or create using factory (prevents cache stampede)
// Factory receives the cache key and a CancellationToken
Task<T?> GetOrSetAsync<T>(
string key,
Func<string, CancellationToken, Task<T>> factory,
CacheOptions? options = null,
CancellationToken cancellationToken = default
);
// Set single value in cache
Task SetAsync<T>(
string key,
T value,
CacheOptions? options = null,
CancellationToken cancellationToken = default
);
// Set multiple values in one operation
Task SetManyAsync<T>(
Dictionary<string, T> items,
CacheOptions? options = null,
CancellationToken cancellationToken = default
);
// Remove value from cache
Task<bool> RemoveAsync(string key, CancellationToken cancellationToken = default);
// Check if key exists
Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default);
}
Note: Initialization is implementation-specific. Each cache type provides its own initialization method with appropriate parameters (e.g., database path for SQLite, connection string for SQL Server). Memory cache requires no initialization.
Cache Options
public class CacheOptions
{
// Sliding expiration - resets on access
public TimeSpan? SlidingExpiration { get; set; }
// Absolute expiration relative to now
public TimeSpan? AbsoluteExpirationRelativeToNow { get; set; }
// Absolute expiration at specific time
public DateTimeOffset? AbsoluteExpiration { get; set; }
// Priority (Memory cache only)
public CacheItemPriority Priority { get; set; } // Low, Normal, High, NeverRemove
}
Statistics Interface (ICacheStatistics)
Memory, SQLite, and SqlServer backends all support detailed statistics via
ICacheStatistics. All backends return the canonical CacheStats record with
three fields:
public interface ICacheStatistics
{
Task<CacheStats> GetStatisticsAsync(CancellationToken cancellationToken = default);
}
public record CacheStats
{
public long ItemCount { get; init; } // Total items in cache
public long? SizeInBytes { get; init; } // Cache size in bytes (SQLite only; null otherwise)
public long? ExpiredEntryCount { get; init; } // Entries past expiration but not yet pruned (SQLite only)
}
Hit/miss/operation-count metrics are no longer fields on CacheStats. Consume
them from the OTel cache.operations Counter on the backend's Meter instead
(see "OpenTelemetry instrumentation" below).
Key Pattern Operations (ICacheKeyOperations)
Memory, SQLite, and SqlServer caches support key pattern matching:
public interface ICacheKeyOperations
{
// Get keys matching pattern (* and ? wildcards)
Task<IEnumerable<string>> GetKeysAsync(
string? pattern = null,
CancellationToken cancellationToken = default
);
// Streaming key traversal — yields keys lazily without buffering the whole result set
IAsyncEnumerable<string> EnumerateKeysAsync(
string? pattern = null,
CancellationToken cancellationToken = default
);
// Remove all keys matching pattern
Task<int> RemoveByPatternAsync(
string pattern,
CancellationToken cancellationToken = default
);
}
// Example usage:
var userKeys = await cache.GetKeysAsync("user:*");
var removedCount = await cache.RemoveByPatternAsync("session:expired:*");
// Streaming variant — useful for large key sets
await foreach (var key in cache.EnumerateKeysAsync("user:*"))
{
// process each key without materializing the full list
}
Memory Cache Specific
// Implements: ICacheOperations, IClearableCache, ICacheStatistics, ICacheKeyOperations
// Synchronous key access
IEnumerable<string> GetAllKeys();
// Cache statistics — async only; returns the canonical CacheStats
Task<CacheStats> GetStatisticsAsync(CancellationToken cancellationToken = default);
SQLite Cache Specific
// Implements: ICacheOperations, IClearableCache, ICacheStatistics, ICacheKeyOperations
// Initialize database (must be called before use)
Task InitializeAsync(string databasePath, CancellationToken cancellationToken = default);
// Cache statistics — returns the canonical CacheStats record
// (ItemCount, SizeInBytes, ExpiredEntryCount)
Task<CacheStats> GetStatisticsAsync(CancellationToken cancellationToken = default);
// Remove expired entries
Task<int> PruneExpiredEntriesAsync(CancellationToken cancellationToken = default);
// Compact database file
Task CompactDatabaseAsync(CancellationToken cancellationToken = default);
SQL Server Cache Specific
// Implements: ICacheOperations only (no statistics or pattern operations)
// Initialize with connection string (must be called before use)
Task InitializeAsync(
string connectionString,
string schemaName = "cache",
string tableName = "CacheData",
CancellationToken cancellationToken = default
);
Pluggable Serializer
The cache package supports pluggable serialization via ICacheSerializer.
The default implementation, JsonCacheSerializer, wraps System.Text.Json.
Customizing JsonSerializerOptions (incl. source-gen)
Register a JsonSerializerOptions with your JsonSerializerContext
before calling the cache extension:
services.AddSingleton(new JsonSerializerOptions
{
TypeInfoResolver = MyAppJsonContext.Default,
});
services.AddSqliteCacheServices(...);
JsonCacheSerializer will pick up the registered options. Source-gen
context eliminates reflection overhead at serialization time.
Replacing the serializer entirely
Implement ICacheSerializer and register it as a singleton:
public sealed class MessagePackCacheSerializer : ICacheSerializer
{
public byte[] Serialize<T>(T? value) =>
MessagePack.MessagePackSerializer.Serialize(value);
public T? Deserialize<T>(ReadOnlySpan<byte> payload) =>
MessagePack.MessagePackSerializer.Deserialize<T>(payload);
}
services.AddSingleton<ICacheSerializer, MessagePackCacheSerializer>();
services.AddSqliteCacheServices(...);
The Memory backend does not use the serializer (it stores references directly).
Compression
The cache package supports pluggable compression via ICacheCompressor.
Three implementations are shipped:
NullCacheCompressor— no-op, prefixes the uncompressed marker (default).GzipCacheCompressor— gzip viaSystem.IO.Compression.GZipStream.BrotliCacheCompressor— brotli viaSystem.IO.Compression.BrotliStream.
Each compressor compresses payloads at or above its MinCompressionSizeBytes
threshold (default 1024) and passes payloads below threshold through with
the uncompressed marker. Compressors interop on read: each can read
payloads written by itself OR the no-op compressor; cross-algorithm reads
throw a helpful InvalidDataException.
services.AddSingleton<ICacheCompressor>(new BrotliCacheCompressor
{
MinCompressionSizeBytes = 512,
});
services.AddSqliteCacheServices(...);
Tag-Based Invalidation
Memory, SQLite, and SqlServer backends support tag-based invalidation: attach symbolic tags to cache entries when storing them, then invalidate groups of related entries by tag in O(tags-touched) time instead of O(all-keys).
// Tag entries on write:
await cache.SetAsync(
$"user:{userId}:profile",
profile,
new CacheOptions { Tags = new[] { $"tenant-{tenantId}", "user-profiles" } });
// Invalidate everything for a tenant:
if (cache is ICacheTagInvalidation tagInvalidator)
{
var removed = await tagInvalidator.InvalidateByTagAsync($"tenant-{tenantId}");
logger.LogInformation("Removed {Count} entries for tenant {TenantId}", removed, tenantId);
}
// Invalidate the union of multiple tags (logical OR):
await tagInvalidator.InvalidateByTagsAsync(new[] { "tenant-42", "stale-reports" });
Backend support matrix
| Backend | Tag invalidation |
|---|---|
| Memory | Yes |
| SQLite | Yes |
| SqlServer | Yes |
Consumers writing backend-agnostic code can detect support via the
runtime type-check shown above; backends that don't implement
ICacheTagInvalidation simply omit the interface, and the cast returns
false.
Atomicity
InvalidateByTagAsync removes everything tagged at the time the query
starts. Entries added concurrently during the invalidation (with the same
tag) survive. Consumers needing strict atomicity should coordinate
externally (e.g., a write-side lock, or invalidate-then-recheck).
Activity instrumentation
Tag-based operations emit:
operation="invalidateByTag"or"invalidateByTags"on thecache.operationsCounter (and corresponding activity span).cache.invalidated_countactivity tag — the number of entries removed.cache.tagsactivity tag — the tag(s) being invalidated. Opt-in viaCacheOperationsOptions.RecordCacheTags = true(or the builder'sWithCacheTagRecording(true)). Off by default to avoid high-cardinality tag explosion on busy systems.
Stale-While-Revalidate
All backends (Memory, SQLite, SqlServer) support stale-while-revalidate
(SWR): set CacheOptions.Refresh when caching a value, and after that
duration passes, reads via GetOrSetAsync return the cached (stale)
value immediately AND trigger a background refresh.
var profile = await cache.GetOrSetAsync(
$"user:{userId}:profile",
(key, ct) => fetchUserProfileAsync(userId, ct),
new CacheOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(60), // hard expiry
Refresh = TimeSpan.FromMinutes(5), // stale after 5 min
});
After 5 minutes, the next read returns the cached profile immediately and kicks off a background fetch. Concurrent stale reads see the same stale value — only one of them triggers the background refresh; the others are no-ops on the refresh path.
After 60 minutes (absolute expiry), the entry is gone; the next read is a normal cache miss with synchronous factory invocation.
Backend support matrix
| Backend | SWR support |
|---|---|
| Memory | Yes |
| SQLite | Yes |
| SqlServer | Yes |
Consumers writing backend-agnostic code don't need explicit detection — SWR is automatically a no-op on backends that don't support it.
Background refresh behavior
- Only one refresh per key runs at a time. Concurrent stale reads see the same stale value but don't all trigger separate refreshes (the per-key lock from cache stampede prevention coordinates).
- Background refresh uses
CancellationToken.None— the caller's cancellation does NOT cancel work other concurrent callers benefit from. If you need bounded refresh time, your factory should set its own cancellation token internally. - If the background factory throws, the error is logged at Warning level. The cache keeps the stale value; the next stale read triggers another refresh attempt (thundering retry, idiomatic for SWR).
- Background refresh writes to the cache via the same pipeline as foreground writes (serializer, compressor, tag preservation if applicable).
Activity instrumentation
SWR adds three new activity tags:
cache.is_stale(bool) ongetOrSetactivities — true when the read returned a stale value.cache.refresh_started(bool) — true if THIS caller's read triggered the background refresh (vs. another caller already running it).cache.refresh_complete(bool) on the syntheticbackgroundRefreshactivity — true on success, false on factory exception.
The cache.operations Counter's result tag gains a new value:
result="hit"— fresh cached value (not stale).result="stale"— cached value past refresh window; background refresh kicked off.result="miss"— factory ran synchronously (cache empty).
Recent Breaking Changes
This section documents breaking changes shipped in the most recent release. For older breaking changes, consult the package's git history and GitHub release notes.
Spec B: API consolidation
1. GetStatistics / GetStatisticsAsync returns CacheStats
The backend-specific stats types (CacheStatistics, SqliteCacheStatistics)
have been deleted. All backends now return the canonical CacheStats record
with three fields: ItemCount (long), SizeInBytes (long?), and
ExpiredEntryCount (long?).
// BEFORE
var stats = await sqliteService.GetStatisticsAsync();
var entries = stats.EntryCount;
var dbSize = stats.DatabaseSize;
// AFTER
var stats = await sqliteService.GetStatisticsAsync();
var entries = stats.ItemCount; // long
var bytes = stats.SizeInBytes; // long?
var expired = stats.ExpiredEntryCount; // long?
Memory's GetStatistics() (sync) is replaced by GetStatisticsAsync():
// BEFORE
var stats = memoryService.GetStatistics();
// AFTER
var stats = await memoryService.GetStatisticsAsync();
Hit-rate and operation-count metrics are no longer stats fields — consume
them from the OTel cache.operations Counter on the backend's Meter
instead.
2. GetOrSetAsync factory takes (string key, CancellationToken ct)
The factory delegate signature has changed from Func<Task<T>> to
Func<string, CancellationToken, Task<T>>.
// BEFORE
await cache.GetOrSetAsync("user:42", () => fetchUserAsync(42));
// AFTER (using the key from the factory)
await cache.GetOrSetAsync("user:42", (key, ct) =>
fetchUserAsync(int.Parse(key.Split(':')[1]), ct));
// AFTER (ignoring the new parameters with discards)
await cache.GetOrSetAsync("user:42", (_, _) => fetchUserAsync(42));
Additionally, GetOrSetAsync now distinguishes "key missing" from "key
present with stored null." Previously, a stored null re-invoked the
factory; with the new internal use of TryGetAsync, a stored null is
treated as a cache hit and the factory is not invoked. Consumers
relying on the old behavior should call RemoveAsync(key) before
GetOrSetAsync.
3. Activity-tag operation values shifted
GetAsync is now a thin wrapper over TryGetAsync. As a result, calls
to GetAsync emit metrics under operation="tryGet" (not
operation="get"). Existing dashboards filtering on operation="get"
should be updated to filter on operation="tryGet" (or use
operation IN ("get", "tryGet") to capture both pre- and post-Spec-B
data).
New additive features
Task<(bool found, T? value)> TryGetAsync<T>(string key, CancellationToken)onICacheOperations— distinguishes key-missing from stored-null.IAsyncEnumerable<string> EnumerateKeysAsync(string? pattern, CancellationToken)onICacheKeyOperations— streaming key traversal (Memory + SQLite + SqlServer).
Spec E: Pluggable serializer + compression
Schema change for SQLite caches
The SQLite Cache table's Value column changed from TEXT to BLOB.
Existing SQLite cache files written by earlier versions of this package
are incompatible with the new format and will fail to read.
Migration: delete the old cache file before upgrading.
if (File.Exists(databasePath))
File.Delete(databasePath);
await sqliteCacheService.InitializeAsync(databasePath);
The cache will repopulate organically.
Schema change for SqlServer caches
The [cache].[CacheData] table schema is unchanged, but the byte format
of the Value column has changed (now includes a 1-byte compression
marker prefix). Existing rows are unreadable.
Migration:
TRUNCATE TABLE [cache].[CacheData];
The cache will repopulate organically.
No migration for Memory caches
The Memory backend stores references in-process. Process restart clears everything; no migration action needed.
Constructor change for direct (non-DI) instantiation
SqliteCacheClient and SqlServerCacheClient constructors now require
ICacheSerializer and ICacheCompressor parameters. DI consumers are
unaffected — the cache extensions register JsonCacheSerializer and
NullCacheCompressor as defaults.
If you instantiate clients directly (not via DI):
// BEFORE
var client = new SqliteCacheClient();
// AFTER
var client = new SqliteCacheClient(
new JsonCacheSerializer(),
new NullCacheCompressor());
Slice 3: SqlServer SWR support
1. ISqlServerCacheClient extends ISwrSupportingClient
The ISqlServerCacheClient public interface now extends ISwrSupportingClient,
which adds one required member:
Task<DateTimeOffset?> GetCreatedAtAsync(string key, CancellationToken cancellationToken = default);
This is a source-break for any consumer with a custom implementation of
ISqlServerCacheClient (test doubles, decorators, wrapping clients). Custom
implementations must add GetCreatedAtAsync — typically delegating to the
inner client or returning null if SWR is not supported by the decorator.
// BEFORE — minimal custom client
public class MyDecorator : ISqlServerCacheClient
{
// implements ICacheOperations + ICacheStatistics + InitializeAsync
}
// AFTER — same client must add GetCreatedAtAsync
public class MyDecorator : ISqlServerCacheClient
{
// existing members...
public Task<DateTimeOffset?> GetCreatedAtAsync(string key, CancellationToken ct = default)
=> _inner.GetCreatedAtAsync(key, ct); // delegate to inner client
}
2. CacheServiceBase.GetOrSetWithLockAsync virtual signature change
The protected virtual method on CacheServiceBase<TClient> gained a new
optional parameter:
// BEFORE
protected virtual Task<(T? value, string resultTag)> GetOrSetWithLockAsync<T>(
string key,
Func<string, CancellationToken, Task<T>> factory,
CacheOptions? options,
CancellationToken cancellationToken);
// AFTER
protected virtual Task<(T? value, string resultTag)> GetOrSetWithLockAsync<T>(
string key,
Func<string, CancellationToken, Task<T>> factory,
CacheOptions? options,
CancellationToken cancellationToken,
Func<string, CancellationToken, Task<T>>? swrRefreshFactory = null);
Call sites with the old four-argument shape continue to compile (the new
parameter has a default value), but external subclasses that override
GetOrSetWithLockAsync must update their override signature to match.
Memory, SQLite, and SqlServer's in-tree subclasses are already updated.
Subclasses that don't customize SWR refresh behavior can ignore the new
parameter and pass it through to base.GetOrSetWithLockAsync(...) unchanged.
Slice 4: SqlServer key enumeration
1. ISqlServerCacheClient extends ICacheKeyOperations
The ISqlServerCacheClient public interface now extends ICacheKeyOperations,
adding three required members:
Task<IEnumerable<string>> GetKeysAsync(string? pattern = null, CancellationToken cancellationToken = default);
IAsyncEnumerable<string> EnumerateKeysAsync(string? pattern = null, CancellationToken cancellationToken = default);
Task<int> RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default);
This is a source-break for consumers with custom implementations of
ISqlServerCacheClient (test doubles, decorators, wrapping clients). Custom
implementations must add all three methods — typically delegating to an inner
client or returning empty/default results if pattern operations aren't supported
by the decorator.
// BEFORE — custom client implementing ICacheOperations + ICacheStatistics + ISwrSupportingClient
public class MyDecorator : ISqlServerCacheClient
{
// existing TryGetAsync, SetAsync, RemoveAsync, ExistsAsync, GetStatisticsAsync, GetCreatedAtAsync...
}
// AFTER — same client must add ICacheKeyOperations members
public class MyDecorator : ISqlServerCacheClient
{
// existing members...
public Task<IEnumerable<string>> GetKeysAsync(string? pattern = null, CancellationToken ct = default)
=> _inner.GetKeysAsync(pattern, ct);
public IAsyncEnumerable<string> EnumerateKeysAsync(string? pattern = null, CancellationToken ct = default)
=> _inner.EnumerateKeysAsync(pattern, ct);
public Task<int> RemoveByPatternAsync(string pattern, CancellationToken ct = default)
=> _inner.RemoveByPatternAsync(pattern, ct);
}
Pattern syntax follows the cross-backend convention: glob wildcards * (zero or
more characters) and ? (single character) translate to SQL Server LIKE % and
_. Same pattern string yields the same matches on Memory, SQLite, and SqlServer.
Note on RemoveByPatternAsync empty-pattern handling: SqlServer's implementation
throws ArgumentException on null/empty/whitespace pattern. SQLite does not
currently treat empty/whitespace input as "match all"; if you intend to remove
all matching keys across backends, pass an explicit wildcard pattern such as *.
Known limitation (both backends): user-supplied keys containing literal %, _,
or [ (SQL Server-specific) produce false-positive matches because the glob → LIKE
translation doesn't escape SQL metacharacters. A future hygiene slice ships proper
escape consistency in both backends as a coordinated documented break.
Slice 5: SqlServer tag invalidation
1. ISqlServerCacheClient extends ICacheTagInvalidation
The ISqlServerCacheClient public interface now extends ICacheTagInvalidation,
adding two required members:
Task<int> InvalidateByTagAsync(string tag, CancellationToken cancellationToken = default);
Task<int> InvalidateByTagsAsync(IEnumerable<string> tags, CancellationToken cancellationToken = default);
This is a source-break for consumers with custom implementations of
ISqlServerCacheClient (test doubles, decorators, wrapping clients). Custom
implementations must add both methods — typically delegating to an inner
client or returning 0 if tag operations aren't supported by the decorator.
// AFTER
public class MyDecorator : ISqlServerCacheClient
{
// existing members...
public Task<int> InvalidateByTagAsync(string tag, CancellationToken ct = default)
=> _inner.InvalidateByTagAsync(tag, ct);
public Task<int> InvalidateByTagsAsync(IEnumerable<string> tags, CancellationToken ct = default)
=> _inner.InvalidateByTagsAsync(tags, ct);
}
2. InitializeAsync gains optional tagTableName parameter
ISqlServerCacheClient.InitializeAsync now accepts an optional tagTableName
parameter, default "CacheTagsV2":
// BEFORE
Task InitializeAsync(
string connectionString,
string schemaName = "cache",
string tableName = "CacheDataV2",
CancellationToken cancellationToken = default);
// AFTER
Task InitializeAsync(
string connectionString,
string schemaName = "cache",
string tableName = "CacheDataV2",
string tagTableName = "CacheTagsV2",
CancellationToken cancellationToken = default);
Call-site compatibility — important caveat: The new parameter has a
default value, so call sites passing ≤3 positional arguments or using
named-argument syntax for cancellationToken continue to compile
unchanged:
// Both still compile after Slice 5:
await client.InitializeAsync(connString); // 1 positional
await client.InitializeAsync(connString, "cache"); // 2 positional
await client.InitializeAsync(connString, "cache", "CacheData"); // 3 positional
await client.InitializeAsync(connString, cancellationToken: token); // named cancellation
However, call sites using the 4-argument positional form that previously
bound CancellationToken to the 4th slot will silently bind their token to
the new tagTableName parameter and fail to compile (or, worse, silently
re-bind if the token argument is a string expression):
// BEFORE Slice 5 — compiled fine, ct bound to position 4 (CancellationToken):
await client.InitializeAsync(connString, "cache", "CacheData", ct);
// AFTER Slice 5 — fails to compile (CancellationToken doesn't convert to string),
// because position 4 is now tagTableName:
await client.InitializeAsync(connString, "cache", "CacheData", ct);
// Migration: switch to named arguments. Recommended for ALL 4+ positional
// callers since this kind of parameter-insertion is rare but corrosive when
// it happens silently.
await client.InitializeAsync(connString, "cache", "CacheData", cancellationToken: ct);
Custom implementations of ISqlServerCacheClient must update their
InitializeAsync override signature to include the new parameter — pass it
through to the inner client unchanged if no customization is needed.
3. Tag length limit (449 characters)
SqlServer enforces a maximum tag length of 449 characters (matching the
schema's NVARCHAR(449) Tag column). Every method that accepts tag input
throws ArgumentException if any tag exceeds this limit:
SetAsyncandSetManyAsync— viaoptions.TagsInvalidateByTagAsync— single tag argumentInvalidateByTagsAsync— each item in the collection (validation runs after the null/whitespace filter, so empty placeholder tags don't trip it)
SQLite has no such limit (its Tag TEXT column is unbounded). Consumers using
SqlServer must ensure their tag namespacing fits within 449 chars; a hash of a
longer identifier is the usual workaround.
A future hygiene slice could either widen SqlServer's column to NVARCHAR(MAX)
(loses indexability) or tighten SQLite to match (cross-backend consistency at
the cost of an existing-data length check). Not in scope for Slice 5.
4. ISqlServerCacheService extends ICacheTagInvalidation
The service-layer interface ISqlServerCacheService also extends
ICacheTagInvalidation to match the cross-backend convention (Memory and
SQLite service interfaces already do). Consumers with custom implementations
of ISqlServerCacheService (e.g., test fakes) must add InvalidateByTagAsync
and InvalidateByTagsAsync — typically as thin delegations to an inner service.
Slice 6: Service-layer key operations parity
All three cache-service interfaces — IMemoryCacheService, ISqliteCacheService,
ISqlServerCacheService — now extend ICacheKeyOperations. Consumers writing
backend-agnostic code via service is ICacheKeyOperations get a working
implementation at the service layer instead of false. (The client interfaces
already extended ICacheKeyOperations from Slice 4; Slice 6 closes the
corresponding service-layer gap.)
Source-break: custom implementations of any of these service interfaces must add three methods:
Task<IEnumerable<string>> GetKeysAsync(string? pattern = null, CancellationToken ct = default);
IAsyncEnumerable<string> EnumerateKeysAsync(string? pattern = null, CancellationToken ct = default);
Task<int> RemoveByPatternAsync(string pattern, CancellationToken ct = default);
Custom implementations can typically delegate to an inner service or client.
See the canonical MemoryCacheService / SqliteCacheService / SqlServerCacheService
implementations for the OTel-instrumented wrapping pattern (GetKeysAsync and
RemoveByPatternAsync go through CacheServiceBase.ExecuteAsync;
EnumerateKeysAsync uses manual instrumentation because IAsyncEnumerable<T>
doesn't fit the ExecuteAsync<T> signature — see the source for the streaming
pattern).
RemoveByPatternAsync empty-pattern guard: the service layer rejects
null/empty/whitespace patterns up-front with ArgumentException, mirroring the
client-layer guard from Slice 4. This prevents OTel from recording a spurious
"error" tick for what's a programming-error input.
Cancellation surface on EnumerateKeysAsync (SqlServer): mid-stream
cancellation may surface as OperationCanceledException OR as a SqlServer-
wrapped SqlException with the cancellation message — the same bimodal
surface Slice 5.6's TranslateCancellation hook addresses for non-streaming
methods. The OTel result tag is recorded as "cancelled" correctly in both
cases (the streaming finally checks the token state), but the exception
type seen by the consumer may differ between backends. A future hygiene slice
could extend TranslateCancellation to streaming paths if needed.
Slice 7: Background Eviction (PSDO-2473)
All three cache-service interfaces — IMemoryCacheService, ISqliteCacheService,
ISqlServerCacheService — now extend ICachePruning:
public interface ICachePruning
{
Task<int> PruneExpiredEntriesAsync(CancellationToken cancellationToken = default);
}
Per-backend semantics
- Memory — backed by
MemoryCache.Compact(0.0). Requires the consumer to registerIMemoryCacheviaservices.AddMemoryCache()(the default). CustomIMemoryCachedecorators are not supported and throw a clearInvalidOperationExceptionon prune. - SQLite —
DELETE FROM Cache WHERE ExpiresAtTime IS NOT NULL AND datetime(ExpiresAtTime) <= datetime('now'). - SqlServer —
DELETE FROM [schema].[table] WHERE ExpiresAtTime <= SYSUTCDATETIME()guarded by a session-scopedsp_getapplock(resource:Davasorus.Cache.Prune:<schema.table hash>, timeout 0). Peer instances arriving during a prune skip cleanly and return 0 — this is not an error.
Background host
CachePruningHostedService is registered automatically by every AddXxxCacheServices
extension. With multiple backends wired into the same DI container, a single host
iterates all of them per tick.
Configuration (CacheOperationsOptions):
| Property | Default | Purpose |
|---|---|---|
PruningInterval |
TimeSpan.FromMinutes(30) |
Base interval. Set null or TimeSpan.Zero to disable. |
PruningIntervalJitter |
0.10 |
±10% random jitter to prevent thundering-herd. |
PruningBackoffCapMultiplier |
5 |
Maximum multiplier applied after consecutive failures. |
When a backend's PruneExpiredEntriesAsync throws inside a tick, the host:
- Logs the exception (
ILogger.LogError). - Increments
cache.errorswithoperation="prune",backend=<type>,exception.type=<full type name>. - Doubles the whole-tick delay on the next tick (based on the max consecutive-failure count across all backends, capped at
PruningBackoffCapMultiplier × base). One sustained failure therefore slows all backends' tick cadence; this is a tradeoff for a single-host design. - Resets backoff on the next successful prune of that backend.
The host emits a per-tick activity Cache.PruneTick with tags backends.count,
backends.succeeded, backends.failed, tick.duration_ms.
Telemetry parity
The cache.evictions counter (tagged backend, reason="expired") is emitted by
each backend's PruneExpiredEntriesAsync — whether called manually or by the host.
Dashboards built on cache.evictions work identically in both invocation paths.
Breaking changes summary
| Break | Mitigation |
|---|---|
Adding ICachePruning to IMemoryCacheService, ISqliteCacheService, ISqlServerCacheService |
Slice 7. Source-level break for direct implementers (mocks/fakes); binary-compatible. Add Task<int> PruneExpiredEntriesAsync(CancellationToken) to your implementation. Custom implementations can typically delegate to an inner service or client. |
CachePruningHostedService runs background DELETE every 30 minutes by default |
Slice 7. Set CacheOperationsOptions.PruningInterval = null to opt out. This restores Microsoft.Extensions.Caching.SqlServer's implicit pre-Slice-1 behavior. |
Performance Characteristics
| Backend | Speed | Persistence | Distributed | Statistics | Pattern Ops | Use Case |
|---|---|---|---|---|---|---|
| Memory | Fastest | No | No | Yes | Yes | High-frequency, session data |
| SQLite | Fast | Yes | No | Yes | Yes | Single-server, desktop apps |
| SQL Server | Moderate | Yes | Yes | Yes | Yes | Multi-server, distributed applications |
OpenTelemetry instrumentation
The cache services emit OpenTelemetry traces and metrics through the
Davasorus.Utility.DotNet.Telemetry package. All instrumentation is
zero-cost when no listener is attached — safe to leave on in production.
ActivitySources
| Backend | ActivitySource |
|---|---|
| Memory | Davasorus.Utility.Cache.memory |
| SQLite | Davasorus.Utility.Cache.sqlite |
| SqlServer | Davasorus.Utility.Cache.mssql |
Meters
| Backend | Meter |
|---|---|
| Memory | Davasorus.Utility.Cache.memory |
| SQLite | Davasorus.Utility.Cache.sqlite |
| SqlServer | Davasorus.Utility.Cache.mssql |
Activity tags
Standard tags emitted on every operation:
| Tag | Description |
|---|---|
cache.backend |
memory | sqlite | mssql |
cache.result |
hit | miss | ok | removed | cancelled | error |
db.operation.name |
get | set | remove | getOrSet | getMany | setMany | clear | exists | ... |
db.system.name |
sqlite | mssql (Memory backend omits this tag) |
db.collection.name |
<schema>.<table> (SqlServer only, after InitializeAsync) |
cache.lock_waited |
bool — True if the GetOrSet caller waited on a contended per-key lock (i.e., observed stampede protection); false if no contention or fast-path hit. Emitted on getOrSet operations only. |
cache.key is opt-in — set RecordCacheKeys = true (default false)
to stamp the literal key on activity tags. Leave disabled for caches with
high-cardinality keyspaces (user IDs, session tokens) to avoid blowing up
trace storage.
Metrics
| Instrument | Type | Backends | Tags |
|---|---|---|---|
cache.operations |
Counter | all | operation, backend, result |
cache.operation.duration |
Histogram (ms) | all | operation, backend |
cache.errors |
Counter | all | operation, backend, exception.type |
cache.entries |
ObservableGauge | Memory, SQLite | backend |
cache.evictions |
Counter | Memory, SQLite | backend, reason |
SqlServer does not yet expose cache.entries or cache.evictions in this
slice. The V2 schema can support entry counts (GetStatisticsAsync already
returns them) and eviction signals; instrument wiring lands in a follow-up
slice.
Configuring cache-key recording
services.AddMemoryCacheServices(cache => cache
.WithCacheKeyRecording(true)); // opts in to stamping cache.key on traces
WithCacheKeyRecording(false) is equivalent to the default and explicitly
disables recording.
Service lifetime requirement
Cache services (IMemoryCacheService, ISqliteCacheService,
ISqlServerCacheService) must be registered as singletons. The
AddMemoryCacheServices / AddSqliteCacheServices / AddSqlServerCacheServices
extensions do this for you. Manual scoped or transient registration causes
the eviction-counter wiring (which takes a strong reference to the cache
client) to retain across DI scopes, leaking memory and producing
inconsistent metrics over the process lifetime.
The cache.entries ObservableGauge is registered exactly once per backend
Meter via a static guard, so it does not leak even under non-singleton
registration — but the eviction wiring still does. Always use the provided
extension methods.
Health Checks
The package ships IHealthCheck implementations for the SQLite and
SqlServer cache backends. Register them in your existing
IHealthChecksBuilder pipeline:
services
.AddHealthChecks()
.AddSqliteCacheHealthCheck()
.AddSqlServerCacheHealthCheck();
The checks probe the cache by writing a reserved key (__healthcheck__)
with a one-minute TTL and reading it back. They return:
Healthy— write and read both succeeded and the value roundtripped.Degraded— write succeeded but the probe key was missing on read or came back with the wrong value.Unhealthy— operation threw (cache backend unreachable, schema is missing, permissions are wrong, etc.).
The Memory backend doesn't have a health check — Memory is in-process, so liveness equals process-up.
Customization
Pass a custom name, failure status, tags, or timeout (standard health-check library options):
.AddSqliteCacheHealthCheck(
name: "primary-cache",
failureStatus: HealthStatus.Degraded,
tags: new[] { "ready", "cache" },
timeout: TimeSpan.FromSeconds(5))
Best Practices
1. Use GetOrSetAsync to Prevent Cache Stampede
// BAD: Multiple requests may hit database simultaneously
var cached = await cache.GetAsync<Data>(key);
if (cached == null)
{
cached = await ExpensiveOperation(); // Multiple threads may execute this
await cache.SetAsync(key, cached);
}
// GOOD: Only one request hits database
// Factory receives (cache key, CancellationToken)
var result = await cache.GetOrSetAsync(key, async (_, ct) =>
await ExpensiveOperation(ct) // Only executed once
);
2. Choose Appropriate Expiration
// Sliding: Resets on access (good for user sessions)
SlidingExpiration = TimeSpan.FromMinutes(20)
// Absolute: Expires at specific time (good for time-sensitive data)
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1)
AbsoluteExpiration = DateTimeOffset.UtcNow.AddHours(1)
3. Use Meaningful Cache Keys
// BAD: Unclear
var key = "u123";
// GOOD: Clear namespace and purpose
var key = $"user:profile:{userId}";
var key = $"products:category:{categoryId}:page:{pageNum}";
4. Handle Nulls Appropriately
var user = await cache.GetAsync<User>(key);
// GetAsync returns null for cache miss AND when null was cached.
// If you need to distinguish, prefer TryGetAsync (single round-trip):
var (found, cachedUser) = await cache.TryGetAsync<User>(key);
if (!found)
{
// Definitely a cache miss
}
else
{
// Hit (cachedUser may legitimately be null if a null was stored)
}
5. SQLite Maintenance
// Run maintenance periodically (e.g., daily background job)
await cache.PruneExpiredEntriesAsync(); // Remove expired entries
await cache.CompactDatabaseAsync(); // Reclaim space
6. SQL Server Table Setup
// Must create table before using SQL Server cache
// Use dotnet-sql-cache tool:
// dotnet tool install --global dotnet-sql-cache
// dotnet sql-cache create "YourConnectionString" --schema cache --table Cache
Cache stampede prevention via GetOrSetAsync
GetOrSetAsync provides built-in stampede prevention: when multiple
callers concurrently request the same key with a missing value, only
one caller's factory delegate is invoked. The other callers wait for
the factory to complete and receive the same cached result.
Lock granularity is per-key: callers requesting different keys run in parallel. The lock is held only for the duration of the factory plus the cache write, then released. Lock cleanup is automatic — the internal lock map self-drains when no waiters remain.
Cancellation: if a caller's CancellationToken fires while waiting
for the lock or during the factory, the caller observes
OperationCanceledException immediately. The cache state is not
modified by a cancelled caller.
Factory exceptions: if the designated factory caller throws, the exception propagates to that caller. Other waiters retry: they acquire the lock next, check the cache (still empty), and become the new designated factory caller. This "thundering retry" pattern is idiomatic for cache stampedes; if you want exception-caching semantics (where a transient error is briefly cached to avoid hammering an unhealthy backend), consider pairing the existing stale-while-revalidate support with your own retry/backoff policy in the factory.
Testing
The package includes 200+ tests covering all functionality:
cd "DotNet/12 Davasorus.Utility.DotNet.Cache"
dotnet test --configuration Release
All tests pass, including:
- Core cache operations (Get, Set, Remove, Exists)
- Batch operations (GetMany, SetMany)
- GetOrSetAsync pattern
- Expiration (sliding and absolute)
- Statistics and key pattern operations
- Service layer validation and logging
- Configuration and DI extension methods
- SQL Server integration tests with Testcontainers
Running benchmarks
The package includes a BenchmarkDotNet suite for measuring per-backend performance. Benchmarks run on-demand (not in CI) — full BDN runs take minutes per class, well beyond CI budget.
cd Davasorus.Utility.DotNet.Cache.Benchmarks
# Run every benchmark on .NET 8:
dotnet run -c Release --framework net8.0
# Same on .NET 10:
dotnet run -c Release --framework net10.0
# Filter to a specific class:
dotnet run -c Release --framework net8.0 -- --filter '*Memory*'
# List all benchmarks without running:
dotnet run -c Release --framework net8.0 -- --list flat
The benchmark project multi-targets net8.0;net10.0, so dotnet run
requires an explicit --framework <tfm> flag.
SqlServer benchmarks require Docker running locally (Testcontainers spins up a SQL Server 2022 container). Memory and SQLite benchmarks have no external dependencies.
Results land in Davasorus.Utility.DotNet.Cache.Benchmarks/BenchmarkDotNet.Artifacts/results/
(gitignored). Use them as a baseline when evaluating perf-sensitive
changes (Spec D per-key locking, Spec E pluggable serializer +
compression, etc.).
Advanced Usage
Custom Serialization
// By default, System.Text.Json is used
// Complex types are automatically serialized/deserialized
public class ComplexObject
{
public int Id { get; set; }
public List<string> Tags { get; set; }
public Dictionary<string, object> Metadata { get; set; }
}
await cache.SetAsync("key", new ComplexObject { ... });
var obj = await cache.GetAsync<ComplexObject>("key");
Batch Operations
// Retrieve multiple cache entries at once
var keys = new[] { "user:1", "user:2", "user:3" };
var results = await cache.GetManyAsync<User>(keys);
foreach (var kvp in results)
{
Console.WriteLine($"{kvp.Key}: {kvp.Value?.Name ?? "not found"}");
}
// Set multiple cache entries at once
var users = new Dictionary<string, User>
{
["user:1"] = new User { Id = 1, Name = "Alice" },
["user:2"] = new User { Id = 2, Name = "Bob" },
["user:3"] = new User { Id = 3, Name = "Charlie" }
};
await cache.SetManyAsync(users, new CacheOptions
{
SlidingExpiration = TimeSpan.FromMinutes(15)
});
Statistics and Monitoring
// Get cache statistics (Memory, SQLite, and SqlServer)
var stats = await cache.GetStatisticsAsync();
Console.WriteLine($"Items: {stats.ItemCount}");
Console.WriteLine($"Size: {stats.SizeInBytes / 1024}KB"); // SQLite only; null on Memory and SqlServer
Console.WriteLine($"Expired: {stats.ExpiredEntryCount}"); // SQLite and SqlServer; null on Memory
// Hit rate, hit/miss counts, and oldest-entry timestamps are no longer fields on
// CacheStats. Consume those from the OTel `cache.operations` Counter on the
// backend's Meter (see "OpenTelemetry instrumentation" above).
Key Pattern Operations
// Get all keys matching a pattern (Memory, SQLite, and SqlServer)
var sessionKeys = await cache.GetKeysAsync("session:*");
var userKeys = await cache.GetKeysAsync("user:profile:*");
// Remove all keys matching a pattern
var removed = await cache.RemoveByPatternAsync("temp:*");
Console.WriteLine($"Removed {removed} temporary items");
// Clear specific user's cache
await cache.RemoveByPatternAsync($"user:{userId}:*");
Dependency Injection Setup
Recommended: Extension Methods
using Davasorus.Utility.DotNet.Cache.Configuration;
public void ConfigureServices(IServiceCollection services)
{
// Memory cache (with defaults)
services.AddMemoryCacheServices();
// SQLite cache (with defaults)
services.AddSqliteCacheServices();
// SQL Server cache (with defaults)
services.AddSqlServerCacheServices();
// Or with fluent configuration
services.AddMemoryCacheServices(cache => cache
.WithParallelBatching()
.WithMaxDegreeOfParallelism(4)
.WithBatchSize(100));
// Or from appsettings.json
services.AddMemoryCacheServices(Configuration.GetSection("CacheOptions"));
}
Manual Registration
// Memory cache — service and client must both be singletons
services.AddMemoryCache();
services.AddSingleton<IMemoryCacheClient, MemoryCacheClient>();
services.AddSingleton<IMemoryCacheService, MemoryCacheService>();
// SQLite cache — service must be singleton
services.AddSingleton<ISqliteCacheClient, SqliteCacheClient>();
services.AddSingleton<ISqliteCacheService, SqliteCacheService>();
// SQL Server cache — service must be singleton; client stays transient
services.AddTransient<ISqlServerCacheClient, SqlServerCacheClient>();
services.AddSingleton<ISqlServerCacheService, SqlServerCacheService>();
Initialization
// SQLite and SQL Server caches require initialization at startup
var sqliteCache = serviceProvider.GetRequiredService<ISqliteCacheService>();
await sqliteCache.InitializeAsync("./data/cache.db");
var sqlServerCache = serviceProvider.GetRequiredService<ISqlServerCacheService>();
await sqlServerCache.InitializeAsync(Configuration.GetConnectionString("Cache"));
Troubleshooting
SQL Server: "Invalid object name 'cache.Cache'"
Solution: Create the cache table using dotnet-sql-cache tool before initializing.
dotnet tool install --global dotnet-sql-cache
dotnet sql-cache create "Server=...;Database=..." --schema cache --table Cache
SQLite: Database file locked
Solution: Ensure only one process accesses the database, or use Cache = SqliteCacheMode.Shared in connection string (already default).
Memory: High memory usage
Solution: Set cache priorities and size limits:
services.AddMemoryCache(options =>
{
options.SizeLimit = 1024; // Limit total size
options.CompactionPercentage = 0.25; // Compact by 25% when limit hit
});
// Use priorities
await cache.SetAsync(key, value, new CacheOptions
{
Priority = CacheItemPriority.Low // Will be evicted first
});
Migration Guide
From IMemoryCache to This Package
// Before
_memoryCache.Set("key", value, TimeSpan.FromMinutes(5));
var cached = _memoryCache.Get<MyType>("key");
// After
await _cache.SetAsync("key", value, new CacheOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5)
});
var cached = await _cache.GetAsync<MyType>("key");
From IDistributedCache to This Package
// Before
var bytes = await _distributedCache.GetAsync("key");
var value = JsonSerializer.Deserialize<MyType>(bytes);
// After
var value = await _cache.GetAsync<MyType>("key");
License
This package follows the repository license.
Contributing
Contributions are welcome! Please follow the repository's contribution guidelines in NEWCOMERS.md.
Support
For issues, questions, or feature requests, please refer to the repository's issue tracker.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Davasorus.Utility.DotNet.Contracts.Types (>= 2026.2.3.3)
- Davasorus.Utility.DotNet.SQL (>= 2026.2.3.1)
- Davasorus.Utility.DotNet.Telemetry (>= 2026.2.2.12)
- Microsoft.Data.Sqlite (>= 10.0.9)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Caching.Memory (>= 10.0.9)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.9)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options (>= 10.0.9)
-
net8.0
- Davasorus.Utility.DotNet.Contracts.Types (>= 2026.2.3.3)
- Davasorus.Utility.DotNet.SQL (>= 2026.2.3.1)
- Davasorus.Utility.DotNet.Telemetry (>= 2026.2.2.12)
- Microsoft.Data.Sqlite (>= 10.0.9)
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Caching.Memory (>= 10.0.9)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.9)
- Microsoft.Extensions.Diagnostics.HealthChecks (>= 10.0.9)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.9)
- Microsoft.Extensions.Options (>= 10.0.9)
- System.Text.Json (>= 10.0.9)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on Davasorus.Utility.DotNet.Cache:
| Package | Downloads |
|---|---|
|
Davasorus.Utility.DotNet.Auth
Handles Authentication for TEPS Utilities |
|
|
Davasorus.Utility.DotNet.Api
API Interaction for TEPS Utilities with generic deserialization, configurable error reporting, and improved DI configuration. Supports REST, GraphQL, gRPC, WebSocket, SignalR, and SSE protocols. |
|
|
Davasorus.Utility.DotNet.Services
Windows Service management (start, stop, enable, disable, enumerate) for TEPS Utilities, with OpenTelemetry tracing and DI-based wiring. Targets .NET 8 and .NET 10. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 2026.2.3.1 | 127 | 6/13/2026 |
| 2026.2.2.29 | 1,477 | 5/31/2026 |
| 2026.2.2.28 | 325 | 5/31/2026 |
| 2026.2.2.26 | 92 | 5/30/2026 |
| 2026.2.2.25 | 95 | 5/30/2026 |
| 2026.2.2.24 | 96 | 5/30/2026 |
| 2026.2.2.23 | 103 | 5/28/2026 |
| 2026.2.2.22 | 104 | 5/27/2026 |
| 2026.2.2.21 | 107 | 5/26/2026 |
| 2026.2.2.20 | 110 | 5/26/2026 |
| 2026.2.2.19 | 100 | 5/25/2026 |
| 2026.2.2.18 | 110 | 5/25/2026 |
| 2026.2.2.17 | 111 | 5/24/2026 |
| 2026.2.2.16 | 481 | 5/23/2026 |
| 2026.2.2.15 | 561 | 5/23/2026 |
| 2026.2.2.14 | 992 | 5/17/2026 |
| 2026.2.2.13 | 576 | 5/15/2026 |
| 2026.2.2.12 | 91 | 5/15/2026 |
| 2026.2.2.11 | 435 | 5/12/2026 |
| 2026.2.2.10 | 283 | 5/9/2026 |