diff --git a/docs/how-to/extending.md b/docs/how-to/extending.md index 60c7fa1..e2c54af 100644 --- a/docs/how-to/extending.md +++ b/docs/how-to/extending.md @@ -286,6 +286,76 @@ That single registration replaces the default JSON serializer for every cache th - **Schema evolution is your problem.** The library does not version cached payloads. If your serializer can't deserialize an older payload after a deploy, `TryDeserialize` should return `false` and the cache will treat that as a miss; the generator will run and write a fresh entry. - **Consider `RedisValue` directly.** `RedisValue` can hold strings, bytes, or numbers without conversion. A binary serializer (MessagePack, ProtoBuf, MemoryPack) should write `byte[]` directly via `RedisValue` implicit conversion rather than going through `string`/Base64 — JSON-style proxies can stay string-based. +### Byte-oriented serializers (`ISerializerProxy`) + +The distributed cache path (`AddDistributedCache`) serializes through `ISerializerProxy` +instead of `ISerializerProxy`. The default, `SystemJsonByteSerializerProxy`, passes +`byte[]` payloads through raw — no base64, no JSON, no extra encoding layer — and JSON-encodes +every other type; the requested type argument decides, there is no format sniffing. Note that the +distributed cache still wraps payloads in its own binary envelope, so the stored values are not +interchangeable with other `IDistributedCache` implementations. + +Swapping it follows the same pattern as the `RedisValue` proxy — and is simpler, because most +binary serializers natively produce `byte[]`: + +```csharp +public sealed class MessagePackByteSerializerProxy(MessagePackSerializerOptions? options = null) + : ISerializerProxy +{ + public byte[]? Serialize(object? value) => + value is null ? null : MessagePackSerializer.Serialize(value.GetType(), value, options); + + public T? Deserialize(byte[]? value) => + value is null or { Length: 0 } ? default : MessagePackSerializer.Deserialize(value, options); + + public bool TryDeserialize(string? value, out T? result) + { + result = default; + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + try + { + result = MessagePackSerializer.Deserialize(Convert.FromBase64String(value), options); + return true; + } + catch + { + return false; + } + } + + public bool TryDeserialize(object? value, out T? result) + { + result = default; + try + { + if (value is byte[] bytes) + { + result = MessagePackSerializer.Deserialize(bytes, options); + return true; + } + return TryDeserialize(value?.ToString(), out result); + } + catch + { + return false; + } + } +} + +services.AddSingleton>(new MessagePackByteSerializerProxy()); +``` + +Two rules for custom implementations: + +1. **Round-trip `byte[]` symmetrically.** The distributed cache stores its envelope as `byte[]`; + raw passthrough (recommended) avoids per-entry encoding overhead, but any symmetric encoding + also works. +2. **This does not change the main caches' wire format** — `ICache`/`IHashCache` still serialize + through `ISerializerProxy`. Swap both registrations if you want one format everywhere. + ## Swapping the default factories `ICacheFactory` and `ICachePolicyFactory` each have a default DI registration set up by `AddCaching` — the concrete `CacheFactory` and `DefaultCachePolicyFactory` respectively. When you need to substitute either, use the fluent `Use*Factory` extensions on `ICachingBuilder`. They internally call `Services.Replace(...)` so the swap survives the rest of the `AddCaching` pipeline (which uses `TryAddSingleton` and would otherwise lose to the default registration). diff --git a/src/UiPath.Caching.Abstractions/CacheKey.cs b/src/UiPath.Caching.Abstractions/CacheKey.cs index 9af14de..9b09a1a 100644 --- a/src/UiPath.Caching.Abstractions/CacheKey.cs +++ b/src/UiPath.Caching.Abstractions/CacheKey.cs @@ -4,21 +4,40 @@ namespace UiPath.Caching; public readonly struct CacheKey : IEquatable { + /// Process-global casing for keys built without an explicit mode; seeded from CacheOptions.KeyCasing. Set only at startup. + public static CacheKeyCasing DefaultCasing { get; set; } = CacheKeyCasing.Insensitive; + public CacheKey() : this(string.Empty) { } - public CacheKey(string? name) => - Name = name?.Trim().ToLowerInvariant() ?? string.Empty; + public CacheKey(string? name) + : this(name, DefaultCasing) + { + } + + public CacheKey(string? name, CacheKeyCasing casing) + { + Casing = casing; + Name = casing == CacheKeyCasing.Insensitive + ? name?.Trim().ToLowerInvariant() ?? string.Empty + : name?.Trim() ?? string.Empty; + } public string Name { get; } + /// Normalization mode this key was built with; not part of equality. + public CacheKeyCasing Casing { get; } + + /// New key from , preserving this key's casing mode. + public CacheKey WithName(string? name) => new(name, Casing); + public override bool Equals(object? obj) => obj is CacheKey cacheKey && Equals(cacheKey); public bool Equals(CacheKey other) => - string.Equals(Name, other.Name, StringComparison.InvariantCultureIgnoreCase); + string.Equals(Name, other.Name, StringComparison.Ordinal); public bool IsNull => string.IsNullOrEmpty(Name); diff --git a/src/UiPath.Caching.Abstractions/CacheKeyCasing.cs b/src/UiPath.Caching.Abstractions/CacheKeyCasing.cs new file mode 100644 index 0000000..768c12b --- /dev/null +++ b/src/UiPath.Caching.Abstractions/CacheKeyCasing.cs @@ -0,0 +1,11 @@ +namespace UiPath.Caching; + +/// How normalizes its name at construction; comparison is always ordinal. +public enum CacheKeyCasing +{ + /// Trim and lowercase (invariant). The historical default. + Insensitive = 0, + + /// Trim only; the caller's casing is preserved. + Sensitive = 1, +} diff --git a/src/UiPath.Caching.Abstractions/CacheKeyComparer.cs b/src/UiPath.Caching.Abstractions/CacheKeyComparer.cs new file mode 100644 index 0000000..0751e77 --- /dev/null +++ b/src/UiPath.Caching.Abstractions/CacheKeyComparer.cs @@ -0,0 +1,27 @@ +namespace UiPath.Caching; + +/// Cached equality comparers: (ordinal, the struct's own equality) and (ordinal, case-folding). +public abstract class CacheKeyComparer : EqualityComparer +{ + public static CacheKeyComparer Sensitive { get; } = new SensitiveComparer(); + + public static CacheKeyComparer Insensitive { get; } = new InsensitiveComparer(); + + private sealed class SensitiveComparer : CacheKeyComparer + { + public override bool Equals(CacheKey x, CacheKey y) => + string.Equals(x.Name, y.Name, StringComparison.Ordinal); + + public override int GetHashCode(CacheKey obj) => + obj.Name is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Name); + } + + private sealed class InsensitiveComparer : CacheKeyComparer + { + public override bool Equals(CacheKey x, CacheKey y) => + string.Equals(x.Name, y.Name, StringComparison.OrdinalIgnoreCase); + + public override int GetHashCode(CacheKey obj) => + obj.Name is null ? 0 : StringComparer.OrdinalIgnoreCase.GetHashCode(obj.Name); + } +} diff --git a/src/UiPath.Caching.Abstractions/CacheOptions.cs b/src/UiPath.Caching.Abstractions/CacheOptions.cs index 4017ebd..da1fb64 100644 --- a/src/UiPath.Caching.Abstractions/CacheOptions.cs +++ b/src/UiPath.Caching.Abstractions/CacheOptions.cs @@ -18,6 +18,9 @@ public class CacheOptions public bool AuditEnabled { get; set; } = true; + /// Seeds when options bind. The distributed cache ignores this — its keys are always sensitive. + public CacheKeyCasing KeyCasing { get; set; } = CacheKeyCasing.Insensitive; + public string DefaultCache { get; set; } = KnownCacheProviderNames.InMemoryRedis; public string DefaultTopic { get; set; } = KnownTopicNames.RedisStreams; diff --git a/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt b/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt index 7dc5c58..e4688ba 100644 --- a/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt +++ b/src/UiPath.Caching.Abstractions/PublicAPI.Unshipped.txt @@ -1 +1,21 @@ #nullable enable +UiPath.Caching.CacheKey.CacheKey(string? name, UiPath.Caching.CacheKeyCasing casing) -> void +UiPath.Caching.CacheKey.Casing.get -> UiPath.Caching.CacheKeyCasing +UiPath.Caching.CacheKey.WithName(string? name) -> UiPath.Caching.CacheKey +UiPath.Caching.CacheKeyCasing +UiPath.Caching.CacheKeyCasing.Insensitive = 0 -> UiPath.Caching.CacheKeyCasing +UiPath.Caching.CacheKeyComparer +UiPath.Caching.CacheKeyComparer.CacheKeyComparer() -> void +static UiPath.Caching.CacheKeyComparer.Insensitive.get -> UiPath.Caching.CacheKeyComparer! +static UiPath.Caching.CacheKeyComparer.Sensitive.get -> UiPath.Caching.CacheKeyComparer! +UiPath.Caching.CacheKeyCasing.Sensitive = 1 -> UiPath.Caching.CacheKeyCasing +UiPath.Caching.CacheOptions.KeyCasing.get -> UiPath.Caching.CacheKeyCasing +UiPath.Caching.CacheOptions.KeyCasing.set -> void +UiPath.Caching.SystemJsonByteSerializerProxy +UiPath.Caching.SystemJsonByteSerializerProxy.SystemJsonByteSerializerProxy(System.Text.Json.JsonSerializerOptions? options = null) -> void +UiPath.Caching.SystemJsonByteSerializerProxy.Serialize(object? value) -> byte[]? +UiPath.Caching.SystemJsonByteSerializerProxy.Deserialize(byte[]? value) -> T? +UiPath.Caching.SystemJsonByteSerializerProxy.TryDeserialize(string? value, out T? result) -> bool +UiPath.Caching.SystemJsonByteSerializerProxy.TryDeserialize(object? value, out T? result) -> bool +static UiPath.Caching.CacheKey.DefaultCasing.get -> UiPath.Caching.CacheKeyCasing +static UiPath.Caching.CacheKey.DefaultCasing.set -> void diff --git a/src/UiPath.Caching.Abstractions/SystemJsonByteSerializerProxy.cs b/src/UiPath.Caching.Abstractions/SystemJsonByteSerializerProxy.cs new file mode 100644 index 0000000..58d88a5 --- /dev/null +++ b/src/UiPath.Caching.Abstractions/SystemJsonByteSerializerProxy.cs @@ -0,0 +1,83 @@ +using System.Text.Json; + +namespace UiPath.Caching; + +/// JSON serializer over byte[]: byte payloads pass through raw, everything else is UTF-8 JSON; the type argument decides, no format sniffing. +public class SystemJsonByteSerializerProxy(JsonSerializerOptions? options = null) : ISerializerProxy +{ + public byte[]? Serialize(object? value) => value switch + { + null => null, + byte[] bytes => bytes, + ReadOnlyMemory memory => memory.ToArray(), + _ => JsonSerializer.SerializeToUtf8Bytes(value, options), + }; + + public T? Deserialize(byte[]? value) + { + if (value is null) + { + return default; + } + if (value is T bytes) + { + return bytes; + } + if (typeof(T) == typeof(ReadOnlyMemory)) + { + return (T)(object)new ReadOnlyMemory(value); + } + if (value.Length == 0) + { + return default; + } + return JsonSerializer.Deserialize(value, options); + } + + public bool TryDeserialize(string? value, out T? result) + { + if (string.IsNullOrWhiteSpace(value)) + { + result = default; + return false; + } + try + { + result = JsonSerializer.Deserialize(value, options); + return true; + } + catch + { + result = default; + return false; + } + } + + public bool TryDeserialize(object? value, out T? result) + { + if (value == null) + { + result = default; + return false; + } + try + { + switch (value) + { + case byte[] bytes when bytes is T typed: + result = typed; + return true; + case JsonElement jsonElement: + result = jsonElement.Deserialize(options); + return true; + default: + return TryDeserialize(value.ToString() ?? string.Empty, out result); + } + } + catch + { + result = default; + return false; + } + } +} diff --git a/src/UiPath.Caching/Config/CachingBuilder.cs b/src/UiPath.Caching/Config/CachingBuilder.cs index d35ef11..29c5431 100644 --- a/src/UiPath.Caching/Config/CachingBuilder.cs +++ b/src/UiPath.Caching/Config/CachingBuilder.cs @@ -29,7 +29,9 @@ internal void Complete() callback(this); } + Services.PostConfigure(options => CacheKey.DefaultCasing = options.KeyCasing); Services.TryAddSingleton>(sp => new SystemJsonSerializerProxy(sp.GetService())); + Services.TryAddSingleton>(sp => new SystemJsonByteSerializerProxy(sp.GetService())); Services.TryAddSingleton(EmptyResiliencePipelineProvider.Instance); Services.TryAddSingleton(NullChangeTokenFactory.Instance); Services.TryAddSingleton(NullTopicFactory.Instance); diff --git a/src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs b/src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs new file mode 100644 index 0000000..3b35f49 --- /dev/null +++ b/src/UiPath.Caching/Config/DistributedCacheCollectionExtensions.cs @@ -0,0 +1,123 @@ +using Microsoft.Extensions.Caching.Distributed; +using UiPath.Caching.Distributed; +using UiPath.Caching.Locking; +using UiPath.Caching.Policies; +using UiPath.Caching.Telemetry; + +namespace UiPath.Caching.Config; + +public static class DistributedCacheCollectionExtensions +{ + /// Service key of the distributed cache's private and registrations. + public const string DistributedCacheServiceKey = "UiPath.Caching.Distributed"; + + /// Registers an backed by a dedicated case-sensitive, raw-byte cache instance. selects the backing tier: Redis (recommended), InMemoryRedis, or InMemory. + public static ICachingBuilder AddDistributedCache( + this ICachingBuilder builder, + string providerName, + Action? configure = null) + { + Guard.NotNullOrWhiteSpace(providerName, nameof(providerName)); + var options = new UiPathDistributedCacheOptions(); + configure?.Invoke(options); + if (!builder.Enabled) + { + return builder; + } + + if (providerName is KnownCacheProviderNames.InMemory or KnownCacheProviderNames.InMemoryRedis) + { + builder.Services.TryAddMemoryCacheFactory(); + } + + builder.Services.AddKeyedSingleton(DistributedCacheServiceKey, + (sp, _) => CreateProvider(sp, providerName, options)); + builder.Services.AddKeyedSingleton(DistributedCacheServiceKey, + (sp, key) => sp.GetRequiredKeyedService(key!).CreateCache()); + builder.Services.TryAddSingleton(sp => new UiPathDistributedCache( + sp.GetRequiredKeyedService(DistributedCacheServiceKey), + options, + sp.GetService(), + sp.GetRequiredService().Create(), + slideByRewrite: providerName == KnownCacheProviderNames.InMemory)); + return builder; + } + + private static ICacheProvider CreateProvider(IServiceProvider sp, string providerName, UiPathDistributedCacheOptions options) + { + var provider = CreateProviderCore(sp, providerName); + EnsureBoundedWrites(sp, providerName, options); + return provider; + } + + /// Writes without a caller expiration take the provider default TTL; reject configurations where that default resolves to unbounded. + private static void EnsureBoundedWrites(IServiceProvider sp, string providerName, UiPathDistributedCacheOptions options) + { + TimeSpan? tierDefault = providerName switch + { + KnownCacheProviderNames.Redis => sp.GetRequiredService>().Value.DefaultExpiration, + KnownCacheProviderNames.InMemoryRedis => sp.GetRequiredService>().Value.DefaultExpiration, + _ => sp.GetRequiredService>().Value.DefaultExpiration, + }; + if (tierDefault is not null) + { + return; + } + + var policyFactory = sp.GetService(); + var policy = options.PolicyName is { } policyName ? policyFactory?.Resolve(policyName) : null; + if ((policy ?? policyFactory?.Default)?.DistributedExpiration is null) + { + throw new InvalidOperationException( + $"AddDistributedCache('{providerName}') would store entries without an expiration: the provider's DefaultExpiration is null and no cache policy supplies DistributedExpiration. Configure DefaultExpiration or a policy with DistributedExpiration."); + } + } + + private static ICacheProvider CreateProviderCore(IServiceProvider sp, string providerName) => + providerName switch + { + KnownCacheProviderNames.Redis => CreateRedisProvider(sp), + KnownCacheProviderNames.InMemoryRedis => new InMemoryRedisCacheProvider( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + () => new CacheFactory( + sp.GetRequiredService>(), + [CreateRedisProvider(sp)], + sp.GetService()), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()), + KnownCacheProviderNames.InMemory => new InMemoryCacheProvider( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()), + _ => throw new InvalidOperationException( + $"Cache provider '{providerName}' is not supported by AddDistributedCache. " + + $"Supported: {KnownCacheProviderNames.Redis}, {KnownCacheProviderNames.InMemoryRedis}, {KnownCacheProviderNames.InMemory}."), + }; + + private static RedisCacheProvider CreateRedisProvider(IServiceProvider sp) => + new( + sp.GetRequiredService>(), + sp.GetRequiredService>(), + sp.GetService() ?? throw new InvalidOperationException( + "AddDistributedCache with a Redis-backed provider requires a Redis connection. Call AddRedisConnection on the caching builder."), + new RedisValueSerializerProxy(sp.GetRequiredService>()), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService()); +} diff --git a/src/UiPath.Caching/Distributed/DistributedCacheEnvelope.cs b/src/UiPath.Caching/Distributed/DistributedCacheEnvelope.cs new file mode 100644 index 0000000..4622433 --- /dev/null +++ b/src/UiPath.Caching/Distributed/DistributedCacheEnvelope.cs @@ -0,0 +1,98 @@ +using System.Buffers.Binary; + +namespace UiPath.Caching.Distributed; + +/// IDistributedCache payload + expiration metadata. Layout: "UPDC" magic, version, flags (bit0 sliding, bit1 absolute), optional LE sliding ticks, optional LE absolute UtcTicks, payload. +internal sealed class DistributedCacheEnvelope(byte[] data, long? slidingTicks, DateTimeOffset? absoluteExpiration) +{ + private const byte FormatVersion = 1; + private const int HeaderLength = 6; + + private static ReadOnlySpan Magic => "UPDC"u8; + + public byte[] Data { get; } = data; + + public long? SlidingTicks { get; } = slidingTicks; + + public DateTimeOffset? AbsoluteExpiration { get; } = absoluteExpiration; + + public byte[] Encode() + { + var length = HeaderLength + + (SlidingTicks.HasValue ? sizeof(long) : 0) + + (AbsoluteExpiration.HasValue ? sizeof(long) : 0) + + Data.Length; + var buffer = new byte[length]; + Magic.CopyTo(buffer); + buffer[4] = FormatVersion; + buffer[5] = (byte)((SlidingTicks.HasValue ? 1 : 0) | (AbsoluteExpiration.HasValue ? 2 : 0)); + var offset = HeaderLength; + if (SlidingTicks is { } sliding) + { + BinaryPrimitives.WriteInt64LittleEndian(buffer.AsSpan(offset), sliding); + offset += sizeof(long); + } + + if (AbsoluteExpiration is { } absolute) + { + BinaryPrimitives.WriteInt64LittleEndian(buffer.AsSpan(offset), absolute.UtcTicks); + offset += sizeof(long); + } + + Data.CopyTo(buffer.AsSpan(offset)); + return buffer; + } + + public static DistributedCacheEnvelope? TryDecode(byte[]? value) + { + if (value is null || value.Length < HeaderLength) + { + return null; + } + + var span = value.AsSpan(); + if (!span[..4].SequenceEqual(Magic) || span[4] != FormatVersion) + { + return null; + } + + var flags = span[5]; + var offset = HeaderLength; + long? sliding = null; + DateTimeOffset? absolute = null; + if ((flags & 1) != 0) + { + if (span.Length < offset + sizeof(long)) + { + return null; + } + + sliding = BinaryPrimitives.ReadInt64LittleEndian(span[offset..]); + if (sliding <= 0) + { + return null; + } + + offset += sizeof(long); + } + + if ((flags & 2) != 0) + { + if (span.Length < offset + sizeof(long)) + { + return null; + } + + var ticks = BinaryPrimitives.ReadInt64LittleEndian(span[offset..]); + if ((ulong)ticks > (ulong)DateTime.MaxValue.Ticks) + { + return null; + } + + absolute = new DateTimeOffset(ticks, TimeSpan.Zero); + offset += sizeof(long); + } + + return new DistributedCacheEnvelope(span[offset..].ToArray(), sliding, absolute); + } +} diff --git a/src/UiPath.Caching/Distributed/RedisValueSerializerProxy.cs b/src/UiPath.Caching/Distributed/RedisValueSerializerProxy.cs new file mode 100644 index 0000000..8835798 --- /dev/null +++ b/src/UiPath.Caching/Distributed/RedisValueSerializerProxy.cs @@ -0,0 +1,17 @@ +namespace UiPath.Caching.Distributed; + +/// Adapts an ISerializerProxy<byte[]> onto the Redis pipeline for the distributed cache's dedicated instance. +internal sealed class RedisValueSerializerProxy(ISerializerProxy inner) : ISerializerProxy +{ + public RedisValue Serialize(object? value) => + inner.Serialize(value); + + public T? Deserialize(RedisValue value) => + value.IsNullOrEmpty ? default : inner.Deserialize(value); + + public bool TryDeserialize(string? value, out T? result) => + inner.TryDeserialize(value, out result); + + public bool TryDeserialize(object? value, out T? result) => + inner.TryDeserialize(value, out result); +} diff --git a/src/UiPath.Caching/Distributed/UiPathDistributedCache.cs b/src/UiPath.Caching/Distributed/UiPathDistributedCache.cs new file mode 100644 index 0000000..629a6ee --- /dev/null +++ b/src/UiPath.Caching/Distributed/UiPathDistributedCache.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace UiPath.Caching.Distributed; + +/// over an : always-sensitive keys, raw-byte envelope payloads. +internal sealed class UiPathDistributedCache : IDistributedCache +{ + private readonly ICache _cache; + private readonly CachePolicy? _policy; + private readonly string _instanceName; + private readonly bool _slideByRewrite; + private readonly ISystemClock _clock; + private readonly ILogger _logger; + + public UiPathDistributedCache( + ICache cache, + UiPathDistributedCacheOptions options, + ICachePolicyFactory? policyFactory, + ILogger logger, + ISystemClock? clock = null, + bool slideByRewrite = false) + { + _cache = cache; + _instanceName = options.InstanceName ?? string.Empty; + _policy = options.PolicyName is { } policyName ? policyFactory?.Resolve(policyName) : null; + _slideByRewrite = slideByRewrite; + _logger = logger; + _clock = clock ?? new SystemClock(); + } + + public byte[]? Get(string key) => + GetAsync(key).GetAwaiter().GetResult(); + + public async Task GetAsync(string key, CancellationToken token = default) + { + var envelope = await GetEnvelopeAndSlideAsync(key, token).ConfigureAwait(false); + return envelope?.Data; + } + + public void Refresh(string key) => + RefreshAsync(key).GetAwaiter().GetResult(); + + public async Task RefreshAsync(string key, CancellationToken token = default) => + _ = await GetEnvelopeAndSlideAsync(key, token).ConfigureAwait(false); + + public void Remove(string key) => + RemoveAsync(key).GetAwaiter().GetResult(); + + public Task RemoveAsync(string key, CancellationToken token = default) => + _cache.RemoveAsync(Encode(key), token).AsTask(); + + public void Set(string key, byte[] value, DistributedCacheEntryOptions options) => + SetAsync(key, value, options).GetAwaiter().GetResult(); + + public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + { + ArgumentNullException.ThrowIfNull(value); + ArgumentNullException.ThrowIfNull(options); + + var now = _clock.UtcNow; + var absolute = ResolveAbsoluteExpiration(now, options); + var envelope = new DistributedCacheEnvelope(value, options.SlidingExpiration?.Ticks, absolute); + + TimeSpan? ttl = (options.SlidingExpiration, absolute) switch + { + ({ } sliding, { } cap) => TimeSpan.FromTicks(Math.Min(sliding.Ticks, (cap - now).Ticks)), + ({ } sliding, null) => sliding, + (null, { } cap) => cap - now, + _ => null, + }; + + _ = await _cache.SetAsync(Encode(key), envelope.Encode(), ttl, _policy, token).ConfigureAwait(false); + } + + private CacheKey Encode(string key) + { + ArgumentNullException.ThrowIfNull(key); + return new CacheKey(_instanceName + key, CacheKeyCasing.Sensitive); + } + + private async ValueTask GetEnvelopeAndSlideAsync(string key, CancellationToken token) + { + var cacheKey = Encode(key); + var stored = await _cache.GetAsync(cacheKey, _policy, token).ConfigureAwait(false); + if (stored is null) + { + return null; + } + + var envelope = DistributedCacheEnvelope.TryDecode(stored); + if (envelope is null) + { + _logger.LogWarning("Value for distributed cache key {Key} has no envelope header; treating as a miss.", key); + return null; + } + + var now = _clock.UtcNow; + if (envelope.AbsoluteExpiration is { } absoluteExpiration && absoluteExpiration <= now) + { + _ = await _cache.RemoveAsync(cacheKey, token).ConfigureAwait(false); + return null; + } + + if (envelope.SlidingTicks is { } slidingTicks) + { + var target = now.AddTicks(slidingTicks); + if (envelope.AbsoluteExpiration is { } absolute && absolute < target) + { + target = absolute; + } + + if (_slideByRewrite) + { + _ = await _cache.SetAsync(cacheKey, stored, target - now, _policy, token).ConfigureAwait(false); + } + else + { + _ = await _cache.RefreshAsync(cacheKey, (DateTimeOffset?)target, _policy, token).ConfigureAwait(false); + } + } + + return envelope; + } + + private static DateTimeOffset? ResolveAbsoluteExpiration(DateTimeOffset now, DistributedCacheEntryOptions options) + { + if (options.AbsoluteExpiration is { } absolute) + { + return absolute <= now + ? throw new ArgumentOutOfRangeException(nameof(options), absolute, "The absolute expiration must be in the future.") + : absolute; + } + + return options.AbsoluteExpirationRelativeToNow is { } relative ? now.Add(relative) : null; + } +} diff --git a/src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs b/src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs new file mode 100644 index 0000000..8903a7e --- /dev/null +++ b/src/UiPath.Caching/Distributed/UiPathDistributedCacheOptions.cs @@ -0,0 +1,10 @@ +namespace UiPath.Caching.Distributed; + +public class UiPathDistributedCacheOptions +{ + /// Optional key prefix, prepended to every caller key. + public string? InstanceName { get; set; } + + /// Optional name, resolved at construction; absent, the provider's default policy applies. + public string? PolicyName { get; set; } +} diff --git a/src/UiPath.Caching/PrefixCacheKeyStrategy.cs b/src/UiPath.Caching/PrefixCacheKeyStrategy.cs index e671974..7d12ff7 100644 --- a/src/UiPath.Caching/PrefixCacheKeyStrategy.cs +++ b/src/UiPath.Caching/PrefixCacheKeyStrategy.cs @@ -11,5 +11,6 @@ public PrefixCacheKeyStrategy(string prefix, char? separator = null) _separator = separator == null ? CacheOptions.KeySeparator : char.ToLowerInvariant(Guard.NotWhiteSpace(separator.Value, nameof(separator))); } - public CacheKey GetCacheKey(CacheKey key) => string.Join(_separator, _prefix, key); + public CacheKey GetCacheKey(CacheKey key) => + key.WithName(string.Join(_separator, _prefix, key.Name)); } diff --git a/src/UiPath.Caching/PublicAPI.Unshipped.txt b/src/UiPath.Caching/PublicAPI.Unshipped.txt index 7dc5c58..effdc5b 100644 --- a/src/UiPath.Caching/PublicAPI.Unshipped.txt +++ b/src/UiPath.Caching/PublicAPI.Unshipped.txt @@ -1 +1,10 @@ #nullable enable +const UiPath.Caching.Config.DistributedCacheCollectionExtensions.DistributedCacheServiceKey = "UiPath.Caching.Distributed" -> string! +static UiPath.Caching.Config.DistributedCacheCollectionExtensions.AddDistributedCache(this UiPath.Caching.Config.ICachingBuilder! builder, string! providerName, System.Action? configure = null) -> UiPath.Caching.Config.ICachingBuilder! +UiPath.Caching.Config.DistributedCacheCollectionExtensions +UiPath.Caching.Distributed.UiPathDistributedCacheOptions +UiPath.Caching.Distributed.UiPathDistributedCacheOptions.InstanceName.get -> string? +UiPath.Caching.Distributed.UiPathDistributedCacheOptions.InstanceName.set -> void +UiPath.Caching.Distributed.UiPathDistributedCacheOptions.PolicyName.get -> string? +UiPath.Caching.Distributed.UiPathDistributedCacheOptions.PolicyName.set -> void +UiPath.Caching.Distributed.UiPathDistributedCacheOptions.UiPathDistributedCacheOptions() -> void diff --git a/tests/UiPath.Caching.Tests/CacheKeyCasingTest.cs b/tests/UiPath.Caching.Tests/CacheKeyCasingTest.cs new file mode 100644 index 0000000..d50fdd3 --- /dev/null +++ b/tests/UiPath.Caching.Tests/CacheKeyCasingTest.cs @@ -0,0 +1,61 @@ +namespace UiPath.Caching.Tests; + +public class CacheKeyCasingTest +{ + [Fact] + public void Insensitive_ctor_trims_and_lowercases() + { + var key = new CacheKey(" AbC ", CacheKeyCasing.Insensitive); + key.Name.Should().Be("abc"); + key.Casing.Should().Be(CacheKeyCasing.Insensitive); + } + + [Fact] + public void Sensitive_ctor_trims_only() + { + var key = new CacheKey(" AbC ", CacheKeyCasing.Sensitive); + key.Name.Should().Be("AbC"); + key.Casing.Should().Be(CacheKeyCasing.Sensitive); + } + + [Fact] + public void Sensitive_keys_with_different_case_are_not_equal() + { + var upper = new CacheKey("AbC", CacheKeyCasing.Sensitive); + var lower = new CacheKey("abc", CacheKeyCasing.Sensitive); + upper.Should().NotBe(lower); + (upper != lower).Should().BeTrue(); + } + + [Fact] + public void Equality_ignores_casing_mode_when_names_match() + { + var viaSensitive = new CacheKey("abc", CacheKeyCasing.Sensitive); + var viaInsensitive = new CacheKey("ABC", CacheKeyCasing.Insensitive); + viaSensitive.Should().Be(viaInsensitive); + viaSensitive.GetHashCode().Should().Be(viaInsensitive.GetHashCode()); + } + + [Fact] + public void WithName_preserves_casing() + { + var key = new CacheKey("AbC", CacheKeyCasing.Sensitive); + var derived = key.WithName("prefix:" + key.Name); + derived.Name.Should().Be("prefix:AbC"); + derived.Casing.Should().Be(CacheKeyCasing.Sensitive); + } + + [Fact] + public void Default_struct_is_insensitive_and_null() + { + default(CacheKey).Casing.Should().Be(CacheKeyCasing.Insensitive); + default(CacheKey).IsNull.Should().BeTrue(); + } + + [Fact] + public void Sensitive_null_and_whitespace_still_map_to_empty() + { + new CacheKey(null, CacheKeyCasing.Sensitive).IsNull.Should().BeTrue(); + new CacheKey(" ", CacheKeyCasing.Sensitive).IsNull.Should().BeTrue(); + } +} diff --git a/tests/UiPath.Caching.Tests/CacheKeyComparerTest.cs b/tests/UiPath.Caching.Tests/CacheKeyComparerTest.cs new file mode 100644 index 0000000..f008f19 --- /dev/null +++ b/tests/UiPath.Caching.Tests/CacheKeyComparerTest.cs @@ -0,0 +1,56 @@ +namespace UiPath.Caching.Tests; + +public class CacheKeyComparerTest +{ + [Fact] + public void Sensitive_distinguishes_case() + { + var upper = new CacheKey("AbC", CacheKeyCasing.Sensitive); + var lower = new CacheKey("abc", CacheKeyCasing.Sensitive); + CacheKeyComparer.Sensitive.Equals(upper, lower).Should().BeFalse(); + CacheKeyComparer.Sensitive.Equals(upper, new CacheKey("AbC", CacheKeyCasing.Sensitive)).Should().BeTrue(); + } + + [Fact] + public void Insensitive_folds_case() + { + var upper = new CacheKey("AbC", CacheKeyCasing.Sensitive); + var lower = new CacheKey("abc", CacheKeyCasing.Sensitive); + CacheKeyComparer.Insensitive.Equals(upper, lower).Should().BeTrue(); + CacheKeyComparer.Insensitive.GetHashCode(upper).Should().Be(CacheKeyComparer.Insensitive.GetHashCode(lower)); + } + + [Fact] + public void Hash_is_consistent_with_equals() + { + var a = new CacheKey("session:x", CacheKeyCasing.Sensitive); + var b = new CacheKey("session:x", CacheKeyCasing.Insensitive); + CacheKeyComparer.Sensitive.Equals(a, b).Should().BeTrue(); + CacheKeyComparer.Sensitive.GetHashCode(a).Should().Be(CacheKeyComparer.Sensitive.GetHashCode(b)); + } + + [Fact] + public void Works_as_hashset_comparer() + { + var set = new HashSet(CacheKeyComparer.Insensitive) + { + new("AbC", CacheKeyCasing.Sensitive), + }; + set.Add(new CacheKey("abc", CacheKeyCasing.Sensitive)).Should().BeFalse(); + set.Should().HaveCount(1); + } + + [Fact] + public void Singletons_are_cached() + { + CacheKeyComparer.Sensitive.Should().BeSameAs(CacheKeyComparer.Sensitive); + CacheKeyComparer.Insensitive.Should().BeSameAs(CacheKeyComparer.Insensitive); + } + + [Fact] + public void Default_key_does_not_throw() + { + CacheKeyComparer.Sensitive.GetHashCode(default).Should().Be(CacheKeyComparer.Sensitive.GetHashCode(default)); + CacheKeyComparer.Insensitive.Equals(default, default).Should().BeTrue(); + } +} diff --git a/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs b/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs new file mode 100644 index 0000000..6953e97 --- /dev/null +++ b/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs @@ -0,0 +1,116 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using UiPath.Caching.Config; +using UiPath.Caching.Distributed; + +namespace UiPath.Caching.Tests.Config; + +public class DistributedCacheRegistrationTests +{ + private static ServiceProvider Build(string providerName, Action? configure = null) + { + var services = new ServiceCollection(); + services.AddCaching(b => + { + b.AddMemory(_ => { }); + b.AddDistributedCache(providerName, configure); + }); + return services.BuildServiceProvider(); + } + + [Fact] + public void InMemory_tier_works_without_AddMemory() + { + var services = new ServiceCollection(); + services.AddCaching(b => b.AddDistributedCache(KnownCacheProviderNames.InMemory)); + using var provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredService(); + + cache.Set("k", [1], new DistributedCacheEntryOptions()); + + cache.Get("k").Should().Equal(1); + } + + [Fact] + public void Null_default_expiration_without_policy_fails_fast() + { + var services = new ServiceCollection(); + services.AddCaching(b => + { + b.Services.Configure(o => o.DefaultExpiration = null); + b.AddDistributedCache(KnownCacheProviderNames.InMemory); + }); + using var provider = services.BuildServiceProvider(); + + var act = () => provider.GetRequiredService(); + + act.Should().Throw().WithMessage("*DefaultExpiration*DistributedExpiration*"); + } + + [Fact] + public void Null_default_expiration_is_accepted_when_a_policy_bounds_writes() + { + var services = new ServiceCollection(); + services.AddCaching( + b => + { + b.Services.Configure(o => o.DefaultExpiration = null); + b.AddDistributedCache(KnownCacheProviderNames.InMemory); + }, + o => o.DefaultCachePolicy = new CachePolicy { DistributedExpiration = TimeSpan.FromMinutes(5) }); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void Redis_tier_without_a_connection_fails_with_guidance() + { + var services = new ServiceCollection(); + services.AddCaching(b => b.AddDistributedCache(KnownCacheProviderNames.Redis)); + using var provider = services.BuildServiceProvider(); + + var act = () => provider.GetRequiredService(); + + act.Should().Throw().WithMessage("*AddRedisConnection*"); + } + + [Fact] + public void Resolves_IDistributedCache_and_keyed_cache() + { + using var provider = Build(KnownCacheProviderNames.InMemory); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredKeyedService(DistributedCacheCollectionExtensions.DistributedCacheServiceKey) + .Should().NotBeNull(); + } + + [Fact] + public void Keyed_cache_is_a_separate_instance_from_the_apps_cache() + { + using var provider = Build(KnownCacheProviderNames.InMemory); + var appCache = provider.GetRequiredService().CreateCache(KnownCacheProviderNames.InMemory); + var distributedCache = provider.GetRequiredKeyedService(DistributedCacheCollectionExtensions.DistributedCacheServiceKey); + distributedCache.Should().NotBeSameAs(appCache); + } + + [Fact] + public void Unknown_provider_name_fails_fast_with_supported_names() + { + using var provider = Build("NoSuchProvider"); + var act = () => provider.GetRequiredService(); + act.Should().Throw() + .WithMessage("*NoSuchProvider*").WithMessage("*Redis*InMemoryRedis*InMemory*"); + } + + [Fact] + public void Round_trips_through_the_real_pipeline() + { + using var provider = Build(KnownCacheProviderNames.InMemory); + var cache = provider.GetRequiredService(); + + cache.Set("AbC", [1, 2, 3], new DistributedCacheEntryOptions()); + + cache.Get("AbC").Should().Equal(1, 2, 3); + cache.Get("abc").Should().BeNull(); + } +} diff --git a/tests/UiPath.Caching.Tests/Config/KeyCasingOptionsTests.cs b/tests/UiPath.Caching.Tests/Config/KeyCasingOptionsTests.cs new file mode 100644 index 0000000..5ffb54e --- /dev/null +++ b/tests/UiPath.Caching.Tests/Config/KeyCasingOptionsTests.cs @@ -0,0 +1,57 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using UiPath.Caching.Config; + +namespace UiPath.Caching.Tests.Config; + +[CollectionDefinition("CacheKeyDefaultCasing", DisableParallelization = true)] +public class CacheKeyDefaultCasingCollection; + +[Collection("CacheKeyDefaultCasing")] +public class KeyCasingOptionsTests +{ + [Fact] + public void KeyCasing_defaults_to_insensitive() + { + new CacheOptions().KeyCasing.Should().Be(CacheKeyCasing.Insensitive); + } + + [Fact] + public void Resolving_options_seeds_the_ambient_default() + { + try + { + var services = new ServiceCollection(); + services.AddCaching(_ => { }, o => o.KeyCasing = CacheKeyCasing.Sensitive); + using var provider = services.BuildServiceProvider(); + + _ = provider.GetRequiredService>().Value; + + CacheKey.DefaultCasing.Should().Be(CacheKeyCasing.Sensitive); + new CacheKey("AbC").Name.Should().Be("AbC"); + } + finally + { + CacheKey.DefaultCasing = CacheKeyCasing.Insensitive; + } + } + + [Fact] + public void Insensitive_configuration_keeps_lowercasing() + { + try + { + var services = new ServiceCollection(); + services.AddCaching(_ => { }); + using var provider = services.BuildServiceProvider(); + _ = provider.GetRequiredService>().Value; + + CacheKey.DefaultCasing.Should().Be(CacheKeyCasing.Insensitive); + new CacheKey("AbC").Name.Should().Be("abc"); + } + finally + { + CacheKey.DefaultCasing = CacheKeyCasing.Insensitive; + } + } +} diff --git a/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs new file mode 100644 index 0000000..c2d8a33 --- /dev/null +++ b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs @@ -0,0 +1,103 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using UiPath.Caching.Config; + +namespace UiPath.Caching.Tests.Distributed; + +[Collection("CacheKeyDefaultCasing")] +public class DistributedCacheEndToEndTests +{ + private static ServiceProvider Build(string? instanceName = "sess:") + { + var services = new ServiceCollection(); + services.AddCaching(b => + { + b.AddMemory(_ => { }); + b.AddDistributedCache(KnownCacheProviderNames.InMemory, o => o.InstanceName = instanceName); + }); + return services.BuildServiceProvider(); + } + + [Fact] + public async Task Session_scenario_idle_keeps_alive_and_absolute_cap_wins() + { + using var provider = Build(); + var cache = provider.GetRequiredService(); + var token = TestContext.Current.CancellationToken; + + await cache.SetAsync("Session-AbC", [1], new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMilliseconds(400), + AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(2), + }, token); + + for (var i = 0; i < 3; i++) + { + await Task.Delay(200, token); + (await cache.GetAsync("Session-AbC", token)).Should().NotBeNull("touch {0} slides the window", i); + } + + await Task.Delay(2100, token); + (await cache.GetAsync("Session-AbC", token)).Should().BeNull(); + } + + [Fact] + public async Task Refresh_extends_without_reading_data() + { + using var provider = Build(); + var cache = provider.GetRequiredService(); + var token = TestContext.Current.CancellationToken; + + await cache.SetAsync("k", [1], new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMilliseconds(500) }, token); + await Task.Delay(300, token); + await cache.RefreshAsync("k", token); + await Task.Delay(300, token); + + (await cache.GetAsync("k", token)).Should().NotBeNull(); + } + + [Fact] + public async Task Global_key_casing_does_not_move_distributed_keys() + { + try + { + using var provider = Build(); + var cache = provider.GetRequiredService(); + var token = TestContext.Current.CancellationToken; + + CacheKey.DefaultCasing = CacheKeyCasing.Insensitive; + await cache.SetAsync("AbC", [1], new DistributedCacheEntryOptions(), token); + + CacheKey.DefaultCasing = CacheKeyCasing.Sensitive; + (await cache.GetAsync("AbC", token)).Should().Equal(1); + (await cache.GetAsync("abc", token)).Should().BeNull(); + } + finally + { + CacheKey.DefaultCasing = CacheKeyCasing.Insensitive; + } + } + + [Fact] + public async Task Whitespace_wrapped_keys_collide_by_design_without_a_prefix() + { + using var provider = Build(instanceName: null); + var cache = provider.GetRequiredService(); + var token = TestContext.Current.CancellationToken; + + await cache.SetAsync(" k ", [1], new DistributedCacheEntryOptions(), token); + (await cache.GetAsync("k", token)).Should().Equal(1); + } + + [Fact] + public async Task With_a_prefix_leading_whitespace_becomes_interior_and_survives() + { + using var provider = Build(); + var cache = provider.GetRequiredService(); + var token = TestContext.Current.CancellationToken; + + await cache.SetAsync(" k ", [1], new DistributedCacheEntryOptions(), token); + (await cache.GetAsync("k", token)).Should().BeNull(); + (await cache.GetAsync(" k ", token)).Should().Equal(1); + } +} diff --git a/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEnvelopeTests.cs b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEnvelopeTests.cs new file mode 100644 index 0000000..6bd8fb0 --- /dev/null +++ b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEnvelopeTests.cs @@ -0,0 +1,83 @@ +using UiPath.Caching.Distributed; + +namespace UiPath.Caching.Tests.Distributed; + +public class DistributedCacheEnvelopeTests +{ + private static readonly byte[] Payload = [0x00, 0x10, 0xFF, 0x7A]; + + public static TheoryData ExpirationCombinations => new() + { + { null, null }, + { TimeSpan.FromMinutes(20).Ticks, null }, + { null, new DateTimeOffset(2026, 8, 13, 12, 0, 0, TimeSpan.Zero) }, + { TimeSpan.FromMinutes(20).Ticks, new DateTimeOffset(2026, 8, 13, 12, 0, 0, TimeSpan.Zero) }, + }; + + [Theory] + [MemberData(nameof(ExpirationCombinations))] + public void Round_trips_all_expiration_combinations(long? sliding, DateTimeOffset? absolute) + { + var encoded = new DistributedCacheEnvelope(Payload, sliding, absolute).Encode(); + var decoded = DistributedCacheEnvelope.TryDecode(encoded)!; + + decoded.Should().NotBeNull(); + decoded.Data.Should().Equal(Payload); + decoded.SlidingTicks.Should().Be(sliding); + decoded.AbsoluteExpiration.Should().Be(absolute); + } + + [Fact] + public void Empty_payload_round_trips() + { + var decoded = DistributedCacheEnvelope.TryDecode(new DistributedCacheEnvelope([], null, null).Encode())!; + decoded.Data.Should().BeEmpty(); + } + + [Theory] + [InlineData(null)] + [InlineData(new byte[0])] + [InlineData(new byte[] { 0x01, 0x02 })] + [InlineData(new byte[] { (byte)'X', (byte)'P', (byte)'D', (byte)'C', 1, 0 })] + [InlineData(new byte[] { (byte)'U', (byte)'P', (byte)'D', (byte)'C', 99, 0 })] + [InlineData(new byte[] { (byte)'U', (byte)'P', (byte)'D', (byte)'C', 1, 1 })] + public void Foreign_or_corrupt_values_decode_to_null(byte[]? value) + { + DistributedCacheEnvelope.TryDecode(value).Should().BeNull(); + } + + [Fact] + public void Out_of_range_expiration_ticks_decode_to_null_instead_of_throwing() + { + DistributedCacheEnvelope.TryDecode(WithAbsoluteTicks(-1)).Should().BeNull(); + DistributedCacheEnvelope.TryDecode(WithAbsoluteTicks(long.MaxValue)).Should().BeNull(); + DistributedCacheEnvelope.TryDecode(WithSlidingTicks(0)).Should().BeNull(); + DistributedCacheEnvelope.TryDecode(WithSlidingTicks(-5)).Should().BeNull(); + } + + private static byte[] WithAbsoluteTicks(long ticks) + { + var buffer = new byte[14]; + "UPDC"u8.CopyTo(buffer); + buffer[4] = 1; + buffer[5] = 2; + BitConverter.GetBytes(ticks).CopyTo(buffer, 6); + return buffer; + } + + private static byte[] WithSlidingTicks(long ticks) + { + var buffer = new byte[14]; + "UPDC"u8.CopyTo(buffer); + buffer[4] = 1; + buffer[5] = 1; + BitConverter.GetBytes(ticks).CopyTo(buffer, 6); + return buffer; + } + + [Fact] + public void Json_payload_is_not_mistaken_for_an_envelope() + { + DistributedCacheEnvelope.TryDecode("""{"Name":"x"}"""u8.ToArray()).Should().BeNull(); + } +} diff --git a/tests/UiPath.Caching.Tests/Distributed/RedisValueSerializerProxyTests.cs b/tests/UiPath.Caching.Tests/Distributed/RedisValueSerializerProxyTests.cs new file mode 100644 index 0000000..d4afae9 --- /dev/null +++ b/tests/UiPath.Caching.Tests/Distributed/RedisValueSerializerProxyTests.cs @@ -0,0 +1,44 @@ +using StackExchange.Redis; +using UiPath.Caching.Distributed; + +namespace UiPath.Caching.Tests.Distributed; + +public class RedisValueSerializerProxyTests +{ + private sealed record Poco(string Name); + + private readonly RedisValueSerializerProxy _proxy = new(new SystemJsonByteSerializerProxy()); + + [Fact] + public void Bytes_round_trip_unencoded() + { + var payload = new byte[] { 0x00, 0x01, 0xFF }; + RedisValue stored = _proxy.Serialize(payload); + ((byte[])stored!).Should().Equal(payload); + _proxy.Deserialize(stored).Should().Equal(payload); + } + + [Fact] + public void Null_and_empty_map_to_defaults() + { + _proxy.Serialize(null).IsNull.Should().BeTrue(); + _proxy.Deserialize(RedisValue.Null).Should().BeNull(); + _proxy.Deserialize(RedisValue.EmptyString).Should().BeNull(); + } + + [Fact] + public void Poco_round_trips_via_inner_json() + { + var value = new Poco("x"); + var stored = _proxy.Serialize(value); + _proxy.Deserialize(stored).Should().Be(value); + } + + [Fact] + public void TryDeserialize_delegates_to_inner() + { + _proxy.TryDeserialize("""{"Name":"x"}""", out var ok).Should().BeTrue(); + ok.Should().Be(new Poco("x")); + _proxy.TryDeserialize("not json", out _).Should().BeFalse(); + } +} diff --git a/tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs b/tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs new file mode 100644 index 0000000..ae2ff9c --- /dev/null +++ b/tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs @@ -0,0 +1,240 @@ +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Logging.Abstractions; +using UiPath.Caching.Distributed; + +namespace UiPath.Caching.Tests.Distributed; + +public class UiPathDistributedCacheTests +{ + private static readonly DateTimeOffset Now = new(2026, 8, 13, 10, 0, 0, TimeSpan.Zero); + private static readonly byte[] Payload = [1, 2, 3]; + + private readonly ICache _inner = Substitute.For(); + private readonly ISystemClock _clock = Substitute.For(); + private readonly UiPathDistributedCache _cache; + + public UiPathDistributedCacheTests() + { + _clock.UtcNow.Returns(Now); + _cache = new UiPathDistributedCache( + _inner, + new UiPathDistributedCacheOptions(), + policyFactory: null, + NullLogger.Instance, + _clock); + } + + private static byte[] Envelope(long? slidingTicks = null, DateTimeOffset? absolute = null) => + new DistributedCacheEnvelope(Payload, slidingTicks, absolute).Encode(); + + [Fact] + public async Task Keys_are_case_sensitive_and_preserved() + { + await _cache.GetAsync("AbC-9xQ", TestContext.Current.CancellationToken); + await _inner.Received(1).GetAsync( + Arg.Is(k => k.Name == "AbC-9xQ" && k.Casing == CacheKeyCasing.Sensitive), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task InstanceName_prefixes_the_key() + { + var prefixed = new UiPathDistributedCache( + _inner, new UiPathDistributedCacheOptions { InstanceName = "Sess:" }, + null, NullLogger.Instance, _clock); + await prefixed.GetAsync("AbC", TestContext.Current.CancellationToken); + await _inner.Received(1).GetAsync( + Arg.Is(k => k.Name == "Sess:AbC"), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Null_key_throws() + { + await FluentActions.Awaiting(() => _cache.GetAsync(null!, TestContext.Current.CancellationToken)).Should().ThrowAsync(); + } + + [Fact] + public async Task Get_miss_returns_null() + { + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns((byte[]?)null); + (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().BeNull(); + } + + [Fact] + public async Task Get_returns_payload_and_slides_when_sliding_set() + { + var sliding = TimeSpan.FromMinutes(20); + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Envelope(sliding.Ticks)); + + (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().Equal(Payload); + + await _inner.Received(1).RefreshAsync( + Arg.Any(), (DateTimeOffset?)Now.Add(sliding), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Get_slide_is_capped_by_absolute_expiration() + { + var sliding = TimeSpan.FromMinutes(20); + var absolute = Now.AddMinutes(5); + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Envelope(sliding.Ticks, absolute)); + + await _cache.GetAsync("k", TestContext.Current.CancellationToken); + + await _inner.Received(1).RefreshAsync( + Arg.Any(), (DateTimeOffset?)absolute, Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Get_without_sliding_does_not_refresh() + { + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Envelope(absolute: Now.AddHours(1))); + + (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().Equal(Payload); + + await _inner.DidNotReceiveWithAnyArgs().RefreshAsync(default, (DateTimeOffset?)null, null, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Expired_absolute_entry_is_a_miss_and_gets_removed() + { + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Envelope(TimeSpan.FromMinutes(20).Ticks, Now.AddMinutes(-1))); + + (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().BeNull(); + + await _inner.Received(1).RemoveAsync(Arg.Any(), Arg.Any()); + await _inner.DidNotReceiveWithAnyArgs().RefreshAsync(default, (DateTimeOffset?)null, null, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Foreign_value_is_a_miss_not_an_exception() + { + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("""{"legacy":"json"}"""u8.ToArray()); + + (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().BeNull(); + } + + [Fact] + public async Task Set_with_sliding_only_uses_sliding_ttl() + { + var sliding = TimeSpan.FromMinutes(20); + byte[]? stored = null; + TimeSpan? ttl = null; + await _inner.SetAsync(Arg.Any(), Arg.Do(v => stored = v), + Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + + await _cache.SetAsync("k", Payload, new DistributedCacheEntryOptions { SlidingExpiration = sliding }, TestContext.Current.CancellationToken); + + ttl.Should().Be(sliding); + var envelope = DistributedCacheEnvelope.TryDecode(stored)!; + envelope.SlidingTicks.Should().Be(sliding.Ticks); + envelope.AbsoluteExpiration.Should().BeNull(); + envelope.Data.Should().Equal(Payload); + } + + [Fact] + public async Task Set_with_both_uses_min_of_sliding_and_remaining_absolute() + { + TimeSpan? ttl = null; + await _inner.SetAsync(Arg.Any(), Arg.Any(), + Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + + await _cache.SetAsync("k", Payload, new DistributedCacheEntryOptions + { + SlidingExpiration = TimeSpan.FromMinutes(20), + AbsoluteExpiration = Now.AddMinutes(5), + }, TestContext.Current.CancellationToken); + + ttl.Should().Be(TimeSpan.FromMinutes(5)); + } + + [Fact] + public async Task Set_with_relative_absolute_computes_from_now() + { + byte[]? stored = null; + await _inner.SetAsync(Arg.Any(), Arg.Do(v => stored = v), + Arg.Any(), Arg.Any(), Arg.Any()); + + await _cache.SetAsync("k", Payload, new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30), + }, TestContext.Current.CancellationToken); + + DistributedCacheEnvelope.TryDecode(stored)!.AbsoluteExpiration.Should().Be(Now.AddMinutes(30)); + } + + [Fact] + public async Task Set_with_no_expiration_passes_null_ttl_for_policy_default() + { + TimeSpan? ttl = TimeSpan.FromDays(999); + await _inner.SetAsync(Arg.Any(), Arg.Any(), + Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + + await _cache.SetAsync("k", Payload, new DistributedCacheEntryOptions(), TestContext.Current.CancellationToken); + + ttl.Should().BeNull(); + } + + [Fact] + public async Task Set_with_past_absolute_expiration_throws() + { + await FluentActions.Awaiting(() => _cache.SetAsync("k", Payload, + new DistributedCacheEntryOptions { AbsoluteExpiration = Now.AddMinutes(-1) }, TestContext.Current.CancellationToken)) + .Should().ThrowAsync(); + } + + [Fact] + public async Task Refresh_slides_without_returning_payload() + { + var sliding = TimeSpan.FromMinutes(20); + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Envelope(sliding.Ticks)); + + await _cache.RefreshAsync("k", TestContext.Current.CancellationToken); + + await _inner.Received(1).RefreshAsync( + Arg.Any(), (DateTimeOffset?)Now.Add(sliding), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Remove_forwards() + { + await _cache.RemoveAsync("AbC", TestContext.Current.CancellationToken); + await _inner.Received(1).RemoveAsync( + Arg.Is(k => k.Name == "AbC"), Arg.Any()); + } + + [Fact] + public async Task SlideByRewrite_extends_by_resetting_the_stored_bytes() + { + var sliding = TimeSpan.FromMinutes(20); + var stored = Envelope(sliding.Ticks); + var rewriting = new UiPathDistributedCache( + _inner, new UiPathDistributedCacheOptions(), + null, NullLogger.Instance, _clock, slideByRewrite: true); + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(stored); + + (await rewriting.GetAsync("k", TestContext.Current.CancellationToken)).Should().Equal(Payload); + + await _inner.Received(1).SetAsync( + Arg.Any(), Arg.Is(v => v == stored), (TimeSpan?)sliding, Arg.Any(), Arg.Any()); + await _inner.DidNotReceiveWithAnyArgs().RefreshAsync(default, (DateTimeOffset?)null, null, TestContext.Current.CancellationToken); + } + + [Fact] + public void Sync_methods_block_on_async() + { + _inner.GetAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Envelope()); + _cache.Get("k").Should().Equal(Payload); + } +} diff --git a/tests/UiPath.Caching.Tests/PrefixCacheKeyStrategyTests.cs b/tests/UiPath.Caching.Tests/PrefixCacheKeyStrategyTests.cs index 319698b..1ab633f 100644 --- a/tests/UiPath.Caching.Tests/PrefixCacheKeyStrategyTests.cs +++ b/tests/UiPath.Caching.Tests/PrefixCacheKeyStrategyTests.cs @@ -37,4 +37,24 @@ public void WorksAsExpected(string prefix, char? separator, string key, string e var actual = Sut.GetCacheKey(cacheKey); actual.Should().Be((CacheKey)expected); } + + [Fact] + public void Preserves_sensitive_casing_through_prefixing() + { + var strategy = new PrefixCacheKeyStrategy("MyApp"); + var key = new CacheKey("AbC", CacheKeyCasing.Sensitive); + + var result = strategy.GetCacheKey(key); + + result.Name.Should().Be("myapp:AbC"); + result.Casing.Should().Be(CacheKeyCasing.Sensitive); + } + + [Fact] + public void Insensitive_keys_behave_exactly_as_before() + { + var strategy = new PrefixCacheKeyStrategy("MyApp"); + var result = strategy.GetCacheKey(new CacheKey("AbC")); + result.Name.Should().Be("myapp:abc"); + } } diff --git a/tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs b/tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs new file mode 100644 index 0000000..89d0afd --- /dev/null +++ b/tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs @@ -0,0 +1,100 @@ +using System.Text; +using System.Text.Json; + +namespace UiPath.Caching.Tests; + +public class SystemJsonByteSerializerProxyTests +{ + private readonly SystemJsonByteSerializerProxy _proxy = new(); + + private sealed record Poco(string Name, int Count); + + [Fact] + public void Byte_array_passes_through_by_reference() + { + var payload = new byte[] { 0x00, 0x01, 0xFF }; + _proxy.Serialize(payload).Should().BeSameAs(payload); + _proxy.Deserialize(payload).Should().BeSameAs(payload); + } + + [Fact] + public void ReadOnlyMemory_is_materialized_not_json_encoded() + { + ReadOnlyMemory memory = new byte[] { 1, 2, 3 }; + _proxy.Serialize(memory).Should().Equal(1, 2, 3); + } + + [Fact] + public void ReadOnlyMemory_round_trips() + { + ReadOnlyMemory memory = new byte[] { 1, 2, 3 }; + var stored = _proxy.Serialize(memory); + _proxy.Deserialize>(stored).ToArray().Should().Equal(1, 2, 3); + } + + [Fact] + public void Empty_byte_array_round_trips() + { + var empty = Array.Empty(); + _proxy.Serialize(empty).Should().BeSameAs(empty); + _proxy.Deserialize(empty).Should().BeSameAs(empty); + } + + [Fact] + public void Null_maps_to_null_and_empty_to_default() + { + _proxy.Serialize(null).Should().BeNull(); + _proxy.Deserialize(null).Should().BeNull(); + _proxy.Deserialize([]).Should().BeNull(); + } + + [Fact] + public void Poco_round_trips_as_utf8_json() + { + var value = new Poco("x", 42); + var bytes = _proxy.Serialize(value)!; + JsonSerializer.Deserialize(bytes).Should().Be(value); + _proxy.Deserialize(bytes).Should().Be(value); + } + + [Fact] + public void Deserializing_json_as_bytes_returns_raw_utf8() + { + var bytes = _proxy.Serialize(new Poco("x", 1))!; + _proxy.Deserialize(bytes).Should().BeSameAs(bytes); + } + + [Fact] + public void TryDeserialize_string_success_and_failure() + { + _proxy.TryDeserialize("""{"Name":"x","Count":1}""", out var ok).Should().BeTrue(); + ok.Should().Be(new Poco("x", 1)); + _proxy.TryDeserialize("not json", out var bad).Should().BeFalse(); + bad.Should().BeNull(); + _proxy.TryDeserialize(" ", out _).Should().BeFalse(); + } + + [Fact] + public void TryDeserialize_object_handles_bytes_json_element_and_text() + { + var raw = Encoding.UTF8.GetBytes("""{"Name":"x","Count":1}"""); + _proxy.TryDeserialize(raw, out var bytes).Should().BeTrue(); + bytes.Should().BeSameAs(raw); + + var element = JsonSerializer.SerializeToElement(new Poco("x", 1)); + _proxy.TryDeserialize(element, out var fromElement).Should().BeTrue(); + fromElement.Should().Be(new Poco("x", 1)); + + _proxy.TryDeserialize((object)"""{"Name":"x","Count":1}""", out var fromText).Should().BeTrue(); + fromText.Should().Be(new Poco("x", 1)); + + _proxy.TryDeserialize(null, out _).Should().BeFalse(); + } + + [Fact] + public void Honors_custom_serializer_options() + { + var proxy = new SystemJsonByteSerializerProxy(new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }); + Encoding.UTF8.GetString(proxy.Serialize(new Poco("x", 1))!).Should().Contain("\"name\""); + } +}