---
title: "Orleans.Lattice.Caching.AzureBlob architecture"
url: "https://nsta1.github.io/Orleans.Lattice/docs/lattice.caching.azureblob/architecture.html"
source: "https://github.com/NSTA1/Orleans.Lattice/blob/release/9.9/docs/lattice.caching.azureblob/architecture.md"
package: "Orleans.Lattice.Caching.AzureBlob"
version: "9.9.0"
documents: "Orleans.Lattice 9.9.0 (release line 9.9)"
built: "2026-10-04"
all-pages: "https://nsta1.github.io/Orleans.Lattice/llms.txt"
bundle: "https://nsta1.github.io/Orleans.Lattice/docs/lattice.caching.azureblob/llms-full.txt"
---
# Orleans.Lattice.Caching.AzureBlob architecture

Part of the [Caching.AzureBlob documentation](README.md).

The package is a single internal `IDistributedCache` implementation, blob-backed, plus two internal helpers that keep the blob-name and expiry logic pure and testable.

## Blob layout

Each cache entry is one **block blob** in the configured container:

- **Blob name** is `{KeyPrefix}{hash}`, where `hash` is the lowercase hex SHA-256 of the UTF-8 cache key, computed by an internal hashed-key mapper. Hashing guarantees a fixed-length, storage-legal name for any caller-supplied key (Microsoft.Identity.Web keys contain characters that are not valid in a blob name), and the optional `KeyPrefix` acts as a virtual directory so several logical caches can share one container.
- **Blob content** is the cached value verbatim (`byte[]`). Values are held whole in memory during a read or write, so the cache targets small entries - tokens and session state, not large blobs.
- **Blob metadata** carries the expiry: the absolute expiration cap, the sliding window, and the current effective expiry instant. Keeping expiry in metadata means a read fetches content and expiry in one download, and a sliding renewal is a cheap metadata-only `SetMetadata` call.

## Expiry protocol

An internal expiry helper is pure and `TimeProvider`-driven - no I/O - so every expiry decision is unit-testable against an injected clock:

- **Computing** turns a `DistributedCacheEntryOptions` (absolute, absolute-relative-to-now, or sliding) plus the current instant into the stored expiry values, capping an initial sliding expiry at the absolute expiration. `AbsoluteExpirationRelativeToNow` takes precedence over `AbsoluteExpiration`; when only the latter is set and it is not in the future, it is rejected with `ArgumentOutOfRangeException`, so `Set` fails rather than writing an already-expired entry.
- **Metadata round-trip** writes only the populated values into the blob's metadata dictionary and reads them back; a missing or unparsable value reads back as absent, so a hand-edited or partially written blob degrades to never expiring rather than failing.
- **Expiry test** treats an entry as expired once the current instant reaches its effective expiry; an entry with no effective expiry never expires on its own.
- **Sliding** recomputes the effective expiry on each read as the read instant plus the sliding window, capped at the absolute expiration, and rewrites nothing when the entry is not sliding, carries no stored effective expiry, or the slide would not move it forward.

Enforcement is **lazy on read**. `Get` downloads the entry (content and expiry metadata in one call) and `Refresh` reads only its properties; if the entry is expired, either best-effort deletes it and reports a miss, and otherwise a sliding entry has its effective expiry advanced. There is no background sweeper, so an entry written and never read again lingers until overwritten or removed. That is acceptable for the low-churn, per-subject workloads (a token cache) this backend targets, and it keeps the implementation free of a timer or lease.

## Container lifecycle

The container is created on first use behind a one-shot async gate: the first operation calls `CreateIfNotExists` under a `SemaphoreSlim`, flips a ready flag, and every subsequent operation skips straight through. Hosts therefore never provision the container out of band, and the create cost is paid once per process.

## Concurrency and failure semantics

- **Writes** are last-writer-wins blob uploads; there is no read-modify-write race because `Set` replaces the whole blob.
- **Sliding renewals and expired-entry deletes are best-effort and conditional.** Each is sent with `If-Match` on the ETag of the version the read observed, so a concurrent `Set` that replaced the blob in between wins: the renewal cannot stamp the old entry's expiry onto the new value, and the eviction cannot delete the fresh entry. The resulting `412`, like any `RequestFailedException` from a concurrent delete, is swallowed: a lost slide only shortens a window (never corrupts the value), and a failed delete is harmless because the entry already read as a miss.
- **404s are misses.** A missing blob on `Get`/`Refresh`/`Remove` is treated as an absent entry, not an error.

## How it attaches

`AddAzureBlobDistributedCache` registers the cache as the last `IDistributedCache` singleton, building the container client from the validated options and resolving a `TimeProvider` (or `TimeProvider.System`). Consumers - the Explorer's Microsoft.Identity.Web distributed token cache, ASP.NET session state, output caching - resolve `IDistributedCache` and transparently use the blob backend.

## See also

- [`Orleans.Lattice.Explorer.Entra.Web`](../lattice.explorer.entra.web/architecture.md) - the primary consumer, whose distributed token cache this backs on a multi-replica host.
