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
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)

### Changed

- **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
nullable was a redundant third state: each of these members already has a sibling overload with no
`expiration` parameter, and passing `null` meant exactly the same thing as not passing it — fall
back to `CachePolicy.DistributedExpiration`, then the provider's `DefaultExpiration`. It also made
`cache.SetAsync(key, value, null)` ambiguous (`CS0121`) between the `TimeSpan?` and
`DateTimeOffset?` overloads, which is why the forwarders had to spell out `(CachePolicy?)null`.
Callers passing a real value are unaffected; a caller forwarding its own `TimeSpan?` now branches
on it and calls the overload without an expiration for the null case. Nullability stays where it
means *inherit* — `CachePolicy.LocalExpiration` / `DistributedExpiration`, the providers'
`DefaultExpiration`, `HashCacheEntryOptions.ExpireTime` / `TimeToLive` — and on reads, where
`TimeToLiveAsync` / `ExpireTimeAsync` still return `null` for a key with no TTL.
- **BREAKING:** a per-call `expiration` that cannot be honored is now rejected instead of silently
absorbed. A `TimeSpan` that is not strictly positive, or a `DateTimeOffset` at or before the
cache's current time, raises `ArgumentOutOfRangeException` (`ParamName` `"expiration"`) and nothing
is written. Previously such a value was quietly treated as "no expiration" and, on `TryAddAsync`,
answered `false` — the same answer as "somebody else holds the key". With the nullable gone there
is no third state left to carry that meaning, so the argument is refused at the boundary. The new
public `CacheExpiration` helper (`ThrowIfNotPositive`, `ThrowIfNotFuture`, `ToDuration`) holds the
guard for out-of-tree implementations. `TimeSpan.MaxValue` and `DateTimeOffset.MaxValue` remain
valid — they are how the providers spell "no TTL". `NullCache`, `NullHashCache` and `NullSetCache`
read no argument at all and so enforce nothing, keeping "caching is off, carry on".
- **BREAKING:** `ICache.Compat.cs`, `IHashCache.Compat.cs` and `ISetCache.Compat.cs` are gone. The
pre-`CachePolicy` convenience overloads they carried as default interface methods —
`GetAsync<T>(key, token)`, `SetAsync<T>(key, value, expiration, token)`,
Expand Down
8 changes: 5 additions & 3 deletions docs/recipes/conditional-add.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,11 @@ guarantee the method makes.
change either side of the call anyway. `IDistributedLock.TryAcquireAsync` does not either; it
documents backend-unavailable and already-held as the same `null`. If the two readings need
different handling, you need a primitive with a richer result than a `bool`.
- **A non-positive TTL claims nothing.** An expiration that is not in the future returns `false` on
every tier rather than a win, because the entry would be evicted on arrival and the next caller
would be told it won too.
- **A non-positive TTL is a bad argument, not a loss.** `expiration` is non-nullable, so a duration
that is not strictly positive — or a deadline already past — raises `ArgumentOutOfRangeException`
and nothing is written. Returning `false` would be indistinguishable from "somebody else holds the
key", which is exactly the confusion the ambiguity above asks you to design around. To inherit the
policy's TTL, call the overload that has no `expiration` parameter.
- **Caching switched off means nobody wins.** `NullCache.TryAddAsync` returns `false` — it cannot
complete the write — and it is what `ICacheFactory.CreateCache` falls back to when the requested
provider is missing or has `Enabled=false`. That is fail-closed rather than silently at-least-once,
Expand Down
101 changes: 63 additions & 38 deletions docs/reference/interfaces.md

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions src/UiPath.Caching.Abstractions/CacheExpiration.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Runtime.CompilerServices;

namespace UiPath.Caching;

