Skip to content

feat(cache): add TryAddAsync conditional add (Redis When.NotExists) - #144

Open
CalinMPopa wants to merge 3 commits into
mainfrom
feat/conditional-add-tryaddasync
Open

feat(cache): add TryAddAsync conditional add (Redis When.NotExists)#144
CalinMPopa wants to merge 3 commits into
mainfrom
feat/conditional-add-tryaddasync

Conversation

@CalinMPopa

@CalinMPopa CalinMPopa commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds TryAddAsync — a create-if-absent member on ICache / ICache<T> that maps to StackExchange.Redis When.NotExists (SET key value EX … NX). Until now the library had no conditional-write path at all: every Redis write hardcoded When.Always, and the only NX primitive was IDistributedLock, which is a lease rather than a value store. This gives callers at-most-once semantics keyed by something — idempotency keys, dedup markers whose TTL is the dedup window, electing which replica runs a job.

Changes

  • ICache.TryAddAsync<T> / ICache<T>.TryAddAsync (+ blocking ICache<T>.TryAdd) — three overloads each (policy / TimeSpan? / DateTimeOffset?), mirroring the existing SetAsync shape. Added as default interface methods so existing implementations keep compiling, per the convention set for the 1.3.0 ICache additions.
  • RedisCache — one SET … NX command, no probe, TTL applied by the same write so a won key is never briefly immortal between the write and a follow-up EXPIRE.
  • MultilayerCache — the L2 arbitrates; L1 population and the invalidation broadcast happen after the win, best-effort. With the L2 disconnected the call returns false rather than granting a local-only claim every node would also be granted. The memory-only provider (NullCache as L2) has no L2 to arbitrate, so the local tier does, serialized by the local lock — which a conditional add takes regardless of Lock.LocalLockEnabled, because here it is the guarantee; a caller that cannot acquire it within Lock.LocalLockTimeout is told it lost.
  • Cache<T>, NullCache — implementations; NullCache returns false. It is the one member where that type does not degrade to "caching is off, carry on": it cannot complete the write, which is exactly what a fail-closed false means, and true there would hand every caller a claim of exclusive ownership. It is also reached by accident, being what ICacheFactory.CreateCache resolves to when the requested provider is absent or has Enabled=false. NullSetCache.AddAsync in UiPath.Caching.QueueSADD, the same question — already answered false.
  • MultilayerCacheBase.AcquireLocalLockAsyncprivate protected helper resolving local-lock enablement/timeout the same way RunUnderLocksAsync does.
  • Docs — new recipe docs/recipes/conditional-add.md; interfaces.md gains the contract plus a per-provider table of who arbitrates; concepts.md places it next to the two lock abstractions.
  • Drive-by fixinterfaces.md described the IHashCache<T>.SetAsync(…, HashCacheEntryOptions, …) overload as offering "conditional set, individual field TTL". It offers neither: HashCacheSetOption selects write scope (HashReplace merges fields, KeyReplace drops the key first), and there is no per-field TTL. Corrected, since it pointed readers at the wrong place for NX.

Contract decisions worth reviewing

  • false is fail-closed and deliberately ambiguous — the key already existed, or the write could not be completed (disconnected, threw, or a null/default the cache cannot represent). A caller treating true as "I own this key" is never wrongly told it won. Same conflation IDistributedLock.TryAcquireAsync already documents.
  • It never deletes. Where SetAsync removes the key when handed a null with CacheNullValues off, TryAddAsync reports false and leaves it untouched.
  • A win is never downgraded. If the L2 add succeeds but the broadcast or L1 write fails, the call still returns true — reporting a loss would strand the entry with no owner until its TTL. Those failures are logged.
  • The default interface body throws NotSupportedException rather than emulating the operation with a probe followed by a write, which would not be atomic and would silently void the only guarantee the method makes. All in-box implementations override it.
  • No multi-key overload — Redis has no atomic multi-key NX, and all-or-nothing vs. per-key would be a guess. No hash-surface memberHSETNX is per-field and a different shape. The set/queue surface already reports add-vs-already-present from AddAsync.

