Microlens.Cache
2.0.0
See the version list below for details.
dotnet add package Microlens.Cache --version 2.0.0
NuGet\Install-Package Microlens.Cache -Version 2.0.0
<PackageReference Include="Microlens.Cache" Version="2.0.0" />
<PackageVersion Include="Microlens.Cache" Version="2.0.0" />
<PackageReference Include="Microlens.Cache" />
paket add Microlens.Cache --version 2.0.0
#r "nuget: Microlens.Cache, 2.0.0"
#:package Microlens.Cache@2.0.0
#addin nuget:?package=Microlens.Cache&version=2.0.0
#tool nuget:?package=Microlens.Cache&version=2.0.0
Microlens.Cache
Stampede-protected, container-scoped in-process caching for .NET, built on Microsoft.Extensions.Caching.Memory.
Microlens.Cache is a high-performance caching library that deduplicates, stores, refreshes and evicts cached values through a single, thread-safe ICachingService.
Unlike using IMemoryCache directly, Microlens.Cache guarantees that a factory runs once per key no matter how many callers miss at the same time, keeps serving the previous value while a refresh loads, and enforces per-container eviction policies (LRU or LFU) with their own capacity, expiration and configuration.
Why Microlens.Cache? | Quick Start | Quick Example | Features | Configuration | API Reference | Behavior & Guarantees | Performance Characteristics | Comparison | Supported Frameworks | When Not To Use Microlens.Cache | License
Why Microlens.Cache?
Caching looks simple until it runs under concurrency. Most hand-rolled and IMemoryCache-based caches eventually hit the same problems:
- Cache Stampede: many concurrent misses run the same expensive factory at the same time.
- Refresh Gaps: evicting before reloading leaves a window where every caller misses.
- Unbounded Growth: entries without expiration or capacity limits grow until memory pressure hits.
- Stale Writes: a slow load started before an invalidation overwrites the newer state after it.
- Mixed Workloads: one global cache cannot give hot lookups and bulk reference data different policies.
Examples of typical use cases:
- Database Lookups: Cache entities by key without flooding the database on cold start.
- Reference Data: Load an entire collection (countries, currencies, settings) once and serve lookups from it.
- External API Responses: Deduplicate concurrent calls to rate-limited or slow third-party services.
- Configuration & Feature Data: Refresh periodically while callers keep reading the current value.
- Hot-Key Workloads: Keep the most frequently used entries with
LFUeviction under a fixed capacity.
Quick Start
Installation
dotnet add package Microlens.Cache
Register Services
builder.Services.AddMicrolensCache();
Inject and Use
public sealed class ProductService(ICachingService cache, IProductRepository repository) {
public Task<Product?> GetAsync(string id, CancellationToken cancellationToken) {
return cache.GetOrAddAsync("products", id, () => repository.FindAsync(id), cancellationToken: cancellationToken);
}
}
That's it.
Concurrent misses for the same key share a single factory execution.
Quick Example
Given 1,000 concurrent requests for the same product on a cold cache:
var product = await cache.GetOrAddAsync(
"products",
"sku-42",
() => repository.FindAsync("sku-42"),
CacheExpiration.AfterWrite(TimeSpan.FromMinutes(5)));
Microlens.Cache executes the factory once, and all 1,000 callers receive the same result.
Request 1 ─┐
Request 2 ─┤
Request 3 ─┼──► factory() ──► Product sku-42 ──► all callers
... ─┤
Request 1000 ─┘
- No locks to write.
- No
Lazy<T>wrappers to manage. - No duplicate database round-trips.
- No partial state on failure.
- No stale writes after invalidation.
Features
Microlens.Cache is NOT a distributed cache.
It is an in-process cache that focuses on correctness under concurrency, predictable eviction and low overhead on the read path.
Stampede Protection
Every miss is coordinated through a single in-flight entry per key.
- The first caller runs the factory.
- Every concurrent caller awaits the same result.
- A failed or cancelled factory is not cached; all waiters receive the same exception, and the next call retries.
nullresults are cached like any other value.
Containers
Entries are grouped into named containers, each backed by its own MemoryCache with its own eviction policy, capacity and default expiration.
await cache.GetOrAddAsync("users", userId, () => LoadUserAsync(userId));
await cache.GetOrAddAsync("settings", "theme", () => LoadThemeAsync());
Collections
Load an entire dictionary once and serve key lookups from it.
CacheResult<Country> result = await cache.GetOrAddAsync<string, Country>("catalog", "countries", "PK", LoadCountriesAsync);
if (result.Found) {
Console.WriteLine(result.Value);
}
CacheResult<TValue>.Founddistinguishes an absent key from a key holdingdefault.- An absent key reloads the collection at most once per
AbsentKeyRebuildInterval(default30seconds), so lookups of keys that do not exist can never flood the source. - Ownership of the returned dictionary transfers to the cache; a
FrozenDictionarygives the fastest lookups on.NET 8+.
Stale-While-Revalidate Refresh
Force a fresh value without creating a miss window.
await cache.GetOrAddAsync("settings", "theme", () => LoadThemeAsync(), refresh: true);
- Concurrent readers keep receiving the previous value until the new one completes.
- Concurrent refreshes of the same key coalesce into one factory execution.
- A failed refresh leaves the previous value in place.
Expiration
CacheExpiration.AfterWrite(TimeSpan.FromMinutes(10)); // absolute
CacheExpiration.AfterAccess(TimeSpan.FromMinutes(2)); // sliding
new CacheExpiration(TimeSpan.FromHours(1), TimeSpan.FromMinutes(5)); // both
- Lifetime starts when the value is stored, never while its factory is still running.
- A per-call expiration overrides the container default, which overrides the global default.
Eviction Policies
| Policy | Implementation | Behavior at capacity |
|---|---|---|
None |
Expiration only | Unbounded |
Lru |
Native MemoryCache SizeLimit compaction |
New entries are rejected until background compaction frees space |
Lfu |
Hand-rolled frequency index with aging, on top of MemoryCache |
Least frequently used entries are evicted synchronously, oldest first on ties |
- Every scalar value or collection counts as one entry toward
Capacity. - Each pass evicts down to
Capacity − Capacity × CompactionPercentage. LFUfrequencies are halved on every pass, so formerly hot entries cannot pin the container forever.- A refreshed
LFUkey keeps its frequency.
Explicit Writes & Invalidation
cache.Set("settings", "theme", theme, CacheExpiration.AfterWrite(TimeSpan.FromHours(1)));
cache.SetCollection("catalog", "countries", countries);
cache.Remove("settings", "theme");
cache.RemoveCollection("catalog", "countries");
cache.Clear("settings");
Set,RemoveandClearsupersede any load already in flight; data fetched before them is never written back.TryGetreads completed values only and never triggers a load.
Cancellation
await cache.GetOrAddAsync("products", id, () => LoadAsync(id), cancellationToken: cancellationToken);
A CancellationToken cancels the caller's wait only. The shared factory runs to completion and still populates the cache for every other caller.
Safety Checks
Misuse fails loudly at the call site instead of hanging or corrupting state:
- Reading a key as a different type than it was stored with throws
InvalidOperationException. - A factory that awaits or refreshes its own in-flight key throws
InvalidOperationExceptioninstead of deadlocking. nullarguments throwArgumentNullExceptionsynchronously.- Invalid configuration throws
InvalidOperationExceptionwhen the service is constructed.
Configuration
Configure a global default and per-container overrides through CacheOptions.
builder.Services.AddMicrolensCache(options => {
options.Defaults.Expiration = CacheExpiration.AfterWrite(TimeSpan.FromMinutes(30));
var users = options.Container("users");
users.Eviction = Registry.EvictionPolicy.Lfu;
users.Capacity = 10_000;
var sessions = options.Container("sessions");
sessions.Eviction = Registry.EvictionPolicy.Lru;
sessions.Capacity = 50_000;
sessions.Expiration = CacheExpiration.AfterAccess(TimeSpan.FromMinutes(20));
});
ContainerOptions
| Option | Default | Inherits from Defaults |
Description |
|---|---|---|---|
Eviction |
None |
No | None, Lru or Lfu. |
Capacity |
0 |
No | Maximum entries; required (> 0) when Eviction is Lru or Lfu. |
CompactionPercentage |
0.05 |
No | Fraction of Capacity removed per eviction pass; must be in (0, 1). |
Expiration |
No expiration | Yes | Default expiration when a call passes none. |
ExpirationScanFrequency |
MemoryCache default |
Yes | Interval of the background sweep for expired entries. |
AbsentKeyRebuildInterval |
30 seconds |
Yes | Minimum age of a collection before an absent key may reload it. |
- Containers without their own entry use
Defaults. - Container, key and collection names are case-sensitive (ordinal).
- Options are copied when a container is first used; later changes do not affect live containers.
AddMicrolensCachecan be called multiple times; configuration actions compose and the service is registered once.
API Reference
All members are exposed through ICachingService, registered as a singleton.
| Member | Description |
|---|---|
GetOrAddAsync<TValue>(container, key, factory, expiration?, refresh?, cancellationToken?) |
Returns the cached value or runs the factory once for all concurrent callers. |
GetOrAddAsync<TKey, TValue>(container, collection, key, factory, expiration?, refresh?, cancellationToken?) |
Looks up a key in a collection loaded once by the factory. |
TryGet<TValue>(container, key, out value) |
Reads a completed value without loading. |
TryGet<TKey, TValue>(container, collection, key, out value) |
Reads a key from a loaded collection without loading. |
Set<TValue>(container, key, value, expiration?) |
Stores a value, superseding any load in flight. |
SetCollection<TKey, TValue>(container, collection, items, expiration?) |
Stores a collection, superseding any load in flight. |
Remove(container, key) |
Removes a value and cancels any load in flight. |
RemoveCollection(container, collection) |
Removes a collection and cancels any load in flight. |
Clear(container) |
Atomically replaces the container with an empty one. |
Behavior & Guarantees
| Scenario | Behavior |
|---|---|
| Concurrent misses for one key | Factory runs once; all callers share the result. |
| Factory throws or is cancelled | Nothing is cached; current waiters share the failure; next call retries. |
Factory returns null |
null is cached. |
refresh: true while a value is cached |
Readers get the previous value until the new one completes. |
| Refresh fails | Previous value stays cached. |
Set / Remove / Clear during a load |
The in-flight result is returned to its callers but never stored. |
| Caller's token is cancelled | Only that caller stops waiting; the factory continues for everyone else. |
| Key read as a different type | InvalidOperationException. |
| Factory awaits its own key | InvalidOperationException (no deadlock). |
LRU container at capacity |
New entries are not cached until native compaction frees space. |
| Service disposed | Every call throws ObjectDisposedException. |
Performance Characteristics
Microlens.Cache is designed for hot read paths and high-concurrency workloads.
Key implementation details:
- Lock-free cache hits through a single
MemoryCache.TryGetValue - Allocation-free scalar hits: the stored completed
Task<TValue>is returned as-is - Atomic single-flight coordination through
ConcurrentDictionaryadd, swap and compare-remove - Striped per-key locks guarding writes, invalidations and evictions only, never reads
System.Threading.Lockon.NET 9+targets- Continuations run asynchronously, so completing a load never executes waiter code on the factory's thread
ConfigureAwait(false)throughout, safe for synchronous callers on.NET Framework- Interned collection keys and cached delegates, so steady-state lookups allocate no keys or closures
LFUfrequency tracking without interlocked operations on the hit path
The library is optimized for throughput and predictable latency while preserving strict correctness under concurrency.
Comparison
| Capability | Microlens.Cache |
IMemoryCache |
|---|---|---|
| In-process key/value caching | Yes | Yes |
| Absolute and sliding expiration | Yes | Yes |
| Single factory execution per key | Yes | No |
| Stale-while-revalidate refresh | Yes | No |
| Invalidation that cancels in-flight loads | Yes | No |
| Collection loading with throttled rebuilds | Yes | No |
| Named containers with independent policies | Yes | No |
LRU eviction |
Yes | Yes |
LFU eviction |
Yes | No |
| Type-safe reads with loud mismatch failures | Yes | No |
| Re-entrant factory deadlock detection | Yes | No |
Microlens.Cache builds on IMemoryCache for storage and expiration, and adds the coordination, refresh and eviction semantics it lacks.
Supported Frameworks
| Target Framework | Supported |
|---|---|
.NET 10 |
✓ |
.NET 8 |
✓ |
.NET Standard 2.0 |
✓ |
.NET Framework 4.8 |
✓ |
.NET Framework 4.7.2 |
✓ |
Dependencies:
Microsoft.Extensions.Caching.MemoryMicrosoft.Extensions.DependencyInjection.AbstractionsMicrosoft.Extensions.Options
When Not To Use Microlens.Cache
Microlens.Cache is not intended for:
- Distributed or shared caching across multiple processes or servers
- Persisting cached data across application restarts
- Replacing
Redis,IDistributedCacheorHybridCachein multi-instance deployments - Caching values that must be evicted by exact memory size in bytes
If your data must be shared between instances or survive restarts, use a distributed cache.
License
Licensed under the Apache License 2.0.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net5.0 was computed. net5.0-windows was computed. net6.0 was computed. net6.0-android was computed. net6.0-ios was computed. net6.0-maccatalyst was computed. net6.0-macos was computed. net6.0-tvos was computed. net6.0-windows was computed. net7.0 was computed. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. 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. |
| .NET Core | netcoreapp2.0 was computed. netcoreapp2.1 was computed. netcoreapp2.2 was computed. netcoreapp3.0 was computed. netcoreapp3.1 was computed. |
| .NET Standard | netstandard2.0 is compatible. netstandard2.1 was computed. |
| .NET Framework | net461 was computed. net462 was computed. net463 was computed. net47 was computed. net471 was computed. net472 is compatible. net48 is compatible. net481 was computed. |
| MonoAndroid | monoandroid was computed. |
| MonoMac | monomac was computed. |
| MonoTouch | monotouch was computed. |
| Tizen | tizen40 was computed. tizen60 was computed. |
| Xamarin.iOS | xamarinios was computed. |
| Xamarin.Mac | xamarinmac was computed. |
| Xamarin.TVOS | xamarintvos was computed. |
| Xamarin.WatchOS | xamarinwatchos was computed. |
-
.NETFramework 4.7.2
- Microsoft.Extensions.Caching.Memory (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
- System.Diagnostics.DiagnosticSource (>= 10.0.12)
-
.NETFramework 4.8
- Microsoft.Extensions.Caching.Memory (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
- System.Diagnostics.DiagnosticSource (>= 10.0.12)
-
.NETStandard 2.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
- System.Diagnostics.DiagnosticSource (>= 10.0.12)
-
net10.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
-
net8.0
- Microsoft.Extensions.Caching.Memory (>= 10.0.12)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.12)
- Microsoft.Extensions.Options (>= 10.0.12)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Compiled binary drop for version tag: v2.0.0 (Commit: 5a754b9fe67a8117e87cbf98dbc1b94bcf754c93)