Skip to content

feat(cache)!: bounded default expiration and one TimeProvider clock - #151

Open
cosmin-staicu wants to merge 4 commits into
mainfrom
feat/default-expiration-floor
Open

feat(cache)!: bounded default expiration and one TimeProvider clock#151
cosmin-staicu wants to merge 4 commits into
mainfrom
feat/default-expiration-floor

Conversation

@cosmin-staicu

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

Copy link
Copy Markdown
Member

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 under CachePolicy.DistributedExpiration and the providers' DefaultExpiration on every write that names no lifetime. Unbounded is still available, by asking: TimeSpan.MaxValue. AddDistributedCache stops throwing for a null default, since that state no longer exists.

DefaultExpiration Before After
left at its initializer 1 h 1 h
set to null (code or config) no TTL 1 h
TimeSpan.MaxValue overflow no TTL

What else is in here

  • One conversion, saturating. TimeProvider.ToDateTimeOffset(...) is the only place a lifetime becomes an expiration; it saturates at DateTimeOffset.MaxValue, so TimeSpan.MaxValue means "no TTL" on every surface. Nine hand-rolled UtcNow.Add(...) sites, two pre-clamps and their ClockDriftSlack constants are gone. CacheExpiration.ToDuration maps the sentinel back, so the round trip is lossless.
  • One clock. System.TimeProvider, registered by AddCaching as TimeProvider.System unless the container has one, injected everywhere. All per-options Clock properties are removed; the MemoryCache judges deadlines on the same instance that computed them. Second commit.
  • Reads report what storage holds. The clock fills no default, so a key with no TTL reads as DateTimeOffset.MaxValue and agrees with ExpireTimeAsync. Side effect: the L1 copy of a no-TTL key no longer turns over every DefaultExpiration; LocalMaxExpiration is the cap for that.
  • Rehydration. The write and the rehydrate threshold share one floored resolution; only writes are jittered, and a caller-supplied lifetime is rehydrated exactly.
  • Helpers on one vocabulary. PolicyDeadline / CallerDeadline / OptionsDeadline are GetExpiration overloads on both base classes.
  • Set cache. MultilayerSetCache takes an IMultilayerSetCacheOptions instead of thirteen arguments; InMemoryRedisQueueCacheOptions.DefaultExpiration outranks the Redis tier's default like the other multilayer options do.
  • Removed. The [Obsolete] options properties (PrimaryMaxExpiration, PrimaryMaxExpirationDisconnected, UsePrimaryOnlyWhenDisconnected, ThreadPoolSocketManager) and CacheClock.

Compatibility

Breaking, 2.0.0 material. Migration: DefaultExpiration = nullTimeSpan.MaxValue if unbounded was intended; an ISystemClock registration → a TimeProvider; the old Primary* keys → Local*. Details per symbol in CHANGELOG.

Test plan

  • dotnet test: 1566 passed / 0 failed / 10 skipped, net8.0 and net10.0, Debug and Release.
  • Per-surface tests for per-call 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.*.txt and CHANGELOG updated; sample settings and docs updated.

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.

🤖 Generated with Claude Code

https://claude.ai/code/session_011QeZZPMo8bFYz98AuPZU2V

@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 3, 2026
@github-actions

github-actions Bot commented Sep 3, 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)
  • changes shipped public API — possible removal/breaking change (PublicAPI.Shipped.txt in src/UiPath.Caching)

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

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

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.

Comment thread src/UiPath.Caching.Queue/MultilayerSetCache.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCacheBase.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCacheBase.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The 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

Comment thread src/UiPath.Caching.Abstractions/CacheExpiration.cs Outdated
Comment thread src/UiPath.Caching.Abstractions/Config/CachePolicy.cs Outdated
@cosmin-staicu
cosmin-staicu force-pushed the feat/default-expiration-floor branch 2 times, most recently from 0502de9 to f07a415 Compare September 4, 2026 07:49
@cosmin-staicu
cosmin-staicu force-pushed the feat/non-nullable-expiration branch 3 times, most recently from 2d24b12 to de29187 Compare September 4, 2026 11:07
Base automatically changed from feat/non-nullable-expiration to main September 4, 2026 11:19
@cosmin-staicu
cosmin-staicu force-pushed the feat/default-expiration-floor branch 2 times, most recently from a11dbd8 to 6537d87 Compare September 4, 2026 18:19
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot September 5, 2026 07:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

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 ResolveWriteDuration runs 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

Comment thread src/UiPath.Caching.Queue/InMemoryQueueCacheProvider.cs Outdated
Comment thread src/UiPath.Caching.Queue/InMemoryRedisQueueCacheProvider.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread src/UiPath.Caching/MultilayerHashCache.cs Outdated
@cosmin-staicu
cosmin-staicu force-pushed the feat/default-expiration-floor branch 2 times, most recently from 6741300 to 4742252 Compare September 5, 2026 07:23
@cosmin-staicu
cosmin-staicu requested a balanced review from Copilot September 5, 2026 07:28

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

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 GetOrAddAsync overloads 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

Comment thread src/UiPath.Caching.Queue/InMemoryRedisQueueCacheOptions.cs
Comment thread src/UiPath.Caching/MemoryCacheFactory.cs Outdated
Comment thread src/UiPath.Caching/MultilayerCache.cs Outdated
Comment thread src/UiPath.Caching/MultilayerHashCache.cs Outdated
Comment thread CHANGELOG.md
…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>
@cosmin-staicu
cosmin-staicu force-pushed the feat/default-expiration-floor branch from 4742252 to 4694c9f Compare September 5, 2026 08:29
cosmin-staicu and others added 2 commits September 5, 2026 11:57
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>
@cosmin-staicu cosmin-staicu changed the title feat(cache)!: floor the resolved expiration at one hour feat(cache)!: bounded default expiration and one TimeProvider clock Sep 5, 2026
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>
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

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);
Comment thread CHANGELOG.md
Comment on lines +297 to +300
- **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
Comment on lines 61 to 63
public Func<ProfilingSession?>? ProfilingSessionFactory { get; set; }

public ISystemClock? Clock { get; set; }

public Func<ConfigurationOptions, IConnectionMultiplexer>? ConnectionFactory { get; set; }
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.

2 participants