Test plan

  • Unit tests added/updated — 36 new cases across four files: RedisCacheTryAddTests (single NX command, never When.Always/When.Exists, lost race, fail-closed on disconnect and on throw, expiration in the same command, cached-null sentinel, never deletes, telemetry scope, cancellation), MultilayerCacheTryAddTests (L2 arbitrates, never probes L1 to decide, no broadcast on a loss, win survives broadcast failure, fails closed when disconnected), InMemoryCacheTryAddTests (a real MultilayerCache over NullCache with a live AsyncKeyedLocalLock — 32 concurrent callers yield exactly one winner), CacheOfTTryAddTests (key strategy and policy snapshot forwarding, blocking facade).
  • Integration tests pass locally (dotnet test) — 1327 passed / 0 failed on both net8.0 and net10.0.
  • CHANGELOG.md updated
  • PublicAPI.Unshipped.txt updated (15 entries)

Linked issues

Fixes #

Contributor declaration

  • I signed off my commits per the DCO (git commit -s).
  • I am contributing on behalf of my employer, or in the course of employment / using employer resources. (If checked, your employer may hold IP rights in this work, which can require a signed CLA — a maintainer will follow up. See CONTRIBUTING.md.)

Important

The commit on this branch has no Signed-off-by trailer, so the DCO check will fail. A sign-off is a certification made by the contributor, so it was left for the author rather than added automatically. To fix: git commit -s --amend --no-edit && git push --force-with-lease. The two declaration boxes above are likewise left unchecked for the author to confirm.

🤖 Generated with Claude Code


Follow-up commit: fix(cache): close the fail-open paths in TryAddAsync

A review pass found several paths where a caller could be told true without owning the key. What changed on top of the original commit:

  • The memory-only tier no longer runs unserialized. AcquireLocalLockAsync returned null both when Lock.LocalLockEnabled was off and when the acquire timed out, and the probe-then-write proceeded anyway — measured 11 of 32 concurrent callers winning with the lock disabled, 20 of 32 with a contended acquire. The lock is now always taken for a conditional add, and a caller that cannot get it is told it lost.
  • NullCache.TryAddAsync returns false (see above).
  • A non-positive expiration reports false on both tiers. IMemoryCache evicts such an entry on arrival, so the memory tier reported success while retaining nothing and the next caller won too, while Redis answered false for the same call.
  • A local win publishes the invalidation broadcast, as SetAsync does.
  • An inner cache's NotSupportedException surfaces instead of becoming a permanent false indistinguishable from contention.
  • In-flight cancellation propagates on both tiers instead of being reported as false.
  • The L2 gate no longer uses GetInnerCacheDisconnected, whose state aggregates the broadcast transport: a dead topic was stopping a healthy Redis from arbitrating. The L2 is asked and fails closed on its own.
  • ICache.Compat.cs gains the three token-positional TryAddAsync forwarders — the only public API this follow-up adds.
  • The docs no longer offer IConnectionState as a way to tell an outage from a lost race, because it is not one: a serialization or command failure returns false with the connection snapshot still healthy. The ambiguity is documented as unrecoverable, and the recipe's worked example is a daily digest rather than a payment capture — a claim marker records that someone started, never that anyone finished.

The SET … NX write stays on the shared Write resilience pipeline. Retrying it is harmless: an attempt whose reply was lost is refused by the key it just wrote and reports the same false the un-retried exception would have, while an attempt that never reached Redis is recovered as the true it should have been. (SPOP is the opposite, which is why RedisSetCacheOptions.ResilienceKeyName exists.)

@github-actions github-actions Bot added the needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) label Aug 31, 2026
@github-actions

Copy link
Copy Markdown

🔎 Maintainer heads-up: automated triage flagged this PR as potentially material, so it may need a signed CLA in addition to the DCO sign-off.

