Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
`AddDistributedCache` enables it on the private provider it builds, because a silently dropped refresh
shortens a session and fire-and-forget cannot report one. Honored by both `RedisCache` and
`RedisHashCache`.
- **Conditional add: `ICache.TryAddAsync` / `ICache<T>.TryAddAsync` (+ blocking `ICache<T>.TryAdd`).**
Writes a key only if it does not already exist, returning `true` only to the caller that created it.
On Redis-backed caches this is StackExchange.Redis `When.NotExists` — `SET key value EX … NX` — a
single atomic round-trip with the TTL applied by the same command, so exactly one caller across all
nodes wins a given key and a won key is never briefly immortal. Intended for at-most-once semantics
keyed by something: idempotency keys, dedup markers whose TTL is the dedup window, electing which
replica runs a job. `false` deliberately conflates "the key already existed" with "the write could
not be completed" (disconnected, threw, or a `null`/`default` value the cache cannot represent) —
fail-closed, so a caller treating `true` as "I own this key" is never wrongly told it won; the same
conflation `IDistributedLock.TryAcquireAsync` already documents. Unlike `SetAsync`, it never
deletes: where `SetAsync` removes the key when handed a `null` with `CacheNullValues` off,
`TryAddAsync` reports `false` and leaves the key untouched. Per tier: `RedisCache` issues the `NX`
write; `MultilayerCache` runs one sequence for every provider — take the local lock, probe the
local tier, ask the L2, populate L1 (plus an invalidation broadcast) on a win, best-effort. A
broadcast or L1 failure never downgrades a real win to `false`, since that would strand the entry
with no owner; with the L2 disconnected the call returns `false` rather than granting a local-only
claim every node would also be granted (`SetAsync` degrades to a local write there). The probe is
what bounds exclusion at one winner per process, and the only thing doing so on the `InMemory`
provider, whose `NullCache` L2 tells every caller it added the key; it also reports a local hit as
a loss without asking the L2, which is fail-closed but costs a win the L2 would have granted when
the local copy outlived the shared one. The local lock is what makes that probe-then-write atomic,
so a conditional add takes it regardless of `Lock.LocalLockEnabled` (which trades single-flight for
throughput on `GetOrAddAsync` and must not be able to hand one key to two callers), and a caller
that cannot acquire it within `Lock.LocalLockTimeout` is told it lost rather than proceeding
unserialized. The L2 itself is only ever asked, never classified by type or provider name, so a
provider a consumer registers participates on the same terms — with the corollary that an L2
arbitrating in-process only is trusted as though it arbitrated for every sharer. On the `InMemory`
provider no invalidation broadcast is published: `ChangeTokenFactory` accepts only `CacheRemoved`
and `CacheRefreshed` there, so peers ignore `CacheSet` — deliberately, since each node's memory is
the store rather than a copy of a shared one.
An expiration that is not in the future reports `false` on every tier: the entry would be evicted on
arrival, so a `true` would be handed to every later caller as well. `NullCache` returns `false` —
the one member where it does not degrade to "caching is off, carry on", because it cannot complete
the write (which is exactly what a fail-closed `false` means) and because `true` there would hand
every caller a claim of exclusive ownership; it is reached by accident, being what
`ICacheFactory.CreateCache` resolves to when the requested provider is absent or has
`Enabled=false`. Added to both interfaces as **default interface methods**, so
**required** members rather than default interface methods: a probe followed by a write is not
atomic, and no fallback body could stand in for one without either voiding the guarantee or hiding
which stores can arbitrate, so an implementation states what its own store can do. **This is
source-breaking for hand-written `ICache` / `ICache<T>` implementations,** which must add the three
overloads. `NullCache` returns `true`, as its `SetAsync` does — the null store retains nothing, so
no key pre-exists and no caller loses; note that it provides no exclusion at all and is what
`ICacheFactory.CreateCache` resolves to for an absent or disabled provider, so assert the provider
you expect at startup when the `true` branch runs a side effect that must not repeat. `ICache.Compat.cs` carries the token-positional forwarders
(`TryAddAsync(key, value, token)` and the `TimeSpan?`/`DateTimeOffset?` pairs), matching `SetAsync`.
No multi-key overload: Redis has no atomic multi-key `NX`, and all-or-nothing versus per-key
semantics would be a guess. This is a cache primitive, not a lock — no ownership token, no early
release, and a later `SetAsync`/`RemoveAsync` ignores the claim; for a fencing token and explicit
release use `IDistributedLock`. No conditional-add member was added to the hash surface, where `NX`
is per-field (`HSETNX`) and a different shape.
- A cancellation raised while the `SET … NX` write is in flight now propagates instead of being
reported as `false`, which would have claimed the key belongs to someone else — a fact the cancelled
call never established. The write itself stays on the shared `Write` resilience pipeline: retries
fire on exceptions only, and re-issuing `NX` is harmless, since an attempt whose reply was lost is
refused by the key it just wrote and reports the same `false` the exception would have, while an
attempt that never reached Redis is recovered as the `true` it should have been.

