Skip to content

refactor(cache)!: move the interface forwarders to extension methods - #146

Open
cosmin-staicu wants to merge 4 commits into
feat/conditional-add-tryaddasyncfrom
refactor/compat-to-extensions
Open

refactor(cache)!: move the interface forwarders to extension methods#146
cosmin-staicu wants to merge 4 commits into
feat/conditional-add-tryaddasyncfrom
refactor/compat-to-extensions

Conversation

@cosmin-staicu

@cosmin-staicu cosmin-staicu commented Sep 2, 2026

Copy link
Copy Markdown
Member

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.

  • 42 pre-CachePolicy convenience overloads (ICache.Compat.cs, IHashCache.Compat.cs, ISetCache.Compat.cs), each forwarding to the policy-bearing member with policy: null.
  • 59 blocking sync forwarders (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) and SetCacheExtensions (UiPath.Caching.Queue) — the 42 forwarders, same UiPath.Caching namespace as the interfaces so no new using is required. [ExcludeFromCodeCoverage] on the class, matching what the Compat files carried per-member.
  • ICache.Compat.cs, IHashCache.Compat.cs, ISetCache.Compat.cs — deleted. partial comes off ICache / IHashCache / ISetCache, which nothing else extends now. The ICache<T> / IHashCache<T> / ISetCache<T> partials stay — their .Sync.cs halves are unaffected.
  • CachePolicy? policy is now 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).
  • ImplementationsMultilayerCache, RedisCache, MultilayerHashCache, RedisHashCache, MultilayerSetCache, RedisSetCache, NullCache, NullHashCache, NullSetCache, plus the DictionaryCache test fake, all drop the defaults so behavior is identical through the interface or the concrete type. The policy ??= DefaultPolicy bodies are untouched: passing null still resolves the default exactly as before.
  • CacheSyncExtensions, HashCacheSyncExtensions, SetCacheSyncExtensions — the 59 blocking forwarders off the three *.Sync.cs partials. Blocking behavior is unchanged; 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> as well as the interfaces. partial comes off ICache<T> / IHashCache<T> / ISetCache<T>, leaving all three as pure async contracts. This half needed no signature change — the forwarders are distinct names (Get, not GetAsync) rather than overloads of what they forward to, so no instance member shadows them.
  • Docsinterfaces.md code blocks track the new signatures, all four surfaces gain a note on their extension surface; the stale ICache.Compat.cs reference in the multi-key GetOrAddAsync note and the "every parameter after the generator is optional" line in batch-get-or-add.md are corrected.

Why policy had to become required

This is the part worth a second opinion. Making policy required 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, so cache.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 policy required, 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.txt record (142 in Abstractions, 41 in Queue). Three **BREAKING:** CHANGELOG entries under Unreleased.

One call shape does not compile, before or after: Set(pairs) with a single argument, where the KeyValuePair[] overloads carrying TimeSpan? expiration = null and DateTimeOffset? expiration = null tie 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 twin SetAsync(pairs) on ICache<T> has the same wart, so I left both alone rather than widen the break.