Strong signals

  • adds public API surface (PublicAPI.Unshipped.txt in src/UiPath.Caching.Abstractions)

Other signals

  • large production change (+362 lines under src/)

Context

  • external contributor (CONTRIBUTOR) with a substantial change (+362 lines under src/)

This is advisory only — the bot does not decide. Please judge against the CLA criteria (material, product-critical, patent-sensitive, corporate contributor, broad commercial use). Note that thresholds can be gamed by splitting PRs, so use your judgement.

  • If a CLA is needed → add the cla-required label (a contributor comment with signing steps is posted automatically).
  • If it is not needed → replace needs-cla-review with cla-not-required so later pushes don't re-flag it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Multilayer cancellation and connection handling are incorrect, and the failure-disambiguation guidance is unsafe.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds atomic conditional cache insertion via TryAddAsync, enabling create-if-absent workflows across Redis, multilayer, and typed cache APIs.

Changes:

  • Adds typed, untyped, synchronous, and compatibility APIs.
  • Implements Redis NX and multilayer/local arbitration.
  • Adds comprehensive tests, public API declarations, and documentation.
File summaries
File Description
tests/UiPath.Caching.Tests/Redis/RedisCacheTryAddTests.cs Tests Redis NX behavior.
tests/UiPath.Caching.Tests/NullCacheConditionalAddTests.cs Tests fail-closed null cache behavior.
tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs Tests multilayer and in-memory arbitration.
tests/UiPath.Caching.Tests/Fakes/DictionaryCache.cs Adds conditional-add test fake.
tests/UiPath.Caching.Tests/CacheOfTTryAddTests.cs Tests the typed facade.
src/UiPath.Caching/Redis/RedisCacheOptions.cs Formatting-only change.
src/UiPath.Caching/Redis/RedisCache.cs Implements Redis SET NX.
src/UiPath.Caching/PublicAPI.Unshipped.txt Records connection-state API additions.
src/UiPath.Caching/MultilayerCacheBase.cs Exposes connection state and local-lock helper.
src/UiPath.Caching/MultilayerCache.cs Implements multilayer conditional add.
src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt Records new public APIs.
src/UiPath.Caching.Abstractions/Policies/ResiliencePipelineNames.cs Formatting-only change.
src/UiPath.Caching.Abstractions/NullCache.cs Adds fail-closed implementation.
src/UiPath.Caching.Abstractions/ICacheOfT.Sync.cs Adds blocking facade methods.
src/UiPath.Caching.Abstractions/ICacheOfT.cs Adds typed async contract.
src/UiPath.Caching.Abstractions/ICache.cs Adds untyped async contract.
src/UiPath.Caching.Abstractions/ICache.Compat.cs Adds positional-token forwarders.
src/UiPath.Caching.Abstractions/ConditionalAdd.cs Provides unsupported-operation errors.
src/UiPath.Caching.Abstractions/CacheOptions.cs Formatting-only change.
src/UiPath.Caching.Abstractions/CacheOfT.cs Forwards typed conditional adds.
docs/reference/interfaces.md Documents contracts and provider behavior.
docs/recipes/conditional-add.md Adds a conditional-add recipe.
docs/index.md Links the new recipe.
docs/concepts.md Compares conditional add with locking.
CHANGELOG.md Records the feature and semantics.
Review details
  • Files reviewed: 25/25 changed files
  • Comments generated: 7
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread docs/recipes/conditional-add.md Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread CHANGELOG.md Outdated
Comment thread docs/reference/interfaces.md Outdated
Comment thread src/UiPath.Caching.Abstractions/ICache.cs Outdated
Comment thread src/UiPath.Caching.Abstractions/NullCache.cs
Adds a create-if-absent member to ICache and ICache<T> (plus a blocking
TryAdd on the typed surface). On Redis-backed caches it maps to
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 between the write and a follow-up EXPIRE.

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. Previously the only NX primitive in the library was IDistributedLock,
which is a lease rather than a value store, and GetOrAddAsync's
check-then-write is not a substitute — the gap between probe and write is
exactly what NX removes.

