diff --git a/CHANGELOG.md b/CHANGELOG.md index eee5d46..7cea7d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,6 +101,41 @@ 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). `CacheExpiration.AddSaturating` (new) turns a resolved duration into a + deadline, saturating at `DateTimeOffset.MaxValue` rather than overflowing, and every path that + converts one now goes through it — `CacheClock`, the memory-only set tier, and the rehydrate + write — so `TimeSpan.MaxValue` reaches the providers as the sentinel they read as "no TTL". + Jitter leaves that sentinel alone: jitter spreads an expiry that is coming, and clamping an + unbounded lifetime would put the key back on the `EXPIRE` path. + + 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. + + `GetOrAddAsync` resolves the lifetime **once** and uses it for both the write deadline and the + rehydrate threshold. These were two separate chains, and the floor would otherwise have applied to + only one of them: entries written under the default would expire after an hour while proactive + rehydration, still bottoming out at `null`, never fired. Resolving once also stops a jittered + policy from computing two different lifetimes for the same write. Rehydration is skipped for an + unbounded lifetime, which has no deadline to pre-empt. +- **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`, `IHashCache`, `IHashCache`, `ISetCache`, `ISetCache` and their extension surfaces takes `TimeSpan` / `DateTimeOffset` instead of `TimeSpan?` / `DateTimeOffset?`. The diff --git a/docs/reference/interfaces.md b/docs/reference/interfaces.md index ed74e26..d435571 100644 --- a/docs/reference/interfaces.md +++ b/docs/reference/interfaces.md @@ -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". diff --git a/docs/reference/settings.md b/docs/reference/settings.md index ae27232..88d4acf 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -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. | @@ -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`. | @@ -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`. | @@ -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 @@ -300,7 +300,7 @@ Entries are keyed by string under `Caching:Policies`. `ICache` 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). | diff --git a/src/UiPath.Caching.Abstractions/CacheExpiration.cs b/src/UiPath.Caching.Abstractions/CacheExpiration.cs index 7a5b905..333c602 100644 --- a/src/UiPath.Caching.Abstractions/CacheExpiration.cs +++ b/src/UiPath.Caching.Abstractions/CacheExpiration.cs @@ -55,6 +55,26 @@ public static DateTimeOffset ThrowIfNotFuture(DateTimeOffset expiration, DateTim return expiration; } + /// + /// Adds to , saturating at + /// instead of throwing. + /// + /// + /// is how a configured lifetime spells "no TTL", so every place + /// that turns a resolved duration into a deadline has to land on the sentinel the providers read + /// as "no TTL" rather than overflow on the way there. + /// + public static DateTimeOffset AddSaturating(DateTimeOffset now, TimeSpan duration) + { + // Add advances the wall-clock DateTime, and the result has to keep the UTC instant + // representable too, so the headroom is whichever of the two runs out first. They differ + // whenever the offset is not zero. + var headroom = Math.Min( + DateTime.MaxValue.Ticks - now.DateTime.Ticks, + DateTime.MaxValue.Ticks - now.UtcDateTime.Ticks); + return duration.Ticks > headroom ? DateTimeOffset.MaxValue : now.Add(duration); + } + /// Validates a caller deadline against and returns it as a duration from . /// is at or before . public static TimeSpan ToDuration(DateTimeOffset expiration, DateTimeOffset now, [CallerArgumentExpression(nameof(expiration))] string? paramName = null) => diff --git a/src/UiPath.Caching.Abstractions/Config/CachePolicy.cs b/src/UiPath.Caching.Abstractions/Config/CachePolicy.cs index 6840e47..d4c9182 100644 --- a/src/UiPath.Caching.Abstractions/Config/CachePolicy.cs +++ b/src/UiPath.Caching.Abstractions/Config/CachePolicy.cs @@ -9,6 +9,26 @@ namespace UiPath.Caching; /// public sealed class CachePolicy { + /// + /// The L2 lifetime a write inherits when nothing else supplies one: the floor under + /// and the providers' DefaultExpiration, applied by + /// the write-side resolution so every write carries a bounded lifetime. + /// + /// + /// 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 + /// — 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. + /// + /// It is deliberately not merged into the resolved default policy alongside the other + /// hardcoded defaults. That policy also builds the clock a read materializes an + /// expiration with, and a key that genuinely has no TTL in storage has to keep reporting + /// rather than a fabricated now + default. + /// + /// + public static readonly TimeSpan DefaultDistributedExpiration = TimeSpan.FromHours(1); + /// /// Per-policy L1 (in-memory tier) cap. Applied at SetAsync / GetOrAddAsync write /// time when the L2 is connected, falling back to diff --git a/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt b/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt index cd96e0c..0a83a8b 100644 --- a/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt +++ b/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt @@ -131,6 +131,7 @@ UiPath.Caching.SystemJsonByteSerializerProxy.Serialize(object? value) -> byte[]? UiPath.Caching.SystemJsonByteSerializerProxy.SystemJsonByteSerializerProxy(System.Text.Json.JsonSerializerOptions? options = null) -> void UiPath.Caching.SystemJsonByteSerializerProxy.TryDeserialize(object? value, out T? result) -> bool UiPath.Caching.SystemJsonByteSerializerProxy.TryDeserialize(string? value, out T? result) -> bool +static UiPath.Caching.CacheExpiration.AddSaturating(System.DateTimeOffset now, System.TimeSpan duration) -> System.DateTimeOffset static UiPath.Caching.CacheExpiration.ThrowIfNotFuture(System.DateTimeOffset expiration, System.DateTimeOffset now, string? paramName = null) -> System.DateTimeOffset static UiPath.Caching.CacheExpiration.ThrowIfNotPositive(System.TimeSpan expiration, string? paramName = null) -> System.TimeSpan static UiPath.Caching.CacheExpiration.ToDuration(System.DateTimeOffset expiration, System.DateTimeOffset now, string? paramName = null) -> System.TimeSpan @@ -219,3 +220,4 @@ static UiPath.Caching.HashCacheSyncExtensions.Set(this UiPath.Caching.IHashCa static UiPath.Caching.HashCacheSyncExtensions.Set(this UiPath.Caching.IHashCache! cache, UiPath.Caching.CacheKey cacheKey, System.Collections.Generic.IDictionary! values, UiPath.Caching.HashCacheEntryOptions options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> bool static UiPath.Caching.HashCacheSyncExtensions.SetMetadata(this UiPath.Caching.IHashCache! cache, UiPath.Caching.CacheKey cacheKey, System.Collections.Generic.IDictionary! metadata, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> bool static UiPath.Caching.HashCacheSyncExtensions.TimeToLive(this UiPath.Caching.IHashCache! cache, UiPath.Caching.CacheKey cacheKey, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.TimeSpan? +static readonly UiPath.Caching.CachePolicy.DefaultDistributedExpiration -> System.TimeSpan diff --git a/src/UiPath.Caching.Queue/InMemoryQueueCacheOptions.cs b/src/UiPath.Caching.Queue/InMemoryQueueCacheOptions.cs index c3469ed..a39a23d 100644 --- a/src/UiPath.Caching.Queue/InMemoryQueueCacheOptions.cs +++ b/src/UiPath.Caching.Queue/InMemoryQueueCacheOptions.cs @@ -13,10 +13,12 @@ public sealed class InMemoryQueueCacheOptions : IMemoryCacheOptions /// /// Default whole-set lifetime applied when no explicit expiration or - /// expiration is supplied. means the set never expires. Every add - /// re-applies the resolved expiration, matching . + /// expiration is supplied. means "inherit", which resolves to + /// ; to keep a set forever, set + /// . Every add re-applies the resolved expiration, matching + /// . /// - public TimeSpan? DefaultExpiration { get; set; } = TimeSpan.FromHours(1); + public TimeSpan? DefaultExpiration { get; set; } = CachePolicy.DefaultDistributedExpiration; /// public bool TrackStatistics { get; set; } = true; diff --git a/src/UiPath.Caching.Queue/MultilayerSetCache.cs b/src/UiPath.Caching.Queue/MultilayerSetCache.cs index 55767ef..9dd6332 100644 --- a/src/UiPath.Caching.Queue/MultilayerSetCache.cs +++ b/src/UiPath.Caching.Queue/MultilayerSetCache.cs @@ -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)); return _inner is NullSetCache ? requested : DisconnectedExpiration(requested); } @@ -267,7 +270,7 @@ private async ValueTask InternalAddAsync(CacheKey cacheKey, IEnumerable } private static DateTimeOffset? FromTtl(TimeSpan? ttl) => - ttl.HasValue ? DateTimeOffset.UtcNow.Add(ttl.Value) : null; + ttl.HasValue ? CacheExpiration.AddSaturating(DateTimeOffset.UtcNow, ttl.Value) : null; private static IEnumerable Materialize(IEnumerable items) { diff --git a/src/UiPath.Caching/CacheClock.cs b/src/UiPath.Caching/CacheClock.cs index b841771..a9ee364 100644 --- a/src/UiPath.Caching/CacheClock.cs +++ b/src/UiPath.Caching/CacheClock.cs @@ -20,14 +20,22 @@ 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); + + /// + /// is how a configured lifetime spells "no TTL", so a duration + /// that would run past the representable range lands on — + /// which the providers already read as "no TTL" — rather than throwing. + /// + private DateTimeOffset AddSaturating(TimeSpan duration) => + CacheExpiration.AddSaturating(_clock.UtcNow, duration); public TimeSpan ToTimeSpan(DateTimeOffset? dateTimeOffset) { diff --git a/src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs b/src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs index e88c631..a27f291 100644 --- a/src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs +++ b/src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs @@ -195,12 +195,14 @@ private static ICacheProvider CreateProvider(IServiceProvider sp, string provide } /// - /// Writes without a caller expiration take a default TTL; reject configurations where none resolves, or - /// resolves non-positive, and unbounded entries are not allowed. The fallback is resolved in the order the + /// Writes without a caller expiration take a default TTL; reject configurations where one resolves + /// non-positive, so such a write would expire on arrival. The fallback is resolved in the order the /// backing cache applies it, which differs by case: a named policy passed per-operation beats the tier /// default (), but with no named policy the cache builds its default from /// the tier default as the primary and the factory default as the fallback - /// (), so the tier default wins there. + /// (), so the tier default wins there — and + /// under both, which is why "nothing configured" + /// is no longer a rejectable state. /// private static void EnsureBoundedWrites(IServiceProvider sp, string providerName, UiPathDistributedCacheOptions options) { @@ -227,22 +229,19 @@ private static void EnsureBoundedWrites(IServiceProvider sp, string providerName KnownCacheProviderNames.InMemoryRedis => sp.GetRequiredService>().Value.DefaultExpiration, _ => sp.GetRequiredService>().Value.DefaultExpiration, }; - var fallback = ResolvePolicy(sp, options) is { } named - ? named.DistributedExpiration ?? tierDefault - : tierDefault ?? sp.GetService()?.Default?.DistributedExpiration; - - if (fallback is { } resolved) - { - if (resolved <= TimeSpan.Zero) - { - throw new InvalidOperationException( - $"AddDistributedCache('{providerName}') resolved {resolved} as the expiration for writes without a caller expiration, so they would expire immediately. Correct the provider's DefaultExpiration or the policy's DistributedExpiration, or set UiPathDistributedCacheOptions.DefaultEntryExpiration."); - } - } - else + // Mirrors the cache's own chain, floor included. An unset default no longer resolves to + // "no TTL", so there is nothing left to reject on that account — only a value someone + // actually configured non-positive. + var resolved = ResolvePolicy(sp, options) is { } named + ? named.DistributedExpiration ?? tierDefault ?? CachePolicy.DefaultDistributedExpiration + : tierDefault + ?? sp.GetService()?.Default?.DistributedExpiration + ?? CachePolicy.DefaultDistributedExpiration; + + if (resolved <= TimeSpan.Zero) { throw new InvalidOperationException( - $"AddDistributedCache('{providerName}') would store entries without an expiration: the provider's DefaultExpiration is null and no cache policy supplies DistributedExpiration. Set UiPathDistributedCacheOptions.DefaultEntryExpiration, configure the provider's DefaultExpiration, or set AllowUnboundedEntries."); + $"AddDistributedCache('{providerName}') resolved {resolved} as the expiration for writes without a caller expiration, so they would expire immediately. Correct the provider's DefaultExpiration or the policy's DistributedExpiration, or set UiPathDistributedCacheOptions.DefaultEntryExpiration."); } } diff --git a/src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs b/src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs index 9b5caf5..a73eb0b 100644 --- a/src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs +++ b/src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs @@ -44,13 +44,16 @@ public class UiPathDistributedCacheOptions /// Expiration applied when the caller supplies none. treats absent /// expiration as "until removed"; unless is set, that is mapped /// to this value so shared storage cannot accumulate unbounded keys. Null falls back to the backing - /// tier's default expiration. + /// tier's default expiration, and under that to + /// . /// public TimeSpan? DefaultEntryExpiration { get; set; } /// - /// Honor "no expiration" literally instead of substituting a bounded default. Off by default: - /// registration fails when no bounded default can be resolved. + /// Honor "no expiration" literally instead of substituting a bounded default. Off by default, and + /// now the only way to reach an unbounded entry through this adapter without naming a lifetime: a + /// default left unset resolves to rather + /// than to "until removed". /// public bool AllowUnboundedEntries { get; set; } } diff --git a/src/UiPath.Caching/InMemoryCacheOptions.cs b/src/UiPath.Caching/InMemoryCacheOptions.cs index 5103fe3..5d23abb 100644 --- a/src/UiPath.Caching/InMemoryCacheOptions.cs +++ b/src/UiPath.Caching/InMemoryCacheOptions.cs @@ -8,7 +8,7 @@ public class InMemoryCacheOptions : IMultilayerCacheOptions, IMemoryCacheOptions { public bool Enabled { get; set; } = true; - public TimeSpan? DefaultExpiration { get; set; } = TimeSpan.FromHours(1); + public TimeSpan? DefaultExpiration { get; set; } = CachePolicy.DefaultDistributedExpiration; public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); diff --git a/src/UiPath.Caching/InMemoryRedisCacheOptions.cs b/src/UiPath.Caching/InMemoryRedisCacheOptions.cs index cc777bc..ee05244 100644 --- a/src/UiPath.Caching/InMemoryRedisCacheOptions.cs +++ b/src/UiPath.Caching/InMemoryRedisCacheOptions.cs @@ -8,7 +8,7 @@ public class InMemoryRedisCacheOptions : IMultilayerCacheOptions, IMemoryCacheOp { public bool Enabled { get; set; } = true; - public TimeSpan? DefaultExpiration { get; set; } = TimeSpan.FromHours(1); + public TimeSpan? DefaultExpiration { get; set; } = CachePolicy.DefaultDistributedExpiration; public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(1); diff --git a/src/UiPath.Caching/MultilayerCache.cs b/src/UiPath.Caching/MultilayerCache.cs index 3d0746a..ddcceb0 100644 --- a/src/UiPath.Caching/MultilayerCache.cs +++ b/src/UiPath.Caching/MultilayerCache.cs @@ -80,8 +80,10 @@ public MultilayerCache( { ArgumentNullException.ThrowIfNull(generator); policy ??= _defaultPolicy; - var duration = policy.DistributedExpiration; - var writeExpiration = _clock.ToDateTimeOffset(ResolveWriteDuration(policy)); + // One resolution feeds both the write deadline and the rehydrate threshold: resolving twice + // would jitter twice, and would leave rehydration on the unfloored chain. + var duration = ResolveWriteDuration(policy); + var writeExpiration = _clock.ToDateTimeOffset(duration); return GetOrAddInternalAsync(cacheKey, generator, writeExpiration, duration, policy, token); } @@ -105,8 +107,10 @@ public MultilayerCache( ArgumentNullException.ThrowIfNull(entries); ArgumentNullException.ThrowIfNull(generator); policy ??= _defaultPolicy; - var duration = policy.DistributedExpiration; - var writeExpiration = _clock.ToDateTimeOffset(ResolveWriteDuration(policy)); + // One resolution feeds both the write deadline and the rehydrate threshold: resolving twice + // would jitter twice, and would leave rehydration on the unfloored chain. + var duration = ResolveWriteDuration(policy); + var writeExpiration = _clock.ToDateTimeOffset(duration); return GetOrAddBatchInternalAsync(entries, generator, writeExpiration, duration, policy, token); } @@ -128,7 +132,7 @@ public MultilayerCache( return GetOrAddBatchInternalAsync(entries, generator, writeExpiration, duration, policy ?? _defaultPolicy, token); } - private async ValueTask GetOrAddInternalAsync(CacheKey cacheKey, Func> generator, DateTimeOffset? expiration, TimeSpan? effectiveDuration, CachePolicy policy, CancellationToken token) + private async ValueTask GetOrAddInternalAsync(CacheKey cacheKey, Func> generator, DateTimeOffset? expiration, TimeSpan effectiveDuration, CachePolicy policy, CancellationToken token) { NotCacheableException.ThrowIfNotCacheable(); var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, expiration, token); @@ -150,7 +154,7 @@ public MultilayerCache( return result.Value; } - private void TryRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpiration, T? currentValue, Func> generator, CachePolicy policy, TimeSpan? effectiveDuration) + private void TryRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpiration, T? currentValue, Func> generator, CachePolicy policy, TimeSpan duration) { if (policy.RehydrateEnabled != true || policy.Rehydrate is null) { @@ -160,8 +164,7 @@ private void TryRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpi { return; } - var resolvedDuration = effectiveDuration ?? policy.DistributedExpiration ?? _multiLayerCacheOptions.DefaultExpiration; - if (resolvedDuration is not { } duration || duration <= TimeSpan.Zero) + if (duration <= TimeSpan.Zero || duration == TimeSpan.MaxValue) { return; } @@ -181,7 +184,7 @@ private void TryRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpi // Factory transitions to null: preserve the original deadline so the null doesn't get a fresh TTL window. var rehydrateExpiration = newValue is null ? entryExpiration - : _clock.UtcNow.Add(duration); + : CacheExpiration.AddSaturating(_clock.UtcNow, duration); var rehydrateOptions = _entryBuilder.BuildEntryOptions(originalCacheKey, rehydrateExpiration, ct); var innerCacheDisconnected = GetInnerCacheDisconnected(); var fired = innerCacheDisconnected || await _eventPublisher.CacheSetAsync(rehydrateOptions).ConfigureAwait(false); @@ -201,15 +204,14 @@ private void TryRehydrateBatch( List<(CacheKey CallerKey, TState State, CacheEntryOptions Options, DateTimeOffset Expiration, T? Value)> hits, Func[]>> generator, CachePolicy policy, - TimeSpan? effectiveDuration) + TimeSpan duration) where TState : notnull { if (policy.RehydrateEnabled != true || policy.Rehydrate is null) { return; } - var resolvedDuration = effectiveDuration ?? policy.DistributedExpiration ?? _multiLayerCacheOptions.DefaultExpiration; - if (resolvedDuration is not { } duration || duration <= TimeSpan.Zero) + if (duration <= TimeSpan.Zero || duration == TimeSpan.MaxValue) { return; } @@ -296,7 +298,7 @@ private static TState[] MapReservedKeysToStates(CacheKey[] reservedKeys, var requested = new HashSet(rehydrateStates); var seen = new HashSet(rehydrateStates.Length); - var freshExpiration = _clock.UtcNow.Add(duration); + var freshExpiration = CacheExpiration.AddSaturating(_clock.UtcNow, duration); var groups = new Dictionary Entry, CacheKey CallerKey)>>(); foreach (var pair in produced ?? []) { @@ -375,7 +377,7 @@ private async ValueTask PublishCacheSetEventsAsync(List<(CacheEntryValu KeyValuePair[] entries, Func[]>> generator, DateTimeOffset? expiration, - TimeSpan? effectiveDuration, + TimeSpan effectiveDuration, CachePolicy policy, CancellationToken token) where TState : notnull diff --git a/src/UiPath.Caching/MultilayerCacheBase.cs b/src/UiPath.Caching/MultilayerCacheBase.cs index c8b125b..26225cf 100644 --- a/src/UiPath.Caching/MultilayerCacheBase.cs +++ b/src/UiPath.Caching/MultilayerCacheBase.cs @@ -34,6 +34,11 @@ public abstract class MultilayerCacheBase : IDisposable // Hardcoded fallback values for the lock fields. Merged in as the lowest-priority policy // (after provider-specific + user DefaultCachePolicy) so every cache instance has a fully // resolved Lock — every field non-null — by the time validation runs. + // + // DistributedExpiration is deliberately NOT floored here: this policy feeds CacheClock, which + // also materializes the expiration a READ found, and a key that genuinely has no TTL must keep + // reporting DateTimeOffset.MaxValue rather than a fabricated now+default. The floor belongs on + // the write paths only — see ResolveWriteDuration. private static readonly CachePolicy HardcodedDefaults = new() { Lock = new LockProfile @@ -165,7 +170,9 @@ private static TimeSpan NonNegativeOrFallback(TimeSpan? value, TimeSpan fallback protected static TimeSpan? ApplyJitter(TimeSpan? duration, TimeSpan? maxJitter, DateTimeOffset utcNow) { - if (duration is not { } d || d <= TimeSpan.Zero || maxJitter is not { } max || max <= TimeSpan.Zero) + // TimeSpan.MaxValue means "no TTL", and jitter exists to spread an expiry that never comes. + // Jittering it would clamp it below the sentinel and put the entry back on the EXPIRE path. + if (duration is not { } d || d <= TimeSpan.Zero || d == TimeSpan.MaxValue || maxJitter is not { } max || max <= TimeSpan.Zero) { return duration; } @@ -180,17 +187,24 @@ private static TimeSpan NonNegativeOrFallback(TimeSpan? value, TimeSpan fallback /// /// Resolves the L2 write duration, applying jitter only when the caller did not pass an explicit /// expiration. Resolves the full fallback chain (policy.DistributedExpiration → - /// IMultilayerCacheOptions.DefaultExpiration) BEFORE jittering so the options-default path - /// is jittered too — not just the policy path. + /// IMultilayerCacheOptions.DefaultExpiration → + /// ) BEFORE jittering so the options-default + /// path is jittered too — not just the policy path. The last step is what stops "nobody + /// configured a lifetime" from meaning "keep this forever"; unbounded has to be asked for, by + /// configuring . /// - protected TimeSpan? ResolveWriteDuration(CachePolicy policy, TimeSpan? callerExpiration = null) + protected TimeSpan ResolveWriteDuration(CachePolicy policy, TimeSpan? callerExpiration = null) { - if (callerExpiration is not null) + if (callerExpiration is { } caller) { - return callerExpiration; + return caller; } - var resolved = policy.DistributedExpiration ?? _multiLayerCacheOptions.DefaultExpiration; - return ApplyJitter(resolved, policy.JitterMaxDuration, _clock.UtcNow); + var resolved = policy.DistributedExpiration + ?? _multiLayerCacheOptions.DefaultExpiration + ?? CachePolicy.DefaultDistributedExpiration; + // ApplyJitter is nullable in, nullable out; it only returns null for a null input, so the + // coalesce is the compiler's price for a resolved value rather than a real branch. + return ApplyJitter(resolved, policy.JitterMaxDuration, _clock.UtcNow) ?? resolved; } /// diff --git a/src/UiPath.Caching/MultilayerHashCache.cs b/src/UiPath.Caching/MultilayerHashCache.cs index ec9f225..bce806e 100644 --- a/src/UiPath.Caching/MultilayerHashCache.cs +++ b/src/UiPath.Caching/MultilayerHashCache.cs @@ -74,8 +74,10 @@ public MultilayerHashCache( { ArgumentNullException.ThrowIfNull(generator); policy ??= _defaultPolicy; - var duration = policy.DistributedExpiration; - var writeExpiration = _clock.ToDateTimeOffset(ResolveWriteDuration(policy)); + // One resolution feeds both the write deadline and the rehydrate threshold: resolving twice + // would jitter twice, and would leave rehydration on the unfloored chain. + var duration = ResolveWriteDuration(policy); + var writeExpiration = _clock.ToDateTimeOffset(duration); return GetOrAddInternalAsync(cacheKey, generator, writeExpiration, duration, HashCacheSetOption.KeyReplace, policy, token); } @@ -107,7 +109,7 @@ public MultilayerHashCache( return GetOrAddInternalAsync(cacheKey, generator, writeExpiration, duration, setOption ?? HashCacheSetOption.KeyReplace, policy ?? _defaultPolicy, token); } - private async ValueTask> GetOrAddInternalAsync(CacheKey cacheKey, Func>> generator, DateTimeOffset? expiration, TimeSpan? effectiveDuration, HashCacheSetOption setOption, CachePolicy policy, CancellationToken token) + private async ValueTask> GetOrAddInternalAsync(CacheKey cacheKey, Func>> generator, DateTimeOffset? expiration, TimeSpan effectiveDuration, HashCacheSetOption setOption, CachePolicy policy, CancellationToken token) { NotCacheableException.ThrowIfNotCacheable(); var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, expiration, setOption, token); @@ -128,7 +130,7 @@ public MultilayerHashCache( return result.Value ?? Empty(); } - private void TryHashRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpiration, IDictionary? currentValue, Func>> generator, CachePolicy policy, TimeSpan? effectiveDuration) + private void TryHashRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpiration, IDictionary? currentValue, Func>> generator, CachePolicy policy, TimeSpan duration) { if (policy.RehydrateEnabled != true || policy.Rehydrate is null) { @@ -138,8 +140,7 @@ private void TryHashRehydrate(CacheKey originalCacheKey, DateTimeOffset entry { return; } - var resolvedDuration = effectiveDuration ?? policy.DistributedExpiration ?? _multiLayerCacheOptions.DefaultExpiration; - if (resolvedDuration is not { } duration || duration <= TimeSpan.Zero) + if (duration <= TimeSpan.Zero || duration == TimeSpan.MaxValue) { return; } @@ -159,7 +160,7 @@ private void TryHashRehydrate(CacheKey originalCacheKey, DateTimeOffset entry // Factory transitions to empty: preserve the original deadline so the marker doesn't get a fresh TTL window. var rehydrateExpiration = IsNullOrEmpty(newValue) ? entryExpiration - : _clock.UtcNow.Add(duration); + : CacheExpiration.AddSaturating(_clock.UtcNow, duration); var rehydrateOptions = _entryBuilder.BuildEntryOptions(originalCacheKey, rehydrateExpiration, HashCacheSetOption.KeyReplace, ct); var innerCacheDisconnected = GetInnerCacheDisconnected(); var fired = innerCacheDisconnected || await _eventPublisher.CacheSetAsync(rehydrateOptions).ConfigureAwait(false); diff --git a/src/UiPath.Caching/PublicAPI.Shipped.txt b/src/UiPath.Caching/PublicAPI.Shipped.txt index 58e5a90..2d19ccd 100644 --- a/src/UiPath.Caching/PublicAPI.Shipped.txt +++ b/src/UiPath.Caching/PublicAPI.Shipped.txt @@ -442,7 +442,6 @@ UiPath.Caching.MultilayerCacheBase.GetInnerCacheDisconnected() -> bool UiPath.Caching.MultilayerCacheBase.InvokeFactoryAsync(UiPath.Caching.CacheKey cacheKey, System.Func!>! factory, System.TimeSpan? factoryTimeout, System.Threading.CancellationToken token) -> System.Threading.Tasks.Task! UiPath.Caching.MultilayerCacheBase.MultilayerCacheBase(string! cacheName, object! innerCache, UiPath.Caching.IMemoryCacheFactory! memoryCacheFactory, UiPath.Caching.Broadcast.ITopicFactory! topicFactory, UiPath.Caching.Broadcast.ICacheEventFactory! cacheEventFactory, UiPath.Caching.Telemetry.ICachingTelemetryProvider! telemetryProvider, UiPath.Caching.IMultilayerCacheOptions! multiLayerCacheOptions, UiPath.Caching.IMemoryCacheOptions! memoryOptions, UiPath.Caching.CacheOptions! cacheOptions, UiPath.Caching.Locking.ILocalLock! localLock, UiPath.Caching.Locking.IDistributedLock! distributedLock, UiPath.Caching.ICachePolicyFactory! policyFactory, Microsoft.Extensions.Logging.ILogger! logger) -> void UiPath.Caching.MultilayerCacheBase.Name.get -> string! -UiPath.Caching.MultilayerCacheBase.ResolveWriteDuration(UiPath.Caching.CachePolicy! policy, System.TimeSpan? callerExpiration = null) -> System.TimeSpan? UiPath.Caching.MultilayerCacheBase.RunUnderLocksAsync(UiPath.Caching.CacheKey cacheKey, System.Func>! readCachedAsync, System.Func! isHit, System.Func>! runGeneratorAndStoreAsync, System.Threading.CancellationToken token, UiPath.Caching.LockProfile? policyLock = null) -> System.Threading.Tasks.ValueTask UiPath.Caching.MultilayerCacheBase.Telemetry.get -> UiPath.Caching.Telemetry.ICachingTelemetryProvider! UiPath.Caching.PrefixCacheKeyStrategy diff --git a/src/UiPath.Caching/PublicAPI.Unshipped.txt b/src/UiPath.Caching/PublicAPI.Unshipped.txt index a6fb3b2..93aa4cf 100644 --- a/src/UiPath.Caching/PublicAPI.Unshipped.txt +++ b/src/UiPath.Caching/PublicAPI.Unshipped.txt @@ -14,6 +14,7 @@ UiPath.Caching.Distributed.UiPathDistributedCacheOptions.RedisKeyDifferentiator. UiPath.Caching.Distributed.UiPathDistributedCacheOptions.RedisKeyStrategyFactory.get -> UiPath.Caching.Redis.IRedisKeyStrategyFactory? UiPath.Caching.Distributed.UiPathDistributedCacheOptions.RedisKeyStrategyFactory.set -> void UiPath.Caching.Distributed.UiPathDistributedCacheOptions.UiPathDistributedCacheOptions() -> void +UiPath.Caching.MultilayerCacheBase.ResolveWriteDuration(UiPath.Caching.CachePolicy! policy, System.TimeSpan? callerExpiration = null) -> System.TimeSpan UiPath.Caching.Redis.RedisCacheBase.CallerDeadline(System.DateTimeOffset expiration, string? paramName = null) -> System.DateTimeOffset UiPath.Caching.Redis.RedisCacheBase.CallerDuration(System.DateTimeOffset expiration, string? paramName = null) -> System.TimeSpan UiPath.Caching.Redis.RedisCacheBase.OptionsDeadline(System.DateTimeOffset? expireTime, System.TimeSpan? timeToLive, UiPath.Caching.CachePolicy? policy) -> System.DateTimeOffset diff --git a/src/UiPath.Caching/Redis/RedisCacheBase.cs b/src/UiPath.Caching/Redis/RedisCacheBase.cs index 0710b57..4a7a83d 100644 --- a/src/UiPath.Caching/Redis/RedisCacheBase.cs +++ b/src/UiPath.Caching/Redis/RedisCacheBase.cs @@ -56,28 +56,29 @@ protected void TrackRead(ITelemetryOperation operation, bool hit, RedisKey key) /// /// Write duration for a call that carried no expiration: the policy's L2 TTL, then the - /// cache default, then for "no TTL". + /// cache default, then . Never unbounded + /// by omission — a lifetime of has to be configured to get that. /// protected TimeSpan PolicyDuration(CachePolicy? policy) => - Clock.ToTimeSpan(policy?.DistributedExpiration ?? DefaultExpiration); + policy?.DistributedExpiration ?? DefaultExpiration ?? CachePolicy.DefaultDistributedExpiration; /// /// Write deadline for a call that carried no expiration, resolved the same way as - /// and yielding for "no TTL". + /// . /// protected DateTimeOffset PolicyDeadline(CachePolicy? policy) => - Clock.ToDateTimeOffset(policy?.DistributedExpiration ?? DefaultExpiration); + Clock.ToDateTimeOffset(PolicyDuration(policy)); /// /// Write deadline carried by an entry-options object. keeps /// its lifetime fields nullable — an options object is the one seam where null still - /// means "inherit" — so this resolves ExpireTime, then TimeToLive, then the policy - /// and cache defaults. + /// means "inherit" — so this resolves ExpireTime, then TimeToLive, then the same + /// chain as . /// protected DateTimeOffset OptionsDeadline(DateTimeOffset? expireTime, TimeSpan? timeToLive, CachePolicy? policy) => expireTime.HasValue ? Clock.ToDateTimeOffset(expireTime) - : Clock.ToDateTimeOffset(timeToLive ?? policy?.DistributedExpiration ?? DefaultExpiration); + : Clock.ToDateTimeOffset(timeToLive ?? PolicyDuration(policy)); /// Validates a caller-supplied duration. protected static TimeSpan CallerDuration(TimeSpan expiration, [CallerArgumentExpression(nameof(expiration))] string? paramName = null) => diff --git a/src/UiPath.Caching/Redis/RedisCacheOptions.cs b/src/UiPath.Caching/Redis/RedisCacheOptions.cs index 63c052b..1d9b975 100644 --- a/src/UiPath.Caching/Redis/RedisCacheOptions.cs +++ b/src/UiPath.Caching/Redis/RedisCacheOptions.cs @@ -4,7 +4,7 @@ public class RedisCacheOptions : ICacheOptions { public bool Enabled { get; set; } = true; - public TimeSpan? DefaultExpiration { get; set; } = TimeSpan.FromHours(1); + public TimeSpan? DefaultExpiration { get; set; } = CachePolicy.DefaultDistributedExpiration; public string KeyPrefix { get; set; } = string.Empty; diff --git a/tests/UiPath.Caching.Tests/CacheExpirationTests.cs b/tests/UiPath.Caching.Tests/CacheExpirationTests.cs index aa2abbf..cee4736 100644 --- a/tests/UiPath.Caching.Tests/CacheExpirationTests.cs +++ b/tests/UiPath.Caching.Tests/CacheExpirationTests.cs @@ -10,6 +10,40 @@ namespace UiPath.Caching.Tests; /// public class CacheExpirationTests { + [Fact] + public void AddSaturating_lands_on_MaxValue_instead_of_overflowing() + { + CacheExpiration.AddSaturating(DateTimeOffset.UtcNow, TimeSpan.MaxValue) + .Should().Be(DateTimeOffset.MaxValue); + } + + /// + /// Add advances the wall-clock DateTime, so a positive offset leaves less room than the UTC + /// instant alone suggests. Measuring only the UTC headroom let this through to a throwing Add. + /// + [Theory] + [InlineData(5)] + [InlineData(-5)] + [InlineData(0)] + public void AddSaturating_saturates_near_the_boundary_whatever_the_offset(int offsetHours) + { + var offset = TimeSpan.FromHours(offsetHours); + var now = new DateTimeOffset(DateTime.MaxValue.AddHours(-10), TimeSpan.Zero).ToOffset(offset); + + var act = () => CacheExpiration.AddSaturating(now, TimeSpan.FromHours(9)); + + act.Should().NotThrow().Which.Should().BeOnOrBefore(DateTimeOffset.MaxValue); + } + + [Fact] + public void AddSaturating_adds_normally_when_the_result_fits() + { + var now = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.FromHours(5)); + + CacheExpiration.AddSaturating(now, TimeSpan.FromHours(2)) + .Should().Be(now.AddHours(2)); + } + [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs b/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs index aa5cef4..e78d5e4 100644 --- a/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs +++ b/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs @@ -34,8 +34,14 @@ public void InMemory_tier_works_without_AddMemory() cache.Get("k").Should().Equal(1); } + /// + /// This used to fail fast: a null provider default meant the adapter would store entries with no + /// TTL at all. It resolves to now, so + /// there is nothing left to reject and registration succeeds. Unbounded is still available, but + /// through AllowUnboundedEntries rather than by leaving a nullable unset. + /// [Fact] - public void Null_default_expiration_without_policy_fails_fast() + public void Null_default_expiration_without_policy_is_bounded_by_the_library_default() { var services = new ServiceCollection(); services.AddCaching(b => @@ -45,9 +51,10 @@ public void Null_default_expiration_without_policy_fails_fast() }); using var provider = services.BuildServiceProvider(); - var act = () => provider.GetRequiredService(); + var cache = provider.GetRequiredService(); + cache.Set("k", [1], new DistributedCacheEntryOptions()); - act.Should().Throw().WithMessage("*DefaultExpiration*DistributedExpiration*"); + cache.Get("k").Should().Equal(1); } [Fact] diff --git a/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs b/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs index 92ffa17..d70cfdc 100644 --- a/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs +++ b/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs @@ -29,6 +29,20 @@ private static ValueTask AddMany(MultilayerSetCache sut, CacheKey key, par [Fact] public void Name_is_InMemory() => CreateSut().Name.Should().Be("InMemory"); + /// + /// Memory-only, this tier resolves the whole lifetime, so the configured spelling of unbounded + /// has to survive the conversion to a deadline instead of overflowing on the way. + /// + [Fact] + public async Task Add_with_an_unbounded_default_expiration_stores_the_item() + { + var sut = CreateSut(new InMemoryQueueCacheOptions { DefaultExpiration = TimeSpan.MaxValue }); + + (await sut.AddAsync("k", "a", token: Ct)).Should().BeTrue(); + + (await sut.MembersAsync("k", token: Ct)).Should().BeEquivalentTo(new[] { "a" }); + } + [Fact] public async Task Add_single_deduplicates() { diff --git a/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs b/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs index e621669..f4ecfb1 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs @@ -116,6 +116,75 @@ await _innerCache.Received(1).SetAsync( Arg.Any()); } + /// + /// Nothing in the chain supplies a lifetime, so the write takes + /// rather than being stored unbounded. + /// + [Fact] + public async Task SetAsync_falls_back_to_the_library_default_when_nothing_configures_a_TTL() + { + _options.DefaultExpiration = null; + _fixture.Inject(new CacheOptions { AppShortName = "test" }); + _sut = null; + + _innerCache.SetAsync(_cacheKey, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _topic.PublishAsync(Arg.Any(), Arg.Any()) + .Returns(_ => true); + + await Sut.SetAsync(_cacheKey, "v", (CachePolicy?)null, TestContext.Current.CancellationToken); + + var floor = CachePolicy.DefaultDistributedExpiration; + await _innerCache.Received(1).SetAsync( + _cacheKey, + "v", + Arg.Is(d => d - DateTimeOffset.UtcNow > floor - TimeSpan.FromSeconds(5) && d - DateTimeOffset.UtcNow < floor + TimeSpan.FromSeconds(5)), + Arg.Any(), + Arg.Any()); + } + + /// An unbounded lifetime is still reachable, by configuring it rather than omitting it. + [Fact] + public async Task SetAsync_stores_unbounded_when_the_default_is_configured_TimeSpan_MaxValue() + { + _options.DefaultExpiration = TimeSpan.MaxValue; + _fixture.Inject(new CacheOptions { AppShortName = "test" }); + _sut = null; + + _innerCache.SetAsync(_cacheKey, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _topic.PublishAsync(Arg.Any(), Arg.Any()) + .Returns(_ => true); + + await Sut.SetAsync(_cacheKey, "v", (CachePolicy?)null, TestContext.Current.CancellationToken); + + await _innerCache.Received(1).SetAsync( + _cacheKey, "v", DateTimeOffset.MaxValue, Arg.Any(), Arg.Any()); + } + + /// + /// Jitter spreads an expiry that is coming; an unbounded entry has none. Jittering the sentinel + /// would clamp it under and put the key back on EXPIRE. + /// + [Fact] + public async Task SetAsync_keeps_an_unbounded_default_unbounded_when_the_policy_jitters() + { + _options.DefaultExpiration = TimeSpan.MaxValue; + _fixture.Inject(new CacheOptions { AppShortName = "test" }); + _sut = null; + + _innerCache.SetAsync(_cacheKey, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + _topic.PublishAsync(Arg.Any(), Arg.Any()) + .Returns(_ => true); + + var jittered = new CachePolicy { JitterMaxDuration = TimeSpan.FromMinutes(5) }; + await Sut.SetAsync(_cacheKey, "v", jittered, TestContext.Current.CancellationToken); + + await _innerCache.Received(1).SetAsync( + _cacheKey, "v", DateTimeOffset.MaxValue, Arg.Any(), Arg.Any()); + } + [Fact] public async Task SetAsync_uses_DefaultCachePolicy_DistributedExpiration_when_caller_omits_expiration() { diff --git a/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs b/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs index 1256417..189da2b 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs @@ -89,6 +89,50 @@ public async Task Hit_past_threshold_acquires_distributed_lock_and_invokes_gener generatorTcs.TrySetResult("rehydrated"); } + /// + /// One resolution feeds both the write deadline and the rehydrate threshold. They used to be + /// separate chains: the write took the floor while the threshold bottomed out at null, so an + /// entry written under the library default expired after an hour and was never rehydrated. + /// + [Fact] + public async Task Hit_past_threshold_rehydrates_when_only_the_library_default_supplies_the_duration() + { + var token = testContextAccessor.Current.CancellationToken; + _options.DefaultExpiration = null; + var policy = new CachePolicy + { + RehydrateEnabled = true, + Rehydrate = new RehydrateOptions + { + Threshold = 0.75, + BaseCooldown = TimeSpan.FromSeconds(1), + MaxCooldown = TimeSpan.FromMinutes(5), + TimeoutFraction = 0.5, + Name = "test-profile", + }, + }; + // Five minutes left of the one-hour default is inside the last quarter, so past the threshold. + var aged = new TestCacheEntry { Value = "cached", Expiration = DateTimeOffset.UtcNow.AddMinutes(5) }; + _innerCache.GetCacheEntryAsync(_cacheKey, Arg.Any(), Arg.Any()).Returns(aged); + + var generatorCalls = 0; + Func> generator = _ => + { + Interlocked.Increment(ref generatorCalls); + return Task.FromResult("rehydrated"); + }; + var acquiredLock = Substitute.For(); + _distributedLock.TryAcquireAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(acquiredLock); + _innerCache.SetAsync(_cacheKey, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + var result = await Sut.GetOrAddAsync(_cacheKey, generator, policy, token); + + result.Should().Be("cached"); + await WaitForAsync(() => Volatile.Read(ref generatorCalls) > 0, TimeSpan.FromSeconds(5), token); + } + [Fact] public async Task Rehydrate_writes_value_back_through_inner_cache_on_success() { diff --git a/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs index 03a29b2..292a39d 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs @@ -667,25 +667,36 @@ public async Task Refresh_no_expiration_works_as_expected() actualOffset.Should().Be(expected); } - [Theory] - [InlineData(typeof(TimeSpan))] - [InlineData(typeof(DateTimeOffset))] - [InlineData(typeof(object))] - public async Task Refresh_no_expiration_no_default(Type expirationType) + /// + /// An unset provider default no longer reads as "no TTL": the write inherits + /// , so the key gets a deadline instead of + /// being persisted. + /// + [Fact] + public async Task Refresh_with_no_expiration_and_no_default_takes_the_library_default() { _cacheOptions.DefaultExpiration = null; - if (expirationType == typeof(TimeSpan)) - { - await Sut.RefreshAsync(_cacheKey, policy: null, token: testContextAccessor.Current.CancellationToken); - } - else if (expirationType == typeof(DateTimeOffset)) - { - await Sut.RefreshAsync(_cacheKey, policy: null, token: testContextAccessor.Current.CancellationToken); - } - else - { - await Sut.RefreshAsync(_cacheKey, policy: null, token: testContextAccessor.Current.CancellationToken); - } + DateTime? actualExpiration = default; + _database.KeyExpireAsync(_redisKey, Arg.Any(), Arg.Any()) + .Returns(ci => + { + actualExpiration = ci.Arg(); + return true; + }); + + await Sut.RefreshAsync(_cacheKey, policy: null, token: testContextAccessor.Current.CancellationToken); + + await _database.DidNotReceive().KeyPersistAsync(Arg.Any(), Arg.Any()); + actualExpiration.Should().Be(_now.Add(CachePolicy.DefaultDistributedExpiration).UtcDateTime); + } + + /// Unbounded is still reachable — it just has to be asked for. + [Fact] + public async Task Refresh_persists_the_key_when_the_default_is_configured_unbounded() + { + _cacheOptions.DefaultExpiration = TimeSpan.MaxValue; + + await Sut.RefreshAsync(_cacheKey, policy: null, token: testContextAccessor.Current.CancellationToken); await _database.DidNotReceive().KeyExpireAsync(Arg.Any(), Arg.Any(), Arg.Any()); await _database.Received(1).KeyPersistAsync(Arg.Any(), Arg.Any()); diff --git a/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs index 55d525f..da22a43 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs @@ -965,9 +965,9 @@ public async Task Refresh_HashCacheEntryOptions_no_extended_props() } [Fact] - public async Task Refresh_HashCacheEntryOptions_with_extended_props_no_default_expiration() + public async Task Refresh_HashCacheEntryOptions_with_extended_props_unbounded_default() { - _redisCacheOptions.DefaultExpiration = null; + _redisCacheOptions.DefaultExpiration = TimeSpan.MaxValue; var metadata = _fixture.Create>(); var options = new HashCacheEntryOptions(default, default, metadata); var actual = await Sut.RefreshAsync(_cacheKey, options, token: testContextAccessor.Current.CancellationToken); @@ -981,7 +981,7 @@ public async Task Refresh_HashCacheEntryOptions_with_extended_props_no_default_e [Fact] public async Task Refresh_HashCacheEntryOptions_transaction_fail() { - _redisCacheOptions.DefaultExpiration = null; + _redisCacheOptions.DefaultExpiration = TimeSpan.MaxValue; var metadata = _fixture.Create>(); var options = new HashCacheEntryOptions(default, default, metadata); _transaction.ExecuteAsync(Arg.Any()).Returns(false); @@ -996,7 +996,7 @@ public async Task Refresh_HashCacheEntryOptions_transaction_fail() [Fact] public async Task Refresh_HashCacheEntryOptions_transaction_exception() { - _redisCacheOptions.DefaultExpiration = null; + _redisCacheOptions.DefaultExpiration = TimeSpan.MaxValue; var metadata = _fixture.Create>(); var options = new HashCacheEntryOptions(default, default, metadata); _transaction.ExecuteAsync(Arg.Any()).ThrowsAsync(new Exception());