### Changed

Expand Down Expand Up @@ -77,6 +134,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
onto the builder object instead. Every `CacheOptions` value supplied through that entry point was
silently ignored; `Enabled`, `AppShortName`, `KeyCasing` and the rest now take effect, which changes
behavior for anyone who was unknowingly running on the defaults.
### Documentation

- New recipe [`conditional-add.md`](docs/recipes/conditional-add.md) — claiming a key exactly once with
`TryAddAsync`, why check-then-set is not equivalent, and when to reach for `IDistributedLock`
instead. `interfaces.md` documents the member on both cache surfaces with a per-provider table of
who arbitrates and how far exclusion reaches; `concepts.md` places it next to the two lock
abstractions, whose goal is reducing redundant work rather than a decision the caller can branch on.
The recipe's worked example is a daily digest rather than a payment capture, and says why: a claim
marker records that someone *started*, never that anyone finished, so an operation that must
eventually happen needs a recorded outcome instead. Both documents also state plainly that the
ambiguity in `false` is not recoverable — a serialization or command failure returns `false` with
the connection snapshot still healthy — rather than pointing at `IConnectionState` as an escape
hatch, and both explain why the `NX` write is safe to retry where `SPOP` is not.
- New `IConnectionState` section in `interfaces.md`, a public type that was documented nowhere (and
that the `TryAddAsync` guidance linked to through an anchor resolving to `IDistributedLock`). It is
described as what it is — a cache-health snapshot for probes, metrics and backoff — not as a way to
explain a negative result.
- Corrected `interfaces.md`, which described the `IHashCache<T>.SetAsync(…, HashCacheEntryOptions, …)`
overload as offering "conditional set, individual field TTL". `HashCacheEntryOptions` carries
neither: `HashCacheSetOption` selects write *scope* (`HashReplace` merges fields, `KeyReplace` drops
the key first), and there is no per-field TTL.

## [1.3.0] - 2026-08-19