/// <summary>
/// Argument validation for the per-call <c>expiration</c> on the write surface.
/// </summary>
/// <remarks>
/// The write members take a non-nullable <see cref="TimeSpan"/> / <see cref="DateTimeOffset"/>, so
/// there is no <c>null</c> left to absorb a nonsensical value: a caller with nothing to say about
/// lifetime calls the overload that has no <c>expiration</c> parameter and gets
/// <see cref="CachePolicy.DistributedExpiration"/>, then the cache default. What remains is a value
/// the caller meant, and a duration that is not positive — or a deadline that has already passed —
/// cannot be honored, so it is rejected rather than silently swallowed.
/// <para>
/// This does not police the resolved default: a policy or provider default that leaves entries
/// unbounded still yields <see cref="TimeSpan.MaxValue"/> / <see cref="DateTimeOffset.MaxValue"/>,
/// which the providers read as "no TTL". Those two sentinels stay valid inputs here.
/// </para>
/// <para>
/// Enforcement sits in the implementations that honor the lifetime. <see cref="NullCache"/> and its
/// siblings read no argument at all — not the key, not the type, not the expiration — so they keep
/// degrading to "caching is off, carry on" rather than throwing on a value they never look at.
/// </para>
/// </remarks>
public static class CacheExpiration
{
/// <summary>Returns <paramref name="expiration"/>, or throws if it is not a positive duration.</summary>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="expiration"/> is zero or negative.</exception>
public static TimeSpan ThrowIfNotPositive(TimeSpan expiration, [CallerArgumentExpression(nameof(expiration))] string? paramName = null)
{
if (expiration <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(
paramName,
expiration,
"The cache expiration must be a positive duration. To inherit the policy's DistributedExpiration or the cache default, call the overload without an expiration argument.");
}

return expiration;
}

/// <summary>Returns <paramref name="expiration"/>, or throws if it is not later than <paramref name="now"/>.</summary>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="expiration"/> is at or before <paramref name="now"/>.</exception>
public static DateTimeOffset ThrowIfNotFuture(DateTimeOffset expiration, DateTimeOffset now, [CallerArgumentExpression(nameof(expiration))] string? paramName = null)
{
if (expiration <= now)
{
throw new ArgumentOutOfRangeException(
paramName,
expiration,
"The cache expiration must be later than the cache's current time. To inherit the policy's DistributedExpiration or the cache default, call the overload without an expiration argument.");
}

return expiration;
}

/// <summary>Validates a caller deadline against <paramref name="now"/> and returns it as a duration from <paramref name="now"/>.</summary>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="expiration"/> is at or before <paramref name="now"/>.</exception>
public static TimeSpan ToDuration(DateTimeOffset expiration, DateTimeOffset now, [CallerArgumentExpression(nameof(expiration))] string? paramName = null) =>
ThrowIfNotFuture(expiration, now, paramName) - now;
}
24 changes: 12 additions & 12 deletions src/UiPath.Caching.Abstractions/CacheExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,48 +24,48 @@ public static class CacheExtensions
public static ValueTask<T?> GetOrAddAsync<T>(this ICache cache, CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, CancellationToken token = default)
=> cache.GetOrAddAsync<T>(cacheKey, generator, (CachePolicy?)null, token);

public static ValueTask<T?> GetOrAddAsync<T>(this ICache cache, CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, TimeSpan? expiration, CancellationToken token = default)
public static ValueTask<T?> GetOrAddAsync<T>(this ICache cache, CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, TimeSpan expiration, CancellationToken token = default)
=> cache.GetOrAddAsync<T>(cacheKey, generator, expiration, null, token);

public static ValueTask<T?> GetOrAddAsync<T>(this ICache cache, CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, DateTimeOffset? expiration, CancellationToken token = default)
public static ValueTask<T?> GetOrAddAsync<T>(this ICache cache, CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, DateTimeOffset expiration, CancellationToken token = default)
=> cache.GetOrAddAsync<T>(cacheKey, generator, expiration, null, token);

public static ValueTask<bool> SetAsync<T>(this ICache cache, CacheKey cacheKey, T? value, CancellationToken token = default)
=> cache.SetAsync<T>(cacheKey, value, (CachePolicy?)null, token);

