feat(cache): add TryAddAsync conditional add (Redis When.NotExists) - #144
feat(cache): add TryAddAsync conditional add (Redis When.NotExists)#144CalinMPopa wants to merge 3 commits into
Conversation
|
🔎 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
Other signals
Context
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.
|
There was a problem hiding this comment.
🟡 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.
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>
e3ddfa3 to
91e35cc
Compare
There was a problem hiding this comment.
🟡 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,InMemoryRedisCacheProviderreceives this nestedMultilayerCache;TryAddAsyncthen delegates to its process-local arbiter and returnstrueindependently 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,
MemorySetcan then report success for an already-expired entry, returningtruewhile 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.Expirationis validated, but the memory-only write is additionally capped bypolicy.LocalExpiration/LocalMaxExpiration. Those settings can be zero or negative;MemoryCacheSetter.Setstill returnstrueafter 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
91e35cc to
ccfc7eb
Compare
There was a problem hiding this comment.
🟡 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
ccfc7eb to
2ae1a9e
Compare
There was a problem hiding this comment.
🟡 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
falsebroadcast result. The built-in Redis topic implementations returnfalserather 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
2ae1a9e to
5fbd334
Compare
There was a problem hiding this comment.
🟡 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
5fbd334 to
ab54312
Compare
There was a problem hiding this comment.
🟡 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
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>
ab54312 to
63794f7
Compare
There was a problem hiding this comment.
🟡 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
TryAddAsynchonorCachePolicy.DistributedExpirationandJitterMaxDuration(and the win path later honorsLocalExpiration), but those publicCachePolicyproperty docs still enumerate onlySetAsync/GetOrAddAsync/RefreshAsync. Update the XML comments inCachePolicy.csto includeTryAddAsync, 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 == falseand are tracked under the sameMisses.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
There was a problem hiding this comment.
🔵 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
falseafter 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 thatfalsemeans 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
falsewhen its retry sees the marker. Saying “you did not create it” is therefore inaccurate. Documentfalseas 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
falsewhen the reply is lost and the retry is refused. Thusfalsedoes 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
falseafter this call actually created the key: the firstSET NXmay land, its reply may be lost, and the retry then sees the key and returnsfalse. Therefore “you did not create it” / “write could not be completed” is not the full contract. Describefalseas “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 thatTryAddAsyncwill returnfalse.
[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
falsea non-win, but the next sentence documents a counterexample: an initial attempt may create the key, lose its reply, and then returnfalsefrom 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>
|



Summary
Adds
TryAddAsync— a create-if-absent member onICache/ICache<T>that maps to StackExchange.RedisWhen.NotExists(SET key value EX … NX). Until now the library had no conditional-write path at all: every Redis write hardcodedWhen.Always, and the only NX primitive wasIDistributedLock, 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(+ blockingICache<T>.TryAdd) — three overloads each (policy /TimeSpan?/DateTimeOffset?), mirroring the existingSetAsyncshape. Added as default interface methods so existing implementations keep compiling, per the convention set for the 1.3.0ICacheadditions.RedisCache— oneSET … NXcommand, no probe, TTL applied by the same write so a won key is never briefly immortal between the write and a follow-upEXPIRE.MultilayerCache— the L2 arbitrates; L1 population and the invalidation broadcast happen after the win, best-effort. With the L2 disconnected the call returnsfalserather than granting a local-only claim every node would also be granted. The memory-only provider (NullCacheas L2) has no L2 to arbitrate, so the local tier does, serialized by the local lock — which a conditional add takes regardless ofLock.LocalLockEnabled, because here it is the guarantee; a caller that cannot acquire it withinLock.LocalLockTimeoutis told it lost.Cache<T>,NullCache— implementations;NullCachereturnsfalse. 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-closedfalsemeans, andtruethere would hand every caller a claim of exclusive ownership. It is also reached by accident, being whatICacheFactory.CreateCacheresolves to when the requested provider is absent or hasEnabled=false.NullSetCache.AddAsyncinUiPath.Caching.Queue—SADD, the same question — already answeredfalse.MultilayerCacheBase.AcquireLocalLockAsync—private protectedhelper resolving local-lock enablement/timeout the same wayRunUnderLocksAsyncdoes.docs/recipes/conditional-add.md;interfaces.mdgains the contract plus a per-provider table of who arbitrates;concepts.mdplaces it next to the two lock abstractions.interfaces.mddescribed theIHashCache<T>.SetAsync(…, HashCacheEntryOptions, …)overload as offering "conditional set, individual field TTL". It offers neither:HashCacheSetOptionselects write scope (HashReplacemerges fields,KeyReplacedrops 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
falseis fail-closed and deliberately ambiguous — the key already existed, or the write could not be completed (disconnected, threw, or anull/defaultthe cache cannot represent). A caller treatingtrueas "I own this key" is never wrongly told it won. Same conflationIDistributedLock.TryAcquireAsyncalready documents.SetAsyncremoves the key when handed anullwithCacheNullValuesoff,TryAddAsyncreportsfalseand leaves it untouched.true— reporting a loss would strand the entry with no owner until its TTL. Those failures are logged.NotSupportedExceptionrather 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.NX, and all-or-nothing vs. per-key would be a guess. No hash-surface member —HSETNXis per-field and a different shape. The set/queue surface already reports add-vs-already-present fromAddAsync.Test plan
RedisCacheTryAddTests(single NX command, neverWhen.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 realMultilayerCacheoverNullCachewith a liveAsyncKeyedLocalLock— 32 concurrent callers yield exactly one winner),CacheOfTTryAddTests(key strategy and policy snapshot forwarding, blocking facade).dotnet test) — 1327 passed / 0 failed on bothnet8.0andnet10.0.PublicAPI.Unshipped.txtupdated (15 entries)Linked issues
Fixes #
Contributor declaration
git commit -s).Important
The commit on this branch has no
Signed-off-bytrailer, 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 TryAddAsyncA review pass found several paths where a caller could be told
truewithout owning the key. What changed on top of the original commit:AcquireLocalLockAsyncreturnednullboth whenLock.LocalLockEnabledwas 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.TryAddAsyncreturnsfalse(see above).falseon both tiers.IMemoryCacheevicts such an entry on arrival, so the memory tier reported success while retaining nothing and the next caller won too, while Redis answeredfalsefor the same call.SetAsyncdoes.NotSupportedExceptionsurfaces instead of becoming a permanentfalseindistinguishable from contention.false.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.csgains the three token-positionalTryAddAsyncforwarders — the only public API this follow-up adds.IConnectionStateas a way to tell an outage from a lost race, because it is not one: a serialization or command failure returnsfalsewith 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 … NXwrite stays on the sharedWriteresilience pipeline. Retrying it is harmless: an attempt whose reply was lost is refused by the key it just wrote and reports the samefalsethe un-retried exception would have, while an attempt that never reached Redis is recovered as thetrueit should have been. (SPOPis the opposite, which is whyRedisSetCacheOptions.ResilienceKeyNameexists.)