refactor(cache)!: move the interface forwarders to extension methods - #146
Open
cosmin-staicu wants to merge 4 commits into
Open
refactor(cache)!: move the interface forwarders to extension methods#146cosmin-staicu wants to merge 4 commits into
cosmin-staicu wants to merge 4 commits into
Conversation
cosmin-staicu
requested review from
alinahornet,
cosminvlad and
litheon
as code owners
September 2, 2026 18:25
cosmin-staicu
force-pushed
the
feat/conditional-add-tryaddasync
branch
from
September 2, 2026 18:25
ccfc7eb to
2ae1a9e
Compare
cosmin-staicu
requested review from
alinahornet,
cosminvlad,
litheon,
lucianaparaschivei and
razvalex
as code owners
September 2, 2026 18:25
|
🔎 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
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.
|
cosmin-staicu
force-pushed
the
refactor/compat-to-extensions
branch
from
September 2, 2026 18:30
450f68a to
ad7c491
Compare
cosmin-staicu
force-pushed
the
feat/conditional-add-tryaddasync
branch
2 times, most recently
from
September 2, 2026 18:46
5fbd334 to
ab54312
Compare
cosmin-staicu
force-pushed
the
refactor/compat-to-extensions
branch
from
September 2, 2026 18:56
ad7c491 to
bf7b1f6
Compare
cosmin-staicu
force-pushed
the
refactor/compat-to-extensions
branch
from
September 2, 2026 19:03
bf7b1f6 to
3bbf697
Compare
cosmin-staicu
force-pushed
the
feat/conditional-add-tryaddasync
branch
from
September 2, 2026 19:07
ab54312 to
63794f7
Compare
cosmin-staicu
force-pushed
the
refactor/compat-to-extensions
branch
from
September 2, 2026 19:28
3bbf697 to
f198c1f
Compare
Two unrelated non-determinisms, both surfaced by running the Release suite
under parallel load on net10.0.
ResiliencePipelineFactoryTest.Pipeline_works_as_expected checked that the
circuit closes using a fixed 250ms + 4x100ms budget against a DurationOfBreak
of 500ms — 150ms of slack, measured from wherever the preceding exception loop
happened to finish. Under load the breaker is still open on the fourth probe.
Replaced with the polling shape already used by
ConnectionStateMonitorTests.WaitUntilAsync: 20ms polls under a 30s ceiling, so
a slow agent costs latency rather than a failure. The guard CTS goes from 5s to
30s for the same reason — it exists only to stop a hang.
The four tests asserting ThrowAsync<TimeoutException> passed the ambient xunit
token as the *caller* token. FactoryTimeout.RunAsync only converts cancellation
to TimeoutException while that token is uncancelled:
catch (OperationCanceledException)
when (linkedCts.IsCancellationRequested && !token.IsCancellationRequested)
so the assertion depended on the runner not cancelling it, and a raw
TaskCanceledException escapes when it does. Each now uses a CancellationTokenSource
it owns, matching GetOrAdd_FactoryTimeout_does_not_swallow_caller_cancellation
next door. The 50ms FactoryTimeout is what bounds these calls, so dropping the
ambient token cannot hang them; the batch test keeps it on its Task.WhenAny guard.
All four are fixed, not just the one observed failing — the Multilayer, Multilayer
hash and both RedisCacheTests cases share the same defect.
Verified with three consecutive full Release runs, 1498/1498 on net8.0 and
net10.0, two of them at 3m35s-3m59s against a 1m25s baseline (i.e. heavier load
than the runs that originally flaked). Before: 2 failures in 5 runs.
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>
…hods ICache.Compat.cs, IHashCache.Compat.cs and ISetCache.Compat.cs carried 42 convenience overloads as default interface methods — GetAsync<T>(key, token), SetAsync<T>(key, value, expiration, token), TryAddAsync<T>(key, value, token), AddAsync<T>(key, item, token), PopAsync<T>(key, token) and so on — each forwarding to the policy-bearing member with policy: null. They are now extension methods on CacheExtensions, HashCacheExtensions and SetCacheExtensions, in the same UiPath.Caching namespace, so no call site needed an edit. The interfaces shrink to just the policy-bearing members. An implementation now writes one member per operation instead of one plus an inherited forwarder it could accidentally override, and no implementation in the repo had declared the forwarders, so nothing had to be rewritten. CachePolicy? policy also becomes *required* on every member that takes one, along with the expiration / setOption parameters that precede it (C# forbids an optional parameter before a required one). This is what makes the extensions load-bearing rather than decorative: instance members always beat extension members in overload resolution, so while the interfaces still declared policy = null an applicable interface overload existed for every short call and the extensions were unreachable. With policy required there is exactly one way to spell each call, and an implementation no longer gets to declare its own default for "no policy". Implementations drop the defaults too — MultilayerCache, RedisCache, their hash counterparts, MultilayerSetCache, RedisSetCache, NullCache, NullHashCache, NullSetCache — so behavior is identical whether the call goes through the interface or the concrete type. The policy ??= DefaultPolicy bodies are untouched, so passing null still resolves the default exactly as before. The typed ICache<T> / IHashCache<T> / ISetCache<T> facades are unchanged; they never had a policy parameter. Binary-breaking for external implementors: 127 entries leave PublicAPI.Shipped.txt across the two packages. Verified: Debug and Release builds clean (only the 8 pre-existing CS0618 warnings), 1498/1498 tests on net8.0 and net10.0. Every existing call site compiled unchanged — the only build errors at any point were RS0016/RS0017 baseline bookkeeping. Compile-probed all 23 short call shapes to confirm they bind to the extensions, and forced GenerateDocumentationFile on to confirm the inheritdoc crefs in the three new files resolve (doc generation is off in this repo, so bad crefs fail silently). 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>
Same treatment as the Compat partials, applied to ICacheOfT.Sync.cs, IHashCacheOfT.Sync.cs and ISetCacheOfT.Sync.cs. The 59 blocking forwarders they carried as default interface methods — Get, GetOrAdd, Set, TryAdd, Refresh, Remove, Contains, TimeToLive, ExpireTime, the hash surface's GetItem, GetCacheEntry, GetMetadata and SetMetadata, and the set surface's Add, Pop, Members, ContainsItem, Count, RemoveItem and RemoveItems — now live on CacheSyncExtensions, HashCacheSyncExtensions and SetCacheSyncExtensions. Each still blocks on the async member via .AsTask().GetAwaiter().GetResult(); nothing about the blocking behavior changed. T becomes a method type parameter inferred from the receiver, so call sites are unchanged, and the forwarders stay reachable through the concrete Cache<T> / HashCache<T> / SetCache<T> classes as well as the interfaces. No implementation declared them, so nothing had to be rewritten. partial comes off ICache<T>, IHashCache<T> and ISetCache<T>, which nothing else extends now. That leaves all three as pure async contracts: an implementation writes only the members it implements rather than inheriting blocking forwarders it could accidentally override. Unlike the Compat move this needs no signature change, because the forwarders are distinct names (Get, not GetAsync) rather than overloads of the members they forward to — so no instance member shadows them. Verified: Release build clean (16 warnings across both TFMs, all pre-existing CS0618), 1503/1503 tests on net8.0 and net10.0. Compile-probed all 59 sync call shapes, including through the concrete Cache<T> and SetCache<T>, then removed the probes. 56 entries leave PublicAPI.Shipped.txt (43 Abstractions, 13 Queue) and 3 leave Unshipped.txt, replaced by 62 extension entries. One call shape does not compile, before or after: Set(pairs) with a single argument, where the KeyValuePair[] overloads with `TimeSpan? expiration = null` and `DateTimeOffset? expiration = null` tie with the token-only overload. That ambiguity is pre-existing and was verified against a default-interface-method reproduction of the old shape — no call site uses it, and the async twin SetAsync(pairs) has the same wart on ICache<T>. 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>
Found while investigating a third intermittent net10.0 failure: RedisStreamSubjectWriterTests.Unknown_command_quarantine_is_lifted_when_the_connection_reconnects flips a captured `fail` bool from the test thread while the writer's fetch loop reads it from its own thread, with no barrier. The neighbouring test in the same file already reads its `attempts` counter through Interlocked/Volatile; this flag was the outlier. Now written with Volatile.Write before the retry-gate release and read with Volatile.Read, so a thread observing the release also observes the flip. This does NOT fix the flake. The test was still seen failing under parallel load after this change, with `recovered` false after its 10s budget and the same ~21s duration. Two hypotheses are ruled out: the wake is not lost (the retry gate is a SemaphoreSlim, so a Release preceding WaitAsync is preserved — and ReleaseRetryGate deliberately swallows SemaphoreFullException for exactly that case), and it is not this data race. I could not capture the assertion message: the failure did not reproduce in five subsequent loaded runs, so the root cause is still open. Committing the race fix on its own merits rather than leaving an unsynchronized cross-thread flag in place. 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>
cosmin-staicu
force-pushed
the
refactor/compat-to-extensions
branch
from
September 2, 2026 21:32
f198c1f to
628b6fd
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on #144 — base branch is
feat/conditional-add-tryaddasync, so review that one first and this diff shows only the refactor on top.Six partials across the cache interfaces held 101 default interface methods that were pure forwarders — no behavior of their own, each delegating to a real member. They are now extension methods, which is where such forwarders belong: the interfaces shrink to the operations an implementation actually has to implement, and an implementor writes one member per operation instead of one plus an inherited forwarder they could accidentally override.
CachePolicyconvenience overloads (ICache.Compat.cs,IHashCache.Compat.cs,ISetCache.Compat.cs), each forwarding to the policy-bearing member withpolicy: null.ICacheOfT.Sync.cs,IHashCacheOfT.Sync.cs,ISetCacheOfT.Sync.cs), each blocking on the async member via.AsTask().GetAwaiter().GetResult().No call site in the repo needed an edit.
Changes
CacheExtensions,HashCacheExtensions(UiPath.Caching.Abstractions) andSetCacheExtensions(UiPath.Caching.Queue) — the 42 forwarders, sameUiPath.Cachingnamespace as the interfaces so no newusingis required.[ExcludeFromCodeCoverage]on the class, matching what theCompatfiles carried per-member.ICache.Compat.cs,IHashCache.Compat.cs,ISetCache.Compat.cs— deleted.partialcomes offICache/IHashCache/ISetCache, which nothing else extends now. TheICache<T>/IHashCache<T>/ISetCache<T>partials stay — their.Sync.cshalves are unaffected.CachePolicy? policyis now required on every member that takes one, along with theexpiration/setOptionparameters that precede it (C# forbids an optional parameter before a required one).MultilayerCache,RedisCache,MultilayerHashCache,RedisHashCache,MultilayerSetCache,RedisSetCache,NullCache,NullHashCache,NullSetCache, plus theDictionaryCachetest fake, all drop the defaults so behavior is identical through the interface or the concrete type. Thepolicy ??= DefaultPolicybodies are untouched: passingnullstill resolves the default exactly as before.CacheSyncExtensions,HashCacheSyncExtensions,SetCacheSyncExtensions— the 59 blocking forwarders off the three*.Sync.cspartials. Blocking behavior is unchanged;Tbecomes a method type parameter inferred from the receiver, so call sites are unchanged and the forwarders stay reachable through the concreteCache<T>/HashCache<T>/SetCache<T>as well as the interfaces.partialcomes offICache<T>/IHashCache<T>/ISetCache<T>, leaving all three as pure async contracts. This half needed no signature change — the forwarders are distinct names (Get, notGetAsync) rather than overloads of what they forward to, so no instance member shadows them.interfaces.mdcode blocks track the new signatures, all four surfaces gain a note on their extension surface; the staleICache.Compat.csreference in the multi-keyGetOrAddAsyncnote and the "every parameter after the generator is optional" line inbatch-get-or-add.mdare corrected.Why
policyhad to become requiredThis is the part worth a second opinion. Making
policyrequired is not cosmetic — it is what makes the extensions reachable at all.Instance members always beat extension members in overload resolution. While the interfaces still declared
policy = null, an applicable interface overload existed for every short call, socache.GetAsync<T>(key, token)kept binding to the interface and the extension methods were unreachable dead code. I built that intermediate state and it compiled clean, which is exactly the problem — nothing tells you the extensions are never called.With
policyrequired, no interface overload is applicable to the short forms and there is exactly one way to spell each call. Two follow-on benefits: an implementation no longer gets to declare its own default for "no policy", and the interface stops carrying two spellings of the same operation.What no longer compiles is an interface call that leaned on the defaults to skip the policy slot positionally. Named arguments (
policy:,token:) and the short forms are unaffected.Compatibility
Source-compatible for callers; binary-breaking for external implementors of any of these interfaces, which is what the 183 entries leaving
PublicAPI.Shipped.txtrecord (142 inAbstractions, 41 inQueue). Three**BREAKING:**CHANGELOG entries under Unreleased.One call shape does not compile, before or after:
Set(pairs)with a single argument, where theKeyValuePair[]overloads carryingTimeSpan? expiration = nullandDateTimeOffset? expiration = nulltie with the token-only overload. I verified that ambiguity is pre-existing by reproducing the old shape as default interface methods — it fails identically there. No call site uses it, and the async twinSetAsync(pairs)onICache<T>has the same wart, so I left both alone rather than widen the break.Test plan
dotnet test— 1503 passed / 0 failed,net8.0andnet10.0, Debug and Release (rebased onto63794f7).CS0618StackExchange.Redis obsoletions. Every build error at any point in this refactor wasRS0016/RS0017API-baseline bookkeeping — never aCSerror, which is the evidence that call sites are genuinely unchanged.Cache<T>andSetCache<T>— to confirm they bind to the extensions rather than silently resolving elsewhere. Probes removed afterwards.GenerateDocumentationFile=trueto check the<inheritdoc cref>targets in all six new files — zeroCS1574/CS1580. Doc generation is off in this repo, so a bad cref would otherwise fail silently.PublicAPI.Shipped.txt/PublicAPI.Unshipped.txtupdated in both packages.Also in this PR: two test de-flakes (first commit)
Running the Release suite repeatedly surfaced two pre-existing non-determinisms on
net10.0— 2 failures in 5 runs, a different test each time. Both are unrelated to the refactor and are fixed in7b72bd9, separated so they can be reviewed or cherry-picked on their own:ResiliencePipelineFactoryTest.Pipeline_works_as_expectedchecked that the breaker closes within a fixed250ms + 4x100msbudget against aDurationOfBreakof500ms— 150ms of slack, measured from wherever the preceding exception loop happened to finish. Under parallel load the breaker is still open on the fourth probe. Now polls at 20ms under a 30s ceiling, the shapeConnectionStateMonitorTests.WaitUntilAsyncalready uses.ThrowAsync<TimeoutException>passed the ambient xunit token as the caller token, butFactoryTimeout.RunAsynconly converts cancellation toTimeoutExceptionwhile that token is uncancelled (when (linkedCts.IsCancellationRequested && !token.IsCancellationRequested)). If the runner cancels it, a rawTaskCanceledExceptionescapes. Each now uses aCancellationTokenSourceit owns, matchingGetOrAdd_FactoryTimeout_does_not_swallow_caller_cancellationnext door. All four are fixed, not just the one observed failing.Verified with three consecutive full Release runs, green on both TFMs, two of them at 3m35s–3m59s against a 1m25s baseline — i.e. heavier load than the runs that originally flaked.
One caveat stated plainly: I never captured the original assertion message for the Redis
FactoryTimeoutfailure, so that diagnosis comes from reading the catch filter rather than from the failure text. The filter is a genuine non-determinism either way and the change is a strict improvement, but I would rather flag that than overstate it.An unresolved third flake (
f198c1f)A third intermittent
net10.0failure showed up while working on this —RedisStreamSubjectWriterTests.Unknown_command_quarantine_is_lifted_when_the_connection_reconnects, ~21s against 352ms in isolation,recoveredfalse after its 10s budget. It has no contact with anything this PR changes.f198c1fcloses a real data race found while investigating it: the test flips a capturedfailbool from the test thread while the writer's fetch loop reads it from its own, with no barrier — and the neighbouring test in the same file already reads itsattemptscounter throughInterlocked/Volatile, so this flag was the outlier.That did not fix the flake, and the commit message says so. The test was still seen failing after the change. Two hypotheses are ruled out: the wake is not lost (the retry gate is a
SemaphoreSlim, so aReleaseprecedingWaitAsyncis preserved — andReleaseRetryGatedeliberately swallowsSemaphoreFullExceptionfor exactly that case), and it is not this data race. The failure then declined to reproduce across five subsequent loaded runs, so I could not capture the assertion message and the root cause is still open. The race fix stands on its own merits; the flake needs a separate look.Linked issues
Fixes #
Contributor declaration
git commit -s).Note
Both commits carry a
Signed-off-bytrailer. The employer box is left for the author to confirm.🤖 Generated with Claude Code
https://claude.ai/code/session_015RLSnsYiZsqeaHHYYybXW1