Skip to content

DistributedCacheExtensions Class

Extension methods for Microsoft.Extensions.Caching.Distributed.IDistributedCache providing atomic operations.

C#
public static class DistributedCacheExtensions

Inheritance System.Object → DistributedCacheExtensions

Methods

DistributedCacheExtensions.TryAddAsync(this IDistributedCache, string, TimeSpan, CancellationToken) Method

Marks a key as present unless it already is, telling a first call from a repeat: the add-if-absent primitive replay caches and other "seen before?" checks are built on.

C#
public static System.Threading.Tasks.Task<bool> TryAddAsync(this Microsoft.Extensions.Caching.Distributed.IDistributedCache cache, string key, System.TimeSpan timeToLive, System.Threading.CancellationToken cancellationToken=default(System.Threading.CancellationToken));

Parameters

cache Microsoft.Extensions.Caching.Distributed.IDistributedCache

The distributed cache instance.

key System.String

The key whose first sighting is being recorded.

timeToLive System.TimeSpan

How long the sighting is remembered.

cancellationToken System.Threading.CancellationToken

Optional cancellation token to cancel the operation.

Returns

System.Threading.Tasks.Task<System.Boolean>
A task containing true when the key was absent and is now marked; false when it was already present.

Remarks

Not atomic:Microsoft.Extensions.Caching.Distributed.IDistributedCache exposes only Get + Set, no compare-and-set primitive, so two concurrent callers of the same key can both observe a miss before either writes and both hear "new". The race window is bounded by the cache round-trip, which makes the duplicate-detection guarantee probabilistic rather than strict. Callers whose domain needs strict exactly-once use a backend-aware primitive instead - for replay prevention that is a ReplayCacheBase over the store's own conditional write (Redis SET ... NX PX, SQL INSERT ... ON CONFLICT DO NOTHING).

The entry stores an opaque marker; only the key's presence carries meaning. The requested time-to-live is floored to a small positive minimum so a value the cache would reject or discard immediately still records the sighting.

DistributedCacheExtensions.TryGetAndRemoveAsync(this IDistributedCache, string, Nullable<TimeSpan>, CancellationToken) Method

Reads a value and removes it, both under one hold of the per-key gate, for a cache with no native primitive of its own. This is NOT the equivalent of Redis GETDEL. The gate holds out other REDEMPTIONS of the key and nothing else, so a plain write landing between the read and the removal is destroyed by this caller while it is handed the earlier bytes - and that is only one of the ways the two differ. AT MOST one caller is handed the value when nothing writes the key, and none may be. Read the remarks before relying on it.

C#
public static System.Threading.Tasks.Task<byte[]?> TryGetAndRemoveAsync(this Microsoft.Extensions.Caching.Distributed.IDistributedCache cache, string key, System.Nullable<System.TimeSpan> lockTimeout=null, System.Threading.CancellationToken cancellationToken=default(System.Threading.CancellationToken));

Parameters

cache Microsoft.Extensions.Caching.Distributed.IDistributedCache

The distributed cache instance.

key System.String

The key of the value to retrieve and remove.

lockTimeout System.Nullable<System.TimeSpan>

Duration after which the lock expires, 5 seconds if null.

cancellationToken System.Threading.CancellationToken

Optional cancellation token to cancel the operation.

Returns

System.Threading.Tasks.Task<System.Byte[]>
A task that completes when the operation finishes, containing the value read under this caller's own hold of the gate, when its claim was still in the store afterwards. Null otherwise, which does NOT mean somebody else took it: see TryRemoveAsync(this IDistributedCache, string, Nullable<TimeSpan>, CancellationToken), whose remarks carry the condition; this method adds nothing to it beyond returning the value. What the placement of the read does and does not settle is in the remarks.

Remarks

Atomicity Protocol:

  1. Step 1:
  2. Step 2:
  3. Step 3:

How it provides atomicity: the removal reports itself under the same condition TryRemoveAsync(this IDistributedCache, string, Nullable<TimeSpan>, CancellationToken) states, and the read is inside the same hold of the gate rather than in front of it.

What the read's placement buys, and what it does not. The bytes handed back are the bytes at the key when this caller got IN - not the ones there before it waited, and that wait is as long as another caller's whole redemption. What is still open is narrower and real: a writer that takes no gate can land between the read and the removal, so this caller destroys that write and is handed the earlier bytes. Nothing in this class closes THAT, and the SERIALIZATION survives no second process at all - the lock protocol still admits at most one winner across nodes, but nothing there holds the read and the removal together. A store whose own primitive returns the removed value closes both, and issue 435 tracks it.

Lock timeout: the claim auto-expires after the specified timeout (5 seconds by default), so a process that crashes mid-protocol does not leave the key claimed forever. It also means a caller slower than the timeout loses its own claim: see TryRemoveAsync(this IDistributedCache, string, Nullable<TimeSpan>, CancellationToken).

