XpressCache 1.0.1
dotnet add package XpressCache --version 1.0.1
NuGet\Install-Package XpressCache -Version 1.0.1
<PackageReference Include="XpressCache" Version="1.0.1" />
<PackageVersion Include="XpressCache" Version="1.0.1" />
<PackageReference Include="XpressCache" />
paket add XpressCache --version 1.0.1
#r "nuget: XpressCache, 1.0.1"
#:package XpressCache@1.0.1
#addin nuget:?package=XpressCache&version=1.0.1
#tool nuget:?package=XpressCache&version=1.0.1
XpressCache
A high-performance, thread-safe in-memory cache for .NET with built-in cache stampede (thundering herd) prevention using per-key single-flight locking.
Features
- ๐ High Performance: Lock-free read operations with value-type keys for zero-allocation lookups
- ๐งต Thread-Safe: All operations are thread-safe with concurrent dictionary and atomic operations
- ๐ก๏ธ Cache Stampede Prevention: Per-key single-flight locking prevents thundering herd problems
- โณ Sliding Expiration: Automatic TTL renewal on access with configurable expiry policies
- โ๏ธ Flexible Configuration: Control stampede prevention globally or per-call
- ๐ฏ Multi-Targeting: Supports .NET 6.0, .NET 7.0, .NET 8.0, .NET 9.0, and .NET 10.0
- ๐งน Automatic Cleanup: Probabilistic and manual cleanup of expired entries
- ๐งช Custom Validation: Support for custom validation functions on cached items
- ๐ Fully Documented: Comprehensive XML documentation for all public APIs
What is Cache Stampede?
Cache stampede (also known as thundering herd) occurs when:
- A cached item expires
- Multiple concurrent requests try to access it simultaneously
- All requests miss the cache and trigger expensive recovery operations
- Resources are wasted computing the same data multiple times
XpressCache solves this by ensuring only one caller executes the recovery function while others wait for and share the result.
Installation
Install via NuGet Package Manager:
dotnet add package XpressCache
Or via Package Manager Console:
Install-Package XpressCache
Quick Start
Basic Usage
using XpressCache;
using Microsoft.Extensions.Logger;
using Microsoft.Extensions.DependencyInjection;
// Setup with dependency injection
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ICacheStore, CacheStore>();
var provider = services.BuildServiceProvider();
var cache = provider.GetRequiredService<ICacheStore>();
// Load an item (with cache-miss recovery)
var userId = Guid.NewGuid();
var user = await cache.LoadItem<User>(
entityId: userId,
subject: "users",
cacheMissRecovery: async (id) =>
{
// This expensive operation runs only once for concurrent requests
return await database.GetUserAsync(id);
}
);
Configuration Options
using Microsoft.Extensions.Options;
// Configure cache behavior
var options = Options.Create(new CacheStoreOptions
{
PreventCacheStampedeByDefault = true, // Enable single-flight (default: true)
DefaultTtlMs = 10 * 60 * 1000, // 10 minutes (default)
InitialCapacity = 512, // Initial dictionary capacity
ProbabilisticCleanupThreshold = 1000 // Trigger cleanup above this size
});
var cache = new CacheStore(logger, options);
Per-Call Behavior Control
// Force stampede prevention for expensive operations
var data = await cache.LoadItem<ExpensiveData>(
entityId: id,
subject: "reports",
cacheMissRecovery: GenerateExpensiveReportAsync,
behavior: CacheLoadBehavior.PreventStampede
);
// Allow parallel loads for cheap, idempotent operations
var config = await cache.LoadItem<Config>(
entityId: id,
subject: "config",
cacheMissRecovery: LoadConfigAsync,
behavior: CacheLoadBehavior.AllowParallelLoad
);
// Use default behavior from CacheStoreOptions
var item = await cache.LoadItem<Item>(
entityId: id,
subject: "items",
cacheMissRecovery: LoadItemAsync,
behavior: CacheLoadBehavior.Default // This is also the default parameter
);
Advanced Usage
Custom Validation
Implement custom validation to invalidate cache entries based on business logic:
var product = await cache.LoadItem<Product>(
entityId: productId,
subject: "products",
cacheMissRecovery: async (id) => await database.GetProductAsync(id),
customValidate: async (cachedProduct) =>
{
// Invalidate if price changed in database
var currentPrice = await database.GetProductPriceAsync(cachedProduct.Id);
return cachedProduct.Price == currentPrice;
}
);
Manual Cache Operations
// Explicitly set an item in cache
await cache.SetItem(userId, "users", userObject);
// Remove a specific item
bool removed = cache.RemoveItem<User>(userId, "users");
// Get all cached items of a type
List<User> allCachedUsers = cache.GetCachedItems<User>("users");
// Clear entire cache
cache.Clear();
// Manual cleanup of expired entries
cache.CleanupCache();
// Enable/disable caching at runtime
cache.EnableCache = false; // All operations become no-ops
cache.EnableCache = true; // Re-enable caching (clears existing cache)
Dependency Injection with ASP.NET Core
// In Program.cs or Startup.cs
builder.Services.Configure<CacheStoreOptions>(options =>
{
options.PreventCacheStampedeByDefault = true;
options.DefaultTtlMs = 15 * 60 * 1000; // 15 minutes
options.InitialCapacity = 1024;
});
builder.Services.AddSingleton<ICacheStore, CacheStore>();
// Usage in controllers or services
public class ProductService
{
private readonly ICacheStore _cache;
private readonly IProductRepository _repository;
public ProductService(ICacheStore cache, IProductRepository repository)
{
_cache = cache;
_repository = repository;
}
public async Task<Product> GetProductAsync(Guid productId)
{
return await _cache.LoadItem<Product>(
entityId: productId,
subject: "products",
cacheMissRecovery: _repository.GetByIdAsync
);
}
}
How It Works
Cache Key Structure
XpressCache uses a composite key consisting of:
- Entity ID (
Guid): Unique identifier for the entity - Type (
Type): The .NET type of the cached item - Subject (
string): Optional categorization (e.g., "users", "products")
This allows you to cache different types with the same ID without conflicts.
Single-Flight Pattern
When stampede prevention is enabled:
First Request (cache miss):
- Acquires per-key lock
- Executes cache-miss recovery function
- Stores result in cache
- Releases lock
- Returns result
Concurrent Requests (for same key):
- Wait for per-key lock
- After lock released, check cache again (double-check pattern)
- Find cached result from first request
- Return cached result without executing recovery
Different Keys:
- Execute in parallel (locks are per-key, not global)
Expiration and Renewal
- Entries have a sliding expiration window (TTL)
- Accessing an entry extends its expiration (best-effort)
- Expired entries are removed lazily during access
- Probabilistic cleanup prevents unbounded growth
- Manual cleanup available via
CleanupCache()
Thread Safety Guarantees
All operations are thread-safe:
- ๐งต
LoadItem- Multiple concurrent calls are safe - โ๏ธ
SetItem- Atomic updates - โ
RemoveItem- Safe concurrent removal - ๐
GetCachedItems- Returns consistent snapshot - ๐งน
Clear- Safe to call anytime - ๐งน
CleanupCache- Can run concurrently with other operations - ๐
EnableCachesetter - Thread-safe property
Performance Characteristics
| Operation | Complexity | Notes |
|---|---|---|
| Cache Hit | O(1) | Lock-free read, no allocations for key |
| Cache Miss (single-flight) | O(1) + recovery time | Per-key lock acquisition |
| Cache Miss (parallel) | O(1) + recovery time | No locking overhead |
| Remove | O(1) | Concurrent dictionary removal |
| GetCachedItems | O(n) | Full cache scan, use sparingly |
| Clear | O(n) | Clears all entries |
| Memory per entry | ~48 bytes | Overhead excluding cached data |
Best Practices
โ DO
- Use stampede prevention for expensive operations (database, API calls)
- Set appropriate TTL based on data volatility
- Use
subjectparameter to categorize related items - Handle null returns from
LoadItemwhen recovery returns null - Consider custom validation for critical data freshness
- Use
AllowParallelLoadfor cheap, idempotent operations - Monitor cache size and adjust
ProbabilisticCleanupThreshold
๐ซ DON'T
- Don't cache very large objects (hundreds of MB per entry)
- Don't use for persistent storage (in-memory only)
- Don't call
GetCachedItemsin hot paths (O(n) operation) - Don't forget to handle exceptions in recovery functions
- Don't disable stampede prevention for expensive operations
- Don't use excessively short TTLs (defeats caching purpose)
Migration from Other Caches
From IMemoryCache
// Before (IMemoryCache)
var user = await cache.GetOrCreateAsync(userId, async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
return await database.GetUserAsync(userId);
});
// After (XpressCache)
var user = await cache.LoadItem<User>(
entityId: userId,
subject: "users",
cacheMissRecovery: database.GetUserAsync
);
From ConcurrentDictionary
// Before (manual ConcurrentDictionary)
var dict = new ConcurrentDictionary<Guid, User>();
var user = dict.GetOrAdd(userId, id => LoadUser(id)); // Synchronous only
// After (XpressCache)
var user = await cache.LoadItem<User>(
entityId: userId,
subject: "users",
cacheMissRecovery: LoadUserAsync // Async support
);
Troubleshooting
High Memory Usage
- Check cache size: monitor entry count
- Reduce
DefaultTtlMsfor faster expiration - Lower
ProbabilisticCleanupThreshold - Call
CleanupCache()periodically - Consider caching smaller objects
Cache Stampede Still Occurring
- Verify
PreventCacheStampedeByDefault = true - Check you're not using
CacheLoadBehavior.AllowParallelLoad - Ensure same key (entityId + type + subject) for related requests
Performance Issues
- Avoid
GetCachedItemsin hot paths - Use appropriate
InitialCapacityto reduce rehashing - Consider if recovery function is the bottleneck
- Check if TTL is too short causing frequent reloads
Examples
See the documentation folder for more examples:
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
License
This project is licensed under the MIT License - see the LICENSE file for details.
Author
Russlan Kafri
Company: Digixoil
Support
For issues, questions, or suggestions:
- Open an issue on GitHub
- Check existing documentation in the
/docfolder - Review XML documentation in source code
Made with โค๏ธ by Digixoil
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net6.0 is compatible. 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 is compatible. 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 is compatible. 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
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.1)
- Microsoft.Extensions.Options (>= 10.0.1)
-
net6.0
- Microsoft.Extensions.Logging.Abstractions (>= 6.0.1)
- Microsoft.Extensions.Options (>= 6.0.1)
-
net7.0
- Microsoft.Extensions.Logging.Abstractions (>= 7.0.1)
- Microsoft.Extensions.Options (>= 7.0.1)
-
net8.0
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.3)
- Microsoft.Extensions.Options (>= 8.0.2)
-
net9.0
- Microsoft.Extensions.Logging.Abstractions (>= 9.0.11)
- Microsoft.Extensions.Options (>= 9.0.11)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Refined release