diff --git a/.editorconfig b/.editorconfig index a1a21a7a..d6665bff 100644 --- a/.editorconfig +++ b/.editorconfig @@ -40,7 +40,7 @@ dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:sil dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent # Modifier preferences -dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning dotnet_style_readonly_field = true:suggestion # Expression-level preferences dotnet_style_object_initializer = true:suggestion @@ -113,7 +113,7 @@ csharp_style_conditional_delegate_call = true:suggestion # Modifier preferences csharp_preferred_modifier_order = public,private,protected,internal,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,volatile,async:suggestion # Expression-level preferences -csharp_prefer_braces = true:silent +csharp_prefer_braces = true:warning csharp_style_deconstructed_variable_declaration = true:suggestion csharp_prefer_simple_default_expression = true:suggestion csharp_style_prefer_local_over_anonymous_function = true:suggestion @@ -245,11 +245,12 @@ dotnet_diagnostic.SA1309.severity = none # Tuple element names should use correct casing dotnet_diagnostic.SA1316.severity = none -# File may only contain a single class -dotnet_diagnostic.SA1402.severity = suggestion +# File may only contain a single class. Off by default and warning for src alone, at the bottom +# of this file: tests, samples and benchmarks keep their small helper types next to what uses them. +dotnet_diagnostic.SA1402.severity = none # Braces must not be omitted -dotnet_diagnostic.SA1503.severity = suggestion +dotnet_diagnostic.SA1503.severity = warning # ElementsMustBeDocumented dotnet_diagnostic.SA1600.severity = none @@ -421,13 +422,13 @@ dotnet_diagnostic.SA1208.severity = none dotnet_diagnostic.SA1135.severity = none # SA1413: Use trailing comma in multi-line initializers -dotnet_diagnostic.SA1413.severity = suggestion +dotnet_diagnostic.SA1413.severity = warning # SA1300: Element should begin with upper-case letter -dotnet_diagnostic.SA1300.severity = suggestion +dotnet_diagnostic.SA1300.severity = warning # SA1117: Parameters should be on same line or separate lines -dotnet_diagnostic.SA1117.severity = suggestion +dotnet_diagnostic.SA1117.severity = warning # CS8600: Converting null literal or possible null value to non-nullable type. dotnet_diagnostic.CS8600.severity = error @@ -471,3 +472,41 @@ dotnet_diagnostic.RS0041.severity = none dotnet_diagnostic.RS0026.severity = none dotnet_diagnostic.RS0027.severity = none +# StyleCop.Analyzers ships ~5000 warnings' worth of rules. Every category below is off except +# OrderingRules. A rule id beats its category here, so anything an exact id further up had already +# switched off has to be named again to apply: SA1204 and SA1208 are, SA1200 and SA1649 are not. +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.SpecialRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.SpacingRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.ReadabilityRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.NamingRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = none +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.DocumentationRules.severity = none + +# SA1649 (file name should match the first type) is already none further up, and stays that way: +# it does not recognise the OfT suffix this repo uses for generic types, so it would ask to +# rename CacheOfT.cs, ICacheOfT.cs and five more to Cache{T}.cs. +# +# SX1309, which asks every field to begin with an underscore, is already warning further up and +# stays that way: it is this repo's field convention, and SA1309 is off above for the same reason. +# Referencing the package is what makes it report, so the five fields that broke it are fixed. + +# SA1204 (static before instance) and SA1208 (System usings first) are none further up. A specific +# id beats the category below, so each has to be named here to actually apply; the pass satisfies +# both. SA1200 stays none: this repo puts using directives above the file-scoped namespace. +dotnet_diagnostic.SA1204.severity = warning +dotnet_diagnostic.SA1208.severity = warning + +# Ordering: the whole category, so SA1201 order by kind, SA1202 order by access, SA1204 static +# first, SA1203 constants first, SA1214 readonly first and SA1210 (usings sorted) all apply +# without a list of ids to maintain. Nothing off the shelf applies these: neither dotnet format nor +# the Roslynator CLI can drive StyleCop's fixer — both answer that no code fix was found — and +# Rider's layout engine ranks constants and statics above accessibility, which moves SA1202 the +# wrong way. The tree was brought into line by a one-off Roslyn pass instead, and is clean now. +dotnet_analyzer_diagnostic.category-StyleCop.CSharp.OrderingRules.severity = warning + +[src/**.cs] +# One top-level type per file, for the shipped surface only. topLevelTypes in stylecop.json widens +# SA1402 past its default of class alone. Tests, samples and benchmarks keep their small helper +# types next to what uses them: 17 files would otherwise have to be broken up. +dotnet_diagnostic.SA1402.severity = warning diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46df16f7..0ecac077 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,8 +101,13 @@ jobs: - name: Restore run: dotnet restore UiPath.Caching.slnx + # -warnaserror is what keeps the analyzer rules in .editorconfig from drifting back: + # TreatWarningsAsErrors stays false so a local build still compiles while you work. It rides + # on this job alone. build-linux builds under the SonarScanner, whose analyzer package is not + # pinned here, so a new Sonar rule would fail the build instead of reporting; the analyzers + # this repo does pin run identically on both jobs, so gating one catches the same drift. - name: Build - run: dotnet build UiPath.Caching.slnx -c Release --no-restore + run: dotnet build UiPath.Caching.slnx -c Release --no-restore -warnaserror # Redis-backed integration tests run on the Linux job (service containers are Linux-only). # Windows is build-only to validate cross-platform compilation. diff --git a/Directory.Build.props b/Directory.Build.props index 3edf7292..bc92c985 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -44,4 +44,13 @@ + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index b7916d14..e5845155 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -59,6 +59,7 @@ + diff --git a/benchmarks/UiPath.Caching.Benchmarks/CacheBenchmark.cs b/benchmarks/UiPath.Caching.Benchmarks/CacheBenchmark.cs index 02d2b99f..23158a04 100644 --- a/benchmarks/UiPath.Caching.Benchmarks/CacheBenchmark.cs +++ b/benchmarks/UiPath.Caching.Benchmarks/CacheBenchmark.cs @@ -9,6 +9,8 @@ namespace UiPath.Caching.Benchmarks; [HtmlExporter] public class CacheBenchmark { + + private const int _batchSize = 50; private Entry[] _entries = default!; [Params(500, 2_500)] @@ -25,7 +27,7 @@ public class CacheBenchmark protected Func CreateRandomObject { get; set; } = default!; - private const int _batchSize = 50; + private ICache RandomCache => _entries[Random.Shared.Next(0, _entries.Length)].Cache; [GlobalSetup] public void Setup() @@ -35,13 +37,11 @@ public void Setup() "Small" => CustomObject.RandomSmall, "Medium" => CustomObject.RandomMedium, "Large" => CustomObject.RandomLarge, - _ => throw new NotSupportedException(ObjectSize) + _ => throw new NotSupportedException(ObjectSize), }; _entries = SetupHelper.Setup(2, Cache, $"Redis{Topic}", NumKeys, CreateRandomObject); } - private ICache RandomCache => _entries[Random.Shared.Next(0, _entries.Length)].Cache; - [GlobalCleanup] public void Cleanup() => SetupHelper.Cleanup(_entries); diff --git a/benchmarks/UiPath.Caching.Benchmarks/CustomObject.cs b/benchmarks/UiPath.Caching.Benchmarks/CustomObject.cs index 907e9491..3cca9399 100644 --- a/benchmarks/UiPath.Caching.Benchmarks/CustomObject.cs +++ b/benchmarks/UiPath.Caching.Benchmarks/CustomObject.cs @@ -18,7 +18,7 @@ public static CustomObject RandomLarge() Property2 = GenerateRandomString(1_000), Property3 = GenerateRandomDoubleArray(10_000), UtcDateTime = DateTime.UtcNow, - GuidList = GenerateRandomGuidList(5_000) + GuidList = GenerateRandomGuidList(5_000), }; return customObject; @@ -32,7 +32,7 @@ public static CustomObject RandomMedium() Property2 = GenerateRandomString(100), Property3 = GenerateRandomDoubleArray(10), UtcDateTime = DateTime.UtcNow, - GuidList = GenerateRandomGuidList(10) + GuidList = GenerateRandomGuidList(10), }; return customObject; diff --git a/benchmarks/UiPath.Caching.Benchmarks/SerializerBenchmark.cs b/benchmarks/UiPath.Caching.Benchmarks/SerializerBenchmark.cs index 4e7c4dd4..e7badae2 100644 --- a/benchmarks/UiPath.Caching.Benchmarks/SerializerBenchmark.cs +++ b/benchmarks/UiPath.Caching.Benchmarks/SerializerBenchmark.cs @@ -11,11 +11,11 @@ namespace UiPath.Caching.Benchmarks; [CategoriesColumn] public class SerializerBenchmark { - [Params("Small", "Medium", "Large")] - public string Size { get; set; } = "Medium"; private CustomObject _obj = default!; private RedisValue _payload; + [Params("Small", "Medium", "Large")] + public string Size { get; set; } = "Medium"; [GlobalSetup] public void Setup() diff --git a/benchmarks/UiPath.Caching.Benchmarks/StreamNotifyDoorbellHarness.cs b/benchmarks/UiPath.Caching.Benchmarks/StreamNotifyDoorbellHarness.cs index 1beda63c..6102b1d7 100644 --- a/benchmarks/UiPath.Caching.Benchmarks/StreamNotifyDoorbellHarness.cs +++ b/benchmarks/UiPath.Caching.Benchmarks/StreamNotifyDoorbellHarness.cs @@ -1,8 +1,8 @@ using System.Collections.Concurrent; using System.Globalization; -using UiPath.Caching.Benchmarks; using Microsoft.Extensions.DependencyInjection; using StackExchange.Redis; +using UiPath.Caching.Benchmarks; using UiPath.Caching.Redis; namespace UiPath.Caching.Benchmarks; @@ -31,6 +31,8 @@ namespace UiPath.Caching.Benchmarks; // Run with: dotnet run -c Release --framework net8.0 -- doorbell [durationSec] [writeHz] internal static class StreamNotifyDoorbellHarness { + + private const string TimestampPrefix = "ts:"; public static async Task RunAsync(int durationSec = 20, int writeHz = 5) { var cells = new (bool NotifyEnabled, string PollInterval)[] @@ -170,6 +172,4 @@ private static async Task> MeasureCellAsync(bool notifyEnabled, s // Hosts dispose via 'using'. } } - - private const string TimestampPrefix = "ts:"; } diff --git a/samples/UiPath.Caching.Sample.ServiceDefaults/Extensions.cs b/samples/UiPath.Caching.Sample.ServiceDefaults/Extensions.cs index 65a8504d..fd1cccce 100644 --- a/samples/UiPath.Caching.Sample.ServiceDefaults/Extensions.cs +++ b/samples/UiPath.Caching.Sample.ServiceDefaults/Extensions.cs @@ -83,25 +83,6 @@ public static TBuilder ConfigureOpenTelemetry(this TBuilder builder) w return builder; } - private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder - { - var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); - - if (useOtlpExporter) - { - builder.Services.AddOpenTelemetry().UseOtlpExporter(); - } - - // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) - //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) - //{ - // builder.Services.AddOpenTelemetry() - // .UseAzureMonitor(); - //} - - return builder; - } - public static TBuilder AddDefaultHealthChecks(this TBuilder builder) where TBuilder : IHostApplicationBuilder { builder.Services.AddHealthChecks() @@ -123,10 +104,29 @@ public static WebApplication MapDefaultEndpoints(this WebApplication app) // Only health checks tagged with the "live" tag must pass for app to be considered alive app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions { - Predicate = r => r.Tags.Contains("live") + Predicate = r => r.Tags.Contains("live"), }); } return app; } + + private static TBuilder AddOpenTelemetryExporters(this TBuilder builder) where TBuilder : IHostApplicationBuilder + { + var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]); + + if (useOtlpExporter) + { + builder.Services.AddOpenTelemetry().UseOtlpExporter(); + } + + // Uncomment the following lines to enable the Azure Monitor exporter (requires the Azure.Monitor.OpenTelemetry.AspNetCore package) + //if (!string.IsNullOrEmpty(builder.Configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"])) + //{ + // builder.Services.AddOpenTelemetry() + // .UseAzureMonitor(); + //} + + return builder; + } } diff --git a/samples/UiPath.Caching.Sample/Program.cs b/samples/UiPath.Caching.Sample/Program.cs index 676f2319..6424e5b6 100644 --- a/samples/UiPath.Caching.Sample/Program.cs +++ b/samples/UiPath.Caching.Sample/Program.cs @@ -45,7 +45,7 @@ builder.Services.AddRequestTimeouts(opt => opt.DefaultPolicy = new Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutPolicy { Timeout = TimeSpan.FromMilliseconds(100), - TimeoutStatusCode = 503 + TimeoutStatusCode = 503, }); var app = builder.Build(); diff --git a/src/UiPath.Caching.Abstractions/Broadcast/IChangeTokenFactory.cs b/src/UiPath.Caching.Abstractions/Broadcast/IChangeTokenFactory.cs index 954b6eba..472e27be 100644 --- a/src/UiPath.Caching.Abstractions/Broadcast/IChangeTokenFactory.cs +++ b/src/UiPath.Caching.Abstractions/Broadcast/IChangeTokenFactory.cs @@ -2,5 +2,5 @@ namespace UiPath.Caching.Broadcast; public interface IChangeTokenFactory { - public ICacheChangeToken Create(string token, ITopic topic, string cacheName, Type entryType); + ICacheChangeToken Create(string token, ITopic topic, string cacheName, Type entryType); } diff --git a/src/UiPath.Caching.Abstractions/Broadcast/ITopic.cs b/src/UiPath.Caching.Abstractions/Broadcast/ITopic.cs index 2e5ce9fe..1a8adaff 100644 --- a/src/UiPath.Caching.Abstractions/Broadcast/ITopic.cs +++ b/src/UiPath.Caching.Abstractions/Broadcast/ITopic.cs @@ -5,9 +5,9 @@ public interface ITopic : IDisposable { TopicKey TopicKey { get; } + EventHandler? OnDisposed { get; set; } + IDisposable Subscribe(IObserver observer); ValueTask PublishAsync(T @event, CancellationToken token = default); - - EventHandler? OnDisposed { get; set; } } diff --git a/src/UiPath.Caching.Abstractions/Broadcast/NullCacheChangeToken.cs b/src/UiPath.Caching.Abstractions/Broadcast/NullCacheChangeToken.cs index c9f5c9e6..0f9b140a 100644 --- a/src/UiPath.Caching.Abstractions/Broadcast/NullCacheChangeToken.cs +++ b/src/UiPath.Caching.Abstractions/Broadcast/NullCacheChangeToken.cs @@ -4,11 +4,11 @@ namespace UiPath.Caching.Broadcast; [ExcludeFromCodeCoverage] public class NullCacheChangeToken : ICacheChangeToken { - public static NullCacheChangeToken Instance { get; } = new NullCacheChangeToken(); private NullCacheChangeToken() { } + public static NullCacheChangeToken Instance { get; } = new NullCacheChangeToken(); public bool HasChanged => false; diff --git a/src/UiPath.Caching.Abstractions/Broadcast/NullCacheEventFactory.cs b/src/UiPath.Caching.Abstractions/Broadcast/NullCacheEventFactory.cs index 41a6cd16..2460cce0 100644 --- a/src/UiPath.Caching.Abstractions/Broadcast/NullCacheEventFactory.cs +++ b/src/UiPath.Caching.Abstractions/Broadcast/NullCacheEventFactory.cs @@ -5,12 +5,12 @@ public sealed class NullCacheEventFactory : ICacheEventFactory { private static ICacheEvent NullEvent = new NullCacheEvent(); - public static NullCacheEventFactory Instance { get; } = new NullCacheEventFactory(); - private NullCacheEventFactory() { } + public static NullCacheEventFactory Instance { get; } = new NullCacheEventFactory(); + public ICacheEvent Create(string cacheName, string eventType, CacheEventData eventData, string? id = null) => NullEvent; diff --git a/src/UiPath.Caching.Abstractions/Broadcast/TopicKey.cs b/src/UiPath.Caching.Abstractions/Broadcast/TopicKey.cs index e1720c08..3b08eb9c 100644 --- a/src/UiPath.Caching.Abstractions/Broadcast/TopicKey.cs +++ b/src/UiPath.Caching.Abstractions/Broadcast/TopicKey.cs @@ -10,29 +10,22 @@ public TopicKey() public TopicKey(string? name) => Name = name?.Trim().ToLowerInvariant() ?? string.Empty; - public string Name { get; } - - public override bool Equals(object? obj) => - obj is TopicKey topicKey && Equals(topicKey); + public static TopicKey Null { get; } = new TopicKey(null); - public bool Equals(TopicKey other) => - string.Equals(Name, other.Name, StringComparison.InvariantCultureIgnoreCase); + public string Name { get; } public bool IsNull => string.IsNullOrEmpty(Name); - - public override string ToString() => - Name; - - public override int GetHashCode() => - HashCode.Combine(Name, IsNull); - public static implicit operator string(TopicKey topicKey) => topicKey.Name; public static implicit operator TopicKey(string? value) { - if (value == null) return default; + if (value == null) + { + return default; + } + return new TopicKey(value); } @@ -42,5 +35,16 @@ public static implicit operator TopicKey(string? value) public static bool operator !=(TopicKey left, TopicKey right) => !(left == right); - public static TopicKey Null { get; } = new TopicKey(null); + public override bool Equals(object? obj) => + obj is TopicKey topicKey && Equals(topicKey); + + public bool Equals(TopicKey other) => + string.Equals(Name, other.Name, StringComparison.InvariantCultureIgnoreCase); + + + public override string ToString() => + Name; + + public override int GetHashCode() => + HashCode.Combine(Name, IsNull); } diff --git a/src/UiPath.Caching.Abstractions/CacheKey.cs b/src/UiPath.Caching.Abstractions/CacheKey.cs index 32839bf8..c84d7ecf 100644 --- a/src/UiPath.Caching.Abstractions/CacheKey.cs +++ b/src/UiPath.Caching.Abstractions/CacheKey.cs @@ -6,19 +6,6 @@ namespace UiPath.Caching; { private static CacheKeyCasing _defaultCasing = CacheKeyCasing.Insensitive; - /// - /// Process-global casing for keys built without an explicit mode; seeded from CacheOptions.KeyCasing. - /// Set only at startup. Rejects a value outside the enum on assignment rather than at the next key built, - /// since this is global state and the throw would otherwise surface far from the assignment that caused it. - /// - public static CacheKeyCasing DefaultCasing - { - get => _defaultCasing; - set => _defaultCasing = value is CacheKeyCasing.Insensitive or CacheKeyCasing.Sensitive - ? value - : throw new ArgumentOutOfRangeException(nameof(value), value, $"Unsupported {nameof(CacheKeyCasing)} value."); - } - public CacheKey() : this(string.Empty) { @@ -45,34 +32,38 @@ public CacheKey(string? name, CacheKeyCasing casing) }; } + /// + /// Process-global casing for keys built without an explicit mode; seeded from CacheOptions.KeyCasing. + /// Set only at startup. Rejects a value outside the enum on assignment rather than at the next key built, + /// since this is global state and the throw would otherwise surface far from the assignment that caused it. + /// + public static CacheKeyCasing DefaultCasing + { + get => _defaultCasing; + set => _defaultCasing = value is CacheKeyCasing.Insensitive or CacheKeyCasing.Sensitive + ? value + : throw new ArgumentOutOfRangeException(nameof(value), value, $"Unsupported {nameof(CacheKeyCasing)} value."); + } + + public static CacheKey Null { get; } = new CacheKey(null); + public string Name { get; } /// Normalization mode this key was built with; not part of equality. public CacheKeyCasing Casing { get; } - /// New key from , preserving this key's casing mode. - public CacheKey WithName(string? name) => new(name, Casing); - - public override bool Equals(object? obj) => - obj is CacheKey cacheKey && Equals(cacheKey); - - public bool Equals(CacheKey other) => - string.Equals(Name, other.Name, StringComparison.Ordinal); - public bool IsNull => string.IsNullOrEmpty(Name); - public override string ToString() => - Name; - - public override int GetHashCode() => - HashCode.Combine(Name, IsNull); - public static implicit operator string(CacheKey cacheKey) => cacheKey.Name; public static implicit operator CacheKey(string? cacheKey) { - if (cacheKey == null) return default; + if (cacheKey == null) + { + return default; + } + return new CacheKey(cacheKey); } @@ -91,5 +82,18 @@ public static implicit operator CacheKey(Guid value) => public static bool operator !=(CacheKey left, CacheKey right) => !(left == right); - public static CacheKey Null { get; } = new CacheKey(null); + /// New key from , preserving this key's casing mode. + public CacheKey WithName(string? name) => new(name, Casing); + + public override bool Equals(object? obj) => + obj is CacheKey cacheKey && Equals(cacheKey); + + public bool Equals(CacheKey other) => + string.Equals(Name, other.Name, StringComparison.Ordinal); + + public override string ToString() => + Name; + + public override int GetHashCode() => + HashCode.Combine(Name, IsNull); } diff --git a/src/UiPath.Caching.Abstractions/CacheOfT.cs b/src/UiPath.Caching.Abstractions/CacheOfT.cs index 879c6b39..13938b1b 100644 --- a/src/UiPath.Caching.Abstractions/CacheOfT.cs +++ b/src/UiPath.Caching.Abstractions/CacheOfT.cs @@ -64,13 +64,6 @@ public ValueTask ContainsAsync(CacheKey cacheKey, CancellationToken token where TState : notnull => _cache.GetOrAddAsync(MapKeys(entries), generator, expiration, Policy, token); - /// Applies the key strategy to each entry's key and leaves its state alone. - private KeyValuePair[] MapKeys(KeyValuePair[] entries) - { - ArgumentNullException.ThrowIfNull(entries); - return Array.ConvertAll(entries, e => new KeyValuePair(GetCacheKey(e.Key), e.Value)); - } - public ValueTask RefreshAsync(CacheKey cacheKey, CancellationToken token = default) => _cache.RefreshAsync(GetCacheKey(cacheKey), policy: Policy, token: token); @@ -95,6 +88,15 @@ public ValueTask SetAsync(CacheKey cacheKey, T? value, TimeSpan expiration public ValueTask SetAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CancellationToken token = default) => _cache.SetAsync(GetCacheKey(cacheKey), value, expiration, Policy, token); + public ValueTask SetAsync(KeyValuePair[] keyValues, CancellationToken token = default) => + _cache.SetAsync(GetKeyValuePairs(keyValues), policy: Policy, token: token); + + public ValueTask SetAsync(KeyValuePair[] keyValues, TimeSpan expiration, CancellationToken token = default) => + _cache.SetAsync(GetKeyValuePairs(keyValues), expiration, Policy, token); + + public ValueTask SetAsync(KeyValuePair[] keyValues, DateTimeOffset expiration, CancellationToken token = default) => + _cache.SetAsync(GetKeyValuePairs(keyValues), expiration, Policy, token); + public ValueTask TryAddAsync(CacheKey cacheKey, T? value, CancellationToken token = default) => _cache.TryAddAsync(GetCacheKey(cacheKey), value, policy: Policy, token: token); @@ -105,21 +107,19 @@ public ValueTask TryAddAsync(CacheKey cacheKey, T? value, DateTimeOffset e _cache.TryAddAsync(GetCacheKey(cacheKey), value, expiration, Policy, token); - public ValueTask SetAsync(KeyValuePair[] keyValues, CancellationToken token = default) => - _cache.SetAsync(GetKeyValuePairs(keyValues), policy: Policy, token: token); - - public ValueTask SetAsync(KeyValuePair[] keyValues, TimeSpan expiration, CancellationToken token = default) => - _cache.SetAsync(GetKeyValuePairs(keyValues), expiration, Policy, token); - - public ValueTask SetAsync(KeyValuePair[] keyValues, DateTimeOffset expiration, CancellationToken token = default) => - _cache.SetAsync(GetKeyValuePairs(keyValues), expiration, Policy, token); - public ValueTask TimeToLiveAsync(CacheKey cacheKey, CancellationToken token = default) => _cache.TimeToLiveAsync(GetCacheKey(cacheKey), token); public ValueTask ExpireTimeAsync(CacheKey cacheKey, CancellationToken token = default) => _cache.ExpireTimeAsync(GetCacheKey(cacheKey), token); + /// Applies the key strategy to each entry's key and leaves its state alone. + private KeyValuePair[] MapKeys(KeyValuePair[] entries) + { + ArgumentNullException.ThrowIfNull(entries); + return Array.ConvertAll(entries, e => new KeyValuePair(GetCacheKey(e.Key), e.Value)); + } + private CacheKey GetCacheKey(CacheKey cacheKey) => _cacheKeyStrategy.GetCacheKey(cacheKey); diff --git a/src/UiPath.Caching.Abstractions/Config/ICachePolicyFactory.cs b/src/UiPath.Caching.Abstractions/Config/ICachePolicyFactory.cs index 0dc6cdb4..482c5918 100644 --- a/src/UiPath.Caching.Abstractions/Config/ICachePolicyFactory.cs +++ b/src/UiPath.Caching.Abstractions/Config/ICachePolicyFactory.cs @@ -13,11 +13,6 @@ namespace UiPath.Caching; /// public interface ICachePolicyFactory { - /// - /// Resolves the named cache policy. Returns null when no specific policy is registered - /// for . - /// - CachePolicy? Resolve(string policyName); /// /// The user-configured default policy. Returns null when no default is configured — @@ -32,4 +27,9 @@ public interface ICachePolicyFactory /// statically may return an empty sequence and opt out of validation. /// IEnumerable Keys { get; } + /// + /// Resolves the named cache policy. Returns null when no specific policy is registered + /// for . + /// + CachePolicy? Resolve(string policyName); } diff --git a/src/UiPath.Caching.Abstractions/Config/NullCachePolicyFactory.cs b/src/UiPath.Caching.Abstractions/Config/NullCachePolicyFactory.cs index 2fcd8de1..a4d0c9cf 100644 --- a/src/UiPath.Caching.Abstractions/Config/NullCachePolicyFactory.cs +++ b/src/UiPath.Caching.Abstractions/Config/NullCachePolicyFactory.cs @@ -7,9 +7,9 @@ public sealed class NullCachePolicyFactory : ICachePolicyFactory private NullCachePolicyFactory() { } - public CachePolicy? Resolve(string policyName) => default; - public CachePolicy? Default => null; public IEnumerable Keys => Array.Empty(); + + public CachePolicy? Resolve(string policyName) => default; } diff --git a/src/UiPath.Caching.Abstractions/HashCacheSetOption.cs b/src/UiPath.Caching.Abstractions/HashCacheSetOption.cs index 4471855a..bf3d50a9 100644 --- a/src/UiPath.Caching.Abstractions/HashCacheSetOption.cs +++ b/src/UiPath.Caching.Abstractions/HashCacheSetOption.cs @@ -9,5 +9,5 @@ public enum HashCacheSetOption /// /// Option to remove the entire specified hash key and set the specified fields to their respective values in the hash stored at key. /// - KeyReplace + KeyReplace, } diff --git a/src/UiPath.Caching.Abstractions/ICacheChangeToken.cs b/src/UiPath.Caching.Abstractions/ICacheChangeToken.cs index 57858113..e10ac222 100644 --- a/src/UiPath.Caching.Abstractions/ICacheChangeToken.cs +++ b/src/UiPath.Caching.Abstractions/ICacheChangeToken.cs @@ -4,11 +4,11 @@ namespace UiPath.Caching; public interface ICacheChangeToken : IChangeToken { + + IDictionary? Metadata { get; } bool MetadataHasChanged { get; } DateTimeOffset? Expiration { get; } string? TransportId { get; } - - public IDictionary? Metadata { get; } } diff --git a/src/UiPath.Caching.Abstractions/ICacheEntry.cs b/src/UiPath.Caching.Abstractions/ICacheEntry.cs index 5d6b0372..38acdcb7 100644 --- a/src/UiPath.Caching.Abstractions/ICacheEntry.cs +++ b/src/UiPath.Caching.Abstractions/ICacheEntry.cs @@ -7,8 +7,6 @@ public interface ICacheEntry IDictionary? Metadata { get; } - ICacheEntry NewEntry(DateTimeOffset? expiration = null, IDictionary? metadata = null); - object? Value { get; } /// @@ -17,4 +15,6 @@ public interface ICacheEntry /// if your implementation populates a real expiration on miss. /// bool Found => Expiration > DateTimeOffset.MinValue; + + ICacheEntry NewEntry(DateTimeOffset? expiration = null, IDictionary? metadata = null); } diff --git a/src/UiPath.Caching.Abstractions/NullCache.cs b/src/UiPath.Caching.Abstractions/NullCache.cs index b1fbf442..95ecde42 100644 --- a/src/UiPath.Caching.Abstractions/NullCache.cs +++ b/src/UiPath.Caching.Abstractions/NullCache.cs @@ -96,6 +96,11 @@ public ValueTask ContainsAsync(CacheKey cacheKey, CancellationToken tok return ValueTask.FromResult(default(TimeSpan?)); } + public void Dispose() + { + // Nothing to dispose + } + private static ValueTask ReturnTrueAsync() { NotCacheableException.ThrowIfNotCacheable(); @@ -108,11 +113,6 @@ private static ValueTask ReturnTrueAsync() return await generator(token).ConfigureAwait(false); } - public void Dispose() - { - // Nothing to dispose - } - private sealed record NullCacheEntry : ICacheEntry { [SuppressMessage("SonarLint.Rule", "S3218:Inner class members should not shadow outer class names")] @@ -120,12 +120,12 @@ private sealed record NullCacheEntry : ICacheEntry public T? Value => default; - object? ICacheEntry.Value => default; - public DateTimeOffset Expiration => DateTimeOffset.MinValue; public IDictionary? Metadata => default; + object? ICacheEntry.Value => default; + public ICacheEntry NewEntry(DateTimeOffset? expiration = null, IDictionary? metadata = null) => NullCacheEntry.Instance; } diff --git a/src/UiPath.Caching.Abstractions/NullHashCache.cs b/src/UiPath.Caching.Abstractions/NullHashCache.cs index 7f3585c4..3accb21a 100644 --- a/src/UiPath.Caching.Abstractions/NullHashCache.cs +++ b/src/UiPath.Caching.Abstractions/NullHashCache.cs @@ -113,12 +113,12 @@ private sealed record NullCacheEntry : ICacheEntry public T? Value => default; - object? ICacheEntry.Value => default; - public DateTimeOffset Expiration => DateTimeOffset.MinValue; public IDictionary? Metadata => default; + object? ICacheEntry.Value => default; + public ICacheEntry NewEntry(DateTimeOffset? expiration = null, IDictionary? metadata = null) => NullCacheEntry.Instance; } diff --git a/src/UiPath.Caching.Abstractions/Telemetry/ICachingTelemetryProvider.cs b/src/UiPath.Caching.Abstractions/Telemetry/ICachingTelemetryProvider.cs index 17e423a5..edce077f 100644 --- a/src/UiPath.Caching.Abstractions/Telemetry/ICachingTelemetryProvider.cs +++ b/src/UiPath.Caching.Abstractions/Telemetry/ICachingTelemetryProvider.cs @@ -2,7 +2,7 @@ namespace UiPath.Caching.Telemetry; public interface ICachingTelemetryProvider { - public ITelemetryOperation StartOperation(string providerName, Type cacheObject, string methodName = "") + ITelemetryOperation StartOperation(string providerName, Type cacheObject, string methodName = "") { var ret = new TelemetryOperation(providerName, methodName, cacheObject, this); ret.Start(); diff --git a/src/UiPath.Caching.Abstractions/Telemetry/TelemetryOperation.cs b/src/UiPath.Caching.Abstractions/Telemetry/TelemetryOperation.cs index 13493796..11e9c701 100644 --- a/src/UiPath.Caching.Abstractions/Telemetry/TelemetryOperation.cs +++ b/src/UiPath.Caching.Abstractions/Telemetry/TelemetryOperation.cs @@ -5,9 +5,6 @@ namespace UiPath.Caching.Telemetry; public sealed class TelemetryOperation(string providerName, string callerMethod, Type cacheObjectType, ICachingTelemetryProvider telemetryProvider) : ITelemetryOperation { - private const string Prefix = "Caching.Stats."; - private const string Hits = Prefix + "Hits."; - private const string Misses = Prefix + "Misses."; public const string DependencyType = "Redis"; public const string OutcomeTag = "Outcome"; @@ -16,6 +13,9 @@ public sealed class TelemetryOperation(string providerName, string callerMethod, public const string TypeTag = "Type"; public const string KeysTag = "Keys"; public const string BatchIdTag = "BatchId"; + private const string Prefix = "Caching.Stats."; + private const string Hits = Prefix + "Hits."; + private const string Misses = Prefix + "Misses."; private const string HitOutcome = "Hit"; private const string MissOutcome = "Miss"; diff --git a/src/UiPath.Caching.Azure/AzureEntraConnectionConfigurator.cs b/src/UiPath.Caching.Azure/AzureEntraConnectionConfigurator.cs index 8b83b987..b4138b5e 100644 --- a/src/UiPath.Caching.Azure/AzureEntraConnectionConfigurator.cs +++ b/src/UiPath.Caching.Azure/AzureEntraConnectionConfigurator.cs @@ -36,6 +36,11 @@ public async ValueTask ConfigureAsync(ConfigurationOptions configuration, Cancel await ApplyAzureAuthenticationAsync(configuration, _credential.Value).ConfigureAwait(false); } + /// Applies Entra authentication via Microsoft.Azure.StackExchangeRedis. Virtual for testability. + [ExcludeFromCodeCoverage(Justification = "Acquires a live Entra token via Microsoft.Azure.StackExchangeRedis — needs Azure to exercise.")] + protected virtual Task ApplyAzureAuthenticationAsync(ConfigurationOptions configuration, TokenCredential credential) => + configuration.ConfigureForAzureWithTokenCredentialAsync(credential); + private static TokenCredential CreateCredential(AzureEntraOptions options, IAzureEntraCredentialFactory credentialFactory) { if (options.Credential is not null) @@ -52,9 +57,4 @@ private static TokenCredential CreateCredential(AzureEntraOptions options, IAzur ? credentialFactory.CreateDefaultCredential() : credentialFactory.CreateManagedIdentityCredential(options.ManagedIdentityClientId); } - - /// Applies Entra authentication via Microsoft.Azure.StackExchangeRedis. Virtual for testability. - [ExcludeFromCodeCoverage(Justification = "Acquires a live Entra token via Microsoft.Azure.StackExchangeRedis — needs Azure to exercise.")] - protected virtual Task ApplyAzureAuthenticationAsync(ConfigurationOptions configuration, TokenCredential credential) => - configuration.ConfigureForAzureWithTokenCredentialAsync(credential); } diff --git a/src/UiPath.Caching.Azure/AzureEntraCredentialFactory.cs b/src/UiPath.Caching.Azure/AzureEntraCredentialFactory.cs index d693bbff..a1f1bbb0 100644 --- a/src/UiPath.Caching.Azure/AzureEntraCredentialFactory.cs +++ b/src/UiPath.Caching.Azure/AzureEntraCredentialFactory.cs @@ -2,11 +2,11 @@ namespace UiPath.Caching.Azure; internal sealed class AzureEntraCredentialFactory : IAzureEntraCredentialFactory { - public static AzureEntraCredentialFactory Instance { get; } = new(); private AzureEntraCredentialFactory() { } + public static AzureEntraCredentialFactory Instance { get; } = new(); public TokenCredential CreateDefaultCredential() => new DefaultAzureCredential(); diff --git a/src/UiPath.Caching.CloudEvents/CacheCloudEventWrapper.cs b/src/UiPath.Caching.CloudEvents/CacheCloudEventWrapper.cs index 6660eda5..78cb556c 100644 --- a/src/UiPath.Caching.CloudEvents/CacheCloudEventWrapper.cs +++ b/src/UiPath.Caching.CloudEvents/CacheCloudEventWrapper.cs @@ -7,9 +7,6 @@ public CacheCloudEventWrapper(CloudEvent cloudEvent) CloudEvent = cloudEvent; Data = CloudEvent.Data as CacheEventData; } - internal CloudEvent CloudEvent { get; } - - public bool IsValid() => CloudEvent.IsValid && !string.IsNullOrWhiteSpace(Data?.Key); public string? Id => CloudEvent.Id; @@ -22,6 +19,9 @@ public CacheCloudEventWrapper(CloudEvent cloudEvent) public string? TransportId { get; private set; } public string? Key => Data?.Key; + internal CloudEvent CloudEvent { get; } + + public bool IsValid() => CloudEvent.IsValid && !string.IsNullOrWhiteSpace(Data?.Key); public void AttachTransportId(string? transportId) { diff --git a/src/UiPath.Caching.CloudEvents/CloudCacheEventFactory.cs b/src/UiPath.Caching.CloudEvents/CloudCacheEventFactory.cs index 8b24acf8..b43512a2 100644 --- a/src/UiPath.Caching.CloudEvents/CloudCacheEventFactory.cs +++ b/src/UiPath.Caching.CloudEvents/CloudCacheEventFactory.cs @@ -27,7 +27,7 @@ public ICacheEvent Create(string cacheName, string eventType, CacheEventData eve Type = eventType.Trim(), Source = _sourceUri, DataContentType = MediaTypeNames.Application.Json, - Data = eventData + Data = eventData, }); } diff --git a/src/UiPath.Caching.Polly/GlobalUsings.cs b/src/UiPath.Caching.Polly/GlobalUsings.cs index c07fa929..27ac8fae 100644 --- a/src/UiPath.Caching.Polly/GlobalUsings.cs +++ b/src/UiPath.Caching.Polly/GlobalUsings.cs @@ -1,10 +1,10 @@ -global using System.Diagnostics.CodeAnalysis; +global using System.Diagnostics.CodeAnalysis; global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.DependencyInjection.Extensions; global using Microsoft.Extensions.Logging; -global using Microsoft.Extensions.Options; global using Microsoft.Extensions.Logging.Abstractions; +global using Microsoft.Extensions.Options; global using Polly; global using Polly.Timeout; global using UiPath.Caching.Config; diff --git a/src/UiPath.Caching.Polly/ResiliencePipelineFactory.cs b/src/UiPath.Caching.Polly/ResiliencePipelineFactory.cs index 58c385a0..8bb2f200 100644 --- a/src/UiPath.Caching.Polly/ResiliencePipelineFactory.cs +++ b/src/UiPath.Caching.Polly/ResiliencePipelineFactory.cs @@ -42,7 +42,7 @@ protected virtual ResiliencePipelineBuilder GetBuilder(ILogger { logger.LogWarning("OnFallback. Operation key {OperationKey}", args.Context.OperationKey); return default; - } + }, }); } @@ -67,7 +67,7 @@ protected virtual ResiliencePipelineBuilder GetBuilder(ILogger { logger.LogWarning("CircuitBreaker OnOpened. Operation key {OperationKey}. Breaking the circuit for {DurationOfBreak}!", args.Context.OperationKey, resilienceOptions.DurationOfBreak); return default; - } + }, }); } @@ -83,7 +83,7 @@ protected virtual ResiliencePipelineBuilder GetBuilder(ILogger { logger.LogWarning("OnRetry, Attempt: {AttemptNumber}. Operation key {OperationKey}", args.AttemptNumber, args.Context.OperationKey); return default; - } + }, }); } @@ -96,7 +96,7 @@ protected virtual ResiliencePipelineBuilder GetBuilder(ILogger { logger.LogWarning("Execution timed out after {TotalMilliseconds} ms. Operation key {OperationKey}", args.Timeout.TotalMilliseconds, args.Context.OperationKey); return default; - } + }, }); } diff --git a/src/UiPath.Caching.Queue/InMemoryQueueCacheProvider.cs b/src/UiPath.Caching.Queue/InMemoryQueueCacheProvider.cs index 1acf4c26..0b0a4373 100644 --- a/src/UiPath.Caching.Queue/InMemoryQueueCacheProvider.cs +++ b/src/UiPath.Caching.Queue/InMemoryQueueCacheProvider.cs @@ -18,10 +18,6 @@ public sealed class InMemoryQueueCacheProvider : IQueueCacheProvider private readonly TimeProvider _clock; private readonly Lazy _setCache; - public string Name => KnownCacheProviderNames.InMemory; - - public bool Enabled { get; } - public InMemoryQueueCacheProvider( IOptions optionsAccessor, IMemoryCacheFactory memoryCacheFactory, @@ -38,6 +34,10 @@ public InMemoryQueueCacheProvider( Enabled = _options.Enabled; } + public string Name => KnownCacheProviderNames.InMemory; + + public bool Enabled { get; } + public ISetCache CreateSetCache() => _setCache.Value; diff --git a/src/UiPath.Caching.Queue/InMemoryRedisQueueCacheProvider.cs b/src/UiPath.Caching.Queue/InMemoryRedisQueueCacheProvider.cs index 57816fa9..293377c7 100644 --- a/src/UiPath.Caching.Queue/InMemoryRedisQueueCacheProvider.cs +++ b/src/UiPath.Caching.Queue/InMemoryRedisQueueCacheProvider.cs @@ -20,10 +20,6 @@ public sealed class InMemoryRedisQueueCacheProvider : IQueueCacheProvider private readonly TimeProvider _clock; private readonly Lazy _setCache; - public string Name => KnownCacheProviderNames.InMemoryRedis; - - public bool Enabled { get; } - public InMemoryRedisQueueCacheProvider( IOptions optionsAccessor, IMemoryCacheFactory memoryCacheFactory, @@ -42,6 +38,10 @@ public InMemoryRedisQueueCacheProvider( Enabled = _options.Enabled; } + public string Name => KnownCacheProviderNames.InMemoryRedis; + + public bool Enabled { get; } + public ISetCache CreateSetCache() => _setCache.Value; diff --git a/src/UiPath.Caching.Queue/MemorySetCache.cs b/src/UiPath.Caching.Queue/MemorySetCache.cs index c33e3e43..05471bf6 100644 --- a/src/UiPath.Caching.Queue/MemorySetCache.cs +++ b/src/UiPath.Caching.Queue/MemorySetCache.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using UiPath.Caching.Locking; namespace UiPath.Caching; @@ -11,21 +11,11 @@ internal sealed class MemorySetCache( IMemoryCacheOptions memoryCacheOptions, TimeProvider clock) { - private readonly bool _trackSize = memoryCacheOptions.SizeLimit.HasValue; - private readonly string _localLockKeyPrefix = cacheName + ":"; private static readonly ImmutableHashSet EmptyMembers = ImmutableHashSet.Create(ByteArrayEqualityComparer.Instance); - - private sealed record Snapshot(ImmutableHashSet Members, DateTimeOffset? Expiration); - - /// - /// A passthrough serializer such as hands back the caller's - /// own array. The snapshot hashes its members, so a caller mutating that array afterwards would - /// change an element's hash while it sits in the set. Only the paths that store need this; the - /// lookup paths may compare against the caller's array directly. - /// - private static byte[] Owned(byte[] value) => value.Length == 0 ? value : (byte[])value.Clone(); + private readonly bool _trackSize = memoryCacheOptions.SizeLimit.HasValue; + private readonly string _localLockKeyPrefix = cacheName + ":"; public bool TryGetMembers(string key, [NotNullWhen(true)] out IReadOnlyCollection? members) { @@ -168,6 +158,14 @@ public async ValueTask RemoveKeyAsync(string key, CancellationToken token) } } + /// + /// A passthrough serializer such as hands back the caller's + /// own array. The snapshot hashes its members, so a caller mutating that array afterwards would + /// change an element's hash while it sits in the set. Only the paths that store need this; the + /// lookup paths may compare against the caller's array directly. + /// + private static byte[] Owned(byte[] value) => value.Length == 0 ? value : (byte[])value.Clone(); + private bool TryGetSnapshot(string key, [NotNullWhen(true)] out Snapshot? snapshot) => memoryCache.TryGetValue(key, out snapshot) && snapshot is not null; @@ -207,4 +205,6 @@ private void StoreSnapshot(string key, Snapshot snapshot) } return list; } + + private sealed record Snapshot(ImmutableHashSet Members, DateTimeOffset? Expiration); } diff --git a/src/UiPath.Caching.Queue/MultilayerSetCache.cs b/src/UiPath.Caching.Queue/MultilayerSetCache.cs index 8fa712ef..f408e7da 100644 --- a/src/UiPath.Caching.Queue/MultilayerSetCache.cs +++ b/src/UiPath.Caching.Queue/MultilayerSetCache.cs @@ -42,11 +42,6 @@ public MultilayerSetCache( _defaultExpiration = options.DefaultExpiration; } - 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, CancellationToken token = default) @@ -64,12 +59,6 @@ public ValueTask AddAsync(CacheKey cacheKey, IEnumerable items, Time public ValueTask AddAsync(CacheKey cacheKey, IEnumerable items, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => AddCoreAsync(cacheKey, items, CacheExpiration.ThrowIfNotFuture(expiration, _clock.GetUtcNow()), policy, token); - private async ValueTask AddCoreAsync(CacheKey cacheKey, IEnumerable items, DateTimeOffset? expiration, CachePolicy? policy, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - return await InternalAddAsync(cacheKey, Materialize(items), expiration, policy, token).ConfigureAwait(false); - } - public async ValueTask PopAsync(CacheKey cacheKey, CachePolicy? policy, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -218,6 +207,33 @@ public void Dispose() } } + private static IConnectionState GetConnectionMonitor(ISetCache inner, TimeSpan? period) => + inner is IConnectionState state + ? new ConnectionStateMonitor(NullTelemetryProvider.Instance, period ?? TimeSpan.FromSeconds(5), state) + : NullConnectionStateMonitor.Instance; + + private static IEnumerable Materialize(IEnumerable items) + { + ArgumentNullException.ThrowIfNull(items); + return items as IReadOnlyCollection ?? items.ToArray(); + } + + private static string Key(CacheKey cacheKey, CancellationToken token) + { + if (cacheKey.IsNull) + { + throw new ArgumentNullException(nameof(cacheKey)); + } + token.ThrowIfCancellationRequested(); + return cacheKey.Name; + } + + private async ValueTask AddCoreAsync(CacheKey cacheKey, IEnumerable items, DateTimeOffset? expiration, CachePolicy? policy, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + return await InternalAddAsync(cacheKey, Materialize(items), expiration, policy, token).ConfigureAwait(false); + } + private bool GetInnerCacheDisconnected() => _inner is NullSetCache || (_useLocalOnlyWhenDisconnected && !_connectionState.IsConnected); private DateTimeOffset? LocalWriteExpiration(DateTimeOffset? requested, CachePolicy? policy) @@ -276,22 +292,6 @@ _inner is not NullSetCache && policy?.DistributedExpiration is null && _defaultE private DateTimeOffset? FromTtl(TimeSpan? ttl) => ttl.HasValue ? _clock.ToDateTimeOffset(ttl.Value) : null; - private static IEnumerable Materialize(IEnumerable items) - { - ArgumentNullException.ThrowIfNull(items); - return items as IReadOnlyCollection ?? items.ToArray(); - } - private DateTimeOffset? LocalExpiration() => _localMaxExpiration.HasValue ? _clock.ToDateTimeOffset(_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/RedisQueueCacheProvider.cs b/src/UiPath.Caching.Queue/RedisQueueCacheProvider.cs index bb5379c4..05ab7c11 100644 --- a/src/UiPath.Caching.Queue/RedisQueueCacheProvider.cs +++ b/src/UiPath.Caching.Queue/RedisQueueCacheProvider.cs @@ -18,10 +18,6 @@ public sealed class RedisQueueCacheProvider : IQueueCacheProvider private readonly TimeProvider _clock; private readonly Lazy _setCache; - public string Name => KnownCacheProviderNames.Redis; - - public bool Enabled { get; } - public RedisQueueCacheProvider( IOptions redisCacheOptions, IOptions cacheOptions, @@ -48,6 +44,10 @@ public RedisQueueCacheProvider( Enabled = _setCacheOptions.Enabled; } + public string Name => KnownCacheProviderNames.Redis; + + public bool Enabled { get; } + public ISetCache CreateSetCache() => _setCache.Value; diff --git a/src/UiPath.Caching.Queue/RedisSetCache.cs b/src/UiPath.Caching.Queue/RedisSetCache.cs index 1cdf13a3..fa716a07 100644 --- a/src/UiPath.Caching.Queue/RedisSetCache.cs +++ b/src/UiPath.Caching.Queue/RedisSetCache.cs @@ -56,70 +56,6 @@ public ValueTask AddAsync(CacheKey cacheKey, IEnumerable items, Time public ValueTask AddAsync(CacheKey cacheKey, IEnumerable items, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => AddCoreAsync(cacheKey, items, GetExpiration(expiration), token); - private ValueTask AddCoreAsync(CacheKey cacheKey, IEnumerable items, DateTimeOffset expiration, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - ArgumentNullException.ThrowIfNull(items); - var values = items.Select(i => (RedisValue)_serializer.Serialize(i)).ToArray(); - return AddManyInnerAsync(cacheKey, values, expiration, token); - } - - private async ValueTask AddManyInnerAsync(CacheKey cacheKey, RedisValue[] values, DateTimeOffset expiration, CancellationToken token) - { - var redisKey = ToRedisKey(cacheKey, token); - var now = Clock.GetUtcNow(); - long ret = 0; - if (!IsConnected || values.Length == 0) - { - return ret; - } - - if (expiration < now) - { - _ = await _write.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); - return ret; - } - - var operation = StartOperation(nameof(AddAsync)); - try - { - var transaction = Database.CreateTransaction(); - var addTask = transaction.SetAddAsync(redisKey, values, CommandFlags.DemandMaster); - QueueExpirationUpdate(transaction, redisKey, expiration); - - var committed = await _write.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); - - if (committed) - { - ret = await addTask.ConfigureAwait(false); - } - else - { - LogRedisTransactionFailed(); - } - operation.Stop(); - } - catch (Exception ex) - { - operation.Stop(); - LogRedisSetCacheException(ex); - } - finally - { - operation.Track(ret > 0); - } - - return ret; - } - public async ValueTask PopAsync(CacheKey cacheKey, CachePolicy? policy, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -138,7 +74,9 @@ private async ValueTask AddManyInnerAsync(CacheKey cacheKey, RedisValue { token.ThrowIfCancellationRequested(); return await Database.SetPopAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, RedisValue.Null, token).ConfigureAwait(false); + }, + RedisValue.Null, + token).ConfigureAwait(false); if (!value.IsNull) { ret = _serializer.Deserialize(value); @@ -181,7 +119,9 @@ private async ValueTask AddManyInnerAsync(CacheKey cacheKey, RedisValue { token.ThrowIfCancellationRequested(); return await Database.SetPopAsync(redisKey, count, CommandFlags.DemandMaster).ConfigureAwait(false); - }, Array.Empty(), token).ConfigureAwait(false); + }, + Array.Empty(), + token).ConfigureAwait(false); ret = Deserialize(values); operation.Stop(); } @@ -215,7 +155,9 @@ private async ValueTask AddManyInnerAsync(CacheKey cacheKey, RedisValue { token.ThrowIfCancellationRequested(); return await Database.SetMembersAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, Array.Empty(), token).ConfigureAwait(false); + }, + Array.Empty(), + token).ConfigureAwait(false); ret = Deserialize(values); operation.Stop(); } @@ -245,7 +187,9 @@ public async ValueTask ContainsItemAsync(CacheKey cacheKey, T item, Can { token.ThrowIfCancellationRequested(); return await Database.SetContainsAsync(redisKey, value, CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -273,7 +217,9 @@ public async ValueTask CountAsync(CacheKey cacheKey, CancellationToken { token.ThrowIfCancellationRequested(); return await Database.SetLengthAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -302,7 +248,9 @@ public async ValueTask RemoveItemAsync(CacheKey cacheKey, T item, Cance { token.ThrowIfCancellationRequested(); return await Database.SetRemoveAsync(redisKey, value, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -337,7 +285,9 @@ public async ValueTask RemoveItemsAsync(CacheKey cacheKey, IEnumerable< { token.ThrowIfCancellationRequested(); return await Database.SetRemoveAsync(redisKey, values, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -365,7 +315,9 @@ public async ValueTask RemoveAsync(CacheKey cacheKey, CancellationToken { token.ThrowIfCancellationRequested(); return await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -393,7 +345,9 @@ public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationTok { token.ThrowIfCancellationRequested(); return await Database.KeyExistsAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -419,6 +373,74 @@ private static void QueueExpirationUpdate(ITransaction transaction, RedisKey red _ = transaction.KeyPersistAsync(redisKey, CommandFlags.DemandMaster | CommandFlags.FireAndForget).ConfigureAwait(false); } + private ValueTask AddCoreAsync(CacheKey cacheKey, IEnumerable items, DateTimeOffset expiration, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + ArgumentNullException.ThrowIfNull(items); + var values = items.Select(i => (RedisValue)_serializer.Serialize(i)).ToArray(); + return AddManyInnerAsync(cacheKey, values, expiration, token); + } + + private async ValueTask AddManyInnerAsync(CacheKey cacheKey, RedisValue[] values, DateTimeOffset expiration, CancellationToken token) + { + var redisKey = ToRedisKey(cacheKey, token); + var now = Clock.GetUtcNow(); + long ret = 0; + if (!IsConnected || values.Length == 0) + { + return ret; + } + + if (expiration < now) + { + _ = await _write.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); + return ret; + } + + var operation = StartOperation(nameof(AddAsync)); + try + { + var transaction = Database.CreateTransaction(); + var addTask = transaction.SetAddAsync(redisKey, values, CommandFlags.DemandMaster); + QueueExpirationUpdate(transaction, redisKey, expiration); + + var committed = await _write.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); + + if (committed) + { + ret = await addTask.ConfigureAwait(false); + } + else + { + LogRedisTransactionFailed(); + } + operation.Stop(); + } + catch (Exception ex) + { + operation.Stop(); + LogRedisSetCacheException(ex); + } + finally + { + operation.Track(ret > 0); + } + + return ret; + } + private IReadOnlyCollection Deserialize(RedisValue[] values) { if (values is null || values.Length == 0) diff --git a/src/UiPath.Caching/Broadcast/CacheEventFactory.cs b/src/UiPath.Caching/Broadcast/CacheEventFactory.cs index a0dddf4f..cbe1f21a 100644 --- a/src/UiPath.Caching/Broadcast/CacheEventFactory.cs +++ b/src/UiPath.Caching/Broadcast/CacheEventFactory.cs @@ -25,7 +25,7 @@ public ICacheEvent Create(string cacheName, string eventType, CacheEventData eve Id = id ?? Guid.NewGuid().ToString(), Type = eventType.Trim(), Source = _sourceUri, - Data = eventData + Data = eventData, }; } diff --git a/src/UiPath.Caching/Broadcast/ChangeToken.cs b/src/UiPath.Caching/Broadcast/ChangeToken.cs index f947d3d4..c11f300c 100644 --- a/src/UiPath.Caching/Broadcast/ChangeToken.cs +++ b/src/UiPath.Caching/Broadcast/ChangeToken.cs @@ -18,12 +18,6 @@ public sealed partial class ChangeToken : ICacheChangeToken, IKeyedObserver callback, object? state)> _callbacks = []; - /// This token's own key: the caller's, rendered inside the composed key it subscribes with. - private LoggedKey Logged() => LoggedKey.For(_masker, _callerKey, _key, _entryType); - - /// A key off the wire has no caller key to judge, so it is masked whole when masking is on. - private LoggedKey LoggedForeign(string key) => LoggedKey.Composed(_masker, key, _entryType); - public ChangeToken( string key, ITopic topic, @@ -66,8 +60,6 @@ internal ChangeToken( public bool MetadataHasChanged { get; private set; } - string IKeyedObserver.Key => _key; - public bool ActiveChangeCallbacks => true; public DateTimeOffset? Expiration { get; private set; } @@ -76,6 +68,8 @@ internal ChangeToken( public string? TransportId { get; private set; } + string IKeyedObserver.Key => _key; + public void OnCompleted() => LogOnCompleted(Logged(), _topic); @@ -111,6 +105,23 @@ public void OnNext(ICacheEvent cacheEvent) } } + + + public IDisposable RegisterChangeCallback(Action callback, object? state) + { + _callbacks.Add(new(callback, state)); + return this; + } + + public void Dispose() => + _unsubscriber?.Dispose(); + + /// This token's own key: the caller's, rendered inside the composed key it subscribes with. + private LoggedKey Logged() => LoggedKey.For(_masker, _callerKey, _key, _entryType); + + /// A key off the wire has no caller key to judge, so it is masked whole when masking is on. + private LoggedKey LoggedForeign(string key) => LoggedKey.Composed(_masker, key, _entryType); + private void Notify(CacheEventData? data = default) { HasChanged = true; @@ -123,14 +134,6 @@ private void Notify(CacheEventData? data = default) _callbacks.ForEach(kv => kv.callback(kv.state)); } - - - public IDisposable RegisterChangeCallback(Action callback, object? state) - { - _callbacks.Add(new(callback, state)); - return this; - } - private bool IsAcceptedEvent(ICacheEvent cacheEvent) { var data = cacheEvent.Data; @@ -165,9 +168,6 @@ private bool IsAcceptedEvent(ICacheEvent cacheEvent) return true; } - public void Dispose() => - _unsubscriber?.Dispose(); - private void ExtractMetadata(IDictionary properties) { if (!properties.TryGetValue(KnownFieldNames.MetadataKey, out object? m) || m is null) diff --git a/src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs b/src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs index 26aace8d..96152e8a 100644 --- a/src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs +++ b/src/UiPath.Caching/Broadcast/ChangeTokenFactory.cs @@ -5,10 +5,10 @@ namespace UiPath.Caching.Broadcast; public sealed partial class ChangeTokenFactory : IChangeTokenFactory, IMaskedChangeTokenFactory { -#pragma warning disable IDE1006 // Naming Styles +#pragma warning disable IDE1006, SX1309 // reads as a constant, so it keeps its PascalCase name private readonly ISet MemoryAcceptedEvents = new HashSet([KnownEventTypes.CacheRemoved, KnownEventTypes.CacheRefreshed], StringComparer.InvariantCultureIgnoreCase); private readonly ISerializerProxy _serializer; -#pragma warning restore IDE1006 // Naming Styles +#pragma warning restore IDE1006, SX1309 private readonly ILoggerFactory _loggerFactory; private readonly ILogger> _logger; diff --git a/src/UiPath.Caching/Broadcast/ChannelHelper.cs b/src/UiPath.Caching/Broadcast/ChannelHelper.cs index a5affa39..9e82661a 100644 --- a/src/UiPath.Caching/Broadcast/ChannelHelper.cs +++ b/src/UiPath.Caching/Broadcast/ChannelHelper.cs @@ -16,7 +16,7 @@ public static Channel Create(bool unbounded, int capacity, BoundedChannelF FullMode = fullMode, SingleReader = true, SingleWriter = true, - AllowSynchronousContinuations = false + AllowSynchronousContinuations = false, }); public static int CalculateBoundedCapacity(int consumerCapacity, int pollBatchSize) => diff --git a/src/UiPath.Caching/Broadcast/EventDispatcher.cs b/src/UiPath.Caching/Broadcast/EventDispatcher.cs index 36283bb8..ec36633b 100644 --- a/src/UiPath.Caching/Broadcast/EventDispatcher.cs +++ b/src/UiPath.Caching/Broadcast/EventDispatcher.cs @@ -30,6 +30,17 @@ public EventDispatcher(TopicKey topicKey, internal Task ConsumeTask { get; } + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _stopTokenSource?.Cancel(); + _stopTokenSource?.Dispose(); + } + private async Task Consume() { while (await _reader.WaitToReadAsync(_cancellationToken).ConfigureAwait(false)) @@ -49,17 +60,6 @@ private async Task Consume() LogStoppedConsuming(_topicKey); } - public void Dispose() - { - if (_disposed) - { - return; - } - _disposed = true; - _stopTokenSource?.Cancel(); - _stopTokenSource?.Dispose(); - } - [LoggerMessage(Level = LogLevel.Debug, Message = "Stopped consuming from topic {TopicKey}")] private partial void LogStoppedConsuming(TopicKey topicKey); diff --git a/src/UiPath.Caching/Broadcast/KeyedSubject.cs b/src/UiPath.Caching/Broadcast/KeyedSubject.cs index 52bca5eb..f92fa2a4 100644 --- a/src/UiPath.Caching/Broadcast/KeyedSubject.cs +++ b/src/UiPath.Caching/Broadcast/KeyedSubject.cs @@ -62,24 +62,6 @@ public void OnNext(T value) } } - private void SafeOnNext(IObserver observer, T value) - { - var start = Stopwatch.GetTimestamp(); - try - { - observer.OnNext(value); - } - catch (Exception ex) - { - LogObserverOnNextFailed(ex, value.Id); - } - var elapsed = Stopwatch.GetElapsedTime(start); - if (elapsed > _slowObserverThreshold) - { - LogObserverSlow(observer.GetType().FullName, elapsed.TotalMilliseconds, value.Id); - } - } - public void OnCompleted() { _completed = true; @@ -101,6 +83,26 @@ public void OnCompleted() _broadcastObservers.Clear(); } + public void Dispose() => OnCompleted(); + + private void SafeOnNext(IObserver observer, T value) + { + var start = Stopwatch.GetTimestamp(); + try + { + observer.OnNext(value); + } + catch (Exception ex) + { + LogObserverOnNextFailed(ex, value.Id); + } + var elapsed = Stopwatch.GetElapsedTime(start); + if (elapsed > _slowObserverThreshold) + { + LogObserverSlow(observer.GetType().FullName, elapsed.TotalMilliseconds, value.Id); + } + } + private void SafeOnCompleted(IObserver observer) { try @@ -113,8 +115,6 @@ private void SafeOnCompleted(IObserver observer) } } - public void Dispose() => OnCompleted(); - private void Unsubscribe(string? key, IObserver observer) { if (key != null) @@ -137,6 +137,15 @@ private void Unsubscribe(string? key, IObserver observer) } } + [LoggerMessage(Level = LogLevel.Warning, Message = "Observer threw in OnNext for event {EventId}; continuing with remaining observers.")] + private partial void LogObserverOnNextFailed(Exception ex, string? eventId); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Observer threw in OnCompleted; continuing.")] + private partial void LogObserverOnCompletedFailed(Exception ex); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Slow observer {Observer} took {ElapsedMs} ms in OnNext for event {EventId}.")] + private partial void LogObserverSlow(string? observer, double elapsedMs, string? eventId); + private sealed class Subscription(KeyedSubject subject, string? key, IObserver observer) : IDisposable { private KeyedSubject? _subject = subject; @@ -146,13 +155,4 @@ public void Dispose() Interlocked.Exchange(ref _subject, null)?.Unsubscribe(key, observer); } } - - [LoggerMessage(Level = LogLevel.Warning, Message = "Observer threw in OnNext for event {EventId}; continuing with remaining observers.")] - private partial void LogObserverOnNextFailed(Exception ex, string? eventId); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Observer threw in OnCompleted; continuing.")] - private partial void LogObserverOnCompletedFailed(Exception ex); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Slow observer {Observer} took {ElapsedMs} ms in OnNext for event {EventId}.")] - private partial void LogObserverSlow(string? observer, double elapsedMs, string? eventId); } diff --git a/src/UiPath.Caching/Broadcast/PerTopicOptionsRegistry.cs b/src/UiPath.Caching/Broadcast/PerTopicOptionsRegistry.cs index cb50ae35..151bb4a4 100644 --- a/src/UiPath.Caching/Broadcast/PerTopicOptionsRegistry.cs +++ b/src/UiPath.Caching/Broadcast/PerTopicOptionsRegistry.cs @@ -45,7 +45,11 @@ public void Configure(string topicName, Action configure) public IReadOnlyList> GetActions(string topicName) { - if (string.IsNullOrWhiteSpace(topicName)) return []; + if (string.IsNullOrWhiteSpace(topicName)) + { + return []; + } + return _configures.TryGetValue(topicName.Trim(), out var actions) ? actions : []; } diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisPubSubSubjectWriter.cs b/src/UiPath.Caching/Broadcast/Redis/RedisPubSubSubjectWriter.cs index d0bf0a86..97a76092 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisPubSubSubjectWriter.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisPubSubSubjectWriter.cs @@ -5,7 +5,6 @@ namespace UiPath.Caching.Broadcast.Redis; internal sealed partial class RedisPubSubSubjectWriter : IDisposable where T : IEvent { - private bool _disposed; private readonly Uri _sourceUri; private readonly RedisChannel _redisChannel; private readonly IRedisConnector _redis; @@ -16,6 +15,7 @@ internal sealed partial class RedisPubSubSubjectWriter : IDisposable private readonly TimeSpan _timerPeriod; private readonly TimeSpan _timerDueTime; private readonly Timer _subscribeTimer; + private bool _disposed; private Action? _unsubscribe; private int _subscribing; @@ -41,6 +41,17 @@ public RedisPubSubSubjectWriter( _subscribeTimer = new Timer(Subscribe, null, _timerDueTime, _timerPeriod); } + public void Dispose() + { + if (!_disposed) + { + _redis.OnReconnected -= OnReconnected; + _subscribeTimer.Dispose(); + Unsubscribe(); + } + _disposed = true; + } + private void Subscribe(object? state) { if (Interlocked.CompareExchange(ref _subscribing, 1, 0) != 0) @@ -78,17 +89,6 @@ private void OnReconnected(object? sender, EventArgs e) _subscribeTimer.Change(_timerDueTime, _timerPeriod); } - public void Dispose() - { - if (!_disposed) - { - _redis.OnReconnected -= OnReconnected; - _subscribeTimer.Dispose(); - Unsubscribe(); - } - _disposed = true; - } - private void OnMessage(RedisValue value) { if (!_disposed) diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopic.cs b/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopic.cs index 89713b98..81870c59 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopic.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopic.cs @@ -18,10 +18,6 @@ public sealed partial class RedisPubSubTopic : ITopic private readonly RedisPubSubTopicOptions _options; private bool _disposed; - public TopicKey TopicKey { get; } - - public EventHandler? OnDisposed { get; set; } - public RedisPubSubTopic( TopicKey topicKey, Uri sourceUri, @@ -50,11 +46,13 @@ public RedisPubSubTopic( _dispatcher = new EventDispatcher(topicKey, channel, _subject, _logger, _stopTokenSource.Token); } + public TopicKey TopicKey { get; } + + public EventHandler? OnDisposed { get; set; } + public IDisposable Subscribe(IObserver observer) => _subject.Subscribe(observer); - internal RedisPubSubTopicOptions GetResolvedOptionsForTests() => _options; - public async ValueTask PublishAsync(T @event, CancellationToken token = default) { token.ThrowIfCancellationRequested(); @@ -72,7 +70,9 @@ public async ValueTask PublishAsync(T @event, CancellationToken token = de { token.ThrowIfCancellationRequested(); return await _redis.Database.PublishAsync(_redisChannel, message, CommandFlags.DemandMaster).ConfigureAwait(false); - }, defaultValue: -1, token).ConfigureAwait(false); + }, + defaultValue: -1, + token).ConfigureAwait(false); return response >= 0; } catch (Exception ex) @@ -97,6 +97,8 @@ public void Dispose() OnDisposed?.Invoke(this, EventArgs.Empty); } + internal RedisPubSubTopicOptions GetResolvedOptionsForTests() => _options; + [LoggerMessage(Level = LogLevel.Trace, Message = "Publishing to topic {TopicKey} event {EventId}")] private partial void LogPublishing(TopicKey topicKey, string? eventId); diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopicProvider.cs b/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopicProvider.cs index 98f6d5d2..bdc36ba8 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopicProvider.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisPubSubTopicProvider.cs @@ -60,9 +60,6 @@ protected override ITopic CreateInternalTopic(TopicKey topicKey) _stopTokenSource.Token); } - private RedisPubSubTopicOptions? ResolveOptions(TopicKey topicKey) => - _registry.Resolve(topicKey, _options.Clone, _logger); - protected override void Dispose(bool disposing) { if (!_disposed) @@ -78,4 +75,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + + private RedisPubSubTopicOptions? ResolveOptions(TopicKey topicKey) => + _registry.Resolve(topicKey, _options.Clone, _logger); } diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs b/src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs index 86d3c5cb..be93c518 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisStreamHealthMaintainer.cs @@ -69,17 +69,6 @@ public Task StopAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - private async Task Start() - { - // Disposing the timer in StopAsync is what ends this loop, without an OperationCanceledException. - while (!_cancellationToken.IsCancellationRequested - && await _timer!.WaitForNextTickAsync(CancellationToken.None) - && await _semaphore.WaitAsync(0, CancellationToken.None)) - { - await CheckStreamsAsync(_cancellationToken).ConfigureAwait(false); - } - } - internal void Initialize() { _timer = new PeriodicTimer(_streamOptions.MaintainerCheckInterval); @@ -141,6 +130,53 @@ internal async Task CheckStreamsAsync(CancellationToken cancellationToken) } } + private static bool TryParseDeliveredIdToDatetimeOffset(string? entryId, out DateTimeOffset? dateTimeOffset) + { + dateTimeOffset = null; + if (string.IsNullOrWhiteSpace(entryId)) + { + return false; + } + + ReadOnlySpan span = entryId.AsSpan(); + int separatorIndex = span.IndexOf('-'); + ReadOnlySpan timestampPart = separatorIndex < 0 ? span : span[..separatorIndex]; + + try + { + if (long.TryParse(timestampPart, NumberStyles.None, CultureInfo.InvariantCulture, out long result) && result > 0) + { + dateTimeOffset = DateTimeOffset.FromUnixTimeMilliseconds(result); + return true; + } + + return false; + } + catch + { + return false; + } + } + + private static void AddProp(List> properties, string key, string? value) + { + if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value)) + { + properties.Add(new(key, value)); + } + } + + private async Task Start() + { + // Disposing the timer in StopAsync is what ends this loop, without an OperationCanceledException. + while (!_cancellationToken.IsCancellationRequested + && await _timer!.WaitForNextTickAsync(CancellationToken.None) + && await _semaphore.WaitAsync(0, CancellationToken.None)) + { + await CheckStreamsAsync(_cancellationToken).ConfigureAwait(false); + } + } + private async Task CheckStreamAsync(StreamContext context, DateTimeOffset minOffset, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -387,42 +423,6 @@ private void TrackStreamGroup(RedisKey stream, StreamGroupInfo groupInfo) _telemetryProvider.TrackMetric(Metrics.StreamGroup, groupInfo.Lag.GetValueOrDefault(), CollectionsMarshal.AsSpan(props)); } - private static bool TryParseDeliveredIdToDatetimeOffset(string? entryId, out DateTimeOffset? dateTimeOffset) - { - dateTimeOffset = null; - if (string.IsNullOrWhiteSpace(entryId)) - { - return false; - } - - ReadOnlySpan span = entryId.AsSpan(); - int separatorIndex = span.IndexOf('-'); - ReadOnlySpan timestampPart = separatorIndex < 0 ? span : span[..separatorIndex]; - - try - { - if (long.TryParse(timestampPart, NumberStyles.None, CultureInfo.InvariantCulture, out long result) && result > 0) - { - dateTimeOffset = DateTimeOffset.FromUnixTimeMilliseconds(result); - return true; - } - - return false; - } - catch - { - return false; - } - } - - private static void AddProp(List> properties, string key, string? value) - { - if (!string.IsNullOrWhiteSpace(key) && !string.IsNullOrWhiteSpace(value)) - { - properties.Add(new(key, value)); - } - } - private (ulong pointer, List keys) ParseStreamScan(RedisResult result) { if (result.IsNull || result.Length == 0) @@ -459,8 +459,6 @@ private static void AddProp(List> properties, strin return (pointer, lst); } - private sealed record StreamContext(RedisKey StreamKey, RedisKey QuarantineKey); - [LoggerMessage(Level = LogLevel.Error, Message = "Redis stream monitor")] private partial void LogRedisStreamMonitorError(Exception ex); @@ -481,4 +479,6 @@ private sealed record StreamContext(RedisKey StreamKey, RedisKey QuarantineKey); [LoggerMessage(Level = LogLevel.Warning, Message = "Consumer group {Group} from stream {Stream} added in quarantine")] private partial void LogConsumerGroupQuarantined(string group, RedisKey stream); + + private sealed record StreamContext(RedisKey StreamKey, RedisKey QuarantineKey); } diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisStreamNotifyChannel.cs b/src/UiPath.Caching/Broadcast/Redis/RedisStreamNotifyChannel.cs index bbd0a413..7a628729 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisStreamNotifyChannel.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisStreamNotifyChannel.cs @@ -40,6 +40,27 @@ public RedisStreamNotifyChannel( _subscribeTimer = new Timer(Subscribe, null, _timerDueTime, _timerPeriod); } + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _redis.OnReconnected -= OnReconnected; + _subscribeTimer.Dispose(); + _subscribingDone.Wait(DisposeDrainTimeout); + try + { + _unsubscribe?.Invoke(); + } + catch (Exception ex) + { + LogUnsubscribeError(ex, _channel); + } + _subscribingDone.Dispose(); + } + private void Subscribe(object? state) { if (_disposed) @@ -150,27 +171,6 @@ private void OnReconnected(object? sender, EventArgs e) } } - public void Dispose() - { - if (_disposed) - { - return; - } - _disposed = true; - _redis.OnReconnected -= OnReconnected; - _subscribeTimer.Dispose(); - _subscribingDone.Wait(DisposeDrainTimeout); - try - { - _unsubscribe?.Invoke(); - } - catch (Exception ex) - { - LogUnsubscribeError(ex, _channel); - } - _subscribingDone.Dispose(); - } - [LoggerMessage(Level = LogLevel.Debug, Message = "Stream notify subscribed: {Channel}")] private partial void LogSubscribed(RedisChannel channel); diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisStreamSubjectWriter.cs b/src/UiPath.Caching/Broadcast/Redis/RedisStreamSubjectWriter.cs index 76a7f14d..a880806e 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisStreamSubjectWriter.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisStreamSubjectWriter.cs @@ -1,4 +1,4 @@ -using System.Threading.Channels; +using System.Threading.Channels; using UiPath.Caching.Telemetry; namespace UiPath.Caching.Broadcast.Redis; @@ -11,8 +11,6 @@ internal sealed partial class RedisStreamSubjectWriter : IDisposable private const string PropTopicKey = "TopicKey"; private const string PropTransportId = "TransportId"; private const string PropEventId = "EventId"; - - private bool _disposed; private readonly RedisStreamContext _context; private readonly IRedisConnector _redis; private readonly IConnectionState _connectionState; @@ -24,8 +22,10 @@ internal sealed partial class RedisStreamSubjectWriter : IDisposable private readonly CancellationTokenSource _stopTokenSource; private readonly CancellationToken _cancelationToken; private readonly IFetchWaiter _waiter; - private RedisValue _lastId = StreamPosition.NewMessages; private readonly SemaphoreSlim _retryGate = new(0, 1); + + private bool _disposed; + private RedisValue _lastId = StreamPosition.NewMessages; private int _consecutiveFailures; private volatile bool _unsupportedCommand; @@ -57,6 +57,10 @@ public RedisStreamSubjectWriter( FetchTask = Task.Run(FetchLoop, _cancelationToken); } + internal Task FetchTask { get; } + + private bool ContinueLoop => !(_disposed || _cancelationToken.IsCancellationRequested); + public void Dispose() { if (_disposed) @@ -72,6 +76,11 @@ public void Dispose() _writer.TryComplete(); } + private static bool IsUnsupportedCommand(Exception ex) => + ex is RedisCommandException || + (ex is RedisServerException && + ex.Message.Contains(StreamConstants.UnknownCommandErrorMessage, StringComparison.OrdinalIgnoreCase)); + private void OnConnectionRecovered(object? sender, EventArgs e) => ReleaseRetryGate(); private void ReleaseRetryGate() @@ -95,10 +104,6 @@ private void ReleaseRetryGate() } } - internal Task FetchTask { get; } - - private bool ContinueLoop => !(_disposed || _cancelationToken.IsCancellationRequested); - private async Task FetchLoop() { LogFetchLoopStarted(); @@ -217,11 +222,6 @@ private async Task ProcessException(Exception ex) return true; } - private static bool IsUnsupportedCommand(Exception ex) => - ex is RedisCommandException || - (ex is RedisServerException && - ex.Message.Contains(StreamConstants.UnknownCommandErrorMessage, StringComparison.OrdinalIgnoreCase)); - private Task BackoffAsync() { var failures = ++_consecutiveFailures; @@ -294,7 +294,7 @@ private ValueTask ProcessEvent(StreamEntry @event, List ids) if (ev.SameSource(_context.SourceUri)) { LogEventFromCurrentSource(ev.Id, _context.Topic, @event.Id); - _cachingTelemetryProvider.TrackTopicReadMetric(_context.Topic!, @event.Id); + _cachingTelemetryProvider.TrackTopicReadMetric(_context.Topic.ToString(), @event.Id); TraceReceipt(ev); ids.Add(@event.Id); return default; @@ -343,8 +343,8 @@ private void HandleInvalidEvent(T ev, StreamEntry @event, List ids) { _cachingTelemetryProvider.TrackEvent(EventInvalid, [ - new(PropTopicKey, _context.Topic!), - new(PropTransportId, @event.Id!), + new(PropTopicKey, _context.Topic.ToString()), + new(PropTransportId, @event.Id.ToString()), ]); LogEventInvalid(ev.Id, _context.Topic, @event.Id); ids.Add(@event.Id); @@ -359,7 +359,7 @@ private void TraceReceipt(T ev) _cachingTelemetryProvider.TrackEvent(EventReceived, [ new(PropEventId, ev.Id!), - new(PropTopicKey, _context.Topic!), + new(PropTopicKey, _context.Topic.ToString()), new(PropTransportId, ev.TransportId!), ]); } diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopic.cs b/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopic.cs index 027d9b39..f8ba1d44 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopic.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopic.cs @@ -30,10 +30,6 @@ public sealed partial class RedisStreamsTopic : ITopic private bool _disposed; private volatile bool _consumerGroupCreated; - public TopicKey TopicKey { get; } - - public EventHandler? OnDisposed { get; set; } - public RedisStreamsTopic( TopicKey topicKey, IConnectionState connectionState, @@ -85,7 +81,9 @@ public RedisStreamsTopic( _dispatcher = new EventDispatcher(topicKey, channel, _subject, _logger, _stopTokenSource.Token); } - internal RedisStreamsTopicOptions GetResolvedOptionsForTests() => _streamOptions; + public TopicKey TopicKey { get; } + + public EventHandler? OnDisposed { get; set; } public IDisposable Subscribe(IObserver observer) { @@ -116,8 +114,10 @@ public async ValueTask PublishAsync(T @event, CancellationToken token = de maxLength: _streamOptions.MaxLength, useApproximateMaxLength: true, flags: CommandFlags.DemandMaster).ConfigureAwait(false); - }, defaultValue: RedisValue.Null, token).ConfigureAwait(false); - _cachingTelemetryProvider.TrackTopicWriteMetric(_context.Topic!, id); + }, + defaultValue: RedisValue.Null, + token).ConfigureAwait(false); + _cachingTelemetryProvider.TrackTopicWriteMetric(_context.Topic.ToString(), id); if (_notifyRedisChannel.HasValue && !id.IsNull) { try @@ -157,6 +157,8 @@ public void Dispose() OnDisposed?.Invoke(this, EventArgs.Empty); } + internal RedisStreamsTopicOptions GetResolvedOptionsForTests() => _streamOptions; + private void CreateConsumerGroup() { if (!_consumerGroupCreated) diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopicProvider.cs b/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopicProvider.cs index d13b9e30..00c51331 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopicProvider.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisStreamsTopicProvider.cs @@ -57,9 +57,6 @@ protected override ITopic CreateInternalTopic(TopicKey topicKey) _stopTokenSource.Token); } - private RedisStreamsTopicOptions? ResolveOptions(TopicKey topicKey) => - _registry.Resolve(topicKey, _redisStreamsTopicOptions.Clone, _logger); - protected override void Dispose(bool disposing) { if (!_disposed) @@ -75,4 +72,7 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + + private RedisStreamsTopicOptions? ResolveOptions(TopicKey topicKey) => + _registry.Resolve(topicKey, _redisStreamsTopicOptions.Clone, _logger); } diff --git a/src/UiPath.Caching/Broadcast/Redis/RedisTopicProviderBase.cs b/src/UiPath.Caching/Broadcast/Redis/RedisTopicProviderBase.cs index a10a8381..9f98e00a 100644 --- a/src/UiPath.Caching/Broadcast/Redis/RedisTopicProviderBase.cs +++ b/src/UiPath.Caching/Broadcast/Redis/RedisTopicProviderBase.cs @@ -11,21 +11,11 @@ public abstract class RedisTopicProviderBase( bool connectionMonitorEnabled) : ITopicProvider, IConnectionState { - private bool _disposed; private readonly ConcurrentDictionary>> _topics = new(); private readonly CancellationTokenSource _stopTokenSource = new(); - - protected IRedisConnector Redis { get; } = redis; - - protected IConnectionState ConnectionState { get; } = connectionMonitorEnabled ? redis : NullConnectionStateMonitor.Instance; - - protected ICachingTelemetryProvider Telemetry { get; } = telemetryProvider; - - protected IRedisProfiler Profiler { get; } = redisProfiler; - - protected ILoggerFactory LoggerFactory { get; } = loggerFactory; + private bool _disposed; public event EventHandler? OnConnectionFailed { @@ -53,6 +43,16 @@ public event EventHandler? OnReconnected public ICollection Keys => _topics.Keys; + protected IRedisConnector Redis { get; } = redis; + + protected IConnectionState ConnectionState { get; } = connectionMonitorEnabled ? redis : NullConnectionStateMonitor.Instance; + + protected ICachingTelemetryProvider Telemetry { get; } = telemetryProvider; + + protected IRedisProfiler Profiler { get; } = redisProfiler; + + protected ILoggerFactory LoggerFactory { get; } = loggerFactory; + public ITopic Create(TopicKey topicKey) => _topics.GetOrAdd(topicKey, tk => new Lazy>(() => { var t = CreateInternalTopic(tk); @@ -90,6 +90,8 @@ protected virtual void Dispose(bool disposing) } } + protected abstract ITopic CreateInternalTopic(TopicKey topicKey); + private void RemoveTopic(object? sender, EventArgs e) { if (sender is ITopic topic) @@ -97,6 +99,4 @@ private void RemoveTopic(object? sender, EventArgs e) Remove(topic.TopicKey); } } - - protected abstract ITopic CreateInternalTopic(TopicKey topicKey); } diff --git a/src/UiPath.Caching/Broadcast/Redis/StreamConstants.cs b/src/UiPath.Caching/Broadcast/Redis/StreamConstants.cs index 44de0c89..850e66e8 100644 --- a/src/UiPath.Caching/Broadcast/Redis/StreamConstants.cs +++ b/src/UiPath.Caching/Broadcast/Redis/StreamConstants.cs @@ -2,7 +2,6 @@ namespace UiPath.Caching.Broadcast.Redis; internal static class StreamConstants { - internal static readonly RedisValue UndeliveredMessages = ">"; internal const string ConsumerGroupNameExistsErrorMessage = "BUSYGROUP Consumer Group name already exists"; /// @@ -21,9 +20,10 @@ internal static class StreamConstants /// internal const string UnknownCommandErrorMessage = "unknown command"; - /// Upper bound for the exponential backoff applied after consecutive fetch failures. - internal static readonly TimeSpan MaxErrorBackoff = TimeSpan.FromSeconds(30); - /// Consecutive failures tolerated at the poll interval before backoff starts growing. internal const int ErrorBackoffThreshold = 3; + internal static readonly RedisValue UndeliveredMessages = ">"; + + /// Upper bound for the exponential backoff applied after consecutive fetch failures. + internal static readonly TimeSpan MaxErrorBackoff = TimeSpan.FromSeconds(30); } diff --git a/src/UiPath.Caching/CacheEntryBuilder.cs b/src/UiPath.Caching/CacheEntryBuilder.cs index 80b3bc75..cddeeac0 100644 --- a/src/UiPath.Caching/CacheEntryBuilder.cs +++ b/src/UiPath.Caching/CacheEntryBuilder.cs @@ -31,7 +31,7 @@ public CacheEntryOptions BuildEntryOptions(CacheKey cacheKey, DateTimeOffset? CallerKey = cacheKey, TopicKey = topicKey, Token = token, - Expiration = _clock.ToDateTimeOffset(expiration) + Expiration = _clock.ToDateTimeOffset(expiration), }; } } diff --git a/src/UiPath.Caching/CacheEntryFactory.cs b/src/UiPath.Caching/CacheEntryFactory.cs index 6305ae25..1404e204 100644 --- a/src/UiPath.Caching/CacheEntryFactory.cs +++ b/src/UiPath.Caching/CacheEntryFactory.cs @@ -16,12 +16,12 @@ public CacheEntry(T? value, DateTimeOffset? expiration = null, IDictionary Value; - public DateTimeOffset Expiration { get; private set; } public IDictionary? Metadata { get; private set; } + object? ICacheEntry.Value => Value; + public ICacheEntry NewEntry(DateTimeOffset? expiration = null, IDictionary? metadata = null) => new CacheEntry(Value, expiration, metadata); } diff --git a/src/UiPath.Caching/CacheEventPublisher.cs b/src/UiPath.Caching/CacheEventPublisher.cs index 2191cd1d..27b4082f 100644 --- a/src/UiPath.Caching/CacheEventPublisher.cs +++ b/src/UiPath.Caching/CacheEventPublisher.cs @@ -79,7 +79,7 @@ private async ValueTask RaiseEventAsync(ICacheEntryOptions options, string LogRaiseEvent(eventType, topicKey, LoggedKey.For(_masker, options.CallerKey, cacheKey.Name, entryType)); var data = new CacheEventData(cacheKey) { - Properties = properties + Properties = properties, }; var ev = _cacheEventFactory.Create(_cacheName, eventType, data); var topic = _topicProvider.Create(topicKey); diff --git a/src/UiPath.Caching/CacheMemoryMonitor.cs b/src/UiPath.Caching/CacheMemoryMonitor.cs index 86de5f9e..e7d9efc6 100644 --- a/src/UiPath.Caching/CacheMemoryMonitor.cs +++ b/src/UiPath.Caching/CacheMemoryMonitor.cs @@ -28,6 +28,18 @@ public CacheMemoryMonitor(string name, internal Task MonitorTask { get; } + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _cancellationTokenSource?.Cancel(); + _cancellationTokenSource?.Dispose(); + _timer.Dispose(); + } + private async Task StartMonitor() { // Disposing the timer in Dispose is what ends this loop, without an OperationCanceledException. @@ -39,7 +51,8 @@ private async Task StartMonitor() continue; } - _telemetryProvider.TrackMetric(_name, currentStats.CurrentEntryCount, + _telemetryProvider.TrackMetric(_name, + currentStats.CurrentEntryCount, [ new("CurrentEntryCount", currentStats.CurrentEntryCount.ToString(CultureInfo.InvariantCulture)), new("CurrentEstimatedSize", currentStats.CurrentEstimatedSize.GetValueOrDefault().ToString(CultureInfo.InvariantCulture)), @@ -49,16 +62,4 @@ private async Task StartMonitor() ]); } } - - public void Dispose() - { - if (_disposed) - { - return; - } - _disposed = true; - _cancellationTokenSource?.Cancel(); - _cancellationTokenSource?.Dispose(); - _timer.Dispose(); - } } diff --git a/src/UiPath.Caching/Config/CachingBuilder.cs b/src/UiPath.Caching/Config/CachingBuilder.cs index e78b0319..7c82293b 100644 --- a/src/UiPath.Caching/Config/CachingBuilder.cs +++ b/src/UiPath.Caching/Config/CachingBuilder.cs @@ -17,6 +17,17 @@ public class CachingBuilder(IServiceCollection services, IConfiguration? configu public bool Enabled { get; set; } = true; + public void RegisterOnCompleteCallback(object key, Action callback) + { + ArgumentNullException.ThrowIfNull(key); + ArgumentNullException.ThrowIfNull(callback); + + if (_registeredKeys.Add(key)) + { + _callbacks.Add(callback); + } + } + internal void Complete() { // Before the switch: a keyspace collision is a configuration error, not a runtime condition. @@ -69,15 +80,4 @@ private void ThrowIfLegacySerializerRegistered() $"{nameof(RawByteSerializerProxy)} stores byte payloads verbatim."); } } - - public void RegisterOnCompleteCallback(object key, Action callback) - { - ArgumentNullException.ThrowIfNull(key); - ArgumentNullException.ThrowIfNull(callback); - - if (_registeredKeys.Add(key)) - { - _callbacks.Add(callback); - } - } } diff --git a/src/UiPath.Caching/Config/RedisCollectionExtensions.cs b/src/UiPath.Caching/Config/RedisCollectionExtensions.cs index 57ec01c1..2f7fef42 100644 --- a/src/UiPath.Caching/Config/RedisCollectionExtensions.cs +++ b/src/UiPath.Caching/Config/RedisCollectionExtensions.cs @@ -38,15 +38,15 @@ public static ICachingBuilder AddRedisConnection(this ICachingBuilder builder, A public static ICachingBuilder AddRedisConnection(this ICachingBuilder builder, string sectionName, Action configure) { - void configureOptions(RedisConnectionOptions opt) + void ConfigureOptions(RedisConnectionOptions opt) { builder.Configuration.GetSection(sectionName).Bind(opt); configure(opt); } RedisConnectionOptions redisConnectionOptions = new RedisConnectionOptions(); - configureOptions(redisConnectionOptions); - builder.Services.Configure((Action)configureOptions); + ConfigureOptions(redisConnectionOptions); + builder.Services.Configure((Action)ConfigureOptions); return builder.AddRedisConnection(redisConnectionOptions); } @@ -64,6 +64,35 @@ public static ICachingBuilder AddRedisConfigurationOptionsProvider( return builder; } + public static ICachingBuilder AddRedisProfiler(this ICachingBuilder builder, bool enabled) + { + if (enabled) + { + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + } + else + { + builder.Services.TryAddSingleton(sp => NullRedisProfiler.Instance); + } + return builder; + } + + public static ICachingBuilder AddIRedisPlannedMaintenance(this ICachingBuilder builder, bool enabled) + { + if (enabled) + { + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(sp => sp.GetRequiredService()); + if (builder.Enabled) + { + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(sp => sp.GetRequiredService())); + } + } + return builder; + } + private static ICachingBuilder AddRedisConnection(this ICachingBuilder builder, RedisConnectionOptions redisConnectionOptions) { builder @@ -97,33 +126,4 @@ private static ICachingBuilder AddRedisConnection(this ICachingBuilder builder, return builder; } - - public static ICachingBuilder AddRedisProfiler(this ICachingBuilder builder, bool enabled) - { - if (enabled) - { - builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(); - } - else - { - builder.Services.TryAddSingleton(sp => NullRedisProfiler.Instance); - } - return builder; - } - - public static ICachingBuilder AddIRedisPlannedMaintenance(this ICachingBuilder builder, bool enabled) - { - if (enabled) - { - builder.Services.TryAddSingleton(); - builder.Services.TryAddSingleton(sp => sp.GetRequiredService()); - if (builder.Enabled) - { - builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(sp => sp.GetRequiredService())); - } - } - return builder; - } } diff --git a/src/UiPath.Caching/Config/ServiceCollectionExtensions.cs b/src/UiPath.Caching/Config/ServiceCollectionExtensions.cs index 0887a10f..1c7cc30b 100644 --- a/src/UiPath.Caching/Config/ServiceCollectionExtensions.cs +++ b/src/UiPath.Caching/Config/ServiceCollectionExtensions.cs @@ -1,4 +1,4 @@ -namespace UiPath.Caching.Config; +namespace UiPath.Caching.Config; [ExcludeFromCodeCoverage] public static class ServiceCollectionExtensions @@ -67,7 +67,7 @@ public static IServiceCollection AddCaching(this IServiceCollection services, IC services.ReserveRedisKeyspace(RedisKeyspaces.Streams, "broadcast streams"); var builder = new CachingBuilder(services, configuration) { - Enabled = options.Enabled + Enabled = options.Enabled, }; configure?.Invoke(builder); builder.Complete(); @@ -90,6 +90,16 @@ public static IServiceCollection TryAddMemoryCacheFactory(this IServiceCollectio return services; } + public static IServiceCollection TryConfigure(this IServiceCollection services, Action configureOptions) + where TOptions : class + { + if (!services.Any(d => d.ServiceType == typeof(IConfigureOptions))) + { + services.Configure(configureOptions); + } + return services; + } + /// /// Validates the casing and seeds . Called eagerly from /// AddCaching, because a key built before anything resolves @@ -108,14 +118,4 @@ internal static void SeedDefaultKeyCasing(CacheOptions options) CacheKey.DefaultCasing = options.KeyCasing; } - - public static IServiceCollection TryConfigure(this IServiceCollection services, Action configureOptions) - where TOptions : class - { - if (!services.Any(d => d.ServiceType == typeof(IConfigureOptions))) - { - services.Configure(configureOptions); - } - return services; - } } diff --git a/src/UiPath.Caching/Distributed/UiPathDistributedCache.cs b/src/UiPath.Caching/Distributed/UiPathDistributedCache.cs index 7337faa0..bfb8775a 100644 --- a/src/UiPath.Caching/Distributed/UiPathDistributedCache.cs +++ b/src/UiPath.Caching/Distributed/UiPathDistributedCache.cs @@ -30,8 +30,6 @@ internal sealed partial class UiPathDistributedCache : IDistributedCache private readonly TimeProvider _clock; private readonly ILogger _logger; - internal bool TierRetainsValues { get; } - /// /// Applied to every composed key — as resolved /// by registration. Applied here rather than through the backing provider's own @@ -62,15 +60,14 @@ public UiPathDistributedCache( _clock = clock; } + internal bool TierRetainsValues { get; } + public byte[]? Get(string key) => GetAsync(key).GetAwaiter().GetResult(); public Task GetAsync(string key, CancellationToken token = default) => ReadPayloadAsync(key, token).AsTask(); - private async ValueTask ReadPayloadAsync(string key, CancellationToken token) => - await ReadAsync(key, includeData: true, token).ConfigureAwait(false) is { } fields ? AsArray(Payload(fields)) : null; - public void Refresh(string key) => RefreshAsync(key).GetAwaiter().GetResult(); @@ -98,6 +95,129 @@ public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions opti return WriteAsync(key, value, options, token).AsTask(); } + /// + /// Past its absolute deadline. Reported as a miss and left to expire on its own: deleting it here would + /// race a writer that has just replaced the value. + /// + private static bool IsExpired(DateTimeOffset? absolute, DateTimeOffset now) => + absolute is { } cap && cap <= now; + + /// + /// The stored payload. Metadata without a data field is a hit with an empty payload, because the hash + /// layer reports a zero-length value as absent. + /// + private static ReadOnlyMemory Payload(IDictionary> fields) => + fields.TryGetValue(DataField, out var data) ? data : default; + + private static byte[] AsArray(ReadOnlyMemory payload) => + MemoryMarshal.TryGetArray(payload, out var segment) + && segment.Array is { } array + && segment.Offset == 0 + && segment.Count == array.Length + ? array + : payload.ToArray(); + + /// + /// Decodes both expiration fields, or reports a miss. The accepted values are exactly the ones a write can + /// produce, so a stored entry always round-trips and anything else — a field the hash layer returned empty + /// because the key is absent, text that does not parse, or a number outside the field's range — is a miss. + /// Presence and meaning are settled in this one pass on purpose: deciding them separately let a value + /// satisfy the presence test and then decode to "no expiration", which serves the payload as an entry that + /// never expires. + /// + private static bool TryDecodeMetadata(IDictionary> fields, out EntryMetadata metadata) + { + metadata = default; + if (!TryDecodeTicks(fields, AbsoluteExpirationField, DateTime.MaxValue.Ticks, out var absoluteTicks) + || !TryDecodeTicks(fields, SlidingExpirationField, TimeSpan.MaxValue.Ticks, out var slidingTicks)) + { + return false; + } + + metadata = new EntryMetadata( + absoluteTicks is { } deadline ? new DateTimeOffset(deadline, TimeSpan.Zero) : null, + slidingTicks is { } window ? new TimeSpan(window) : null); + return true; + } + + /// + /// One field. True with a value, true with null for the sentinel, false when the field + /// holds something no write could have produced. A write emits either the sentinel or a strictly positive + /// tick count within the field's range: an absolute deadline is required to be in the future, and + /// only permits positive durations. Parsed + /// straight from the bytes: only a leading sign is tolerated around the digits, because that is all + /// emits, and the whole field has to be consumed. + /// + private static bool TryDecodeTicks(IDictionary> fields, string field, long maxTicks, out long? ticks) + { + ticks = null; + if (!fields.TryGetValue(field, out var raw) + || raw.IsEmpty + || !Utf8Parser.TryParse(raw.Span, out long value, out var consumed) + || consumed != raw.Length) + { + return false; + } + + if (value == Absent) + { + return true; + } + + if (value < 1 || value > maxTicks) + { + return false; + } + + ticks = value; + return true; + } + + private static ReadOnlyMemory EncodeTicks(long? ticks) + { + if (ticks is not { } value) + { + return AbsentTicks; + } + + Span digits = stackalloc byte[20]; // long.MinValue is 20 characters + Utf8Formatter.TryFormat(value, digits, out var written); + return digits[..written].ToArray(); + } + + private static DateTimeOffset AddClamped(DateTimeOffset now, long ticks) + { + var remaining = DateTimeOffset.MaxValue.UtcTicks - now.UtcTicks; + return ticks >= remaining ? DateTimeOffset.MaxValue : now.AddTicks(ticks); + } + + private static TimeSpan? ResolveTimeToLive(DateTimeOffset now, TimeSpan? sliding, DateTimeOffset? absolute) + { + var remaining = absolute is { } cap ? cap - now : (TimeSpan?)null; + return (sliding, remaining) switch + { + ({ } window, { } left) => TimeSpan.FromTicks(Math.Min(window.Ticks, left.Ticks)), + ({ } window, null) => window, + (null, { } left) => left, + _ => null, + }; + } + + private static DateTimeOffset? ResolveAbsoluteExpiration(DateTimeOffset now, DistributedCacheEntryOptions options) + { + if (options.AbsoluteExpiration is { } absolute) + { + return absolute <= now + ? throw new ArgumentOutOfRangeException(nameof(options), absolute, "The absolute expiration must be in the future.") + : absolute; + } + + return options.AbsoluteExpirationRelativeToNow is { } relative ? AddClamped(now, relative.Ticks) : null; + } + + private async ValueTask ReadPayloadAsync(string key, CancellationToken token) => + await ReadAsync(key, includeData: true, token).ConfigureAwait(false) is { } fields ? AsArray(Payload(fields)) : null; + private async ValueTask WriteAsync(string key, ReadOnlyMemory value, DistributedCacheEntryOptions options, CancellationToken token) { ArgumentNullException.ThrowIfNull(options); @@ -206,28 +326,6 @@ private CacheKey Encode(string key) private string[] FieldsToRead(bool includeData) => includeData || _slideByRewrite ? EntryFields : MetadataFields; - /// - /// Past its absolute deadline. Reported as a miss and left to expire on its own: deleting it here would - /// race a writer that has just replaced the value. - /// - private static bool IsExpired(DateTimeOffset? absolute, DateTimeOffset now) => - absolute is { } cap && cap <= now; - - /// - /// The stored payload. Metadata without a data field is a hit with an empty payload, because the hash - /// layer reports a zero-length value as absent. - /// - private static ReadOnlyMemory Payload(IDictionary> fields) => - fields.TryGetValue(DataField, out var data) ? data : default; - - private static byte[] AsArray(ReadOnlyMemory payload) => - MemoryMarshal.TryGetArray(payload, out var segment) - && segment.Array is { } array - && segment.Offset == 0 - && segment.Count == array.Length - ? array - : payload.ToArray(); - /// /// Extends the entry's deadline. Memory-backed tiers write it back instead of refreshing, because their /// refresh evicts the local entry and the inner cache cannot restore it. @@ -247,110 +345,12 @@ private async ValueTask SlideAsync( _ = await _cache.RefreshAsync>(cacheKey, target, _policy, token).ConfigureAwait(false); } - /// Expiration metadata as written, decoded once. Null means the sentinel: that deadline was not set. - private readonly record struct EntryMetadata(DateTimeOffset? AbsoluteExpiration, TimeSpan? SlidingExpiration); - - /// - /// Decodes both expiration fields, or reports a miss. The accepted values are exactly the ones a write can - /// produce, so a stored entry always round-trips and anything else — a field the hash layer returned empty - /// because the key is absent, text that does not parse, or a number outside the field's range — is a miss. - /// Presence and meaning are settled in this one pass on purpose: deciding them separately let a value - /// satisfy the presence test and then decode to "no expiration", which serves the payload as an entry that - /// never expires. - /// - private static bool TryDecodeMetadata(IDictionary> fields, out EntryMetadata metadata) - { - metadata = default; - if (!TryDecodeTicks(fields, AbsoluteExpirationField, DateTime.MaxValue.Ticks, out var absoluteTicks) - || !TryDecodeTicks(fields, SlidingExpirationField, TimeSpan.MaxValue.Ticks, out var slidingTicks)) - { - return false; - } - - metadata = new EntryMetadata( - absoluteTicks is { } deadline ? new DateTimeOffset(deadline, TimeSpan.Zero) : null, - slidingTicks is { } window ? new TimeSpan(window) : null); - return true; - } - - /// - /// One field. True with a value, true with null for the sentinel, false when the field - /// holds something no write could have produced. A write emits either the sentinel or a strictly positive - /// tick count within the field's range: an absolute deadline is required to be in the future, and - /// only permits positive durations. Parsed - /// straight from the bytes: only a leading sign is tolerated around the digits, because that is all - /// emits, and the whole field has to be consumed. - /// - private static bool TryDecodeTicks(IDictionary> fields, string field, long maxTicks, out long? ticks) - { - ticks = null; - if (!fields.TryGetValue(field, out var raw) - || raw.IsEmpty - || !Utf8Parser.TryParse(raw.Span, out long value, out var consumed) - || consumed != raw.Length) - { - return false; - } - - if (value == Absent) - { - return true; - } - - if (value < 1 || value > maxTicks) - { - return false; - } - - ticks = value; - return true; - } - - private static ReadOnlyMemory EncodeTicks(long? ticks) - { - if (ticks is not { } value) - { - return AbsentTicks; - } - - Span digits = stackalloc byte[20]; // long.MinValue is 20 characters - Utf8Formatter.TryFormat(value, digits, out var written); - return digits[..written].ToArray(); - } - - private static DateTimeOffset AddClamped(DateTimeOffset now, long ticks) - { - var remaining = DateTimeOffset.MaxValue.UtcTicks - now.UtcTicks; - return ticks >= remaining ? DateTimeOffset.MaxValue : now.AddTicks(ticks); - } - - private static TimeSpan? ResolveTimeToLive(DateTimeOffset now, TimeSpan? sliding, DateTimeOffset? absolute) - { - var remaining = absolute is { } cap ? cap - now : (TimeSpan?)null; - return (sliding, remaining) switch - { - ({ } window, { } left) => TimeSpan.FromTicks(Math.Min(window.Ticks, left.Ticks)), - ({ } window, null) => window, - (null, { } left) => left, - _ => null, - }; - } - - private static DateTimeOffset? ResolveAbsoluteExpiration(DateTimeOffset now, DistributedCacheEntryOptions options) - { - if (options.AbsoluteExpiration is { } absolute) - { - return absolute <= now - ? throw new ArgumentOutOfRangeException(nameof(options), absolute, "The absolute expiration must be in the future.") - : absolute; - } - - return options.AbsoluteExpirationRelativeToNow is { } relative ? AddClamped(now, relative.Ticks) : null; - } - [LoggerMessage(Level = LogLevel.Warning, Message = "Distributed cache write for key {Key} was not applied by the backing cache.")] private partial void LogWriteNotApplied(LoggedKey key); [LoggerMessage(Level = LogLevel.Debug, Message = "Distributed cache remove for key {Key} reported no change.")] private partial void LogRemoveNotApplied(LoggedKey key); + + /// Expiration metadata as written, decoded once. Null means the sentinel: that deadline was not set. + private readonly record struct EntryMetadata(DateTimeOffset? AbsoluteExpiration, TimeSpan? SlidingExpiration); } diff --git a/src/UiPath.Caching/HashCacheEntryBuilder.cs b/src/UiPath.Caching/HashCacheEntryBuilder.cs index c6f19cfb..c1f1868b 100644 --- a/src/UiPath.Caching/HashCacheEntryBuilder.cs +++ b/src/UiPath.Caching/HashCacheEntryBuilder.cs @@ -40,7 +40,7 @@ internal InternalHashCacheEntryOptions BuildEntryOptions(CacheKey cacheKey, s Token = token, Expiration = _clock.ToDateTimeOffset(expiration), SetOption = setOption, - Metadata = default + Metadata = default, }; } } diff --git a/src/UiPath.Caching/ICacheEntryOptions.cs b/src/UiPath.Caching/ICacheEntryOptions.cs index 7ad108f1..3e9a38e2 100644 --- a/src/UiPath.Caching/ICacheEntryOptions.cs +++ b/src/UiPath.Caching/ICacheEntryOptions.cs @@ -2,6 +2,8 @@ namespace UiPath.Caching; public interface ICacheEntryOptions { + + IDictionary? Metadata { get; } CacheKey CacheKey { get; } /// The key the caller passed, before composed from it; the composed key when a source has only that. @@ -10,6 +12,4 @@ public interface ICacheEntryOptions TopicKey TopicKey { get; } DateTimeOffset Expiration { get; } - - public IDictionary? Metadata { get; } } diff --git a/src/UiPath.Caching/ICacheOptions.cs b/src/UiPath.Caching/ICacheOptions.cs index 99af5283..4652d15a 100644 --- a/src/UiPath.Caching/ICacheOptions.cs +++ b/src/UiPath.Caching/ICacheOptions.cs @@ -2,17 +2,17 @@ namespace UiPath.Caching; public interface ICacheOptions { - public bool Enabled { get; } + bool Enabled { get; } - public TimeSpan? DefaultExpiration { get; } + TimeSpan? DefaultExpiration { get; } - public TimeSpan Timeout { get; set; } + TimeSpan Timeout { get; set; } - public ICacheEntryFactory? EntryFactory { get; set; } + ICacheEntryFactory? EntryFactory { get; set; } - public ICacheKeyStrategy? CacheKeyStrategy { get; set; } + ICacheKeyStrategy? CacheKeyStrategy { get; set; } - public bool? ConnectionMonitorEnabled { get; set; } + bool? ConnectionMonitorEnabled { get; set; } /// /// When true, GetOrAddAsync caches a generator's null / empty result instead of re-invoking the @@ -20,5 +20,5 @@ public interface ICacheOptions /// likewise persist the sentinel instead of removing the entry. Default false preserves legacy /// behavior for callers that haven't opted in. /// - public bool CacheNullValues { get => false; set => _ = value; } + bool CacheNullValues { get => false; set => _ = value; } } diff --git a/src/UiPath.Caching/IMultilayerCacheOptions.cs b/src/UiPath.Caching/IMultilayerCacheOptions.cs index e17960e3..6d5878eb 100644 --- a/src/UiPath.Caching/IMultilayerCacheOptions.cs +++ b/src/UiPath.Caching/IMultilayerCacheOptions.cs @@ -4,20 +4,20 @@ namespace UiPath.Caching; public interface IMultilayerCacheOptions : ICacheOptions { - public string? Topic { get; set; } + string? Topic { get; set; } - public ITopicKeyStrategy? TopicKeyStrategy { get; set; } + ITopicKeyStrategy? TopicKeyStrategy { get; set; } /// L1 (in-memory tier) cap on entry lifetime. Aligns with .NET HybridCache's `LocalCacheExpiration` naming. - public TimeSpan? LocalMaxExpiration { get; set; } + TimeSpan? LocalMaxExpiration { get; set; } - public TimeSpan? ConnectionMonitorPeriod { get; set; } + TimeSpan? ConnectionMonitorPeriod { get; set; } /// Serve from L1 only (without falling back to default) when the L2 connection is unhealthy. Aligns with Local/Distributed tier naming. - public bool? UseLocalOnlyWhenDisconnected { get; set; } + bool? UseLocalOnlyWhenDisconnected { get; set; } /// L1 cap on entry lifetime while the L2 connection is unhealthy (paired with ). - public TimeSpan? LocalMaxExpirationDisconnected { get; set; } + TimeSpan? LocalMaxExpirationDisconnected { get; set; } /// /// Enables the per-key in-process lock that serializes the cache-miss generator across @@ -28,7 +28,7 @@ public interface IMultilayerCacheOptions : ICacheOptions /// for the same key, and the distributed lock's contention timeout (rather than the local /// lock) becomes the only bound on how many generators run. /// - public bool? LocalLockEnabled { get; set; } + bool? LocalLockEnabled { get; set; } /// /// How long a caller blocks trying to acquire the per-key in-process lock before giving up @@ -38,14 +38,14 @@ public interface IMultilayerCacheOptions : ICacheOptions /// above your p99 generator runtime plus if distributed /// locking is also enabled. /// - public TimeSpan? LocalLockTimeout { get; set; } + TimeSpan? LocalLockTimeout { get; set; } /// /// Enables the distributed (cross-node) lock around the cache-miss generator. Has no effect /// on cache providers that don't supply a real implementation /// (e.g. the in-memory-only provider, which always passes ). /// - public bool? DistributedLockEnabled { get; set; } + bool? DistributedLockEnabled { get; set; } /// /// How long a waiter blocks trying to acquire the distributed lock before giving up and @@ -53,7 +53,7 @@ public interface IMultilayerCacheOptions : ICacheOptions /// re-stampedes the generator across nodes — pick a value that comfortably exceeds your /// generator's typical runtime. /// - public TimeSpan? DistributedLockTimeout { get; set; } + TimeSpan? DistributedLockTimeout { get; set; } /// /// TTL for the Redis lock. Acts as a safety net so a crashed holder doesn't deadlock the @@ -63,7 +63,7 @@ public interface IMultilayerCacheOptions : ICacheOptions /// acquire it, which can produce duplicate generator invocations under load. Set above your /// p99 generator runtime, or accept the partial herd as a trade-off. /// - public TimeSpan? DistributedLockExpiry { get; set; } + TimeSpan? DistributedLockExpiry { get; set; } /// /// Strategy that derives the Redis distributed-lock key from a cache key. The default @@ -74,6 +74,6 @@ public interface IMultilayerCacheOptions : ICacheOptions /// a non-trivial cache-key strategy (e.g. ), supply /// a matching lock-key strategy here too. /// - public IDistributedLockKeyStrategy? LockKeyStrategy { get; set; } + IDistributedLockKeyStrategy? LockKeyStrategy { get; set; } } diff --git a/src/UiPath.Caching/InMemoryCacheProvider.cs b/src/UiPath.Caching/InMemoryCacheProvider.cs index ac0ad227..298c423e 100644 --- a/src/UiPath.Caching/InMemoryCacheProvider.cs +++ b/src/UiPath.Caching/InMemoryCacheProvider.cs @@ -1,4 +1,4 @@ -using UiPath.Caching.Locking; +using UiPath.Caching.Locking; using UiPath.Caching.Telemetry; namespace UiPath.Caching; @@ -21,10 +21,6 @@ public sealed class InMemoryCacheProvider : ICacheProvider private readonly Lazy _cache; private readonly Lazy _hashCache; - public string Name => KnownCacheProviderNames.InMemory; - - public bool Enabled => _options.Enabled; - public InMemoryCacheProvider( IOptions optionsAccessor, IOptions cacheOptionsAccessor, @@ -73,6 +69,10 @@ public InMemoryCacheProvider( _hashCache = new Lazy(BuildHashCache); } + public string Name => KnownCacheProviderNames.InMemory; + + public bool Enabled => _options.Enabled; + public ICache CreateCache() => _cache.Value; diff --git a/src/UiPath.Caching/InMemoryRedisCacheProvider.cs b/src/UiPath.Caching/InMemoryRedisCacheProvider.cs index 0bbf389a..0b6a79b6 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; @@ -23,10 +23,6 @@ public sealed class InMemoryRedisCacheProvider : ICacheProvider private readonly Lazy _cache; private readonly Lazy _hashCache; - public string Name => KnownCacheProviderNames.InMemoryRedis; - - public bool Enabled { get; } - public InMemoryRedisCacheProvider( IOptions optionsAccessor, IOptions cacheOptionsAccessor, @@ -71,6 +67,10 @@ public InMemoryRedisCacheProvider( Enabled = _options.Enabled; } + public string Name => KnownCacheProviderNames.InMemoryRedis; + + public bool Enabled { get; } + public ICache CreateCache() => _cache.Value; diff --git a/src/UiPath.Caching/Locking/RedisDistributedLock.cs b/src/UiPath.Caching/Locking/RedisDistributedLock.cs index 10399bcc..8abd977c 100644 --- a/src/UiPath.Caching/Locking/RedisDistributedLock.cs +++ b/src/UiPath.Caching/Locking/RedisDistributedLock.cs @@ -116,21 +116,11 @@ public async ValueTask AcquireAsync(string key, TimeSpan expir return acquired ? BuildAcquiredLease(redisKey, lockToken, key, contended: false) : null; } - private string BuildLockToken() => - string.Create(_tokenPrefix.Length + 32, _tokenPrefix, static (span, prefix) => - { - prefix.AsSpan().CopyTo(span); - Guid.NewGuid().TryFormat(span[prefix.Length..], out _, "N"); - }); - internal static TimeSpan NextPollInterval(TimeSpan current, TimeSpan max) => current < max ? TimeSpan.FromTicks(Math.Min(current.Ticks * 2, max.Ticks)) : max; - private static TimeSpan ComputeRetryDelay(bool hasDeadline, long startTimestamp, TimeSpan waitTimeout, TimeSpan pollInterval) => - ComputeRetryDelayWithJitter(hasDeadline, startTimestamp, waitTimeout, pollInterval, Random.Shared.NextDouble()); - internal static TimeSpan ComputeRetryDelayWithJitter(bool hasDeadline, long startTimestamp, TimeSpan waitTimeout, TimeSpan pollInterval, double jitterUnit) { if (!hasDeadline) @@ -147,6 +137,16 @@ internal static TimeSpan ComputeRetryDelayWithJitter(bool hasDeadline, long star return jittered < remaining ? jittered : remaining; } + private static TimeSpan ComputeRetryDelay(bool hasDeadline, long startTimestamp, TimeSpan waitTimeout, TimeSpan pollInterval) => + ComputeRetryDelayWithJitter(hasDeadline, startTimestamp, waitTimeout, pollInterval, Random.Shared.NextDouble()); + + private string BuildLockToken() => + string.Create(_tokenPrefix.Length + 32, _tokenPrefix, static (span, prefix) => + { + prefix.AsSpan().CopyTo(span); + Guid.NewGuid().TryFormat(span[prefix.Length..], out _, "N"); + }); + private Releaser BuildAcquiredLease(RedisKey redisKey, RedisValue lockToken, string key, bool contended) { _telemetry.TrackEvent(EventAcquired, [new(PropKey, key), new(PropContended, contended.ToString())]); diff --git a/src/UiPath.Caching/Logging/AlwaysMaskKeyMaskingPolicy.cs b/src/UiPath.Caching/Logging/AlwaysMaskKeyMaskingPolicy.cs new file mode 100644 index 00000000..9d4c4944 --- /dev/null +++ b/src/UiPath.Caching/Logging/AlwaysMaskKeyMaskingPolicy.cs @@ -0,0 +1,13 @@ +namespace UiPath.Caching.Logging; + +/// Masks every key it is asked about. The distributed adapter uses it: those keys are the consumer's. +public sealed class AlwaysMaskKeyMaskingPolicy : IKeyMaskingPolicy +{ + public static readonly AlwaysMaskKeyMaskingPolicy Instance = new(); + + private AlwaysMaskKeyMaskingPolicy() + { + } + + public bool ShouldMask(in MaskingContext context) => true; +} diff --git a/src/UiPath.Caching/Logging/IKeyMaskingPolicy.cs b/src/UiPath.Caching/Logging/IKeyMaskingPolicy.cs index ccd53cf2..46bb80f2 100644 --- a/src/UiPath.Caching/Logging/IKeyMaskingPolicy.cs +++ b/src/UiPath.Caching/Logging/IKeyMaskingPolicy.cs @@ -1,35 +1,7 @@ namespace UiPath.Caching.Logging; -/// What the library is about to name in a log line, so a policy can judge whether it is a secret. -/// What the cache holds, when the call site knows it: ICache<SessionToken> is a secret, ICache<int> is not. -public readonly record struct MaskingContext(string Key, Type? ValueType, string CacheName); - /// Decides whether a cache key is a secret; registered once and consulted by every component that logs a key. public interface IKeyMaskingPolicy { bool ShouldMask(in MaskingContext context); } - -/// Masks nothing; what a container resolves until masking is configured. -public sealed class NullKeyMaskingPolicy : IKeyMaskingPolicy -{ - public static readonly NullKeyMaskingPolicy Instance = new(); - - private NullKeyMaskingPolicy() - { - } - - public bool ShouldMask(in MaskingContext context) => false; -} - -/// Masks every key it is asked about. The distributed adapter uses it: those keys are the consumer's. -public sealed class AlwaysMaskKeyMaskingPolicy : IKeyMaskingPolicy -{ - public static readonly AlwaysMaskKeyMaskingPolicy Instance = new(); - - private AlwaysMaskKeyMaskingPolicy() - { - } - - public bool ShouldMask(in MaskingContext context) => true; -} diff --git a/src/UiPath.Caching/Logging/KeyMasker.cs b/src/UiPath.Caching/Logging/KeyMasker.cs index ede0ec9f..9b0947ab 100644 --- a/src/UiPath.Caching/Logging/KeyMasker.cs +++ b/src/UiPath.Caching/Logging/KeyMasker.cs @@ -1,17 +1,17 @@ -using System.Globalization; +using System.Globalization; namespace UiPath.Caching; /// One cache's view of the policy; rendering lives here so a custom policy cannot emit the raw key. internal sealed class KeyMasker { - private const int Revealed = 3; - private const string MaskText = "****"; public static readonly KeyMasker Off = new(NullKeyMaskingPolicy.Instance, string.Empty); /// For keys known to be the consumer's, whatever the application registered. public static readonly KeyMasker Always = new(AlwaysMaskKeyMaskingPolicy.Instance, string.Empty); + private const int Revealed = 3; + private const string MaskText = "****"; private readonly IKeyMaskingPolicy _policy; private readonly string _cacheName; @@ -22,9 +22,20 @@ public KeyMasker(IKeyMaskingPolicy policy, string cacheName) _cacheName = cacheName ?? string.Empty; } + /// Whether a policy is installed at all; what a composed key with no caller key falls back to. + public bool IsMasking => !ReferenceEquals(_policy, NullKeyMaskingPolicy.Instance); + public static KeyMasker For(IKeyMaskingPolicy? policy, string cacheName) => policy is null or NullKeyMaskingPolicy ? Off : new KeyMasker(policy, cacheName); + /// First three characters then ****: enough to correlate two lines, not to replay a key. + public static string Mask(string value) => + value.Length > Revealed ? string.Concat(value.AsSpan(0, Revealed), MaskText) : MaskText; + + /// A number or a GUID names a row, not a person; the built-in policy leaves those readable. + public static bool IsIdentifier(string value) => + long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) || Guid.TryParse(value, out _); + /// Splices the caller's key out of wherever it sits; a composed key it is not part of is masked whole. public string Render(string? key, string? composed, Type? valueType) { @@ -51,9 +62,6 @@ public string Render(string? key, string? composed, Type? valueType) } /// A policy is application code running inside log formatting: a throwing one masks rather than escapes. - /// Whether a policy is installed at all; what a composed key with no caller key falls back to. - public bool IsMasking => !ReferenceEquals(_policy, NullKeyMaskingPolicy.Instance); - private bool ShouldMask(string key, Type? valueType) { if (!IsMasking) @@ -70,12 +78,4 @@ private bool ShouldMask(string key, Type? valueType) return true; } } - - /// First three characters then ****: enough to correlate two lines, not to replay a key. - public static string Mask(string value) => - value.Length > Revealed ? string.Concat(value.AsSpan(0, Revealed), MaskText) : MaskText; - - /// A number or a GUID names a row, not a person; the built-in policy leaves those readable. - public static bool IsIdentifier(string value) => - long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) || Guid.TryParse(value, out _); } diff --git a/src/UiPath.Caching/Logging/LoggedKey.cs b/src/UiPath.Caching/Logging/LoggedKey.cs index f469cc1f..a200ffc5 100644 --- a/src/UiPath.Caching/Logging/LoggedKey.cs +++ b/src/UiPath.Caching/Logging/LoggedKey.cs @@ -39,44 +39,3 @@ public override string ToString() return _masker.Render(_key, composed, _valueType); } } - -/// The same, for the log lines that name several keys at once. -internal readonly struct LoggedKeys -{ - private readonly KeyMasker _masker; - private readonly IReadOnlyCollection? _keys; - private readonly IReadOnlyCollection? _entries; - private readonly Type? _valueType; - private readonly bool _composedOnly; - - public LoggedKeys(KeyMasker masker, IReadOnlyCollection keys, Type? valueType = null, bool composedOnly = false) - { - _masker = masker; - _keys = keys; - _valueType = valueType; - _composedOnly = composedOnly; - } - - /// Shows each composed key but judges, and masks, the caller's own key inside it. - public LoggedKeys(KeyMasker masker, IReadOnlyCollection entries, Type? valueType = null) - { - _masker = masker; - _entries = entries; - _valueType = valueType; - } - - public override string ToString() - { - var masker = _masker; - var valueType = _valueType; - if (_entries is not null) - { - return string.Join(',', _entries.Select(e => masker.Render(e.CallerKey.Name ?? string.Empty, e.CacheKey.Name, valueType))); - } - - var composedOnly = _composedOnly; - return string.Join(',', (_keys ?? []).Select(key => composedOnly - ? masker.Render(key: null, key.Name, valueType) - : masker.Render(key.Name ?? string.Empty, composed: null, valueType))); - } -} diff --git a/src/UiPath.Caching/Logging/LoggedKeys.cs b/src/UiPath.Caching/Logging/LoggedKeys.cs new file mode 100644 index 00000000..77475114 --- /dev/null +++ b/src/UiPath.Caching/Logging/LoggedKeys.cs @@ -0,0 +1,42 @@ +namespace UiPath.Caching; + +/// The same, for the log lines that name several keys at once. +internal readonly struct LoggedKeys +{ + private readonly KeyMasker _masker; + private readonly IReadOnlyCollection? _keys; + private readonly IReadOnlyCollection? _entries; + private readonly Type? _valueType; + private readonly bool _composedOnly; + + public LoggedKeys(KeyMasker masker, IReadOnlyCollection keys, Type? valueType = null, bool composedOnly = false) + { + _masker = masker; + _keys = keys; + _valueType = valueType; + _composedOnly = composedOnly; + } + + /// Shows each composed key but judges, and masks, the caller's own key inside it. + public LoggedKeys(KeyMasker masker, IReadOnlyCollection entries, Type? valueType = null) + { + _masker = masker; + _entries = entries; + _valueType = valueType; + } + + public override string ToString() + { + var masker = _masker; + var valueType = _valueType; + if (_entries is not null) + { + return string.Join(',', _entries.Select(e => masker.Render(e.CallerKey.Name ?? string.Empty, e.CacheKey.Name, valueType))); + } + + var composedOnly = _composedOnly; + return string.Join(',', (_keys ?? []).Select(key => composedOnly + ? masker.Render(key: null, key.Name, valueType) + : masker.Render(key.Name ?? string.Empty, composed: null, valueType))); + } +} diff --git a/src/UiPath.Caching/Logging/MaskingContext.cs b/src/UiPath.Caching/Logging/MaskingContext.cs new file mode 100644 index 00000000..e7915952 --- /dev/null +++ b/src/UiPath.Caching/Logging/MaskingContext.cs @@ -0,0 +1,5 @@ +namespace UiPath.Caching.Logging; + +/// What the library is about to name in a log line, so a policy can judge whether it is a secret. +/// What the cache holds, when the call site knows it: ICache<SessionToken> is a secret, ICache<int> is not. +public readonly record struct MaskingContext(string Key, Type? ValueType, string CacheName); diff --git a/src/UiPath.Caching/Logging/NullKeyMaskingPolicy.cs b/src/UiPath.Caching/Logging/NullKeyMaskingPolicy.cs new file mode 100644 index 00000000..68bcda64 --- /dev/null +++ b/src/UiPath.Caching/Logging/NullKeyMaskingPolicy.cs @@ -0,0 +1,13 @@ +namespace UiPath.Caching.Logging; + +/// Masks nothing; what a container resolves until masking is configured. +public sealed class NullKeyMaskingPolicy : IKeyMaskingPolicy +{ + public static readonly NullKeyMaskingPolicy Instance = new(); + + private NullKeyMaskingPolicy() + { + } + + public bool ShouldMask(in MaskingContext context) => false; +} diff --git a/src/UiPath.Caching/MemoryCacheFactory.cs b/src/UiPath.Caching/MemoryCacheFactory.cs index 91e50337..712767e3 100644 --- a/src/UiPath.Caching/MemoryCacheFactory.cs +++ b/src/UiPath.Caching/MemoryCacheFactory.cs @@ -10,7 +10,7 @@ public IMemoryCache Get(IMemoryCacheOptions memoryOptions) var memoryCacheOptions = new MemoryCacheOptions { TrackStatistics = memoryOptions.TrackStatistics, - Clock = _systemClock + Clock = _systemClock, }; if (memoryOptions.SizeLimit > 0) diff --git a/src/UiPath.Caching/MemoryCacheSetter.cs b/src/UiPath.Caching/MemoryCacheSetter.cs index 90d649bc..9bc64715 100644 --- a/src/UiPath.Caching/MemoryCacheSetter.cs +++ b/src/UiPath.Caching/MemoryCacheSetter.cs @@ -1,4 +1,4 @@ -using UiPath.Caching.Telemetry; +using UiPath.Caching.Telemetry; namespace UiPath.Caching; @@ -15,17 +15,17 @@ internal abstract class MemoryCacheSetter( KeyMasker? masker = null ) { - private readonly KeyMasker _masker = masker ?? KeyMasker.Off; private const string EventRefreshMetadataFailed = "Caching." + nameof(MemoryCacheSetter) + "." + nameof(RefreshMetadata) + ".Failed"; private const string PropCacheKey = "CacheKey"; private const string PropTopicKey = "TopicKey"; private const string PropTransportId = "TransportId"; - - private ICacheEntrySizeProvider SizeProvider { get; } = memoryCacheOptions.SizeProvider ?? new DefaultCacheEntrySizeProvider(); + private readonly KeyMasker _masker = masker ?? KeyMasker.Off; protected TimeProvider Clock { get; } = clock; + private ICacheEntrySizeProvider SizeProvider { get; } = memoryCacheOptions.SizeProvider ?? new DefaultCacheEntrySizeProvider(); + public bool Set(ICacheEntryOptions options, ICacheEntry item, Type entryType, TimeSpan? maxExpiration) { try @@ -56,19 +56,21 @@ public bool Set(ICacheEntryOptions options, ICacheEntry item, Type entryType, Ti } } - static void PostEviction(object key, object? value, EvictionReason reason, object? state) + internal void RefreshMetadata(object? state) { - if (state is IDisposable disposable) + if (state is RefreshMetadataState metadataState) { - disposable.Dispose(); + RefreshMetadata(metadataState); } } - internal void RefreshMetadata(object? state) + protected abstract ICacheEntryOptions CreateEntry(RefreshMetadataState metadataState, CancellationToken cancellationToken); + + private static void PostEviction(object key, object? value, EvictionReason reason, object? state) { - if (state is RefreshMetadataState metadataState) + if (state is IDisposable disposable) { - RefreshMetadata(metadataState); + disposable.Dispose(); } } @@ -110,8 +112,6 @@ private void RefreshMetadata(RefreshMetadataState metadataState) } - protected abstract ICacheEntryOptions CreateEntry(RefreshMetadataState metadataState, CancellationToken cancellationToken); - private DateTimeOffset GetCacheExpiration(DateTimeOffset expiration, TimeSpan? maxExpiration) { if (maxExpiration.HasValue) diff --git a/src/UiPath.Caching/MetricExtensions.cs b/src/UiPath.Caching/MetricExtensions.cs index 30b67b5a..ad76e21c 100644 --- a/src/UiPath.Caching/MetricExtensions.cs +++ b/src/UiPath.Caching/MetricExtensions.cs @@ -45,19 +45,23 @@ private static void TrackTopicMetric( var streamIdValue = streamId.GetStreamIdFromRedisValue(); if (streamIdValue.Valid) { - cachingTelemetryProvider.TrackMetric(metricName, streamIdValue.Timestamp, - [ - new(Metrics.TopicName, topicName), - new(Metrics.SequenceNumber, streamIdValue.Sequence.ToString(CultureInfo.InvariantCulture)), - ]); + cachingTelemetryProvider.TrackMetric( + metricName, + streamIdValue.Timestamp, + [ + new(Metrics.TopicName, topicName), + new(Metrics.SequenceNumber, streamIdValue.Sequence.ToString(CultureInfo.InvariantCulture)), + ]); } else { - cachingTelemetryProvider.TrackMetric(metricName, 0, - [ - new(Metrics.TopicName, topicName), - new(Metrics.SequenceNumber, Metrics.Invalid), - ]); + cachingTelemetryProvider.TrackMetric( + metricName, + 0, + [ + new(Metrics.TopicName, topicName), + new(Metrics.SequenceNumber, Metrics.Invalid), + ]); } } } diff --git a/src/UiPath.Caching/Metrics.cs b/src/UiPath.Caching/Metrics.cs index 8c40320e..f26e4813 100644 --- a/src/UiPath.Caching/Metrics.cs +++ b/src/UiPath.Caching/Metrics.cs @@ -22,12 +22,12 @@ public static class Metrics private static readonly ConcurrentDictionary _topicWriteMetricNames = new(); private static readonly ConcurrentDictionary _topicReadMetricNames = new(); - private static string GetMetricName(string topicName, ConcurrentDictionary metricDictionary, string operationType) - => metricDictionary.GetOrAdd(topicName, tn => $"{Topic}{tn}{operationType}"); - public static string GetWriteTopicMetricName(string topicName) => GetMetricName(topicName, _topicWriteMetricNames, Write); public static string GetReadTopicMetricName(string topicName) => GetMetricName(topicName, _topicReadMetricNames, Read); + + private static string GetMetricName(string topicName, ConcurrentDictionary metricDictionary, string operationType) + => metricDictionary.GetOrAdd(topicName, tn => $"{Topic}{tn}{operationType}"); } diff --git a/src/UiPath.Caching/MultilayerCache.cs b/src/UiPath.Caching/MultilayerCache.cs index 90f13758..dcab0e75 100644 --- a/src/UiPath.Caching/MultilayerCache.cs +++ b/src/UiPath.Caching/MultilayerCache.cs @@ -120,6 +120,154 @@ public MultilayerCache( return GetOrAddBatchInternalAsync(entries, generator, writeExpiration, duration, rehydrateJitter: null, policy ?? _defaultPolicy, token); } + public ValueTask SetAsync(CacheKey cacheKey, T? value, CachePolicy? policy, CancellationToken token = default) + { + policy ??= _defaultPolicy; + return SetCoreAsync(cacheKey, value, GetExpiration(policy), policy, token); + } + + public ValueTask SetAsync(CacheKey cacheKey, T? value, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => + SetCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + public ValueTask SetAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => + SetCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + public ValueTask SetAsync(KeyValuePair[] keyValues, CachePolicy? policy, CancellationToken token = default) + { + policy ??= _defaultPolicy; + return SetCoreAsync(keyValues, GetExpiration(policy), policy, token); + } + + public ValueTask SetAsync(KeyValuePair[] keyValues, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => + SetCoreAsync(keyValues, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + public ValueTask SetAsync(KeyValuePair[] keyValues, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => + SetCoreAsync(keyValues, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + + /// + /// One path for every provider: take the local lock, probe the local tier, let the L2 decide, + /// then populate the local tier — the reverse of SetAsync, which writes both tiers + /// unconditionally. The probe is what narrows an L2 that retains nothing, and so grants every + /// caller a win, back to one winner per process; where the L2 does arbitrate, a local hit means + /// the key was already claimed or read here, so the loss is reported without a round-trip. That + /// can cost a win the L2 would have granted, when the local copy outlived the shared one — the + /// fail-closed direction the ambiguous false already covers. A disconnected L2 answers + /// for itself (RedisCache checks its connection first) rather than being gated on + /// GetInnerCacheDisconnected, whose state also covers the broadcast transport: a dead + /// topic must not stop a healthy Redis. + /// + public ValueTask TryAddAsync(CacheKey cacheKey, T? value, CachePolicy? policy, CancellationToken token = default) + { + policy ??= _defaultPolicy; + return TryAddCoreAsync(cacheKey, value, GetExpiration(policy), policy, token); + } + + /// + public ValueTask TryAddAsync(CacheKey cacheKey, T? value, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => + TryAddCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + /// + public ValueTask TryAddAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => + TryAddCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + public ValueTask RemoveAsync(CacheKey cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + return RemoveAsync(_entryBuilder.BuildEntryOptions(cacheKey, default, token)); + } + + public ValueTask RemoveAsync(CacheKey[] cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var options = cacheKey.Select(k => _entryBuilder.BuildEntryOptions(k, default, token)).ToArray(); + return RemoveAsync(options, token); + } + + public ValueTask RefreshAsync(CacheKey cacheKey, CachePolicy? policy, CancellationToken token = default) + { + policy ??= _defaultPolicy; + return RefreshCoreAsync(cacheKey, GetExpiration(policy), policy, token); + } + + public ValueTask RefreshAsync(CacheKey cacheKey, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => + RefreshCoreAsync(cacheKey, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + public ValueTask RefreshAsync(CacheKey cacheKey, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => + RefreshCoreAsync(cacheKey, GetExpiration(expiration), policy ?? _defaultPolicy, token); + + public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, default, token); + try + { + return _memoryCache.TryGetValue(cacheEntryOptions.CacheKey, out _) || await _innerCache.ContainsAsync(cacheEntryOptions.CacheKey, cacheEntryOptions.Token).ConfigureAwait(false); + } + catch (Exception ex) + { + LogInnerCacheContainsError(ex, Logged(cacheKey, typeof(T))); + return false; + } + } + + public async ValueTask TimeToLiveAsync(CacheKey cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, default, token); + return _memoryCache.TryGetValue(cacheEntryOptions.CacheKey, out var value) + ? value?.Expiration.Subtract(_clock.GetUtcNow()) + : await _innerCache.TimeToLiveAsync(cacheEntryOptions.CacheKey, token); + } + + public async ValueTask ExpireTimeAsync(CacheKey cacheKey, CancellationToken token = default) + { + NotCacheableException.ThrowIfNotCacheable(); + var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, default, token); + + return _memoryCache.TryGetValue(cacheEntryOptions.CacheKey, out var value) + ? value?.Expiration + : await _innerCache.ExpireTimeAsync(cacheEntryOptions.CacheKey, token); + } + + /// Translates the reserved caller keys back into the generator's states. + private static TState[] MapReservedKeysToStates(CacheKey[] reservedKeys, Dictionary stateByCallerKey) + where TState : notnull + { + var rehydrateStates = new TState[reservedKeys.Length]; + for (var i = 0; i < reservedKeys.Length; i++) + { + rehydrateStates[i] = stateByCallerKey[reservedKeys[i]]; + } + return rehydrateStates; + } + + /// Restricts the generator's output to what we asked for; the first value wins per state. + private static Dictionary SelectRequestedProduced( + KeyValuePair[]? produced, + TState[] requestStates) + where TState : notnull + { + var producedByState = new Dictionary(requestStates.Length); + var requested = new HashSet(requestStates); + foreach (var pair in (produced ?? []).Where(pair => requested.Contains(pair.Key))) + { + _ = producedByState.TryAdd(pair.Key, pair.Value); + } + return producedByState; + } + + private static KeyValuePair[] Project(TState[] states, int[] keyIndexOfState, T?[] values) + where TState : notnull + { + var results = new KeyValuePair[states.Length]; + for (var i = 0; i < states.Length; i++) + { + results[i] = new KeyValuePair(states[i], values[keyIndexOfState[i]]); + } + return results; + } + private async ValueTask GetOrAddInternalAsync(CacheKey cacheKey, Func> generator, DateTimeOffset? expiration, TimeSpan effectiveDuration, TimeSpan? rehydrateJitter, CachePolicy policy, CancellationToken token) { NotCacheableException.ThrowIfNotCacheable(); @@ -185,9 +333,6 @@ private void TryRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpi entryType: typeof(T)); } - /// What the batch rehydrate write needs to know about one state. - private readonly record struct RehydrateTarget(CacheEntryOptions Options, DateTimeOffset Expiration, CacheKey CallerKey); - /// Coalesces the rehydration of every hit past its threshold into one background generator call. private void TryRehydrateBatch( List<(CacheKey CallerKey, TState State, CacheEntryOptions Options, DateTimeOffset Expiration, T? Value)> hits, @@ -267,18 +412,6 @@ private async ValueTask RehydrateReservedAsync( await WriteRehydrateGroupsAsync(groups, policy, token).ConfigureAwait(false); } - /// Translates the reserved caller keys back into the generator's states. - private static TState[] MapReservedKeysToStates(CacheKey[] reservedKeys, Dictionary stateByCallerKey) - where TState : notnull - { - var rehydrateStates = new TState[reservedKeys.Length]; - for (var i = 0; i < reservedKeys.Length; i++) - { - rehydrateStates[i] = stateByCallerKey[reservedKeys[i]]; - } - return rehydrateStates; - } - /// Groups the produced pairs by target expiration, which InternalSetAsync applies per write. private Dictionary Entry, CacheKey CallerKey)>> GroupRehydratedByExpiration( KeyValuePair[]? produced, @@ -501,21 +634,6 @@ private async ValueTask PublishCacheSetEventsAsync(List<(CacheEntryValu return BuildBatchEntries(missOptions, missStates, probe, producedByState); } - /// Restricts the generator's output to what we asked for; the first value wins per state. - private static Dictionary SelectRequestedProduced( - KeyValuePair[]? produced, - TState[] requestStates) - where TState : notnull - { - var producedByState = new Dictionary(requestStates.Length); - var requested = new HashSet(requestStates); - foreach (var pair in (produced ?? []).Where(pair => requested.Contains(pair.Key))) - { - _ = producedByState.TryAdd(pair.Key, pair.Value); - } - return producedByState; - } - /// The write set: the answered still-missing slots, minus the nulls this cache does not store. private List> SelectEntriesToStore( CacheEntryOptions[] missOptions, @@ -601,56 +719,6 @@ private List> SelectEntriesToStore( return (states.ToArray(), keyIndexOfState.ToArray(), callerKeys.ToArray(), options.ToArray(), firstStateOfKey.ToArray()); } - private static KeyValuePair[] Project(TState[] states, int[] keyIndexOfState, T?[] values) - where TState : notnull - { - var results = new KeyValuePair[states.Length]; - for (var i = 0; i < states.Length; i++) - { - results[i] = new KeyValuePair(states[i], values[keyIndexOfState[i]]); - } - return results; - } - - public ValueTask SetAsync(CacheKey cacheKey, T? value, CachePolicy? policy, CancellationToken token = default) - { - policy ??= _defaultPolicy; - return SetCoreAsync(cacheKey, value, GetExpiration(policy), policy, token); - } - - public ValueTask SetAsync(CacheKey cacheKey, T? value, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => - SetCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); - - public ValueTask SetAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => - SetCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); - - - /// - /// One path for every provider: take the local lock, probe the local tier, let the L2 decide, - /// then populate the local tier — the reverse of SetAsync, which writes both tiers - /// unconditionally. The probe is what narrows an L2 that retains nothing, and so grants every - /// caller a win, back to one winner per process; where the L2 does arbitrate, a local hit means - /// the key was already claimed or read here, so the loss is reported without a round-trip. That - /// can cost a win the L2 would have granted, when the local copy outlived the shared one — the - /// fail-closed direction the ambiguous false already covers. A disconnected L2 answers - /// for itself (RedisCache checks its connection first) rather than being gated on - /// GetInnerCacheDisconnected, whose state also covers the broadcast transport: a dead - /// topic must not stop a healthy Redis. - /// - public ValueTask TryAddAsync(CacheKey cacheKey, T? value, CachePolicy? policy, CancellationToken token = default) - { - policy ??= _defaultPolicy; - return TryAddCoreAsync(cacheKey, value, GetExpiration(policy), policy, token); - } - - /// - public ValueTask TryAddAsync(CacheKey cacheKey, T? value, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => - TryAddCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); - - /// - public ValueTask TryAddAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => - TryAddCoreAsync(cacheKey, value, GetExpiration(expiration), policy ?? _defaultPolicy, token); - private async ValueTask TryAddCoreAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CachePolicy policy, CancellationToken token) { NotCacheableException.ThrowIfNotCacheable(); @@ -744,18 +812,6 @@ private async ValueTask TryAddUnderLocalLockAsync(CacheEntryOptions opt return true; } - public ValueTask SetAsync(KeyValuePair[] keyValues, CachePolicy? policy, CancellationToken token = default) - { - policy ??= _defaultPolicy; - return SetCoreAsync(keyValues, GetExpiration(policy), policy, token); - } - - public ValueTask SetAsync(KeyValuePair[] keyValues, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => - SetCoreAsync(keyValues, GetExpiration(expiration), policy ?? _defaultPolicy, token); - - public ValueTask SetAsync(KeyValuePair[] keyValues, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => - SetCoreAsync(keyValues, GetExpiration(expiration), policy ?? _defaultPolicy, token); - private async ValueTask SetCoreAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CachePolicy policy, CancellationToken token) { NotCacheableException.ThrowIfNotCacheable(); @@ -834,31 +890,6 @@ private async ValueTask SetCoreAsync(KeyValuePair[] keyVa return true; } - public ValueTask RemoveAsync(CacheKey cacheKey, CancellationToken token = default) - { - NotCacheableException.ThrowIfNotCacheable(); - return RemoveAsync(_entryBuilder.BuildEntryOptions(cacheKey, default, token)); - } - - public ValueTask RemoveAsync(CacheKey[] cacheKey, CancellationToken token = default) - { - NotCacheableException.ThrowIfNotCacheable(); - var options = cacheKey.Select(k => _entryBuilder.BuildEntryOptions(k, default, token)).ToArray(); - return RemoveAsync(options, token); - } - - public ValueTask RefreshAsync(CacheKey cacheKey, CachePolicy? policy, CancellationToken token = default) - { - policy ??= _defaultPolicy; - return RefreshCoreAsync(cacheKey, GetExpiration(policy), policy, token); - } - - public ValueTask RefreshAsync(CacheKey cacheKey, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => - RefreshCoreAsync(cacheKey, GetExpiration(expiration), policy ?? _defaultPolicy, token); - - public ValueTask RefreshAsync(CacheKey cacheKey, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => - RefreshCoreAsync(cacheKey, GetExpiration(expiration), policy ?? _defaultPolicy, token); - private async ValueTask RefreshCoreAsync(CacheKey cacheKey, DateTimeOffset expiration, CachePolicy policy, CancellationToken token) { NotCacheableException.ThrowIfNotCacheable(); @@ -878,40 +909,6 @@ private async ValueTask RefreshCoreAsync(CacheKey cacheKey, DateTimeOff } } - public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationToken token = default) - { - NotCacheableException.ThrowIfNotCacheable(); - var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, default, token); - try - { - return _memoryCache.TryGetValue(cacheEntryOptions.CacheKey, out _) || await _innerCache.ContainsAsync(cacheEntryOptions.CacheKey, cacheEntryOptions.Token).ConfigureAwait(false); - } - catch (Exception ex) - { - LogInnerCacheContainsError(ex, Logged(cacheKey, typeof(T))); - return false; - } - } - - public async ValueTask TimeToLiveAsync(CacheKey cacheKey, CancellationToken token = default) - { - NotCacheableException.ThrowIfNotCacheable(); - var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, default, token); - return _memoryCache.TryGetValue(cacheEntryOptions.CacheKey, out var value) - ? value?.Expiration.Subtract(_clock.GetUtcNow()) - : await _innerCache.TimeToLiveAsync(cacheEntryOptions.CacheKey, token); - } - - public async ValueTask ExpireTimeAsync(CacheKey cacheKey, CancellationToken token = default) - { - NotCacheableException.ThrowIfNotCacheable(); - var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, default, token); - - return _memoryCache.TryGetValue(cacheEntryOptions.CacheKey, out var value) - ? value?.Expiration - : await _innerCache.ExpireTimeAsync(cacheEntryOptions.CacheKey, token); - } - private async ValueTask RemoveAsync(CacheEntryOptions options) { LogClearingLocalCached(Logged(options, typeof(T))); @@ -1222,12 +1219,6 @@ private bool MemorySet(CacheEntryOptions options, T value, TimeSpan? maxExpir return _localMemorySetter.Set(options, item, typeof(T), maxExpiration); } - private readonly struct CacheEntryValue(CacheEntryOptions cacheEntry, T? value) - { - public CacheEntryOptions CacheEntry { get; init; } = cacheEntry; - public T? Value { get; init; } = value; - } - [LoggerMessage(Level = LogLevel.Debug, Message = "Cache missed. generating new {CacheKey}")] private partial void LogCacheMissed(LoggedKey cacheKey); @@ -1302,4 +1293,13 @@ private readonly struct CacheEntryValue(CacheEntryOptions cacheEntry, T? valu [LoggerMessage(Level = LogLevel.Warning, Message = "Inner cache set value for {CacheKeys}")] private partial void LogInnerCacheSetKeysError(Exception ex, LoggedKeys cacheKeys); + + /// What the batch rehydrate write needs to know about one state. + private readonly record struct RehydrateTarget(CacheEntryOptions Options, DateTimeOffset Expiration, CacheKey CallerKey); + + private readonly struct CacheEntryValue(CacheEntryOptions cacheEntry, T? value) + { + public CacheEntryOptions CacheEntry { get; init; } = cacheEntry; + public T? Value { get; init; } = value; + } } diff --git a/src/UiPath.Caching/MultilayerCacheBase.cs b/src/UiPath.Caching/MultilayerCacheBase.cs index c70a3bc5..7a7b1755 100644 --- a/src/UiPath.Caching/MultilayerCacheBase.cs +++ b/src/UiPath.Caching/MultilayerCacheBase.cs @@ -7,7 +7,6 @@ namespace UiPath.Caching; public abstract class MultilayerCacheBase : IDisposable { - private bool _disposed; protected readonly ILogger _logger; protected readonly IMemoryCache _memoryCache; protected readonly ICacheEntryFactory _cacheEntryFactory; @@ -18,16 +17,7 @@ public abstract class MultilayerCacheBase : IDisposable protected readonly IConnectionState _connectionState; protected readonly ITopicProvider _topicProvider; protected readonly bool _useLocalOnlyWhenDisconnected; - private readonly ILocalLock _localLock; - private readonly IDistributedLock _distributedLock; - private readonly IDistributedLockKeyStrategy _lockKeyStrategy; private protected readonly RehydrationCoordinator _rehydrator; - private readonly string _localLockKeyPrefix; - private readonly TimeSpan _distributedLockExpiry; - private readonly TimeSpan _distributedLockTimeout; - private readonly TimeSpan _localLockTimeout; - private readonly bool _localLockEnabled; - private readonly bool _distributedLockEnabled; private protected readonly CachePolicy _defaultPolicy; private protected readonly KeyMasker _masker; @@ -48,6 +38,16 @@ public abstract class MultilayerCacheBase : IDisposable DistributedLockExpiry = TimeSpan.FromSeconds(5), }, }; + private readonly ILocalLock _localLock; + private readonly IDistributedLock _distributedLock; + private readonly IDistributedLockKeyStrategy _lockKeyStrategy; + private readonly string _localLockKeyPrefix; + private readonly TimeSpan _distributedLockExpiry; + private readonly TimeSpan _distributedLockTimeout; + private readonly TimeSpan _localLockTimeout; + private readonly bool _localLockEnabled; + private readonly bool _distributedLockEnabled; + private bool _disposed; protected MultilayerCacheBase( string cacheName, @@ -100,30 +100,24 @@ protected MultilayerCacheBase( public string Name { get; } - /// The key as a log line should show it. Nothing is rendered unless the line is written. - private protected LoggedKey Logged(CacheKey key, Type? valueType = null) => LoggedKey.For(_masker, key, valueType); - - /// Shows the composed key but judges, and masks, the caller's own key inside it. - private protected LoggedKey Logged(CacheEntryOptions options, Type? valueType = null) => - LoggedKey.For(_masker, options.CallerKey, options.CacheKey.Name, valueType); - - /// - private protected LoggedKeys Logged(IReadOnlyCollection options, Type? valueType = null) => new(_masker, options, valueType); - - /// - private protected LoggedKey Logged(InternalHashCacheEntryOptions options, Type? valueType = null) => - LoggedKey.For(_masker, options.CallerKey, options.CacheKey.Name, valueType); - - /// - private protected LoggedKeys Logged(IReadOnlyCollection keys, Type? valueType = null) => new(_masker, keys, valueType); - - /// A key the site only has in composed form; with no caller key to judge it is masked whole. - private protected LoggedKey LoggedComposed(CacheKey composed, Type? valueType = null) => - LoggedKey.Composed(_masker, composed.Name, valueType); + protected ICachingTelemetryProvider Telemetry { get; } + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - /// - private protected LoggedKeys LoggedComposed(IReadOnlyCollection composed, Type? valueType = null) => - new(_masker, composed, valueType, composedOnly: true); + protected static TimeSpan ApplyJitter(TimeSpan duration, TimeSpan? maxJitter) + { + // TimeSpan.MaxValue means "no TTL"; jittering it would clamp it under the sentinel and back onto EXPIRE. + if (duration <= TimeSpan.Zero || duration == TimeSpan.MaxValue || maxJitter is not { } max || max <= TimeSpan.Zero) + { + return duration; + } + // Bounded under the sentinel, so the sum can neither overflow nor land on it; the clock fits it into the DateTime range. + var bonusTicks = Random.Shared.NextInt64(Math.Min(max.Ticks, TimeSpan.MaxValue.Ticks - duration.Ticks)); + return duration + new TimeSpan(bonusTicks); + } protected async ValueTask RunUnderLocksAsync( CacheKey cacheKey, @@ -189,24 +183,6 @@ protected Task InvokeFactoryAsync( CancellationToken token) => FactoryTimeout.RunAsync(factory, factoryTimeout, cacheKey, Name, Telemetry, token); - private static TimeSpan PositiveOrFallback(TimeSpan? value, TimeSpan fallback) => - value is { } v && v > TimeSpan.Zero ? v : fallback; - - private static TimeSpan NonNegativeOrFallback(TimeSpan? value, TimeSpan fallback) => - value is { } v && v >= TimeSpan.Zero ? v : fallback; - - protected static TimeSpan ApplyJitter(TimeSpan duration, TimeSpan? maxJitter) - { - // TimeSpan.MaxValue means "no TTL"; jittering it would clamp it under the sentinel and back onto EXPIRE. - if (duration <= TimeSpan.Zero || duration == TimeSpan.MaxValue || maxJitter is not { } max || max <= TimeSpan.Zero) - { - return duration; - } - // Bounded under the sentinel, so the sum can neither overflow nor land on it; the clock fits it into the DateTime range. - var bonusTicks = Random.Shared.NextInt64(Math.Min(max.Ticks, TimeSpan.MaxValue.Ticks - duration.Ticks)); - return duration + new TimeSpan(bonusTicks); - } - /// The L2 write lifetime: a caller value as-is, else jittered. protected TimeSpan ResolveWriteDuration(CachePolicy policy, TimeSpan? callerExpiration = null) => callerExpiration ?? ApplyJitter(ResolveDuration(policy), policy.JitterMaxDuration); @@ -220,6 +196,50 @@ protected TimeSpan ResolveDuration(CachePolicy policy) => ?? _multiLayerCacheOptions.DefaultExpiration ?? CachePolicy.DefaultDistributedExpiration; + protected bool GetInnerCacheDisconnected() => _useLocalOnlyWhenDisconnected && !_connectionState.IsConnected; + + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _monitor.Dispose(); + _memoryCache.Dispose(); + if (_connectionState is IDisposable connectionState) + { + connectionState.Dispose(); + } + } + _disposed = true; + } + } + + /// The key as a log line should show it. Nothing is rendered unless the line is written. + private protected LoggedKey Logged(CacheKey key, Type? valueType = null) => LoggedKey.For(_masker, key, valueType); + + /// Shows the composed key but judges, and masks, the caller's own key inside it. + private protected LoggedKey Logged(CacheEntryOptions options, Type? valueType = null) => + LoggedKey.For(_masker, options.CallerKey, options.CacheKey.Name, valueType); + + /// + private protected LoggedKeys Logged(IReadOnlyCollection options, Type? valueType = null) => new(_masker, options, valueType); + + /// + private protected LoggedKey Logged(InternalHashCacheEntryOptions options, Type? valueType = null) => + LoggedKey.For(_masker, options.CallerKey, options.CacheKey.Name, valueType); + + /// + private protected LoggedKeys Logged(IReadOnlyCollection keys, Type? valueType = null) => new(_masker, keys, valueType); + + /// A key the site only has in composed form; with no caller key to judge it is masked whole. + private protected LoggedKey LoggedComposed(CacheKey composed, Type? valueType = null) => + LoggedKey.Composed(_masker, composed.Name, valueType); + + /// + private protected LoggedKeys LoggedComposed(IReadOnlyCollection composed, Type? valueType = null) => + new(_masker, composed, valueType, composedOnly: true); + /// /// Validates a caller-supplied duration and pairs it with the deadline it implies. The write path /// needs both: the deadline for the entry options, the duration for the L1 cap and the rehydrate @@ -260,6 +280,12 @@ private protected DateTimeOffset GetExpiration(DateTimeOffset expiration, [Calle private protected ValueTask AcquireLocalLockAsync(CacheKey cacheKey, LockProfile? policyLock, CancellationToken token) => TryAcquireLocalLockAsync(cacheKey, ResolveLocalLock(policyLock).Timeout, token); + private static TimeSpan PositiveOrFallback(TimeSpan? value, TimeSpan fallback) => + value is { } v && v > TimeSpan.Zero ? v : fallback; + + private static TimeSpan NonNegativeOrFallback(TimeSpan? value, TimeSpan fallback) => + value is { } v && v >= TimeSpan.Zero ? v : fallback; + /// /// One place for the local-lock policy: a per-call wins over the /// options. It bypasses the options validators, so the timeout falls back when out of range. @@ -287,36 +313,10 @@ private protected DateTimeOffset GetExpiration(DateTimeOffset expiration, [Calle } } - protected ICachingTelemetryProvider Telemetry { get; } - - protected bool GetInnerCacheDisconnected() => _useLocalOnlyWhenDisconnected && !_connectionState.IsConnected; - private IConnectionState GetConnectionMonitor(params object[] connectionStates) { var lst = connectionStates.OfType().ToArray(); return lst.Length == 0 ? NullConnectionStateMonitor.Instance : new ConnectionStateMonitor(Telemetry, _multiLayerCacheOptions.ConnectionMonitorPeriod ?? TimeSpan.FromSeconds(5), lst); } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - _monitor.Dispose(); - _memoryCache.Dispose(); - if (_connectionState is IDisposable connectionState) - { - connectionState.Dispose(); - } - } - _disposed = true; - } - } } diff --git a/src/UiPath.Caching/MultilayerHashCache.cs b/src/UiPath.Caching/MultilayerHashCache.cs index 0fbf3700..855c3b74 100644 --- a/src/UiPath.Caching/MultilayerHashCache.cs +++ b/src/UiPath.Caching/MultilayerHashCache.cs @@ -110,83 +110,6 @@ public MultilayerHashCache( return GetOrAddInternalAsync(cacheKey, generator, writeExpiration, duration, rehydrateJitter: null, setOption ?? HashCacheSetOption.KeyReplace, policy ?? _defaultPolicy, token); } - private async ValueTask> GetOrAddInternalAsync(CacheKey cacheKey, Func>> generator, DateTimeOffset? expiration, TimeSpan effectiveDuration, TimeSpan? rehydrateJitter, HashCacheSetOption setOption, CachePolicy policy, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, expiration, setOption, token); - var cacheEntry = await GetCacheEntryAsync(cacheEntryOptions, policy).ConfigureAwait(false); - if (cacheEntry.Found) - { - TryHashRehydrate(cacheKey, cacheEntry.Expiration, cacheEntry.Value, generator, policy, effectiveDuration, rehydrateJitter); - return cacheEntry.Value ?? Empty(); - } - - var result = await RunUnderLocksAsync>>( - cacheEntryOptions.CacheKey, - () => GetCacheEntryAsync(cacheEntryOptions, policy), - e => e.Found, - ct => RunHashGeneratorAndStoreEntryAsync(cacheEntryOptions, generator, policy, ct), - token, - policyLock: policy.Lock).ConfigureAwait(false); - return result.Value ?? Empty(); - } - - private void TryHashRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpiration, IDictionary? currentValue, Func>> generator, CachePolicy policy, TimeSpan duration, TimeSpan? rehydrateJitter) - { - if (policy.RehydrateEnabled != true || policy.Rehydrate is null) - { - return; - } - if (IsNullOrEmpty(currentValue) && _multiLayerCacheOptions.CacheNullValues) - { - return; - } - if (duration <= TimeSpan.Zero || duration == TimeSpan.MaxValue) - { - return; - } - _rehydrator.TryTrigger( - originalCacheKey, - entryExpiration, - policy, - duration, - kind: "hash", - rehydrateAsync: async ct => - { - var newValue = await generator(ct).ConfigureAwait(false); - if (IsNullOrEmpty(newValue) && !_multiLayerCacheOptions.CacheNullValues) - { - return; - } - // Factory transitions to empty: preserve the original deadline so the marker doesn't get a fresh TTL window. - var rehydrateExpiration = IsNullOrEmpty(newValue) - ? entryExpiration - : _clock.ToDateTimeOffset(ApplyJitter(duration, rehydrateJitter)); - var rehydrateOptions = _entryBuilder.BuildEntryOptions(originalCacheKey, rehydrateExpiration, HashCacheSetOption.KeyReplace, ct); - var innerCacheDisconnected = GetInnerCacheDisconnected(); - var fired = innerCacheDisconnected || await _eventPublisher.CacheSetAsync(rehydrateOptions, typeof(T)).ConfigureAwait(false); - var written = fired && await InternalSetAsync(rehydrateOptions, newValue ?? Empty(), innerCacheDisconnected, policy).ConfigureAwait(false); - if (!written) - { - throw new RehydrateWriteFailedException(originalCacheKey.Name); - } - }, - entryType: typeof(T)); - } - - private async ValueTask>> RunHashGeneratorAndStoreEntryAsync(InternalHashCacheEntryOptions cacheEntryOptions, Func>> generator, CachePolicy policy, CancellationToken token) - { - LogCacheMissed(Logged(cacheEntryOptions, typeof(T))); - var ret = await InvokeFactoryAsync(cacheEntryOptions.CacheKey, generator, policy.FactoryTimeout, token).ConfigureAwait(false); - - if (!IsNullOrEmpty(ret) || _multiLayerCacheOptions.CacheNullValues) - { - var innerCacheDisconnected = GetInnerCacheDisconnected(); - await InternalSetAsync(cacheEntryOptions, ret ?? Empty(), innerCacheDisconnected, policy).ConfigureAwait(false); - } - return _cacheEntryFactory.Create>(ret ?? Empty(), cacheEntryOptions.Expiration, cacheEntryOptions.Metadata); - } - public ValueTask SetAsync(CacheKey cacheKey, IDictionary values, CachePolicy? policy, CancellationToken token = default) { policy ??= _defaultPolicy; @@ -199,31 +122,6 @@ public ValueTask SetAsync(CacheKey cacheKey, IDictionary va public ValueTask SetAsync(CacheKey cacheKey, IDictionary values, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => SetCoreAsync(cacheKey, values, GetExpiration(expiration), policy ?? _defaultPolicy, token); - private async ValueTask SetCoreAsync(CacheKey cacheKey, IDictionary values, DateTimeOffset expiration, CachePolicy policy, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - var options = _entryBuilder.BuildEntryOptions(cacheKey, expiration, token: token); - if (IsNullOrEmpty(values) && !_multiLayerCacheOptions.CacheNullValues) - { - return await RemoveAsync(options).ConfigureAwait(false); - } - - values ??= new Dictionary(); - - LogReplacingCachedKey(Logged(options, typeof(T))); - var innerCacheDisconnected = GetInnerCacheDisconnected(); - if (innerCacheDisconnected) - { - LogSettingLocalOnly(Logged(options, typeof(T))); - return await InternalSetAsync(options, values, innerCacheDisconnected, policy).ConfigureAwait(false); - } - else - { - var fired = await _eventPublisher.CacheSetAsync(options, typeof(T)).ConfigureAwait(false); - return fired && await InternalSetAsync(options, values, innerCacheDisconnected, policy).ConfigureAwait(false); - } - } - public async ValueTask SetAsync(CacheKey cacheKey, IDictionary values, HashCacheEntryOptions options, CachePolicy? policy, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -366,6 +264,114 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< } } + private static bool IsNullOrEmpty(IDictionary? value) => + value is null || value.Count == 0; + + private static ImmutableDictionary Empty() => + ImmutableDictionary.Empty; + + private async ValueTask> GetOrAddInternalAsync(CacheKey cacheKey, Func>> generator, DateTimeOffset? expiration, TimeSpan effectiveDuration, TimeSpan? rehydrateJitter, HashCacheSetOption setOption, CachePolicy policy, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + var cacheEntryOptions = _entryBuilder.BuildEntryOptions(cacheKey, expiration, setOption, token); + var cacheEntry = await GetCacheEntryAsync(cacheEntryOptions, policy).ConfigureAwait(false); + if (cacheEntry.Found) + { + TryHashRehydrate(cacheKey, cacheEntry.Expiration, cacheEntry.Value, generator, policy, effectiveDuration, rehydrateJitter); + return cacheEntry.Value ?? Empty(); + } + + var result = await RunUnderLocksAsync>>( + cacheEntryOptions.CacheKey, + () => GetCacheEntryAsync(cacheEntryOptions, policy), + e => e.Found, + ct => RunHashGeneratorAndStoreEntryAsync(cacheEntryOptions, generator, policy, ct), + token, + policyLock: policy.Lock).ConfigureAwait(false); + return result.Value ?? Empty(); + } + + private void TryHashRehydrate(CacheKey originalCacheKey, DateTimeOffset entryExpiration, IDictionary? currentValue, Func>> generator, CachePolicy policy, TimeSpan duration, TimeSpan? rehydrateJitter) + { + if (policy.RehydrateEnabled != true || policy.Rehydrate is null) + { + return; + } + if (IsNullOrEmpty(currentValue) && _multiLayerCacheOptions.CacheNullValues) + { + return; + } + if (duration <= TimeSpan.Zero || duration == TimeSpan.MaxValue) + { + return; + } + _rehydrator.TryTrigger( + originalCacheKey, + entryExpiration, + policy, + duration, + kind: "hash", + rehydrateAsync: async ct => + { + var newValue = await generator(ct).ConfigureAwait(false); + if (IsNullOrEmpty(newValue) && !_multiLayerCacheOptions.CacheNullValues) + { + return; + } + // Factory transitions to empty: preserve the original deadline so the marker doesn't get a fresh TTL window. + var rehydrateExpiration = IsNullOrEmpty(newValue) + ? entryExpiration + : _clock.ToDateTimeOffset(ApplyJitter(duration, rehydrateJitter)); + var rehydrateOptions = _entryBuilder.BuildEntryOptions(originalCacheKey, rehydrateExpiration, HashCacheSetOption.KeyReplace, ct); + var innerCacheDisconnected = GetInnerCacheDisconnected(); + var fired = innerCacheDisconnected || await _eventPublisher.CacheSetAsync(rehydrateOptions, typeof(T)).ConfigureAwait(false); + var written = fired && await InternalSetAsync(rehydrateOptions, newValue ?? Empty(), innerCacheDisconnected, policy).ConfigureAwait(false); + if (!written) + { + throw new RehydrateWriteFailedException(originalCacheKey.Name); + } + }, + entryType: typeof(T)); + } + + private async ValueTask>> RunHashGeneratorAndStoreEntryAsync(InternalHashCacheEntryOptions cacheEntryOptions, Func>> generator, CachePolicy policy, CancellationToken token) + { + LogCacheMissed(Logged(cacheEntryOptions, typeof(T))); + var ret = await InvokeFactoryAsync(cacheEntryOptions.CacheKey, generator, policy.FactoryTimeout, token).ConfigureAwait(false); + + if (!IsNullOrEmpty(ret) || _multiLayerCacheOptions.CacheNullValues) + { + var innerCacheDisconnected = GetInnerCacheDisconnected(); + await InternalSetAsync(cacheEntryOptions, ret ?? Empty(), innerCacheDisconnected, policy).ConfigureAwait(false); + } + return _cacheEntryFactory.Create>(ret ?? Empty(), cacheEntryOptions.Expiration, cacheEntryOptions.Metadata); + } + + private async ValueTask SetCoreAsync(CacheKey cacheKey, IDictionary values, DateTimeOffset expiration, CachePolicy policy, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + var options = _entryBuilder.BuildEntryOptions(cacheKey, expiration, token: token); + if (IsNullOrEmpty(values) && !_multiLayerCacheOptions.CacheNullValues) + { + return await RemoveAsync(options).ConfigureAwait(false); + } + + values ??= new Dictionary(); + + LogReplacingCachedKey(Logged(options, typeof(T))); + var innerCacheDisconnected = GetInnerCacheDisconnected(); + if (innerCacheDisconnected) + { + LogSettingLocalOnly(Logged(options, typeof(T))); + return await InternalSetAsync(options, values, innerCacheDisconnected, policy).ConfigureAwait(false); + } + else + { + var fired = await _eventPublisher.CacheSetAsync(options, typeof(T)).ConfigureAwait(false); + return fired && await InternalSetAsync(options, values, innerCacheDisconnected, policy).ConfigureAwait(false); + } + } + private async ValueTask RemoveAsync(InternalHashCacheEntryOptions options) { LogClearingLocalCached(Logged(options, typeof(T))); @@ -461,12 +467,6 @@ private async ValueTask InternalSetAsync(InternalHashCacheEntryOptions private ICacheEntry> CreateEntry(IDictionary values, InternalHashCacheEntryOptions options) => _cacheEntryFactory.Create>(values.ToImmutableDictionary(), options.Expiration, options.Metadata?.ToImmutableDictionary()); - private static bool IsNullOrEmpty(IDictionary? value) => - value is null || value.Count == 0; - - private static ImmutableDictionary Empty() => - ImmutableDictionary.Empty; - [LoggerMessage(Level = LogLevel.Debug, Message = "Cache missed. generating new {CacheKey}")] private partial void LogCacheMissed(LoggedKey cacheKey); diff --git a/src/UiPath.Caching/Redis/ClusterConfigurationReader.cs b/src/UiPath.Caching/Redis/ClusterConfigurationReader.cs new file mode 100644 index 00000000..6a2399ad --- /dev/null +++ b/src/UiPath.Caching/Redis/ClusterConfigurationReader.cs @@ -0,0 +1,17 @@ +using System.Net; + +namespace UiPath.Caching.Redis; + +[ExcludeFromCodeCoverage(Justification = "ClusterConfiguration has no public constructor; only a live cluster handshake populates it.")] +internal sealed class ClusterConfigurationReader : IClusterTopologyReader +{ + public static readonly ClusterConfigurationReader Instance = new(); + + public object? GetConfiguration(IServer server) => server.ClusterConfiguration; + + public HashSet GetMembers(object configuration) => + ((ClusterConfiguration)configuration).Nodes + .Where(node => node.EndPoint is not null && !node.IsHandshake) // the client does not dial nodes still joining, so a rebuilt connection would not retry them + .Select(node => node.EndPoint!) + .ToHashSet(); +} diff --git a/src/UiPath.Caching/Redis/ClusterMembership.cs b/src/UiPath.Caching/Redis/ClusterMembership.cs new file mode 100644 index 00000000..6cda98c1 --- /dev/null +++ b/src/UiPath.Caching/Redis/ClusterMembership.cs @@ -0,0 +1,9 @@ +using System.Net; + +namespace UiPath.Caching.Redis; + +/// What a topology refresh reported; when conclusive, a null means membership can never be judged. +internal readonly record struct ClusterMembership(bool Conclusive, HashSet? Members) +{ + public static ClusterMembership Inconclusive => default; +} diff --git a/src/UiPath.Caching/Redis/ClusterTopologyReader.cs b/src/UiPath.Caching/Redis/ClusterTopologyReader.cs index 9d9ad0e5..a67e528c 100644 --- a/src/UiPath.Caching/Redis/ClusterTopologyReader.cs +++ b/src/UiPath.Caching/Redis/ClusterTopologyReader.cs @@ -10,23 +10,3 @@ internal interface IClusterTopologyReader HashSet GetMembers(object configuration); } - -/// What a topology refresh reported; when conclusive, a null means membership can never be judged. -internal readonly record struct ClusterMembership(bool Conclusive, HashSet? Members) -{ - public static ClusterMembership Inconclusive => default; -} - -[ExcludeFromCodeCoverage(Justification = "ClusterConfiguration has no public constructor; only a live cluster handshake populates it.")] -internal sealed class ClusterConfigurationReader : IClusterTopologyReader -{ - public static readonly ClusterConfigurationReader Instance = new(); - - public object? GetConfiguration(IServer server) => server.ClusterConfiguration; - - public HashSet GetMembers(object configuration) => - ((ClusterConfiguration)configuration).Nodes - .Where(node => node.EndPoint is not null && !node.IsHandshake) // the client does not dial nodes still joining, so a rebuilt connection would not retry them - .Select(node => node.EndPoint!) - .ToHashSet(); -} diff --git a/src/UiPath.Caching/Redis/ConnectionStateMonitor.cs b/src/UiPath.Caching/Redis/ConnectionStateMonitor.cs index 13cfa559..2643c73f 100644 --- a/src/UiPath.Caching/Redis/ConnectionStateMonitor.cs +++ b/src/UiPath.Caching/Redis/ConnectionStateMonitor.cs @@ -13,8 +13,8 @@ public sealed class ConnectionStateMonitor : IConnectionState, IDisposable private const string PropConnected = "connected"; private readonly IConnectionState[] _connectionStates; - private Lazy _isConnected = default!; private readonly ICachingTelemetryProvider _telemetryProvider; + private Lazy _isConnected = default!; private Timer? _timer; private TimeSpan _monitorInterval; diff --git a/src/UiPath.Caching/Redis/IProfiledCommandProcessor.cs b/src/UiPath.Caching/Redis/IProfiledCommandProcessor.cs index 69503f1f..58926dcc 100644 --- a/src/UiPath.Caching/Redis/IProfiledCommandProcessor.cs +++ b/src/UiPath.Caching/Redis/IProfiledCommandProcessor.cs @@ -4,5 +4,5 @@ namespace UiPath.Caching.Redis; public interface IProfiledCommandProcessor { - public void Process(IProfiledCommand command, string? sessionId); + void Process(IProfiledCommand command, string? sessionId); } diff --git a/src/UiPath.Caching/Redis/IProfilingSessionCommandReader.cs b/src/UiPath.Caching/Redis/IProfilingSessionCommandReader.cs index e11eb9af..03a5fa04 100644 --- a/src/UiPath.Caching/Redis/IProfilingSessionCommandReader.cs +++ b/src/UiPath.Caching/Redis/IProfilingSessionCommandReader.cs @@ -4,5 +4,5 @@ namespace UiPath.Caching.Redis; public interface IProfilingSessionCommandReader { - public ProfileInfo Get(ProfilingSession? session); + ProfileInfo Get(ProfilingSession? session); } diff --git a/src/UiPath.Caching/Redis/IReservedRedisKeyspace.cs b/src/UiPath.Caching/Redis/IReservedRedisKeyspace.cs new file mode 100644 index 00000000..e0f0adf2 --- /dev/null +++ b/src/UiPath.Caching/Redis/IReservedRedisKeyspace.cs @@ -0,0 +1,14 @@ +namespace UiPath.Caching.Redis; + +/// A Redis keyspace a package occupies, so nothing else can be configured onto it. +public interface IReservedRedisKeyspace +{ + string Keyspace { get; } + + /// + /// Who occupies it. Also the identity a repeat reservation is matched on, so make it specific to the + /// package: "ICache", "ISetCache (UiPath.Caching.Queue)". Two packages sharing one owner string on one + /// keyspace read as the same package reserving twice. + /// + string Owner { get; } +} diff --git a/src/UiPath.Caching/Redis/PrefixRedisKeyStrategy.cs b/src/UiPath.Caching/Redis/PrefixRedisKeyStrategy.cs index 2a17ac89..edd65fb3 100644 --- a/src/UiPath.Caching/Redis/PrefixRedisKeyStrategy.cs +++ b/src/UiPath.Caching/Redis/PrefixRedisKeyStrategy.cs @@ -2,14 +2,14 @@ namespace UiPath.Caching.Redis; public class PrefixRedisKeyStrategy : IRedisKeyStrategy { - protected string Prefix { get; set; } - protected char Separator { get; set; } public PrefixRedisKeyStrategy(string prefix, char separator) { Prefix = Guard.NotNullOrWhiteSpace(prefix, nameof(prefix)).ToLowerInvariant(); Separator = char.ToLowerInvariant(Guard.NotWhiteSpace(separator, nameof(separator))); } + protected string Prefix { get; set; } + protected char Separator { get; set; } public virtual RedisKey GetRedisKey(CacheKey key) => string.Join(Separator, Prefix, key); diff --git a/src/UiPath.Caching/Redis/ProfiledCommandExtensions.cs b/src/UiPath.Caching/Redis/ProfiledCommandExtensions.cs index 482b60a4..b0daf0f7 100644 --- a/src/UiPath.Caching/Redis/ProfiledCommandExtensions.cs +++ b/src/UiPath.Caching/Redis/ProfiledCommandExtensions.cs @@ -9,6 +9,8 @@ namespace UiPath.Caching.Redis; [ExcludeFromCodeCoverage] public static class ProfiledCommandExtensions { + + internal static Lazy FetcherLazy { get; set; } = new(FetcherFactory); public static string GetCommandName(this IProfiledCommand profiledCommand) { var name = GetCommand(profiledCommand); @@ -32,8 +34,6 @@ public static string GetStatement(this IProfiledCommand profiledCommand) => _ => null, }; - internal static Lazy FetcherLazy { get; set; } = new(FetcherFactory); - private static RedisProfileFetcher FetcherFactory() { var messageType = Type.GetType("StackExchange.Redis.Message,StackExchange.Redis", false); @@ -50,7 +50,7 @@ private static RedisProfileFetcher FetcherFactory() { Message = _messageFetcher, CommandAndKey = _commandAndKeyFetcher, - ProfiledCommandType = profiledCommandType + ProfiledCommandType = profiledCommandType, }; } } @@ -93,7 +93,9 @@ private static string GetCommand(this IProfiledCommand profiledCommand) => { var fetcher = FetcherLazy.Value; if (profiledCommand.GetType() != fetcher.ProfiledCommandType || fetcher.Message == null) + { return null; + } var message = fetcher.Message.Invoke(profiledCommand); return fetcher.CommandAndKey?.Invoke(message) as string; diff --git a/src/UiPath.Caching/Redis/RedisCache.cs b/src/UiPath.Caching/Redis/RedisCache.cs index 1085fa7d..39c72dbe 100644 --- a/src/UiPath.Caching/Redis/RedisCache.cs +++ b/src/UiPath.Caching/Redis/RedisCache.cs @@ -83,15 +83,6 @@ public RedisCache( public ValueTask GetOrAddAsync(CacheKey cacheKey, Func> generator, TimeSpan expiration, CachePolicy? policy, CancellationToken token = default) => GetOrAddCoreAsync(cacheKey, generator, CallerDuration(expiration), policy, token); - private ValueTask GetOrAddCoreAsync(CacheKey cacheKey, Func> generator, TimeSpan duration, CachePolicy? policy, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - ArgumentNullException.ThrowIfNull(generator); - var redisKey = ToRedisKey(cacheKey, token); - var wrappedGenerator = WrapWithFactoryTimeout(generator, (policy ?? DefaultPolicy)?.FactoryTimeout, cacheKey); - return GetOrAddInternalAsync(cacheKey, redisKey, wrappedGenerator, duration, token); - } - /// public ValueTask[]> GetOrAddAsync(KeyValuePair[] entries, Func[]>> generator, CachePolicy? policy, CancellationToken token = default) where TState : notnull @@ -119,34 +110,6 @@ public RedisCache( return BatchGetOrAdd.RunAsync(this, entries, wrappedGenerator, (pairs, t) => SetAsync(pairs, expiration, policy, t), policy, token); } - private Func> WrapWithFactoryTimeout(Func> generator, TimeSpan? factoryTimeout, CacheKey cacheKey) - { - if (factoryTimeout is null || factoryTimeout.Value <= TimeSpan.Zero) - { - return generator; - } - return token => FactoryTimeout.RunAsync(generator, factoryTimeout, cacheKey, Name, Telemetry, token); - } - - private Func[]>> WrapBatchWithFactoryTimeout( - KeyValuePair[] entries, - Func[]>> generator, - TimeSpan? factoryTimeout) - where TState : notnull - { - if (factoryTimeout is null || factoryTimeout.Value <= TimeSpan.Zero || entries is not { Length: > 0 }) - { - return generator; - } - return (states, token) => FactoryTimeout.RunAsync( - t => generator(states, t), - factoryTimeout, - CompositeCacheKey.For(Array.ConvertAll(entries, e => e.Key)), - Name, - Telemetry, - token); - } - public ValueTask RefreshAsync(CacheKey cacheKey, CachePolicy? policy, CancellationToken token = default) => RefreshCoreAsync(cacheKey, GetExpiration(policy), token); @@ -156,47 +119,6 @@ public ValueTask RefreshAsync(CacheKey cacheKey, TimeSpan expiration, C public ValueTask RefreshAsync(CacheKey cacheKey, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => RefreshCoreAsync(cacheKey, GetExpiration(expiration), token); - private async ValueTask RefreshCoreAsync(CacheKey cacheKey, DateTimeOffset expiration, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - var redisKey = ToRedisKey(cacheKey, token); - - LogRefreshingKey(Logged(cacheKey, redisKey, typeof(T)), expiration); - var ret = false; - var operation = StartOperation(); - try - { - if (expiration == DateTimeOffset.MaxValue) - { - ret = await _write.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await Database.KeyPersistAsync(redisKey, RefreshFlags).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); - } - else - { - ret = await _write.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await Database.KeyExpireAsync(redisKey, expiration.UtcDateTime, RefreshFlags).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); - } - operation.Stop(); - } - catch (Exception ex) - { - operation.Stop(); - LogRedisCacheException(ex); - } - finally - { - operation.Track(ret); - } - - return ret; - } - public ValueTask RemoveAsync(CacheKey cacheKey, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -226,18 +148,6 @@ public ValueTask SetAsync(KeyValuePair[] keyValues, TimeS public ValueTask SetAsync(KeyValuePair[] keyValues, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => SetCoreAsync(keyValues, CallerDuration(expiration), token); - private ValueTask SetCoreAsync(CacheKey cacheKey, T? value, TimeSpan duration, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - return SetInternalAsync(ToRedisKey(cacheKey, token), value, duration, token); - } - - private ValueTask SetCoreAsync(KeyValuePair[] keyValues, TimeSpan duration, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - return SetInternalAsync(keyValues, duration, token); - } - public ValueTask TryAddAsync(CacheKey cacheKey, T? value, CachePolicy? policy, CancellationToken token = default) => TryAddCoreAsync(cacheKey, value, PolicyDuration(policy), token); @@ -247,12 +157,6 @@ public ValueTask TryAddAsync(CacheKey cacheKey, T? value, TimeSpan expi public ValueTask TryAddAsync(CacheKey cacheKey, T? value, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => TryAddCoreAsync(cacheKey, value, CallerDuration(expiration), token); - private ValueTask TryAddCoreAsync(CacheKey cacheKey, T? value, TimeSpan duration, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - return TryAddInternalAsync(cacheKey, ToRedisKey(cacheKey, token), value, duration, token); - } - public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -266,7 +170,9 @@ public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationTok { token.ThrowIfCancellationRequested(); return await Database.KeyExistsAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -293,7 +199,9 @@ public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationTok { token.ThrowIfCancellationRequested(); return await Database.KeyTimeToLiveAsync(ToRedisKey(cacheKey, token), CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -322,7 +230,9 @@ public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationTok { token.ThrowIfCancellationRequested(); return await Database.KeyExpireTimeAsync(ToRedisKey(cacheKey, token), CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } else { @@ -344,6 +254,119 @@ public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationTok return ret; } + private static (RedisKey Key, bool Hit)[] InitReads(RedisKey[] redisKeys) + { + var reads = new (RedisKey Key, bool Hit)[redisKeys.Length]; + for (int i = 0; i < redisKeys.Length; i++) + { + reads[i] = (redisKeys[i], false); + } + return reads; + } + + private static KeyValuePair[] GetDefaultValues(CacheKey[] keys) => + [.. keys.Select(k => new KeyValuePair(k, default))]; + + private Func> WrapWithFactoryTimeout(Func> generator, TimeSpan? factoryTimeout, CacheKey cacheKey) + { + if (factoryTimeout is null || factoryTimeout.Value <= TimeSpan.Zero) + { + return generator; + } + return token => FactoryTimeout.RunAsync(generator, factoryTimeout, cacheKey, Name, Telemetry, token); + } + + private Func[]>> WrapBatchWithFactoryTimeout( + KeyValuePair[] entries, + Func[]>> generator, + TimeSpan? factoryTimeout) + where TState : notnull + { + if (factoryTimeout is null || factoryTimeout.Value <= TimeSpan.Zero || entries is not { Length: > 0 }) + { + return generator; + } + return (states, token) => FactoryTimeout.RunAsync( + t => generator(states, t), + factoryTimeout, + CompositeCacheKey.For(Array.ConvertAll(entries, e => e.Key)), + Name, + Telemetry, + token); + } + + private async ValueTask RefreshCoreAsync(CacheKey cacheKey, DateTimeOffset expiration, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + var redisKey = ToRedisKey(cacheKey, token); + + LogRefreshingKey(Logged(cacheKey, redisKey, typeof(T)), expiration); + var ret = false; + var operation = StartOperation(); + try + { + if (expiration == DateTimeOffset.MaxValue) + { + ret = await _write.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await Database.KeyPersistAsync(redisKey, RefreshFlags).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); + } + else + { + ret = await _write.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await Database.KeyExpireAsync(redisKey, expiration.UtcDateTime, RefreshFlags).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); + } + operation.Stop(); + } + catch (Exception ex) + { + operation.Stop(); + LogRedisCacheException(ex); + } + finally + { + operation.Track(ret); + } + + return ret; + } + + private ValueTask SetCoreAsync(CacheKey cacheKey, T? value, TimeSpan duration, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + return SetInternalAsync(ToRedisKey(cacheKey, token), value, duration, token); + } + + private ValueTask SetCoreAsync(KeyValuePair[] keyValues, TimeSpan duration, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + return SetInternalAsync(keyValues, duration, token); + } + + private ValueTask TryAddCoreAsync(CacheKey cacheKey, T? value, TimeSpan duration, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + return TryAddInternalAsync(cacheKey, ToRedisKey(cacheKey, token), value, duration, token); + } + + private ValueTask GetOrAddCoreAsync(CacheKey cacheKey, Func> generator, TimeSpan duration, CachePolicy? policy, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + ArgumentNullException.ThrowIfNull(generator); + var redisKey = ToRedisKey(cacheKey, token); + var wrappedGenerator = WrapWithFactoryTimeout(generator, (policy ?? DefaultPolicy)?.FactoryTimeout, cacheKey); + return GetOrAddInternalAsync(cacheKey, redisKey, wrappedGenerator, duration, token); + } + private async ValueTask GetOrAddInternalAsync(CacheKey cacheKey, RedisKey redisKey, Func> generator, TimeSpan expiration, CancellationToken token) { NotCacheableException.ThrowIfNotCacheable(); @@ -407,7 +430,9 @@ _memorySerializer is { } memory { token.ThrowIfCancellationRequested(); return await Database.StringGetAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, RedisValue.Null, token).ConfigureAwait(false); + }, + RedisValue.Null, + token).ConfigureAwait(false); _auditKeySize?.Invoke(Logged(cacheKey, redisKey, typeof(T)), value); (found, deserialized) = InterpretReadResult(value); @@ -447,7 +472,9 @@ private async ValueTask SetInternalAsync(RedisKey redisKey, T? value, T { token.ThrowIfCancellationRequested(); return await Database.StringSetAsync(redisKey, RedisValue.EmptyString, expiration, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } else { @@ -464,7 +491,9 @@ private async ValueTask SetInternalAsync(RedisKey redisKey, T? value, T { token.ThrowIfCancellationRequested(); return await Database.StringSetAsync(redisKey, serialized, expiration, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } operation.Stop(); } @@ -481,48 +510,58 @@ private async ValueTask SetInternalAsync(RedisKey redisKey, T? value, T return ret; } - /// - /// SET key value EX … NX in one round-trip: Redis decides, so no probe precedes the write. - /// Safe to retry — an attempt whose reply was lost is refused by the key it just wrote, and - /// reports the same false the exception would have. - /// - private async ValueTask TryAddInternalAsync(CacheKey cacheKey, RedisKey redisKey, T? value, TimeSpan expiration, CancellationToken token) + private async ValueTask SetInternalAsync(KeyValuePair[] keyValues, TimeSpan expiration, CancellationToken token) { bool ret = default; token.ThrowIfCancellationRequested(); + var redisKeys = keyValues.Select(kv => ToRedisKey(kv.Key, token)).ToArray(); if (!IsConnected) { return false; } - var operation = StartOperation(nameof(TryAddAsync)); + var operation = StartOperation(nameof(SetAsync)); try { - var isNull = IsDefault(value); - if (expiration <= TimeSpan.Zero) - { - LogTryAddSkippedExpiredEntry(Logged(cacheKey, redisKey, typeof(T)), expiration); - } - else if (isNull && !_cacheNullValues) + ThrowIfCrossSlot(Array.ConvertAll(keyValues, kv => kv.Key), redisKeys, typeof(T), nameof(SetAsync)); + var transaction = Database.CreateTransaction(asyncState: null); + + for (var i = 0; i < keyValues.Length; i++) { - LogTryAddSkippedUnrepresentableValue(Logged(cacheKey, redisKey, typeof(T))); + var redisKey = redisKeys[i]; + var value = keyValues[i].Value; + if (IsDefault(value)) + { + if (_cacheNullValues && expiration > TimeSpan.Zero) + { + _ = transaction.StringSetAsync(redisKey, RedisValue.EmptyString, expiration, When.Always, CommandFlags.DemandMaster); + } + else + { + _ = transaction.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster); + } + } + else + { + var serialized = SerializeValue(value); + _ = transaction.StringSetAsync(redisKey, serialized, expiration, When.Always, CommandFlags.DemandMaster); + } } - else + + ret = await _write.ExecuteAsync(async token => { - var serialized = isNull ? RedisValue.EmptyString : SerializeValue(value); + token.ThrowIfCancellationRequested(); + return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); - ret = await _write.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await Database.StringSetAsync(redisKey, serialized, expiration, When.NotExists, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); - } operation.Stop(); } - // false would claim someone else owns the key, which a cancelled call never established. - catch (OperationCanceledException) when (token.IsCancellationRequested) + catch (CrossSlotKeysException) { + // A batch the caller has to fix, not a Redis failure to report as a miss. operation.Stop(); throw; } @@ -533,62 +572,56 @@ private async ValueTask TryAddInternalAsync(CacheKey cacheKey, RedisKey } finally { - operation.Track(ret); + operation.Track(ret, keyValues.Length); } return ret; } - private async ValueTask SetInternalAsync(KeyValuePair[] keyValues, TimeSpan expiration, CancellationToken token) + /// + /// SET key value EX … NX in one round-trip: Redis decides, so no probe precedes the write. + /// Safe to retry — an attempt whose reply was lost is refused by the key it just wrote, and + /// reports the same false the exception would have. + /// + private async ValueTask TryAddInternalAsync(CacheKey cacheKey, RedisKey redisKey, T? value, TimeSpan expiration, CancellationToken token) { bool ret = default; token.ThrowIfCancellationRequested(); - var redisKeys = keyValues.Select(kv => ToRedisKey(kv.Key, token)).ToArray(); if (!IsConnected) { return false; } - var operation = StartOperation(nameof(SetAsync)); + var operation = StartOperation(nameof(TryAddAsync)); try { - ThrowIfCrossSlot(Array.ConvertAll(keyValues, kv => kv.Key), redisKeys, typeof(T), nameof(SetAsync)); - var transaction = Database.CreateTransaction(asyncState: null); - - for (var i = 0; i < keyValues.Length; i++) + var isNull = IsDefault(value); + if (expiration <= TimeSpan.Zero) { - var redisKey = redisKeys[i]; - var value = keyValues[i].Value; - if (IsDefault(value)) - { - if (_cacheNullValues && expiration > TimeSpan.Zero) - { - _ = transaction.StringSetAsync(redisKey, RedisValue.EmptyString, expiration, When.Always, CommandFlags.DemandMaster); - } - else - { - _ = transaction.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster); - } - } - else - { - var serialized = SerializeValue(value); - _ = transaction.StringSetAsync(redisKey, serialized, expiration, When.Always, CommandFlags.DemandMaster); - } + LogTryAddSkippedExpiredEntry(Logged(cacheKey, redisKey, typeof(T)), expiration); } - - ret = await _write.ExecuteAsync(async token => + else if (isNull && !_cacheNullValues) { - token.ThrowIfCancellationRequested(); - return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + LogTryAddSkippedUnrepresentableValue(Logged(cacheKey, redisKey, typeof(T))); + } + else + { + var serialized = isNull ? RedisValue.EmptyString : SerializeValue(value); + ret = await _write.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await Database.StringSetAsync(redisKey, serialized, expiration, When.NotExists, CommandFlags.DemandMaster).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); + } operation.Stop(); } - catch (CrossSlotKeysException) + // false would claim someone else owns the key, which a cancelled call never established. + catch (OperationCanceledException) when (token.IsCancellationRequested) { - // A batch the caller has to fix, not a Redis failure to report as a miss. operation.Stop(); throw; } @@ -599,7 +632,7 @@ private async ValueTask SetInternalAsync(KeyValuePair[] k } finally { - operation.Track(ret, keyValues.Length); + operation.Track(ret); } return ret; @@ -617,7 +650,9 @@ private async ValueTask RemoveAsync(RedisKey redisKey, CancellationToke token.ThrowIfCancellationRequested(); await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); return true; - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -645,7 +680,9 @@ private async ValueTask RemoveAsync(CacheKey[] cacheKeys, RedisKey[] re { token.ThrowIfCancellationRequested(); return await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, -1, token).ConfigureAwait(false); + }, + -1, + token).ConfigureAwait(false); operation.Stop(); ret = response > -1; } @@ -687,7 +724,9 @@ private async ValueTask RemoveAsync(CacheKey[] cacheKeys, RedisKey[] re { token.ThrowIfCancellationRequested(); return await Database.StringGetAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, RedisValue.Null, token).ConfigureAwait(false); + }, + RedisValue.Null, + token).ConfigureAwait(false); _auditKeySize?.Invoke(Logged(cacheKey, redisKey, typeof(T)), value); (found, ret) = InterpretReadResult(value); operation.Stop(); @@ -727,7 +766,9 @@ private async ValueTask RemoveAsync(CacheKey[] cacheKeys, RedisKey[] re { token.ThrowIfCancellationRequested(); return await Database.StringGetAsync(redisKeys, CommandFlags.PreferReplica); - }, [], token).ConfigureAwait(false); + }, + [], + token).ConfigureAwait(false); if (values.Length == redisKeys.Length) { for (int i = 0; i < redisKeys.Length; i++) @@ -796,7 +837,9 @@ private async ValueTask RemoveAsync(CacheKey[] cacheKeys, RedisKey[] re { token.ThrowIfCancellationRequested(); return await transaction.ExecuteAsync(CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); if (!transactionResult) { @@ -860,7 +903,9 @@ private async ValueTask RemoveAsync(CacheKey[] cacheKeys, RedisKey[] re { token.ThrowIfCancellationRequested(); return await transaction.ExecuteAsync(CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); if (!transactionResult) { @@ -945,16 +990,6 @@ private RedisKey ToRedisKey(CacheKey cacheKey, CancellationToken token = default private ITelemetryOperation StartOperation([CallerMemberName] string methodName = "") => Telemetry.StartOperation(Name, typeof(T), methodName); - private static (RedisKey Key, bool Hit)[] InitReads(RedisKey[] redisKeys) - { - var reads = new (RedisKey Key, bool Hit)[redisKeys.Length]; - for (int i = 0; i < redisKeys.Length; i++) - { - reads[i] = (redisKeys[i], false); - } - return reads; - } - private void TrackPerKey(ITelemetryOperation operation, (RedisKey Key, bool Hit)[] reads) { var anyHit = false; @@ -975,9 +1010,6 @@ private void TrackPerKey(ITelemetryOperation operation, (RedisKey Key, bool Hit) } } - private static KeyValuePair[] GetDefaultValues(CacheKey[] keys) => - [.. keys.Select(k => new KeyValuePair(k, default))]; - private void AuditKeySize(LoggedKey key, RedisValue value) { var valueLen = value.Length(); diff --git a/src/UiPath.Caching/Redis/RedisCacheBase.cs b/src/UiPath.Caching/Redis/RedisCacheBase.cs index f479be17..ed8d4398 100644 --- a/src/UiPath.Caching/Redis/RedisCacheBase.cs +++ b/src/UiPath.Caching/Redis/RedisCacheBase.cs @@ -37,24 +37,51 @@ protected RedisCacheBase( : CommandFlags.DemandMaster | CommandFlags.FireAndForget; } - protected ICachingTelemetryProvider Telemetry { get; } - - /// The key as a log line should show it. Nothing is rendered unless the line is written. - private protected LoggedKey Logged(CacheKey key, RedisKey composed, Type? valueType = null) => - LoggedKey.For(_masker, key, composed, valueType); + public event EventHandler? OnConnectionFailed + { + add => _connectionState.OnConnectionFailed += value; + remove => _connectionState.OnConnectionFailed -= value; + } - /// - private protected LoggedKey Logged(CacheKey key, Type? valueType = null) => LoggedKey.For(_masker, key, valueType); + public event EventHandler? OnConnectionRestored + { + add => _connectionState.OnConnectionRestored += value; + remove => _connectionState.OnConnectionRestored -= value; + } - /// For the sites that only hold the composed key; it is judged, and masked, whole. - private protected LoggedKey Logged(RedisKey composed, Type? valueType = null) => - LoggedKey.Composed(_masker, composed, valueType); + public event EventHandler? OnReconnected + { + add => _connectionState.OnReconnected += value; + remove => _connectionState.OnReconnected -= value; + } - protected bool KeyReadTelemetryEnabled { get; } + public bool IsConnected => _connectionState.IsConnected; /// Flags for a standalone TTL write, shared by both caches so the option cannot be honored in one and not the other. internal CommandFlags RefreshFlags { get; } + protected ICachingTelemetryProvider Telemetry { get; } + + protected bool KeyReadTelemetryEnabled { get; } + + protected CachePolicy DefaultPolicy { get; } + + protected TimeSpan? DefaultExpiration { get; } + + protected TimeProvider Clock { get; } + + protected IDatabase Database => _redis.Database; + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// Validates a caller-supplied duration. + protected static TimeSpan CallerDuration(TimeSpan expiration, [CallerArgumentExpression(nameof(expiration))] string? paramName = null) => + CacheExpiration.ThrowIfNotPositive(expiration, paramName); + protected void TrackRead(ITelemetryOperation operation, bool hit, RedisKey key) { operation.Track(hit, 1); @@ -64,20 +91,10 @@ protected void TrackRead(ITelemetryOperation operation, bool hit, RedisKey key) } } - protected CachePolicy DefaultPolicy { get; } - - protected TimeSpan? DefaultExpiration { get; } - - protected TimeProvider Clock { get; } - /// Write duration when the call carries none: policy, then cache default, then ; never unbounded by omission. protected TimeSpan PolicyDuration(CachePolicy? policy) => policy?.DistributedExpiration ?? DefaultExpiration ?? CachePolicy.DefaultDistributedExpiration; - /// Validates a caller-supplied duration. - protected static TimeSpan CallerDuration(TimeSpan expiration, [CallerArgumentExpression(nameof(expiration))] string? paramName = null) => - CacheExpiration.ThrowIfNotPositive(expiration, paramName); - /// Validates a caller-supplied expiration and turns it into a duration from the cache's now. protected TimeSpan CallerDuration(DateTimeOffset expiration, [CallerArgumentExpression(nameof(expiration))] string? paramName = null) => CacheExpiration.ToDuration(expiration, Clock.GetUtcNow(), paramName); @@ -98,25 +115,28 @@ protected DateTimeOffset GetExpiration(TimeSpan expiration, [CallerArgumentExpre protected DateTimeOffset GetExpiration(DateTimeOffset expiration, [CallerArgumentExpression(nameof(expiration))] string? paramName = null) => CacheExpiration.ThrowIfNotFuture(expiration, Clock.GetUtcNow(), paramName); - public event EventHandler? OnConnectionFailed + protected virtual void Dispose(bool disposing) { - add => _connectionState.OnConnectionFailed += value; - remove => _connectionState.OnConnectionFailed -= value; + if (!_disposed) + { + if (disposing) + { + // Dispose managed resources + } + _disposed = true; + } } - public event EventHandler? OnConnectionRestored - { - add => _connectionState.OnConnectionRestored += value; - remove => _connectionState.OnConnectionRestored -= value; - } + /// The key as a log line should show it. Nothing is rendered unless the line is written. + private protected LoggedKey Logged(CacheKey key, RedisKey composed, Type? valueType = null) => + LoggedKey.For(_masker, key, composed, valueType); - public event EventHandler? OnReconnected - { - add => _connectionState.OnReconnected += value; - remove => _connectionState.OnReconnected -= value; - } + /// + private protected LoggedKey Logged(CacheKey key, Type? valueType = null) => LoggedKey.For(_masker, key, valueType); - public bool IsConnected => _connectionState.IsConnected; + /// For the sites that only hold the composed key; it is judged, and masked, whole. + private protected LoggedKey Logged(RedisKey composed, Type? valueType = null) => + LoggedKey.Composed(_masker, composed, valueType); /// /// Redis answers a cross-slot command with an error the caches log and report as a miss, so a batch @@ -147,24 +167,4 @@ private protected void ThrowIfCrossSlot(CacheKey[] cacheKeys, RedisKey[] redisKe "into one call per group of keys that already share a tag."); } } - - protected IDatabase Database => _redis.Database; - - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposed) - { - if (disposing) - { - // Dispose managed resources - } - _disposed = true; - } - } } diff --git a/src/UiPath.Caching/Redis/RedisCacheProvider.cs b/src/UiPath.Caching/Redis/RedisCacheProvider.cs index e345a1d0..8968a340 100644 --- a/src/UiPath.Caching/Redis/RedisCacheProvider.cs +++ b/src/UiPath.Caching/Redis/RedisCacheProvider.cs @@ -18,10 +18,6 @@ public sealed class RedisCacheProvider : ICacheProvider private readonly Lazy _cache; private readonly Lazy _hashCache; - public string Name => KnownCacheProviderNames.Redis; - - public bool Enabled { get; } - public RedisCacheProvider( IOptions redisCacheOptions, IOptions cacheOptions, @@ -49,6 +45,10 @@ public RedisCacheProvider( Enabled = _redisCacheOptions.Enabled; } + public string Name => KnownCacheProviderNames.Redis; + + public bool Enabled { get; } + public ICache CreateCache() => _cache.Value; diff --git a/src/UiPath.Caching/Redis/RedisConfigurationOptionsProvider.cs b/src/UiPath.Caching/Redis/RedisConfigurationOptionsProvider.cs index 18a96dd2..2d0e93bb 100644 --- a/src/UiPath.Caching/Redis/RedisConfigurationOptionsProvider.cs +++ b/src/UiPath.Caching/Redis/RedisConfigurationOptionsProvider.cs @@ -23,7 +23,7 @@ public ConfigurationOptions GetConfiguration() { return new ConfigurationOptions { - LoggerFactory = loggerFactory + LoggerFactory = loggerFactory, }; } diff --git a/src/UiPath.Caching/Redis/RedisConnector.cs b/src/UiPath.Caching/Redis/RedisConnector.cs index 4da411e2..5f0d124e 100644 --- a/src/UiPath.Caching/Redis/RedisConnector.cs +++ b/src/UiPath.Caching/Redis/RedisConnector.cs @@ -104,8 +104,219 @@ internal RedisConnector(ICachingTelemetryProvider telemetryProvider, [ExcludeFromCodeCoverage(Justification = "Lazy resolves through GetVersion, which is itself excluded as live-multiplexer-only.")] public Version Version => _version.Value; + public bool IsConnected + { + get + { + var lazy = _lazyCacheConnectionMultiplexer; + return lazy.IsValueCreated + && lazy.Value.IsCompletedSuccessfully + && lazy.Value.Result.IsConnected; + } + } + private IConnectionMultiplexer ConnectionMultiplexer => GetConnectionTask().GetAwaiter().GetResult(); + public EndPoint[] GetEndPoints(bool configuredOnly = false) + { + var lazy = _lazyCacheConnectionMultiplexer; + return lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully + ? lazy.Value.Result.GetEndPoints(configuredOnly) + : []; + } + + public void ForceReconnect() => ForceReconnect(_lazyCacheConnectionMultiplexer); + + public async ValueTask ConnectAsync(CancellationToken cancellationToken = default) + { + var task = GetConnectionTask(); + _ = task.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); + await task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + internal static string FormatEndPoint(EndPoint endPoint) => endPoint switch + { + IPEndPoint ip => $"{ip.Address}:{ip.Port}", + DnsEndPoint dns => $"{dns.Host}:{dns.Port}", + _ => endPoint.ToString() ?? string.Empty, + }; + + internal static bool IsHangDetected(int awaitingResponseCount, int now, int lastWrite, int writeStatus, int lastRead, int lastWriteThresholdMs, int lastReadThresholdMs) => + awaitingResponseCount > 100 + && now - lastWrite > lastWriteThresholdMs + && writeStatus == 3 + && now - lastRead > lastReadThresholdMs; + + /// Only topology-discovered endpoints can go stale; configured ones are left to StackExchange.Redis. + internal async Task ScanStaleEndpointsAsync() + { + if (_disposed || _staleScanDisabled || Volatile.Read(ref _reconnecting) > 0) + { + return; + } + + var lazy = _lazyCacheConnectionMultiplexer; + if (!lazy.IsValueCreated || !lazy.Value.IsCompletedSuccessfully) + { + return; + } + + if (Interlocked.CompareExchange(ref _staleScanRunning, 1, 0) != 0) + { + return; + } + + try + { + if (_scansToSkip > 0) + { + _scansToSkip--; + return; + } + + var stale = await FindStaleEndpointsAsync(lazy.Value.Result).ConfigureAwait(false); + _scanFailures = 0; + if (!ReferenceEquals(_lazyCacheConnectionMultiplexer, lazy)) + { + return; + } + + if (stale is null) + { + DisableStaleEndpointScan(lazy); + return; + } + + if (stale.Count == 0) + { + return; + } + + _telemetryProvider.TrackEvent( + "Redis.StaleEndpointDetected", + [ + new("EndPoints", string.Join(";", stale.Select(FormatEndPoint))), + new("Threshold", _staleEndpointThreshold.ToString()), + ]); + ForceReconnect(lazy); // the timestamps stay until a scan of the new multiplexer prunes them, so a failed rebuild is retried next scan + } + catch (Exception ex) + { + _scansToSkip = Math.Min(1 << Math.Min(++_scanFailures, 7), MaxScanBackoff); // a handshake that never completes fails every refresh; back off instead of tracking it every interval + _telemetryProvider.TrackException(ex); + } + finally + { + Interlocked.Exchange(ref _staleScanRunning, 0); + } + } + + internal async Task RefreshClusterMembershipAsync(IConnectionMultiplexer multiplexer) + { + // A non-initial reconfigure re-handshakes the configured endpoints only, so only their configuration can become current. + var configured = multiplexer.GetEndPoints(configuredOnly: true); + var candidates = multiplexer.GetServers() + .Where(server => server.IsConnected && Array.IndexOf(configured, server.EndPoint) >= 0) + .Select(server => (Server: server, Before: _topologyReader.GetConfiguration(server))) + .ToList(); + + // The client's own handshake reads CLUSTER NODES as an internal call, so AllowAdmin does not apply; false means another reconfiguration held the guard. + if (candidates.Count == 0 || !await multiplexer.ConfigureAsync().ConfigureAwait(false)) + { + return ClusterMembership.Inconclusive; + } + + var unchanged = false; + var neverConfigured = false; + foreach (var (server, before) in candidates) + { + var after = _topologyReader.GetConfiguration(server); + if (after is null) + { + neverConfigured |= before is null && server.IsConnected; + } + else if (ReferenceEquals(after, before)) + { + unchanged = true; // a landed re-read installs a new instance, so the same one means this node's refresh failed + } + else if (server.IsConnected) + { + _nullTopologyRefreshes = 0; + return new(Conclusive: true, _topologyReader.GetMembers(after)); + } + } + + if (unchanged || !neverConfigured) + { + _nullTopologyRefreshes = 0; + return ClusterMembership.Inconclusive; + } + + // A node with no configuration may have lost only the topology reply; a persistent absence means it cannot answer it. + // The streak belongs to the multiplexer it was observed on, so a rebuilt one starts over. + if (!ReferenceEquals(_nullTopologyMultiplexer, multiplexer)) + { + _nullTopologyMultiplexer = multiplexer; + _nullTopologyRefreshes = 0; + } + + return ++_nullTopologyRefreshes >= NullTopologyRefreshLimit ? new(Conclusive: true, Members: null) : ClusterMembership.Inconclusive; + } + +#pragma warning disable IDE0079 // Remove unnecessary suppression + [SuppressMessage("SonarQube", "S3011:Reflection should not be used to create instances of types", Justification = "By design")] +#pragma warning restore IDE0079 // Remove unnecessary suppression + [ExcludeFromCodeCoverage(Justification = "Reflects into StackExchange.Redis private fields (server/interactive/physical) — values only exist on a live multiplexer with established physical connections.")] + internal ReadWriteStatus? GetMasterPhysicalConnectionMetrics(IConnectionMultiplexer multiplexer) + { + if (multiplexer.GetEndPoints().Select(x => multiplexer.GetServer(x)).FirstOrDefault(x => !x.IsReplica && x.IsConnected) is not IServer master) + { + return null; + } + + try + { +#pragma warning disable CS8602 // Dereference of a possibly null reference. + var serverEndpoint = master.GetType().GetField("server", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(master); + var interactivePhysicalBridge = serverEndpoint.GetType().GetField("interactive", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(serverEndpoint); + var physicalConnection = interactivePhysicalBridge.GetType().GetField("physical", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(interactivePhysicalBridge); + var lastWriteTickCount = physicalConnection.GetType().GetField("lastWriteTickCount", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); + var writeStatus = physicalConnection.GetType().GetField("_writeStatus", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); + var lastReadTickCount = physicalConnection.GetType().GetField("lastReadTickCount", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); + var readStatus = physicalConnection.GetType().GetField("_readStatus", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); + var awaitingResponseCount = physicalConnection.GetType().GetMethod("GetSentAwaitingResponseCount", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(physicalConnection, []); +#pragma warning restore CS8602 // Dereference of a possibly null reference. + + return new ReadWriteStatus( + master.EndPoint, + Convert.ToInt32(awaitingResponseCount, CultureInfo.InvariantCulture), + Convert.ToInt32(lastWriteTickCount, CultureInfo.InvariantCulture), + Convert.ToInt32(writeStatus, CultureInfo.InvariantCulture), + Convert.ToInt32(lastReadTickCount, CultureInfo.InvariantCulture), + Convert.ToInt32(readStatus, CultureInfo.InvariantCulture)); + } + catch (Exception ex) + { + _telemetryProvider.TrackException(ex); + return null; + } + } + + [ExcludeFromCodeCoverage(Justification = "Only called from the excluded OnInternalConnection* event handlers.")] + private static KeyValuePair[] GetEventProperties(ConnectionFailedEventArgs e) => + [ + new(nameof(e.EndPoint), e.EndPoint?.ToString() ?? string.Empty), + new(nameof(e.FailureType), e.FailureType.ToString()), + new("ExceptionMessage", e.Exception?.Message ?? string.Empty), + new("ExceptionType", e.Exception?.GetType()?.FullName ?? string.Empty), + ]; + private Lazy> CreateLazyConnection() => new(() => { @@ -142,27 +353,6 @@ private Task GetConnectionTask() } } - public bool IsConnected - { - get - { - var lazy = _lazyCacheConnectionMultiplexer; - return lazy.IsValueCreated - && lazy.Value.IsCompletedSuccessfully - && lazy.Value.Result.IsConnected; - } - } - - public EndPoint[] GetEndPoints(bool configuredOnly = false) - { - var lazy = _lazyCacheConnectionMultiplexer; - return lazy.IsValueCreated && lazy.Value.IsCompletedSuccessfully - ? lazy.Value.Result.GetEndPoints(configuredOnly) - : []; - } - - public void ForceReconnect() => ForceReconnect(_lazyCacheConnectionMultiplexer); - private void ForceReconnect(Lazy> current) { if (_disposed || !ReferenceEquals(_lazyCacheConnectionMultiplexer, current)) @@ -257,13 +447,6 @@ private async Task CloseAndDisposeAsync(Task multiplexer } } - public async ValueTask ConnectAsync(CancellationToken cancellationToken = default) - { - var task = GetConnectionTask(); - _ = task.ContinueWith(static t => _ = t.Exception, CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default); - await task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - [ExcludeFromCodeCoverage(Justification = "Reads server.Version off a live IConnectionMultiplexer endpoint — needs a real Redis to exercise.")] private Version GetVersion() { @@ -310,12 +493,6 @@ Version DefaultVersion() } } - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - private void Dispose(bool disposing) { if (disposing) @@ -359,70 +536,6 @@ private void Dispose(bool disposing) } } - /// Only topology-discovered endpoints can go stale; configured ones are left to StackExchange.Redis. - internal async Task ScanStaleEndpointsAsync() - { - if (_disposed || _staleScanDisabled || Volatile.Read(ref _reconnecting) > 0) - { - return; - } - - var lazy = _lazyCacheConnectionMultiplexer; - if (!lazy.IsValueCreated || !lazy.Value.IsCompletedSuccessfully) - { - return; - } - - if (Interlocked.CompareExchange(ref _staleScanRunning, 1, 0) != 0) - { - return; - } - - try - { - if (_scansToSkip > 0) - { - _scansToSkip--; - return; - } - - var stale = await FindStaleEndpointsAsync(lazy.Value.Result).ConfigureAwait(false); - _scanFailures = 0; - if (!ReferenceEquals(_lazyCacheConnectionMultiplexer, lazy)) - { - return; - } - - if (stale is null) - { - DisableStaleEndpointScan(lazy); - return; - } - - if (stale.Count == 0) - { - return; - } - - _telemetryProvider.TrackEvent( - "Redis.StaleEndpointDetected", - [ - new("EndPoints", string.Join(";", stale.Select(FormatEndPoint))), - new("Threshold", _staleEndpointThreshold.ToString()), - ]); - ForceReconnect(lazy); // the timestamps stay until a scan of the new multiplexer prunes them, so a failed rebuild is retried next scan - } - catch (Exception ex) - { - _scansToSkip = Math.Min(1 << Math.Min(++_scanFailures, 7), MaxScanBackoff); // a handshake that never completes fails every refresh; back off instead of tracking it every interval - _telemetryProvider.TrackException(ex); - } - finally - { - Interlocked.Exchange(ref _staleScanRunning, 0); - } - } - /// Null when no connected node carries a cluster configuration, so membership can never be judged. private async Task?> FindStaleEndpointsAsync(IConnectionMultiplexer multiplexer) { @@ -505,58 +618,6 @@ private void RecordConfirmedMembers(List confirmed, long now) private bool IsRecentlyConfirmedMember(EndPoint endpoint, long now) => _memberConfirmedAt.TryGetValue(endpoint, out var confirmedAt) && _clock.GetElapsedTime(confirmedAt, now) < _staleEndpointThreshold; - internal async Task RefreshClusterMembershipAsync(IConnectionMultiplexer multiplexer) - { - // A non-initial reconfigure re-handshakes the configured endpoints only, so only their configuration can become current. - var configured = multiplexer.GetEndPoints(configuredOnly: true); - var candidates = multiplexer.GetServers() - .Where(server => server.IsConnected && Array.IndexOf(configured, server.EndPoint) >= 0) - .Select(server => (Server: server, Before: _topologyReader.GetConfiguration(server))) - .ToList(); - - // The client's own handshake reads CLUSTER NODES as an internal call, so AllowAdmin does not apply; false means another reconfiguration held the guard. - if (candidates.Count == 0 || !await multiplexer.ConfigureAsync().ConfigureAwait(false)) - { - return ClusterMembership.Inconclusive; - } - - var unchanged = false; - var neverConfigured = false; - foreach (var (server, before) in candidates) - { - var after = _topologyReader.GetConfiguration(server); - if (after is null) - { - neverConfigured |= before is null && server.IsConnected; - } - else if (ReferenceEquals(after, before)) - { - unchanged = true; // a landed re-read installs a new instance, so the same one means this node's refresh failed - } - else if (server.IsConnected) - { - _nullTopologyRefreshes = 0; - return new(Conclusive: true, _topologyReader.GetMembers(after)); - } - } - - if (unchanged || !neverConfigured) - { - _nullTopologyRefreshes = 0; - return ClusterMembership.Inconclusive; - } - - // A node with no configuration may have lost only the topology reply; a persistent absence means it cannot answer it. - // The streak belongs to the multiplexer it was observed on, so a rebuilt one starts over. - if (!ReferenceEquals(_nullTopologyMultiplexer, multiplexer)) - { - _nullTopologyMultiplexer = multiplexer; - _nullTopologyRefreshes = 0; - } - - return ++_nullTopologyRefreshes >= NullTopologyRefreshLimit ? new(Conclusive: true, Members: null) : ClusterMembership.Inconclusive; - } - private void DisableStaleEndpointScan(Lazy> judged) { lock (_swapLock) @@ -573,13 +634,6 @@ private void DisableStaleEndpointScan(Lazy> judged) _telemetryProvider.TrackEvent("Redis.StaleEndpointScanDisabled", [new("Reason", "NoClusterConfiguration")]); } - internal static string FormatEndPoint(EndPoint endPoint) => endPoint switch - { - IPEndPoint ip => $"{ip.Address}:{ip.Port}", - DnsEndPoint dns => $"{dns.Host}:{dns.Port}", - _ => endPoint.ToString() ?? string.Empty, - }; - private void TryDisposeMultiplexer(IConnectionMultiplexer multiplexer) { try @@ -598,60 +652,6 @@ private async ValueTask CreateConnectionMultiplexerAsync return ConfigureMultiplexerEvents(multiplexer); } - [ExcludeFromCodeCoverage(Justification = "Only called from the excluded OnInternalConnection* event handlers.")] - private static KeyValuePair[] GetEventProperties(ConnectionFailedEventArgs e) => - [ - new(nameof(e.EndPoint), e.EndPoint?.ToString() ?? string.Empty), - new(nameof(e.FailureType), e.FailureType.ToString()), - new("ExceptionMessage", e.Exception?.Message ?? string.Empty), - new("ExceptionType", e.Exception?.GetType()?.FullName ?? string.Empty), - ]; - -#pragma warning disable IDE0079 // Remove unnecessary suppression - [SuppressMessage("SonarQube", "S3011:Reflection should not be used to create instances of types", Justification = "By design")] -#pragma warning restore IDE0079 // Remove unnecessary suppression - [ExcludeFromCodeCoverage(Justification = "Reflects into StackExchange.Redis private fields (server/interactive/physical) — values only exist on a live multiplexer with established physical connections.")] - internal ReadWriteStatus? GetMasterPhysicalConnectionMetrics(IConnectionMultiplexer multiplexer) - { - if (multiplexer.GetEndPoints().Select(x => multiplexer.GetServer(x)).FirstOrDefault(x => !x.IsReplica && x.IsConnected) is not IServer master) - { - return null; - } - - try - { -#pragma warning disable CS8602 // Dereference of a possibly null reference. - var serverEndpoint = master.GetType().GetField("server", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(master); - var interactivePhysicalBridge = serverEndpoint.GetType().GetField("interactive", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(serverEndpoint); - var physicalConnection = interactivePhysicalBridge.GetType().GetField("physical", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(interactivePhysicalBridge); - var lastWriteTickCount = physicalConnection.GetType().GetField("lastWriteTickCount", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); - var writeStatus = physicalConnection.GetType().GetField("_writeStatus", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); - var lastReadTickCount = physicalConnection.GetType().GetField("lastReadTickCount", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); - var readStatus = physicalConnection.GetType().GetField("_readStatus", BindingFlags.NonPublic | BindingFlags.Instance).GetValue(physicalConnection); - var awaitingResponseCount = physicalConnection.GetType().GetMethod("GetSentAwaitingResponseCount", BindingFlags.NonPublic | BindingFlags.Instance).Invoke(physicalConnection, []); -#pragma warning restore CS8602 // Dereference of a possibly null reference. - - return new ReadWriteStatus( - master.EndPoint, - Convert.ToInt32(awaitingResponseCount, CultureInfo.InvariantCulture), - Convert.ToInt32(lastWriteTickCount, CultureInfo.InvariantCulture), - Convert.ToInt32(writeStatus, CultureInfo.InvariantCulture), - Convert.ToInt32(lastReadTickCount, CultureInfo.InvariantCulture), - Convert.ToInt32(readStatus, CultureInfo.InvariantCulture)); - } - catch (Exception ex) - { - _telemetryProvider.TrackException(ex); - return null; - } - } - - internal static bool IsHangDetected(int awaitingResponseCount, int now, int lastWrite, int writeStatus, int lastRead, int lastWriteThresholdMs, int lastReadThresholdMs) => - awaitingResponseCount > 100 - && now - lastWrite > lastWriteThresholdMs - && writeStatus == 3 - && now - lastRead > lastReadThresholdMs; - [ExcludeFromCodeCoverage(Justification = "Timer callback driven by hang-detection on live multiplexer metrics — depends on GetMasterPhysicalConnectionMetrics reflection output that only exists on a real Redis connection.")] private void OnHangScan() { diff --git a/src/UiPath.Caching/Redis/RedisHashCache.cs b/src/UiPath.Caching/Redis/RedisHashCache.cs index 9fe19b1f..1d258dba 100644 --- a/src/UiPath.Caching/Redis/RedisHashCache.cs +++ b/src/UiPath.Caching/Redis/RedisHashCache.cs @@ -87,119 +87,6 @@ public RedisHashCache( public ValueTask> GetOrAddAsync(CacheKey cacheKey, Func>> generator, DateTimeOffset expiration, HashCacheSetOption? setOption, CachePolicy? policy, CancellationToken token = default) => GetOrAddCoreAsync(cacheKey, generator, GetExpiration(expiration), setOption ?? HashCacheSetOption.KeyReplace, policy, token); - private async ValueTask> GetOrAddCoreAsync(CacheKey cacheKey, Func>> generator, DateTimeOffset effectiveExpiration, HashCacheSetOption setOption, CachePolicy? policy, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - ArgumentNullException.ThrowIfNull(generator); - var (found, cached) = await GetInnerWithFoundAsync(cacheKey, token).ConfigureAwait(false); - if (found) - { - return cached; - } - - LogCacheMissed(Logged(cacheKey, typeof(T))); - var wrappedGenerator = WrapWithFactoryTimeout(generator, (policy ?? DefaultPolicy)?.FactoryTimeout, cacheKey); - var ret = await wrappedGenerator(token).ConfigureAwait(false); - if (ret.Count > 0) - { - var options = new HashCacheEntryOptions(effectiveExpiration, default, default, setOption); - await SetAsync(cacheKey, ret, options, policy, token).ConfigureAwait(false); - } - else if (_cacheNullValues) - { - var options = new HashCacheEntryOptions(effectiveExpiration, default, default, setOption); - await SetEmptyMarkerAsync(cacheKey, options, policy, token).ConfigureAwait(false); - } - else - { - await RemoveAsync(cacheKey, token).ConfigureAwait(false); - } - - return ret; - } - - private Func>> WrapWithFactoryTimeout(Func>> generator, TimeSpan? factoryTimeout, CacheKey cacheKey) - { - if (factoryTimeout is null || factoryTimeout.Value <= TimeSpan.Zero) - { - return generator; - } - return token => FactoryTimeout.RunAsync(generator, factoryTimeout, cacheKey, Name, Telemetry, token); - } - - private async ValueTask<(bool Found, IDictionary Values)> GetInnerWithFoundAsync(CacheKey cacheKey, CancellationToken token) - { - var redisKey = ToRedisKey(cacheKey, token); - if (!IsConnected) - { - return (false, Empty()); - } - - var operation = StartOperation(nameof(GetOrAddAsync)); - bool found = false; - IDictionary ret = Empty(); - try - { - var hashEntries = await _read.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await Database.HashGetAllAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, [], token).ConfigureAwait(false); - - if (hashEntries.Length == 0) - { - operation.Stop(); - return (false, ret); - } - - var values = new Dictionary(); - var hasEmptyMarker = false; - var anyValue = false; - foreach (var hashEntry in hashEntries) - { - var name = hashEntry.Name.ToString(); - if (name == KnownFieldNames.MetadataKey) - { - hasEmptyMarker = hashEntry.Value.Length() == 0; - continue; - } - if (KnownFieldNames.IsSystemField(name)) - { - continue; - } - var v = hashEntry.Value; - _auditKeySize?.Invoke(Logged(cacheKey, redisKey, typeof(T)), name, v); - anyValue |= IsCacheHit(v); - values.Add(name, DeserializeField(v)); - } - ret = values; - found = anyValue || (hasEmptyMarker && _cacheNullValues); - operation.Stop(); - } - catch (Exception ex) - { - operation.Stop(); - LogRedisHashCacheException(ex); - } - finally - { - TrackRead(operation, found, redisKey); - } - - return (found, ret); - } - - private ValueTask SetEmptyMarkerAsync(CacheKey cacheKey, HashCacheEntryOptions options, CachePolicy? policy, CancellationToken token) - { - var redisKey = ToRedisKey(cacheKey, token); - RedisValue metadata = options.Metadata != null && options.Metadata.Count > 0 - ? _serializer.Serialize(options.Metadata) - : RedisValue.EmptyString; - var entries = new[] { new HashEntry(KnownFieldNames.MetadataKey, metadata) }; - var expiration = GetExpiration(options, policy); - return SetInnerAsync(redisKey, entries, HashCacheSetOption.KeyReplace, expiration, token); - } - public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -212,7 +99,9 @@ public async ValueTask ContainsAsync(CacheKey cacheKey, CancellationTok { token.ThrowIfCancellationRequested(); return await Database.KeyExistsAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -237,41 +126,6 @@ public ValueTask RefreshAsync(CacheKey cacheKey, TimeSpan expiration, C public ValueTask RefreshAsync(CacheKey cacheKey, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => RefreshCoreAsync(cacheKey, GetExpiration(expiration), token); - private async ValueTask RefreshCoreAsync(CacheKey cacheKey, DateTimeOffset localExpiration, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - var redisKey = ToRedisKey(cacheKey, token); - LogRefreshingKey(Logged(cacheKey, redisKey, typeof(T)), localExpiration); - var ret = false; - var operation = StartOperation(); - try - { - ret = localExpiration != DateTimeOffset.MaxValue - ? await _write.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await Database.KeyExpireAsync(redisKey, localExpiration.UtcDateTime, RefreshFlags).ConfigureAwait(false); - }, default, token).ConfigureAwait(false) - : await _write.ExecuteAsync(async token => - { - token.ThrowIfCancellationRequested(); - return await Database.KeyPersistAsync(redisKey, RefreshFlags).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); - operation.Stop(); - } - catch (Exception ex) - { - operation.Stop(); - LogRedisHashCacheException(ex); - } - finally - { - operation.Track(ret); - } - - return ret; - } - public async ValueTask RefreshAsync(CacheKey cacheKey, HashCacheEntryOptions options, CachePolicy? policy, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -288,7 +142,9 @@ public async ValueTask RefreshAsync(CacheKey cacheKey, HashCacheEntryOp { token.ThrowIfCancellationRequested(); return await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } else { @@ -300,7 +156,9 @@ public async ValueTask RefreshAsync(CacheKey cacheKey, HashCacheEntryOp { token.ThrowIfCancellationRequested(); return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); if (!ret) { @@ -322,38 +180,6 @@ public async ValueTask RefreshAsync(CacheKey cacheKey, HashCacheEntryOp return ret; } - private void QueueMetadataWrite(ITransaction transaction, RedisKey redisKey, IDictionary? metadata) - { - if (metadata != null) - { - if (_cacheNullValues) - { - transaction.AddCondition(Condition.KeyExists(redisKey)); - } - var hashEntries = new[] { new HashEntry(KnownFieldNames.MetadataKey, _serializer.Serialize(metadata)) }; - _ = transaction.HashSetAsync(redisKey, hashEntries, CommandFlags.DemandMaster).ConfigureAwait(false); - return; - } - if (_cacheNullValues) - { - transaction.AddCondition(Condition.KeyExists(redisKey)); - var entries = new[] { new HashEntry(KnownFieldNames.MetadataKey, RedisValue.EmptyString) }; - _ = transaction.HashSetAsync(redisKey, entries, CommandFlags.DemandMaster).ConfigureAwait(false); - return; - } - _ = transaction.HashDeleteAsync(redisKey, new RedisValue(KnownFieldNames.MetadataKey), CommandFlags.DemandMaster).ConfigureAwait(false); - } - - private static void QueueExpirationUpdate(ITransaction transaction, RedisKey redisKey, DateTimeOffset expiration) - { - if (expiration != DateTimeOffset.MaxValue) - { - _ = transaction.KeyExpireAsync(redisKey, expiration.UtcDateTime, CommandFlags.DemandMaster | CommandFlags.FireAndForget).ConfigureAwait(false); - return; - } - _ = transaction.KeyPersistAsync(redisKey, CommandFlags.DemandMaster | CommandFlags.FireAndForget).ConfigureAwait(false); - } - public async ValueTask RemoveAsync(CacheKey cacheKey, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -366,7 +192,9 @@ public async ValueTask RemoveAsync(CacheKey cacheKey, CancellationToken { token.ThrowIfCancellationRequested(); return await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -391,20 +219,6 @@ public ValueTask SetAsync(CacheKey cacheKey, IDictionary va public ValueTask SetAsync(CacheKey cacheKey, IDictionary values, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => SetCoreAsync(cacheKey, values, GetExpiration(expiration), token); - private ValueTask SetCoreAsync(CacheKey cacheKey, IDictionary values, DateTimeOffset effective, CancellationToken token) - { - NotCacheableException.ThrowIfNotCacheable(); - ValidateForWrite(values); - var redisKey = ToRedisKey(cacheKey, token); - var hashEntries = new HashEntry[values.Count]; - var i = 0; - foreach (var kv in values) - { - hashEntries[i++] = new HashEntry(kv.Key, SerializeFieldValue(kv.Value)); - } - return SetInnerAsync(redisKey, hashEntries, HashCacheSetOption.KeyReplace, effective, token); - } - public ValueTask SetAsync(CacheKey cacheKey, IDictionary values, HashCacheEntryOptions options, CachePolicy? policy, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -428,18 +242,6 @@ public ValueTask SetAsync(CacheKey cacheKey, IDictionary va return SetInnerAsync(redisKey, entries, setOption, expiration, token); } - /// Borrowed memory is safe here because every write awaits its command and the connection copies the value while writing it. - private RedisValue SerializeFieldValue(T? value) - { - if (_cacheNullValues && IsDefault(value)) - { - return RedisValue.EmptyString; - } - return _memorySerializer is { } memory - ? (RedisValue)memory.SerializeToMemory(value) - : (RedisValue)_serializer.Serialize(value); - } - public async ValueTask TimeToLiveAsync(CacheKey cacheKey, CancellationToken token = default) { NotCacheableException.ThrowIfNotCacheable(); @@ -451,7 +253,9 @@ private RedisValue SerializeFieldValue(T? value) { token.ThrowIfCancellationRequested(); return await Database.KeyTimeToLiveAsync(ToRedisKey(cacheKey, token), CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); operation.Stop(); } catch (Exception ex) @@ -480,7 +284,9 @@ private RedisValue SerializeFieldValue(T? value) { token.ThrowIfCancellationRequested(); return await Database.KeyExpireTimeAsync(ToRedisKey(cacheKey, token), CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } else { @@ -520,7 +326,9 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< { token.ThrowIfCancellationRequested(); return await Database.KeyExistsAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); if (keyExists) { if (metadata.Count > 0) @@ -535,7 +343,9 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< transaction.AddCondition(Condition.KeyExists(redisKey)); _ = transaction.HashSetAsync(redisKey, KnownFieldNames.MetadataKey, metadataValue, When.Always, CommandFlags.DemandMaster); return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } else { @@ -544,7 +354,9 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< token.ThrowIfCancellationRequested(); await Database.HashSetAsync(redisKey, KnownFieldNames.MetadataKey, metadataValue, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false); return true; - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } } else if (_cacheNullValues) @@ -556,7 +368,9 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< transaction.AddCondition(Condition.KeyExists(redisKey)); _ = transaction.HashSetAsync(redisKey, KnownFieldNames.MetadataKey, RedisValue.EmptyString, When.Always, CommandFlags.DemandMaster); return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } else { @@ -564,11 +378,229 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< { token.ThrowIfCancellationRequested(); return await Database.HashDeleteAsync(redisKey, KnownFieldNames.MetadataKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); + } + } + operation.Stop(); + + } + catch (Exception ex) + { + operation.Stop(); + LogRedisHashCacheException(ex); + } + finally + { + operation.Track(ret); + } + + return ret; + } + + private static void QueueExpirationUpdate(ITransaction transaction, RedisKey redisKey, DateTimeOffset expiration) + { + if (expiration != DateTimeOffset.MaxValue) + { + _ = transaction.KeyExpireAsync(redisKey, expiration.UtcDateTime, CommandFlags.DemandMaster | CommandFlags.FireAndForget).ConfigureAwait(false); + return; + } + _ = transaction.KeyPersistAsync(redisKey, CommandFlags.DemandMaster | CommandFlags.FireAndForget).ConfigureAwait(false); + } + + private static void ValidateForWrite(IDictionary values) + { + ArgumentNullException.ThrowIfNull(values); + ValidateFieldsForWrite(values.Keys); + } + + private static void ValidateFieldsForWrite(ICollection fields) + { + ArgumentNullException.ThrowIfNull(fields); + foreach (var key in fields) + { + ValidateFieldForWrite(key); + } + } + + private static void ValidateFieldForWrite(string field) + { + ValidateFieldShape(field); + if (KnownFieldNames.IsReserved(field)) + { + throw new ArgumentException($"Field name '{field}' follows the reserved '_word_' pattern and is reserved for system metadata (e.g. {KnownFieldNames.MetadataKey}).", nameof(field)); + } + } + + private static void ValidateFieldsForRead(ICollection fields) + { + ArgumentNullException.ThrowIfNull(fields); + foreach (var key in fields) + { + ValidateFieldForRead(key); + } + } + + private static void ValidateFieldForRead(string field) + { + ValidateFieldShape(field); + if (KnownFieldNames.IsSystemField(field)) + { + throw new ArgumentException($"Field name '{field}' is reserved for system metadata and cannot be read directly.", nameof(field)); + } + } + + private static void ValidateFieldShape(string field) + { + if (string.IsNullOrWhiteSpace(field)) + { + throw new ArgumentOutOfRangeException(nameof(field)); + } + } + + private static ImmutableDictionary Empty() => ImmutableDictionary.Empty; + + private async ValueTask> GetOrAddCoreAsync(CacheKey cacheKey, Func>> generator, DateTimeOffset effectiveExpiration, HashCacheSetOption setOption, CachePolicy? policy, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + ArgumentNullException.ThrowIfNull(generator); + var (found, cached) = await GetInnerWithFoundAsync(cacheKey, token).ConfigureAwait(false); + if (found) + { + return cached; + } + + LogCacheMissed(Logged(cacheKey, typeof(T))); + var wrappedGenerator = WrapWithFactoryTimeout(generator, (policy ?? DefaultPolicy)?.FactoryTimeout, cacheKey); + var ret = await wrappedGenerator(token).ConfigureAwait(false); + if (ret.Count > 0) + { + var options = new HashCacheEntryOptions(effectiveExpiration, default, default, setOption); + await SetAsync(cacheKey, ret, options, policy, token).ConfigureAwait(false); + } + else if (_cacheNullValues) + { + var options = new HashCacheEntryOptions(effectiveExpiration, default, default, setOption); + await SetEmptyMarkerAsync(cacheKey, options, policy, token).ConfigureAwait(false); + } + else + { + await RemoveAsync(cacheKey, token).ConfigureAwait(false); + } + + return ret; + } + + private Func>> WrapWithFactoryTimeout(Func>> generator, TimeSpan? factoryTimeout, CacheKey cacheKey) + { + if (factoryTimeout is null || factoryTimeout.Value <= TimeSpan.Zero) + { + return generator; + } + return token => FactoryTimeout.RunAsync(generator, factoryTimeout, cacheKey, Name, Telemetry, token); + } + + private async ValueTask<(bool Found, IDictionary Values)> GetInnerWithFoundAsync(CacheKey cacheKey, CancellationToken token) + { + var redisKey = ToRedisKey(cacheKey, token); + if (!IsConnected) + { + return (false, Empty()); + } + + var operation = StartOperation(nameof(GetOrAddAsync)); + bool found = false; + IDictionary ret = Empty(); + try + { + var hashEntries = await _read.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await Database.HashGetAllAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); + }, + [], + token).ConfigureAwait(false); + + if (hashEntries.Length == 0) + { + operation.Stop(); + return (false, ret); + } + + var values = new Dictionary(); + var hasEmptyMarker = false; + var anyValue = false; + foreach (var hashEntry in hashEntries) + { + var name = hashEntry.Name.ToString(); + if (name == KnownFieldNames.MetadataKey) + { + hasEmptyMarker = hashEntry.Value.Length() == 0; + continue; + } + if (KnownFieldNames.IsSystemField(name)) + { + continue; } + var v = hashEntry.Value; + _auditKeySize?.Invoke(Logged(cacheKey, redisKey, typeof(T)), name, v); + anyValue |= IsCacheHit(v); + values.Add(name, DeserializeField(v)); } + ret = values; + found = anyValue || (hasEmptyMarker && _cacheNullValues); operation.Stop(); + } + catch (Exception ex) + { + operation.Stop(); + LogRedisHashCacheException(ex); + } + finally + { + TrackRead(operation, found, redisKey); + } + + return (found, ret); + } + private ValueTask SetEmptyMarkerAsync(CacheKey cacheKey, HashCacheEntryOptions options, CachePolicy? policy, CancellationToken token) + { + var redisKey = ToRedisKey(cacheKey, token); + RedisValue metadata = options.Metadata != null && options.Metadata.Count > 0 + ? _serializer.Serialize(options.Metadata) + : RedisValue.EmptyString; + var entries = new[] { new HashEntry(KnownFieldNames.MetadataKey, metadata) }; + var expiration = GetExpiration(options, policy); + return SetInnerAsync(redisKey, entries, HashCacheSetOption.KeyReplace, expiration, token); + } + + private async ValueTask RefreshCoreAsync(CacheKey cacheKey, DateTimeOffset localExpiration, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + var redisKey = ToRedisKey(cacheKey, token); + LogRefreshingKey(Logged(cacheKey, redisKey, typeof(T)), localExpiration); + var ret = false; + var operation = StartOperation(); + try + { + ret = localExpiration != DateTimeOffset.MaxValue + ? await _write.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await Database.KeyExpireAsync(redisKey, localExpiration.UtcDateTime, RefreshFlags).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false) + : await _write.ExecuteAsync(async token => + { + token.ThrowIfCancellationRequested(); + return await Database.KeyPersistAsync(redisKey, RefreshFlags).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); + operation.Stop(); } catch (Exception ex) { @@ -583,6 +615,54 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< return ret; } + private void QueueMetadataWrite(ITransaction transaction, RedisKey redisKey, IDictionary? metadata) + { + if (metadata != null) + { + if (_cacheNullValues) + { + transaction.AddCondition(Condition.KeyExists(redisKey)); + } + var hashEntries = new[] { new HashEntry(KnownFieldNames.MetadataKey, _serializer.Serialize(metadata)) }; + _ = transaction.HashSetAsync(redisKey, hashEntries, CommandFlags.DemandMaster).ConfigureAwait(false); + return; + } + if (_cacheNullValues) + { + transaction.AddCondition(Condition.KeyExists(redisKey)); + var entries = new[] { new HashEntry(KnownFieldNames.MetadataKey, RedisValue.EmptyString) }; + _ = transaction.HashSetAsync(redisKey, entries, CommandFlags.DemandMaster).ConfigureAwait(false); + return; + } + _ = transaction.HashDeleteAsync(redisKey, new RedisValue(KnownFieldNames.MetadataKey), CommandFlags.DemandMaster).ConfigureAwait(false); + } + + private ValueTask SetCoreAsync(CacheKey cacheKey, IDictionary values, DateTimeOffset effective, CancellationToken token) + { + NotCacheableException.ThrowIfNotCacheable(); + ValidateForWrite(values); + var redisKey = ToRedisKey(cacheKey, token); + var hashEntries = new HashEntry[values.Count]; + var i = 0; + foreach (var kv in values) + { + hashEntries[i++] = new HashEntry(kv.Key, SerializeFieldValue(kv.Value)); + } + return SetInnerAsync(redisKey, hashEntries, HashCacheSetOption.KeyReplace, effective, token); + } + + /// Borrowed memory is safe here because every write awaits its command and the connection copies the value while writing it. + private RedisValue SerializeFieldValue(T? value) + { + if (_cacheNullValues && IsDefault(value)) + { + return RedisValue.EmptyString; + } + return _memorySerializer is { } memory + ? (RedisValue)memory.SerializeToMemory(value) + : (RedisValue)_serializer.Serialize(value); + } + private async ValueTask>> GetCacheEntryForKeyAsync(CacheKey cacheKey, RedisKey redisKey, CancellationToken token) { token.ThrowIfCancellationRequested(); @@ -603,7 +683,9 @@ public async ValueTask SetMetadataAsync(CacheKey cacheKey, IDictionary< { token.ThrowIfCancellationRequested(); return await transaction.ExecuteAsync(CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); if (!transactionResult) { throw new InvalidOperationException("Unable to read from redis"); @@ -702,7 +784,9 @@ private bool IsCacheHit(RedisValue value) => { token.ThrowIfCancellationRequested(); return await Database.HashGetAsync(redisKey, field, CommandFlags.PreferReplica).ConfigureAwait(false); - }, RedisValue.Null, token).ConfigureAwait(false); + }, + RedisValue.Null, + token).ConfigureAwait(false); _auditKeySize?.Invoke(Logged(cacheKey, redisKey, typeof(T)), field, value); ret = DeserializeField(value); found = IsCacheHit(value); @@ -744,7 +828,9 @@ private bool IsCacheHit(RedisValue value) => { token.ThrowIfCancellationRequested(); return await Database.HashGetAsync(redisKey, fields.Select(k => (RedisValue)k).ToArray(), CommandFlags.PreferReplica).ConfigureAwait(false); - },[], token).ConfigureAwait(false); + }, + [], + token).ConfigureAwait(false); if (values.Length == fields.Length) { var dict = new Dictionary(fields.Length); @@ -792,7 +878,9 @@ private bool IsCacheHit(RedisValue value) => { token.ThrowIfCancellationRequested(); return await Database.HashGetAllAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, [], token).ConfigureAwait(false); + }, + [], + token).ConfigureAwait(false); if (hashEntries.Length > 0) { var values = new Dictionary(); @@ -849,7 +937,9 @@ private bool IsCacheHit(RedisValue value) => { token.ThrowIfCancellationRequested(); return await Database.KeyExistsAsync(redisKey, CommandFlags.PreferReplica).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); if (keyExists) { ret = await GetCacheEntryForKeyAsync(cacheKey, redisKey, token).ConfigureAwait(false); @@ -894,7 +984,9 @@ private async ValueTask SetInnerAsync(RedisKey redisKey, HashEntry[] ha { token.ThrowIfCancellationRequested(); return await Database.KeyDeleteAsync(redisKey, CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); } else { @@ -919,7 +1011,9 @@ private async ValueTask SetInnerAsync(RedisKey redisKey, HashEntry[] ha { token.ThrowIfCancellationRequested(); return await transaction.ExecuteAsync(CommandFlags.DemandMaster).ConfigureAwait(false); - }, default, token).ConfigureAwait(false); + }, + default, + token).ConfigureAwait(false); if (!ret) { LogRedisTransactionFailed(); @@ -968,58 +1062,6 @@ private void AuditKeySize(LoggedKey key, string field, RedisValue value) } } - private static void ValidateForWrite(IDictionary values) - { - ArgumentNullException.ThrowIfNull(values); - ValidateFieldsForWrite(values.Keys); - } - - private static void ValidateFieldsForWrite(ICollection fields) - { - ArgumentNullException.ThrowIfNull(fields); - foreach (var key in fields) - { - ValidateFieldForWrite(key); - } - } - - private static void ValidateFieldForWrite(string field) - { - ValidateFieldShape(field); - if (KnownFieldNames.IsReserved(field)) - { - throw new ArgumentException($"Field name '{field}' follows the reserved '_word_' pattern and is reserved for system metadata (e.g. {KnownFieldNames.MetadataKey}).", nameof(field)); - } - } - - private static void ValidateFieldsForRead(ICollection fields) - { - ArgumentNullException.ThrowIfNull(fields); - foreach (var key in fields) - { - ValidateFieldForRead(key); - } - } - - private static void ValidateFieldForRead(string field) - { - ValidateFieldShape(field); - if (KnownFieldNames.IsSystemField(field)) - { - throw new ArgumentException($"Field name '{field}' is reserved for system metadata and cannot be read directly.", nameof(field)); - } - } - - private static void ValidateFieldShape(string field) - { - if (string.IsNullOrWhiteSpace(field)) - { - throw new ArgumentOutOfRangeException(nameof(field)); - } - } - - private static ImmutableDictionary Empty() => ImmutableDictionary.Empty; - private ICacheEntry> Default() => _cacheEntryFactory.Create(Empty(), DateTimeOffset.MinValue); [LoggerMessage(Level = LogLevel.Debug, Message = "Cache missed. generating new {CacheKey}")] diff --git a/src/UiPath.Caching/Redis/RedisPlannedMaintenance.cs b/src/UiPath.Caching/Redis/RedisPlannedMaintenance.cs index 4159f3b0..5b551beb 100644 --- a/src/UiPath.Caching/Redis/RedisPlannedMaintenance.cs +++ b/src/UiPath.Caching/Redis/RedisPlannedMaintenance.cs @@ -46,6 +46,12 @@ public RedisPlannedMaintenance( _connectionRetryDelay = retryDelay <= TimeSpan.Zero ? TimeSpan.FromSeconds(1) : retryDelay; } + public bool InProgress + { + get => Interlocked.Read(ref _maintenanceInProgress) == 1; + set => Interlocked.Exchange(ref _maintenanceInProgress, value ? 1 : 0); + } + public Task StartAsync(CancellationToken cancellationToken) { _ = Task.Run(() => InitializeAsync(_cancellationTokenSource.Token), _cancellationTokenSource.Token); @@ -58,20 +64,6 @@ public Task StopAsync(CancellationToken cancellationToken) return Task.CompletedTask; } - private void Cancel() - { - if (Interlocked.Exchange(ref _stopped, 1) == 0) - { - _cancellationTokenSource.Cancel(); - } - } - - public bool InProgress - { - get => Interlocked.Read(ref _maintenanceInProgress) == 1; - set => Interlocked.Exchange(ref _maintenanceInProgress, value ? 1 : 0); - } - public void Dispose() { IConnectionMultiplexer? multiplexer; @@ -96,6 +88,14 @@ public void Dispose() } } + private void Cancel() + { + if (Interlocked.Exchange(ref _stopped, 1) == 0) + { + _cancellationTokenSource.Cancel(); + } + } + private async Task InitializeAsync(CancellationToken cancellationToken) { var attempt = 0; diff --git a/src/UiPath.Caching/Redis/RedisProfiler.cs b/src/UiPath.Caching/Redis/RedisProfiler.cs index 2cae89f9..dbf3a268 100644 --- a/src/UiPath.Caching/Redis/RedisProfiler.cs +++ b/src/UiPath.Caching/Redis/RedisProfiler.cs @@ -114,6 +114,23 @@ public IDisposable CreateSession(string? sessionId) } } + public void Dispose() + { + if (_disposed) + { + return; + } + _disposed = true; + _timer?.Dispose(); + if (_flushWorker is not null) + { + _flushWorker.Wait(_options.ProfilerFlushInterval.Multiply(10)); + _flushWorker.Dispose(); + } + DrainAllSessions(); + GC.SuppressFinalize(this); + } + private async Task FlushSessionsAsync() { while (!_disposed && await _timer!.WaitForNextTickAsync()) @@ -184,22 +201,11 @@ private void Process(ProfileInfo profileInfo) } - public void Dispose() - { - if (_disposed) - { - return; - } - _disposed = true; - _timer?.Dispose(); - if (_flushWorker is not null) - { - _flushWorker.Wait(_options.ProfilerFlushInterval.Multiply(10)); - _flushWorker.Dispose(); - } - DrainAllSessions(); - GC.SuppressFinalize(this); - } + [LoggerMessage(Level = LogLevel.Trace, Message = "Disposing profiling session {SessionId}. Count:{Count}")] + private partial void LogDisposingProfilingSession(string? sessionId, int count); + + [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to process redis profiled commands in {SessionId}")] + private partial void LogFailedToProcessProfiledCommands(Exception ex, string? sessionId); private sealed record RedisProfileEntry { @@ -214,10 +220,4 @@ public RedisProfileEntry(ProfilingSession session, DateTimeOffset created) public int Count { get; set; } } - - [LoggerMessage(Level = LogLevel.Trace, Message = "Disposing profiling session {SessionId}. Count:{Count}")] - private partial void LogDisposingProfilingSession(string? sessionId, int count); - - [LoggerMessage(Level = LogLevel.Warning, Message = "Failed to process redis profiled commands in {SessionId}")] - private partial void LogFailedToProcessProfiledCommands(Exception ex, string? sessionId); } diff --git a/src/UiPath.Caching/Redis/ReservedRedisKeyspace.cs b/src/UiPath.Caching/Redis/ReservedRedisKeyspace.cs index b247fa45..d92f9ccb 100644 --- a/src/UiPath.Caching/Redis/ReservedRedisKeyspace.cs +++ b/src/UiPath.Caching/Redis/ReservedRedisKeyspace.cs @@ -1,18 +1,5 @@ namespace UiPath.Caching.Redis; -/// A Redis keyspace a package occupies, so nothing else can be configured onto it. -public interface IReservedRedisKeyspace -{ - string Keyspace { get; } - - /// - /// Who occupies it. Also the identity a repeat reservation is matched on, so make it specific to the - /// package: "ICache", "ISetCache (UiPath.Caching.Queue)". Two packages sharing one owner string on one - /// keyspace read as the same package reserving twice. - /// - string Owner { get; } -} - internal sealed class ReservedRedisKeyspace : IReservedRedisKeyspace { public ReservedRedisKeyspace(string keyspace, string owner, bool isDistributedCache = false) @@ -22,6 +9,13 @@ public ReservedRedisKeyspace(string keyspace, string owner, bool isDistributedCa IsDistributedCache = isDistributedCache; } + public string Keyspace { get; } + + public string Owner { get; } + + /// Set only by AddDistributedCache: is display text a caller could imitate. + public bool IsDistributedCache { get; } + /// /// A keyspace fills one segment between AppShortName and the rest of the key. Letters and digits /// only, so it cannot span segments under any punctuation separator: "x" and "x:y" would @@ -35,146 +29,4 @@ private static string SingleSegment(string keyspace) => : throw new ArgumentException( $"Redis keyspace '{keyspace}' must be a single segment of letters and digits. It fills the slot between AppShortName and the rest of the key, so anything else risks overlapping another keyspace.", nameof(keyspace)); - - public string Keyspace { get; } - - public string Owner { get; } - - /// Set only by AddDistributedCache: is display text a caller could imitate. - public bool IsDistributedCache { get; } -} - -public static class ReservedRedisKeyspaceExtensions -{ - private const string DistributedCacheOwner = "AddDistributedCache"; - - /// - /// Declares a keyspace this package occupies; call it from the package's own builder extension. The - /// keyspace is one segment of letters and digits. A different owner already on it is rejected, a repeat - /// by the same owner is ignored so a package can reserve from several registration methods, and keyspace - /// comparison is case-insensitive because the key strategy lowercases. is - /// matched exactly, so qualify it with the package name. - /// - public static IServiceCollection ReserveRedisKeyspace(this IServiceCollection services, string keyspace, string owner) - { - ArgumentNullException.ThrowIfNull(services); - ArgumentNullException.ThrowIfNull(keyspace); - ArgumentNullException.ThrowIfNull(owner); - return services.Reserve(new ReservedRedisKeyspace(keyspace, owner)); - } - - /// - /// Reserves the keyspace AddDistributedCache takes with its differentiator. The reservation is - /// marked by type rather than by owner, which is display text a caller could imitate. - /// - internal static IServiceCollection ReserveDistributedCacheRedisKeyspace(this IServiceCollection services, string differentiator) => - services.Reserve(new ReservedRedisKeyspace(differentiator, DistributedCacheOwner, isDistributedCache: true)); - - private static IServiceCollection Reserve(this IServiceCollection services, ReservedRedisKeyspace reservation) - { - // Materialized, so every descriptor is validated even when the match is found early. - var reserved = services.ReservedRedisKeyspaces().ToList(); - if (reserved.FirstOrDefault(r => r.Covers(reservation.Keyspace)) is { } taken) - { - if (string.Equals(taken.Owner, reservation.Owner, StringComparison.Ordinal) - && taken.IsDistributedCache() == reservation.IsDistributedCache) - { - return services; - } - - throw new InvalidOperationException(KeyspaceCollisionMessage(taken, reservation)); - } - - services.AddSingleton(reservation); - return services; - } - - /// - /// The declarations made so far. Only instance registrations are visible before the container exists, - /// so a reservation added any other way would be enforced at resolution but not at registration. - /// - internal static IEnumerable ReservedRedisKeyspaces(this IServiceCollection services) - { - foreach (var descriptor in services.Where(d => d.ServiceType == typeof(IReservedRedisKeyspace))) - { - yield return descriptor.ImplementationInstance as ReservedRedisKeyspace ?? throw Unsupported(); - } - } - - /// Reservations that reached the container any other way, which the registration-time check never saw. - internal static IEnumerable Validated(this IEnumerable reserved) => - reserved.Select(r => r as ReservedRedisKeyspace ?? throw Unsupported()); - - /// - /// The separator is only known once options bind, so this is where one keyspace containing another is - /// caught: with separator 'x', "a" and "axb" render the same key from "bxk" - /// and "k". - /// - internal static IReadOnlyList ValidatedFor( - this IEnumerable reserved, char separator) - { - var all = reserved.Validated().ToList(); - foreach (var outer in all) - { - var nested = all.Find(other => !ReferenceEquals(other, outer) - && other.Keyspace.StartsWith(outer.Keyspace + separator, StringComparison.OrdinalIgnoreCase)); - if (nested is not null) - { - throw new InvalidOperationException( - $"The Redis keyspace '{nested.Keyspace}' reserved by {nested.Owner} sits inside '{outer.Keyspace}' reserved by {outer.Owner} under the configured separator '{separator}', so the two would render the same key from different cache keys. Choose keyspaces that do not nest, or a separator that does not appear in them."); - } - } - - return all; - } - - private static InvalidOperationException Unsupported() => - new("An IReservedRedisKeyspace is registered by something other than IServiceCollection.ReserveRedisKeyspace(keyspace, owner) — from a factory, an implementation type, or a custom implementation. Reservations made that way are invisible to the registration-time keyspace check, so they cannot be checked against each other. Register it with ReserveRedisKeyspace instead."); - - internal static bool Covers(this IReservedRedisKeyspace reserved, string keyspace) => - string.Equals(reserved.Keyspace, keyspace, StringComparison.OrdinalIgnoreCase); - - internal static bool IsDistributedCache(this IReservedRedisKeyspace reserved) => - reserved is ReservedRedisKeyspace { IsDistributedCache: true }; - - /// Whether AddDistributedCache already took a keyspace, enabled or not. - internal static bool HasDistributedCacheRedisKeyspace(this IServiceCollection services) => - services.ReservedRedisKeyspaces().ToList().Exists(reserved => reserved.IsDistributedCache()); - - /// Names the differentiator when the distributed cache is one of the two sides, since that is the value the reader can change. - private static string KeyspaceCollisionMessage(IReservedRedisKeyspace taken, ReservedRedisKeyspace wanted) - { - if (wanted.IsDistributedCache) - { - return $"UiPathDistributedCacheOptions.RedisKeyDifferentiator '{wanted.Keyspace}' is the Redis keyspace reserved by {taken.Owner}, which would put the distributed cache in the same Redis keyspace. Choose another value."; - } - - if (taken.IsDistributedCache()) - { - return $"UiPathDistributedCacheOptions.RedisKeyDifferentiator '{taken.Keyspace}' is the Redis keyspace reserved by {wanted.Owner}, which would put the distributed cache in the same Redis keyspace. Choose another value."; - } - - return $"{wanted.Owner} reserves the Redis keyspace '{wanted.Keyspace}', which {taken.Owner} already occupies. Two packages cannot share one keyspace: their keys would collide on Redis."; - } -} - -/// -/// Runs the separator-dependent keyspace checks when is first resolved, so they -/// hold for package-to-package layouts and not only where the distributed cache probes them. -/// -internal sealed class ReservedRedisKeyspaceValidator(IEnumerable reserved) - : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, CacheOptions options) - { - try - { - reserved.ValidatedFor(options.Separator); - return ValidateOptionsResult.Success; - } - catch (InvalidOperationException ex) - { - return ValidateOptionsResult.Fail(ex.Message); - } - } } diff --git a/src/UiPath.Caching/Redis/ReservedRedisKeyspaceExtensions.cs b/src/UiPath.Caching/Redis/ReservedRedisKeyspaceExtensions.cs new file mode 100644 index 00000000..49c1eb7c --- /dev/null +++ b/src/UiPath.Caching/Redis/ReservedRedisKeyspaceExtensions.cs @@ -0,0 +1,115 @@ +namespace UiPath.Caching.Redis; + +public static class ReservedRedisKeyspaceExtensions +{ + private const string DistributedCacheOwner = "AddDistributedCache"; + + /// + /// Declares a keyspace this package occupies; call it from the package's own builder extension. The + /// keyspace is one segment of letters and digits. A different owner already on it is rejected, a repeat + /// by the same owner is ignored so a package can reserve from several registration methods, and keyspace + /// comparison is case-insensitive because the key strategy lowercases. is + /// matched exactly, so qualify it with the package name. + /// + public static IServiceCollection ReserveRedisKeyspace(this IServiceCollection services, string keyspace, string owner) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(keyspace); + ArgumentNullException.ThrowIfNull(owner); + return services.Reserve(new ReservedRedisKeyspace(keyspace, owner)); + } + + /// + /// Reserves the keyspace AddDistributedCache takes with its differentiator. The reservation is + /// marked by type rather than by owner, which is display text a caller could imitate. + /// + internal static IServiceCollection ReserveDistributedCacheRedisKeyspace(this IServiceCollection services, string differentiator) => + services.Reserve(new ReservedRedisKeyspace(differentiator, DistributedCacheOwner, isDistributedCache: true)); + + /// + /// The declarations made so far. Only instance registrations are visible before the container exists, + /// so a reservation added any other way would be enforced at resolution but not at registration. + /// + internal static IEnumerable ReservedRedisKeyspaces(this IServiceCollection services) + { + foreach (var descriptor in services.Where(d => d.ServiceType == typeof(IReservedRedisKeyspace))) + { + yield return descriptor.ImplementationInstance as ReservedRedisKeyspace ?? throw Unsupported(); + } + } + + /// Reservations that reached the container any other way, which the registration-time check never saw. + internal static IEnumerable Validated(this IEnumerable reserved) => + reserved.Select(r => r as ReservedRedisKeyspace ?? throw Unsupported()); + + /// + /// The separator is only known once options bind, so this is where one keyspace containing another is + /// caught: with separator 'x', "a" and "axb" render the same key from "bxk" + /// and "k". + /// + internal static IReadOnlyList ValidatedFor( + this IEnumerable reserved, char separator) + { + var all = reserved.Validated().ToList(); + foreach (var outer in all) + { + var nested = all.Find(other => !ReferenceEquals(other, outer) + && other.Keyspace.StartsWith(outer.Keyspace + separator, StringComparison.OrdinalIgnoreCase)); + if (nested is not null) + { + throw new InvalidOperationException( + $"The Redis keyspace '{nested.Keyspace}' reserved by {nested.Owner} sits inside '{outer.Keyspace}' reserved by {outer.Owner} under the configured separator '{separator}', so the two would render the same key from different cache keys. Choose keyspaces that do not nest, or a separator that does not appear in them."); + } + } + + return all; + } + + internal static bool Covers(this IReservedRedisKeyspace reserved, string keyspace) => + string.Equals(reserved.Keyspace, keyspace, StringComparison.OrdinalIgnoreCase); + + internal static bool IsDistributedCache(this IReservedRedisKeyspace reserved) => + reserved is ReservedRedisKeyspace { IsDistributedCache: true }; + + /// Whether AddDistributedCache already took a keyspace, enabled or not. + internal static bool HasDistributedCacheRedisKeyspace(this IServiceCollection services) => + services.ReservedRedisKeyspaces().ToList().Exists(reserved => reserved.IsDistributedCache()); + + private static IServiceCollection Reserve(this IServiceCollection services, ReservedRedisKeyspace reservation) + { + // Materialized, so every descriptor is validated even when the match is found early. + var reserved = services.ReservedRedisKeyspaces().ToList(); + if (reserved.FirstOrDefault(r => r.Covers(reservation.Keyspace)) is { } taken) + { + if (string.Equals(taken.Owner, reservation.Owner, StringComparison.Ordinal) + && taken.IsDistributedCache() == reservation.IsDistributedCache) + { + return services; + } + + throw new InvalidOperationException(KeyspaceCollisionMessage(taken, reservation)); + } + + services.AddSingleton(reservation); + return services; + } + + private static InvalidOperationException Unsupported() => + new("An IReservedRedisKeyspace is registered by something other than IServiceCollection.ReserveRedisKeyspace(keyspace, owner) — from a factory, an implementation type, or a custom implementation. Reservations made that way are invisible to the registration-time keyspace check, so they cannot be checked against each other. Register it with ReserveRedisKeyspace instead."); + + /// Names the differentiator when the distributed cache is one of the two sides, since that is the value the reader can change. + private static string KeyspaceCollisionMessage(IReservedRedisKeyspace taken, ReservedRedisKeyspace wanted) + { + if (wanted.IsDistributedCache) + { + return $"UiPathDistributedCacheOptions.RedisKeyDifferentiator '{wanted.Keyspace}' is the Redis keyspace reserved by {taken.Owner}, which would put the distributed cache in the same Redis keyspace. Choose another value."; + } + + if (taken.IsDistributedCache()) + { + return $"UiPathDistributedCacheOptions.RedisKeyDifferentiator '{taken.Keyspace}' is the Redis keyspace reserved by {wanted.Owner}, which would put the distributed cache in the same Redis keyspace. Choose another value."; + } + + return $"{wanted.Owner} reserves the Redis keyspace '{wanted.Keyspace}', which {taken.Owner} already occupies. Two packages cannot share one keyspace: their keys would collide on Redis."; + } +} diff --git a/src/UiPath.Caching/Redis/ReservedRedisKeyspaceValidator.cs b/src/UiPath.Caching/Redis/ReservedRedisKeyspaceValidator.cs new file mode 100644 index 00000000..33f7d330 --- /dev/null +++ b/src/UiPath.Caching/Redis/ReservedRedisKeyspaceValidator.cs @@ -0,0 +1,22 @@ +namespace UiPath.Caching.Redis; + +/// +/// Runs the separator-dependent keyspace checks when is first resolved, so they +/// hold for package-to-package layouts and not only where the distributed cache probes them. +/// +internal sealed class ReservedRedisKeyspaceValidator(IEnumerable reserved) + : IValidateOptions +{ + public ValidateOptionsResult Validate(string? name, CacheOptions options) + { + try + { + reserved.ValidatedFor(options.Separator); + return ValidateOptionsResult.Success; + } + catch (InvalidOperationException ex) + { + return ValidateOptionsResult.Fail(ex.Message); + } + } +} diff --git a/src/UiPath.Caching/Redis/StreamId.cs b/src/UiPath.Caching/Redis/StreamId.cs index 5995092c..e969ee33 100644 --- a/src/UiPath.Caching/Redis/StreamId.cs +++ b/src/UiPath.Caching/Redis/StreamId.cs @@ -1,9 +1,6 @@ namespace UiPath.Caching.Redis; internal struct StreamId { - public long Timestamp { get; private set; } - public long Sequence { get; private set; } - public bool Valid { get; private set; } public static readonly StreamId Invalid = new StreamId(); public StreamId() { @@ -17,4 +14,7 @@ public StreamId(long timestamp, long sequence) Sequence = sequence; Valid = true; } + public long Timestamp { get; private set; } + public long Sequence { get; private set; } + public bool Valid { get; private set; } } diff --git a/src/UiPath.Caching/RehydrateWriteFailedException.cs b/src/UiPath.Caching/RehydrateWriteFailedException.cs index d2834856..0171c318 100644 --- a/src/UiPath.Caching/RehydrateWriteFailedException.cs +++ b/src/UiPath.Caching/RehydrateWriteFailedException.cs @@ -1,6 +1,7 @@ namespace UiPath.Caching; -[SuppressMessage("Major Code Smell", "S3871:Exception types should be \"public\"", +[SuppressMessage("Major Code Smell", + "S3871:Exception types should be \"public\"", Justification = "Internal control-flow signal thrown by the rehydrate lambda and caught by RehydrationCoordinator's exception handler to drive cache.rehydrate.failed telemetry. Never escapes the assembly; making it public would expose an implementation detail with no caller value.")] internal sealed class RehydrateWriteFailedException(string cacheKey) : Exception($"Inner cache write failed during rehydrate for key '{cacheKey}'.") diff --git a/src/UiPath.Caching/RehydrationCoordinator.cs b/src/UiPath.Caching/RehydrationCoordinator.cs index 222ea6c9..7c17051d 100644 --- a/src/UiPath.Caching/RehydrationCoordinator.cs +++ b/src/UiPath.Caching/RehydrationCoordinator.cs @@ -14,7 +14,6 @@ internal sealed class RehydrationCoordinator( ILogger logger, KeyMasker? masker = null) { - private readonly KeyMasker _masker = masker ?? KeyMasker.Off; private const string EventTriggered = "cache.rehydrate.triggered"; private const string EventSucceeded = "cache.rehydrate.succeeded"; @@ -32,6 +31,7 @@ internal sealed class RehydrationCoordinator( private const string LockKeyPrefix = "rehydrate:"; private const double MinTimeoutMs = 1000.0; private const int MaxBackoffShift = 30; + private readonly KeyMasker _masker = masker ?? KeyMasker.Off; private readonly ConcurrentDictionary _inFlight = new(StringComparer.Ordinal); // Timestamp lets us evict entries older than MaxCooldown so the dictionary can't grow // unbounded for high-cardinality caches where a key fails once and never recurs. @@ -62,6 +62,26 @@ public bool TryTriggerBatch( Type? entryType = null) => TryTriggerCore(candidates, policy, duration, kind, rehydrateAsync, entryType); + private static TimeSpan SafeAdd(TimeSpan a, TimeSpan b) => + a.Ticks > TimeSpan.MaxValue.Ticks - b.Ticks ? TimeSpan.MaxValue : a + b; + + private static TimeSpan ComputeCooldown(TimeSpan baseCooldown, TimeSpan maxCooldown, int failureCount) + { + if (failureCount <= 0) + { + return TimeSpan.FromTicks(Math.Min(baseCooldown.Ticks, maxCooldown.Ticks)); + } + var shift = Math.Min(failureCount, MaxBackoffShift); + var multiplier = 1L << shift; + var ticks = baseCooldown.Ticks; + if (ticks > 0 && multiplier > long.MaxValue / ticks) + { + return maxCooldown; + } + var product = ticks * multiplier; + return TimeSpan.FromTicks(Math.Min(product, maxCooldown.Ticks)); + } + private bool TryTriggerCore( IReadOnlyList<(CacheKey Key, DateTimeOffset Expiration)> candidates, CachePolicy? policy, @@ -300,9 +320,6 @@ private async ValueTask DisposeLockQuietlyAsync(IAsyncDisposable handle, CacheKe } } - private static TimeSpan SafeAdd(TimeSpan a, TimeSpan b) => - a.Ticks > TimeSpan.MaxValue.Ticks - b.Ticks ? TimeSpan.MaxValue : a + b; - private int ReadFailureCount(string key, TimeSpan maxCooldown) { if (!_failureCount.TryGetValue(key, out var entry)) @@ -327,21 +344,4 @@ private void IncrementFailureCount(string key) static (_, current, ts) => (current.Count + 1, ts), nowTicks); } - - private static TimeSpan ComputeCooldown(TimeSpan baseCooldown, TimeSpan maxCooldown, int failureCount) - { - if (failureCount <= 0) - { - return TimeSpan.FromTicks(Math.Min(baseCooldown.Ticks, maxCooldown.Ticks)); - } - var shift = Math.Min(failureCount, MaxBackoffShift); - var multiplier = 1L << shift; - var ticks = baseCooldown.Ticks; - if (ticks > 0 && multiplier > long.MaxValue / ticks) - { - return maxCooldown; - } - var product = ticks * multiplier; - return TimeSpan.FromTicks(Math.Min(product, maxCooldown.Ticks)); - } } diff --git a/src/UiPath.Caching/TypeExtensions.cs b/src/UiPath.Caching/TypeExtensions.cs index 6dca49a8..d248ddd2 100644 --- a/src/UiPath.Caching/TypeExtensions.cs +++ b/src/UiPath.Caching/TypeExtensions.cs @@ -21,7 +21,7 @@ public static class TypeExtensions {typeof(ulong), "ulong"}, {typeof(short), "short"}, {typeof(ushort), "ushort"}, - {typeof(string), "string"} + {typeof(string), "string"}, }.ToFrozenDictionary(); public static List GetAllPublicConstantValues(this Type type) => diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 00000000..8f17fbf1 --- /dev/null +++ b/stylecop.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "maintainabilityRules": { + "topLevelTypes": [ "class", "interface", "struct", "enum", "delegate" ] + } + } +} diff --git a/tests/UiPath.Caching.Tests/AutoFixtureCreator.cs b/tests/UiPath.Caching.Tests/AutoFixtureCreator.cs index c447abad..58eaf060 100644 --- a/tests/UiPath.Caching.Tests/AutoFixtureCreator.cs +++ b/tests/UiPath.Caching.Tests/AutoFixtureCreator.cs @@ -85,7 +85,7 @@ public object Create(object request, ISpecimenContext context) return new InMemoryRedisCacheOptions { LocalMaxExpiration = TimeSpan.FromMinutes(3), - LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30) + LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30), }; } if (type == typeof(InMemoryCacheOptions)) @@ -93,7 +93,7 @@ public object Create(object request, ISpecimenContext context) return new InMemoryCacheOptions { LocalMaxExpiration = TimeSpan.FromMinutes(3), - LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30) + LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30), }; } if (typeof(IMultilayerCacheOptions).IsAssignableFrom(type)) @@ -101,7 +101,7 @@ public object Create(object request, ISpecimenContext context) return new InMemoryRedisCacheOptions { LocalMaxExpiration = TimeSpan.FromMinutes(3), - LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30) + LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30), }; } } diff --git a/tests/UiPath.Caching.Tests/Azure/AzureEntraConnectionConfiguratorTests.cs b/tests/UiPath.Caching.Tests/Azure/AzureEntraConnectionConfiguratorTests.cs index a378754d..78544d50 100644 --- a/tests/UiPath.Caching.Tests/Azure/AzureEntraConnectionConfiguratorTests.cs +++ b/tests/UiPath.Caching.Tests/Azure/AzureEntraConnectionConfiguratorTests.cs @@ -7,30 +7,6 @@ namespace UiPath.Caching.Tests.Azure; public class AzureEntraConnectionConfiguratorTests { - private sealed class CapturingConfigurator : AzureEntraConnectionConfigurator - { - public CapturingConfigurator(IOptions options) - : base(options) - { - } - - public CapturingConfigurator(IOptions options, IAzureEntraCredentialFactory credentialFactory) - : base(options, credentialFactory) - { - } - - public bool Applied { get; private set; } - public bool SslAtApplyTime { get; private set; } - public TokenCredential? CapturedCredential { get; private set; } - - protected override Task ApplyAzureAuthenticationAsync(ConfigurationOptions configuration, TokenCredential credential) - { - Applied = true; - SslAtApplyTime = configuration.Ssl; - CapturedCredential = credential; - return Task.CompletedTask; - } - } private enum CredentialFactoryCall { @@ -40,52 +16,6 @@ private enum CredentialFactoryCall ManagedIdentityOptions, } - private sealed class CapturingCredentialFactory(TokenCredential credential) : IAzureEntraCredentialFactory - { - public CredentialFactoryCall Call { get; private set; } - public int CallCount { get; private set; } - public string? ClientId { get; private set; } - public ManagedIdentityCredentialOptions? Options { get; private set; } - - public TokenCredential CreateDefaultCredential() - { - CallCount++; - Call = CredentialFactoryCall.Default; - return credential; - } - - public TokenCredential CreateManagedIdentityCredential(string clientId) - { - CallCount++; - Call = CredentialFactoryCall.ManagedIdentityClientId; - ClientId = clientId; - return credential; - } - - public TokenCredential CreateManagedIdentityCredential(ManagedIdentityCredentialOptions options) - { - CallCount++; - Call = CredentialFactoryCall.ManagedIdentityOptions; - Options = options; - return credential; - } - } - - private sealed class FakeCredential : TokenCredential - { - public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) => default; - - public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) => default; - } - - private static CapturingConfigurator Create(AzureEntraOptions options, IAzureEntraCredentialFactory? credentialFactory = null) - { - var configuredOptions = Options.Create(options); - return credentialFactory is null - ? new CapturingConfigurator(configuredOptions) - : new CapturingConfigurator(configuredOptions, credentialFactory); - } - [Fact] public async Task ConfigureAsync_EnablesSsl_ByDefault() { @@ -201,7 +131,8 @@ public async Task ConfigureAsync_ManagedIdentityOptions_TakePrecedenceOverClient { ManagedIdentityClientId = "managed-identity-client-id", ManagedIdentityOptions = options, - }, factory); + }, + factory); await sut.ConfigureAsync(new ConfigurationOptions(), TestContext.Current.CancellationToken); @@ -233,4 +164,74 @@ public void AzureEntraCredentialFactory_CreatesExpectedCredentialTypes() factory.CreateManagedIdentityCredential("managed-identity-client-id").Should().BeOfType(); factory.CreateManagedIdentityCredential(new ManagedIdentityCredentialOptions()).Should().BeOfType(); } + + private static CapturingConfigurator Create(AzureEntraOptions options, IAzureEntraCredentialFactory? credentialFactory = null) + { + var configuredOptions = Options.Create(options); + return credentialFactory is null + ? new CapturingConfigurator(configuredOptions) + : new CapturingConfigurator(configuredOptions, credentialFactory); + } + private sealed class CapturingConfigurator : AzureEntraConnectionConfigurator + { + public CapturingConfigurator(IOptions options) + : base(options) + { + } + + public CapturingConfigurator(IOptions options, IAzureEntraCredentialFactory credentialFactory) + : base(options, credentialFactory) + { + } + + public bool Applied { get; private set; } + public bool SslAtApplyTime { get; private set; } + public TokenCredential? CapturedCredential { get; private set; } + + protected override Task ApplyAzureAuthenticationAsync(ConfigurationOptions configuration, TokenCredential credential) + { + Applied = true; + SslAtApplyTime = configuration.Ssl; + CapturedCredential = credential; + return Task.CompletedTask; + } + } + + private sealed class CapturingCredentialFactory(TokenCredential credential) : IAzureEntraCredentialFactory + { + public CredentialFactoryCall Call { get; private set; } + public int CallCount { get; private set; } + public string? ClientId { get; private set; } + public ManagedIdentityCredentialOptions? Options { get; private set; } + + public TokenCredential CreateDefaultCredential() + { + CallCount++; + Call = CredentialFactoryCall.Default; + return credential; + } + + public TokenCredential CreateManagedIdentityCredential(string clientId) + { + CallCount++; + Call = CredentialFactoryCall.ManagedIdentityClientId; + ClientId = clientId; + return credential; + } + + public TokenCredential CreateManagedIdentityCredential(ManagedIdentityCredentialOptions options) + { + CallCount++; + Call = CredentialFactoryCall.ManagedIdentityOptions; + Options = options; + return credential; + } + } + + private sealed class FakeCredential : TokenCredential + { + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) => default; + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) => default; + } } diff --git a/tests/UiPath.Caching.Tests/BatchGetOrAddTests.cs b/tests/UiPath.Caching.Tests/BatchGetOrAddTests.cs index 385e4e4a..e5091cb3 100644 --- a/tests/UiPath.Caching.Tests/BatchGetOrAddTests.cs +++ b/tests/UiPath.Caching.Tests/BatchGetOrAddTests.cs @@ -12,21 +12,6 @@ public class BatchGetOrAddTests(ITestContextAccessor testContextAccessor) private static readonly long[] States3Then1Then2 = [3L, 1L, 2L]; private static readonly string?[] Gen1Twice = ["gen:1", "gen:1"]; - private static KeyValuePair[] Entries(params long[] ids) => - ids.Select(id => new KeyValuePair((CacheKey)$"user:{id}", id)).ToArray(); - - private static Func[]>> Generator( - List observed, Func? produce = null, params long[] omit) - { - produce ??= id => "gen:" + id; - return (ids, _) => - { - observed.Add(ids); - return Task.FromResult(ids.Where(id => !omit.Contains(id)) - .Select(id => new KeyValuePair(id, produce(id))).ToArray()); - }; - } - [Fact] public async Task Generator_receives_states_not_keys() { @@ -248,4 +233,19 @@ public async Task NullCache_runs_the_generator_and_returns_its_values() observed.Single().Should().Equal(States1And2); result.Select(r => r.Value).Should().Equal("gen:1", "gen:2"); } + + private static KeyValuePair[] Entries(params long[] ids) => + ids.Select(id => new KeyValuePair((CacheKey)$"user:{id}", id)).ToArray(); + + private static Func[]>> Generator( + List observed, Func? produce = null, params long[] omit) + { + produce ??= id => "gen:" + id; + return (ids, _) => + { + observed.Add(ids); + return Task.FromResult(ids.Where(id => !omit.Contains(id)) + .Select(id => new KeyValuePair(id, produce(id))).ToArray()); + }; + } } diff --git a/tests/UiPath.Caching.Tests/Broadcast/ChangeTokenTests.cs b/tests/UiPath.Caching.Tests/Broadcast/ChangeTokenTests.cs index 44846e17..0560ef2e 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/ChangeTokenTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/ChangeTokenTests.cs @@ -8,6 +8,8 @@ public class ChangeTokenTests : IAsyncLifetime { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly RecordingTelemetryProvider _telemetryProvider = new(); + private string _key = default!; private TopicKey _topicKey = default!; private ITopic _topic = default!; @@ -15,11 +17,48 @@ public class ChangeTokenTests : IAsyncLifetime private Uri? _source = null; private ISet? _acceptedEvents = null; private SystemJsonByteSerializerProxy _serializer = default!; - - private readonly RecordingTelemetryProvider _telemetryProvider = new(); private ChangeToken? _sut = null; private ChangeToken Sut => _sut ??= new ChangeToken(_key, _topic, _source, _serializer, _fixture.Freeze>>(), _telemetryProvider, _acceptedEvents); + public static IEnumerable InvalidEvents() => new TestCacheEvent[] + { + new TestCacheEvent + { + Id = Guid.NewGuid().ToString(), + Data = new CacheEventData(Guid.NewGuid().ToString()), + }, + new TestCacheEvent + { + Id = Guid.NewGuid().ToString(), + Source = new Uri("urn:machine"), + Data = new CacheEventData(Guid.NewGuid().ToString()), + }, + new TestCacheEvent + { + Id = Guid.NewGuid().ToString(), + Source = new Uri("urn:machine"), + Data = null, + }, + new TestCacheEvent + { + Id = Guid.NewGuid().ToString(), + Source = new Uri("urn:machine"), + }, + new TestCacheEvent + { + Id = Guid.NewGuid().ToString(), + Source = new Uri("urn:machine"), + Data = new CacheEventData(Guid.NewGuid().ToString()), + }, + + new TestCacheEvent + { + Id = Guid.NewGuid().ToString(), + Source = new Uri("urn:machine"), + Data = null, + }, + }.Select(cv => new object[] { cv }); + [Fact] public void Verify_ActiveChangeCallbacks() { @@ -70,7 +109,7 @@ public void OnNext_Changes_when_corect_key(string? source, bool hasChanged) { Id = Guid.NewGuid().ToString(), Source = new Uri("urn:machine"), - Data = new CacheEventData(_key) + Data = new CacheEventData(_key), }; Sut.OnNext(cloudEVent); Sut.HasChanged.Should().Be(hasChanged); @@ -123,7 +162,7 @@ public void AcceptedEvents() Id = Guid.NewGuid().ToString(), Source = new Uri("urn:machine"), Data = new CacheEventData(_key), - Type = _fixture.Create() + Type = _fixture.Create(), }); Sut.HasChanged.Should().BeFalse(); @@ -132,7 +171,7 @@ public void AcceptedEvents() Id = Guid.NewGuid().ToString(), Source = new Uri("urn:machine"), Data = new CacheEventData(_key), - Type = _acceptedEvents.First() + Type = _acceptedEvents.First(), }); Sut.HasChanged.Should().BeTrue(); } @@ -150,7 +189,7 @@ public void Events_with_extended_data() ["_metadata_"] = new Dictionary { ["key"] = _key, - } + }, }), Type = _fixture.Create(), @@ -175,7 +214,7 @@ public void Accepted_event_emits_telemetry_on_read() ["_metadata_"] = new Dictionary { ["key"] = _key, - } + }, }), Type = _fixture.Create(), @@ -202,7 +241,7 @@ public void Accepted_event_emits_telemetry_on_unaccepted_read() ["_metadata_"] = new Dictionary { ["key"] = _key, - } + }, }), Type = _fixture.Create(), @@ -239,43 +278,4 @@ public ValueTask InitializeAsync() _fixture.Inject>(_formatter); return ValueTask.CompletedTask; } - - public static IEnumerable InvalidEvents() => new TestCacheEvent[] - { - new TestCacheEvent - { - Id = Guid.NewGuid().ToString(), - Data = new CacheEventData(Guid.NewGuid().ToString()) - }, - new TestCacheEvent - { - Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine"), - Data = new CacheEventData(Guid.NewGuid().ToString()) - }, - new TestCacheEvent - { - Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine"), - Data = null - }, - new TestCacheEvent - { - Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine"), - }, - new TestCacheEvent - { - Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine"), - Data = new CacheEventData(Guid.NewGuid().ToString()) - }, - - new TestCacheEvent - { - Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine"), - Data = null - } - }.Select(cv => new object[] { cv }); } diff --git a/tests/UiPath.Caching.Tests/Broadcast/ConnectionStateMonitorTests.cs b/tests/UiPath.Caching.Tests/Broadcast/ConnectionStateMonitorTests.cs index bc8e0505..cbc6b7fd 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/ConnectionStateMonitorTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/ConnectionStateMonitorTests.cs @@ -41,20 +41,6 @@ public async Task Works_as_expected_when_no_events() await WaitUntilAsync(() => Sut.IsConnected, TimeSpan.FromSeconds(30), testContextAccessor.Current.CancellationToken); } - private static async Task WaitUntilAsync(Func predicate, TimeSpan timeout, CancellationToken token) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - while (sw.Elapsed < timeout) - { - if (predicate()) - { - return; - } - await Task.Delay(20, token); - } - throw new TimeoutException($"Predicate was not satisfied within {timeout}."); - } - [Fact] public void Dispose_works_as_expected() { @@ -74,5 +60,19 @@ public ValueTask InitializeAsync() _fixture.Inject(TimeSpan.FromMilliseconds(100)); return ValueTask.CompletedTask; } + + private static async Task WaitUntilAsync(Func predicate, TimeSpan timeout, CancellationToken token) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (predicate()) + { + return; + } + await Task.Delay(20, token); + } + throw new TimeoutException($"Predicate was not satisfied within {timeout}."); + } } diff --git a/tests/UiPath.Caching.Tests/Broadcast/KeyedSubjectTests.cs b/tests/UiPath.Caching.Tests/Broadcast/KeyedSubjectTests.cs index 5e915a6e..4385045e 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/KeyedSubjectTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/KeyedSubjectTests.cs @@ -296,7 +296,8 @@ public async Task Concurrent_subscribe_unsubscribe_is_thread_safe() subs.Add(_sut.Subscribe(new TestKeyedObserver($"key{i}"))); } return subs; - }, ct); + }, + ct); var dispatchTask = OnDedicatedThread(() => { @@ -305,7 +306,8 @@ public async Task Concurrent_subscribe_unsubscribe_is_thread_safe() { _sut.OnNext(CreateEvent($"key{i}")); } - }, ct); + }, + ct); var unsubscribeTask = OnDedicatedThread(() => { @@ -315,9 +317,13 @@ public async Task Concurrent_subscribe_unsubscribe_is_thread_safe() var sub = _sut.Subscribe(new TestKeyedObserver($"temp{i}")); sub.Dispose(); } - }, ct); + }, + ct); await Task.WhenAll(subscribeTask, dispatchTask, unsubscribeTask); + + (await subscribeTask).Should().HaveCount(iterations, + "every concurrent Subscribe must return a live subscription, none lost to a racing dispatch or unsubscribe"); } [Fact] @@ -353,7 +359,8 @@ public async Task Concurrent_subscribe_unsubscribe_on_same_key_does_not_lose_obs var final_obs = new TestKeyedObserver(key); var final_sub = _sut.Subscribe(final_obs); survivors.Add((final_obs, final_sub)); - }, CancellationToken.None)).ToArray(); + }, + CancellationToken.None)).ToArray(); await Task.WhenAll(tasks); @@ -386,7 +393,8 @@ public async Task Unsubscribe_does_not_remove_other_observers_on_same_key() var sub = _sut.Subscribe(obs); sub.Dispose(); } - }, ct); + }, + ct); var dispatchTask = Task.Run(() => { @@ -395,7 +403,8 @@ public async Task Unsubscribe_does_not_remove_other_observers_on_same_key() _sut.OnNext(CreateEvent(key)); Interlocked.Increment(ref eventCount); } - }, ct); + }, + ct); await Task.WhenAll(churnTask, dispatchTask); @@ -433,7 +442,8 @@ public async Task Concurrent_subscribe_and_dispatch_delivers_to_all_subscribed_o _sut.Subscribe(obs); lateObservers.Add(obs); } - }, ct); + }, + ct); var dispatchTask = OnDedicatedThread(() => { @@ -442,7 +452,8 @@ public async Task Concurrent_subscribe_and_dispatch_delivers_to_all_subscribed_o { _sut.OnNext(CreateEvent(key)); } - }, ct); + }, + ct); await Task.WhenAll(lateTask, dispatchTask); @@ -472,9 +483,13 @@ public async Task High_volume_subscribe_unsubscribe_completes_without_error() _sut.OnNext(CreateEvent(key)); sub.Dispose(); } - }, CancellationToken.None)).ToArray(); + }, + CancellationToken.None)).ToArray(); await Task.WhenAll(tasks); + + tasks.Should().OnlyContain(t => t.IsCompletedSuccessfully, + "no worker may fault under concurrent subscribe, dispatch and dispose"); } private static ICacheEvent CreateEvent(string? key) @@ -483,10 +498,17 @@ private static ICacheEvent CreateEvent(string? key) { Id = Guid.NewGuid().ToString(), Source = new Uri("urn:test"), - Data = key != null ? new CacheEventData(key) : null + Data = key != null ? new CacheEventData(key) : null, }; } + // Barrier participants on pool threads park the pool until it grows, starving every other test's timers and continuations. + private static Task OnDedicatedThread(Action action, CancellationToken token) => + Task.Factory.StartNew(action, token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + + private static Task OnDedicatedThread(Func function, CancellationToken token) => + Task.Factory.StartNew(function, token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + private sealed class TestKeyedObserver(string key) : IKeyedObserver { public string Key { get; } = key; @@ -524,13 +546,6 @@ public void OnError(Exception error) { } public void OnCompleted() => throw new InvalidOperationException("boom on completed"); } - // Barrier participants on pool threads park the pool until it grows, starving every other test's timers and continuations. - private static Task OnDedicatedThread(Action action, CancellationToken token) => - Task.Factory.StartNew(action, token, TaskCreationOptions.LongRunning, TaskScheduler.Default); - - private static Task OnDedicatedThread(Func function, CancellationToken token) => - Task.Factory.StartNew(function, token, TaskCreationOptions.LongRunning, TaskScheduler.Default); - private sealed class ThrowingBroadcastObserver : IObserver { public void OnNext(ICacheEvent value) => throw new InvalidOperationException("boom on next"); diff --git a/tests/UiPath.Caching.Tests/Broadcast/OptionsCloneTests.cs b/tests/UiPath.Caching.Tests/Broadcast/OptionsCloneTests.cs index b1598c45..85992803 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/OptionsCloneTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/OptionsCloneTests.cs @@ -55,14 +55,46 @@ private static void AssertAllSettablePropertiesEqual(object expected, object act private static object? MakeNonDefault(Type type, object? current) { - if (type == typeof(bool)) return !(bool)(current ?? false); - if (type == typeof(bool?)) return !(((bool?)current) ?? false); - if (type == typeof(int)) return ((int?)current ?? 0) + 17; - if (type == typeof(long)) return ((long?)current ?? 0L) + 31L; - if (type == typeof(long?)) return (((long?)current) ?? 0L) + 31L; - if (type == typeof(string)) return Guid.NewGuid().ToString("N"); - if (type == typeof(TimeSpan)) return ((TimeSpan?)current ?? TimeSpan.Zero) + TimeSpan.FromSeconds(7); - if (type == typeof(TimeSpan?)) return (((TimeSpan?)current) ?? TimeSpan.Zero) + TimeSpan.FromSeconds(7); + if (type == typeof(bool)) + { + return !(bool)(current ?? false); + } + + if (type == typeof(bool?)) + { + return !(((bool?)current) ?? false); + } + + if (type == typeof(int)) + { + return ((int?)current ?? 0) + 17; + } + + if (type == typeof(long)) + { + return ((long?)current ?? 0L) + 31L; + } + + if (type == typeof(long?)) + { + return (((long?)current) ?? 0L) + 31L; + } + + if (type == typeof(string)) + { + return Guid.NewGuid().ToString("N"); + } + + if (type == typeof(TimeSpan)) + { + return ((TimeSpan?)current ?? TimeSpan.Zero) + TimeSpan.FromSeconds(7); + } + + if (type == typeof(TimeSpan?)) + { + return (((TimeSpan?)current) ?? TimeSpan.Zero) + TimeSpan.FromSeconds(7); + } + if (type == typeof(System.Threading.Channels.BoundedChannelFullMode)) { var cur = (System.Threading.Channels.BoundedChannelFullMode)(current ?? System.Threading.Channels.BoundedChannelFullMode.Wait); @@ -70,8 +102,16 @@ private static void AssertAllSettablePropertiesEqual(object expected, object act ? System.Threading.Channels.BoundedChannelFullMode.DropOldest : System.Threading.Channels.BoundedChannelFullMode.Wait; } - if (type == typeof(IRedisStreamKeyStrategy)) return Substitute.For(); - if (type == typeof(IRedisChannelStrategy)) return Substitute.For(); + if (type == typeof(IRedisStreamKeyStrategy)) + { + return Substitute.For(); + } + + if (type == typeof(IRedisChannelStrategy)) + { + return Substitute.For(); + } + throw new NotSupportedException($"Add a non-default factory for {type.FullName}"); } } diff --git a/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubSubjectWriterTests.cs b/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubSubjectWriterTests.cs index b38fe62b..45db95dc 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubSubjectWriterTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubSubjectWriterTests.cs @@ -8,26 +8,18 @@ namespace UiPath.Caching.Tests.Broadcast; public class RedisPubSubSubjectWriterTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly TimeSpan _delay = 50.Milliseconds(); + private readonly TaskCompletionSource _subscribeCalled = new(TaskCreationOptions.RunContinuationsAsynchronously); private ISubscriber _subscriber = default!; private Channel _channel = default!; private IEventFormatterProxy _formatter = default!; private RedisChannel _redisChannel = default!; private RedisPubSubTopicOptions _options = default!; - private readonly TimeSpan _delay = 50.Milliseconds(); private Action? _capturedAction; - private readonly TaskCompletionSource _subscribeCalled = new(TaskCreationOptions.RunContinuationsAsynchronously); private RedisPubSubSubjectWriter? _sut = null; - private RedisPubSubSubjectWriter Sut() => - _sut ??= _fixture.Create>(); - - private Task> WaitForSubscribeAsync() => - _subscribeCalled.Task - .WaitAsync(TimeSpan.FromSeconds(30), testContextAccessor.Current.CancellationToken) - .ContinueWith(_ => _capturedAction!, TaskContinuationOptions.OnlyOnRanToCompletion); - [Fact] public async Task Receive_redis_null() { @@ -107,9 +99,17 @@ public ValueTask InitializeAsync() _options = new RedisPubSubTopicOptions { SubscriberTimeout = _delay, - SubscriberDueTime = TimeSpan.Zero + SubscriberDueTime = TimeSpan.Zero, }; _fixture.Inject(_options); return ValueTask.CompletedTask; } + + private RedisPubSubSubjectWriter Sut() => + _sut ??= _fixture.Create>(); + + private Task> WaitForSubscribeAsync() => + _subscribeCalled.Task + .WaitAsync(TimeSpan.FromSeconds(30), testContextAccessor.Current.CancellationToken) + .ContinueWith(_ => _capturedAction!, TaskContinuationOptions.OnlyOnRanToCompletion); } diff --git a/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubTopicTests.cs b/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubTopicTests.cs index ce108547..d8a7e9c2 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubTopicTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/RedisPubSubTopicTests.cs @@ -9,12 +9,15 @@ public class RedisPubSubTopicTests(ITestContextAccessor testContextAccessor) : I { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private readonly List _onNextMessages = []; + private readonly TimeSpan _delay = 50.Milliseconds(); + private readonly TaskCompletionSource _subscribeCalled = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly TaskCompletionSource _unsubscribeCalled = new(TaskCreationOptions.RunContinuationsAsynchronously); private TopicKey _topicKey; private ISubscriber _subscriber = default!; private IObserver _observer = default!; private bool _onCompleted = false; - Action? _handler; + private Action? _handler; private TestCacheEventFormatterProxy _formatter = default!; private IDatabase _database = default!; private IRedisConnector _redisConnector = default!; @@ -24,25 +27,9 @@ public class RedisPubSubTopicTests(ITestContextAccessor testContextAccessor) : I private IResiliencePipelineProvider _resiliencePipelineProvider = default!; private RedisPubSubTopicOptions _options = default!; private string? _actualRedisChannel; - private readonly TimeSpan _delay = 50.Milliseconds(); private bool _isConnected = true; private RedisPubSubTopic? _sut; - private readonly TaskCompletionSource _subscribeCalled = new(TaskCreationOptions.RunContinuationsAsynchronously); - private readonly TaskCompletionSource _unsubscribeCalled = new(TaskCreationOptions.RunContinuationsAsynchronously); - private async Task> Sut(int delayMultiplier = 2) - { - if (_sut != null) - { - return _sut; - } - _sut = _fixture.Create>(); - await Task.Delay(_delay.Multiply(delayMultiplier), testContextAccessor.Current.CancellationToken); - return _sut; - } - - private Task WaitForSubscribeAsync() => - _subscribeCalled.Task.WaitAsync(TimeSpan.FromSeconds(30), testContextAccessor.Current.CancellationToken); [Fact] public async Task Publish_WhenDisconnected() @@ -74,7 +61,7 @@ public async Task Message_received_when_channel_event_received() var cloudEvent = new TestCacheEvent { Id = Guid.NewGuid().ToString(), - Source = new Uri($"urn:{machineName}") + Source = new Uri($"urn:{machineName}"), }; var bytes = _formatter.Encode(cloudEvent); var message = Encoding.UTF8.GetString(bytes.Span); @@ -162,7 +149,7 @@ public async Task Publish_works_as_expected() var cloudEvent = new TestCacheEvent { Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine") + Source = new Uri("urn:machine"), }; _database.ClearReceivedCalls(); var executed = false; @@ -185,7 +172,7 @@ public async Task Canceling_token_stops_execution() var cloudEvent = new TestCacheEvent { Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine") + Source = new Uri("urn:machine"), }; var cancelSource = new CancellationTokenSource(); var token = cancelSource.Token; @@ -202,7 +189,7 @@ public async Task No_exceptions_are_thrown_when_redis_fails() var cloudEvent = new TestCacheEvent { Id = Guid.NewGuid().ToString(), - Source = new Uri("urn:machine") + Source = new Uri("urn:machine"), }; _database.ClearReceivedCalls(); _database.PublishAsync(Arg.Any(), Arg.Any(), Arg.Any()) @@ -262,7 +249,7 @@ public ValueTask InitializeAsync() { SubscriberTimeout = _delay, SubscriberDueTime = TimeSpan.Zero, - ConnectionMonitorEnabled = true + ConnectionMonitorEnabled = true, }; _fixture.Inject(_options); @@ -275,4 +262,17 @@ public ValueTask InitializeAsync() return ValueTask.CompletedTask; } + private async Task> Sut(int delayMultiplier = 2) + { + if (_sut != null) + { + return _sut; + } + _sut = _fixture.Create>(); + await Task.Delay(_delay.Multiply(delayMultiplier), testContextAccessor.Current.CancellationToken); + return _sut; + } + + private Task WaitForSubscribeAsync() => + _subscribeCalled.Task.WaitAsync(TimeSpan.FromSeconds(30), testContextAccessor.Current.CancellationToken); } diff --git a/tests/UiPath.Caching.Tests/Broadcast/RedisStreamNotifyChannelTests.cs b/tests/UiPath.Caching.Tests/Broadcast/RedisStreamNotifyChannelTests.cs index a6698f34..d2c8b9f1 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/RedisStreamNotifyChannelTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/RedisStreamNotifyChannelTests.cs @@ -140,8 +140,15 @@ public async Task OnReconnected_reschedules_subscribe() .Do(_ => { var n = Interlocked.Increment(ref calls); - if (n == 1) firstCall.TrySetResult(true); - if (n >= 2) secondCall.TrySetResult(true); + if (n == 1) + { + firstCall.TrySetResult(true); + } + + if (n >= 2) + { + secondCall.TrySetResult(true); + } }); using var waiter = new SignalingFetchWaiter(5.Seconds()); @@ -289,8 +296,15 @@ public async Task Resubscribe_swallows_previous_unsubscribe_failure_and_resubscr .Do(_ => { var n = Interlocked.Increment(ref subscribeCalls); - if (n == 1) firstSubscribed.TrySetResult(true); - if (n >= 2) secondSubscribed.TrySetResult(true); + if (n == 1) + { + firstSubscribed.TrySetResult(true); + } + + if (n >= 2) + { + secondSubscribed.TrySetResult(true); + } }); subscriber.When(s => s.Unsubscribe(channel, Arg.Any>(), Arg.Any())) .Do(_ => throw new RedisConnectionException(ConnectionFailureType.SocketFailure, CommandFlags.None, "stale")); diff --git a/tests/UiPath.Caching.Tests/Broadcast/RedisStreamSubjectWriterTests.cs b/tests/UiPath.Caching.Tests/Broadcast/RedisStreamSubjectWriterTests.cs index c447aa52..afac73bb 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/RedisStreamSubjectWriterTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/RedisStreamSubjectWriterTests.cs @@ -13,6 +13,8 @@ public class RedisStreamSubjectWriterTests : IAsyncLifetime private static readonly TimeSpan DefaultPollInterval = TimeSpan.FromMilliseconds(50); private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan NogroupPollTimeout = TimeSpan.FromSeconds(30); + private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private IEventFormatterProxy _formatter = default!; @@ -222,6 +224,8 @@ public async Task NOGROUP_error_triggers_StreamCreateConsumerGroup() await createCalled.Task.WaitAsync(NogroupPollTimeout, TestContext.Current.CancellationToken); _cancellationTokenSource.Cancel(); try { await fetchTask.WaitAsync(WaitTimeout, TestContext.Current.CancellationToken); } catch (OperationCanceledException) { } + + await _database.ReceivedWithAnyArgs().StreamCreateConsumerGroupAsync(_context.Topic, _context.ConsumerGroup, Arg.Any()); } [Fact] @@ -240,6 +244,8 @@ public async Task NOGROUP_recovery_swallows_BUSYGROUP_when_group_already_exists( await createCalled.Task.WaitAsync(NogroupPollTimeout, TestContext.Current.CancellationToken); _cancellationTokenSource.Cancel(); try { await fetchTask.WaitAsync(WaitTimeout, TestContext.Current.CancellationToken); } catch (OperationCanceledException) { } + + fetchTask.IsFaulted.Should().BeFalse("BUSYGROUP only means the group is already there, so recovery must swallow it and leave the fetch loop running"); } [Fact] @@ -256,9 +262,9 @@ public async Task NOGROUP_recovery_logs_and_continues_when_StreamCreate_throws_u await createCalled.Task.WaitAsync(NogroupPollTimeout, TestContext.Current.CancellationToken); _cancellationTokenSource.Cancel(); try { await fetchTask.WaitAsync(WaitTimeout, TestContext.Current.CancellationToken); } catch (OperationCanceledException) { } - } - private static readonly TimeSpan NogroupPollTimeout = TimeSpan.FromSeconds(30); + fetchTask.IsFaulted.Should().BeFalse("an unexpected StreamCreate failure must be logged and the fetch loop kept alive, not surfaced as a fault"); + } [Theory] [InlineData("ERR unknown command 'XREADGROUP'")] @@ -360,21 +366,6 @@ public async Task Unknown_command_quarantine_is_lifted_when_the_connection_recon try { await sut.FetchTask.WaitAsync(WaitTimeout, TestContext.Current.CancellationToken); } catch (OperationCanceledException) { } } - private static async Task WaitUntil(Func condition) - { - var deadline = DateTime.UtcNow + WaitTimeout; - while (DateTime.UtcNow < deadline) - { - if (condition()) - { - return true; - } - await Task.Delay(20, TestContext.Current.CancellationToken); - } - - return false; - } - [Fact] public async Task ProcessEvent_swallows_formatter_exception() { @@ -445,7 +436,7 @@ public async Task ChannelClosed_during_dispatch_logs_information_and_skips_ack() channel.Writer.Complete(); var recordingLogger = new RecordingLogger(); var loggedClosed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - recordingLogger.OnRecord = r => { if (r.Message.Contains("Channel closed during dispatch")) loggedClosed.TrySetResult(true); }; + recordingLogger.OnRecord = r => { if (r.Message.Contains("Channel closed during dispatch")) { loggedClosed.TrySetResult(true); } }; var entries = new[] { new StreamEntry(_fixture.Create(), [new NameValueEntry(_fieldName, _fixture.Create())]) }; _formatter.Decode(Arg.Any>()).Returns(new TestCacheEvent { Valid = true, Source = new Uri("urn:other-source") }); SetupSingleBatch(entries); @@ -515,6 +506,62 @@ public async Task Dispatch_failure_logs_error_with_event_and_stream_ids() await _database.DidNotReceiveWithAnyArgs().StreamAcknowledgeAsync(default, default, default(RedisValue[])!); } + public ValueTask DisposeAsync() + { + return ValueTask.CompletedTask; + } + + public ValueTask InitializeAsync() + { + _topic = _fixture.Create(); + _fieldName = _fixture.Create(); + _consumerName = _fixture.Create(); + _consumerGroup = _fixture.Create(); + _sourceUri = new Uri("urn:" + _fixture.Create()); + _pollBatchSize = _fixture.Create(); + _pollInterval = DefaultPollInterval; + _context = new RedisStreamContext(_topic, _fieldName, _consumerName, _consumerGroup, _sourceUri, _pollBatchSize, _pollInterval, false, true); + _fixture.Inject(_context); + _cancellationTokenSource = new CancellationTokenSource(); + _fixture.Inject(_cancellationTokenSource.Token); + _database = _fixture.Freeze(); + _logger = _fixture.Freeze(); + _formatter = _fixture.Freeze>(); + + var connectionState = _fixture.Freeze(); + connectionState.IsConnected.Returns(true); + + var redisConnector = _fixture.Freeze(); + redisConnector.Database.Returns(_database); + + _fixture.Inject(_formatter); + _fixture.Inject(new TimedFetchWaiter(_pollInterval)); + return ValueTask.CompletedTask; + } + + private static async Task WaitUntil(Func condition) + { + var deadline = DateTime.UtcNow + WaitTimeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + { + return true; + } + await Task.Delay(20, TestContext.Current.CancellationToken); + } + + return false; + } + + // RedisServerException(string) is obsolete as of StackExchange.Redis 3.1 and slated for removal in 3.2. + // Its replacement takes RedisErrorKind, which upstream still marks [Experimental] (SER007), so the + // suppression is centralized here rather than repeated at each call site. +#pragma warning disable SER007 // RedisErrorKind is for evaluation purposes only + private static RedisServerException UnknownCommandError(string message) => + new(RedisErrorKind.UnknownCommand, CommandFlags.None, message); +#pragma warning restore SER007 + private void SetupSingleBatch(StreamEntry[] entries) { var emitted = 0; @@ -572,45 +619,4 @@ private sealed class NullScope : IDisposable public void Dispose() { } } } - - public ValueTask DisposeAsync() - { - return ValueTask.CompletedTask; - } - - public ValueTask InitializeAsync() - { - _topic = _fixture.Create(); - _fieldName = _fixture.Create(); - _consumerName = _fixture.Create(); - _consumerGroup = _fixture.Create(); - _sourceUri = new Uri("urn:" + _fixture.Create()); - _pollBatchSize = _fixture.Create(); - _pollInterval = DefaultPollInterval; - _context = new RedisStreamContext(_topic, _fieldName, _consumerName, _consumerGroup, _sourceUri, _pollBatchSize, _pollInterval, false, true); - _fixture.Inject(_context); - _cancellationTokenSource = new CancellationTokenSource(); - _fixture.Inject(_cancellationTokenSource.Token); - _database = _fixture.Freeze(); - _logger = _fixture.Freeze(); - _formatter = _fixture.Freeze>(); - - var connectionState = _fixture.Freeze(); - connectionState.IsConnected.Returns(true); - - var redisConnector = _fixture.Freeze(); - redisConnector.Database.Returns(_database); - - _fixture.Inject(_formatter); - _fixture.Inject(new TimedFetchWaiter(_pollInterval)); - return ValueTask.CompletedTask; - } - - // RedisServerException(string) is obsolete as of StackExchange.Redis 3.1 and slated for removal in 3.2. - // Its replacement takes RedisErrorKind, which upstream still marks [Experimental] (SER007), so the - // suppression is centralized here rather than repeated at each call site. -#pragma warning disable SER007 // RedisErrorKind is for evaluation purposes only - private static RedisServerException UnknownCommandError(string message) => - new(RedisErrorKind.UnknownCommand, CommandFlags.None, message); -#pragma warning restore SER007 } diff --git a/tests/UiPath.Caching.Tests/Broadcast/RedisStreamTopicMonitorTests.cs b/tests/UiPath.Caching.Tests/Broadcast/RedisStreamTopicMonitorTests.cs index b8d71bce..268104a4 100644 --- a/tests/UiPath.Caching.Tests/Broadcast/RedisStreamTopicMonitorTests.cs +++ b/tests/UiPath.Caching.Tests/Broadcast/RedisStreamTopicMonitorTests.cs @@ -260,7 +260,7 @@ public ValueTask InitializeAsync() { TrackStatistics = true, MaintainerEnabled = true, - MaintainerCheckInterval = TimeSpan.FromMilliseconds(100) + MaintainerCheckInterval = TimeSpan.FromMilliseconds(100), }; _redisCacheOptions = new RedisCacheOptions { diff --git a/tests/UiPath.Caching.Tests/CacheCloudEventWrapperTests.cs b/tests/UiPath.Caching.Tests/CacheCloudEventWrapperTests.cs index 7ff660c3..0b9b27e7 100644 --- a/tests/UiPath.Caching.Tests/CacheCloudEventWrapperTests.cs +++ b/tests/UiPath.Caching.Tests/CacheCloudEventWrapperTests.cs @@ -14,7 +14,7 @@ public void Works_as_expected() Type = _fixture.Create(), Id = _fixture.Create(), Source = new Uri("urn:" + _fixture.Create()), - Data = _fixture.Create() + Data = _fixture.Create(), }; var sut = new CacheCloudEventWrapper(cloudEvent); sut.Type.Should().BeEquivalentTo(cloudEvent.Type); @@ -36,7 +36,7 @@ public void InvalidKey(string? key) Type = _fixture.Create(), Id = _fixture.Create(), Source = new Uri("urn:" + _fixture.Create()), - Data = key == null ? null : new CacheEventData(key) + Data = key == null ? null : new CacheEventData(key), }; var sut = new CacheCloudEventWrapper(cloudEvent); sut.IsValid().Should().BeFalse(); diff --git a/tests/UiPath.Caching.Tests/CacheEventFactoryTests.cs b/tests/UiPath.Caching.Tests/CacheEventFactoryTests.cs index 1916d6b4..a864042a 100644 --- a/tests/UiPath.Caching.Tests/CacheEventFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/CacheEventFactoryTests.cs @@ -70,7 +70,7 @@ public void Create_with_no_source_options() [InlineData("CacheRemoved", true)] [InlineData("CACHESET", true)] [InlineData("cacherefreshed", true)] - public void known_events(string? eventType, bool isKnown) + public void Known_events(string? eventType, bool isKnown) { var actual = Sut.IsKnown(eventType); actual.Should().Be(isKnown); diff --git a/tests/UiPath.Caching.Tests/CacheEventTests.cs b/tests/UiPath.Caching.Tests/CacheEventTests.cs index 10332356..12c0a55d 100644 --- a/tests/UiPath.Caching.Tests/CacheEventTests.cs +++ b/tests/UiPath.Caching.Tests/CacheEventTests.cs @@ -16,7 +16,7 @@ public void Works_as_expected(string? id, string? url, string? type, string? key Id = id, Source = url == null ? null : new Uri(url), Type = type, - Data = key == null ? null : new CacheEventData(key) + Data = key == null ? null : new CacheEventData(key), }; sut.IsValid().Should().Be(isValid); } diff --git a/tests/UiPath.Caching.Tests/CacheExpirationTests.cs b/tests/UiPath.Caching.Tests/CacheExpirationTests.cs index 1a8d2446..b2674352 100644 --- a/tests/UiPath.Caching.Tests/CacheExpirationTests.cs +++ b/tests/UiPath.Caching.Tests/CacheExpirationTests.cs @@ -97,39 +97,12 @@ public void ToDuration_rejects_a_deadline_that_has_passed() /// public class CacheExpirationGuardTests { - private static CancellationToken Ct => TestContext.Current.CancellationToken; private static readonly TimeSpan[] NonPositive = [TimeSpan.Zero, TimeSpan.FromMinutes(-5)]; - - private static MultilayerCache CreateSut() - { - var options = new InMemoryCacheOptions(); - var cacheOptions = new CacheOptions { AppShortName = "test" }; - return new MultilayerCache( - KnownCacheProviderNames.InMemory, - NullCache.Instance, - new MemoryCacheFactory(TimeProvider.System, NullLoggerFactory.Instance), - NullChangeTokenFactory.Instance, - NullTopicFactory.Instance, - NullCacheEventFactory.Instance, - NullTelemetryProvider.Instance, - options, - options, - cacheOptions, - localLock: new AsyncKeyedLocalLock(Options.Create(cacheOptions)), - distributedLock: NullDistributedLock.Instance, - policyFactory: NullCachePolicyFactory.Instance, - clock: TimeProvider.System, - logger: NullLogger.Instance); - } + private static CancellationToken Ct => TestContext.Current.CancellationToken; private static DateTimeOffset Past => DateTimeOffset.UtcNow.AddMinutes(-5); - private static async Task Rejects(Func write) - { - (await write.Should().ThrowAsync()).And.ParamName.Should().Be("expiration"); - } - [Fact] public async Task SetAsync_rejects_a_non_positive_duration() { @@ -180,14 +153,14 @@ public async Task GetOrAddAsync_rejects_a_bad_expiration_without_calling_the_gen { using var sut = CreateSut(); var called = false; - Task generator(CancellationToken _) + Task Generator(CancellationToken _) { called = true; return Task.FromResult("v"); } - await Rejects(async () => await sut.GetOrAddAsync("k", generator, TimeSpan.Zero, policy: null, Ct)); - await Rejects(async () => await sut.GetOrAddAsync("k", generator, Past, policy: null, Ct)); + await Rejects(async () => await sut.GetOrAddAsync("k", Generator, TimeSpan.Zero, policy: null, Ct)); + await Rejects(async () => await sut.GetOrAddAsync("k", Generator, Past, policy: null, Ct)); called.Should().BeFalse(); } @@ -213,4 +186,31 @@ public async Task Omitting_the_expiration_still_writes() (await sut.SetAsync("k", "v", policy: null, Ct)).Should().BeTrue(); (await sut.GetAsync("k", policy: null, token: Ct)).Should().Be("v"); } + + private static MultilayerCache CreateSut() + { + var options = new InMemoryCacheOptions(); + var cacheOptions = new CacheOptions { AppShortName = "test" }; + return new MultilayerCache( + KnownCacheProviderNames.InMemory, + NullCache.Instance, + new MemoryCacheFactory(TimeProvider.System, NullLoggerFactory.Instance), + NullChangeTokenFactory.Instance, + NullTopicFactory.Instance, + NullCacheEventFactory.Instance, + NullTelemetryProvider.Instance, + options, + options, + cacheOptions, + localLock: new AsyncKeyedLocalLock(Options.Create(cacheOptions)), + distributedLock: NullDistributedLock.Instance, + policyFactory: NullCachePolicyFactory.Instance, + clock: TimeProvider.System, + logger: NullLogger.Instance); + } + + private static async Task Rejects(Func write) + { + (await write.Should().ThrowAsync()).And.ParamName.Should().Be("expiration"); + } } diff --git a/tests/UiPath.Caching.Tests/CacheFactoryTests.cs b/tests/UiPath.Caching.Tests/CacheFactoryTests.cs index f04101e2..9b96a4ac 100644 --- a/tests/UiPath.Caching.Tests/CacheFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/CacheFactoryTests.cs @@ -37,7 +37,7 @@ public void Works_as_expected() } [Fact] - public void empty_factory() + public void Empty_factory() { _sut = new CacheFactory(Options.Create(_cacheOptions)); Sut.CreateCache(_fixture.Create()).Should().Be(NullCache.Instance); diff --git a/tests/UiPath.Caching.Tests/CacheMemoryMonitorTests.cs b/tests/UiPath.Caching.Tests/CacheMemoryMonitorTests.cs index 27d9a9f0..28c51eb8 100644 --- a/tests/UiPath.Caching.Tests/CacheMemoryMonitorTests.cs +++ b/tests/UiPath.Caching.Tests/CacheMemoryMonitorTests.cs @@ -49,7 +49,7 @@ public ValueTask InitializeAsync() { TrackStatistics = true, TrackLinkedCacheEntries = true, - Clock = new SystemClock() + Clock = new SystemClock(), })); return ValueTask.CompletedTask; } diff --git a/tests/UiPath.Caching.Tests/CacheOfTBatchGetOrAddTests.cs b/tests/UiPath.Caching.Tests/CacheOfTBatchGetOrAddTests.cs index 866ab4d7..39c85dd4 100644 --- a/tests/UiPath.Caching.Tests/CacheOfTBatchGetOrAddTests.cs +++ b/tests/UiPath.Caching.Tests/CacheOfTBatchGetOrAddTests.cs @@ -4,18 +4,10 @@ namespace UiPath.Caching.Tests; public class CacheOfTBatchGetOrAddTests(ITestContextAccessor testContextAccessor) { - /// Prefixes every key. - private sealed class PrefixStrategy : ICacheKeyStrategy - { - public CacheKey GetCacheKey(CacheKey key) => "p:" + key.Name; - } private static readonly long[] States2 = [2L]; private static readonly long[] States1And2 = [1L, 2L]; - private static KeyValuePair[] Entries(params long[] ids) => - ids.Select(id => new KeyValuePair((CacheKey)$"user:{id}", id)).ToArray(); - [Fact] public async Task State_passes_through_the_key_strategy_untouched() { @@ -70,11 +62,6 @@ public async Task Distinct_states_colliding_onto_one_mapped_key_both_survive() result.Select(r => r.Value).Should().Equal("shared", "shared"); } - private sealed class CollapsingStrategy : ICacheKeyStrategy - { - public CacheKey GetCacheKey(CacheKey key) => "collapsed"; - } - [Fact] public async Task Expiration_overloads_are_callable() { @@ -105,4 +92,17 @@ public void Sync_GetOrAdd_keeps_the_key_only_shape() result.Select(r => r.Value).Should().Equal("v:a", "v:b"); } + + private static KeyValuePair[] Entries(params long[] ids) => + ids.Select(id => new KeyValuePair((CacheKey)$"user:{id}", id)).ToArray(); + /// Prefixes every key. + private sealed class PrefixStrategy : ICacheKeyStrategy + { + public CacheKey GetCacheKey(CacheKey key) => "p:" + key.Name; + } + + private sealed class CollapsingStrategy : ICacheKeyStrategy + { + public CacheKey GetCacheKey(CacheKey key) => "collapsed"; + } } diff --git a/tests/UiPath.Caching.Tests/CancelationTokenCacheTests.cs b/tests/UiPath.Caching.Tests/CancelationTokenCacheTests.cs index c8d395cb..6fd1bcbc 100644 --- a/tests/UiPath.Caching.Tests/CancelationTokenCacheTests.cs +++ b/tests/UiPath.Caching.Tests/CancelationTokenCacheTests.cs @@ -64,6 +64,8 @@ public Task Contains() => await sut.ContainsAsync(Fixture.Create(), token); }); + protected abstract ICache CreateSut(); + private async Task ValidateCancellationToken(Func act) { var sut = CreateSut(); @@ -72,6 +74,4 @@ private async Task ValidateCancellationToken(Func(() => act(sut, token)); } - - protected abstract ICache CreateSut(); } diff --git a/tests/UiPath.Caching.Tests/CloudCacheEventFactoryTests.cs b/tests/UiPath.Caching.Tests/CloudCacheEventFactoryTests.cs index 9266a671..09e9c2e7 100644 --- a/tests/UiPath.Caching.Tests/CloudCacheEventFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/CloudCacheEventFactoryTests.cs @@ -70,7 +70,7 @@ public void Create_with_no_source_options() [InlineData("CacheRemoved")] [InlineData("CACHESET")] [InlineData("cacherefreshed")] - public void known_event_types(string? eventType) + public void Known_event_types(string? eventType) { var actual = Sut.IsKnown(eventType); actual.Should().BeTrue(); @@ -80,7 +80,7 @@ public void known_event_types(string? eventType) [InlineData(" ")] [InlineData("")] [InlineData(null)] - public void unknown_event_types(string? eventType) + public void Unknown_event_types(string? eventType) { var actual = Sut.IsKnown(eventType); actual.Should().BeFalse(); diff --git a/tests/UiPath.Caching.Tests/CollectionPropertyOmitter.cs b/tests/UiPath.Caching.Tests/CollectionPropertyOmitter.cs index f7a2f544..c7efb5fc 100644 --- a/tests/UiPath.Caching.Tests/CollectionPropertyOmitter.cs +++ b/tests/UiPath.Caching.Tests/CollectionPropertyOmitter.cs @@ -13,7 +13,9 @@ public object Create(object request, ISpecimenContext context) if (pi != null && pi.PropertyType.IsGenericType && pi.PropertyType.GetGenericTypeDefinition() == typeof(ICollection<>)) + { return new OmitSpecimen(); + } return new NoSpecimen(); } diff --git a/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs b/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs index accdecc9..006ac256 100644 --- a/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs +++ b/tests/UiPath.Caching.Tests/Config/DistributedCacheRegistrationTests.cs @@ -11,16 +11,6 @@ namespace UiPath.Caching.Tests.Config; public class DistributedCacheRegistrationTests { - private static ServiceProvider Build(string providerName, Action? configure = null) - { - var services = new ServiceCollection(); - services.AddCaching(b => - { - b.AddMemory(_ => { }); - b.AddDistributedCache(providerName, configure); - }); - return services.BuildServiceProvider(); - } [Fact] public void InMemory_tier_works_without_AddMemory() @@ -381,13 +371,6 @@ public void Reservation_from_a_foreign_implementation_is_reported() act.Should().Throw().WithMessage("*ReserveRedisKeyspace*"); } - private sealed class ForeignKeyspace : IReservedRedisKeyspace - { - public string Keyspace => "zz"; - - public string Owner => "Some.Package"; - } - [Fact] public void Reservation_registered_without_an_instance_is_reported() { @@ -439,38 +422,6 @@ public void Key_strategy_that_fails_to_render_a_reserved_keyspace_surfaces() act.Should().Throw().WithMessage("*cannot render*"); } - /// Builds a strategy for the keyspace but cannot render a key with it, which is a fault rather than a refusal. - private sealed class UnrenderableRedisKeyStrategyFactory(string unrenderable) : IRedisKeyStrategyFactory - { - private readonly DefaultRedisKeyStrategyFactory _inner = new(); - - public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); - - public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => - string.Equals(differentiator, unrenderable, StringComparison.OrdinalIgnoreCase) - ? new ThrowingRedisKeyStrategy(differentiator) - : _inner.Create(options, differentiator); - - private sealed class ThrowingRedisKeyStrategy(string keyspace) : IRedisKeyStrategy - { - public RedisKey GetRedisKey(CacheKey cacheKey) => - throw new InvalidOperationException($"cannot render a key for {keyspace}"); - } - } - - /// Refuses to build a key for a keyspace it does not know, the shape the extending guide encourages. - private sealed class PickyRedisKeyStrategyFactory(string rejected) : IRedisKeyStrategyFactory - { - private readonly DefaultRedisKeyStrategyFactory _inner = new(); - - public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); - - public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => - string.Equals(differentiator, rejected, StringComparison.OrdinalIgnoreCase) - ? throw new ArgumentException($"unknown keyspace {differentiator}", nameof(differentiator)) - : _inner.Create(options, differentiator); - } - [Fact] public void Nested_keyspaces_are_rejected_even_when_caching_is_disabled() { @@ -593,16 +544,6 @@ public void Redis_key_strategy_landing_on_a_package_keyspace_is_rejected_when_re act.Should().Throw().WithMessage("*IListCache (Some.Package)*share one keyspace*"); } - /// A factory that ignores the differentiator it is given and lands on a fixed one. - private sealed class FixedDifferentiatorRedisKeyStrategyFactory(string differentiator) : IRedisKeyStrategyFactory - { - private readonly DefaultRedisKeyStrategyFactory _inner = new(); - - public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); - - public IRedisKeyStrategy Create(CacheOptions options, string _) => _inner.Create(options, differentiator); - } - [Fact] public void Custom_redis_key_strategy_factory_is_used_over_the_applications() { @@ -648,21 +589,6 @@ public void Distributed_only_factory_landing_on_an_application_key_fails_fast() act.Should().Throw().WithMessage("*share one keyspace*"); } - /// - /// Composes the application's hash keyspace whatever differentiator it is handed, while its own type - /// overloads answer differently — so only comparing against the application's factory catches it. - /// - private sealed class ApplicationHashImpersonatingFactory : IRedisKeyStrategyFactory - { - private readonly DefaultRedisKeyStrategyFactory _inner = new(); - - public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => - _inner.Create(options, "elsewhere"); - - public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => - _inner.Create(options, RedisKeyspaces.Hash); - } - /// /// The application's own factory is inherited when the distributed cache does not override it, so one that /// composes the same key whatever differentiator it is handed puts both caches on one keyspace. @@ -708,29 +634,6 @@ public void Distributed_only_factory_on_a_disjoint_keyspace_is_accepted() provider.GetRequiredService().Should().NotBeNull(); } - private sealed class RecordingRedisKeyStrategyFactory : IRedisKeyStrategyFactory - { - private readonly DefaultRedisKeyStrategyFactory _inner = new(); - - public List Differentiators { get; } = []; - - public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); - - public IRedisKeyStrategy Create(CacheOptions options, string differentiator) - { - Differentiators.Add(differentiator); - return _inner.Create(options, differentiator); - } - } - - private sealed class FixedRedisKeyStrategyFactory : IRedisKeyStrategyFactory - { - public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => Create(options, "ignored"); - - public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => - new PrefixRedisKeyStrategy("fixed", options.Separator); - } - /// /// The prerequisite check must not depend on call order: AddInMemoryRedis installs the broadcast wiring /// from its own completion callback, and callbacks run in registration order. @@ -993,11 +896,6 @@ public void Application_tier_key_strategy_does_not_reach_the_distributed_keys(st cache.Get("abc").Should().BeNull(); } - private sealed class LowercasingCacheKeyStrategy : ICacheKeyStrategy - { - public CacheKey GetCacheKey(CacheKey key) => new(key.Name, CacheKeyCasing.Insensitive); - } - /// Registration-time, not resolve-time: a typo must not survive until the first cache hit. [Fact] public void Redis_key_differentiator_is_validated_before_the_container_is_built() @@ -1009,4 +907,106 @@ public void Redis_key_differentiator_is_validated_before_the_container_is_built( act.Should().Throw(); } + private static ServiceProvider Build(string providerName, Action? configure = null) + { + var services = new ServiceCollection(); + services.AddCaching(b => + { + b.AddMemory(_ => { }); + b.AddDistributedCache(providerName, configure); + }); + return services.BuildServiceProvider(); + } + + private sealed class ForeignKeyspace : IReservedRedisKeyspace + { + public string Keyspace => "zz"; + + public string Owner => "Some.Package"; + } + + /// Builds a strategy for the keyspace but cannot render a key with it, which is a fault rather than a refusal. + private sealed class UnrenderableRedisKeyStrategyFactory(string unrenderable) : IRedisKeyStrategyFactory + { + private readonly DefaultRedisKeyStrategyFactory _inner = new(); + + public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); + + public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => + string.Equals(differentiator, unrenderable, StringComparison.OrdinalIgnoreCase) + ? new ThrowingRedisKeyStrategy(differentiator) + : _inner.Create(options, differentiator); + + private sealed class ThrowingRedisKeyStrategy(string keyspace) : IRedisKeyStrategy + { + public RedisKey GetRedisKey(CacheKey cacheKey) => + throw new InvalidOperationException($"cannot render a key for {keyspace}"); + } + } + + /// Refuses to build a key for a keyspace it does not know, the shape the extending guide encourages. + private sealed class PickyRedisKeyStrategyFactory(string rejected) : IRedisKeyStrategyFactory + { + private readonly DefaultRedisKeyStrategyFactory _inner = new(); + + public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); + + public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => + string.Equals(differentiator, rejected, StringComparison.OrdinalIgnoreCase) + ? throw new ArgumentException($"unknown keyspace {differentiator}", nameof(differentiator)) + : _inner.Create(options, differentiator); + } + + /// A factory that ignores the differentiator it is given and lands on a fixed one. + private sealed class FixedDifferentiatorRedisKeyStrategyFactory(string differentiator) : IRedisKeyStrategyFactory + { + private readonly DefaultRedisKeyStrategyFactory _inner = new(); + + public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); + + public IRedisKeyStrategy Create(CacheOptions options, string _) => _inner.Create(options, differentiator); + } + + /// + /// Composes the application's hash keyspace whatever differentiator it is handed, while its own type + /// overloads answer differently — so only comparing against the application's factory catches it. + /// + private sealed class ApplicationHashImpersonatingFactory : IRedisKeyStrategyFactory + { + private readonly DefaultRedisKeyStrategyFactory _inner = new(); + + public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => + _inner.Create(options, "elsewhere"); + + public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => + _inner.Create(options, RedisKeyspaces.Hash); + } + + private sealed class RecordingRedisKeyStrategyFactory : IRedisKeyStrategyFactory + { + private readonly DefaultRedisKeyStrategyFactory _inner = new(); + + public List Differentiators { get; } = []; + + public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => _inner.Create(options, cacheType); + + public IRedisKeyStrategy Create(CacheOptions options, string differentiator) + { + Differentiators.Add(differentiator); + return _inner.Create(options, differentiator); + } + } + + private sealed class FixedRedisKeyStrategyFactory : IRedisKeyStrategyFactory + { + public IRedisKeyStrategy Create(CacheOptions options, Type cacheType) => Create(options, "ignored"); + + public IRedisKeyStrategy Create(CacheOptions options, string differentiator) => + new PrefixRedisKeyStrategy("fixed", options.Separator); + } + + private sealed class LowercasingCacheKeyStrategy : ICacheKeyStrategy + { + public CacheKey GetCacheKey(CacheKey key) => new(key.Name, CacheKeyCasing.Insensitive); + } } diff --git a/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs index 8593619b..c513438c 100644 --- a/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs +++ b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheEndToEndTests.cs @@ -11,31 +11,9 @@ namespace UiPath.Caching.Tests.Distributed; [Collection("CacheKeyDefaultCasing")] // mutates CacheKey.DefaultCasing — serialized collection public class DistributedCacheEndToEndTests { - /// - /// Drives both the adapter and the backing memory cache, so expiration can be advanced - /// deterministically instead of racing the wall clock. - /// - private sealed class FakeClock(DateTimeOffset now) : ISystemClock - { - public DateTimeOffset UtcNow { get; private set; } = now; - - public void Advance(TimeSpan delta) => UtcNow = UtcNow.Add(delta); - } private static readonly DateTimeOffset Start = new(2026, 8, 19, 12, 0, 0, TimeSpan.Zero); - private static ServiceProvider Build(FakeClock clock) - { - var services = new ServiceCollection(); - services.AddSingleton(new SystemClockTimeProvider(clock)); - services.AddCaching(b => - { - b.AddMemory(); - b.AddDistributedCache(KnownCacheProviderNames.InMemory); - }); - return services.BuildServiceProvider(); - } - /// Each touch falls inside the sliding window, so the entry survives well past one window — until the absolute cap passes. [Fact] public async Task Session_scenario_idle_keeps_alive_and_absolute_cap_wins() @@ -45,11 +23,14 @@ public async Task Session_scenario_idle_keeps_alive_and_absolute_cap_wins() var cache = provider.GetRequiredService(); var token = TestContext.Current.CancellationToken; - await cache.SetAsync("Session-AbC", [1], new DistributedCacheEntryOptions + await cache.SetAsync("Session-AbC", + [1], + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(20), AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2), - }, token); + }, + token); for (var i = 0; i < 4; i++) { @@ -113,18 +94,24 @@ public async Task Buffer_half_interoperates_with_the_array_half() var token = TestContext.Current.CancellationToken; var destination = new ArrayBufferWriter(); - await buffered.SetAsync("AbC", new ReadOnlySequence([1, 2, 3]), new DistributedCacheEntryOptions + await buffered.SetAsync("AbC", + new ReadOnlySequence([1, 2, 3]), + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30), - }, token); + }, + token); (await cache.GetAsync("AbC", token)).Should() .Equal(new byte[] { 1, 2, 3 }, "the array half reads what the buffer half wrote"); - await cache.SetAsync("xYz", [4, 5], new DistributedCacheEntryOptions + await cache.SetAsync("xYz", + [4, 5], + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30), - }, token); + }, + token); (await buffered.TryGetAsync("xYz", destination, token)).Should().BeTrue(); destination.WrittenSpan.ToArray().Should().Equal(4, 5); @@ -145,4 +132,26 @@ public async Task Whitespace_wrapped_keys_collide_by_design() await cache.SetAsync(" k ", [1], new DistributedCacheEntryOptions(), token); (await cache.GetAsync("k", token)).Should().Equal(1); } + + private static ServiceProvider Build(FakeClock clock) + { + var services = new ServiceCollection(); + services.AddSingleton(new SystemClockTimeProvider(clock)); + services.AddCaching(b => + { + b.AddMemory(); + b.AddDistributedCache(KnownCacheProviderNames.InMemory); + }); + return services.BuildServiceProvider(); + } + /// + /// Drives both the adapter and the backing memory cache, so expiration can be advanced + /// deterministically instead of racing the wall clock. + /// + private sealed class FakeClock(DateTimeOffset now) : ISystemClock + { + public DateTimeOffset UtcNow { get; private set; } = now; + + public void Advance(TimeSpan delta) => UtcNow = UtcNow.Add(delta); + } } diff --git a/tests/UiPath.Caching.Tests/Distributed/DistributedCacheRedisIntegrationTests.cs b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheRedisIntegrationTests.cs index 972cefdf..bc9302ba 100644 --- a/tests/UiPath.Caching.Tests/Distributed/DistributedCacheRedisIntegrationTests.cs +++ b/tests/UiPath.Caching.Tests/Distributed/DistributedCacheRedisIntegrationTests.cs @@ -1,4 +1,4 @@ -#if NET9_0_OR_GREATER +#if NET9_0_OR_GREATER using System.Buffers; #endif using Microsoft.Extensions.Caching.Distributed; @@ -22,23 +22,6 @@ public class DistributedCacheRedisIntegrationTests(RedisContainerFixture fixture { private const string AppShortName = "dcit"; - private static ServiceProvider Build(string connectionString) => - new ServiceCollection() - .AddCaching( - b => - { - b.AddRedisConnection(o => o.ConnectionString = connectionString); - b.AddRedis(_ => { }); - b.AddDistributedCache(KnownCacheProviderNames.Redis); - }, - o => o.AppShortName = AppShortName) - .BuildServiceProvider(); - - private static string Unique() => $"it-{Guid.NewGuid():N}"; - - private Task ConnectAsync() => - ConnectionMultiplexer.ConnectAsync(fixture.ConnectionString); - [Fact] public async Task Round_trips_a_payload_through_redis() { @@ -48,10 +31,13 @@ public async Task Round_trips_a_payload_through_redis() using var provider = Build(fixture.ConnectionString); var cache = provider.GetRequiredService(); - await cache.SetAsync(key, [1, 2, 3], new DistributedCacheEntryOptions + await cache.SetAsync(key, + [1, 2, 3], + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10), - }, token); + }, + token); (await cache.GetAsync(key, token)).Should().Equal(1, 2, 3); (await cache.GetAsync(key.ToUpperInvariant(), token)).Should().BeNull("keys are case-sensitive"); @@ -72,11 +58,14 @@ public async Task Stores_a_hash_with_the_documented_fields_in_its_own_keyspace() await using var multiplexer = await ConnectAsync(); var database = multiplexer.GetDatabase(); - await cache.SetAsync(key, [7, 8], new DistributedCacheEntryOptions + await cache.SetAsync(key, + [7, 8], + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(20), AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(2), - }, token); + }, + token); var redisKey = $"{AppShortName}:{UiPathDistributedCacheOptions.DefaultRedisKeyDifferentiator}:{UiPathDistributedCacheOptions.DefaultKeyPrefix}:{key}"; (await database.KeyTypeAsync(redisKey)).Should().Be(RedisType.Hash); @@ -103,65 +92,19 @@ public async Task Empty_payload_round_trips_as_empty() using var provider = Build(fixture.ConnectionString); var cache = provider.GetRequiredService(); - await cache.SetAsync(key, [], new DistributedCacheEntryOptions + await cache.SetAsync(key, + [], + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5), - }, token); + }, + token); (await cache.GetAsync(key, token)).Should().NotBeNull().And.BeEmpty(); await cache.RemoveAsync(key, token); } -#if NET9_0_OR_GREATER - [Fact] - public async Task Buffer_half_round_trips_through_redis_on_the_same_wire_layout() - { - Assert.SkipUnless(fixture.Enabled, "Set RUN_REDIS_INTEGRATION_TESTS=1 (Docker required) to run."); - var token = TestContext.Current.CancellationToken; - var key = Unique(); - using var provider = Build(fixture.ConnectionString); - var cache = provider.GetRequiredService(); - var buffered = cache.Should().BeAssignableTo().Subject; - await using var multiplexer = await ConnectAsync(); - var database = multiplexer.GetDatabase(); - var redisKey = $"{AppShortName}:{UiPathDistributedCacheOptions.DefaultRedisKeyDifferentiator}:{UiPathDistributedCacheOptions.DefaultKeyPrefix}:{key}"; - - var payload = new byte[] { 1, 2, 3, 4, 5 }; - var first = new BufferSegment(payload.AsMemory(0, 2), runningIndex: 0); - var second = first.Append(payload.AsMemory(2)); - - await buffered.SetAsync(key, new ReadOnlySequence(first, 0, second, second.Memory.Length), - new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, token); - - ((byte[]?)await database.HashGetAsync(redisKey, "data")).Should().Equal(payload, "stored raw and contiguous"); - (await cache.GetAsync(key, token)).Should().Equal(payload); - - var destination = new ArrayBufferWriter(); - (await buffered.TryGetAsync(key, destination, token)).Should().BeTrue(); - destination.WrittenSpan.ToArray().Should().Equal(payload); - - await cache.RemoveAsync(key, token); - (await buffered.TryGetAsync(key, new ArrayBufferWriter(), token)).Should().BeFalse(); - } - - private sealed class BufferSegment : ReadOnlySequenceSegment - { - public BufferSegment(ReadOnlyMemory memory, long runningIndex) - { - Memory = memory; - RunningIndex = runningIndex; - } - - public BufferSegment Append(ReadOnlyMemory memory) - { - var next = new BufferSegment(memory, RunningIndex + Memory.Length); - Next = next; - return next; - } - } -#endif - /// Refresh extends the TTL without transferring the payload, and the absolute deadline still caps it. [Fact] public async Task Refresh_extends_the_ttl_and_the_absolute_deadline_caps_it() @@ -175,11 +118,14 @@ public async Task Refresh_extends_the_ttl_and_the_absolute_deadline_caps_it() var database = multiplexer.GetDatabase(); var redisKey = $"{AppShortName}:{UiPathDistributedCacheOptions.DefaultRedisKeyDifferentiator}:{UiPathDistributedCacheOptions.DefaultKeyPrefix}:{key}"; - await cache.SetAsync(key, [1], new DistributedCacheEntryOptions + await cache.SetAsync(key, + [1], + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(30), AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(45), - }, token); + }, + token); await database.KeyExpireAsync(redisKey, TimeSpan.FromMinutes(5)); (await database.KeyTimeToLiveAsync(redisKey)).Should().BeCloseTo(TimeSpan.FromMinutes(5), TimeSpan.FromSeconds(30)); @@ -226,4 +172,75 @@ public async Task Refresh_is_applied_and_reported_before_it_returns() await hash.RemoveAsync(cacheKey, token); } + +#if NET9_0_OR_GREATER + [Fact] + public async Task Buffer_half_round_trips_through_redis_on_the_same_wire_layout() + { + Assert.SkipUnless(fixture.Enabled, "Set RUN_REDIS_INTEGRATION_TESTS=1 (Docker required) to run."); + var token = TestContext.Current.CancellationToken; + var key = Unique(); + using var provider = Build(fixture.ConnectionString); + var cache = provider.GetRequiredService(); + var buffered = cache.Should().BeAssignableTo().Subject; + await using var multiplexer = await ConnectAsync(); + var database = multiplexer.GetDatabase(); + var redisKey = $"{AppShortName}:{UiPathDistributedCacheOptions.DefaultRedisKeyDifferentiator}:{UiPathDistributedCacheOptions.DefaultKeyPrefix}:{key}"; + + var payload = new byte[] { 1, 2, 3, 4, 5 }; + var first = new BufferSegment(payload.AsMemory(0, 2), runningIndex: 0); + var second = first.Append(payload.AsMemory(2)); + + await buffered.SetAsync(key, + new ReadOnlySequence(first, 0, second, second.Memory.Length), + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }, + token); + + ((byte[]?)await database.HashGetAsync(redisKey, "data")).Should().Equal(payload, "stored raw and contiguous"); + (await cache.GetAsync(key, token)).Should().Equal(payload); + + var destination = new ArrayBufferWriter(); + (await buffered.TryGetAsync(key, destination, token)).Should().BeTrue(); + destination.WrittenSpan.ToArray().Should().Equal(payload); + + await cache.RemoveAsync(key, token); + (await buffered.TryGetAsync(key, new ArrayBufferWriter(), token)).Should().BeFalse(); + } +#endif + + private static ServiceProvider Build(string connectionString) => + new ServiceCollection() + .AddCaching( + b => + { + b.AddRedisConnection(o => o.ConnectionString = connectionString); + b.AddRedis(_ => { }); + b.AddDistributedCache(KnownCacheProviderNames.Redis); + }, + o => o.AppShortName = AppShortName) + .BuildServiceProvider(); + + private static string Unique() => $"it-{Guid.NewGuid():N}"; + + private Task ConnectAsync() => + ConnectionMultiplexer.ConnectAsync(fixture.ConnectionString); + +#if NET9_0_OR_GREATER + private sealed class BufferSegment : ReadOnlySequenceSegment + { + public BufferSegment(ReadOnlyMemory memory, long runningIndex) + { + Memory = memory; + RunningIndex = runningIndex; + } + + public BufferSegment Append(ReadOnlyMemory memory) + { + var next = new BufferSegment(memory, RunningIndex + Memory.Length); + Next = next; + return next; + } + } +#endif + } diff --git a/tests/UiPath.Caching.Tests/Distributed/UiPathBufferDistributedCacheTests.cs b/tests/UiPath.Caching.Tests/Distributed/UiPathBufferDistributedCacheTests.cs index 429ed3b5..b40aea85 100644 --- a/tests/UiPath.Caching.Tests/Distributed/UiPathBufferDistributedCacheTests.cs +++ b/tests/UiPath.Caching.Tests/Distributed/UiPathBufferDistributedCacheTests.cs @@ -1,4 +1,4 @@ -#if NET9_0_OR_GREATER +#if NET9_0_OR_GREATER using System.Buffers; using System.Globalization; using System.Runtime.InteropServices; @@ -29,72 +29,6 @@ public UiPathBufferDistributedCacheTests() _cache = Build(); } - private UiPathDistributedCache Build(UiPathDistributedCacheOptions? options = null, bool tierRetainsValues = false) => - new(_inner, - options ?? new UiPathDistributedCacheOptions(), - new PrefixCacheKeyStrategy(UiPathDistributedCacheOptions.DefaultKeyPrefix), - policy: null, - NullLogger.Instance, - new SystemClockTimeProvider(_clock), - tierRetainsValues: tierRetainsValues); - - private static byte[] Ticks(long? value) => - Encoding.UTF8.GetBytes((value ?? -1).ToString(CultureInfo.InvariantCulture)); - - private static Dictionary> Entry( - byte[]? payload = null, long? slidingTicks = null, DateTimeOffset? absolute = null) => new() - { - [DataField] = payload ?? Payload, - [AbsoluteExpirationField] = Ticks(absolute?.UtcTicks), - [SlidingExpirationField] = Ticks(slidingTicks), - }; - - private void StoredEntry(Dictionary> fields) => - _inner.GetAsync>(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(fields); - - /// Copies at capture time: on the pass-through shape the payload memory is only valid during the call. - private async Task> CaptureWriteAsync(Func write) - { - Dictionary? written = null; - await _inner.SetAsync(Arg.Any(), - Arg.Do>>(v => written = v.ToDictionary(p => p.Key, p => p.Value.ToArray())), - Arg.Any(), Arg.Any(), Arg.Any()); - await write(); - written.Should().NotBeNull(); - return written!; - } - - private static byte[] BackingArray(ReadOnlyMemory memory) - { - MemoryMarshal.TryGetArray(memory, out var segment).Should().BeTrue(); - return segment.Array!; - } - - /// Two segments, the shape a pooled writer produces. - private static ReadOnlySequence Segmented(byte[] payload, int split) - { - var first = new Segment(payload.AsMemory(0, split), runningIndex: 0); - var second = first.Append(payload.AsMemory(split)); - return new ReadOnlySequence(first, 0, second, second.Memory.Length); - } - - private sealed class Segment : ReadOnlySequenceSegment - { - public Segment(ReadOnlyMemory memory, long runningIndex) - { - Memory = memory; - RunningIndex = runningIndex; - } - - public Segment Append(ReadOnlyMemory memory) - { - var next = new Segment(memory, RunningIndex + Memory.Length); - Next = next; - return next; - } - } - [Fact] public void Adapter_is_discoverable_as_a_buffer_cache() { @@ -148,7 +82,8 @@ public async Task TryGet_reads_the_composed_key_and_asks_for_the_data_field() await _inner.Received(1).GetAsync>( Arg.Is(k => k.Name == "d:AbC-9xQ" && k.Casing == CacheKeyCasing.Sensitive), Arg.Is(f => f != null && f.Contains(DataField)), - Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] @@ -210,11 +145,16 @@ public async Task Set_flattens_every_segment_and_writes_the_same_fields() { var sliding = TimeSpan.FromMinutes(20); TimeSpan? ttl = null; - await _inner.SetAsync(Arg.Any(), Arg.Any>>(), - Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Any>>(), + Arg.Do(t => ttl = t), + Arg.Any(), + Arg.Any()); - var written = await CaptureWriteAsync(() => _cache.SetAsync("k", Segmented([1, 2, 3, 4, 5], split: 2), - new DistributedCacheEntryOptions { SlidingExpiration = sliding }, TestContext.Current.CancellationToken).AsTask()); + var written = await CaptureWriteAsync(() => _cache.SetAsync("k", + Segmented([1, 2, 3, 4, 5], split: 2), + new DistributedCacheEntryOptions { SlidingExpiration = sliding }, + TestContext.Current.CancellationToken).AsTask()); ttl.Should().Be(sliding); written[DataField].Should().Equal(1, 2, 3, 4, 5); @@ -229,11 +169,15 @@ public async Task Set_on_a_retaining_tier_copies_the_callers_buffer() ReadOnlyMemory handed = default; await _inner.SetAsync(Arg.Any(), Arg.Do>>(v => handed = v[DataField]), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); var buffer = new byte[] { 1, 2, 3 }; - await retaining.SetAsync("k", new ReadOnlySequence(buffer), - new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, TestContext.Current.CancellationToken); + await retaining.SetAsync("k", + new ReadOnlySequence(buffer), + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, + TestContext.Current.CancellationToken); buffer[0] = 99; BackingArray(handed).Should().NotBeSameAs(buffer); @@ -246,11 +190,15 @@ public async Task Set_on_a_pass_through_tier_hands_over_the_callers_memory_itsel ReadOnlyMemory handed = default; await _inner.SetAsync(Arg.Any(), Arg.Do>>(v => handed = v[DataField]), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); var buffer = new byte[] { 1, 2, 3 }; - await _cache.SetAsync("k", new ReadOnlySequence(buffer), - new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, TestContext.Current.CancellationToken); + await _cache.SetAsync("k", + new ReadOnlySequence(buffer), + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, + TestContext.Current.CancellationToken); BackingArray(handed).Should().BeSameAs(buffer); } @@ -258,8 +206,10 @@ await _inner.SetAsync(Arg.Any(), [Fact] public async Task Segmented_set_on_a_pass_through_tier_writes_the_flattened_bytes() { - var written = await CaptureWriteAsync(() => _cache.SetAsync("k", Segmented([1, 2, 3, 4, 5, 6, 7], split: 3), - new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, TestContext.Current.CancellationToken).AsTask()); + var written = await CaptureWriteAsync(() => _cache.SetAsync("k", + Segmented([1, 2, 3, 4, 5, 6, 7], split: 3), + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, + TestContext.Current.CancellationToken).AsTask()); written[DataField].Should().Equal(1, 2, 3, 4, 5, 6, 7); } @@ -267,8 +217,10 @@ public async Task Segmented_set_on_a_pass_through_tier_writes_the_flattened_byte [Fact] public async Task Empty_sequence_writes_an_empty_payload() { - var written = await CaptureWriteAsync(() => _cache.SetAsync("k", ReadOnlySequence.Empty, - new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, TestContext.Current.CancellationToken).AsTask()); + var written = await CaptureWriteAsync(() => _cache.SetAsync("k", + ReadOnlySequence.Empty, + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(5) }, + TestContext.Current.CancellationToken).AsTask()); written[DataField].Should().BeEmpty(); Encoding.UTF8.GetString(written[SlidingExpirationField]).Should().NotBe("-1", "the metadata is what marks the entry as ours"); @@ -288,8 +240,10 @@ await _inner.DidNotReceive().SetAsync( [Fact] public async Task Set_with_a_past_absolute_expiration_throws() { - await FluentActions.Awaiting(() => _cache.SetAsync("k", new ReadOnlySequence(Payload), - new DistributedCacheEntryOptions { AbsoluteExpiration = Now.AddMinutes(-1) }, TestContext.Current.CancellationToken).AsTask()) + await FluentActions.Awaiting(() => _cache.SetAsync("k", + new ReadOnlySequence(Payload), + new DistributedCacheEntryOptions { AbsoluteExpiration = Now.AddMinutes(-1) }, + TestContext.Current.CancellationToken).AsTask()) .Should().ThrowAsync(); } @@ -316,5 +270,73 @@ public void Null_cache_reports_a_miss_and_swallows_the_write() destination.WrittenCount.Should().Be(0); } + + private static byte[] Ticks(long? value) => + Encoding.UTF8.GetBytes((value ?? -1).ToString(CultureInfo.InvariantCulture)); + + private static Dictionary> Entry( + byte[]? payload = null, long? slidingTicks = null, DateTimeOffset? absolute = null) => new() + { + [DataField] = payload ?? Payload, + [AbsoluteExpirationField] = Ticks(absolute?.UtcTicks), + [SlidingExpirationField] = Ticks(slidingTicks), + }; + + private static byte[] BackingArray(ReadOnlyMemory memory) + { + MemoryMarshal.TryGetArray(memory, out var segment).Should().BeTrue(); + return segment.Array!; + } + + /// Two segments, the shape a pooled writer produces. + private static ReadOnlySequence Segmented(byte[] payload, int split) + { + var first = new Segment(payload.AsMemory(0, split), runningIndex: 0); + var second = first.Append(payload.AsMemory(split)); + return new ReadOnlySequence(first, 0, second, second.Memory.Length); + } + + private UiPathDistributedCache Build(UiPathDistributedCacheOptions? options = null, bool tierRetainsValues = false) => + new(_inner, + options ?? new UiPathDistributedCacheOptions(), + new PrefixCacheKeyStrategy(UiPathDistributedCacheOptions.DefaultKeyPrefix), + policy: null, + NullLogger.Instance, + new SystemClockTimeProvider(_clock), + tierRetainsValues: tierRetainsValues); + + private void StoredEntry(Dictionary> fields) => + _inner.GetAsync>(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(fields); + + /// Copies at capture time: on the pass-through shape the payload memory is only valid during the call. + private async Task> CaptureWriteAsync(Func write) + { + Dictionary? written = null; + await _inner.SetAsync(Arg.Any(), + Arg.Do>>(v => written = v.ToDictionary(p => p.Key, p => p.Value.ToArray())), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await write(); + written.Should().NotBeNull(); + return written!; + } + + private sealed class Segment : ReadOnlySequenceSegment + { + public Segment(ReadOnlyMemory memory, long runningIndex) + { + Memory = memory; + RunningIndex = runningIndex; + } + + public Segment Append(ReadOnlyMemory memory) + { + var next = new Segment(memory, RunningIndex + Memory.Length); + Next = next; + return next; + } + } } #endif diff --git a/tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs b/tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs index a3748b5f..79f769fc 100644 --- a/tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs +++ b/tests/UiPath.Caching.Tests/Distributed/UiPathDistributedCacheTests.cs @@ -16,6 +16,8 @@ public class UiPathDistributedCacheTests private const string AbsoluteExpirationField = "absexp"; private const string SlidingExpirationField = "sldexp"; + private const string FieldOmitted = "\0omitted"; + private static readonly DateTimeOffset Now = new(2026, 8, 13, 10, 0, 0, TimeSpan.Zero); private static readonly byte[] Payload = [1, 2, 3]; @@ -29,41 +31,42 @@ public UiPathDistributedCacheTests() _cache = Build(); } - private UiPathDistributedCache Build( - UiPathDistributedCacheOptions? options = null, - bool slideByRewrite = false, - ICacheKeyStrategy? keyStrategy = null) => - new(_inner, - options ?? new UiPathDistributedCacheOptions(), - keyStrategy ?? new PrefixCacheKeyStrategy(UiPathDistributedCacheOptions.DefaultKeyPrefix), - policy: null, - NullLogger.Instance, - new SystemClockTimeProvider(_clock), - slideByRewrite); - - private static byte[] Ticks(long? value) => - Encoding.UTF8.GetBytes((value ?? -1).ToString(CultureInfo.InvariantCulture)); - - private static Dictionary> Entry(long? slidingTicks = null, DateTimeOffset? absolute = null) => new() - { - [DataField] = Payload, - [AbsoluteExpirationField] = Ticks(absolute?.UtcTicks), - [SlidingExpirationField] = Ticks(slidingTicks), - }; - - private void StoredEntry(Dictionary> fields) => - _inner.GetAsync>(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(fields); - + /// + /// Values no write can produce, per field. Each is a miss: serving one would hand back the payload with its + /// expiration silently dropped — as an entry that never expires — which is the failure this decode exists to + /// prevent. Whitespace padding is included because the writer never emits it. + /// + public static TheoryData RejectedMetadataValues() => + [ + (string?)null, // field present, empty value — the shape a Redis miss returns + FieldOmitted, // field absent entirely + "", + " ", + "garbage", + "1.5", + "1e3", + " 1 ", + "0", // parses, but is skipped downstream rather than applied + "-2", // not the sentinel + "9223372036854775808", // one past long.MaxValue: does not parse + ]; /// - /// The storage key the default strategy produces. Tests whose subject is not the key's shape go - /// through this, so "prefix plus separator" is asserted in one place — as the default, not as the - /// contract — rather than restated in every expectation. + /// The expiration shapes a caller can write, as serializable parts rather than a + /// — the options type is not serializable, so passing it + /// directly leaves the runner unable to enumerate the rows individually. /// - private static string Key(string callerKey) => - new PrefixCacheKeyStrategy(UiPathDistributedCacheOptions.DefaultKeyPrefix) - .GetCacheKey>(new CacheKey(callerKey, CacheKeyCasing.Sensitive)).Name; + public static TheoryData WriteShapes() => new() + { + { null, null, null }, + { TimeSpan.FromMinutes(20), null, null }, + { TimeSpan.FromTicks(1), null, null }, + { TimeSpan.MaxValue, null, null }, + { null, TimeSpan.FromHours(2), null }, + { null, TimeSpan.MaxValue, null }, + { null, null, Now.AddDays(1) }, + { TimeSpan.FromMinutes(20), TimeSpan.FromHours(2), null }, + }; /// Pinned literally, because changing the default relocates every stored entry. [Fact] @@ -73,7 +76,9 @@ public async Task Default_key_composition_is_pinned() await _cache.GetAsync("AbC-9xQ", TestContext.Current.CancellationToken); await _inner.Received(1).GetAsync>( Arg.Is(k => k.Name == "d:AbC-9xQ"), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] @@ -84,7 +89,9 @@ public async Task Keys_are_case_sensitive_and_preserved() await _cache.GetAsync("AbC-9xQ", TestContext.Current.CancellationToken); await _inner.Received(1).GetAsync>( Arg.Is(k => k.Name == expected && k.Casing == CacheKeyCasing.Sensitive), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); } /// The application's own asking for the caller key must never land on these entries. @@ -102,10 +109,14 @@ public async Task Composed_key_keeps_the_bare_caller_key_unreachable() expected.Should().NotBe("AbC"); await _inner.Received(1).GetAsync>( Arg.Is(k => k.Name == expected), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); await _inner.Received(1).SetAsync( Arg.Is(k => k.Name == expected), - Arg.Any>>(), Arg.Any(), Arg.Any()); + Arg.Any>>(), + Arg.Any(), + Arg.Any()); await _inner.Received(1).RemoveAsync>( Arg.Is(k => k.Name == expected), Arg.Any()); } @@ -118,7 +129,9 @@ public async Task Custom_key_strategy_replaces_the_default() await custom.GetAsync("AbC", TestContext.Current.CancellationToken); await _inner.Received(1).GetAsync>( Arg.Is(k => k.Name == "mine/AbC" && k.Casing == CacheKeyCasing.Sensitive), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); } /// The seam only transforms the key, so nothing downstream assumes where the marker sits. @@ -130,7 +143,9 @@ public async Task Key_strategy_need_not_be_a_prefix() await suffixed.GetAsync("AbC", TestContext.Current.CancellationToken); await _inner.Received(1).GetAsync>( Arg.Is(k => k.Name == "AbC:d" && k.Casing == CacheKeyCasing.Sensitive), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] @@ -141,7 +156,9 @@ public async Task Default_cache_key_strategy_stores_the_bare_key() await bare.GetAsync("AbC", TestContext.Current.CancellationToken); await _inner.Received(1).GetAsync>( Arg.Is(k => k.Name == "AbC"), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] @@ -164,21 +181,6 @@ public async Task Key_strategy_dropping_case_sensitivity_fails_loudly() .Should().ThrowAsync()).WithMessage("*case-significant*WithName*"); } - private sealed class SuffixKeyStrategy(string suffix) : ICacheKeyStrategy - { - public CacheKey GetCacheKey(CacheKey key) => key.WithName(key.Name + suffix); - } - - private sealed class AmbientCasingKeyStrategy : ICacheKeyStrategy - { - public CacheKey GetCacheKey(CacheKey key) => new(key.Name, CacheKeyCasing.Insensitive); - } - - private sealed class EmptyKeyStrategy : ICacheKeyStrategy - { - public CacheKey GetCacheKey(CacheKey key) => default; - } - [Fact] public async Task Null_key_throws() { @@ -200,11 +202,17 @@ public async Task Failed_write_masks_the_key() const string key = "Session-AbC"; var logger = new CapturingLogger(); var cache = new UiPathDistributedCache( - _inner, new UiPathDistributedCacheOptions(), + _inner, + new UiPathDistributedCacheOptions(), new PrefixCacheKeyStrategy(UiPathDistributedCacheOptions.DefaultKeyPrefix), - policy: null, logger, new SystemClockTimeProvider(_clock)); - _inner.SetAsync(Arg.Any(), Arg.Any>>(), - Arg.Any(), Arg.Any(), Arg.Any()).Returns(false); + policy: null, + logger, + new SystemClockTimeProvider(_clock)); + _inner.SetAsync(Arg.Any(), + Arg.Any>>(), + Arg.Any(), + Arg.Any(), + Arg.Any()).Returns(false); await cache.SetAsync(key, Payload, new DistributedCacheEntryOptions(), TestContext.Current.CancellationToken); @@ -212,18 +220,6 @@ public async Task Failed_write_masks_the_key() logger.Messages.Should().ContainSingle().Which.Should().Contain("Ses****").And.NotContain(key); } - private sealed class CapturingLogger : ILogger - { - public List Messages { get; } = []; - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => - Messages.Add(formatter(state, exception)); - } - /// The prefix the strategy adds must not make an empty caller key look valid. [Theory] [InlineData("")] @@ -260,8 +256,10 @@ public async Task Get_requests_all_fields_including_data() StoredEntry(Entry()); await _cache.GetAsync("k", TestContext.Current.CancellationToken); await _inner.Received(1).GetAsync>( - Arg.Any(), Arg.Is(f => f != null && f.Contains(DataField)), - Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Is(f => f != null && f.Contains(DataField)), + Arg.Any(), + Arg.Any()); } [Fact] @@ -323,8 +321,11 @@ public async Task Set_writes_payload_and_metadata_fields() var sliding = TimeSpan.FromMinutes(20); IDictionary>? written = null; TimeSpan? ttl = null; - await _inner.SetAsync(Arg.Any(), Arg.Do>>(v => written = v), - Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Do>>(v => written = v), + Arg.Do(t => ttl = t), + Arg.Any(), + Arg.Any()); await _cache.SetAsync("k", Payload, new DistributedCacheEntryOptions { SlidingExpiration = sliding }, TestContext.Current.CancellationToken); @@ -339,14 +340,20 @@ await _inner.SetAsync(Arg.Any(), Arg.Do(), Arg.Any>>(), - Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); - - await _cache.SetAsync("k", Payload, new DistributedCacheEntryOptions + await _inner.SetAsync(Arg.Any(), + Arg.Any>>(), + Arg.Do(t => ttl = t), + Arg.Any(), + Arg.Any()); + + await _cache.SetAsync("k", + Payload, + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.FromMinutes(20), AbsoluteExpiration = Now.AddMinutes(5), - }, TestContext.Current.CancellationToken); + }, + TestContext.Current.CancellationToken); ttl.Should().Be(TimeSpan.FromMinutes(5)); } @@ -355,13 +362,19 @@ await _inner.SetAsync(Arg.Any(), Arg.Any>? written = null; - await _inner.SetAsync(Arg.Any(), Arg.Do>>(v => written = v), - Arg.Any(), Arg.Any(), Arg.Any()); - - await _cache.SetAsync("k", Payload, new DistributedCacheEntryOptions + await _inner.SetAsync(Arg.Any(), + Arg.Do>>(v => written = v), + Arg.Any(), + Arg.Any(), + Arg.Any()); + + await _cache.SetAsync("k", + Payload, + new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30), - }, TestContext.Current.CancellationToken); + }, + TestContext.Current.CancellationToken); Encoding.UTF8.GetString(written![AbsoluteExpirationField].Span) .Should().Be(Now.AddMinutes(30).UtcTicks.ToString(CultureInfo.InvariantCulture)); @@ -371,8 +384,11 @@ await _inner.SetAsync(Arg.Any(), Arg.Do(), Arg.Any>>(), - Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Any>>(), + Arg.Do(t => ttl = t), + Arg.Any(), + Arg.Any()); var bounded = Build(new UiPathDistributedCacheOptions { DefaultEntryExpiration = TimeSpan.FromHours(2) }); await bounded.SetAsync("k", Payload, new DistributedCacheEntryOptions(), TestContext.Current.CancellationToken); @@ -396,8 +412,10 @@ await _inner.DidNotReceive().SetAsync( [Fact] public async Task Set_with_past_absolute_expiration_throws() { - await FluentActions.Awaiting(() => _cache.SetAsync("k", Payload, - new DistributedCacheEntryOptions { AbsoluteExpiration = Now.AddMinutes(-1) }, TestContext.Current.CancellationToken)) + await FluentActions.Awaiting(() => _cache.SetAsync("k", + Payload, + new DistributedCacheEntryOptions { AbsoluteExpiration = Now.AddMinutes(-1) }, + TestContext.Current.CancellationToken)) .Should().ThrowAsync(); } @@ -411,8 +429,10 @@ public async Task Refresh_reads_metadata_only() await _cache.RefreshAsync("k", TestContext.Current.CancellationToken); await _inner.Received(1).GetAsync>( - Arg.Any(), Arg.Is(f => f != null && !f.Contains(DataField)), - Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Is(f => f != null && !f.Contains(DataField)), + Arg.Any(), + Arg.Any()); await _inner.Received(1).RefreshAsync>( Arg.Any(), Now.Add(sliding), Arg.Any(), Arg.Any()); } @@ -433,8 +453,11 @@ public async Task SlideByRewrite_extends_by_writing_the_entry_back() var sliding = TimeSpan.FromMinutes(20); StoredEntry(Entry(sliding.Ticks)); IDictionary>? written = null; - await _inner.SetAsync(Arg.Any(), Arg.Do>>(v => written = v), - Arg.Any(), Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Do>>(v => written = v), + Arg.Any(), + Arg.Any(), + Arg.Any()); var rewriting = Build(slideByRewrite: true); (await rewriting.GetAsync("k", TestContext.Current.CancellationToken)).Should().Equal(Payload); @@ -457,8 +480,11 @@ public void Sync_methods_block_on_async() public async Task Unbounded_entries_persist_instead_of_taking_a_default() { DateTimeOffset? expiration = null; - await _inner.SetAsync(Arg.Any(), Arg.Any>>(), - Arg.Do(e => expiration = e), Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Any>>(), + Arg.Do(e => expiration = e), + Arg.Any(), + Arg.Any()); var unbounded = Build(new UiPathDistributedCacheOptions { AllowUnboundedEntries = true }); await unbounded.SetAsync("k", Payload, new DistributedCacheEntryOptions(), TestContext.Current.CancellationToken); @@ -470,11 +496,16 @@ await _inner.SetAsync(Arg.Any(), Arg.Any(), Arg.Any>>(), - Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Any>>(), + Arg.Do(t => ttl = t), + Arg.Any(), + Arg.Any()); - await _cache.SetAsync("k", Payload, - new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.MaxValue }, TestContext.Current.CancellationToken); + await _cache.SetAsync("k", + Payload, + new DistributedCacheEntryOptions { SlidingExpiration = TimeSpan.MaxValue }, + TestContext.Current.CancellationToken); ttl.Should().Be(TimeSpan.MaxValue); } @@ -500,8 +531,11 @@ public async Task Empty_payload_reads_back_as_empty_not_a_miss() public async Task Unbounded_default_entry_expiration_reaches_the_backing_cache_as_the_sentinel() { TimeSpan? ttl = null; - await _inner.SetAsync(Arg.Any(), Arg.Any>>(), - Arg.Do(t => ttl = t), Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Any>>(), + Arg.Do(t => ttl = t), + Arg.Any(), + Arg.Any()); var cache = Build(new UiPathDistributedCacheOptions { DefaultEntryExpiration = TimeSpan.MaxValue }); await cache.SetAsync("k", Payload, new DistributedCacheEntryOptions(), TestContext.Current.CancellationToken); @@ -509,28 +543,6 @@ await _inner.SetAsync(Arg.Any(), Arg.Any - /// Values no write can produce, per field. Each is a miss: serving one would hand back the payload with its - /// expiration silently dropped — as an entry that never expires — which is the failure this decode exists to - /// prevent. Whitespace padding is included because the writer never emits it. - /// - public static TheoryData RejectedMetadataValues() => - [ - (string?)null, // field present, empty value — the shape a Redis miss returns - FieldOmitted, // field absent entirely - "", - " ", - "garbage", - "1.5", - "1e3", - " 1 ", - "0", // parses, but is skipped downstream rather than applied - "-2", // not the sentinel - "9223372036854775808", // one past long.MaxValue: does not parse - ]; - [Theory] [MemberData(nameof(RejectedMetadataValues))] public async Task Rejected_absolute_expiration_is_a_miss(string? value) => @@ -541,29 +553,6 @@ public async Task Rejected_absolute_expiration_is_a_miss(string? value) => public async Task Rejected_sliding_expiration_is_a_miss(string? value) => await AssertMetadataIsRejected(SlidingExpirationField, value); - private async Task AssertMetadataIsRejected(string field, string? value) - { - var entry = new Dictionary>(StringComparer.Ordinal) - { - [DataField] = Payload, - [AbsoluteExpirationField] = Ticks(null), - [SlidingExpirationField] = Ticks(null), - }; - - if (value == FieldOmitted) - { - entry.Remove(field); - } - else - { - entry[field] = value is null ? default(ReadOnlyMemory) : Encoding.UTF8.GetBytes(value); - } - - StoredEntry(entry); - - (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().BeNull(); - } - /// /// The two fields carry different quantities, so their ranges differ: an absolute deadline is a /// tick count, a sliding window a one, which reaches further. @@ -591,23 +580,6 @@ public async Task Field_ranges_follow_the_quantity_each_field_carries() (await _cache.GetAsync("k", token)).Should().Equal(Payload, "the same number is a valid TimeSpan"); } - /// - /// The expiration shapes a caller can write, as serializable parts rather than a - /// — the options type is not serializable, so passing it - /// directly leaves the runner unable to enumerate the rows individually. - /// - public static TheoryData WriteShapes() => new() - { - { null, null, null }, - { TimeSpan.FromMinutes(20), null, null }, - { TimeSpan.FromTicks(1), null, null }, - { TimeSpan.MaxValue, null, null }, - { null, TimeSpan.FromHours(2), null }, - { null, TimeSpan.MaxValue, null }, - { null, null, Now.AddDays(1) }, - { TimeSpan.FromMinutes(20), TimeSpan.FromHours(2), null }, - }; - /// /// Whatever a write produces must decode as a hit. This is the property that keeps the accepted set and the /// producible set in step: validating and decoding separately let the two rule sets drift, which is what @@ -626,10 +598,15 @@ public async Task Everything_a_write_produces_decodes_as_a_hit( }; var token = TestContext.Current.CancellationToken; IDictionary>? written = null; - await _inner.SetAsync(Arg.Any(), Arg.Do>>(v => written = v), - Arg.Any(), Arg.Any(), Arg.Any()); - await _inner.SetAsync(Arg.Any(), Arg.Do>>(v => written = v), - Arg.Any(), Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Do>>(v => written = v), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _inner.SetAsync(Arg.Any(), + Arg.Do>>(v => written = v), + Arg.Any(), + Arg.Any()); await _cache.SetAsync("k", Payload, options, token); @@ -655,4 +632,90 @@ public async Task Redis_shaped_miss_is_a_miss_not_an_empty_payload() (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().BeNull(); } + + private static byte[] Ticks(long? value) => + Encoding.UTF8.GetBytes((value ?? -1).ToString(CultureInfo.InvariantCulture)); + + private static Dictionary> Entry(long? slidingTicks = null, DateTimeOffset? absolute = null) => new() + { + [DataField] = Payload, + [AbsoluteExpirationField] = Ticks(absolute?.UtcTicks), + [SlidingExpirationField] = Ticks(slidingTicks), + }; + + + /// + /// The storage key the default strategy produces. Tests whose subject is not the key's shape go + /// through this, so "prefix plus separator" is asserted in one place — as the default, not as the + /// contract — rather than restated in every expectation. + /// + private static string Key(string callerKey) => + new PrefixCacheKeyStrategy(UiPathDistributedCacheOptions.DefaultKeyPrefix) + .GetCacheKey>(new CacheKey(callerKey, CacheKeyCasing.Sensitive)).Name; + + private UiPathDistributedCache Build( + UiPathDistributedCacheOptions? options = null, + bool slideByRewrite = false, + ICacheKeyStrategy? keyStrategy = null) => + new(_inner, + options ?? new UiPathDistributedCacheOptions(), + keyStrategy ?? new PrefixCacheKeyStrategy(UiPathDistributedCacheOptions.DefaultKeyPrefix), + policy: null, + NullLogger.Instance, + new SystemClockTimeProvider(_clock), + slideByRewrite); + + private void StoredEntry(Dictionary> fields) => + _inner.GetAsync>(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(fields); + + private async Task AssertMetadataIsRejected(string field, string? value) + { + var entry = new Dictionary>(StringComparer.Ordinal) + { + [DataField] = Payload, + [AbsoluteExpirationField] = Ticks(null), + [SlidingExpirationField] = Ticks(null), + }; + + if (value == FieldOmitted) + { + entry.Remove(field); + } + else + { + entry[field] = value is null ? default(ReadOnlyMemory) : Encoding.UTF8.GetBytes(value); + } + + StoredEntry(entry); + + (await _cache.GetAsync("k", TestContext.Current.CancellationToken)).Should().BeNull(); + } + + private sealed class SuffixKeyStrategy(string suffix) : ICacheKeyStrategy + { + public CacheKey GetCacheKey(CacheKey key) => key.WithName(key.Name + suffix); + } + + private sealed class AmbientCasingKeyStrategy : ICacheKeyStrategy + { + public CacheKey GetCacheKey(CacheKey key) => new(key.Name, CacheKeyCasing.Insensitive); + } + + private sealed class EmptyKeyStrategy : ICacheKeyStrategy + { + public CacheKey GetCacheKey(CacheKey key) => default; + } + + private sealed class CapturingLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) => + Messages.Add(formatter(state, exception)); + } } diff --git a/tests/UiPath.Caching.Tests/Fakes/DictionaryCache.cs b/tests/UiPath.Caching.Tests/Fakes/DictionaryCache.cs index 79798c9c..d93073ca 100644 --- a/tests/UiPath.Caching.Tests/Fakes/DictionaryCache.cs +++ b/tests/UiPath.Caching.Tests/Fakes/DictionaryCache.cs @@ -37,22 +37,6 @@ internal sealed class DictionaryCache : ICache return ValueTask.FromResult(results); } - public ValueTask SetAsync(KeyValuePair[] keyValues, CachePolicy? policy, CancellationToken token = default) - { - SetCalls++; - SetKeySets.Add(keyValues.Select(kv => kv.Key).ToArray()); - foreach (var kv in keyValues) - { - if (kv.Value is null && !CacheNullValues) - { - _store.Remove(kv.Key); - continue; - } - _store[kv.Key] = kv.Value; - } - return ValueTask.FromResult(true); - } - public ValueTask> GetCacheEntryAsync(CacheKey cacheKey, CachePolicy? policy, CancellationToken token = default) => ValueTask.FromResult>(_store.TryGetValue(cacheKey, out var v) ? new TestCacheEntry { Value = (T?)v, Expiration = DateTimeOffset.MaxValue, Found = true } @@ -88,6 +72,22 @@ public ValueTask SetAsync(KeyValuePair[] keyValues, TimeS public ValueTask SetAsync(KeyValuePair[] keyValues, DateTimeOffset expiration, CachePolicy? policy, CancellationToken token = default) => SetAsync(keyValues, policy, token); + public ValueTask SetAsync(KeyValuePair[] keyValues, CachePolicy? policy, CancellationToken token = default) + { + SetCalls++; + SetKeySets.Add(keyValues.Select(kv => kv.Key).ToArray()); + foreach (var kv in keyValues) + { + if (kv.Value is null && !CacheNullValues) + { + _store.Remove(kv.Key); + continue; + } + _store[kv.Key] = kv.Value; + } + return ValueTask.FromResult(true); + } + public ValueTask TryAddAsync(CacheKey cacheKey, T? value, CachePolicy? policy, CancellationToken token = default) { TryAddCalls++; diff --git a/tests/UiPath.Caching.Tests/GenerationDepthBehavior.cs b/tests/UiPath.Caching.Tests/GenerationDepthBehavior.cs index 2789e11c..6e4824f0 100644 --- a/tests/UiPath.Caching.Tests/GenerationDepthBehavior.cs +++ b/tests/UiPath.Caching.Tests/GenerationDepthBehavior.cs @@ -1,15 +1,20 @@ -using System.Collections; +using System.Collections; using System.Diagnostics; using AutoFixture.Kernel; namespace UiPath.Caching.Tests; #nullable disable +public interface IGenerationDepthHandler +{ + object HandleGenerationDepthLimitRequest(object request, IEnumerable recordedRequests, int depth); +} + // from https://stackoverflow.com/questions/19951272/controlling-the-depth-of-generation-of-an-object-tree-with-autofixture/50118981#50118981 [DebuggerStepThrough] public class GenerationDepthBehavior : ISpecimenBuilderTransformation { private const int DefaultGenerationDepth = 1; - private readonly int generationDepth; + private readonly int _generationDepth; public GenerationDepthBehavior() : this(DefaultGenerationDepth) { @@ -18,34 +23,24 @@ public GenerationDepthBehavior() : this(DefaultGenerationDepth) public GenerationDepthBehavior(int generationDepth) { if (generationDepth < 1) + { throw new ArgumentOutOfRangeException(nameof(generationDepth), "Generation depth must be greater than 0."); + } - this.generationDepth = generationDepth; + _generationDepth = generationDepth; } public ISpecimenBuilderNode Transform(ISpecimenBuilder builder) { - if (builder == null) throw new ArgumentNullException(nameof(builder)); + ArgumentNullException.ThrowIfNull(builder); - return new GenerationDepthGuard(builder, new GenerationDepthHandler(), generationDepth); + return new GenerationDepthGuard(builder, new GenerationDepthHandler(), _generationDepth); } } -public interface IGenerationDepthHandler -{ - object HandleGenerationDepthLimitRequest(object request, IEnumerable recordedRequests, int depth); -} - [DebuggerStepThrough] public class DepthSeededRequest : SeededRequest { - public int Depth { get; } - - public int MaxDepth { get; set; } - - public bool ContinueSeed { get; } - - public int GenerationLevel { get; private set; } public DepthSeededRequest(object request, object seed, int depth) : base(request, seed) { @@ -65,6 +60,13 @@ public DepthSeededRequest(object request, object seed, int depth) : base(request } } } + public int Depth { get; } + + public int MaxDepth { get; set; } + + public bool ContinueSeed { get; } + + public int GenerationLevel { get; private set; } private int GetGenerationLevel(Type innerRequest) { @@ -92,11 +94,9 @@ private int GetGenerationLevel(Type innerRequest) [DebuggerStepThrough] public class GenerationDepthGuard : ISpecimenBuilderNode { - private readonly ThreadLocal> requestsByThread + private readonly ThreadLocal> _requestsByThread = new ThreadLocal>(() => new Stack()); - private Stack GetMonitoredRequestsForCurrentThread() => requestsByThread.Value; - public GenerationDepthGuard(ISpecimenBuilder builder) : this(builder, EqualityComparer.Default) { @@ -150,11 +150,16 @@ public GenerationDepthGuard( IEqualityComparer comparer, int generationDepth) { - if (builder == null) throw new ArgumentNullException(nameof(builder)); - if (depthHandler == null) throw new ArgumentNullException(nameof(depthHandler)); - if (comparer == null) throw new ArgumentNullException(nameof(comparer)); + ArgumentNullException.ThrowIfNull(builder); + + ArgumentNullException.ThrowIfNull(depthHandler); + + ArgumentNullException.ThrowIfNull(comparer); + if (generationDepth < 1) + { throw new ArgumentOutOfRangeException(nameof(generationDepth), "Generation depth must be greater than 0."); + } Builder = builder; GenerationDepthHandler = depthHandler; @@ -178,7 +183,8 @@ public virtual object HandleGenerationDepthLimitRequest(object request, int curr { return GenerationDepthHandler.HandleGenerationDepthLimitRequest( request, - GetMonitoredRequestsForCurrentThread(), currentDepth); + GetMonitoredRequestsForCurrentThread(), + currentDepth); } public object Create(object request, ISpecimenContext context) @@ -236,6 +242,16 @@ public virtual ISpecimenBuilderNode Compose( GenerationDepth); } + public virtual IEnumerator GetEnumerator() + { + yield return Builder; + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + internal static ISpecimenBuilder ComposeIfMultiple(IEnumerable builders) { ISpecimenBuilder singleItem = null; @@ -274,15 +290,7 @@ internal static ISpecimenBuilder ComposeIfMultiple(IEnumerable return new CompositeSpecimenBuilder(multipleItems); } - public virtual IEnumerator GetEnumerator() - { - yield return Builder; - } - - IEnumerator IEnumerable.GetEnumerator() - { - return GetEnumerator(); - } + private Stack GetMonitoredRequestsForCurrentThread() => _requestsByThread.Value; } [DebuggerStepThrough] @@ -290,7 +298,8 @@ public class GenerationDepthHandler : IGenerationDepthHandler { public object HandleGenerationDepthLimitRequest( object request, - IEnumerable recordedRequests, int depth) + IEnumerable recordedRequests, + int depth) { return new OmitSpecimen(); } diff --git a/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs b/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs index 60703567..3f7b16a4 100644 --- a/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs +++ b/tests/UiPath.Caching.Tests/InMemorySetCacheTests.cs @@ -7,32 +7,14 @@ namespace UiPath.Caching.Tests; // tier is the storage — the set analog of InMemoryCacheProvider serving MultilayerCache over NullCache. public class InMemorySetCacheTests { - private static CancellationToken Ct => TestContext.Current.CancellationToken; // Hoisted out of the call below so the array is not rebuilt per invocation (CA1861). private static readonly string[] OneItem = ["a"]; - - private static MultilayerSetCache CreateSut(InMemoryQueueCacheOptions? options = null, TimeProvider? clock = null) - { - options ??= new InMemoryQueueCacheOptions(); - var cacheClock = clock ?? TimeProvider.System; - return new MultilayerSetCache( - KnownCacheProviderNames.InMemory, NullSetCache.Instance, - new MemoryCacheFactory(cacheClock, NullLoggerFactory.Instance), - new SystemJsonByteSerializerProxy(), options, - NullLocalLock.Instance, cacheClock); - } - - // 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); + private static CancellationToken Ct => TestContext.Current.CancellationToken; [Fact] public void Name_is_InMemory() => CreateSut().Name.Should().Be("InMemory"); - private sealed record Member(int Id, string Name); - /// /// The snapshot is keyed on the serializer's byte[] output, which compares by reference. /// A populated local tier is authoritative, so without structural equality the wrong answer is @@ -46,10 +28,13 @@ private sealed record Member(int Id, string Name); public async Task A_member_mutated_after_being_added_does_not_corrupt_the_snapshot() { var sut = new MultilayerSetCache( - KnownCacheProviderNames.InMemory, NullSetCache.Instance, + KnownCacheProviderNames.InMemory, + NullSetCache.Instance, new MemoryCacheFactory(TimeProvider.System, NullLoggerFactory.Instance), - new RawByteSerializerProxy(), new InMemoryQueueCacheOptions { DefaultExpiration = null }, - NullLocalLock.Instance, TimeProvider.System); + new RawByteSerializerProxy(), + new InMemoryQueueCacheOptions { DefaultExpiration = null }, + NullLocalLock.Instance, + TimeProvider.System); var payload = new byte[] { 1, 2, 3 }; (await sut.AddAsync("k", payload, (CachePolicy?)null, Ct)).Should().BeTrue(); @@ -245,4 +230,25 @@ public void Dispose_can_be_called() var act = () => sut.Dispose(); act.Should().NotThrow(); } + + private static MultilayerSetCache CreateSut(InMemoryQueueCacheOptions? options = null, TimeProvider? clock = null) + { + options ??= new InMemoryQueueCacheOptions(); + var cacheClock = clock ?? TimeProvider.System; + return new MultilayerSetCache( + KnownCacheProviderNames.InMemory, + NullSetCache.Instance, + new MemoryCacheFactory(cacheClock, NullLoggerFactory.Instance), + new SystemJsonByteSerializerProxy(), + options, + NullLocalLock.Instance, + cacheClock); + } + + // 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); + + private sealed record Member(int Id, string Name); } diff --git a/tests/UiPath.Caching.Tests/LegacySerializerWireCompatTests.cs b/tests/UiPath.Caching.Tests/LegacySerializerWireCompatTests.cs index e9849536..8d356d22 100644 --- a/tests/UiPath.Caching.Tests/LegacySerializerWireCompatTests.cs +++ b/tests/UiPath.Caching.Tests/LegacySerializerWireCompatTests.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Text.Json; using StackExchange.Redis; namespace UiPath.Caching.Tests; @@ -9,18 +9,9 @@ namespace UiPath.Caching.Tests; /// public class LegacySerializerWireCompatTests { - private sealed record Sample(int Id, string Name, int[] Values); private readonly SystemJsonByteSerializerProxy _proxy = new(); - /// The 1.x proxy's own output: JsonSerializer.SerializeToUtf8Bytes. - private static byte[] WrittenByLegacyProxy(object? value) => - JsonSerializer.SerializeToUtf8Bytes(value); - - /// A custom 1.x serializer returning a string-backed RedisValue, as the docs blessed. - private static byte[] WrittenByLegacyStringPath(object? value) => - ((byte[]?)(RedisValue)JsonSerializer.Serialize(value))!; - [Fact] public void Poco_written_by_the_legacy_proxy_still_reads() { @@ -103,4 +94,13 @@ public void A_payload_that_is_not_json_surfaces_as_an_exception_for_typed_reads( act.Should().Throw(); } + + /// The 1.x proxy's own output: JsonSerializer.SerializeToUtf8Bytes. + private static byte[] WrittenByLegacyProxy(object? value) => + JsonSerializer.SerializeToUtf8Bytes(value); + + /// A custom 1.x serializer returning a string-backed RedisValue, as the docs blessed. + private static byte[] WrittenByLegacyStringPath(object? value) => + ((byte[]?)(RedisValue)JsonSerializer.Serialize(value))!; + private sealed record Sample(int Id, string Name, int[] Values); } diff --git a/tests/UiPath.Caching.Tests/LocalCacheSetterTests.cs b/tests/UiPath.Caching.Tests/LocalCacheSetterTests.cs index 565d9402..40735c48 100644 --- a/tests/UiPath.Caching.Tests/LocalCacheSetterTests.cs +++ b/tests/UiPath.Caching.Tests/LocalCacheSetterTests.cs @@ -34,7 +34,7 @@ public void Setter_inner_exception() { _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions { - Clock = _clock + Clock = _clock, })); _fixture.Inject(_memoryCache); @@ -63,7 +63,7 @@ public void Setter_max_duration() { _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions { - Clock = _clock + Clock = _clock, })); _fixture.Inject(_memoryCache); diff --git a/tests/UiPath.Caching.Tests/Locking/AsyncKeyedLocalLockTests.cs b/tests/UiPath.Caching.Tests/Locking/AsyncKeyedLocalLockTests.cs index d40fa237..c198d01e 100644 --- a/tests/UiPath.Caching.Tests/Locking/AsyncKeyedLocalLockTests.cs +++ b/tests/UiPath.Caching.Tests/Locking/AsyncKeyedLocalLockTests.cs @@ -4,8 +4,6 @@ namespace UiPath.Caching.Tests.Locking; public class AsyncKeyedLocalLockTests(ITestContextAccessor testContextAccessor) { - private static AsyncKeyedLocalLock NewLocker() => - new(Options.Create(new CacheOptions())); [Fact] public async Task Acquire_returns_disposable_that_releases_on_dispose() @@ -55,7 +53,10 @@ async Task Worker() do { observedSnapshot = Volatile.Read(ref maxObserved); - if (current <= observedSnapshot) break; + if (current <= observedSnapshot) + { + break; + } } while (Interlocked.CompareExchange(ref maxObserved, current, observedSnapshot) != observedSnapshot); @@ -171,4 +172,6 @@ public async Task Acquire_after_cancellation_still_blocks_while_holder_is_alive( holder.Dispose(); } + private static AsyncKeyedLocalLock NewLocker() => + new(Options.Create(new CacheOptions())); } diff --git a/tests/UiPath.Caching.Tests/Locking/CacheOptionsLockValidatorTests.cs b/tests/UiPath.Caching.Tests/Locking/CacheOptionsLockValidatorTests.cs index 5f60655b..5a8f9968 100644 --- a/tests/UiPath.Caching.Tests/Locking/CacheOptionsLockValidatorTests.cs +++ b/tests/UiPath.Caching.Tests/Locking/CacheOptionsLockValidatorTests.cs @@ -4,13 +4,6 @@ namespace UiPath.Caching.Tests.Locking; public class CacheOptionsLockValidatorTests { - private static CacheOptions Valid() => new() - { - LocalLockPoolSize = 100, - LocalLockPoolInitialFill = 10, - DistributedLockPollInterval = TimeSpan.FromMilliseconds(50), - DistributedLockMaxPollInterval = TimeSpan.FromMilliseconds(500), - }; [Fact] public void Succeeds_for_valid_options() @@ -85,4 +78,11 @@ public void Fails_when_DistributedLockMaxPollInterval_is_less_than_DistributedLo result.Failed.Should().BeTrue(); result.FailureMessage.Should().Contain(nameof(CacheOptions.DistributedLockMaxPollInterval)); } + private static CacheOptions Valid() => new() + { + LocalLockPoolSize = 100, + LocalLockPoolInitialFill = 10, + DistributedLockPollInterval = TimeSpan.FromMilliseconds(50), + DistributedLockMaxPollInterval = TimeSpan.FromMilliseconds(500), + }; } diff --git a/tests/UiPath.Caching.Tests/Locking/MultilayerCacheBatchGetOrAddLockTests.cs b/tests/UiPath.Caching.Tests/Locking/MultilayerCacheBatchGetOrAddLockTests.cs index 00e68bfd..02956e14 100644 --- a/tests/UiPath.Caching.Tests/Locking/MultilayerCacheBatchGetOrAddLockTests.cs +++ b/tests/UiPath.Caching.Tests/Locking/MultilayerCacheBatchGetOrAddLockTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Memory; using UiPath.Caching.Locking; using UiPath.Caching.Tests.Broadcast; @@ -7,13 +7,17 @@ namespace UiPath.Caching.Tests.Locking; public class MultilayerCacheBatchGetOrAddLockTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { - private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private static readonly long[] States1 = [1L]; private static readonly long[] States2 = [2L]; private static readonly long[] States1And2 = [1L, 2L]; private static readonly string?[] V1AndV2 = ["v:1", "v:2"]; private static readonly string?[] RefilledAAndGen2 = ["refilled:a", "gen:2"]; + private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + + private readonly object _sutLock = new(); + + private readonly ConcurrentDictionary _stored = new(); private ICache _innerCache = default!; private MemoryCache _memoryCache = default!; @@ -29,19 +33,19 @@ public class MultilayerCacheBatchGetOrAddLockTests(ITestContextAccessor testCont private InMemoryRedisCacheOptions _options = default!; private TopicKey _topicKey = default!; private MultilayerCache? _sut; - - private readonly object _sutLock = new(); private MultilayerCache Sut { get { - if (_sut is not null) return _sut; + if (_sut is not null) + { + return _sut; + } + lock (_sutLock) { return _sut ??= _fixture.Create(); } } } - private readonly ConcurrentDictionary _stored = new(); - [Fact] public async Task Concurrent_identical_batches_invoke_the_generator_once() { @@ -57,7 +61,11 @@ public async Task Concurrent_identical_batches_invoke_the_generator_once() { var inside = Interlocked.Increment(ref concurrent); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); firstEntered.TrySetResult(); await release.Task.WaitAsync(TimeSpan.FromSeconds(30), ct); @@ -165,7 +173,11 @@ async Task Track(CancellationToken ct) { var inside = Interlocked.Increment(ref concurrent); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); if (Interlocked.Increment(ref started) == 2) { bothStarted.TrySetResult(); } await Task.WhenAny(bothStarted.Task, Task.Delay(TimeSpan.FromSeconds(2), ct)).ConfigureAwait(false); @@ -175,11 +187,13 @@ async Task Track(CancellationToken ct) var batch = Task.Run(async () => await Sut.GetOrAddAsync( [new((CacheKey)"a", 1L)], async (ids, ct) => { await Track(ct); return ids.Select(id => new KeyValuePair(id, "v")).ToArray(); }, - (CachePolicy?)null, token)); + (CachePolicy?)null, + token)); var single = Task.Run(async () => await Sut.GetOrAddAsync( (CacheKey)"a", async ct => { await Track(ct); return "v"; }, - (CachePolicy?)null, token)); + (CachePolicy?)null, + token)); await Task.WhenAll(batch, single); @@ -254,11 +268,6 @@ public ValueTask InitializeAsync() return ValueTask.CompletedTask; } - private TestCacheEntry Entry(CacheKey key) => - _stored.TryGetValue(key, out var value) - ? new TestCacheEntry { Value = value, Expiration = DateTimeOffset.UtcNow.AddMinutes(10), Found = true } - : new TestCacheEntry { Value = null, Expiration = DateTimeOffset.MinValue }; - public ValueTask DisposeAsync() { _locker?.Dispose(); @@ -266,4 +275,9 @@ public ValueTask DisposeAsync() GC.SuppressFinalize(this); return ValueTask.CompletedTask; } + + private TestCacheEntry Entry(CacheKey key) => + _stored.TryGetValue(key, out var value) + ? new TestCacheEntry { Value = value, Expiration = DateTimeOffset.UtcNow.AddMinutes(10), Found = true } + : new TestCacheEntry { Value = null, Expiration = DateTimeOffset.MinValue }; } diff --git a/tests/UiPath.Caching.Tests/Locking/MultilayerCacheGetOrAddLockTests.cs b/tests/UiPath.Caching.Tests/Locking/MultilayerCacheGetOrAddLockTests.cs index 46756af3..5e9c30a1 100644 --- a/tests/UiPath.Caching.Tests/Locking/MultilayerCacheGetOrAddLockTests.cs +++ b/tests/UiPath.Caching.Tests/Locking/MultilayerCacheGetOrAddLockTests.cs @@ -7,6 +7,7 @@ namespace UiPath.Caching.Tests.Locking; public class MultilayerCacheGetOrAddLockTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly object _sutLock = new(); private ICache _innerCache = default!; private IChangeTokenFactory _changeTokenFactory = default!; @@ -23,12 +24,15 @@ public class MultilayerCacheGetOrAddLockTests(ITestContextAccessor testContextAc private AsyncKeyedLocalLock _locker = default!; private MultilayerCache? _sut; - private readonly object _sutLock = new(); private MultilayerCache Sut { get { - if (_sut is not null) return _sut; + if (_sut is not null) + { + return _sut; + } + lock (_sutLock) { return _sut ??= _fixture.Create(); } } } @@ -60,7 +64,11 @@ public async Task GetOrAddAsync_serializes_concurrent_generator_invocations_for_ { var inside = Interlocked.Increment(ref concurrentInGenerator); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); firstEntered.TrySetResult(); await release.Task.WaitAsync(TimeSpan.FromSeconds(30), ct); @@ -106,7 +114,11 @@ public async Task GetOrAddAsync_does_not_block_concurrent_callers_on_different_k { var inside = Interlocked.Increment(ref concurrentInGenerator); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); if (Interlocked.Increment(ref startedCount) == 2) @@ -223,7 +235,11 @@ public async Task GetOrAddAsync_falls_through_to_generator_when_local_lock_is_di { var inside = Interlocked.Increment(ref concurrentInGenerator); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); if (Interlocked.Increment(ref startedCount) == concurrentCallers) diff --git a/tests/UiPath.Caching.Tests/Locking/MultilayerCacheLockCrossOptionsValidatorTests.cs b/tests/UiPath.Caching.Tests/Locking/MultilayerCacheLockCrossOptionsValidatorTests.cs index 40947e7e..9d19912c 100644 --- a/tests/UiPath.Caching.Tests/Locking/MultilayerCacheLockCrossOptionsValidatorTests.cs +++ b/tests/UiPath.Caching.Tests/Locking/MultilayerCacheLockCrossOptionsValidatorTests.cs @@ -4,8 +4,6 @@ namespace UiPath.Caching.Tests.Locking; public class MultilayerCacheLockCrossOptionsValidatorTests { - private static MultilayerCacheLockCrossOptionsValidator NewSut(CacheOptions? cacheOptions = null) => - new(Options.Create(cacheOptions ?? new CacheOptions())); [Fact] public void Succeeds_for_default_options() @@ -73,4 +71,6 @@ public void Succeeds_when_DistributedLockMaxPollInterval_exceeds_DistributedLock }); result.Succeeded.Should().BeTrue(); } + private static MultilayerCacheLockCrossOptionsValidator NewSut(CacheOptions? cacheOptions = null) => + new(Options.Create(cacheOptions ?? new CacheOptions())); } diff --git a/tests/UiPath.Caching.Tests/Locking/MultilayerHashCacheGetOrAddLockTests.cs b/tests/UiPath.Caching.Tests/Locking/MultilayerHashCacheGetOrAddLockTests.cs index 84bff992..c5ce2d40 100644 --- a/tests/UiPath.Caching.Tests/Locking/MultilayerHashCacheGetOrAddLockTests.cs +++ b/tests/UiPath.Caching.Tests/Locking/MultilayerHashCacheGetOrAddLockTests.cs @@ -7,6 +7,7 @@ namespace UiPath.Caching.Tests.Locking; public class MultilayerHashCacheGetOrAddLockTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly object _sutLock = new(); private IHashCache _innerCache = default!; private IChangeTokenFactory _changeTokenFactory = default!; @@ -23,12 +24,15 @@ public class MultilayerHashCacheGetOrAddLockTests(ITestContextAccessor testConte private AsyncKeyedLocalLock _locker = default!; private MultilayerHashCache? _sut; - private readonly object _sutLock = new(); private MultilayerHashCache Sut { get { - if (_sut is not null) return _sut; + if (_sut is not null) + { + return _sut; + } + lock (_sutLock) { return _sut ??= _fixture.Create(); } } } @@ -65,7 +69,11 @@ public async Task GetOrAddAsync_runs_generator_exactly_once_under_concurrent_cal { var inside = Interlocked.Increment(ref concurrentInGenerator); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); firstEntered.TrySetResult(); await release.Task.WaitAsync(TimeSpan.FromSeconds(30), ct); @@ -111,7 +119,11 @@ public async Task GetOrAddAsync_does_not_block_concurrent_callers_on_different_k { var inside = Interlocked.Increment(ref concurrentInGenerator); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); if (Interlocked.Increment(ref startedCount) == 2) @@ -154,7 +166,11 @@ public async Task GetOrAddAsync_falls_through_to_generator_when_local_lock_is_di { var inside = Interlocked.Increment(ref concurrentInGenerator); int observed; - do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) break; } + do { observed = Volatile.Read(ref maxConcurrent); if (inside <= observed) + { + break; + } + } while (Interlocked.CompareExchange(ref maxConcurrent, inside, observed) != observed); if (Interlocked.Increment(ref startedCount) == concurrentCallers) diff --git a/tests/UiPath.Caching.Tests/Locking/RedisDistributedLockTests.cs b/tests/UiPath.Caching.Tests/Locking/RedisDistributedLockTests.cs index 9da5273f..8e6eef5e 100644 --- a/tests/UiPath.Caching.Tests/Locking/RedisDistributedLockTests.cs +++ b/tests/UiPath.Caching.Tests/Locking/RedisDistributedLockTests.cs @@ -12,13 +12,6 @@ public class RedisDistributedLockTests(ITestContextAccessor testContextAccessor) private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); - private RedisDistributedLock NewLock(IRedisConnector? redis = null, CacheOptions? options = null, ICachingTelemetryProvider? telemetry = null) - { - redis ??= _fixture.Freeze(); - var opts = Options.Create(options ?? new CacheOptions()); - return new RedisDistributedLock(redis, opts, telemetry ?? NullTelemetryProvider.Instance); - } - [Fact] public async Task Acquire_returns_disposable_when_LockTake_succeeds() { @@ -380,4 +373,11 @@ public void Ctor_throws_when_DistributedLockMaxPollInterval_is_less_than_Distrib act.Should().Throw() .Which.ParamName.Should().Be("cacheOptions.DistributedLockMaxPollInterval"); } + + private RedisDistributedLock NewLock(IRedisConnector? redis = null, CacheOptions? options = null, ICachingTelemetryProvider? telemetry = null) + { + redis ??= _fixture.Freeze(); + var opts = Options.Create(options ?? new CacheOptions()); + return new RedisDistributedLock(redis, opts, telemetry ?? NullTelemetryProvider.Instance); + } } diff --git a/tests/UiPath.Caching.Tests/Logging/KeyMaskingTests.cs b/tests/UiPath.Caching.Tests/Logging/KeyMaskingTests.cs index 21f7e81a..b33fbdac 100644 --- a/tests/UiPath.Caching.Tests/Logging/KeyMaskingTests.cs +++ b/tests/UiPath.Caching.Tests/Logging/KeyMaskingTests.cs @@ -8,8 +8,6 @@ namespace UiPath.Caching.Tests.Logging; public class KeyMaskingTests { - private static string Render(IKeyMaskingPolicy policy, string key, string? composed = null, Type? valueType = null) => - new KeyMasker(policy, KnownCacheProviderNames.Redis).Render(key, composed, valueType); [Fact] public void Nothing_is_masked_without_a_policy() @@ -143,6 +141,8 @@ public void AddKeyMasking_takes_a_policy_of_your_own() provider.GetRequiredService().Should().BeOfType(); } + private static string Render(IKeyMaskingPolicy policy, string key, string? composed = null, Type? valueType = null) => + new KeyMasker(policy, KnownCacheProviderNames.Redis).Render(key, composed, valueType); private sealed class ThrowingPolicy : IKeyMaskingPolicy { diff --git a/tests/UiPath.Caching.Tests/Logging/MaskedLogSiteTests.cs b/tests/UiPath.Caching.Tests/Logging/MaskedLogSiteTests.cs index 84ff6783..16611162 100644 --- a/tests/UiPath.Caching.Tests/Logging/MaskedLogSiteTests.cs +++ b/tests/UiPath.Caching.Tests/Logging/MaskedLogSiteTests.cs @@ -11,25 +11,6 @@ public class MaskedLogSiteTests { private const string SecretKey = "session:cosmin"; - private static ServiceProvider BuildContainer(bool masked, CapturingLoggerProvider logs) => - new ServiceCollection() - .AddLogging(b => b.AddProvider(logs).SetMinimumLevel(LogLevel.Trace)) - .AddCaching( - b => - { - b.AddMemory(_ => { }); - if (masked) - { - b.AddKeyMasking(); - } - }, - o => - { - o.AppShortName = "app"; - o.DefaultCache = KnownCacheProviderNames.InMemory; - }) - .BuildServiceProvider(); - [Theory] [InlineData(true)] [InlineData(false)] @@ -75,6 +56,25 @@ public void The_change_token_masks_the_key_it_waits_on() logs.Lines.Should().Contain(l => l.Contains("ses****")).And.NotContain(l => l.Contains(SecretKey)); } + private static ServiceProvider BuildContainer(bool masked, CapturingLoggerProvider logs) => + new ServiceCollection() + .AddLogging(b => b.AddProvider(logs).SetMinimumLevel(LogLevel.Trace)) + .AddCaching( + b => + { + b.AddMemory(_ => { }); + if (masked) + { + b.AddKeyMasking(); + } + }, + o => + { + o.AppShortName = "app"; + o.DefaultCache = KnownCacheProviderNames.InMemory; + }) + .BuildServiceProvider(); + private sealed class CapturingLoggerProvider : ILoggerProvider { private readonly List _lines = []; diff --git a/tests/UiPath.Caching.Tests/MemoryCacheFactoryTests.cs b/tests/UiPath.Caching.Tests/MemoryCacheFactoryTests.cs index 0bcc6541..29cea2c0 100644 --- a/tests/UiPath.Caching.Tests/MemoryCacheFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/MemoryCacheFactoryTests.cs @@ -1,10 +1,14 @@ -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging.Abstractions; namespace UiPath.Caching.Tests; public class MemoryCacheFactoryTests { + + private static readonly DateTimeOffset Deadline = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); + private static readonly ISystemClock Before = new FakeClock(Deadline.AddYears(-1)); + private static readonly ISystemClock After = new FakeClock(Deadline.AddYears(1)); private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); [Fact] @@ -16,14 +20,14 @@ public void MemoryCacheFactory_CanCreateMemoryCache_with_size_limit() var memoryOptions = new MemoryCacheOptions { SizeLimit = 1, - CompactionPercentage = 0.1 + CompactionPercentage = 0.1, }; // Act var memoryCache = factory.Get(memoryOptions); memoryCache.Should().NotBeNull(); var act = () => memoryCache.Set("testKey", "testValue",new MemoryCacheEntryOptions { - AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(5) + AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(5), }); act.Should().Throw(); } @@ -37,7 +41,7 @@ public void MemoryCacheFactory_CanCreateMemoryCache_with_size_limit_set() var memoryOptions = new MemoryCacheOptions { SizeLimit = 1, - CompactionPercentage = 0.1 + CompactionPercentage = 0.1, }; // Act var memoryCache = factory.Get(memoryOptions); @@ -45,7 +49,7 @@ public void MemoryCacheFactory_CanCreateMemoryCache_with_size_limit_set() var act = () => memoryCache.Set("testKey", "testValue", new MemoryCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(5), - Size = 1 + Size = 1, }); act.Should().NotThrow(); } @@ -64,7 +68,7 @@ public void MemoryCacheFactory_CanCreateMemoryCache_with_size_no_limit() memoryCache.Should().NotBeNull(); var act = () => memoryCache.Set("testKey", "testValue", new MemoryCacheEntryOptions { - AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(5) + AbsoluteExpiration = DateTimeOffset.UtcNow.AddMinutes(5), }); act.Should().NotThrow(); } @@ -75,11 +79,6 @@ public void The_memory_cache_judges_deadlines_on_the_injected_clock() IsLiveUnder(Before).Should().BeTrue("the clock is a year before the deadline"); IsLiveUnder(After).Should().BeFalse("the clock is a year past the deadline"); } - - private static readonly DateTimeOffset Deadline = new(2025, 1, 1, 0, 0, 0, TimeSpan.Zero); - private static readonly ISystemClock Before = new FakeClock(Deadline.AddYears(-1)); - private static readonly ISystemClock After = new FakeClock(Deadline.AddYears(1)); - private static bool IsLiveUnder(ISystemClock clock) { var cache = new MemoryCacheFactory(new SystemClockTimeProvider(clock), NullLoggerFactory.Instance).Get(new MemoryCacheOptions()); @@ -87,11 +86,6 @@ private static bool IsLiveUnder(ISystemClock clock) return cache.TryGetValue("k", out _); } - private sealed class FakeClock(DateTimeOffset now) : ISystemClock - { - public DateTimeOffset UtcNow { get; } = now; - } - public class MemoryCacheOptions : IMemoryCacheOptions { public bool TrackStatistics { get; set; } @@ -104,4 +98,9 @@ public class MemoryCacheOptions : IMemoryCacheOptions public ICacheEntrySizeProvider? SizeProvider { get; set; } } + + private sealed class FakeClock(DateTimeOffset now) : ISystemClock + { + public DateTimeOffset UtcNow { get; } = now; + } } diff --git a/tests/UiPath.Caching.Tests/MemoryCacheSetterTests.cs b/tests/UiPath.Caching.Tests/MemoryCacheSetterTests.cs index d326adf2..6fad8319 100644 --- a/tests/UiPath.Caching.Tests/MemoryCacheSetterTests.cs +++ b/tests/UiPath.Caching.Tests/MemoryCacheSetterTests.cs @@ -36,7 +36,7 @@ public void Setter_inner_exception() { _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions { - Clock = _clock + Clock = _clock, })); _fixture.Inject(_memoryCache); @@ -45,7 +45,7 @@ public void Setter_inner_exception() ActiveChangeCallbacks = true, HasChanged = false, Expiration = _clock.UtcNow.AddDays(1), - TransportId = "1234567890" + TransportId = "1234567890", }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token, c=> throw new Exception()); @@ -66,7 +66,7 @@ public void Setter_emits_failure_event() { _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions { - Clock = _clock + Clock = _clock, })); _fixture.Inject(_memoryCache); @@ -113,7 +113,7 @@ public void RefreshMetadata_swallows_NewEntry_exception_and_emits_failure_event( { _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions { - Clock = _clock + Clock = _clock, })); _fixture.Inject(_memoryCache); @@ -152,7 +152,7 @@ public void Setter_max_duration() { _memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions { - Clock = _clock + Clock = _clock, })); _fixture.Inject(_memoryCache); diff --git a/tests/UiPath.Caching.Tests/MultilayerCacheBatchGetOrAddTests.cs b/tests/UiPath.Caching.Tests/MultilayerCacheBatchGetOrAddTests.cs index e6ecf296..c01172bb 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCacheBatchGetOrAddTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCacheBatchGetOrAddTests.cs @@ -1,11 +1,10 @@ -using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Caching.Memory; using UiPath.Caching.Locking; namespace UiPath.Caching.Tests; public class MultilayerCacheBatchGetOrAddTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { - private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private static readonly long[] States1 = [1L]; private static readonly long[] States7 = [7L]; @@ -19,6 +18,11 @@ public class MultilayerCacheBatchGetOrAddTests(ITestContextAccessor testContextA private static readonly string?[] Gen1Twice = ["gen:1", "gen:1"]; private static readonly int[] SeededOfTen = [2, 5, 9]; private static readonly long[] MissingOfTen = [1L, 3L, 4L, 6L, 7L, 8L, 10L]; + private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + + private readonly Dictionary _stored = []; + private readonly List _generatorCalls = []; + private readonly List _innerSetCalls = []; private ICache _innerCache = default!; private MemoryCache _memoryCache = default!; @@ -37,16 +41,6 @@ public class MultilayerCacheBatchGetOrAddTests(ITestContextAccessor testContextA private MultilayerCache Sut => _sut ??= _fixture.Create(); - private readonly Dictionary _stored = []; - private readonly List _generatorCalls = []; - private readonly List _innerSetCalls = []; - - private Task[]> Generate(long[] states, CancellationToken _) - { - _generatorCalls.Add(states); - return Task.FromResult(states.Select(s => new KeyValuePair(s, "gen:" + s)).ToArray()); - } - [Fact] public async Task Generator_receives_only_missing_states_and_runs_once() { @@ -364,4 +358,10 @@ public ValueTask DisposeAsync() GC.SuppressFinalize(this); return ValueTask.CompletedTask; } + + private Task[]> Generate(long[] states, CancellationToken _) + { + _generatorCalls.Add(states); + return Task.FromResult(states.Select(s => new KeyValuePair(s, "gen:" + s)).ToArray()); + } } diff --git a/tests/UiPath.Caching.Tests/MultilayerCacheBatchRehydrateTests.cs b/tests/UiPath.Caching.Tests/MultilayerCacheBatchRehydrateTests.cs index d1631c53..7a91e8f2 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCacheBatchRehydrateTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCacheBatchRehydrateTests.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Memory; using UiPath.Caching.Locking; using UiPath.Caching.Telemetry; @@ -8,13 +8,20 @@ namespace UiPath.Caching.Tests; public class MultilayerCacheBatchRehydrateTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { - private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private static readonly long[] States1 = [1L]; private static readonly long[] States2 = [2L]; private static readonly long[] States1And2 = [1L, 2L]; private static readonly string?[] AAndB = ["A", "B"]; + private static readonly TimeSpan Duration = TimeSpan.FromMinutes(10); + private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + + private readonly ConcurrentDictionary _stored = new(); + private readonly ConcurrentQueue _innerSetCalls = new(); + private readonly ConcurrentQueue _innerSetExpirations = new(); + private readonly HashSet _agedKeys = []; + private ICache _innerCache = default!; private MemoryCache _memoryCache = default!; private ICacheKeyStrategy _cacheKeyStrategy = default!; @@ -34,34 +41,6 @@ public class MultilayerCacheBatchRehydrateTests(ITestContextAccessor testContext private MultilayerCache Sut => _sut ??= _fixture.Create(); - private static readonly TimeSpan Duration = TimeSpan.FromMinutes(10); - - private readonly ConcurrentDictionary _stored = new(); - private readonly ConcurrentQueue _innerSetCalls = new(); - private readonly ConcurrentQueue _innerSetExpirations = new(); - private readonly HashSet _agedKeys = []; - - private static CachePolicy RehydratePolicy(double threshold = 0.75) => new() - { - DistributedExpiration = Duration, - RehydrateEnabled = true, - Rehydrate = new RehydrateOptions - { - Threshold = threshold, - BaseCooldown = TimeSpan.FromSeconds(1), - MaxCooldown = TimeSpan.FromMinutes(5), - TimeoutFraction = 0.5, - Name = "test-profile", - }, - }; - - /// Seeds a hit past the rehydrate threshold. - private void SeedAged(CacheKey key, string? value) - { - _agedKeys.Add(key); - _stored[key] = value; - } - [Fact] public async Task Hits_past_threshold_are_rehydrated_in_one_background_call() { @@ -332,11 +311,6 @@ await Sut.GetOrAddAsync( "the group key must be derived from the RESERVED set, so a batch that refreshes only `a` takes the same lock single-key rehydration of `a` takes"); } - private sealed class PrefixingLockKeyStrategy : IDistributedLockKeyStrategy - { - public string GetLockKey(CacheKey cacheKey) => "lck:" + cacheKey.Name; - } - [Fact] public async Task Batch_rehydrate_tags_telemetry_with_the_group_size() { @@ -369,25 +343,6 @@ await WaitForAsync( sizes.Should().AllBe("2", "the coalesced set had exactly two keys"); } - private static List Snapshot(List calls) - { - lock (calls) { return [.. calls]; } - } - - private static async Task WaitForAsync(Func predicate, TimeSpan timeout, CancellationToken token) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - while (sw.Elapsed < timeout) - { - if (predicate()) - { - return; - } - await Task.Delay(10, token); - } - throw new TimeoutException($"WaitForAsync timed out after {timeout} — predicate never became true. Background batch rehydrate likely never ran."); - } - public ValueTask InitializeAsync() { _topicKey = _fixture.Create(); @@ -470,4 +425,49 @@ public ValueTask DisposeAsync() GC.SuppressFinalize(this); return ValueTask.CompletedTask; } + + private static CachePolicy RehydratePolicy(double threshold = 0.75) => new() + { + DistributedExpiration = Duration, + RehydrateEnabled = true, + Rehydrate = new RehydrateOptions + { + Threshold = threshold, + BaseCooldown = TimeSpan.FromSeconds(1), + MaxCooldown = TimeSpan.FromMinutes(5), + TimeoutFraction = 0.5, + Name = "test-profile", + }, + }; + + private static List Snapshot(List calls) + { + lock (calls) { return [.. calls]; } + } + + private static async Task WaitForAsync(Func predicate, TimeSpan timeout, CancellationToken token) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (predicate()) + { + return; + } + await Task.Delay(10, token); + } + throw new TimeoutException($"WaitForAsync timed out after {timeout} — predicate never became true. Background batch rehydrate likely never ran."); + } + + /// Seeds a hit past the rehydrate threshold. + private void SeedAged(CacheKey key, string? value) + { + _agedKeys.Add(key); + _stored[key] = value; + } + + private sealed class PrefixingLockKeyStrategy : IDistributedLockKeyStrategy + { + public string GetLockKey(CacheKey cacheKey) => "lck:" + cacheKey.Name; + } } diff --git a/tests/UiPath.Caching.Tests/MultilayerCachePerNameLockTests.cs b/tests/UiPath.Caching.Tests/MultilayerCachePerNameLockTests.cs index db60f389..f41894b9 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCachePerNameLockTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCachePerNameLockTests.cs @@ -22,6 +22,10 @@ public class MultilayerCachePerNameLockTests(ITestContextAccessor testContextAcc private TopicKey _topicKey = default!; private MultilayerCache? _sut; + public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState + { + } + private MultilayerCache Sut => _sut ??= _fixture.Create(); [Fact] @@ -143,8 +147,4 @@ public ValueTask InitializeAsync() _cacheEventFactory = _fixture.Freeze(); return ValueTask.CompletedTask; } - - public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState - { - } } diff --git a/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs b/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs index 3382ce38..3351fe70 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCachePerNamePolicyWiringTests.cs @@ -242,7 +242,8 @@ public async Task GetCacheEntry_L2_hit_populates_L1_with_DefaultCachePolicy_Loca _ = await Sut.GetCacheEntryAsync(_cacheKey, policy: null, token: TestContext.Current.CancellationToken); var nowPlusPolicyCap = DateTimeOffset.UtcNow.Add(policyCap); - cacheEntry.AbsoluteExpiration.Should().BeCloseTo(nowPlusPolicyCap, TimeSpan.FromSeconds(5), + cacheEntry.AbsoluteExpiration.Should().BeCloseTo(nowPlusPolicyCap, + TimeSpan.FromSeconds(5), "the L1 entry's absolute expiration must come from policy.LocalExpiration, not from LocalMaxExpiration or the L2 entry's 1-hour TTL"); } @@ -263,7 +264,8 @@ public async Task GetCacheEntry_L2_hit_populates_L1_with_caller_supplied_policy_ _ = await Sut.GetCacheEntryAsync(_cacheKey, callerPolicy, TestContext.Current.CancellationToken); var nowPlusCallerCap = DateTimeOffset.UtcNow.Add(callerPolicyCap); - cacheEntry.AbsoluteExpiration.Should().BeCloseTo(nowPlusCallerCap, TimeSpan.FromSeconds(5), + cacheEntry.AbsoluteExpiration.Should().BeCloseTo(nowPlusCallerCap, + TimeSpan.FromSeconds(5), "the L1 entry's absolute expiration must come from the CALLER-supplied policy.LocalExpiration, not from LocalMaxExpiration or the L2 entry's 1-hour TTL"); } @@ -288,7 +290,9 @@ public async Task RefreshAsync_uses_DefaultCachePolicy_DistributedExpiration_whe await _innerCache.Received(1).RefreshAsync( _cacheKey, Arg.Is(d => d - DateTimeOffset.UtcNow > policyTtl - TimeSpan.FromSeconds(5) - && d - DateTimeOffset.UtcNow < policyTtl + TimeSpan.FromSeconds(5)), Arg.Any(), Arg.Any()); + && d - DateTimeOffset.UtcNow < policyTtl + TimeSpan.FromSeconds(5)), + Arg.Any(), + Arg.Any()); } [Fact] @@ -458,9 +462,11 @@ public async Task SetAsync_jitter_actually_varies_across_calls() _sut = null; var ttls = new List(); - _innerCache.SetAsync(_cacheKey, Arg.Any(), + _innerCache.SetAsync(_cacheKey, + Arg.Any(), Arg.Do(d => ttls.Add(d - DateTimeOffset.UtcNow)), - Arg.Any(), Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns(true); _topic.PublishAsync(Arg.Any(), Arg.Any()) .Returns(_ => true); @@ -539,9 +545,11 @@ await act.Should().NotThrowAsync( "the jitter draw is bounded under TimeSpan.MaxValue and the clock saturates the deadline, so an absurd JitterMaxDuration can't crash writes"); await _innerCache.Received(1).SetAsync( - _cacheKey, "v", + _cacheKey, + "v", Arg.Is(d => d <= DateTimeOffset.MaxValue), - Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any()); } public ValueTask DisposeAsync() => ValueTask.CompletedTask; diff --git a/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs b/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs index ff02471c..0f94b91e 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCacheRehydrateTests.cs @@ -6,6 +6,8 @@ namespace UiPath.Caching.Tests; public class MultilayerCacheRehydrateTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { + + private static readonly TimeSpan Duration = TimeSpan.FromMinutes(10); private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private ICache _innerCache = default!; @@ -27,22 +29,6 @@ public class MultilayerCacheRehydrateTests(ITestContextAccessor testContextAcces private MultilayerCache Sut => _sut ??= _fixture.Create(); - private static readonly TimeSpan Duration = TimeSpan.FromMinutes(10); - - private static CachePolicy RehydratePolicy(double threshold = 0.75) => new() - { - DistributedExpiration = Duration, - RehydrateEnabled = true, - Rehydrate = new RehydrateOptions - { - Threshold = threshold, - BaseCooldown = TimeSpan.FromSeconds(1), - MaxCooldown = TimeSpan.FromMinutes(5), - TimeoutFraction = 0.5, - Name = "test-profile", - }, - }; - [Fact] public async Task Hit_before_threshold_does_not_trigger_rehydrate() { @@ -452,20 +438,6 @@ public async Task Single_key_rehydrate_does_not_emit_a_batch_size_tag() "single-key rehydrate telemetry must be unchanged by the set refactor")); } - private static async Task WaitForAsync(Func predicate, TimeSpan timeout, CancellationToken token) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - while (sw.Elapsed < timeout) - { - if (predicate()) - { - return; - } - await Task.Delay(10, token); - } - throw new TimeoutException($"WaitForAsync timed out after {timeout} — predicate never became true. Background rehydrate path likely never ran."); - } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask InitializeAsync() @@ -502,4 +474,32 @@ public ValueTask InitializeAsync() _cacheEventFactory = _fixture.Freeze(); return ValueTask.CompletedTask; } + + private static CachePolicy RehydratePolicy(double threshold = 0.75) => new() + { + DistributedExpiration = Duration, + RehydrateEnabled = true, + Rehydrate = new RehydrateOptions + { + Threshold = threshold, + BaseCooldown = TimeSpan.FromSeconds(1), + MaxCooldown = TimeSpan.FromMinutes(5), + TimeoutFraction = 0.5, + Name = "test-profile", + }, + }; + + private static async Task WaitForAsync(Func predicate, TimeSpan timeout, CancellationToken token) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (predicate()) + { + return; + } + await Task.Delay(10, token); + } + throw new TimeoutException($"WaitForAsync timed out after {timeout} — predicate never became true. Background rehydrate path likely never ran."); + } } diff --git a/tests/UiPath.Caching.Tests/MultilayerCacheTests.cs b/tests/UiPath.Caching.Tests/MultilayerCacheTests.cs index d08f1afa..41a869f1 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCacheTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCacheTests.cs @@ -33,6 +33,10 @@ public class MultilayerCacheTests(ITestContextAccessor testContextAccessor) : IA private MultilayerCache? _sut = null; + public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState + { + } + private MultilayerCache Sut => _sut ??= _fixture.Create(); [Fact] @@ -71,7 +75,7 @@ public async Task Multi_get_does_not_call_inner_ExpireTimeAsync_separately() .Returns(new KeyValuePair>[] { new(_innerCacheKey, new TestCacheEntry { Value = expected, Expiration = _fixture.Create() }), - new(_innerMultiKey, new TestCacheEntry { Value = expected, Expiration = _fixture.Create() }) + new(_innerMultiKey, new TestCacheEntry { Value = expected, Expiration = _fixture.Create() }), }); await Sut.GetAsync(new CacheKey[] { _cacheKey, _multiKey }, policy: null, token: testContextAccessor.Current.CancellationToken); @@ -114,7 +118,7 @@ public async Task Multi_get_data_from_inner_cache() .Returns(new KeyValuePair>[] { new(_innerCacheKey, new TestCacheEntry { Value = expected }), - new(_innerMultiKey, new TestCacheEntry { Value = expected }) + new(_innerMultiKey, new TestCacheEntry { Value = expected }), }); var actual = await Sut.GetAsync(new CacheKey[] { _cacheKey, _multiKey }, policy: null, token: testContextAccessor.Current.CancellationToken); @@ -169,7 +173,7 @@ public async Task GetCacheEntries_preserves_input_order_with_mixed_local_and_rem _innerCache.GetCacheEntriesAsync(Arg.Is(k => k != null && k.Length == 1 && k.Contains(_innerMultiKey)), Arg.Any(), Arg.Any()) .Returns(new KeyValuePair>[] { - new(_innerMultiKey, new TestCacheEntry { Value = remoteValue }) + new(_innerMultiKey, new TestCacheEntry { Value = remoteValue }), }); var entries = await Sut.GetCacheEntriesAsync(new CacheKey[] { _cacheKey, _multiKey }, policy: null, token: testContextAccessor.Current.CancellationToken); @@ -198,7 +202,7 @@ public async Task GetCacheEntries_returns_one_entry_per_input_key_when_disconnec _innerCache.GetCacheEntriesAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new KeyValuePair>[] { - new(_innerMultiKey, new TestCacheEntry { Value = default }) + new(_innerMultiKey, new TestCacheEntry { Value = default }), }); var entries = await Sut.GetCacheEntriesAsync(new CacheKey[] { _cacheKey, _multiKey }, policy: null, token: testContextAccessor.Current.CancellationToken); @@ -530,7 +534,9 @@ public async Task Multi_set_keeps_null_entries_in_set_path_when_CacheNullValues_ await _innerCache.DidNotReceive().RemoveAsync(Arg.Any(), Arg.Any()); await _innerCache.Received(1).SetAsync( Arg.Is[]>(p => p != null && p.Length == 2 && p.Any(kv => kv.Value == null)), - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] @@ -551,7 +557,9 @@ public async Task Multi_set_forwards_caller_expiration_to_inner_cache() await _innerCache.Received(1).SetAsync( Arg.Any[]>(), - Arg.Is(exp => exp > _clock.UtcNow), Arg.Any(), Arg.Any()); + Arg.Is(exp => exp > _clock.UtcNow), + Arg.Any(), + Arg.Any()); } [Fact] @@ -716,7 +724,7 @@ public async Task Remove_evict_active_token() var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(_innerCacheKey, Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token); @@ -740,7 +748,7 @@ public async Task Remove_evict_active_token_callback() var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(_innerCacheKey, Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token); @@ -767,7 +775,7 @@ public async Task Remove_evict_token_non_active() var token = new TestChangeToken { ActiveChangeCallbacks = false, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(_innerCacheKey, Arg.Any>(), Arg.Any(), Arg.Any()) @@ -821,14 +829,14 @@ public async Task Multi_remove_evict_active_token() _innerCache.GetCacheEntriesAsync(Arg.Is(k => k != null && k.Contains(_innerCacheKey)), Arg.Any(), Arg.Any()) .Returns(new KeyValuePair>[] { - new(_innerCacheKey, new TestCacheEntry { Value = expected }) + new(_innerCacheKey, new TestCacheEntry { Value = expected }), }); _innerCache.RemoveAsync(Arg.Any(), Arg.Any()) .Returns(true); var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(_innerCacheKey, Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token); @@ -850,12 +858,12 @@ public async Task Multi_remove_evict_active_token_callback() _innerCache.GetCacheEntriesAsync(Arg.Is(k => k != null && k.Contains(_innerCacheKey)), Arg.Any(), Arg.Any()) .Returns(new KeyValuePair>[] { - new(_innerCacheKey, new TestCacheEntry { Value = expected, Expiration = _clock.UtcNow.AddDays(1) }) + new(_innerCacheKey, new TestCacheEntry { Value = expected, Expiration = _clock.UtcNow.AddDays(1) }), }); var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(_innerCacheKey, Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token); @@ -880,12 +888,12 @@ public async Task Multi_remove_evict_token_non_active() _innerCache.GetCacheEntriesAsync(Arg.Is(k => k != null && k.Contains(_innerCacheKey)), Arg.Any(), Arg.Any()) .Returns(new KeyValuePair>[] { - new(_innerCacheKey, new TestCacheEntry { Value = expected, Expiration = now.AddDays(1) }) + new(_innerCacheKey, new TestCacheEntry { Value = expected, Expiration = now.AddDays(1) }), }); var token = new TestChangeToken { ActiveChangeCallbacks = false, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(_innerCacheKey, Arg.Any>(), Arg.Any(), Arg.Any()) @@ -1020,7 +1028,7 @@ public async Task Read_ExpireTime_For_Key() var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(token); @@ -1044,7 +1052,7 @@ public async Task Read_ExpireTimeToLive_For_Key() var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) @@ -1063,7 +1071,7 @@ public async Task Read_ExpireTimeToLive_For_Key() public async Task When_no_inner_cache_expire_time_use_max() { var expected = _fixture.Create(); - Task generator(CancellationToken token) => Task.FromResult((string?)expected); + Task Generator(CancellationToken token) => Task.FromResult((string?)expected); var cacheEntry = _fixture.Freeze(); _memoryCache.CreateEntry(Arg.Any()) .Returns(cacheEntry); @@ -1071,7 +1079,7 @@ public async Task When_no_inner_cache_expire_time_use_max() _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(new TestCacheEntry { Value = expected, Expiration = DateTimeOffset.MaxValue }); _options.DefaultExpiration = null; - _ = await Sut.GetOrAddAsync(_cacheKey, generator, token: testContextAccessor.Current.CancellationToken); + _ = await Sut.GetOrAddAsync(_cacheKey, Generator, token: testContextAccessor.Current.CancellationToken); cacheEntry.AbsoluteExpiration.Should().Be(DateTimeOffset.MaxValue); } @@ -1451,8 +1459,4 @@ protected virtual CacheKey ToInnerCacheKey(CacheKey key) { return key; } - - public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState - { - } } diff --git a/tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs b/tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs index c03c412e..dd1412df 100644 --- a/tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerCacheTryAddTests.cs @@ -34,6 +34,10 @@ public class MultilayerCacheTryAddTests(ITestContextAccessor testContextAccessor private MultilayerCache? _sut; + public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState + { + } + private MultilayerCache Sut => _sut ??= _fixture.Create(); private CancellationToken Ct => testContextAccessor.Current.CancellationToken; @@ -304,28 +308,6 @@ public async Task The_L2_answer_is_taken_as_given_whatever_the_L2_is() added.Should().BeTrue("the L2 granted the claim, and the outer cache does not second-guess it"); } - private static MultilayerCache CreateInMemorySut() - { - var options = new InMemoryCacheOptions(); - var cacheOptions = new CacheOptions { AppShortName = "test" }; - return new MultilayerCache( - KnownCacheProviderNames.InMemory, - NullCache.Instance, - new MemoryCacheFactory(TimeProvider.System, NullLoggerFactory.Instance), - NullChangeTokenFactory.Instance, - NullTopicFactory.Instance, - NullCacheEventFactory.Instance, - NullTelemetryProvider.Instance, - options, - options, - cacheOptions, - localLock: new AsyncKeyedLocalLock(Options.Create(cacheOptions)), - distributedLock: NullDistributedLock.Instance, - policyFactory: NullCachePolicyFactory.Instance, - clock: TimeProvider.System, - logger: NullLogger.Instance); - } - [Fact] public async Task TryAdd_rejects_a_null_key() { @@ -386,26 +368,9 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } - public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState - { - } -} - -/// -/// The memory-only provider: a real over , so -/// the local tier is the storage and the arbiter. Exclusion here is in-process only, which -/// is the honest ceiling for a cache with no shared store — these tests pin that it is at least -/// correct within the process. -/// -public class InMemoryCacheTryAddTests -{ - private static CancellationToken Ct => TestContext.Current.CancellationToken; - - private static MultilayerCache CreateSut( - InMemoryCacheOptions? options = null, - ILocalLock? localLock = null) + private static MultilayerCache CreateInMemorySut() { - options ??= new InMemoryCacheOptions(); + var options = new InMemoryCacheOptions(); var cacheOptions = new CacheOptions { AppShortName = "test" }; return new MultilayerCache( KnownCacheProviderNames.InMemory, @@ -418,12 +383,23 @@ private static MultilayerCache CreateSut( options, options, cacheOptions, - localLock: localLock ?? new AsyncKeyedLocalLock(Options.Create(cacheOptions)), + localLock: new AsyncKeyedLocalLock(Options.Create(cacheOptions)), distributedLock: NullDistributedLock.Instance, policyFactory: NullCachePolicyFactory.Instance, clock: TimeProvider.System, logger: NullLogger.Instance); } +} + +/// +/// The memory-only provider: a real over , so +/// the local tier is the storage and the arbiter. Exclusion here is in-process only, which +/// is the honest ceiling for a cache with no shared store — these tests pin that it is at least +/// correct within the process. +/// +public class InMemoryCacheTryAddTests +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; [Fact] public async Task First_caller_adds_and_the_second_loses() @@ -521,11 +497,6 @@ public async Task A_size_limited_memory_cache_that_drops_the_entry_still_reports (await sut.TryAddAsync("k", "second", policy: null, token: Ct)).Should().BeTrue(); } - private sealed class OversizedEntryProvider : ICacheEntrySizeProvider - { - public long GetSize(ICacheEntry entry) => long.MaxValue; - } - [Theory] [InlineData(0)] [InlineData(-1)] @@ -551,6 +522,35 @@ public async Task An_expiration_that_has_already_passed_is_rejected() (await sut.GetAsync("k", policy: null, token: Ct)).Should().BeNull(); } + private static MultilayerCache CreateSut( + InMemoryCacheOptions? options = null, + ILocalLock? localLock = null) + { + options ??= new InMemoryCacheOptions(); + var cacheOptions = new CacheOptions { AppShortName = "test" }; + return new MultilayerCache( + KnownCacheProviderNames.InMemory, + NullCache.Instance, + new MemoryCacheFactory(TimeProvider.System, NullLoggerFactory.Instance), + NullChangeTokenFactory.Instance, + NullTopicFactory.Instance, + NullCacheEventFactory.Instance, + NullTelemetryProvider.Instance, + options, + options, + cacheOptions, + localLock: localLock ?? new AsyncKeyedLocalLock(Options.Create(cacheOptions)), + distributedLock: NullDistributedLock.Instance, + policyFactory: NullCachePolicyFactory.Instance, + clock: TimeProvider.System, + logger: NullLogger.Instance); + } + + private sealed class OversizedEntryProvider : ICacheEntrySizeProvider + { + public long GetSize(ICacheEntry entry) => long.MaxValue; + } + /// /// Stands in for a local lock held by someone else for longer than the acquire budget: the wait is /// abandoned by the linked timeout, which is the only way AcquireLocalLockAsync answers null. diff --git a/tests/UiPath.Caching.Tests/MultilayerHashCacheRehydrateTests.cs b/tests/UiPath.Caching.Tests/MultilayerHashCacheRehydrateTests.cs index f195b0d8..0cf26c0c 100644 --- a/tests/UiPath.Caching.Tests/MultilayerHashCacheRehydrateTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerHashCacheRehydrateTests.cs @@ -4,6 +4,10 @@ namespace UiPath.Caching.Tests; public class MultilayerHashCacheRehydrateTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { + + private static readonly TimeSpan Duration = TimeSpan.FromMinutes(10); + private static readonly IDictionary CachedDict = new Dictionary { ["f"] = "cached" }; + private static readonly IDictionary RefreshedDict = new Dictionary { ["f"] = "rehydrated" }; private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private IHashCache _innerCache = default!; @@ -24,24 +28,6 @@ public class MultilayerHashCacheRehydrateTests(ITestContextAccessor testContextA private MultilayerHashCache Sut => _sut ??= _fixture.Create(); - private static readonly TimeSpan Duration = TimeSpan.FromMinutes(10); - private static readonly IDictionary CachedDict = new Dictionary { ["f"] = "cached" }; - private static readonly IDictionary RefreshedDict = new Dictionary { ["f"] = "rehydrated" }; - - private static CachePolicy RehydratePolicy(double threshold = 0.75) => new() - { - DistributedExpiration = Duration, - RehydrateEnabled = true, - Rehydrate = new RehydrateOptions - { - Threshold = threshold, - BaseCooldown = TimeSpan.FromSeconds(1), - MaxCooldown = TimeSpan.FromMinutes(5), - TimeoutFraction = 0.5, - Name = "test-hash-profile", - }, - }; - [Fact] public async Task Hit_before_threshold_does_not_trigger_rehydrate() { @@ -308,20 +294,6 @@ await _innerCache.Received(1).SetAsync( Arg.Any()); } - private static async Task WaitForAsync(Func predicate, TimeSpan timeout, CancellationToken token) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - while (sw.Elapsed < timeout) - { - if (predicate()) - { - return; - } - await Task.Delay(10, token); - } - throw new TimeoutException($"WaitForAsync timed out after {timeout} — predicate never became true. Background rehydrate path likely never ran."); - } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask InitializeAsync() @@ -356,4 +328,32 @@ public ValueTask InitializeAsync() _cacheEventFactory = _fixture.Freeze(); return ValueTask.CompletedTask; } + + private static CachePolicy RehydratePolicy(double threshold = 0.75) => new() + { + DistributedExpiration = Duration, + RehydrateEnabled = true, + Rehydrate = new RehydrateOptions + { + Threshold = threshold, + BaseCooldown = TimeSpan.FromSeconds(1), + MaxCooldown = TimeSpan.FromMinutes(5), + TimeoutFraction = 0.5, + Name = "test-hash-profile", + }, + }; + + private static async Task WaitForAsync(Func predicate, TimeSpan timeout, CancellationToken token) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + while (sw.Elapsed < timeout) + { + if (predicate()) + { + return; + } + await Task.Delay(10, token); + } + throw new TimeoutException($"WaitForAsync timed out after {timeout} — predicate never became true. Background rehydrate path likely never ran."); + } } diff --git a/tests/UiPath.Caching.Tests/MultilayerHashCacheTests.cs b/tests/UiPath.Caching.Tests/MultilayerHashCacheTests.cs index c1c96b99..eafbe5c4 100644 --- a/tests/UiPath.Caching.Tests/MultilayerHashCacheTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerHashCacheTests.cs @@ -31,6 +31,10 @@ public class MultilayerHashCacheTests(ITestContextAccessor testContextAccessor) private CacheKey _innerCacheKey = default!; private MultilayerHashCache? _sut = null; + + public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState + { + } private MultilayerHashCache Sut => _sut ??= _fixture.Create(); @@ -40,7 +44,7 @@ public async Task Get_data_from_inner_cache() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); @@ -58,7 +62,7 @@ public async Task Get_does_not_call_inner_ExpireTimeAsync_separately() ICacheEntry> expectedCacheEntry = new TestCacheEntry> { Value = expected, - Expiration = _fixture.Create() + Expiration = _fixture.Create(), }; _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); @@ -74,7 +78,7 @@ public async Task Get_unknown_cacheKey() { ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = null + Value = null, }; _innerCache.GetCacheEntryAsync(_cacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); @@ -89,7 +93,7 @@ public async Task Get_cache_entry() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); @@ -106,7 +110,7 @@ public async Task Get_known_item() var field = expected.Keys.First(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .ReturnsForAnyArgs(_ => expectedCacheEntry); @@ -123,7 +127,7 @@ public async Task Get_item_unknown_key() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _innerCache.GetCacheEntryAsync(_cacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); @@ -139,7 +143,7 @@ public async Task Get_item_unknown_key_unknown_cacheKey() { ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = null + Value = null, }; _innerCache.GetCacheEntryAsync(_cacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); @@ -154,7 +158,7 @@ public async Task Get_data_from_memory_cache() var expected = _fixture.Create>(); var expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _memoryCache.TryGetValue(Arg.Any(), out Arg.Any()) @@ -174,7 +178,7 @@ public async Task GetOrAdd_data_from_inner_cache_timespan() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; var generatorExpected = _fixture.Create>(); var generatorWasCalled = false; @@ -199,11 +203,11 @@ public async Task GetOrAdd_data_from_inner_cache_HashCacheSetOption(HashCacheSet var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; var generatorExpected = _fixture.Create>(); var generatorWasCalled = false; - Task> generator(CancellationToken token) + Task> Generator(CancellationToken token) { generatorWasCalled = true; return Task.FromResult(generatorExpected); @@ -211,7 +215,7 @@ public async Task GetOrAdd_data_from_inner_cache_HashCacheSetOption(HashCacheSet _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); - var actual = await Sut.GetOrAddAsync(_cacheKey, generator, expiration: DateTimeOffset.UtcNow.AddMinutes(5), setOption: hashCacheSetOption, token: testContextAccessor.Current.CancellationToken); + var actual = await Sut.GetOrAddAsync(_cacheKey, Generator, expiration: DateTimeOffset.UtcNow.AddMinutes(5), setOption: hashCacheSetOption, token: testContextAccessor.Current.CancellationToken); generatorWasCalled.Should().BeFalse(); actual.Should().BeEquivalentTo(expected); } @@ -222,11 +226,11 @@ public async Task GetOrAdd_data_from_inner_cache_datetime() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; var generatorExpected = _fixture.Create>(); var generatorWasCalled = false; - Task> generator(CancellationToken token) + Task> Generator(CancellationToken token) { generatorWasCalled = true; return Task.FromResult(generatorExpected); @@ -234,7 +238,7 @@ public async Task GetOrAdd_data_from_inner_cache_datetime() _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); - var actual = await Sut.GetOrAddAsync(_cacheKey, generator, DateTimeOffset.UtcNow.AddMinutes(5), (CachePolicy?)null, testContextAccessor.Current.CancellationToken); + var actual = await Sut.GetOrAddAsync(_cacheKey, Generator, DateTimeOffset.UtcNow.AddMinutes(5), (CachePolicy?)null, testContextAccessor.Current.CancellationToken); generatorWasCalled.Should().BeFalse(); actual.Should().BeEquivalentTo(expected); } @@ -245,7 +249,7 @@ public async Task GetOrAdd_data_from_inner_cache_no_expiration() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; var generatorExpected = _fixture.Create>(); var generatorWasCalled = false; @@ -425,13 +429,16 @@ public async Task Set_empty_with_options_and_CacheNullValues_persists_with_metad await Sut.SetAsync( _cacheKey, new Dictionary(), - new HashCacheEntryOptions(TimeToLive: _fixture.Create(), Metadata: metadata), token: testContextAccessor.Current.CancellationToken); + new HashCacheEntryOptions(TimeToLive: _fixture.Create(), Metadata: metadata), + token: testContextAccessor.Current.CancellationToken); await _innerCache.DidNotReceive().RemoveAsync(_innerCacheKey, Arg.Any()); await _innerCache.Received(1).SetAsync( _innerCacheKey, Arg.Is>(d => d != null && d.Count == 0), - Arg.Is(o => o.Metadata == metadata), Arg.Any(), Arg.Any()); + Arg.Is(o => o.Metadata == metadata), + Arg.Any(), + Arg.Any()); } [Fact] @@ -557,7 +564,7 @@ public async Task Remove_evict_active_token() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) @@ -565,7 +572,7 @@ public async Task Remove_evict_active_token() var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token); @@ -584,7 +591,7 @@ public async Task Remove_evict_active_token_callback() var expected = _fixture.Create>(); ICacheEntry> expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _innerCache.GetCacheEntryAsync(_cacheKey, Arg.Any(), Arg.Any()) @@ -594,7 +601,7 @@ public async Task Remove_evict_active_token_callback() var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token); @@ -705,7 +712,7 @@ public async Task Refresh_metadata_callback_cache_throw_exception() { Value = expected, Metadata = _fixture.Create>(), - Expiration = _clock.UtcNow.AddDays(1) + Expiration = _clock.UtcNow.AddDays(1), }; TestChangeToken? token = default; @@ -716,7 +723,7 @@ public async Task Refresh_metadata_callback_cache_throw_exception() { ActiveChangeCallbacks = true, HasChanged = false, - MetadataHasChanged = false + MetadataHasChanged = false, }; return token; }); @@ -754,7 +761,7 @@ public async Task Remove_evict_token_non_active() ICacheEntry> expectedCacheEntry = new TestCacheEntry> { Value = expected, - Expiration = _clock.UtcNow.AddDays(1) + Expiration = _clock.UtcNow.AddDays(1), }; _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(expectedCacheEntry); @@ -763,7 +770,7 @@ public async Task Remove_evict_token_non_active() var token = new TestChangeToken { ActiveChangeCallbacks = false, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(c => token); @@ -903,7 +910,7 @@ public async Task Read_ExpireTime_For_Key() IChangeToken? token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(token); @@ -934,7 +941,7 @@ public async Task Read_ExpireTimeToLive_For_Key() var token = new TestChangeToken { ActiveChangeCallbacks = true, - HasChanged = false + HasChanged = false, }; _changeTokenFactory.Create(Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any()) .Returns(_ => token); @@ -958,7 +965,7 @@ public async Task GetMetadata_from_memory() var expectedCacheEntry = new TestCacheEntry> { Value = _fixture.Create>(), - Metadata = expected + Metadata = expected, }; _memoryCache.TryGetValue(Arg.Any(), out Arg.Any()) @@ -982,7 +989,7 @@ public async Task GetMetadata_from_innerCache() var expectedCacheEntry = new TestCacheEntry> { Value = _fixture.Create>(), - Metadata = expected + Metadata = expected, }; _innerCache.GetMetadataAsync(_innerCacheKey, Arg.Any()) @@ -1008,7 +1015,7 @@ public async Task SetMetadata_works_as_exptected() var expectedCacheEntry = new TestCacheEntry> { Value = _fixture.Create>(), - Metadata = expected + Metadata = expected, }; _innerCache.GetMetadataAsync(_cacheKey, Arg.Any()) @@ -1036,7 +1043,7 @@ public async Task SetMetadata_works_exception() var expectedCacheEntry = new TestCacheEntry> { Value = _fixture.Create>(), - Metadata = expected + Metadata = expected, }; _innerCache.SetMetadataAsync(_innerCacheKey, Arg.Any>(), Arg.Any()) @@ -1075,7 +1082,7 @@ public async Task SetMetadata_reads_expiration_from_memory() var expectedCacheEntry = new TestCacheEntry> { Value = expected, - Expiration = _clock.UtcNow.AddSeconds(10) + Expiration = _clock.UtcNow.AddSeconds(10), }; _memoryCache.TryGetValue(Arg.Any(), out Arg.Any()) @@ -1094,7 +1101,7 @@ public async Task SetMetadata_reads_expiration_from_memory() public async Task When_inner_cache_returns_max_expiration_local_uses_max() { var expected = _fixture.Create>(); - Task> generator(CancellationToken token) => Task.FromResult(expected); + Task> Generator(CancellationToken token) => Task.FromResult(expected); _innerCache.GetCacheEntryAsync(_innerCacheKey, Arg.Any(), Arg.Any()) .Returns(new TestCacheEntry> { Value = expected, Expiration = DateTimeOffset.MaxValue }); @@ -1104,7 +1111,7 @@ public async Task When_inner_cache_returns_max_expiration_local_uses_max() .Returns(cacheEntry); _options.DefaultExpiration = null; - _ = await Sut.GetOrAddAsync(_cacheKey, generator, (CachePolicy?)null, testContextAccessor.Current.CancellationToken); + _ = await Sut.GetOrAddAsync(_cacheKey, Generator, (CachePolicy?)null, testContextAccessor.Current.CancellationToken); cacheEntry.AbsoluteExpiration.Should().Be(DateTimeOffset.MaxValue); } @@ -1114,7 +1121,7 @@ public async Task Get_returns_local_when_disconnected_and_UseLocalOnlyWhenDiscon var expected = _fixture.Create>(); var expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _options.UseLocalOnlyWhenDisconnected = true; _topicProvider.IsConnected.Returns(false); @@ -1136,7 +1143,7 @@ public async Task Get_returns_empty_when_disconnected_and_UseLocalOnlyWhenDiscon var expected = _fixture.Create>(); var expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _options.UseLocalOnlyWhenDisconnected = false; _options.ConnectionMonitorEnabled = true; @@ -1221,7 +1228,7 @@ public async Task GetCacheEntry_returns_local_when_disconnected_and_UseLocalOnly var expected = _fixture.Create>(); var expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _options.UseLocalOnlyWhenDisconnected = true; _topicProvider.IsConnected.Returns(false); @@ -1245,7 +1252,7 @@ public async Task GetItem_returns_local_when_disconnected_and_UseLocalOnlyWhenDi var field = expected.Keys.First(); var expectedCacheEntry = new TestCacheEntry> { - Value = expected + Value = expected, }; _options.UseLocalOnlyWhenDisconnected = true; _topicProvider.IsConnected.Returns(false); @@ -1487,8 +1494,4 @@ protected virtual CacheKey ToInnerCacheKey(CacheKey key) { return key; } - - public interface ITopicProviderWithConnectionState : ITopicProvider, IConnectionState - { - } } diff --git a/tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs b/tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs index 8669709c..3873b794 100644 --- a/tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs +++ b/tests/UiPath.Caching.Tests/MultilayerSetCacheTests.cs @@ -7,21 +7,6 @@ public class MultilayerSetCacheTests { private static CancellationToken Ct => TestContext.Current.CancellationToken; - private static MemoryCacheFactory MemoryFactory() => new(TimeProvider.System, 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 SystemJsonByteSerializerProxy(), - new InMemoryRedisQueueCacheOptions { LocalMaxExpiration = TimeSpan.FromMinutes(5) }, - NullLocalLock.Instance, TimeProvider.System); - return (sut, l2); - } - [Fact] public async Task Configured_default_reaches_L2_as_the_write_deadline() { @@ -65,13 +50,6 @@ public async Task Deadlines_are_computed_on_the_injected_clock() await l2.Received(1).AddAsync("k", Arg.Any>(), now.AddHours(1), Arg.Any(), Arg.Any()); } - private static void SetupMembers(ISetCache l2, params string?[] members) - { - IReadOnlyCollection snapshot = members.ToList(); - l2.MembersAsync(default, default, Ct) - .ReturnsForAnyArgs>(_ => snapshot); - } - [Fact] public void Name_reflects_provider() { @@ -210,26 +188,6 @@ public async Task Reads_fall_through_to_inner_when_not_cached() 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 options = new InMemoryRedisQueueCacheOptions - { - LocalMaxExpiration = TimeSpan.FromMinutes(5), - ConnectionMonitorEnabled = true, - UseLocalOnlyWhenDisconnected = useLocalOnlyWhenDisconnected, - LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30), - }; - var sut = new MultilayerSetCache( - KnownCacheProviderNames.InMemoryRedis, l2, - MemoryFactory(), new SystemJsonByteSerializerProxy(), options, - NullLocalLock.Instance, TimeProvider.System); - return (sut, l2); - } - [Fact] public async Task Disconnected_add_with_local_only_writes_to_L1_and_skips_inner() { @@ -281,4 +239,53 @@ public async Task Disconnected_without_local_only_still_writes_through_to_inner( await l2.ReceivedWithAnyArgs(1).AddAsync(default, default(string)!, default, Ct); } + + private static MemoryCacheFactory MemoryFactory() => new(TimeProvider.System, 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 SystemJsonByteSerializerProxy(), + new InMemoryRedisQueueCacheOptions { LocalMaxExpiration = TimeSpan.FromMinutes(5) }, + NullLocalLock.Instance, + TimeProvider.System); + return (sut, l2); + } + + private static void SetupMembers(ISetCache l2, params string?[] members) + { + IReadOnlyCollection snapshot = members.ToList(); + l2.MembersAsync(default, default, Ct) + .ReturnsForAnyArgs>(_ => snapshot); + } + + // 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 options = new InMemoryRedisQueueCacheOptions + { + LocalMaxExpiration = TimeSpan.FromMinutes(5), + ConnectionMonitorEnabled = true, + UseLocalOnlyWhenDisconnected = useLocalOnlyWhenDisconnected, + LocalMaxExpirationDisconnected = TimeSpan.FromSeconds(30), + }; + var sut = new MultilayerSetCache( + KnownCacheProviderNames.InMemoryRedis, + l2, + MemoryFactory(), + new SystemJsonByteSerializerProxy(), + options, + NullLocalLock.Instance, + TimeProvider.System); + return (sut, l2); + } } diff --git a/tests/UiPath.Caching.Tests/OpenTelemetry/OpenTelemetryCachingTelemetryProviderTests.cs b/tests/UiPath.Caching.Tests/OpenTelemetry/OpenTelemetryCachingTelemetryProviderTests.cs index d06ecb4d..8945a3f6 100644 --- a/tests/UiPath.Caching.Tests/OpenTelemetry/OpenTelemetryCachingTelemetryProviderTests.cs +++ b/tests/UiPath.Caching.Tests/OpenTelemetry/OpenTelemetryCachingTelemetryProviderTests.cs @@ -9,41 +9,12 @@ namespace UiPath.Caching.Tests.OpenTelemetry; public class OpenTelemetryCachingTelemetryProviderTests { - private static List<(double Value, Dictionary Tags)> RecordDouble(string instrumentName, Action act) - { - using var provider = new CachingTelemetryProvider(); - var measurements = new List<(double, Dictionary)>(); - using var listener = new MeterListener - { - InstrumentPublished = (instrument, l) => - { - if (instrument.Meter.Name == CachingTelemetryProvider.MeterName && instrument.Name == instrumentName) - { - l.EnableMeasurementEvents(instrument); - } - }, - }; - listener.SetMeasurementEventCallback((_, value, tags, _) => - { - var dict = new Dictionary(); - foreach (var t in tags) dict[t.Key] = t.Value; - measurements.Add((value, dict)); - }); - listener.SetMeasurementEventCallback((_, value, tags, _) => - { - var dict = new Dictionary(); - foreach (var t in tags) dict[t.Key] = t.Value; - measurements.Add((value, dict)); - }); - listener.Start(); - act(provider); - return measurements; - } [Fact] public void TrackMetric_records_value_and_tags_to_meter() { - var m = RecordDouble("uipath.caching.metric", p => + var m = RecordDouble("uipath.caching.metric", + p => p.TrackMetric("hits.test", 42.5, new[] { new KeyValuePair("region", "eu") })); m.Should().ContainSingle(); @@ -85,8 +56,14 @@ public void TrackDependency_creates_client_activity() }; ActivitySource.AddActivityListener(listener); - provider.TrackDependency("redis", "localhost", "GET", "GET key", - DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(5), "OK", success: true); + provider.TrackDependency("redis", + "localhost", + "GET", + "GET key", + DateTimeOffset.UtcNow, + TimeSpan.FromMilliseconds(5), + "OK", + success: true); activities.Should().ContainSingle(); activities[0].OperationName.Should().Be("GET"); @@ -118,4 +95,42 @@ public void AddOpenTelemetry_when_disabled_registers_null_provider() var provider = services.BuildServiceProvider().GetRequiredService(); provider.Should().BeOfType(); } + private static List<(double Value, Dictionary Tags)> RecordDouble(string instrumentName, Action act) + { + using var provider = new CachingTelemetryProvider(); + var measurements = new List<(double, Dictionary)>(); + using var listener = new MeterListener + { + InstrumentPublished = (instrument, l) => + { + if (instrument.Meter.Name == CachingTelemetryProvider.MeterName && instrument.Name == instrumentName) + { + l.EnableMeasurementEvents(instrument); + } + }, + }; + listener.SetMeasurementEventCallback((_, value, tags, _) => + { + var dict = new Dictionary(); + foreach (var t in tags) + { + dict[t.Key] = t.Value; + } + + measurements.Add((value, dict)); + }); + listener.SetMeasurementEventCallback((_, value, tags, _) => + { + var dict = new Dictionary(); + foreach (var t in tags) + { + dict[t.Key] = t.Value; + } + + measurements.Add((value, dict)); + }); + listener.Start(); + act(provider); + return measurements; + } } diff --git a/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs b/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs index 56a368cc..5cb47892 100644 --- a/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs +++ b/tests/UiPath.Caching.Tests/PackageVersionFloorTests.cs @@ -73,19 +73,6 @@ public void EveryShippedExtensionsPackageIsFlooredExceptLoggingAbstractions() "a Microsoft.Extensions.* package that a shipped project references without a per-TFM floor resolves 10.x for net8.0 consumers too, which is what the floors exist to avoid; Logging.Abstractions is the documented exception, forced by StackExchange.Redis 3.x asking for 10.0.5 or later on every target"); } - private static IEnumerable ShippedPackageReferences() - { - var src = new DirectoryInfo(Path.Combine(RepositoryRoot().FullName, "src")); - src.Exists.Should().BeTrue("the shipped projects live under src"); - - return src.EnumerateFiles("*.csproj", SearchOption.AllDirectories) - .SelectMany(f => XDocument.Load(f.FullName).Descendants("PackageReference")) - .Select(e => e.Attribute("Include")?.Value) - .Where(id => id is not null) - .Select(id => id!) - .Distinct(StringComparer.OrdinalIgnoreCase); - } - [Fact] public void TheNet10FloorIsDeclaredFirst() { @@ -108,6 +95,19 @@ public void TheNet10FloorIsPinnedThroughOneProperty() "the net10 family ships in lockstep, so a bump should have one line to change and no way to leave the group disagreeing"); } + private static IEnumerable ShippedPackageReferences() + { + var src = new DirectoryInfo(Path.Combine(RepositoryRoot().FullName, "src")); + src.Exists.Should().BeTrue("the shipped projects live under src"); + + return src.EnumerateFiles("*.csproj", SearchOption.AllDirectories) + .SelectMany(f => XDocument.Load(f.FullName).Descendants("PackageReference")) + .Select(e => e.Attribute("Include")?.Value) + .Where(id => id is not null) + .Select(id => id!) + .Distinct(StringComparer.OrdinalIgnoreCase); + } + private static Predicate IsFloor(string tfm) => g => g.Attribute("Condition")?.Value.Contains($"'{tfm}'", StringComparison.Ordinal) == true; diff --git a/tests/UiPath.Caching.Tests/PrefixRedisStrategyTests.cs b/tests/UiPath.Caching.Tests/PrefixRedisStrategyTests.cs index 72a2c626..073a248c 100644 --- a/tests/UiPath.Caching.Tests/PrefixRedisStrategyTests.cs +++ b/tests/UiPath.Caching.Tests/PrefixRedisStrategyTests.cs @@ -21,7 +21,7 @@ public void Create_WhenCalled_ThrowsException(string appShortName, string prefix _prefix = prefix; _cacheOptions = new CacheOptions { Separator = separator, - AppShortName = appShortName + AppShortName = appShortName, }; var act = () => Sut; @@ -38,7 +38,7 @@ public void WorksAsExpected(string appShortName, string prefix, char separator, _cacheOptions = new CacheOptions { Separator = separator, - AppShortName = appShortName + AppShortName = appShortName, }; var actual = Sut.GetRedisKey(key); diff --git a/tests/UiPath.Caching.Tests/ProfiledCommandProcessorTests.cs b/tests/UiPath.Caching.Tests/ProfiledCommandProcessorTests.cs index 2eeebaf6..bfd616d9 100644 --- a/tests/UiPath.Caching.Tests/ProfiledCommandProcessorTests.cs +++ b/tests/UiPath.Caching.Tests/ProfiledCommandProcessorTests.cs @@ -80,7 +80,7 @@ public ValueTask InitializeAsync() { CommandAndKey = cmd => _fixture.Create(), Message = cmd => _fixture.Create(), - ProfiledCommandType = _profiledCommand.GetType() + ProfiledCommandType = _profiledCommand.GetType(), }); return ValueTask.CompletedTask; } diff --git a/tests/UiPath.Caching.Tests/PropagateCacheNullValuesFromMultilayerTests.cs b/tests/UiPath.Caching.Tests/PropagateCacheNullValuesFromMultilayerTests.cs index 8adcc735..20780816 100644 --- a/tests/UiPath.Caching.Tests/PropagateCacheNullValuesFromMultilayerTests.cs +++ b/tests/UiPath.Caching.Tests/PropagateCacheNullValuesFromMultilayerTests.cs @@ -5,11 +5,6 @@ namespace UiPath.Caching.Tests; public class PropagateCacheNullValuesFromMultilayerTests { - private static PropagateCacheNullValuesFromMultilayer Create(bool sourceCacheNullValues) - { - var source = Options.Create(new InMemoryRedisCacheOptions { CacheNullValues = sourceCacheNullValues }); - return new PropagateCacheNullValuesFromMultilayer(source, NullLoggerFactory.Instance); - } [Fact] public void PostConfigure_forces_target_on_when_source_on() @@ -48,4 +43,9 @@ public void PostConfigure_ignores_non_default_named_options(string namedKey) target.CacheNullValues.Should().BeFalse(); } + private static PropagateCacheNullValuesFromMultilayer Create(bool sourceCacheNullValues) + { + var source = Options.Create(new InMemoryRedisCacheOptions { CacheNullValues = sourceCacheNullValues }); + return new PropagateCacheNullValuesFromMultilayer(source, NullLoggerFactory.Instance); + } } diff --git a/tests/UiPath.Caching.Tests/RawByteSerializerProxyTests.cs b/tests/UiPath.Caching.Tests/RawByteSerializerProxyTests.cs index 76ced94f..3d3aa070 100644 --- a/tests/UiPath.Caching.Tests/RawByteSerializerProxyTests.cs +++ b/tests/UiPath.Caching.Tests/RawByteSerializerProxyTests.cs @@ -8,8 +8,6 @@ public class RawByteSerializerProxyTests { private readonly RawByteSerializerProxy _proxy = new(); - private sealed record Poco(string Name, int Count); - [Fact] public void Byte_array_passes_through_by_reference() { @@ -180,4 +178,6 @@ public void Memory_of_byte_round_trips() stored.Should().Equal(4, 5, 6); _proxy.Deserialize>(stored).ToArray().Should().Equal(4, 5, 6); } + + private sealed record Poco(string Name, int Count); } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs index b9cb3de3..9d6ec8b0 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisCacheTests.cs @@ -12,6 +12,7 @@ namespace UiPath.Caching.Tests.Redis; public class RedisCacheTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly RecordingTelemetryProvider _telemetry = new(); private ISystemClock _clock = default!; private IResiliencePipelineProvider _resiliencePipelineProvider = default!; private RedisCacheOptions _cacheOptions = default!; @@ -29,11 +30,14 @@ public class RedisCacheTests(ITestContextAccessor testContextAccessor) : IAsyncL private IRedisConnector _connector = default!; private bool _isConnected = true; private Version _version = new(6, 0); - private readonly RecordingTelemetryProvider _telemetry = new(); private RedisCache? _sut = null; private RedisCache Sut => _sut ??= _fixture.Create(); + private int HitCount => _telemetry.Metrics.Count(m => m.Name.Contains(".Hits.")); + private int MissCount => _telemetry.Metrics.Count(m => m.Name.Contains(".Misses.")); + private IEnumerable ReadDeps => _telemetry.Dependencies.Where(d => d.Type == TelemetryOperation.DependencyType); + [Fact] public async Task Get_works_as_expected() { @@ -131,12 +135,6 @@ public async Task Multi_get_still_reports_a_miss_when_the_connection_cannot_be_r actual.Should().BeEquivalentTo(new KeyValuePair[] { new(_cacheKey, null), new(_multiKey, null) }); } - private void GiveKeysDifferentSlots() - { - _database.Multiplexer.GetHashSlot(_redisKey).Returns(1); - _database.Multiplexer.GetHashSlot(_redisMultiKey).Returns(2); - } - [Fact] public async Task Multi_get_has_no_redis_exceptions() { @@ -146,10 +144,6 @@ public async Task Multi_get_has_no_redis_exceptions() actualValue.Should().BeEquivalentTo(new KeyValuePair[] { new(_cacheKey, default), new(_multiKey, default) }); } - private int HitCount => _telemetry.Metrics.Count(m => m.Name.Contains(".Hits.")); - private int MissCount => _telemetry.Metrics.Count(m => m.Name.Contains(".Misses.")); - private IEnumerable ReadDeps => _telemetry.Dependencies.Where(d => d.Type == TelemetryOperation.DependencyType); - [Fact] public async Task Multi_get_returns_defaults_without_redis_or_telemetry_when_disconnected() { @@ -1290,25 +1284,6 @@ public async Task SetAsync_asks_a_memory_serializer_for_memory_instead_of_an_arr ((byte[]?)captured!.Value).Should().Equal(1, 2, 3); } - private sealed class RecordingRawSerializer : RawByteSerializerProxy - { - public int ArrayCalls { get; private set; } - - public int MemoryCalls { get; private set; } - - public override byte[]? Serialize(object? value) - { - ArrayCalls++; - return base.Serialize(value); - } - - public override ReadOnlyMemory SerializeToMemory(T? value) where T : default - { - MemoryCalls++; - return base.SerializeToMemory(value); - } - } - [Fact] public async Task SetAsync_writes_empty_value_when_CacheNullValues_true_and_value_is_null() { @@ -1442,7 +1417,7 @@ public ValueTask InitializeAsync() _cacheOptions = new RedisCacheOptions { CacheKeyStrategy = _cacheKeyStrategy, - RedisKeyStrategyFactory = redisKeyStrategyFactory + RedisKeyStrategyFactory = redisKeyStrategyFactory, }; _database = _fixture.Freeze(); @@ -1465,6 +1440,12 @@ public ValueTask InitializeAsync() return ValueTask.CompletedTask; } + private void GiveKeysDifferentSlots() + { + _database.Multiplexer.GetHashSlot(_redisKey).Returns(1); + _database.Multiplexer.GetHashSlot(_redisMultiKey).Returns(2); + } + private async Task GetOrAdd_works_as_expected(string? redisReturn, string? generatorReturn, bool expectedGeneratorCall, int stringSetCalls, Type expirationType) { var generatorWasCalled = false; @@ -1542,4 +1523,23 @@ private async Task Multi_set_works_as_expected(Type expirationType) await _transaction.Received(1).StringSetAsync(_redisKey, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Is(f => f.HasFlag(CommandFlags.DemandMaster))); await _transaction.Received(1).StringSetAsync(_redisMultiKey, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Is(f => f.HasFlag(CommandFlags.DemandMaster))); } + + private sealed class RecordingRawSerializer : RawByteSerializerProxy + { + public int ArrayCalls { get; private set; } + + public int MemoryCalls { get; private set; } + + public override byte[]? Serialize(object? value) + { + ArrayCalls++; + return base.Serialize(value); + } + + public override ReadOnlyMemory SerializeToMemory(T? value) where T : default + { + MemoryCalls++; + return base.SerializeToMemory(value); + } + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisCacheTryAddTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisCacheTryAddTests.cs index 5872d023..644c6839 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisCacheTryAddTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisCacheTryAddTests.cs @@ -16,17 +16,17 @@ namespace UiPath.Caching.Tests.Redis; public class RedisCacheTryAddTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly DateTimeOffset _now = DateTimeOffset.UtcNow; + private readonly RecordingTelemetryProvider _telemetry = new(); private ISystemClock _clock = default!; private RedisCacheOptions _cacheOptions = default!; private IDatabase _database = default!; private SystemJsonByteSerializerProxy _serializer = default!; - private readonly DateTimeOffset _now = DateTimeOffset.UtcNow; private CacheKey _cacheKey = default!; private RedisKey _redisKey = default!; private IRedisConnector _connector = default!; private IResiliencePipelineProvider _pipelineProvider = default!; private bool _isConnected = true; - private readonly RecordingTelemetryProvider _telemetry = new(); private RedisCache? _sut; private RedisCache Sut => _sut ??= _fixture.Create(); diff --git a/tests/UiPath.Caching.Tests/Redis/RedisConfigurationOptionsProviderFactoryTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisConfigurationOptionsProviderFactoryTests.cs index 4474b658..fb099db8 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisConfigurationOptionsProviderFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisConfigurationOptionsProviderFactoryTests.cs @@ -7,10 +7,6 @@ namespace UiPath.Caching.Tests.Redis; public class RedisConfigurationOptionsProviderFactoryTests { - private sealed class FakeConfigurationOptionsProvider : IRedisConfigurationOptionsProvider - { - public ConfigurationOptions GetConfiguration() => new(); - } [Fact] public void Factory_provider_wins_when_registered_after_AddRedisConnection() @@ -60,4 +56,8 @@ public void Null_factory_throws() act.Should().Throw(); } + private sealed class FakeConfigurationOptionsProvider : IRedisConfigurationOptionsProvider + { + public ConfigurationOptions GetConfiguration() => new(); + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisConnectionConfiguratorTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisConnectionConfiguratorTests.cs index 0b77507b..6cf7f57c 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisConnectionConfiguratorTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisConnectionConfiguratorTests.cs @@ -1,17 +1,9 @@ -using StackExchange.Redis; +using StackExchange.Redis; namespace UiPath.Caching.Tests.Redis; public class RedisConnectionConfiguratorTests { - private sealed class ClientNameConfigurator(string name) : IRedisConnectionConfigurator - { - public ValueTask ConfigureAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) - { - configuration.ClientName = name; - return ValueTask.CompletedTask; - } - } [Fact] public async Task ApplyAsync_AppliesConfigurators() @@ -52,4 +44,12 @@ public async Task ApplyAsync_WithNullConfigurators_LeavesBaseConfiguration() config.ClientName.Should().BeNull(); } + private sealed class ClientNameConfigurator(string name) : IRedisConnectionConfigurator + { + public ValueTask ConfigureAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) + { + configuration.ClientName = name; + return ValueTask.CompletedTask; + } + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisConnectionWarmupTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisConnectionWarmupTests.cs index 55154cdc..969b6b78 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisConnectionWarmupTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisConnectionWarmupTests.cs @@ -9,38 +9,6 @@ namespace UiPath.Caching.Tests.Redis; public class RedisConnectionWarmupTests { - private sealed class CapturingTelemetry : ICachingTelemetryProvider - { - private readonly TaskCompletionSource _exceptionTracked = new(TaskCreationOptions.RunContinuationsAsynchronously); - public Task ExceptionTracked => _exceptionTracked.Task; - public void TrackException(Exception ex, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => _exceptionTracked.TrySetResult(); - } - - private sealed class FakeConnector(Func? onConnect = null) : IRedisConnector - { - private int _connectCount; - public int ConnectCount => Volatile.Read(ref _connectCount); - - public async ValueTask ConnectAsync(CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref _connectCount); - if (onConnect is not null) - { - await onConnect().ConfigureAwait(false); - } - } - - public bool IsConnected => false; - public Version Version => new(6, 0); - public IDatabase Database => throw new NotSupportedException(); - public ISubscriber Subscriber => throw new NotSupportedException(); - public EndPoint[] GetEndPoints(bool configuredOnly = false) => []; - public void ForceReconnect() { } - public void Dispose() { } - public event EventHandler? OnConnectionFailed { add { } remove { } } - public event EventHandler? OnConnectionRestored { add { } remove { } } - public event EventHandler? OnReconnected { add { } remove { } } - } [Fact] public async Task StartAsync_TriggersConnect() @@ -112,4 +80,36 @@ public void AddRedisConnection_RegistersWarmup_PerWarmUpOnStart(bool warmUpOnSta services.Any(d => d.ImplementationType == typeof(RedisConnectionWarmup)).Should().Be(warmUpOnStart); } + private sealed class CapturingTelemetry : ICachingTelemetryProvider + { + private readonly TaskCompletionSource _exceptionTracked = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task ExceptionTracked => _exceptionTracked.Task; + public void TrackException(Exception ex, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => _exceptionTracked.TrySetResult(); + } + + private sealed class FakeConnector(Func? onConnect = null) : IRedisConnector + { + private int _connectCount; + public event EventHandler? OnConnectionFailed { add { } remove { } } + public event EventHandler? OnConnectionRestored { add { } remove { } } + public event EventHandler? OnReconnected { add { } remove { } } + public int ConnectCount => Volatile.Read(ref _connectCount); + + public bool IsConnected => false; + public Version Version => new(6, 0); + public IDatabase Database => throw new NotSupportedException(); + public ISubscriber Subscriber => throw new NotSupportedException(); + + public async ValueTask ConnectAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _connectCount); + if (onConnect is not null) + { + await onConnect().ConfigureAwait(false); + } + } + public EndPoint[] GetEndPoints(bool configuredOnly = false) => []; + public void ForceReconnect() { } + public void Dispose() { } + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisConnectorIntegrationTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisConnectorIntegrationTests.cs index 7cdd2e2d..25eec6f8 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisConnectorIntegrationTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisConnectorIntegrationTests.cs @@ -10,13 +10,6 @@ namespace UiPath.Caching.Tests.Redis; [Trait("Category", "Integration")] public class RedisConnectorIntegrationTests(RedisContainerFixture fixture) { - private RedisConnector NewConnector() - { - var options = Options.Create(new RedisConnectionOptions { ConnectionString = fixture.ConnectionString, EnableHangDetection = false }); - var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); - var factory = new ConnectionMultiplexerFactory(options, NullRedisProfiler.Instance); - return new RedisConnector(NullTelemetryProvider.Instance, optionsProvider, factory, options); - } [Fact] public async Task Connects_And_RoundTrips() @@ -75,15 +68,6 @@ public async Task RefreshClusterMembership_ReachesTheServer_UnderTheDefaultConne RedisConnector.NullTopologyRefreshLimit, "each refresh must carry the client's own CLUSTER NODES to the server"); } - /// Server-side count of CLUSTER commands, which the client only ever sends from its handshake. - private static async Task ClusterCommandCallsAsync(ConnectionMultiplexer observer) - { - var sections = await observer.GetServer(observer.GetEndPoints()[0]).InfoAsync("commandstats"); - return sections.SelectMany(section => section) - .Where(stat => stat.Key.StartsWith("cmdstat_cluster", StringComparison.OrdinalIgnoreCase)) - .Sum(stat => long.Parse(stat.Value.Split(',')[0]["calls=".Length..], CultureInfo.InvariantCulture)); - } - [Fact] public async Task GetMasterPhysicalConnectionMetrics_ReturnsData_OnLiveConnection() { @@ -102,4 +86,20 @@ public async Task GetMasterPhysicalConnectionMetrics_ReturnsData_OnLiveConnectio metrics!.EndPoint.Should().BeOneOf(multiplexer.GetEndPoints()); metrics.AwaitingResponseCount.Should().BeGreaterThanOrEqualTo(0); } + + /// Server-side count of CLUSTER commands, which the client only ever sends from its handshake. + private static async Task ClusterCommandCallsAsync(ConnectionMultiplexer observer) + { + var sections = await observer.GetServer(observer.GetEndPoints()[0]).InfoAsync("commandstats"); + return sections.SelectMany(section => section) + .Where(stat => stat.Key.StartsWith("cmdstat_cluster", StringComparison.OrdinalIgnoreCase)) + .Sum(stat => long.Parse(stat.Value.Split(',')[0]["calls=".Length..], CultureInfo.InvariantCulture)); + } + private RedisConnector NewConnector() + { + var options = Options.Create(new RedisConnectionOptions { ConnectionString = fixture.ConnectionString, EnableHangDetection = false }); + var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); + var factory = new ConnectionMultiplexerFactory(options, NullRedisProfiler.Instance); + return new RedisConnector(NullTelemetryProvider.Instance, optionsProvider, factory, options); + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisConnectorLifecycleTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisConnectorLifecycleTests.cs index 4552575b..325ec22d 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisConnectorLifecycleTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisConnectorLifecycleTests.cs @@ -7,82 +7,6 @@ namespace UiPath.Caching.Tests.Redis; public class RedisConnectorLifecycleTests { - private sealed class SequenceFactory : IConnectionMultiplexerFactory - { - private readonly Queue _multiplexers; - public int CreateCount { get; private set; } - public SequenceFactory(params IConnectionMultiplexer[] multiplexers) => _multiplexers = new Queue(multiplexers); - public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) - { - CreateCount++; - return new ValueTask(_multiplexers.Dequeue()); - } - } - - private sealed class GatedFactory(IConnectionMultiplexer multiplexer, Task gate) : IConnectionMultiplexerFactory - { - public int CreateAsyncCount; - public async ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) - { - Interlocked.Increment(ref CreateAsyncCount); - await gate.ConfigureAwait(false); - return multiplexer; - } - } - - private sealed class ThreadCapturingFactory : IConnectionMultiplexerFactory - { - private readonly IConnectionMultiplexer _multiplexer = Substitute.For(); - - public ThreadCapturingFactory() - { - _multiplexer.GetDatabase(Arg.Any(), Arg.Any()).Returns(Database); - } - - public IDatabase Database { get; } = Substitute.For(); - - public int? CreateThreadId { get; private set; } - - public SynchronizationContext? CreateSynchronizationContext { get; private set; } - - public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) - { - CreateThreadId = Environment.CurrentManagedThreadId; - CreateSynchronizationContext = SynchronizationContext.Current; - return new ValueTask(_multiplexer); - } - } - - private sealed class ScriptedFactory(params Func[] steps) : IConnectionMultiplexerFactory - { - private int _index; - public int CreateCount { get; private set; } - public async ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) - { - CreateCount++; - var step = steps[Math.Min(_index, steps.Length - 1)]; - _index++; - await Task.Yield(); - return step(); - } - } - - private sealed class SignalingTelemetry : ICachingTelemetryProvider - { - private readonly TaskCompletionSource _exceptionTracked = new(TaskCreationOptions.RunContinuationsAsynchronously); - public Task ExceptionTracked => _exceptionTracked.Task; - public void TrackException(Exception ex, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => _exceptionTracked.TrySetResult(); - public void TrackEvent(string eventName, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) { } - } - - private static RedisConnector NewConnector(IConnectionMultiplexerFactory factory, ICachingTelemetryProvider? telemetry = null) - { - var options = Options.Create(new RedisConnectionOptions { ConnectionString = "localhost:6379", EnableHangDetection = false }); - var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); - return new RedisConnector(telemetry ?? NullTelemetryProvider.Instance, optionsProvider, factory, options); - } - - private static RedisConnectionException ConnectFailure() => new(ConnectionFailureType.UnableToConnect, CommandFlags.None, "boom"); [Fact] public async Task Dispose_DisposesMultiplexer_WhenConnected() @@ -406,4 +330,80 @@ public async Task Dispose_Swallows_WhenMultiplexerDisposeThrows() dispose.Should().NotThrow(); multiplexer.Received(1).Dispose(); } + + private static RedisConnector NewConnector(IConnectionMultiplexerFactory factory, ICachingTelemetryProvider? telemetry = null) + { + var options = Options.Create(new RedisConnectionOptions { ConnectionString = "localhost:6379", EnableHangDetection = false }); + var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); + return new RedisConnector(telemetry ?? NullTelemetryProvider.Instance, optionsProvider, factory, options); + } + + private static RedisConnectionException ConnectFailure() => new(ConnectionFailureType.UnableToConnect, CommandFlags.None, "boom"); + private sealed class SequenceFactory : IConnectionMultiplexerFactory + { + private readonly Queue _multiplexers; + public SequenceFactory(params IConnectionMultiplexer[] multiplexers) => _multiplexers = new Queue(multiplexers); + public int CreateCount { get; private set; } + public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) + { + CreateCount++; + return new ValueTask(_multiplexers.Dequeue()); + } + } + + private sealed class GatedFactory(IConnectionMultiplexer multiplexer, Task gate) : IConnectionMultiplexerFactory + { + public int CreateAsyncCount; + public async ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref CreateAsyncCount); + await gate.ConfigureAwait(false); + return multiplexer; + } + } + + private sealed class ThreadCapturingFactory : IConnectionMultiplexerFactory + { + private readonly IConnectionMultiplexer _multiplexer = Substitute.For(); + + public ThreadCapturingFactory() + { + _multiplexer.GetDatabase(Arg.Any(), Arg.Any()).Returns(Database); + } + + public IDatabase Database { get; } = Substitute.For(); + + public int? CreateThreadId { get; private set; } + + public SynchronizationContext? CreateSynchronizationContext { get; private set; } + + public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) + { + CreateThreadId = Environment.CurrentManagedThreadId; + CreateSynchronizationContext = SynchronizationContext.Current; + return new ValueTask(_multiplexer); + } + } + + private sealed class ScriptedFactory(params Func[] steps) : IConnectionMultiplexerFactory + { + private int _index; + public int CreateCount { get; private set; } + public async ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) + { + CreateCount++; + var step = steps[Math.Min(_index, steps.Length - 1)]; + _index++; + await Task.Yield(); + return step(); + } + } + + private sealed class SignalingTelemetry : ICachingTelemetryProvider + { + private readonly TaskCompletionSource _exceptionTracked = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task ExceptionTracked => _exceptionTracked.Task; + public void TrackException(Exception ex, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => _exceptionTracked.TrySetResult(); + public void TrackEvent(string eventName, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) { } + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisConnectorStaleEndpointTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisConnectorStaleEndpointTests.cs index 657d59ff..c5b73777 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisConnectorStaleEndpointTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisConnectorStaleEndpointTests.cs @@ -11,152 +11,6 @@ public class RedisConnectorStaleEndpointTests private static readonly IPEndPoint Live = new(IPAddress.Parse("4.195.18.22"), 8500); private static readonly IPEndPoint Retired = new(IPAddress.Parse("4.195.18.22"), 8502); - private sealed class AdvancingTimeProvider : TimeProvider - { - private readonly List _timers = []; - private DateTimeOffset _now = new(2026, 9, 3, 23, 36, 0, TimeSpan.Zero); - - public override DateTimeOffset GetUtcNow() => _now; - public override long GetTimestamp() => _now.UtcTicks; - public override long TimestampFrequency => TimeSpan.TicksPerSecond; - - public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) - { - var timer = new FakeTimer(callback, state, _now + dueTime, period); - _timers.Add(timer); - return timer; - } - - public void Advance(TimeSpan by) - { - var target = _now + by; - while (_timers.Where(t => t.Due <= target).MinBy(t => t.Due) is { } next) - { - _now = next.Due!.Value; - next.Fire(); - } - _now = target; - } - - private sealed class FakeTimer(TimerCallback callback, object? state, DateTimeOffset due, TimeSpan period) : ITimer - { - public DateTimeOffset? Due { get; private set; } = due; - - public void Fire() - { - Due = period > TimeSpan.Zero ? Due + period : null; - callback(state); - } - - public bool Change(TimeSpan dueTime, TimeSpan period) => false; - public void Dispose() => Due = null; - public ValueTask DisposeAsync() - { - Due = null; - return ValueTask.CompletedTask; - } - } - } - - private sealed class SequenceFactory(params IConnectionMultiplexer[] multiplexers) : IConnectionMultiplexerFactory - { - private readonly Queue _multiplexers = new(multiplexers); - public int CreateCount { get; private set; } - public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) - { - CreateCount++; - return new ValueTask(_multiplexers.Dequeue()); - } - } - - private sealed class RecordingTelemetry : ICachingTelemetryProvider - { - public List Events { get; } = []; - public List Exceptions { get; } = []; - public void TrackException(Exception ex, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => Exceptions.Add(ex); - public void TrackEvent(string eventName, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => Events.Add(eventName); - } - - private sealed class FakeTopology : IClusterTopologyReader - { - private object _configuration = new(); - - public HashSet? Members { get; set; } - public bool RefreshLands { get; set; } = true; - public int Reads { get; private set; } - - public object? GetConfiguration(IServer server) => Members is null ? null : _configuration; - - public HashSet GetMembers(object configuration) - { - Reads++; - return Members!; - } - - public Task Refreshed() - { - if (RefreshLands) - { - _configuration = new(); - } - - return Task.FromResult(true); - } - } - - private sealed class Harness - { - public AdvancingTimeProvider Clock { get; } = new(); - public RecordingTelemetry Telemetry { get; } = new(); - public IConnectionMultiplexer Multiplexer { get; } = Substitute.For(); - public IConnectionMultiplexer Replacement { get; } = Substitute.For(); - public IServer LiveServer { get; } - public IServer RetiredServer { get; } = Server(Retired, connected: false); - public FakeTopology Topology { get; } = new(); - public SequenceFactory Factory { get; } - public RedisConnector Connector { get; } - public TaskCompletionSource OldDisposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - - public Harness(bool retiredIsMember = false, bool clusterConfigurationKnown = true, bool retiredIsConfigured = false, bool replacementAvailable = true, bool timerDriven = false, bool connectedNodeIsConfigured = true) - { - LiveServer = Server(connectedNodeIsConfigured ? Seed : Live, connected: true); // only the configured endpoints are re-handshaked by the refresh - Topology.Members = clusterConfigurationKnown ? (retiredIsMember ? [Live, Retired] : [Live]) : null; - Multiplexer.GetEndPoints(true).Returns(retiredIsConfigured ? [Seed, Retired] : [Seed]); - Multiplexer.GetServers().Returns([LiveServer, RetiredServer]); - Multiplexer.ConfigureAsync(Arg.Any()).Returns(_ => Topology.Refreshed()); - Multiplexer.CloseAsync(Arg.Any()).Returns(Task.CompletedTask); - Multiplexer.When(m => m.Dispose()).Do(_ => OldDisposed.TrySetResult()); - - Factory = replacementAvailable ? new SequenceFactory(Multiplexer, Replacement) : new SequenceFactory(Multiplexer); - var options = Options.Create(new RedisConnectionOptions - { - ConnectionString = "redis.example.net:10000", - EnableHangDetection = false, - EnableStaleEndpointDetection = timerDriven, // otherwise the test calls the scan itself - StaleEndpointThreshold = TimeSpan.FromMinutes(5), - StaleEndpointScanInterval = TimeSpan.FromSeconds(30), - }); - var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); - Connector = new RedisConnector(Telemetry, optionsProvider, Factory, options, configurators: null, clock: Clock, topologyReader: Topology); - } - - public async Task ScanTwiceAcrossThresholdAsync() - { - await Connector.ConnectAsync(TestContext.Current.CancellationToken); - await Connector.ScanStaleEndpointsAsync(); - Clock.Advance(TimeSpan.FromMinutes(5)); - await Connector.ScanStaleEndpointsAsync(); - } - - private static IServer Server(EndPoint endPoint, bool connected) - { - var server = Substitute.For(); - server.EndPoint.Returns(endPoint); - server.IsConnected.Returns(connected); - return server; - } - } - [Fact] public async Task Scan_ForcesReconnect_WhenDiscoveredEndpointLeftClusterAndStayedDownPastThreshold() { @@ -595,4 +449,150 @@ public void FormatEndPoint_MatchesClusterNodesAddressShape() RedisConnector.FormatEndPoint(Live).Should().Be("4.195.18.22:8500"); RedisConnector.FormatEndPoint(Seed).Should().Be("redis.example.net:10000"); } + + private sealed class AdvancingTimeProvider : TimeProvider + { + private readonly List _timers = []; + private DateTimeOffset _now = new(2026, 9, 3, 23, 36, 0, TimeSpan.Zero); + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override DateTimeOffset GetUtcNow() => _now; + public override long GetTimestamp() => _now.UtcTicks; + + public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period) + { + var timer = new FakeTimer(callback, state, _now + dueTime, period); + _timers.Add(timer); + return timer; + } + + public void Advance(TimeSpan by) + { + var target = _now + by; + while (_timers.Where(t => t.Due <= target).MinBy(t => t.Due) is { } next) + { + _now = next.Due!.Value; + next.Fire(); + } + _now = target; + } + + private sealed class FakeTimer(TimerCallback callback, object? state, DateTimeOffset due, TimeSpan period) : ITimer + { + public DateTimeOffset? Due { get; private set; } = due; + + public void Fire() + { + Due = period > TimeSpan.Zero ? Due + period : null; + callback(state); + } + + public bool Change(TimeSpan dueTime, TimeSpan period) => false; + public void Dispose() => Due = null; + public ValueTask DisposeAsync() + { + Due = null; + return ValueTask.CompletedTask; + } + } + } + + private sealed class SequenceFactory(params IConnectionMultiplexer[] multiplexers) : IConnectionMultiplexerFactory + { + private readonly Queue _multiplexers = new(multiplexers); + public int CreateCount { get; private set; } + public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) + { + CreateCount++; + return new ValueTask(_multiplexers.Dequeue()); + } + } + + private sealed class RecordingTelemetry : ICachingTelemetryProvider + { + public List Events { get; } = []; + public List Exceptions { get; } = []; + public void TrackException(Exception ex, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => Exceptions.Add(ex); + public void TrackEvent(string eventName, ReadOnlySpan> properties = default, ReadOnlySpan> metrics = default) => Events.Add(eventName); + } + + private sealed class FakeTopology : IClusterTopologyReader + { + private object _configuration = new(); + + public HashSet? Members { get; set; } + public bool RefreshLands { get; set; } = true; + public int Reads { get; private set; } + + public object? GetConfiguration(IServer server) => Members is null ? null : _configuration; + + public HashSet GetMembers(object configuration) + { + Reads++; + return Members!; + } + + public Task Refreshed() + { + if (RefreshLands) + { + _configuration = new(); + } + + return Task.FromResult(true); + } + } + + private sealed class Harness + { + + public Harness(bool retiredIsMember = false, bool clusterConfigurationKnown = true, bool retiredIsConfigured = false, bool replacementAvailable = true, bool timerDriven = false, bool connectedNodeIsConfigured = true) + { + LiveServer = Server(connectedNodeIsConfigured ? Seed : Live, connected: true); // only the configured endpoints are re-handshaked by the refresh + Topology.Members = clusterConfigurationKnown ? (retiredIsMember ? [Live, Retired] : [Live]) : null; + Multiplexer.GetEndPoints(true).Returns(retiredIsConfigured ? [Seed, Retired] : [Seed]); + Multiplexer.GetServers().Returns([LiveServer, RetiredServer]); + Multiplexer.ConfigureAsync(Arg.Any()).Returns(_ => Topology.Refreshed()); + Multiplexer.CloseAsync(Arg.Any()).Returns(Task.CompletedTask); + Multiplexer.When(m => m.Dispose()).Do(_ => OldDisposed.TrySetResult()); + + Factory = replacementAvailable ? new SequenceFactory(Multiplexer, Replacement) : new SequenceFactory(Multiplexer); + var options = Options.Create(new RedisConnectionOptions + { + ConnectionString = "redis.example.net:10000", + EnableHangDetection = false, + EnableStaleEndpointDetection = timerDriven, // otherwise the test calls the scan itself + StaleEndpointThreshold = TimeSpan.FromMinutes(5), + StaleEndpointScanInterval = TimeSpan.FromSeconds(30), + }); + var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); + Connector = new RedisConnector(Telemetry, optionsProvider, Factory, options, configurators: null, clock: Clock, topologyReader: Topology); + } + public AdvancingTimeProvider Clock { get; } = new(); + public RecordingTelemetry Telemetry { get; } = new(); + public IConnectionMultiplexer Multiplexer { get; } = Substitute.For(); + public IConnectionMultiplexer Replacement { get; } = Substitute.For(); + public IServer LiveServer { get; } + public IServer RetiredServer { get; } = Server(Retired, connected: false); + public FakeTopology Topology { get; } = new(); + public SequenceFactory Factory { get; } + public RedisConnector Connector { get; } + public TaskCompletionSource OldDisposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task ScanTwiceAcrossThresholdAsync() + { + await Connector.ConnectAsync(TestContext.Current.CancellationToken); + await Connector.ScanStaleEndpointsAsync(); + Clock.Advance(TimeSpan.FromMinutes(5)); + await Connector.ScanStaleEndpointsAsync(); + } + + private static IServer Server(EndPoint endPoint, bool connected) + { + var server = Substitute.For(); + server.EndPoint.Returns(endPoint); + server.IsConnected.Returns(connected); + return server; + } + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisConnectorTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisConnectorTests.cs index 9d1de798..396a4847 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisConnectorTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisConnectorTests.cs @@ -7,11 +7,11 @@ namespace UiPath.Caching.Tests.Redis; public class RedisConnectorTests : IAsyncLifetime { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly string _connectionString = "localhost:6379"; private ICachingTelemetryProvider _telemetryProvider = default!; private IOptions _redisOptions = default!; private IRedisConfigurationOptionsProvider _redisConfigurationOptionsProvider = default!; private IConnectionMultiplexerFactory _connectionMultiplexerFactory = default!; - private readonly string _connectionString = "localhost:6379"; [Fact] public void NotNullConnection() @@ -29,7 +29,7 @@ public void ConnectionStringExtraParams() var opt = new RedisConnectionOptions { ConnectionString = "localhost:6379,ssl=True,abortConnect=True,connectTimeout=1001", - ConnectionStringExtraParams = "allowAdmin=true,abortConnect=false,connectRetry=2,keepAlive=30,name=test,syncTimeout=250,connectTimeout=1000" + ConnectionStringExtraParams = "allowAdmin=true,abortConnect=false,connectRetry=2,keepAlive=30,name=test,syncTimeout=250,connectTimeout=1000", }; var sut = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, Options.Create(opt)); var connection = sut.GetConfiguration(); @@ -52,7 +52,7 @@ public void ConnectionStringExtraParamsX(string connectionString, string extraPa var opt = new RedisConnectionOptions { ConnectionString = connectionString, - ConnectionStringExtraParams = extraParams + ConnectionStringExtraParams = extraParams, }; var sut = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, Options.Create(opt)); var cnn = sut.GetConfiguration().ToString(); @@ -64,18 +64,12 @@ public ValueTask DisposeAsync() return ValueTask.CompletedTask; } - private sealed class SubstituteMultiplexerFactory : IConnectionMultiplexerFactory - { - public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) => - new(Substitute.For()); - } - public ValueTask InitializeAsync() { _telemetryProvider = _fixture.Create(); _redisOptions = Options.Create(new RedisConnectionOptions { - ConnectionString = _connectionString + ConnectionString = _connectionString, }); _fixture.Inject(_redisOptions); _redisConfigurationOptionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, _redisOptions); @@ -84,4 +78,10 @@ public ValueTask InitializeAsync() _fixture.Inject(_connectionMultiplexerFactory); return ValueTask.CompletedTask; } + + private sealed class SubstituteMultiplexerFactory : IConnectionMultiplexerFactory + { + public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) => + new(Substitute.For()); + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs index 2b481bae..cdbc701e 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisHashCacheTests.cs @@ -15,6 +15,7 @@ public class RedisHashCacheTests(ITestContextAccessor testContextAccessor) : IAs { private static readonly string[] TwoFields = ["f1", "f2"]; private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); + private readonly RecordingTelemetryProvider _telemetry = new(); private string _prefix = default!; private IDatabase _database = default!; @@ -32,11 +33,12 @@ public class RedisHashCacheTests(ITestContextAccessor testContextAccessor) : IAs private bool _isConnected = true; private Version _version = new(6, 0); private ILogger _logger = default!; - private readonly RecordingTelemetryProvider _telemetry = new(); private RedisHashCache? _sut = null; private RedisHashCache Sut => _sut ??= _fixture.Create(); + private IEnumerable ReadDeps => _telemetry.Dependencies.Where(d => d.Type == TelemetryOperation.DependencyType); + [Fact] public async Task Get_data_from_cacheKey_cache() { @@ -115,8 +117,6 @@ public async Task Get_fields_data_from_cacheKey_redis_exception() actual.Should().BeEmpty(); } - private IEnumerable ReadDeps => _telemetry.Dependencies.Where(d => d.Type == TelemetryOperation.DependencyType); - [Fact] public async Task Hash_multi_field_get_does_not_emit_dependency_by_default() { @@ -294,13 +294,13 @@ public async Task GetOrAdd_generator_not_called() return ret; }); var generatorCalled = false; - Task> generator(CancellationToken token) + Task> Generator(CancellationToken token) { generatorCalled = true; return Task.FromResult(fields.ToDictionary(k => k, k => _fixture.Create()) as IDictionary); } - var actual = await Sut.GetOrAddAsync(_cacheKey, generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); + var actual = await Sut.GetOrAddAsync(_cacheKey, Generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); actual.Should().BeEquivalentTo(expected); generatorCalled.Should().BeFalse(); } @@ -342,13 +342,13 @@ public async Task GetOrAdd_generator_called() return Array.Empty(); }); var generatorCalled = false; - Task> generator(CancellationToken _) + Task> Generator(CancellationToken _) { generatorCalled = true; return Task.FromResult(expected); } _transaction.ExecuteAsync(Arg.Any()).Returns(true); - var actual = await Sut.GetOrAddAsync(_cacheKey, generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); + var actual = await Sut.GetOrAddAsync(_cacheKey, Generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); actual.Should().BeEquivalentTo(expected); _database.Received(1).CreateTransaction(); await _transaction.Received(1).HashSetAsync(_redisKey, Arg.Any(), CommandFlags.DemandMaster); @@ -396,12 +396,12 @@ public async Task GetOrAdd_generator_called_empty_result() return Array.Empty(); }); var generatorCalled = false; - Task> generator(CancellationToken _) + Task> Generator(CancellationToken _) { generatorCalled = true; return Task.FromResult(expected); } - var actual = await Sut.GetOrAddAsync(_cacheKey, generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); + var actual = await Sut.GetOrAddAsync(_cacheKey, Generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); actual.Should().BeEquivalentTo(expected); _database.Received(0).CreateTransaction(); await _transaction.Received(0).HashSetAsync(_redisKey, Arg.Any(), CommandFlags.DemandMaster); @@ -417,13 +417,13 @@ public async Task GetOrAdd_returns_empty_dict_without_invoking_generator_when_on _database.HashGetAllAsync(_redisKey, CommandFlags.PreferReplica) .Returns(_ => new[] { new HashEntry(KnownFieldNames.MetadataKey, RedisValue.EmptyString) }); var generatorCalled = false; - Task> generator(CancellationToken _) + Task> Generator(CancellationToken _) { generatorCalled = true; return Task.FromResult>(new Dictionary { ["fresh"] = "v" }); } - var actual = await Sut.GetOrAddAsync(_cacheKey, generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); + var actual = await Sut.GetOrAddAsync(_cacheKey, Generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); actual.Should().BeEmpty(); generatorCalled.Should().BeFalse(); @@ -518,25 +518,6 @@ public async Task Set_uses_the_array_member_of_a_serializer_without_memory() serializer.Received(1).Serialize("v"); } - private sealed class RecordingRawSerializer : RawByteSerializerProxy - { - public int ArrayCalls { get; private set; } - - public int MemoryCalls { get; private set; } - - public override byte[]? Serialize(object? value) - { - ArrayCalls++; - return base.Serialize(value); - } - - public override ReadOnlyMemory SerializeToMemory(T? value) where T : default - { - MemoryCalls++; - return base.SerializeToMemory(value); - } - } - [Fact] public async Task GetOrAdd_empty_result_with_CacheNullValues_writes_metadata_marker() { @@ -644,13 +625,13 @@ public async Task GetOrAdd_legacy_nonempty_metadata_only_hash_runs_generator_whe _database.HashGetAllAsync(_redisKey, CommandFlags.PreferReplica) .Returns(_ => new[] { new HashEntry(KnownFieldNames.MetadataKey, legacyMetadata) }); var generatorCalled = false; - Task> generator(CancellationToken _) + Task> Generator(CancellationToken _) { generatorCalled = true; return Task.FromResult>(new Dictionary { ["fresh"] = "v" }); } - await Sut.GetOrAddAsync(_cacheKey, generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); + await Sut.GetOrAddAsync(_cacheKey, Generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); generatorCalled.Should().BeTrue("only Length==0 _metadata_ is the cached-empty sentinel in the GetOrAdd probe path; legacy non-empty _metadata_-only hashes must remain misses"); } @@ -664,13 +645,13 @@ public async Task GetOrAdd_marker_only_hash_runs_generator_when_CacheNullValues_ .Returns(_ => new[] { new HashEntry(KnownFieldNames.MetadataKey, RedisValue.EmptyString) }); var generated = new Dictionary { ["k"] = "v" }; bool generatorCalled = false; - Task> generator(CancellationToken _) + Task> Generator(CancellationToken _) { generatorCalled = true; return Task.FromResult>(generated); } - var actual = await Sut.GetOrAddAsync(_cacheKey, generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); + var actual = await Sut.GetOrAddAsync(_cacheKey, Generator, TimeSpan.FromMinutes(5), token: testContextAccessor.Current.CancellationToken); generatorCalled.Should().BeTrue(); actual.Should().BeEquivalentTo(generated); @@ -687,7 +668,8 @@ public async Task SetAsync_empty_with_metadata_and_CacheNullValues_false_removes await Sut.SetAsync( _cacheKey, new Dictionary(), - new HashCacheEntryOptions(TimeToLive: _fixture.Create(), Metadata: metadata), token: testContextAccessor.Current.CancellationToken); + new HashCacheEntryOptions(TimeToLive: _fixture.Create(), Metadata: metadata), + token: testContextAccessor.Current.CancellationToken); await _database.Received().KeyDeleteAsync(_redisKey, Arg.Any()); await _transaction.DidNotReceive().HashSetAsync(_redisKey, Arg.Any(), Arg.Any()); @@ -859,7 +841,8 @@ public async Task SetAsync_empty_with_metadata_and_CacheNullValues_preserves_met await Sut.SetAsync( _cacheKey, new Dictionary(), - new HashCacheEntryOptions(TimeToLive: _fixture.Create(), Metadata: metadata), token: testContextAccessor.Current.CancellationToken); + new HashCacheEntryOptions(TimeToLive: _fixture.Create(), Metadata: metadata), + token: testContextAccessor.Current.CancellationToken); captured.Should().NotBeNull(); captured!.Should().ContainSingle() @@ -1518,7 +1501,7 @@ public ValueTask InitializeAsync() DefaultExpiration = TimeSpan.FromSeconds(Random.Shared.Next(1, 100)), EntryFactory = new TestCacheEntryFactory(), CacheKeyStrategy = _cacheKeyStrategy, - RedisKeyStrategyFactory = redisKeyStrategyFactory + RedisKeyStrategyFactory = redisKeyStrategyFactory, }; _serializer = new JsonSerializer(); _fixture.Inject>(_serializer); @@ -1532,4 +1515,23 @@ public ValueTask InitializeAsync() _connector.IsConnected.Returns(_ => _isConnected); return ValueTask.CompletedTask; } + + private sealed class RecordingRawSerializer : RawByteSerializerProxy + { + public int ArrayCalls { get; private set; } + + public int MemoryCalls { get; private set; } + + public override byte[]? Serialize(object? value) + { + ArrayCalls++; + return base.Serialize(value); + } + + public override ReadOnlyMemory SerializeToMemory(T? value) where T : default + { + MemoryCalls++; + return base.SerializeToMemory(value); + } + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisPlannedMaintenanceIntegrationTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisPlannedMaintenanceIntegrationTests.cs index bc1e485e..c48f6eb4 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisPlannedMaintenanceIntegrationTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisPlannedMaintenanceIntegrationTests.cs @@ -11,64 +11,6 @@ namespace UiPath.Caching.Tests.Redis; [Trait("Category", "Integration")] public class RedisPlannedMaintenanceIntegrationTests(RedisContainerFixture fixture) { - private sealed class CapturingLogger : ILogger - { - public ConcurrentQueue Warnings { get; } = new(); - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => true; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - if (logLevel == LogLevel.Warning) - { - Warnings.Enqueue(formatter(state, exception)); - } - } - } - - private sealed class ThrowingConnectionMultiplexerFactory(int expectedAttempts) : IConnectionMultiplexerFactory - { - private readonly TaskCompletionSource _expectedAttemptsReached = new(TaskCreationOptions.RunContinuationsAsynchronously); - private int _createCount; - - public int CreateCount => Volatile.Read(ref _createCount); - - public Task ExpectedAttemptsReached => _expectedAttemptsReached.Task; - - public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) - { - var createCount = Interlocked.Increment(ref _createCount); - if (createCount >= expectedAttempts) - { - _expectedAttemptsReached.TrySetResult(); - } - - return new ValueTask( - Task.FromException( - new RedisConnectionException(ConnectionFailureType.UnableToConnect, CommandFlags.None, "boom"))); - } - } - - private static RedisPlannedMaintenance NewMaintenance( - RedisConnectionOptions connectionOptions, - CapturingLogger logger, - IConnectionMultiplexerFactory? factory = null) - { - var options = Options.Create(connectionOptions); - var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); - factory ??= new ConnectionMultiplexerFactory(options, NullRedisProfiler.Instance); - return new RedisPlannedMaintenance(NullTelemetryProvider.Instance, Substitute.For(), optionsProvider, factory, logger, options); - } - - private static async Task WaitForWarningsAsync(CapturingLogger logger, int count, CancellationToken cancellationToken) - { - for (var i = 0; i < 200 && logger.Warnings.Count < count; i++) - { - await Task.Delay(50, cancellationToken); - } - } [Fact] public async Task StartAsync_SubscribesWithoutWarnings_AgainstLiveRedis() @@ -189,4 +131,62 @@ public async Task StopAsync_HaltsRetryLoop_WhenConnectionFails() (logger.Warnings.Count - countAtStop).Should().BeLessThanOrEqualTo(2); } + + private static RedisPlannedMaintenance NewMaintenance( + RedisConnectionOptions connectionOptions, + CapturingLogger logger, + IConnectionMultiplexerFactory? factory = null) + { + var options = Options.Create(connectionOptions); + var optionsProvider = new RedisConfigurationOptionsProvider(NullLoggerFactory.Instance, options); + factory ??= new ConnectionMultiplexerFactory(options, NullRedisProfiler.Instance); + return new RedisPlannedMaintenance(NullTelemetryProvider.Instance, Substitute.For(), optionsProvider, factory, logger, options); + } + + private static async Task WaitForWarningsAsync(CapturingLogger logger, int count, CancellationToken cancellationToken) + { + for (var i = 0; i < 200 && logger.Warnings.Count < count; i++) + { + await Task.Delay(50, cancellationToken); + } + } + private sealed class CapturingLogger : ILogger + { + public ConcurrentQueue Warnings { get; } = new(); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Enqueue(formatter(state, exception)); + } + } + } + + private sealed class ThrowingConnectionMultiplexerFactory(int expectedAttempts) : IConnectionMultiplexerFactory + { + private readonly TaskCompletionSource _expectedAttemptsReached = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _createCount; + + public int CreateCount => Volatile.Read(ref _createCount); + + public Task ExpectedAttemptsReached => _expectedAttemptsReached.Task; + + public ValueTask CreateAsync(ConfigurationOptions configuration, CancellationToken cancellationToken = default) + { + var createCount = Interlocked.Increment(ref _createCount); + if (createCount >= expectedAttempts) + { + _expectedAttemptsReached.TrySetResult(); + } + + return new ValueTask( + Task.FromException( + new RedisConnectionException(ConnectionFailureType.UnableToConnect, CommandFlags.None, "boom"))); + } + } } diff --git a/tests/UiPath.Caching.Tests/Redis/RedisSetCacheTests.cs b/tests/UiPath.Caching.Tests/Redis/RedisSetCacheTests.cs index 76fd03bf..d3f949a5 100644 --- a/tests/UiPath.Caching.Tests/Redis/RedisSetCacheTests.cs +++ b/tests/UiPath.Caching.Tests/Redis/RedisSetCacheTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Internal; +using Microsoft.Extensions.Internal; using Microsoft.Extensions.Logging; using NSubstitute.ExceptionExtensions; using StackExchange.Redis; @@ -8,6 +8,8 @@ namespace UiPath.Caching.Tests.Redis; public class RedisSetCacheTests(ITestContextAccessor testContextAccessor) : IAsyncLifetime { + private const string PopResilienceKeyName = "set-pop"; + private readonly RedisSetCacheOptions _setCacheOptions = new() { ResilienceKeyName = PopResilienceKeyName }; private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private string _prefix = default!; @@ -15,9 +17,7 @@ public class RedisSetCacheTests(ITestContextAccessor testContextAccessor) : IAsy private ITransaction _transaction = default!; private SystemJsonByteSerializerProxy _serializer = default!; private ISystemClock _clock = default!; - private const string PopResilienceKeyName = "set-pop"; private RedisCacheOptions _redisCacheOptions = new(); - private readonly RedisSetCacheOptions _setCacheOptions = new() { ResilienceKeyName = PopResilienceKeyName }; private DateTimeOffset _now = DateTimeOffset.UtcNow; private IResiliencePipelineProvider _pipelineProvider = default!; private CacheKey _cacheKey = default!; @@ -519,17 +519,6 @@ public void Dispose_can_be_called() act.Should().NotThrow(); } - private sealed class CountingPipeline : IResiliencePipeline - { - public int Calls { get; private set; } - - public ValueTask ExecuteAsync(Func> callback, TResult defaultValue, CancellationToken cancellationToken = default) - { - Calls++; - return callback(cancellationToken); - } - } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask InitializeAsync() @@ -563,7 +552,7 @@ public ValueTask InitializeAsync() { DefaultExpiration = TimeSpan.FromSeconds(Random.Shared.Next(1, 100)), CacheKeyStrategy = _cacheKeyStrategy, - RedisKeyStrategyFactory = _redisKeyStrategyFactory + RedisKeyStrategyFactory = _redisKeyStrategyFactory, }; _serializer = new SystemJsonByteSerializerProxy(); _fixture.Inject>(_serializer); @@ -575,4 +564,15 @@ public ValueTask InitializeAsync() _connector.IsConnected.Returns(_ => _isConnected); return ValueTask.CompletedTask; } + + private sealed class CountingPipeline : IResiliencePipeline + { + public int Calls { get; private set; } + + public ValueTask ExecuteAsync(Func> callback, TResult defaultValue, CancellationToken cancellationToken = default) + { + Calls++; + return callback(cancellationToken); + } + } } diff --git a/tests/UiPath.Caching.Tests/RedisProfilerTests.cs b/tests/UiPath.Caching.Tests/RedisProfilerTests.cs index f18cf9f7..c9f48903 100644 --- a/tests/UiPath.Caching.Tests/RedisProfilerTests.cs +++ b/tests/UiPath.Caching.Tests/RedisProfilerTests.cs @@ -118,7 +118,10 @@ public void NoMaxSettings() public void Dispose_twice() { Sut.Dispose(); - Sut.Dispose(); + + Action act = () => Sut.Dispose(); + + act.Should().NotThrow("a second Dispose must be a no-op"); } [Fact] @@ -170,7 +173,7 @@ public ValueTask InitializeAsync() { ProfilerEnabled = true, ProfilerFlushInterval = TimeSpan.FromMilliseconds(100), - ProfilerHasDefaultSession = true + ProfilerHasDefaultSession = true, }; _fixture.Inject(Options.Create(_redisConnectionOptions)); return ValueTask.CompletedTask; diff --git a/tests/UiPath.Caching.Tests/RehydrationCoordinatorTests.cs b/tests/UiPath.Caching.Tests/RehydrationCoordinatorTests.cs index cfdde2d3..67c28e83 100644 --- a/tests/UiPath.Caching.Tests/RehydrationCoordinatorTests.cs +++ b/tests/UiPath.Caching.Tests/RehydrationCoordinatorTests.cs @@ -9,38 +9,6 @@ public class RehydrationCoordinatorTests { private static readonly TimeSpan Duration = TimeSpan.FromMinutes(10); - private static RehydrationCoordinator NewCoordinator( - IDistributedLock? distributedLock = null, - RecordingTelemetryProvider? telemetry = null) - { - var clock = TimeProvider.System; - var lockKeyStrategy = new DefaultDistributedLockKeyStrategy(separator: ':'); - return new RehydrationCoordinator( - cacheName: "test-cache", - clock, - distributedLock ?? NullDistributedLock.Instance, - lockKeyStrategy, - telemetry ?? new RecordingTelemetryProvider(), - NullLogger.Instance); - } - - private static CachePolicy RehydratePolicy( - double threshold = 0.5, - double timeoutFraction = 0.5, - TimeSpan? baseCooldown = null) => new() - { - DistributedExpiration = Duration, - RehydrateEnabled = true, - Rehydrate = new RehydrateOptions - { - Threshold = threshold, - BaseCooldown = baseCooldown ?? TimeSpan.FromSeconds(1), - MaxCooldown = TimeSpan.FromMinutes(5), - TimeoutFraction = timeoutFraction, - Name = "test", - }, - }; - [Fact] public void TryTrigger_returns_false_when_RehydrateEnabled_is_null() { @@ -370,6 +338,38 @@ public async Task Batch_cooldown_uses_the_max_failure_count_across_the_set() Assert.Fail("\"failing\" never left the in-flight set, so the max-failure-count path was never exercised."); } + private static RehydrationCoordinator NewCoordinator( + IDistributedLock? distributedLock = null, + RecordingTelemetryProvider? telemetry = null) + { + var clock = TimeProvider.System; + var lockKeyStrategy = new DefaultDistributedLockKeyStrategy(separator: ':'); + return new RehydrationCoordinator( + cacheName: "test-cache", + clock, + distributedLock ?? NullDistributedLock.Instance, + lockKeyStrategy, + telemetry ?? new RecordingTelemetryProvider(), + NullLogger.Instance); + } + + private static CachePolicy RehydratePolicy( + double threshold = 0.5, + double timeoutFraction = 0.5, + TimeSpan? baseCooldown = null) => new() + { + DistributedExpiration = Duration, + RehydrateEnabled = true, + Rehydrate = new RehydrateOptions + { + Threshold = threshold, + BaseCooldown = baseCooldown ?? TimeSpan.FromSeconds(1), + MaxCooldown = TimeSpan.FromMinutes(5), + TimeoutFraction = timeoutFraction, + Name = "test", + }, + }; + private static async Task WaitForCallAsync(Func predicate, TimeSpan timeout) { var sw = System.Diagnostics.Stopwatch.StartNew(); diff --git a/tests/UiPath.Caching.Tests/ResiliencePipelineFactoryTests.cs b/tests/UiPath.Caching.Tests/ResiliencePipelineFactoryTests.cs index 3da3d6af..65ca3bf6 100644 --- a/tests/UiPath.Caching.Tests/ResiliencePipelineFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/ResiliencePipelineFactoryTests.cs @@ -115,7 +115,8 @@ public async Task Timeout_waits_for_a_callback_that_ignores_cancellation() await Task.Delay(TimeSpan.FromMilliseconds(300), testContextAccessor.Current.CancellationToken); completed = true; return true; - }, testContextAccessor.Current.CancellationToken); + }, + testContextAccessor.Current.CancellationToken); completed.Should().BeTrue("the pipeline returned only once the callback had finished"); result.Should().BeTrue("a callback that completes is not reported as timed out"); @@ -204,17 +205,6 @@ public async Task Pipeline_works_as_expected() logMessages.Should().Contain(log => log.Contains("OnHalfOpened")); } - private void AssertStrategies(int count) - { - var resiliencePipelineFactory = _fixture.Create(); - var pipeline = resiliencePipelineFactory.Create("read", false); - var x = typeof(ResiliencePipeline).GetProperty("Component", BindingFlags.Instance | BindingFlags.NonPublic); - var component = x!.GetValue(pipeline); - var strategies = component!.GetType().GetProperty("Components", BindingFlags.Instance | BindingFlags.Public)!.GetValue(component) as IEnumerable; - strategies.Should().NotBeNull().And.HaveCount(count); - pipeline.Should().NotBeNull(); - } - public ValueTask DisposeAsync() { return ValueTask.CompletedTask; @@ -236,4 +226,15 @@ public ValueTask InitializeAsync() _loggerFactory.CreateLogger(Arg.Any()).ReturnsForAnyArgs(_boolLogger); return ValueTask.CompletedTask; } + + private void AssertStrategies(int count) + { + var resiliencePipelineFactory = _fixture.Create(); + var pipeline = resiliencePipelineFactory.Create("read", false); + var x = typeof(ResiliencePipeline).GetProperty("Component", BindingFlags.Instance | BindingFlags.NonPublic); + var component = x!.GetValue(pipeline); + var strategies = component!.GetType().GetProperty("Components", BindingFlags.Instance | BindingFlags.Public)!.GetValue(component) as IEnumerable; + strategies.Should().NotBeNull().And.HaveCount(count); + pipeline.Should().NotBeNull(); + } } diff --git a/tests/UiPath.Caching.Tests/ResiliencePipelineProviderTests.cs b/tests/UiPath.Caching.Tests/ResiliencePipelineProviderTests.cs index e7e147e8..5687b71c 100644 --- a/tests/UiPath.Caching.Tests/ResiliencePipelineProviderTests.cs +++ b/tests/UiPath.Caching.Tests/ResiliencePipelineProviderTests.cs @@ -7,16 +7,6 @@ public class ResiliencePipelineProviderTests { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); - private ResiliencePipelineProvider CreateSut(params string[] registered) - { - var registry = new ResiliencePipelineRegistry(); - foreach (var name in registered) - { - registry.Add(name); - } - return new(_fixture.Freeze(), registry); - } - [Theory] [InlineData(ResiliencePipelineNames.Read)] [InlineData(ResiliencePipelineNames.Write)] @@ -49,4 +39,14 @@ public void Get_returns_noop_for_null_empty_or_unregistered_name(string? name) sut.Get(name).Should().BeOfType(); } + + private ResiliencePipelineProvider CreateSut(params string[] registered) + { + var registry = new ResiliencePipelineRegistry(); + foreach (var name in registered) + { + registry.Add(name); + } + return new(_fixture.Freeze(), registry); + } } diff --git a/tests/UiPath.Caching.Tests/ResiliencePipelineWrapperTests.cs b/tests/UiPath.Caching.Tests/ResiliencePipelineWrapperTests.cs index 0dd45b3f..03fa7fe5 100644 --- a/tests/UiPath.Caching.Tests/ResiliencePipelineWrapperTests.cs +++ b/tests/UiPath.Caching.Tests/ResiliencePipelineWrapperTests.cs @@ -6,8 +6,8 @@ public class ResiliencePipelineWrapperTests(ITestContextAccessor testContextAcce { private readonly IFixture _fixture = AutoFixtureCreator.NSubstitute(); private IResiliencePipelineFactory _resiliencePipelineFactory = default!; - private int boolCallCount = 0; - private int intCallCount = 0; + private int _boolCallCount = 0; + private int _intCallCount = 0; [Fact] public async Task IntPipelineIsCached_same_default() @@ -15,7 +15,7 @@ public async Task IntPipelineIsCached_same_default() var sut = _fixture.Create(); await sut.ExecuteAsync(_ => new ValueTask(1), 1, testContextAccessor.Current.CancellationToken); await sut.ExecuteAsync(_ => new ValueTask(1), 1, testContextAccessor.Current.CancellationToken); - intCallCount.Should().Be(1); + _intCallCount.Should().Be(1); } [Fact] @@ -24,7 +24,7 @@ public async Task IntPipeline_different_default() var sut = _fixture.Create(); await sut.ExecuteAsync(_ => new ValueTask(1), 1, testContextAccessor.Current.CancellationToken); await sut.ExecuteAsync(_ => new ValueTask(1), 2, testContextAccessor.Current.CancellationToken); - intCallCount.Should().Be(2); + _intCallCount.Should().Be(2); } [Fact] @@ -33,7 +33,7 @@ public async Task BoolPipeline_different_default() var sut = _fixture.Create(); await sut.ExecuteAsync(_ => new ValueTask(false), false, testContextAccessor.Current.CancellationToken); await sut.ExecuteAsync(_ => new ValueTask(false), true, testContextAccessor.Current.CancellationToken); - boolCallCount.Should().Be(2); + _boolCallCount.Should().Be(2); } [Fact] @@ -44,8 +44,8 @@ public async Task AllCached() await sut.ExecuteAsync(_ => new ValueTask(1), 1, testContextAccessor.Current.CancellationToken); await sut.ExecuteAsync(_ => new ValueTask(false), false, testContextAccessor.Current.CancellationToken); await sut.ExecuteAsync(_ => new ValueTask(false), false, testContextAccessor.Current.CancellationToken); - boolCallCount.Should().Be(1); - intCallCount.Should().Be(1); + _boolCallCount.Should().Be(1); + _intCallCount.Should().Be(1); } public ValueTask DisposeAsync() @@ -60,13 +60,13 @@ public ValueTask InitializeAsync() _resiliencePipelineFactory.Create(Arg.Any(), Arg.Any()) .Returns(ctx => { - boolCallCount++; + _boolCallCount++; return new ResiliencePipelineBuilder().Build(); }); _resiliencePipelineFactory.Create(Arg.Any(), Arg.Any()) .Returns(ctx => { - intCallCount++; + _intCallCount++; return new ResiliencePipelineBuilder().Build(); }); return ValueTask.CompletedTask; diff --git a/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs b/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs index 12e9884c..271340a7 100644 --- a/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs +++ b/tests/UiPath.Caching.Tests/SetCacheProviderTests.cs @@ -5,12 +5,6 @@ namespace UiPath.Caching.Tests; public class InMemoryQueueCacheProviderTests { - private static InMemoryQueueCacheProvider CreateSut(InMemoryQueueCacheOptions? options = null) => - new(Options.Create(options ?? new InMemoryQueueCacheOptions()), - new MemoryCacheFactory(TimeProvider.System, NullLoggerFactory.Instance), - new SystemJsonByteSerializerProxy(), - NullLocalLock.Instance, - TimeProvider.System); [Fact] public void Creates_in_memory_set_cache() @@ -55,6 +49,12 @@ public void Dispose_can_be_called() var act = () => sut.Dispose(); act.Should().NotThrow(); } + private static InMemoryQueueCacheProvider CreateSut(InMemoryQueueCacheOptions? options = null) => + new(Options.Create(options ?? new InMemoryQueueCacheOptions()), + new MemoryCacheFactory(TimeProvider.System, NullLoggerFactory.Instance), + new SystemJsonByteSerializerProxy(), + NullLocalLock.Instance, + TimeProvider.System); } public class InMemoryRedisQueueCacheProviderTests @@ -90,14 +90,6 @@ public async Task Resolves_its_L2_from_the_factory_Redis_provider() public class QueueCacheFactoryProviderSelectionTests { - private static IQueueCacheProvider 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() @@ -210,4 +202,12 @@ public void Dispose_disposes_providers_and_swallows_exceptions() act.Should().NotThrow(); provider.Received(1).Dispose(); } + private static IQueueCacheProvider 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; + } } diff --git a/tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs b/tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs index d09cceb4..a28640d2 100644 --- a/tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs +++ b/tests/UiPath.Caching.Tests/SystemJsonByteSerializerProxyTests.cs @@ -7,8 +7,6 @@ public class SystemJsonByteSerializerProxyTests { private readonly SystemJsonByteSerializerProxy _proxy = new(); - private sealed record Poco(string Name, int Count); - [Fact] public void Byte_array_is_base64_encoded_inside_json() { @@ -179,4 +177,6 @@ public void Memory_of_byte_round_trips() _proxy.Deserialize>(stored).ToArray().Should().Equal(4, 5, 6); } + + private sealed record Poco(string Name, int Count); } diff --git a/tests/UiPath.Caching.Tests/TestCacheEntry.cs b/tests/UiPath.Caching.Tests/TestCacheEntry.cs index 7806e521..7ca2c65a 100644 --- a/tests/UiPath.Caching.Tests/TestCacheEntry.cs +++ b/tests/UiPath.Caching.Tests/TestCacheEntry.cs @@ -6,8 +6,6 @@ internal class TestCacheEntry : ICacheEntry public T? Value { get; set; } - object? ICacheEntry.Value => Value; - public DateTimeOffset Expiration { get; set; } = DateTimeOffset.MaxValue; public IDictionary? Metadata { get; set; } @@ -22,6 +20,8 @@ public bool Found init => _foundOverride = value; } + object? ICacheEntry.Value => Value; + public ICacheEntry NewEntry(DateTimeOffset? expiration = null, IDictionary? metadata = null) => _foundOverride.HasValue ? new TestCacheEntry diff --git a/tests/UiPath.Caching.Tests/TopicFactoryTests.cs b/tests/UiPath.Caching.Tests/TopicFactoryTests.cs index a1740312..05c74f03 100644 --- a/tests/UiPath.Caching.Tests/TopicFactoryTests.cs +++ b/tests/UiPath.Caching.Tests/TopicFactoryTests.cs @@ -32,7 +32,7 @@ public void Works_as_expected() } [Fact] - public void empty_factory() + public void Empty_factory() { _sut = new TopicFactory(Options.Create(_cacheOptions)); Sut.Get(_fixture.Create()).Should().BeOfType();