Performance: up to six cache operations - one read, and up to five in the removal protocol - so it has higher latency than a store's own atomic primitive, and works with any IDistributedCache in exchange. All of them inside the gate, so a contended key serializes for the whole of that rather than for the removal alone.

DistributedCacheExtensions.TryRemoveAsync(this IDistributedCache, string, Nullable<TimeSpan>, CancellationToken) Method

Atomically attempts to remove a value from the distributed cache. Uses a lock-based protocol to ensure atomic removal semantics, preventing race conditions where multiple threads attempt to remove the same key concurrently.

C#
public static System.Threading.Tasks.Task<bool> TryRemoveAsync(this Microsoft.Extensions.Caching.Distributed.IDistributedCache cache, string key, System.Nullable<System.TimeSpan> lockTimeout=null, System.Threading.CancellationToken cancellationToken=default(System.Threading.CancellationToken));

Parameters

cache Microsoft.Extensions.Caching.Distributed.IDistributedCache

The distributed cache instance.

key System.String

The key of the value to remove.

lockTimeout System.Nullable<System.TimeSpan>

Duration after which the lock expires, 5 seconds if null.

cancellationToken System.Threading.CancellationToken

Optional cancellation token to cancel the operation.

Returns

System.Threading.Tasks.Task<System.Boolean>
A task that completes when the operation finishes, containing true when the value was removed by this caller AND its own lock token was still in the store afterwards. False otherwise - which covers the key not being there, another caller having taken it, and the case where the value is gone and nobody can be told they took it. That last one does not need a second node: see the remarks. A store fault after the removal raises rather than returning, and loses the value the same way.

Remarks

Atomicity Protocol:

  1. Step 1:
  2. Step 2:
  3. Step 3:
  4. Step 4:
  5. Step 5:

How it provides atomicity: a caller reports the removal as its own only when its own claim is still in the store at the end of the protocol. What that condition does and does not buy is spelled out below.

Use Case: This method is useful when you need to atomically remove a value without retrieving it (unlike TryGetAndRemoveAsync(this IDistributedCache, string, Nullable<TimeSpan>, CancellationToken)). For example, in the Device Authorization Grant flow when a user denies authorization, you only need confirmation that the request was removed, not the request data itself.

What the protocol decides: a caller is told it took the value only when the protocol runs to the end AND finds its own lock token still in the store.

That is the whole contract, and it is deliberately not followed by a count of what can go wrong. Such a list is not closable - the token can be overwritten, it can expire while a cache call stalls, and the store calls after the removal can fail, and there is no argument that those are all. What the tests carry instead, each dying when its fact stops holding: TryRemoveAsync_TheLockExpiresMidProtocol_OneCallerAloneLosesTheValue for a removal with nobody told, on one node with no competitor, and TryRemoveAsync_TheStoreFaultsAfterTheRemoval_TheValueIsGoneAndNobodyIsTold for the same outcome reached by a fault, where the caller gets an exception rather than an answer at all.

What the check does NOT give you. It does not make this a take-once: the gate serializes callers within one process, so a competitor cannot overwrite another's token HERE, and nothing about that survives a second node - see below.

Across processes even the overwrite reopens. Two nodes redeeming the same key at the same moment can end with the value removed and neither told it took it, because the lock protocol is assembled from Get, Set and Remove as three separate operations and there is a window between any two of them. A take-once needs one indivisible read-modify-write, and this interface exposes none: no compare-and-swap, no set-if-absent, no delete-returning-value.

A deployment on several nodes that cannot afford that supplies its own storage and reaches for the primitive its store already has. No new API is needed for that: IDeviceAuthorizationStorage, IBackChannelRequestStorage and IEntityStorage are public and registered with TryAddSingleton, so a host's own registration wins. Which primitive to reach for depends on the store, which is why it cannot be chosen here:

  • Redis 6.2 and later: GETDEL key, one command, which returns the value to exactly one caller and deletes it. Earlier versions get the same effect from a two-line EVAL script, since Redis runs a script indivisibly. Both are for a storage of your own: they read a STRING, and the IDistributedCache implementation for Redis keeps each entry as a hash whose value sits in a field, so pointed at these keys they answer WRONGTYPE.
  • PostgreSQL: DELETE FROM ... WHERE key = $1 RETURNING value. The row lock picks the winner, and at the default isolation level the loser returns no rows; under REPEATABLE READ or SERIALIZABLE it fails to serialize instead, which is the same answer through an exception.
  • SQL Server: DELETE ... OUTPUT deleted.value, the same shape.
  • Oracle: DELETE ... RETURNING value INTO :out. Oracle documents the RETURNING INTO clause as belonging to DELETE among others, and for a DELETE it yields the pre-deletion value.

Lock timeout: Locks auto-expire after the specified timeout (default 5 seconds) to prevent orphaned locks if a process crashes between writing the lock token and cleaning it up.