public static ValueTask<bool> SetAsync<T>(this ICache cache, CacheKey cacheKey, T? value, TimeSpan? expiration, CancellationToken token = default)
public static ValueTask<bool> SetAsync<T>(this ICache cache, CacheKey cacheKey, T? value, TimeSpan expiration, CancellationToken token = default)
=> cache.SetAsync<T>(cacheKey, value, expiration, null, token);

public static ValueTask<bool> SetAsync<T>(this ICache cache, CacheKey cacheKey, T? value, DateTimeOffset? expiration, CancellationToken token = default)
public static ValueTask<bool> SetAsync<T>(this ICache cache, CacheKey cacheKey, T? value, DateTimeOffset expiration, CancellationToken token = default)
=> cache.SetAsync<T>(cacheKey, value, expiration, null, token);

public static ValueTask<bool> SetAsync<T>(this ICache cache, KeyValuePair<CacheKey, T?>[] keyValues, CancellationToken token = default)
=> cache.SetAsync<T>(keyValues, (CachePolicy?)null, token);

public static ValueTask<bool> SetAsync<T>(this ICache cache, KeyValuePair<CacheKey, T?>[] keyValues, TimeSpan? expiration, CancellationToken token = default)
public static ValueTask<bool> SetAsync<T>(this ICache cache, KeyValuePair<CacheKey, T?>[] keyValues, TimeSpan expiration, CancellationToken token = default)
=> cache.SetAsync<T>(keyValues, expiration, null, token);

public static ValueTask<bool> SetAsync<T>(this ICache cache, KeyValuePair<CacheKey, T?>[] keyValues, DateTimeOffset? expiration, CancellationToken token = default)
public static ValueTask<bool> SetAsync<T>(this ICache cache, KeyValuePair<CacheKey, T?>[] keyValues, DateTimeOffset expiration, CancellationToken token = default)
=> cache.SetAsync<T>(keyValues, expiration, null, token);

/// <inheritdoc cref="ICache.TryAddAsync{T}(CacheKey, T, CachePolicy, CancellationToken)"/>
public static ValueTask<bool> TryAddAsync<T>(this ICache cache, CacheKey cacheKey, T? value, CancellationToken token = default)
=> cache.TryAddAsync<T>(cacheKey, value, (CachePolicy?)null, token);

/// <inheritdoc cref="ICache.TryAddAsync{T}(CacheKey, T, TimeSpan?, CachePolicy, CancellationToken)"/>
public static ValueTask<bool> TryAddAsync<T>(this ICache cache, CacheKey cacheKey, T? value, TimeSpan? expiration, CancellationToken token = default)
/// <inheritdoc cref="ICache.TryAddAsync{T}(CacheKey, T, TimeSpan, CachePolicy, CancellationToken)"/>
public static ValueTask<bool> TryAddAsync<T>(this ICache cache, CacheKey cacheKey, T? value, TimeSpan expiration, CancellationToken token = default)
=> cache.TryAddAsync<T>(cacheKey, value, expiration, null, token);

/// <inheritdoc cref="ICache.TryAddAsync{T}(CacheKey, T, TimeSpan?, CachePolicy, CancellationToken)"/>
public static ValueTask<bool> TryAddAsync<T>(this ICache cache, CacheKey cacheKey, T? value, DateTimeOffset? expiration, CancellationToken token = default)
/// <inheritdoc cref="ICache.TryAddAsync{T}(CacheKey, T, TimeSpan, CachePolicy, CancellationToken)"/>
public static ValueTask<bool> TryAddAsync<T>(this ICache cache, CacheKey cacheKey, T? value, DateTimeOffset expiration, CancellationToken token = default)
=> cache.TryAddAsync<T>(cacheKey, value, expiration, null, token);

public static ValueTask<bool> RefreshAsync<T>(this ICache cache, CacheKey cacheKey, CancellationToken token = default)
=> cache.RefreshAsync<T>(cacheKey, (CachePolicy?)null, token);

public static ValueTask<bool> RefreshAsync<T>(this ICache cache, CacheKey cacheKey, TimeSpan? expiration, CancellationToken token = default)
public static ValueTask<bool> RefreshAsync<T>(this ICache cache, CacheKey cacheKey, TimeSpan expiration, CancellationToken token = default)
=> cache.RefreshAsync<T>(cacheKey, expiration, null, token);

