From ce49be89761491e6ac9e2400b71112778139a439 Mon Sep 17 00:00:00 2001 From: Cosmin Staicu Date: Thu, 10 Sep 2026 18:45:09 +0300 Subject: [PATCH] refactor: order type members, and enforce it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Members are laid out the way the ordering rules ask, and the rules are on so it stays that way. The build reports no warnings at all: every SA1201, SA1202, SA1203, SA1204, SA1210, SA1214 and IDE0040 is gone, and S4136 with them. What is enforced .editorconfig already carried a curated StyleCop block, so this joins it rather than replacing it. StyleCop.Analyzers is now referenced, and every one of its eight categories is none except OrderingRules, with the rules we want named individually where a category cannot reach them. Without that the package reports around five thousand warnings. - The OrderingRules category: order by kind, by access, constants first, readonly first, using directives alphabetical. The category rather than a list of ids, because it costs 28 more violations and no maintenance. - SA1204, static before instance, was already none further up the file. A specific id beats a category, so the category alone would not have applied it; it is named explicitly. SA1200 and SA1208, on using directive placement, stay none as they were. - SA1402, scoped to src: one top-level type per file on the shipped surface. topLevelTypes in stylecop.json widens it past its default of class alone. Four files held nine extra types and are now thirteen. Tests and samples keep small helper types next to what uses them. - SA1649 was already none and stays there: it does not recognise the OfT suffix this repo uses for generic types, so it would rename CacheOfT.cs and six siblings to Cache{T}.cs. - SX1309, the alternative to SA1309, wants every field to begin with an underscore. It belongs to no StyleCop category, so switching the categories off does not reach it; the SX family is named off explicitly. SA1309 is already off above for the opposite reason. - IDE0040 was configured but silent. It is a warning now: a class member always states its accessibility, an interface member never repeats the public it already has. 25 interface members carried a redundant public and two class members had none. How the members were moved Neither dotnet format nor the Roslynator CLI can drive StyleCop's fixer for the ordering rules — both answer that no code fix was found — and Rider's layout engine ranks constants and statics above accessibility, which pushes SA1202 up rather than down. So a Roslyn pass did it: parse, sort each type's members by StyleCop's default elementOrder, write the tree back, which carries each member's doc comments and blank lines with it. Three rules kept it safe. The sort is stable, so anything the comparer calls equal keeps the order the author chose. A field whose initializer reads a sibling member, or this, is pinned where it is, because C# runs field initializers in textual order. And a file containing #region or #if is refused outright, since those directives are trivia on the members around them and a sort can carry one away from its partner. That left seven conditional-compilation files and six other places to settle by hand: three field blocks moved as units so their initializers keep reading what they read, an interface that sat below a class in its file, and the two buffer tests whose nested helper lives beside the tests that use it. Also here S4136 had been reporting eight non-adjacent overload groups on every build, and #164 proposed turning the rule off. Measuring first said otherwise: every group is of uniform accessibility, five are split by a single private Core helper sitting between the public overloads it serves, and moving that helper below the group is what SA1202 asks for anyway. The two rules agree rather than conflict. KeyMasker.IsMasking carried two summary tags, having taken ShouldMask's when it was inserted above it. ShouldMask has its documentation back. Verification The reordering is a move and nothing else, which the diff shows: the insertions and deletions match. Both suites are unchanged at 1701 on net10 and 1680 on net8 — the count is the check that matters for the conditional files, because a test carried out of a NET9_0_OR_GREATER block would raise the net8 total and one carried in would lower it. #if pairs stay balanced. The packed nuspec is untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01NsPw6MZHPGmpbo6WDuLzF1 Signed-off-by: Cosmin Staicu --- .editorconfig | 55 +- .github/workflows/ci.yml | 7 +- Directory.Build.props | 9 + Directory.Packages.props | 1 + .../CacheBenchmark.cs | 8 +- .../UiPath.Caching.Benchmarks/CustomObject.cs | 4 +- .../SerializerBenchmark.cs | 4 +- .../StreamNotifyDoorbellHarness.cs | 6 +- .../Extensions.cs | 40 +- samples/UiPath.Caching.Sample/Program.cs | 2 +- .../Broadcast/IChangeTokenFactory.cs | 2 +- .../Broadcast/ITopic.cs | 4 +- .../Broadcast/NullCacheChangeToken.cs | 2 +- .../Broadcast/NullCacheEventFactory.cs | 4 +- .../Broadcast/TopicKey.cs | 34 +- src/UiPath.Caching.Abstractions/CacheKey.cs | 64 +- src/UiPath.Caching.Abstractions/CacheOfT.cs | 32 +- .../Config/ICachePolicyFactory.cs | 10 +- .../Config/NullCachePolicyFactory.cs | 4 +- .../HashCacheSetOption.cs | 2 +- .../ICacheChangeToken.cs | 4 +- .../ICacheEntry.cs | 4 +- src/UiPath.Caching.Abstractions/NullCache.cs | 14 +- .../NullHashCache.cs | 4 +- .../Telemetry/ICachingTelemetryProvider.cs | 2 +- .../Telemetry/TelemetryOperation.cs | 6 +- .../AzureEntraConnectionConfigurator.cs | 10 +- .../AzureEntraCredentialFactory.cs | 2 +- .../CacheCloudEventWrapper.cs | 6 +- .../CloudCacheEventFactory.cs | 2 +- src/UiPath.Caching.Polly/GlobalUsings.cs | 4 +- .../ResiliencePipelineFactory.cs | 8 +- .../InMemoryQueueCacheProvider.cs | 8 +- .../InMemoryRedisQueueCacheProvider.cs | 8 +- src/UiPath.Caching.Queue/MemorySetCache.cs | 26 +- .../MultilayerSetCache.cs | 54 +- .../RedisQueueCacheProvider.cs | 8 +- src/UiPath.Caching.Queue/RedisSetCache.cs | 168 ++--- .../Broadcast/CacheEventFactory.cs | 2 +- src/UiPath.Caching/Broadcast/ChangeToken.cs | 38 +- .../Broadcast/ChangeTokenFactory.cs | 4 +- src/UiPath.Caching/Broadcast/ChannelHelper.cs | 2 +- .../Broadcast/EventDispatcher.cs | 22 +- src/UiPath.Caching/Broadcast/KeyedSubject.cs | 58 +- .../Broadcast/PerTopicOptionsRegistry.cs | 6 +- .../Redis/RedisPubSubSubjectWriter.cs | 24 +- .../Broadcast/Redis/RedisPubSubTopic.cs | 16 +- .../Redis/RedisPubSubTopicProvider.cs | 6 +- .../Redis/RedisStreamHealthMaintainer.cs | 98 +-- .../Redis/RedisStreamNotifyChannel.cs | 42 +- .../Redis/RedisStreamSubjectWriter.cs | 34 +- .../Broadcast/Redis/RedisStreamsTopic.cs | 16 +- .../Redis/RedisStreamsTopicProvider.cs | 6 +- .../Broadcast/Redis/RedisTopicProviderBase.cs | 26 +- .../Broadcast/Redis/StreamConstants.cs | 8 +- src/UiPath.Caching/CacheEntryBuilder.cs | 2 +- src/UiPath.Caching/CacheEntryFactory.cs | 4 +- src/UiPath.Caching/CacheEventPublisher.cs | 2 +- src/UiPath.Caching/CacheMemoryMonitor.cs | 27 +- src/UiPath.Caching/Config/CachingBuilder.cs | 22 +- .../Config/RedisCollectionExtensions.cs | 64 +- .../Config/ServiceCollectionExtensions.cs | 24 +- .../Distributed/UiPathDistributedCache.cs | 256 ++++---- src/UiPath.Caching/HashCacheEntryBuilder.cs | 2 +- src/UiPath.Caching/ICacheEntryOptions.cs | 4 +- src/UiPath.Caching/ICacheOptions.cs | 14 +- src/UiPath.Caching/IMultilayerCacheOptions.cs | 24 +- src/UiPath.Caching/InMemoryCacheProvider.cs | 10 +- .../InMemoryRedisCacheProvider.cs | 10 +- .../Locking/RedisDistributedLock.cs | 20 +- .../Logging/AlwaysMaskKeyMaskingPolicy.cs | 13 + .../Logging/IKeyMaskingPolicy.cs | 28 - src/UiPath.Caching/Logging/KeyMasker.cs | 28 +- src/UiPath.Caching/Logging/LoggedKey.cs | 41 -- src/UiPath.Caching/Logging/LoggedKeys.cs | 42 ++ src/UiPath.Caching/Logging/MaskingContext.cs | 5 + .../Logging/NullKeyMaskingPolicy.cs | 13 + src/UiPath.Caching/MemoryCacheFactory.cs | 2 +- src/UiPath.Caching/MemoryCacheSetter.cs | 24 +- src/UiPath.Caching/MetricExtensions.cs | 24 +- src/UiPath.Caching/Metrics.cs | 6 +- src/UiPath.Caching/MultilayerCache.cs | 314 ++++----- src/UiPath.Caching/MultilayerCacheBase.cs | 154 ++--- src/UiPath.Caching/MultilayerHashCache.cs | 216 +++---- .../Redis/ClusterConfigurationReader.cs | 17 + src/UiPath.Caching/Redis/ClusterMembership.cs | 9 + .../Redis/ClusterTopologyReader.cs | 20 - .../Redis/ConnectionStateMonitor.cs | 2 +- .../Redis/IProfiledCommandProcessor.cs | 2 +- .../Redis/IProfilingSessionCommandReader.cs | 2 +- .../Redis/IReservedRedisKeyspace.cs | 14 + .../Redis/PrefixRedisKeyStrategy.cs | 4 +- .../Redis/ProfiledCommandExtensions.cs | 8 +- src/UiPath.Caching/Redis/RedisCache.cs | 390 ++++++------ src/UiPath.Caching/Redis/RedisCacheBase.cs | 110 ++-- .../Redis/RedisCacheProvider.cs | 8 +- .../RedisConfigurationOptionsProvider.cs | 2 +- src/UiPath.Caching/Redis/RedisConnector.cs | 422 ++++++------- src/UiPath.Caching/Redis/RedisHashCache.cs | 594 ++++++++++-------- .../Redis/RedisPlannedMaintenance.cs | 28 +- src/UiPath.Caching/Redis/RedisProfiler.cs | 44 +- .../Redis/ReservedRedisKeyspace.cs | 162 +---- .../Redis/ReservedRedisKeyspaceExtensions.cs | 115 ++++ .../Redis/ReservedRedisKeyspaceValidator.cs | 22 + src/UiPath.Caching/Redis/StreamId.cs | 6 +- .../RehydrateWriteFailedException.cs | 3 +- src/UiPath.Caching/RehydrationCoordinator.cs | 42 +- src/UiPath.Caching/TypeExtensions.cs | 2 +- stylecop.json | 8 + .../AutoFixtureCreator.cs | 6 +- .../AzureEntraConnectionConfiguratorTests.cs | 143 ++--- .../BatchGetOrAddTests.cs | 30 +- .../Broadcast/ChangeTokenTests.cs | 94 +-- .../Broadcast/ConnectionStateMonitorTests.cs | 28 +- .../Broadcast/KeyedSubjectTests.cs | 49 +- .../Broadcast/OptionsCloneTests.cs | 60 +- .../RedisPubSubSubjectWriterTests.cs | 22 +- .../Broadcast/RedisPubSubTopicTests.cs | 44 +- .../RedisStreamNotifyChannelTests.cs | 22 +- .../RedisStreamSubjectWriterTests.cs | 124 ++-- .../Broadcast/RedisStreamTopicMonitorTests.cs | 2 +- .../CacheCloudEventWrapperTests.cs | 4 +- .../CacheEventFactoryTests.cs | 2 +- tests/UiPath.Caching.Tests/CacheEventTests.cs | 2 +- .../CacheExpirationTests.cs | 62 +- .../UiPath.Caching.Tests/CacheFactoryTests.cs | 2 +- .../CacheMemoryMonitorTests.cs | 2 +- .../CacheOfTBatchGetOrAddTests.cs | 26 +- .../CancelationTokenCacheTests.cs | 4 +- .../CloudCacheEventFactoryTests.cs | 4 +- .../CollectionPropertyOmitter.cs | 2 + .../DistributedCacheRegistrationTests.cs | 204 +++--- .../DistributedCacheEndToEndTests.cs | 65 +- .../DistributedCacheRedisIntegrationTests.cs | 167 ++--- .../UiPathBufferDistributedCacheTests.cs | 190 +++--- .../UiPathDistributedCacheTests.cs | 395 +++++++----- .../Fakes/DictionaryCache.cs | 32 +- .../GenerationDepthBehavior.cs | 77 ++- .../InMemorySetCacheTests.cs | 50 +- .../LegacySerializerWireCompatTests.cs | 20 +- .../LocalCacheSetterTests.cs | 4 +- .../Locking/AsyncKeyedLocalLockTests.cs | 9 +- .../Locking/CacheOptionsLockValidatorTests.cs | 14 +- .../MultilayerCacheBatchGetOrAddLockTests.cs | 46 +- .../MultilayerCacheGetOrAddLockTests.cs | 26 +- ...ayerCacheLockCrossOptionsValidatorTests.cs | 4 +- .../MultilayerHashCacheGetOrAddLockTests.cs | 26 +- .../Locking/RedisDistributedLockTests.cs | 14 +- .../Logging/KeyMaskingTests.cs | 4 +- .../Logging/MaskedLogSiteTests.cs | 38 +- .../MemoryCacheFactoryTests.cs | 31 +- .../MemoryCacheSetterTests.cs | 10 +- .../MultilayerCacheBatchGetOrAddTests.cs | 24 +- .../MultilayerCacheBatchRehydrateTests.cs | 108 ++-- .../MultilayerCachePerNameLockTests.cs | 8 +- ...MultilayerCachePerNamePolicyWiringTests.cs | 22 +- .../MultilayerCacheRehydrateTests.cs | 60 +- .../MultilayerCacheTests.cs | 50 +- .../MultilayerCacheTryAddTests.cs | 94 +-- .../MultilayerHashCacheRehydrateTests.cs | 64 +- .../MultilayerHashCacheTests.cs | 89 +-- .../MultilayerSetCacheTests.cs | 91 +-- ...nTelemetryCachingTelemetryProviderTests.cs | 81 ++- .../PackageVersionFloorTests.cs | 26 +- .../PrefixRedisStrategyTests.cs | 4 +- .../ProfiledCommandProcessorTests.cs | 2 +- ...agateCacheNullValuesFromMultilayerTests.cs | 10 +- .../RawByteSerializerProxyTests.cs | 4 +- .../Redis/RedisCacheTests.cs | 62 +- .../Redis/RedisCacheTryAddTests.cs | 4 +- ...onfigurationOptionsProviderFactoryTests.cs | 8 +- .../Redis/RedisConnectionConfiguratorTests.cs | 18 +- .../Redis/RedisConnectionWarmupTests.cs | 64 +- .../Redis/RedisConnectorIntegrationTests.cs | 32 +- .../Redis/RedisConnectorLifecycleTests.cs | 152 ++--- .../Redis/RedisConnectorStaleEndpointTests.cs | 292 ++++----- .../Redis/RedisConnectorTests.cs | 20 +- .../Redis/RedisHashCacheTests.cs | 76 +-- ...RedisPlannedMaintenanceIntegrationTests.cs | 116 ++-- .../Redis/RedisSetCacheTests.cs | 30 +- .../RedisProfilerTests.cs | 7 +- .../RehydrationCoordinatorTests.cs | 64 +- .../ResiliencePipelineFactoryTests.cs | 25 +- .../ResiliencePipelineProviderTests.cs | 20 +- .../ResiliencePipelineWrapperTests.cs | 18 +- .../SetCacheProviderTests.cs | 28 +- .../SystemJsonByteSerializerProxyTests.cs | 4 +- tests/UiPath.Caching.Tests/TestCacheEntry.cs | 4 +- .../UiPath.Caching.Tests/TopicFactoryTests.cs | 2 +- 189 files changed, 4452 insertions(+), 3962 deletions(-) create mode 100644 src/UiPath.Caching/Logging/AlwaysMaskKeyMaskingPolicy.cs create mode 100644 src/UiPath.Caching/Logging/LoggedKeys.cs create mode 100644 src/UiPath.Caching/Logging/MaskingContext.cs create mode 100644 src/UiPath.Caching/Logging/NullKeyMaskingPolicy.cs create mode 100644 src/UiPath.Caching/Redis/ClusterConfigurationReader.cs create mode 100644 src/UiPath.Caching/Redis/ClusterMembership.cs create mode 100644 src/UiPath.Caching/Redis/IReservedRedisKeyspace.cs create mode 100644 src/UiPath.Caching/Redis/ReservedRedisKeyspaceExtensions.cs create mode 100644 src/UiPath.Caching/Redis/ReservedRedisKeyspaceValidator.cs create mode 100644 stylecop.json 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();