feat(cache)!: bounded default expiration and one TimeProvider clock - #151
feat(cache)!: bounded default expiration and one TimeProvider clock#151cosmin-staicu wants to merge 4 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
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
Unbounded set writes can overflow, jitter changes unbounded semantics, and fallback-based writes disable rehydration.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Introduces a one-hour fallback expiration for writes without a configured TTL while preserving explicit unbounded lifetimes.
Changes:
- Centralizes the default expiration in
CachePolicy. - Applies fallback expiration and saturating deadline conversion.
- Updates registration behavior, tests, API baselines, and documentation.
File summaries
| File | Description |
|---|---|
tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs |
Updates unbounded refresh tests. |
tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs |
Tests bounded and unbounded refreshes. |
tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs |
Tests multilayer fallback behavior. |
tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs |
Updates null-default registration expectations. |
src/UiPath.Caching/Redis/RedisCacheOptions.cs |
References the centralized default. |
src/UiPath.Caching/Redis/RedisCacheBase.cs |
Floors Redis write expirations. |
src/UiPath.Caching/PublicAPI.Unshipped.txt |
Records the updated API. |
src/UiPath.Caching/PublicAPI.Shipped.txt |
Removes the old signature. |
src/UiPath.Caching/MultilayerCacheBase.cs |
Floors multilayer write durations. |
src/UiPath.Caching/InMemoryRedisCacheOptions.cs |
References the centralized default. |
src/UiPath.Caching/InMemoryCacheOptions.cs |
References the centralized default. |
src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs |
Documents adapter expiration semantics. |
src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs |
Revises registration validation. |
src/UiPath.Caching/CacheClock.cs |
Adds saturating deadline conversion. |
src/UiPath.Caching.Queue/MultilayerSetCache.cs |
Floors memory-only set expirations. |
src/UiPath.Caching.Queue/InMemoryQueueCacheOptions.cs |
Centralizes the queue default. |
src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt |
Records the new public field. |
src/UiPath.Caching.Abstractions/Config/CachePolicy.cs |
Defines the one-hour fallback. |
docs/reference/settings.md |
Documents configuration behavior. |
docs/reference/interfaces.md |
Documents expiration resolution. |
CHANGELOG.md |
Records breaking changes. |
Review details
- Files reviewed: 21/21 changed files
- Comments generated: 3
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
34241fb to
0d9b141
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The new saturating addition can still overflow for positive-offset DateTimeOffset values near the representable maximum.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 26/26 changed files
- Comments generated: 2
- Review effort level: Balanced
0502de9 to
f07a415
Compare
2d24b12 to
de29187
Compare
a11dbd8 to
6537d87
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Rehydration uses fresh jitter on cache hits, and queue provider clocks are not propagated to the backing memory cache.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/UiPath.Caching/MultilayerCache.cs:102
- The batch hit path now bases rehydration on a newly sampled jitter value from this invocation, not on the lifetime used when each existing entry was written. Because
ResolveWriteDurationruns before determining whether there are misses, repeated reads can move the rehydrate threshold and factory timeout randomly even when no write happens. Use the deterministic floored policy/default duration for threshold math and reserve jitter sampling for entries actually written.
var duration = ResolveWriteDuration(policy);
var writeExpiration = _clock.ToDateTimeOffset(duration);
return GetOrAddBatchInternalAsync<T, TState>(entries, generator, writeExpiration, duration, policy, token);
- Files reviewed: 46/46 changed files
- Comments generated: 4
- Review effort level: Balanced
6741300 to
4742252
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Rehydration incorrectly jitters explicit caller expirations, and custom DI clocks can still diverge from deadline computation.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
src/UiPath.Caching/MultilayerCache.cs:292
- The batch
GetOrAddAsyncoverloads can supply an explicit expiration, but this fresh rehydrate deadline always adds policy jitter. Explicit caller lifetimes are documented as exact and previously bypassed jitter; preserve that distinction when calculating the batch rehydrate deadline.
var freshExpiration = _clock.ToDateTimeOffset(ApplyJitter(duration, policy.JitterMaxDuration));
- Files reviewed: 51/51 changed files
- Comments generated: 5
- Review effort level: Balanced
…annot mean "forever" `CachePolicy.DefaultDistributedExpiration` (1 hour) is the new floor under `CachePolicy.DistributedExpiration` and the providers' `DefaultExpiration`, applied on every write that carries no caller expiration. The whole chain was nullable with nothing underneath it, so `DefaultExpiration = null` — set in code or bound from configuration — wrote entries with no TTL into shared storage, and `HardcodedDefaults` in `MultilayerCacheBase` already established the pattern for exactly this problem on the lock fields. The 1 hour that four options classes each declared as a property initializer now comes from that one constant. Unbounded entries stay available and now have to be asked for: configure a lifetime of `TimeSpan.MaxValue`, which is already what the providers read as "no TTL" (`SET` with none, `PERSIST` on refresh) and the convention StackExchange.Redis' own `Expiration` struct uses. The floor sits on the write paths only — `MultilayerCacheBase.ResolveWriteDuration`, `RedisCacheBase.PolicyDuration` / `GetExpiration`, and `MultilayerSetCache.LocalWriteExpiration` for the memory-only set cache, which extends neither base. It is deliberately not merged into the resolved default policy, so "nothing configured" stays observable as `null`. `ResolveWriteDuration` returns `TimeSpan` rather than `TimeSpan?` as a result. `AddDistributedCache` loses its "would store entries without an expiration" registration throw: that state no longer exists. The non-positive check stays, since zero or negative is a value someone configured rather than one they left unset, and `AllowUnboundedEntries` keeps its meaning as the adapter's way to honor `IDistributedCache`'s "until removed" literally. One clock. `ICacheClock` (`UtcNow` only, in the abstractions package) is the single source of "now": `AddCaching` registers one `CacheClock` over the container's `ISystemClock` when there is one, else the system clock, and every provider, cache base class, helper, the profiler and the stream maintainer take it from DI. The per-options `Clock` properties are removed: they let each cache carry its own clock while the `IMemoryCache` judging its deadlines ran on the DI-wide one, so a configured clock made L1 entries expire early or late against the clock judging them, and the memory-only set tier read `DateTimeOffset.UtcNow` directly and ignored a clock altogether. One conversion from a lifetime to an expiration. `ICacheClock.ToDateTimeOffset` (an extension in the abstractions package, so a test double that fixes `UtcNow` converts like the real clock) saturates at `DateTimeOffset.MaxValue` instead of throwing, and every site that used to do `UtcNow.Add(duration)` by hand — six per-call write paths, two `ExpireTimeAsync` fallbacks, the L1 cap, the memory-only set tier and the rehydrate write — goes through it. The same per-call `TimeSpan.MaxValue` used to throw from `MultilayerCache.SetAsync`, persist through `RedisCache.SetAsync` and saturate through `MultilayerSetCache.AddAsync`; it now means "no TTL" on every surface. With the downstream add total, the two pre-clamps that protected it are gone: `ApplyJitter` no longer trims to "headroom minus a minute of drift slack" (and takes one clock reading per write fewer; the draw is bounded under the sentinel instead), and `UiPathDistributedCache.Clamp` is deleted. `CacheExpiration.ToDuration` maps `DateTimeOffset.MaxValue` to `TimeSpan.MaxValue`, so the round trip is lossless both ways and the rehydrate guard recognizes a per-call unbounded deadline. Jitter leaves the sentinel alone. `GetOrAddAsync` resolves the lifetime once for both the write and the rehydrate threshold — they were separate chains and the floor reached only the first — and only the write is jittered. `ResolveDuration` (floored, unjittered) feeds the threshold and the rehydrate timeout, so a hit crosses the soft-TTL threshold at the same point on every read instead of against a fresh random draw. The rehydrate write of a policy-derived lifetime is jittered like any other write; a caller-supplied lifetime is rehydrated exactly, as documented. `CacheClock` fills no default. After the floor no write path passed `null`, so the fill was reachable only on reads, where `GetCacheEntryAsync` on a key with no TTL reported `now + DefaultExpiration` while `ExpireTimeAsync` on the same key returned `null`. Reads now report `DateTimeOffset.MaxValue` for such a key. The one observable side effect: the fabricated deadline doubled as a hidden L1 cap, so the L1 copy of a no-TTL key turned over every `DefaultExpiration`; it now lives until a broadcast invalidation or `LocalMaxExpiration` evicts it. `MultilayerSetCache` reads all of its settings from `IMultilayerSetCacheOptions`, implemented by both queue options types, instead of thirteen constructor arguments. `InMemoryRedisQueueCacheOptions.DefaultExpiration` outranks the Redis tier's default for sets written through the provider, as `InMemoryRedisCacheOptions.DefaultExpiration` does for the other caches. The expiration helpers share one vocabulary: `PolicyDeadline`, `CallerDeadline` and `OptionsDeadline` are four `GetExpiration` overloads on both base classes — by policy, caller `TimeSpan`, caller `DateTimeOffset`, `HashCacheEntryOptions` — and the options overload takes the object rather than its two fields. BREAKING CHANGE: `DefaultExpiration = null` now resolves to 1 hour instead of "no TTL"; ask for unbounded with `TimeSpan.MaxValue`. `AddDistributedCache` stops throwing for that configuration. The per-options `Clock` properties are removed (`ICacheOptions` and its three implementations, `RedisConnectionOptions`, both queue options types); `MemoryCacheFactory`, `UiPathDistributedCache`, the six provider constructors, `MultilayerCacheBase`, `RedisCacheBase`, `RedisProfiler` and `RedisStreamHealthMaintainer` take an `ICacheClock`; `CacheClock` loses its `defaultExpiration` parameter and its `DefaultDateTimeOffset()` / `ToTimeSpan(...)` / `ToDateTimeOffset(...)` members (the latter are `ICacheClock` extensions). `MultilayerCacheBase.ResolveWriteDuration` returns `TimeSpan`; `ApplyJitter` is `(TimeSpan, TimeSpan?) -> TimeSpan`; `ResolveDuration` is added. `RedisCacheBase.PolicyDeadline`, `CallerDeadline` and `OptionsDeadline` are `GetExpiration`. The `[Obsolete]` options properties are removed: `PrimaryMaxExpiration`, `PrimaryMaxExpirationDisconnected`, `UsePrimaryOnlyWhenDisconnected` on `IMultilayerCacheOptions` / `InMemoryCacheOptions` / `InMemoryRedisCacheOptions`, and `RedisConnectionOptions.ThreadPoolSocketManager`; configuration bound under the old keys is silently ignored by the binder and has to be renamed. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QeZZPMo8bFYz98AuPZU2V Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
4742252 to
4694c9f
Compare
The previous commit gave the library one clock, `ICacheClock`, registered once by `AddCaching`. The interface was ours only because the natural alternative, `Microsoft.Extensions.Internal.ISystemClock`, lives in an `Internal` namespace and is on its way out. The platform already has the seam: `System.TimeProvider`, in the BCL on both target frameworks, with `TimeProvider.System` as the default. Using it means no library clock type to learn, none to obsolete later, and the abstractions package stays dependency-free. `AddCaching` registers `TimeProvider.System` unless the container already has a `TimeProvider`; every provider, cache base class, helper, the profiler and the stream maintainer take a `TimeProvider`; the lifetime-to-expiration conversion is `TimeProviderExtensions.ToDateTimeOffset`. `ICacheClock` and `CacheClock` are gone. The `MemoryCache` still reads an `ISystemClock`, so the memory-cache factory adapts the one `TimeProvider` into that shape: the same instance judges the deadlines it was used to compute. BREAKING CHANGE: `ICacheClock` and `CacheClock` are removed; every constructor that took an `ICacheClock` takes a `System.TimeProvider`. Registering an `ISystemClock` no longer drives the library's time; register a `TimeProvider`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QeZZPMo8bFYz98AuPZU2V Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
Doc comments and inline notes are cut to what the code and the names do not already say; the CHANGELOG's IDistributedCache entry no longer claims a registration throw that the floor removed, and the jitter section states that the rehydrate threshold measures against the configured lifetime and that a caller-supplied lifetime is rehydrated exactly. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QeZZPMo8bFYz98AuPZU2V Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
The three queue backings bind from the same sections as the core providers into their own options types, which the settings reference never listed. Adds a table each for InMemoryQueueCacheOptions, RedisSetCacheOptions and InMemoryRedisQueueCacheOptions, the one queue-only key to the sample settings, and drops the removed Clock seam and a stale sample path from that page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011QeZZPMo8bFYz98AuPZU2V Signed-off-by: Cosmin Staicu <cosmin.staicu@uipath.com>
|
There was a problem hiding this comment.
🟡 Changes recommended
Unbounded HashReplace writes can retain an existing Redis TTL, and two documentation inconsistencies remain.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 78/78 changed files
- Comments generated: 3
- Review effort level: Balanced
| } | ||
|
|
||
| var expiration = OptionsDeadline(options.ExpireTime, options.TimeToLive, policy); | ||
| var expiration = GetExpiration(options, policy); |
| - **BREAKING:** the per-options `Clock` properties: `ICacheOptions.Clock` and its implementations on | ||
| `InMemoryCacheOptions`, `InMemoryRedisCacheOptions` and `RedisCacheOptions`, | ||
| `RedisConnectionOptions.Clock`, `InMemoryQueueCacheOptions.Clock` and | ||
| `InMemoryRedisQueueCacheOptions.Clock`. Time comes from the one `TimeProvider` in DI — see |
| public Func<ProfilingSession?>? ProfilingSessionFactory { get; set; } | ||
|
|
||
| public ISystemClock? Clock { get; set; } | ||
|
|
||
| public Func<ConfigurationOptions, IConnectionMultiplexer>? ConnectionFactory { get; set; } |



