CacheGraph.Redis
1.1.0
dotnet add package CacheGraph.Redis --version 1.1.0
NuGet\Install-Package CacheGraph.Redis -Version 1.1.0
<PackageReference Include="CacheGraph.Redis" Version="1.1.0" />
<PackageVersion Include="CacheGraph.Redis" Version="1.1.0" />
<PackageReference Include="CacheGraph.Redis" />
paket add CacheGraph.Redis --version 1.1.0
#r "nuget: CacheGraph.Redis, 1.1.0"
#:package CacheGraph.Redis@1.1.0
#addin nuget:?package=CacheGraph.Redis&version=1.1.0
#tool nuget:?package=CacheGraph.Redis&version=1.1.0
CacheGraph π§ π
Cache-aside with dependency graph and cascade invalidation for .NET
CacheGraph combines the cache-aside pattern with a dependency graph that maps cache keys to business entities. When an entity changes, all cache entries that depend on it are automatically invalidated in cascade β no manual cache key management needed.
Unlike FusionCache, EasyCaching, or Microsoft HybridCache (which only support flat tag-based invalidation), CacheGraph models relationships as a traversable bidirectional graph, enabling true cascade invalidation across entity hierarchies.
π¦ Installation
dotnet add package CacheGraph.Core
For Microsoft.Extensions integration (DI, Logging, IMemoryCache, IDistributedCache):
dotnet add package CacheGraph.Extensions
β¨ Features
- Cache-aside with
GetOrSetAsyncβ on-demand loading with factory and stampede protection - Dependency graph β bidirectional relationships between cache keys and entities
- Cascade invalidation β invalidate all entries that depend on an entity, type, collection, predicate, or query
- Stampede protection β distributed lock ensures only one thread executes the factory per key
- 8 interfaces for extensibility (cache, graph, lock, events, telemetry, logging, serialization)
- DI integration β
AddCacheGraph()/AddCacheGraphWithLogging()for Microsoft.Extensions.DependencyInjection - Cache adapters β wrap
IMemoryCacheorIDistributedCacheasICacheProvider - Events β
CacheSetEvent,CacheRemovedEvent,EntityInvalidatedEvent,QueryInvalidatedEvent - Cross-instance backplane β
CacheGraphBackplanereplicates invalidations between instances over the event provider (in-memory or Redis Pub/Sub) without re-publish loops - Declarative caching β
[CacheResult]attribute +CacheProxy(Castle DynamicProxy) caches interface methods automatically and registers dependencies for cascade invalidation - Redis providers (
CacheGraph.Redis) β distributed cache, graph storage, distributed locks, and pub/sub event invalidation with self-message filtering - EF Core interceptor (
CacheGraph.EntityFrameworkCore) β automatic query caching viaDbCommandInterceptor, caches SQL query results as row-column dictionaries - Economic cache β threshold-based adaptive caching that analyzes query cost (execution time + call frequency) to decide what to cache; configurable rules, TTL scaling, manual overrides, and injectable
IEconomicCacheManagerfor monitoring dashboards - Metrics β hits, misses, sets, removes, invalidations, operation times, graph stats
- Thread-safe β all in-memory providers use lock-based writes and snapshot reads
- Benchmarks β BenchmarkDotNet suite comparing CacheGraph vs manual cache-aside vs FusionCache
- Multi-target β .NET 8, .NET 9, and .NET 10
- Zero external dependencies (Core) β only the .NET BCL
π Quick Start
Manual setup
using CacheGraph.Core;
using CacheGraph.Core.Implementaciones;
using CacheGraph.Core.Interfaces;
using CacheGraph.Core.Modelos;
var cacheGraph = new CacheGraph(
graphProvider: new MemoryGraphProvider(),
cacheProvider: new MemoryCacheProvider(),
eventProvider: new MemoryEventProvider(),
logger: new ConsoleLogger(),
telemetryProvider: new NoOpTelemetryProvider(),
distributedLock: new MemoryDistributedLock(),
options: new GraphOptions
{
DefaultTtl = TimeSpan.FromMinutes(30),
LockTimeout = TimeSpan.FromSeconds(30),
LockRetryCount = 3,
});
// Cache-aside with entity dependency
var product = await cacheGraph.GetOrSetAsync(
"product:42",
factory: () => _db.GetProductAsync(42),
dependencies: new[] { "entity:Product:42" },
ttl: TimeSpan.FromMinutes(5));
// When the entity changes, invalidate it β product:42 is removed automatically
await cacheGraph.InvalidateEntityAsync<Product>("42");
// Invalidate all Product cache entries (by type)
await cacheGraph.InvalidateEntityTypeAsync<Product>();
// Invalidate a collection of entities
await cacheGraph.InvalidateCollectionAsync<Product>(new[] { "42", "43", "44" });
// Predicate-based invalidation
await cacheGraph.InvalidateByPredicateAsync<Product>(p => p.Discontinued);
// Query-based invalidation
await cacheGraph.InvalidateByQueryAsync<Product>(q => q.Where(p => p.Price < 10));
Dependency Injection setup
// Program.cs
builder.Services.AddCacheGraph(options =>
{
options.DefaultTtl = TimeSpan.FromMinutes(30);
});
// With Microsoft.Extensions.Logging
builder.Services.AddCacheGraphWithLogging(options =>
{
options.DefaultTtl = TimeSpan.FromMinutes(30);
});
// Fluent builder
builder.Services.AddCacheGraph(builder => builder
.UseMemoryCache()
.UseMicrosoftLogging()
.WithDefaultTtl(TimeSpan.FromMinutes(30))
);
Using IMemoryCache or IDistributedCache as the cache provider
// Wrap IMemoryCache as ICacheProvider
var memoryCache = new MemoryCache(new MemoryCacheOptions());
var cacheProvider = new MemoryCacheProviderAdapter(memoryCache);
// Wrap IDistributedCache as ICacheProvider (uses JSON serialization)
var distributedCache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions()));
var cacheProvider = new DistributedCacheProviderAdapter(
distributedCache,
new JsonCacheSerializer());
Declarative caching with attributes (CacheGraph.Attributes)
using CacheGraph.Attributes;
public interface IProductService
{
[CacheResult("products:{id}", 300, "entity:Product:{id}")]
Task<Product> GetByIdAsync(int id);
}
// Manual wiring
var proxy = CacheProxy.Create<IProductService>(new ProductService(), cacheGraph);
// Or via DI
services.AddCacheGraph();
services.AddCacheGraphProxied<IProductService, ProductService>();
Keys support {paramName} placeholders; dependencies can be declared as templates
and are also auto-detected from id/ids parameters (entity:{Type}:{value}).
Cache hits skip the target; cascade invalidation works transparently.
Cross-instance backplane (CacheGraph.Backplane)
// Same event provider shared across instances (Redis Pub/Sub with CacheGraph.Redis)
services.AddCacheGraph();
services.AddCacheGraphBackplane();
// At startup β start replicating remote invalidations
var backplane = provider.GetRequiredService<CacheGraphBackplane>();
await backplane.StartAsync();
Invalidation events carry the affected keys, so every instance removes the same entries from its local cache and graph. Replication is silent β no re-publish loops. With Redis, messages from the publishing instance are filtered out automatically.
EF Core automatic query caching
// Program.cs β register with default options (caches everything)
builder.Services.AddCacheGraphEntityFrameworkCore(options =>
{
options.DefaultQueryTtl = TimeSpan.FromMinutes(5);
});
// Add to DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connStr)
.AddCacheGraphInterceptor()); // <-- registers DbCommandInterceptor
Economic cache (threshold-based adaptive caching)
Instead of caching every query, the economic cache analyzes execution time and call frequency to decide intelligently what to cache:
// Program.cs
builder.Services.AddCacheGraphEntityFrameworkCore(options =>
{
options.EconomicCache.Enabled = true; // activate economic mode
options.EconomicCache.AlwaysCacheAboveDuration = TimeSpan.FromSeconds(10);
options.EconomicCache.NeverCacheBelowDuration = TimeSpan.FromMilliseconds(500);
options.EconomicCache.ScaleTtlByDuration = true; // slower queries β longer TTL
});
Threshold rules (defaults):
| Zone | Duration | Condition | Action |
|---|---|---|---|
| Always | > 10 s | Any frequency | Cache |
| High | 1 s β 10 s | > 10 calls/hour | Cache |
| Medium | 500 ms β 1 s | > 10 calls/hour | Cache |
| Fast | 0 β 500 ms | > 1000 calls/hour | Cache |
| Fast | 0 β 500 ms | β€ 1000 calls/hour | Skip |
| Pending | Any | < 3 samples | Cache (conservative) |
Inject IEconomicCacheManager into a controller to build a monitoring dashboard:
public class CacheMetricsController : ControllerBase
{
private readonly IEconomicCacheManager _economy;
[HttpGet("slowest")]
public IActionResult GetSlowest([FromQuery] int top = 10)
=> Ok(_economy.GetTopSlowestQueries(top));
[HttpGet("profiles")]
public IActionResult GetAll()
=> Ok(_economy.GetAllProfiles());
[HttpPost("overrides/force-cache/{key}")]
public IActionResult ForceCache(string key)
{
_economy.ForceCache(key);
return Ok();
}
[HttpPost("thresholds")]
public IActionResult UpdateThresholds([FromBody] EconomicCacheOptions opts)
{
_economy.UpdateThresholds(o =>
{
o.AlwaysCacheAboveDuration = opts.AlwaysCacheAboveDuration;
o.NeverCacheBelowDuration = opts.NeverCacheBelowDuration;
});
return Ok();
}
}
Benchmarks (CacheGraph.Benchmarks)
dotnet run --project CacheGraph.Benchmarks -c Release -- --filter '*GetOrSetBenchmarks*'
Covers hot/cold GetOrSetAsync throughput, cascade invalidation cost at
10/100/1000 dependents, and a head-to-head comparison against manual cache-aside
and FusionCache.
EF Core Query Caching Benchmark
Real-world comparison of EF Core + SQLite with and without CacheGraph's query
interceptor. ~300K master records, 5 Level2 tables (1K rows each), 3 Level3
tables (500 rows each), 12 diverse queries, 10 iterations each. Both projects
share identical schema, DbContext, data generator, and queries β only
Program.cs and .csproj differ.
Only ~20 lines of code separate the baseline from the cached version (4 using directives + 8 lines of DI registration + 8 lines to inject interceptors via a custom
DbContextFactory).
Results
| Query | No Cache (ms) | With CacheGraph (ms) | Speedup |
|---|---|---|---|
| Q1 SingleById | 17.3 | 15.4 | 1.1Γ |
| Q2 Filter+Count | 8.8 | 0.5 | 17.6Γ |
| Q3 Pagination (LIKE+Skip) | 6.7 | 1.1 | 6.1Γ |
| Q4 JOIN 2-table | 3.7 | 1.4 | 2.6Γ |
| Q5 GROUP BY SUM/AVG | 1,579.7 | 0.1 | 15,797Γ |
| Q6 IncludeβThenInclude | 2.3 | 1.4 | 1.6Γ |
| Q7 3x Include | 0.6 | 0.4 | 1.5Γ |
| Q8 EXISTS Subquery | 61.8 | 1.1 | 56.2Γ |
| Q9 TOP 100 ORDER BY | 200.8 | 1.0 | 200.8Γ |
| Q10 JOIN 3-table | 1.8 | 1.8 | 1.0Γ |
| Q11 GROUP BY HAVING | 0.6 | 0.6 | 1.0Γ |
| Q12 OR Filter | 4.7 | 1.5 | 3.1Γ |
| TOTAL | 1,888.8 | 26.3 | 71.8Γ |
Aggregate and full-scan queries (Q5, Q8, Q9) benefit most β those go from hundreds or thousands of milliseconds to sub-millisecond responses. Queries that are already < 2 ms on small tables (Q10, Q11) see no measurable difference, confirming that the cache adds negligible overhead.
Run it yourself
# Baseline (no cache)
dotnet run --project CacheGraph.Benchmarks.EfCore/NoCache -- 10
# With CacheGraph
dotnet run --project CacheGraph.Benchmarks.EfCore/WithCache -- 10
ποΈ Architecture
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β ICacheGraph β
β (main faΓ§ade) β
ββββββββββββββ¬βββββββββββββββ¬βββββββββββββββ¬βββββββββββββββββ€
β ICache β IGraph β IDistributed β IEvent β
β Provider β Provider β Lock β Provider β
ββββββββββββββ΄βββββββββββββββ΄βββββββββββββββ΄βββββββββββββββββ€
β ITelemetryProvider β ICacheGraphLogger β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Edge direction: cache-key β entity (the cache key depends on the entity).
product:42 βββββββΊ entity:Product:42 βββββββ products:featured
(dep) (entity) (dep)
cart:user:42 βββββββΊ entity:User:42
(dep) (entity)
When entity:Product:42 is invalidated, the graph traverses incoming edges to find product:42 and products:featured, removing both from the cache.
π Interfaces
| Interface | Purpose |
|---|---|
ICacheGraph |
Main faΓ§ade β cache-aside, invalidation, graph queries |
ICacheProvider |
Key-value storage (get, set, remove, exists) |
IGraphProvider |
Dependency graph (nodes, edges, traversal) |
IDistributedLock |
Distributed lock for stampede protection |
IEventProvider |
Publish/subscribe event system |
ITelemetryProvider |
Activity tracking and performance telemetry |
ICacheGraphLogger |
Logging abstraction |
ICacheSerializer |
Serialization for distributed cache adapters |
IEconomicCacheManager |
Economic cache management β metrics, thresholds, overrides |
βοΈ Configuration
var options = new GraphOptions
{
DefaultTtl = TimeSpan.FromHours(1), // Default cache entry TTL
LockTimeout = TimeSpan.FromSeconds(30), // Max wait to acquire lock
LockRetryCount = 3, // Lock acquisition retries
LockRetryDelay = TimeSpan.FromMilliseconds(100),
EnableValidation = true, // Input validation
};
π§ͺ Tests
dotnet test --framework net10.0
331 tests with xUnit + FluentAssertions, covering:
- Unit tests for all providers, models, and the main CacheGraph faΓ§ade
- DI integration tests
- Cache adapter tests (IMemoryCache, IDistributedCache)
- Concurrency stress tests (stampede protection, concurrent invalidation, concurrent edge addition, metrics accuracy under load)
- Backplane tests (cross-instance invalidation replication, no re-publish loops)
- Redis Pub/Sub tests (channel subscription, self-message filtering, unsubscribe)
- Declarative caching tests (key templates, auto-dependency detection, invalidation)
- EF Core interceptor tests (caching, parameter normalization, command suppression)
- Economic cache tests (threshold zones, TTL scaling, force overrides, configuration)
π― Demo
dotnet run --project CacheGraph.Demo
π License
MIT License β see LICENSE for details.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | 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
- CacheGraph (>= 1.1.0)
- CacheGraph.Extensions (>= 1.1.0)
- StackExchange.Redis (>= 2.8.16)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.