Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

### Changed

- **BREAKING:** an unset expiration no longer means "keep this forever". `CachePolicy` gained
`DefaultDistributedExpiration` (1 hour), applied as the floor under
`CachePolicy.DistributedExpiration` and the providers' `DefaultExpiration` on every write that
carries no caller expiration. Previously the whole chain was nullable with nothing underneath it,
so a provider whose `DefaultExpiration` was set to `null` — in code or bound from configuration —
wrote entries with no TTL into shared storage. The 1 hour that four options classes each declared
as a property initializer now comes from that one constant, so the value is stated once.
Unbounded entries are still available and now have to be asked for: configure a lifetime of
`TimeSpan.MaxValue`, which is what the providers already read as "no TTL" (`SET` with no TTL,
`PERSIST` on refresh). `CacheClock` saturates a duration that would run past the representable
range to `DateTimeOffset.MaxValue` instead of throwing, so `TimeSpan.MaxValue` works on the
deadline path too.

The floor is on the **write** paths only — `MultilayerCacheBase.ResolveWriteDuration`,
`RedisCacheBase.PolicyDuration` / `PolicyDeadline` / `OptionsDeadline` — deliberately not in the
resolved default policy that `CacheClock` is built from, because that same clock materializes the
expiration a *read* found: a key that genuinely has no TTL in Redis must keep reporting
`DateTimeOffset.MaxValue` rather than a fabricated `now + default`.
`MultilayerCacheBase.ResolveWriteDuration` returns `TimeSpan` rather than `TimeSpan?` as a result.
- **BREAKING:** `AddDistributedCache` no longer fails at registration when no bounded default
resolves, because that state no longer exists — an unset default now resolves to the floor. The
`"would store entries without an expiration"` throw is removed; the non-positive check stays,
since a value someone configured as zero or negative is still a real misconfiguration.
`UiPathDistributedCacheOptions.AllowUnboundedEntries` keeps its meaning and is now the only way
to reach an unbounded entry through that adapter without naming a lifetime.
- **BREAKING:** the per-call `expiration` is no longer nullable. Every write on `ICache`,
`ICache<T>`, `IHashCache`, `IHashCache<T>`, `ISetCache`, `ISetCache<T>` and their extension
surfaces takes `TimeSpan` / `DateTimeOffset` instead of `TimeSpan?` / `DateTimeOffset?`. The
Expand Down
5 changes: 4 additions & 1 deletion docs/reference/interfaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,10 @@ That leaves one resolution chain with no redundant state in it:
| passes `expiration` | exactly that value, no jitter |
| omits `expiration` | `CachePolicy.DistributedExpiration`, jittered by `CachePolicy.JitterMaxDuration` |
| omits it, policy has no TTL | the provider's `DefaultExpiration`, jittered |
| omits it, nothing configured | unbounded — `TimeSpan.MaxValue` / `DateTimeOffset.MaxValue`, which the providers store as "no TTL" |
| omits it, nothing configured | `CachePolicy.DefaultDistributedExpiration` — **1 hour**, jittered |
| omits it, a lifetime is configured as `TimeSpan.MaxValue` | unbounded — stored as `DateTimeOffset.MaxValue`, which the providers read as "no TTL" |

The last two rows are the point: **omission never means "keep this forever"**. Every level of the chain is nullable-meaning-*inherit*, and the floor under all of them is a bounded hour, so a provider whose `DefaultExpiration` was left unset — or bound to `null` from configuration — writes an entry that expires rather than one that accumulates in shared storage. Unbounded is still available; it has to be asked for, by configuring a lifetime of `TimeSpan.MaxValue`. The one exception is the `IDistributedCache` adapter, whose `AllowUnboundedEntries` exists to honor that contract's "until removed" literally.

Because the argument can no longer be `null`, there is nothing left for a meaningless value to mean, so it is rejected rather than absorbed: a duration that is not strictly positive, or a deadline at or before the cache's current time, raises `ArgumentOutOfRangeException` with `ParamName` `"expiration"` and nothing is written. `TimeSpan.MaxValue` and `DateTimeOffset.MaxValue` stay valid — they are how the providers spell "no TTL". `CacheExpiration` holds the guard if you need it in your own implementation. The no-op caches (`NullCache`, `NullHashCache`, `NullSetCache`) read no argument at all and so enforce nothing; they keep degrading to "caching is off, carry on".