Summary
An unset expiration can no longer mean "keep this forever", and the library reads time from one clock.
CachePolicy.DefaultDistributedExpiration(1 h) is the floor underCachePolicy.DistributedExpirationand the providers'DefaultExpirationon every write that names no lifetime. Unbounded is still available, by asking:TimeSpan.MaxValue.AddDistributedCachestops throwing for anulldefault, since that state no longer exists.DefaultExpirationnull(code or config)TimeSpan.MaxValueWhat else is in here
TimeProvider.ToDateTimeOffset(...)is the only place a lifetime becomes an expiration; it saturates atDateTimeOffset.MaxValue, soTimeSpan.MaxValuemeans "no TTL" on every surface. Nine hand-rolledUtcNow.Add(...)sites, two pre-clamps and theirClockDriftSlackconstants are gone.CacheExpiration.ToDurationmaps the sentinel back, so the round trip is lossless.System.TimeProvider, registered byAddCachingasTimeProvider.Systemunless the container has one, injected everywhere. All per-optionsClockproperties are removed; theMemoryCachejudges deadlines on the same instance that computed them. Second commit.DateTimeOffset.MaxValueand agrees withExpireTimeAsync. Side effect: the L1 copy of a no-TTL key no longer turns over everyDefaultExpiration;LocalMaxExpirationis the cap for that.PolicyDeadline/CallerDeadline/OptionsDeadlineareGetExpirationoverloads on both base classes.MultilayerSetCachetakes anIMultilayerSetCacheOptionsinstead of thirteen arguments;InMemoryRedisQueueCacheOptions.DefaultExpirationoutranks the Redis tier's default like the other multilayer options do.[Obsolete]options properties (PrimaryMaxExpiration,PrimaryMaxExpirationDisconnected,UsePrimaryOnlyWhenDisconnected,ThreadPoolSocketManager) andCacheClock.Compatibility
Breaking, 2.0.0 material. Migration:
DefaultExpiration = null→TimeSpan.MaxValueif unbounded was intended; anISystemClockregistration → aTimeProvider; the oldPrimary*keys →Local*. Details per symbol in CHANGELOG.Test plan
dotnet test: 1566 passed / 0 failed / 10 skipped,net8.0andnet10.0, Debug and Release.TimeSpan.MaxValue, the floor, the deterministic rehydrate threshold, exact rehydration of caller lifetimes, the configured set-cache default reaching L2, and the memory cache judging on the injected clock.PublicAPI.*.txtand CHANGELOG updated; sample settings and docs updated.Contributor declaration
git commit -s).🤖 Generated with Claude Code
https://claude.ai/code/session_011QeZZPMo8bFYz98AuPZU2V