Test plan

  • dotnet test1503 passed / 0 failed, net8.0 and net10.0, Debug and Release (rebased onto 63794f7).
  • Debug and Release builds clean; the only warnings are the 8 pre-existing CS0618 StackExchange.Redis obsoletions. Every build error at any point in this refactor was RS0016/RS0017 API-baseline bookkeeping — never a CS error, which is the evidence that call sites are genuinely unchanged.
  • Compile-probed all 82 affected call shapes against the new surface — 23 short policy-free shapes (11 cache, 12 set) and 59 sync shapes, the latter including calls through the concrete Cache<T> and SetCache<T> — to confirm they bind to the extensions rather than silently resolving elsewhere. Probes removed afterwards.
  • Forced GenerateDocumentationFile=true to check the <inheritdoc cref> targets in all six new files — zero CS1574/CS1580. Doc generation is off in this repo, so a bad cref would otherwise fail silently.
  • PublicAPI.Shipped.txt / PublicAPI.Unshipped.txt updated in both packages.
  • CHANGELOG.md updated.

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 in 7b72bd9, separated so they can be reviewed or cherry-picked on their own:

  • ResiliencePipelineFactoryTest.Pipeline_works_as_expected checked that the breaker closes within a fixed 250ms + 4x100ms budget against a DurationOfBreak of 500ms — 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 shape ConnectionStateMonitorTests.WaitUntilAsync already uses.
  • The four tests asserting ThrowAsync<TimeoutException> passed the ambient xunit token as the caller token, but FactoryTimeout.RunAsync only converts cancellation to TimeoutException while that token is uncancelled (when (linkedCts.IsCancellationRequested && !token.IsCancellationRequested)). If the runner cancels it, a raw TaskCanceledException escapes. Each now uses a CancellationTokenSource it owns, matching GetOrAdd_FactoryTimeout_does_not_swallow_caller_cancellation next 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 FactoryTimeout failure, 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.0 failure showed up while working on this — RedisStreamSubjectWriterTests.Unknown_command_quarantine_is_lifted_when_the_connection_reconnects, ~21s against 352ms in isolation, recovered false after its 10s budget. It has no contact with anything this PR changes.

f198c1f closes a real data race found while investigating it: the test flips a captured fail bool 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 its attempts counter through Interlocked/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 a Release preceding WaitAsync is preserved — and ReleaseRetryGate deliberately swallows SemaphoreFullException for 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

  • 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.)

Note

Both commits carry a Signed-off-by trailer. The employer box is left for the author to confirm.

🤖 Generated with Claude Code

https://claude.ai/code/session_015RLSnsYiZsqeaHHYYybXW1

@cosmin-staicu
cosmin-staicu force-pushed the feat/conditional-add-tryaddasync branch from ccfc7eb to 2ae1a9e Compare September 2, 2026 18:25
@github-actions github-actions Bot added the needs-cla-review A maintainer should assess whether a signed CLA is required (see CONTRIBUTING.md) label Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

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, src/UiPath.Caching.Queue, src/UiPath.Caching)
  • changes shipped public API — possible removal/breaking change (PublicAPI.Shipped.txt in src/UiPath.Caching.Abstractions, src/UiPath.Caching.Queue)

Other signals

  • large production change (+618 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.

@cosmin-staicu
cosmin-staicu force-pushed the refactor/compat-to-extensions branch from 450f68a to ad7c491 Compare September 2, 2026 18:30
@cosmin-staicu
cosmin-staicu force-pushed the feat/conditional-add-tryaddasync branch 2 times, most recently from 5fbd334 to ab54312 Compare September 2, 2026 18:46
@cosmin-staicu
cosmin-staicu force-pushed the refactor/compat-to-extensions branch from ad7c491 to bf7b1f6 Compare September 2, 2026 18:56
@cosmin-staicu cosmin-staicu changed the title refactor(cache)!: move the pre-CachePolicy overloads to extension methods refactor(cache)!: move the interface forwarders to extension methods Sep 2, 2026
@cosmin-staicu
cosmin-staicu force-pushed the refactor/compat-to-extensions branch from bf7b1f6 to 3bbf697 Compare September 2, 2026 19:03
@cosmin-staicu
cosmin-staicu force-pushed the feat/conditional-add-tryaddasync branch from ab54312 to 63794f7 Compare September 2, 2026 19:07
@cosmin-staicu
cosmin-staicu force-pushed the refactor/compat-to-extensions branch from 3bbf697 to f198c1f Compare September 2, 2026 19:28
cosmin-staicu and others added 4 commits September 3, 2026 00:29
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
cosmin-staicu force-pushed the refactor/compat-to-extensions branch from f198c1f to 628b6fd Compare September 2, 2026 21:32
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.

1 participant