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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>()`) 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<T>()`. It mirrors `CacheFactory.CreateCache` / `CacheFactoryExtensions.CreateCache<T>` 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<T>()` wraps it in `SetCache<T>` with no policy factory, exactly like `CreateCache<T>` (the underlying `RedisSetCache` still applies the global default policy). Registered automatically by `AddRedisSetCache`; inject `IQueueCacheFactory` where you need sets.
- `IQueueCacheFactory` / `QueueCacheFactory` (+ `QueueCacheFactoryExtensions.CreateSetCache<T>()`) 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<T>()`. It mirrors `CacheFactory.CreateCache` / `CacheFactoryExtensions.CreateCache<T>` 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<T>()` wraps the resolved set cache in `SetCache<T>` with no policy factory, exactly like `CreateCache<T>` (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<ResiliencePoliciesOptions>)` — 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<ResiliencePoliciesOptions>` instead of `IOptions<ResiliencePoliciesOptions>` so it can resolve per-scope options; `Create<TResult>` selects the options for its scope via `optionsMonitor.Get(scope)`.
Expand Down
132 changes: 114 additions & 18 deletions src/UiPath.Caching.Queue/Config/SetCacheCollectionExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,56 @@
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<InMemorySetCacheOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configureOptions);
if (!builder.Enabled)
{
builder.Services.AddNullSetCache();
return builder;
}

builder.Services.AddInMemorySetCache(configureOptions);
return builder;
}

/// <inheritdoc cref="AddInMemorySetCache(IServiceCollection, Action{InMemorySetCacheOptions})"/>
public static IServiceCollection AddInMemorySetCache(this IServiceCollection services) =>
services.AddInMemorySetCache(static _ => { });

/// <summary>
/// Registers the in-process <see cref="ISetCache"/> provider (<c>InMemory</c>) plus
/// <see cref="IQueueCacheFactory"/>, <see cref="ISetCache"/> and <see cref="ISetCache{T}"/>.
/// Requires the core caching services (<c>AddCaching</c>). Unlike the Redis providers, this one
/// has no Redis prerequisite. The factory selects providers via
/// <see cref="CacheOptions.DefaultCache"/> (default <c>InMemoryRedis</c>, like the core cache
/// factory) — point it at <c>InMemory</c> when this is the only registered set-cache provider.
/// </summary>
public static IServiceCollection AddInMemorySetCache(this IServiceCollection services, Action<InMemorySetCacheOptions> 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<ISetCacheProvider, InMemorySetCacheProvider>());
return services.AddSetCacheCore();
}

public static ICachingBuilder AddRedisSetCache(this ICachingBuilder builder) =>
builder.AddRedisSetCache(static _ => { });

Expand All @@ -25,11 +73,12 @@ public static IServiceCollection AddRedisSetCache(this IServiceCollection servic
services.AddRedisSetCache(static _ => { });

/// <summary>
/// Registers <see cref="ISetCache"/> and <see cref="ISetCache{T}"/> on the service collection.
/// Registers the Redis-backed <see cref="ISetCache"/> provider (<c>Redis</c>) plus
/// <see cref="IQueueCacheFactory"/>, <see cref="ISetCache"/> and <see cref="ISetCache{T}"/>.
/// </summary>
/// <remarks>
/// Prerequisite: the core caching and Redis services must already be registered (via
/// <c>AddCaching(... builder.AddRedis())</c>). The set cache resolves <see cref="IRedisConnector"/>,
/// <c>AddCaching(... builder.AddRedis())</c>). The provider resolves <see cref="IRedisConnector"/>,
/// <see cref="ISerializerProxy{RedisValue}"/>, <see cref="IResiliencePipelineProvider"/>, the core
/// cache options and <see cref="ICachePolicyFactory"/> from the container; if <c>AddCaching()</c>
/// has not run, resolving <see cref="ISetCache"/> throws at first use. Prefer the
Expand All @@ -40,16 +89,75 @@ 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)
{
return services.AddNullSetCache();
}

services.TryAddSingleton<ISetCache>(BuildRedisSetCache);
services.TryAddSingleton<IQueueCacheFactory, QueueCacheFactory>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISetCacheProvider, RedisSetCacheProvider>());
return services.AddSetCacheCore();
}

public static ICachingBuilder AddInMemoryRedisSetCache(this ICachingBuilder builder) =>
builder.AddInMemoryRedisSetCache(static _ => { });

public static ICachingBuilder AddInMemoryRedisSetCache(this ICachingBuilder builder, Action<InMemoryRedisSetCacheOptions> configureOptions)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configureOptions);
if (!builder.Enabled)
{
builder.Services.AddNullSetCache();
return builder;
}

builder.Services.AddInMemoryRedisSetCache(configureOptions);
return builder;
}

/// <inheritdoc cref="AddInMemoryRedisSetCache(IServiceCollection, Action{InMemoryRedisSetCacheOptions})"/>
public static IServiceCollection AddInMemoryRedisSetCache(this IServiceCollection services) =>
services.AddInMemoryRedisSetCache(static _ => { });

/// <summary>
/// Registers the multilayer <see cref="ISetCache"/> provider (<c>InMemoryRedis</c>): a local
/// in-process snapshot in front of the Redis set cache. Also registers the <c>Redis</c> provider,
/// since the multilayer provider obtains its L2 tier from the factory
/// (<see cref="IQueueCacheFactory.CreateSetCache(string)"/> for <c>Redis</c>). Same Redis
/// prerequisites as <see cref="AddRedisSetCache(IServiceCollection, Action{RedisSetCacheOptions})"/>;
/// the Redis tier reuses <see cref="RedisSetCacheOptions"/> / <see cref="RedisCacheOptions"/>.
/// </summary>
public static IServiceCollection AddInMemoryRedisSetCache(this IServiceCollection services, Action<InMemoryRedisSetCacheOptions> 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<ISetCacheProvider, RedisSetCacheProvider>());
services.TryAddEnumerable(ServiceDescriptor.Singleton<ISetCacheProvider, InMemoryRedisSetCacheProvider>());
return services.AddSetCacheCore();
}

private static IServiceCollection AddSetCacheCore(this IServiceCollection services)
{
services.TryAddSingleton<IQueueCacheFactory>(sp =>
new QueueCacheFactory(sp.GetRequiredService<IOptions<CacheOptions>>(), sp.GetServices<ISetCacheProvider>()));
// 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<ICacheFactory>.
services.TryAddTransient<Func<IQueueCacheFactory>>(sp => () => sp.GetRequiredService<IQueueCacheFactory>());
services.TryAddSingleton<ISetCache>(sp => sp.GetRequiredService<IQueueCacheFactory>().CreateSetCache());
services.TryAddTransient(typeof(ISetCache<>), typeof(SetCache<>));
return services;
}
Expand All @@ -61,16 +169,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<IRedisConnector>(),
sp.GetRequiredService<ISerializerProxy<RedisValue>>(),
sp.GetRequiredService<IResiliencePipelineProvider>(),
sp.GetService<ICachingTelemetryProvider>() ?? NullTelemetryProvider.Instance,
sp.GetRequiredService<IOptions<RedisCacheOptions>>().Value,
sp.GetRequiredService<IOptions<CacheOptions>>().Value,
sp.GetRequiredService<IOptions<RedisSetCacheOptions>>().Value,
sp.GetRequiredService<ICachePolicyFactory>(),
(sp.GetService<ILoggerFactory>() ?? NullLoggerFactory.Instance).CreateLogger<RedisSetCache>());
}
3 changes: 3 additions & 0 deletions src/UiPath.Caching.Queue/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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")]
22 changes: 19 additions & 3 deletions src/UiPath.Caching.Queue/IQueueCacheFactory.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
namespace UiPath.Caching;

/// <summary>
/// Creates set caches, mirroring <see cref="ICacheFactory"/> for Redis sets. Lives in the
/// Creates set caches, mirroring <see cref="ICacheFactory"/>. Lives in the
/// <c>UiPath.Caching.Queue</c> package (set support is opt-in); register it via
/// <c>AddRedisSetCache</c>. Inject this instead of <see cref="ICacheFactory"/> when you need sets.
/// <c>AddInMemorySetCache</c> / <c>AddRedisSetCache</c> / <c>AddInMemoryRedisSetCache</c>. Inject
/// this instead of <see cref="ICacheFactory"/> when you need sets.
/// </summary>
public interface IQueueCacheFactory
public interface IQueueCacheFactory : IDisposable
{
/// <summary>Names of the registered, enabled set-cache providers.</summary>
IEnumerable<string> ProviderNames { get; }

/// <summary>Returns the <see cref="ISetCache"/> for the default provider.</summary>
ISetCache CreateSetCache();

/// <summary>
/// Returns the <see cref="ISetCache"/> for the given provider (see
/// <see cref="KnownCacheProviderNames"/>). When <paramref name="providerName"/> is
/// <see langword="null"/> or not registered, the <see cref="CacheOptions.DefaultCache"/>
/// provider is used, degrading to <see cref="NullSetCache"/> when that is missing too.
/// </summary>
ISetCache CreateSetCache(string? providerName);

/// <summary>Registers (or replaces) a provider under its <see cref="ISetCacheProvider.Name"/>.</summary>
void AddProvider(ISetCacheProvider provider);
}
18 changes: 18 additions & 0 deletions src/UiPath.Caching.Queue/ISetCacheProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
namespace UiPath.Caching;

/// <summary>
/// Creates <see cref="ISetCache"/> instances for a specific backing (InMemory, Redis or
/// InMemoryRedis), mirroring <see cref="ICacheProvider"/> for the normal/hash caches. Registered
/// providers are selected by name through <see cref="IQueueCacheFactory"/>.
/// </summary>
public interface ISetCacheProvider : IDisposable
{
/// <summary>The provider name, e.g. one of <see cref="KnownCacheProviderNames"/>.</summary>
string Name { get; }

/// <summary>Whether this provider is enabled and eligible for selection.</summary>
bool Enabled { get; }

/// <summary>Returns the (typically cached) <see cref="ISetCache"/> for this provider.</summary>
ISetCache CreateSetCache();
}
Loading