Expand Down
14 changes: 7 additions & 7 deletions docs/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ Per-topic overrides: add entries to `Topics[]` under `Broadcast:RedisPubSub`. Ea
| Property | Type | Default | Scope | Notes |
|---|---|---|---|---|
| `Enabled` | `bool` | `true` | Per-provider | Enable/disable this two-tier (L1 in-memory + L2 Redis) cache provider. |
| `DefaultExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Default TTL when no per-call or per-policy expiration is set. |
| `DefaultExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Default TTL when no per-call or per-policy expiration is set. `null` means *inherit*, which resolves to `CachePolicy.DefaultDistributedExpiration` (1 h) — it does **not** mean "never expire". For unbounded entries set `TimeSpan.MaxValue`. |
| `Timeout` | `TimeSpan` | `00:00:01` | Per-provider | Max wait for a cache operation before giving up and falling through. |
| `TrackStatistics` | `bool` | `true` | Per-provider | Emit hit/miss/eviction counters via the telemetry provider. |
| `StatisticsFlushInterval` | `TimeSpan` | `00:01:00` | Per-provider | How often statistics are flushed to the telemetry sink. |
Expand Down Expand Up @@ -168,7 +168,7 @@ Per-topic overrides: add entries to `Topics[]` under `Broadcast:RedisPubSub`. Ea
| Property | Type | Default | Scope | Notes |
|---|---|---|---|---|
| `Enabled` | `bool` | `true` | Per-provider | Enable/disable the standalone Redis cache provider. |
| `DefaultExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Default TTL when no per-call or per-policy expiration is set. |
| `DefaultExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Default TTL when no per-call or per-policy expiration is set. `null` means *inherit*, which resolves to `CachePolicy.DefaultDistributedExpiration` (1 h) — it does **not** mean "never expire". For unbounded entries set `TimeSpan.MaxValue`. |
| `KeyPrefix` | `string` | `""` | Per-provider | Prefix prepended to every Redis key before `AppShortName` and the cache key segments. |
| `Timeout` | `TimeSpan` | `00:00:01` | Per-provider | Max wait for a cache operation before giving up and falling through. |
| `ConnectionMonitorEnabled` | `bool?` | `null` | Per-provider | `null` = inherit from `CacheOptions.ConnectionMonitorEnabled`. |
Expand All @@ -185,13 +185,13 @@ Per-topic overrides: add entries to `Topics[]` under `Broadcast:RedisPubSub`. Ea
| Property | Type | Default | Scope | Notes |
|---|---|---|---|---|
| `Enabled` | `bool` | `true` | Per-provider | Enable/disable the in-memory-only cache provider. |
| `DefaultExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Default TTL when no per-call or per-policy expiration is set. |
| `DefaultExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Default TTL when no per-call or per-policy expiration is set. `null` means *inherit*, which resolves to `CachePolicy.DefaultDistributedExpiration` (1 h) — it does **not** mean "never expire". For unbounded entries set `TimeSpan.MaxValue`. |
| `Timeout` | `TimeSpan` | `00:00:01` | Per-provider | Max wait for a cache operation before giving up. |
| `TrackStatistics` | `bool` | `true` | Per-provider | Emit hit/miss/eviction counters via the telemetry provider. |
| `StatisticsFlushInterval` | `TimeSpan` | `00:01:00` | Per-provider | How often statistics are flushed to the telemetry sink. |
| `BroadcastEnable` | `bool` | `false` | Per-provider | Enable broadcast invalidation for this in-memory cache instance. |
| `Topic` | `string?` | `null` | Per-provider | Topic name for invalidation broadcasts; `null` = use `CacheOptions.DefaultTopic`. |
| `LocalMaxExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Cap on in-memory TTL; `null` = no cap (falls back to `DefaultExpiration`). |
| `LocalMaxExpiration` | `TimeSpan?` | `01:00:00` | Per-provider | Cap on in-memory TTL; `null` = no cap (falls back to the resolved `DefaultExpiration`). |
| `ConnectionMonitorEnabled` | `bool?` | `null` | Per-provider | Inert for this provider (no Redis connection); present to satisfy `IMultilayerCacheOptions`. |
| `CacheNullValues` | `bool` | `false` | Per-provider | Persist `null`/empty factory returns as sentinels. |
| `ConnectionMonitorPeriod` | `TimeSpan?` | `00:00:05` | Per-provider | Inert for this provider; present to satisfy `IMultilayerCacheOptions`. |
Expand Down Expand Up @@ -221,8 +221,8 @@ extension rather than bound from configuration.
| `RedisKeyDifferentiator` | `string?` | `null` | Per registration | Fills the slot after `AppShortName` that the application's caches fill with a `RedisTypePrefixes` value. Null uses `DefaultRedisKeyDifferentiator` (`"dh"`). Inert on the `InMemory` tier; a value matching a `RedisTypePrefixes` value is rejected at registration. Prefixes belonging to packages layered on top of `UiPath.Caching` are **not** checked — it cannot see them without depending on them — so avoid those too: `UiPath.Caching.Queue`'s set cache uses `"se"`. |
| `RedisKeyStrategyFactory` | `IRedisKeyStrategyFactory?` | `null` | Per registration | Builds the Redis key, receiving `RedisKeyDifferentiator`. Null inherits the application's `RedisCacheOptions.RedisKeyStrategyFactory`, keeping its `AppShortName`, separator and sharding conventions. Code-only seam. |
| `PolicyName` | `string?` | `null` | Per registration | Named `CachePolicy` applied to the adapter's operations; an unregistered name fails fast at startup. |
| `DefaultEntryExpiration` | `TimeSpan?` | `null` | Per registration | Expiration used when the caller supplies none. `IDistributedCache` treats absent expiration as "until removed"; unless `AllowUnboundedEntries` is set that is mapped to this value, falling back to the backing tier's `DefaultExpiration`. |
| `AllowUnboundedEntries` | `bool` | `false` | Per registration | Honor "no expiration" literally. Off by default: registration fails when no bounded default can be resolved, so shared storage cannot accumulate keys that never expire. |
| `DefaultEntryExpiration` | `TimeSpan?` | `null` | Per registration | Expiration used when the caller supplies none. `IDistributedCache` treats absent expiration as "until removed"; unless `AllowUnboundedEntries` is set that is mapped to this value, falling back to the backing tier's `DefaultExpiration` and then to `CachePolicy.DefaultDistributedExpiration`. |
| `AllowUnboundedEntries` | `bool` | `false` | Per registration | Honor "no expiration" literally. Off by default, and now the only way to reach an unbounded entry through this adapter without naming a lifetime — an unset default resolves to `CachePolicy.DefaultDistributedExpiration` rather than to "until removed". |

Entries are stored as a Redis hash (`data`, `absexp`, `sldexp`) in a keyspace disjoint from the
application's own caches, so `Refresh` reads only the expiration metadata. Keys are always
Expand Down Expand Up @@ -300,7 +300,7 @@ Entries are keyed by string under `Caching:Policies`. `ICache<T>` and `IHashCach
|---|---|---|---|---|
| `LocalExpiration` | `TimeSpan?` | `null` | Per-policy | L1 (in-memory) TTL cap for this policy; `null` = inherit from provider `LocalMaxExpiration`. Effective L1 TTL is `min(entry.Expiration, LocalExpiration)`. |
| `LocalExpirationDisconnected` | `TimeSpan?` | `null` | Per-policy | L1 TTL cap when L2 is disconnected; `null` = inherit from provider `LocalMaxExpirationDisconnected`. |
| `DistributedExpiration` | `TimeSpan?` | `null` | Per-policy | L2 (Redis) entry lifetime; `null` = use provider `DefaultExpiration`. Per-call expiration arguments still take precedence. |
| `DistributedExpiration` | `TimeSpan?` | `null` | Per-policy | L2 (Redis) entry lifetime; `null` = use provider `DefaultExpiration`, and under that `CachePolicy.DefaultDistributedExpiration` (1 h). Per-call expiration arguments still take precedence. Set `TimeSpan.MaxValue` for unbounded. |
| `FactoryTimeout` | `TimeSpan?` | `null` | Per-policy | Max time allowed for the value factory before it is abandoned; `null` = no timeout. |
| `JitterMaxDuration` | `TimeSpan?` | `null` | Per-policy | Max random duration added to the L2 TTL at write time (uniform in `[0, JitterMaxDuration)`); `null` or `00:00:00` disables jitter. Caller-supplied expiration is honored exactly (no jitter). |
| `RehydrateEnabled` | `bool?` | `null` | Per-policy | Master switch for proactive background refresh; `null` = inherit (default off). |
Expand Down
14 changes: 14 additions & 0 deletions src/UiPath.Caching.Abstractions/Config/CachePolicy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ namespace UiPath.Caching;
/// </summary>
public sealed class CachePolicy
{
/// <summary>
/// The L2 lifetime a write inherits when nothing else supplies one: the floor under
/// <see cref="DistributedExpiration"/> and the providers' <c>DefaultExpiration</c>, merged in
/// last so every cache instance resolves to a bounded lifetime.
/// </summary>
/// <remarks>
/// It exists so that "nobody configured a TTL" cannot mean "keep this forever". An unbounded
/// entry in shared storage has to be asked for, by setting a lifetime of
/// <see cref="TimeSpan.MaxValue"/> — which is what the providers already read as "no TTL" —
/// rather than by leaving a nullable unset. Per-call and per-policy values still win; this only
/// answers the case where the whole chain came back empty.
/// </remarks>
public static readonly TimeSpan DefaultDistributedExpiration = TimeSpan.FromHours(1);

/// <summary>
/// Per-policy L1 (in-memory tier) cap. Applied at <c>SetAsync</c> / <c>GetOrAddAsync</c> write
/// time when the L2 is connected, falling back to <see cref="IMultilayerCacheOptions.LocalMaxExpiration"/>
Expand Down
1 change: 1 addition & 0 deletions src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,4 @@ static UiPath.Caching.HashCacheSyncExtensions.Set<T>(this UiPath.Caching.IHashCa
static UiPath.Caching.HashCacheSyncExtensions.Set<T>(this UiPath.Caching.IHashCache<T>! cache, UiPath.Caching.CacheKey cacheKey, System.Collections.Generic.IDictionary<string!, T?>! values, UiPath.Caching.HashCacheEntryOptions options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> bool
static UiPath.Caching.HashCacheSyncExtensions.SetMetadata<T>(this UiPath.Caching.IHashCache<T>! cache, UiPath.Caching.CacheKey cacheKey, System.Collections.Generic.IDictionary<string!, string?>! metadata, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> bool
static UiPath.Caching.HashCacheSyncExtensions.TimeToLive<T>(this UiPath.Caching.IHashCache<T>! cache, UiPath.Caching.CacheKey cacheKey, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.TimeSpan?
static readonly UiPath.Caching.CachePolicy.DefaultDistributedExpiration -> System.TimeSpan
8 changes: 5 additions & 3 deletions src/UiPath.Caching.Queue/InMemoryQueueCacheOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ public sealed class InMemoryQueueCacheOptions : IMemoryCacheOptions

/// <summary>
/// Default whole-set lifetime applied when no explicit expiration or <see cref="CachePolicy"/>
/// expiration is supplied. <see langword="null"/> means the set never expires. Every add
/// re-applies the resolved expiration, matching <see cref="ISetCache"/>.
/// expiration is supplied. <see langword="null"/> means "inherit", which resolves to
/// <see cref="CachePolicy.DefaultDistributedExpiration"/>; to keep a set forever, set
/// <see cref="TimeSpan.MaxValue"/>. Every add re-applies the resolved expiration, matching
/// <see cref="ISetCache"/>.
/// </summary>
public TimeSpan? DefaultExpiration { get; set; } = TimeSpan.FromHours(1);
public TimeSpan? DefaultExpiration { get; set; } = CachePolicy.DefaultDistributedExpiration;

/// <inheritdoc/>
public bool TrackStatistics { get; set; } = true;
Expand Down
5 changes: 4 additions & 1 deletion src/UiPath.Caching.Queue/MultilayerSetCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,10 @@ public void Dispose()

private DateTimeOffset? LocalWriteExpiration(DateTimeOffset? requested, CachePolicy? policy)
{
requested ??= FromTtl(policy?.DistributedExpiration ?? (_inner is NullSetCache ? _defaultExpiration : null));
// With a Redis inner the L2 resolves the lifetime and this only caps L1; memory-only, this
// is the whole answer, so the floor applies here too rather than leaving the set unbounded.
requested ??= FromTtl(policy?.DistributedExpiration
?? (_inner is NullSetCache ? _defaultExpiration ?? CachePolicy.DefaultDistributedExpiration : null));
Comment on lines +229 to +230
return _inner is NullSetCache ? requested : DisconnectedExpiration(requested);
}

Expand Down
19 changes: 16 additions & 3 deletions src/UiPath.Caching/CacheClock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,27 @@ public DateTimeOffset ToDateTimeOffset(TimeSpan? timeSpan)
{
if (_defaultExpiration.HasValue)
{
return _clock.UtcNow.Add(timeSpan ?? _defaultExpiration.Value);
return AddSaturating(timeSpan ?? _defaultExpiration.Value);
}

return timeSpan.HasValue ? _clock.UtcNow.Add(timeSpan.Value) : DateTimeOffset.MaxValue;
return timeSpan.HasValue ? AddSaturating(timeSpan.Value) : DateTimeOffset.MaxValue;
}

public DateTimeOffset ToDateTimeOffset(DateTimeOffset? dateTimeOffset) =>
dateTimeOffset ?? (_defaultExpiration.HasValue ? _clock.UtcNow.Add(_defaultExpiration.Value) : DateTimeOffset.MaxValue);
dateTimeOffset ?? (_defaultExpiration.HasValue ? AddSaturating(_defaultExpiration.Value) : DateTimeOffset.MaxValue);

/// <summary>
/// <see cref="TimeSpan.MaxValue"/> is how a configured lifetime spells "no TTL", so a duration
/// that would run past the representable range lands on <see cref="DateTimeOffset.MaxValue"/> —
/// which the providers already read as "no TTL" — rather than throwing.
/// </summary>
private DateTimeOffset AddSaturating(TimeSpan duration)
{
var now = _clock.UtcNow;
return duration.Ticks > DateTime.MaxValue.Ticks - now.UtcTicks
? DateTimeOffset.MaxValue
: now.Add(duration);
}

public TimeSpan ToTimeSpan(DateTimeOffset? dateTimeOffset)
{
Expand Down
Loading