![]() |
VOOZH | about |
dotnet add package Pandatech.DistributedCache --version 6.0.0
NuGet\Install-Package Pandatech.DistributedCache -Version 6.0.0
<PackageReference Include="Pandatech.DistributedCache" Version="6.0.0" />
<PackageVersion Include="Pandatech.DistributedCache" Version="6.0.0" />Directory.Packages.props
<PackageReference Include="Pandatech.DistributedCache" />Project file
paket add Pandatech.DistributedCache --version 6.0.0
#r "nuget: Pandatech.DistributedCache, 6.0.0"
#:package Pandatech.DistributedCache@6.0.0
#addin nuget:?package=Pandatech.DistributedCache&version=6.0.0Install as a Cake Addin
#tool nuget:?package=Pandatech.DistributedCache&version=6.0.0Install as a Cake Tool
A focused .NET library that implements Microsoft's HybridCache abstraction on top of Redis. Provides strongly typed
caching with MessagePack serialization, distributed locking, stampede protection, tag-based invalidation, and rate
limiting — in under 500 lines of code.
Targets net9.0 and net10.0 only. HybridCache graduated from preview in .NET 9; net8 is not supported.
HybridCache abstraction with Redis, no local L1 layerGetOrCreateAsync calls on the same key are serialized; only one caller hits
the factoryIDistributedLockService with atomic acquire/release via LuaHybridCache extension methods — GetOrDefaultAsync, TryGetAsync, ExistsAsyncPrefixWithAssemblyName and PrefixWith for structured, collision-safe key namingAddDistributedCachedotnet add package Pandatech.DistributedCache
One call in Program.cs wires everything up:
builder.AddDistributedCache(options =>
{
options.RedisConnectionString = "localhost:6379"; // required
options.ChannelPrefix = "myapp"; // optional, default: null
});
AddDistributedCache registers:
IConnectionMultiplexer (singleton, with exponential reconnect)HybridCache → RedisDistributedCache (singleton)IRateLimitService → RedisRateLimitService (singleton)IDistributedLockService → RedisLockService (singleton)Decorate your model with [MessagePackObject] and implement ICacheEntity:
[MessagePackObject]
public class UserSessionCache : ICacheEntity
{
[Key(0)] public Guid UserId { get; set; }
[Key(1)] public string Role { get; set; } = string.Empty;
[Key(2)] public DateTime ExpiresAt { get; set; }
}
ICacheEntity is a marker interface with no members. It exists to make the intent explicit at the type level.
Inject HybridCache directly. If the key is absent, the factory runs once — concurrent callers block until the first
writer is done (stampede protection):
public class SessionService(HybridCache cache)
{
public async Task<UserSessionCache> GetSessionAsync(Guid userId, CancellationToken ct = default)
{
return await cache.GetOrCreateAsync(
$"session:{userId}",
async _ => await LoadFromDbAsync(userId, ct),
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(30) },
tags: [$"user:{userId}"],
cancellationToken: ct);
}
}
await cache.SetAsync(
$"session:{userId}",
session,
new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(30) },
tags: [$"user:{userId}"],
cancellationToken: ct);
If Expiration is omitted, DefaultExpiration from configuration is used (default: 15 minutes). Pass
TimeSpan.MaxValue to store without an expiry.
await cache.RemoveAsync($"session:{userId}", ct);
Tags let you invalidate a group of related entries without knowing their individual keys. Calling RemoveByTagAsync
writes a tombstone timestamp for that tag. The next read of any entry carrying that tag checks the tombstone — if the
tag was updated after the entry was written, the entry is evicted and re-fetched.
// Invalidate all cache entries tagged with "user:{userId}"
await cache.RemoveByTagAsync($"user:{userId}", ct);
An entry can carry multiple tags:
tags: ["user:42", "tenant:7"]
Invalidating either tag is enough to evict the entry on next read.
Three extension methods on HybridCache cover the most common patterns that the base API handles awkwardly.
Returns a cached value or a caller-supplied default without writing anything to Redis:
var value = await cache.GetOrDefaultAsync("feature-flag:dark-mode", defaultValue: false, ct);
Returns whether the key exists alongside its value in one round-trip:
var (exists, session) = await cache.TryGetAsync<UserSessionCache>($"session:{userId}", ct);
if (!exists)
{
// key is not in cache
}
Checks presence without deserializing the value:
var isActive = await cache.ExistsAsync<UserSessionCache>($"session:{userId}", ct);
All three extensions are implemented against the HybridCache abstraction, so they work with any compatible
implementation — not just this one.
IRateLimitService applies business-logic rate limits per action type and identity. State is stored in Redis and is
consistent across all instances of your service.
public enum ActionType
{
SmsOtp = 1,
EmailOtp = 2,
Login = 3
}
public static class RateLimits
{
public static RateLimitConfiguration SmsOtp() => new()
{
ActionType = (int)ActionType.SmsOtp,
MaxAttempts = 3,
TimeToLive = TimeSpan.FromMinutes(10)
};
public static RateLimitConfiguration Login() => new()
{
ActionType = (int)ActionType.Login,
MaxAttempts = 10,
TimeToLive = TimeSpan.FromMinutes(15)
};
}
public class AuthService(IRateLimitService rateLimitService)
{
public async Task<RateLimitState> RequestOtpAsync(string phoneNumber, CancellationToken ct = default)
{
var config = RateLimits.SmsOtp().SetIdentifiers(phoneNumber);
var state = await rateLimitService.RateLimitAsync(config, ct);
if (state.Status == RateLimitStatus.Exceeded)
{
// state.TimeToReset — how long until the window resets
// state.RemainingAttempts — always 0 here
throw new TooManyRequestsException($"Try again in {state.TimeToReset.TotalSeconds:0}s.");
}
// state.RemainingAttempts — how many calls are left in the window
await SendSmsAsync(phoneNumber, ct);
return state;
}
}
SetIdentifiers takes a primary identifier (e.g. phone number) and an optional secondary identifier (e.g. tenant ID).
The two together form a unique rate-limit key for that action type.
RateLimitState always contains:
| Property | Meaning |
|---|---|
Status |
NotExceeded or Exceeded |
TimeToReset |
Remaining TTL of the current window |
RemainingAttempts |
Calls left before Exceeded (0 when already exceeded) |
IDistributedLockService is available for cases where you need explicit locking outside of the cache layer. The
implementation uses SET NX for acquire and a Lua script for atomic release — the standard Redis lock pattern.
public class InventoryService(IDistributedLockService locks)
{
public async Task DeductStockAsync(int productId, int quantity, CancellationToken ct = default)
{
var key = $"product:{productId}";
var token = Guid.NewGuid().ToString();
if (!await locks.AcquireLockAsync(key, token))
{
await locks.WaitUntilLockIsReleasedAsync(key, ct);
// re-read state and decide what to do
return;
}
try
{
// exclusive access to this product's stock
}
finally
{
await locks.ReleaseLockAsync(key, token);
}
}
}
| Method | Behaviour |
|---|---|
AcquireLockAsync(key, token) |
Returns true if the lock was taken; false if already held by another caller |
HasLockAsync(key) |
Returns true if any lock currently exists on this key |
WaitUntilLockIsReleasedAsync |
Polls every 10 ms; throws TimeoutException if the lock isn't released within 2 × DistributedLockMaxDuration |
ReleaseLockAsync(key, token) |
Releases the lock only if the stored token matches; safe against accidental cross-caller release |
Utilities for building structured, collision-safe Redis key names.
// Prefix with a literal string
"user:42".PrefixWith("myapp"); // → "myapp:user:42"
// Prefix with the calling assembly's name (resolved at call site)
"user:42".PrefixWithAssemblyName(); // → "MyService.Api:user:42"
// Batch prefix
new[] { "user:1", "user:2" }.PrefixWith("myapp"); // → ["myapp:user:1", "myapp:user:2"]
new[] { "user:1", "user:2" }.PrefixWithAssemblyName();
PrefixWithAssemblyName calls Assembly.GetCallingAssembly(), so it captures the assembly that actually calls the
method — useful for shared utilities that should tag keys with the service that owns them.
All options except RedisConnectionString have sensible defaults and are optional.
| Option | Type | Default | Description |
|---|---|---|---|
RedisConnectionString |
string |
— | Required. Standard StackExchange.Redis connection string. |
ChannelPrefix |
string? |
null |
Optional namespace prefix inserted between DistributedCache and your key. |
ConnectRetry |
int |
10 |
Number of connection retries on startup. |
ConnectTimeout |
TimeSpan |
10s |
Timeout for establishing a connection. |
SyncTimeout |
TimeSpan |
5s |
Timeout for synchronous Redis commands. |
DistributedLockMaxDuration |
TimeSpan |
8s |
TTL applied to each lock key. Also governs the wait timeout (2 × this value). |
DefaultExpiration |
TimeSpan |
15min |
Fallback TTL when no Expiration is supplied in HybridCacheEntryOptions. |
All cache keys are stored in Redis under the pattern:
DistributedCache[:{ChannelPrefix}]:{yourKey}
Tag tombstone keys follow:
DistributedCache[:{ChannelPrefix}]:tag:{tagName}
Lock keys append :lock to the prefixed cache key.
All cache values are serialized with MessagePack. This is not configurable — by design.
MessagePack is binary, compact (~50% of equivalent JSON), and significantly faster to serialize and deserialize than JSON or Protobuf in most .NET benchmarks. It also renders as a JSON-like view in most Redis desktop clients (e.g. Another Redis Desktop Manager), so debugging is not meaningfully harder than with JSON.
Enforcing a single serializer removes an entire class of subtle bugs (mismatched serializers between writers and readers, type name handling differences, DateTime encoding differences) and keeps the library surface small.
The trade-off: your cached models must carry [MessagePackObject] and [Key(n)] attributes. This is a one-time,
mechanical annotation and does not affect your domain logic.
AddDistributedCache automatically registers a Redis health check via AspNetCore.HealthChecks.Redis with a 3-second
timeout. No additional configuration is needed.
If you expose a health endpoint:
app.MapHealthChecks("/health");
Redis connectivity is included in the response automatically. This integrates with Kubernetes liveness/readiness probes, load-balancer health checks, and any monitoring stack that speaks the ASP.NET Core health check protocol.
MIT
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net9.0 net9.0 is compatible. net9.0-android net9.0-android was computed. net9.0-browser net9.0-browser was computed. net9.0-ios net9.0-ios was computed. net9.0-maccatalyst net9.0-maccatalyst was computed. net9.0-macos net9.0-macos was computed. net9.0-tvos net9.0-tvos was computed. net9.0-windows net9.0-windows was computed. net10.0 net10.0 is compatible. net10.0-android net10.0-android was computed. net10.0-browser net10.0-browser was computed. net10.0-ios net10.0-ios was computed. net10.0-maccatalyst net10.0-maccatalyst was computed. net10.0-macos net10.0-macos was computed. net10.0-tvos net10.0-tvos was computed. net10.0-windows net10.0-windows was computed. |
Showing the top 1 NuGet packages that depend on Pandatech.DistributedCache:
| Package | Downloads |
|---|---|
|
Pandatech.SharedKernel
Opinionated ASP.NET Core 10 infrastructure kernel: OpenAPI (Swagger + Scalar), Serilog, MediatR, FluentValidation, CORS, SignalR, OpenTelemetry, health checks, maintenance mode, resilience pipelines, and shared utilities. |
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 6.0.0 | 206 | 2/28/2026 |
| 5.0.1 | 166 | 1/26/2026 |
| 5.0.0 | 139 | 12/28/2025 |
| 4.0.9 | 403 | 8/7/2025 |
| 4.0.8 | 314 | 6/1/2025 |
| 4.0.7 | 285 | 2/28/2025 |
| 4.0.6 | 222 | 2/17/2025 |
| 4.0.5 | 238 | 1/29/2025 |
| 4.0.4 | 210 | 1/29/2025 |
| 4.0.3 | 197 | 1/29/2025 |
| 4.0.2 | 215 | 1/28/2025 |
| 4.0.1 | 182 | 1/28/2025 |
| 4.0.0 | 180 | 1/27/2025 |
| 3.0.1 | 296 | 11/22/2024 |
| 3.0.0 | 213 | 11/21/2024 |
| 2.0.0 | 262 | 9/5/2024 |
| 1.2.3 | 243 | 8/16/2024 |
| 1.2.2 | 239 | 8/16/2024 |
| 1.2.1 | 267 | 8/16/2024 |
| 1.2.0 | 247 | 8/16/2024 |
Multi-target net9.0/net10.0, removed BuildServiceProvider anti-pattern, source-generated logging, internal sealed service implementations