Expand Down
14 changes: 14 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,20 @@ register `RedisDistributedLock` (the in-memory provider passes
custom wiring that skips `AddInMemoryRedis()` should add
`.AddRedisDistributedLock()` explicitly on the builder.

`ICache.TryAddAsync` sits alongside these but answers a different question. The
locks exist to reduce redundant work — the degradation above is deliberate, and
neither lock guarantees exactly-once generator execution. `TryAddAsync` instead
returns a decision the caller can branch on: it writes the key only if it is
absent (Redis `SET … NX`) and returns `true` only to the caller that created it,
so it is the right primitive for at-most-once semantics keyed by something (a
dedup marker, an idempotency key, electing which node runs a job). It is not a
lock: the claim expires on its own TTL, carries no ownership token, and any later
`SetAsync`/`RemoveAsync` on the key ignores it. When you need a fencing token and
an explicit release, use `IDistributedLock`; when you need an atomic, expiring
"first one here wins" marker, use `TryAddAsync`. See
[the interfaces reference](reference/interfaces.md#icache) for the per-provider
behavior, including why it fails closed when the L2 is disconnected.

When both locks are enabled, `GetOrAddAsync` takes the local lock first (cheap,
in-process) and then — under a double-checked read to avoid redundant generator
runs after waiting — attempts the distributed lock. A failure to acquire the
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ UiPath's internal multilayer caching library. L1 in-memory + L2 Redis, cross-nod
- [provider-fallback.md](recipes/provider-fallback.md) — InMemoryRedis when Redis is on, InMemory otherwise.
- [factory-extension-methods.md](recipes/factory-extension-methods.md) — typed `ICache<T>` via `ICacheFactory` extension methods.
- [batch-get-or-add.md](recipes/batch-get-or-add.md) — one source round trip for N cache misses via multi-key `GetOrAddAsync`.
- [conditional-add.md](recipes/conditional-add.md) — claim a key exactly once with `TryAddAsync` (Redis `SET … NX`).
- [app-version-prefix.md](recipes/app-version-prefix.md) — auto-invalidate on deploy via assembly-version key prefix.
- [mediatr-pipeline-behavior.md](recipes/mediatr-pipeline-behavior.md) — generic per-request cache as a MediatR behavior.
- [hash-cache-with-metadata.md](recipes/hash-cache-with-metadata.md) — payload + freshness metadata side-by-side.
Expand Down
157 changes: 157 additions & 0 deletions docs/recipes/conditional-add.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Claim a key exactly once with `TryAddAsync`

**What:** Use `TryAddAsync` when you need "only one caller may proceed", keyed by something. It writes
the key only if it is absent and returns `true` only to the caller that created it. On a Redis-backed
cache that is StackExchange.Redis `When.NotExists` — `SET key value EX … NX` — one atomic round-trip,
so exactly one caller across every node wins.

**When to use:**
- **At-most-once side effects,** where skipping is the safe failure. "Send this alert / this welcome
email once per user per day."
- **Electing a worker.** Which of N replicas runs a periodic job this tick.
- **Dedup markers** where the marker's TTL *is* the dedup window.
- **Suppressing duplicate work,** as an optimization ahead of an operation that is idempotent anyway.

**When not to use:** as the sole guard on an operation that must eventually happen. The claim marker
records that someone started, not that anyone finished, so a winner that dies mid-flight leaves the
work undone until the TTL lapses — and `false` cannot tell you which of "already claimed" or "write
failed" you are looking at. Those cases need a recorded outcome, not a claim.

**When not to use:** if you need a lock you can release early, or a fencing token that proves you
still hold it, use [`IDistributedLock`](../reference/interfaces.md#idistributedlock) instead. See
[Notes](#notes).

## Code

```csharp
using UiPath.Caching;

public class DailyDigest(ICache cache, IMailer mailer)
{
// The key is the dedup unit: one send per user per UTC calendar day. The TTL only
// cleans the marker up afterwards — it is not a rolling 24-hour window, so two
// triggers minutes apart either side of midnight use different keys and both send.
private static readonly TimeSpan MarkerTtl = TimeSpan.FromHours(24);

public async Task SendOnceAsync(string userId, CancellationToken token)
{
var claimed = await cache.TryAddAsync(
(CacheKey)$"digest:{userId}:{DateTime.UtcNow:yyyyMMdd}",
DateTimeOffset.UtcNow,
MarkerTtl,
token: token);

if (!claimed)
{
// Someone else claimed it, or the write could not be completed. Both mean
// "do not send" — which is the whole reason this side effect fits the
// primitive: skipping is the safe failure, so the ambiguity costs nothing.
return;
}

await mailer.SendDigestAsync(userId, token);
}
}
```

`TryAddAsync` fits here because **not** sending is the safe failure. Note what it is *not* doing:
nothing recovers the missed digest if the winner crashes between the claim and the send, and nothing
tells a lost race apart from a failed write. If your side effect must eventually happen — capturing a
payment, say — a claim marker is the wrong shape and no amount of branching on `false` fixes it: the
marker says "someone is handling this", never "this was handled". Record the *outcome* instead
(`Pending` → `Captured` on a durable store, or an idempotency key the downstream provider itself
honors) and let redelivery retry until the outcome is written.

The typed surface is the same shape without the `policy` parameter:

```csharp
public class JobElection(ICache<string> cache)
{
public Task<bool> TryClaimTickAsync(string jobName, DateTimeOffset tick, CancellationToken token) =>
cache.TryAddAsync(
(CacheKey)$"{jobName}:{tick:yyyyMMddHHmm}",
Environment.MachineName,
TimeSpan.FromMinutes(5),
token)
.AsTask();
}
```

## Why not check-then-set

The obvious hand-rolled version is not equivalent:

```csharp
// BROKEN: two callers can both observe "absent" before either writes.
if (!await cache.ContainsAsync<DateTimeOffset>(key, token))
{
await cache.SetAsync(key, DateTimeOffset.UtcNow, MarkerTtl, token: token);
await payments.CaptureAsync(eventId, token); // runs twice under concurrency
}
```

The gap between the probe and the write is the whole problem, and no amount of narrowing closes it —
that is exactly the gap `NX` removes by making the decision part of the write. This is also why
`TryAddAsync` is a **required** member of `ICache` and `ICache<T>` rather than a default interface
method, precisely so nothing can inherit the code above: a non-atomic emulation would void the only
guarantee the method makes, and a fallback that quietly reported one answer for every store would
hide which stores can actually arbitrate. An implementation says what its own store can do.

## Notes

- **`false` does not mean "the key existed".** It means "you did not create it" — the key already
existed, *or* the write could not be completed (store disconnected, write threw, or the value was a
`null`/`default` the cache cannot represent). This is fail-closed on purpose: nobody is ever wrongly
told they won. Design the `false` branch so that "skip the side effect" is the safe outcome —
nothing recovers the distinction. `IConnectionState.IsConnected` does not: a serialization or
command failure returns `false` with the connection snapshot still healthy, and the snapshot can
change either side of the call anyway. `IDistributedLock.TryAcquireAsync` does not either; it
documents backend-unavailable and already-held as the same `null`. If the two readings need
different handling, you need a primitive with a richer result than a `bool`.
- **A non-positive TTL claims nothing.** An expiration that is not in the future returns `false` on
every tier rather than a win, because the entry would be evicted on arrival and the next caller
would be told it won too.
- **Caching switched off means everyone wins.** `NullCache.TryAddAsync` returns `true` — it retains
nothing, so no key pre-exists and nobody loses — and it is what `ICacheFactory.CreateCache` falls
back to when the requested provider is missing or has `Enabled=false`. A mistyped provider name
therefore turns at-most-once into at-least-once with no error, so assert the provider you expect at
startup: `if (cacheFactory.CreateCache(KnownCacheProviderNames.Redis) is NullCache) throw …`.
- **The `NX` write is retryable,** so it stays on the shared `Write` resilience pipeline. Retries
fire on exceptions only, and the ambiguous case costs nothing: if the write lands but its reply is
lost, the retry is refused by the key it just wrote and reports `false` — the same answer the
un-retried exception would have produced — while a first attempt that never reached Redis is
recovered as the `true` it should have been. (`SPOP` in `UiPath.Caching.Queue` is the opposite: a
retry there pops a second item and loses the first, which is why it has its own opt-in pipeline.)
- **It never deletes.** `SetAsync` handed a `null` with `CacheNullValues` off *removes* the key;
`TryAddAsync` returns `false` and leaves it alone. With `CacheNullValues` on, a `null` claims the key
through the cached-null sentinel — so prefer a meaningful value (a timestamp, the machine name) that
makes the claim diagnosable in `redis-cli`.
- **The TTL is applied by the same command,** so a won key is never briefly immortal between the write
and a follow-up `EXPIRE`. Give every claim a TTL you are willing to wait out: there is no release,
so a crashed winner blocks the key until it expires. That is the main reason to prefer
`IDistributedLock` for long critical sections.
- **It is not a lock.** No ownership token, no early release, and a later `SetAsync`/`RemoveAsync` on
the key silently overwrites the claim. If a bug elsewhere writes that key, exclusion is gone with no
error. On the `InMemory` provider the reverse also holds: the local lock serializes conditional adds
against each other, but `SetAsync` takes no lock, so a set landing between the probe and the write
is overwritten by the claim, which still reports `true`. Redis has no such gap — `NX` is atomic
against a concurrent `SET`.
- **In-memory-only caches exclude in-process only.** With the `InMemory` provider there is no shared
store to arbitrate, so the local tier does — serialized by the local lock, which a conditional add
takes whatever `Lock.LocalLockEnabled` says, because here it *is* the guarantee rather than a
single-flight optimization. A caller that cannot acquire it within `Lock.LocalLockTimeout` is told
it lost. Two processes still both win. `InMemoryRedis` and `Redis` are cross-node correct.
- **Both tiers contribute, in one sequence:** the local tier is probed under the local lock, then
the L2 decides, then L1 is populated on a win. The probe bounds exclusion at one winner per
process — the only thing doing so when the L2 retains nothing — and reports a local hit as a loss
without asking the L2, which is fail-closed but costs a win the L2 would have granted if the local
copy outlived the shared one. When the L2 is disconnected the call returns `false` rather than
granting a local claim every node would also be granted, unlike `SetAsync`, which degrades to a
local-only write there.
- **There is no multi-key overload.** Redis has no atomic multi-key `NX`, and choosing all-or-nothing
versus per-key semantics on your behalf would be a guess. Claim keys one at a time, or take a single
claim on a key that stands for the whole batch.

**See also:** [`ICache`](../reference/interfaces.md#icache),
[`IDistributedLock`](../reference/interfaces.md#idistributedlock),
[Concepts — locking](../concepts.md)
Loading
Loading