Introduction #
A near cache for .NET whose entries are invalidated by the Redis server itself.
RedisNearCache keeps an in-process copy (an L1) of the Redis values your application reads, and lets the Redis server tell you when one of them changes, using the CLIENT TRACKING feature Redis 6 introduced.
The one-sentence mechanism: your app keeps an in-process copy of the Redis values it reads, Redis remembers which keys each tracked connection has read, and when any client writes one of those keys, Redis pushes a message naming it and the local copy is thrown away. Nothing in the write path has to cooperate: a Java service, a Go job, or someone at redis-cli all invalidate you the same way.
IConnectionMultiplexer is never touched, reconfigured, or depended on.Why server-assisted beats a backplane
FusionCache and the proposed HybridCache backplane solve staleness with an application-level pub/sub channel that the writer publishes to. That only works when every writer uses the same library and remembers to publish. A writer in another language or process silently leaves readers stale. Server-assisted tracking moves the responsibility to the one place every write already passes through: Redis itself. Any client, in any language, doing a plain SET or DEL (or redis-cli by hand) causes Redis to push an invalidation, with no cooperation required from writers.
Requirements
| Requirement | Detail |
|---|---|
| Redis server | Redis 6 or newer, or Valkey (any version that implements CLIENT TRACKING). |
| Garnet | Not supported. Garnet does not implement CLIENT TRACKING. |
| .NET | .NET 8 or .NET 10. |
| StackExchange.Redis | 3.2.0 (pinned; see Directory.Packages.props). |
| Your own connection | No special requirements. Any protocol (RESP2 or RESP3), no admin mode needed. |
| RedisNearCache's private connection | Opened by RedisNearCache itself, forced to RESP2 with admin mode (AllowAdmin=true) and its own client name. Required for CLIENT TRACKING and CLIENT LIST, and because StackExchange.Redis 3.x swallows RESP3 invalidation push frames. |
dotnet add package RedisNearCacheOne package for the core near cache. RedisNearCache.HybridCache is a separate, optional package covered in HybridCache and IDistributedCache.
Quickstart #
Install the package, register the cache, and watch an outside write invalidate it.
1. Install
Add the package to your project.
dotnet add package RedisNearCache2. Register and resolve
Register IRedisNearCache against a connection string and resolve it from DI. Await Ready once at startup so the invalidation subscription and the initial tracking arm have completed before you rely on the cache being coherent.
using Microsoft.Extensions.DependencyInjection;
using RedisNearCache;
var services = new ServiceCollection();
services.AddRedisNearCache("localhost:6379");
await using var provider = services.BuildServiceProvider();
var cache = provider.GetRequiredService<IRedisNearCache>();
// Wait for the invalidation subscription and initial arming to complete.
await cache.Ready;3. Write, then read
SetAsync writes through to Redis and evicts any local copy. The first GetAsync<T> after that is always a miss: it fetches from Redis, stores the value in L1, and arms server-side tracking for that key. Every read after that is served from L1 with no network call, until the key is invalidated.
await cache.SetAsync("user:42", new { Name = "Ada" });
var user = await cache.GetAsync<User>("user:42"); // miss: reads Redis, populates L1, arms tracking
var again = await cache.GetAsync<User>("user:42"); // hit: served from L1, no network call
await cache.RemoveAsync("user:42");
if (cache.TryGetLocal<User>("user:42", out var local))
{
// local is only set if the key is currently cached in L1; Redis is never touched.
}
record User(string Name);4. See an invalidation from redis-cli
Read a key so it is cached, then change it from a completely different client, one that knows nothing about RedisNearCache. The very next read reflects the new value, because Redis itself pushed the invalidation.
# in another terminal, or from any language's Redis client
redis-cli SET user:42 '{"Name":"Grace"}'Back in your application, the next GetAsync<T>("user:42") is a miss (it goes to Redis to re-populate L1 and re-arm tracking) and returns Grace. Every read after that is a hit again, until the next external write.
Console.WriteLine(cache.Statistics);
// hits=1 misses=1 invalidations=0 flushes=0 rearms=0 raceDiscards=05. Check the stats
Statistics exposes six cumulative, thread-safe counters. See Operations for what to watch in production.
How it works #
The private connection, one-shot tracking, NOLOOP writes, and the in-flight guard.
Why not an app-level backplane
FusionCache and the proposed HybridCache backplane solve staleness with pub/sub that the writer publishes to. That only works when every writer uses the same library. Server-assisted tracking moves the responsibility to the one place every write already passes through.
The parts
RedisNearCache opens one private StackExchange.Redis multiplexer, cloned from your connection settings with Protocol=Resp2, AllowAdmin=true and a unique client name. Under RESP2 that multiplexer has two connections per Redis node: an interactive connection, used for the actual GET/SET calls, and a subscriber connection, which RedisNearCache tells Redis to redirect invalidations to (CLIENT TRACKING ON REDIRECT <subscriber id>). The subscriber subscribes to __redis__:invalidate and turns each incoming message into an eviction from L1.
One-shot tracking
Tracking is one-shot per key: after Redis sends an invalidation for a key, it forgets that key was being tracked until the connection reads it again. Every GetAsync<T> miss re-arms tracking for that key by reading it over the tracked interactive connection.
sequenceDiagram
autonumber
participant App as Your code
participant NC as RedisNearCache
participant L1
participant I as interactive conn
participant S as subscriber conn
participant R as Redis
participant W as Any writer
App->>NC: GetAsync("user:42")
NC->>L1: TryGet
L1-->>NC: miss
NC->>NC: inflight.Begin(user:42)
NC->>I: GET user:42
I->>R: GET user:42
Note over R: remembers user:42 was read by this connection
R-->>I: value v1
I-->>NC: value v1
NC->>NC: invalidated while in flight? no
NC->>L1: Set(user:42, v1)
NC-->>App: v1
App->>NC: GetAsync("user:42")
NC->>L1: TryGet
L1-->>NC: hit
NC-->>App: v1 (no network)
W->>R: SET user:42 v2
R->>S: message __redis__:invalidate [user:42]
Note over R: forgets user:42 for this connection (one-shot)
S->>NC: KeyInvalidated(user:42)
NC->>L1: Remove(user:42)
App->>NC: GetAsync("user:42")
NC->>L1: TryGet
L1-->>NC: miss
NC->>I: GET user:42
I->>R: GET user:42
Note over R: tracks user:42 again
R-->>NC: value v2
NC->>L1: Set(user:42, v2)
NC-->>App: v2
NOLOOP
Writes made through RedisNearCache’s own SetAsync/RemoveAsync are armed with NOLOOP, so Redis does not echo them back as invalidations; RedisNearCache evicts its own L1 entry for the key directly around the write instead.
The in-flight guard
Because an invalidation can arrive between sending a GET and storing its reply, RedisNearCache keeps an in-flight guard: each read records a version token before the request goes out, and the reply is only stored in L1 if no invalidation for that key was seen in the meantime. A stale reply is still returned to the caller (it was correct when read); it is simply not cached, and Statistics.RaceDiscards counts it.
sequenceDiagram
participant NC as RedisNearCache
participant L1
participant R as Redis
participant W as Any writer
NC->>NC: token = inflight.Begin(k) (version 7)
NC->>R: GET k
R-->>NC: v1 (in transit)
W->>R: SET k v2
R->>NC: invalidate [k]
NC->>NC: inflight.MarkInvalidated(k) (version 8)
NC->>L1: Remove(k) (nothing there yet)
Note over NC: reply v1 arrives
NC->>NC: WasInvalidated(k, token)? version 8 ≠ 7 → yes
NC->>NC: discard v1, Statistics.RaceDiscards++
Note over NC: return v1 to the caller (it was true when read); next read fetches v2
Reconnects, the part that actually needs care
Tracking is per-connection state on the server. The spike showed two silent failure modes, both handled by re-arming and flushing.
| What breaks | What the server does | What we do |
|---|---|---|
| Interactive connection drops and reconnects | New connection, tracking off (TRACKINGINFO flags=off, redirect=-1). Reads succeed untracked. | On ConnectionRestored(Interactive): re-issue CLIENT TRACKING ON REDIRECT, flush L1. |
| Subscriber connection drops and reconnects | New client id. The interactive connection still redirects to the dead id. Invalidations vanish with no error. | On ConnectionRestored(Subscription): look up the new id in CLIENT LIST, TRACKING OFF then ON REDIRECT <new id>, flush L1. |
| Any connection failed, not yet restored | Nothing can be trusted. | Flush L1; serve from Redis until re-armed. |
sequenceDiagram
participant A as TrackingArmer
participant L1
participant I as interactive conn (id 26)
participant S as subscriber conn (id 27 → 58)
participant R as Redis
Note over R: redirect target for id 26 is 27
R-xS: connection killed
S->>R: reconnect, CLIENT ID = 58
Note over R: id 26 still redirects to dead 27
S-->>A: ConnectionRestored(Subscription)
A->>L1: Clear() (anything missed is gone)
A->>R: CLIENT LIST (via interactive)
R-->>A: our subscriber = 58
A->>R: CLIENT TRACKING OFF
A->>R: CLIENT TRACKING ON REDIRECT 58
R-->>A: OK
A-->>L1: Armed(SubscriptionRestored)
Per-endpoint state
stateDiagram-v2
[*] --> Unarmed
Unarmed --> Arming: StartAsync / find subscriber id
Arming --> Armed: TRACKING ON REDIRECT ok
Arming --> Arming: subscriber not visible yet, backoff retry
Armed --> Lost: ConnectionFailed (either type)
Lost --> Arming: ConnectionRestored
Armed --> Arming: ConnectionRestored (server dropped or re-pointed)
note right of Armed: L1 trusted
note right of Lost: L1 flushed, reads pass through
Cluster
Client ids and tracking tables live on each node, and a node sends invalidations only for keys it owns. The private multiplexer already keeps an interactive and a subscriber connection per master, so the armer repeats the same dance per node with that node’s subscriber id.
flowchart LR
subgraph P[private multiplexer]
direction TB
A1[interactive → 7100]
S1[subscriber id 11]
A2[interactive → 7101]
S2[subscriber id 7]
A3[interactive → 7102]
S3[subscriber id 7]
end
subgraph C[cluster]
N1[(master 7100
slots 0-5460)]
N2[(master 7101
slots 5461-10922)]
N3[(master 7102
slots 10923-16383)]
end
A1 -- "TRACKING ON REDIRECT 11" --> N1
A2 -- "TRACKING ON REDIRECT 7" --> N2
A3 -- "TRACKING ON REDIRECT 7" --> N3
N1 -. "invalidate spike:c1" .-> S1
N2 -. "invalidate spike:c3" .-> S2
N3 -. "invalidate spike:c2" .-> S3
What was ruled out, and why
| Option | Finding |
|---|---|
| Use your existing multiplexer | Every CLIENT TRACKING and CLIENT LIST call needs allowAdmin, and every read on it would be tracked, cache or not. |
| RESP3 push messages (the 3.x default) | StackExchange.Redis collapses to one connection and drops the invalidate pushes. Server says tracking is on; zero messages arrive. |
| Lua to bypass admin mode | CLIENT TRACKING is flagged noscript. |
OPTIN / CLIENT CACHING YES | Must be adjacent on the wire to the next command; impossible on a multiplexed connection. Selection lives in the package instead. |
| Raw-socket sidecar per node | Works, kept as fallback. It re-implements TLS, auth, topology and reconnects that the library already provides. |
| Garnet | Does not implement CLIENT TRACKING. Redis 6+ and Valkey do. |
The mechanism diagrams and mermaid sequences on this page are reused verbatim from docs/how-it-works.html.
Configuration #
Two ways to register the cache, and every option on RedisNearCacheOptions.
AddRedisNearCache has two overloads. Both register IRedisNearCache as a singleton together with the private connection, the tracking armer, and the invalidation listener it depends on.
services.AddRedisNearCache("localhost:6379");The convenience overload takes a connection string directly and sets RedisNearCacheOptions.ConnectionString.
services.AddRedisNearCache(options =>
{
options.ConnectionString = "localhost:6379";
options.KeyPrefixes.Add("user:");
options.L1MaxAge = TimeSpan.FromMinutes(5);
});The full overload takes an Action<RedisNearCacheOptions> and lets you set every option, including Configuration (a ConfigurationOptions instance) instead of a plain connection string. Either Configuration or ConnectionString must end up set; resolving IRedisNearCache without one throws InvalidOperationException.
RedisNearCacheOptions
| Property | Default | What it does |
|---|---|---|
Configuration | null | ConfigurationOptions for the Redis deployment. RedisNearCache clones this and forces Protocol=Resp2, AllowAdmin=true and its own ClientName. Either this or ConnectionString must be set. |
ConnectionString | null | Alternative to Configuration; parsed with ConfigurationOptions.Parse. |
KeyPrefixes | empty | Key prefixes that RedisNearCache will cache locally. Reads of keys outside these prefixes still go through RedisNearCache to Redis but are not stored in L1, so they cost nothing to invalidate. Empty (the default) means every key read through RedisNearCache is cached. |
L1SizeLimit | 10_000 | Maximum number of entries held in L1. Least-recently-used entries are evicted beyond this. |
L1MaxAge | 5 minutes | Safety net: an L1 entry is dropped after this age even if no invalidation arrived. Protects against a missed invalidation. Set to Timeout.InfiniteTimeSpan to disable. |
Serializer | JsonRedisNearCacheSerializer.Instance | Serializer for values. Defaults to System.Text.Json; string and byte[] are passed through untouched. |
ClientNamePrefix | "rnc" | Prefix for the Redis client name RedisNearCache sets on its own connections. A unique suffix is appended. |
using System.Text.Json;
services.AddRedisNearCache(options =>
{
options.ConnectionString = "localhost:6379";
options.Serializer = new JsonRedisNearCacheSerializer(new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
});Serializer customisation
The default JsonRedisNearCacheSerializer uses System.Text.Json, with string and byte[] passed through untouched (no double-encoding, which matters for the IDistributedCache adapter). Pass your own JsonSerializerOptions to the constructor, or implement IRedisNearCacheSerializer directly for a non-JSON format.
API reference #
Every public member of RedisNearCache, one entry at a time.
GetAsync<T>method #
Gets the value stored at key, serving from L1 when present and tracked. On a miss, reads the key over RedisNearCache’s tracked interactive connection, which arms server-side tracking for it, stores it in L1 if the key matches KeyPrefixes, and returns it.
default when the key does not exist in Redis.ValueTask<T?> GetAsync<T>(
string key,
CancellationToken cancellationToken = default);SetAsync<T>method #
Writes value to Redis through RedisNearCache’s own connection (armed with NOLOOP so the write does not invalidate itself) and evicts any L1 copy. The next GetAsync<T> re-reads and re-tracks the key. SetAsync does not populate L1 itself; write-through population of L1 is out of scope for v1.
ValueTask SetAsync<T>(
string key,
T value,
TimeSpan? expiry = null,
CancellationToken cancellationToken = default);RemoveAsyncmethod #
Deletes the key in Redis and evicts any L1 copy.
ValueTask<bool> RemoveAsync(
string key,
CancellationToken cancellationToken = default);EvictLocalmethod #
Evicts the L1 copy only. Redis is not touched. Use this to force the next read to re-fetch from Redis without deleting the key.
void EvictLocal(string key);TryGetLocal<T>method #
Returns true and the L1 value when the key is currently cached locally. Never touches Redis, so this never blocks and never counts as a hit or miss against Redis traffic.
true if the key is cached in L1 right now.bool TryGetLocal<T>(string key, out T? value);Statisticsproperty #
Counters for hits, misses, invalidations, flushes, re-arms and race discards. See RedisNearCacheStatistics below and Operations for what to watch.
RedisNearCacheStatistics Statistics { get; }Readyproperty #
Completes once the invalidation subscription is up and the initial arming pass over every master has finished. Masters that could not be armed are retried in the background every 5 seconds; until every master is armed the cache serves every read from Redis and stores nothing locally (pass-through). Faults only if no master could be armed at all, in which case the cache stays in pass-through mode permanently.
Task Ready { get; }RedisNearCacheStatisticsclass #
Six thread-safe, cumulative counters, each backed by Interlocked/Volatile. Sample them periodically and graph the rate, not the raw value.
| Counter | Meaning |
|---|---|
Hits | Reads served from L1 without touching Redis. |
Misses | Reads that went to Redis. |
Invalidations | Per-key invalidation messages received from the server. |
Flushes | Whole-cache flushes (null invalidation, reconnect, re-arm). |
Rearms | Times CLIENT TRACKING was (re)issued on an endpoint after the initial arm. |
RaceDiscards | Redis replies discarded because an invalidation for the key arrived while the read was in flight. |
cache.Statistics.ToString();
// "hits=1 misses=1 invalidations=0 flushes=0 rearms=0 raceDiscards=0"IRedisNearCacheSerializerinterface #
Converts values to and from the bytes stored in Redis. Implement this to plug in a non-JSON format.
byte[] Serialize<T>(T value);
T? Deserialize<T>(ReadOnlyMemory<byte> data);JsonRedisNearCacheSerializerclass #
Default serializer: System.Text.Json, with string and byte[] passed through untouched (no double-encoding). Instance is a shared instance using JsonSerializerOptions.Default; the constructor takes custom JsonSerializerOptions.
public static JsonRedisNearCacheSerializer Instance { get; }
public JsonRedisNearCacheSerializer(JsonSerializerOptions options);AddRedisNearCache(configure)method #
Adds IRedisNearCache as a singleton, together with the private connection, tracking armer and invalidation listener it depends on. configure must set either RedisNearCacheOptions.Configuration or RedisNearCacheOptions.ConnectionString; otherwise resolving IRedisNearCache throws InvalidOperationException.
public static IServiceCollection AddRedisNearCache(
this IServiceCollection services,
Action<RedisNearCacheOptions> configure);AddRedisNearCache(connectionString)method #
Convenience overload that sets RedisNearCacheOptions.ConnectionString. An optional configure callback runs after that, so it can still set every other option.
public static IServiceCollection AddRedisNearCache(
this IServiceCollection services,
string connectionString,
Action<RedisNearCacheOptions>? configure = null);HybridCache and IDistributedCache #
An optional package that adapts IRedisNearCache to the standard distributed-cache abstractions.
dotnet add package RedisNearCache.HybridCacheA separate package from the core library.
services.AddRedisNearCache("localhost:6379");
services.AddRedisNearCacheHybridCache();This registers RedisNearCacheDistributedCache as both IDistributedCache and IBufferDistributedCache, backed by the IRedisNearCache that AddRedisNearCache already registered, and then calls AddHybridCache so that Microsoft.Extensions.Caching.Hybrid.HybridCache uses RedisNearCache as its distributed tier. Use AddRedisNearCacheDistributedCache() alone if you only want the IDistributedCache/IBufferDistributedCache adapters (for session state, output caching, etc.) without HybridCache.
AddRedisNearCacheDistributedCachemethod #
Registers RedisNearCacheDistributedCache as a singleton, exposed as both IDistributedCache and IBufferDistributedCache, backed by the IRedisNearCache that AddRedisNearCache must already have registered. Resolving either interface without a prior call to AddRedisNearCache throws InvalidOperationException when IRedisNearCache itself is resolved, not from this method.
public static IServiceCollection AddRedisNearCacheDistributedCache(
this IServiceCollection services);AddRedisNearCacheHybridCachemethod #
Registers RedisNearCacheDistributedCache (via AddRedisNearCacheDistributedCache) and then calls AddHybridCache. Requires a prior call to AddRedisNearCache.
Why DisableLocalCache. HybridCache keeps its own in-process L1 in front of whatever IDistributedCache it is given, and that L1 knows nothing about Redis invalidations: only RedisNearCache’s own L1 is evicted when the server invalidates a key. Two L1s where only one is coherent would let HybridCache keep serving a value RedisNearCache already evicted. So unless configure overrides it, this sets HybridCacheOptions.DefaultEntryOptions.Flags to HybridCacheEntryFlags.DisableLocalCache. Every HybridCache read then goes to IDistributedCache, i.e. RedisNearCache’s tracked L1, so a hit is still served in-process. Flags merge per call: a HybridCacheEntryOptions passed to GetOrCreateAsync with Flags == null inherits the default flags, so GetOrCreateAsync(key, factory, new HybridCacheEntryOptions { Expiration = ... }) keeps the local cache disabled. If you pass explicit Flags per call, keep DisableLocalCache in them. A small LocalCacheExpiration would not have worked: a per-call Expiration silently becomes the local expiration too, which is how an earlier build of this adapter served stale values for the full entry lifetime.
The factory may run twice before the background write lands. HybridCache writes a freshly computed value to the distributed tier in the background, so a second GetOrCreateAsync issued before that write lands (well under 10 ms locally) may run the factory again. Concurrent callers are still coalesced by HybridCache’s stampede protection. That is an occasional extra factory call, never a stale read.
public static IServiceCollection AddRedisNearCacheHybridCache(
this IServiceCollection services,
Action<HybridCacheOptions>? configure = null);RedisNearCacheDistributedCacheclass #
Adapts IRedisNearCache to IDistributedCache and IBufferDistributedCache. Values are stored as raw byte[]/ReadOnlySequence<byte> through IRedisNearCache.GetAsync<T> and SetAsync<T> instantiated at byte[]; the default IRedisNearCacheSerializer passes byte[] through untouched, so no double-encoding happens. Every sync member (Get, Set, Remove, TryGet) simply calls its async counterpart and blocks with .GetAwaiter().GetResult(); prefer the async members.
Sliding expiration limitation. Redis TTLs, and RedisNearCache’s SetAsync, have no notion of a sliding window. DistributedCacheEntryOptions.SlidingExpiration is mapped to a plain absolute expiry equal to the sliding window, applied once at write time, and is never extended by a later read.
Refresh/RefreshAsync are no-ops for this reason: the Redis TTL set at write time is authoritative until the key expires, is overwritten, or is deleted. Callers that need a true sliding window must re-Set on each access themselves.
// DistributedCacheEntryOptions -> TimeSpan? passed to SetAsync
// 1. AbsoluteExpirationRelativeToNow, if set
// 2. AbsoluteExpiration, converted to a relative duration
// 3. SlidingExpiration, treated as an absolute expiry
// null (never expires) if none of the three is setReconnects and cluster #
Tracking is per-connection state on the server. A reconnect of either connection invalidates it.
Tracking is state Redis holds per connection, so a reconnect of either of RedisNearCache’s own connections (on any node) invalidates that state.
| Event | What Redis does | What RedisNearCache does |
|---|---|---|
| Interactive connection reconnects | Server drops tracking entirely (CLIENT TRACKINGINFO reports flags=off, redirect=-1). | Re-issues CLIENT TRACKING ON REDIRECT on that node, then flushes L1. |
| Subscriber connection reconnects | Server keeps redirecting to the now-dead old client id; invalidations are silently lost. | Looks up the new subscriber client id via CLIENT LIST, re-issues CLIENT TRACKING OFF then ON REDIRECT <new id>, then flushes L1. |
| Cluster topology change (a master added/rediscovered) | Slots may have moved to the new master. | Arms the new master, then flushes L1 (ArmReason.TopologyChanged); masters already armed are left alone. |
| Any connection failed but not yet restored | Nothing can be trusted. | Flushes L1 and serves every read straight from Redis (no caching) until that node is re-armed. |
Every re-arm after the very first one, and every “tracking lost” event, flushes L1 completely: anything invalidated during the gap would otherwise be lost. Statistics.Rearms counts each re-arm issued after the initial pass, and Statistics.Flushes counts every whole-cache flush (a null invalidation/FLUSHDB, a lost connection, or a re-arm). On a cluster, each master node is armed and re-armed independently: losing one node’s connections flushes L1 (nothing can be trusted while any node is unarmed) but only that node is re-armed.
Pass-through mode
Caching is only enabled while every known master is believed armed. The moment any endpoint is lost, the facade stops populating and reading L1 for every key, not only that endpoint’s keys, and flushes what is already there; reads go straight to Redis until that endpoint re-arms. This is what Ready’s doc comment calls pass-through: nothing is stored locally, so nothing can go stale from the gap.
Background retry every 5 seconds
An arm that exhausts its fast retry ladder does not give up: RedisNearCache starts a background loop, at most one per endpoint, that retries the arm every 5 seconds until it succeeds, the endpoint stops being a master, or the cache is disposed. A successful background retry is always reported with ArmReason.Recovered, which flushes L1 the same way any other re-arm does, because reads that were in flight while the node was untracked cannot be trusted.
Topology changes and EndpointRemoved
On a cluster configuration change, RedisNearCache arms any master it has never armed (ArmReason.TopologyChanged) and leaves already-armed masters alone. If a previously known master stops being a master (or drops out of the deployment) and a retry loop is still chasing it, RedisNearCache stops retrying and raises an internal EndpointRemoved event so that a node which will never come back does not keep the whole cache stuck in pass-through forever.
Replicas are ignored
Only masters are armed. A replica is never a valid arm target: CLIENT TRACKING is issued per master node, and RedisNearCache’s internal check for “is this a master we track” excludes replicas outright, whether or not the replica is currently connected.
info: RedisNearCache.Tracking.TrackingArmer[0]
RedisNearCache armed CLIENT TRACKING on Unspecified/localhost:6379 redirecting to client 3562 (Initial)See Operations for the full set of log categories and what to watch.
Operations #
Logging categories, statistics to graph, detecting a lost endpoint, and sizing L1.
RedisNearCache does not register logging itself; if the host never called AddLogging, it silently uses a no-op logger. Wire up ILoggerFactory in your host to see any of this.
Logging categories
| Category | What it logs |
|---|---|
RedisNearCache.Internal.RedisNearCacheConnection | One Information line on connect: the client name and endpoints of the private multiplexer. |
RedisNearCache.Tracking.TrackingArmer | The most operationally important category. Information for every successful arm/re-arm (with the endpoint, redirect client id, and ArmReason) and for ConnectionRestored/topology-change events triggering a re-arm. Warning for a retry attempt, for “no connected master to arm”, for giving up after the retry ladder is exhausted, and for a lost connection (ConnectionFailed). Debug/Trace for verification detail (CLIENT TRACKINGINFO replies, skipped replicas). |
RedisNearCache.Tracking.InvalidationListener | Information once, on subscribing. Debug for a null (FLUSHDB/FLUSHALL) invalidation. Trace for every single-key invalidation (high-volume; do not run at Trace in production unless actively diagnosing). Error if handling a message throws (should not happen). |
RedisNearCache.Caching.RedisNearCache | Error if the initial StartAsync (listener + armer) fails entirely, i.e. the near cache will never be coherent for at least one endpoint. |
What to watch in production. Warning-level messages from TrackingArmer are the signal that something is wrong: a node that could not be armed after retries, or a connection that dropped and has not come back. A steady stream of Information-level “re-arming” messages (rather than occasional ones around deploys/restarts) suggests something is repeatedly killing the private connections.
Statistics to graph
IRedisNearCache.Statistics exposes six thread-safe counters. Sample them periodically (they are cumulative, so graph the rate/delta, not the raw value) rather than trying to log every change.
| Counter | What a change means | What to watch for |
|---|---|---|
Hits | A read served entirely from L1, no Redis call. | The primary payoff metric. Compare against Misses for a hit ratio. |
Misses | A read that went to Redis (first read of a key, or after eviction/expiry). | A sudden sustained rise (with a proportional Flushes/Rearms rise) usually means something is invalidating or flushing more than expected. |
Invalidations | A per-key invalidation message received from the server. | Should track your actual external write volume against tracked keys. Unexpectedly high counts on a specific key are the signal to move it out of KeyPrefixes. |
Flushes | A whole-cache L1 clear: a null invalidation, a lost connection, or any re-arm after the first. | Should be rare in steady state. A rising rate means reconnects are happening repeatedly; check TrackingArmer warnings for why. |
Rearms | CLIENT TRACKING was (re)issued on an endpoint after its initial arm. | Same signal as Flushes (every re-arm causes a flush) but isolates the reconnect-driven case from FLUSHDB/FLUSHALL. |
RaceDiscards | A Redis reply was thrown away because an invalidation for that key arrived while the read was still in flight. | Expected to be occasionally non-zero under concurrent read/write load on the same key; a value comparable to Misses suggests either very hot keys or unusually high latency to Redis. |
Detecting a lost or unarmed endpoint
There is no dedicated “is everything armed” property, but you can reconstruct the current state from the pieces the library already exposes.
- Logs.
TrackingArmerlogsWarningforConnectionFailedand for giving up on the retry ladder. Alert on these. - Statistics. A rising
Flushes/Rearmsrate, orHitsdropping to near zero whileMissesstays high, both indicate pass-through mode on at least one endpoint. Ready. Awaitingcache.Readyonly tells you whether at least one master armed during startup; it does not fault for a single node that failed to arm among several, and it does not report a later loss. Treat a completedReadyas “the cache is usable”, not “every node is armed”.- Direct check against Redis.
CLIENT LISTfiltered to RedisNearCache’s client name (ClientNamePrefix, defaultrnc-<guid>) and theP(pub/sub subscriber) flag tells you whether the subscriber connection currently exists;CLIENT TRACKINGINFOon the interactive connection reports whether tracking is on and what it currently redirects to.
Sizing L1
L1SizeLimit (default 10_000) counts entries, not bytes: every stored value costs a size of 1 regardless of how large it is. There is no automatic memory-based eviction, so size the limit based on (expected working-set key count) rather than a memory budget, and separately estimate memory as (number of entries you expect resident) × (average serialized value size). If your key space includes large or infrequently-reused values you would rather not hold in-process at all, use KeyPrefixes to opt only the keys worth caching into L1.
L1MaxAge (default 5 minutes) is a time-based safety net independent of size: it bounds how long a value can survive after a missed invalidation, not a sizing lever by itself. Setting it very high increases the blast radius of anything that does get missed; setting it very low pushes more traffic back to Redis even when tracking is working correctly.
redis-cli CLIENT LIST | grep 'name=rnc-'Limitations #
- RESP3 push tracking is not used. StackExchange.Redis 3.x collapses RESP3 to a single connection and swallows the invalidation push frames on it, so RedisNearCache always forces RESP2 for its own connection.
- No
OPTIN/OPTOUTtracking mode.CLIENT CACHING YESmust be sent immediately adjacent to the next command on the wire, which is not guaranteed on a multiplexed connection. Selection of what gets cached is done in the library instead, viaKeyPrefixes. - Garnet is not supported. Garnet does not implement
CLIENT TRACKING. - TTL expiry is only reflected in L1 once Redis actually expires the key. Redis’s active expiry cycle, not the TTL deadline itself, is what triggers the invalidation push; there can be a gap between a key’s TTL elapsing and Redis noticing and sending the invalidation.
L1MaxAgeis the safety net for this and for any other missed invalidation. - Per-write invalidation cost on hot keys. Every write to a tracked key causes Redis to push an invalidation to every reader that had it tracked; a very hot key can mean a lot of pushes. Use
KeyPrefixesto opt only the keys you actually want cached into L1, so writes to everything else cost nothing extra. Readyonly faults if no master could be armed at all. If some masters armed and others did not,Readycompletes, but the cache stays in pass-through until every master is armed. Unarmed masters are retried in the background every 5 seconds. WatchStatistics.Hits: it stays flat while the cache is in pass-through.- Sentinel, ElastiCache, and Azure Managed Redis are not yet tested. The tests in this repository run against a plain standalone Redis 7.4 container and a 3-master Redis Cluster started via
cluster-up.sh. Nothing in the design specifically depends on the absence of Sentinel or a managed offering, but no test exercises those configurations, so this is genuinely untested rather than a documented guarantee.
Performance #
Quoted from bench/RedisNearCache.Bench/README.md; see that file for full methodology.
Both modes need a Redis server reachable at localhost:6379 (the repo’s docker compose container).
Zero-traffic demo
--demo seeds a key, reads it once through the near cache (a miss), then reads it 1,000 more times and checks the server’s cmdstat_get counter delta.
1000 reads of 'demo:near-cache:zero-traffic' through the near cache (all should be L1 hits):
cmdstat_get calls before : 263325
cmdstat_get calls after : 263325
delta (Redis GETs issued): 0 (expected 0)
Write-to-eviction latency : 1688.0 µs
Statistics: hits=1000 misses=1 invalidations=1 flushes=0 rearms=0 raceDiscards=0All 1,000 repeated reads were served from L1 with zero Redis traffic; the one external write (from a second, separate multiplexer standing in for another client) evicted the local copy in 1688.0 µs, which is the invalidation push’s round trip: write, Redis, __redis__:invalidate, subscriber, L1 evicted.
BenchmarkDotNet suite
Run on an Apple M4 Pro against a local single-node Redis container (Toolchain=InProcessEmitToolchain, RunStrategy=Monitoring, 10 iterations).
| Method | Kind | Mean | Allocated |
|---|---|---|---|
Plain_StringGet | String | 173.717 µs | 1360 B |
NearCache_Hit | String | 1.604 µs | 2144 B |
NearCache_Miss | String | 198.658 µs | 9088 B |
NearCache_TryGetLocal | String | 1.325 µs | 2072 B |
Plain_StringGet | Json | 276.979 µs | 5456 B |
NearCache_Hit | Json | 3.967 µs | 424 B |
NearCache_Miss | Json | 280.596 µs | 6472 B |
NearCache_TryGetLocal | Json | 2.875 µs | 352 B |
NearCache_Hit and NearCache_TryGetLocal are roughly 100x faster than a plain Redis round trip in this run, because they never leave the process. NearCache_Miss lands close to (and, for the string case, a little above) Plain_StringGet, since both do a real Redis round trip; the near cache’s overhead on the miss path is locking, in-flight bookkeeping, and deserialization. RunStrategy.Monitoring with only 10 iterations means run-to-run variance is higher than a long Throughput run would show; this is a deliberate trade-off to keep the whole suite finishing in well under a minute.
Load test
--load is a production-shaped run: many application instances, each with its own private multiplexer (two tracked connections), many concurrent readers per instance, and foreign writers whose every SET fans out an invalidation to every instance that has the key tracked. Ends with a staleness audit of every instance’s L1 against Redis. Run on an Apple M4 Pro against the repo’s local Redis 7.4 container, 2026-09-13.
Profile: 20 instances (41 server connections), 8 readers per instance reading as fast as they can, 4 writers at 2,000 foreign writes/s, 10,000 keys of 512 bytes with 80% of traffic on 500 hot keys, 30 s.
dotnet run -c Release --project bench/RedisNearCache.Bench -- --load # near cache
dotnet run -c Release --project bench/RedisNearCache.Bench -- --load --baseline # plain StackExchange.Redis
dotnet run -c Release --project bench/RedisNearCache.Bench -- --load --chaos # kills a quarter of connections at T/2
# knobs: --instances 20 --readers 8 --writers 4 --writes-per-sec 2000 --keys 10000
# --hot-keys 500 --hot-fraction 0.8 --seconds 30 --value-bytes 512The three run modes.
| Near cache | Plain StackExchange.Redis | Near cache + chaos | |
|---|---|---|---|
| Reads | 41.1 M (1,369,673/s) | 5.7 M (190,756/s) | 42.3 M (1,407,049/s) |
| Hit ratio | 95.8 % | n/a | 95.5 % |
| Hit latency p50 / p99 / p999 | 0.8 / 24.8 / 4,089 µs | n/a | 0.8 / 29.8 / 3,408 µs |
| Miss latency p50 / p99 / p999 | 1,643 / 12,210 / 36,459 µs | 793 / 1,643 / 2,366 µs (all reads) | 1,370 / 8,479 / 25,319 µs |
| Foreign writes | 60,040 (2,000/s) | 60,035 (2,000/s) | 60,120 (2,000/s) |
| Server GET commands | 1.73 M (57,582/s) | 5.72 M (190,756/s) | 1.90 M (63,316/s) |
| Server total commands | 1.79 M (59,585/s) | 5.78 M (192,758/s) | 1.96 M (65,323/s) |
| Server network output | 922 MB | 2,839 MB | 994 MB |
| Invalidations received | 1.07 M (35,532/s, 17.8 per write) | n/a | 0.83 M (27,579/s, 13.8 per write) |
| Race discards | 4,393 | n/a | 3,879 |
| Flushes / re-arms | 0 / 0 | n/a | 20 / 10 |
| Tracking keys on server | 10,025 | n/a | 10,051 |
| L1 entries audited | 184,246 | n/a | 134,963 |
| Stale L1 entries after quiescence | 0 | n/a | 0 |
What the numbers say:
- With the same 20 clients, the near cache served 7.2x the reads while sending the server 3.2x fewer commands and 3x less network output. The server’s
GETrate is the miss rate, 4% of reads. - Every foreign write reached, on average, 14 to 18 of the 20 instances as an invalidation (the 500 hot keys are tracked by almost everyone). 35k invalidations/s were absorbed with zero stale entries in the audit.
- The chaos run killed both connections of 5 instances at T/2. Each raised
TrackingLostfor both connections, reconnected, and was re-armed (InteractiveRestoredandSubscriptionRestored) within about 120 ms; the sampled hit ratio dipped by under a point and recovered in the next 5 s window. - Miss latency in the near-cache runs is worse than the baseline’s read latency (p99 12 ms vs 1.6 ms). That is a load-generator artefact: 160 readers spin at 1.4 M reads/s on a 14-core machine, so the continuation of a network read waits for a thread-pool slot. A service that does real work between reads would not see this. The hit path is unaffected (p50 0.8 µs).
- Race discards ran at about 1 per 10,000 reads. Each costs one extra round trip on the next read; none cost correctness.
- Observed once in five near-cache runs and not reproduced: the server closed all 40 tracked sockets at once (
SocketClosedon both connection types of every instance, noCLIENT KILLissued). Every instance raisedTrackingLost, reconnected and re-armed, and the audit still found 0 stale entries.
Samples #
Two runnable samples that show IRedisNearCache invalidated by something outside the process.
Both connect to localhost:6379. Start the standalone Redis container first, from the repo root.
docker compose up -dBring up Redis.
MinimalApi
An ASP.NET Core minimal API (samples/MinimalApi) with:
GET /products/{id}— reads throughIRedisNearCache.GetAsync<Product>("product:{id}"); on a miss it falls back to an in-memoryProductRepository(a fake data store with a simulated 50 ms lookup delay) and thenSetAsyncs the result back into Redis.PUT /products/{id}— writes aProductviaSetAsync.GET /stats— returnsIRedisNearCache.Statisticsas JSON.GET /hybrid/{id}— the same read, throughMicrosoft.Extensions.Caching.Hybrid.HybridCache(backed by RedisNearCache viaAddRedisNearCacheHybridCache()).
dotnet run --project samples/MinimalApi --urls http://localhost:5199Run it on a free port.
curl http://localhost:5199/products/1
curl http://localhost:5199/products/1
curl http://localhost:5199/stats
docker exec redis-near-cache-redis redis-cli SET product:1 '{"id":1,"name":"changed"}'
curl http://localhost:5199/products/1
curl http://localhost:5199/statsFrom a second terminal, curl it a couple of times, then invalidate the key from outside the app (a plain redis-cli SET, nothing that goes through this process) and curl again. The third call reflects the change because the server pushed an invalidation over CLIENT TRACKING and evicted the app’s L1 entry.
=== GET /products/1 (1st) ===
{"id":1,"name":"Widget"}
HTTP 200
=== GET /products/1 (2nd) ===
{"id":1,"name":"Widget"}
HTTP 200
=== GET /stats ===
{"hits":1,"misses":1,"invalidations":0,"flushes":0,"rearms":0,"raceDiscards":0}
HTTP 200
=== external write ===
docker exec redis-near-cache-redis redis-cli SET product:1 '{"id":1,"name":"changed"}'
OK
=== GET /products/1 (3rd, after external write) ===
{"id":1,"name":"changed"}
HTTP 200
=== GET /stats (final) ===
{"hits":1,"misses":2,"invalidations":1,"flushes":0,"rearms":0,"raceDiscards":0}
HTTP 200The 2nd call is an L1 hit (hits goes from 0 to 1, misses stays at 1). After the external redis-cli SET, the 3rd call already returns "changed": invalidations goes from 0 to 1 (the server pushed one), and the GET after it counts as a miss again because the near cache had to go back to Redis to re-populate L1 for that key. Note: because SetAsync (used by PUT /products/{id} and by the repository-fallback path on GET /products/{id}) evicts L1 rather than populating it, the read immediately after a write is always a miss; the next read after that is what gets served from L1.
Worker
A BackgroundService (samples/Worker) that polls the key config:feature-flags through the near cache every 500 ms and logs whether that tick was an L1 hit or a Redis miss, plus a full Statistics snapshot every 5 seconds.
dotnet run --project samples/WorkerRun it.
docker exec redis-near-cache-redis redis-cli SET config:feature-flags '{"beta":true}'From a second terminal, change the watched key at any point and watch the very next tick pick it up.
info: RedisNearCache.Tracking.TrackingArmer[0]
RedisNearCache armed CLIENT TRACKING on Unspecified/localhost:6379 redirecting to client 3562 (Initial)
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
MISS config:feature-flags = <null> (cumulative hits=0 misses=1)
... (17 more MISS ticks while the key does not exist yet) ...
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
Statistics: hits=0 misses=11 invalidations=0 flushes=0 rearms=0 raceDiscards=0
... (redis-cli SET config:feature-flags '{"beta":true}' run here) ...
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
MISS config:feature-flags = {"beta":true} (cumulative hits=0 misses=19)
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
hit config:feature-flags = {"beta":true} (cumulative hits=1 misses=19)
info: RedisNearCache.Samples.Worker.ConfigPollingWorker[0]
Statistics: hits=3 misses=19 invalidations=1 flushes=0 rearms=0 raceDiscards=0config:feature-flags did not exist yet, so every tick is a miss (RedisNearCache never stores a null value in L1, so a missing key stays a miss on every poll) until the external redis-cli SET partway through. The first tick after the SET (misses=19) is still a miss, the read that goes to Redis and populates L1, but it already returns the new value. Every tick after that is a hit until the key is written again from outside.
FAQ #
Do my writers need to change? #
No. Any client, in any language, doing a plain SET, DEL, MSET, or a redis-cli command by hand causes Redis itself to push an invalidation to every connection tracking that key. RedisNearCache does not require writers to publish anything or use the same library.
Can I use my existing IConnectionMultiplexer? #
Your existing multiplexer is never touched, reconfigured, or depended on. RedisNearCache opens its own private multiplexer (cloned connection settings, forced to RESP2 with admin mode). Your own connection can stay on whatever protocol and configuration it already uses.
Why does RedisNearCache need its own connection instead of using mine? #
Two reasons: CLIENT TRACKING and CLIENT LIST require AllowAdmin=true, which most applications do not (and should not) set on their main connection; and every read issued on a tracked connection is tracked by Redis, whether or not it is something you actually want cached. Giving RedisNearCache its own connection means only cache reads are tracked, and your main connection’s admin surface is untouched.
Why RESP2 instead of RESP3? #
StackExchange.Redis 3.x is RESP3 by default, but under RESP3 the library collapses to a single connection per node and swallows the invalidate push frames internally. The spike measured this directly: the server reports tracking as on, but zero invalidation messages ever reach the application. RESP2 is the only protocol under which StackExchange.Redis opens a separate subscriber connection, which is what RedisNearCache uses as the REDIRECT target.
Why does it need admin mode (AllowAdmin=true)? #
CLIENT TRACKING ON/OFF, CLIENT TRACKINGINFO, and CLIENT LIST are all gated behind admin mode in StackExchange.Redis; without it they throw RedisCommandException: not available unless admin mode is enabled. This only applies to RedisNearCache’s own private connection, not yours.
What if Redis restarts, or my connection drops? #
RedisNearCache re-arms automatically. An interactive-connection reconnect means the server dropped tracking entirely; a subscriber-connection reconnect means the server is still redirecting to a now-dead client id and would otherwise silently lose every invalidation. Both cases are handled: RedisNearCache re-issues CLIENT TRACKING (finding the new subscriber id via CLIENT LIST when needed) and flushes L1. While any node is down and not yet re-armed, every read goes straight to Redis and nothing is cached (pass-through), so the cache can never serve something stale from that window. This is exercised directly by the integration tests, including killing both connections back to back (Chaos/BothConnectionsKilledTests) and restarting a whole cluster node (Chaos/NodeRestartClusterTests).
What if Redis crashes and does not come back? #
IRedisNearCache.Ready only faults if no master could be armed at all after the retry ladder is exhausted; in that case the cache stays permanently in pass-through rather than throwing on every call. If Redis is unreachable, the reads themselves will fail the same way they would through a plain StackExchange.Redis connection. RedisNearCache does not add its own resilience layer on top of that.
Does it work with Redis Cluster? #
Yes. Tracking is per node, so RedisNearCache’s armer repeats the arm/re-arm dance independently on every connected master, using that node’s own subscriber connection as the redirect target. A lost or restarted node re-arms on its own; the other nodes are unaffected. This is covered by tests/RedisNearCache.Tests/ClusterPerNodeInvalidationTests.cs, ClusterKillOneNodeInteractiveRearmsTests.cs, and Chaos/NodeRestartClusterTests.cs.
Does it work with Sentinel, ElastiCache, or Azure Managed Redis? #
Not yet tested. The tests in this repository run against a plain standalone Redis 7.4 container and a 3-master Redis Cluster started via cluster-up.sh. Nothing in the design specifically depends on the absence of Sentinel or a managed offering (it works through StackExchange.Redis’s own topology handling and per-master CLIENT TRACKING), but no test exercises those configurations, so this is genuinely untested rather than a documented guarantee.
How much memory does L1 use? #
L1 is a Microsoft.Extensions.Caching.Memory.MemoryCache where every entry costs a size of 1 against RedisNearCacheOptions.L1SizeLimit (default 10_000). There is no accounting for the byte size of values, so memory use is roughly (number of cached entries) × (average serialized value size), bounded above by L1SizeLimit entries. Use KeyPrefixes to limit which keys are eligible for L1 at all if you have large or very numerous values you do not want held locally.
What happens to a key's TTL? #
RedisNearCache does not track expiry deadlines itself. When Redis’s own active expiry cycle actually removes an expired key, that is a normal deletion from Redis’s point of view and it is invalidated like any other write. Because active expiry can lag the TTL deadline, there can be a small window where L1 still holds a value whose TTL has technically passed. L1MaxAge (default 5 minutes) is the safety net for this and for any other missed invalidation.
Can I invalidate a key without touching Redis? #
Yes, EvictLocal(key) removes only the local L1 copy without going to Redis. This is separate from RemoveAsync, which deletes the key in Redis too.
Does SetAsync populate L1 immediately? #
No. SetAsync writes to Redis and evicts any existing L1 copy of the key; the next GetAsync<T> re-reads it from Redis and re-tracks it. Write-through population of L1 is explicitly out of scope for v1.