From 6d7984a6d0a95a50d202124d0095c6edaed30407 Mon Sep 17 00:00:00 2001 From: robert-mihaiAdam Date: Mon, 20 Jul 2026 15:33:03 +0300 Subject: [PATCH 1/2] Feat: Add support for InMemoryRedisSetCache Signed-off-by: robert-mihaiAdam --- .../Config/SetCacheCollectionExtensions.cs | 130 ++++- src/UiPath.Caching.Queue/GlobalUsings.cs | 3 + .../IQueueCacheFactory.cs | 16 +- src/UiPath.Caching.Queue/ISetCacheProvider.cs | 18 + .../InMemoryRedisSetCacheOptions.cs | 62 +++ .../InMemoryRedisSetCacheProvider.cs | 49 ++ .../InMemorySetCacheOptions.cs | 34 ++ .../InMemorySetCacheProvider.cs | 41 ++ .../MultilayerSetCache.cs | 451 ++++++++++++++++++ .../PublicAPI.Shipped.txt | 10 +- .../PublicAPI.Unshipped.txt | 76 +++ src/UiPath.Caching.Queue/QueueCacheFactory.cs | 68 ++- .../QueueCacheFactoryExtensions.cs | 9 +- .../RedisSetCacheProvider.cs | 50 ++ .../UiPath.Caching.Queue.csproj | 1 + src/UiPath.Caching/InMemoryCacheProvider.cs | 3 +- .../InMemoryRedisCacheProvider.cs | 4 +- .../InMemorySetCacheTests.cs | 177 +++++++ .../MultilayerSetCacheTests.cs | 234 +++++++++ .../QueueCacheFactoryTests.cs | 8 +- .../SetCacheCollectionExtensionsTests.cs | 48 +- .../SetCacheProviderTests.cs | 159 ++++++ 22 files changed, 1610 insertions(+), 41 deletions(-) create mode 100644 src/UiPath.Caching.Queue/ISetCacheProvider.cs create mode 100644 src/UiPath.Caching.Queue/InMemoryRedisSetCacheOptions.cs create mode 100644 src/UiPath.Caching.Queue/InMemoryRedisSetCacheProvider.cs create mode 100644 src/UiPath.Caching.Queue/InMemorySetCacheOptions.cs create mode 100644 src/UiPath.Caching.Queue/InMemorySetCacheProvider.cs create mode 100644 src/UiPath.Caching.Queue/MultilayerSetCache.cs create mode 100644 src/UiPath.Caching.Queue/RedisSetCacheProvider.cs create mode 100644 tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs create mode 100644 tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs create mode 100644 tests/UiPath.Caching.Tests/SetCacheProviderTests.cs diff --git a/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs b/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs index af53241..6c72269 100644 --- a/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs +++ b/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs @@ -1,8 +1,54 @@ -namespace UiPath.Caching.Config; +using UiPath.Caching.Config; + +namespace UiPath.Caching.Queue.Config; [ExcludeFromCodeCoverage] public static class SetCacheCollectionExtensions { + public static ICachingBuilder AddInMemorySetCache(this ICachingBuilder builder) => + builder.AddInMemorySetCache(static _ => { }); + + public static ICachingBuilder AddInMemorySetCache(this ICachingBuilder builder, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + if (!builder.Enabled) + { + builder.Services.AddNullSetCache(); + return builder; + } + + builder.Services.AddInMemorySetCache(configureOptions); + return builder; + } + + /// + public static IServiceCollection AddInMemorySetCache(this IServiceCollection services) => + services.AddInMemorySetCache(static _ => { }); + + /// + /// Registers the in-process provider (InMemory) plus + /// , and . + /// Requires the core caching services (AddCaching). Unlike the Redis providers, this one + /// has no Redis prerequisite. + /// + public static IServiceCollection AddInMemorySetCache(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + var options = new InMemorySetCacheOptions(); + configureOptions.Invoke(options); + services.TryConfigure(configureOptions); + if (!options.Enabled) + { + return services.AddNullSetCache(); + } + + services.TryAddMemoryCacheFactory(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services.AddSetCacheCore(); + } + public static ICachingBuilder AddRedisSetCache(this ICachingBuilder builder) => builder.AddRedisSetCache(static _ => { }); @@ -25,11 +71,12 @@ public static IServiceCollection AddRedisSetCache(this IServiceCollection servic services.AddRedisSetCache(static _ => { }); /// - /// Registers and on the service collection. + /// Registers the Redis-backed provider (Redis) plus + /// , and . /// /// /// Prerequisite: the core caching and Redis services must already be registered (via - /// AddCaching(... builder.AddRedis())). The set cache resolves , + /// AddCaching(... builder.AddRedis())). The provider resolves , /// , , the core /// cache options and from the container; if AddCaching() /// has not run, resolving throws at first use. Prefer the @@ -40,7 +87,7 @@ public static IServiceCollection AddRedisSetCache(this IServiceCollection servic { ArgumentNullException.ThrowIfNull(services); ArgumentNullException.ThrowIfNull(configureOptions); - RedisSetCacheOptions options = new(); + var options = new RedisSetCacheOptions(); configureOptions.Invoke(options); services.TryConfigure(configureOptions); if (!options.Enabled) @@ -48,8 +95,67 @@ public static IServiceCollection AddRedisSetCache(this IServiceCollection servic return services.AddNullSetCache(); } - services.TryAddSingleton(BuildRedisSetCache); - services.TryAddSingleton(); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services.AddSetCacheCore(); + } + + public static ICachingBuilder AddInMemoryRedisSetCache(this ICachingBuilder builder) => + builder.AddInMemoryRedisSetCache(static _ => { }); + + public static ICachingBuilder AddInMemoryRedisSetCache(this ICachingBuilder builder, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(configureOptions); + if (!builder.Enabled) + { + builder.Services.AddNullSetCache(); + return builder; + } + + builder.Services.AddInMemoryRedisSetCache(configureOptions); + return builder; + } + + /// + public static IServiceCollection AddInMemoryRedisSetCache(this IServiceCollection services) => + services.AddInMemoryRedisSetCache(static _ => { }); + + /// + /// Registers the multilayer provider (InMemoryRedis): a local + /// in-process snapshot in front of the Redis set cache. Also registers the Redis provider, + /// since the multilayer provider obtains its L2 tier from the factory + /// ( for Redis). Same Redis + /// prerequisites as ; + /// the Redis tier reuses / . + /// + public static IServiceCollection AddInMemoryRedisSetCache(this IServiceCollection services, Action configureOptions) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configureOptions); + var options = new InMemoryRedisSetCacheOptions(); + configureOptions.Invoke(options); + services.TryConfigure(configureOptions); + if (!options.Enabled) + { + return services.AddNullSetCache(); + } + + services.TryAddMemoryCacheFactory(); + // The multilayer provider resolves its Redis L2 through the factory, so the Redis provider + // must be present. TryAddEnumerable makes this idempotent with an explicit AddRedisSetCache. + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + services.TryAddEnumerable(ServiceDescriptor.Singleton()); + return services.AddSetCacheCore(); + } + + private static IServiceCollection AddSetCacheCore(this IServiceCollection services) + { + services.TryAddSingleton(sp => + new QueueCacheFactory(sp.GetServices(), sp.GetRequiredService>())); + // Deferred accessor so a provider can reach the factory without a construction-time cycle + // (the factory is built from the providers). Mirrors the core's Func. + services.TryAddTransient>(sp => () => sp.GetRequiredService()); + services.TryAddSingleton(sp => sp.GetRequiredService().CreateSetCache()); services.TryAddTransient(typeof(ISetCache<>), typeof(SetCache<>)); return services; } @@ -61,16 +167,4 @@ private static IServiceCollection AddNullSetCache(this IServiceCollection servic services.TryAddTransient(typeof(ISetCache<>), typeof(SetCache<>)); return services; } - - private static RedisSetCache BuildRedisSetCache(IServiceProvider sp) => - new( - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService(), - sp.GetService() ?? NullTelemetryProvider.Instance, - sp.GetRequiredService>().Value, - sp.GetRequiredService>().Value, - sp.GetRequiredService>().Value, - sp.GetRequiredService(), - (sp.GetService() ?? NullLoggerFactory.Instance).CreateLogger()); } diff --git a/src/UiPath.Caching.Queue/GlobalUsings.cs b/src/UiPath.Caching.Queue/GlobalUsings.cs index f7ec16b..3434b1f 100644 --- a/src/UiPath.Caching.Queue/GlobalUsings.cs +++ b/src/UiPath.Caching.Queue/GlobalUsings.cs @@ -1,4 +1,5 @@ global using System.Diagnostics.CodeAnalysis; +global using Microsoft.Extensions.Caching.Memory; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection.Extensions; global using Microsoft.Extensions.Logging; @@ -8,3 +9,5 @@ global using UiPath.Caching.Policies; global using UiPath.Caching.Redis; global using UiPath.Caching.Telemetry; + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("UiPath.Caching.Tests")] diff --git a/src/UiPath.Caching.Queue/IQueueCacheFactory.cs b/src/UiPath.Caching.Queue/IQueueCacheFactory.cs index 3972c06..6b92f75 100644 --- a/src/UiPath.Caching.Queue/IQueueCacheFactory.cs +++ b/src/UiPath.Caching.Queue/IQueueCacheFactory.cs @@ -1,11 +1,23 @@ namespace UiPath.Caching; /// -/// Creates set caches, mirroring for Redis sets. Lives in the +/// Creates set caches, mirroring . Lives in the /// UiPath.Caching.Queue package (set support is opt-in); register it via -/// AddRedisSetCache. Inject this instead of when you need sets. +/// AddInMemorySetCache / AddRedisSetCache / AddInMemoryRedisSetCache. Inject +/// this instead of when you need sets. /// public interface IQueueCacheFactory { + /// Returns the for the default provider. ISetCache CreateSetCache(); + + /// Names of the registered, enabled set-cache providers. + IEnumerable ProviderNames => []; + + /// + /// Returns the for the given provider (see + /// ). When is + /// , the configured default provider is used. + /// + ISetCache CreateSetCache(string? providerName) => CreateSetCache(); } diff --git a/src/UiPath.Caching.Queue/ISetCacheProvider.cs b/src/UiPath.Caching.Queue/ISetCacheProvider.cs new file mode 100644 index 0000000..e3cd65f --- /dev/null +++ b/src/UiPath.Caching.Queue/ISetCacheProvider.cs @@ -0,0 +1,18 @@ +namespace UiPath.Caching; + +/// +/// Creates instances for a specific backing (InMemory, Redis or +/// InMemoryRedis), mirroring for the normal/hash caches. Registered +/// providers are selected by name through . +/// +public interface ISetCacheProvider : IDisposable +{ + /// The provider name, e.g. one of . + string Name { get; } + + /// Whether this provider is enabled and eligible for selection. + bool Enabled { get; } + + /// Returns the (typically cached) for this provider. + ISetCache CreateSetCache(); +} diff --git a/src/UiPath.Caching.Queue/InMemoryRedisSetCacheOptions.cs b/src/UiPath.Caching.Queue/InMemoryRedisSetCacheOptions.cs new file mode 100644 index 0000000..a33bb38 --- /dev/null +++ b/src/UiPath.Caching.Queue/InMemoryRedisSetCacheOptions.cs @@ -0,0 +1,62 @@ +namespace UiPath.Caching; + +/// +/// Options for the multilayer (the InMemoryRedis provider): a local +/// in-process snapshot (L1) in front of the Redis set cache (L2). The Redis tier itself is +/// configured via / . +/// +public sealed class InMemoryRedisSetCacheOptions : IMemoryCacheOptions +{ + /// Indicates whether the multilayer set cache is enabled. + public bool Enabled { get; set; } = true; + + /// + /// Upper bound on how long a locally-cached set snapshot is served before it is re-fetched from + /// Redis. This also bounds the staleness window for mutations performed on other nodes, since + /// this tier does not subscribe to cross-node broadcast invalidation. + /// caches snapshots without a time bound (not recommended for multi-node deployments). + /// Defaults to one minute. + /// + public TimeSpan? LocalMaxExpiration { get; set; } = TimeSpan.FromMinutes(1); + + /// + /// Monitors the Redis tier's connection state, mirroring InMemoryRedisCacheOptions. Must be + /// enabled for to take effect; when enabled without it, + /// locally-cached snapshots are dropped instead of served while Redis is unreachable. Disabled by + /// default. + /// + public bool ConnectionMonitorEnabled { get; set; } + + /// How often the connection monitor re-evaluates a failed connection. Defaults to five seconds. + public TimeSpan? ConnectionMonitorPeriod { get; set; } = TimeSpan.FromSeconds(5); + + /// + /// Serves reads from the local snapshot and applies mutations locally while Redis is unreachable, + /// instead of failing through to the disconnected tier. Local state written while disconnected + /// expires after . Requires + /// . Disabled by default. + /// + public bool UseLocalOnlyWhenDisconnected { get; set; } + + /// + /// Upper bound on the lifetime of local set state written while Redis is unreachable (see + /// ), so it dies quickly once connectivity returns. + /// Defaults to thirty seconds. + /// + public TimeSpan? LocalMaxExpirationDisconnected { get; set; } = TimeSpan.FromSeconds(30); + + /// + public bool TrackStatistics { get; set; } = true; + + /// + public TimeSpan StatisticsFlushInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// + public long? SizeLimit { get; set; } + + /// + public double? CompactionPercentage { get; set; } + + /// + public ICacheEntrySizeProvider? SizeProvider { get; set; } +} diff --git a/src/UiPath.Caching.Queue/InMemoryRedisSetCacheProvider.cs b/src/UiPath.Caching.Queue/InMemoryRedisSetCacheProvider.cs new file mode 100644 index 0000000..0dfdd61 --- /dev/null +++ b/src/UiPath.Caching.Queue/InMemoryRedisSetCacheProvider.cs @@ -0,0 +1,49 @@ +namespace UiPath.Caching; + +/// +/// for the multilayer . Mirrors +/// InMemoryRedisCacheProvider: it composes a local in-process snapshot (L1) in front of the +/// Redis set cache (L2), obtaining the Redis tier from the +/// () rather than constructing it by hand. The factory is +/// injected as a so it can be resolved lazily — the factory is built from +/// the providers, so a direct dependency would be a construction-time cycle. +/// +public sealed class InMemoryRedisSetCacheProvider : ISetCacheProvider +{ + private readonly Lazy _cache; + + public InMemoryRedisSetCacheProvider( + Func queueCacheFactory, + IMemoryCacheFactory memoryCacheFactory, + ISerializerProxy serializer, + IOptions options) + { + ArgumentNullException.ThrowIfNull(queueCacheFactory); + ArgumentNullException.ThrowIfNull(memoryCacheFactory); + ArgumentNullException.ThrowIfNull(serializer); + ArgumentNullException.ThrowIfNull(options); + Enabled = options.Value.Enabled; + var factory = new Lazy(queueCacheFactory); + _cache = new Lazy(() => + { + var l2 = factory.Value.CreateSetCache(KnownCacheProviderNames.Redis); + return new MultilayerSetCache(Name, l2, memoryCacheFactory, serializer, options.Value, options.Value.LocalMaxExpiration, + options.Value.ConnectionMonitorEnabled, options.Value.ConnectionMonitorPeriod, + options.Value.UseLocalOnlyWhenDisconnected, options.Value.LocalMaxExpirationDisconnected); + }); + } + + public string Name => KnownCacheProviderNames.InMemoryRedis; + + public bool Enabled { get; } + + public ISetCache CreateSetCache() => _cache.Value; + + public void Dispose() + { + if (_cache.IsValueCreated) + { + _cache.Value.Dispose(); + } + } +} diff --git a/src/UiPath.Caching.Queue/InMemorySetCacheOptions.cs b/src/UiPath.Caching.Queue/InMemorySetCacheOptions.cs new file mode 100644 index 0000000..b5892c9 --- /dev/null +++ b/src/UiPath.Caching.Queue/InMemorySetCacheOptions.cs @@ -0,0 +1,34 @@ +namespace UiPath.Caching; + +/// +/// Options for the in-memory (the InMemory provider). Mirrors the +/// memory-tier knobs of ; the set cache stores each set as a +/// single entry. +/// +public sealed class InMemorySetCacheOptions : IMemoryCacheOptions +{ + /// Indicates whether the in-memory set cache is enabled. + public bool Enabled { get; set; } = true; + + /// + /// 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 . + /// + public TimeSpan? DefaultExpiration { get; set; } = TimeSpan.FromHours(1); + + /// + public bool TrackStatistics { get; set; } = true; + + /// + public TimeSpan StatisticsFlushInterval { get; set; } = TimeSpan.FromMinutes(1); + + /// + public long? SizeLimit { get; set; } + + /// + public double? CompactionPercentage { get; set; } + + /// + public ICacheEntrySizeProvider? SizeProvider { get; set; } +} diff --git a/src/UiPath.Caching.Queue/InMemorySetCacheProvider.cs b/src/UiPath.Caching.Queue/InMemorySetCacheProvider.cs new file mode 100644 index 0000000..739dd63 --- /dev/null +++ b/src/UiPath.Caching.Queue/InMemorySetCacheProvider.cs @@ -0,0 +1,41 @@ +namespace UiPath.Caching; + +/// +/// for the in-process . Mirrors +/// InMemoryCacheProvider exactly: it produces a over +/// , so the multilayer's local tier is the storage — the +/// multilayer detects the no-op inner and serves mutations from its local tier, the way it serves +/// them while disconnected. +/// +public sealed class InMemorySetCacheProvider : ISetCacheProvider +{ + private readonly Lazy _cache; + + public InMemorySetCacheProvider( + IMemoryCacheFactory memoryCacheFactory, + ISerializerProxy serializer, + IOptions options) + { + ArgumentNullException.ThrowIfNull(memoryCacheFactory); + ArgumentNullException.ThrowIfNull(serializer); + ArgumentNullException.ThrowIfNull(options); + Enabled = options.Value.Enabled; + _cache = new Lazy(() => + new MultilayerSetCache(Name, NullSetCache.Instance, memoryCacheFactory, serializer, options.Value, + localMaxExpiration: null, defaultExpiration: options.Value.DefaultExpiration)); + } + + public string Name => KnownCacheProviderNames.InMemory; + + public bool Enabled { get; } + + public ISetCache CreateSetCache() => _cache.Value; + + public void Dispose() + { + if (_cache.IsValueCreated) + { + _cache.Value.Dispose(); + } + } +} diff --git a/src/UiPath.Caching.Queue/MultilayerSetCache.cs b/src/UiPath.Caching.Queue/MultilayerSetCache.cs new file mode 100644 index 0000000..6487136 --- /dev/null +++ b/src/UiPath.Caching.Queue/MultilayerSetCache.cs @@ -0,0 +1,451 @@ +using System.Collections.Concurrent; +using System.Collections.Immutable; + +namespace UiPath.Caching; + +internal sealed class MultilayerSetCache : ISetCache +{ + private readonly string _name; + private readonly ISetCache _inner; + private readonly IMemoryCache _memoryCache; + private readonly ISerializerProxy _serializer; + private readonly bool _trackSize; + private readonly TimeSpan? _localMaxExpiration; + private readonly IConnectionState _connectionState; + private readonly bool _useLocalOnlyWhenDisconnected; + private readonly TimeSpan? _localMaxExpirationDisconnected; + private readonly TimeSpan? _defaultExpiration; + private readonly bool _localIsStore; + private readonly ConcurrentDictionary _gates = new(StringComparer.Ordinal); + private sealed record Snapshot(ImmutableHashSet Members, DateTimeOffset? Expiration); + + public MultilayerSetCache( + string name, + ISetCache inner, + IMemoryCacheFactory memoryCacheFactory, + ISerializerProxy serializer, + IMemoryCacheOptions memoryOptions, + TimeSpan? localMaxExpiration, + bool connectionMonitorEnabled = false, + TimeSpan? connectionMonitorPeriod = null, + bool useLocalOnlyWhenDisconnected = false, + TimeSpan? localMaxExpirationDisconnected = null, + TimeSpan? defaultExpiration = null) + { + ArgumentNullException.ThrowIfNull(inner); + ArgumentNullException.ThrowIfNull(memoryCacheFactory); + ArgumentNullException.ThrowIfNull(serializer); + ArgumentNullException.ThrowIfNull(memoryOptions); + _name = name; + _inner = inner; + _memoryCache = memoryCacheFactory.Get(memoryOptions); + _serializer = serializer; + _trackSize = memoryOptions.SizeLimit.HasValue; + _localMaxExpiration = localMaxExpiration; + _connectionState = connectionMonitorEnabled ? GetConnectionMonitor(inner, connectionMonitorPeriod) : NullConnectionStateMonitor.Instance; + _useLocalOnlyWhenDisconnected = useLocalOnlyWhenDisconnected && connectionMonitorEnabled; + _localMaxExpirationDisconnected = localMaxExpirationDisconnected; + _defaultExpiration = defaultExpiration; + _localIsStore = inner is NullSetCache; + } + + private static IConnectionState GetConnectionMonitor(ISetCache inner, TimeSpan? period) => + inner is IConnectionState state + ? new ConnectionStateMonitor(NullTelemetryProvider.Instance, period ?? TimeSpan.FromSeconds(5), state) + : NullConnectionStateMonitor.Instance; + + public string Name => _name; + + public async ValueTask AddAsync(CacheKey cacheKey, T item, CachePolicy? policy = null, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (UseLocalOnly()) + { + return LocalAdd(key, [item], LocalWriteExpiration(null, policy)) > 0; + } + var added = await _inner.AddAsync(cacheKey, item, policy, token).ConfigureAwait(false); + LocalAddIfExists(key, [item]); + return added; + } + + public async ValueTask AddAsync(CacheKey cacheKey, IEnumerable items, CachePolicy? policy = null, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + var materialized = Materialize(items); + if (UseLocalOnly()) + { + return LocalAdd(key, materialized, LocalWriteExpiration(null, policy)); + } + var added = await _inner.AddAsync(cacheKey, materialized, policy, token).ConfigureAwait(false); + LocalAddIfExists(key, materialized); + return added; + } + + public async ValueTask AddAsync(CacheKey cacheKey, IEnumerable items, TimeSpan? expiration = null, CachePolicy? policy = null, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + var materialized = Materialize(items); + if (UseLocalOnly()) + { + return LocalAdd(key, materialized, LocalWriteExpiration(FromTtl(expiration), policy)); + } + var added = await _inner.AddAsync(cacheKey, materialized, expiration, policy, token).ConfigureAwait(false); + LocalAddIfExists(key, materialized); + return added; + } + + public async ValueTask AddAsync(CacheKey cacheKey, IEnumerable items, DateTimeOffset? expiration = null, CachePolicy? policy = null, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + var materialized = Materialize(items); + if (UseLocalOnly()) + { + return LocalAdd(key, materialized, LocalWriteExpiration(expiration, policy)); + } + var added = await _inner.AddAsync(cacheKey, materialized, expiration, policy, token).ConfigureAwait(false); + LocalAddIfExists(key, materialized); + return added; + } + + public async ValueTask PopAsync(CacheKey cacheKey, CachePolicy? policy = null, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (UseLocalOnly()) + { + var popped = LocalPop(key, 1); + return popped.Count > 0 ? popped.First() : default; + } + var value = await _inner.PopAsync(cacheKey, policy, token).ConfigureAwait(false); + if (value is not null) + { + LocalRemove(key, [value]); + } + return value; + } + + public async ValueTask> PopAsync(CacheKey cacheKey, long count, CachePolicy? policy = null, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (UseLocalOnly()) + { + return LocalPop(key, count); + } + var values = await _inner.PopAsync(cacheKey, count, policy, token).ConfigureAwait(false); + if (values.Count > 0) + { + LocalRemove(key, values.Where(v => v is not null).Select(v => v!)); + } + return values; + } + + public async ValueTask> MembersAsync(CacheKey cacheKey, CachePolicy? policy = null, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (TryGetSnapshot(key, out var snapshot)) + { + if (LocalUsable()) + { + return Deserialize(snapshot.Members); + } + _memoryCache.Remove(key); + return []; + } + var members = await _inner.MembersAsync(cacheKey, policy, token).ConfigureAwait(false); + if (members.Count > 0) + { + LocalReplace(key, members.Where(m => m is not null).Select(m => m!), LocalExpiration()); + } + return members; + } + + public async ValueTask ContainsItemAsync(CacheKey cacheKey, T item, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (TryGetSnapshot(key, out var snapshot)) + { + if (LocalUsable()) + { + return snapshot.Members.Contains(_serializer.Serialize(item)); + } + _memoryCache.Remove(key); + return false; + } + return await _inner.ContainsItemAsync(cacheKey, item, token).ConfigureAwait(false); + } + + public async ValueTask CountAsync(CacheKey cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (TryGetSnapshot(key, out var snapshot)) + { + if (LocalUsable()) + { + return snapshot.Members.Count; + } + _memoryCache.Remove(key); + return 0; + } + return await _inner.CountAsync(cacheKey, token).ConfigureAwait(false); + } + + public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (TryGetSnapshot(key, out _)) + { + if (LocalUsable()) + { + return true; + } + _memoryCache.Remove(key); + return false; + } + return await _inner.ContainsAsync(cacheKey, token).ConfigureAwait(false); + } + + public async ValueTask RemoveItemAsync(CacheKey cacheKey, T item, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (UseLocalOnly()) + { + return LocalRemove(key, [item]) > 0; + } + LocalRemove(key, [item]); + return await _inner.RemoveItemAsync(cacheKey, item, token).ConfigureAwait(false); + } + + public async ValueTask RemoveItemsAsync(CacheKey cacheKey, IEnumerable items, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + var materialized = Materialize(items); + if (UseLocalOnly()) + { + return LocalRemove(key, materialized); + } + LocalRemove(key, materialized); + return await _inner.RemoveItemsAsync(cacheKey, materialized, token).ConfigureAwait(false); + } + + public async ValueTask RemoveAsync(CacheKey cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var key = Key(cacheKey, token); + if (UseLocalOnly()) + { + return LocalRemoveKey(key); + } + _memoryCache.Remove(key); + return await _inner.RemoveAsync(cacheKey, token).ConfigureAwait(false); + } + + public void Dispose() + { + _memoryCache.Dispose(); + if (_connectionState is IDisposable connectionState) + { + connectionState.Dispose(); + } + } + + private bool GetInnerCacheDisconnected() => _useLocalOnlyWhenDisconnected && !_connectionState.IsConnected; + + private bool UseLocalOnly() => _localIsStore || GetInnerCacheDisconnected(); + + private bool LocalUsable() => _connectionState.IsConnected || _useLocalOnlyWhenDisconnected; + + private DateTimeOffset? LocalWriteExpiration(DateTimeOffset? requested, CachePolicy? policy) + { + requested ??= FromTtl(policy?.DistributedExpiration ?? (_localIsStore ? _defaultExpiration : null)); + return _localIsStore ? requested : DisconnectedExpiration(requested); + } + + private object Gate(string key) => _gates.GetOrAdd(key, static _ => new object()); + + private bool TryGetSnapshot(string key, [NotNullWhen(true)] out Snapshot? snapshot) => + _memoryCache.TryGetValue(key, out snapshot) && snapshot is not null; + + private void StoreSnapshot(string key, Snapshot snapshot) + { + if (snapshot.Members.IsEmpty) + { + _memoryCache.Remove(key); + return; + } + var options = new MemoryCacheEntryOptions(); + if (snapshot.Expiration.HasValue) + { + options.SetAbsoluteExpiration(snapshot.Expiration.Value); + } + if (_trackSize) + { + options.SetSize(1); + } + _memoryCache.Set(key, snapshot, options); + } + + private void LocalReplace(string key, IEnumerable members, DateTimeOffset? expiration) + { + var set = members.Select(m => _serializer.Serialize(m)).ToImmutableHashSet(); + lock (Gate(key)) + { + StoreSnapshot(key, new Snapshot(set, expiration)); + } + } + + private void LocalAddIfExists(string key, IEnumerable items) + { + var values = items.Select(i => _serializer.Serialize(i)).ToArray(); + if (values.Length == 0) + { + return; + } + lock (Gate(key)) + { + if (TryGetSnapshot(key, out var snapshot)) + { + StoreSnapshot(key, snapshot with { Members = snapshot.Members.Union(values) }); + } + } + } + + private long LocalAdd(string key, IEnumerable items, DateTimeOffset? expiration) + { + var values = items.Select(i => _serializer.Serialize(i)).ToArray(); + if (values.Length == 0) + { + return 0; + } + lock (Gate(key)) + { + if (expiration.HasValue && expiration.Value <= DateTimeOffset.UtcNow) + { + _memoryCache.Remove(key); + return 0; + } + var members = TryGetSnapshot(key, out var snapshot) ? snapshot.Members : ImmutableHashSet.Empty; + var updated = members.Union(values); + StoreSnapshot(key, new Snapshot(updated, expiration)); + return updated.Count - members.Count; + } + } + + private long LocalRemove(string key, IEnumerable items) + { + var values = items.Select(i => _serializer.Serialize(i)).ToArray(); + if (values.Length == 0) + { + return 0; + } + lock (Gate(key)) + { + if (!TryGetSnapshot(key, out var snapshot)) + { + return 0; + } + var updated = snapshot.Members.Except(values); + StoreSnapshot(key, snapshot with { Members = updated }); + return snapshot.Members.Count - updated.Count; + } + } + + private bool LocalRemoveKey(string key) + { + lock (Gate(key)) + { + if (!TryGetSnapshot(key, out _)) + { + return false; + } + _memoryCache.Remove(key); + return true; + } + } + + private IReadOnlyCollection LocalPop(string key, long count) + { + if (count <= 0) + { + return []; + } + lock (Gate(key)) + { + if (!TryGetSnapshot(key, out var snapshot) || snapshot.Members.IsEmpty) + { + return []; + } + var pool = snapshot.Members.ToArray(); + var take = (int)Math.Min(count, pool.Length); + var picked = new RedisValue[take]; + for (var i = 0; i < take; i++) + { + var j = Random.Shared.Next(i, pool.Length); + (pool[i], pool[j]) = (pool[j], pool[i]); + picked[i] = pool[i]; + } + StoreSnapshot(key, snapshot with { Members = snapshot.Members.Except(picked) }); + return Deserialize(picked); + } + } + + private IReadOnlyCollection Deserialize(IReadOnlyCollection values) + { + if (values.Count == 0) + { + return []; + } + var list = new List(values.Count); + foreach (var value in values) + { + if (value.IsNull) + { + continue; + } + list.Add(_serializer.Deserialize(value)); + } + return list; + } + + private DateTimeOffset? DisconnectedExpiration(DateTimeOffset? requested) + { + if (!_localMaxExpirationDisconnected.HasValue) + { + return requested; + } + var cap = DateTimeOffset.UtcNow.Add(_localMaxExpirationDisconnected.Value); + return requested.HasValue && requested.Value < cap ? requested.Value : cap; + } + + private static DateTimeOffset? FromTtl(TimeSpan? ttl) => + ttl.HasValue ? DateTimeOffset.UtcNow.Add(ttl.Value) : null; + + private static IEnumerable Materialize(IEnumerable items) + { + ArgumentNullException.ThrowIfNull(items); + return items as IReadOnlyCollection ?? items.ToArray(); + } + + private DateTimeOffset? LocalExpiration() => + _localMaxExpiration.HasValue ? DateTimeOffset.UtcNow.Add(_localMaxExpiration.Value) : null; + + private static string Key(CacheKey cacheKey, CancellationToken token) + { + if (cacheKey.IsNull) + { + throw new ArgumentNullException(nameof(cacheKey)); + } + token.ThrowIfCancellationRequested(); + return cacheKey.Name; + } +} diff --git a/src/UiPath.Caching.Queue/PublicAPI.Shipped.txt b/src/UiPath.Caching.Queue/PublicAPI.Shipped.txt index 411f18f..85892ac 100644 --- a/src/UiPath.Caching.Queue/PublicAPI.Shipped.txt +++ b/src/UiPath.Caching.Queue/PublicAPI.Shipped.txt @@ -1,5 +1,5 @@ #nullable enable -UiPath.Caching.Config.SetCacheCollectionExtensions +UiPath.Caching.Queue.Config.SetCacheCollectionExtensions UiPath.Caching.IQueueCacheFactory UiPath.Caching.IQueueCacheFactory.CreateSetCache() -> UiPath.Caching.ISetCache! UiPath.Caching.ISetCache @@ -110,9 +110,9 @@ UiPath.Caching.SetCache.RemoveAsync(UiPath.Caching.CacheKey cacheKey, System. UiPath.Caching.SetCache.RemoveItemAsync(UiPath.Caching.CacheKey cacheKey, T item, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask UiPath.Caching.SetCache.RemoveItemsAsync(UiPath.Caching.CacheKey cacheKey, System.Collections.Generic.IEnumerable! items, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask UiPath.Caching.SetCache.SetCache(UiPath.Caching.ISetCache! cache, UiPath.Caching.ICacheKeyStrategy? cacheKeyStrategy = null, UiPath.Caching.ICachePolicyFactory? policyFactory = null, string? policyName = null) -> void -static UiPath.Caching.Config.SetCacheCollectionExtensions.AddRedisSetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static UiPath.Caching.Config.SetCacheCollectionExtensions.AddRedisSetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static UiPath.Caching.Config.SetCacheCollectionExtensions.AddRedisSetCache(this UiPath.Caching.Config.ICachingBuilder! builder) -> UiPath.Caching.Config.ICachingBuilder! -static UiPath.Caching.Config.SetCacheCollectionExtensions.AddRedisSetCache(this UiPath.Caching.Config.ICachingBuilder! builder, System.Action! configureOptions) -> UiPath.Caching.Config.ICachingBuilder! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddRedisSetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddRedisSetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddRedisSetCache(this UiPath.Caching.Config.ICachingBuilder! builder) -> UiPath.Caching.Config.ICachingBuilder! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddRedisSetCache(this UiPath.Caching.Config.ICachingBuilder! builder, System.Action! configureOptions) -> UiPath.Caching.Config.ICachingBuilder! static UiPath.Caching.QueueCacheFactoryExtensions.CreateSetCache(this UiPath.Caching.IQueueCacheFactory! factory) -> UiPath.Caching.ISetCache! static readonly UiPath.Caching.NullSetCache.Instance -> UiPath.Caching.NullSetCache! diff --git a/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt b/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt index 757204f..e23dfff 100644 --- a/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt +++ b/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt @@ -5,3 +5,79 @@ UiPath.Caching.NullQueueCacheFactory.NullQueueCacheFactory() -> void static readonly UiPath.Caching.NullQueueCacheFactory.Instance -> UiPath.Caching.NullQueueCacheFactory! UiPath.Caching.Redis.RedisSetCacheOptions.Enabled.get -> bool UiPath.Caching.Redis.RedisSetCacheOptions.Enabled.set -> void +UiPath.Caching.IQueueCacheFactory.CreateSetCache(string? providerName) -> UiPath.Caching.ISetCache! +UiPath.Caching.IQueueCacheFactory.ProviderNames.get -> System.Collections.Generic.IEnumerable! +UiPath.Caching.ISetCacheProvider +UiPath.Caching.ISetCacheProvider.CreateSetCache() -> UiPath.Caching.ISetCache! +UiPath.Caching.ISetCacheProvider.Enabled.get -> bool +UiPath.Caching.ISetCacheProvider.Name.get -> string! +UiPath.Caching.InMemoryRedisSetCacheOptions +UiPath.Caching.InMemoryRedisSetCacheOptions.CompactionPercentage.get -> double? +UiPath.Caching.InMemoryRedisSetCacheOptions.CompactionPercentage.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.ConnectionMonitorEnabled.get -> bool +UiPath.Caching.InMemoryRedisSetCacheOptions.ConnectionMonitorEnabled.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.ConnectionMonitorPeriod.get -> System.TimeSpan? +UiPath.Caching.InMemoryRedisSetCacheOptions.ConnectionMonitorPeriod.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.LocalMaxExpirationDisconnected.get -> System.TimeSpan? +UiPath.Caching.InMemoryRedisSetCacheOptions.LocalMaxExpirationDisconnected.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.UseLocalOnlyWhenDisconnected.get -> bool +UiPath.Caching.InMemoryRedisSetCacheOptions.UseLocalOnlyWhenDisconnected.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.Enabled.get -> bool +UiPath.Caching.InMemoryRedisSetCacheOptions.Enabled.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.InMemoryRedisSetCacheOptions() -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.LocalMaxExpiration.get -> System.TimeSpan? +UiPath.Caching.InMemoryRedisSetCacheOptions.LocalMaxExpiration.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.SizeLimit.get -> long? +UiPath.Caching.InMemoryRedisSetCacheOptions.SizeLimit.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.SizeProvider.get -> UiPath.Caching.ICacheEntrySizeProvider? +UiPath.Caching.InMemoryRedisSetCacheOptions.SizeProvider.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.StatisticsFlushInterval.get -> System.TimeSpan +UiPath.Caching.InMemoryRedisSetCacheOptions.StatisticsFlushInterval.set -> void +UiPath.Caching.InMemoryRedisSetCacheOptions.TrackStatistics.get -> bool +UiPath.Caching.InMemoryRedisSetCacheOptions.TrackStatistics.set -> void +UiPath.Caching.InMemoryRedisSetCacheProvider +UiPath.Caching.InMemoryRedisSetCacheProvider.CreateSetCache() -> UiPath.Caching.ISetCache! +UiPath.Caching.InMemoryRedisSetCacheProvider.Dispose() -> void +UiPath.Caching.InMemoryRedisSetCacheProvider.Enabled.get -> bool +UiPath.Caching.InMemoryRedisSetCacheProvider.InMemoryRedisSetCacheProvider(System.Func! queueCacheFactory, UiPath.Caching.IMemoryCacheFactory! memoryCacheFactory, UiPath.Caching.ISerializerProxy! serializer, Microsoft.Extensions.Options.IOptions! options) -> void +UiPath.Caching.InMemoryRedisSetCacheProvider.Name.get -> string! +UiPath.Caching.InMemorySetCacheOptions +UiPath.Caching.InMemorySetCacheOptions.CompactionPercentage.get -> double? +UiPath.Caching.InMemorySetCacheOptions.CompactionPercentage.set -> void +UiPath.Caching.InMemorySetCacheOptions.DefaultExpiration.get -> System.TimeSpan? +UiPath.Caching.InMemorySetCacheOptions.DefaultExpiration.set -> void +UiPath.Caching.InMemorySetCacheOptions.Enabled.get -> bool +UiPath.Caching.InMemorySetCacheOptions.Enabled.set -> void +UiPath.Caching.InMemorySetCacheOptions.InMemorySetCacheOptions() -> void +UiPath.Caching.InMemorySetCacheOptions.SizeLimit.get -> long? +UiPath.Caching.InMemorySetCacheOptions.SizeLimit.set -> void +UiPath.Caching.InMemorySetCacheOptions.SizeProvider.get -> UiPath.Caching.ICacheEntrySizeProvider? +UiPath.Caching.InMemorySetCacheOptions.SizeProvider.set -> void +UiPath.Caching.InMemorySetCacheOptions.StatisticsFlushInterval.get -> System.TimeSpan +UiPath.Caching.InMemorySetCacheOptions.StatisticsFlushInterval.set -> void +UiPath.Caching.InMemorySetCacheOptions.TrackStatistics.get -> bool +UiPath.Caching.InMemorySetCacheOptions.TrackStatistics.set -> void +UiPath.Caching.InMemorySetCacheProvider +UiPath.Caching.InMemorySetCacheProvider.CreateSetCache() -> UiPath.Caching.ISetCache! +UiPath.Caching.InMemorySetCacheProvider.Dispose() -> void +UiPath.Caching.InMemorySetCacheProvider.Enabled.get -> bool +UiPath.Caching.InMemorySetCacheProvider.InMemorySetCacheProvider(UiPath.Caching.IMemoryCacheFactory! memoryCacheFactory, UiPath.Caching.ISerializerProxy! serializer, Microsoft.Extensions.Options.IOptions! options) -> void +UiPath.Caching.InMemorySetCacheProvider.Name.get -> string! +UiPath.Caching.QueueCacheFactory.CreateSetCache(string? providerName) -> UiPath.Caching.ISetCache! +UiPath.Caching.QueueCacheFactory.ProviderNames.get -> System.Collections.Generic.IEnumerable! +UiPath.Caching.QueueCacheFactory.QueueCacheFactory(System.Collections.Generic.IEnumerable! providers, Microsoft.Extensions.Options.IOptions! cacheOptions) -> void +UiPath.Caching.RedisSetCacheProvider +UiPath.Caching.RedisSetCacheProvider.CreateSetCache() -> UiPath.Caching.ISetCache! +UiPath.Caching.RedisSetCacheProvider.Dispose() -> void +UiPath.Caching.RedisSetCacheProvider.Enabled.get -> bool +UiPath.Caching.RedisSetCacheProvider.Name.get -> string! +UiPath.Caching.RedisSetCacheProvider.RedisSetCacheProvider(UiPath.Caching.Redis.IRedisConnector! redis, UiPath.Caching.ISerializerProxy! serializer, UiPath.Caching.Policies.IResiliencePipelineProvider! resiliencePipelineProvider, Microsoft.Extensions.Options.IOptions! redisCacheOptions, Microsoft.Extensions.Options.IOptions! cacheOptions, Microsoft.Extensions.Options.IOptions! setCacheOptions, UiPath.Caching.ICachePolicyFactory! policyFactory, UiPath.Caching.Telemetry.ICachingTelemetryProvider? telemetryProvider = null, Microsoft.Extensions.Logging.ILoggerFactory? loggerFactory = null) -> void +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemoryRedisSetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemoryRedisSetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemoryRedisSetCache(this UiPath.Caching.Config.ICachingBuilder! builder) -> UiPath.Caching.Config.ICachingBuilder! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemoryRedisSetCache(this UiPath.Caching.Config.ICachingBuilder! builder, System.Action! configureOptions) -> UiPath.Caching.Config.ICachingBuilder! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCache(this UiPath.Caching.Config.ICachingBuilder! builder) -> UiPath.Caching.Config.ICachingBuilder! +static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCache(this UiPath.Caching.Config.ICachingBuilder! builder, System.Action! configureOptions) -> UiPath.Caching.Config.ICachingBuilder! +static UiPath.Caching.QueueCacheFactoryExtensions.CreateSetCache(this UiPath.Caching.IQueueCacheFactory! factory, string? providerName = null) -> UiPath.Caching.ISetCache! diff --git a/src/UiPath.Caching.Queue/QueueCacheFactory.cs b/src/UiPath.Caching.Queue/QueueCacheFactory.cs index a790276..12d1096 100644 --- a/src/UiPath.Caching.Queue/QueueCacheFactory.cs +++ b/src/UiPath.Caching.Queue/QueueCacheFactory.cs @@ -1,19 +1,75 @@ namespace UiPath.Caching; /// -/// Default . Sets have a single Redis backing, so it simply hands out -/// the registered by AddRedisSetCache (analogous to how -/// CacheFactory.CreateCache() returns a provider's cache). +/// Default . Mirrors CacheFactory: holds the registered +/// instances keyed by name and hands out the matching +/// , defaulting to . /// public sealed class QueueCacheFactory : IQueueCacheFactory { - private readonly ISetCache _setCache; + private readonly Dictionary _providers = new(StringComparer.OrdinalIgnoreCase); + private readonly ISetCache? _single; + private readonly string _defaultCache = KnownCacheProviderNames.InMemoryRedis; + /// + /// Single-provider factory: always hands out regardless of the + /// requested provider name. Retained for backward compatibility. + /// public QueueCacheFactory(ISetCache setCache) { ArgumentNullException.ThrowIfNull(setCache); - _setCache = setCache; + _single = setCache; } - public ISetCache CreateSetCache() => _setCache; + /// + /// Provider-based factory: selects an by name (or by + /// when no name is given). + /// + public QueueCacheFactory(IEnumerable providers, IOptions cacheOptions) + { + ArgumentNullException.ThrowIfNull(providers); + _defaultCache = cacheOptions?.Value.DefaultCache ?? KnownCacheProviderNames.InMemoryRedis; + foreach (var provider in providers) + { + _providers[provider.Name] = provider; + } + } + + public IEnumerable ProviderNames => + _single is not null ? [_single.Name] : _providers.Values.Where(p => p.Enabled).Select(p => p.Name); + + public ISetCache CreateSetCache() => Resolve(_defaultCache, allowFallback: true); + + // A null providerName means "the default provider" and is allowed to fall back to the sole + // registered provider. An explicit name must resolve exactly (or yield NullSetCache) — this is + // what the multilayer provider uses to fetch its Redis tier, and falling back there could + // resolve the multilayer provider back to itself. + public ISetCache CreateSetCache(string? providerName) => + providerName is null ? Resolve(_defaultCache, allowFallback: true) : Resolve(providerName, allowFallback: false); + + private ISetCache Resolve(string providerName, bool allowFallback) + { + if (_single is not null) + { + return _single; + } + var provider = GetProvider(providerName); + if (provider is not null) + { + return provider.CreateSetCache(); + } + return allowFallback && FallbackProvider() is { } sole ? sole.CreateSetCache() : NullSetCache.Instance; + } + + private ISetCacheProvider? GetProvider(string providerName) => + _providers.TryGetValue(providerName, out var provider) && provider.Enabled ? provider : null; + + // When the default provider is not registered, fall back to the sole enabled provider (if + // unambiguous) so a single AddXxxSetCache registration "just works" without configuring + // CacheOptions.DefaultCache. + private ISetCacheProvider? FallbackProvider() + { + var enabled = _providers.Values.Where(p => p.Enabled).ToArray(); + return enabled.Length == 1 ? enabled[0] : null; + } } diff --git a/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs b/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs index 2cd664a..715db77 100644 --- a/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs +++ b/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs @@ -3,10 +3,13 @@ namespace UiPath.Caching; public static class QueueCacheFactoryExtensions { // Mirrors CacheFactoryExtensions.CreateCache (which wraps ICache in Cache). No policy - // factory is passed: the underlying RedisSetCache already applies the global default policy. - public static ISetCache CreateSetCache(this IQueueCacheFactory factory) + // factory is passed: the underlying set cache already applies the global default policy. + public static ISetCache CreateSetCache(this IQueueCacheFactory factory, string? providerName = null) { ArgumentNullException.ThrowIfNull(factory); - return new SetCache(factory.CreateSetCache()); + // Route the default case through the parameterless method so it stays equivalent to + // CreateSetCache() (the provider-name overload is a default interface method). + var setCache = providerName is null ? factory.CreateSetCache() : factory.CreateSetCache(providerName); + return new SetCache(setCache); } } diff --git a/src/UiPath.Caching.Queue/RedisSetCacheProvider.cs b/src/UiPath.Caching.Queue/RedisSetCacheProvider.cs new file mode 100644 index 0000000..d6a402d --- /dev/null +++ b/src/UiPath.Caching.Queue/RedisSetCacheProvider.cs @@ -0,0 +1,50 @@ +namespace UiPath.Caching; + +/// +/// for the Redis-backed . Mirrors +/// RedisCacheProvider; lazily builds a over the shared +/// Redis connection. +/// +public sealed class RedisSetCacheProvider : ISetCacheProvider +{ + private readonly Lazy _cache; + + public RedisSetCacheProvider( + IRedisConnector redis, + ISerializerProxy serializer, + IResiliencePipelineProvider resiliencePipelineProvider, + IOptions redisCacheOptions, + IOptions cacheOptions, + IOptions setCacheOptions, + ICachePolicyFactory policyFactory, + ICachingTelemetryProvider? telemetryProvider = null, + ILoggerFactory? loggerFactory = null) + { + ArgumentNullException.ThrowIfNull(redis); + Enabled = setCacheOptions.Value.Enabled; + _cache = new Lazy(() => new RedisSetCache( + redis, + serializer, + resiliencePipelineProvider, + telemetryProvider ?? NullTelemetryProvider.Instance, + redisCacheOptions.Value, + cacheOptions.Value, + setCacheOptions.Value, + policyFactory, + (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger())); + } + + public string Name => KnownCacheProviderNames.Redis; + + public bool Enabled { get; } + + public ISetCache CreateSetCache() => _cache.Value; + + public void Dispose() + { + if (_cache.IsValueCreated) + { + _cache.Value.Dispose(); + } + } +} diff --git a/src/UiPath.Caching.Queue/UiPath.Caching.Queue.csproj b/src/UiPath.Caching.Queue/UiPath.Caching.Queue.csproj index 075fd12..16dffdd 100644 --- a/src/UiPath.Caching.Queue/UiPath.Caching.Queue.csproj +++ b/src/UiPath.Caching.Queue/UiPath.Caching.Queue.csproj @@ -11,6 +11,7 @@ + diff --git a/src/UiPath.Caching/InMemoryCacheProvider.cs b/src/UiPath.Caching/InMemoryCacheProvider.cs index 7ea3bd2..adc0275 100644 --- a/src/UiPath.Caching/InMemoryCacheProvider.cs +++ b/src/UiPath.Caching/InMemoryCacheProvider.cs @@ -1,7 +1,8 @@ -using UiPath.Caching.Locking; +using UiPath.Caching.Locking; using UiPath.Caching.Telemetry; namespace UiPath.Caching; + public sealed class InMemoryCacheProvider : ICacheProvider { private readonly InMemoryCacheOptions _options; diff --git a/src/UiPath.Caching/InMemoryRedisCacheProvider.cs b/src/UiPath.Caching/InMemoryRedisCacheProvider.cs index a0136bf..3666a65 100644 --- a/src/UiPath.Caching/InMemoryRedisCacheProvider.cs +++ b/src/UiPath.Caching/InMemoryRedisCacheProvider.cs @@ -1,4 +1,4 @@ -using UiPath.Caching.Locking; +using UiPath.Caching.Locking; using UiPath.Caching.Telemetry; namespace UiPath.Caching; @@ -25,8 +25,6 @@ public sealed class InMemoryRedisCacheProvider : ICacheProvider public bool Enabled { get; } - - public InMemoryRedisCacheProvider( IOptions optionsAccessor, IOptions cacheOptionsAccessor, diff --git a/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs b/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs new file mode 100644 index 0000000..2394ca7 --- /dev/null +++ b/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs @@ -0,0 +1,177 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace UiPath.Caching.Tests; + +// The InMemory provider's cache: MultilayerSetCache over NullSetCache, where the multilayer's local +// tier is the storage — the set analog of InMemoryCacheProvider serving MultilayerCache over NullCache. +public class InMemorySetCacheTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static MultilayerSetCache CreateSut(InMemorySetCacheOptions? options = null) + { + options ??= new InMemorySetCacheOptions(); + return new MultilayerSetCache( + KnownCacheProviderNames.InMemory, NullSetCache.Instance, + new MemoryCacheFactory(null, NullLoggerFactory.Instance), + new SystemJsonSerializerProxy(), options, + localMaxExpiration: null, + defaultExpiration: options.DefaultExpiration); + } + + // Casts to IEnumerable so the call binds to the IEnumerable AddAsync overload rather + // than the single-item AddAsync(..., T item, ...) overload (T = string[]). + private static ValueTask AddMany(MultilayerSetCache sut, CacheKey key, params string[] items) => + sut.AddAsync(key, (IEnumerable)items, (CachePolicy?)null, Ct); + + [Fact] + public void Name_is_InMemory() => CreateSut().Name.Should().Be("InMemory"); + + [Fact] + public async Task Add_single_deduplicates() + { + var sut = CreateSut(); + + (await sut.AddAsync("k", "a", token: Ct)).Should().BeTrue(); + (await sut.AddAsync("k", "a", token: Ct)).Should().BeFalse(); + + (await sut.CountAsync("k", Ct)).Should().Be(1); + (await sut.ContainsItemAsync("k", "a", Ct)).Should().BeTrue(); + (await sut.ContainsAsync("k", Ct)).Should().BeTrue(); + } + + [Fact] + public async Task Add_many_returns_added_count_and_members() + { + var sut = CreateSut(); + + var added = await AddMany(sut, "k", "a", "b", "a"); + + added.Should().Be(2); + (await sut.MembersAsync("k", token: Ct)).Should().BeEquivalentTo(new[] { "a", "b" }); + } + + [Fact] + public async Task Pop_removes_a_random_member() + { + var sut = CreateSut(); + await AddMany(sut, "k", "a", "b", "c"); + + var popped = await sut.PopAsync("k", token: Ct); + + popped.Should().NotBeNull(); + new[] { "a", "b", "c" }.Should().Contain(popped!); + (await sut.CountAsync("k", Ct)).Should().Be(2); + (await sut.ContainsItemAsync("k", popped!, Ct)).Should().BeFalse(); + } + + [Fact] + public async Task Pop_count_removes_multiple() + { + var sut = CreateSut(); + await AddMany(sut, "k", "a", "b", "c"); + + var popped = await sut.PopAsync("k", 2, token: Ct); + + popped.Should().HaveCount(2); + popped.Should().OnlyHaveUniqueItems(); + (await sut.CountAsync("k", Ct)).Should().Be(1); + } + + [Fact] + public async Task Pop_more_than_present_returns_all_and_deletes_key() + { + var sut = CreateSut(); + await AddMany(sut, "k", "a", "b"); + + var popped = await sut.PopAsync("k", 5, token: Ct); + + popped.Should().HaveCount(2); + (await sut.ContainsAsync("k", Ct)).Should().BeFalse(); + } + + [Fact] + public async Task Pop_on_missing_key_returns_default() + { + var sut = CreateSut(); + + (await sut.PopAsync("missing", token: Ct)).Should().BeNull(); + (await sut.PopAsync("missing", 3, token: Ct)).Should().BeEmpty(); + } + + [Fact] + public async Task RemoveItem_and_RemoveItems() + { + var sut = CreateSut(); + await AddMany(sut, "k", "a", "b", "c"); + + (await sut.RemoveItemAsync("k", "a", Ct)).Should().BeTrue(); + (await sut.RemoveItemAsync("k", "a", Ct)).Should().BeFalse(); + (await sut.RemoveItemsAsync("k", new[] { "b", "missing" }, Ct)).Should().Be(1); + + (await sut.MembersAsync("k", token: Ct)).Should().BeEquivalentTo(new[] { "c" }); + } + + [Fact] + public async Task Removing_last_member_deletes_the_key() + { + var sut = CreateSut(); + await sut.AddAsync("k", "a", token: Ct); + + (await sut.RemoveItemAsync("k", "a", Ct)).Should().BeTrue(); + + (await sut.ContainsAsync("k", Ct)).Should().BeFalse(); + (await sut.CountAsync("k", Ct)).Should().Be(0); + } + + [Fact] + public async Task Remove_deletes_whole_set() + { + var sut = CreateSut(); + await AddMany(sut, "k", "a", "b"); + + (await sut.RemoveAsync("k", Ct)).Should().BeTrue(); + (await sut.RemoveAsync("k", Ct)).Should().BeFalse(); + (await sut.MembersAsync("k", token: Ct)).Should().BeEmpty(); + } + + [Fact] + public async Task Add_with_past_expiration_stores_nothing() + { + var sut = CreateSut(); + + var added = await sut.AddAsync("k", new[] { "a" }, TimeSpan.FromSeconds(-1), null, Ct); + + added.Should().Be(0); + (await sut.ContainsAsync("k", Ct)).Should().BeFalse(); + } + + [Fact] + public async Task Reads_on_missing_key_are_empty() + { + var sut = CreateSut(); + + (await sut.MembersAsync("missing", token: Ct)).Should().BeEmpty(); + (await sut.CountAsync("missing", Ct)).Should().Be(0); + (await sut.ContainsItemAsync("missing", "a", Ct)).Should().BeFalse(); + (await sut.ContainsAsync("missing", Ct)).Should().BeFalse(); + } + + [Fact] + public async Task Null_key_throws() + { + var sut = CreateSut(); + + var act = async () => await sut.AddAsync(CacheKey.Null, "a", token: Ct); + + await act.Should().ThrowAsync(); + } + + [Fact] + public void Dispose_can_be_called() + { + var sut = CreateSut(); + var act = () => sut.Dispose(); + act.Should().NotThrow(); + } +} diff --git a/tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs b/tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs new file mode 100644 index 0000000..d7ca48f --- /dev/null +++ b/tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs @@ -0,0 +1,234 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace UiPath.Caching.Tests; + +public class MultilayerSetCacheTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static IMemoryCacheFactory MemoryFactory() => new MemoryCacheFactory(null, NullLoggerFactory.Instance); + + // The inner (L2) is always a real store; the InMemory and InMemoryRedis providers differ only in + // what they pass as L2. A substitute stands in for it here. + private static (MultilayerSetCache Sut, ISetCache L2) CreateSut() + { + var l2 = Substitute.For(); + var sut = new MultilayerSetCache( + KnownCacheProviderNames.InMemoryRedis, l2, + MemoryFactory(), new SystemJsonSerializerProxy(), new InMemoryRedisSetCacheOptions(), + localMaxExpiration: TimeSpan.FromMinutes(5)); + return (sut, l2); + } + + private static void SetupMembers(ISetCache l2, params string?[] members) + { + IReadOnlyCollection snapshot = members.ToList(); + l2.MembersAsync(default, default, Ct) + .ReturnsForAnyArgs(_ => new ValueTask>(snapshot)); + } + + [Fact] + public void Name_reflects_provider() + { + var (sut, _) = CreateSut(); + sut.Name.Should().Be("InMemoryRedis"); + } + + [Fact] + public async Task Members_are_served_from_L1_after_first_fetch() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a", "b"); + + var first = await sut.MembersAsync("k", token: Ct); + var second = await sut.MembersAsync("k", token: Ct); + + first.Should().BeEquivalentTo(new[] { "a", "b" }); + second.Should().BeEquivalentTo(new[] { "a", "b" }); + await l2.ReceivedWithAnyArgs(1).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task Add_writes_through_into_the_local_snapshot() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a"); + l2.AddAsync(default, default(string)!, default, Ct).ReturnsForAnyArgs(_ => new ValueTask(true)); + + await sut.MembersAsync("k", token: Ct); // primes L1 + await sut.AddAsync("k", "b", token: Ct); // must patch L1, not evict it + var members = await sut.MembersAsync("k", token: Ct); // must be served from L1 + + members.Should().BeEquivalentTo(new[] { "a", "b" }); + (await sut.ContainsItemAsync("k", "b", Ct)).Should().BeTrue(); + await l2.ReceivedWithAnyArgs(1).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task Add_many_writes_through_into_the_local_snapshot() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a"); + l2.AddAsync(default, default(IEnumerable)!, default(CachePolicy), Ct).ReturnsForAnyArgs(_ => new ValueTask(2)); + + await sut.MembersAsync("k", token: Ct); // primes L1 + await sut.AddAsync("k", (IEnumerable)new[] { "b", "c" }, (CachePolicy?)null, Ct); + var members = await sut.MembersAsync("k", token: Ct); + + members.Should().BeEquivalentTo(new[] { "a", "b", "c" }); + await l2.ReceivedWithAnyArgs(1).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task Add_does_not_create_a_partial_local_snapshot() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a", "b"); + l2.AddAsync(default, default(string)!, default, Ct).ReturnsForAnyArgs(_ => new ValueTask(true)); + + await sut.AddAsync("k", "b", token: Ct); // no snapshot cached: must not create one + var members = await sut.MembersAsync("k", token: Ct); // must fetch the whole set from L2 + + members.Should().BeEquivalentTo(new[] { "a", "b" }); + await l2.ReceivedWithAnyArgs(1).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task Pop_removes_the_popped_value_from_the_local_snapshot() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a", "b"); + l2.PopAsync(default, default(CachePolicy?), Ct).ReturnsForAnyArgs(_ => new ValueTask("a")); + + await sut.MembersAsync("k", token: Ct); // primes L1 + var popped = await sut.PopAsync("k", token: Ct); + var members = await sut.MembersAsync("k", token: Ct); // must be served from the patched L1 + + popped.Should().Be("a"); + members.Should().BeEquivalentTo(new[] { "b" }); + await l2.ReceivedWithAnyArgs(1).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task RemoveItem_removes_the_value_from_the_local_snapshot() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a", "b"); + l2.RemoveItemAsync(default, default(string)!, Ct).ReturnsForAnyArgs(_ => new ValueTask(true)); + + await sut.MembersAsync("k", token: Ct); // primes L1 + await sut.RemoveItemAsync("k", "a", Ct); + var members = await sut.MembersAsync("k", token: Ct); + + members.Should().BeEquivalentTo(new[] { "b" }); + await l2.ReceivedWithAnyArgs(1).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task Remove_evicts_the_local_snapshot() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a"); + l2.RemoveAsync(default, Ct).ReturnsForAnyArgs(_ => new ValueTask(true)); + + await sut.MembersAsync("k", token: Ct); // primes L1 + await sut.RemoveAsync("k", Ct); + await sut.MembersAsync("k", token: Ct); // must re-read from L2 + + await l2.ReceivedWithAnyArgs(2).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task ContainsItem_Count_and_Contains_use_L1_when_cached() + { + var (sut, l2) = CreateSut(); + SetupMembers(l2, "a", "b"); + + await sut.MembersAsync("k", token: Ct); // primes L1 + + (await sut.ContainsItemAsync("k", "a", Ct)).Should().BeTrue(); + (await sut.CountAsync("k", Ct)).Should().Be(2); + (await sut.ContainsAsync("k", Ct)).Should().BeTrue(); + + await l2.DidNotReceiveWithAnyArgs().ContainsItemAsync(default, default(string)!, Ct); + await l2.DidNotReceiveWithAnyArgs().CountAsync(default, Ct); + } + + [Fact] + public async Task Reads_fall_through_to_inner_when_not_cached() + { + var (sut, l2) = CreateSut(); + l2.CountAsync(default, Ct).ReturnsForAnyArgs(_ => new ValueTask(7)); + + (await sut.CountAsync("k", Ct)).Should().Be(7); + await l2.ReceivedWithAnyArgs(1).CountAsync(default, Ct); + } + + // Inner implementing IConnectionState is picked up by the connection monitor, mirroring how + // MultilayerCacheBase resolves the monitor from its inner cache. + private static (MultilayerSetCache Sut, ISetCache L2) CreateMonitoredSut(bool connected, bool useLocalOnlyWhenDisconnected) + { + var l2 = Substitute.For(); + ((IConnectionState)l2).IsConnected.Returns(connected); + var sut = new MultilayerSetCache( + KnownCacheProviderNames.InMemoryRedis, l2, + MemoryFactory(), new SystemJsonSerializerProxy(), new InMemoryRedisSetCacheOptions(), + localMaxExpiration: TimeSpan.FromMinutes(5), + connectionMonitorEnabled: true, + useLocalOnlyWhenDisconnected: useLocalOnlyWhenDisconnected, + localMaxExpirationDisconnected: TimeSpan.FromSeconds(30)); + return (sut, l2); + } + + [Fact] + public async Task Disconnected_add_with_local_only_writes_to_L1_and_skips_inner() + { + var (sut, l2) = CreateMonitoredSut(connected: false, useLocalOnlyWhenDisconnected: true); + + (await sut.AddAsync("k", "x", token: Ct)).Should().BeTrue(); + (await sut.MembersAsync("k", token: Ct)).Should().BeEquivalentTo(new[] { "x" }); + (await sut.ContainsItemAsync("k", "x", Ct)).Should().BeTrue(); + + await l2.DidNotReceiveWithAnyArgs().AddAsync(default, default(string)!, default, Ct); + await l2.DidNotReceiveWithAnyArgs().MembersAsync(default, default, Ct); + } + + [Fact] + public async Task Disconnected_pop_and_remove_with_local_only_operate_on_L1() + { + var (sut, l2) = CreateMonitoredSut(connected: false, useLocalOnlyWhenDisconnected: true); + await sut.AddAsync("k", (IEnumerable)new[] { "a", "b", "c" }, (CachePolicy?)null, Ct); + + var popped = await sut.PopAsync("k", token: Ct); + new[] { "a", "b", "c" }.Should().Contain(popped!); + (await sut.RemoveItemAsync("k", new[] { "a", "b", "c" }.First(v => v != popped), Ct)).Should().BeTrue(); + (await sut.CountAsync("k", Ct)).Should().Be(1); + + await l2.DidNotReceiveWithAnyArgs().PopAsync(default, default(CachePolicy?), Ct); + await l2.DidNotReceiveWithAnyArgs().RemoveItemAsync(default, default(string)!, Ct); + } + + [Fact] + public async Task Disconnected_read_without_local_only_evicts_the_snapshot_and_returns_default() + { + var (sut, l2) = CreateMonitoredSut(connected: true, useLocalOnlyWhenDisconnected: false); + SetupMembers(l2, "a"); + + await sut.MembersAsync("k", token: Ct); // primes L1 while connected + ((IConnectionState)l2).IsConnected.Returns(false); + + (await sut.MembersAsync("k", token: Ct)).Should().BeEmpty(); + await l2.ReceivedWithAnyArgs(1).MembersAsync(default, default, Ct); + } + + [Fact] + public async Task Disconnected_without_local_only_still_writes_through_to_inner() + { + var (sut, l2) = CreateMonitoredSut(connected: false, useLocalOnlyWhenDisconnected: false); + l2.AddAsync(default, default(string)!, default, Ct).ReturnsForAnyArgs(_ => new ValueTask(true)); + + (await sut.AddAsync("k", "x", token: Ct)).Should().BeTrue(); + + await l2.ReceivedWithAnyArgs(1).AddAsync(default, default(string)!, default, Ct); + } +} diff --git a/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs b/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs index 382316b..58c1a3f 100644 --- a/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs @@ -1,5 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using UiPath.Caching.Config; +using UiPath.Caching.Queue.Config; namespace UiPath.Caching.Tests; @@ -54,8 +55,11 @@ public void AddRedisSetCache_registers_IQueueCacheFactory() services.AddRedisSetCache(); + // The factory is registered from the set of ISetCacheProvider registrations, so it is wired + // via a factory delegate rather than an implementation type. + services.Should().ContainSingle(d => d.ServiceType == typeof(IQueueCacheFactory)); services.Should().ContainSingle(d => - d.ServiceType == typeof(IQueueCacheFactory) && d.ImplementationType == typeof(QueueCacheFactory)); + d.ServiceType == typeof(ISetCacheProvider) && d.ImplementationType == typeof(RedisSetCacheProvider)); } [Fact] diff --git a/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs b/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs index 6992499..9811b9a 100644 --- a/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs +++ b/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs @@ -1,5 +1,6 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection; using UiPath.Caching.Config; +using UiPath.Caching.Queue.Config; namespace UiPath.Caching.Tests; @@ -39,4 +40,49 @@ public void AddRedisSetCache_on_service_collection_propagates_ResilienceKeyName( var options = provider.GetRequiredService>().Value; options.ResilienceKeyName.Should().Be("set-pop"); } + + [Fact] + public void AddInMemorySetCache_resolves_in_memory_set_cache_and_factory() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCaching(builder => builder.AddInMemorySetCache()); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Should().BeOfType(); + + var factory = provider.GetRequiredService(); + var setCache = factory.CreateSetCache(); + setCache.Should().BeOfType(); + setCache.Name.Should().Be(KnownCacheProviderNames.InMemory); + factory.ProviderNames.Should().Contain(KnownCacheProviderNames.InMemory); + + provider.GetRequiredService>().Should().BeOfType>(); + } + + [Fact] + public void AddInMemorySetCache_disabled_registers_null_set_cache() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCaching(builder => builder.AddInMemorySetCache(o => o.Enabled = false)); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService().Should().BeSameAs(NullSetCache.Instance); + provider.GetRequiredService().Should().BeSameAs(NullQueueCacheFactory.Instance); + } + + [Fact] + public void AddInMemoryRedisSetCache_registers_provider_and_options() + { + var services = new ServiceCollection(); + services.AddInMemoryRedisSetCache(o => o.LocalMaxExpiration = TimeSpan.FromSeconds(30)); + using var provider = services.BuildServiceProvider(); + + provider.GetRequiredService>().Value.LocalMaxExpiration + .Should().Be(TimeSpan.FromSeconds(30)); + services.Should().Contain(d => + d.ServiceType == typeof(ISetCacheProvider) && + d.ImplementationType == typeof(InMemoryRedisSetCacheProvider)); + } } diff --git a/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs b/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs new file mode 100644 index 0000000..51894a4 --- /dev/null +++ b/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs @@ -0,0 +1,159 @@ +using Microsoft.Extensions.Logging.Abstractions; + +namespace UiPath.Caching.Tests; + +public class InMemorySetCacheProviderTests +{ + private static InMemorySetCacheProvider CreateSut(InMemorySetCacheOptions? options = null) => + new(new MemoryCacheFactory(null, NullLoggerFactory.Instance), + new SystemJsonSerializerProxy(), + Options.Create(options ?? new InMemorySetCacheOptions())); + + [Fact] + public void Creates_in_memory_set_cache() + { + var sut = CreateSut(); + + sut.Name.Should().Be("InMemory"); + sut.Enabled.Should().BeTrue(); + sut.CreateSetCache().Should().BeOfType(); + sut.CreateSetCache().Name.Should().Be("InMemory"); + sut.CreateSetCache().Should().BeSameAs(sut.CreateSetCache()); + } + + [Fact] + public void Enabled_reflects_options() + { + CreateSut(new InMemorySetCacheOptions { Enabled = false }).Enabled.Should().BeFalse(); + } + + [Fact] + public async Task Provider_cache_stores_and_reads() + { + var ct = TestContext.Current.CancellationToken; + var cache = CreateSut().CreateSetCache(); + + (await cache.AddAsync("k", "a", token: ct)).Should().BeTrue(); + (await cache.AddAsync("k", "b", token: ct)).Should().BeTrue(); + + (await cache.MembersAsync("k", token: ct)).Should().BeEquivalentTo(new[] { "a", "b" }); + (await cache.CountAsync("k", ct)).Should().Be(2); + + var popped = await cache.PopAsync("k", token: ct); + new[] { "a", "b" }.Should().Contain(popped!); + (await cache.CountAsync("k", ct)).Should().Be(1); + } + + [Fact] + public void Dispose_can_be_called() + { + var sut = CreateSut(); + _ = sut.CreateSetCache(); + var act = () => sut.Dispose(); + act.Should().NotThrow(); + } +} + +public class InMemoryRedisSetCacheProviderTests +{ + [Fact] + public async Task Resolves_its_L2_from_the_factory_Redis_provider() + { + var ct = TestContext.Current.CancellationToken; + var redisL2 = Substitute.For(); + redisL2.AddAsync(default, default(string)!, default, ct).ReturnsForAnyArgs(_ => new ValueTask(true)); + + var factory = Substitute.For(); + factory.CreateSetCache(KnownCacheProviderNames.Redis).Returns(redisL2); + + var provider = new InMemoryRedisSetCacheProvider( + () => factory, + new MemoryCacheFactory(null, NullLoggerFactory.Instance), + new SystemJsonSerializerProxy(), + Options.Create(new InMemoryRedisSetCacheOptions())); + + var cache = provider.CreateSetCache(); + cache.Should().BeOfType(); + cache.Name.Should().Be(KnownCacheProviderNames.InMemoryRedis); + + await cache.AddAsync("k", "a", (CachePolicy?)null, ct); + + factory.Received(1).CreateSetCache(KnownCacheProviderNames.Redis); + await redisL2.ReceivedWithAnyArgs(1).AddAsync(default, default(string)!, default, ct); + } +} + +public class QueueCacheFactoryProviderSelectionTests +{ + private static ISetCacheProvider Provider(string name, ISetCache cache, bool enabled = true) + { + var provider = Substitute.For(); + provider.Name.Returns(name); + provider.Enabled.Returns(enabled); + provider.CreateSetCache().Returns(cache); + return provider; + } + + [Fact] + public void Selects_default_provider_then_by_name() + { + var inMem = Substitute.For(); + var redis = Substitute.For(); + var factory = new QueueCacheFactory( + new[] { Provider(KnownCacheProviderNames.InMemory, inMem), Provider(KnownCacheProviderNames.Redis, redis) }, + Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.Redis })); + + factory.CreateSetCache().Should().BeSameAs(redis); + factory.CreateSetCache(KnownCacheProviderNames.InMemory).Should().BeSameAs(inMem); + factory.ProviderNames.Should().BeEquivalentTo(KnownCacheProviderNames.InMemory, KnownCacheProviderNames.Redis); + } + + [Fact] + public void Falls_back_to_single_registered_provider_when_default_missing() + { + var inMem = Substitute.For(); + var factory = new QueueCacheFactory( + new[] { Provider(KnownCacheProviderNames.InMemory, inMem) }, + Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.InMemoryRedis })); + + factory.CreateSetCache().Should().BeSameAs(inMem); + } + + [Fact] + public void Unknown_provider_with_multiple_registered_returns_null_cache() + { + var factory = new QueueCacheFactory( + new[] + { + Provider(KnownCacheProviderNames.InMemory, Substitute.For()), + Provider(KnownCacheProviderNames.Redis, Substitute.For()), + }, + Options.Create(new CacheOptions { DefaultCache = "does-not-exist" })); + + factory.CreateSetCache().Should().BeSameAs(NullSetCache.Instance); + factory.CreateSetCache("also-missing").Should().BeSameAs(NullSetCache.Instance); + } + + [Fact] + public void Disabled_provider_is_not_selected() + { + var factory = new QueueCacheFactory( + new[] { Provider(KnownCacheProviderNames.InMemory, Substitute.For(), enabled: false) }, + Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.InMemory })); + + factory.CreateSetCache().Should().BeSameAs(NullSetCache.Instance); + factory.ProviderNames.Should().BeEmpty(); + } + + [Fact] + public void Single_cache_ctor_ignores_provider_name() + { + var cache = Substitute.For(); + cache.Name.Returns(KnownCacheProviderNames.Redis); + var factory = new QueueCacheFactory(cache); + + factory.CreateSetCache().Should().BeSameAs(cache); + factory.CreateSetCache("anything").Should().BeSameAs(cache); + factory.ProviderNames.Should().BeEquivalentTo(KnownCacheProviderNames.Redis); + } +} From a268401762f489d50f245e45238751e563b0283a Mon Sep 17 00:00:00 2001 From: robert-mihaiAdam Date: Mon, 20 Jul 2026 17:14:57 +0300 Subject: [PATCH 2/2] Fix --- CHANGELOG.md | 2 +- .../Config/SetCacheCollectionExtensions.cs | 6 +- .../IQueueCacheFactory.cs | 16 +-- .../NullQueueCacheFactory.cs | 14 +++ .../PublicAPI.Unshipped.txt | 12 ++- src/UiPath.Caching.Queue/QueueCacheFactory.cs | 97 +++++++++++-------- .../QueueCacheFactoryExtensions.cs | 17 ++-- .../QueueCacheFactoryTests.cs | 2 +- .../SetCacheCollectionExtensionsTests.cs | 21 +++- .../SetCacheProviderTests.cs | 81 ++++++++++++++-- 10 files changed, 196 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cbc84f7..a4dfc24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - The hit/miss metric (`Caching.Stats.Hits`/`Misses`) is now emitted **once per read operation** with a `Keys` dimension carrying the number of keys in the operation (a batch's outcome is hit when any key hit). A multi-key `MGET` no longer fans out into one elapsed-time sample per key, which would skew the metric's latency distribution by batch size. Every hit/miss metric carries the `Keys` dimension (`1` for single-key and non-read operations such as writes/deletes/TTL, the batch size for multi-key reads and bulk set/remove) so the dimension is consistent across the metric name and group-by-`Keys` queries don't bucket operations into a null dimension. - `RedisCacheOptions.KeyReadTelemetryEnabled` (default `false`) — opt-in per-key read attribution for `RedisCache` and `RedisHashCache`. When enabled, the read paths additionally emit a dependency **per key** with `type = "Redis"`, the Redis key in `data`, hit/miss carried by `resultCode` (`Hit`/`Miss`) plus an `Outcome` property, `Provider`/`Method`/`Type` properties, and a `BatchId` shared by all keys of one operation so the keys read together can be correlated (`summarize by BatchId` — a `Guid` minted per operation, since neither Redis nor StackExchange.Redis exposes a transaction id). The dependency's `success` is always `true` (a cache miss is not a failure, and `type = "Redis"` is shared with the profiler's dependency telemetry, so misses must not skew dependency-failure dashboards). For `RedisHashCache` it is **one dependency per hash key** (not per field) — an HGETALL on a hash with thousands of fields must not fan out into thousands of telemetry items — so `data` is the hash key and the field set is not enumerated. It lands in the same `dependencies` table the Redis profiler uses, so existing key-level KQL keeps working — restoring the "which keys are being read" attribution the profiler can no longer provide once batched reads are bundled into a single `MULTI`/`EXEC` transaction (the keys are no longer visible on the wire). It is opt-in because raw keys are high-cardinality. Surfaced through two new `ITelemetryOperation` members — `Track(bool hit, int keyCount)` (per-operation hit/miss metric with key count) and `TrackKeyReads((string Key, bool Hit)[] reads)` (opt-in per-key dependencies sharing a `BatchId`) — implemented by `TelemetryOperation` and no-op'd by `NullTelemetryOperation`. The per-key dependency's start time is captured at operation start (not at emit time). -- `IQueueCacheFactory` / `QueueCacheFactory` (+ `QueueCacheFactoryExtensions.CreateSetCache()`) in `UiPath.Platform.Caching.Queue` — a dedicated set-cache factory that lets you create a typed set the same ergonomic way you create a cache: `queueCacheFactory.CreateSetCache()`. It mirrors `CacheFactory.CreateCache` / `CacheFactoryExtensions.CreateCache` for Redis sets. It is a separate factory (not a method on `ICacheFactory`) because the entire set implementation lives in the optional `Caching.Queue` package, which is downstream of `ICacheFactory` in `Caching.Abstractions` — so `ICacheFactory` cannot reference `ISetCache`. `QueueCacheFactory` hands out the singleton `ISetCache` registered by `AddRedisSetCache` (sets have a single Redis backing); `CreateSetCache()` wraps it in `SetCache` with no policy factory, exactly like `CreateCache` (the underlying `RedisSetCache` still applies the global default policy). Registered automatically by `AddRedisSetCache`; inject `IQueueCacheFactory` where you need sets. +- `IQueueCacheFactory` / `QueueCacheFactory` (+ `QueueCacheFactoryExtensions.CreateSetCache()`) in `UiPath.Platform.Caching.Queue` — a dedicated set-cache factory that lets you create a typed set the same ergonomic way you create a cache: `queueCacheFactory.CreateSetCache()`. It mirrors `CacheFactory.CreateCache` / `CacheFactoryExtensions.CreateCache` for Redis sets. It is a separate factory (not a method on `ICacheFactory`) because the entire set implementation lives in the optional `Caching.Queue` package, which is downstream of `ICacheFactory` in `Caching.Abstractions` — so `ICacheFactory` cannot reference `ISetCache`. `QueueCacheFactory` follows `CacheFactory`'s provider model: the registered `ISetCacheProvider`s (`InMemory` / `Redis` / `InMemoryRedis`) are keyed by name and selected via `CacheOptions.DefaultCache` or an explicit provider name — an unknown name falls back to the default provider, then `NullSetCache.Instance` — and the factory is `IDisposable` with `AddProvider`, exactly like `ICacheFactory`. `CreateSetCache()` wraps the resolved set cache in `SetCache` with no policy factory, exactly like `CreateCache` (the underlying set cache still applies the global default policy). Registered automatically by the `AddInMemorySetCache` / `AddRedisSetCache` / `AddInMemoryRedisSetCache` extensions; inject `IQueueCacheFactory` where you need sets. - `NullQueueCacheFactory` (in `UiPath.Caching`) — a no-op `IQueueCacheFactory` whose `CreateSetCache()` returns the singleton `NullSetCache.Instance`. `AddRedisSetCache` now registers `NullSetCache`, `NullQueueCacheFactory`, and `SetCache<>` when caching is disabled (`ICachingBuilder.Enabled == false`), mirroring how the core cache degrades to no-ops. Previously the disabled branch registered nothing, so any service depending on `ISetCache` / `IQueueCacheFactory` failed to resolve (and `ValidateOnBuild` threw) when caching was turned off; dependents now construct against the null set cache instead. - `IResiliencePipelineProvider` — a name-based factory (`IResiliencePipeline Get(string? name)`) that replaces `IResiliencePipelineHolder`. It builds and caches a `ResiliencePipelineWrapper` for any **registered** name on first use, and returns a noop `EmptyResiliencePipeline` for a null/empty/unregistered name, so every component resolves the pipeline it needs through DI instead of receiving a fixed holder. The default registration is `EmptyResiliencePipelineProvider.Instance` (all-noop); `AddResilienceStrategies` registers the real `ResiliencePipelineProvider`. **BREAKING:** `IResiliencePipelineHolder` / `ResiliencePipelineHolder` are removed in favor of `IResiliencePipelineProvider`; the cache and topic components now take an `IResiliencePipelineProvider` constructor parameter. Consumers using the shipped DI wiring are unaffected; direct callers must pass the provider (use `EmptyResiliencePipelineProvider.Instance` to opt out of resilience). - `CachingBuilderExtensions.AddResiliencePipeline(name, Action)` — registers a named resilience pipeline, where `name` is the scope passed to `IResiliencePipelineProvider.Get(name)`. Each name gets its own `ResiliencePoliciesOptions` (resolved via `IOptionsMonitor` named options), so different scopes can carry different retry/timeout/circuit-breaker settings. `AddResilienceStrategies` predefines `ResiliencePipelineNames.Read` and `Write` from the base configuration; consumers can add new pipelines or retune the built-ins. Only registered names resolve to a real pipeline. The `ResiliencePipelineFactory` constructor now takes `IOptionsMonitor` instead of `IOptions` so it can resolve per-scope options; `Create` selects the options for its scope via `optionsMonitor.Get(scope)`. diff --git a/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs b/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs index 6c72269..cdf2803 100644 --- a/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs +++ b/src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs @@ -30,7 +30,9 @@ public static IServiceCollection AddInMemorySetCache(this IServiceCollection ser /// Registers the in-process provider (InMemory) plus /// , and . /// Requires the core caching services (AddCaching). Unlike the Redis providers, this one - /// has no Redis prerequisite. + /// has no Redis prerequisite. The factory selects providers via + /// (default InMemoryRedis, like the core cache + /// factory) — point it at InMemory when this is the only registered set-cache provider. /// public static IServiceCollection AddInMemorySetCache(this IServiceCollection services, Action configureOptions) { @@ -151,7 +153,7 @@ public static IServiceCollection AddInMemoryRedisSetCache(this IServiceCollectio private static IServiceCollection AddSetCacheCore(this IServiceCollection services) { services.TryAddSingleton(sp => - new QueueCacheFactory(sp.GetServices(), sp.GetRequiredService>())); + new QueueCacheFactory(sp.GetRequiredService>(), sp.GetServices())); // Deferred accessor so a provider can reach the factory without a construction-time cycle // (the factory is built from the providers). Mirrors the core's Func. services.TryAddTransient>(sp => () => sp.GetRequiredService()); diff --git a/src/UiPath.Caching.Queue/IQueueCacheFactory.cs b/src/UiPath.Caching.Queue/IQueueCacheFactory.cs index 6b92f75..faa459c 100644 --- a/src/UiPath.Caching.Queue/IQueueCacheFactory.cs +++ b/src/UiPath.Caching.Queue/IQueueCacheFactory.cs @@ -6,18 +6,22 @@ namespace UiPath.Caching; /// AddInMemorySetCache / AddRedisSetCache / AddInMemoryRedisSetCache. Inject /// this instead of when you need sets. /// -public interface IQueueCacheFactory +public interface IQueueCacheFactory : IDisposable { + /// Names of the registered, enabled set-cache providers. + IEnumerable ProviderNames { get; } + /// Returns the for the default provider. ISetCache CreateSetCache(); - /// Names of the registered, enabled set-cache providers. - IEnumerable ProviderNames => []; - /// /// Returns the for the given provider (see /// ). When is - /// , the configured default provider is used. + /// or not registered, the + /// provider is used, degrading to when that is missing too. /// - ISetCache CreateSetCache(string? providerName) => CreateSetCache(); + ISetCache CreateSetCache(string? providerName); + + /// Registers (or replaces) a provider under its . + void AddProvider(ISetCacheProvider provider); } diff --git a/src/UiPath.Caching.Queue/NullQueueCacheFactory.cs b/src/UiPath.Caching.Queue/NullQueueCacheFactory.cs index 91a3d9b..91f01bf 100644 --- a/src/UiPath.Caching.Queue/NullQueueCacheFactory.cs +++ b/src/UiPath.Caching.Queue/NullQueueCacheFactory.cs @@ -5,5 +5,19 @@ public sealed class NullQueueCacheFactory : IQueueCacheFactory { public static readonly NullQueueCacheFactory Instance = new(); + public IEnumerable ProviderNames => []; + + public void AddProvider(ISetCacheProvider provider) + { + // do nothing + } + public ISetCache CreateSetCache() => NullSetCache.Instance; + + public ISetCache CreateSetCache(string? providerName) => NullSetCache.Instance; + + public void Dispose() + { + // do nothing + } } diff --git a/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt b/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt index e23dfff..9cbf024 100644 --- a/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt +++ b/src/UiPath.Caching.Queue/PublicAPI.Unshipped.txt @@ -1,10 +1,15 @@ #nullable enable UiPath.Caching.NullQueueCacheFactory +UiPath.Caching.NullQueueCacheFactory.AddProvider(UiPath.Caching.ISetCacheProvider! provider) -> void UiPath.Caching.NullQueueCacheFactory.CreateSetCache() -> UiPath.Caching.ISetCache! +UiPath.Caching.NullQueueCacheFactory.CreateSetCache(string? providerName) -> UiPath.Caching.ISetCache! +UiPath.Caching.NullQueueCacheFactory.Dispose() -> void UiPath.Caching.NullQueueCacheFactory.NullQueueCacheFactory() -> void +UiPath.Caching.NullQueueCacheFactory.ProviderNames.get -> System.Collections.Generic.IEnumerable! static readonly UiPath.Caching.NullQueueCacheFactory.Instance -> UiPath.Caching.NullQueueCacheFactory! UiPath.Caching.Redis.RedisSetCacheOptions.Enabled.get -> bool UiPath.Caching.Redis.RedisSetCacheOptions.Enabled.set -> void +UiPath.Caching.IQueueCacheFactory.AddProvider(UiPath.Caching.ISetCacheProvider! provider) -> void UiPath.Caching.IQueueCacheFactory.CreateSetCache(string? providerName) -> UiPath.Caching.ISetCache! UiPath.Caching.IQueueCacheFactory.ProviderNames.get -> System.Collections.Generic.IEnumerable! UiPath.Caching.ISetCacheProvider @@ -63,9 +68,12 @@ UiPath.Caching.InMemorySetCacheProvider.Dispose() -> void UiPath.Caching.InMemorySetCacheProvider.Enabled.get -> bool UiPath.Caching.InMemorySetCacheProvider.InMemorySetCacheProvider(UiPath.Caching.IMemoryCacheFactory! memoryCacheFactory, UiPath.Caching.ISerializerProxy! serializer, Microsoft.Extensions.Options.IOptions! options) -> void UiPath.Caching.InMemorySetCacheProvider.Name.get -> string! +UiPath.Caching.QueueCacheFactory.AddProvider(UiPath.Caching.ISetCacheProvider! provider) -> void UiPath.Caching.QueueCacheFactory.CreateSetCache(string? providerName) -> UiPath.Caching.ISetCache! +UiPath.Caching.QueueCacheFactory.Dispose() -> void UiPath.Caching.QueueCacheFactory.ProviderNames.get -> System.Collections.Generic.IEnumerable! -UiPath.Caching.QueueCacheFactory.QueueCacheFactory(System.Collections.Generic.IEnumerable! providers, Microsoft.Extensions.Options.IOptions! cacheOptions) -> void +UiPath.Caching.QueueCacheFactory.QueueCacheFactory(Microsoft.Extensions.Options.IOptions! cacheOptions) -> void +UiPath.Caching.QueueCacheFactory.QueueCacheFactory(Microsoft.Extensions.Options.IOptions! cacheOptions, System.Collections.Generic.IEnumerable! providers) -> void UiPath.Caching.RedisSetCacheProvider UiPath.Caching.RedisSetCacheProvider.CreateSetCache() -> UiPath.Caching.ISetCache! UiPath.Caching.RedisSetCacheProvider.Dispose() -> void @@ -80,4 +88,4 @@ static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCa static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configureOptions) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCache(this UiPath.Caching.Config.ICachingBuilder! builder) -> UiPath.Caching.Config.ICachingBuilder! static UiPath.Caching.Queue.Config.SetCacheCollectionExtensions.AddInMemorySetCache(this UiPath.Caching.Config.ICachingBuilder! builder, System.Action! configureOptions) -> UiPath.Caching.Config.ICachingBuilder! -static UiPath.Caching.QueueCacheFactoryExtensions.CreateSetCache(this UiPath.Caching.IQueueCacheFactory! factory, string? providerName = null) -> UiPath.Caching.ISetCache! +static UiPath.Caching.QueueCacheFactoryExtensions.CreateSetCache(this UiPath.Caching.IQueueCacheFactory! factory, string? providerName) -> UiPath.Caching.ISetCache! diff --git a/src/UiPath.Caching.Queue/QueueCacheFactory.cs b/src/UiPath.Caching.Queue/QueueCacheFactory.cs index 12d1096..c0c40ed 100644 --- a/src/UiPath.Caching.Queue/QueueCacheFactory.cs +++ b/src/UiPath.Caching.Queue/QueueCacheFactory.cs @@ -1,75 +1,94 @@ namespace UiPath.Caching; /// -/// Default . Mirrors CacheFactory: holds the registered -/// instances keyed by name and hands out the matching -/// , defaulting to . +/// Default . Mirrors : holds the +/// registered instances keyed by name and hands out the matching +/// , defaulting to and degrading to +/// when no provider matches. /// public sealed class QueueCacheFactory : IQueueCacheFactory { private readonly Dictionary _providers = new(StringComparer.OrdinalIgnoreCase); - private readonly ISetCache? _single; - private readonly string _defaultCache = KnownCacheProviderNames.InMemoryRedis; + private readonly CacheOptions _options; + private volatile bool _disposed; /// - /// Single-provider factory: always hands out regardless of the - /// requested provider name. Retained for backward compatibility. + /// Single-cache factory: hands out for every request. Retained for + /// backward compatibility; prefer the provider-based constructors. /// public QueueCacheFactory(ISetCache setCache) { ArgumentNullException.ThrowIfNull(setCache); - _single = setCache; + _options = new CacheOptions { DefaultCache = setCache.Name }; + AddProvider(new SingleSetCacheProvider(setCache)); } - /// - /// Provider-based factory: selects an by name (or by - /// when no name is given). - /// - public QueueCacheFactory(IEnumerable providers, IOptions cacheOptions) + public QueueCacheFactory(IOptions cacheOptions) + : this(cacheOptions, []) + { + } + + public QueueCacheFactory(IOptions cacheOptions, IEnumerable providers) { - ArgumentNullException.ThrowIfNull(providers); - _defaultCache = cacheOptions?.Value.DefaultCache ?? KnownCacheProviderNames.InMemoryRedis; + _options = cacheOptions.Value; foreach (var provider in providers) { - _providers[provider.Name] = provider; + AddProvider(provider); } } - public IEnumerable ProviderNames => - _single is not null ? [_single.Name] : _providers.Values.Where(p => p.Enabled).Select(p => p.Name); + public IEnumerable ProviderNames => _providers.Values.Where(p => p.Enabled).Select(p => p.Name); - public ISetCache CreateSetCache() => Resolve(_defaultCache, allowFallback: true); + public void AddProvider(ISetCacheProvider provider) + { + ObjectDisposedException.ThrowIf(_disposed, this); + _providers[provider.Name] = provider; + } + + public ISetCache CreateSetCache() => CreateSetCache(providerName: null); - // A null providerName means "the default provider" and is allowed to fall back to the sole - // registered provider. An explicit name must resolve exactly (or yield NullSetCache) — this is - // what the multilayer provider uses to fetch its Redis tier, and falling back there could - // resolve the multilayer provider back to itself. public ISetCache CreateSetCache(string? providerName) => - providerName is null ? Resolve(_defaultCache, allowFallback: true) : Resolve(providerName, allowFallback: false); + GetProvider(providerName ?? _options.DefaultCache)?.CreateSetCache() ?? DefaultSetCache(); - private ISetCache Resolve(string providerName, bool allowFallback) + public void Dispose() { - if (_single is not null) - { - return _single; - } - var provider = GetProvider(providerName); - if (provider is not null) + foreach (var p in _providers) { - return provider.CreateSetCache(); + try + { + p.Value.Dispose(); + } + catch + { + // Swallow exceptions on dispose + } } - return allowFallback && FallbackProvider() is { } sole ? sole.CreateSetCache() : NullSetCache.Instance; + _providers.Clear(); + _disposed = true; } + private ISetCache DefaultSetCache() => GetProvider(_options.DefaultCache)?.CreateSetCache() ?? NullSetCache.Instance; + private ISetCacheProvider? GetProvider(string providerName) => _providers.TryGetValue(providerName, out var provider) && provider.Enabled ? provider : null; - // When the default provider is not registered, fall back to the sole enabled provider (if - // unambiguous) so a single AddXxxSetCache registration "just works" without configuring - // CacheOptions.DefaultCache. - private ISetCacheProvider? FallbackProvider() + // Adapts the single-cache constructor to the provider model: that constructor points + // CacheOptions.DefaultCache at this provider, so every request resolves to the supplied cache. + private sealed class SingleSetCacheProvider : ISetCacheProvider { - var enabled = _providers.Values.Where(p => p.Enabled).ToArray(); - return enabled.Length == 1 ? enabled[0] : null; + private readonly ISetCache _setCache; + + public SingleSetCacheProvider(ISetCache setCache) => _setCache = setCache; + + public string Name => _setCache.Name; + + public bool Enabled => true; + + public ISetCache CreateSetCache() => _setCache; + + public void Dispose() + { + // The factory does not own the externally supplied cache. + } } } diff --git a/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs b/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs index 715db77..edea7a7 100644 --- a/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs +++ b/src/UiPath.Caching.Queue/QueueCacheFactoryExtensions.cs @@ -2,14 +2,11 @@ namespace UiPath.Caching; public static class QueueCacheFactoryExtensions { - // Mirrors CacheFactoryExtensions.CreateCache (which wraps ICache in Cache). No policy - // factory is passed: the underlying set cache already applies the global default policy. - public static ISetCache CreateSetCache(this IQueueCacheFactory factory, string? providerName = null) - { - ArgumentNullException.ThrowIfNull(factory); - // Route the default case through the parameterless method so it stays equivalent to - // CreateSetCache() (the provider-name overload is a default interface method). - var setCache = providerName is null ? factory.CreateSetCache() : factory.CreateSetCache(providerName); - return new SetCache(setCache); - } + // Mirrors CacheFactoryExtensions.CreateCache. Split into two overloads (rather than one + // optional parameter) because the parameterless one is the originally shipped surface. + public static ISetCache CreateSetCache(this IQueueCacheFactory factory) => + new SetCache(factory.CreateSetCache()); + + public static ISetCache CreateSetCache(this IQueueCacheFactory factory, string? providerName) => + new SetCache(factory.CreateSetCache(providerName)); } diff --git a/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs b/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs index 58c1a3f..41cc23a 100644 --- a/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/QueueCacheFactoryTests.cs @@ -43,7 +43,7 @@ public void QueueCacheFactory_hands_out_the_registered_set_cache() [Fact] public void QueueCacheFactory_throws_when_set_cache_is_null() { - var act = () => new QueueCacheFactory(null!); + var act = () => new QueueCacheFactory((ISetCache)null!); act.Should().Throw(); } diff --git a/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs b/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs index 9811b9a..da5ab75 100644 --- a/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs +++ b/tests/UiPath.Caching.Tests/SetCacheCollectionExtensionsTests.cs @@ -46,7 +46,11 @@ public void AddInMemorySetCache_resolves_in_memory_set_cache_and_factory() { var services = new ServiceCollection(); services.AddLogging(); - services.AddCaching(builder => builder.AddInMemorySetCache()); + // The factory resolves the default provider from CacheOptions.DefaultCache (InMemoryRedis + // when unset), exactly like CacheFactory — point it at the InMemory provider. + services.AddCaching( + builder => builder.AddInMemorySetCache(), + opt => opt.DefaultCache = KnownCacheProviderNames.InMemory); using var provider = services.BuildServiceProvider(); provider.GetRequiredService().Should().BeOfType(); @@ -60,6 +64,21 @@ public void AddInMemorySetCache_resolves_in_memory_set_cache_and_factory() provider.GetRequiredService>().Should().BeOfType>(); } + [Fact] + public void AddInMemorySetCache_without_default_cache_configured_resolves_null_set_cache() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddCaching(builder => builder.AddInMemorySetCache()); + using var provider = services.BuildServiceProvider(); + + // Mirrors CacheFactory: an unregistered default (InMemoryRedis when unset) resolves to the + // null cache — there is no fallback to the sole registered provider. + provider.GetRequiredService().Should().BeSameAs(NullSetCache.Instance); + provider.GetRequiredService() + .CreateSetCache(KnownCacheProviderNames.InMemory).Should().BeOfType(); + } + [Fact] public void AddInMemorySetCache_disabled_registers_null_set_cache() { diff --git a/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs b/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs index 51894a4..4862df8 100644 --- a/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs +++ b/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs @@ -100,8 +100,8 @@ public void Selects_default_provider_then_by_name() var inMem = Substitute.For(); var redis = Substitute.For(); var factory = new QueueCacheFactory( - new[] { Provider(KnownCacheProviderNames.InMemory, inMem), Provider(KnownCacheProviderNames.Redis, redis) }, - Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.Redis })); + Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.Redis }), + new[] { Provider(KnownCacheProviderNames.InMemory, inMem), Provider(KnownCacheProviderNames.Redis, redis) }); factory.CreateSetCache().Should().BeSameAs(redis); factory.CreateSetCache(KnownCacheProviderNames.InMemory).Should().BeSameAs(inMem); @@ -109,42 +109,103 @@ public void Selects_default_provider_then_by_name() } [Fact] - public void Falls_back_to_single_registered_provider_when_default_missing() + public void Unknown_provider_name_falls_back_to_the_default_provider() + { + var inMem = Substitute.For(); + var redis = Substitute.For(); + var factory = new QueueCacheFactory( + Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.Redis }), + new[] { Provider(KnownCacheProviderNames.InMemory, inMem), Provider(KnownCacheProviderNames.Redis, redis) }); + + factory.CreateSetCache("does-not-exist").Should().BeSameAs(redis); + } + + [Fact] + public void Unknown_default_returns_null_cache_even_with_single_provider() { var inMem = Substitute.For(); var factory = new QueueCacheFactory( - new[] { Provider(KnownCacheProviderNames.InMemory, inMem) }, - Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.InMemoryRedis })); + Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.InMemoryRedis }), + new[] { Provider(KnownCacheProviderNames.InMemory, inMem) }); - factory.CreateSetCache().Should().BeSameAs(inMem); + // Mirrors CacheFactory: no fallback to the sole registered provider — the default must be + // registered (or requested by name). + factory.CreateSetCache().Should().BeSameAs(NullSetCache.Instance); + factory.CreateSetCache(KnownCacheProviderNames.InMemory).Should().BeSameAs(inMem); } [Fact] public void Unknown_provider_with_multiple_registered_returns_null_cache() { var factory = new QueueCacheFactory( + Options.Create(new CacheOptions { DefaultCache = "does-not-exist" }), new[] { Provider(KnownCacheProviderNames.InMemory, Substitute.For()), Provider(KnownCacheProviderNames.Redis, Substitute.For()), - }, - Options.Create(new CacheOptions { DefaultCache = "does-not-exist" })); + }); factory.CreateSetCache().Should().BeSameAs(NullSetCache.Instance); factory.CreateSetCache("also-missing").Should().BeSameAs(NullSetCache.Instance); } + [Fact] + public void Empty_factory_returns_null_cache() + { + var factory = new QueueCacheFactory(Options.Create(new CacheOptions())); + + factory.CreateSetCache().Should().BeSameAs(NullSetCache.Instance); + factory.CreateSetCache("anything").Should().BeSameAs(NullSetCache.Instance); + factory.ProviderNames.Should().BeEmpty(); + } + [Fact] public void Disabled_provider_is_not_selected() { var factory = new QueueCacheFactory( - new[] { Provider(KnownCacheProviderNames.InMemory, Substitute.For(), enabled: false) }, - Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.InMemory })); + Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.InMemory }), + new[] { Provider(KnownCacheProviderNames.InMemory, Substitute.For(), enabled: false) }); factory.CreateSetCache().Should().BeSameAs(NullSetCache.Instance); factory.ProviderNames.Should().BeEmpty(); } + [Fact] + public void AddProvider_registers_the_provider() + { + var redis = Substitute.For(); + var factory = new QueueCacheFactory(Options.Create(new CacheOptions { DefaultCache = KnownCacheProviderNames.Redis })); + + factory.AddProvider(Provider(KnownCacheProviderNames.Redis, redis)); + + factory.CreateSetCache().Should().BeSameAs(redis); + factory.CreateSetCache(KnownCacheProviderNames.Redis).Should().BeSameAs(redis); + } + + [Fact] + public void Disposed_factory_add_provider_exception() + { + var factory = new QueueCacheFactory(Options.Create(new CacheOptions())); + factory.Dispose(); + + var act = () => factory.AddProvider(Provider(KnownCacheProviderNames.Redis, Substitute.For())); + + act.Should().Throw(); + } + + [Fact] + public void Dispose_disposes_providers_and_swallows_exceptions() + { + var provider = Provider(KnownCacheProviderNames.Redis, Substitute.For()); + provider.When(p => p.Dispose()).Throw(new Exception("test")); + var factory = new QueueCacheFactory(Options.Create(new CacheOptions()), new[] { provider }); + + var act = () => factory.Dispose(); + + act.Should().NotThrow(); + provider.Received(1).Dispose(); + } + [Fact] public void Single_cache_ctor_ignores_provider_name() {