Contract decisions:

- false is fail-closed and deliberately ambiguous: the key already existed,
  or the write could not be completed (disconnected, threw, or a
  null/default value the cache cannot represent). A caller treating true as
  "I own this key" is never wrongly told it won. Same conflation
  IDistributedLock.TryAcquireAsync already documents.
- Never deletes. Where SetAsync removes the key when handed a null with
  CacheNullValues off, TryAddAsync reports false and leaves it untouched.
- A win is never downgraded. On MultilayerCache the L2 arbitrates and the
  L1 write plus invalidation broadcast are best-effort *after* the win —
  reporting a loss there would strand the entry with no owner until its TTL.
- L1 never arbitrates while an L2 exists, since a key absent locally may be
  present in the shared store. With the L2 disconnected the call returns
  false rather than granting a local-only claim every node would also get
  (SetAsync degrades to a local write there). The memory-only provider has
  no L2, so the local tier arbitrates and exclusion narrows to in-process,
  serialized by Lock.LocalLockEnabled.

Ships as default interface methods so existing implementations keep
compiling, per the convention established for the 1.3.0 ICache additions.
The default body throws NotSupportedException rather than emulating the
operation with a probe followed by a write, which would not be atomic and
would silently void the only guarantee the method makes.

No multi-key overload: Redis has no atomic multi-key NX, and all-or-nothing
versus per-key semantics would be a guess. No hash-surface member: NX there
is per-field (HSETNX) and a different shape.

Also corrects interfaces.md, which described the
IHashCache<T>.SetAsync(.., HashCacheEntryOptions, ..) overload as offering
"conditional set, individual field TTL". It offers neither —
HashCacheSetOption selects write scope, and there is no per-field TTL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cosmin-staicu
cosmin-staicu force-pushed the feat/conditional-add-tryaddasync branch 2 times, most recently from e3ddfa3 to 91e35cc Compare September 2, 2026 17:29
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot September 2, 2026 17:42

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Several in-memory and fallback paths can still return a successful claim without preserving the required exclusion guarantee.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

