Skip to main content

Drop-In Cache for ASP.NET Core: IDistributedCache and HybridCache on Zaris

· 10 min read
Clustron Team
Distributed Systems Engineering

Drop-in distributed cache for ASP.NET Core on Zaris

Most of the reasons to reach for a distributed cache in a .NET app never mention a cache by name. You add output caching and it wants an IDistributedCache. You enable sessions and the middleware wants a backing store. You turn on data-protection key sharing across instances and, again, it wants a place to put bytes that every node can see. The application code is written against an abstraction; the question is only which implementation you register at startup.

Zaris answers that question with three small NuGet packages, each targeting a specific ASP.NET Core seam: a plain IDistributedCache, a two-tier HybridCache, and a session store. You register one line at startup and the rest of your app — including third-party libraries that only know the abstraction — caches on a Zaris cluster without a single change to the code that uses the cache. This post walks through all three, and is deliberate about where each one honestly stops.

The three packages​

PackageImplementsUse it for
Clustron.Zaris.DistributedCacheIDistributedCacheAny library or code that asks for the standard distributed-cache abstraction
Clustron.Zaris.HybridCacheHybridCache (from Microsoft.Extensions.Caching.Hybrid)A hot path that wants an in-process L1 in front of the shared cache, with stampede protection
Clustron.Zaris.AspNetCore.SessionISessionStoreASP.NET Core sessions with a sliding idle timeout

All three sit on the same Zaris .NET client. They don't invent a side channel — they resolve a normal Zaris client from a connection string and translate Get/Set/Remove into the client's GetAsync/PutAsync/DeleteAsync. If you've read One Connection String, Any Platform, the wiring will look familiar: the cache is just another consumer of a named store.

1. The standard IDistributedCache​

This is the drop-in. IDistributedCache is the interface that Microsoft.Extensions.Caching.Abstractions has exposed for years, and a large amount of the .NET ecosystem — session, output caching, auth-ticket stores, third-party libraries — depends on exactly that interface and nothing more. Register Zaris as the implementation and all of it is backed by your cluster.

builder.Services.AddClustronZaris("app-cache", "zaris://cache-host:7861/app-cache");

builder.Services.AddClustronDistributedCache(
storeName: "app-cache",
configure: o => o.KeyPrefix = "myapp:");

The first line registers a Zaris client under the DI key app-cache; the second registers an IDistributedCache that uses it. From here, anything that takes an IDistributedCache — including code you didn't write — works:

public sealed class WeatherController(IDistributedCache cache)
{
public async Task<Forecast> Get(string city)
{
var cached = await cache.GetAsync($"forecast:{city}");
if (cached is not null)
return Deserialize(cached);

var fresh = await FetchForecastAsync(city);
await cache.SetAsync(
$"forecast:{city}",
Serialize(fresh),
new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10)
});
return fresh;
}
}

Under the hood, GetAsync is a client.GetAsync<byte[]> with the configured prefix applied, and a cache miss (the key doesn't exist) returns null — exactly the contract the interface promises. SetAsync with AbsoluteExpirationRelativeToNow maps that window straight onto a Zaris TTL, so the store expires the entry for you.

Two honest edges​

Only the relative absolute expiration is mapped. The Zaris IDistributedCache reads AbsoluteExpirationRelativeToNow and turns it into a TTL. If you set no expiration, the entry is written without a TTL and lives until evicted or overwritten. SlidingExpiration on this general-purpose cache is not honoured — its Refresh/RefreshAsync is a no-op. That's a deliberate scoping choice, not an oversight: sliding expiration is a session concern, and the session package below implements it properly rather than half-implementing it here.

You can register more than one, and the first wins by default. Each call to AddClustronDistributedCache registers a named cache instance bound to a specific store. The first one registered becomes the app-wide IDistributedCache; pass setAsDefault: true to make a later registration the default instead. If you want several caches on different stores, resolve them explicitly through IClustronCacheProvider.GetCache("other-store") rather than relying on the ambient default.

2. HybridCache — an in-process tier in front of the cluster​

.NET's newer HybridCache abstraction solves a problem IDistributedCache leaves to you: the combination of a fast local (L1) cache and a shared distributed (L2) cache, plus stampede protection — making sure that when a popular key expires, a thundering herd of concurrent requests doesn't all run the expensive factory at once.

Zaris implements the abstract HybridCache class directly. The twist is that both tiers are Zaris stores you name — you point L1 at an in-process embedded store (no network, no serialization hop off-box) and L2 at the distributed cluster:

builder.Services.AddClustronZaris("l1", "zaris://inproc/l1");          // embedded, in-process
builder.Services.AddClustronZaris("l2", "zaris://cache-host:7861/l2"); // the cluster

builder.Services.AddClustronHybridCache(l1Store: "l1", l2Store: "l2");

The read path is the classic two-tier lookup, and it's worth seeing in full because every guarantee below falls out of it:

var product = await cache.GetOrCreateAsync(
$"product:{id}",
state: id,
factory: async (pid, ct) => await db.LoadProductAsync(pid, ct),
options: new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10), // L2 TTL
LocalCacheExpiration = TimeSpan.FromMinutes(1) // L1 TTL (shorter)
},
tags: new[] { "catalog" });

L1 is checked first with no lock. On a miss, the caller takes a per-key lock, re-checks L1 (in case another caller just filled it), then L2; only if both miss does it run the factory, write L2, then write L1. The TTLs come from HybridCacheEntryOptions — Expiration for L2, LocalCacheExpiration for L1 — and the implementation clamps L1 to be no longer than L2, so you can never have a local copy outlive the shared one. Omit the options and both default to five minutes.

Stampede protection, measured​

The per-key lock is the stampede protection. The package's own test fires twenty concurrent GetOrCreateAsync calls at one cold key, each with a factory that sleeps 100 ms, and asserts the factory ran exactly once — nineteen callers wait on the lock, and the one that filled the cache serves them all. That turns a potential twenty-way hammer on your database into a single load.

The honest edge: this lock lives in a ConcurrentDictionary<string, SemaphoreSlim> inside the process. It collapses the stampede within one app instance. With ten app instances all cold on the same key at the same instant, you can still see up to ten factory runs, one per process — not one cluster-wide. That's the normal shape of client-side stampede protection, and for the common case (a handful of instances, a factory that's expensive but not catastrophic to run a few times) it's exactly what you want. If you need strictly one load across the whole fleet, that's a distributed-lock problem, not a cache feature — and a job better modelled with the compare-and-swap claim pattern from Race-Free Coordination.

Tag invalidation, and why it's lazy​

HybridCache lets you tag entries and invalidate a whole group at once:

await cache.RemoveByTagAsync("catalog");   // every entry tagged "catalog" is now stale

Zaris implements this with timestamps rather than an active sweep. Each entry is written with two metadata labels: the time it was created, and its tags. RemoveByTagAsync("catalog") simply writes a key recording "catalog was invalidated at time T." On the next read of a tagged entry, the cache compares: if any of the entry's tags was invalidated after the entry was created, the entry is treated as a miss and the factory re-runs. The test confirms it — tag an entry, invalidate the tag, read again, and the factory is called a second time.

The design trade-off to know: invalidation is lazy and read-triggered, not an eager purge. Calling RemoveByTagAsync is O(1) — it writes one marker and returns — but the stale entries themselves stay resident in the store until something reads them (and gets a fresh value) or their TTL expires. You get cheap, instant logical invalidation; you don't get an immediate reclamation of the memory those entries hold. For cache data that's exactly the right bargain. Just don't lean on it as a way to force-delete data for correctness reasons — for that, RemoveAsync(key) deletes both tiers outright.

3. Sessions, with real sliding expiration​

ASP.NET Core sessions need a store that does something the general cache deliberately doesn't: slide the expiration on every request that touches the session, so an active user's session never times out from under them while an idle one eventually does. The session package is a purpose-built IDistributedCache that honours exactly those semantics, wired in with one call:

builder.Services.AddZarisSession(o =>
{
o.StoreName = "sessions";
o.ConnectionString = "zaris://cache-host:7861/sessions";
o.IdleTimeout = TimeSpan.FromMinutes(30);
o.CookieName = ".MyApp.Session";
});

// ... later in the pipeline, as usual:
app.UseSession();

Give it a connection string and it registers the backing Zaris store for you; omit it and it binds to a store you've already registered under StoreName. Either way you then use HttpContext.Session exactly as you always have.

The piece that matters is Refresh. When the session middleware loads a session that was read but not modified, it calls RefreshAsync to keep it alive. The Zaris session cache implements that as a read-then-re-put with a fresh TTL equal to the idle timeout — so every request that touches the session slides the 30-minute window forward. A write (SetAsync) resolves its TTL from the entry's sliding or absolute expiration, falling back to the configured idle timeout. The result is standard ASP.NET Core session behaviour, backed by a Zaris cluster, surviving an app-instance restart because the session data lives in the store and not in the instance's memory.

A deliberate implementation note from the code itself: Refresh is a read-plus-re-put rather than a native "just reset the TTL" call. That costs one extra round-trip, but it keeps sliding correct on every Zaris deployment — including the embedded in-process one — without depending on a server-side TTL-reset primitive. Correctness across every topology beat shaving a round-trip off one of them.

Picking the right one​

  • Reach for IDistributedCache when something already asks for it, or when you want the simplest shared cache and a library is going to consume it for you. It's the maximum-compatibility seam.
  • Reach for HybridCache on a genuinely hot read path where an in-process L1 earns its keep and a stampede on a cold popular key would hurt. You pay for an extra moving part (two stores, local memory for L1); you get near-zero-latency repeat reads and a herd collapsed to one load per instance.
  • Reach for AddZarisSession for ASP.NET Core sessions specifically — it's the one with true sliding expiration, and it stays out of the way of your app's own IDistributedCache.

None of the three asks you to learn a cache-specific API. They meet the framework where it already defines the abstraction, register in a line, and put the bytes on a cluster you can scale, secure, and run on Kubernetes independently of the app in front of it. The best integration is the one your application code never has to notice — and, pleasingly often, that's exactly the one you get.