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