src/UiPath.Caching/MultilayerCache.cs:34

  • Logging this fallback does not preserve the conditional-add guarantee. If Redis is absent and the factory's default provider is InMemory, InMemoryRedisCacheProvider receives this nested MultilayerCache; TryAddAsync then delegates to its process-local arbiter and returns true independently on every node. A nested multilayer tier must fail closed for conditional adds (or the configuration must be rejected), rather than only emitting a warning.
        if (innerCache is MultilayerCache)
        {
            LogInnerCacheArbitratesInProcessOnly(cacheName, innerCache.Name);

src/UiPath.Caching/MultilayerCache.cs:763

  • The expiration was checked before awaiting the local lock, so a short TTL can expire while this caller waits. After acquiring the lock, MemorySet can then report success for an already-expired entry, returning true while retaining no claim and allowing the next caller to win. Recheck the absolute expiration inside the lock before probing or writing.
            if (_memoryCache.TryGetValue(options.CacheKey, out _))

src/UiPath.Caching/MultilayerCache.cs:777

  • Only options.Expiration is validated, but the memory-only write is additionally capped by policy.LocalExpiration/LocalMaxExpiration. Those settings can be zero or negative; MemoryCacheSetter.Set still returns true after inserting an immediately expired entry, so this method can report a win without retaining the key. Resolve the local cap and fail closed when it is non-positive.
            return MemorySet(options, value, policy.LocalExpiration ?? _multiLayerCacheOptions.LocalMaxExpiration);
  • Files reviewed: 24/24 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Broadcast failure can suppress L1 population, and non-positive local expiration can allow multiple in-memory callers to report wins.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/UiPath.Caching/MultilayerCache.cs
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

In-memory arbitration is not atomic against concurrent unconditional writes, and normal broadcast failures are silently ignored.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (1)

src/UiPath.Caching/MultilayerCache.cs:792

  • This local arbitration path also ignores a false broadcast result. The built-in Redis topic implementations return false rather than throw when disconnected or when publishing fails, so a local winner can silently leave peer caches stale. Check the result and emit the existing stale-peer warning (without changing the successful add result).
                await _eventPublisher.CacheSetAsync(options).ConfigureAwait(false);
  • Files reviewed: 24/24 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread src/UiPath.Caching/MultilayerCache.cs
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The in-memory propagation path publishes an event that peer change tokens explicitly ignore, so stale copies are not invalidated as promised.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread docs/concepts.md Outdated
Comment thread docs/recipes/conditional-add.md Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Recognized local fallback tiers can still grant cross-process claims, and capacity rejection can produce false in-memory wins.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details
  • Files reviewed: 24/24 changed files
  • Comments generated: 4
  • Review effort level: Balanced

Comment thread src/UiPath.Caching/MultilayerCache.cs
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs
Comment thread CHANGELOG.md Outdated
Review follow-up on the conditional add. The contract the member exists to
make is "a caller told true owns this key"; several paths could break it.

MultilayerCache, memory-only tier: AcquireLocalLockAsync answered null both
when Lock.LocalLockEnabled was off and when the acquire timed out, and the
probe-then-write ran unserialized anyway — 11 of 32 concurrent callers were
told they added the key with the lock disabled, 20 of 32 with a contended
acquire. The local lock is the whole guarantee here rather than a
single-flight optimization, so it is now taken regardless of
Lock.LocalLockEnabled, and a caller that cannot acquire it within
Lock.LocalLockTimeout is told it lost.

NullCache.TryAddAsync returns false. It is the one member where the type does
not degrade to "caching is off, carry on": it cannot complete the write,
which is what a fail-closed false means, and true there hands every caller a
claim of exclusive ownership. It is also reached by accident, being what
ICacheFactory.CreateCache resolves to when the requested provider is absent
or has Enabled=false, so the old true turned at-most-once into at-least-once
with no error. NullSetCache.AddAsync — SADD, the same question — already
answered false; this aligns the two.

Local arbitration is restricted to the InMemory provider. NullCache is the L2
both for the memory-only provider by design and for any provider whose real L2
was absent or disabled, since CacheFactory falls back to it — so an
InMemoryRedis configured with DefaultCache=Redis and no Redis provider was
routed through LocalTryAddAsync and handed every process its own winner, under
a provider name that promises cross-node exclusion. It now reaches the L2 and
takes NullCache's fail-closed false, and the construction-time warning covers
this composition as well as a nested multilayer L2.

A nested in-process arbiter fails closed instead of being delegated to. An
InMemoryRedis whose DefaultCache is InMemory resolves that provider's multilayer
cache as its L2, and delegating let its local arbiter grant a win per process
under a provider name that promises cross-node exclusion; a construction-time
warning did not change that, so the add path now reports false.

MemorySet reporting success is no longer taken as retention on the local path. A
size-limited IMemoryCache declines an entry it cannot fit without throwing, and
MemoryCacheSetter still returns true, so the key was probed after the write.

The L2 gate no longer runs through GetInnerCacheDisconnected, whose state
aggregates the broadcast transport as well as the inner cache: with
UseLocalOnlyWhenDisconnected on, a dead topic stopped an otherwise healthy
Redis from arbitrating, in a method that already treats broadcast as
best-effort after a win. The L2 is asked instead and fails closed on its own
— RedisCache checks its connection before issuing NX — so a disconnected L2
still yields false rather than a local-only claim. The test that covered this
disconnected only the topic provider, which is exactly the case that should
now succeed; it is split into that expectation and one on the L2's own answer.

A publish that reports false rather than throwing is logged too. CacheSetAsync
signals an ordinary failure — a disconnected topic among them — with a false
return, which both broadcast sites discarded, so a win could leave peers on
stale L1 data without the propagation warning the code promises.

The local lock serializes conditional adds against each other only: SetAsync and
RemoveAsync take no lock, so a set landing between the probe and the write is
overwritten by the claim, which still reports true. IMemoryCache has no
create-if-absent primitive to close that with, and locking every local mutation
is a change to a hot path well outside this member, so the limit is documented
on the method, in the recipe and in the interfaces.md exclusion column instead.
Redis has no such gap, NX being atomic against a concurrent SET.

On the L2-win path the invalidation broadcast and the L1 write are now separate
best-effort steps: sharing one try/catch meant a dead topic also cost the winning
node its local copy, though they are described as independent.

The local path publishes no broadcast. It runs only on the InMemory provider, and
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, and a peer's write says nothing about this node's entry.

A non-positive effective local retention reports false on the InMemory provider,
where it is the only retention: MemoryCacheSetter writes an entry IMemoryCache
evicts on arrival and still returns true, so every later caller would win too.
CachePolicyFactoryValidator catches the options-level value, but a per-call
CachePolicy is not validated at all, which is the path that reaches this.

An expiration that is not in the future now reports false on both tiers.
IMemoryCache evicts such an entry on the way in, so MemorySet reported
success while retaining nothing and the next caller won too; Redis rejected
the negative PX and answered false.

A win on the local tier now publishes the invalidation broadcast, as SetAsync
does — without it a broadcast-enabled memory provider leaves peers serving a
stale copy of a key this node believes it just claimed.

An inner cache's NotSupportedException is no longer swallowed into false.
That exception is the ICache default body saying the store has no atomic
create-if-absent primitive; reported as false it is indistinguishable from
permanent contention, so no caller ever wins and the guarded work silently
never runs.

A cancellation raised while the write is in flight now propagates rather than
being reported as false, which would assert the key belongs to someone else —
a fact the cancelled call never established. Both tiers do this, so
InMemoryRedis and Redis agree. The write itself stays on the shared Write
resilience pipeline: retries fire on exceptions only, and re-issuing
SET .. NX is harmless — 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.
Contrast SPOP behind ISetCache.PopAsync, where a retry pops a second item and
loses the first, which is why RedisSetCacheOptions.ResilienceKeyName exists.

MultilayerCache warns at construction when it resolved another multilayer
cache as its distributed tier, which arbitrates in-process only under a
provider name that suggests otherwise. Reaching that state takes a deliberate
misconfiguration — with the default DefaultCache the same composition fails
loudly on Lazy re-entrancy instead — so it stays a warning next to the
existing innerCache is NullCache test rather than earning a capability member
on ICache.

The docs no longer offer IConnectionState as a way to tell an outage from a
lost race, because it is not one: a serialization or command failure returns
false with the connection snapshot still healthy, and
IDistributedLock.TryAcquireAsync conflates backend-unavailable with
already-held in the same way. The ambiguity is documented as unrecoverable —
design the false branch so that not proceeding is safe — and interfaces.md
describes IConnectionState as the cache-health signal it actually is. For the
same reason the recipe's worked example is now a daily digest rather than a
payment capture: a claim marker records that someone started, never that
anyone finished, so an at-least-once operation needs a recorded outcome and
no branching on false substitutes for one.

The XML docs on the conditional-add members are cut to a couple of lines each,
pointing at docs/recipes/conditional-add.md for the contract. Every other member
of ICache carries no doc comment at all, and the reference docs had drifted from
these ones twice already.

ICache.Compat.cs gains the three token-positional TryAddAsync forwarders, so
cache.TryAddAsync(key, value, ttl, ct) compiles like the SetAsync it is
written next to — the only public API this commit adds. RunUnderLocksAsync
and AcquireLocalLockAsync now resolve the local-lock policy through one
ResolveLocalLock helper instead of two copies of the same expressions.

Tests: 21 added, pinning each of the above — including the two paths where
the contract actually broke (the lock-disabled and lock-timeout local paths),
the NotSupportedException surfacing, and cancellation crossing both tiers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkpKLown2fG6juC2DDiQgS
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Absolute-expiration retries can create entries after their requested deadline.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

docs/recipes/conditional-add.md:1

  • “Exactly once” overstates this fail-closed primitive: an unavailable store or a failed write means nobody claims the key, and a winner can crash before guarded work completes. The recipe itself correctly describes at-most-once semantics, so use that terminology in the title.
# Claim a key exactly once with `TryAddAsync`

src/UiPath.Caching/MultilayerCache.cs:675

  • This call makes TryAddAsync honor CachePolicy.DistributedExpiration and JitterMaxDuration (and the win path later honors LocalExpiration), but those public CachePolicy property docs still enumerate only SetAsync/GetOrAddAsync/RefreshAsync. Update the XML comments in CachePolicy.cs to include TryAddAsync, otherwise generated API documentation incorrectly implies these settings do not affect conditional adds.
        policy ??= _defaultPolicy;
        return TryAddAsync(cacheKey, value, ResolveWriteDuration(policy), policy, token);

tests/UiPath.Caching.Tests/Redis/RedisCacheTryAddTests.cs:188

  • This comment claims telemetry separates lost races from write failures, but both paths leave ret == false and are tracked under the same Misses.Redis.TryAddAsync.* metric. Reword it to describe the separately scoped overall win/non-win rate rather than a distinction the metric cannot provide.
        // Conditional adds must be attributable on their own, not folded into the SetAsync scope,
        // so a lost-race rate is observable separately from a write-failure rate.
        _telemetry.Metrics.Should().ContainSingle(m => m.Name == expectedMetric);
  • Files reviewed: 24/24 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread src/UiPath.Caching/Redis/RedisCache.cs

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The multilayer warning and false-outcome documentation remain inaccurate.

Review details

Suppressed comments (8)

CHANGELOG.md:60

  • The later retry description permits false after this call’s first attempt actually created the key but lost its reply. The release note’s two-way explanation (“already existed” or “write could not be completed”) excludes that unknown-success outcome. State that false means no win was confirmed, not that creation definitely did not occur.
  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

docs/recipes/conditional-add.md:103

  • This omits the retry outcome described below: the same call can successfully create the marker, lose the reply, and return false when its retry sees the marker. Saying “you did not create it” is therefore inaccurate. Document false as an unconfirmed win and include this unknown-success case.
- **`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

docs/recipes/conditional-add.md:48

  • The lost-reply retry case makes this dichotomy incomplete: this invocation may have created the marker even though it receives false. Phrase the branch in terms of lacking a confirmed win so the example matches the documented retry behavior.
            // 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.

docs/recipes/conditional-add.md:1

  • “Exactly once” overstates the contract: the same key can be claimed again after its TTL expires or after RemoveAsync, and the recipe itself describes the marker as expiring. Name this as an atomic claim while the key is absent/retained so readers do not mistake it for durable exactly-once delivery.
# Claim a key exactly once with `TryAddAsync`

docs/reference/interfaces.md:177

  • The documented retry behavior at line 202 allows this call to write the key and still return false when the reply is lost and the retry is refused. Thus false does not prove “you did not create this key” or that the write was incomplete; it means the caller has no confirmed win. Include this unknown-success case in the public contract.
- **`false` is deliberately ambiguous.** It means "you did not create this key" — either it already existed, or the write could not be completed (backing store disconnected, write threw, or the value was a `null`/`default` that the cache has no way to represent). This is fail-closed by design: a caller treating `true` as "I own this key" is never wrongly told it won. The ambiguity is not recoverable: a serialization or command failure also returns `false`, with [`IConnectionState.IsConnected`](#iconnectionstate) still reporting healthy, and [`IDistributedLock.TryAcquireAsync`](#idistributedlock) conflates backend-unavailable with already-held in the same way. Design the `false` branch so that not proceeding is safe; if the two readings must be handled differently, the caller needs a primitive with a richer result than a `bool`.

src/UiPath.Caching.Abstractions/ICache.cs:56

  • The Redis retry path can return false after this call actually created the key: the first SET NX may land, its reply may be lost, and the retry then sees the key and returns false. Therefore “you did not create it” / “write could not be completed” is not the full contract. Describe false as “no win was confirmed,” including an unknown-success outcome, so consumers do not infer that another caller created the key.
    /// <c>true</c> only if this call created the key. <c>false</c> means "you did not create it" —
    /// it existed, or the write could not be completed — and the two are deliberately conflated,
    /// fail-closed. Never deletes. Not a lock: no ownership token, no release. See

src/UiPath.Caching/MultilayerCache.cs:1310

  • This warning describes the recognized NullCache/nested-multilayer configuration as non-excluding, but the new branches deliberately fail closed and never return a local win. That wording can make operators diagnose this as duplicate execution instead of a permanently skipped operation; state that TryAddAsync will return false.
    [LoggerMessage(Level = LogLevel.Warning, Message = "Cache {CacheName} resolved {InnerCacheName} as its distributed tier, which cannot arbitrate a conditional add across nodes, so TryAddAsync on {CacheName} does not exclude other nodes. Check that the intended distributed provider is registered and Enabled.")]

src/UiPath.Caching/Redis/RedisCache.cs:484

  • This calls every false a non-win, but the next sentence documents a counterexample: an initial attempt may create the key, lose its reply, and then return false from the refused retry. Rephrase this as an unconfirmed outcome rather than claiming the write did not win.
    /// the write. <c>false</c> for every non-win — present, disconnected, threw, or unrepresentable.
    /// Safe on the retrying write pipeline: a retry after a lost reply is refused by the key it just
    /// wrote and reports the same <c>false</c> the exception would have.
  • Files reviewed: 24/24 changed files
  • Comments generated: 0 new
  • Review effort level: Balanced

Four findings Sonar raised on this PR's changed lines. All three rules sit at
their default Info severity, so they surface in the IDE and in Sonar's import
but never as build warnings — which is why the build has been clean throughout.

CA1859 on RedisCacheTryAddTests._serializer needs care: narrowing the field to
SystemJsonSerializerProxy as the rule suggests silently breaks the fixture,
because AutoFixture's Inject binds on the argument's *static* type. With a bare
Inject the concrete type gets registered, RedisCache resolves a substitute for
ISerializerProxy<RedisValue> instead of the real serializer, and
TryAdd_writes_a_payload_a_reader_can_deserialize fails with
"JsonException: 'v' is an invalid start of a value" — the payload was written raw
rather than as JSON. Verified by applying the naive form first. The registration
is now pinned with an explicit type argument, with a comment saying why.

CA1816 on both DisposeAsync hooks: xunit v3's IAsyncLifetime derives from
IAsyncDisposable, so the rule fires on what is really a runner-invoked lifecycle
hook. Neither class has a finalizer, so the call is a no-op, but it is a one-liner
and keeps the file clean.

CA2012 on the NSubstitute arrange: suppressed with a pragma and a justification.
NSubstitute intercepts the call and Returns only uses the ValueTask as its
receiver — it is never awaited, so there is no single-consumption hazard.

Scope note: these rules fire 57 times across the repo (42 CA1816, all in tests;
13 CA1859, 2 of them in src; 2 CA2012), and this commit clears the 4 that Sonar
attributed to this PR, leaving 53. The remaining CA1816 and CA2012 hits are the
same two false-positive patterns — xunit lifecycle hooks and NSubstitute arranges
— so silencing them for the test project in .editorconfig would be a better fix
than 40 more SuppressFinalize calls; the 2 src CA1859 hits
(MemorySetCache/RedisSetCache Deserialize returning IReadOnlyCollection<T?> where
List<T?> would do) are legitimate and worth their own change.

Verified: Release build clean (16 warnings, all pre-existing CS0618), 1503/1503
on net8.0 and net10.0, and the 4 findings confirmed gone by temporarily raising
the three rules to warning (57 -> 53).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015RLSnsYiZsqeaHHYYybXW1
Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants