diff --git a/Directory.Build.targets b/Directory.Build.targets index 956a4cf8078..5ee66133050 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -22,7 +22,7 @@ $(NoWarn);AD0001 - $(NoWarn);EXTEXP0001;EXTEXP0002;EXTEXP0003;EXTEXP0004;EXTEXP0005;EXTEXP0006;EXTEXP0007;EXTEXP0008;EXTEXP0009;EXTEXP0010;EXTEXP0011;EXTEXP0012;EXTEXP0013;EXTEXP0014;EXTEXP0015;EXTEXP0016;EXTEXP0017 + $(NoWarn);EXTEXP0001;EXTEXP0002;EXTEXP0003;EXTEXP0004;EXTEXP0005;EXTEXP0006;EXTEXP0007;EXTEXP0008;EXTEXP0009;EXTEXP0010;EXTEXP0011;EXTEXP0012;EXTEXP0013;EXTEXP0014;EXTEXP0015;EXTEXP0016;EXTEXP0017;EXTEXP0018 $(NoWarn);EXTOBS0001; diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index ba8e170a878..4ba19ed1099 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -40,6 +40,7 @@ if desired. | `EXTEXP0015` | Environmental probes experiments | | `EXTEXP0016` | Hosting integration testing experiments | | `EXTEXP0017` | Contextual options experiments | +| `EXTEXP0018` | HybridCache experiments | # Obsoletions @@ -81,7 +82,7 @@ You may continue using obsolete APIs in your application, but we advise explorin | `LOGGEN023` | Tag provider method is inaccessible | | `LOGGEN024` | Property provider method has an invalid signature | | `LOGGEN025` | Logging method parameters can't have "ref" or "out" modifiers | -| `LOGGEN026` | Parameters with a custom tag provider are not subject to redaciton | +| `LOGGEN026` | Parameters with a custom tag provider are not subject to redaction | | `LOGGEN027` | Multiple logging methods shouldn't use the same event name | | `LOGGEN028` | Logging method parameter's type has a hidden property | | `LOGGEN029` | A logging method parameter causes name conflicts | diff --git a/eng/MSBuild/LegacySupport.props b/eng/MSBuild/LegacySupport.props index c96a83d34d6..8ebacbd60f7 100644 --- a/eng/MSBuild/LegacySupport.props +++ b/eng/MSBuild/LegacySupport.props @@ -7,11 +7,11 @@ - + - + @@ -47,7 +47,7 @@ - + diff --git a/eng/MSBuild/Packaging.targets b/eng/MSBuild/Packaging.targets index e233142f1fa..14a5a04d492 100644 --- a/eng/MSBuild/Packaging.targets +++ b/eng/MSBuild/Packaging.targets @@ -11,15 +11,17 @@ false $(BeforePack);_VerifyMinimumSupportedTfmForPackagingIsUsed;_AddNETStandardCompatErrorFileForPackaging + true + Condition="'$(IsPackNet462)' == 'true'" /> diff --git a/eng/Version.Details.xml b/eng/Version.Details.xml index d4c73506e52..68047d29a49 100644 --- a/eng/Version.Details.xml +++ b/eng/Version.Details.xml @@ -1,174 +1,182 @@ - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed + + + https://github.com/dotnet/runtime + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed - + https://github.com/dotnet/runtime - d0f3235d312f7cf9683012b3fe96b2c6f20a1743 + db95ac47f72d605e7676ad155db2bab00be889ed + + + https://github.com/dotnet/aspnetcore + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/aspnetcore - b4a37b11c6a29a19f2015de0d0818c3f8f2d35e6 + c68cfd0f718c3991e3c38b6f913f17f4b8d0b169 - + https://github.com/dotnet/arcade - d21db44e84b9038ea7b2add139adee2303d46800 + 04b9022eba9c184a8036328af513c22e6949e8b6 - + https://github.com/dotnet/arcade - d21db44e84b9038ea7b2add139adee2303d46800 + 04b9022eba9c184a8036328af513c22e6949e8b6 diff --git a/eng/Versions.props b/eng/Versions.props index 8a8676279af..0bbe98bc699 100644 --- a/eng/Versions.props +++ b/eng/Versions.props @@ -4,7 +4,7 @@ 0 0 preview - 8 + 9 $(MajorVersion).$(MinorVersion).$(PatchVersion) true $(MajorVersion).$(MinorVersion).0.0 @@ -28,46 +28,48 @@ --> - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 - 9.0.0-rc.2.24429.19 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 + 9.0.0-rtm.24468.5 - 9.0.0-rc.2.24453.17 - 9.0.0-rc.2.24453.17 - 9.0.0-rc.2.24453.17 - 9.0.0-rc.2.24453.17 - 9.0.0-rc.2.24453.17 - 9.0.0-rc.2.24453.17 - 9.0.0-rc.2.24453.17 - 9.0.0-rc.2.24453.17 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + 9.0.0-rtm.24470.3 + + + + + + + + + + + + diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json new file mode 100644 index 00000000000..2c1a811b223 Binary files /dev/null and b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.json differ diff --git a/src/Libraries/Microsoft.Extensions.Caching.Hybrid/README.md b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/README.md new file mode 100644 index 00000000000..02dc3e5bae5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Caching.Hybrid/README.md @@ -0,0 +1,81 @@ +# Microsoft.Extensions.Caching.Hybrid + +This package contains a concrete implementation of [the `HybridCache` API](https://learn.microsoft.com/dotnet/api/microsoft.extensions.caching.hybrid), +simplifying and enhancing cache usage that might previously have been built on top of [`IDistributedCache`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.caching.distributed.idistributedcache). + +Key features: + +- built on top of `IDistributedCache` - all existing cache backends (Redis, SQL Server, CosmosDB, etc) should work immediately +- simple API (all the cache, serialization, etc details from are encapsulated) +- cache-stampede protection (combining of concurrent requests for the same data) +- performance enhancements such as inbuilt support for the newer [`IBufferDistributedCache`](https://learn.microsoft.com/dotnet/api/microsoft.extensions.caching.distributed.ibufferdistributedcache) API +- fully configurable serialization + +Full `HybridCache` documentation is [here](https://learn.microsoft.com/aspnet/core/performance/caching/hybrid). + +## Full documentation + +See [learn.microsoft.com](https://learn.microsoft.com/aspnet/core/performance/caching/hybrid) for full discussion of `HybridCache`. + +## Install the package + +From the command-line: + +```console +dotnet add package Microsoft.Extensions.Caching.Hybrid +``` + +Or directly in the C# project file: + +```xml + + + +``` + +## Usage example + +The `HybridCache` service can be registered and configured via `IServiceCollection`, for example: + +```csharp +builder.Services.AddHybridCache(/* optional configuration /*); +``` + +Note that in many cases you may also wish to register a distributed cache backend, as +[discussed here](https://learn.microsoft.com/aspnet/core/performance/caching/distributed); for example +a Redis instance: + +```csharp +builder.Services.AddStackExchangeRedisCache(options => +{ + options.Configuration = builder.Configuration.GetConnectionString("MyRedisConStr"); +}); +``` + +Once registered, the `HybridCache` instance can be obtained via dependency-injection, allowing the +`GetOrCreateAsync` API to be used to obtain data: + +```csharp +public class SomeService(HybridCache cache) +{ + private HybridCache _cache = cache; + + public async Task GetSomeInfoAsync(string name, int id, CancellationToken token = default) + { + return await _cache.GetOrCreateAsync( + $"{name}-{id}", // Unique key to the cache entry + async cancel => await GetDataFromTheSourceAsync(name, id, cancel), + cancellationToken: token + ); + } + + private async Task GetDataFromTheSourceAsync(string name, int id, CancellationToken token) + { + // talk to the underlying data store here - could be SQL, gRPC, HTTP, etc + } +} +``` + +Additional usage guidance - including expiration, custom serialization support, and alternate usage +to reduce delegate allocation - is available +on [learn.microsoft.com](https://learn.microsoft.com/aspnet/core/performance/caching/hybrid). diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/RedactorProvider.cs b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/RedactorProvider.cs index 0963784fa80..18a356d1a10 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Redaction/RedactorProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Redaction/RedactorProvider.cs @@ -37,6 +37,12 @@ public Redactor GetRedactor(DataClassificationSet classifications) private static FrozenDictionary GetClassRedactorMap(IEnumerable redactors, Dictionary map) { + if (!map.ContainsKey(DataClassification.None)) + { + map.Add(DataClassification.None, typeof(NullRedactor)); + redactors = [.. redactors, NullRedactor.Instance]; + } + var dict = new Dictionary(map.Count); foreach (var m in map) { @@ -45,6 +51,7 @@ private static FrozenDictionary GetClassRedacto if (r.GetType() == m.Value) { dict[m.Key] = r; + break; } } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheck.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheck.cs index b60a6068b50..13ac4cac9bc 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheck.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheck.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Diagnostics.ResourceMonitoring; @@ -14,7 +15,6 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks; /// internal sealed class ResourceUtilizationHealthCheck : IHealthCheck { - private static readonly Task _healthy = Task.FromResult(HealthCheckResult.Healthy()); private readonly ResourceUtilizationHealthCheckOptions _options; private readonly IResourceMonitor _dataTracker; @@ -39,26 +39,56 @@ public ResourceUtilizationHealthCheck(IOptions CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { var utilization = _dataTracker.GetUtilization(_options.SamplingWindow); - if (utilization.CpuUsedPercentage > _options.CpuThresholds.UnhealthyUtilizationPercentage) + IReadOnlyDictionary data = new Dictionary { - return Task.FromResult(HealthCheckResult.Unhealthy("CPU usage is above the limit")); - } + { nameof(utilization.CpuUsedPercentage), utilization.CpuUsedPercentage }, + { nameof(utilization.MemoryUsedPercentage), utilization.MemoryUsedPercentage }, + }; - if (utilization.MemoryUsedPercentage > _options.MemoryThresholds.UnhealthyUtilizationPercentage) - { - return Task.FromResult(HealthCheckResult.Unhealthy("Memory usage is above the limit")); - } + bool cpuUnhealthy = utilization.CpuUsedPercentage > _options.CpuThresholds.UnhealthyUtilizationPercentage; + bool memoryUnhealthy = utilization.MemoryUsedPercentage > _options.MemoryThresholds.UnhealthyUtilizationPercentage; - if (utilization.CpuUsedPercentage > _options.CpuThresholds.DegradedUtilizationPercentage) + if (cpuUnhealthy || memoryUnhealthy) { - return Task.FromResult(HealthCheckResult.Degraded("CPU usage is close to the limit")); + string message = string.Empty; + if (cpuUnhealthy && memoryUnhealthy) + { + message = "CPU and memory usage is above the limit"; + } + else if (cpuUnhealthy) + { + message = "CPU usage is above the limit"; + } + else + { + message = "Memory usage is above the limit"; + } + + return Task.FromResult(HealthCheckResult.Unhealthy(message, default, data)); } - if (utilization.MemoryUsedPercentage > _options.MemoryThresholds.DegradedUtilizationPercentage) + bool cpuDegraded = utilization.CpuUsedPercentage > _options.CpuThresholds.DegradedUtilizationPercentage; + bool memoryDegraded = utilization.MemoryUsedPercentage > _options.MemoryThresholds.DegradedUtilizationPercentage; + + if (cpuDegraded || memoryDegraded) { - return Task.FromResult(HealthCheckResult.Degraded("Memory usage is close to the limit")); + string message = string.Empty; + if (cpuDegraded && memoryDegraded) + { + message = "CPU and memory usage is close to the limit"; + } + else if (cpuDegraded) + { + message = "CPU usage is close to the limit"; + } + else + { + message = "Memory usage is close to the limit"; + } + + return Task.FromResult(HealthCheckResult.Degraded(message, default, data)); } - return _healthy; + return Task.FromResult(HealthCheckResult.Healthy(default, data)); } } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheckExtensions.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheckExtensions.cs index ee5d466e0a3..8ae91b32a0e 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheckExtensions.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization/ResourceUtilizationHealthCheckExtensions.cs @@ -28,6 +28,8 @@ public static IHealthChecksBuilder AddResourceUtilizationHealthCheck(this IHealt _ = Throw.IfNull(builder); _ = Throw.IfNull(tags); + _ = builder.Services.AddResourceMonitoring(); + _ = builder.Services.AddOptionsWithValidateOnStart(); return builder.AddCheck(HealthCheckName, tags: tags); } @@ -44,6 +46,8 @@ public static IHealthChecksBuilder AddResourceUtilizationHealthCheck(this IHealt _ = Throw.IfNull(builder); _ = Throw.IfNull(tags); + _ = builder.Services.AddResourceMonitoring(); + _ = builder.Services.AddOptionsWithValidateOnStart(); return builder.AddCheck(HealthCheckName, tags: tags); } diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs index 3e2172e9135..d7914104adb 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Linux/LinuxUtilizationProvider.cs @@ -62,7 +62,7 @@ public LinuxUtilizationProvider(IOptions options, ILi // We don't dispose the meter because IMeterFactory handles that // An issue on analyzer side: https://github.com/dotnet/roslyn-analyzers/issues/6912 // Related documentation: https://github.com/dotnet/docs/pull/37170 - var meter = meterFactory.Create(nameof(Microsoft.Extensions.Diagnostics.ResourceMonitoring)); + var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ContainerCpuLimitUtilization, observeValue: () => CpuUtilization() * _scaleRelativeToCpuLimit, unit: "1"); diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceUtilizationInstruments.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceUtilizationInstruments.cs index f787ec2ecf4..fe8e508afb2 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceUtilizationInstruments.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/ResourceUtilizationInstruments.cs @@ -11,6 +11,11 @@ namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring; /// internal static class ResourceUtilizationInstruments { + /// + /// The name of the ResourceMonitoring Meter. + /// + public const string MeterName = "Microsoft.Extensions.Diagnostics.ResourceMonitoring"; + /// /// The name of an instrument to retrieve CPU limit consumption of all processes running inside a container or control group in range [0, 1]. /// diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs index 56dbb4ae33a..3f2c4b8638a 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsContainerSnapshotProvider.cs @@ -105,7 +105,7 @@ internal WindowsContainerSnapshotProvider( // We don't dispose the meter because IMeterFactory handles that // An issue on analyzer side: https://github.com/dotnet/roslyn-analyzers/issues/6912 // Related documentation: https://github.com/dotnet/docs/pull/37170 - var meter = meterFactory.Create(nameof(Microsoft.Extensions.Diagnostics.ResourceMonitoring)); + var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope // Container based metrics: diff --git a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs index 7104b7b9e5f..7197499afd9 100644 --- a/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs +++ b/src/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring/Windows/WindowsSnapshotProvider.cs @@ -81,7 +81,7 @@ internal WindowsSnapshotProvider( // We don't dispose the meter because IMeterFactory handles that // An issue on analyzer side: https://github.com/dotnet/roslyn-analyzers/issues/6912 // Related documentation: https://github.com/dotnet/docs/pull/37170 - var meter = meterFactory.Create(nameof(Microsoft.Extensions.Diagnostics.ResourceMonitoring)); + var meter = meterFactory.Create(ResourceUtilizationInstruments.MeterName); #pragma warning restore CA2000 // Dispose objects before losing scope _ = meter.CreateObservableGauge(name: ResourceUtilizationInstruments.ProcessCpuUtilization, observeValue: CpuPercentage); diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs index e6039bd65c4..3f7b82ba0de 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Hedging/ResilienceHttpClientBuilderExtensions.Hedging.cs @@ -3,6 +3,7 @@ using System; using System.Net.Http; +using System.Threading; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Http.Resilience; @@ -139,6 +140,9 @@ public static IStandardHedgingHandlerBuilder AddStandardHedgingHandler(this IHtt }) .SelectPipelineByAuthority(); + // Disable the HttpClient timeout to allow the timeout strategies to control the timeout. + _ = builder.ConfigureHttpClient(client => client.Timeout = Timeout.InfiniteTimeSpan); + return new StandardHedgingHandlerBuilder(builder.Name, builder.Services, routingBuilder); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj index 7bf27efddb3..f0499dada26 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Microsoft.Extensions.Http.Resilience.csproj @@ -38,4 +38,39 @@ + + + + + + + + + + + + + <_AdditionalNETStandardCompatErrorFileContents> + +]]> + + diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/README.md b/src/Libraries/Microsoft.Extensions.Http.Resilience/README.md index a157dcfa4bc..d4ad24b5e00 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/README.md +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/README.md @@ -73,6 +73,36 @@ clientBuilder.AddResilienceHandler("myHandler", b => }); ``` +## Known issues + +The following sections detail various known issues. + +### Compatibility with the `Grpc.Net.ClientFactory` package + +If you're using `Grpc.Net.ClientFactory` version `2.63.0` or earlier, then enabling the standard resilience or hedging handlers for a gRPC client could cause a runtime exception. Specifically, consider the following code sample: + +```csharp +services + .AddGrpcClient() + .AddStandardResilienceHandler(); +``` + +The preceding code results in the following exception: + +```Output +System.InvalidOperationException: The ConfigureHttpClient method is not supported when creating gRPC clients. Unable to create client with name 'GreeterClient'. +``` + +To resolve this issue, we recommend upgrading to `Grpc.Net.ClientFactory` version `2.64.0` or later. + +There's a build time check that verifies if you're using `Grpc.Net.ClientFactory` version `2.63.0` or earlier, and if you are the check produces a compilation warning. You can suppress the warning by setting the following property in your project file: + +```xml + + true + +``` + ## Feedback & Contributing We welcome feedback and contributions in [our GitHub repo](https://github.com/dotnet/extensions). diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.StandardResilience.cs b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.StandardResilience.cs index f27c2e76eac..a4315eaa006 100644 --- a/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.StandardResilience.cs +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/Resilience/ResilienceHttpClientBuilderExtensions.StandardResilience.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Threading; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Http.Resilience; @@ -86,6 +87,9 @@ public static IHttpStandardResiliencePipelineBuilder AddStandardResilienceHandle .AddTimeout(options.AttemptTimeout); }); + // Disable the HttpClient timeout to allow the timeout strategies to control the timeout. + _ = builder.ConfigureHttpClient(client => client.Timeout = Timeout.InfiniteTimeSpan); + return new HttpStandardResiliencePipelineBuilder(optionsName, builder.Services); } diff --git a/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets b/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets new file mode 100644 index 00000000000..268f0720433 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Http.Resilience/buildTransitive/Microsoft.Extensions.Http.Resilience.targets @@ -0,0 +1,54 @@ + + + <_GrpcNetClientFactory>Grpc.Net.ClientFactory + <_CompatibleGrpcNetClientFactoryVersion>2.64.0 + <_GrpcNetClientFactoryVersionIsIncorrect>Grpc.Net.ClientFactory 2.63.0 or earlier could cause issues when used together with Microsoft.Extensions.Http.Resilience. For more details, see https://learn.microsoft.com/dotnet/core/resilience/http-resilience#known-issues. Consider using Grpc.Net.ClientFactory $(_CompatibleGrpcNetClientFactoryVersion) or later. To suppress the warning set SuppressCheckGrpcNetClientFactoryVersion=true. + + + + + + + <_GrpcNetClientFactoryPackageReference Include="@(PackageReference)" Condition=" '%(PackageReference.Identity)' == '$(_GrpcNetClientFactory)' " /> + + + <_GrpcNetClientFactoryPackageVersion Include="@(PackageVersion)" Condition=" '%(PackageVersion.Identity)' == '$(_GrpcNetClientFactory)' " /> + + + <_GrpcNetClientFactoryTransitiveDependency Include="@(ReferencePath)" Condition=" '%(ReferencePath.NuGetPackageId)' == '$(_GrpcNetClientFactory)' " /> + + + + + + + + + + + + + + + diff --git a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs index 506d67f7d59..a8c5d752fc9 100644 --- a/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs +++ b/test/Generators/Microsoft.Gen.Logging/Generated/LogPropertiesTests.cs @@ -313,6 +313,45 @@ public void LogPropertiesInTemplateTest() _logger.Collector.LatestRecord.StructuredState.Should().NotBeNull().And.Equal(expectedState); } + [Fact] + public void LogPropertiesTestNullablePropertyInClass() + { + var now = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var classToLog = new MyClassWithNullableProperty { NullableDateTime = now, NonNullableDateTime = now }; + LogMethodNullablePropertyInClassMatchesNonNullable(_logger, classToLog); + Assert.Equal(1, _logger.Collector.Count); + Assert.Equal(LogLevel.Information, _logger.Collector.LatestRecord.Level); + Assert.Equal($"Testing nullable property within class here...", _logger.Collector.LatestRecord.Message); + var expectedState = new Dictionary + { + // Note that, regardless of nullability, the datetime fields SHOULD match given the same, non-null input. + ["classWithNullablePropertyParam.NullableDateTime"] = "01/01/2024 00:00:00", + ["classWithNullablePropertyParam.NonNullableDateTime"] = "01/01/2024 00:00:00", + ["{OriginalFormat}"] = "Testing nullable property within class here..." + }; + + _logger.Collector.LatestRecord.StructuredState.Should().NotBeNull().And.Equal(expectedState); + } + + [Fact] + public void LogPropertiesTestNullablePropertyInClass_WhenNull() + { + var now = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var classToLog = new MyClassWithNullableProperty { NullableDateTime = null, NonNullableDateTime = now }; + LogMethodNullablePropertyInClassMatchesNonNullable(_logger, classToLog); + Assert.Equal(1, _logger.Collector.Count); + Assert.Equal(LogLevel.Information, _logger.Collector.LatestRecord.Level); + Assert.Equal($"Testing nullable property within class here...", _logger.Collector.LatestRecord.Message); + var expectedState = new Dictionary + { + ["classWithNullablePropertyParam.NullableDateTime"] = null, + ["classWithNullablePropertyParam.NonNullableDateTime"] = "01/01/2024 00:00:00", + ["{OriginalFormat}"] = "Testing nullable property within class here..." + }; + + _logger.Collector.LatestRecord.StructuredState.Should().NotBeNull().And.Equal(expectedState); + } + [Fact] public void LogPropertiesNonStaticClassTest() { @@ -458,7 +497,7 @@ public void LogPropertiesInterfaceArgument() Assert.Equal(1, _logger.Collector.Count); var latestRecord = _logger.Collector.LatestRecord; Assert.Null(latestRecord.Exception); - Assert.Equal(5, latestRecord.Id.Id); + Assert.Equal(6, latestRecord.Id.Id); Assert.Equal(LogLevel.Information, latestRecord.Level); Assert.Equal("Testing interface-typed argument here...", latestRecord.Message); diff --git a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs index eb8aa661cdd..d2b9c05b05b 100644 --- a/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs +++ b/test/Generators/Microsoft.Gen.Logging/TestClasses/LogPropertiesExtensions.cs @@ -111,6 +111,12 @@ public MyCustomStruct(object _) } } + public class MyClassWithNullableProperty + { + public DateTime? NullableDateTime { get; set; } + public DateTime NonNullableDateTime { get; set; } + } + public struct MyTransitiveStruct { public DateTimeOffset DateTimeOffsetProperty { get; set; } = DateTimeOffset.UtcNow; @@ -230,10 +236,13 @@ public override string ToString() [LoggerMessage(4, LogLevel.Information, "Testing explicit nullable struct here...")] public static partial void LogMethodExplicitNullableStruct(ILogger logger, [LogProperties] Nullable structParam); + [LoggerMessage(5, LogLevel.Information, "Testing nullable property within class here...")] + public static partial void LogMethodNullablePropertyInClassMatchesNonNullable(ILogger logger, [LogProperties] MyClassWithNullableProperty classWithNullablePropertyParam); + [LoggerMessage] public static partial void LogMethodDefaultAttrCtor(ILogger logger, LogLevel level, [LogProperties] ClassAsParam? complexParam); - [LoggerMessage(5, LogLevel.Information, "Testing interface-typed argument here...")] + [LoggerMessage(6, LogLevel.Information, "Testing interface-typed argument here...")] public static partial void LogMethodInterfaceArg(ILogger logger, [LogProperties] IMyInterface complexParam); } } diff --git a/test/Generators/Microsoft.Gen.Logging/Unit/ParserTests.LogProperties.cs b/test/Generators/Microsoft.Gen.Logging/Unit/ParserTests.LogProperties.cs index a5e663dc57e..b638be739b1 100644 --- a/test/Generators/Microsoft.Gen.Logging/Unit/ParserTests.LogProperties.cs +++ b/test/Generators/Microsoft.Gen.Logging/Unit/ParserTests.LogProperties.cs @@ -1,8 +1,14 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.IO; +using System.Reflection; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; using Microsoft.Gen.Logging.Parsing; +using Microsoft.Gen.Shared; +using VerifyXunit; using Xunit; namespace Microsoft.Gen.Logging.Test; @@ -433,4 +439,54 @@ static partial void M0(this ILogger logger, MyRecordStruct p6); }", DiagDescriptors.DefaultToString); } + + [Fact] + public async Task ClassWithNullableProperty() + { + string source = @" + namespace Test + { + using System; + + using Microsoft.Extensions.Logging; + + internal static class LoggerUtils + { + public class MyClassWithNullableProperty + { + public DateTime? NullableDateTime { get; set; } + public DateTime NonNullableDateTime { get; set; } + } + + partial class MyLogger + { + [LoggerMessage(0, LogLevel.Information, ""Testing nullable property within class here..."")] + public static partial void LogMethodNullablePropertyInClassMatchesNonNullable(ILogger logger, [LogProperties] MyClassWithNullableProperty classWithNullablePropertyParam); + } + } + }"; + +#if NET6_0_OR_GREATER + var symbols = new[] { "NET7_0_OR_GREATER", "NET6_0_OR_GREATER", "NET5_0_OR_GREATER" }; +#else + var symbols = new[] { "NET5_0_OR_GREATER" }; +#endif + + var (d, r) = await RoslynTestUtils.RunGenerator( + new LoggingGenerator(), + new[] + { + Assembly.GetAssembly(typeof(ILogger))!, + Assembly.GetAssembly(typeof(LogPropertiesAttribute))!, + Assembly.GetAssembly(typeof(LoggerMessageAttribute))!, + Assembly.GetAssembly(typeof(DateTime))!, + }, + [source], + symbols); + + Assert.Empty(d); + await Verifier.Verify(r[0].SourceText.ToString()) + .AddScrubber(_ => _.Replace(GeneratorUtilities.CurrentVersion, "VERSION")) + .UseDirectory(Path.Combine("..", "Verified")); + } } diff --git a/test/Generators/Microsoft.Gen.Logging/Verified/ParserTests.ClassWithNullableProperty.verified.txt b/test/Generators/Microsoft.Gen.Logging/Verified/ParserTests.ClassWithNullableProperty.verified.txt new file mode 100644 index 00000000000..174f9e63faa --- /dev/null +++ b/test/Generators/Microsoft.Gen.Logging/Verified/ParserTests.ClassWithNullableProperty.verified.txt @@ -0,0 +1,44 @@ + +// +#nullable enable +#pragma warning disable CS1591 // Compensate for https://github.com/dotnet/roslyn/issues/54103 + +namespace Test +{ + partial class LoggerUtils + { + partial class MyLogger + { + /// + /// Logs "Testing nullable property within class here..." at "Information" level. + /// + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.Logging", "VERSION")] + public static partial void LogMethodNullablePropertyInClassMatchesNonNullable(global::Microsoft.Extensions.Logging.ILogger logger, global::Test.LoggerUtils.MyClassWithNullableProperty classWithNullablePropertyParam) + { + if (!logger.IsEnabled(global::Microsoft.Extensions.Logging.LogLevel.Information)) + { + return; + } + + var state = global::Microsoft.Extensions.Logging.LoggerMessageHelper.ThreadLocalState; + + _ = state.ReserveTagSpace(3); + state.TagArray[2] = new("classWithNullablePropertyParam.NullableDateTime", classWithNullablePropertyParam?.NullableDateTime); + state.TagArray[1] = new("classWithNullablePropertyParam.NonNullableDateTime", classWithNullablePropertyParam?.NonNullableDateTime); + state.TagArray[0] = new("{OriginalFormat}", "Testing nullable property within class here..."); + + logger.Log( + global::Microsoft.Extensions.Logging.LogLevel.Information, + new(0, nameof(LogMethodNullablePropertyInClassMatchesNonNullable)), + state, + null, + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Gen.Logging", "VERSION")] static string (s, _) => + { + return "Testing nullable property within class here..."; + }); + + state.Clear(); + } + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json new file mode 100644 index 00000000000..374114fb1db --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BasicConfig.json @@ -0,0 +1,12 @@ +{ + "no_entry_options": { + "MaximumKeyLength": 937 + }, + "with_entry_options": { + "MaximumKeyLength": 937, + "DefaultEntryOptions": { + "LocalCacheExpiration": "00:02:00", + "Flags": "DisableCompression,DisableLocalCacheRead" + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs new file mode 100644 index 00000000000..4996406c09a --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/BufferReleaseTests.cs @@ -0,0 +1,235 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using static Microsoft.Extensions.Caching.Hybrid.Internal.DefaultHybridCache; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class BufferReleaseTests // note that buffer ref-counting is only enabled for DEBUG builds; can only verify general behaviour without that +{ + private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) + { + var services = new ServiceCollection(); + config?.Invoke(services); + services.AddHybridCache(); + ServiceProvider provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + [Fact] + public async Task BufferGetsReleased_NoL2() + { + using var provider = GetDefaultCache(out var cache); +#if DEBUG + cache.DebugOnlyGetOutstandingBuffers(flush: true); +#endif + + var key = Me(); +#if DEBUG + Assert.Equal(0, cache.DebugOnlyGetOutstandingBuffers()); +#endif + var first = await cache.GetOrCreateAsync(key, _ => GetAsync()); + Assert.NotNull(first); +#if DEBUG + Assert.Equal(1, cache.DebugOnlyGetOutstandingBuffers()); +#endif + Assert.True(cache.DebugTryGetCacheItem(key, out var cacheItem)); + + // assert that we can reserve the buffer *now* (mostly to see that it behaves differently later) + Assert.True(cacheItem.NeedsEvictionCallback, "should be pooled memory"); + Assert.True(cacheItem.TryReserveBuffer(out _)); + cacheItem.Release(); // for the above reserve + + var second = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); + Assert.NotNull(second); + Assert.NotSame(first, second); + + Assert.Equal(1, cacheItem.RefCount); + await cache.RemoveAsync(key); + var third = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); + Assert.Null(third); + + // give it a moment for the eviction callback to kick in + for (var i = 0; i < 10 && cacheItem.NeedsEvictionCallback; i++) + { + await Task.Delay(250); + } +#if DEBUG + Assert.Equal(0, cache.DebugOnlyGetOutstandingBuffers()); +#endif + + // assert that we can *no longer* reserve this buffer, because we've already recycled it + Assert.False(cacheItem.TryReserveBuffer(out _)); + Assert.Equal(0, cacheItem.RefCount); + Assert.False(cacheItem.NeedsEvictionCallback, "should be recycled now"); + static ValueTask GetAsync() => new(new Customer { Id = 42, Name = "Fred" }); + } + + private static readonly HybridCacheEntryOptions _noUnderlying = new() { Flags = HybridCacheEntryFlags.DisableUnderlyingData }; + + private class TestCache : MemoryDistributedCache, IBufferDistributedCache + { + public TestCache(IOptions options) + : base(options) + { + } + + void IBufferDistributedCache.Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) + => Set(key, value.ToArray(), options); // efficiency not important for this + + ValueTask IBufferDistributedCache.SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token) + => new(SetAsync(key, value.ToArray(), options, token)); // efficiency not important for this + + bool IBufferDistributedCache.TryGet(string key, IBufferWriter destination) + => Write(destination, Get(key)); + + async ValueTask IBufferDistributedCache.TryGetAsync(string key, IBufferWriter destination, CancellationToken token) + => Write(destination, await GetAsync(key, token)); + + private static bool Write(IBufferWriter destination, byte[]? buffer) + { + if (buffer is null) + { + return false; + } + + destination.Write(buffer); + return true; + } + } + + [Fact] + public async Task BufferDoesNotNeedRelease_LegacyL2() // byte[] API; not pooled + { + using var provider = GetDefaultCache(out var cache, + services => services.AddSingleton()); + + cache.DebugRemoveFeatures(CacheFeatures.BackendBuffers); + + // prep the backend with our data + var key = Me(); + Assert.NotNull(cache.BackendCache); + IHybridCacheSerializer serializer = cache.GetSerializer(); + using (RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue)) + { + serializer.Serialize(await GetAsync(), writer); + cache.BackendCache.Set(key, writer.ToArray()); + } +#if DEBUG + cache.DebugOnlyGetOutstandingBuffers(flush: true); + Assert.Equal(0, cache.DebugOnlyGetOutstandingBuffers()); +#endif + var first = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); // we expect this to come from L2, hence NoUnderlying + Assert.NotNull(first); +#if DEBUG + Assert.Equal(0, cache.DebugOnlyGetOutstandingBuffers()); +#endif + Assert.True(cache.DebugTryGetCacheItem(key, out var cacheItem)); + + // assert that we can reserve the buffer *now* (mostly to see that it behaves differently later) + Assert.False(cacheItem.NeedsEvictionCallback, "should NOT be pooled memory"); + Assert.True(cacheItem.TryReserveBuffer(out _)); + cacheItem.Release(); // for the above reserve + + var second = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); + Assert.NotNull(second); + Assert.NotSame(first, second); + + Assert.Equal(1, cacheItem.RefCount); + await cache.RemoveAsync(key); + var third = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); + Assert.Null(third); + Assert.Null(await cache.BackendCache.GetAsync(key)); // should be gone from L2 too + + // give it a moment for the eviction callback to kick in + for (var i = 0; i < 10 && cacheItem.NeedsEvictionCallback; i++) + { + await Task.Delay(250); + } +#if DEBUG + Assert.Equal(0, cache.DebugOnlyGetOutstandingBuffers()); +#endif + + // assert that we can *no longer* reserve this buffer, because we've already recycled it + Assert.True(cacheItem.TryReserveBuffer(out _)); // always readable + cacheItem.Release(); + Assert.Equal(1, cacheItem.RefCount); // not decremented because there was no need to add the hook + + Assert.False(cacheItem.NeedsEvictionCallback, "should still not need recycling"); + static ValueTask GetAsync() => new(new Customer { Id = 42, Name = "Fred" }); + } + + [Fact] + public async Task BufferGetsReleased_BufferL2() // IBufferWriter API; pooled + { + using var provider = GetDefaultCache(out var cache, + services => services.AddSingleton()); + + // prep the backend with our data + var key = Me(); + Assert.NotNull(cache.BackendCache); + IHybridCacheSerializer serializer = cache.GetSerializer(); + using (RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue)) + { + serializer.Serialize(await GetAsync(), writer); + cache.BackendCache.Set(key, writer.ToArray()); + } +#if DEBUG + cache.DebugOnlyGetOutstandingBuffers(flush: true); + Assert.Equal(0, cache.DebugOnlyGetOutstandingBuffers()); +#endif + var first = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); // we expect this to come from L2, hence NoUnderlying + Assert.NotNull(first); +#if DEBUG + Assert.Equal(1, cache.DebugOnlyGetOutstandingBuffers()); +#endif + Assert.True(cache.DebugTryGetCacheItem(key, out var cacheItem)); + + // assert that we can reserve the buffer *now* (mostly to see that it behaves differently later) + Assert.True(cacheItem.NeedsEvictionCallback, "should be pooled memory"); + Assert.True(cacheItem.TryReserveBuffer(out _)); + cacheItem.Release(); // for the above reserve + + var second = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); + Assert.NotNull(second); + Assert.NotSame(first, second); + + Assert.Equal(1, cacheItem.RefCount); + await cache.RemoveAsync(key); + var third = await cache.GetOrCreateAsync(key, _ => GetAsync(), _noUnderlying); + Assert.Null(third); + Assert.Null(await cache.BackendCache.GetAsync(key)); // should be gone from L2 too + + // give it a moment for the eviction callback to kick in + for (var i = 0; i < 10 && cacheItem.NeedsEvictionCallback; i++) + { + await Task.Delay(250); + } +#if DEBUG + Assert.Equal(0, cache.DebugOnlyGetOutstandingBuffers()); +#endif + + // assert that we can *no longer* reserve this buffer, because we've already recycled it + Assert.False(cacheItem.TryReserveBuffer(out _)); // released now + Assert.Equal(0, cacheItem.RefCount); + + Assert.False(cacheItem.NeedsEvictionCallback, "should be recycled by now"); + static ValueTask GetAsync() => new(new Customer { Id = 42, Name = "Fred" }); + } + + public class Customer + { + public int Id { get; set; } + public string Name { get; set; } = ""; + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs new file mode 100644 index 00000000000..5a565866f63 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/DistributedCacheTests.cs @@ -0,0 +1,397 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Internal; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +/// +/// Validate over-arching expectations of DC implementations, in particular behaviour re IBufferDistributedCache added for HybridCache. +/// +public abstract class DistributedCacheTests +{ + protected DistributedCacheTests(ITestOutputHelper log) + { + Log = log; + } + + protected ITestOutputHelper Log { get; } + protected abstract ValueTask ConfigureAsync(IServiceCollection services); + protected abstract bool CustomClockSupported { get; } + + protected FakeTime Clock { get; } = new(); + + protected sealed class FakeTime : TimeProvider, ISystemClock + { + private DateTimeOffset _now = DateTimeOffset.UtcNow; + public void Reset() => _now = DateTimeOffset.UtcNow; + + DateTimeOffset ISystemClock.UtcNow => _now; + + public override DateTimeOffset GetUtcNow() => _now; + + public void Add(TimeSpan delta) => _now += delta; + } + + private async ValueTask InitAsync() + { + Clock.Reset(); + var services = new ServiceCollection(); + services.AddSingleton(Clock); + services.AddSingleton(Clock); + await ConfigureAsync(services); + return services; + } + + [Theory] + [InlineData(0)] + [InlineData(128)] + [InlineData(1024)] + [InlineData(16 * 1024)] + public async Task SimpleBufferRoundtrip(int size) + { + var cache = (await InitAsync()).BuildServiceProvider().GetService(); + if (cache is null) + { + Log.WriteLine("Cache is not available"); + return; // inconclusive + } + + var key = $"{Me()}:{size}"; + cache.Remove(key); + Assert.Null(cache.Get(key)); + + var expected = new byte[size]; + new Random().NextBytes(expected); + cache.Set(key, expected, _fiveMinutes); + + var actual = cache.Get(key); + Assert.NotNull(actual); + Assert.True(expected.SequenceEqual(actual)); + Log.WriteLine("Data validated"); + + if (CustomClockSupported) + { + Clock.Add(TimeSpan.FromMinutes(4)); + actual = cache.Get(key); + Assert.NotNull(actual); + Assert.True(expected.SequenceEqual(actual)); + + Clock.Add(TimeSpan.FromMinutes(2)); + actual = cache.Get(key); + Assert.Null(actual); + + Log.WriteLine("Expiration validated"); + } + else + { + Log.WriteLine("Expiration not validated - TimeProvider not supported"); + } + } + + [Theory] + [InlineData(0)] + [InlineData(128)] + [InlineData(1024)] + [InlineData(16 * 1024)] + public async Task SimpleBufferRoundtripAsync(int size) + { + var cache = (await InitAsync()).BuildServiceProvider().GetService(); + if (cache is null) + { + Log.WriteLine("Cache is not available"); + return; // inconclusive + } + + var key = $"{Me()}:{size}"; + await cache.RemoveAsync(key); + Assert.Null(cache.Get(key)); + + var expected = new byte[size]; + new Random().NextBytes(expected); + await cache.SetAsync(key, expected, _fiveMinutes); + + var actual = await cache.GetAsync(key); + Assert.NotNull(actual); + Assert.True(expected.SequenceEqual(actual)); + Log.WriteLine("Data validated"); + + if (CustomClockSupported) + { + Clock.Add(TimeSpan.FromMinutes(4)); + actual = await cache.GetAsync(key); + Assert.NotNull(actual); + Assert.True(expected.SequenceEqual(actual)); + + Clock.Add(TimeSpan.FromMinutes(2)); + actual = await cache.GetAsync(key); + Assert.Null(actual); + + Log.WriteLine("Expiration validated"); + } + else + { + Log.WriteLine("Expiration not validated - TimeProvider not supported"); + } + } + + public enum SequenceKind + { + FullArray, + PaddedArray, + CustomMemory, + MultiSegment, + } + + [Theory] + [InlineData(0, SequenceKind.FullArray)] + [InlineData(128, SequenceKind.FullArray)] + [InlineData(1024, SequenceKind.FullArray)] + [InlineData(16 * 1024, SequenceKind.FullArray)] + [InlineData(0, SequenceKind.PaddedArray)] + [InlineData(128, SequenceKind.PaddedArray)] + [InlineData(1024, SequenceKind.PaddedArray)] + [InlineData(16 * 1024, SequenceKind.PaddedArray)] + [InlineData(0, SequenceKind.CustomMemory)] + [InlineData(128, SequenceKind.CustomMemory)] + [InlineData(1024, SequenceKind.CustomMemory)] + [InlineData(16 * 1024, SequenceKind.CustomMemory)] + [InlineData(0, SequenceKind.MultiSegment)] + [InlineData(128, SequenceKind.MultiSegment)] + [InlineData(1024, SequenceKind.MultiSegment)] + [InlineData(16 * 1024, SequenceKind.MultiSegment)] + public async Task ReadOnlySequenceBufferRoundtrip(int size, SequenceKind kind) + { + var cache = (await InitAsync()).BuildServiceProvider().GetService() as IBufferDistributedCache; + if (cache is null) + { + Log.WriteLine("Cache is not available or does not support IBufferDistributedCache"); + return; // inconclusive + } + + var key = $"{Me()}:{size}/{kind}"; + cache.Remove(key); + Assert.Null(cache.Get(key)); + + var payload = Invent(size, kind); + ReadOnlyMemory expected = payload.ToArray(); // simplify for testing + Assert.Equal(size, expected.Length); + cache.Set(key, payload, _fiveMinutes); + + RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue); + Assert.True(cache.TryGet(key, writer)); + Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span)); + writer.ResetInPlace(); + Log.WriteLine("Data validated"); + + if (CustomClockSupported) + { + Clock.Add(TimeSpan.FromMinutes(4)); + Assert.True(cache.TryGet(key, writer)); + Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span)); + writer.ResetInPlace(); + + Clock.Add(TimeSpan.FromMinutes(2)); + Assert.False(cache.TryGet(key, writer)); + Assert.Equal(0, writer.CommittedBytes); + + Log.WriteLine("Expiration validated"); + } + else + { + Log.WriteLine("Expiration not validated - TimeProvider not supported"); + } + + writer.Dispose(); // intentionally only recycle on success + } + + [Theory] + [InlineData(0, SequenceKind.FullArray)] + [InlineData(128, SequenceKind.FullArray)] + [InlineData(1024, SequenceKind.FullArray)] + [InlineData(16 * 1024, SequenceKind.FullArray)] + [InlineData(0, SequenceKind.PaddedArray)] + [InlineData(128, SequenceKind.PaddedArray)] + [InlineData(1024, SequenceKind.PaddedArray)] + [InlineData(16 * 1024, SequenceKind.PaddedArray)] + [InlineData(0, SequenceKind.CustomMemory)] + [InlineData(128, SequenceKind.CustomMemory)] + [InlineData(1024, SequenceKind.CustomMemory)] + [InlineData(16 * 1024, SequenceKind.CustomMemory)] + [InlineData(0, SequenceKind.MultiSegment)] + [InlineData(128, SequenceKind.MultiSegment)] + [InlineData(1024, SequenceKind.MultiSegment)] + [InlineData(16 * 1024, SequenceKind.MultiSegment)] + public async Task ReadOnlySequenceBufferRoundtripAsync(int size, SequenceKind kind) + { + var cache = (await InitAsync()).BuildServiceProvider().GetService() as IBufferDistributedCache; + if (cache is null) + { + Log.WriteLine("Cache is not available or does not support IBufferDistributedCache"); + return; // inconclusive + } + + var key = $"{Me()}:{size}/{kind}"; + await cache.RemoveAsync(key); + Assert.Null(await cache.GetAsync(key)); + + var payload = Invent(size, kind); + ReadOnlyMemory expected = payload.ToArray(); // simplify for testing + Assert.Equal(size, expected.Length); + await cache.SetAsync(key, payload, _fiveMinutes); + + RecyclableArrayBufferWriter writer = RecyclableArrayBufferWriter.Create(int.MaxValue); + Assert.True(await cache.TryGetAsync(key, writer)); + Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span)); + writer.ResetInPlace(); + Log.WriteLine("Data validated"); + + if (CustomClockSupported) + { + Clock.Add(TimeSpan.FromMinutes(4)); + Assert.True(await cache.TryGetAsync(key, writer)); + Assert.True(expected.Span.SequenceEqual(writer.GetCommittedMemory().Span)); + writer.ResetInPlace(); + + Clock.Add(TimeSpan.FromMinutes(2)); + Assert.False(await cache.TryGetAsync(key, writer)); + Assert.Equal(0, writer.CommittedBytes); + + Log.WriteLine("Expiration validated"); + } + else + { + Log.WriteLine("Expiration not validated - TimeProvider not supported"); + } + + writer.Dispose(); // intentionally only recycle on success + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope", Justification = "Not relevant for this test - no-op")] + private static ReadOnlySequence Invent(int size, SequenceKind kind) + { + var rand = new Random(); + ReadOnlySequence payload; + switch (kind) + { + case SequenceKind.FullArray: + var arr = new byte[size]; + rand.NextBytes(arr); + payload = new(arr); + break; + case SequenceKind.PaddedArray: + arr = new byte[size + 10]; + rand.NextBytes(arr); + payload = new(arr, 5, arr.Length - 10); + break; + case SequenceKind.CustomMemory: + var mem = new CustomMemory(size, rand).Memory; + payload = new(mem); + break; + case SequenceKind.MultiSegment: + if (size == 0) + { + payload = default; + break; + } + + if (size < 10) + { + throw new ArgumentException("small segments not considered"); // a pain to construct + } + + CustomSegment first = new(10, rand, null); // we'll take the last 3 of this 10 + CustomSegment second = new(size - 7, rand, first); // we'll take all of this one + CustomSegment third = new(10, rand, second); // we'll take the first 4 of this 10 + payload = new(first, 7, third, 4); + break; + default: + throw new ArgumentOutOfRangeException(nameof(kind)); + } + + // now validate what we expect of that payload + Assert.Equal(size, payload.Length); + switch (kind) + { + case SequenceKind.CustomMemory or SequenceKind.MultiSegment when size == 0: + Assert.True(payload.IsSingleSegment); + Assert.True(MemoryMarshal.TryGetArray(payload.First, out _)); + break; + case SequenceKind.MultiSegment: + Assert.False(payload.IsSingleSegment); + break; + case SequenceKind.CustomMemory: + Assert.True(payload.IsSingleSegment); + Assert.False(MemoryMarshal.TryGetArray(payload.First, out _)); + break; + case SequenceKind.FullArray: + Assert.True(payload.IsSingleSegment); + Assert.True(MemoryMarshal.TryGetArray(payload.First, out var segment)); + Assert.Equal(0, segment.Offset); + Assert.NotNull(segment.Array); + Assert.Equal(size, segment.Count); + Assert.Equal(size, segment.Array.Length); + break; + case SequenceKind.PaddedArray: + Assert.True(payload.IsSingleSegment); + Assert.True(MemoryMarshal.TryGetArray(payload.First, out segment)); + Assert.NotEqual(0, segment.Offset); + Assert.NotNull(segment.Array); + Assert.Equal(size, segment.Count); + Assert.NotEqual(size, segment.Array.Length); + break; + } + + return payload; + } + + private class CustomSegment : ReadOnlySequenceSegment + { + public CustomSegment(int size, Random? rand, CustomSegment? previous) + { + var arr = new byte[size + 10]; + rand?.NextBytes(arr); + Memory = new(arr, 5, arr.Length - 10); + if (previous is not null) + { + RunningIndex = previous.RunningIndex + previous.Memory.Length; + previous.Next = this; + } + } + } + + private class CustomMemory : MemoryManager + { + private readonly byte[] _data; + public CustomMemory(int size, Random? rand = null) + { + _data = new byte[size + 10]; + rand?.NextBytes(_data); + } + + public override Span GetSpan() => new(_data, 5, _data.Length - 10); + public override MemoryHandle Pin(int elementIndex = 0) => throw new NotSupportedException(); + public override void Unpin() => throw new NotSupportedException(); + protected override void Dispose(bool disposing) + { + } + + protected override bool TryGetArray(out ArraySegment segment) + { + segment = default; + return false; + } + } + + private static readonly DistributedCacheEntryOptions _fiveMinutes + = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }; + + protected static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs new file mode 100644 index 00000000000..5edd99722ac --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/FunctionalTests.cs @@ -0,0 +1,82 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class FunctionalTests +{ + private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache, Action? config = null) + { + var services = new ServiceCollection(); + config?.Invoke(services); + services.AddHybridCache(); + ServiceProvider provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + [Fact] + public async Task RemoveSingleKey() + { + using var provider = GetDefaultCache(out var cache); + var key = Me(); + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(42))); + + // now slightly different func to show delta; should use cached value initially + await cache.RemoveAsync("unrelated"); + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(96))); + + // now remove and repeat - should get updated value + await cache.RemoveAsync(key); + Assert.Equal(96, await cache.GetOrCreateAsync(key, _ => new ValueTask(96))); + } + + [Fact] + public async Task RemoveNoKeyViaArray() + { + using var provider = GetDefaultCache(out var cache); + var key = Me(); + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(42))); + + // now slightly different func to show delta; should use same cached value + await cache.RemoveAsync([]); + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(96))); + } + + [Fact] + public async Task RemoveSingleKeyViaArray() + { + using var provider = GetDefaultCache(out var cache); + var key = Me(); + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(42))); + + // now slightly different func to show delta; should use cached value initially + await cache.RemoveAsync(["unrelated"]); + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(96))); + + // now remove and repeat - should get updated value + await cache.RemoveAsync([key]); + Assert.Equal(96, await cache.GetOrCreateAsync(key, _ => new ValueTask(96))); + } + + [Fact] + public async Task RemoveMultipleKeysViaArray() + { + using var provider = GetDefaultCache(out var cache); + var key = Me(); + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(42))); + + // now slightly different func to show delta; should use cached value initially + Assert.Equal(42, await cache.GetOrCreateAsync(key, _ => new ValueTask(96))); + + // now remove and repeat - should get updated value + await cache.RemoveAsync([key, "unrelated"]); + Assert.Equal(96, await cache.GetOrCreateAsync(key, _ => new ValueTask(96))); + } + + private static string Me([CallerMemberName] string caller = "") => caller; + +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs new file mode 100644 index 00000000000..850c6a054b9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/L2Tests.cs @@ -0,0 +1,274 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class L2Tests(ITestOutputHelper log) +{ + private static string CreateString(bool work = false) + { + Assert.True(work, "we didn't expect this to be invoked"); + return Guid.NewGuid().ToString(); + } + + private static readonly HybridCacheEntryOptions _expiry = new() { Expiration = TimeSpan.FromMinutes(3.5) }; + + private static readonly HybridCacheEntryOptions _expiryNoL1 = new() { Flags = HybridCacheEntryFlags.DisableLocalCache, Expiration = TimeSpan.FromMinutes(3.5) }; + + private ITestOutputHelper Log => log; + + private class Options(T value) : IOptions + where T : class + { + T IOptions.Value => value; + } + + private ServiceProvider GetDefaultCache(bool buffers, out DefaultHybridCache cache) + { + var services = new ServiceCollection(); + var localCacheOptions = new Options(new()); + var localCache = new MemoryDistributedCache(localCacheOptions); + services.AddSingleton(buffers ? new BufferLoggingCache(Log, localCache) : new LoggingCache(Log, localCache)); + services.AddHybridCache(); + ServiceProvider provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AssertL2Operations_Immutable(bool buffers) + { + using var provider = GetDefaultCache(buffers, out var cache); + var backend = Assert.IsAssignableFrom(cache.BackendCache); + Log.WriteLine("Inventing key..."); + var s = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString(true))); + Assert.Equal(2, backend.OpCount); // GET, SET + + Log.WriteLine("Reading with L1..."); + for (var i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString())); + Assert.Equal(s, x); + Assert.Same(s, x); + } + + Assert.Equal(2, backend.OpCount); // shouldn't be hit + + Log.WriteLine("Reading without L1..."); + for (var i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString()), _expiryNoL1); + Assert.Equal(s, x); + Assert.NotSame(s, x); + } + + Assert.Equal(7, backend.OpCount); // should be read every time + + Log.WriteLine("Setting value directly"); + s = CreateString(true); + await cache.SetAsync(Me(), s); + for (var i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString())); + Assert.Equal(s, x); + Assert.Same(s, x); + } + + Assert.Equal(8, backend.OpCount); // SET + + Log.WriteLine("Removing key..."); + await cache.RemoveAsync(Me()); + Assert.Equal(9, backend.OpCount); // DEL + + Log.WriteLine("Fetching new..."); + var t = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString(true))); + Assert.NotEqual(s, t); + Assert.Equal(11, backend.OpCount); // GET, SET + } + + public sealed class Foo + { + public string Value { get; set; } = ""; + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AssertL2Operations_Mutable(bool buffers) + { + using var provider = GetDefaultCache(buffers, out var cache); + var backend = Assert.IsAssignableFrom(cache.BackendCache); + Log.WriteLine("Inventing key..."); + var s = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString(true) }), _expiry); + Assert.Equal(2, backend.OpCount); // GET, SET + + Log.WriteLine("Reading with L1..."); + for (var i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString() }), _expiry); + Assert.Equal(s.Value, x.Value); + Assert.NotSame(s, x); + } + + Assert.Equal(2, backend.OpCount); // shouldn't be hit + + Log.WriteLine("Reading without L1..."); + for (var i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString() }), _expiryNoL1); + Assert.Equal(s.Value, x.Value); + Assert.NotSame(s, x); + } + + Assert.Equal(7, backend.OpCount); // should be read every time + + Log.WriteLine("Setting value directly"); + s = new Foo { Value = CreateString(true) }; + await cache.SetAsync(Me(), s); + for (var i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString() }), _expiry); + Assert.Equal(s.Value, x.Value); + Assert.NotSame(s, x); + } + + Assert.Equal(8, backend.OpCount); // SET + + Log.WriteLine("Removing key..."); + await cache.RemoveAsync(Me()); + Assert.Equal(9, backend.OpCount); // DEL + + Log.WriteLine("Fetching new..."); + var t = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString(true) }), _expiry); + Assert.NotEqual(s.Value, t.Value); + Assert.Equal(11, backend.OpCount); // GET, SET + } + + private class BufferLoggingCache : LoggingCache, IBufferDistributedCache + { + public BufferLoggingCache(ITestOutputHelper log, IDistributedCache tail) + : base(log, tail) + { + } + + void IBufferDistributedCache.Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"Set (ROS-byte): {key}"); + Tail.Set(key, value.ToArray(), options); + } + + ValueTask IBufferDistributedCache.SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"SetAsync (ROS-byte): {key}"); + return new(Tail.SetAsync(key, value.ToArray(), options, token)); + } + + bool IBufferDistributedCache.TryGet(string key, IBufferWriter destination) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"TryGet: {key}"); + var buffer = Tail.Get(key); + if (buffer is null) + { + return false; + } + + destination.Write(buffer); + return true; + } + + async ValueTask IBufferDistributedCache.TryGetAsync(string key, IBufferWriter destination, CancellationToken token) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"TryGetAsync: {key}"); + var buffer = await Tail.GetAsync(key, token); + if (buffer is null) + { + return false; + } + + destination.Write(buffer); + return true; + } + } + + private class LoggingCache(ITestOutputHelper log, IDistributedCache tail) : IDistributedCache + { + protected ITestOutputHelper Log => log; + protected IDistributedCache Tail => tail; + + protected int ProtectedOpCount; + + public int OpCount => Volatile.Read(ref ProtectedOpCount); + + byte[]? IDistributedCache.Get(string key) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"Get: {key}"); + return Tail.Get(key); + } + + Task IDistributedCache.GetAsync(string key, CancellationToken token) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"GetAsync: {key}"); + return Tail.GetAsync(key, token); + } + + void IDistributedCache.Refresh(string key) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"Refresh: {key}"); + Tail.Refresh(key); + } + + Task IDistributedCache.RefreshAsync(string key, CancellationToken token) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"RefreshAsync: {key}"); + return Tail.RefreshAsync(key, token); + } + + void IDistributedCache.Remove(string key) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"Remove: {key}"); + Tail.Remove(key); + } + + Task IDistributedCache.RemoveAsync(string key, CancellationToken token) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"RemoveAsync: {key}"); + return Tail.RemoveAsync(key, token); + } + + void IDistributedCache.Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"Set (byte[]): {key}"); + Tail.Set(key, value, options); + } + + Task IDistributedCache.SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token) + { + Interlocked.Increment(ref ProtectedOpCount); + Log.WriteLine($"SetAsync (byte[]): {key}"); + return Tail.SetAsync(key, value, options, token); + } + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj new file mode 100644 index 00000000000..ef80a84eee9 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -0,0 +1,30 @@ + + + + $(NetCoreTargetFrameworks)$(ConditionalNet462) + enable + enable + true + + + + + + + + + + + + + + + + + + + PreserveNewest + + + + diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/RedisFixture.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/RedisFixture.cs new file mode 100644 index 00000000000..09b37e16466 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/RedisFixture.cs @@ -0,0 +1,30 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using StackExchange.Redis; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public sealed class RedisFixture : IDisposable +{ + private ConnectionMultiplexer? _muxer; + private Task? _sharedConnect; + public Task ConnectAsync() => _sharedConnect ??= DoConnectAsync(); + + public void Dispose() => _muxer?.Dispose(); + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "catch-all")] + private async Task DoConnectAsync() + { + try + { + _muxer = await ConnectionMultiplexer.ConnectAsync("127.0.0.1:6379"); + await _muxer.GetDatabase().PingAsync(); + return _muxer; + } + catch + { + return null; + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/RedisTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/RedisTests.cs new file mode 100644 index 00000000000..86303044c48 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/RedisTests.cs @@ -0,0 +1,90 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.StackExchangeRedis; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class RedisTests : DistributedCacheTests, IClassFixture +{ + private readonly RedisFixture _fixture; + public RedisTests(RedisFixture fixture, ITestOutputHelper log) + : base(log) + { + _fixture = fixture; + } + + protected override bool CustomClockSupported => false; + + protected override async ValueTask ConfigureAsync(IServiceCollection services) + { + var redis = await _fixture.ConnectAsync(); + if (redis is null) + { + Log.WriteLine("Redis is not available"); + return; // inconclusive + } + + Log.WriteLine("Redis is available"); + services.AddSingleton(redis); + services.AddStackExchangeRedisCache(options => + { + options.ConnectionMultiplexerFactory = () => Task.FromResult(redis); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BasicUsage(bool useBuffers) + { + var services = new ServiceCollection(); + await ConfigureAsync(services); + services.AddHybridCache(); + ServiceProvider provider = services.BuildServiceProvider(); // not "using" - that will tear down our redis; use the fixture for that + + var cache = Assert.IsType(provider.GetRequiredService()); + if (cache.BackendCache is null) + { + Log.WriteLine("Backend cache not available; inconclusive"); + return; + } + + Assert.IsAssignableFrom(cache.BackendCache); + + if (!useBuffers) + { + // force byte[] mode + cache.DebugRemoveFeatures(DefaultHybridCache.CacheFeatures.BackendBuffers); + } + + Log.WriteLine($"features: {cache.GetFeatures()}"); + + var key = Me(); + var redis = provider.GetRequiredService(); + await redis.GetDatabase().KeyDeleteAsync(key); // start from known state + Assert.False(await redis.GetDatabase().KeyExistsAsync(key)); + + var count = 0; + for (var i = 0; i < 10; i++) + { + await cache.GetOrCreateAsync(key, _ => + { + Interlocked.Increment(ref count); + return new(Guid.NewGuid()); + }); + } + + Assert.Equal(1, count); + + await Task.Delay(500); // the L2 write continues in the background; give it a chance + + var ttl = await redis.GetDatabase().KeyTimeToLiveAsync(key); + Log.WriteLine($"ttl: {ttl}"); + Assert.NotNull(ttl); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SampleUsage.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SampleUsage.cs new file mode 100644 index 00000000000..0172525b128 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SampleUsage.cs @@ -0,0 +1,199 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Text.Json; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class SampleUsage +{ + [Fact] + public async Task DistributedCacheWorks() + { + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddTransient(); + using ServiceProvider provider = services.BuildServiceProvider(); + + var obj = provider.GetRequiredService(); + string name = "abc"; + int id = 42; + var x = await obj.GetSomeInformationAsync(name, id); + var y = await obj.GetSomeInformationAsync(name, id); + Assert.NotSame(x, y); + Assert.Equal(id, x.Id); + Assert.Equal(name, x.Name); + Assert.Equal(id, y.Id); + Assert.Equal(name, y.Name); + } + + [Fact] + public async Task HybridCacheWorks() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + services.AddTransient(); + using ServiceProvider provider = services.BuildServiceProvider(); + + var obj = provider.GetRequiredService(); + string name = "abc"; + int id = 42; + var x = await obj.GetSomeInformationAsync(name, id); + var y = await obj.GetSomeInformationAsync(name, id); + Assert.NotSame(x, y); + Assert.Equal(id, x.Id); + Assert.Equal(name, x.Name); + Assert.Equal(id, y.Id); + Assert.Equal(name, y.Name); + } + + [Fact] + public async Task HybridCacheNoCaptureWorks() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + services.AddTransient(); + using ServiceProvider provider = services.BuildServiceProvider(); + + var obj = provider.GetRequiredService(); + string name = "abc"; + int id = 42; + var x = await obj.GetSomeInformationAsync(name, id); + var y = await obj.GetSomeInformationAsync(name, id); + Assert.NotSame(x, y); + Assert.Equal(id, x.Id); + Assert.Equal(name, x.Name); + Assert.Equal(id, y.Id); + Assert.Equal(name, y.Name); + } + + [Fact] + public async Task HybridCacheNoCaptureObjReuseWorks() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + services.AddTransient(); + using ServiceProvider provider = services.BuildServiceProvider(); + + var obj = provider.GetRequiredService(); + string name = "abc"; + int id = 42; + var x = await obj.GetSomeInformationAsync(name, id); + var y = await obj.GetSomeInformationAsync(name, id); + Assert.Same(x, y); + Assert.Equal(id, x.Id); + Assert.Equal(name, x.Name); + } + + public class SomeDCService(IDistributedCache cache) + { + public async Task GetSomeInformationAsync(string name, int id, CancellationToken token = default) + { + var key = $"someinfo:{name}:{id}"; // unique key for this combination + + var bytes = await cache.GetAsync(key, token); // try to get from cache + SomeInformation info; + if (bytes is null) + { + // cache miss; get the data from the real source + info = await SomeExpensiveOperationAsync(name, id, token); + + // serialize and cache it + bytes = SomeSerializer.Serialize(info); + await cache.SetAsync(key, bytes, token); + } + else + { + // cache hit; deserialize it + info = SomeSerializer.Deserialize(bytes); + } + + return info; + } + } + + public class SomeHCService(HybridCache cache) + { + public async Task GetSomeInformationAsync(string name, int id, CancellationToken token = default) + { + return await cache.GetOrCreateAsync( + $"someinfo:{name}:{id}", // unique key for this combination + async ct => await SomeExpensiveOperationAsync(name, id, ct), + cancellationToken: token); + } + } + + // this is the work we're trying to cache + private static Task SomeExpensiveOperationAsync(string name, int id, + CancellationToken token = default) + { + _ = token; + return Task.FromResult(new SomeInformation { Id = id, Name = name }); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Minor Code Smell", "S3398:\"private\" methods called only by inner classes should be moved to those classes", + Justification = "Allow future sharing")] + private static Task SomeExpensiveOperationReuseAsync(string name, int id, + CancellationToken token = default) + { + _ = token; + return Task.FromResult(new SomeInformationReuse { Id = id, Name = name }); + } + + public class SomeHCServiceNoCapture(HybridCache cache) + { + public async Task GetSomeInformationAsync(string name, int id, CancellationToken token = default) + { + return await cache.GetOrCreateAsync( + $"someinfo:{name}:{id}", // unique key for this combination + (name, id), // all of the state we need for the final call, if needed + static async (state, token) => + await SomeExpensiveOperationAsync(state.name, state.id, token), + cancellationToken: token); + } + } + + public class SomeHCServiceNoCaptureObjReuse(HybridCache cache, CancellationToken token = default) + { + public async Task GetSomeInformationAsync(string name, int id) + { + return await cache.GetOrCreateAsync( + $"someinfo:{name}:{id}", // unique key for this combination + (name, id), // all of the state we need for the final call, if needed + static async (state, token) => + await SomeExpensiveOperationReuseAsync(state.name, state.id, token), + cancellationToken: token); + } + } + + private static class SomeSerializer + { + internal static T Deserialize(byte[] bytes) + { + return JsonSerializer.Deserialize(bytes)!; + } + + internal static byte[] Serialize(T info) + { + using var ms = new MemoryStream(); + JsonSerializer.Serialize(ms, info); + return ms.ToArray(); + } + } + + public class SomeInformation + { + public int Id { get; set; } + public string? Name { get; set; } + } + + [ImmutableObject(true)] + public sealed class SomeInformationReuse + { + public int Id { get; set; } + public string? Name { get; set; } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs new file mode 100644 index 00000000000..decc47d3964 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/ServiceConstructionTests.cs @@ -0,0 +1,263 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +#if NET9_0_OR_GREATER +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Json; +#endif + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously +#pragma warning disable CS8769 // Nullability of reference types in type of parameter doesn't match implemented member (possibly because of nullability attributes). + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class ServiceConstructionTests +{ + [Fact] + public void CanCreateDefaultService() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetService()); + } + + [Fact] + public void CanCreateServiceWithManualOptions() + { + var services = new ServiceCollection(); + services.AddHybridCache(options => + { + options.MaximumKeyLength = 937; + options.DefaultEntryOptions = new() { Expiration = TimeSpan.FromSeconds(120), Flags = HybridCacheEntryFlags.DisableLocalCacheRead }; + }); + using ServiceProvider provider = services.BuildServiceProvider(); + var obj = Assert.IsType(provider.GetService()); + var options = obj.Options; + Assert.Equal(937, options.MaximumKeyLength); + var defaults = options.DefaultEntryOptions; + Assert.NotNull(defaults); + Assert.Equal(TimeSpan.FromSeconds(120), defaults.Expiration); + Assert.Equal(HybridCacheEntryFlags.DisableLocalCacheRead, defaults.Flags); + Assert.Null(defaults.LocalCacheExpiration); // wasn't specified + } + +#if NET9_0_OR_GREATER // for Bind API + [Fact] + public void CanParseOptions_NoEntryOptions() + { + var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; + var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var config = configBuilder.Build(); + var options = new HybridCacheOptions(); + ConfigurationBinder.Bind(config, "no_entry_options", options); + + Assert.Equal(937, options.MaximumKeyLength); + Assert.Null(options.DefaultEntryOptions); + } + + [Fact] + public void CanParseOptions_WithEntryOptions() // in particular, check we can parse the timespan and [Flags] enums + { + var source = new JsonConfigurationSource { Path = "BasicConfig.json" }; + var configBuilder = new ConfigurationBuilder { Sources = { source } }; + var config = configBuilder.Build(); + var options = new HybridCacheOptions(); + ConfigurationBinder.Bind(config, "with_entry_options", options); + + Assert.Equal(937, options.MaximumKeyLength); + var defaults = options.DefaultEntryOptions; + Assert.NotNull(defaults); + Assert.Equal(HybridCacheEntryFlags.DisableCompression | HybridCacheEntryFlags.DisableLocalCacheRead, defaults.Flags); + Assert.Equal(TimeSpan.FromSeconds(120), defaults.LocalCacheExpiration); + Assert.Null(defaults.Expiration); // wasn't specified + } +#endif + + [Fact] + public async Task BasicStatelessUsage() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredService(); + + var expected = Guid.NewGuid().ToString(); + var actual = await cache.GetOrCreateAsync(Me(), async _ => expected); + Assert.Equal(expected, actual); + } + + [Fact] + public async Task BasicStatefulUsage() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = provider.GetRequiredService(); + + var expected = Guid.NewGuid().ToString(); + var actual = await cache.GetOrCreateAsync(Me(), expected, async (state, _) => state); + Assert.Equal(expected, actual); + } + + [Fact] + public void DefaultSerializerConfiguration() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.IsType(cache.GetSerializer()); + Assert.IsType(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + } + + [Fact] + public void CustomSerializerConfiguration() + { + var services = new ServiceCollection(); + services.AddHybridCache().AddSerializer(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.IsType(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + } + + [Fact] + public void CustomSerializerFactoryConfiguration() + { + var services = new ServiceCollection(); + services.AddHybridCache().AddSerializerFactory(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.IsType(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void DefaultMemoryDistributedCacheIsIgnored(bool manual) + { + var services = new ServiceCollection(); + if (manual) + { + services.AddSingleton(); + } + else + { + services.AddDistributedMemoryCache(); + } + + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.Null(cache.BackendCache); + } + + [Fact] + public void SubclassMemoryDistributedCacheIsNotIgnored() + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.NotNull(cache.BackendCache); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void SubclassMemoryCacheIsNotIgnored(bool manual) + { + var services = new ServiceCollection(); + if (manual) + { + services.AddSingleton(); + } + else + { + services.AddDistributedMemoryCache(); + } + + services.AddSingleton(); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.NotNull(cache.BackendCache); + } + + private class CustomMemoryCache : MemoryCache + { + public CustomMemoryCache(IOptions options) + : base(options) + { + } + + public CustomMemoryCache(IOptions options, ILoggerFactory loggerFactory) + : base(options, loggerFactory) + { + } + } + + private class CustomMemoryDistributedCache : MemoryDistributedCache + { + public CustomMemoryDistributedCache(IOptions options) + : base(options) + { + } + + public CustomMemoryDistributedCache(IOptions options, ILoggerFactory loggerFactory) + : base(options, loggerFactory) + { + } + } + + private class Customer + { + } + + private class Order + { + } + + private class CustomerSerializer : IHybridCacheSerializer + { + Customer IHybridCacheSerializer.Deserialize(ReadOnlySequence source) => throw new NotSupportedException(); + void IHybridCacheSerializer.Serialize(Customer value, IBufferWriter target) => throw new NotSupportedException(); + } + + private class CustomFactory : IHybridCacheSerializerFactory + { + bool IHybridCacheSerializerFactory.TryCreateSerializer(out IHybridCacheSerializer? serializer) + { + if (typeof(T) == typeof(Customer)) + { + serializer = (IHybridCacheSerializer)new CustomerSerializer(); + return true; + } + + serializer = null; + return false; + } + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs new file mode 100644 index 00000000000..119c2297882 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SizeTests.cs @@ -0,0 +1,95 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class SizeTests +{ + [Theory] + [InlineData(null, true)] // does not enforce size limits + [InlineData(8L, false)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time + [InlineData(1024L, true)] // reasonable size limit + public async Task ValidateSizeLimit_Immutable(long? sizeLimit, bool expectFromL1) + { + var services = new ServiceCollection(); + services.AddMemoryCache(options => options.SizeLimit = sizeLimit); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + const string Key = "abc"; + + // this looks weird; it is intentionally not a const - we want to check + // same instance without worrying about interning from raw literals + string expected = new("simple value".ToArray()); + var actual = await cache.GetOrCreateAsync(Key, ct => new(expected)); + + // expect same contents + Assert.Equal(expected, actual); + + // expect same instance, because string is special-cased as a type + // that doesn't need defensive copies + Assert.Same(expected, actual); + + // rinse and repeat, to check we get the value from L1 + actual = await cache.GetOrCreateAsync(Key, ct => new(Guid.NewGuid().ToString())); + + if (expectFromL1) + { + // expect same contents from L1 + Assert.Equal(expected, actual); + + // expect same instance, because string is special-cased as a type + // that doesn't need defensive copies + Assert.Same(expected, actual); + } + else + { + // L1 cache not used + Assert.NotEqual(expected, actual); + } + } + + [Theory] + [InlineData(null, true)] // does not enforce size limits + [InlineData(8L, false)] // unreasonably small limit; chosen because our test string has length 12 - hence no expectation to find the second time + [InlineData(1024L, true)] // reasonable size limit + public async Task ValidateSizeLimit_Mutable(long? sizeLimit, bool expectFromL1) + { + var services = new ServiceCollection(); + services.AddMemoryCache(options => options.SizeLimit = sizeLimit); + services.AddHybridCache(); + using ServiceProvider provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + const string Key = "abc"; + + string expected = "simple value"; + var actual = await cache.GetOrCreateAsync(Key, ct => new(new MutablePoco { Value = expected })); + + // expect same contents + Assert.Equal(expected, actual.Value); + + // rinse and repeat, to check we get the value from L1 + actual = await cache.GetOrCreateAsync(Key, ct => new(new MutablePoco { Value = Guid.NewGuid().ToString() })); + + if (expectFromL1) + { + // expect same contents from L1 + Assert.Equal(expected, actual.Value); + } + else + { + // L1 cache not used + Assert.NotEqual(expected, actual.Value); + } + } + + public class MutablePoco + { + public string Value { get; set; } = ""; + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SqlServerTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SqlServerTests.cs new file mode 100644 index 00000000000..e2859ec9f0b --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/SqlServerTests.cs @@ -0,0 +1,50 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class SqlServerTests : DistributedCacheTests +{ + public SqlServerTests(ITestOutputHelper log) + : base(log) + { + } + + protected override bool CustomClockSupported => true; + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "CA1031:Do not catch general exception types", Justification = "Caught and logged")] + protected override async ValueTask ConfigureAsync(IServiceCollection services) + { + // create a local DB named CacheBench, then + // dotnet tool install --global dotnet-sql-cache + // dotnet sql-cache create "Data Source=.;Initial Catalog=CacheBench;Integrated Security=True;Trust Server Certificate=True" dbo BenchmarkCache + + const string ConnectionString = "Data Source=.;Initial Catalog=CacheBench;Integrated Security=True;Trust Server Certificate=True"; + + try + { + using var conn = new SqlConnection(ConnectionString); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "truncate table dbo.BenchmarkCache"; + await conn.OpenAsync(); + await cmd.ExecuteNonQueryAsync(); + + // if that worked: we should be fine + services.AddDistributedSqlServerCache(options => + { + options.SchemaName = "dbo"; + options.TableName = "BenchmarkCache"; + options.ConnectionString = ConnectionString; + options.SystemClock = Clock; + }); + } + catch (Exception ex) + { + Log.WriteLine(ex.Message); + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/StampedeTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/StampedeTests.cs new file mode 100644 index 00000000000..4680f589f98 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/StampedeTests.cs @@ -0,0 +1,474 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; + +public class StampedeTests +{ + private static ServiceProvider GetDefaultCache(out DefaultHybridCache cache) + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(); + services.AddHybridCache(options => + { + options.DefaultEntryOptions = new() + { + Flags = HybridCacheEntryFlags.DisableDistributedCache | HybridCacheEntryFlags.DisableLocalCache + }; + }); + ServiceProvider provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + public sealed class InvalidCache : IDistributedCache, IMemoryCache + { + public void Dispose() + { + // nothing to do + } + + ICacheEntry IMemoryCache.CreateEntry(object key) => throw new NotSupportedException("Intentionally not provided"); + + byte[]? IDistributedCache.Get(string key) => throw new NotSupportedException("Intentionally not provided"); + + Task IDistributedCache.GetAsync(string key, CancellationToken token) => throw new NotSupportedException("Intentionally not provided"); + + void IDistributedCache.Refresh(string key) => throw new NotSupportedException("Intentionally not provided"); + + Task IDistributedCache.RefreshAsync(string key, CancellationToken token) => throw new NotSupportedException("Intentionally not provided"); + + void IDistributedCache.Remove(string key) => throw new NotSupportedException("Intentionally not provided"); + + void IMemoryCache.Remove(object key) => throw new NotSupportedException("Intentionally not provided"); + + Task IDistributedCache.RemoveAsync(string key, CancellationToken token) => throw new NotSupportedException("Intentionally not provided"); + + void IDistributedCache.Set(string key, byte[] value, DistributedCacheEntryOptions options) => throw new NotSupportedException("Intentionally not provided"); + + Task IDistributedCache.SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token) => throw new NotSupportedException("Intentionally not provided"); + + bool IMemoryCache.TryGetValue(object key, out object? value) => throw new NotSupportedException("Intentionally not provided"); + } + + [Theory] + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(10, false)] + [InlineData(10, true)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Critical Code Smell", "S5034:\"ValueTask\" should be consumed correctly", Justification = "False positive, is only awaited once")] + public async Task MultipleCallsShareExecution_NoCancellation(int callerCount, bool canBeCanceled) + { + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + using var cts = canBeCanceled ? new CancellationTokenSource() : null; + var token = cts?.Token ?? CancellationToken.None; + + int executeCount = 0; + int cancelCount = 0; + var results = new Task[callerCount]; + for (var i = 0; i < callerCount; i++) + { + results[i] = cache.GetOrCreateAsync(Me(), async ct => + { + using var reg = ct.Register(() => Interlocked.Increment(ref cancelCount)); + if (!await semaphore.WaitAsync(5_000, CancellationToken.None)) + { + throw new TimeoutException("Failed to activate"); + } + + Interlocked.Increment(ref executeCount); + ct.ThrowIfCancellationRequested(); // assert not cancelled + return Guid.NewGuid(); + }, cancellationToken: token).AsTask(); + } + + Assert.Equal(callerCount, cache.DebugGetCallerCount(Me())); + + // everyone is queued up; release the hounds and check + // that we all got the same result + Assert.Equal(0, Volatile.Read(ref executeCount)); + Assert.Equal(0, Volatile.Read(ref cancelCount)); + semaphore.Release(); + var first = await results[0]; + Assert.Equal(1, Volatile.Read(ref executeCount)); + Assert.Equal(0, Volatile.Read(ref cancelCount)); + foreach (var result in results) + { + Assert.Equal(first, await result); + } + + Assert.Equal(1, Volatile.Read(ref executeCount)); + Assert.Equal(0, Volatile.Read(ref cancelCount)); + + // and do it a second time; we expect different results + Volatile.Write(ref executeCount, 0); + for (var i = 0; i < callerCount; i++) + { + results[i] = cache.GetOrCreateAsync(Me(), async ct => + { + using var reg = ct.Register(() => Interlocked.Increment(ref cancelCount)); + if (!await semaphore.WaitAsync(5_000, CancellationToken.None)) + { + throw new TimeoutException("Failed to activate"); + } + + Interlocked.Increment(ref executeCount); + ct.ThrowIfCancellationRequested(); // assert not cancelled + return Guid.NewGuid(); + }, cancellationToken: token).AsTask(); + } + + Assert.Equal(callerCount, cache.DebugGetCallerCount(Me())); + + // everyone is queued up; release the hounds and check + // that we all got the same result + Assert.Equal(0, Volatile.Read(ref executeCount)); + Assert.Equal(0, Volatile.Read(ref cancelCount)); + semaphore.Release(); + var second = await results[0]; + Assert.NotEqual(first, second); + Assert.Equal(1, Volatile.Read(ref executeCount)); + Assert.Equal(0, Volatile.Read(ref cancelCount)); + foreach (var result in results) + { + Assert.Equal(second, await result); + } + + Assert.Equal(1, Volatile.Read(ref executeCount)); + Assert.Equal(0, Volatile.Read(ref cancelCount)); + } + + [Theory] + [InlineData(1)] + [InlineData(10)] + public async Task MultipleCallsShareExecution_EveryoneCancels(int callerCount) + { + // what we want to prove here is that everyone ends up cancelling promptly by + // *their own* cancellation (not dependent on the shared task), and that + // the shared task becomes cancelled (which can be later) + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + int executeCount = 0; + int cancelCount = 0; + var results = new Task[callerCount]; + var cancels = new CancellationTokenSource[callerCount]; + for (var i = 0; i < callerCount; i++) + { + cancels[i] = new CancellationTokenSource(); + results[i] = cache.GetOrCreateAsync(Me(), async ct => + { + using var reg = ct.Register(() => Interlocked.Increment(ref cancelCount)); + if (!await semaphore.WaitAsync(5_000, CancellationToken.None)) + { + throw new TimeoutException("Failed to activate"); + } + + try + { + Interlocked.Increment(ref executeCount); + ct.ThrowIfCancellationRequested(); + return Guid.NewGuid(); + } + finally + { + semaphore.Release(); // handshake so we can check when available again + } + }, cancellationToken: cancels[i].Token).AsTask(); + } + + Assert.Equal(callerCount, cache.DebugGetCallerCount(Me())); + + // everyone is queued up; release the hounds and check + // that we all got the same result + foreach (var cancel in cancels) + { + cancel.Cancel(); + } + + await Task.Delay(500); // cancellation happens on a worker; need to allow a moment + for (var i = 0; i < callerCount; i++) + { + var result = results[i]; + + // should have already cancelled, even though underlying task hasn't finished yet + Assert.Equal(TaskStatus.Canceled, result.Status); + var ex = Assert.Throws(() => result.GetAwaiter().GetResult()); + Assert.Equal(cancels[i].Token, ex.CancellationToken); // each gets the correct blame + } + + Assert.Equal(0, Volatile.Read(ref executeCount)); + semaphore.Release(); + + // wait for underlying task to hand back to us + if (!await semaphore.WaitAsync(5_000)) + { + throw new TimeoutException("Didn't get handshake back from task"); + } + + Assert.Equal(1, Volatile.Read(ref executeCount)); + Assert.Equal(1, Volatile.Read(ref cancelCount)); + } + + [Theory] + [InlineData(2, 0)] + [InlineData(2, 1)] + [InlineData(10, 0)] + [InlineData(10, 1)] + [InlineData(10, 7)] + public async Task MultipleCallsShareExecution_MostCancel(int callerCount, int remaining) + { + Assert.True(callerCount >= 2); // "most" is not "one" + + // what we want to prove here is that everyone ends up cancelling promptly by + // *their own* cancellation (not dependent on the shared task), and that + // the shared task becomes cancelled (which can be later) + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + int executeCount = 0; + int cancelCount = 0; + var results = new Task[callerCount]; + var cancels = new CancellationTokenSource[callerCount]; + for (var i = 0; i < callerCount; i++) + { + cancels[i] = new CancellationTokenSource(); + results[i] = cache.GetOrCreateAsync(Me(), async ct => + { + using var reg = ct.Register(() => Interlocked.Increment(ref cancelCount)); + if (!await semaphore.WaitAsync(5_000, CancellationToken.None)) + { + throw new TimeoutException("Failed to activate"); + } + + try + { + Interlocked.Increment(ref executeCount); + ct.ThrowIfCancellationRequested(); + return Guid.NewGuid(); + } + finally + { + semaphore.Release(); // handshake so we can check when available again + } + }, cancellationToken: cancels[i].Token).AsTask(); + } + + Assert.Equal(callerCount, cache.DebugGetCallerCount(Me())); + + // everyone is queued up; release the hounds and check + // that we all got the same result + for (var i = 0; i < callerCount; i++) + { + if (i != remaining) + { + cancels[i].Cancel(); + } + } + + await Task.Delay(500); // cancellation happens on a worker; need to allow a moment + for (var i = 0; i < callerCount; i++) + { + if (i != remaining) + { + var result = results[i]; + + // should have already cancelled, even though underlying task hasn't finished yet + Assert.Equal(TaskStatus.Canceled, result.Status); + var ex = Assert.Throws(() => result.GetAwaiter().GetResult()); + Assert.Equal(cancels[i].Token, ex.CancellationToken); // each gets the correct blame + } + } + + Assert.Equal(0, Volatile.Read(ref executeCount)); + semaphore.Release(); + + // wait for underlying task to hand back to us + if (!await semaphore.WaitAsync(5_000)) + { + throw new TimeoutException("Didn't get handshake back from task"); + } + + Assert.Equal(1, Volatile.Read(ref executeCount)); + Assert.Equal(0, Volatile.Read(ref cancelCount)); // ran to completion + await results[remaining]; + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Critical Code Smell", "S5034:\"ValueTask\" should be consumed correctly", Justification = "False positive, is only awaited once")] + public async Task ImmutableTypesShareFinalTask(bool withCancelation) + { + using CancellationTokenSource? cts = withCancelation ? new() : null; + var token = cts?.Token ?? CancellationToken.None; + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + // note AsTask *in this scenario* fetches the underlying incomplete task + var first = cache.GetOrCreateAsync(Me(), async ct => + { + await semaphore.WaitAsync(CancellationToken.None); + semaphore.Release(); + return Guid.NewGuid(); + }, cancellationToken: token).AsTask(); + + var second = cache.GetOrCreateAsync(Me(), async ct => + { + await semaphore.WaitAsync(CancellationToken.None); + semaphore.Release(); + return Guid.NewGuid(); + }, cancellationToken: token).AsTask(); + + if (withCancelation) + { + Assert.NotSame(first, second); + } + else + { + Assert.Same(first, second); + } + + semaphore.Release(); + Assert.Equal(await first, await second); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Critical Code Smell", "S5034:\"ValueTask\" should be consumed correctly", Justification = "False positive, is only awaited once")] + public async Task ImmutableCustomTypesShareFinalTask(bool withCancelation) + { + using var cts = withCancelation ? new CancellationTokenSource() : null; + var token = cts?.Token ?? CancellationToken.None; + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + // AsTask *in this scenario* fetches the underlying incomplete task + var first = cache.GetOrCreateAsync(Me(), async ct => + { + await semaphore.WaitAsync(CancellationToken.None); + semaphore.Release(); + return new Immutable(Guid.NewGuid()); + }, cancellationToken: token).AsTask(); + + var second = cache.GetOrCreateAsync(Me(), async ct => + { + await semaphore.WaitAsync(CancellationToken.None); + semaphore.Release(); + return new Immutable(Guid.NewGuid()); + }, cancellationToken: token).AsTask(); + + if (withCancelation) + { + Assert.NotSame(first, second); + } + else + { + Assert.Same(first, second); + } + + semaphore.Release(); + + var x = await first; + var y = await second; + Assert.Equal(x.Value, y.Value); + Assert.Same(x, y); // same instance regardless of whether the tasks were shared + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Critical Code Smell", "S5034:\"ValueTask\" should be consumed correctly", Justification = "False positive, is only awaited once")] + public async Task MutableTypesNeverShareFinalTask(bool withCancelation) + { + using CancellationTokenSource? cts = withCancelation ? new() : null; + var token = cts?.Token ?? CancellationToken.None; + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + // AsTask *in this scenario* fetches the underlying incomplete task + var first = cache.GetOrCreateAsync(Me(), async ct => + { + await semaphore.WaitAsync(CancellationToken.None); + semaphore.Release(); + return new Mutable(Guid.NewGuid()); + }, cancellationToken: token).AsTask(); + + var second = cache.GetOrCreateAsync(Me(), async ct => + { + await semaphore.WaitAsync(CancellationToken.None); + semaphore.Release(); + return new Mutable(Guid.NewGuid()); + }, cancellationToken: token).AsTask(); + + Assert.NotSame(first, second); + semaphore.Release(); + + var x = await first; + var y = await second; + Assert.Equal(x.Value, y.Value); + Assert.NotSame(x, y); + } + + [Fact] + public void ValidatePartitioning() + { + // we just want to validate that key-level partitioning is + // happening to some degree, i.e. it isn't fundamentally broken + using var scope = GetDefaultCache(out var cache); + Dictionary counts = []; + for (int i = 0; i < 1024; i++) + { + var key = new DefaultHybridCache.StampedeKey(Guid.NewGuid().ToString(), default); + var obj = cache.GetPartitionedSyncLock(in key); + if (!counts.TryGetValue(obj, out var count)) + { + count = 0; + } + + counts[obj] = count + 1; + } + + // We just want to prove that we got 8 non-empty partitions. + // This is *technically* non-deterministic, but: we'd + // need to be having a very bad day for the math gods + // to conspire against us that badly - if this test + // starts failing, maybe buy a lottery ticket? + Assert.Equal(8, counts.Count); + foreach (var pair in counts) + { + // the *median* should be 128 here; let's + // not be aggressive about it, though + Assert.True(pair.Value > 16); + } + } + + private class Mutable(Guid value) + { + public Guid Value => value; + } + + [ImmutableObject(true)] + public sealed class Immutable(Guid value) + { + public Guid Value => value; + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs new file mode 100644 index 00000000000..c2ab242a6b0 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/TypeTests.cs @@ -0,0 +1,87 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.ComponentModel; +using System.Reflection; +using Microsoft.Extensions.Caching.Hybrid.Internal; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class TypeTests +{ + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(int))] // primitive + [InlineData(typeof(int?))] + [InlineData(typeof(Guid))] // non-primitive but blittable + [InlineData(typeof(Guid?))] + [InlineData(typeof(SealedCustomClassAttribTrue))] // attrib says explicitly true, and sealed + [InlineData(typeof(CustomBlittableStruct))] // blittable, and we're copying each time + [InlineData(typeof(CustomNonBlittableStructAttribTrue))] // non-blittable, attrib says explicitly true + public void ImmutableTypes(Type type) + { + Assert.True((bool)typeof(ImmutableTypeCache<>).MakeGenericType(type) + .GetField(nameof(ImmutableTypeCache.IsImmutable), BindingFlags.Static | BindingFlags.Public)! + .GetValue(null)!); + } + + [Theory] + [InlineData(typeof(byte[]))] + [InlineData(typeof(string[]))] + [InlineData(typeof(object))] + [InlineData(typeof(CustomClassNoAttrib))] // no attrib, who knows? + [InlineData(typeof(CustomClassAttribFalse))] // attrib says explicitly no + [InlineData(typeof(CustomClassAttribTrue))] // attrib says explicitly true, but not sealed: we might have a sub-class + [InlineData(typeof(CustomNonBlittableStructNoAttrib))] // no attrib, who knows? + [InlineData(typeof(CustomNonBlittableStructAttribFalse))] // attrib says explicitly no + public void MutableTypes(Type type) + { + Assert.False((bool)typeof(ImmutableTypeCache<>).MakeGenericType(type) + .GetField(nameof(ImmutableTypeCache.IsImmutable), BindingFlags.Static | BindingFlags.Public)! + .GetValue(null)!); + } + + private class CustomClassNoAttrib + { + } + + [ImmutableObject(false)] + private class CustomClassAttribFalse + { + } + + [ImmutableObject(true)] + private class CustomClassAttribTrue + { + } + + [ImmutableObject(true)] + private sealed class SealedCustomClassAttribTrue + { + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Needed to be non-trivial blittable")] + private struct CustomBlittableStruct(int x) + { + public readonly int X => x; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Needed to force non-blittable")] + private struct CustomNonBlittableStructNoAttrib(string x) + { + public readonly string X => x; + } + + [ImmutableObject(false)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Needed to force non-blittable")] + private struct CustomNonBlittableStructAttribFalse(string x) + { + public readonly string X => x; + } + + [ImmutableObject(true)] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Major Code Smell", "S1144:Unused private types or members should be removed", Justification = "Needed to force non-blittable")] + private struct CustomNonBlittableStructAttribTrue(string x) + { + public readonly string X => x; + } +} diff --git a/test/Libraries/Microsoft.Extensions.Compliance.Redaction.Tests/RedactorProviderTests.cs b/test/Libraries/Microsoft.Extensions.Compliance.Redaction.Tests/RedactorProviderTests.cs index 0a7c97f34f1..9a69e83be50 100644 --- a/test/Libraries/Microsoft.Extensions.Compliance.Redaction.Tests/RedactorProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Compliance.Redaction.Tests/RedactorProviderTests.cs @@ -10,6 +10,16 @@ namespace Microsoft.Extensions.Compliance.Redaction.Test; public class RedactorProviderTests { + [Fact] + public void RedactorProvider_Returns_NullRedactor_For_NoneDataClassification() + { + var redactorProvider = new RedactorProvider( + redactors: [ErasingRedactor.Instance], + options: Microsoft.Extensions.Options.Options.Create(new RedactorProviderOptions())); + + Assert.IsType(redactorProvider.GetRedactor(DataClassification.None)); + } + [Fact] public void RedactorProvider_Returns_Redactor_For_Every_Data_Classification() { @@ -38,13 +48,15 @@ public void RedactorProvider_Returns_Redactor_For_Data_Classifications() redactors: new Redactor[] { ErasingRedactor.Instance, NullRedactor.Instance }, options: Microsoft.Extensions.Options.Options.Create(opt)); - var r1 = redactorProvider.GetRedactor(_dataClassification1); - var r2 = redactorProvider.GetRedactor(_dataClassification2); - var r3 = redactorProvider.GetRedactor(_dataClassification3); + Redactor r1 = redactorProvider.GetRedactor(_dataClassification1); + Redactor r2 = redactorProvider.GetRedactor(_dataClassification2); + Redactor r3 = redactorProvider.GetRedactor(_dataClassification3); + Redactor r4 = redactorProvider.GetRedactor(DataClassification.None); - Assert.Equal(typeof(ErasingRedactor), r1.GetType()); - Assert.Equal(typeof(NullRedactor), r2.GetType()); - Assert.Equal(typeof(ErasingRedactor), r3.GetType()); + Assert.IsType(r1); + Assert.IsType(r2); + Assert.IsType(r3); + Assert.IsType(r4); } [Fact] diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs index 60758bf7042..122c283ede8 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckExtensionsTests.cs @@ -16,10 +16,10 @@ namespace Microsoft.Extensions.Diagnostics.HealthChecks.Test; public class ResourceHealthCheckExtensionsTests { [Fact] - public async Task Extensions_AddResourceHealthCheck() + public async Task AddResourceHealthCheck() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(1); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); var serviceCollection = new ServiceCollection(); serviceCollection @@ -29,17 +29,57 @@ public async Task Extensions_AddResourceHealthCheck() .AddResourceUtilizationHealthCheck(options => options.SamplingWindow = samplingWindow); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithTags() + public async Task AddResourceHealthCheck_WithCustomResourceMonitorAddedAfterInternalResourceMonitor_OverridesIt() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(1); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); + + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(options => + options.SamplingWindow = samplingWindow).Services + .AddSingleton(dataTracker.Object); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); + _ = await service.CheckHealthAsync(); + dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); + } + + [Fact] + public void AddResourceHealthCheck_RegistersInternalResourceMonitoring() + { + var dataTracker = new Mock(); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); + + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddSingleton(dataTracker.Object) + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(options => + options.SamplingWindow = samplingWindow); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithTags() + { + var dataTracker = new Mock(); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); var serviceCollection = new ServiceCollection(); serviceCollection @@ -49,17 +89,52 @@ public async Task Extensions_AddResourceHealthCheck_WithTags() .AddResourceUtilizationHealthCheck(options => options.SamplingWindow = samplingWindow, "test"); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithTagsEnumerable() + public void AddResourceHealthCheck_WithTags_RegistersInternalResourceMonitoring() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck("test"); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithTags_WithCustomResourceMonitorAddedAfterInternalResourceMonitor_OverridesIt() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(1); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); + + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(options => + options.SamplingWindow = samplingWindow, "test").Services + .AddSingleton(dataTracker.Object); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); + _ = await service.CheckHealthAsync(); + dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); + } + + [Fact] + public async Task AddResourceHealthCheck_WithTagsEnumerable() + { + var dataTracker = new Mock(); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); var serviceCollection = new ServiceCollection(); serviceCollection @@ -69,17 +144,32 @@ public async Task Extensions_AddResourceHealthCheck_WithTagsEnumerable() .AddResourceUtilizationHealthCheck(options => options.SamplingWindow = samplingWindow, new List { "test" }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithAction() + public void AddResourceHealthCheck_WithTagsEnumerable_RegistersInternalResourceMonitoring() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(new List { "test" }); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithAction() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(1); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); var serviceCollection = new ServiceCollection(); serviceCollection @@ -92,17 +182,35 @@ public async Task Extensions_AddResourceHealthCheck_WithAction() o.SamplingWindow = samplingWindow; }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithActionAndTags() + public void AddResourceHealthCheck_WithAction_RegistersInternalResourceMonitoring() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(o => + { + o.CpuThresholds = new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }; + }); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithActionAndTags() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(1); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); var serviceCollection = new ServiceCollection(); serviceCollection @@ -116,17 +224,36 @@ public async Task Extensions_AddResourceHealthCheck_WithActionAndTags() }, "test"); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithActionAndTagsEnumerable() + public void AddResourceHealthCheck_WithActionAndTags_RegistersInternalResourceMonitoring() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(o => + { + o.CpuThresholds = new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }; + }, + "test"); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithActionAndTagsEnumerable() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(1); + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); var serviceCollection = new ServiceCollection(); serviceCollection @@ -140,18 +267,37 @@ public async Task Extensions_AddResourceHealthCheck_WithActionAndTagsEnumerable( }, new List { "test" }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithConfigurationSection() + public void AddResourceHealthCheck_WithActionAndTagsEnumerable_RegistersInternalResourceMonitoring() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(o => + { + o.CpuThresholds = new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }; + }, + new List { "test" }); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithConfigurationSection() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(5); + TimeSpan samplingWindow = TimeSpan.FromSeconds(5); var serviceCollection = new ServiceCollection(); serviceCollection .AddLogging() @@ -159,18 +305,33 @@ public async Task Extensions_AddResourceHealthCheck_WithConfigurationSection() .AddHealthChecks() .AddResourceUtilizationHealthCheck(SetupResourceHealthCheckConfiguration("0.5", "0.7", "0.5", "0.7", "00:00:05").GetSection(nameof(ResourceUtilizationHealthCheckOptions))); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithConfigurationSectionAndTags() + public void AddResourceHealthCheck_WithConfigurationSection_RegistersInternalResourceMonitoring() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(SetupResourceHealthCheckConfiguration("0.5", "0.7", "0.5", "0.7", "00:00:05").GetSection(nameof(ResourceUtilizationHealthCheckOptions))); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithConfigurationSectionAndTags() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(5); + TimeSpan samplingWindow = TimeSpan.FromSeconds(5); var serviceCollection = new ServiceCollection(); serviceCollection .AddLogging() @@ -180,18 +341,34 @@ public async Task Extensions_AddResourceHealthCheck_WithConfigurationSectionAndT SetupResourceHealthCheckConfiguration("0.5", "0.7", "0.5", "0.7", "00:00:05").GetSection(nameof(ResourceUtilizationHealthCheckOptions)), "test"); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public async Task Extensions_AddResourceHealthCheck_WithConfigurationSectionAndTagsEnumerable() + public void AddResourceHealthCheck_WithConfigurationSectionAndTags_RegistersInternalResourceMonitoring() + { + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck(SetupResourceHealthCheckConfiguration("0.5", "0.7", "0.5", "0.7", "00:00:05").GetSection(nameof(ResourceUtilizationHealthCheckOptions)), + "test"); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public async Task AddResourceHealthCheck_WithConfigurationSectionAndTagsEnumerable() { var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(5); + TimeSpan samplingWindow = TimeSpan.FromSeconds(5); var serviceCollection = new ServiceCollection(); serviceCollection .AddLogging() @@ -201,16 +378,33 @@ public async Task Extensions_AddResourceHealthCheck_WithConfigurationSectionAndT SetupResourceHealthCheckConfiguration("0.5", "0.7", "0.5", "0.7", "00:00:05").GetSection(nameof(ResourceUtilizationHealthCheckOptions)), new List { "test" }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var service = serviceProvider.GetRequiredService(); + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + HealthCheckService service = serviceProvider.GetRequiredService(); _ = await service.CheckHealthAsync(); dataTracker.Verify(tracker => tracker.GetUtilization(samplingWindow), Times.Once); } [Fact] - public void Extensions_ConfigureResourceUtilizationHealthCheck_WithAction() + public void AddResourceHealthCheck_WithConfigurationSectionAndTagsEnumerable_RegistersInternalResourceMonitoring() { - var samplingWindow = TimeSpan.FromSeconds(1); + var serviceCollection = new ServiceCollection(); + serviceCollection + .AddLogging() + .AddHealthChecks() + .AddResourceUtilizationHealthCheck( + SetupResourceHealthCheckConfiguration("0.5", "0.7", "0.5", "0.7", "00:00:05").GetSection(nameof(ResourceUtilizationHealthCheckOptions)), + new List { "test" }); + + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + + IResourceMonitor? resourceMonitor = serviceProvider.GetService(); + Assert.NotNull(resourceMonitor); + } + + [Fact] + public void ConfigureResourceUtilizationHealthCheck_WithAction() + { + TimeSpan samplingWindow = TimeSpan.FromSeconds(1); var serviceCollection = new ServiceCollection(); serviceCollection @@ -221,8 +415,8 @@ public void Extensions_ConfigureResourceUtilizationHealthCheck_WithAction() o.SamplingWindow = samplingWindow; }); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var options = serviceProvider.GetRequiredService>().Value; + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + ResourceUtilizationHealthCheckOptions options = serviceProvider.GetRequiredService>().Value; Assert.Equal(samplingWindow, options.SamplingWindow); Assert.Equal(0.2, options.CpuThresholds.DegradedUtilizationPercentage); @@ -230,17 +424,17 @@ public void Extensions_ConfigureResourceUtilizationHealthCheck_WithAction() } [Fact] - public void Extensions_ConfigureResourceUtilizationHealthCheck_WithConfigurationSection() + public void ConfigureResourceUtilizationHealthCheck_WithConfigurationSection() { - var samplingWindow = TimeSpan.FromSeconds(5); + TimeSpan samplingWindow = TimeSpan.FromSeconds(5); var serviceCollection = new ServiceCollection(); serviceCollection .AddHealthChecks() .AddResourceUtilizationHealthCheck(SetupResourceHealthCheckConfiguration("0.5", "0.7", "0.5", "0.7", "00:00:05").GetSection(nameof(ResourceUtilizationHealthCheckOptions))); - var serviceProvider = serviceCollection.BuildServiceProvider(); - var options = serviceProvider.GetRequiredService>().Value; + ServiceProvider serviceProvider = serviceCollection.BuildServiceProvider(); + ResourceUtilizationHealthCheckOptions options = serviceProvider.GetRequiredService>().Value; Assert.Equal(samplingWindow, options.SamplingWindow); Assert.Equal(0.5, options.CpuThresholds.DegradedUtilizationPercentage); @@ -268,31 +462,31 @@ private static IConfiguration SetupResourceHealthCheckConfiguration( ResourceUtilizationHealthCheckOptions resourceHealthCheckOptions; var configurationDict = new Dictionary + { + { + $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.CpuThresholds)}:" + + $"{nameof(resourceHealthCheckOptions.CpuThresholds.DegradedUtilizationPercentage)}", + cpuDegradedThreshold + }, + { + $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.CpuThresholds)}:" + + $"{nameof(resourceHealthCheckOptions.CpuThresholds.UnhealthyUtilizationPercentage)}", + cpuUnhealthyThreshold + }, + { + $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.MemoryThresholds)}:" + + $"{nameof(resourceHealthCheckOptions.MemoryThresholds.DegradedUtilizationPercentage)}", + memoryDegradedThreshold + }, + { + $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.MemoryThresholds)}:" + +$"{nameof(resourceHealthCheckOptions.MemoryThresholds.UnhealthyUtilizationPercentage)}", + memoryUnhealthyThreshold + }, { - { - $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.CpuThresholds)}:" - + $"{nameof(resourceHealthCheckOptions.CpuThresholds.DegradedUtilizationPercentage)}", - cpuDegradedThreshold - }, - { - $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.CpuThresholds)}:" - + $"{nameof(resourceHealthCheckOptions.CpuThresholds.UnhealthyUtilizationPercentage)}", - cpuUnhealthyThreshold - }, - { - $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.MemoryThresholds)}:" - + $"{nameof(resourceHealthCheckOptions.MemoryThresholds.DegradedUtilizationPercentage)}", - memoryDegradedThreshold - }, - { - $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.MemoryThresholds)}:" - +$"{nameof(resourceHealthCheckOptions.MemoryThresholds.UnhealthyUtilizationPercentage)}", - memoryUnhealthyThreshold - }, - { - $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.SamplingWindow)}", samplingWindow - } - }; + $"{nameof(ResourceUtilizationHealthCheckOptions)}:{nameof(resourceHealthCheckOptions.SamplingWindow)}", samplingWindow + } + }; return new ConfigurationBuilder().AddInMemoryCollection(configurationDict).Build(); } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckTests.cs index d6129017d79..77a145c218a 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.HealthChecks.ResourceUtilization.Tests/ResourceHealthCheckTests.cs @@ -23,6 +23,7 @@ public class ResourceHealthCheckTests 0UL, 1000UL, new ResourceUsageThresholds(), + new ResourceUsageThresholds(), "", }, new object[] @@ -32,6 +33,7 @@ public class ResourceHealthCheckTests 0UL, 1000UL, new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.2 }, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.2 }, "" }, new object[] @@ -41,6 +43,7 @@ public class ResourceHealthCheckTests 2UL, 1000UL, new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.2 }, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.2 }, "" }, new object[] @@ -50,7 +53,8 @@ public class ResourceHealthCheckTests 3UL, 1000UL, new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, - " usage is close to the limit" + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, + "CPU and memory usage is close to the limit" }, new object[] { @@ -59,7 +63,8 @@ public class ResourceHealthCheckTests 5UL, 1000UL, new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, - " usage is above the limit" + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, + "CPU and memory usage is above the limit" }, new object[] { @@ -68,7 +73,8 @@ public class ResourceHealthCheckTests 5UL, 1000UL, new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.4, UnhealthyUtilizationPercentage = 0.2 }, - " usage is above the limit" + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.4, UnhealthyUtilizationPercentage = 0.2 }, + "CPU and memory usage is above the limit" }, new object[] { @@ -77,7 +83,8 @@ public class ResourceHealthCheckTests 3UL, 1000UL, new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2 }, - " usage is close to the limit" + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2 }, + "CPU and memory usage is close to the limit" }, new object[] { @@ -86,67 +93,79 @@ public class ResourceHealthCheckTests 5UL, 1000UL, new ResourceUsageThresholds { UnhealthyUtilizationPercentage = 0.4 }, - " usage is above the limit" + new ResourceUsageThresholds { UnhealthyUtilizationPercentage = 0.4 }, + "CPU and memory usage is above the limit" + }, + new object[] + { + HealthStatus.Degraded, + 0.3, + 3UL, + 1000UL, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.9, UnhealthyUtilizationPercentage = 0.9 }, + "CPU usage is close to the limit" + }, + new object[] + { + HealthStatus.Degraded, + 0.1, + 3UL, + 1000UL, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.9, UnhealthyUtilizationPercentage = 0.9 }, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, + "Memory usage is close to the limit" + }, + new object[] + { + HealthStatus.Unhealthy, + 0.5, + 5UL, + 1000UL, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.9, UnhealthyUtilizationPercentage = 0.9 }, + "CPU usage is above the limit" + }, + new object[] + { + HealthStatus.Unhealthy, + 0.1, + 5UL, + 1000UL, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.9, UnhealthyUtilizationPercentage = 0.9 }, + new ResourceUsageThresholds { DegradedUtilizationPercentage = 0.2, UnhealthyUtilizationPercentage = 0.4 }, + "Memory usage is above the limit" }, }; [Theory] [MemberData(nameof(Data))] -#pragma warning disable xUnit1026 // Theory methods should use all of their parameters - public async Task TestCpuChecks(HealthStatus expected, double utilization, ulong _, ulong totalMemory, ResourceUsageThresholds thresholds, string expectedDescription) -#pragma warning restore xUnit1026 // Theory methods should use all of their parameters - { - var systemResources = new SystemResources(1.0, 1.0, totalMemory, totalMemory); - var dataTracker = new Mock(); - var samplingWindow = TimeSpan.FromSeconds(1); - dataTracker - .Setup(tracker => tracker.GetUtilization(samplingWindow)) - .Returns(new ResourceUtilization(cpuUsedPercentage: utilization, memoryUsedInBytes: 0, systemResources)); - - var checkContext = new HealthCheckContext(); - var cpuCheckOptions = new ResourceUtilizationHealthCheckOptions - { - CpuThresholds = thresholds, - SamplingWindow = samplingWindow - }; - - var options = Microsoft.Extensions.Options.Options.Create(cpuCheckOptions); - var healthCheck = new ResourceUtilizationHealthCheck(options, dataTracker.Object); - var healthCheckResult = await healthCheck.CheckHealthAsync(checkContext); - Assert.Equal(expected, healthCheckResult.Status); - if (healthCheckResult.Status != HealthStatus.Healthy) - { - Assert.Equal("CPU" + expectedDescription, healthCheckResult.Description); - } - } - - [Theory] - [MemberData(nameof(Data))] -#pragma warning disable xUnit1026 // Theory methods should use all of their parameters - public async Task TestMemoryChecks(HealthStatus expected, double _, ulong memoryUsed, ulong totalMemory, ResourceUsageThresholds thresholds, string expectedDescription) -#pragma warning restore xUnit1026 // Theory methods should use all of their parameters + public async Task TestCpuAndMemoryChecks(HealthStatus expected, double utilization, ulong memoryUsed, ulong totalMemory, + ResourceUsageThresholds cpuThresholds, ResourceUsageThresholds memoryThresholds, string expectedDescription) { var systemResources = new SystemResources(1.0, 1.0, totalMemory, totalMemory); var dataTracker = new Mock(); var samplingWindow = TimeSpan.FromSeconds(1); dataTracker .Setup(tracker => tracker.GetUtilization(samplingWindow)) - .Returns(new ResourceUtilization(cpuUsedPercentage: 0, memoryUsedInBytes: memoryUsed, systemResources)); + .Returns(new ResourceUtilization(cpuUsedPercentage: utilization, memoryUsedInBytes: memoryUsed, systemResources)); var checkContext = new HealthCheckContext(); - var memCheckOptions = new ResourceUtilizationHealthCheckOptions + var checkOptions = new ResourceUtilizationHealthCheckOptions { - MemoryThresholds = thresholds, + CpuThresholds = cpuThresholds, + MemoryThresholds = memoryThresholds, SamplingWindow = samplingWindow }; - var options = Microsoft.Extensions.Options.Options.Create(memCheckOptions); + var options = Microsoft.Extensions.Options.Options.Create(checkOptions); var healthCheck = new ResourceUtilizationHealthCheck(options, dataTracker.Object); var healthCheckResult = await healthCheck.CheckHealthAsync(checkContext); Assert.Equal(expected, healthCheckResult.Status); + Assert.NotEmpty(healthCheckResult.Data); if (healthCheckResult.Status != HealthStatus.Healthy) { - Assert.Equal("Memory" + expectedDescription, healthCheckResult.Description); + Assert.Equal(expectedDescription, healthCheckResult.Description); } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Helpers/TestMeterFactory.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Helpers/TestMeterFactory.cs new file mode 100644 index 00000000000..42ac54926c4 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Helpers/TestMeterFactory.cs @@ -0,0 +1,41 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.Metrics; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; + +internal class TestMeterFactory : IMeterFactory +{ + public List Meters { get; } = new List(); + + public Meter Create(MeterOptions options) + { + var meter = new Meter(options.Name, options.Version, Array.Empty>(), scope: this); + Meters.Add(meter); + + return meter; + } + + public Meter Create(string name) + { + return Create(new MeterOptions(name) + { + Version = null, + Tags = null, + Scope = null + }); + } + + public void Dispose() + { + foreach (var meter in Meters) + { + meter.Dispose(); + } + + Meters.Clear(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs index 44297768a70..8a9b10ff460 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/LinuxUtilizationProviderTests.cs @@ -7,6 +7,7 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; using Microsoft.Extensions.Logging.Testing; using Microsoft.TestUtilities; using Moq; @@ -191,4 +192,17 @@ public Task Provider_EmitsLogRecord() return Verifier.Verify(logRecords).UseDirectory(VerifiedDataDirectory); } + + [Fact] + public void Provider_Creates_Meter_With_Correct_Name() + { + var options = Options.Options.Create(new()); + using var meterFactory = new TestMeterFactory(); + + var parser = new DummyLinuxUtilizationParser(); + _ = new LinuxUtilizationProvider(options, parser, meterFactory); + + var meter = meterFactory.Meters.Single(); + Assert.Equal(ResourceUtilizationInstruments.MeterName, meter.Name); + } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Resources/DummyLinuxUtilizationParser.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Resources/DummyLinuxUtilizationParser.cs new file mode 100644 index 00000000000..b06b8134a39 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Linux/Resources/DummyLinuxUtilizationParser.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux; + +namespace Microsoft.Extensions.Diagnostics.ResourceMonitoring.Linux.Test; + +internal class DummyLinuxUtilizationParser : ILinuxUtilizationParser +{ + public ulong GetAvailableMemoryInBytes() => 1; + public long GetCgroupCpuUsageInNanoseconds() => 0; + public float GetCgroupLimitedCpus() => 1; + public float GetCgroupRequestCpu() => 1; + public ulong GetHostAvailableMemory() => 0; + public float GetHostCpuCount() => 1; + public long GetHostCpuUsageInNanoseconds() => 0; + public ulong GetMemoryUsageInBytes() => 0; +} \ No newline at end of file diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs index 3310f70d798..c013c77d7b4 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsContainerSnapshotProviderTests.cs @@ -3,8 +3,10 @@ using System; using System.Diagnostics.Metrics; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Time.Testing; @@ -322,4 +324,24 @@ public Task SnapshotProvider_EmitsLogRecord() return Verifier.Verify(logRecords).UniqueForRuntime().UseDirectory(VerifiedDataDirectory); } + + [Fact] + public void Provider_Creates_Meter_With_Correct_Name() + { + var options = Options.Options.Create(new()); + using var meterFactory = new TestMeterFactory(); + + _ = new WindowsContainerSnapshotProvider( + _memoryInfoMock.Object, + _systemInfoMock.Object, + _processInfoMock.Object, + _logger, + meterFactory, + () => _jobHandleMock.Object, + new FakeTimeProvider(), + new()); + + var meter = meterFactory.Meters.Single(); + Assert.Equal(ResourceUtilizationInstruments.MeterName, meter.Name); + } } diff --git a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsSnapshotProviderTests.cs b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsSnapshotProviderTests.cs index aa53a431b06..6a76d8a95f4 100644 --- a/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsSnapshotProviderTests.cs +++ b/test/Libraries/Microsoft.Extensions.Diagnostics.ResourceMonitoring.Tests/Windows/WindowsSnapshotProviderTests.cs @@ -3,8 +3,10 @@ using System; using System.Diagnostics.Metrics; +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.Diagnostics.Metrics.Testing; +using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Test.Helpers; using Microsoft.Extensions.Diagnostics.ResourceMonitoring.Windows.Interop; using Microsoft.Extensions.Logging.Testing; using Microsoft.Extensions.Options; @@ -155,4 +157,15 @@ public void Provider_Returns_MemoryConsumption() var usage = WindowsSnapshotProvider.GetMemoryUsageInBytes(); Assert.InRange(usage, 0, long.MaxValue); } + + [ConditionalFact] + public void Provider_Creates_Meter_With_Correct_Name() + { + using var meterFactory = new TestMeterFactory(); + + _ = new WindowsSnapshotProvider(_fakeLogger, meterFactory, _options); + + var meter = meterFactory.Meters.Single(); + Assert.Equal(ResourceUtilizationInstruments.MeterName, meter.Name); + } } diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs index 237ccfa6e73..7db71a08873 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Hedging/StandardHedgingTests.cs @@ -265,6 +265,14 @@ public async Task DynamicReloads_Ok(bool asynchronous = true) AssertNoResponse(); } + [Fact] + public void AddStandardResilienceHandler_EnsureHttpClientTimeoutDisabled() + { + var client = CreateClientWithHandler(); + + client.Timeout.Should().Be(Timeout.InfiniteTimeSpan); + } + [Theory] #if NET6_0_OR_GREATER [CombinatorialData] diff --git a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpClientBuilderExtensionsTests.Standard.cs b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpClientBuilderExtensionsTests.Standard.cs index 79ce7aa654d..faecd6e317d 100644 --- a/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpClientBuilderExtensionsTests.Standard.cs +++ b/test/Libraries/Microsoft.Extensions.Http.Resilience.Tests/Resilience/HttpClientBuilderExtensionsTests.Standard.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Net; using System.Net.Http; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -257,6 +258,16 @@ public async Task DynamicReloads_Ok(bool asynchronous = true) requests.Should().HaveCount(11); } + [Fact] + public void AddStandardResilienceHandler_EnsureHttpClientTimeoutDisabled() + { + var builder = new ServiceCollection().AddLogging().AddMetrics().AddHttpClient("test").AddStandardResilienceHandler(); + + using var client = builder.Services.BuildServiceProvider().GetRequiredService().CreateClient("test"); + + client.Timeout.Should().Be(Timeout.InfiniteTimeSpan); + } + private static void AddStandardResilienceHandler( MethodArgs mode, IHttpClientBuilder builder,