DistributedCacheExtensions Class
Extension methods for Microsoft.Extensions.Caching.Distributed.IDistributedCache providing atomic operations.
public static class DistributedCacheExtensionsInheritance 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.
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.
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:
- Step 1:
- Step 2:
- 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.
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:
- Step 1:
- Step 2:
- Step 3:
- Step 4:
- 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-lineEVALscript, since Redis runs a script indivisibly. Both are for a storage of your own: they read a STRING, and theIDistributedCacheimplementation for Redis keeps each entry as a hash whose value sits in a field, so pointed at these keys they answerWRONGTYPE. - 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.