public static ValueTask<bool> RefreshAsync<T>(this ICache cache, CacheKey cacheKey, DateTimeOffset? expiration, CancellationToken token = default)
public static ValueTask<bool> RefreshAsync<T>(this ICache cache, CacheKey cacheKey, DateTimeOffset expiration, CancellationToken token = default)
=> cache.RefreshAsync<T>(cacheKey, expiration, null, token);
}
24 changes: 12 additions & 12 deletions src/UiPath.Caching.Abstractions/CacheOfT.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,21 @@ public ValueTask<bool> ContainsAsync(CacheKey cacheKey, CancellationToken token
public ValueTask<T?> GetOrAddAsync(CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, CancellationToken token = default) =>
_cache.GetOrAddAsync(GetCacheKey(cacheKey), generator, policy: Policy, token: token);

public ValueTask<T?> GetOrAddAsync(CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, TimeSpan? expiration, CancellationToken token = default) =>
public ValueTask<T?> GetOrAddAsync(CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, TimeSpan expiration, CancellationToken token = default) =>
_cache.GetOrAddAsync(GetCacheKey(cacheKey), generator, expiration, Policy, token);

public ValueTask<T?> GetOrAddAsync(CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, DateTimeOffset? expiration, CancellationToken token = default) =>
public ValueTask<T?> GetOrAddAsync(CacheKey cacheKey, Func<CancellationToken, Task<T?>> generator, DateTimeOffset expiration, CancellationToken token = default) =>
_cache.GetOrAddAsync(GetCacheKey(cacheKey), generator, expiration, Policy, token);

public ValueTask<KeyValuePair<TState, T?>[]> GetOrAddAsync<TState>(KeyValuePair<CacheKey, TState>[] entries, Func<TState[], CancellationToken, Task<KeyValuePair<TState, T?>[]>> generator, CancellationToken token = default)
where TState : notnull =>
_cache.GetOrAddAsync<T, TState>(MapKeys(entries), generator, policy: Policy, token: token);

public ValueTask<KeyValuePair<TState, T?>[]> GetOrAddAsync<TState>(KeyValuePair<CacheKey, TState>[] entries, Func<TState[], CancellationToken, Task<KeyValuePair<TState, T?>[]>> generator, TimeSpan? expiration, CancellationToken token = default)
public ValueTask<KeyValuePair<TState, T?>[]> GetOrAddAsync<TState>(KeyValuePair<CacheKey, TState>[] entries, Func<TState[], CancellationToken, Task<KeyValuePair<TState, T?>[]>> generator, TimeSpan expiration, CancellationToken token = default)
where TState : notnull =>
_cache.GetOrAddAsync<T, TState>(MapKeys(entries), generator, expiration, Policy, token);

public ValueTask<KeyValuePair<TState, T?>[]> GetOrAddAsync<TState>(KeyValuePair<CacheKey, TState>[] entries, Func<TState[], CancellationToken, Task<KeyValuePair<TState, T?>[]>> generator, DateTimeOffset? expiration, CancellationToken token = default)
public ValueTask<KeyValuePair<TState, T?>[]> GetOrAddAsync<TState>(KeyValuePair<CacheKey, TState>[] entries, Func<TState[], CancellationToken, Task<KeyValuePair<TState, T?>[]>> generator, DateTimeOffset expiration, CancellationToken token = default)
where TState : notnull =>
_cache.GetOrAddAsync<T, TState>(MapKeys(entries), generator, expiration, Policy, token);

Expand All @@ -74,10 +74,10 @@ private KeyValuePair<CacheKey, TState>[] MapKeys<TState>(KeyValuePair<CacheKey,
public ValueTask<bool> RefreshAsync(CacheKey cacheKey, CancellationToken token = default) =>
_cache.RefreshAsync<T>(GetCacheKey(cacheKey), policy: Policy, token: token);

public ValueTask<bool> RefreshAsync(CacheKey cacheKey, TimeSpan? expiration, CancellationToken token = default) =>
public ValueTask<bool> RefreshAsync(CacheKey cacheKey, TimeSpan expiration, CancellationToken token = default) =>
_cache.RefreshAsync<T>(GetCacheKey(cacheKey), expiration, Policy, token);

public ValueTask<bool> RefreshAsync(CacheKey cacheKey, DateTimeOffset? expiration, CancellationToken token = default) =>
public ValueTask<bool> RefreshAsync(CacheKey cacheKey, DateTimeOffset expiration, CancellationToken token = default) =>
_cache.RefreshAsync<T>(GetCacheKey(cacheKey), expiration, Policy, token);

public ValueTask<bool> RemoveAsync(CacheKey cacheKey, CancellationToken token = default) =>
Expand All @@ -89,29 +89,29 @@ public ValueTask<bool> RemoveAsync(CacheKey[] cacheKeys, CancellationToken token
public ValueTask<bool> SetAsync(CacheKey cacheKey, T? value, CancellationToken token = default) =>
_cache.SetAsync(GetCacheKey(cacheKey), value, policy: Policy, token: token);

public ValueTask<bool> SetAsync(CacheKey cacheKey, T? value, TimeSpan? expiration, CancellationToken token = default) =>
public ValueTask<bool> SetAsync(CacheKey cacheKey, T? value, TimeSpan expiration, CancellationToken token = default) =>
_cache.SetAsync(GetCacheKey(cacheKey), value, expiration, Policy, token);

public ValueTask<bool> SetAsync(CacheKey cacheKey, T? value, DateTimeOffset? expiration, CancellationToken token = default) =>
public ValueTask<bool> SetAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CancellationToken token = default) =>
_cache.SetAsync(GetCacheKey(cacheKey), value, expiration, Policy, token);

public ValueTask<bool> TryAddAsync(CacheKey cacheKey, T? value, CancellationToken token = default) =>
_cache.TryAddAsync(GetCacheKey(cacheKey), value, policy: Policy, token: token);

public ValueTask<bool> TryAddAsync(CacheKey cacheKey, T? value, TimeSpan? expiration, CancellationToken token = default) =>
public ValueTask<bool> TryAddAsync(CacheKey cacheKey, T? value, TimeSpan expiration, CancellationToken token = default) =>
_cache.TryAddAsync(GetCacheKey(cacheKey), value, expiration, Policy, token);

public ValueTask<bool> TryAddAsync(CacheKey cacheKey, T? value, DateTimeOffset? expiration, CancellationToken token = default) =>
public ValueTask<bool> TryAddAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CancellationToken token = default) =>
_cache.TryAddAsync(GetCacheKey(cacheKey), value, expiration, Policy, token);


public ValueTask<bool> SetAsync(KeyValuePair<CacheKey, T?>[] keyValues, CancellationToken token = default) =>
_cache.SetAsync(GetKeyValuePairs(keyValues), policy: Policy, token: token);

public ValueTask<bool> SetAsync(KeyValuePair<CacheKey, T?>[] keyValues, TimeSpan? expiration = null, CancellationToken token = default) =>
public ValueTask<bool> SetAsync(KeyValuePair<CacheKey, T?>[] keyValues, TimeSpan expiration, CancellationToken token = default) =>
_cache.SetAsync(GetKeyValuePairs(keyValues), expiration, Policy, token);

public ValueTask<bool> SetAsync(KeyValuePair<CacheKey, T?>[] keyValues, DateTimeOffset? expiration = null, CancellationToken token = default) =>
public ValueTask<bool> SetAsync(KeyValuePair<CacheKey, T?>[] keyValues, DateTimeOffset expiration, CancellationToken token = default) =>
_cache.SetAsync(GetKeyValuePairs(keyValues), expiration, Policy, token);

public ValueTask<TimeSpan?> TimeToLiveAsync(CacheKey cacheKey, CancellationToken token = default) =>
Expand Down
Loading