EdiHybridCache 0.5.6
dotnet add package EdiHybridCache --version 0.5.6
NuGet\Install-Package EdiHybridCache -Version 0.5.6
<PackageReference Include="EdiHybridCache" Version="0.5.6" />
<PackageVersion Include="EdiHybridCache" Version="0.5.6" />
<PackageReference Include="EdiHybridCache" />
paket add EdiHybridCache --version 0.5.6
#r "nuget: EdiHybridCache, 0.5.6"
#:package EdiHybridCache@0.5.6
#addin nuget:?package=EdiHybridCache&version=0.5.6
#tool nuget:?package=EdiHybridCache&version=0.5.6
๐ EdiHybridCache - Event Driven Cache Invalidation with Hybrid Cache
The .NET Hybrid Cache Library โ Blazing Fast, Battle-Tested, Enterprise-Ready
Why EdiHybridCache?
Hybrid caching combines the speed of in-process memory (L1) with the durability and sharing of Redis (L2). EdiHybridCache takes this further with:
- โก L1:
IMemoryCacheโ microsecond reads, zero network - ๐ก L2: Redis โ shared across instances, persistent
- ๐ง Event-driven invalidation: RabbitMQ fanout โ invalidate all L1s instantly
- ๐ก๏ธ Anti-stampede: Per-key async locking โ only one request hits Redis
- ๐ Resilience: Polly retries with exponential backoff + jitter
- ๐ฆ Compression: GZip for large values
- ๐ Secure by design: CWE-409, CWE-502, CWE-754, CWE-295, CWE-770 mitigated
๐ Performance
| Metric | Value | Proof |
|---|---|---|
| GetAsync L1 Hit | 2.23 ฮผs, 72 B allocated | Benchmark |
| SetAsync (100B) | 55.0 ฮผs, 1.7 KB allocated | Benchmark |
| Throughput (standalone) | 15,164 req/s @ 5,000 VUs | k6 Load Test |
| Throughput (Aspire) | 14,063 req/s @ 5,000 VUs | k6 Load Test |
| Failures | 0.00% @ 2.5M requests | k6 Load Test |
| Code Coverage | 90.57% line, 83.33% branch | Coverage |
| CRAP Score | Reduced up to 69% | Complexity |
๐ฆ Installation
dotnet add package EdiHybridCache
Or reference the project directly:
<ProjectReference Include="..\src\EdiHybridCache\EdiHybridCache.csproj" />
๐ Verifying Package Signature (Sigstore)
Every NuGet release is signed with Sigstore using a private key stored as a GitHub Actions secret. The public key (cosign.pub) is available in the repository and in every workflow artifact.
- Download the
.sigfile for the release version from the workflow artifacts and the public key from the repo root. - Verify with
cosign:
cosign verify-blob \
--key cosign.pub \
--signature EdiHybridCache.X.Y.Z.nupkg.sig \
EdiHybridCache.X.Y.Z.nupkg
Replace
X.Y.Zwith the actual version number. Verification succeeds if the package was signed by the trusted private key.
๐ง Quick Start
1. Register in DI
// Program.cs
builder.Services.AddEdiHybridCache(builder.Configuration);
2. Configure appsettings.json
{
"EdiHybridCache": {
"RedisConnectionString": "localhost:6379",
"RabbitMqHost": "localhost",
"L1TtlSeconds": 300,
"DefaultL2TtlSeconds": 3600,
"EnableCompression": true
}
}
3. Inject and Use
public class MyService
{
private readonly IHybridCache _cache;
public MyService(IHybridCache cache) => _cache = cache;
public async Task<string?> GetUserAsync(int id)
{
var key = $"user:{id}";
return await _cache.GetAsync<string>(key);
}
public async Task SetUserAsync(int id, string data)
{
var key = $"user:{id}";
await _cache.SetAsync(key, data, TimeSpan.FromMinutes(30));
}
public async Task RemoveUserAsync(int id)
{
var key = $"user:{id}";
await _cache.RemoveAsync(key);
}
}
4. Start the Invalidation Subscriber (optional)
using (var scope = app.Services.CreateScope())
{
await scope.ServiceProvider.UseEdiHybridCacheSubscriberAsync();
}
๐๏ธ Architecture
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ Instance A โ โ Instance B โ โ Instance C โ
โ โโโโโโโโโ โ โ โโโโโโโโโ โ โ โโโโโโโโโ โ
โ โ L1 โ โ โ โ L1 โ โ โ โ L1 โ โ
โ โMemory โ โ โ โMemory โ โ โ โMemory โ โ
โ โโโโโฌโโโโ โ โ โโโโโฌโโโโ โ โ โโโโโฌโโโโ โ
โ โ โ โ โ โ โ โ โ
โ โโโโโผโโโโ โ โ โโโโโผโโโโ โ โ โโโโโผโโโโ โ
โ โ L2 โ โ โ โ L2 โ โ โ โ L2 โ โ
โ โ Redis โ โ โ โ Redis โ โ โ โ Redis โ โ
โ โโโโโโโโโ โ โ โโโโโโโโโ โ โ โโโโโโโโโ โ
โ โ โ โ โ โ โ โ โ
โโโโโโโโผโโโโโโโโ โโโโโโโโผโโโโโโโโ โโโโโโโโผโโโโโโโโ
โ โ โ
โโโโโโโโโโโโฌโโโโโโโโโโดโโโโโโโโโโโฌโโโโโโโโโโ
โ โ
โโโโโโโโโผโโโโโโโโ โโโโโโโโโผโโโโโโโโ
โ RabbitMQ โ โ RabbitMQ โ
โ Exchange โ โ Queue (each โ
โ (Fanout) โโโโโถโ instance) โ
โโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
L1 โ In-Process Memory
- Provider:
Microsoft.Extensions.Caching.Memory - Latency: 1.28 ฮผs (microseconds)
- Allocation: 144 B per hit (dropping to ~104 B with ValueTask)
- Scope: Per-instance, ephemeral
L2 โ Redis
- Provider:
StackExchange.Redis - Persistence: Shared across all instances
- Resilience: Automatic retry via Polly (exponential backoff 1s โ 2s โ 4s + jitter)
- Connection: Singleton via DI
Event-Driven Invalidation
- Provider: RabbitMQ fanout exchange
- Flow:
RemoveAsyncโ publish event โ all subscribers receive โ each clears its L1 - Graceful degradation: If RabbitMQ is unavailable, invalidation events are skipped with a warning
- Durable: Messages are persistent; queues are auto-deleted per instance
๐ API Reference
ValueTask<T?> GetAsync<T>(string key, CancellationToken ct = default)
Retrieves a value from the cache.
Behavior:
- Check L1 (memory) โ if found, return immediately (synchronous ValueTask, zero Task allocation)
- Acquire per-key async lock
- Double-check L1 (anti-stampede)
- Read from L2 (Redis) with Polly retry
- If found, populate L1 and return
- If not found, return
null
var user = await cache.GetAsync<User>("user:42");
// Returns null if not found
Task SetAsync<T>(string key, T value, TimeSpan? ttlL2 = null, CancellationToken ct = default)
Stores a value in the cache.
Behavior:
- Validate key length (max 512 chars)
- Serialize value with
System.Text.Json(camelCase) - Optionally compress with GZip (threshold configurable)
- Write to L1 (memory) - always
- Write to L2 (Redis) with Polly retry
- Log the operation
await cache.SetAsync("user:42", user, TimeSpan.FromMinutes(30));
// Uses L1 TTL from config, L2 TTL = 30min (adjusted if below minimum)
TTL Adjustment:
- If
ttlL2 < L1TtlSeconds ร L2TtlMultiplier, it's automatically raised to the minimum - This prevents race conditions where L2 expires before L1
Task RemoveAsync(string key, CancellationToken ct = default)
Removes a value from the cache and notifies other instances.
Behavior:
- Remove from L1 (memory)
- Delete from L2 (Redis) with Polly retry
- Publish invalidation event via RabbitMQ (best-effort)
await cache.RemoveAsync("user:42");
Task PublishInvalidationAsync(string key, CancellationToken ct = default)
Publishes an invalidation event without modifying the local cache. Useful when another service writes directly to Redis.
await cache.PublishInvalidationAsync("user:42");
void InvalidateLocal(string key)
Synchronously removes a value from L1 only. No network I/O.
if (cache is HybridCache hc)
hc.InvalidateLocal("user:42");
๐ Security
| CWE | CVSS | Vulnerability | Status |
|---|---|---|---|
| CWE-409 | 7.5 | ZIP Bomb โ decompression bomb | โ Hard cap at 100 MB, detection via leftover bytes |
| CWE-502 | 6.5 | Deserialization injection | โ
Type-safe System.Text.Json + where T : class + exception logging |
| CWE-754 | 7.5 | Deadlock from .GetAwaiter().GetResult() |
โ Async lazy init โ no blocking in constructors |
| CWE-295 | 7.4 | Missing SSL/TLS for RabbitMQ | โ
Configurable via RabbitMqUseSsl, RabbitMqSslServerName, RabbitMqSslCertificatePath |
| CWE-770 | 5.3 | Unbounded resource allocation | โ Size limits โ max key length (512), max value size (100 MB) |
| CWE-312 | 5.9 | Cleartext secrets in memory | โ ๏ธ Documented โ operate on trusted network, HMAC/add encryption if needed |
| CWE-117 | 3.1 | Log injection | โ
Structured logging via LoggerMessage.Define โ no string interpolation in logs |
๐ Benchmark Results
BenchmarkDotNet v0.14.0, .NET 10.0.9, AMD Ryzen 7 5700U
9 benchmarks, 2 warmup, 5 iterations each
| Method | Mean | Gen0 | Gen1 | Allocated |
|---|---|---|---|---|
| GetAsync L1 Hit | 2.23 ฮผs | 0.03 | โ | 72 B |
| GetAsync L2 Hit | 6,699 ฮผs | โ | โ | 25.2 KB |
| GetAsync L2 Miss | 6,614 ฮผs | โ | โ | 26.3 KB |
| SetAsync 100B | 55.0 ฮผs | 0.24 | โ | 1.7 KB |
| SetAsync 10KB | 29.7 ฮผs | 1.71 | 0.73 | 11.5 KB |
| SetAsync 200KB (LOH) | 244 ฮผs | โ | โ | 201.8 KB |
| SetAsync 10KB + compress | 22.5 ฮผs | 3.97 | 1.19 | 11.9 KB ๐ |
| RemoveAsync | 9.30 ฮผs | 0.37 | 0.11 | 2.4 KB |
| InvalidateLocal | 7.34 ฮผs | 0.24 | 0.06 | 1.5 KB |
Key takeaways:
- GetAsync L1 Hit in 2.23 ฮผs, 72 B allocated โ Zero-allocation fast path via synchronous lock acquisition
- SetAsync 10KB + compress: 45% less allocations (22 KB โ 11.9 KB) after removing extra
MemoryStreamcopy inTryDecompress - Zero LOH allocations on hot paths โ
ArrayPool<byte>+struct Releaser+ReadOnlySpan - LoggerMessage.Define eliminated
params object[]allocation, saving ~32 B per hot log call
๐งช k6 Load Test
Standalone (direct Redis connection)
15,164 req/s ยท 0% failure ยท p(95) = 226 ms ยท 5,000 VUs
1,289,184 total requests, 1,718,912 checks passed โ
Aspire AppHost (with DCP proxy)
14,063 req/s ยท 0% failure ยท p(95) = 225 ms ยท 5,000 VUs
1,195,692 total requests, 1,594,256 checks passed โ
Test scenario: Set โ Get(L1) โ InvalidateLocal โ Get(L2) โ Remove โ Get(Miss) (6 requests/iteration)
| Metric | Standalone | Aspire AppHost |
|---|---|---|
| Peak throughput | 15,164 req/s | 14,063 req/s |
| Avg latency | 78 ms | 73 ms |
| p(95) latency | 226 ms โ (< 2000ms) | 225 ms โ (< 2000ms) |
| HTTP failures | 0.00% | 0.00% |
| Total requests | 1,289,184 | 1,195,692 |
| Total checks | 1,718,912 โ | 1,594,256 โ |
| L1 hit rate | 100% | 100% |
| L2 hit rate | 100% | 100% |
Status Final
| Metric | Status |
|---|---|
| Standalone throughput | 15,164 req/s ๐ฅ |
| Aspire throughput | 14,063 req/s ๐ฅ |
| p(95) latency | 225 ms โ |
| Failures | 0.00% |
| p(95) < 2s threshold | โ Passed |
| Memory usage | ~200 MB ๐ |
๐ Code Coverage
| Metric | Value |
|---|---|
| Line Coverage | 90.57% |
| Branch Coverage | 83.33% |
| Lines covered | 222 of 245 (excluding RabbitMQ classes) |
Per-Class Coverage
| Class | Coverage |
|---|---|
HybridCache |
100% |
HybridCacheOptions |
100% |
CompressionHelper |
100% |
AsyncLock |
100% |
ServiceCollectionExtensions |
97.87% |
GetAsync state machine |
97.29% |
| RabbitMQ classes | [ExcludeFromCodeCoverage] (require infrastructure) |
๐ Code Quality & Complexity
Cyclomatic Complexity Reduction
| Method | Before | After | Reduction |
|---|---|---|---|
TryDecompress |
CC 8 | CC 4 | ๐ฝ 50% |
DeserializeRedisValue |
CC 6 | CC 2 | ๐ฝ 67% |
GetAsync |
CC 5 | CC 3 | ๐ฝ 40% |
SetAsync |
CC 3 | CC 3 | โ |
| Overall | CC 22 | CC 12 | ๐ฝ 45% |
CRAP Score Improvement
CRAP = (CCยฒ) ร (1 โ coverage)ยณ + CC
| Method | CC | Coverage | CRAP Before | CRAP After | Improvement |
|---|---|---|---|---|---|
TryDecompress |
8โ4 | 70% | 13.3 | 5.0 | ๐ฝ 62% |
DeserializeRedisValue |
6โ2 | 90% | 6.4 | 2.0 | ๐ฝ 69% |
GetAsync |
5โ3 | 95% | 5.0 | 3.0 | ๐ฝ 40% |
Clean Code Practices
- โ
DRY:
RedisSafeExecuteAsync<T>extracted from 3 repetitions - โ
DRY:
TryOverrideFromEnv/TryParseEnvInt/TryParseEnvDoublereplace 8 repetitions - โ
DRY:
ValidateMaxSizeextracted from 2 repetitions - โ Single Responsibility: Each method does one thing
- โ Early Return: No else branches โ early exit pattern
- โ Static members before instance (SA1204 compliance)
- โ
Zero
params object[]in hot path logs (LoggerMessage.Define) - โ No magic strings โ all constants named
โ๏ธ Configuration
Environment Variables
| Variable | Default | Description |
|---|---|---|
REDIS_CONNECTION |
โ | Redis connection string |
RABBITMQ_HOST |
localhost |
RabbitMQ host |
RABBITMQ_PORT |
5672 |
RabbitMQ port |
RABBITMQ_USERNAME |
guest |
RabbitMQ username |
RABBITMQ_PASSWORD |
guest |
RabbitMQ password |
L1_TTL_SECONDS |
300 |
L1 TTL (in-process memory) |
DEFAULT_L2_TTL_SECONDS |
3600 |
Default L2 TTL (Redis) |
L2_TTL_MULTIPLIER |
1.5 |
Minimum L2/L1 TTL ratio |
RABBITMQ_USE_SSL |
false |
Enable SSL/TLS for RabbitMQ |
RABBITMQ_SSL_SERVER_NAME |
โ | RabbitMQ SSL server name |
RABBITMQ_SSL_CERT_PATH |
โ | RabbitMQ SSL certificate path |
appsettings.json Example
{
"EdiHybridCache": {
"RedisConnectionString": "localhost:6379",
"RabbitMqHost": "localhost",
"RabbitMqPort": 5672,
"RabbitMqUseSsl": false,
"L1TtlSeconds": 300,
"DefaultL2TtlSeconds": 3600,
"L2TtlMultiplier": 1.5,
"EnableCompression": true,
"CompressionThresholdBytes": 4096,
"RetryCount": 3,
"RetryBaseDelaySeconds": 1
}
}
๐งฐ How to Run
Docker (Redis + RabbitMQ)
Start the required infrastructure (Redis and RabbitMQ) with Docker Compose:
docker-compose up -d
This starts:
- Redis on
localhost:6379 - RabbitMQ on
localhost:5672(AMQP) andlocalhost:15672(Management UI โguest/guest)
Standalone
# Build
dotnet build
# Run tests
dotnet test tests/EdiHybridCache.Tests
# Run benchmarks
dotnet run -c Release --project benchmarks/EdiHybridCache.Benchmarks
# Run the playground (Web API with Swagger) - requires Redis + RabbitMQ
# (start docker-compose first, or have Redis + RabbitMQ running locally)
dotnet run --project playground/EdiHybridCache.Playground
# Swagger UI: http://localhost:5000/swagger/index.html
# API base URL: http://localhost:5000
# Run k6 load test (while playground is running)
k6 run k6-load-test.js
With Aspire AppHost (recommended)
The Aspire AppHost automatically provisions Redis and RabbitMQ containers, injects environment variables, and starts the Playground:
dotnet run --project src/EdiHybridCache.AppHost/EdiHybridCache.AppHost.csproj
The dashboard will be available at https://localhost:XXXXX (random port). Redis and RabbitMQ credentials are auto-generated โ no manual configuration needed.
๐๏ธ Project Structure
EdiHybridCache/
โโโ src/EdiHybridCache/ # ๐ Library source
โ โโโ Cache/
โ โ โโโ HybridCache.cs # Core implementation
โ โ โโโ IHybridCache.cs # Public interface
โ โ โโโ HybridCacheOptions.cs # Configuration options
โ โ โโโ AsyncLock.cs # Per-key async locking
โ โ โโโ CacheMetrics.cs # OpenTelemetry metrics
โ โ โโโ CompressionHelper.cs # GZip compression (ArrayPool)
โ โ โโโ Constants.cs # Central constants
โ โ โโโ Invalidation/
โ โ โโโ ICacheInvalidationPublisher.cs
โ โ โโโ ICacheInvalidationSubscriber.cs
โ โ โโโ RabbitMqInvalidationPublisher.cs
โ โ โโโ RabbitMqInvalidationSubscriber.cs
โ โโโ Configuration/
โ โ โโโ HybridCacheServiceCollectionExtensions.cs
โ โโโ EdiHybridCache.csproj # NuGet package
โโโ src/EdiHybridCache.AppHost/ # ๐ Aspire orchestration
โ โโโ Program.cs # AppHost entry point
โ โโโ AppHostConstants.cs # Resource names & env vars
โ โโโ EdiHybridCache.AppHost.csproj
โโโ tests/ # โ
Unit tests (26/26 passing)
โ โโโ EdiHybridCache.Tests/
โโโ benchmarks/ # โก Performance benchmarks
โ โโโ EdiHybridCache.Benchmarks/
โโโ playground/ # ๐ฎ Sample Web API (Swagger)
โ โโโ EdiHybridCache.Playground/
โโโ k6-load-test.js # ๐ Load testing script
โโโ README.md
๐ง Anti-Stampede (Cache Stampede Protection)
When a popular key expires in L1 and multiple requests arrive simultaneously, only one request hits Redis:
using (await _asyncLock.LockAsync(key, cancellationToken))
{
// Double-check: if another thread already populated L1, return it
if (_memoryCache.TryGetValue(key, out cached))
return cached;
// Only ONE request reaches Redis
var redisValue = await _redisDb.StringGetAsync(key);
}
๐ Resilience
- Redis retries: Automatic Polly retry policy (configurable count + exponential backoff + jitter)
- RabbitMQ retries: Separate Polly retry policy for publisher; background reconnection with exponential backoff (1s โ 60s max) for subscriber
- Graceful degradation: If RabbitMQ is down, cache continues operating; invalidation events are skipped with a warning; subscriber retries in background
- Timeouts: Configurable
RedisOperationTimeoutSeconds(default: 5s) - Connection tuning:
AbortOnConnectFail=false,SyncTimeout=5s,KeepAlive=60s,ReconnectRetryPolicyfor StackExchange.Redis
๐ Security Features
- Key length validation: Max 512 characters (ArgumentException)
- Value size cap: Max 100 MB (LogWarning + skip)
- ZIP bomb protection: Hard cap on decompression buffer doubling; leftover byte detection
- Cache poisoning prevention:
TypeNameHandlingis not supported bySystem.Text.Json;JsonExceptionis caught and logged with "Possible cache poisoning" - Deadlock prevention: No
.GetAwaiter().GetResult()in constructors (CWE-754) - SSL/TLS: Configurable for RabbitMQ connections
- Log injection prevention: Structured logging via
LoggerMessage.Defineโ noparams object[]on hot paths
โ๏ธ License
MIT License โ Free to use, modify, distribute, and incorporate into any project (commercial or not). No attribution required, though appreciated.
Copyright ยฉ 2026 Valdomiro Galo ยท
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
| 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
- Microsoft.Extensions.Caching.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Caching.Memory (>= 10.0.0)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Options (>= 10.0.0)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.0)
- Polly (>= 8.4.1 && < 9.0.0)
- Polly.Extensions.Http (>= 3.0.0 && < 4.0.0)
- RabbitMQ.Client (>= 7.0.0 && < 8.0.0)
- StackExchange.Redis (>= 2.7.33 && < 3.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.