From 35e9ceaeef68a85296d8b3669cea0fc1d5e09385 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 10 Apr 2024 19:21:32 +0100 Subject: [PATCH 01/75] initial API cut (post review) --- AspNetCore.sln | 41 ++++++ eng/ProjectReferences.props | 1 + eng/SharedFramework.Local.props | 1 + eng/ShippingAssemblies.props | 1 + eng/TrimmableProjects.props | 1 + src/Caching/Caching.slnf | 2 + .../src/HybridCacheBuilderExtensions.cs | 64 +++++++++ src/Caching/Hybrid/src/HybridCacheOptions.cs | 45 +++++++ .../src/HybridCacheServiceExtensions.cs | 64 +++++++++ src/Caching/Hybrid/src/IHybridCacheBuilder.cs | 27 ++++ .../Hybrid/src/Internal/DefaultHybridCache.cs | 23 ++++ .../Internal/DefaultJsonSerializerFactory.cs | 37 ++++++ .../src/Internal/InbuiltTypeSerializer.cs | 54 ++++++++ ...Microsoft.Extensions.Caching.Hybrid.csproj | 26 ++++ src/Caching/Hybrid/src/PublicAPI.Shipped.txt | 1 + .../Hybrid/src/PublicAPI.Unshipped.txt | 60 +++++++++ src/Caching/Hybrid/src/Runtime/HybridCache.cs | 125 ++++++++++++++++++ .../src/Runtime/HybridCacheEntryFlags.cs | 50 +++++++ .../src/Runtime/HybridCacheEntryOptions.cs | 32 +++++ .../src/Runtime/IBufferDistributedCache.cs | 50 +++++++ .../src/Runtime/IHybridCacheSerializer.cs | 24 ++++ .../Runtime/IHybridCacheSerializerFactory.cs | 20 +++ .../Hybrid/src/Runtime/IsExternalInit.cs | 11 ++ src/Caching/Hybrid/src/Runtime/readme.md | 2 + ...oft.Extensions.Caching.Hybrid.Tests.csproj | 13 ++ 25 files changed, 775 insertions(+) create mode 100644 src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs create mode 100644 src/Caching/Hybrid/src/HybridCacheOptions.cs create mode 100644 src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs create mode 100644 src/Caching/Hybrid/src/IHybridCacheBuilder.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs create mode 100644 src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs create mode 100644 src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj create mode 100644 src/Caching/Hybrid/src/PublicAPI.Shipped.txt create mode 100644 src/Caching/Hybrid/src/PublicAPI.Unshipped.txt create mode 100644 src/Caching/Hybrid/src/Runtime/HybridCache.cs create mode 100644 src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs create mode 100644 src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs create mode 100644 src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs create mode 100644 src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs create mode 100644 src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs create mode 100644 src/Caching/Hybrid/src/Runtime/IsExternalInit.cs create mode 100644 src/Caching/Hybrid/src/Runtime/readme.md create mode 100644 src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj diff --git a/AspNetCore.sln b/AspNetCore.sln index 68269bb213ca..8547083d378e 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1788,6 +1788,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NotReferencedInWasmCodePack EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Components.WasmRemoteAuthentication", "src\Components\test\testassets\Components.WasmRemoteAuthentication\Components.WasmRemoteAuthentication.csproj", "{8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hybrid", "Hybrid", "{2D64CA23-6E81-488E-A7D3-9BDF87240098}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid", "src\Caching\Hybrid\src\Microsoft.Extensions.Caching.Hybrid.csproj", "{2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid.Tests", "src\Caching\Hybrid\test\Microsoft.Extensions.Caching.Hybrid.Tests.csproj", "{CF63C942-895A-4F6B-888A-7653D7C4991A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10789,6 +10795,38 @@ Global {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x64.Build.0 = Release|Any CPU {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.ActiveCfg = Release|Any CPU {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13}.Release|x86.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|arm64.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|arm64.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x64.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x64.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x86.ActiveCfg = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Debug|x86.Build.0 = Debug|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|Any CPU.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|arm64.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|arm64.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x64.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x64.Build.0 = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x86.ActiveCfg = Release|Any CPU + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9}.Release|x86.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|arm64.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|arm64.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x64.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x64.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x86.ActiveCfg = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Debug|x86.Build.0 = Debug|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|Any CPU.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|arm64.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|arm64.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x64.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x64.Build.0 = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.ActiveCfg = Release|Any CPU + {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11672,6 +11710,9 @@ Global {15D08EA7-8C63-45FB-8B4D-C5F8E43B433E} = {05A169C7-4F20-4516-B10A-B13C5649D346} {433F91E4-E39D-4EB0-B798-2998B3969A2C} = {6126DCE4-9692-4EE2-B240-C65743572995} {8A021D6D-7935-4AB3-BB47-38D4FF9B0D13} = {6126DCE4-9692-4EE2-B240-C65743572995} + {2D64CA23-6E81-488E-A7D3-9BDF87240098} = {0F39820F-F4A5-41C6-9809-D79B68F032EF} + {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} + {CF63C942-895A-4F6B-888A-7653D7C4991A} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 7686ce1e869c..caac54022a4d 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -5,6 +5,7 @@ --> + diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index 46be57d9577b..ea4d7df14844 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -8,6 +8,7 @@ + diff --git a/eng/ShippingAssemblies.props b/eng/ShippingAssemblies.props index bd06923b2454..9a84ba0198ba 100644 --- a/eng/ShippingAssemblies.props +++ b/eng/ShippingAssemblies.props @@ -5,6 +5,7 @@ --> + diff --git a/eng/TrimmableProjects.props b/eng/TrimmableProjects.props index c61ab1b2e4ac..e4ec572527d4 100644 --- a/eng/TrimmableProjects.props +++ b/eng/TrimmableProjects.props @@ -7,6 +7,7 @@ --> + diff --git a/src/Caching/Caching.slnf b/src/Caching/Caching.slnf index dcecdb8a91c7..63610b8e28d5 100644 --- a/src/Caching/Caching.slnf +++ b/src/Caching/Caching.slnf @@ -2,6 +2,8 @@ "solution": { "path": "..\\..\\AspNetCore.sln", "projects": [ + "src\\Caching\\Hybrid\\src\\Microsoft.Extensions.Caching.Hybrid.csproj", + "src\\Caching\\Hybrid\\test\\Microsoft.Extensions.Caching.Hybrid.Tests.csproj", "src\\Caching\\SqlServer\\src\\Microsoft.Extensions.Caching.SqlServer.csproj", "src\\Caching\\SqlServer\\test\\Microsoft.Extensions.Caching.SqlServer.Tests.csproj", "src\\Caching\\StackExchangeRedis\\src\\Microsoft.Extensions.Caching.StackExchangeRedis.csproj", diff --git a/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs b/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs new file mode 100644 index 000000000000..3ce69ead2c35 --- /dev/null +++ b/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Configuration extension methods for / +/// +public static class HybridCacheBuilderExtensions +{ + /// + /// Serialize values of type with the specified serializer from + /// + public static IHybridCacheBuilder WithSerializer(this IHybridCacheBuilder builder, IHybridCacheSerializer serializer) + { + builder.Services.AddSingleton>(serializer); + return builder; + } + + /// + /// Serialize values of type with the serializer of type + /// + public static IHybridCacheBuilder WithSerializer(this IHybridCacheBuilder builder) + where TImplementation : class, IHybridCacheSerializer + { + builder.Services.AddSingleton, TImplementation>(); + return builder; + } + + /// + /// Add as an additional serializer factory, which can provide serializers for multiple types + /// + public static IHybridCacheBuilder WithSerializerFactory(this IHybridCacheBuilder builder, IHybridCacheSerializerFactory factory) + { + builder.Services.AddSingleton(factory); + return builder; + } + + /// + /// Add a factory of type as an additional serializer factory, which can provide serializers for multiple types + /// + public static IHybridCacheBuilder WithSerializerFactory< +#if NET5_0_OR_GREATER + [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)] +#endif + TImplementation>(this IHybridCacheBuilder builder) + where TImplementation : class, IHybridCacheSerializerFactory + { + builder.Services.AddSingleton(); + return builder; + } +} diff --git a/src/Caching/Hybrid/src/HybridCacheOptions.cs b/src/Caching/Hybrid/src/HybridCacheOptions.cs new file mode 100644 index 000000000000..daca158a4504 --- /dev/null +++ b/src/Caching/Hybrid/src/HybridCacheOptions.cs @@ -0,0 +1,45 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Options for configuring the default implementation +/// +public class HybridCacheOptions +{ + /// + /// Default global options to be applied to operations; if options are + /// specified at the individual call level, the non-null values are merged (with the per-call + /// options being used in preference to the global options). If no value is specified for a given + /// option (globally or per-call), the implementation may choose a reasonable default. + /// + public HybridCacheEntryOptions? DefaultOptions { get; set; } + + /// + /// Disallow compression for this instance + /// + public bool DisableCompression { get; set; } + + /// + /// The maximum size of cache items; attempts to store values over this size will be logged. + /// + public long MaximumPayloadBytes { get; set; } = 1 << 20; // 1MiB + + /// + /// The maximum permitted length (in characters) of keys; attempts to use keys over this size will be logged. + /// + public int MaximumKeyLength { get; set; } = 1024; // characters + + /// + /// Use "tags" data as dimensions on metric reporting; if enabled, care should be used to ensure that + /// tags do not contain data that should not be visible in metrics systems. + /// + public bool ReportTagMetrics { get; set; } +} diff --git a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs new file mode 100644 index 000000000000..26bf37fc1495 --- /dev/null +++ b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs @@ -0,0 +1,64 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Internal; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Configuration extension methods for +/// +public static class HybridCacheServiceExtensions +{ + /// + /// Adds support for multi-tier caching services + /// + /// A builder instance that allows further configuration of the system + public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services, Action setupAction) + { +#if NET7_0_OR_GREATER + ArgumentNullException.ThrowIfNull(setupAction); +#else + _ = setupAction ?? throw new ArgumentNullException(nameof(setupAction)); +#endif + AddHybridCache(services); + services.Configure(setupAction); + return new HybridCacheBuilder(services); + } + + /// + /// Adds support for multi-tier caching services + /// + /// A builder instance that allows further configuration of the system + public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services) + { +#if NET7_0_OR_GREATER + ArgumentNullException.ThrowIfNull(services); +#else + _ = services ?? throw new ArgumentNullException(nameof(services)); +#endif + +#if NET8_0_OR_GREATER + services.TryAddSingleton(TimeProvider.System); +#else + services.TryAddSingleton(); +#endif + services.AddOptions(); + services.AddMemoryCache(); + services.AddDistributedMemoryCache(); // we need a backend; use in-proc by default + services.AddSingleton(); + services.AddSingleton>(InbuiltTypeSerializer.Instance); + services.AddSingleton>(InbuiltTypeSerializer.Instance); + services.AddSingleton(); + return new HybridCacheBuilder(services); + } +} diff --git a/src/Caching/Hybrid/src/IHybridCacheBuilder.cs b/src/Caching/Hybrid/src/IHybridCacheBuilder.cs new file mode 100644 index 000000000000..fae49c030fc3 --- /dev/null +++ b/src/Caching/Hybrid/src/IHybridCacheBuilder.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Helper API for configuring . +/// +public interface IHybridCacheBuilder +{ + /// + /// Gets the services collection associated with this instance. + /// + IServiceCollection Services { get; } +} + +internal sealed class HybridCacheBuilder(IServiceCollection services) : IHybridCacheBuilder +{ + public IServiceCollection Services { get; } = services; +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs new file mode 100644 index 000000000000..dfad1bac3cd8 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -0,0 +1,23 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; +internal sealed class DefaultHybridCache : HybridCache +{ + public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) + => underlyingDataCallback(state, token); // pass-thru without caching for initial API pass + + public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) + => default; // no cache, nothing to remove + + public override ValueTask RemoveTagAsync(string tag, CancellationToken token = default) + => default; // no cache, nothing to remove + + public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) + => default; // no cache, nothing to set +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs b/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs new file mode 100644 index 000000000000..dbe94684d185 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs @@ -0,0 +1,37 @@ +// 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.Diagnostics.CodeAnalysis; +using System.Text.Json; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; +internal sealed class DefaultJsonSerializerFactory : IHybridCacheSerializerFactory +{ + public bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer) + { + // no restriction + serializer = new DefaultJsonSerializer(); + return true; + } + + internal sealed class DefaultJsonSerializer : IHybridCacheSerializer + { + T IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + { + var reader = new Utf8JsonReader(source); +#pragma warning disable IL2026, IL3050 // AOT bits + return JsonSerializer.Deserialize(ref reader)!; +#pragma warning restore IL2026, IL3050 + } + + void IHybridCacheSerializer.Serialize(T value, IBufferWriter target) + { + using var writer = new Utf8JsonWriter(target); +#pragma warning disable IL2026, IL3050 // AOT bits + JsonSerializer.Serialize(writer, value, JsonSerializerOptions.Default); +#pragma warning restore IL2026, IL3050 + } + } + +} diff --git a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs new file mode 100644 index 000000000000..6160a2e2ae55 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs @@ -0,0 +1,54 @@ +// 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.Buffers; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; +internal sealed class InbuiltTypeSerializer : IHybridCacheSerializer, IHybridCacheSerializer +{ + private static InbuiltTypeSerializer? _instance; + public static InbuiltTypeSerializer Instance => _instance ??= new(); + string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + { +#if NET5_0_OR_GREATER + return Encoding.UTF8.GetString(source); +#else + if (source.IsSingleSegment && MemoryMarshal.TryGetArray(source.First, out var segment)) + { + // we can use the existing single chunk as-is + return Encoding.UTF8.GetString(segment.Array, segment.Offset, segment.Count); + } + + var length = checked((int)source.Length); + var oversized = ArrayPool.Shared.Rent(length); + source.CopyTo(oversized); + var s = Encoding.UTF8.GetString(oversized, 0, length); + ArrayPool.Shared.Return(oversized); + return s; +#endif + } + + void IHybridCacheSerializer.Serialize(string value, IBufferWriter target) + { +#if NET5_0_OR_GREATER + Encoding.UTF8.GetBytes(value, target); +#else + var length = Encoding.UTF8.GetByteCount(value); + var oversized = ArrayPool.Shared.Rent(length); + var actual = Encoding.UTF8.GetBytes(value, 0, value.Length, oversized, 0); + Debug.Assert(actual == length); + target.Write(new(oversized, 0, length)); +#endif + } + + byte[] IHybridCacheSerializer.Deserialize(ReadOnlySequence source) + => source.ToArray(); + + void IHybridCacheSerializer.Serialize(byte[] value, IBufferWriter target) + => target.Write(value); +} diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj new file mode 100644 index 000000000000..54f94640dd73 --- /dev/null +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -0,0 +1,26 @@ + + + + Multi-level caching implementation building on and extending IDistributedCache + $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework);netstandard2.0 + true + cache;distributedcache;hybrid + true + true + true + true + true + + + + + + + + + + + + + + diff --git a/src/Caching/Hybrid/src/PublicAPI.Shipped.txt b/src/Caching/Hybrid/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..ab058de62d44 --- /dev/null +++ b/src/Caching/Hybrid/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..b7195e28c677 --- /dev/null +++ b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt @@ -0,0 +1,60 @@ +#nullable enable +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, TState state, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.ICollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeyAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagAsync(string! tag, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.SetAsync(string! key, T value, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.ICollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.Set(string! key, System.Buffers.ReadOnlySequence value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options) -> void +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.SetAsync(string! key, System.Buffers.ReadOnlySequence value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGet(string! key, System.Buffers.IBufferWriter! destination) -> bool +Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGetAsync(string! key, System.Buffers.IBufferWriter! destination, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Hybrid.HybridCache +Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.ICollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Hybrid.HybridCache.HybridCache() -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableCompression = 32 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCache = Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheRead | Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheWrite -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheRead = 4 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCacheWrite = 8 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCache = Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheRead | Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheWrite -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheRead = 1 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheWrite = 2 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableUndelyingData = 16 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.None = 0 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Expiration.get -> System.TimeSpan? +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Expiration.init -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Flags.get -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags? +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Flags.init -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.HybridCacheEntryOptions() -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.LocalCacheExpiration.get -> System.TimeSpan? +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.LocalCacheExpiration.init -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultOptions.get -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultOptions.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableCompression.get -> bool +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableCompression.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.HybridCacheOptions() -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumKeyLength.get -> int +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumKeyLength.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumPayloadBytes.get -> long +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.MaximumPayloadBytes.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.ReportTagMetrics.get -> bool +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.ReportTagMetrics.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions +Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder +Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder.Services.get -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer.Deserialize(System.Buffers.ReadOnlySequence source) -> T +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer.Serialize(T value, System.Buffers.IBufferWriter! target) -> void +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializerFactory +Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializerFactory.TryCreateSerializer(out Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer? serializer) -> bool +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializer(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializer(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder, Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializer! serializer) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializerFactory(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder, Microsoft.Extensions.Caching.Hybrid.IHybridCacheSerializerFactory! factory) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializerFactory(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions.AddHybridCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +static Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions.AddHybridCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! +virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeysAsync(System.Collections.Generic.ICollection! keys, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagsAsync(System.Collections.Generic.ICollection! tags, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs new file mode 100644 index 000000000000..f9f27808a7d3 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Provides multi-tier caching services building on backends. +/// +public abstract class HybridCache +{ + /// + /// Get data from the cache, or the underlying data service if not available. + /// + /// The type of the data being considered + /// The type of additional state required by + /// The unique key for this cache entry + /// Provides the underlying data service is the data is not available in the cache + /// Additional state required for + /// Additional options for this cache entry + /// The tags to associate with this cache item + /// Cancellation for this operation + /// The data, either from cache or the underlying data service + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] + public abstract ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, + HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default); + + /// + /// Get data from the cache, or the underlying data service if not available. + /// + /// The type of the data being considered + /// The unique key for this cache entry + /// Provides the underlying data service is the data is not available in the cache + /// Additional options for this cache entry + /// The tags to associate with this cache item + /// Cancellation for this operation + /// The data, either from cache or the underlying data service + [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] + public ValueTask GetOrCreateAsync(string key, Func> underlyingDataCallback, + HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) + => GetOrCreateAsync(key, underlyingDataCallback, WrappedCallbackCache.Instance, options, tags, token); + + private static class WrappedCallbackCache // per-T memoized helper that allows GetOrCreateAsync and GetOrCreateAsync to share an implementation + { + // for the simple usage scenario (no TState), pack the original callback as the "state", and use a wrapper function that just unrolls and invokes from the state + public static readonly Func>, CancellationToken, ValueTask> Instance = static (callback, ct) => callback(ct); + } + + /// + /// Manually insert or overwrite a cache entry. + /// + /// The type of the data being considered + /// The unique key for this cache entry + /// The value to assign for this cache item + /// Additional options for this cache entry + /// The tags to associate with this cache item + /// Cancellation for this operation + public abstract ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default); + + /// + /// Removes cache data with the specified key + /// + public abstract ValueTask RemoveKeyAsync(string key, CancellationToken token = default); + + /// + /// Removes cache data with the specified keys + /// + public virtual ValueTask RemoveKeysAsync(ICollection keys, CancellationToken token = default) + { + if (keys is null) + { + return default; // for consistency with GetOrCreate/Set: interpret as "none" + } + return keys.Count switch + { + 0 => default, // nothing to do + 1 => RemoveKeyAsync(keys.Single(), token), + _ => Walk(this, keys, token), + }; + + // default implementation is to call RemoveKeyAsync for each key in turn + static async ValueTask Walk(HybridCache @this, IEnumerable keys, CancellationToken token) + { + foreach (var key in keys) + { + await @this.RemoveKeyAsync(key, token).ConfigureAwait(false); + } + } + } + + /// + /// Removes cache data associated with the specified tags + /// + public virtual ValueTask RemoveTagsAsync(ICollection tags, CancellationToken token = default) + { + if (tags is null) + { + return default; // for consistency with GetOrCreate/Set: interpret as "none" + } + return tags.Count switch + { + 0 => default, // nothing to do + 1 => RemoveTagAsync(tags.Single(), token), + _ => Walk(this, tags, token), + }; + + // default implementation is to call RemoveTagAsync for each key in turn + static async ValueTask Walk(HybridCache @this, IEnumerable keys, CancellationToken token) + { + foreach (var key in keys) + { + await @this.RemoveTagAsync(key, token).ConfigureAwait(false); + } + } + } + + /// + /// Removes cache data associated with the specified tag + /// + public abstract ValueTask RemoveTagAsync(string tag, CancellationToken token = default); +} diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs new file mode 100644 index 000000000000..d2521233a72c --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.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 System; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Additional flags that apply to a operation +/// +[Flags] +public enum HybridCacheEntryFlags +{ + /// + /// No additional flags + /// + None = 0, + /// + /// Do not read from the local in-process cache + /// + DisableLocalCacheRead = 1 << 0, + /// + /// Do not write to the local in-process cache + /// + DisableLocalCacheWrite = 1 << 1, + /// + /// Do not use the local in-process cache for reads or writes + /// + DisableLocalCache = DisableLocalCacheRead | DisableLocalCacheWrite, + /// + /// Do not read from the secondary distributed cache + /// + DisableDistributedCacheRead = 1 << 2, + /// + /// Do not write to the secondary distributed cache + /// + DisableDistributedCacheWrite = 1 << 3, + /// + /// Do not use the local in-process cache for reads or writes + /// + DisableDistributedCache = DisableDistributedCacheRead | DisableDistributedCacheWrite, + /// + /// Only fetch the value from cache - do not attempt to access the underlying data store + /// + DisableUndelyingData = 1 << 4, + /// + /// Do not compress this payload + /// + DisableCompression = 1 << 5, +} diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs new file mode 100644 index 000000000000..2d4c1e660f41 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Additional options (expiration, etc) that apply to a operation. When options +/// can be specified at miltiple levels (for example globally and per-call), the values are composed; the +/// most granular non-null value is used, with null values being inherited. If no value is specified at +/// any level, the implementation may choose a reasonable default. +/// +public sealed class HybridCacheEntryOptions +{ + /// + /// Overall cache duration of this entry, passed to the backend distributed cache + /// + public TimeSpan? Expiration { get; init; } // overall cache duration + + /// + /// Cache duration in local cache; when retrieving a cached value + /// from an external cache store, this value will be used to calculate the local + /// cache expiration, not exceeding the remaining overall cache lifetime + /// + public TimeSpan? LocalCacheExpiration { get; init; } // TTL in L1 + + /// + /// Additional flags that apply to this usage + /// + public HybridCacheEntryFlags? Flags { get; init; } +} diff --git a/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs new file mode 100644 index 000000000000..887bef0802f6 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.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 System.Buffers; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Distributed; // intentional for parity with IDistributedCache + +/// +/// Represents a distributed cache of serialized values, with support for low allocation data transfer. +/// +public interface IBufferDistributedCache : IDistributedCache +{ + /// + /// Attempt to retrieve an existing cache item. + /// + /// The unique key for the cache item. + /// Target to write the cache contents on success. + /// True if the cache item is found, False otherwise. + /// This is functionally similar to , but avoiding the array allocation. + bool TryGet(string key, IBufferWriter destination); + /// + /// Attempt to asynchronously retrieve an existing cache item. + /// + /// The unique key for the cache item. + /// Target to write the cache contents on success. + /// Cancellation for this operation. + /// True if the cache item is found, False otherwise. + /// This is functionally similar to , but avoiding the array allocation. + ValueTask TryGetAsync(string key, IBufferWriter destination, CancellationToken token = default); + + /// + /// Insert or overwrite a cache item. + /// + /// The unique key for the cache item. + /// The value for this cache item. + /// The cache options for the value. + /// This is functionally similar to , but avoiding the array allocation. + void Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options); + /// + /// Asynchronously insert or overwrite a cache item. + /// + /// The unique key for the cache item. + /// The value for this cache item. + /// The cache options for the value. + /// Cancellation for this operation. + /// This is functionally similar to , but avoiding the array allocation. + ValueTask SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token = default); +} diff --git a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs new file mode 100644 index 000000000000..a0595936e70e --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs @@ -0,0 +1,24 @@ +// 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; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Per-type serialization/deserialization support for +/// +/// The type being serialized/deserialized +public interface IHybridCacheSerializer +{ + /// + /// Deserialize a value from the provided + /// + T Deserialize(ReadOnlySequence source); + + /// + /// Serialize , writing to the provided + /// + void Serialize(T value, IBufferWriter target); +} + diff --git a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs new file mode 100644 index 000000000000..98a27c740e35 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.Caching.Hybrid; + +/// +/// Factory provider for per-type instances. +/// +public interface IHybridCacheSerializerFactory +{ + /// + /// Request a serializer for the provided type, if possible. + /// + /// The type being serialized/deserialized + /// The serializer + /// True if the factory supports this type, False otherwise + bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer); +} diff --git a/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs b/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs new file mode 100644 index 000000000000..9c1f8e7280e5 --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs @@ -0,0 +1,11 @@ +// 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; + +namespace System.Runtime.CompilerServices; + +#if !NET5_0_OR_GREATER +[EditorBrowsable(EditorBrowsableState.Never)] +internal static class IsExternalInit { } // for "init" support on down-level TFMs +#endif diff --git a/src/Caching/Hybrid/src/Runtime/readme.md b/src/Caching/Hybrid/src/Runtime/readme.md new file mode 100644 index 000000000000..1e2289449f0b --- /dev/null +++ b/src/Caching/Hybrid/src/Runtime/readme.md @@ -0,0 +1,2 @@ +These types are intended to be added to be relocated to `Microsoft.Extensions.Caching.Abstractions`; their inclusion +here is a preview placeholder diff --git a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj new file mode 100644 index 000000000000..924bd11e1153 --- /dev/null +++ b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -0,0 +1,13 @@ + + + + net8.0 + enable + enable + + + + + + + From 12b3a0ef7613fca61f6cdd75d1f3b81ce04cdf98 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 14:50:54 +0100 Subject: [PATCH 02/75] basic API test --- .../Hybrid/src/Internal/DefaultHybridCache.cs | 16 ++++++ ...Microsoft.Extensions.Caching.Hybrid.csproj | 2 +- ...oft.Extensions.Caching.Hybrid.Tests.csproj | 2 +- .../Hybrid/test/ServiceConstructionTests.cs | 54 +++++++++++++++++++ 4 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 src/Caching/Hybrid/test/ServiceConstructionTests.cs diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index dfad1bac3cd8..c5e23199a1a1 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -5,10 +5,26 @@ using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +/// +/// The inbuilt ASP.NET implementation of +/// internal sealed class DefaultHybridCache : HybridCache { + private readonly IDistributedCache backendCache; + private readonly IServiceProvider services; + private readonly HybridCacheOptions options; + public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IServiceProvider services) + { + this.backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); + this.services = services ?? throw new ArgumentNullException(nameof(services)); + this.options = options.Value; + } + public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) => underlyingDataCallback(state, token); // pass-thru without caching for initial API pass diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index 54f94640dd73..c7f87a1b5079 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -16,11 +16,11 @@ + - diff --git a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index 924bd11e1153..8ab5ad30e764 100644 --- a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework) enable enable diff --git a/src/Caching/Hybrid/test/ServiceConstructionTests.cs b/src/Caching/Hybrid/test/ServiceConstructionTests.cs new file mode 100644 index 000000000000..c4244c67df29 --- /dev/null +++ b/src/Caching/Hybrid/test/ServiceConstructionTests.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class ServiceConstructionTests +{ + [Fact] + public void CanCreateService() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using var provider = services.BuildServiceProvider(); + Assert.IsType(provider.GetService()); + } + + [Fact] + public async Task BasicStatelessUsage() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using var 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 var 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); + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} From c5d28d41fd7c23b91b55c7f3b76bd119113fcde3 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 16:07:27 +0100 Subject: [PATCH 03/75] prove that the API can be configured --- src/Caching/Hybrid/src/HybridCacheOptions.cs | 2 +- .../Hybrid/src/Internal/DefaultHybridCache.cs | 1 + .../Hybrid/src/PublicAPI.Unshipped.txt | 8 +-- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 26 ++++----- src/Caching/Hybrid/test/BasicConfig.json | 12 ++++ ...oft.Extensions.Caching.Hybrid.Tests.csproj | 7 +++ .../Hybrid/test/ServiceConstructionTests.cs | 58 +++++++++++++++++-- 7 files changed, 87 insertions(+), 27 deletions(-) create mode 100644 src/Caching/Hybrid/test/BasicConfig.json diff --git a/src/Caching/Hybrid/src/HybridCacheOptions.cs b/src/Caching/Hybrid/src/HybridCacheOptions.cs index daca158a4504..c6274d3491d9 100644 --- a/src/Caching/Hybrid/src/HybridCacheOptions.cs +++ b/src/Caching/Hybrid/src/HybridCacheOptions.cs @@ -20,7 +20,7 @@ public class HybridCacheOptions /// options being used in preference to the global options). If no value is specified for a given /// option (globally or per-call), the implementation may choose a reasonable default. /// - public HybridCacheEntryOptions? DefaultOptions { get; set; } + public HybridCacheEntryOptions? DefaultEntryOptions { get; set; } /// /// Disallow compression for this instance diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index c5e23199a1a1..a4a3faea64ce 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -24,6 +24,7 @@ public DefaultHybridCache(IOptions options, IDistributedCach this.services = services ?? throw new ArgumentNullException(nameof(services)); this.options = options.Value; } + internal HybridCacheOptions Options => options; public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) => underlyingDataCallback(state, token); // pass-thru without caching for initial API pass diff --git a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt index b7195e28c677..78901c957cce 100644 --- a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt +++ b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt @@ -31,8 +31,8 @@ Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.HybridCacheEntryOpti Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.LocalCacheExpiration.get -> System.TimeSpan? Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.LocalCacheExpiration.init -> void Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions -Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultOptions.get -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? -Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultOptions.set -> void +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultEntryOptions.get -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? +Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DefaultEntryOptions.set -> void Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableCompression.get -> bool Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.DisableCompression.set -> void Microsoft.Extensions.Caching.Hybrid.HybridCacheOptions.HybridCacheOptions() -> void @@ -56,5 +56,5 @@ static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSeri static Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions.WithSerializerFactory(this Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! builder) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! static Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions.AddHybridCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! static Microsoft.Extensions.Caching.Hybrid.HybridCacheServiceExtensions.AddHybridCache(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! setupAction) -> Microsoft.Extensions.Caching.Hybrid.IHybridCacheBuilder! -virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeysAsync(System.Collections.Generic.ICollection! keys, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagsAsync(System.Collections.Generic.ICollection! tags, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeysAsync(System.Collections.Generic.IEnumerable! keys, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +virtual Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagsAsync(System.Collections.Generic.IEnumerable! tags, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index f9f27808a7d3..2b71c72401ab 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -69,16 +69,13 @@ private static class WrappedCallbackCache // per-T memoized helper that allow /// /// Removes cache data with the specified keys /// - public virtual ValueTask RemoveKeysAsync(ICollection keys, CancellationToken token = default) + public virtual ValueTask RemoveKeysAsync(IEnumerable keys, CancellationToken token = default) { - if (keys is null) + return keys switch { - return default; // for consistency with GetOrCreate/Set: interpret as "none" - } - return keys.Count switch - { - 0 => default, // nothing to do - 1 => RemoveKeyAsync(keys.Single(), token), + // for consistency with GetOrCreate/Set: interpret null as "none" + null or ICollection { Count: 0 } => default, + ICollection { Count: 1 } => RemoveTagAsync(keys.Single(), token), _ => Walk(this, keys, token), }; @@ -95,16 +92,13 @@ static async ValueTask Walk(HybridCache @this, IEnumerable keys, Cancell /// /// Removes cache data associated with the specified tags /// - public virtual ValueTask RemoveTagsAsync(ICollection tags, CancellationToken token = default) + public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationToken token = default) { - if (tags is null) - { - return default; // for consistency with GetOrCreate/Set: interpret as "none" - } - return tags.Count switch + return tags switch { - 0 => default, // nothing to do - 1 => RemoveTagAsync(tags.Single(), token), + // for consistency with GetOrCreate/Set: interpret null as "none" + null or ICollection { Count: 0 } => default, + ICollection { Count: 1 } => RemoveTagAsync(tags.Single(), token), _ => Walk(this, tags, token), }; diff --git a/src/Caching/Hybrid/test/BasicConfig.json b/src/Caching/Hybrid/test/BasicConfig.json new file mode 100644 index 000000000000..374114fb1dba --- /dev/null +++ b/src/Caching/Hybrid/test/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/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index 8ab5ad30e764..c589f1499cc8 100644 --- a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -8,6 +8,13 @@ + + + + + + PreserveNewest + diff --git a/src/Caching/Hybrid/test/ServiceConstructionTests.cs b/src/Caching/Hybrid/test/ServiceConstructionTests.cs index c4244c67df29..6b64765bf336 100644 --- a/src/Caching/Hybrid/test/ServiceConstructionTests.cs +++ b/src/Caching/Hybrid/test/ServiceConstructionTests.cs @@ -1,13 +1,10 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration.Json; using Microsoft.Extensions.DependencyInjection; #pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously @@ -16,7 +13,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Tests; public class ServiceConstructionTests { [Fact] - public void CanCreateService() + public void CanCreateDefaultService() { var services = new ServiceCollection(); services.AddHybridCache(); @@ -24,6 +21,55 @@ public void CanCreateService() 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 var 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 + } + + [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 + } + [Fact] public async Task BasicStatelessUsage() { From 20e1cb251d2d79abd615aa4a277a1d9809db9067 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 16:31:23 +0100 Subject: [PATCH 04/75] demonstrate serializer/factory configuration working --- .../Hybrid/src/Internal/DefaultHybridCache.cs | 20 ++++++ .../Hybrid/test/ServiceConstructionTests.cs | 62 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index a4a3faea64ce..712c79894b4e 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -3,9 +3,11 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -37,4 +39,22 @@ public override ValueTask RemoveTagAsync(string tag, CancellationToken token = d public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) => default; // no cache, nothing to set + + internal IHybridCacheSerializer GetSerializer() + { + // unused API, primarily intended to show configuration is working; + // the real version would memoize the result + var service = services.GetServices>().LastOrDefault(); + if (service is null) + { + foreach (var factory in services.GetServices()) + { + if (factory.TryCreateSerializer(out var current)) + { + service = current; + } + } + } + return service ?? throw new InvalidOperationException("No serializer configured for type: " + typeof(T).Name); + } } diff --git a/src/Caching/Hybrid/test/ServiceConstructionTests.cs b/src/Caching/Hybrid/test/ServiceConstructionTests.cs index 6b64765bf336..d9515816f222 100644 --- a/src/Caching/Hybrid/test/ServiceConstructionTests.cs +++ b/src/Caching/Hybrid/test/ServiceConstructionTests.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.Buffers; using System.Runtime.CompilerServices; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.Configuration; @@ -8,6 +9,7 @@ using Microsoft.Extensions.DependencyInjection; #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 @@ -96,5 +98,65 @@ public async Task BasicStatefulUsage() Assert.Equal(expected, actual); } + [Fact] + public void DefaultSerializerConfiguration() + { + var services = new ServiceCollection(); + services.AddHybridCache(); + using var 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().WithSerializer(); + using var 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().WithSerializerFactory(); + using var provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.IsType(cache.GetSerializer()); + Assert.IsType>(cache.GetSerializer()); + } + + class Customer { } + class Order { } + + class CustomerSerializer : IHybridCacheSerializer + { + Customer IHybridCacheSerializer.Deserialize(ReadOnlySequence source) => throw new NotImplementedException(); + void IHybridCacheSerializer.Serialize(Customer value, IBufferWriter target) => throw new NotImplementedException(); + } + + 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; } From 4379647bba4c98697d006394514df74b00486d2f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 16:44:07 +0100 Subject: [PATCH 05/75] move to NuGet only to make the build happier --- eng/SharedFramework.Local.props | 1 - eng/ShippingAssemblies.props | 2 +- .../Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/eng/SharedFramework.Local.props b/eng/SharedFramework.Local.props index ea4d7df14844..46be57d9577b 100644 --- a/eng/SharedFramework.Local.props +++ b/eng/SharedFramework.Local.props @@ -8,7 +8,6 @@ - diff --git a/eng/ShippingAssemblies.props b/eng/ShippingAssemblies.props index 9a84ba0198ba..d0cae638afbb 100644 --- a/eng/ShippingAssemblies.props +++ b/eng/ShippingAssemblies.props @@ -5,7 +5,6 @@ --> - @@ -105,6 +104,7 @@ + diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index c7f87a1b5079..c345269ce040 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -6,7 +6,7 @@ true cache;distributedcache;hybrid true - true + false true true true From d382a7ad080f615bd7d4f24197402bbb89813e09 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 17:54:14 +0100 Subject: [PATCH 06/75] defer on trimming --- .../Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index c345269ce040..77283688e68c 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -8,7 +8,6 @@ true false true - true true From e8bc9a9e5624fb9d7b2ee6443b3690c01cb0ef9f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 18:11:05 +0100 Subject: [PATCH 07/75] PR review comments --- src/Caching/Hybrid/src/HybridCacheOptions.cs | 3 ++- src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs | 4 ++-- src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs | 3 +-- src/Caching/Hybrid/src/PublicAPI.Unshipped.txt | 6 +++--- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/Caching/Hybrid/src/HybridCacheOptions.cs b/src/Caching/Hybrid/src/HybridCacheOptions.cs index c6274d3491d9..a17a01671d1e 100644 --- a/src/Caching/Hybrid/src/HybridCacheOptions.cs +++ b/src/Caching/Hybrid/src/HybridCacheOptions.cs @@ -28,7 +28,8 @@ public class HybridCacheOptions public bool DisableCompression { get; set; } /// - /// The maximum size of cache items; attempts to store values over this size will be logged. + /// The maximum size of cache items; attempts to store values over this size will be logged + /// and the value will not be stored in cache. /// public long MaximumPayloadBytes { get; set; } = 1 << 20; // 1MiB diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 712c79894b4e..591d134a63d9 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -28,7 +28,7 @@ public DefaultHybridCache(IOptions options, IDistributedCach } internal HybridCacheOptions Options => options; - public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) + public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) => underlyingDataCallback(state, token); // pass-thru without caching for initial API pass public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) @@ -37,7 +37,7 @@ public override ValueTask RemoveKeyAsync(string key, CancellationToken token = d public override ValueTask RemoveTagAsync(string tag, CancellationToken token = default) => default; // no cache, nothing to remove - public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) + public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) => default; // no cache, nothing to set internal IHybridCacheSerializer GetSerializer() diff --git a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs index 6160a2e2ae55..20250f8f031f 100644 --- a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs +++ b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs @@ -11,8 +11,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal sealed class InbuiltTypeSerializer : IHybridCacheSerializer, IHybridCacheSerializer { - private static InbuiltTypeSerializer? _instance; - public static InbuiltTypeSerializer Instance => _instance ??= new(); + public static InbuiltTypeSerializer Instance { get; } = new(); string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) { #if NET5_0_OR_GREATER diff --git a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt index 78901c957cce..ac739b5d4874 100644 --- a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt +++ b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt @@ -1,15 +1,15 @@ #nullable enable -abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, TState state, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.ICollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, TState state, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeyAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagAsync(string! tag, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask -abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.SetAsync(string! key, T value, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.ICollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.SetAsync(string! key, T value, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.Set(string! key, System.Buffers.ReadOnlySequence value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options) -> void Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.SetAsync(string! key, System.Buffers.ReadOnlySequence value, Microsoft.Extensions.Caching.Distributed.DistributedCacheEntryOptions! options, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGet(string! key, System.Buffers.IBufferWriter! destination) -> bool Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGetAsync(string! key, System.Buffers.IBufferWriter! destination, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Microsoft.Extensions.Caching.Hybrid.HybridCache -Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.ICollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Microsoft.Extensions.Caching.Hybrid.HybridCache.HybridCache() -> void Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 2b71c72401ab..a917028285a8 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -27,7 +27,7 @@ public abstract class HybridCache /// The data, either from cache or the underlying data service [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] public abstract ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, - HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default); + HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); /// /// Get data from the cache, or the underlying data service if not available. @@ -41,7 +41,7 @@ public abstract ValueTask GetOrCreateAsync(string key, TState stat /// The data, either from cache or the underlying data service [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] public ValueTask GetOrCreateAsync(string key, Func> underlyingDataCallback, - HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default) + HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) => GetOrCreateAsync(key, underlyingDataCallback, WrappedCallbackCache.Instance, options, tags, token); private static class WrappedCallbackCache // per-T memoized helper that allows GetOrCreateAsync and GetOrCreateAsync to share an implementation @@ -59,7 +59,7 @@ private static class WrappedCallbackCache // per-T memoized helper that allow /// Additional options for this cache entry /// The tags to associate with this cache item /// Cancellation for this operation - public abstract ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, ICollection? tags = null, CancellationToken token = default); + public abstract ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); /// /// Removes cache data with the specified key From 7edddb10aaf46ca36c8266600db5764041535e4d Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 18:13:32 +0100 Subject: [PATCH 08/75] tyop --- src/Caching/Hybrid/src/PublicAPI.Unshipped.txt | 2 +- src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt index ac739b5d4874..bb571e4d3840 100644 --- a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt +++ b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt @@ -20,7 +20,7 @@ Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableDistributedCach Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCache = Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheRead | Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheWrite -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheRead = 1 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableLocalCacheWrite = 2 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags -Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableUndelyingData = 16 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags +Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.DisableUnderlyingData = 16 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags.None = 0 -> Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions.Expiration.get -> System.TimeSpan? diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs index d2521233a72c..5ab2804a57e6 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs @@ -42,7 +42,7 @@ public enum HybridCacheEntryFlags /// /// Only fetch the value from cache - do not attempt to access the underlying data store /// - DisableUndelyingData = 1 << 4, + DisableUnderlyingData = 1 << 4, /// /// Do not compress this payload /// From dc622a8cee8294e52e24a92ba361ebfbb5731f92 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 18:13:45 +0100 Subject: [PATCH 09/75] Update src/Caching/Hybrid/src/Runtime/IsExternalInit.cs Co-authored-by: Andrew Casey --- src/Caching/Hybrid/src/Runtime/IsExternalInit.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs b/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs index 9c1f8e7280e5..4c68490902c9 100644 --- a/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs +++ b/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs @@ -7,5 +7,5 @@ namespace System.Runtime.CompilerServices; #if !NET5_0_OR_GREATER [EditorBrowsable(EditorBrowsableState.Never)] -internal static class IsExternalInit { } // for "init" support on down-level TFMs +internal static class IsExternalInit { } // for "init" support on down-level TFMs #endif From 49201828b85c1f26650f9bd2bb22e15418c01a63 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 18:29:45 +0100 Subject: [PATCH 10/75] return a leased array on netfx --- src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs index 20250f8f031f..1ffbd117aa18 100644 --- a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs +++ b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs @@ -42,6 +42,7 @@ void IHybridCacheSerializer.Serialize(string value, IBufferWriter var actual = Encoding.UTF8.GetBytes(value, 0, value.Length, oversized, 0); Debug.Assert(actual == length); target.Write(new(oversized, 0, length)); + ArrayPool.Shared.Return(oversized); #endif } From 8841e97a7b588f6ad76f3ff4c30cadbdab8df4dc Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 18:38:26 +0100 Subject: [PATCH 11/75] prefer ForEach to Walk --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index a917028285a8..2d00ddd910a8 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -76,11 +76,11 @@ public virtual ValueTask RemoveKeysAsync(IEnumerable keys, CancellationT // for consistency with GetOrCreate/Set: interpret null as "none" null or ICollection { Count: 0 } => default, ICollection { Count: 1 } => RemoveTagAsync(keys.Single(), token), - _ => Walk(this, keys, token), + _ => ForEach(this, keys, token), }; // default implementation is to call RemoveKeyAsync for each key in turn - static async ValueTask Walk(HybridCache @this, IEnumerable keys, CancellationToken token) + static async ValueTask ForEach(HybridCache @this, IEnumerable keys, CancellationToken token) { foreach (var key in keys) { @@ -99,11 +99,11 @@ public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationT // for consistency with GetOrCreate/Set: interpret null as "none" null or ICollection { Count: 0 } => default, ICollection { Count: 1 } => RemoveTagAsync(tags.Single(), token), - _ => Walk(this, tags, token), + _ => ForEach(this, tags, token), }; // default implementation is to call RemoveTagAsync for each key in turn - static async ValueTask Walk(HybridCache @this, IEnumerable keys, CancellationToken token) + static async ValueTask ForEach(HybridCache @this, IEnumerable keys, CancellationToken token) { foreach (var key in keys) { From 6cbe02e18f701049ca043c96f66e904c80ed2190 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 19:01:22 +0100 Subject: [PATCH 12/75] Update src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs Co-authored-by: Brennan --- src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs index 1ffbd117aa18..1abb64d02054 100644 --- a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs +++ b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs @@ -9,6 +9,7 @@ using System.Text; namespace Microsoft.Extensions.Caching.Hybrid.Internal; + internal sealed class InbuiltTypeSerializer : IHybridCacheSerializer, IHybridCacheSerializer { public static InbuiltTypeSerializer Instance { get; } = new(); From 0c0d589213ac604f2a76e4e10e83231c65c88e35 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 19:02:20 +0100 Subject: [PATCH 13/75] Update src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs Co-authored-by: Brennan --- src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs b/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs index dbe94684d185..e925a033951f 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultJsonSerializerFactory.cs @@ -6,6 +6,7 @@ using System.Text.Json; namespace Microsoft.Extensions.Caching.Hybrid.Internal; + internal sealed class DefaultJsonSerializerFactory : IHybridCacheSerializerFactory { public bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer) From ef0ad56454d96b334897498b54c35c2e1e0fa071 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 19:02:33 +0100 Subject: [PATCH 14/75] Update src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs Co-authored-by: Brennan --- src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs index 1abb64d02054..a043fc1ca203 100644 --- a/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs +++ b/src/Caching/Hybrid/src/Internal/InbuiltTypeSerializer.cs @@ -13,6 +13,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal sealed class InbuiltTypeSerializer : IHybridCacheSerializer, IHybridCacheSerializer { public static InbuiltTypeSerializer Instance { get; } = new(); + string IHybridCacheSerializer.Deserialize(ReadOnlySequence source) { #if NET5_0_OR_GREATER From e28f316e8bf8b57ea76bd8932a342f246e0f70ac Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 19:09:07 +0100 Subject: [PATCH 15/75] comment nits --- .../src/HybridCacheBuilderExtensions.cs | 10 ++-- src/Caching/Hybrid/src/HybridCacheOptions.cs | 6 ++- .../src/HybridCacheServiceExtensions.cs | 10 ++-- .../Hybrid/src/Internal/DefaultHybridCache.cs | 2 +- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 52 +++++++++---------- .../src/Runtime/HybridCacheEntryFlags.cs | 20 +++---- .../src/Runtime/HybridCacheEntryOptions.cs | 6 +-- .../src/Runtime/IHybridCacheSerializer.cs | 8 +-- .../Runtime/IHybridCacheSerializerFactory.cs | 6 +-- 9 files changed, 61 insertions(+), 59 deletions(-) diff --git a/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs b/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs index 3ce69ead2c35..a27240f66418 100644 --- a/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs +++ b/src/Caching/Hybrid/src/HybridCacheBuilderExtensions.cs @@ -12,12 +12,12 @@ namespace Microsoft.Extensions.Caching.Hybrid; /// -/// Configuration extension methods for / +/// Configuration extension methods for / . /// public static class HybridCacheBuilderExtensions { /// - /// Serialize values of type with the specified serializer from + /// Serialize values of type with the specified serializer from . /// public static IHybridCacheBuilder WithSerializer(this IHybridCacheBuilder builder, IHybridCacheSerializer serializer) { @@ -26,7 +26,7 @@ public static IHybridCacheBuilder WithSerializer(this IHybridCacheBuilder bui } /// - /// Serialize values of type with the serializer of type + /// Serialize values of type with the serializer of type . /// public static IHybridCacheBuilder WithSerializer(this IHybridCacheBuilder bui } /// - /// Add as an additional serializer factory, which can provide serializers for multiple types + /// Add as an additional serializer factory, which can provide serializers for multiple types. /// public static IHybridCacheBuilder WithSerializerFactory(this IHybridCacheBuilder builder, IHybridCacheSerializerFactory factory) { @@ -49,7 +49,7 @@ public static IHybridCacheBuilder WithSerializerFactory(this IHybridCacheBuilder } /// - /// Add a factory of type as an additional serializer factory, which can provide serializers for multiple types + /// Add a factory of type as an additional serializer factory, which can provide serializers for multiple types. /// public static IHybridCacheBuilder WithSerializerFactory< #if NET5_0_OR_GREATER diff --git a/src/Caching/Hybrid/src/HybridCacheOptions.cs b/src/Caching/Hybrid/src/HybridCacheOptions.cs index a17a01671d1e..62407b9bf6a9 100644 --- a/src/Caching/Hybrid/src/HybridCacheOptions.cs +++ b/src/Caching/Hybrid/src/HybridCacheOptions.cs @@ -10,7 +10,7 @@ namespace Microsoft.Extensions.Caching.Hybrid; /// -/// Options for configuring the default implementation +/// Options for configuring the default implementation. /// public class HybridCacheOptions { @@ -23,7 +23,7 @@ public class HybridCacheOptions public HybridCacheEntryOptions? DefaultEntryOptions { get; set; } /// - /// Disallow compression for this instance + /// Disallow compression for this instance. /// public bool DisableCompression { get; set; } @@ -31,11 +31,13 @@ public class HybridCacheOptions /// The maximum size of cache items; attempts to store values over this size will be logged /// and the value will not be stored in cache. /// + /// The default value is 1 MiB. public long MaximumPayloadBytes { get; set; } = 1 << 20; // 1MiB /// /// The maximum permitted length (in characters) of keys; attempts to use keys over this size will be logged. /// + /// The default value is 1024 characters. public int MaximumKeyLength { get; set; } = 1024; // characters /// diff --git a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs index 26bf37fc1495..019b6d19b19c 100644 --- a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs +++ b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs @@ -15,14 +15,14 @@ namespace Microsoft.Extensions.Caching.Hybrid; /// -/// Configuration extension methods for +/// Configuration extension methods for . /// public static class HybridCacheServiceExtensions { /// - /// Adds support for multi-tier caching services + /// Adds support for multi-tier caching services. /// - /// A builder instance that allows further configuration of the system + /// A builder instance that allows further configuration of the system. public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services, Action setupAction) { #if NET7_0_OR_GREATER @@ -36,9 +36,9 @@ public static IHybridCacheBuilder AddHybridCache(this IServiceCollection service } /// - /// Adds support for multi-tier caching services + /// Adds support for multi-tier caching services. /// - /// A builder instance that allows further configuration of the system + /// A builder instance that allows further configuration of the system. public static IHybridCacheBuilder AddHybridCache(this IServiceCollection services) { #if NET7_0_OR_GREATER diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 591d134a63d9..e989b94ca725 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -13,7 +13,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// -/// The inbuilt ASP.NET implementation of +/// The inbuilt ASP.NET implementation of . /// internal sealed class DefaultHybridCache : HybridCache { diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 2d00ddd910a8..fab11d095c9b 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -16,15 +16,15 @@ public abstract class HybridCache /// /// Get data from the cache, or the underlying data service if not available. /// - /// The type of the data being considered - /// The type of additional state required by - /// The unique key for this cache entry - /// Provides the underlying data service is the data is not available in the cache - /// Additional state required for - /// Additional options for this cache entry - /// The tags to associate with this cache item - /// Cancellation for this operation - /// The data, either from cache or the underlying data service + /// The type of the data being considered. + /// The type of additional state required by . + /// The unique key for this cache entry. + /// Provides the underlying data service is the data is not available in the cache. + /// Additional state required for . + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// Cancellation for this operation. + /// The data, either from cache or the underlying data service. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] public abstract ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); @@ -32,13 +32,13 @@ public abstract ValueTask GetOrCreateAsync(string key, TState stat /// /// Get data from the cache, or the underlying data service if not available. /// - /// The type of the data being considered - /// The unique key for this cache entry - /// Provides the underlying data service is the data is not available in the cache - /// Additional options for this cache entry - /// The tags to associate with this cache item - /// Cancellation for this operation - /// The data, either from cache or the underlying data service + /// The type of the data being considered. + /// The unique key for this cache entry. + /// Provides the underlying data service is the data is not available in the cache. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// Cancellation for this operation. + /// The data, either from cache or the underlying data service. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] public ValueTask GetOrCreateAsync(string key, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) @@ -53,21 +53,21 @@ private static class WrappedCallbackCache // per-T memoized helper that allow /// /// Manually insert or overwrite a cache entry. /// - /// The type of the data being considered - /// The unique key for this cache entry - /// The value to assign for this cache item - /// Additional options for this cache entry - /// The tags to associate with this cache item - /// Cancellation for this operation + /// The type of the data being considered. + /// The unique key for this cache entry. + /// The value to assign for this cache item. + /// Additional options for this cache entry. + /// The tags to associate with this cache item. + /// Cancellation for this operation. public abstract ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); /// - /// Removes cache data with the specified key + /// Removes cache data with the specified key. /// public abstract ValueTask RemoveKeyAsync(string key, CancellationToken token = default); /// - /// Removes cache data with the specified keys + /// Removes cache data with the specified keys. /// public virtual ValueTask RemoveKeysAsync(IEnumerable keys, CancellationToken token = default) { @@ -90,7 +90,7 @@ static async ValueTask ForEach(HybridCache @this, IEnumerable keys, Canc } /// - /// Removes cache data associated with the specified tags + /// Removes cache data associated with the specified tags. /// public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationToken token = default) { @@ -113,7 +113,7 @@ static async ValueTask ForEach(HybridCache @this, IEnumerable keys, Canc } /// - /// Removes cache data associated with the specified tag + /// Removes cache data associated with the specified tag. /// public abstract ValueTask RemoveTagAsync(string tag, CancellationToken token = default); } diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs index 5ab2804a57e6..0f2b4e69cc0c 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs @@ -6,45 +6,45 @@ namespace Microsoft.Extensions.Caching.Hybrid; /// -/// Additional flags that apply to a operation +/// Additional flags that apply to a operation. /// [Flags] public enum HybridCacheEntryFlags { /// - /// No additional flags + /// No additional flags. /// None = 0, /// - /// Do not read from the local in-process cache + /// Do not read from the local in-process cache. /// DisableLocalCacheRead = 1 << 0, /// - /// Do not write to the local in-process cache + /// Do not write to the local in-process cache. /// DisableLocalCacheWrite = 1 << 1, /// - /// Do not use the local in-process cache for reads or writes + /// Do not use the local in-process cache for reads or writes. /// DisableLocalCache = DisableLocalCacheRead | DisableLocalCacheWrite, /// - /// Do not read from the secondary distributed cache + /// Do not read from the secondary distributed cache. /// DisableDistributedCacheRead = 1 << 2, /// - /// Do not write to the secondary distributed cache + /// Do not write to the secondary distributed cache. /// DisableDistributedCacheWrite = 1 << 3, /// - /// Do not use the local in-process cache for reads or writes + /// Do not use the local in-process cache for reads or writes. /// DisableDistributedCache = DisableDistributedCacheRead | DisableDistributedCacheWrite, /// - /// Only fetch the value from cache - do not attempt to access the underlying data store + /// Only fetch the value from cache - do not attempt to access the underlying data store. /// DisableUnderlyingData = 1 << 4, /// - /// Do not compress this payload + /// Do not compress this payload. /// DisableCompression = 1 << 5, } diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs index 2d4c1e660f41..6e8491759f92 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs @@ -14,19 +14,19 @@ namespace Microsoft.Extensions.Caching.Hybrid; public sealed class HybridCacheEntryOptions { /// - /// Overall cache duration of this entry, passed to the backend distributed cache + /// Overall cache duration of this entry, passed to the backend distributed cache. /// public TimeSpan? Expiration { get; init; } // overall cache duration /// /// Cache duration in local cache; when retrieving a cached value /// from an external cache store, this value will be used to calculate the local - /// cache expiration, not exceeding the remaining overall cache lifetime + /// cache expiration, not exceeding the remaining overall cache lifetime. /// public TimeSpan? LocalCacheExpiration { get; init; } // TTL in L1 /// - /// Additional flags that apply to this usage + /// Additional flags that apply to this usage. /// public HybridCacheEntryFlags? Flags { get; init; } } diff --git a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs index a0595936e70e..f5c869a71772 100644 --- a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs +++ b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializer.cs @@ -6,18 +6,18 @@ namespace Microsoft.Extensions.Caching.Hybrid; /// -/// Per-type serialization/deserialization support for +/// Per-type serialization/deserialization support for . /// -/// The type being serialized/deserialized +/// The type being serialized/deserialized. public interface IHybridCacheSerializer { /// - /// Deserialize a value from the provided + /// Deserialize a value from the provided . /// T Deserialize(ReadOnlySequence source); /// - /// Serialize , writing to the provided + /// Serialize , writing to the provided . /// void Serialize(T value, IBufferWriter target); } diff --git a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs index 98a27c740e35..7e56d5b83835 100644 --- a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs +++ b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs @@ -13,8 +13,8 @@ public interface IHybridCacheSerializerFactory /// /// Request a serializer for the provided type, if possible. /// - /// The type being serialized/deserialized - /// The serializer - /// True if the factory supports this type, False otherwise + /// The type being serialized/deserialized. + /// The serializer. + /// True if the factory supports this type, False otherwise. bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer); } From b49151e9ecc16fa0806cbb8c4ac77b52ae804431 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 19:12:51 +0100 Subject: [PATCH 16/75] use TimeProvider throughout --- src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs | 4 ---- .../Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj | 1 + 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs index 019b6d19b19c..c585b2d3320f 100644 --- a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs +++ b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs @@ -47,11 +47,7 @@ public static IHybridCacheBuilder AddHybridCache(this IServiceCollection service _ = services ?? throw new ArgumentNullException(nameof(services)); #endif -#if NET8_0_OR_GREATER services.TryAddSingleton(TimeProvider.System); -#else - services.TryAddSingleton(); -#endif services.AddOptions(); services.AddMemoryCache(); services.AddDistributedMemoryCache(); // we need a backend; use in-proc by default diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index 77283688e68c..825815fbf115 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -21,5 +21,6 @@ + From c2326fddfd178c211574590d708b33612b10c329 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 19:18:36 +0100 Subject: [PATCH 17/75] more nits --- .../src/Microsoft.Extensions.Caching.Hybrid.csproj | 6 +++++- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 8 ++++---- src/Caching/Hybrid/src/Runtime/IsExternalInit.cs | 11 ----------- 3 files changed, 9 insertions(+), 16 deletions(-) delete mode 100644 src/Caching/Hybrid/src/Runtime/IsExternalInit.cs diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index 825815fbf115..49671f048347 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -11,6 +11,10 @@ true + + + + @@ -19,7 +23,7 @@ - + diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index fab11d095c9b..84978c2c204e 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -76,11 +76,11 @@ public virtual ValueTask RemoveKeysAsync(IEnumerable keys, CancellationT // for consistency with GetOrCreate/Set: interpret null as "none" null or ICollection { Count: 0 } => default, ICollection { Count: 1 } => RemoveTagAsync(keys.Single(), token), - _ => ForEach(this, keys, token), + _ => ForEachAsync(this, keys, token), }; // default implementation is to call RemoveKeyAsync for each key in turn - static async ValueTask ForEach(HybridCache @this, IEnumerable keys, CancellationToken token) + static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, CancellationToken token) { foreach (var key in keys) { @@ -99,11 +99,11 @@ public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationT // for consistency with GetOrCreate/Set: interpret null as "none" null or ICollection { Count: 0 } => default, ICollection { Count: 1 } => RemoveTagAsync(tags.Single(), token), - _ => ForEach(this, tags, token), + _ => ForEachAsync(this, tags, token), }; // default implementation is to call RemoveTagAsync for each key in turn - static async ValueTask ForEach(HybridCache @this, IEnumerable keys, CancellationToken token) + static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, CancellationToken token) { foreach (var key in keys) { diff --git a/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs b/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs deleted file mode 100644 index 4c68490902c9..000000000000 --- a/src/Caching/Hybrid/src/Runtime/IsExternalInit.cs +++ /dev/null @@ -1,11 +0,0 @@ -// 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; - -namespace System.Runtime.CompilerServices; - -#if !NET5_0_OR_GREATER -[EditorBrowsable(EditorBrowsableState.Never)] -internal static class IsExternalInit { } // for "init" support on down-level TFMs -#endif From 29dcc2ef7710feada7f5f544c87cc0f64b4036c2 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 11 Apr 2024 19:41:19 +0100 Subject: [PATCH 18/75] regen projects list --- eng/TrimmableProjects.props | 1 - 1 file changed, 1 deletion(-) diff --git a/eng/TrimmableProjects.props b/eng/TrimmableProjects.props index e4ec572527d4..c61ab1b2e4ac 100644 --- a/eng/TrimmableProjects.props +++ b/eng/TrimmableProjects.props @@ -7,7 +7,6 @@ --> - From 4ddba7fcb650edb4a310f24139c259a1a9196a32 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 12 Apr 2024 07:29:57 +0100 Subject: [PATCH 19/75] TryAdd --- src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs index c585b2d3320f..bcbde7462a39 100644 --- a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs +++ b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs @@ -51,10 +51,10 @@ public static IHybridCacheBuilder AddHybridCache(this IServiceCollection service services.AddOptions(); services.AddMemoryCache(); services.AddDistributedMemoryCache(); // we need a backend; use in-proc by default - services.AddSingleton(); - services.AddSingleton>(InbuiltTypeSerializer.Instance); - services.AddSingleton>(InbuiltTypeSerializer.Instance); - services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton>(InbuiltTypeSerializer.Instance); + services.TryAddSingleton>(InbuiltTypeSerializer.Instance); + services.TryAddSingleton(); return new HybridCacheBuilder(services); } } From 427601c0de1b263a732c94e1f8325ef9ac0df993 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 12 Apr 2024 11:47:29 +0100 Subject: [PATCH 20/75] clarify intent of null on Remove{Tags|Keys}Async --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 84978c2c204e..45f219a40265 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -69,6 +69,7 @@ private static class WrappedCallbackCache // per-T memoized helper that allow /// /// Removes cache data with the specified keys. /// + /// Implementors should treat null as empty public virtual ValueTask RemoveKeysAsync(IEnumerable keys, CancellationToken token = default) { return keys switch @@ -92,6 +93,7 @@ static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, /// /// Removes cache data associated with the specified tags. /// + /// Implementors should treat null as empty public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationToken token = default) { return tags switch From cef964f3787d1a59e7f555f74514ab4447792797 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Fri, 12 Apr 2024 17:01:08 +0100 Subject: [PATCH 21/75] basic stampede infrastructure --- .../DefaultHybridCache.Serialization.cs | 66 +++++ .../Internal/DefaultHybridCache.Stampede.cs | 86 ++++++ .../DefaultHybridCache.StampedeKey.cs | 32 +++ .../DefaultHybridCache.StampedeState.cs | 58 ++++ .../DefaultHybridCache.StampedeStateT.cs | 27 ++ .../Hybrid/src/Internal/DefaultHybridCache.cs | 74 ++++-- .../Hybrid/test/ServiceConstructionTests.cs | 1 + src/Caching/Hybrid/test/StampedeTests.cs | 249 ++++++++++++++++++ 8 files changed, 572 insertions(+), 21 deletions(-) create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs create mode 100644 src/Caching/Hybrid/test/StampedeTests.cs diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs new file mode 100644 index 000000000000..329896ba3365 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; +partial class DefaultHybridCache +{ + private readonly ConcurrentDictionary serializers = new(); // per instance cache of typed serializers + + internal IHybridCacheSerializer GetSerializer() + { + return serializers.TryGetValue(typeof(T), out var serializer) + ? Unsafe.As>(serializer) : ResolveAndAddSerializer(this); + + static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @this) + { + // it isn't critical that we get only one serializer instance during start-up; what matters + // is that we don't get a new serializer instance *every time* + var serializer = @this.services.GetServices>().LastOrDefault(); + if (serializer is null) + { + foreach (var factory in @this.serializerFactories) + { + if (factory.TryCreateSerializer(out var current)) + { + serializer = current; + break; // we've already reversed the factories, so: the first hit is what we want + } + } + } + if (serializer is null) + { + throw new InvalidOperationException($"No {nameof(IHybridCacheSerializer)} configured for type '{typeof(T).Name}'"); + } + // store the result so we don't repeat this in future + @this.serializers[typeof(T)] = serializer; + return serializer; + } + } + + private static class ImmutableTypeCache // lazy memoize; T doesn't change per cache instance + { + public static readonly bool IsImmutable = DefaultHybridCache.IsImmutable(typeof(T)); + } + + internal static bool IsImmutable(Type type) + { + if (type is null || type == typeof(string) || type.IsPrimitive) + { + return true; // trivial cases + } + + if (Nullable.GetUnderlyingType(type) is { } nullable) + { + type = nullable; // from Foo? to Foo + } + + return Attribute.IsDefined(type, typeof(ImmutableObjectAttribute)); + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs new file mode 100644 index 000000000000..a38e423d4ae2 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + private readonly ConcurrentDictionary currentOperations = new(); + + internal int DebugGetCallerCount(string key, HybridCacheEntryFlags flags = HybridCacheEntryFlags.None) + { + var stampedeKey = new StampedeKey(key, flags); + return currentOperations.TryGetValue(stampedeKey, out var state) ? state.DebugCallerCount : 0; + } + + // returns true for a new session (in which case: we need to start the work), false for a pre-existing session + public bool GetOrCreateStampede(string key, HybridCacheEntryFlags flags, out StampedeState state) + { + var stampedeKey = new StampedeKey(key, flags); + if (currentOperations.TryGetValue(stampedeKey, out var found)) + { + var tmp = found as StampedeState; + if (tmp is null) + { + ThrowWrongType(key); + } + + if (tmp.TryAddCaller()) + { + // we joined an existing session + state = tmp; + return false; + } + } + + // create a new session + state = new StampedeState(stampedeKey); + currentOperations[stampedeKey] = state; + return true; + } + + private static ValueTask JoinAsync(StampedeState stampede, CancellationToken token) + { + return token.CanBeCanceled ? WithCancellation(stampede, token) : new(stampede.Task); + + static async ValueTask WithCancellation(StampedeState stampede, CancellationToken token) + { + var cancelStub = new TaskCompletionSource(); + using var reg = token.Register(static obj => + { + ((TaskCompletionSource)obj!).TrySetResult(true); + }, cancelStub); + + try + { + var first = await Task.WhenAny(stampede.Task, cancelStub.Task).ConfigureAwait(false); + if (ReferenceEquals(first, cancelStub.Task)) + { + // we expect this to throw, because otherwise we wouldn't have gotten here + token.ThrowIfCancellationRequested(); // get an appropriate exception + } + Debug.Assert(ReferenceEquals(first, stampede.Task)); + + // this has already completed, but we'll get the stack nicely + return await stampede.Task.ConfigureAwait(false); + } + finally + { + stampede.RemoveCaller(); + } + } + } + + [DoesNotReturn] + static void ThrowWrongType(string key) => throw new InvalidOperationException($"All calls to {nameof(HybridCache)} with the same key should use the same data type") + { + Data = { { "CacheKey", key } } + }; +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs new file mode 100644 index 000000000000..55b65d411dfa --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs @@ -0,0 +1,32 @@ +// 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.Diagnostics.CodeAnalysis; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + internal readonly struct StampedeKey : IEquatable + { + private readonly string key; + private readonly HybridCacheEntryFlags flags; + private readonly int hashCode; // we know we'll need it; compute it once only + public StampedeKey(string key, HybridCacheEntryFlags flags) + { + this.key = key; + this.flags = flags; + this.hashCode = key.GetHashCode() ^ (int)flags; + } + + public bool Equals(StampedeKey other) => this.flags == other.flags & this.key == other.key; + + public override bool Equals([NotNullWhen(true)] object? obj) + => obj is StampedeKey other && Equals(other); + + public override int GetHashCode() => hashCode; + + public override string ToString() => $"{key} ({flags})"; + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs new file mode 100644 index 000000000000..e3c2114ee845 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -0,0 +1,58 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + internal abstract class StampedeState + { + protected StampedeState(in StampedeKey key) => Key = key; + + public StampedeKey Key { get; } + + public override string ToString() => Key.ToString(); + + // because multiple callers can enlist, we need to track when the *last* caller cancels + // (and keep going until then); that means we need to run with custom cancellation + private readonly CancellationTokenSource sharedCancellation = new(); + + protected abstract void SetCanceled(); + + public CancellationToken SharedToken => sharedCancellation.Token; + + protected object SyncLock => this; // not exposed externally; we'll use the instance as the lock + + public int DebugCallerCount => Volatile.Read(ref activeCallers); + + private int activeCallers = 1; + public void RemoveCaller() + { + lock (SyncLock) + { + if (--activeCallers == 0) + { + // nobody is left, we're done + sharedCancellation.Cancel(); + SetCanceled(); + } + } + } + + public bool TryAddCaller() + { + lock (SyncLock) + { + if (activeCallers <= 0) + { + return false; // already burned + } + activeCallers++; + } + return true; + } + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs new file mode 100644 index 000000000000..4965515cf74a --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -0,0 +1,27 @@ +// 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.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + + internal sealed class StampedeState : StampedeState + { + public StampedeState(in StampedeKey key) : base(key) { } + + private readonly TaskCompletionSource result = new(); + + public Task Task => result.Task; + + internal void SetException(Exception ex) => result.TrySetException(ex); + + internal void SetResult(T value) => result.TrySetResult(value); + + protected override void SetCanceled() => result.TrySetCanceled(SharedToken); + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index e989b94ca725..82eae3f05b31 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -15,46 +16,77 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// /// The inbuilt ASP.NET implementation of . /// -internal sealed class DefaultHybridCache : HybridCache +internal sealed partial class DefaultHybridCache : HybridCache { private readonly IDistributedCache backendCache; private readonly IServiceProvider services; + private readonly IHybridCacheSerializerFactory[] serializerFactories; private readonly HybridCacheOptions options; + private readonly BackendFeatures features; + + [Flags] + private enum BackendFeatures + { + None = 0, + Buffers = 1 << 0, + } + public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IServiceProvider services) { this.backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); this.services = services ?? throw new ArgumentNullException(nameof(services)); this.options = options.Value; + + // perform type-tests on the backend once only + if (backendCache is IBufferDistributedCache) + { + this.features |= BackendFeatures.Buffers; + } + + // When resolving serializers via the factory API, we will want the *last* instance, + // i.e. "last added wins"; we can optimize by reversing the array ahead of time, and + // taking the first match + var factories = services.GetServices().ToArray(); + Array.Reverse(factories); + this.serializerFactories = factories; } internal HybridCacheOptions Options => options; + private bool BackendBuffers => (features & BackendFeatures.Buffers) != 0; + public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) - => underlyingDataCallback(state, token); // pass-thru without caching for initial API pass + { + token.ThrowIfCancellationRequested(); + + if (GetOrCreateStampede(key, HybridCacheEntryFlags.None, out var stampede)) + { + // new query; we're responsible for making it happen + _ = Task.Run(async () => + { + try + { + var result = await underlyingDataCallback(state, stampede.SharedToken).ConfigureAwait(false); + currentOperations.TryRemove(stampede.Key, out _); + stampede.SetResult(result); + } + catch (Exception ex) + { + stampede.SetException(ex); + } + }, stampede.SharedToken); + + return JoinAsync(stampede, token); + } + + return JoinAsync(stampede, token); + } public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) - => default; // no cache, nothing to remove + => new(backendCache.RemoveAsync(key, token)); public override ValueTask RemoveTagAsync(string tag, CancellationToken token = default) => default; // no cache, nothing to remove public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) => default; // no cache, nothing to set - - internal IHybridCacheSerializer GetSerializer() - { - // unused API, primarily intended to show configuration is working; - // the real version would memoize the result - var service = services.GetServices>().LastOrDefault(); - if (service is null) - { - foreach (var factory in services.GetServices()) - { - if (factory.TryCreateSerializer(out var current)) - { - service = current; - } - } - } - return service ?? throw new InvalidOperationException("No serializer configured for type: " + typeof(T).Name); - } } diff --git a/src/Caching/Hybrid/test/ServiceConstructionTests.cs b/src/Caching/Hybrid/test/ServiceConstructionTests.cs index d9515816f222..8e97441fd4f9 100644 --- a/src/Caching/Hybrid/test/ServiceConstructionTests.cs +++ b/src/Caching/Hybrid/test/ServiceConstructionTests.cs @@ -158,5 +158,6 @@ bool IHybridCacheSerializerFactory.TryCreateSerializer(out IHybridCacheSerial return false; } } + private static string Me([CallerMemberName] string caller = "") => caller; } diff --git a/src/Caching/Hybrid/test/StampedeTests.cs b/src/Caching/Hybrid/test/StampedeTests.cs new file mode 100644 index 000000000000..61516f8aaefc --- /dev/null +++ b/src/Caching/Hybrid/test/StampedeTests.cs @@ -0,0 +1,249 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class StampedeTests +{ + static IDisposable GetDefaultCache(out DefaultHybridCache cache) + { + var services = new ServiceCollection(); + services.AddHybridCache(); + var provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + [Theory] + [InlineData(1)] + [InlineData(10)] + public async Task MultipleCallsShareExecution_NoCancellation(int callerCount) + { + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + int executeCount = 0, cancelCount = 0; + Task[] results = new Task[callerCount]; + for (int 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)) + { + throw new TimeoutException("Failed to activate"); + } + Interlocked.Increment(ref executeCount); + ct.ThrowIfCancellationRequested(); // assert not cancelled + return Guid.NewGuid(); + }).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 (int 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)) + { + throw new TimeoutException("Failed to activate"); + } + Interlocked.Increment(ref executeCount); + ct.ThrowIfCancellationRequested(); // assert not cancelled + return Guid.NewGuid(); + }).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, cancelCount = 0; + Task[] results = new Task[callerCount]; + CancellationTokenSource[] cancels = new CancellationTokenSource[callerCount]; + for (int 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)) + { + 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 + } + }, token: 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 (int 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(); + if (!await semaphore.WaitAsync(5_000)) // wait for underlying task to hand back to us + { + 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, cancelCount = 0; + Task[] results = new Task[callerCount]; + CancellationTokenSource[] cancels = new CancellationTokenSource[callerCount]; + for (int 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)) + { + 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 + } + }, token: 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 (int 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 (int 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(); + if (!await semaphore.WaitAsync(5_000)) // wait for underlying task to hand back to us + { + 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]; + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} From 6f572f35f6b5dec9b97f8868e79cc971be6ee642 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 11:19:03 +0100 Subject: [PATCH 22/75] streamline the non-canceled scenario --- .../Internal/DefaultHybridCache.Stampede.cs | 53 ++------ .../DefaultHybridCache.StampedeState.cs | 64 +++++++--- .../DefaultHybridCache.StampedeStateT.cs | 114 ++++++++++++++++-- .../Hybrid/src/Internal/DefaultHybridCache.cs | 34 +++--- src/Caching/Hybrid/test/StampedeTests.cs | 14 ++- 5 files changed, 187 insertions(+), 92 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs index a38e423d4ae2..3f6387a3aa35 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs @@ -3,10 +3,7 @@ using System; using System.Collections.Concurrent; -using System.Diagnostics; using System.Diagnostics.CodeAnalysis; -using System.Threading; -using System.Threading.Tasks; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -21,12 +18,12 @@ internal int DebugGetCallerCount(string key, HybridCacheEntryFlags flags = Hybri } // returns true for a new session (in which case: we need to start the work), false for a pre-existing session - public bool GetOrCreateStampede(string key, HybridCacheEntryFlags flags, out StampedeState state) + public bool GetOrCreateStampede(string key, HybridCacheEntryFlags flags, out StampedeState stampedeState, bool canBeCanceled) { var stampedeKey = new StampedeKey(key, flags); if (currentOperations.TryGetValue(stampedeKey, out var found)) { - var tmp = found as StampedeState; + var tmp = found as StampedeState; if (tmp is null) { ThrowWrongType(key); @@ -35,52 +32,20 @@ public bool GetOrCreateStampede(string key, HybridCacheEntryFlags flags, out if (tmp.TryAddCaller()) { // we joined an existing session - state = tmp; + stampedeState = tmp; return false; } } // create a new session - state = new StampedeState(stampedeKey); - currentOperations[stampedeKey] = state; + stampedeState = new StampedeState(currentOperations, stampedeKey, canBeCanceled); + currentOperations[stampedeKey] = stampedeState; return true; - } - - private static ValueTask JoinAsync(StampedeState stampede, CancellationToken token) - { - return token.CanBeCanceled ? WithCancellation(stampede, token) : new(stampede.Task); - static async ValueTask WithCancellation(StampedeState stampede, CancellationToken token) + [DoesNotReturn] + static void ThrowWrongType(string key) => throw new InvalidOperationException($"All calls to {nameof(HybridCache)} with the same key should use the same data type") { - var cancelStub = new TaskCompletionSource(); - using var reg = token.Register(static obj => - { - ((TaskCompletionSource)obj!).TrySetResult(true); - }, cancelStub); - - try - { - var first = await Task.WhenAny(stampede.Task, cancelStub.Task).ConfigureAwait(false); - if (ReferenceEquals(first, cancelStub.Task)) - { - // we expect this to throw, because otherwise we wouldn't have gotten here - token.ThrowIfCancellationRequested(); // get an appropriate exception - } - Debug.Assert(ReferenceEquals(first, stampede.Task)); - - // this has already completed, but we'll get the stack nicely - return await stampede.Task.ConfigureAwait(false); - } - finally - { - stampede.RemoveCaller(); - } - } + Data = { { "CacheKey", key } } + }; } - - [DoesNotReturn] - static void ThrowWrongType(string key) => throw new InvalidOperationException($"All calls to {nameof(HybridCache)} with the same key should use the same data type") - { - Data = { { "CacheKey", key } } - }; } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index e3c2114ee845..a0794c492e47 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -1,58 +1,84 @@ // 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.Concurrent; +using System.Runtime.CompilerServices; using System.Threading; -using System.Threading.Tasks; namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { internal abstract class StampedeState +#if NETCOREAPP3_0_OR_GREATER + : IThreadPoolWorkItem +#endif { - protected StampedeState(in StampedeKey key) => Key = key; + private readonly ConcurrentDictionary currentOperations; public StampedeKey Key { get; } + protected StampedeState(ConcurrentDictionary currentOperations, in StampedeKey key, bool canBeCanceled) + { + this.currentOperations = currentOperations; + Key = key; + if (canBeCanceled) + { + // if the first (or any) caller can't be cancelled; we'll never get to zero; no point tracking + // (in reality, all callers usually use the same path, so cancellation is usually "all" or "none") + this.sharedCancellation = new(); + } + } + +#if !NETCOREAPP3_0_OR_GREATER + protected static readonly WaitCallback SharedWaitCallback = static obj => Unsafe.As(obj).Execute(); +#endif + + public abstract void Execute(); + + protected void RemoveCurrentOperation() => currentOperations.TryRemove(Key, out _); + public override string ToString() => Key.ToString(); // because multiple callers can enlist, we need to track when the *last* caller cancels // (and keep going until then); that means we need to run with custom cancellation - private readonly CancellationTokenSource sharedCancellation = new(); + private readonly CancellationTokenSource? sharedCancellation; protected abstract void SetCanceled(); - public CancellationToken SharedToken => sharedCancellation.Token; - - protected object SyncLock => this; // not exposed externally; we'll use the instance as the lock + public CancellationToken SharedToken => sharedCancellation?.Token ?? default; public int DebugCallerCount => Volatile.Read(ref activeCallers); private int activeCallers = 1; public void RemoveCaller() { - lock (SyncLock) + // note that TryAddCaller has protections to avoid getting back from zero + if (Interlocked.Decrement(ref activeCallers) == 0) { - if (--activeCallers == 0) - { - // nobody is left, we're done - sharedCancellation.Cancel(); - SetCanceled(); - } + // we're the last to leave; turn off the lights + sharedCancellation?.Cancel(); + SetCanceled(); } } - public bool TryAddCaller() + public bool TryAddCaller() // essentially just interlocked-increment, but with a leading zero check and overflow detection { - lock (SyncLock) + int oldValue = Volatile.Read(ref activeCallers); + do { - if (activeCallers <= 0) + if (oldValue == 0) { return false; // already burned } - activeCallers++; - } - return true; + + var updated = Interlocked.CompareExchange(ref activeCallers, checked(oldValue + 1), oldValue); + if (updated == oldValue) + { + return true; // we exchanged + } + oldValue = updated; // we failed, but we have an updated state + } while (true); } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 4965515cf74a..35b1ef7afe8e 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Collections.Concurrent; +using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -9,19 +11,117 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - - internal sealed class StampedeState : StampedeState + internal sealed class StampedeState : StampedeState { - public StampedeState(in StampedeKey key) : base(key) { } - private readonly TaskCompletionSource result = new(); + private TState? state; + private Func>? underlying; - public Task Task => result.Task; + public StampedeState(ConcurrentDictionary currentOperations, in StampedeKey key, bool canBeCanceled) : base(currentOperations, key, canBeCanceled) { } + + public void QueueUserWorkItem(in TState state, Func> underlying) + { + Debug.Assert(this.underlying is null); + Debug.Assert(underlying is not null); + + // initialize the callback state + this.state = state; + this.underlying = underlying; + +#if NETCOREAPP3_0_OR_GREATER + ThreadPool.UnsafeQueueUserWorkItem(this, false); +#else + ThreadPool.UnsafeQueueUserWorkItem(SharedWaitCallback, this); +#endif + } + + public Task ExecuteDirectAsync(in TState state, Func> underlying) + { + Debug.Assert(this.underlying is null); + Debug.Assert(underlying is not null); + + // initialize the callback state + this.state = state; + this.underlying = underlying; - internal void SetException(Exception ex) => result.TrySetException(ex); + Execute(); + return Task; + } - internal void SetResult(T value) => result.TrySetResult(value); + public override void Execute() + { + try + { + var pending = underlying!(state!, SharedToken); + if (pending.IsCompleted) + { + var underlyingResult = pending.GetAwaiter().GetResult(); + RemoveCurrentOperation(); + result.TrySetResult(underlyingResult); + } + else + { + _ = Awaited(this, pending); + } + } + catch (Exception ex) + { + RemoveCurrentOperation(); + result.TrySetException(ex); + } + + static async Task Awaited(StampedeState @this, ValueTask pending) + { + try + { + var underlyingResult = await pending.ConfigureAwait(false); + @this.RemoveCurrentOperation(); + @this.result.TrySetResult(underlyingResult); + } + catch (Exception ex) + { + @this.RemoveCurrentOperation(); + @this.result.TrySetException(ex); + } + } + } + + public Task Task => result.Task; protected override void SetCanceled() => result.TrySetCanceled(SharedToken); + + public ValueTask JoinAsync(CancellationToken token) + { + // if the underlying has already completed, and/or our local token can't cancel: we + // can simply wrap the shared task; otherwise, we need our own cancellation state + return token.CanBeCanceled && !Task.IsCompleted ? WithCancellation(this, token) : new(Task); + + static async ValueTask WithCancellation(StampedeState stampede, CancellationToken token) + { + var cancelStub = new TaskCompletionSource(); + using var reg = token.Register(static obj => + { + ((TaskCompletionSource)obj!).TrySetResult(true); + }, cancelStub); + + try + { + var first = await System.Threading.Tasks.Task.WhenAny(stampede.Task, cancelStub.Task).ConfigureAwait(false); + if (ReferenceEquals(first, cancelStub.Task)) + { + // we expect this to throw, because otherwise we wouldn't have gotten here + token.ThrowIfCancellationRequested(); // get an appropriate exception + } + Debug.Assert(ReferenceEquals(first, stampede.Task)); + + // this has already completed, but we'll get the stack nicely + return await stampede.Task.ConfigureAwait(false); + } + finally + { + stampede.RemoveCaller(); + } + } + } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 82eae3f05b31..4ba8c335a482 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -56,29 +56,29 @@ public DefaultHybridCache(IOptions options, IDistributedCach public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) { - token.ThrowIfCancellationRequested(); + bool canBeCanceled = token.CanBeCanceled; + if (canBeCanceled) + { + token.ThrowIfCancellationRequested(); + } - if (GetOrCreateStampede(key, HybridCacheEntryFlags.None, out var stampede)) + if (GetOrCreateStampede(key, HybridCacheEntryFlags.None, out var stampede, canBeCanceled)) { // new query; we're responsible for making it happen - _ = Task.Run(async () => + if (canBeCanceled) { - try - { - var result = await underlyingDataCallback(state, stampede.SharedToken).ConfigureAwait(false); - currentOperations.TryRemove(stampede.Key, out _); - stampede.SetResult(result); - } - catch (Exception ex) - { - stampede.SetException(ex); - } - }, stampede.SharedToken); - - return JoinAsync(stampede, token); + // *we* might cancel, but someone else might be depending on the result; start the + // work independently, then we'll with join the outcome + stampede.QueueUserWorkItem(in state, underlyingDataCallback); + } + else + { + // we're going to run to completion; no need to get complicated + return new(stampede.ExecuteDirectAsync(in state, underlyingDataCallback)); + } } - return JoinAsync(stampede, token); + return stampede.JoinAsync(token); } public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) diff --git a/src/Caching/Hybrid/test/StampedeTests.cs b/src/Caching/Hybrid/test/StampedeTests.cs index 61516f8aaefc..fffe6585d6f4 100644 --- a/src/Caching/Hybrid/test/StampedeTests.cs +++ b/src/Caching/Hybrid/test/StampedeTests.cs @@ -24,13 +24,17 @@ static IDisposable GetDefaultCache(out DefaultHybridCache cache) } [Theory] - [InlineData(1)] - [InlineData(10)] - public async Task MultipleCallsShareExecution_NoCancellation(int callerCount) + [InlineData(1, false)] + [InlineData(1, true)] + [InlineData(10, false)] + [InlineData(10, true)] + public async Task MultipleCallsShareExecution_NoCancellation(int callerCount, bool canBeCanceled) { using var scope = GetDefaultCache(out var cache); using var semaphore = new SemaphoreSlim(0); + var token = canBeCanceled ? new CancellationTokenSource().Token : CancellationToken.None; + int executeCount = 0, cancelCount = 0; Task[] results = new Task[callerCount]; for (int i = 0; i < callerCount; i++) @@ -45,7 +49,7 @@ public async Task MultipleCallsShareExecution_NoCancellation(int callerCount) Interlocked.Increment(ref executeCount); ct.ThrowIfCancellationRequested(); // assert not cancelled return Guid.NewGuid(); - }).AsTask(); + }, token: token).AsTask(); } Assert.Equal(callerCount, cache.DebugGetCallerCount(Me())); @@ -79,7 +83,7 @@ public async Task MultipleCallsShareExecution_NoCancellation(int callerCount) Interlocked.Increment(ref executeCount); ct.ThrowIfCancellationRequested(); // assert not cancelled return Guid.NewGuid(); - }).AsTask(); + }, token: token).AsTask(); } Assert.Equal(callerCount, cache.DebugGetCallerCount(Me())); From a41175c0dacb5263f97823c14870dbc8ddffff19 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 13:43:44 +0100 Subject: [PATCH 23/75] L2 --- .../Internal/DefaultHybridCache.CacheItem.cs | 14 ++ .../DefaultHybridCache.ImmutableCacheItem.cs | 18 ++ .../src/Internal/DefaultHybridCache.L2.cs | 67 ++++++++ .../DefaultHybridCache.MutableCacheItem.cs | 39 +++++ .../DefaultHybridCache.Serialization.cs | 2 + .../Internal/DefaultHybridCache.Stampede.cs | 6 +- .../DefaultHybridCache.StampedeKey.cs | 3 + .../DefaultHybridCache.StampedeState.cs | 12 +- .../DefaultHybridCache.StampedeStateT.cs | 112 ++++++++---- .../Hybrid/src/Internal/DefaultHybridCache.cs | 39 ++++- .../Internal/RecyclableArrayBufferWriter.cs | 160 ++++++++++++++++++ src/Caching/Hybrid/src/Internal/readme.md | 20 +++ .../src/Runtime/HybridCacheEntryOptions.cs | 6 + src/Caching/Hybrid/test/StampedeTests.cs | 30 +++- 14 files changed, 484 insertions(+), 44 deletions(-) create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs create mode 100644 src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs create mode 100644 src/Caching/Hybrid/src/Internal/readme.md diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs new file mode 100644 index 000000000000..92ff1ad08a05 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + internal abstract class CacheItem + { + public abstract T GetValue(); + + public abstract byte[]? TryGetBytes(out int length); + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs new file mode 100644 index 000000000000..148cefbb4f52 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + private sealed class ImmutableCacheItem(T value) : CacheItem + { + public override T GetValue() => value; + + public override byte[]? TryGetBytes(out int length) + { + length = 0; + return null; + } + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs new file mode 100644 index 000000000000..e93f07e9b01e --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -0,0 +1,67 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + internal async Task> GetFromL2Async(string key, CancellationToken token) + { + if ((features & BackendFeatures.Buffers) == 0) + { + var bytes = await backendCache.GetAsync(key, token).ConfigureAwait(false); + if (bytes is not null) + { + if (bytes.Length > MaximumPayloadBytes) + { + ThrowQuota(); + } + return new(bytes); + } + } + else + { + using var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); + var cache = Unsafe.As(backendCache); // type-checked already + if (await cache.TryGetAsync(key, writer, token).ConfigureAwait(false)) + { + return new(writer.DetachCommitted(out var length), 0, length); + } + } + return default; + + static void ThrowQuota() => throw new InvalidOperationException("Maximum cache length exceeded"); + } + + internal ValueTask SetL2Async(string key, byte[] value, int length, HybridCacheEntryOptions? options, CancellationToken token) + { + Debug.Assert(value.Length >= length); + if ((features & BackendFeatures.Buffers) == 0) + { + if (value.Length > length) + { + Array.Resize(ref value, length); + } + return new(backendCache.SetAsync(key, value, GetOptions(options), token)); + } + else + { + var cache = Unsafe.As(backendCache); // type-checked already + return cache.SetAsync(key, new(value, 0, length), GetOptions(options), token); + } + } + + private DistributedCacheEntryOptions GetOptions(HybridCacheEntryOptions? options) + { + DistributedCacheEntryOptions? result = null; + if (options is not null && options.Expiration.HasValue && options.Expiration.GetValueOrDefault() != defaultExpiration) + { + result = options.ToDistributedCacheEntryOptions(); + } + return result ?? defaultDistributedCacheExpiration; + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs new file mode 100644 index 000000000000..a388045f111b --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -0,0 +1,39 @@ +// 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; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + private sealed class MutableCacheItem : CacheItem + { + public MutableCacheItem(byte[] bytes, int length, IHybridCacheSerializer serializer) + { + this.serializer = serializer; + this.bytes = bytes; + this.length = length; + } + + public MutableCacheItem(T value, IHybridCacheSerializer serializer, int maxLength) + { + this.serializer = serializer; + using var writer = new RecyclableArrayBufferWriter(maxLength); + serializer.Serialize(value, writer); + bytes = writer.DetachCommitted(out length); + } + + private readonly IHybridCacheSerializer serializer; + private readonly byte[] bytes; + private readonly int length; + + public override T GetValue() => serializer.Deserialize(new ReadOnlySequence(bytes, 0, length)); + + public override byte[]? TryGetBytes(out int length) + { + length = this.length; + return bytes; + } + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs index 329896ba3365..67faa697b1cd 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs @@ -13,6 +13,8 @@ partial class DefaultHybridCache { private readonly ConcurrentDictionary serializers = new(); // per instance cache of typed serializers + internal int MaximumPayloadBytes { get; } + internal IHybridCacheSerializer GetSerializer() { return serializers.TryGetValue(typeof(T), out var serializer) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs index 3f6387a3aa35..1de00053eab4 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs @@ -11,9 +11,9 @@ partial class DefaultHybridCache { private readonly ConcurrentDictionary currentOperations = new(); - internal int DebugGetCallerCount(string key, HybridCacheEntryFlags flags = HybridCacheEntryFlags.None) + internal int DebugGetCallerCount(string key, HybridCacheEntryFlags? flags = null) { - var stampedeKey = new StampedeKey(key, flags); + var stampedeKey = new StampedeKey(key, flags ?? defaultFlags); return currentOperations.TryGetValue(stampedeKey, out var state) ? state.DebugCallerCount : 0; } @@ -38,7 +38,7 @@ public bool GetOrCreateStampede(string key, HybridCacheEntryFlags fla } // create a new session - stampedeState = new StampedeState(currentOperations, stampedeKey, canBeCanceled); + stampedeState = new StampedeState(this, stampedeKey, canBeCanceled); currentOperations[stampedeKey] = stampedeState; return true; diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs index 55b65d411dfa..6abdd4266e39 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs @@ -20,6 +20,9 @@ public StampedeKey(string key, HybridCacheEntryFlags flags) this.hashCode = key.GetHashCode() ^ (int)flags; } + public string Key => key; + public HybridCacheEntryFlags Flags => flags; + public bool Equals(StampedeKey other) => this.flags == other.flags & this.key == other.key; public override bool Equals([NotNullWhen(true)] object? obj) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index a0794c492e47..66d3346cb5c6 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -14,13 +14,13 @@ internal abstract class StampedeState : IThreadPoolWorkItem #endif { - private readonly ConcurrentDictionary currentOperations; + private readonly DefaultHybridCache cache; public StampedeKey Key { get; } - protected StampedeState(ConcurrentDictionary currentOperations, in StampedeKey key, bool canBeCanceled) + protected StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) { - this.currentOperations = currentOperations; + this.cache = cache; Key = key; if (canBeCanceled) { @@ -34,9 +34,11 @@ protected StampedeState(ConcurrentDictionary current protected static readonly WaitCallback SharedWaitCallback = static obj => Unsafe.As(obj).Execute(); #endif + protected DefaultHybridCache Cache => cache; + public abstract void Execute(); - protected void RemoveCurrentOperation() => currentOperations.TryRemove(Key, out _); + protected int MaximumPayloadBytes => cache.MaximumPayloadBytes; public override string ToString() => Key.ToString(); @@ -81,4 +83,6 @@ public bool TryAddCaller() // essentially just interlocked-increment, but with a } while (true); } } + + private void RemoveStampede(StampedeKey key) => currentOperations.TryRemove(key, out _); } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 35b1ef7afe8e..5c9c45d2b6d5 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Concurrent; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; @@ -13,13 +12,15 @@ partial class DefaultHybridCache { internal sealed class StampedeState : StampedeState { - private readonly TaskCompletionSource result = new(); + private readonly TaskCompletionSource> result = new(); private TState? state; private Func>? underlying; + private HybridCacheEntryOptions? options; - public StampedeState(ConcurrentDictionary currentOperations, in StampedeKey key, bool canBeCanceled) : base(currentOperations, key, canBeCanceled) { } + public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) + : base(cache, key, canBeCanceled) { } - public void QueueUserWorkItem(in TState state, Func> underlying) + public void QueueUserWorkItem(in TState state, Func> underlying, HybridCacheEntryOptions? options) { Debug.Assert(this.underlying is null); Debug.Assert(underlying is not null); @@ -27,6 +28,7 @@ public void QueueUserWorkItem(in TState state, Func ExecuteDirectAsync(in TState state, Func> underlying) + public ValueTask ExecuteDirectAsync(in TState state, Func> underlying, HybridCacheEntryOptions? options) { Debug.Assert(this.underlying is null); Debug.Assert(underlying is not null); @@ -43,50 +45,94 @@ public Task ExecuteDirectAsync(in TState state, Func _ = BackgroundFetchAsync(); + + private async Task BackgroundFetchAsync() { try { - var pending = underlying!(state!, SharedToken); - if (pending.IsCompleted) + // read from L2 if appropriate + if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheRead) == 0) { - var underlyingResult = pending.GetAwaiter().GetResult(); - RemoveCurrentOperation(); - result.TrySetResult(underlyingResult); + var result = await Cache.GetFromL2Async(Key.Key, SharedToken).ConfigureAwait(false); + + // need to distinguish "valid but empty" from "nothing came back" (thanks protobuf!) + if (result.Array is not null) + { + SetResult(result); + return; + } } - else + + // nothing from L2; invoke the underlying data store + var cacheItem = SetResult(await underlying!(state!, SharedToken).ConfigureAwait(false)); + + // note that at this point we've already released most or all of the waiting callers; everything + // else here is background + + // write to L2 if appropriate + if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) { - _ = Awaited(this, pending); + var bytes = cacheItem.TryGetBytes(out int length); + if (bytes is not null) + { + // we've already serialized it for the shared cache item + await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + } + else + { + // we'll need to do the serialize ourselves + using var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); // note this lifetime spans the SetL2Async + bytes = writer.GetBuffer(out length); + await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + } } } catch (Exception ex) { - RemoveCurrentOperation(); - result.TrySetException(ex); + SetException(ex); } + } - static async Task Awaited(StampedeState @this, ValueTask pending) - { - try - { - var underlyingResult = await pending.ConfigureAwait(false); - @this.RemoveCurrentOperation(); - @this.result.TrySetResult(underlyingResult); - } - catch (Exception ex) - { - @this.RemoveCurrentOperation(); - @this.result.TrySetException(ex); - } - } + public Task> Task => result.Task; + + private void SetException(Exception ex) + { + Cache.RemoveStampede(Key); + result.TrySetException(ex); } - public Task Task => result.Task; + private void SetResult(ArraySegment value) + { + // set a result from L2 cache + Debug.Assert(value.Array is not null && value.Offset == 0); + + var serializer = Cache.GetSerializer(); + CacheItem cacheItem = ImmutableTypeCache.IsImmutable + ? new ImmutableCacheItem(serializer.Deserialize(new(value.Array!, value.Offset, value.Count))) // deserialize + : new MutableCacheItem(value.Array!, value.Count, Cache.GetSerializer()); // store the same bytes + + Cache.RemoveStampede(Key); + result.TrySetResult(cacheItem); + } + + private CacheItem SetResult(T value) + { + // set a result from a value we calculated directly + CacheItem cacheItem = ImmutableTypeCache.IsImmutable + ? new ImmutableCacheItem(value) // no serialize needed + : new MutableCacheItem(value, Cache.GetSerializer(), MaximumPayloadBytes); // serialization happens here + + Cache.RemoveStampede(Key); + result.TrySetResult(cacheItem); + return cacheItem; + } protected override void SetCanceled() => result.TrySetCanceled(SharedToken); @@ -94,7 +140,7 @@ public ValueTask JoinAsync(CancellationToken token) { // if the underlying has already completed, and/or our local token can't cancel: we // can simply wrap the shared task; otherwise, we need our own cancellation state - return token.CanBeCanceled && !Task.IsCompleted ? WithCancellation(this, token) : new(Task); + return token.CanBeCanceled && !Task.IsCompleted ? WithCancellation(this, token) : UnwrapAsync(Task); static async ValueTask WithCancellation(StampedeState stampede, CancellationToken token) { @@ -115,7 +161,7 @@ static async ValueTask WithCancellation(StampedeState stampede, Ca Debug.Assert(ReferenceEquals(first, stampede.Task)); // this has already completed, but we'll get the stack nicely - return await stampede.Task.ConfigureAwait(false); + return (await stampede.Task.ConfigureAwait(false)).GetValue(); } finally { diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 4ba8c335a482..2a2cc6b0606a 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -24,6 +24,12 @@ internal sealed partial class DefaultHybridCache : HybridCache private readonly HybridCacheOptions options; private readonly BackendFeatures features; + private readonly HybridCacheEntryFlags defaultFlags; + private readonly TimeSpan defaultExpiration; + private readonly TimeSpan defaultLocalCacheExpiration; + + private readonly DistributedCacheEntryOptions defaultDistributedCacheExpiration; + [Flags] private enum BackendFeatures { @@ -49,7 +55,17 @@ public DefaultHybridCache(IOptions options, IDistributedCach var factories = services.GetServices().ToArray(); Array.Reverse(factories); this.serializerFactories = factories; + + MaximumPayloadBytes = checked((int)this.options.MaximumPayloadBytes); // for now hard-limit to 2GiB + + var defaultEntryOptions = this.options.DefaultEntryOptions; + defaultFlags = defaultEntryOptions?.Flags ?? HybridCacheEntryFlags.None; + defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(5); + defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1); + + defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = defaultExpiration }; } + internal HybridCacheOptions Options => options; private bool BackendBuffers => (features & BackendFeatures.Buffers) != 0; @@ -62,25 +78,42 @@ public override ValueTask GetOrCreateAsync(string key, TState stat token.ThrowIfCancellationRequested(); } - if (GetOrCreateStampede(key, HybridCacheEntryFlags.None, out var stampede, canBeCanceled)) + var flags = options?.Flags ?? defaultFlags; + if (GetOrCreateStampede(key, flags, out var stampede, canBeCanceled)) { // new query; we're responsible for making it happen if (canBeCanceled) { // *we* might cancel, but someone else might be depending on the result; start the // work independently, then we'll with join the outcome - stampede.QueueUserWorkItem(in state, underlyingDataCallback); + stampede.QueueUserWorkItem(in state, underlyingDataCallback, options); } else { // we're going to run to completion; no need to get complicated - return new(stampede.ExecuteDirectAsync(in state, underlyingDataCallback)); + return stampede.ExecuteDirectAsync(in state, underlyingDataCallback, options); } } return stampede.JoinAsync(token); } + static ValueTask UnwrapAsync(Task> task) + { +#if NETCOREAPP2_0_OR_GREATER + if (task.IsCompletedSuccessfully) +#else + if (task.Status == TaskStatus.RanToCompletion) +#endif + { + return new(task.Result.GetValue()); + } + return Awaited(task); + + static async ValueTask Awaited(Task> task) + => (await task.ConfigureAwait(false)).GetValue(); + } + public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) => new(backendCache.RemoveAsync(key, token)); diff --git a/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs b/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs new file mode 100644 index 000000000000..0ee64a9bc016 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs @@ -0,0 +1,160 @@ +// 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.Buffers; +using System.Diagnostics; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +// this is effectively a cut-down re-implementation of ArrayBufferWriter +// from https://github.com/dotnet/runtime/blob/6cd9bf1937c3b4d2f7304a6c534aacde58a202b6/src/libraries/Common/src/System/Buffers/ArrayBufferWriter.cs +// except it uses the array pool for allocations +internal sealed class RecyclableArrayBufferWriter : IBufferWriter, IDisposable +{ + + // Copy of Array.MaxLength. + // Used by projects targeting .NET Framework. + private const int ArrayMaxLength = 0x7FFFFFC7; + + private const int DefaultInitialBufferSize = 256; + + private T[] _buffer; + private int _index; + private readonly int _maxLength; + + public int FreeCapacity => _buffer.Length - _index; + + public RecyclableArrayBufferWriter(int maxLength) + { + _buffer = Array.Empty(); + _index = 0; + _maxLength = maxLength; + } + + public void Dispose() + { + var tmp = _buffer; + _index = 0; + _buffer = Array.Empty(); + if (tmp.Length != 0) + { + ArrayPool.Shared.Return(tmp); + } + } + + public void Advance(int count) + { + if (count < 0) + { + throw new ArgumentException(null, nameof(count)); + } + + if (_index > _buffer.Length - count) + { + ThrowCount(); + } + + if (_index + count > _maxLength) + { + ThrowQuota(); + } + + _index += count; + + static void ThrowCount() + => throw new ArgumentOutOfRangeException(nameof(count)); + + static void ThrowQuota() + => throw new InvalidOperationException("Max length exceeded"); + } + + /// + /// Disconnect the current buffer so that we can store it without it being recycled + /// + internal T[] DetachCommitted(out int length) + { + var tmp = _index == 0 ? [] : _buffer; + length = _index; + + _buffer = []; + _index = 0; + + return tmp; + } + + internal T[] GetBuffer(out int length) + { + length = _index; + return _index == 0 ? [] : _buffer; + } + + public ReadOnlyMemory GetCommittedMemory() => new ReadOnlyMemory(_buffer, 0, _index); // could also directly expose a ReadOnlySpan if useful + + public Memory GetMemory(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return _buffer.AsMemory(_index); + } + + public Span GetSpan(int sizeHint = 0) + { + CheckAndResizeBuffer(sizeHint); + Debug.Assert(_buffer.Length > _index); + return _buffer.AsSpan(_index); + } + + // create a standalone isolated copy of the buffer + public T[] ToArray() => _buffer.AsSpan(0, _index).ToArray(); + + private void CheckAndResizeBuffer(int sizeHint) + { + if (sizeHint <= 0) + { + sizeHint = 1; + } + + if (sizeHint > FreeCapacity) + { + int currentLength = _buffer.Length; + + // Attempt to grow by the larger of the sizeHint and double the current size. + int growBy = Math.Max(sizeHint, currentLength); + + if (currentLength == 0) + { + growBy = Math.Max(growBy, DefaultInitialBufferSize); + } + + int newSize = currentLength + growBy; + + if ((uint)newSize > int.MaxValue) + { + // Attempt to grow to ArrayMaxLength. + uint needed = (uint)(currentLength - FreeCapacity + sizeHint); + Debug.Assert(needed > currentLength); + + if (needed > ArrayMaxLength) + { + ThrowOutOfMemoryException(); + } + + newSize = ArrayMaxLength; + } + + // resize the backing buffer + var oldArray = _buffer; + _buffer = ArrayPool.Shared.Rent(newSize); + oldArray.AsSpan(0, _index).CopyTo(_buffer); + if (oldArray.Length != 0) + { + ArrayPool.Shared.Return(oldArray); + } + } + + Debug.Assert(FreeCapacity > 0 && FreeCapacity >= sizeHint); + + static void ThrowOutOfMemoryException() => throw new InvalidOperationException("Unable to grow buffer as requested"); + } +} diff --git a/src/Caching/Hybrid/src/Internal/readme.md b/src/Caching/Hybrid/src/Internal/readme.md new file mode 100644 index 000000000000..8d20f4f43c27 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/readme.md @@ -0,0 +1,20 @@ +# HybridCache internal design + +`HybridCache` encapsulates serialization, caching and stampede protection. + +The `DefaultHybridCache` implementation keeps a collection of `StampedeState` entries +that represent the current in-flight operations (keyed by `StampedeKey`); if a duplicate +operation occurs during the execution, the second operation will be joined with that +same flow, rather than executing independently. + +The `StampedeState<>` performs back-end fetch operations, resulting not in a `T` (of the final +value), but instead a `CacheItem`; this is the object that gets put into L1 cache, +and can describe both mutable and immutable types; the significance here is that for +mutable types, we need a defensive copy per-call to prevent callers impacting each-other. + +`StampedeState<>` combines cancellation (so that operations proceed as long as *a* caller +is still active); this covers all L2 access and serialization operations, releasing all pending +shared callers for the same operation. Note that L2 storage can occur *after* callers +have been released. + + diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs index 6e8491759f92..e55cd35b5fcf 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using Microsoft.Extensions.Caching.Distributed; namespace Microsoft.Extensions.Caching.Hybrid; @@ -29,4 +30,9 @@ public sealed class HybridCacheEntryOptions /// Additional flags that apply to this usage. /// public HybridCacheEntryFlags? Flags { get; init; } + + // memoize when possible + private DistributedCacheEntryOptions? dc; + internal DistributedCacheEntryOptions? ToDistributedCacheEntryOptions() + => Expiration is null ? null : (dc ??= new() { AbsoluteExpirationRelativeToNow = Expiration }); } diff --git a/src/Caching/Hybrid/test/StampedeTests.cs b/src/Caching/Hybrid/test/StampedeTests.cs index fffe6585d6f4..4756c3e5e935 100644 --- a/src/Caching/Hybrid/test/StampedeTests.cs +++ b/src/Caching/Hybrid/test/StampedeTests.cs @@ -8,8 +8,10 @@ using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; namespace Microsoft.Extensions.Caching.Hybrid.Tests; public class StampedeTests @@ -17,12 +19,38 @@ public class StampedeTests static IDisposable GetDefaultCache(out DefaultHybridCache cache) { var services = new ServiceCollection(); - services.AddHybridCache(); + services.AddSingleton(); + services.AddHybridCache(options => + { + options.DefaultEntryOptions = new() + { + Flags = HybridCacheEntryFlags.DisableDistributedCache + }; + }); var provider = services.BuildServiceProvider(); cache = Assert.IsType(provider.GetRequiredService()); return provider; } + public class InvalidDistributedCache : IDistributedCache + { + 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"); + + 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"); + } + [Theory] [InlineData(1, false)] [InlineData(1, true)] From 82a34e32871773e8a0fed9a2dd4f7e75fa3b178c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 14:31:27 +0100 Subject: [PATCH 24/75] L1 --- .../DefaultHybridCache.ImmutableCacheItem.cs | 3 + .../src/Internal/DefaultHybridCache.L2.cs | 4 ++ .../DefaultHybridCache.StampedeStateT.cs | 69 +++++++++++++------ .../Hybrid/src/Internal/DefaultHybridCache.cs | 11 ++- src/Caching/Hybrid/test/StampedeTests.cs | 15 +++- 5 files changed, 76 insertions(+), 26 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs index 148cefbb4f52..1da4ee4badca 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -7,6 +7,9 @@ partial class DefaultHybridCache { private sealed class ImmutableCacheItem(T value) : CacheItem { + private static ImmutableCacheItem? sharedDefault; + public static ImmutableCacheItem Default => sharedDefault ??= new(default!); // this is only used when the underlying store is disabled + public override T GetValue() => value; public override byte[]? TryGetBytes(out int length) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs index e93f07e9b01e..67e3b3e2276b 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -4,6 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -64,4 +65,7 @@ private DistributedCacheEntryOptions GetOptions(HybridCacheEntryOptions? options } return result ?? defaultDistributedCacheExpiration; } + + internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions? options) + => localCache.Set(key, value, options?.LocalCacheExpiration ?? defaultLocalCacheExpiration); } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 5c9c45d2b6d5..978eee5c2cf6 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using static Microsoft.Extensions.Caching.Hybrid.Internal.DefaultHybridCache; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -62,7 +63,6 @@ private async Task BackgroundFetchAsync() { var result = await Cache.GetFromL2Async(Key.Key, SharedToken).ConfigureAwait(false); - // need to distinguish "valid but empty" from "nothing came back" (thanks protobuf!) if (result.Array is not null) { SetResult(result); @@ -71,28 +71,37 @@ private async Task BackgroundFetchAsync() } // nothing from L2; invoke the underlying data store - var cacheItem = SetResult(await underlying!(state!, SharedToken).ConfigureAwait(false)); + if ((Key.Flags & HybridCacheEntryFlags.DisableUnderlyingData) == 0) + { + var cacheItem = SetResult(await underlying!(state!, SharedToken).ConfigureAwait(false)); - // note that at this point we've already released most or all of the waiting callers; everything - // else here is background + // note that at this point we've already released most or all of the waiting callers; everything + // else here is background - // write to L2 if appropriate - if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) - { - var bytes = cacheItem.TryGetBytes(out int length); - if (bytes is not null) + // write to L2 if appropriate + if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) { - // we've already serialized it for the shared cache item - await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); - } - else - { - // we'll need to do the serialize ourselves - using var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); // note this lifetime spans the SetL2Async - bytes = writer.GetBuffer(out length); - await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + var bytes = cacheItem.TryGetBytes(out int length); + if (bytes is not null) + { + // we've already serialized it for the shared cache item + await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + } + else + { + // we'll need to do the serialize ourselves + using var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); // note this lifetime spans the SetL2Async + bytes = writer.GetBuffer(out length); + await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + } } } + else + { + // can't read from data store; implies we shouldn't write + // back to anywhere else, either + SetDefaultResult(); + } } catch (Exception ex) { @@ -108,6 +117,24 @@ private void SetException(Exception ex) result.TrySetException(ex); } + private void SetResult(CacheItem value) + { + if ((Key.Flags & HybridCacheEntryFlags.DisableLocalCacheWrite) == 0) + { + Cache.SetL1(Key.Key, value, options); + } + + Cache.RemoveStampede(Key); + result.TrySetResult(value); + } + + private void SetDefaultResult() + { + // note we don't store this dummy result in L1 or L2 + Cache.RemoveStampede(Key); + result.TrySetResult(ImmutableCacheItem.Default); + } + private void SetResult(ArraySegment value) { // set a result from L2 cache @@ -118,8 +145,7 @@ private void SetResult(ArraySegment value) ? new ImmutableCacheItem(serializer.Deserialize(new(value.Array!, value.Offset, value.Count))) // deserialize : new MutableCacheItem(value.Array!, value.Count, Cache.GetSerializer()); // store the same bytes - Cache.RemoveStampede(Key); - result.TrySetResult(cacheItem); + SetResult(cacheItem); } private CacheItem SetResult(T value) @@ -129,8 +155,7 @@ private CacheItem SetResult(T value) ? new ImmutableCacheItem(value) // no serialize needed : new MutableCacheItem(value, Cache.GetSerializer(), MaximumPayloadBytes); // serialization happens here - Cache.RemoveStampede(Key); - result.TrySetResult(cacheItem); + SetResult(cacheItem); return cacheItem; } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 2a2cc6b0606a..71e3a1798971 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; @@ -19,6 +20,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; internal sealed partial class DefaultHybridCache : HybridCache { private readonly IDistributedCache backendCache; + private readonly IMemoryCache localCache; private readonly IServiceProvider services; private readonly IHybridCacheSerializerFactory[] serializerFactories; private readonly HybridCacheOptions options; @@ -37,9 +39,10 @@ private enum BackendFeatures Buffers = 1 << 0, } - public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IServiceProvider services) + public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IMemoryCache localCache, IServiceProvider services) { this.backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); + this.localCache = localCache ?? throw new ArgumentNullException(nameof(localCache)); this.services = services ?? throw new ArgumentNullException(nameof(services)); this.options = options.Value; @@ -79,6 +82,12 @@ public override ValueTask GetOrCreateAsync(string key, TState stat } var flags = options?.Flags ?? defaultFlags; + if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && localCache.TryGetValue(key, out var untyped) && untyped is CacheItem typed) + { + // short-circuit + return new(typed.GetValue()); + } + if (GetOrCreateStampede(key, flags, out var stampede, canBeCanceled)) { // new query; we're responsible for making it happen diff --git a/src/Caching/Hybrid/test/StampedeTests.cs b/src/Caching/Hybrid/test/StampedeTests.cs index 4756c3e5e935..bc890ea0b531 100644 --- a/src/Caching/Hybrid/test/StampedeTests.cs +++ b/src/Caching/Hybrid/test/StampedeTests.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -19,12 +20,13 @@ public class StampedeTests static IDisposable GetDefaultCache(out DefaultHybridCache cache) { var services = new ServiceCollection(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddHybridCache(options => { options.DefaultEntryOptions = new() { - Flags = HybridCacheEntryFlags.DisableDistributedCache + Flags = HybridCacheEntryFlags.DisableDistributedCache | HybridCacheEntryFlags.DisableLocalCache }; }); var provider = services.BuildServiceProvider(); @@ -32,8 +34,11 @@ static IDisposable GetDefaultCache(out DefaultHybridCache cache) return provider; } - public class InvalidDistributedCache : IDistributedCache + public class InvalidCache : IDistributedCache, IMemoryCache { + void IDisposable.Dispose() { } + 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"); @@ -44,11 +49,15 @@ public class InvalidDistributedCache : IDistributedCache 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] From e3a917301674819f2948c87ebb529f8b3a92956b Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 15:52:50 +0100 Subject: [PATCH 25/75] L2 tests --- src/Caching/Hybrid/src/HybridCacheOptions.cs | 5 +- .../DefaultHybridCache.StampedeStateT.cs | 5 +- .../Hybrid/src/Internal/DefaultHybridCache.cs | 10 +- src/Caching/Hybrid/test/L2Tests.cs | 188 ++++++++++++++++++ 4 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 src/Caching/Hybrid/test/L2Tests.cs diff --git a/src/Caching/Hybrid/src/HybridCacheOptions.cs b/src/Caching/Hybrid/src/HybridCacheOptions.cs index 62407b9bf6a9..a181250f1ec6 100644 --- a/src/Caching/Hybrid/src/HybridCacheOptions.cs +++ b/src/Caching/Hybrid/src/HybridCacheOptions.cs @@ -6,13 +6,14 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Caching.Hybrid; /// /// Options for configuring the default implementation. /// -public class HybridCacheOptions +public class HybridCacheOptions : IOptions { /// /// Default global options to be applied to operations; if options are @@ -45,4 +46,6 @@ public class HybridCacheOptions /// tags do not contain data that should not be visible in metrics systems. /// public bool ReportTagMetrics { get; set; } + + HybridCacheOptions IOptions.Value => this; } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 978eee5c2cf6..6c9c1298757e 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -84,13 +84,14 @@ private async Task BackgroundFetchAsync() var bytes = cacheItem.TryGetBytes(out int length); if (bytes is not null) { - // we've already serialized it for the shared cache item + // mutable; we've already serialized it for the shared cache item await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); } else { - // we'll need to do the serialize ourselves + // immutable: we'll need to do the serialize ourselves using var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); // note this lifetime spans the SetL2Async + Cache.GetSerializer().Serialize(cacheItem.GetValue(), writer); // note GetValue() is fixed value here bytes = writer.GetBuffer(out length); await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 71e3a1798971..bd1770120c20 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -69,6 +69,9 @@ public DefaultHybridCache(IOptions options, IDistributedCach defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = defaultExpiration }; } + internal IDistributedCache BackendCache => backendCache; + internal IMemoryCache LocalCache => localCache; + internal HybridCacheOptions Options => options; private bool BackendBuffers => (features & BackendFeatures.Buffers) != 0; @@ -124,10 +127,13 @@ static async ValueTask Awaited(Task> task) } public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) - => new(backendCache.RemoveAsync(key, token)); + { + localCache.Remove(key); + return new(backendCache.RemoveAsync(key, token)); + } public override ValueTask RemoveTagAsync(string tag, CancellationToken token = default) - => default; // no cache, nothing to remove + => default; // tags not yet implemented public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) => default; // no cache, nothing to set diff --git a/src/Caching/Hybrid/test/L2Tests.cs b/src/Caching/Hybrid/test/L2Tests.cs new file mode 100644 index 000000000000..1b799e7ff01e --- /dev/null +++ b/src/Caching/Hybrid/test/L2Tests.cs @@ -0,0 +1,188 @@ +// 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.Collections; +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.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit.Abstractions; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class L2Tests(ITestOutputHelper Log) +{ + class Options(T Value) : IOptions where T : class + { + T IOptions.Value => Value; + } + IDisposable 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(); + var provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + static string CreateString(bool work = false) + { + Assert.True(work, "we didn't expect this to be invoked"); + return Guid.NewGuid().ToString(); + } + + static readonly HybridCacheEntryOptions NoL1 = new HybridCacheEntryOptions { Flags = HybridCacheEntryFlags.DisableLocalCache }; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task AssertL2Operations(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 (int 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 (int i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString()), NoL1); + Assert.Equal(s, x); + Assert.NotSame(s, x); + } + Assert.Equal(7, backend.OpCount); // should be read every time + + Log.WriteLine("Removing key..."); + await cache.RemoveKeyAsync(Me()); + Assert.Equal(8, backend.OpCount); // DEL + + Log.WriteLine("Fetching new..."); + var t = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(CreateString(true))); + Assert.NotEqual(s, t); + Assert.Equal(10, backend.OpCount); // GET, SET + } + + class BufferLoggingCache(ITestOutputHelper Log, IDistributedCache Tail) : LoggingCache(Log, Tail), IBufferDistributedCache + { + void IBufferDistributedCache.Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) + { + Interlocked.Increment(ref opcount); + 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 opcount); + 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 opcount); + 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 opcount); + Log.WriteLine($"TryGetAsync: {key}"); + var buffer = await Tail.GetAsync(key, token); + if (buffer is null) + { + return false; + } + destination.Write(buffer); + return true; + } + } + + class LoggingCache(ITestOutputHelper Log, IDistributedCache Tail) : IDistributedCache + { + protected int opcount; + public int OpCount => Volatile.Read(ref opcount); + + byte[]? IDistributedCache.Get(string key) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"Get: {key}"); + return Tail.Get(key); + } + + Task IDistributedCache.GetAsync(string key, CancellationToken token) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"GetAsync: {key}"); + return Tail.GetAsync(key, token); + } + + void IDistributedCache.Refresh(string key) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"Refresh: {key}"); + Tail.Refresh(key); + } + + Task IDistributedCache.RefreshAsync(string key, CancellationToken token) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"RefreshAsync: {key}"); + return Tail.RefreshAsync(key, token); + } + + void IDistributedCache.Remove(string key) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"Remove: {key}"); + Tail.Remove(key); + } + + Task IDistributedCache.RemoveAsync(string key, CancellationToken token) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"RemoveAsync: {key}"); + return Tail.RemoveAsync(key, token); + } + + void IDistributedCache.Set(string key, byte[] value, DistributedCacheEntryOptions options) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"Set (byte[]): {key}"); + Tail.Set(key, value, options); + } + + Task IDistributedCache.SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token) + { + Interlocked.Increment(ref opcount); + Log.WriteLine($"SetAsync (byte[]): {key}"); + return Tail.SetAsync(key, value, options); + } + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} From 373aaa89cc369b0e412952dcdb4076c9ee50cba0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 15:57:02 +0100 Subject: [PATCH 26/75] mutable/immutable tests --- .../DefaultHybridCache.ImmutableCacheItem.cs | 2 +- .../DefaultHybridCache.MutableCacheItem.cs | 2 +- src/Caching/Hybrid/test/L2Tests.cs | 46 ++++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs index 1da4ee4badca..b7892eaaf2bd 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -5,7 +5,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - private sealed class ImmutableCacheItem(T value) : CacheItem + private sealed class ImmutableCacheItem(T value) : CacheItem // used to hold types that do not require defensive copies { private static ImmutableCacheItem? sharedDefault; public static ImmutableCacheItem Default => sharedDefault ??= new(default!); // this is only used when the underlying store is disabled diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs index a388045f111b..26014dcee1a6 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -7,7 +7,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - private sealed class MutableCacheItem : CacheItem + private sealed class MutableCacheItem : CacheItem // used to hold types that require defensive copies { public MutableCacheItem(byte[] bytes, int length, IHybridCacheSerializer serializer) { diff --git a/src/Caching/Hybrid/test/L2Tests.cs b/src/Caching/Hybrid/test/L2Tests.cs index 1b799e7ff01e..b846ce315cf8 100644 --- a/src/Caching/Hybrid/test/L2Tests.cs +++ b/src/Caching/Hybrid/test/L2Tests.cs @@ -43,7 +43,7 @@ static string CreateString(bool work = false) [Theory] [InlineData(true)] [InlineData(false)] - public async Task AssertL2Operations(bool buffers) + public async Task AssertL2Operations_Immutable(bool buffers) { using var provider = GetDefaultCache(buffers, out var cache); var backend = Assert.IsAssignableFrom(cache.BackendCache); @@ -79,6 +79,50 @@ public async Task AssertL2Operations(bool buffers) Assert.Equal(10, 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) })); + Assert.Equal(2, backend.OpCount); // GET, SET + + Log.WriteLine("Reading with L1..."); + for (int i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString() })); + 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 (int i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString() }), NoL1); + Assert.Equal(s.Value, x.Value); + Assert.NotSame(s, x); + } + Assert.Equal(7, backend.OpCount); // should be read every time + + Log.WriteLine("Removing key..."); + await cache.RemoveKeyAsync(Me()); + Assert.Equal(8, backend.OpCount); // DEL + + Log.WriteLine("Fetching new..."); + var t = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString(true) })); + Assert.NotEqual(s.Value, t.Value); + Assert.Equal(10, backend.OpCount); // GET, SET + } + class BufferLoggingCache(ITestOutputHelper Log, IDistributedCache Tail) : LoggingCache(Log, Tail), IBufferDistributedCache { void IBufferDistributedCache.Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) From 8c1cc9e32f84b56d7b04e03cb9b0e6bf1618aa12 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 16:01:12 +0100 Subject: [PATCH 27/75] build warnings --- src/Caching/Hybrid/test/L2Tests.cs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Caching/Hybrid/test/L2Tests.cs b/src/Caching/Hybrid/test/L2Tests.cs index b846ce315cf8..c31a5d588d35 100644 --- a/src/Caching/Hybrid/test/L2Tests.cs +++ b/src/Caching/Hybrid/test/L2Tests.cs @@ -81,7 +81,7 @@ public async Task AssertL2Operations_Immutable(bool buffers) public sealed class Foo { - public string Value { get; set; } + public string Value { get; set; } = ""; } [Theory] @@ -123,8 +123,10 @@ public async Task AssertL2Operations_Mutable(bool buffers) Assert.Equal(10, backend.OpCount); // GET, SET } - class BufferLoggingCache(ITestOutputHelper Log, IDistributedCache Tail) : LoggingCache(Log, Tail), IBufferDistributedCache + 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 opcount); @@ -166,8 +168,11 @@ async ValueTask IBufferDistributedCache.TryGetAsync(string key, IBufferWri } } - class LoggingCache(ITestOutputHelper Log, IDistributedCache Tail) : IDistributedCache + class LoggingCache(ITestOutputHelper log, IDistributedCache tail) : IDistributedCache { + protected ITestOutputHelper Log => log; + protected IDistributedCache Tail => tail; + protected int opcount; public int OpCount => Volatile.Read(ref opcount); From 40e2c478e50da86b2424e5afc41ea829caa686f0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 16:57:00 +0100 Subject: [PATCH 28/75] implement SetValueAsync --- .../DefaultHybridCache.StampedeState.cs | 22 +++++++- .../DefaultHybridCache.StampedeStateT.cs | 52 ++++++++++++++----- .../Hybrid/src/Internal/DefaultHybridCache.cs | 11 +++- src/Caching/Hybrid/test/L2Tests.cs | 30 +++++++++-- 4 files changed, 93 insertions(+), 22 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index 66d3346cb5c6..d49b9b5e581e 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -18,6 +18,9 @@ internal abstract class StampedeState public StampedeKey Key { get; } + /// + /// Create a stamped token optionally with shared cancellation support + /// protected StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) { this.cache = cache; @@ -26,8 +29,23 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBe { // if the first (or any) caller can't be cancelled; we'll never get to zero; no point tracking // (in reality, all callers usually use the same path, so cancellation is usually "all" or "none") - this.sharedCancellation = new(); + sharedCancellation = new(); + SharedToken = sharedCancellation.Token; } + else + { + SharedToken = CancellationToken.None; + } + } + + /// + /// Create a stamped token using a fixed cancellation token + /// + protected StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationToken token) + { + this.cache = cache; + Key = key; + SharedToken = token; } #if !NETCOREAPP3_0_OR_GREATER @@ -48,7 +66,7 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBe protected abstract void SetCanceled(); - public CancellationToken SharedToken => sharedCancellation?.Token ?? default; + public readonly CancellationToken SharedToken; public int DebugCallerCount => Volatile.Read(ref activeCallers); diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 6c9c1298757e..efc0d9bc5a3c 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -13,13 +13,20 @@ partial class DefaultHybridCache { internal sealed class StampedeState : StampedeState { - private readonly TaskCompletionSource> result = new(); + private readonly TaskCompletionSource>? result; private TState? state; private Func>? underlying; + private HybridCacheEntryOptions? options; public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) - : base(cache, key, canBeCanceled) { } + : base(cache, key, canBeCanceled) + { + result = new(); + } + + public StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationToken token) + : base(cache, key, token) { } // no TCS in this case - this is for SetValue only public void QueueUserWorkItem(in TState state, Func> underlying, HybridCacheEntryOptions? options) { @@ -38,7 +45,7 @@ public void QueueUserWorkItem(in TState state, Func ExecuteDirectAsync(in TState state, Func> underlying, HybridCacheEntryOptions? options) + public Task ExecuteDirectAsync(in TState state, Func> underlying, HybridCacheEntryOptions? options) { Debug.Assert(this.underlying is null); Debug.Assert(underlying is not null); @@ -48,8 +55,7 @@ public ValueTask ExecuteDirectAsync(in TState state, Func _ = BackgroundFetchAsync(); @@ -110,30 +116,48 @@ private async Task BackgroundFetchAsync() } } - public Task> Task => result.Task; + public Task> Task + { + get + { + Debug.Assert(result is not null); + return result is null ? Invalid() : result.Task; + + static Task> Invalid() => System.Threading.Tasks.Task.FromException>(new InvalidOperationException("Task should not be accessed for non-shared instances")); + } + } private void SetException(Exception ex) { - Cache.RemoveStampede(Key); - result.TrySetException(ex); + if (result is not null) + { + Cache.RemoveStampede(Key); + result.TrySetException(ex); + } } private void SetResult(CacheItem value) { if ((Key.Flags & HybridCacheEntryFlags.DisableLocalCacheWrite) == 0) { - Cache.SetL1(Key.Key, value, options); + Cache.SetL1(Key.Key, value, options); // we can do this without a TCS, for SetValue } - Cache.RemoveStampede(Key); - result.TrySetResult(value); + if (result is not null) + { + Cache.RemoveStampede(Key); + result?.TrySetResult(value); + } } private void SetDefaultResult() { // note we don't store this dummy result in L1 or L2 - Cache.RemoveStampede(Key); - result.TrySetResult(ImmutableCacheItem.Default); + if (result is not null) + { + Cache.RemoveStampede(Key); + result.TrySetResult(ImmutableCacheItem.Default); + } } private void SetResult(ArraySegment value) @@ -160,7 +184,7 @@ private CacheItem SetResult(T value) return cacheItem; } - protected override void SetCanceled() => result.TrySetCanceled(SharedToken); + protected override void SetCanceled() => result?.TrySetCanceled(SharedToken); public ValueTask JoinAsync(CancellationToken token) { diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index bd1770120c20..18d599f5f49f 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -103,7 +103,8 @@ public override ValueTask GetOrCreateAsync(string key, TState stat else { // we're going to run to completion; no need to get complicated - return stampede.ExecuteDirectAsync(in state, underlyingDataCallback, options); + _ = stampede.ExecuteDirectAsync(in state, underlyingDataCallback, options); // this larger task includes L2 write etc + return UnwrapAsync(stampede.Task); } } @@ -136,5 +137,11 @@ public override ValueTask RemoveTagAsync(string tag, CancellationToken token = d => default; // tags not yet implemented public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) - => default; // no cache, nothing to set + { + // since we're forcing a write: disable L1+L2 read; we'll use a direct pass-thru of the value as the callback, to reuse all the code; + // note also that stampede token is not shared with anyone else + var flags = (options?.Flags ?? defaultFlags) | (HybridCacheEntryFlags.DisableLocalCacheRead | HybridCacheEntryFlags.DisableDistributedCacheRead); + var state = new StampedeState(this, new StampedeKey(key, flags), token); + return new(state.ExecuteDirectAsync(value, static (state, _) => new(state), options)); // note this spans L2 write etc + } } diff --git a/src/Caching/Hybrid/test/L2Tests.cs b/src/Caching/Hybrid/test/L2Tests.cs index c31a5d588d35..533de639eca3 100644 --- a/src/Caching/Hybrid/test/L2Tests.cs +++ b/src/Caching/Hybrid/test/L2Tests.cs @@ -69,14 +69,25 @@ public async Task AssertL2Operations_Immutable(bool buffers) } Assert.Equal(7, backend.OpCount); // should be read every time + Log.WriteLine("Setting value directly"); + s = CreateString(true); + await cache.SetAsync(Me(), s); + for (int 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.RemoveKeyAsync(Me()); - Assert.Equal(8, backend.OpCount); // DEL + 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(10, backend.OpCount); // GET, SET + Assert.Equal(11, backend.OpCount); // GET, SET } public sealed class Foo @@ -113,14 +124,25 @@ public async Task AssertL2Operations_Mutable(bool buffers) } 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 (int i = 0; i < 5; i++) + { + var x = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString() })); + Assert.Equal(s.Value, x.Value); + Assert.NotSame(s, x); + } + Assert.Equal(8, backend.OpCount); // SET + Log.WriteLine("Removing key..."); await cache.RemoveKeyAsync(Me()); - Assert.Equal(8, backend.OpCount); // DEL + Assert.Equal(9, backend.OpCount); // DEL Log.WriteLine("Fetching new..."); var t = await cache.GetOrCreateAsync(Me(), ct => new ValueTask(new Foo { Value = CreateString(true) })); Assert.NotEqual(s.Value, t.Value); - Assert.Equal(10, backend.OpCount); // GET, SET + Assert.Equal(11, backend.OpCount); // GET, SET } class BufferLoggingCache : LoggingCache, IBufferDistributedCache From cede0e96c328f3b773e2a642df374d37d8934db1 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Mon, 15 Apr 2024 17:40:20 +0100 Subject: [PATCH 29/75] support ns2.1 --- .../Hybrid/src/Internal/DefaultHybridCache.Serialization.cs | 6 +++++- src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs | 2 +- .../Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs index 67faa697b1cd..50ad0fafdd00 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs @@ -48,7 +48,11 @@ static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @thi private static class ImmutableTypeCache // lazy memoize; T doesn't change per cache instance { - public static readonly bool IsImmutable = DefaultHybridCache.IsImmutable(typeof(T)); + public static readonly bool IsImmutable = +#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + !RuntimeHelpers.IsReferenceOrContainsReferences() || // a pure struct will be a full copy every time +#endif + DefaultHybridCache.IsImmutable(typeof(T)); } internal static bool IsImmutable(Type type) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 18d599f5f49f..c14412be248e 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -113,7 +113,7 @@ public override ValueTask GetOrCreateAsync(string key, TState stat static ValueTask UnwrapAsync(Task> task) { -#if NETCOREAPP2_0_OR_GREATER +#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER if (task.IsCompletedSuccessfully) #else if (task.Status == TaskStatus.RanToCompletion) diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index 49671f048347..c1af3d2f3cb8 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -2,7 +2,7 @@ Multi-level caching implementation building on and extending IDistributedCache - $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework);netstandard2.0 + $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework);netstandard2.0;netstandard2.1 true cache;distributedcache;hybrid true @@ -23,7 +23,7 @@ - + From ad88ef11c7a8020c33c981e0972e195b38fbcac5 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 16 Apr 2024 10:10:52 +0100 Subject: [PATCH 30/75] prove immutable type behaviours --- .../DefaultHybridCache.Serialization.cs | 56 +++++++++--- .../DefaultHybridCache.StampedeStateT.cs | 27 +++++- .../Hybrid/src/Internal/DefaultHybridCache.cs | 19 +--- src/Caching/Hybrid/test/StampedeTests.cs | 88 +++++++++++++++++++ src/Caching/Hybrid/test/TypeTests.cs | 62 +++++++++++++ 5 files changed, 221 insertions(+), 31 deletions(-) create mode 100644 src/Caching/Hybrid/test/TypeTests.cs diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs index 50ad0fafdd00..f32cae4af668 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs @@ -5,7 +5,10 @@ using System.Collections.Concurrent; using System.ComponentModel; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Serialization; using Microsoft.Extensions.DependencyInjection; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -46,27 +49,60 @@ static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @thi } } - private static class ImmutableTypeCache // lazy memoize; T doesn't change per cache instance + internal static class ImmutableTypeCache // lazy memoize; T doesn't change per cache instance + { + // note for blittable types: a pure struct will be a full copy every time - nothing shared to mutate + public static readonly bool IsImmutable = (typeof(T).IsValueType && IsBlittable()) || IsImmutable(typeof(T)); + } + + private static bool IsBlittable() // minimize the generic portion { - public static readonly bool IsImmutable = #if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - !RuntimeHelpers.IsReferenceOrContainsReferences() || // a pure struct will be a full copy every time + return !RuntimeHelpers.IsReferenceOrContainsReferences(); +#else + try // down-level: only blittable types can be pinned + { + // get a typed, zeroed, non-null boxed instance of the appropriate type + // (can't use (object)default(T), as that would box to null for nullable types) + var obj = FormatterServices.GetUninitializedObject(Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T)); + GCHandle.Alloc(obj, GCHandleType.Pinned).Free(); + return true; + } + catch + { + return false; + } #endif - DefaultHybridCache.IsImmutable(typeof(T)); } - internal static bool IsImmutable(Type type) + private static bool IsImmutable(Type type) { - if (type is null || type == typeof(string) || type.IsPrimitive) + // check for known types + if (type == typeof(string)) + { + return true; + } + + if (type.IsValueType) { - return true; // trivial cases + // switch from Foo? to Foo if necessary + if (Nullable.GetUnderlyingType(type) is { } nullable) + { + type = nullable; + } } - if (Nullable.GetUnderlyingType(type) is { } nullable) + if (type.IsValueType || (type.IsClass & type.IsSealed)) { - type = nullable; // from Foo? to Foo + // check for [ImmutableObject(true)]; note we're looking at this as a statement about + // the overall nullability; for example, a type could contain a private int[] field, + // where the field is mutable and the list is mutable; but if the type is annotated: + // we're trusting that the API and use-case is such that the type is immutable + return type.GetCustomAttribute() is { Immutable: true }; } + // don't trust interfaces and non-sealed types; we might have any concrete + // type that has different behaviour + return false; - return Attribute.IsDefined(type, typeof(ImmutableObjectAttribute)); } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index efc0d9bc5a3c..f24ff67116ac 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -5,7 +5,6 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; -using static Microsoft.Extensions.Caching.Hybrid.Internal.DefaultHybridCache; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -22,7 +21,7 @@ internal sealed class StampedeState : StampedeState public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) : base(cache, key, canBeCanceled) { - result = new(); + result = new(); } public StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationToken token) @@ -186,11 +185,33 @@ private CacheItem SetResult(T value) protected override void SetCanceled() => result?.TrySetCanceled(SharedToken); + private Task? _sharedUnwrap; + + internal ValueTask UnwrapAsync() + { + var task = Task; +#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + if (task.IsCompletedSuccessfully) +#else + if (task.Status == TaskStatus.RanToCompletion) +#endif + { + return new(task.Result.GetValue()); + } + + // if the type is immutable, callers can share the final step too + Task result = ImmutableTypeCache.IsImmutable ? (_sharedUnwrap ??= Awaited(Task)) : Awaited(Task); + return new(result); + + static async Task Awaited(Task> task) + => (await task.ConfigureAwait(false)).GetValue(); + } + public ValueTask JoinAsync(CancellationToken token) { // if the underlying has already completed, and/or our local token can't cancel: we // can simply wrap the shared task; otherwise, we need our own cancellation state - return token.CanBeCanceled && !Task.IsCompleted ? WithCancellation(this, token) : UnwrapAsync(Task); + return token.CanBeCanceled && !Task.IsCompleted ? WithCancellation(this, token) : UnwrapAsync(); static async ValueTask WithCancellation(StampedeState stampede, CancellationToken token) { diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index c14412be248e..9625525357ca 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -104,29 +103,13 @@ public override ValueTask GetOrCreateAsync(string key, TState stat { // we're going to run to completion; no need to get complicated _ = stampede.ExecuteDirectAsync(in state, underlyingDataCallback, options); // this larger task includes L2 write etc - return UnwrapAsync(stampede.Task); + return stampede.UnwrapAsync(); } } return stampede.JoinAsync(token); } - static ValueTask UnwrapAsync(Task> task) - { -#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER - if (task.IsCompletedSuccessfully) -#else - if (task.Status == TaskStatus.RanToCompletion) -#endif - { - return new(task.Result.GetValue()); - } - return Awaited(task); - - static async ValueTask Awaited(Task> task) - => (await task.ConfigureAwait(false)).GetValue(); - } - public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) { localCache.Remove(key); diff --git a/src/Caching/Hybrid/test/StampedeTests.cs b/src/Caching/Hybrid/test/StampedeTests.cs index bc890ea0b531..d9c0105d8b9a 100644 --- a/src/Caching/Hybrid/test/StampedeTests.cs +++ b/src/Caching/Hybrid/test/StampedeTests.cs @@ -286,5 +286,93 @@ public async Task MultipleCallsShareExecution_MostCancel(int callerCount, int re await results[remaining]; } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ImmutableTypesShareFinalTask(bool withCancelation) + { + CancellationToken token = withCancelation ? new CancellationTokenSource().Token : CancellationToken.None; + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + var first = cache.GetOrCreateAsync(Me(), async ct => { await semaphore.WaitAsync(); semaphore.Release(); return Guid.NewGuid(); }, token: token); + var second = cache.GetOrCreateAsync(Me(), async ct => { await semaphore.WaitAsync(); semaphore.Release(); return Guid.NewGuid(); }, token: token); + + if (withCancelation) + { + Assert.NotSame(first.AsTask(), second.AsTask()); // fetches the underlying incomplete task + } + else + { + Assert.Same(first.AsTask(), second.AsTask()); + } + semaphore.Release(); + Assert.Equal(await first, await second); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ImmutableCustomTypesShareFinalTask(bool withCancelation) + { + CancellationToken token = withCancelation ? new CancellationTokenSource().Token : CancellationToken.None; + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + var first = cache.GetOrCreateAsync(Me(), async ct => { await semaphore.WaitAsync(); semaphore.Release(); return new Immutable(Guid.NewGuid()); }, token: token); + var second = cache.GetOrCreateAsync(Me(), async ct => { await semaphore.WaitAsync(); semaphore.Release(); return new Immutable(Guid.NewGuid()); }, token: token); + + if (withCancelation) + { + Assert.NotSame(first.AsTask(), second.AsTask()); // fetches the underlying incomplete task + } + else + { + Assert.Same(first.AsTask(), second.AsTask()); + } + 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)] + public async Task MutableTypesNeverShareFinalTask(bool withCancelation) + { + CancellationToken token = withCancelation ? new CancellationTokenSource().Token : CancellationToken.None; + + using var scope = GetDefaultCache(out var cache); + using var semaphore = new SemaphoreSlim(0); + + var first = cache.GetOrCreateAsync(Me(), async ct => { await semaphore.WaitAsync(); semaphore.Release(); return new Mutable(Guid.NewGuid()); }, token: token); + var second = cache.GetOrCreateAsync(Me(), async ct => { await semaphore.WaitAsync(); semaphore.Release(); return new Mutable(Guid.NewGuid()); }, token: token); + + Assert.NotSame(first.AsTask(), second.AsTask()); // fetches the underlying incomplete task + semaphore.Release(); + + var x = await first; + var y = await second; + Assert.Equal(x.Value, y.Value); + Assert.NotSame(x, y); + } + + class Mutable(Guid value) + { + public Guid Value => value; + } + + [ImmutableObject(true)] + public sealed class Immutable(Guid value) + { + public Guid Value => value; + } + + private static string Me([CallerMemberName] string caller = "") => caller; } diff --git a/src/Caching/Hybrid/test/TypeTests.cs b/src/Caching/Hybrid/test/TypeTests.cs new file mode 100644 index 000000000000..dc1f6f06749b --- /dev/null +++ b/src/Caching/Hybrid/test/TypeTests.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Hybrid.Internal; + +namespace Microsoft.Extensions.Caching.Hybrid.Tests; +public class TypeTests +{ + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(int))] // primitive + [InlineData(typeof(int?))] + [InlineData(typeof(Guid))] // non-primitive but blittable + [InlineData(typeof(Guid?))] + [InlineData(typeof(SealedCustomClassAttribTrue))] // attrib says explicitly true, and sealed + [InlineData(typeof(CustomBlittableStruct))] // blittable, and we're copying each time + [InlineData(typeof(CustomNonBlittableStructAttribTrue))] // non-blittable, attrib says explicitly true + public void ImmutableTypes(Type type) + { + Assert.True((bool)typeof(DefaultHybridCache.ImmutableTypeCache<>).MakeGenericType(type) + .GetField(nameof(DefaultHybridCache.ImmutableTypeCache.IsImmutable), BindingFlags.Static | BindingFlags.Public)! + .GetValue(null)!); + } + + [Theory] + [InlineData(typeof(byte[]))] + [InlineData(typeof(string[]))] + [InlineData(typeof(object))] + [InlineData(typeof(CustomClassNoAttrib))] // no attrib, who knows? + [InlineData(typeof(CustomClassAttribFalse))] // attrib says explicitly no + [InlineData(typeof(CustomClassAttribTrue))] // attrib says explicitly true, but not sealed: we might have a sub-class + [InlineData(typeof(CustomNonBlittableStructNoAttrib))] // no attrib, who knows? + [InlineData(typeof(CustomNonBlittableStructAttribFalse))] // attrib says explicitly no + public void MutableTypes(Type type) + { + Assert.False((bool)typeof(DefaultHybridCache.ImmutableTypeCache<>).MakeGenericType(type) + .GetField(nameof(DefaultHybridCache.ImmutableTypeCache.IsImmutable), BindingFlags.Static | BindingFlags.Public)! + .GetValue(null)!); + } + + class CustomClassNoAttrib { } + [ImmutableObject(false)] + class CustomClassAttribFalse { } + [ImmutableObject(true)] + class CustomClassAttribTrue { } + [ImmutableObject(true)] + sealed class SealedCustomClassAttribTrue { } + + struct CustomBlittableStruct(int x) { public int X => x; } + struct CustomNonBlittableStructNoAttrib(string x) { public string X => x; } + [ImmutableObject(false)] + struct CustomNonBlittableStructAttribFalse(string x) { public string X => x; } + [ImmutableObject(true)] + struct CustomNonBlittableStructAttribTrue(string x) { public string X => x; } +} From e427ad97e8b50a2d7e2dcedd8c016ea6a31656e6 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 16 Apr 2024 10:22:32 +0100 Subject: [PATCH 31/75] TFM summary --- .../Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index c1af3d2f3cb8..3b480ff1fd49 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -2,6 +2,13 @@ Multi-level caching implementation building on and extending IDistributedCache + $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework);netstandard2.0;netstandard2.1 true cache;distributedcache;hybrid From b9bc68ac79bd618acf01a3cc1e67ab795aa42d46 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 16 Apr 2024 11:52:59 +0100 Subject: [PATCH 32/75] make L2 optional; fast-path L2 --- .../src/HybridCacheServiceExtensions.cs | 1 - .../src/Internal/DefaultHybridCache.L2.cs | 85 ++++++++++++++----- .../Hybrid/src/Internal/DefaultHybridCache.cs | 69 ++++++++++----- ...Microsoft.Extensions.Caching.Hybrid.csproj | 4 +- .../Hybrid/test/ServiceConstructionTests.cs | 71 ++++++++++++++++ src/Caching/Hybrid/test/StampedeTests.cs | 1 - 6 files changed, 184 insertions(+), 47 deletions(-) diff --git a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs index bcbde7462a39..fcb7e4ae4d57 100644 --- a/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs +++ b/src/Caching/Hybrid/src/HybridCacheServiceExtensions.cs @@ -50,7 +50,6 @@ public static IHybridCacheBuilder AddHybridCache(this IServiceCollection service services.TryAddSingleton(TimeProvider.System); services.AddOptions(); services.AddMemoryCache(); - services.AddDistributedMemoryCache(); // we need a backend; use in-proc by default services.TryAddSingleton(); services.TryAddSingleton>(InbuiltTypeSerializer.Instance); services.TryAddSingleton>(InbuiltTypeSerializer.Instance); diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs index 67e3b3e2276b..50e62395b3ff 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -10,30 +10,68 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - internal async Task> GetFromL2Async(string key, CancellationToken token) + internal ValueTask> GetFromL2Async(string key, CancellationToken token) { - if ((features & BackendFeatures.Buffers) == 0) + switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { - var bytes = await backendCache.GetAsync(key, token).ConfigureAwait(false); + case CacheFeatures.BackendCache: // legacy byte[]-based + var pendingLegacy = backendCache!.GetAsync(key, token); +#if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER + if (!pendingLegacy.IsCompletedSuccessfully) +#else + if (pendingLegacy.Status != TaskStatus.RanToCompletion) +#endif + { + return new(AwaitedLegacy(pendingLegacy, MaximumPayloadBytes)); + } + var bytes = pendingLegacy.Result; // already complete + if (bytes is not null) + { + if (bytes.Length > MaximumPayloadBytes) + { + ThrowQuota(); + } + return new(new ArraySegment(bytes)); + } + break; + case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter-based + var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); + var cache = Unsafe.As(backendCache!); // type-checked already + var pendingBuffers = cache.TryGetAsync(key, writer, token); + if (!pendingBuffers.IsCompletedSuccessfully) + { + return new(AwaitedBuffers(pendingBuffers, writer)); + } + ArraySegment result = pendingBuffers.GetAwaiter().GetResult() + ? new(writer.DetachCommitted(out var length), 0, length) + : default; + writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened + return new(result); + } + return default; + + static async Task> AwaitedLegacy(Task pending, int maximumPayloadBytes) + { + var bytes = await pending.ConfigureAwait(false); if (bytes is not null) { - if (bytes.Length > MaximumPayloadBytes) + if (bytes.Length > maximumPayloadBytes) { ThrowQuota(); } return new(bytes); } + return default; } - else + + static async Task> AwaitedBuffers(ValueTask pending, RecyclableArrayBufferWriter writer) { - using var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); - var cache = Unsafe.As(backendCache); // type-checked already - if (await cache.TryGetAsync(key, writer, token).ConfigureAwait(false)) - { - return new(writer.DetachCommitted(out var length), 0, length); - } + ArraySegment result = await pending.ConfigureAwait(false) + ? new(writer.DetachCommitted(out var length), 0, length) + : default; + writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened + return result; } - return default; static void ThrowQuota() => throw new InvalidOperationException("Maximum cache length exceeded"); } @@ -41,19 +79,20 @@ internal async Task> GetFromL2Async(string key, CancellationT internal ValueTask SetL2Async(string key, byte[] value, int length, HybridCacheEntryOptions? options, CancellationToken token) { Debug.Assert(value.Length >= length); - if ((features & BackendFeatures.Buffers) == 0) - { - if (value.Length > length) - { - Array.Resize(ref value, length); - } - return new(backendCache.SetAsync(key, value, GetOptions(options), token)); - } - else + switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { - var cache = Unsafe.As(backendCache); // type-checked already - return cache.SetAsync(key, new(value, 0, length), GetOptions(options), token); + case CacheFeatures.BackendCache: // legacy byte[]-based + if (value.Length > length) + { + Array.Resize(ref value, length); + } + Debug.Assert(value.Length == length); + return new(backendCache!.SetAsync(key, value, GetOptions(options), token)); + case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // ReadOnlySequence-based + var cache = Unsafe.As(backendCache!); // type-checked already + return cache.SetAsync(key, new(value, 0, length), GetOptions(options), token); } + return default; } private DistributedCacheEntryOptions GetOptions(HybridCacheEntryOptions? options) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 9625525357ca..eeac002aec03 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -4,11 +4,13 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -18,39 +20,58 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// internal sealed partial class DefaultHybridCache : HybridCache { - private readonly IDistributedCache backendCache; + private readonly IDistributedCache? backendCache; private readonly IMemoryCache localCache; - private readonly IServiceProvider services; + private readonly IServiceProvider services; // we can't resolve per-type serializers until we see each T private readonly IHybridCacheSerializerFactory[] serializerFactories; private readonly HybridCacheOptions options; - private readonly BackendFeatures features; + private readonly ILogger? logger; + private readonly CacheFeatures features; // used to avoid constant type-testing - private readonly HybridCacheEntryFlags defaultFlags; + private readonly HybridCacheEntryFlags hardFlags; // *always* present (for example, because no L2) + private readonly HybridCacheEntryFlags defaultFlags; // note this already includes hardFlags private readonly TimeSpan defaultExpiration; private readonly TimeSpan defaultLocalCacheExpiration; private readonly DistributedCacheEntryOptions defaultDistributedCacheExpiration; [Flags] - private enum BackendFeatures + internal enum CacheFeatures { None = 0, - Buffers = 1 << 0, + BackendCache = 1 << 0, + BackendBuffers = 1 << 1, } - public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IMemoryCache localCache, IServiceProvider services) + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private CacheFeatures GetFeatures(CacheFeatures mask) => features & mask; + + public DefaultHybridCache(IOptions options, IServiceProvider services) { - this.backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); - this.localCache = localCache ?? throw new ArgumentNullException(nameof(localCache)); this.services = services ?? throw new ArgumentNullException(nameof(services)); + this.localCache = services.GetRequiredService(); this.options = options.Value; + this.logger = services.GetService()?.CreateLogger(typeof(HybridCache)); // note optional - // perform type-tests on the backend once only - if (backendCache is IBufferDistributedCache) + this.backendCache = services.GetService(); // note optional + + // ignore L2 if it is really just the same L1, wrapped + // (note not just an "is" test; if someone has a custom subclass, who knows what it does?) + if (this.backendCache is not null + && this.backendCache.GetType() == typeof(MemoryDistributedCache) + && this.localCache.GetType() == typeof(MemoryCache)) { - this.features |= BackendFeatures.Buffers; + this.backendCache = null; } + // perform type-tests on the backend once only + this.features |= backendCache switch + { + IBufferDistributedCache => CacheFeatures.BackendCache | CacheFeatures.BackendBuffers, + not null => CacheFeatures.BackendCache, + _ => CacheFeatures.None + }; + // When resolving serializers via the factory API, we will want the *last* instance, // i.e. "last added wins"; we can optimize by reversing the array ahead of time, and // taking the first match @@ -61,19 +82,25 @@ public DefaultHybridCache(IOptions options, IDistributedCach MaximumPayloadBytes = checked((int)this.options.MaximumPayloadBytes); // for now hard-limit to 2GiB var defaultEntryOptions = this.options.DefaultEntryOptions; - defaultFlags = defaultEntryOptions?.Flags ?? HybridCacheEntryFlags.None; - defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(5); - defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1); - defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = defaultExpiration }; + if (this.backendCache is null) + { + this.hardFlags |= HybridCacheEntryFlags.DisableDistributedCache; + } + this.defaultFlags = (defaultEntryOptions?.Flags ?? HybridCacheEntryFlags.None) | this.hardFlags; + this.defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(5); + this.defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1); + this.defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = defaultExpiration }; } - internal IDistributedCache BackendCache => backendCache; + internal IDistributedCache? BackendCache => backendCache; internal IMemoryCache LocalCache => localCache; internal HybridCacheOptions Options => options; - private bool BackendBuffers => (features & BackendFeatures.Buffers) != 0; + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private HybridCacheEntryFlags GetEffectiveFlags(HybridCacheEntryOptions? options) + => (options?.Flags | hardFlags) ?? defaultFlags; public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) { @@ -83,7 +110,7 @@ public override ValueTask GetOrCreateAsync(string key, TState stat token.ThrowIfCancellationRequested(); } - var flags = options?.Flags ?? defaultFlags; + var flags = GetEffectiveFlags(options); if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && localCache.TryGetValue(key, out var untyped) && untyped is CacheItem typed) { // short-circuit @@ -113,7 +140,7 @@ public override ValueTask GetOrCreateAsync(string key, TState stat public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) { localCache.Remove(key); - return new(backendCache.RemoveAsync(key, token)); + return backendCache is null ? default : new(backendCache.RemoveAsync(key, token)); } public override ValueTask RemoveTagAsync(string tag, CancellationToken token = default) @@ -123,7 +150,7 @@ public override ValueTask SetAsync(string key, T value, HybridCacheEntryOptio { // since we're forcing a write: disable L1+L2 read; we'll use a direct pass-thru of the value as the callback, to reuse all the code; // note also that stampede token is not shared with anyone else - var flags = (options?.Flags ?? defaultFlags) | (HybridCacheEntryFlags.DisableLocalCacheRead | HybridCacheEntryFlags.DisableDistributedCacheRead); + var flags = GetEffectiveFlags(options) | (HybridCacheEntryFlags.DisableLocalCacheRead | HybridCacheEntryFlags.DisableDistributedCacheRead); var state = new StampedeState(this, new StampedeKey(key, flags), token); return new(state.ExecuteDirectAsync(value, static (state, _) => new(state), options)); // note this spans L2 write etc } diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index 3b480ff1fd49..7bd31d97a30e 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -26,8 +26,10 @@ - + + + diff --git a/src/Caching/Hybrid/test/ServiceConstructionTests.cs b/src/Caching/Hybrid/test/ServiceConstructionTests.cs index 8e97441fd4f9..50887d990aa0 100644 --- a/src/Caching/Hybrid/test/ServiceConstructionTests.cs +++ b/src/Caching/Hybrid/test/ServiceConstructionTests.cs @@ -3,10 +3,14 @@ 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.Configuration; using Microsoft.Extensions.Configuration.Json; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; #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). @@ -136,6 +140,73 @@ public void CustomSerializerFactoryConfiguration() 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 var 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 var 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 var provider = services.BuildServiceProvider(); + var cache = Assert.IsType(provider.GetRequiredService()); + + Assert.NotNull(cache.BackendCache); + } + + class CustomMemoryCache : MemoryCache + { + public CustomMemoryCache(IOptions options) : base(options) { } + public CustomMemoryCache(IOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + } + + class CustomMemoryDistributedCache : MemoryDistributedCache + { + public CustomMemoryDistributedCache(IOptions options) : base(options) { } + public CustomMemoryDistributedCache(IOptions options, ILoggerFactory loggerFactory) : base(options, loggerFactory) { } + } + class Customer { } class Order { } diff --git a/src/Caching/Hybrid/test/StampedeTests.cs b/src/Caching/Hybrid/test/StampedeTests.cs index d9c0105d8b9a..61583a3f8962 100644 --- a/src/Caching/Hybrid/test/StampedeTests.cs +++ b/src/Caching/Hybrid/test/StampedeTests.cs @@ -373,6 +373,5 @@ public sealed class Immutable(Guid value) public Guid Value => value; } - private static string Me([CallerMemberName] string caller = "") => caller; } From 1d548bfd3271c01929428f45496672cf102b0844 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 16 Apr 2024 13:19:53 +0100 Subject: [PATCH 33/75] redis tests --- .../Hybrid/src/Internal/DefaultHybridCache.cs | 5 + ...oft.Extensions.Caching.Hybrid.Tests.csproj | 1 + src/Caching/Hybrid/test/RedisTests.cs | 94 ++++++++++++ ...tensions.Caching.StackExchangeRedis.csproj | 3 + .../StackExchangeRedis/src/RedisCache.cs | 134 +++++++++++++++++- 5 files changed, 232 insertions(+), 5 deletions(-) create mode 100644 src/Caching/Hybrid/test/RedisTests.cs diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index eeac002aec03..08d86e747e58 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -46,6 +46,11 @@ internal enum CacheFeatures [MethodImpl(MethodImplOptions.AggressiveInlining)] private CacheFeatures GetFeatures(CacheFeatures mask) => features & mask; + internal CacheFeatures GetFeatures() => features; + + // used to restrict features in test suite + internal void DebugRemoveFeatures(CacheFeatures features) => Unsafe.AsRef(in this.features) &= ~features; + public DefaultHybridCache(IOptions options, IServiceProvider services) { this.services = services ?? throw new ArgumentNullException(nameof(services)); diff --git a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index c589f1499cc8..05734e1c043b 100644 --- a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Caching/Hybrid/test/RedisTests.cs b/src/Caching/Hybrid/test/RedisTests.cs new file mode 100644 index 000000000000..df9d29904db3 --- /dev/null +++ b/src/Caching/Hybrid/test/RedisTests.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Distributed; +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 RedisFixture : IDisposable +{ + private IConnectionMultiplexer? muxer; + private Task? sharedConnect; + public Task ConnectAsync() => sharedConnect ??= DoConnectAsync(); + + public void Dispose() => muxer?.Dispose(); + + async Task DoConnectAsync() + { + try + { + muxer = await ConnectionMultiplexer.ConnectAsync("127.0.0.1:6379"); + await muxer.GetDatabase().PingAsync(); + return muxer; + } + catch + { + return null; + } + } +} +public class RedisTests(RedisFixture fixture, ITestOutputHelper log) : IClassFixture +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task BasicUsage(bool useBuffers) + { + var redis = await fixture.ConnectAsync(); + if (redis is null) + { + log.WriteLine("Redis is not available"); + return; // inconclusive + } + log.WriteLine("Redis is available"); + var services = new ServiceCollection(); + services.AddStackExchangeRedisCache(options => + { + options.ConnectionMultiplexerFactory = () => Task.FromResult(redis); + }); + services.AddHybridCache(); + var provider = services.BuildServiceProvider(); // not "using" - that will tear down our redis; use the fixture for that + + var cache = Assert.IsType(provider.GetRequiredService()); + Assert.IsAssignableFrom(cache.BackendCache); + + if (!useBuffers) // force byte[] mode + { + cache.DebugRemoveFeatures(DefaultHybridCache.CacheFeatures.BackendBuffers); + } + log.WriteLine($"features: {cache.GetFeatures()}"); + + var key = Me(); + await redis.GetDatabase().KeyDeleteAsync(key); // start from known state + Assert.False(await redis.GetDatabase().KeyExistsAsync(key)); + + int count = 0; + for (int 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); + } + + private static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj b/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj index 7ff2d071e039..31d5fcf2c965 100644 --- a/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj +++ b/src/Caching/StackExchangeRedis/src/Microsoft.Extensions.Caching.StackExchangeRedis.csproj @@ -15,6 +15,9 @@ + + + diff --git a/src/Caching/StackExchangeRedis/src/RedisCache.cs b/src/Caching/StackExchangeRedis/src/RedisCache.cs index 749b5fc79d8c..93129813b035 100644 --- a/src/Caching/StackExchangeRedis/src/RedisCache.cs +++ b/src/Caching/StackExchangeRedis/src/RedisCache.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Net.Sockets; @@ -20,7 +21,7 @@ namespace Microsoft.Extensions.Caching.StackExchangeRedis; /// Distributed cache implementation using Redis. /// Uses StackExchange.Redis as the Redis client. /// -public partial class RedisCache : IDistributedCache, IDisposable +public partial class RedisCache : IBufferDistributedCache, IDisposable { // Note that the "force reconnect" pattern as described https://learn.microsoft.com/en-us/azure/azure-cache-for-redis/cache-best-practices-connection#using-forcereconnect-with-stackexchangeredis // can be enabled via the "Microsoft.AspNetCore.Caching.StackExchangeRedis.UseForceReconnect" app-context switch @@ -125,8 +126,37 @@ internal RedisCache(IOptions optionsAccessor, ILogger logger) return await GetAndRefreshAsync(key, getData: true, token: token).ConfigureAwait(false); } + private static ReadOnlyMemory Linearize(in ReadOnlySequence value, out byte[]? lease) + { + // RedisValue only supports single-segment chunks; this will almost never be an issue, but + // on those rare occasions: use a leased array to harmonize things + if (value.IsSingleSegment) + { + lease = null; + return value.First; + } + var length = checked((int)value.Length); + lease = ArrayPool.Shared.Rent(length); + value.CopyTo(lease); + return new(lease, 0, length); + } + + private static void Recycle(byte[]? lease) + { + if (lease is not null) + { + ArrayPool.Shared.Return(lease); + } + } + /// public void Set(string key, byte[] value, DistributedCacheEntryOptions options) + => SetImpl(key, new(value), options); + + void IBufferDistributedCache.Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) + => SetImpl(key, value, options); + + private void SetImpl(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) { ArgumentNullThrowHelper.ThrowIfNull(key); ArgumentNullThrowHelper.ThrowIfNull(value); @@ -137,12 +167,11 @@ public void Set(string key, byte[] value, DistributedCacheEntryOptions options) var creationTime = DateTimeOffset.UtcNow; var absoluteExpiration = GetAbsoluteExpiration(creationTime, options); - try { var prefixedKey = _instancePrefix.Append(key); var ttl = GetExpirationInSeconds(creationTime, absoluteExpiration, options); - var fields = GetHashFields(value, absoluteExpiration, options.SlidingExpiration); + var fields = GetHashFields(Linearize(value, out var lease), absoluteExpiration, options.SlidingExpiration); if (ttl is null) { @@ -158,6 +187,7 @@ public void Set(string key, byte[] value, DistributedCacheEntryOptions options) batch.Execute(); // synchronous wait-for-all; the two tasks should be either complete or *literally about to* (race conditions) cache.WaitAll(setFields, setTtl); // note this applies usual SE.Redis timeouts etc } + Recycle(lease); // we're happy to only recycle on success } catch (Exception ex) { @@ -167,7 +197,13 @@ public void Set(string key, byte[] value, DistributedCacheEntryOptions options) } /// - public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) + => SetImplAsync(key, new(value), options, token); + + ValueTask IBufferDistributedCache.SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token) + => new(SetImplAsync(key, value, options, token)); + + private async Task SetImplAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token = default) { ArgumentNullThrowHelper.ThrowIfNull(key); ArgumentNullThrowHelper.ThrowIfNull(value); @@ -186,7 +222,7 @@ public async Task SetAsync(string key, byte[] value, DistributedCacheEntryOption { var prefixedKey = _instancePrefix.Append(key); var ttl = GetExpirationInSeconds(creationTime, absoluteExpiration, options); - var fields = GetHashFields(value, absoluteExpiration, options.SlidingExpiration); + var fields = GetHashFields(Linearize(value, out var lease), absoluteExpiration, options.SlidingExpiration); if (ttl is null) { @@ -199,6 +235,7 @@ await Task.WhenAll( cache.KeyExpireAsync(prefixedKey, TimeSpan.FromSeconds(ttl.GetValueOrDefault())) ).ConfigureAwait(false); } + Recycle(lease); // we're happy to only recycle on success } catch (Exception ex) { @@ -653,4 +690,91 @@ static void ReleaseConnection(IDatabase? cache) } } } + + bool IBufferDistributedCache.TryGet(string key, IBufferWriter destination) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + + var cache = Connect(); + + // This also resets the LRU status as desired. + // TODO: Can this be done in one operation on the server side? Probably, the trick would just be the DateTimeOffset math. + RedisValue[] metadata; + Lease? data; + try + { + var prefixed = _instancePrefix.Append(key); + var pendingMetadata = cache.HashGetAsync(prefixed, GetHashFields(false)); + data = cache.HashGetLease(prefixed, DataKey); + metadata = pendingMetadata.GetAwaiter().GetResult(); + // ^^^ this *looks* like a sync-over-async, but the FIFO nature of + // redis means that since HashGetLease has returned: *so has this*; + // all we're actually doing is getting rid of a latency delay + } + catch (Exception ex) + { + OnRedisError(ex, cache); + throw; + } + + if (data is not null) + { + if (metadata.Length >= 2) + { + MapMetadata(metadata, out DateTimeOffset? absExpr, out TimeSpan? sldExpr); + Refresh(cache, key, absExpr, sldExpr); + } + + // this is where we actually copy the data out + destination.Write(data.Span); + data.Dispose(); // recycle the lease + return true; + } + + return false; + } + + async ValueTask IBufferDistributedCache.TryGetAsync(string key, IBufferWriter destination, CancellationToken token) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + + token.ThrowIfCancellationRequested(); + + var cache = await ConnectAsync(token).ConfigureAwait(false); + Debug.Assert(cache is not null); + + // This also resets the LRU status as desired. + // TODO: Can this be done in one operation on the server side? Probably, the trick would just be the DateTimeOffset math. + RedisValue[] metadata; + Lease? data; + try + { + var prefixed = _instancePrefix.Append(key); + var pendingMetadata = cache.HashGetAsync(prefixed, GetHashFields(false)); + data = await cache.HashGetLeaseAsync(prefixed, DataKey).ConfigureAwait(false); + metadata = await pendingMetadata.ConfigureAwait(false); + // ^^^ inversion of order here is deliberate to avoid a latency delay + } + catch (Exception ex) + { + OnRedisError(ex, cache); + throw; + } + + if (data is not null) + { + if (metadata.Length >= 2) + { + MapMetadata(metadata, out DateTimeOffset? absExpr, out TimeSpan? sldExpr); + await RefreshAsync(cache, key, absExpr, sldExpr, token).ConfigureAwait(false); + } + + // this is where we actually copy the data out + destination.Write(data.Span); + data.Dispose(); // recycle the lease + return true; + } + + return false; + } } From 18ae5848aed2aaddf9a67030cb827d55039b9c6f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 16 Apr 2024 17:12:29 +0100 Subject: [PATCH 34/75] implement streaming via SqlClient --- .../SqlServer/src/DatabaseOperations.cs | 119 ++++++++++++++---- .../SqlServer/src/IDatabaseOperations.cs | 10 +- ...rosoft.Extensions.Caching.SqlServer.csproj | 3 + .../src/SqlParameterCollectionExtensions.cs | 35 ++++-- src/Caching/SqlServer/src/SqlServerCache.cs | 87 ++++++++++++- 5 files changed, 217 insertions(+), 37 deletions(-) diff --git a/src/Caching/SqlServer/src/DatabaseOperations.cs b/src/Caching/SqlServer/src/DatabaseOperations.cs index 04544e600a42..dbc56e4f9960 100644 --- a/src/Caching/SqlServer/src/DatabaseOperations.cs +++ b/src/Caching/SqlServer/src/DatabaseOperations.cs @@ -2,8 +2,10 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; using System.Data; using System.Linq; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.Data.SqlClient; @@ -77,11 +79,24 @@ public void DeleteCacheItem(string key) return GetCacheItem(key, includeValue: true); } - public async Task GetCacheItemAsync(string key, CancellationToken token = default(CancellationToken)) + public bool TryGetCacheItem(string key, IBufferWriter destination) + { + return GetCacheItem(key, includeValue: true, destination: destination) is not null; + } + + public Task GetCacheItemAsync(string key, CancellationToken token = default(CancellationToken)) { token.ThrowIfCancellationRequested(); - return await GetCacheItemAsync(key, includeValue: true, token: token).ConfigureAwait(false); + return GetCacheItemAsync(key, includeValue: true, token: token); + } + + public async Task TryGetCacheItemAsync(string key, IBufferWriter destination, CancellationToken token = default(CancellationToken)) + { + token.ThrowIfCancellationRequested(); + + var arr = await GetCacheItemAsync(key, includeValue: true, destination: destination, token: token).ConfigureAwait(false); + return arr is not null; } public void RefreshCacheItem(string key) @@ -89,11 +104,11 @@ public void RefreshCacheItem(string key) GetCacheItem(key, includeValue: false); } - public async Task RefreshCacheItemAsync(string key, CancellationToken token = default(CancellationToken)) + public Task RefreshCacheItemAsync(string key, CancellationToken token = default(CancellationToken)) { token.ThrowIfCancellationRequested(); - await GetCacheItemAsync(key, includeValue: false, token: token).ConfigureAwait(false); + return GetCacheItemAsync(key, includeValue: false, token: token); } public void DeleteExpiredCacheItems() @@ -111,7 +126,7 @@ public void DeleteExpiredCacheItems() } } - public void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions options) + public void SetCacheItem(string key, ArraySegment value, DistributedCacheEntryOptions options) { var utcNow = SystemClock.UtcNow; @@ -149,7 +164,7 @@ public void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions } } - public async Task SetCacheItemAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default(CancellationToken)) + public async Task SetCacheItemAsync(string key, ArraySegment value, DistributedCacheEntryOptions options, CancellationToken token = default(CancellationToken)) { token.ThrowIfCancellationRequested(); @@ -189,7 +204,7 @@ public void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions } } - private byte[]? GetCacheItem(string key, bool includeValue) + private byte[]? GetCacheItem(string key, bool includeValue, IBufferWriter? destination = null) { var utcNow = SystemClock.UtcNow; @@ -213,27 +228,34 @@ public void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions connection.Open(); - using (var reader = command.ExecuteReader( - CommandBehavior.SequentialAccess | CommandBehavior.SingleRow | CommandBehavior.SingleResult)) + if (includeValue) { + using var reader = command.ExecuteReader( + CommandBehavior.SequentialAccess | CommandBehavior.SingleRow | CommandBehavior.SingleResult); + if (reader.Read()) { - if (includeValue) + if (destination is null) { value = reader.GetFieldValue(Columns.Indexes.CacheItemValueIndex); } - } - else - { - return null; + else + { + StreamOut(reader, Columns.Indexes.CacheItemValueIndex, destination); + value = []; // use non-null here as a sentinel to say "we got one" + } } } + else + { + command.ExecuteNonQuery(); + } } return value; } - private async Task GetCacheItemAsync(string key, bool includeValue, CancellationToken token = default(CancellationToken)) + private async Task GetCacheItemAsync(string key, bool includeValue, IBufferWriter? destination = null, CancellationToken token = default(CancellationToken)) { token.ThrowIfCancellationRequested(); @@ -259,25 +281,78 @@ public void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions await connection.OpenAsync(token).ConfigureAwait(false); - using (var reader = await command.ExecuteReaderAsync( - CommandBehavior.SequentialAccess | CommandBehavior.SingleRow | CommandBehavior.SingleResult, - token).ConfigureAwait(false)) + if (includeValue) { + using var reader = await command.ExecuteReaderAsync( + CommandBehavior.SequentialAccess | CommandBehavior.SingleRow | CommandBehavior.SingleResult, token).ConfigureAwait(false); + if (await reader.ReadAsync(token).ConfigureAwait(false)) { - if (includeValue) + if (destination is null) { value = await reader.GetFieldValueAsync(Columns.Indexes.CacheItemValueIndex, token).ConfigureAwait(false); } + else + { + StreamOut(reader, Columns.Indexes.CacheItemValueIndex, destination); + value = []; // use non-null here as a sentinel to say "we got one" + } } - else + } + else + { + await command.ExecuteNonQueryAsync(token).ConfigureAwait(false); + } + } + + return value; + } + + static long StreamOut(SqlDataReader source, int ordinal, IBufferWriter destination) + { + long dataIndex = 0; + int read = 0; + byte[]? lease = null; + do + { + dataIndex += read; // increment offset + + var memory = destination.GetMemory(8192); // start from the page size + if (MemoryMarshal.TryGetArray(memory, out var segment)) + { + // avoid an extra copy by writing directly to the target array when possible + read = (int)source.GetBytes(ordinal, dataIndex, segment.Array, segment.Offset, segment.Count); + if (read > 0) + { + destination.Advance(read); + } + } + else + { + lease ??= ArrayPool.Shared.Rent(8192); + read = (int)source.GetBytes(ordinal, dataIndex, lease, 0, lease.Length); + + if (read > 0) { - return null; + if (new ReadOnlySpan(lease, 0, read).TryCopyTo(memory.Span)) + { + destination.Advance(read); + } + else + { + // multi-chunk write (utility method) + destination.Write(new(lease, 0, read)); + } } } } + while (read > 0); - return value; + if (lease is not null) + { + ArrayPool.Shared.Return(lease); + } + return dataIndex; } private static bool IsDuplicateKeyException(SqlException ex) diff --git a/src/Caching/SqlServer/src/IDatabaseOperations.cs b/src/Caching/SqlServer/src/IDatabaseOperations.cs index fedac9c1ca24..34f69ca6f761 100644 --- a/src/Caching/SqlServer/src/IDatabaseOperations.cs +++ b/src/Caching/SqlServer/src/IDatabaseOperations.cs @@ -1,6 +1,8 @@ // 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.Buffers; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; @@ -11,8 +13,12 @@ internal interface IDatabaseOperations { byte[]? GetCacheItem(string key); + bool TryGetCacheItem(string key, IBufferWriter destination); + Task GetCacheItemAsync(string key, CancellationToken token = default(CancellationToken)); + Task TryGetCacheItemAsync(string key, IBufferWriter destination, CancellationToken token = default(CancellationToken)); + void RefreshCacheItem(string key); Task RefreshCacheItemAsync(string key, CancellationToken token = default(CancellationToken)); @@ -21,9 +27,9 @@ internal interface IDatabaseOperations Task DeleteCacheItemAsync(string key, CancellationToken token = default(CancellationToken)); - void SetCacheItem(string key, byte[] value, DistributedCacheEntryOptions options); + void SetCacheItem(string key, ArraySegment value, DistributedCacheEntryOptions options); - Task SetCacheItemAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default(CancellationToken)); + Task SetCacheItemAsync(string key, ArraySegment value, DistributedCacheEntryOptions options, CancellationToken token = default(CancellationToken)); void DeleteExpiredCacheItems(); } diff --git a/src/Caching/SqlServer/src/Microsoft.Extensions.Caching.SqlServer.csproj b/src/Caching/SqlServer/src/Microsoft.Extensions.Caching.SqlServer.csproj index 3088ff699532..21c98b778833 100644 --- a/src/Caching/SqlServer/src/Microsoft.Extensions.Caching.SqlServer.csproj +++ b/src/Caching/SqlServer/src/Microsoft.Extensions.Caching.SqlServer.csproj @@ -20,6 +20,9 @@ + + + diff --git a/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs b/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs index 39bf14087e27..ca44ec14cff5 100644 --- a/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs +++ b/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs @@ -22,20 +22,35 @@ public static SqlParameterCollection AddCacheItemId(this SqlParameterCollection return parameters.AddWithValue(Columns.Names.CacheItemId, SqlDbType.NVarChar, CacheItemIdColumnWidth, value); } - public static SqlParameterCollection AddCacheItemValue(this SqlParameterCollection parameters, byte[]? value) + public static SqlParameterCollection AddCacheItemValue(this SqlParameterCollection parameters, ArraySegment value) { - if (value != null && value.Length < DefaultValueColumnWidth) + if (value.Array is null) // null array (not really anticipating this, but...) { - return parameters.AddWithValue( - Columns.Names.CacheItemValue, - SqlDbType.VarBinary, - DefaultValueColumnWidth, - value); + return parameters.AddWithValue(Columns.Names.CacheItemValue, SqlDbType.VarBinary, Array.Empty()); } - else + else if (value.Offset == 0 & value.Count == value.Array.Length) // right-sized array + { + if (value.Count < DefaultValueColumnWidth) + { + return parameters.AddWithValue( + Columns.Names.CacheItemValue, + SqlDbType.VarBinary, + DefaultValueColumnWidth, // send as varbinary(constantSize) + value.Array); + } + else + { + // do not mention the size + return parameters.AddWithValue(Columns.Names.CacheItemValue, SqlDbType.VarBinary, value); + } + } + else // array fragment; set the Size and Offset accordingly { - // do not mention the size - return parameters.AddWithValue(Columns.Names.CacheItemValue, SqlDbType.VarBinary, value); + var p = new SqlParameter(Columns.Names.CacheItemValue, SqlDbType.VarBinary, value.Count); + p.Value = value.Array; + p.Offset = value.Offset; + parameters.Add(p); + return parameters; } } diff --git a/src/Caching/SqlServer/src/SqlServerCache.cs b/src/Caching/SqlServer/src/SqlServerCache.cs index 5ff0812e6fde..766ffebe4c9b 100644 --- a/src/Caching/SqlServer/src/SqlServerCache.cs +++ b/src/Caching/SqlServer/src/SqlServerCache.cs @@ -2,6 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.Buffers; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Shared; @@ -14,7 +16,7 @@ namespace Microsoft.Extensions.Caching.SqlServer; /// /// Distributed cache implementation using Microsoft SQL Server database. /// -public class SqlServerCache : IDistributedCache +public class SqlServerCache : IDistributedCache, IBufferDistributedCache { private static readonly TimeSpan MinimumExpiredItemsDeletionInterval = TimeSpan.FromMinutes(5); private static readonly TimeSpan DefaultExpiredItemsDeletionInterval = TimeSpan.FromMinutes(30); @@ -81,6 +83,18 @@ public SqlServerCache(IOptions options) return value; } + bool IBufferDistributedCache.TryGet(string key, IBufferWriter destination) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + ArgumentNullThrowHelper.ThrowIfNull(destination); + + var value = _dbOperations.TryGetCacheItem(key, destination); + + ScanForExpiredItemsIfRequired(); + + return value; + } + /// public async Task GetAsync(string key, CancellationToken token = default(CancellationToken)) { @@ -95,6 +109,18 @@ public SqlServerCache(IOptions options) return value; } + async ValueTask IBufferDistributedCache.TryGetAsync(string key, IBufferWriter destination, CancellationToken token) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + ArgumentNullThrowHelper.ThrowIfNull(destination); + + var value = await _dbOperations.TryGetCacheItemAsync(key, destination, token).ConfigureAwait(false); + + ScanForExpiredItemsIfRequired(); + + return value; + } + /// public void Refresh(string key) { @@ -148,7 +174,20 @@ public void Set(string key, byte[] value, DistributedCacheEntryOptions options) GetOptions(ref options); - _dbOperations.SetCacheItem(key, value, options); + _dbOperations.SetCacheItem(key, new(value), options); + + ScanForExpiredItemsIfRequired(); + } + + void IBufferDistributedCache.Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + ArgumentNullThrowHelper.ThrowIfNull(options); + + GetOptions(ref options); + + _dbOperations.SetCacheItem(key, Linearize(value, out var lease), options); + Recycle(lease); // we're fine to only recycle on success ScanForExpiredItemsIfRequired(); } @@ -168,11 +207,53 @@ public async Task SetAsync( GetOptions(ref options); - await _dbOperations.SetCacheItemAsync(key, value, options, token).ConfigureAwait(false); + await _dbOperations.SetCacheItemAsync(key, new(value), options, token).ConfigureAwait(false); + + ScanForExpiredItemsIfRequired(); + } + + async ValueTask IBufferDistributedCache.SetAsync( + string key, + ReadOnlySequence value, + DistributedCacheEntryOptions options, + CancellationToken token) + { + ArgumentNullThrowHelper.ThrowIfNull(key); + ArgumentNullThrowHelper.ThrowIfNull(options); + + token.ThrowIfCancellationRequested(); + + GetOptions(ref options); + + await _dbOperations.SetCacheItemAsync(key, Linearize(value, out var lease), options, token).ConfigureAwait(false); + Recycle(lease); // we're fine to only recycle on success ScanForExpiredItemsIfRequired(); } + private static ArraySegment Linearize(in ReadOnlySequence value, out byte[]? lease) + { + // SqlClient only supports single-segment chunks via byte[] with offset/count; this will + // almost never be an issue, but on those rare occasions: use a leased array to harmonize things + if (value.IsSingleSegment && MemoryMarshal.TryGetArray(value.First, out var segment)) + { + lease = null; + return segment; + } + var length = checked((int)value.Length); + lease = ArrayPool.Shared.Rent(length); + value.CopyTo(lease); + return new(lease, 0, length); + } + + private static void Recycle(byte[]? lease) + { + if (lease is not null) + { + ArrayPool.Shared.Return(lease); + } + } + // Called by multiple actions to see how long it's been since we last checked for expired items. // If sufficient time has elapsed then a scan is initiated on a background task. private void ScanForExpiredItemsIfRequired() From e60099b8791d59401376427a2995c1700bbf71ad Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 09:19:11 +0100 Subject: [PATCH 35/75] add benchmark project --- AspNetCore.sln | 22 ++ src/Caching/Caching.slnf | 1 + .../Internal/RecyclableArrayBufferWriter.cs | 9 + .../DistributedCacheBenchmarks.cs | 283 ++++++++++++++++++ ...osoft.Extensions.Caching.Benchmarks.csproj | 24 ++ .../Program.cs | 41 +++ 6 files changed, 380 insertions(+) create mode 100644 src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs create mode 100644 src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj create mode 100644 src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs diff --git a/AspNetCore.sln b/AspNetCore.sln index 8547083d378e..f00cbe118c9a 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1794,6 +1794,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Cachin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid.Tests", "src\Caching\Hybrid\test\Microsoft.Extensions.Caching.Hybrid.Tests.csproj", "{CF63C942-895A-4F6B-888A-7653D7C4991A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Benchmarks", "Benchmarks", "{6469F11E-8CEE-4292-820B-324DFFC88EBC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Benchmarks", "src\Caching\perf\Microsoft.Extensions.Caching.Benchmarks\Microsoft.Extensions.Caching.Benchmarks.csproj", "{268CF55F-94A8-4F87-9482-D5B755CFA79C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -10827,6 +10831,22 @@ Global {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x64.Build.0 = Release|Any CPU {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.ActiveCfg = Release|Any CPU {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.Build.0 = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|arm64.ActiveCfg = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|arm64.Build.0 = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x64.ActiveCfg = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x64.Build.0 = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x86.ActiveCfg = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x86.Build.0 = Debug|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|Any CPU.Build.0 = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|arm64.ActiveCfg = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|arm64.Build.0 = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x64.ActiveCfg = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x64.Build.0 = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x86.ActiveCfg = Release|Any CPU + {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11713,6 +11733,8 @@ Global {2D64CA23-6E81-488E-A7D3-9BDF87240098} = {0F39820F-F4A5-41C6-9809-D79B68F032EF} {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} {CF63C942-895A-4F6B-888A-7653D7C4991A} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} + {6469F11E-8CEE-4292-820B-324DFFC88EBC} = {0F39820F-F4A5-41C6-9809-D79B68F032EF} + {268CF55F-94A8-4F87-9482-D5B755CFA79C} = {6469F11E-8CEE-4292-820B-324DFFC88EBC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Caching/Caching.slnf b/src/Caching/Caching.slnf index 63610b8e28d5..f82ab1ad0d5f 100644 --- a/src/Caching/Caching.slnf +++ b/src/Caching/Caching.slnf @@ -8,6 +8,7 @@ "src\\Caching\\SqlServer\\test\\Microsoft.Extensions.Caching.SqlServer.Tests.csproj", "src\\Caching\\StackExchangeRedis\\src\\Microsoft.Extensions.Caching.StackExchangeRedis.csproj", "src\\Caching\\StackExchangeRedis\\test\\Microsoft.Extensions.Caching.StackExchangeRedis.Tests.csproj", + "src\\Caching\\perf\\Microsoft.Extensions.Caching.Benchmarks\\Microsoft.Extensions.Caching.Benchmarks.csproj", "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj" ] } diff --git a/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs b/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs index 0ee64a9bc016..e2eb5abbe833 100644 --- a/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs +++ b/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs @@ -23,6 +23,7 @@ internal sealed class RecyclableArrayBufferWriter : IBufferWriter, IDispos private int _index; private readonly int _maxLength; + public int CommittedBytes => _index; public int FreeCapacity => _buffer.Length - _index; public RecyclableArrayBufferWriter(int maxLength) @@ -83,6 +84,14 @@ internal T[] DetachCommitted(out int length) return tmp; } + public void ResetInPlace() + { + // resets the writer *without* resetting the buffer; + // the existing memory should be considered "gone" + // (to claim the buffer instead, use DetachCommitted) + _index = 0; + } + internal T[] GetBuffer(out int length) { length = _index; diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs new file mode 100644 index 000000000000..dfb70cc6610f --- /dev/null +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs @@ -0,0 +1,283 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Threading.Tasks; +using System; +using BenchmarkDotNet.Attributes; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Diagnostics.Tracing.Parsers.Clr; +using StackExchange.Redis; +using System.Linq; +using Microsoft.Extensions.Caching.Hybrid.Internal; + +namespace Microsoft.Extensions.Caching.Benchmarks; + +[MemoryDiagnoser, ShortRunJob] +public class DistributedCacheBenchmarks : IDisposable +{ + private readonly IBufferDistributedCache sqlServer, redis; + private readonly ConnectionMultiplexer multiplexer; + private readonly Random random = new Random(); + private readonly string[] keys; + private readonly Task[] pendingBlobs = new Task[OperationsPerInvoke]; + + // 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 + + private const string SqlServerConnectionString = "Data Source=.;Initial Catalog=CacheBench;Integrated Security=True;Trust Server Certificate=True"; + private const string RedisConfigurationString = "127.0.0.1,AllowAdmin=true"; + public const int OperationsPerInvoke = 256; + + public void Dispose() + { + (sqlServer as IDisposable)?.Dispose(); + (redis as IDisposable)?.Dispose(); + multiplexer.Dispose(); + } + + public enum BackendType + { + Redis, + SqlServer, + } + [Params(BackendType.Redis, BackendType.SqlServer)] + public BackendType Backend { get; set; } = BackendType.Redis; + + private IBufferDistributedCache _backend = null!; + + public DistributedCacheBenchmarks() + { + var services = new ServiceCollection(); + services.AddDistributedSqlServerCache(options => + { + options.TableName = "BenchmarkCache"; + options.SchemaName = "dbo"; + options.ConnectionString = SqlServerConnectionString; + }); + sqlServer = (IBufferDistributedCache)services.BuildServiceProvider().GetRequiredService(); + + multiplexer = ConnectionMultiplexer.Connect(RedisConfigurationString); + services = new ServiceCollection(); + services.AddStackExchangeRedisCache(options => + { + options.ConnectionMultiplexerFactory = () => Task.FromResult(multiplexer); + }); + redis = (IBufferDistributedCache)services.BuildServiceProvider().GetRequiredService(); + + keys = new string[10000]; + for (int i = 0; i < keys.Length; i++) + { + keys[i] = Guid.NewGuid().ToString(); + } + } + + [GlobalSetup] + public void GlobalSetup() + { + // reset + _backend = Backend switch + { + BackendType.Redis => redis, + BackendType.SqlServer => sqlServer, + _ => throw new ArgumentOutOfRangeException(nameof(Backend)), + }; + _backend.Get(new Guid().ToString()); // just to touch it first + switch (Backend) + { + case BackendType.SqlServer: + using (var conn = new SqlConnection(SqlServerConnectionString)) + { + using var cmd = conn.CreateCommand(); + cmd.CommandText = "truncate table dbo.BenchmarkCache"; + conn.Open(); + cmd.ExecuteNonQuery(); + } + break; + case BackendType.Redis: + using (var multiplexer = ConnectionMultiplexer.Connect(RedisConfigurationString)) + { + multiplexer.GetServer(multiplexer.GetEndPoints().Single()).FlushDatabase(); + } + break; + + } + var options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) }; + options.SlidingExpiration = Sliding ? TimeSpan.FromMinutes(5) : null; + + var value = new byte[PayloadSize]; + foreach (var key in keys) + { + random.NextBytes(value); + _backend.Set(key, value, options); + } + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public int GetSingleRandom() + { + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += _backend.Get(RandomKey())?.Length ?? 0; + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public int GetSingleRandomBuffer() + { + using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + if (_backend.TryGet(RandomKey(), writer)) + { + total += writer.CommittedBytes; + } + writer.ResetInPlace(); + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public void GetConcurrentRandom() + { + Parallel.For(0, OperationsPerInvoke, _ => + { + _backend.Get(RandomKey()); + }); + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public async Task GetSingleRandomAsync() + { + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += (await _backend.GetAsync(RandomKey()))?.Length ?? 0; + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public async Task GetSingleRandomBufferAsync() + { + using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + if (await _backend.TryGetAsync(RandomKey(), writer)) + { + total += writer.CommittedBytes; + } + writer.ResetInPlace(); + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public async Task GetConcurrentRandomAsync() + { + for (int i = 0; i < OperationsPerInvoke; i++) + { + pendingBlobs[i] = _backend.GetAsync(RandomKey()); + } + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += (await pendingBlobs[i])?.Length ?? 0; + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public int GetSingleFixed() + { + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += _backend.Get(FixedKey())?.Length ?? 0; + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public int GetSingleFixedBuffer() + { + using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + if (_backend.TryGet(FixedKey(), writer)) + { + total += writer.CommittedBytes; + } + writer.ResetInPlace(); + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public void GetConcurrentFixed() + { + Parallel.For(0, OperationsPerInvoke, _ => + { + _backend.Get(FixedKey()); + }); + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public async Task GetSingleFixedAsync() + { + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += (await _backend.GetAsync(FixedKey()))?.Length ?? 0; + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public async Task GetSingleFixedBufferAsync() + { + using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + if (await _backend.TryGetAsync(FixedKey(), writer)) + { + total += writer.CommittedBytes; + } + writer.ResetInPlace(); + } + return total; + } + + [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] + public async Task GetConcurrentFixedAsync() + { + for (int i = 0; i < OperationsPerInvoke; i++) + { + pendingBlobs[i] = _backend.GetAsync(FixedKey()); + } + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += (await pendingBlobs[i])?.Length ?? 0; + } + return total; + } + + private string FixedKey() => keys[42]; + + private string RandomKey() => keys[random.Next(keys.Length)]; + + [Params(1024, 128, 10*1024)] + public int PayloadSize { get; set; } = 1024; + + [Params(true, false)] + public bool Sliding { get; set; } = true; +} diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj new file mode 100644 index 000000000000..300f5123b645 --- /dev/null +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj @@ -0,0 +1,24 @@ + + + + $(DefaultNetCoreTargetFramework);$(DefaultNetFxTargetFramework) + Exe + true + true + false + $(DefineConstants);IS_BENCHMARKS + enable + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs new file mode 100644 index 000000000000..7e293b4d573b --- /dev/null +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs @@ -0,0 +1,41 @@ +using System; +using BenchmarkDotNet.Running; +using Microsoft.Extensions.Caching.Benchmarks; + +#if DEBUG +// validation +using var obj = new DistributedCacheBenchmarks { PayloadSize = 512, Sliding = true }; +Console.WriteLine($"Expected: {obj.PayloadSize}*{DistributedCacheBenchmarks.OperationsPerInvoke} = {obj.PayloadSize * DistributedCacheBenchmarks.OperationsPerInvoke}"); +Console.WriteLine(); + +obj.Backend = DistributedCacheBenchmarks.BackendType.Redis; +obj.GlobalSetup(); +Console.WriteLine(obj.GetSingleRandom()); +Console.WriteLine(obj.GetSingleFixed()); +Console.WriteLine(obj.GetSingleRandomBuffer()); +Console.WriteLine(obj.GetSingleFixedBuffer()); +Console.WriteLine(await obj.GetSingleRandomAsync()); +Console.WriteLine(await obj.GetSingleFixedAsync()); +Console.WriteLine(await obj.GetSingleRandomBufferAsync()); +Console.WriteLine(await obj.GetSingleFixedBufferAsync()); +Console.WriteLine(await obj.GetConcurrentRandomAsync()); +Console.WriteLine(await obj.GetConcurrentFixedAsync()); +Console.WriteLine(); + +obj.Backend = DistributedCacheBenchmarks.BackendType.SqlServer; +obj.GlobalSetup(); +Console.WriteLine(obj.GetSingleRandom()); +Console.WriteLine(obj.GetSingleFixed()); +Console.WriteLine(obj.GetSingleRandomBuffer()); +Console.WriteLine(obj.GetSingleFixedBuffer()); +Console.WriteLine(await obj.GetSingleRandomAsync()); +Console.WriteLine(await obj.GetSingleFixedAsync()); +Console.WriteLine(await obj.GetSingleRandomBufferAsync()); +Console.WriteLine(await obj.GetSingleFixedBufferAsync()); +Console.WriteLine(await obj.GetConcurrentRandomAsync()); +Console.WriteLine(await obj.GetConcurrentFixedAsync()); +Console.WriteLine(); + +#else +BenchmarkRunner.Run(typeof(DistributedCacheBenchmarks).Assembly, args: args); +#endif From 5421398eae3d21581b9f3f0e5731e722ab764c6c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 09:20:29 +0100 Subject: [PATCH 36/75] nit --- .../DistributedCacheBenchmarks.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs index dfb70cc6610f..faa9aa513fa6 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs @@ -275,7 +275,7 @@ public async Task GetConcurrentFixedAsync() private string RandomKey() => keys[random.Next(keys.Length)]; - [Params(1024, 128, 10*1024)] + [Params(1024, 128, 10 * 1024)] public int PayloadSize { get; set; } = 1024; [Params(true, false)] From c3adf749b250dcaa060c08c9a9768c48d572c4a7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 10:02:33 +0100 Subject: [PATCH 37/75] fixes --- .../src/Internal/DefaultHybridCache.L2.cs | 2 +- .../DefaultHybridCache.MutableCacheItem.cs | 3 +- .../DefaultHybridCache.StampedeStateT.cs | 3 +- .../Internal/RecyclableArrayBufferWriter.cs | 29 ++++++++--- .../src/SqlParameterCollectionExtensions.cs | 2 +- .../DistributedCacheBenchmarks.cs | 49 +++++++++++++------ .../Program.cs | 6 ++- 7 files changed, 68 insertions(+), 26 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs index 50e62395b3ff..cab00196e576 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -35,7 +35,7 @@ internal ValueTask> GetFromL2Async(string key, CancellationTo } break; case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter-based - var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); + var writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); var cache = Unsafe.As(backendCache!); // type-checked already var pendingBuffers = cache.TryGetAsync(key, writer, token); if (!pendingBuffers.IsCompletedSuccessfully) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs index 26014dcee1a6..3beb23e614ed 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -19,9 +19,10 @@ public MutableCacheItem(byte[] bytes, int length, IHybridCacheSerializer seri public MutableCacheItem(T value, IHybridCacheSerializer serializer, int maxLength) { this.serializer = serializer; - using var writer = new RecyclableArrayBufferWriter(maxLength); + var writer = RecyclableArrayBufferWriter.Create(maxLength); serializer.Serialize(value, writer); bytes = writer.DetachCommitted(out length); + writer.Dispose(); // only recycle on success } private readonly IHybridCacheSerializer serializer; diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index f24ff67116ac..313c50d4e57c 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -95,10 +95,11 @@ private async Task BackgroundFetchAsync() else { // immutable: we'll need to do the serialize ourselves - using var writer = new RecyclableArrayBufferWriter(MaximumPayloadBytes); // note this lifetime spans the SetL2Async + var writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async Cache.GetSerializer().Serialize(cacheItem.GetValue(), writer); // note GetValue() is fixed value here bytes = writer.GetBuffer(out length); await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + writer.Dispose(); // recycle on success } } } diff --git a/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs b/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs index e2eb5abbe833..68b62e3aa4d0 100644 --- a/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs +++ b/src/Caching/Hybrid/src/Internal/RecyclableArrayBufferWriter.cs @@ -4,6 +4,7 @@ using System; using System.Buffers; using System.Diagnostics; +using System.Threading; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -21,26 +22,40 @@ internal sealed class RecyclableArrayBufferWriter : IBufferWriter, IDispos private T[] _buffer; private int _index; - private readonly int _maxLength; + private int _maxLength; public int CommittedBytes => _index; public int FreeCapacity => _buffer.Length - _index; - public RecyclableArrayBufferWriter(int maxLength) + private static RecyclableArrayBufferWriter? _spare; + public static RecyclableArrayBufferWriter Create(int maxLength) + { + var obj = Interlocked.Exchange(ref _spare, null) ?? new(); + Debug.Assert(obj._index == 0); + obj._maxLength = maxLength; + return obj; + } + + private RecyclableArrayBufferWriter() { _buffer = Array.Empty(); _index = 0; - _maxLength = maxLength; + _maxLength = int.MaxValue; } public void Dispose() { - var tmp = _buffer; + // attempt to reuse everything via "spare"; if that isn't possible, + // recycle the buffers instead _index = 0; - _buffer = Array.Empty(); - if (tmp.Length != 0) + if (Interlocked.CompareExchange(ref _spare, this, null) != null) { - ArrayPool.Shared.Return(tmp); + var tmp = _buffer; + _buffer = Array.Empty(); + if (tmp.Length != 0) + { + ArrayPool.Shared.Return(tmp); + } } } diff --git a/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs b/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs index ca44ec14cff5..51e77498ea3e 100644 --- a/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs +++ b/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs @@ -41,7 +41,7 @@ public static SqlParameterCollection AddCacheItemValue(this SqlParameterCollecti else { // do not mention the size - return parameters.AddWithValue(Columns.Names.CacheItemValue, SqlDbType.VarBinary, value); + return parameters.AddWithValue(Columns.Names.CacheItemValue, SqlDbType.VarBinary, value.Array); } } else // array fragment; set the Size and Offset accordingly diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs index faa9aa513fa6..8e67615d6e29 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs @@ -129,7 +129,7 @@ public int GetSingleRandom() [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] public int GetSingleRandomBuffer() { - using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + var writer = RecyclableArrayBufferWriter.Create(int.MaxValue); int total = 0; for (int i = 0; i < OperationsPerInvoke; i++) { @@ -139,16 +139,25 @@ public int GetSingleRandomBuffer() } writer.ResetInPlace(); } + writer.Dispose(); return total; } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] - public void GetConcurrentRandom() + public int GetConcurrentRandom() { - Parallel.For(0, OperationsPerInvoke, _ => + Func> callback = () => _backend.GetAsync(RandomKey()); + for (int i = 0; i < OperationsPerInvoke; i++) { - _backend.Get(RandomKey()); - }); + pendingBlobs[i] = Task.Run(callback); + } + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += (pendingBlobs[i].Result)?.Length ?? 0; + } + return total; + } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] @@ -165,7 +174,7 @@ public async Task GetSingleRandomAsync() [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] public async Task GetSingleRandomBufferAsync() { - using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + var writer = RecyclableArrayBufferWriter.Create(int.MaxValue); int total = 0; for (int i = 0; i < OperationsPerInvoke; i++) { @@ -175,15 +184,17 @@ public async Task GetSingleRandomBufferAsync() } writer.ResetInPlace(); } + writer.Dispose(); return total; } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] public async Task GetConcurrentRandomAsync() { + Func> callback = () => _backend.GetAsync(RandomKey()); for (int i = 0; i < OperationsPerInvoke; i++) { - pendingBlobs[i] = _backend.GetAsync(RandomKey()); + pendingBlobs[i] = Task.Run(callback); } int total = 0; for (int i = 0; i < OperationsPerInvoke; i++) @@ -207,7 +218,7 @@ public int GetSingleFixed() [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] public int GetSingleFixedBuffer() { - using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + var writer = RecyclableArrayBufferWriter.Create(int.MaxValue); int total = 0; for (int i = 0; i < OperationsPerInvoke; i++) { @@ -217,16 +228,24 @@ public int GetSingleFixedBuffer() } writer.ResetInPlace(); } + writer.Dispose(); return total; } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] - public void GetConcurrentFixed() + public int GetConcurrentFixed() { - Parallel.For(0, OperationsPerInvoke, _ => + Func> callback = () => _backend.GetAsync(FixedKey()); + for (int i = 0; i < OperationsPerInvoke; i++) { - _backend.Get(FixedKey()); - }); + pendingBlobs[i] = Task.Run(callback); + } + int total = 0; + for (int i = 0; i < OperationsPerInvoke; i++) + { + total += (pendingBlobs[i].Result)?.Length ?? 0; + } + return total; } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] @@ -243,7 +262,7 @@ public async Task GetSingleFixedAsync() [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] public async Task GetSingleFixedBufferAsync() { - using var writer = new RecyclableArrayBufferWriter(int.MaxValue); + var writer = RecyclableArrayBufferWriter.Create(int.MaxValue); int total = 0; for (int i = 0; i < OperationsPerInvoke; i++) { @@ -253,15 +272,17 @@ public async Task GetSingleFixedBufferAsync() } writer.ResetInPlace(); } + writer.Dispose(); return total; } [Benchmark(OperationsPerInvoke = OperationsPerInvoke)] public async Task GetConcurrentFixedAsync() { + Func> callback = () => _backend.GetAsync(FixedKey()); for (int i = 0; i < OperationsPerInvoke; i++) { - pendingBlobs[i] = _backend.GetAsync(FixedKey()); + pendingBlobs[i] = Task.Run(callback); } int total = 0; for (int i = 0; i < OperationsPerInvoke; i++) diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs index 7e293b4d573b..299b6a5d5b7f 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs @@ -4,7 +4,7 @@ #if DEBUG // validation -using var obj = new DistributedCacheBenchmarks { PayloadSize = 512, Sliding = true }; +using var obj = new DistributedCacheBenchmarks { PayloadSize = 11512, Sliding = true }; Console.WriteLine($"Expected: {obj.PayloadSize}*{DistributedCacheBenchmarks.OperationsPerInvoke} = {obj.PayloadSize * DistributedCacheBenchmarks.OperationsPerInvoke}"); Console.WriteLine(); @@ -14,6 +14,8 @@ Console.WriteLine(obj.GetSingleFixed()); Console.WriteLine(obj.GetSingleRandomBuffer()); Console.WriteLine(obj.GetSingleFixedBuffer()); +Console.WriteLine(obj.GetConcurrentRandom()); +Console.WriteLine(obj.GetConcurrentFixed()); Console.WriteLine(await obj.GetSingleRandomAsync()); Console.WriteLine(await obj.GetSingleFixedAsync()); Console.WriteLine(await obj.GetSingleRandomBufferAsync()); @@ -28,6 +30,8 @@ Console.WriteLine(obj.GetSingleFixed()); Console.WriteLine(obj.GetSingleRandomBuffer()); Console.WriteLine(obj.GetSingleFixedBuffer()); +Console.WriteLine(obj.GetConcurrentRandom()); +Console.WriteLine(obj.GetConcurrentFixed()); Console.WriteLine(await obj.GetSingleRandomAsync()); Console.WriteLine(await obj.GetSingleFixedAsync()); Console.WriteLine(await obj.GetSingleRandomBufferAsync()); From 0ac4dae4bbc01a9763eaa3472004fd92eb74fa12 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 10:49:19 +0100 Subject: [PATCH 38/75] nit --- .../DistributedCacheBenchmarks.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs index 8e67615d6e29..132a9cb6c3a8 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs @@ -1,16 +1,15 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Threading.Tasks; using System; +using System.Linq; +using System.Threading.Tasks; using BenchmarkDotNet.Attributes; using Microsoft.Data.SqlClient; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Diagnostics.Tracing.Parsers.Clr; using StackExchange.Redis; -using System.Linq; -using Microsoft.Extensions.Caching.Hybrid.Internal; namespace Microsoft.Extensions.Caching.Benchmarks; From 1db2758bd9688d97d4a46c9d8a2e9da599455cd0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:18:45 +0100 Subject: [PATCH 39/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 45f219a40265..97c322eedc7b 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -51,14 +51,14 @@ private static class WrappedCallbackCache // per-T memoized helper that allow } /// - /// Manually insert or overwrite a cache entry. + /// Asynchronously sets or overwrites the value associated with the key. /// /// The type of the data being considered. - /// The unique key for this cache entry. - /// The value to assign for this cache item. + /// The key of the entry to create. + /// The value to assign for this cache entry. /// Additional options for this cache entry. - /// The tags to associate with this cache item. - /// Cancellation for this operation. + /// The tags to associate with this cache entry. + /// The used to propagate notifications that the operation should be canceled. public abstract ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); /// From 2945ce062fc7084c617f34d0bbbe841cf4d390c8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:18:58 +0100 Subject: [PATCH 40/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 97c322eedc7b..23b2f7b46139 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -62,7 +62,7 @@ private static class WrappedCallbackCache // per-T memoized helper that allow public abstract ValueTask SetAsync(string key, T value, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); /// - /// Removes cache data with the specified key. + /// Asynchronously removes the value associated with the key if it exists. /// public abstract ValueTask RemoveKeyAsync(string key, CancellationToken token = default); From 94d9fe104312ba924e1b200cf3ba540f2d2258e0 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:19:05 +0100 Subject: [PATCH 41/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 23b2f7b46139..f908eb8e48cb 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -67,7 +67,7 @@ private static class WrappedCallbackCache // per-T memoized helper that allow public abstract ValueTask RemoveKeyAsync(string key, CancellationToken token = default); /// - /// Removes cache data with the specified keys. + /// Asynchronously removes the value associated with the key if it exists. /// /// Implementors should treat null as empty public virtual ValueTask RemoveKeysAsync(IEnumerable keys, CancellationToken token = default) From a13e1d33637dc18cc79c7a70181bc853e518e81a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:19:13 +0100 Subject: [PATCH 42/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index f908eb8e48cb..845feb468cbc 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -91,7 +91,7 @@ static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, } /// - /// Removes cache data associated with the specified tags. + /// Asynchronously removes the value associated with the specified tags. /// /// Implementors should treat null as empty public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationToken token = default) From 2a779202dffaed623749a62db1be73898b36d9a1 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:19:46 +0100 Subject: [PATCH 43/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 845feb468cbc..cdaf152cd973 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -26,7 +26,7 @@ public abstract class HybridCache /// Cancellation for this operation. /// The data, either from cache or the underlying data service. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] - public abstract ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, + public abstract ValueTask GetOrCreateAsync(string key, TState state, Func> factory, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); /// From cee8ddcbe9d9dda5c4586fecea3703825943af68 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:19:53 +0100 Subject: [PATCH 44/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index cdaf152cd973..3acaca0b394c 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -14,16 +14,16 @@ namespace Microsoft.Extensions.Caching.Hybrid; public abstract class HybridCache { /// - /// Get data from the cache, or the underlying data service if not available. + /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. /// /// The type of the data being considered. /// The type of additional state required by . - /// The unique key for this cache entry. + /// The key of the entry to look for or create. /// Provides the underlying data service is the data is not available in the cache. /// Additional state required for . /// Additional options for this cache entry. /// The tags to associate with this cache item. - /// Cancellation for this operation. + /// The used to propagate notifications that the operation should be canceled. /// The data, either from cache or the underlying data service. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] public abstract ValueTask GetOrCreateAsync(string key, TState state, Func> factory, From 7989f168f7b3d23403845c6cc2963629f781a3bf Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:20:08 +0100 Subject: [PATCH 45/75] Update src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- .../Hybrid/src/Runtime/HybridCacheEntryFlags.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs index 0f2b4e69cc0c..b6a51b11691f 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryFlags.cs @@ -16,35 +16,35 @@ public enum HybridCacheEntryFlags /// None = 0, /// - /// Do not read from the local in-process cache. + /// Disables reading from the local in-process cache. /// DisableLocalCacheRead = 1 << 0, /// - /// Do not write to the local in-process cache. + /// Disables writing to the local in-process cache. /// DisableLocalCacheWrite = 1 << 1, /// - /// Do not use the local in-process cache for reads or writes. + /// Disables both reading from and writing to the local in-process cache. /// DisableLocalCache = DisableLocalCacheRead | DisableLocalCacheWrite, /// - /// Do not read from the secondary distributed cache. + /// Disables reading from the secondary distributed cache. /// DisableDistributedCacheRead = 1 << 2, /// - /// Do not write to the secondary distributed cache. + /// Disables writing to the secondary distributed cache. /// DisableDistributedCacheWrite = 1 << 3, /// - /// Do not use the local in-process cache for reads or writes. + /// Disables both reading from and writing to the secondary distributed cache. /// DisableDistributedCache = DisableDistributedCacheRead | DisableDistributedCacheWrite, /// - /// Only fetch the value from cache - do not attempt to access the underlying data store. + /// Only fetches the value from cache; does not attempt to access the underlying data store. /// DisableUnderlyingData = 1 << 4, /// - /// Do not compress this payload. + /// Disables compression for this payload. /// DisableCompression = 1 << 5, } From 49a477f6155db4cdffc6d3020931263e91fa3060 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:20:17 +0100 Subject: [PATCH 46/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 3acaca0b394c..2215896fa709 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -30,14 +30,14 @@ public abstract ValueTask GetOrCreateAsync(string key, TState stat HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default); /// - /// Get data from the cache, or the underlying data service if not available. + /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. /// /// The type of the data being considered. - /// The unique key for this cache entry. + /// The key of the entry to look for or create. /// Provides the underlying data service is the data is not available in the cache. /// Additional options for this cache entry. /// The tags to associate with this cache item. - /// Cancellation for this operation. + /// The used to propagate notifications that the operation should be canceled. /// The data, either from cache or the underlying data service. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] public ValueTask GetOrCreateAsync(string key, Func> underlyingDataCallback, From 5f8bcb8a85f78239691a1ae971e6682df7f99fb4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:20:38 +0100 Subject: [PATCH 47/75] Update src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs index 6e8491759f92..a5416cce9692 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCacheEntryOptions.cs @@ -6,8 +6,8 @@ namespace Microsoft.Extensions.Caching.Hybrid; /// -/// Additional options (expiration, etc) that apply to a operation. When options -/// can be specified at miltiple levels (for example globally and per-call), the values are composed; the +/// Additional options (expiration, etc.) that apply to a operation. When options +/// can be specified at multiple levels (for example, globally and per-call), the values are composed; the /// most granular non-null value is used, with null values being inherited. If no value is specified at /// any level, the implementation may choose a reasonable default. /// From 7b32e0f6751d229dbfbec5aa1a2ea455cd9206e8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:20:45 +0100 Subject: [PATCH 48/75] Update src/Caching/Hybrid/src/Runtime/HybridCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 2215896fa709..84db9196c473 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -115,7 +115,7 @@ static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, } /// - /// Removes cache data associated with the specified tag. + /// Asynchronously removes the value associated with the specified tag. /// public abstract ValueTask RemoveTagAsync(string tag, CancellationToken token = default); } From 62b4e6f82c98460fbc2c53a38b0393a48516c81c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:20:57 +0100 Subject: [PATCH 49/75] Update src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs index 887bef0802f6..735545db09a3 100644 --- a/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs +++ b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs @@ -20,6 +20,7 @@ public interface IBufferDistributedCache : IDistributedCache /// True if the cache item is found, False otherwise. /// This is functionally similar to , but avoiding the array allocation. bool TryGet(string key, IBufferWriter destination); + /// /// Attempt to asynchronously retrieve an existing cache item. /// From c6cdeae10bb00026a6276f39b0f522d7024f4bcd Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:23:06 +0100 Subject: [PATCH 50/75] Apply suggestions from code review Co-authored-by: Brennan Co-authored-by: joegoldman2 <147369450+joegoldman2@users.noreply.github.com> --- .../Hybrid/src/Internal/DefaultHybridCache.cs | 2 + .../src/Runtime/IBufferDistributedCache.cs | 39 ++++++++++--------- .../Runtime/IHybridCacheSerializerFactory.cs | 2 +- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index e989b94ca725..18aea6730a0b 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -20,12 +20,14 @@ internal sealed class DefaultHybridCache : HybridCache private readonly IDistributedCache backendCache; private readonly IServiceProvider services; private readonly HybridCacheOptions options; + public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IServiceProvider services) { this.backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); this.services = services ?? throw new ArgumentNullException(nameof(services)); this.options = options.Value; } + internal HybridCacheOptions Options => options; public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) diff --git a/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs index 735545db09a3..994d52766a9d 100644 --- a/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs +++ b/src/Caching/Hybrid/src/Runtime/IBufferDistributedCache.cs @@ -16,36 +16,37 @@ public interface IBufferDistributedCache : IDistributedCache /// Attempt to retrieve an existing cache item. /// /// The unique key for the cache item. - /// Target to write the cache contents on success. - /// True if the cache item is found, False otherwise. - /// This is functionally similar to , but avoiding the array allocation. + /// The target to write the cache contents on success. + /// true if the cache item is found, false otherwise. + /// This is functionally similar to , but avoids the array allocation. bool TryGet(string key, IBufferWriter destination); /// - /// Attempt to asynchronously retrieve an existing cache item. + /// Asynchronously attempt to retrieve an existing cache entry. /// - /// The unique key for the cache item. - /// Target to write the cache contents on success. - /// Cancellation for this operation. - /// True if the cache item is found, False otherwise. - /// This is functionally similar to , but avoiding the array allocation. + /// The unique key for the cache entry. + /// The target to write the cache contents on success. + /// The used to propagate notifications that the operation should be canceled. + /// true if the cache entry is found, false otherwise. + /// This is functionally similar to , but avoids the array allocation. ValueTask TryGetAsync(string key, IBufferWriter destination, CancellationToken token = default); /// - /// Insert or overwrite a cache item. + /// Sets or overwrites a cache item. /// - /// The unique key for the cache item. - /// The value for this cache item. - /// The cache options for the value. - /// This is functionally similar to , but avoiding the array allocation. + /// The key of the entry to create. + /// The value for this cache entry. + /// The cache options for the entry. + /// This is functionally similar to , but avoids the array allocation. void Set(string key, ReadOnlySequence value, DistributedCacheEntryOptions options); + /// - /// Asynchronously insert or overwrite a cache item. + /// Asynchronously sets or overwrites a cache entry. /// - /// The unique key for the cache item. - /// The value for this cache item. + /// The key of the entry to create. + /// The value for this cache entry. /// The cache options for the value. - /// Cancellation for this operation. - /// This is functionally similar to , but avoiding the array allocation. + /// The used to propagate notifications that the operation should be canceled. + /// This is functionally similar to , but avoids the array allocation. ValueTask SetAsync(string key, ReadOnlySequence value, DistributedCacheEntryOptions options, CancellationToken token = default); } diff --git a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs index 7e56d5b83835..d500ddfb2ba9 100644 --- a/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs +++ b/src/Caching/Hybrid/src/Runtime/IHybridCacheSerializerFactory.cs @@ -15,6 +15,6 @@ public interface IHybridCacheSerializerFactory /// /// The type being serialized/deserialized. /// The serializer. - /// True if the factory supports this type, False otherwise. + /// true if the factory supports this type, false otherwise. bool TryCreateSerializer([NotNullWhen(true)] out IHybridCacheSerializer? serializer); } From d624726a3e406a09bfc7d6e72efb06e2d3423cdd Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:30:01 +0100 Subject: [PATCH 51/75] more PR feedback --- .../Hybrid/src/Internal/DefaultHybridCache.cs | 18 +++++++++--------- src/Caching/Hybrid/src/PublicAPI.Unshipped.txt | 4 ++-- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 15 +++++++++------ 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 18aea6730a0b..0ec73b682118 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -17,18 +17,18 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// internal sealed class DefaultHybridCache : HybridCache { - private readonly IDistributedCache backendCache; - private readonly IServiceProvider services; - private readonly HybridCacheOptions options; + private readonly IDistributedCache _backendCache; + private readonly IServiceProvider _services; + private readonly HybridCacheOptions _options; public DefaultHybridCache(IOptions options, IDistributedCache backendCache, IServiceProvider services) { - this.backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); - this.services = services ?? throw new ArgumentNullException(nameof(services)); - this.options = options.Value; + _backendCache = backendCache ?? throw new ArgumentNullException(nameof(backendCache)); + _services = services ?? throw new ArgumentNullException(nameof(services)); + _options = options.Value; } - internal HybridCacheOptions Options => options; + internal HybridCacheOptions Options => _options; public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) => underlyingDataCallback(state, token); // pass-thru without caching for initial API pass @@ -46,10 +46,10 @@ internal IHybridCacheSerializer GetSerializer() { // unused API, primarily intended to show configuration is working; // the real version would memoize the result - var service = services.GetServices>().LastOrDefault(); + var service = _services.GetService>(); if (service is null) { - foreach (var factory in services.GetServices()) + foreach (var factory in _services.GetServices()) { if (factory.TryCreateSerializer(out var current)) { diff --git a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt index bb571e4d3840..47e46d6d30ce 100644 --- a/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt +++ b/src/Caching/Hybrid/src/PublicAPI.Unshipped.txt @@ -1,5 +1,5 @@ #nullable enable -abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, TState state, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, TState state, System.Func>! factory, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveKeyAsync(string! key, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.RemoveTagAsync(string! tag, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask abstract Microsoft.Extensions.Caching.Hybrid.HybridCache.SetAsync(string! key, T value, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask @@ -9,7 +9,7 @@ Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.SetAsync(string Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGet(string! key, System.Buffers.IBufferWriter! destination) -> bool Microsoft.Extensions.Caching.Distributed.IBufferDistributedCache.TryGetAsync(string! key, System.Buffers.IBufferWriter! destination, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Microsoft.Extensions.Caching.Hybrid.HybridCache -Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, System.Func>! underlyingDataCallback, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask +Microsoft.Extensions.Caching.Hybrid.HybridCache.GetOrCreateAsync(string! key, System.Func>! factory, Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryOptions? options = null, System.Collections.Generic.IReadOnlyCollection? tags = null, System.Threading.CancellationToken token = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.ValueTask Microsoft.Extensions.Caching.Hybrid.HybridCache.HybridCache() -> void Microsoft.Extensions.Caching.Hybrid.HybridCacheBuilderExtensions Microsoft.Extensions.Caching.Hybrid.HybridCacheEntryFlags diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index 84db9196c473..a2aaad2c0f26 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -1,3 +1,6 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + using System; using System.Collections; using System.Collections.Generic; @@ -17,10 +20,10 @@ public abstract class HybridCache /// Asynchronously gets the value associated with the key if it exists, or generates a new entry using the provided key and a value from the given factory if the key is not found. /// /// The type of the data being considered. - /// The type of additional state required by . + /// The type of additional state required by . /// The key of the entry to look for or create. - /// Provides the underlying data service is the data is not available in the cache. - /// Additional state required for . + /// Provides the underlying data service is the data is not available in the cache. + /// Additional state required for . /// Additional options for this cache entry. /// The tags to associate with this cache item. /// The used to propagate notifications that the operation should be canceled. @@ -34,15 +37,15 @@ public abstract ValueTask GetOrCreateAsync(string key, TState stat /// /// The type of the data being considered. /// The key of the entry to look for or create. - /// Provides the underlying data service is the data is not available in the cache. + /// Provides the underlying data service is the data is not available in the cache. /// Additional options for this cache entry. /// The tags to associate with this cache item. /// The used to propagate notifications that the operation should be canceled. /// The data, either from cache or the underlying data service. [System.Diagnostics.CodeAnalysis.SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Delegate differences make this unambiguous")] - public ValueTask GetOrCreateAsync(string key, Func> underlyingDataCallback, + public ValueTask GetOrCreateAsync(string key, Func> factory, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) - => GetOrCreateAsync(key, underlyingDataCallback, WrappedCallbackCache.Instance, options, tags, token); + => GetOrCreateAsync(key, factory, WrappedCallbackCache.Instance, options, tags, token); private static class WrappedCallbackCache // per-T memoized helper that allows GetOrCreateAsync and GetOrCreateAsync to share an implementation { From 83b7a1d1cb4131c417085f1b743cebadca7523c7 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:41:58 +0100 Subject: [PATCH 52/75] apply _ field convention --- .../src/Internal/DefaultHybridCache.L2.cs | 14 ++-- .../DefaultHybridCache.MutableCacheItem.cs | 22 ++--- .../DefaultHybridCache.Serialization.cs | 10 +-- .../Internal/DefaultHybridCache.Stampede.cs | 10 +-- .../DefaultHybridCache.StampedeKey.cs | 22 ++--- .../DefaultHybridCache.StampedeState.cs | 12 +-- .../DefaultHybridCache.StampedeStateT.cs | 52 ++++++------ .../Hybrid/src/Internal/DefaultHybridCache.cs | 82 +++++++++---------- 8 files changed, 112 insertions(+), 112 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs index cab00196e576..30faf23d7342 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -15,7 +15,7 @@ internal ValueTask> GetFromL2Async(string key, CancellationTo switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { case CacheFeatures.BackendCache: // legacy byte[]-based - var pendingLegacy = backendCache!.GetAsync(key, token); + var pendingLegacy = _backendCache!.GetAsync(key, token); #if NETCOREAPP2_0_OR_GREATER || NETSTANDARD2_1_OR_GREATER if (!pendingLegacy.IsCompletedSuccessfully) #else @@ -36,7 +36,7 @@ internal ValueTask> GetFromL2Async(string key, CancellationTo break; case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter-based var writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); - var cache = Unsafe.As(backendCache!); // type-checked already + var cache = Unsafe.As(_backendCache!); // type-checked already var pendingBuffers = cache.TryGetAsync(key, writer, token); if (!pendingBuffers.IsCompletedSuccessfully) { @@ -87,9 +87,9 @@ internal ValueTask SetL2Async(string key, byte[] value, int length, HybridCacheE Array.Resize(ref value, length); } Debug.Assert(value.Length == length); - return new(backendCache!.SetAsync(key, value, GetOptions(options), token)); + return new(_backendCache!.SetAsync(key, value, GetOptions(options), token)); case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // ReadOnlySequence-based - var cache = Unsafe.As(backendCache!); // type-checked already + var cache = Unsafe.As(_backendCache!); // type-checked already return cache.SetAsync(key, new(value, 0, length), GetOptions(options), token); } return default; @@ -98,13 +98,13 @@ internal ValueTask SetL2Async(string key, byte[] value, int length, HybridCacheE private DistributedCacheEntryOptions GetOptions(HybridCacheEntryOptions? options) { DistributedCacheEntryOptions? result = null; - if (options is not null && options.Expiration.HasValue && options.Expiration.GetValueOrDefault() != defaultExpiration) + if (options is not null && options.Expiration.HasValue && options.Expiration.GetValueOrDefault() != _defaultExpiration) { result = options.ToDistributedCacheEntryOptions(); } - return result ?? defaultDistributedCacheExpiration; + return result ?? _defaultDistributedCacheExpiration; } internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions? options) - => localCache.Set(key, value, options?.LocalCacheExpiration ?? defaultLocalCacheExpiration); + => _localCache.Set(key, value, options?.LocalCacheExpiration ?? _defaultLocalCacheExpiration); } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs index 3beb23e614ed..8cb3ceba2143 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -11,30 +11,30 @@ private sealed class MutableCacheItem : CacheItem // used to hold types th { public MutableCacheItem(byte[] bytes, int length, IHybridCacheSerializer serializer) { - this.serializer = serializer; - this.bytes = bytes; - this.length = length; + _serializer = serializer; + _bytes = bytes; + _length = length; } public MutableCacheItem(T value, IHybridCacheSerializer serializer, int maxLength) { - this.serializer = serializer; + _serializer = serializer; var writer = RecyclableArrayBufferWriter.Create(maxLength); serializer.Serialize(value, writer); - bytes = writer.DetachCommitted(out length); + _bytes = writer.DetachCommitted(out _length); writer.Dispose(); // only recycle on success } - private readonly IHybridCacheSerializer serializer; - private readonly byte[] bytes; - private readonly int length; + private readonly IHybridCacheSerializer _serializer; + private readonly byte[] _bytes; + private readonly int _length; - public override T GetValue() => serializer.Deserialize(new ReadOnlySequence(bytes, 0, length)); + public override T GetValue() => _serializer.Deserialize(new ReadOnlySequence(_bytes, 0, _length)); public override byte[]? TryGetBytes(out int length) { - length = this.length; - return bytes; + length = _length; + return _bytes; } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs index f32cae4af668..8ed699ec6853 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs @@ -14,23 +14,23 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - private readonly ConcurrentDictionary serializers = new(); // per instance cache of typed serializers + private readonly ConcurrentDictionary _serializers = new(); // per instance cache of typed serializers internal int MaximumPayloadBytes { get; } internal IHybridCacheSerializer GetSerializer() { - return serializers.TryGetValue(typeof(T), out var serializer) + return _serializers.TryGetValue(typeof(T), out var serializer) ? Unsafe.As>(serializer) : ResolveAndAddSerializer(this); static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @this) { // it isn't critical that we get only one serializer instance during start-up; what matters // is that we don't get a new serializer instance *every time* - var serializer = @this.services.GetServices>().LastOrDefault(); + var serializer = @this._services.GetService>(); if (serializer is null) { - foreach (var factory in @this.serializerFactories) + foreach (var factory in @this._serializerFactories) { if (factory.TryCreateSerializer(out var current)) { @@ -44,7 +44,7 @@ static IHybridCacheSerializer ResolveAndAddSerializer(DefaultHybridCache @thi throw new InvalidOperationException($"No {nameof(IHybridCacheSerializer)} configured for type '{typeof(T).Name}'"); } // store the result so we don't repeat this in future - @this.serializers[typeof(T)] = serializer; + @this._serializers[typeof(T)] = serializer; return serializer; } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs index 1de00053eab4..a838bef2cdc8 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs @@ -9,19 +9,19 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - private readonly ConcurrentDictionary currentOperations = new(); + private readonly ConcurrentDictionary _currentOperations = new(); internal int DebugGetCallerCount(string key, HybridCacheEntryFlags? flags = null) { - var stampedeKey = new StampedeKey(key, flags ?? defaultFlags); - return currentOperations.TryGetValue(stampedeKey, out var state) ? state.DebugCallerCount : 0; + var stampedeKey = new StampedeKey(key, flags ?? _defaultFlags); + return _currentOperations.TryGetValue(stampedeKey, out var state) ? state.DebugCallerCount : 0; } // returns true for a new session (in which case: we need to start the work), false for a pre-existing session public bool GetOrCreateStampede(string key, HybridCacheEntryFlags flags, out StampedeState stampedeState, bool canBeCanceled) { var stampedeKey = new StampedeKey(key, flags); - if (currentOperations.TryGetValue(stampedeKey, out var found)) + if (_currentOperations.TryGetValue(stampedeKey, out var found)) { var tmp = found as StampedeState; if (tmp is null) @@ -39,7 +39,7 @@ public bool GetOrCreateStampede(string key, HybridCacheEntryFlags fla // create a new session stampedeState = new StampedeState(this, stampedeKey, canBeCanceled); - currentOperations[stampedeKey] = stampedeState; + _currentOperations[stampedeKey] = stampedeState; return true; [DoesNotReturn] diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs index 6abdd4266e39..3d02810806e9 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs @@ -10,26 +10,26 @@ partial class DefaultHybridCache { internal readonly struct StampedeKey : IEquatable { - private readonly string key; - private readonly HybridCacheEntryFlags flags; - private readonly int hashCode; // we know we'll need it; compute it once only + private readonly string _key; + private readonly HybridCacheEntryFlags _flags; + private readonly int _hashCode; // we know we'll need it; compute it once only public StampedeKey(string key, HybridCacheEntryFlags flags) { - this.key = key; - this.flags = flags; - this.hashCode = key.GetHashCode() ^ (int)flags; + _key = key; + _flags = flags; + _hashCode = key.GetHashCode() ^ (int)flags; } - public string Key => key; - public HybridCacheEntryFlags Flags => flags; + public string Key => _key; + public HybridCacheEntryFlags Flags => _flags; - public bool Equals(StampedeKey other) => this.flags == other.flags & this.key == other.key; + public bool Equals(StampedeKey other) => _flags == other._flags & _key == other._key; public override bool Equals([NotNullWhen(true)] object? obj) => obj is StampedeKey other && Equals(other); - public override int GetHashCode() => hashCode; + public override int GetHashCode() => _hashCode; - public override string ToString() => $"{key} ({flags})"; + public override string ToString() => $"{_key} ({_flags})"; } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index d49b9b5e581e..428866f2ac6c 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -14,7 +14,7 @@ internal abstract class StampedeState : IThreadPoolWorkItem #endif { - private readonly DefaultHybridCache cache; + private readonly DefaultHybridCache _cache; public StampedeKey Key { get; } @@ -23,7 +23,7 @@ internal abstract class StampedeState /// protected StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) { - this.cache = cache; + _cache = cache; Key = key; if (canBeCanceled) { @@ -43,7 +43,7 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBe /// protected StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationToken token) { - this.cache = cache; + _cache = cache; Key = key; SharedToken = token; } @@ -52,11 +52,11 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, Cancellati protected static readonly WaitCallback SharedWaitCallback = static obj => Unsafe.As(obj).Execute(); #endif - protected DefaultHybridCache Cache => cache; + protected DefaultHybridCache Cache => _cache; public abstract void Execute(); - protected int MaximumPayloadBytes => cache.MaximumPayloadBytes; + protected int MaximumPayloadBytes => _cache.MaximumPayloadBytes; public override string ToString() => Key.ToString(); @@ -102,5 +102,5 @@ public bool TryAddCaller() // essentially just interlocked-increment, but with a } } - private void RemoveStampede(StampedeKey key) => currentOperations.TryRemove(key, out _); + private void RemoveStampede(StampedeKey key) => _currentOperations.TryRemove(key, out _); } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 313c50d4e57c..d650ebaf3817 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -12,16 +12,16 @@ partial class DefaultHybridCache { internal sealed class StampedeState : StampedeState { - private readonly TaskCompletionSource>? result; - private TState? state; - private Func>? underlying; + private readonly TaskCompletionSource>? _result; + private TState? _state; + private Func>? _underlying; - private HybridCacheEntryOptions? options; + private HybridCacheEntryOptions? _options; public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) : base(cache, key, canBeCanceled) { - result = new(); + _result = new(); } public StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationToken token) @@ -29,13 +29,13 @@ public StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationT public void QueueUserWorkItem(in TState state, Func> underlying, HybridCacheEntryOptions? options) { - Debug.Assert(this.underlying is null); + Debug.Assert(_underlying is null); Debug.Assert(underlying is not null); // initialize the callback state - this.state = state; - this.underlying = underlying; - this.options = options; + _state = state; + _underlying = underlying; + _options = options; #if NETCOREAPP3_0_OR_GREATER ThreadPool.UnsafeQueueUserWorkItem(this, false); @@ -46,13 +46,13 @@ public void QueueUserWorkItem(in TState state, Func> underlying, HybridCacheEntryOptions? options) { - Debug.Assert(this.underlying is null); + Debug.Assert(_underlying is null); Debug.Assert(underlying is not null); // initialize the callback state - this.state = state; - this.underlying = underlying; - this.options = options; + _state = state; + _underlying = underlying; + _options = options; return BackgroundFetchAsync(); } @@ -78,7 +78,7 @@ private async Task BackgroundFetchAsync() // nothing from L2; invoke the underlying data store if ((Key.Flags & HybridCacheEntryFlags.DisableUnderlyingData) == 0) { - var cacheItem = SetResult(await underlying!(state!, SharedToken).ConfigureAwait(false)); + var cacheItem = SetResult(await _underlying!(_state!, SharedToken).ConfigureAwait(false)); // note that at this point we've already released most or all of the waiting callers; everything // else here is background @@ -90,7 +90,7 @@ private async Task BackgroundFetchAsync() if (bytes is not null) { // mutable; we've already serialized it for the shared cache item - await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + await Cache.SetL2Async(Key.Key, bytes, length, _options, SharedToken).ConfigureAwait(false); } else { @@ -98,7 +98,7 @@ private async Task BackgroundFetchAsync() var writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async Cache.GetSerializer().Serialize(cacheItem.GetValue(), writer); // note GetValue() is fixed value here bytes = writer.GetBuffer(out length); - await Cache.SetL2Async(Key.Key, bytes, length, options, SharedToken).ConfigureAwait(false); + await Cache.SetL2Async(Key.Key, bytes, length, _options, SharedToken).ConfigureAwait(false); writer.Dispose(); // recycle on success } } @@ -120,8 +120,8 @@ public Task> Task { get { - Debug.Assert(result is not null); - return result is null ? Invalid() : result.Task; + Debug.Assert(_result is not null); + return _result is null ? Invalid() : _result.Task; static Task> Invalid() => System.Threading.Tasks.Task.FromException>(new InvalidOperationException("Task should not be accessed for non-shared instances")); } @@ -129,10 +129,10 @@ public Task> Task private void SetException(Exception ex) { - if (result is not null) + if (_result is not null) { Cache.RemoveStampede(Key); - result.TrySetException(ex); + _result.TrySetException(ex); } } @@ -140,23 +140,23 @@ private void SetResult(CacheItem value) { if ((Key.Flags & HybridCacheEntryFlags.DisableLocalCacheWrite) == 0) { - Cache.SetL1(Key.Key, value, options); // we can do this without a TCS, for SetValue + Cache.SetL1(Key.Key, value, _options); // we can do this without a TCS, for SetValue } - if (result is not null) + if (_result is not null) { Cache.RemoveStampede(Key); - result?.TrySetResult(value); + _result?.TrySetResult(value); } } private void SetDefaultResult() { // note we don't store this dummy result in L1 or L2 - if (result is not null) + if (_result is not null) { Cache.RemoveStampede(Key); - result.TrySetResult(ImmutableCacheItem.Default); + _result.TrySetResult(ImmutableCacheItem.Default); } } @@ -184,7 +184,7 @@ private CacheItem SetResult(T value) return cacheItem; } - protected override void SetCanceled() => result?.TrySetCanceled(SharedToken); + protected override void SetCanceled() => _result?.TrySetCanceled(SharedToken); private Task? _sharedUnwrap; diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 08d86e747e58..3c9e4ec11506 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -20,20 +20,20 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; /// internal sealed partial class DefaultHybridCache : HybridCache { - private readonly IDistributedCache? backendCache; - private readonly IMemoryCache localCache; - private readonly IServiceProvider services; // we can't resolve per-type serializers until we see each T - private readonly IHybridCacheSerializerFactory[] serializerFactories; - private readonly HybridCacheOptions options; - private readonly ILogger? logger; - private readonly CacheFeatures features; // used to avoid constant type-testing + private readonly IDistributedCache? _backendCache; + private readonly IMemoryCache _localCache; + private readonly IServiceProvider _services; // we can't resolve per-type serializers until we see each T + private readonly IHybridCacheSerializerFactory[] _serializerFactories; + private readonly HybridCacheOptions _options; + private readonly ILogger? _logger; + private readonly CacheFeatures _features; // used to avoid constant type-testing - private readonly HybridCacheEntryFlags hardFlags; // *always* present (for example, because no L2) - private readonly HybridCacheEntryFlags defaultFlags; // note this already includes hardFlags - private readonly TimeSpan defaultExpiration; - private readonly TimeSpan defaultLocalCacheExpiration; + private readonly HybridCacheEntryFlags _hardFlags; // *always* present (for example, because no L2) + private readonly HybridCacheEntryFlags _defaultFlags; // note this already includes hardFlags + private readonly TimeSpan _defaultExpiration; + private readonly TimeSpan _defaultLocalCacheExpiration; - private readonly DistributedCacheEntryOptions defaultDistributedCacheExpiration; + private readonly DistributedCacheEntryOptions _defaultDistributedCacheExpiration; [Flags] internal enum CacheFeatures @@ -44,33 +44,33 @@ internal enum CacheFeatures } [MethodImpl(MethodImplOptions.AggressiveInlining)] - private CacheFeatures GetFeatures(CacheFeatures mask) => features & mask; + private CacheFeatures GetFeatures(CacheFeatures mask) => _features & mask; - internal CacheFeatures GetFeatures() => features; + internal CacheFeatures GetFeatures() => _features; // used to restrict features in test suite - internal void DebugRemoveFeatures(CacheFeatures features) => Unsafe.AsRef(in this.features) &= ~features; + internal void DebugRemoveFeatures(CacheFeatures features) => Unsafe.AsRef(in _features) &= ~features; public DefaultHybridCache(IOptions options, IServiceProvider services) { - this.services = services ?? throw new ArgumentNullException(nameof(services)); - this.localCache = services.GetRequiredService(); - this.options = options.Value; - this.logger = services.GetService()?.CreateLogger(typeof(HybridCache)); // note optional + _services = services ?? throw new ArgumentNullException(nameof(services)); + _localCache = services.GetRequiredService(); + _options = options.Value; + _logger = services.GetService()?.CreateLogger(typeof(HybridCache)); // note optional - this.backendCache = services.GetService(); // note optional + _backendCache = services.GetService(); // note optional // ignore L2 if it is really just the same L1, wrapped // (note not just an "is" test; if someone has a custom subclass, who knows what it does?) - if (this.backendCache is not null - && this.backendCache.GetType() == typeof(MemoryDistributedCache) - && this.localCache.GetType() == typeof(MemoryCache)) + if (_backendCache is not null + && _backendCache.GetType() == typeof(MemoryDistributedCache) + && _localCache.GetType() == typeof(MemoryCache)) { - this.backendCache = null; + _backendCache = null; } // perform type-tests on the backend once only - this.features |= backendCache switch + _features |= _backendCache switch { IBufferDistributedCache => CacheFeatures.BackendCache | CacheFeatures.BackendBuffers, not null => CacheFeatures.BackendCache, @@ -82,30 +82,30 @@ public DefaultHybridCache(IOptions options, IServiceProvider // taking the first match var factories = services.GetServices().ToArray(); Array.Reverse(factories); - this.serializerFactories = factories; + _serializerFactories = factories; - MaximumPayloadBytes = checked((int)this.options.MaximumPayloadBytes); // for now hard-limit to 2GiB + MaximumPayloadBytes = checked((int)_options.MaximumPayloadBytes); // for now hard-limit to 2GiB - var defaultEntryOptions = this.options.DefaultEntryOptions; + var defaultEntryOptions = _options.DefaultEntryOptions; - if (this.backendCache is null) + if (_backendCache is null) { - this.hardFlags |= HybridCacheEntryFlags.DisableDistributedCache; + _hardFlags |= HybridCacheEntryFlags.DisableDistributedCache; } - this.defaultFlags = (defaultEntryOptions?.Flags ?? HybridCacheEntryFlags.None) | this.hardFlags; - this.defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(5); - this.defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1); - this.defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = defaultExpiration }; + _defaultFlags = (defaultEntryOptions?.Flags ?? HybridCacheEntryFlags.None) | _hardFlags; + _defaultExpiration = defaultEntryOptions?.Expiration ?? TimeSpan.FromMinutes(5); + _defaultLocalCacheExpiration = defaultEntryOptions?.LocalCacheExpiration ?? TimeSpan.FromMinutes(1); + _defaultDistributedCacheExpiration = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = _defaultExpiration }; } - internal IDistributedCache? BackendCache => backendCache; - internal IMemoryCache LocalCache => localCache; + internal IDistributedCache? BackendCache => _backendCache; + internal IMemoryCache LocalCache => _localCache; - internal HybridCacheOptions Options => options; + internal HybridCacheOptions Options => _options; [MethodImpl(MethodImplOptions.AggressiveInlining)] private HybridCacheEntryFlags GetEffectiveFlags(HybridCacheEntryOptions? options) - => (options?.Flags | hardFlags) ?? defaultFlags; + => (options?.Flags | _hardFlags) ?? _defaultFlags; public override ValueTask GetOrCreateAsync(string key, TState state, Func> underlyingDataCallback, HybridCacheEntryOptions? options = null, IReadOnlyCollection? tags = null, CancellationToken token = default) { @@ -116,7 +116,7 @@ public override ValueTask GetOrCreateAsync(string key, TState stat } var flags = GetEffectiveFlags(options); - if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && localCache.TryGetValue(key, out var untyped) && untyped is CacheItem typed) + if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && _localCache.TryGetValue(key, out var untyped) && untyped is CacheItem typed) { // short-circuit return new(typed.GetValue()); @@ -144,8 +144,8 @@ public override ValueTask GetOrCreateAsync(string key, TState stat public override ValueTask RemoveKeyAsync(string key, CancellationToken token = default) { - localCache.Remove(key); - return backendCache is null ? default : new(backendCache.RemoveAsync(key, token)); + _localCache.Remove(key); + return _backendCache is null ? default : new(_backendCache.RemoveAsync(key, token)); } public override ValueTask RemoveTagAsync(string tag, CancellationToken token = default) From 59bc62a70229090107777df48deddfd23a6c18a9 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 11:44:32 +0100 Subject: [PATCH 53/75] nit comment --- src/Caching/Hybrid/src/Runtime/HybridCache.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Caching/Hybrid/src/Runtime/HybridCache.cs b/src/Caching/Hybrid/src/Runtime/HybridCache.cs index a2aaad2c0f26..d2ba7f809cd4 100644 --- a/src/Caching/Hybrid/src/Runtime/HybridCache.cs +++ b/src/Caching/Hybrid/src/Runtime/HybridCache.cs @@ -94,7 +94,7 @@ static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, } /// - /// Asynchronously removes the value associated with the specified tags. + /// Asynchronously removes all values associated with the specified tags. /// /// Implementors should treat null as empty public virtual ValueTask RemoveTagsAsync(IEnumerable tags, CancellationToken token = default) @@ -118,7 +118,7 @@ static async ValueTask ForEachAsync(HybridCache @this, IEnumerable keys, } /// - /// Asynchronously removes the value associated with the specified tag. + /// Asynchronously removes all values associated with the specified tag. /// public abstract ValueTask RemoveTagAsync(string tag, CancellationToken token = default); } From 0e36776b03f62e2b33f5494c99cebc0639c4b936 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 14:05:54 +0100 Subject: [PATCH 54/75] add tests for advanced buffer scenarios for redis+sql --- src/Caching/Hybrid/src/Internal/readme.md | 5 +- ...Microsoft.Extensions.Caching.Hybrid.csproj | 2 +- .../Hybrid/test/DistributedCacheTests.cs | 381 ++++++++++++++++++ ...oft.Extensions.Caching.Hybrid.Tests.csproj | 1 + src/Caching/Hybrid/test/RedisTests.cs | 43 +- src/Caching/Hybrid/test/SqlServerTests.cs | 46 +++ .../src/SqlParameterCollectionExtensions.cs | 9 +- src/Caching/SqlServer/src/SqlServerCache.cs | 6 + .../StackExchangeRedis/src/RedisCache.cs | 98 ++--- ...osoft.Extensions.Caching.Benchmarks.csproj | 2 +- 10 files changed, 523 insertions(+), 70 deletions(-) create mode 100644 src/Caching/Hybrid/test/DistributedCacheTests.cs create mode 100644 src/Caching/Hybrid/test/SqlServerTests.cs diff --git a/src/Caching/Hybrid/src/Internal/readme.md b/src/Caching/Hybrid/src/Internal/readme.md index 8d20f4f43c27..2339d3631253 100644 --- a/src/Caching/Hybrid/src/Internal/readme.md +++ b/src/Caching/Hybrid/src/Internal/readme.md @@ -5,7 +5,10 @@ The `DefaultHybridCache` implementation keeps a collection of `StampedeState` entries that represent the current in-flight operations (keyed by `StampedeKey`); if a duplicate operation occurs during the execution, the second operation will be joined with that -same flow, rather than executing independently. +same flow, rather than executing independently. When attempting to merge with an +existing flow, interlocked counting is used: we can only join if we can successfully +increment the value from a non-zero value (zero meaning all existing consumers have +canceled, and the shared token is therefore canceled) The `StampedeState<>` performs back-end fetch operations, resulting not in a `T` (of the final value), but instead a `CacheItem`; this is the object that gets put into L1 cache, diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index 7bd31d97a30e..d395ffadd900 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/Caching/Hybrid/test/DistributedCacheTests.cs b/src/Caching/Hybrid/test/DistributedCacheTests.cs new file mode 100644 index 000000000000..017bc2a9baa3 --- /dev/null +++ b/src/Caching/Hybrid/test/DistributedCacheTests.cs @@ -0,0 +1,381 @@ +// 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 +{ + public 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 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); + + var 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"); + } + } + + [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); + + var 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"); + } + } + + 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 + second = new(size - 7, rand, first), // we'll take all of this one + 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; + } + + 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; + } + } + } + + 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 Span(_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 readonly static DistributedCacheEntryOptions FiveMinutes + = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }; + + protected static string Me([CallerMemberName] string caller = "") => caller; +} diff --git a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj index 05734e1c043b..54c0de3adf67 100644 --- a/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj +++ b/src/Caching/Hybrid/test/Microsoft.Extensions.Caching.Hybrid.Tests.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Caching/Hybrid/test/RedisTests.cs b/src/Caching/Hybrid/test/RedisTests.cs index df9d29904db3..a039ebd4611d 100644 --- a/src/Caching/Hybrid/test/RedisTests.cs +++ b/src/Caching/Hybrid/test/RedisTests.cs @@ -1,13 +1,6 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System; -using System.Collections.Generic; -using System.Linq; -using System.Runtime.CompilerServices; -using System.Text; -using System.Threading.Tasks; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Hybrid.Internal; using Microsoft.Extensions.Caching.StackExchangeRedis; using Microsoft.Extensions.DependencyInjection; @@ -38,25 +31,36 @@ public class RedisFixture : IDisposable } } } -public class RedisTests(RedisFixture fixture, ITestOutputHelper log) : IClassFixture +public class RedisTests : DistributedCacheTests, IClassFixture { - [Theory] - [InlineData(false)] - [InlineData(true)] - public async Task BasicUsage(bool useBuffers) + 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(); + var redis = await _fixture.ConnectAsync(); if (redis is null) { - log.WriteLine("Redis is not available"); + Log.WriteLine("Redis is not available"); return; // inconclusive } - log.WriteLine("Redis is available"); - var services = new ServiceCollection(); + 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(); var provider = services.BuildServiceProvider(); // not "using" - that will tear down our redis; use the fixture for that @@ -67,9 +71,10 @@ public async Task BasicUsage(bool useBuffers) { cache.DebugRemoveFeatures(DefaultHybridCache.CacheFeatures.BackendBuffers); } - log.WriteLine($"features: {cache.GetFeatures()}"); + 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)); @@ -86,9 +91,7 @@ await cache.GetOrCreateAsync(key, _ => { 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}"); + Log.WriteLine($"ttl: {ttl}"); Assert.NotNull(ttl); } - - private static string Me([CallerMemberName] string caller = "") => caller; } diff --git a/src/Caching/Hybrid/test/SqlServerTests.cs b/src/Caching/Hybrid/test/SqlServerTests.cs new file mode 100644 index 000000000000..bbfc18338933 --- /dev/null +++ b/src/Caching/Hybrid/test/SqlServerTests.cs @@ -0,0 +1,46 @@ +// 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; + + 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/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs b/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs index 51e77498ea3e..8d474ab2427a 100644 --- a/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs +++ b/src/Caching/SqlServer/src/SqlParameterCollectionExtensions.cs @@ -28,7 +28,14 @@ public static SqlParameterCollection AddCacheItemValue(this SqlParameterCollecti { return parameters.AddWithValue(Columns.Names.CacheItemValue, SqlDbType.VarBinary, Array.Empty()); } - else if (value.Offset == 0 & value.Count == value.Array.Length) // right-sized array + + if (value.Count == 0) + { + // workaround for https://github.com/dotnet/SqlClient/issues/2465 + value = new([], 0, 0); + } + + if (value.Offset == 0 & value.Count == value.Array!.Length) // right-sized array { if (value.Count < DefaultValueColumnWidth) { diff --git a/src/Caching/SqlServer/src/SqlServerCache.cs b/src/Caching/SqlServer/src/SqlServerCache.cs index 766ffebe4c9b..fea47497160a 100644 --- a/src/Caching/SqlServer/src/SqlServerCache.cs +++ b/src/Caching/SqlServer/src/SqlServerCache.cs @@ -233,6 +233,12 @@ async ValueTask IBufferDistributedCache.SetAsync( private static ArraySegment Linearize(in ReadOnlySequence value, out byte[]? lease) { + if (value.IsEmpty) + { + lease = null; + return new([], 0, 0); + } + // SqlClient only supports single-segment chunks via byte[] with offset/count; this will // almost never be an issue, but on those rare occasions: use a leased array to harmonize things if (value.IsSingleSegment && MemoryMarshal.TryGetArray(value.First, out var segment)) diff --git a/src/Caching/StackExchangeRedis/src/RedisCache.cs b/src/Caching/StackExchangeRedis/src/RedisCache.cs index 93129813b035..f35983b341eb 100644 --- a/src/Caching/StackExchangeRedis/src/RedisCache.cs +++ b/src/Caching/StackExchangeRedis/src/RedisCache.cs @@ -404,7 +404,10 @@ private void TryAddSuffix(IConnectionMultiplexer connection) if (results.Length >= 2) { MapMetadata(results, out DateTimeOffset? absExpr, out TimeSpan? sldExpr); - Refresh(cache, key, absExpr, sldExpr); + if (sldExpr.HasValue) + { + Refresh(cache, key, absExpr, sldExpr.GetValueOrDefault()); + } } if (results.Length >= 3 && !results[2].IsNull) @@ -440,7 +443,10 @@ private void TryAddSuffix(IConnectionMultiplexer connection) if (results.Length >= 2) { MapMetadata(results, out DateTimeOffset? absExpr, out TimeSpan? sldExpr); - await RefreshAsync(cache, key, absExpr, sldExpr, token).ConfigureAwait(false); + if (sldExpr.HasValue) + { + await RefreshAsync(cache, key, absExpr, sldExpr.GetValueOrDefault(), token).ConfigureAwait(false); + } } if (results.Length >= 3 && !results[2].IsNull) @@ -503,63 +509,57 @@ private static void MapMetadata(RedisValue[] results, out DateTimeOffset? absolu } } - private void Refresh(IDatabase cache, string key, DateTimeOffset? absExpr, TimeSpan? sldExpr) + private void Refresh(IDatabase cache, string key, DateTimeOffset? absExpr, TimeSpan sldExpr) { ArgumentNullThrowHelper.ThrowIfNull(key); // Note Refresh has no effect if there is just an absolute expiration (or neither). - if (sldExpr.HasValue) + TimeSpan? expr; + if (absExpr.HasValue) { - TimeSpan? expr; - if (absExpr.HasValue) - { - var relExpr = absExpr.Value - DateTimeOffset.Now; - expr = relExpr <= sldExpr.Value ? relExpr : sldExpr; - } - else - { - expr = sldExpr; - } - try - { - cache.KeyExpire(_instancePrefix.Append(key), expr); - } - catch (Exception ex) - { - OnRedisError(ex, cache); - throw; - } + var relExpr = absExpr.Value - DateTimeOffset.Now; + expr = relExpr <= sldExpr ? relExpr : sldExpr; + } + else + { + expr = sldExpr; + } + try + { + cache.KeyExpire(_instancePrefix.Append(key), expr); + } + catch (Exception ex) + { + OnRedisError(ex, cache); + throw; } } - private async Task RefreshAsync(IDatabase cache, string key, DateTimeOffset? absExpr, TimeSpan? sldExpr, CancellationToken token = default) + private async Task RefreshAsync(IDatabase cache, string key, DateTimeOffset? absExpr, TimeSpan sldExpr, CancellationToken token) { ArgumentNullThrowHelper.ThrowIfNull(key); token.ThrowIfCancellationRequested(); // Note Refresh has no effect if there is just an absolute expiration (or neither). - if (sldExpr.HasValue) + TimeSpan? expr; + if (absExpr.HasValue) { - TimeSpan? expr; - if (absExpr.HasValue) - { - var relExpr = absExpr.Value - DateTimeOffset.Now; - expr = relExpr <= sldExpr.Value ? relExpr : sldExpr; - } - else - { - expr = sldExpr; - } - try - { - await cache.KeyExpireAsync(_instancePrefix.Append(key), expr).ConfigureAwait(false); - } - catch (Exception ex) - { - OnRedisError(ex, cache); - throw; - } + var relExpr = absExpr.Value - DateTimeOffset.Now; + expr = relExpr <= sldExpr ? relExpr : sldExpr; + } + else + { + expr = sldExpr; + } + try + { + await cache.KeyExpireAsync(_instancePrefix.Append(key), expr).ConfigureAwait(false); + } + catch (Exception ex) + { + OnRedisError(ex, cache); + throw; } } @@ -722,7 +722,10 @@ bool IBufferDistributedCache.TryGet(string key, IBufferWriter destination) if (metadata.Length >= 2) { MapMetadata(metadata, out DateTimeOffset? absExpr, out TimeSpan? sldExpr); - Refresh(cache, key, absExpr, sldExpr); + if (sldExpr.HasValue) + { + Refresh(cache, key, absExpr, sldExpr.GetValueOrDefault()); + } } // this is where we actually copy the data out @@ -766,7 +769,10 @@ async ValueTask IBufferDistributedCache.TryGetAsync(string key, IBufferWri if (metadata.Length >= 2) { MapMetadata(metadata, out DateTimeOffset? absExpr, out TimeSpan? sldExpr); - await RefreshAsync(cache, key, absExpr, sldExpr, token).ConfigureAwait(false); + if (sldExpr.HasValue) + { + await RefreshAsync(cache, key, absExpr, sldExpr.GetValueOrDefault(), token).ConfigureAwait(false); + } } // this is where we actually copy the data out diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj index 300f5123b645..2fdc30b58718 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj @@ -17,7 +17,7 @@ - + From ee15beb3053c99364283febc2f67af093876d61c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 14:37:24 +0100 Subject: [PATCH 55/75] Empty-Commit From ffa0e0a5264c2347726e5ded03ae3737477ea777 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Wed, 17 Apr 2024 15:51:24 +0100 Subject: [PATCH 56/75] hybrid cache baseline perf --- ...Microsoft.Extensions.Caching.Hybrid.csproj | 2 +- .../HybridCacheBenchmarks.cs | 137 ++++++++++++++++++ ...osoft.Extensions.Caching.Benchmarks.csproj | 2 + .../Program.cs | 81 ++++++----- 4 files changed, 187 insertions(+), 35 deletions(-) create mode 100644 src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/HybridCacheBenchmarks.cs diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index d395ffadd900..d30f322d8bd6 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/HybridCacheBenchmarks.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/HybridCacheBenchmarks.cs new file mode 100644 index 000000000000..badb770797ed --- /dev/null +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/HybridCacheBenchmarks.cs @@ -0,0 +1,137 @@ +// 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.ComponentModel; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using BenchmarkDotNet.Attributes; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace Microsoft.Extensions.Caching.Benchmarks; + +[MemoryDiagnoser] +public class HybridCacheBenchmarks : IDisposable +{ + private const string RedisConfigurationString = "127.0.0.1,AllowAdmin=true"; + private readonly ConnectionMultiplexer _multiplexer; + private readonly IDistributedCache _distributed; + private readonly HybridCache _hybrid; + public HybridCacheBenchmarks() + { + _multiplexer = ConnectionMultiplexer.Connect(RedisConfigurationString); + var services = new ServiceCollection(); + services.AddStackExchangeRedisCache(options => + { + options.ConnectionMultiplexerFactory = () => Task.FromResult(_multiplexer); + }); + services.AddHybridCache(); + var provider = services.BuildServiceProvider(); + + _distributed = provider.GetRequiredService(); + + _distributed.Remove(KeyDirect); + _distributed.Remove(KeyHybrid); + _distributed.Remove(KeyHybridImmutable); + + _hybrid = provider.GetRequiredService(); + } + + private const string KeyDirect = "direct"; + private const string KeyHybrid = "hybrid"; + private const string KeyHybridImmutable = "I_brid"; // want 6 chars + + public void Dispose() => _multiplexer.Dispose(); + + private const int CustomerId = 42; + + private static readonly DistributedCacheEntryOptions OneHour = new DistributedCacheEntryOptions() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) + }; + + [Benchmark(Baseline = true)] + public async ValueTask HitDistributedCache() // scenario: 100% (or as-near-as) cache hit rate + { + var bytes = await _distributed.GetAsync(KeyDirect); + if (bytes is null) + { + var cust = await Customer.GetAsync(CustomerId); + await _distributed.SetAsync(KeyDirect, Serialize(cust), OneHour); + return cust; + } + else + { + return Deserialize(bytes)!; + } + } + + [Benchmark] + public ValueTask HitHybridCache() // scenario: 100% (or as-near-as) cache hit rate + => _hybrid.GetOrCreateAsync(KeyHybrid, CustomerId, static (id, ct) => Customer.GetAsync(id, ct)); + + [Benchmark] + public ValueTask HitHybridCacheImmutable() // scenario: 100% (or as-near-as) cache hit rate + => _hybrid.GetOrCreateAsync(KeyHybridImmutable, CustomerId, static (id, ct) => ImmutableCustomer.GetAsync(id, ct)); + + private static byte[] Serialize(T obj) + { + using var ms = new MemoryStream(); + JsonSerializer.Serialize(ms, obj); + return ms.ToArray(); + } + + private static T? Deserialize(byte[] bytes) + { + using var ms = new MemoryStream(); + return JsonSerializer.Deserialize(bytes); + } + + public class Customer + { + public static ValueTask GetAsync(int id, CancellationToken token = default) + => new(new Customer + { + Id = id, + Name = "Random customer", + Region = 2, + Description = "Good for testing", + CreationDate = new DateTime(2024, 04, 17), + OrderValue = 123_456.789M + }); + + public int Id { get; set; } + public string? Name {get; set; } + public int Region { get; set; } + public string? Description { get; set; } + public DateTime CreationDate { get; set; } + public decimal OrderValue { get; set; } + } + + [ImmutableObject(true)] + public sealed class ImmutableCustomer + { + public static ValueTask GetAsync(int id, CancellationToken token = default) + => new(new ImmutableCustomer + { + Id = id, + Name = "Random customer", + Region = 2, + Description = "Good for testing", + CreationDate = new DateTime(2024, 04, 17), + OrderValue = 123_456.789M + }); + + public int Id { get; init; } + public string? Name { get; init; } + public int Region { get; init; } + public string? Description { get; init; } + public DateTime CreationDate { get; init; } + public decimal OrderValue { get; init; } + } +} diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj index 2fdc30b58718..62a6b1da49aa 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj @@ -19,6 +19,8 @@ + + \ No newline at end of file diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs index 299b6a5d5b7f..b75a13c01ed6 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs +++ b/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs @@ -4,42 +4,55 @@ #if DEBUG // validation -using var obj = new DistributedCacheBenchmarks { PayloadSize = 11512, Sliding = true }; -Console.WriteLine($"Expected: {obj.PayloadSize}*{DistributedCacheBenchmarks.OperationsPerInvoke} = {obj.PayloadSize * DistributedCacheBenchmarks.OperationsPerInvoke}"); -Console.WriteLine(); +using (var hc = new HybridCacheBenchmarks()) +{ + for (int i = 0; i < 10; i++) + { + Console.WriteLine((await hc.HitDistributedCache()).Name); + Console.WriteLine((await hc.HitHybridCache()).Name); + Console.WriteLine((await hc.HitHybridCacheImmutable()).Name); + } +} -obj.Backend = DistributedCacheBenchmarks.BackendType.Redis; -obj.GlobalSetup(); -Console.WriteLine(obj.GetSingleRandom()); -Console.WriteLine(obj.GetSingleFixed()); -Console.WriteLine(obj.GetSingleRandomBuffer()); -Console.WriteLine(obj.GetSingleFixedBuffer()); -Console.WriteLine(obj.GetConcurrentRandom()); -Console.WriteLine(obj.GetConcurrentFixed()); -Console.WriteLine(await obj.GetSingleRandomAsync()); -Console.WriteLine(await obj.GetSingleFixedAsync()); -Console.WriteLine(await obj.GetSingleRandomBufferAsync()); -Console.WriteLine(await obj.GetSingleFixedBufferAsync()); -Console.WriteLine(await obj.GetConcurrentRandomAsync()); -Console.WriteLine(await obj.GetConcurrentFixedAsync()); -Console.WriteLine(); +/* +using (var obj = new DistributedCacheBenchmarks { PayloadSize = 11512, Sliding = true }) +{ + Console.WriteLine($"Expected: {obj.PayloadSize}*{DistributedCacheBenchmarks.OperationsPerInvoke} = {obj.PayloadSize * DistributedCacheBenchmarks.OperationsPerInvoke}"); + Console.WriteLine(); -obj.Backend = DistributedCacheBenchmarks.BackendType.SqlServer; -obj.GlobalSetup(); -Console.WriteLine(obj.GetSingleRandom()); -Console.WriteLine(obj.GetSingleFixed()); -Console.WriteLine(obj.GetSingleRandomBuffer()); -Console.WriteLine(obj.GetSingleFixedBuffer()); -Console.WriteLine(obj.GetConcurrentRandom()); -Console.WriteLine(obj.GetConcurrentFixed()); -Console.WriteLine(await obj.GetSingleRandomAsync()); -Console.WriteLine(await obj.GetSingleFixedAsync()); -Console.WriteLine(await obj.GetSingleRandomBufferAsync()); -Console.WriteLine(await obj.GetSingleFixedBufferAsync()); -Console.WriteLine(await obj.GetConcurrentRandomAsync()); -Console.WriteLine(await obj.GetConcurrentFixedAsync()); -Console.WriteLine(); + obj.Backend = DistributedCacheBenchmarks.BackendType.Redis; + obj.GlobalSetup(); + Console.WriteLine(obj.GetSingleRandom()); + Console.WriteLine(obj.GetSingleFixed()); + Console.WriteLine(obj.GetSingleRandomBuffer()); + Console.WriteLine(obj.GetSingleFixedBuffer()); + Console.WriteLine(obj.GetConcurrentRandom()); + Console.WriteLine(obj.GetConcurrentFixed()); + Console.WriteLine(await obj.GetSingleRandomAsync()); + Console.WriteLine(await obj.GetSingleFixedAsync()); + Console.WriteLine(await obj.GetSingleRandomBufferAsync()); + Console.WriteLine(await obj.GetSingleFixedBufferAsync()); + Console.WriteLine(await obj.GetConcurrentRandomAsync()); + Console.WriteLine(await obj.GetConcurrentFixedAsync()); + Console.WriteLine(); + obj.Backend = DistributedCacheBenchmarks.BackendType.SqlServer; + obj.GlobalSetup(); + Console.WriteLine(obj.GetSingleRandom()); + Console.WriteLine(obj.GetSingleFixed()); + Console.WriteLine(obj.GetSingleRandomBuffer()); + Console.WriteLine(obj.GetSingleFixedBuffer()); + Console.WriteLine(obj.GetConcurrentRandom()); + Console.WriteLine(obj.GetConcurrentFixed()); + Console.WriteLine(await obj.GetSingleRandomAsync()); + Console.WriteLine(await obj.GetSingleFixedAsync()); + Console.WriteLine(await obj.GetSingleRandomBufferAsync()); + Console.WriteLine(await obj.GetSingleFixedBufferAsync()); + Console.WriteLine(await obj.GetConcurrentRandomAsync()); + Console.WriteLine(await obj.GetConcurrentFixedAsync()); + Console.WriteLine(); +} +*/ #else -BenchmarkRunner.Run(typeof(DistributedCacheBenchmarks).Assembly, args: args); +BenchmarkSwitcher.FromAssembly(typeof(DistributedCacheBenchmarks).Assembly).Run(args: args); #endif From 1b12e1ab4d2339537b93222e79a92cfe5087f7ba Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 11:57:40 +0100 Subject: [PATCH 57/75] move benchmarks ala PR feedback --- AspNetCore.sln | 38 +++++++++---------- src/Caching/Caching.slnf | 2 +- .../DistributedCacheBenchmarks.cs | 0 .../HybridCacheBenchmarks.cs | 15 ++++++-- ...Extensions.Caching.MicroBenchmarks.csproj} | 2 +- .../Program.cs | 0 6 files changed, 33 insertions(+), 24 deletions(-) rename src/Caching/perf/{Microsoft.Extensions.Caching.Benchmarks => MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks}/DistributedCacheBenchmarks.cs (100%) rename src/Caching/perf/{Microsoft.Extensions.Caching.Benchmarks => MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks}/HybridCacheBenchmarks.cs (88%) rename src/Caching/perf/{Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj => MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks.csproj} (88%) rename src/Caching/perf/{Microsoft.Extensions.Caching.Benchmarks => MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks}/Program.cs (100%) diff --git a/AspNetCore.sln b/AspNetCore.sln index f00cbe118c9a..e2d44f7619e0 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1794,9 +1794,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Cachin EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Hybrid.Tests", "src\Caching\Hybrid\test\Microsoft.Extensions.Caching.Hybrid.Tests.csproj", "{CF63C942-895A-4F6B-888A-7653D7C4991A}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Benchmarks", "Benchmarks", "{6469F11E-8CEE-4292-820B-324DFFC88EBC}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "MicroBenchmarks", "MicroBenchmarks", "{6469F11E-8CEE-4292-820B-324DFFC88EBC}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.Benchmarks", "src\Caching\perf\Microsoft.Extensions.Caching.Benchmarks\Microsoft.Extensions.Caching.Benchmarks.csproj", "{268CF55F-94A8-4F87-9482-D5B755CFA79C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Extensions.Caching.MicroBenchmarks", "src\Caching\perf\MicroBenchmarks\Microsoft.Extensions.Caching.MicroBenchmarks\Microsoft.Extensions.Caching.MicroBenchmarks.csproj", "{8D2CC6ED-5105-4F52-8757-C21F4DE78589}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -10831,22 +10831,22 @@ Global {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x64.Build.0 = Release|Any CPU {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.ActiveCfg = Release|Any CPU {CF63C942-895A-4F6B-888A-7653D7C4991A}.Release|x86.Build.0 = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|arm64.ActiveCfg = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|arm64.Build.0 = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x64.ActiveCfg = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x64.Build.0 = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x86.ActiveCfg = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Debug|x86.Build.0 = Debug|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|Any CPU.Build.0 = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|arm64.ActiveCfg = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|arm64.Build.0 = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x64.ActiveCfg = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x64.Build.0 = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x86.ActiveCfg = Release|Any CPU - {268CF55F-94A8-4F87-9482-D5B755CFA79C}.Release|x86.Build.0 = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|arm64.ActiveCfg = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|arm64.Build.0 = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|x64.ActiveCfg = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|x64.Build.0 = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|x86.ActiveCfg = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Debug|x86.Build.0 = Debug|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|Any CPU.Build.0 = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|arm64.ActiveCfg = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|arm64.Build.0 = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|x64.ActiveCfg = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|x64.Build.0 = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|x86.ActiveCfg = Release|Any CPU + {8D2CC6ED-5105-4F52-8757-C21F4DE78589}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -11734,7 +11734,7 @@ Global {2B60E6D3-9E7C-427A-AD4E-BBE9A6D935B9} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} {CF63C942-895A-4F6B-888A-7653D7C4991A} = {2D64CA23-6E81-488E-A7D3-9BDF87240098} {6469F11E-8CEE-4292-820B-324DFFC88EBC} = {0F39820F-F4A5-41C6-9809-D79B68F032EF} - {268CF55F-94A8-4F87-9482-D5B755CFA79C} = {6469F11E-8CEE-4292-820B-324DFFC88EBC} + {8D2CC6ED-5105-4F52-8757-C21F4DE78589} = {6469F11E-8CEE-4292-820B-324DFFC88EBC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Caching/Caching.slnf b/src/Caching/Caching.slnf index f82ab1ad0d5f..522505d8ab90 100644 --- a/src/Caching/Caching.slnf +++ b/src/Caching/Caching.slnf @@ -8,7 +8,7 @@ "src\\Caching\\SqlServer\\test\\Microsoft.Extensions.Caching.SqlServer.Tests.csproj", "src\\Caching\\StackExchangeRedis\\src\\Microsoft.Extensions.Caching.StackExchangeRedis.csproj", "src\\Caching\\StackExchangeRedis\\test\\Microsoft.Extensions.Caching.StackExchangeRedis.Tests.csproj", - "src\\Caching\\perf\\Microsoft.Extensions.Caching.Benchmarks\\Microsoft.Extensions.Caching.Benchmarks.csproj", + "src\\Caching\\perf\\MicroBenchmarks\\Microsoft.Extensions.Caching.MicroBenchmarks\\Microsoft.Extensions.Caching.MicroBenchmarks.csproj", "src\\Middleware\\OutputCaching\\src\\Microsoft.AspNetCore.OutputCaching.csproj" ] } diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/DistributedCacheBenchmarks.cs similarity index 100% rename from src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/DistributedCacheBenchmarks.cs rename to src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/DistributedCacheBenchmarks.cs diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/HybridCacheBenchmarks.cs b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/HybridCacheBenchmarks.cs similarity index 88% rename from src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/HybridCacheBenchmarks.cs rename to src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/HybridCacheBenchmarks.cs index badb770797ed..3f68c9468040 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/HybridCacheBenchmarks.cs +++ b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/HybridCacheBenchmarks.cs @@ -55,8 +55,9 @@ public HybridCacheBenchmarks() AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1) }; + // scenario: 100% (or as-near-as) cache hit rate [Benchmark(Baseline = true)] - public async ValueTask HitDistributedCache() // scenario: 100% (or as-near-as) cache hit rate + public async ValueTask HitDistributedCache() { var bytes = await _distributed.GetAsync(KeyDirect); if (bytes is null) @@ -71,9 +72,17 @@ public async ValueTask HitDistributedCache() // scenario: 100% (or as- } } + // scenario: 100% (or as-near-as) cache hit rate [Benchmark] - public ValueTask HitHybridCache() // scenario: 100% (or as-near-as) cache hit rate - => _hybrid.GetOrCreateAsync(KeyHybrid, CustomerId, static (id, ct) => Customer.GetAsync(id, ct)); + public ValueTask HitCaptureHybridCache() + => _hybrid.GetOrCreateAsync(KeyHybrid, + ct => Customer.GetAsync(CustomerId, ct)); + + // scenario: 100% (or as-near-as) cache hit rate + [Benchmark] + public ValueTask HitHybridCache() + => _hybrid.GetOrCreateAsync(KeyHybrid, CustomerId, + static (id, ct) => Customer.GetAsync(id, ct)); [Benchmark] public ValueTask HitHybridCacheImmutable() // scenario: 100% (or as-near-as) cache hit rate diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks.csproj similarity index 88% rename from src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj rename to src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks.csproj index 62a6b1da49aa..50b8df19317e 100644 --- a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Microsoft.Extensions.Caching.Benchmarks.csproj +++ b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks.csproj @@ -17,7 +17,7 @@ - + diff --git a/src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Program.cs similarity index 100% rename from src/Caching/perf/Microsoft.Extensions.Caching.Benchmarks/Program.cs rename to src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Program.cs From 3e42524579e6cde7cd63e6736c7809f9c5f99c8f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:19:57 +0100 Subject: [PATCH 58/75] include max allowed payload size in fault --- .../src/Internal/DefaultHybridCache.L2.cs | 46 ++++++++++--------- src/Caching/Hybrid/test/RedisTests.cs | 5 ++ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs index 30faf23d7342..bcfb6c2a63e5 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -1,5 +1,6 @@ using System; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -22,18 +23,9 @@ internal ValueTask> GetFromL2Async(string key, CancellationTo if (pendingLegacy.Status != TaskStatus.RanToCompletion) #endif { - return new(AwaitedLegacy(pendingLegacy, MaximumPayloadBytes)); + return new(AwaitedLegacy(pendingLegacy, this)); } - var bytes = pendingLegacy.Result; // already complete - if (bytes is not null) - { - if (bytes.Length > MaximumPayloadBytes) - { - ThrowQuota(); - } - return new(new ArraySegment(bytes)); - } - break; + return new(GetValidPayloadSegment(pendingLegacy.Result)); // already complete case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // IBufferWriter-based var writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); var cache = Unsafe.As(_backendCache!); // type-checked already @@ -50,18 +42,10 @@ internal ValueTask> GetFromL2Async(string key, CancellationTo } return default; - static async Task> AwaitedLegacy(Task pending, int maximumPayloadBytes) + static async Task> AwaitedLegacy(Task pending, DefaultHybridCache @this) { var bytes = await pending.ConfigureAwait(false); - if (bytes is not null) - { - if (bytes.Length > maximumPayloadBytes) - { - ThrowQuota(); - } - return new(bytes); - } - return default; + return @this.GetValidPayloadSegment(bytes); } static async Task> AwaitedBuffers(ValueTask pending, RecyclableArrayBufferWriter writer) @@ -72,8 +56,26 @@ static async Task> AwaitedBuffers(ValueTask pending, Re writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened return result; } + } + + private ArraySegment GetValidPayloadSegment(byte[]? payload) + { + if (payload is not null) + { + if (payload.Length > MaximumPayloadBytes) + { + ThrowPayloadLengthExceeded(payload.Length); + } + return new(payload); + } + return default; + } - static void ThrowQuota() => throw new InvalidOperationException("Maximum cache length exceeded"); + [DoesNotReturn, MethodImpl(MethodImplOptions.NoInlining)] + private void ThrowPayloadLengthExceeded(int size) // splitting the exception bits out to a different method + { + // TODO: also log to logger (hence instance method) + throw new InvalidOperationException($"Maximum cache length ({MaximumPayloadBytes} bytes) exceeded"); } internal ValueTask SetL2Async(string key, byte[] value, int length, HybridCacheEntryOptions? options, CancellationToken token) diff --git a/src/Caching/Hybrid/test/RedisTests.cs b/src/Caching/Hybrid/test/RedisTests.cs index a039ebd4611d..0334a1dbc476 100644 --- a/src/Caching/Hybrid/test/RedisTests.cs +++ b/src/Caching/Hybrid/test/RedisTests.cs @@ -65,6 +65,11 @@ public async Task BasicUsage(bool useBuffers) var 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 From 8c0e1b58e31cb9ef24cae5d742c4a71c8238c83a Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:25:01 +0100 Subject: [PATCH 59/75] add comment on the serializer cache --- .../Hybrid/src/Internal/DefaultHybridCache.Serialization.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs index 8ed699ec6853..0adf22b793be 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Serialization.cs @@ -14,7 +14,11 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - private readonly ConcurrentDictionary _serializers = new(); // per instance cache of typed serializers + // per instance cache of typed serializers; each serializer is a + // IHybridCacheSerializer for the corresponding Type, but we can't + // know which here - and undesirable to add an artificial non-generic + // IHybridCacheSerializer base that serves no other purpose + private readonly ConcurrentDictionary _serializers = new(); internal int MaximumPayloadBytes { get; } From 6c10b8d8b7bc13e9e1e91e2d17cfceebf0bf1020 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:30:43 +0100 Subject: [PATCH 60/75] include type data in the wrong-type message --- .../src/Internal/DefaultHybridCache.Stampede.cs | 13 +++++++++---- .../Internal/DefaultHybridCache.StampedeState.cs | 3 +++ .../Internal/DefaultHybridCache.StampedeStateT.cs | 2 ++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs index a838bef2cdc8..24680c58d866 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Concurrent; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -26,7 +27,7 @@ public bool GetOrCreateStampede(string key, HybridCacheEntryFlags fla var tmp = found as StampedeState; if (tmp is null) { - ThrowWrongType(key); + ThrowWrongType(key, found.Type, typeof(T)); } if (tmp.TryAddCaller()) @@ -43,9 +44,13 @@ public bool GetOrCreateStampede(string key, HybridCacheEntryFlags fla return true; [DoesNotReturn] - static void ThrowWrongType(string key) => throw new InvalidOperationException($"All calls to {nameof(HybridCache)} with the same key should use the same data type") + static void ThrowWrongType(string key, Type existingType, Type newType) { - Data = { { "CacheKey", key } } - }; + Debug.Assert(existingType != newType); + throw new InvalidOperationException($"All calls to {nameof(HybridCache)} with the same key should use the same data type; the same key is being used for '{existingType.FullName}' and '{newType.FullName}' data") + { + Data = { { "CacheKey", key } } + }; + } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index 428866f2ac6c..adfb249ef222 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.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; using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Threading; @@ -70,6 +71,8 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, Cancellati public int DebugCallerCount => Volatile.Read(ref activeCallers); + public abstract Type Type { get; } + private int activeCallers = 1; public void RemoveCaller() { diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index d650ebaf3817..118dbe7a5186 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -24,6 +24,8 @@ public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCan _result = new(); } + public override Type Type => typeof(T); + public StampedeState(DefaultHybridCache cache, in StampedeKey key, CancellationToken token) : base(cache, key, token) { } // no TCS in this case - this is for SetValue only From 552676fbc8b0424ae06c6b2fa748e7f97db7feda Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:34:20 +0100 Subject: [PATCH 61/75] constify --- src/Caching/SqlServer/src/DatabaseOperations.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Caching/SqlServer/src/DatabaseOperations.cs b/src/Caching/SqlServer/src/DatabaseOperations.cs index dbc56e4f9960..0adbd9d54128 100644 --- a/src/Caching/SqlServer/src/DatabaseOperations.cs +++ b/src/Caching/SqlServer/src/DatabaseOperations.cs @@ -317,7 +317,9 @@ static long StreamOut(SqlDataReader source, int ordinal, IBufferWriter des { dataIndex += read; // increment offset - var memory = destination.GetMemory(8192); // start from the page size + const int DefaultPageSize = 8192; + + var memory = destination.GetMemory(DefaultPageSize); // start from the page size if (MemoryMarshal.TryGetArray(memory, out var segment)) { // avoid an extra copy by writing directly to the target array when possible @@ -329,7 +331,7 @@ static long StreamOut(SqlDataReader source, int ordinal, IBufferWriter des } else { - lease ??= ArrayPool.Shared.Rent(8192); + lease ??= ArrayPool.Shared.Rent(DefaultPageSize); read = (int)source.GetBytes(ordinal, dataIndex, lease, 0, lease.Length); if (read > 0) From bb1f85625bf067ab35e9f366eaeb8104df304271 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:36:26 +0100 Subject: [PATCH 62/75] missing lic headers --- src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs | 3 +++ .../Microsoft.Extensions.Caching.MicroBenchmarks/Program.cs | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs index bcfb6c2a63e5..9d7c434314fd 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -1,3 +1,6 @@ +// 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.Diagnostics; using System.Diagnostics.CodeAnalysis; diff --git a/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Program.cs b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Program.cs index b75a13c01ed6..bfb33dc46a30 100644 --- a/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Program.cs +++ b/src/Caching/perf/MicroBenchmarks/Microsoft.Extensions.Caching.MicroBenchmarks/Program.cs @@ -1,3 +1,6 @@ +// 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 BenchmarkDotNet.Running; using Microsoft.Extensions.Caching.Benchmarks; From 6a256010a77da97c929278dba3a20c94e052d98f Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:41:28 +0100 Subject: [PATCH 63/75] use standard Try* pattern --- .../Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs | 4 +++- .../src/Internal/DefaultHybridCache.ImmutableCacheItem.cs | 7 +++++-- .../src/Internal/DefaultHybridCache.MutableCacheItem.cs | 5 +++-- .../src/Internal/DefaultHybridCache.StampedeStateT.cs | 3 +-- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs index 92ff1ad08a05..1632e7091891 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache @@ -9,6 +11,6 @@ internal abstract class CacheItem { public abstract T GetValue(); - public abstract byte[]? TryGetBytes(out int length); + public abstract bool TryGetBytes(out int length, [NotNullWhen(true)] out byte[]? data); } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs index b7892eaaf2bd..3bad33406851 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache @@ -12,10 +14,11 @@ partial class DefaultHybridCache public override T GetValue() => value; - public override byte[]? TryGetBytes(out int length) + public override bool TryGetBytes(out int length, [NotNullWhen(true)] out byte[]? data) { length = 0; - return null; + data = null; + return false; } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs index 8cb3ceba2143..9c44968f7b46 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -31,10 +31,11 @@ public MutableCacheItem(T value, IHybridCacheSerializer serializer, int maxLe public override T GetValue() => _serializer.Deserialize(new ReadOnlySequence(_bytes, 0, _length)); - public override byte[]? TryGetBytes(out int length) + public override bool TryGetBytes(out int length, out byte[] data) { length = _length; - return _bytes; + data = _bytes; + return true; } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 118dbe7a5186..b04fd2e9e5e8 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -88,8 +88,7 @@ private async Task BackgroundFetchAsync() // write to L2 if appropriate if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) { - var bytes = cacheItem.TryGetBytes(out int length); - if (bytes is not null) + if (cacheItem.TryGetBytes(out int length, out var bytes)) { // mutable; we've already serialized it for the shared cache item await Cache.SetL2Async(Key.Key, bytes, length, _options, SharedToken).ConfigureAwait(false); From 17383c4e2395e2efecf21d419c30d66bbbdcbcd3 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:44:01 +0100 Subject: [PATCH 64/75] redundant ?. --- .../Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index b04fd2e9e5e8..5fdbce6bc831 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -147,7 +147,7 @@ private void SetResult(CacheItem value) if (_result is not null) { Cache.RemoveStampede(Key); - _result?.TrySetResult(value); + _result.TrySetResult(value); } } From 14bb5fcc77a1ea086619626685148a7d1e23ba69 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 12:47:21 +0100 Subject: [PATCH 65/75] TCS: async completions --- .../Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 5fdbce6bc831..2e7892d5e192 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -21,7 +21,7 @@ internal sealed class StampedeState : StampedeState public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) : base(cache, key, canBeCanceled) { - _result = new(); + _result = new(TaskCreationOptions.RunContinuationsAsynchronously); } public override Type Type => typeof(T); @@ -217,7 +217,7 @@ public ValueTask JoinAsync(CancellationToken token) static async ValueTask WithCancellation(StampedeState stampede, CancellationToken token) { - var cancelStub = new TaskCompletionSource(); + var cancelStub = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); using var reg = token.Register(static obj => { ((TaskCompletionSource)obj!).TrySetResult(true); From e6b2cca9cc80e35985356b09f37c97bf12c2d429 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 13:27:13 +0100 Subject: [PATCH 66/75] use double-checked lock to avoid race conditions creating two concurrent states --- .../Internal/DefaultHybridCache.Stampede.cs | 68 +++++++++++++++---- .../DefaultHybridCache.StampedeState.cs | 3 +- .../DefaultHybridCache.StampedeStateT.cs | 2 +- 3 files changed, 57 insertions(+), 16 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs index 24680c58d866..fa88def35647 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Stampede.cs @@ -22,27 +22,69 @@ internal int DebugGetCallerCount(string key, HybridCacheEntryFlags? flags = null public bool GetOrCreateStampede(string key, HybridCacheEntryFlags flags, out StampedeState stampedeState, bool canBeCanceled) { var stampedeKey = new StampedeKey(key, flags); - if (_currentOperations.TryGetValue(stampedeKey, out var found)) + + // double-checked locking to try to avoid unnecessary sessions in race conditions, + // while avoiding the lock completely whenever possible + if (TryJoinExistingSession(this, stampedeKey, out var existing)) + { + stampedeState = existing; + return false; // someone ELSE is running the work + } + + // most common scenario here, then, is that we're not fighting with anyone else; + // go ahead and create a placeholder state object and *try* to add it + stampedeState = new StampedeState(this, stampedeKey, canBeCanceled); + if (_currentOperations.TryAdd(stampedeKey, stampedeState)) + { + // successfully added; indeed, no-one else was fighting: we're done + return true; // the CURRENT caller is responsible for making the work happen + } + + // hmm; failed to add - there's concurrent activity on the same key; we're now + // in very rare race condition territory; go ahead and take a lock while we + // collect our thoughts + lock (_currentOperations) { - var tmp = found as StampedeState; - if (tmp is null) + // check again while we hold the lock + if (TryJoinExistingSession(this, stampedeKey, out existing)) { - ThrowWrongType(key, found.Type, typeof(T)); + // we found an existing state we can join; do that + stampedeState.SetCanceled(); // to be thorough: mark our speculative one as doomed (no-one has seen it, though) + stampedeState = existing; // and replace with the one we found + return false; // someone ELSE is running the work + + // note that in this case we allocated a StampedeState that got dropped on + // the floor; in the grand scheme of things, that's OK; this is a rare outcome } - if (tmp.TryAddCaller()) + // otherwise, either nothing existed - or the thing that already exists can't be joined; + // in that case, go ahead and use the state that we invented a moment ago (outside of the lock) + _currentOperations[stampedeKey] = stampedeState; + return true; // the CURRENT caller is responsible for making the work happen + } + + static bool TryJoinExistingSession(DefaultHybridCache @this, in StampedeKey stampedeKey, + [NotNullWhen(true)] out StampedeState? stampedeState) + { + if (@this._currentOperations.TryGetValue(stampedeKey, out var found)) { - // we joined an existing session - stampedeState = tmp; - return false; + var tmp = found as StampedeState; + if (tmp is null) + { + ThrowWrongType(stampedeKey.Key, found.Type, typeof(T)); + } + + if (tmp.TryAddCaller()) + { + // we joined an existing session + stampedeState = tmp; + return true; + } } + stampedeState = null; + return false; } - // create a new session - stampedeState = new StampedeState(this, stampedeKey, canBeCanceled); - _currentOperations[stampedeKey] = stampedeState; - return true; - [DoesNotReturn] static void ThrowWrongType(string key, Type existingType, Type newType) { diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index adfb249ef222..72c4a1145ab5 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Concurrent; using System.Runtime.CompilerServices; using System.Threading; @@ -65,7 +64,7 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, Cancellati // (and keep going until then); that means we need to run with custom cancellation private readonly CancellationTokenSource? sharedCancellation; - protected abstract void SetCanceled(); + public abstract void SetCanceled(); public readonly CancellationToken SharedToken; diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 2e7892d5e192..73926e69c0b5 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -185,7 +185,7 @@ private CacheItem SetResult(T value) return cacheItem; } - protected override void SetCanceled() => _result?.TrySetCanceled(SharedToken); + public override void SetCanceled() => _result?.TrySetCanceled(SharedToken); private Task? _sharedUnwrap; From 96d75fa80aa5d206e69a969fb86baec20ab65137 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 13:45:46 +0100 Subject: [PATCH 67/75] move state above methods; use NullLogger --- .../DefaultHybridCache.ImmutableCacheItem.cs | 6 +++-- .../DefaultHybridCache.MutableCacheItem.cs | 8 +++--- .../DefaultHybridCache.StampedeState.cs | 27 +++++++++---------- .../DefaultHybridCache.StampedeStateT.cs | 6 ++--- .../Hybrid/src/Internal/DefaultHybridCache.cs | 5 ++-- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs index 3bad33406851..7ab5e2bdc235 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -9,8 +9,10 @@ partial class DefaultHybridCache { private sealed class ImmutableCacheItem(T value) : CacheItem // used to hold types that do not require defensive copies { - private static ImmutableCacheItem? sharedDefault; - public static ImmutableCacheItem Default => sharedDefault ??= new(default!); // this is only used when the underlying store is disabled + private static ImmutableCacheItem? SharedDefault; + + // this is only used when the underlying store is disabled; we don't need 100% singleton; "good enough is" + public static ImmutableCacheItem Default => SharedDefault ??= new(default!); public override T GetValue() => value; diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs index 9c44968f7b46..92643c3a4b8f 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -9,6 +9,10 @@ partial class DefaultHybridCache { private sealed class MutableCacheItem : CacheItem // used to hold types that require defensive copies { + private readonly IHybridCacheSerializer _serializer; + private readonly byte[] _bytes; + private readonly int _length; + public MutableCacheItem(byte[] bytes, int length, IHybridCacheSerializer serializer) { _serializer = serializer; @@ -25,10 +29,6 @@ public MutableCacheItem(T value, IHybridCacheSerializer serializer, int maxLe writer.Dispose(); // only recycle on success } - private readonly IHybridCacheSerializer _serializer; - private readonly byte[] _bytes; - private readonly int _length; - public override T GetValue() => _serializer.Deserialize(new ReadOnlySequence(_bytes, 0, _length)); public override bool TryGetBytes(out int length, out byte[] data) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index 72c4a1145ab5..02c54e7146a0 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -15,6 +15,12 @@ internal abstract class StampedeState #endif { private readonly DefaultHybridCache _cache; + private int _activeCallers = 1; + + // because multiple callers can enlist, we need to track when the *last* caller cancels + // (and keep going until then); that means we need to run with custom cancellation + private readonly CancellationTokenSource? _sharedCancellation; + internal readonly CancellationToken SharedToken; // this might have a value even when _sharedCancellation is null public StampedeKey Key { get; } @@ -29,8 +35,8 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBe { // if the first (or any) caller can't be cancelled; we'll never get to zero; no point tracking // (in reality, all callers usually use the same path, so cancellation is usually "all" or "none") - sharedCancellation = new(); - SharedToken = sharedCancellation.Token; + _sharedCancellation = new(); + SharedToken = _sharedCancellation.Token; } else { @@ -60,33 +66,26 @@ protected StampedeState(DefaultHybridCache cache, in StampedeKey key, Cancellati public override string ToString() => Key.ToString(); - // because multiple callers can enlist, we need to track when the *last* caller cancels - // (and keep going until then); that means we need to run with custom cancellation - private readonly CancellationTokenSource? sharedCancellation; - public abstract void SetCanceled(); - public readonly CancellationToken SharedToken; - - public int DebugCallerCount => Volatile.Read(ref activeCallers); + public int DebugCallerCount => Volatile.Read(ref _activeCallers); public abstract Type Type { get; } - private int activeCallers = 1; public void RemoveCaller() { // note that TryAddCaller has protections to avoid getting back from zero - if (Interlocked.Decrement(ref activeCallers) == 0) + if (Interlocked.Decrement(ref _activeCallers) == 0) { // we're the last to leave; turn off the lights - sharedCancellation?.Cancel(); + _sharedCancellation?.Cancel(); SetCanceled(); } } public bool TryAddCaller() // essentially just interlocked-increment, but with a leading zero check and overflow detection { - int oldValue = Volatile.Read(ref activeCallers); + int oldValue = Volatile.Read(ref _activeCallers); do { if (oldValue == 0) @@ -94,7 +93,7 @@ public bool TryAddCaller() // essentially just interlocked-increment, but with a return false; // already burned } - var updated = Interlocked.CompareExchange(ref activeCallers, checked(oldValue + 1), oldValue); + var updated = Interlocked.CompareExchange(ref _activeCallers, checked(oldValue + 1), oldValue); if (updated == oldValue) { return true; // we exchanged diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 73926e69c0b5..3daf4bc68694 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -14,9 +14,9 @@ internal sealed class StampedeState : StampedeState { private readonly TaskCompletionSource>? _result; private TState? _state; - private Func>? _underlying; - + private Func>? _underlying; // main data factory private HybridCacheEntryOptions? _options; + private Task? _sharedUnwrap; // allows multiple non-cancellable callers to share a single task (when no defensive copy needed) public StampedeState(DefaultHybridCache cache, in StampedeKey key, bool canBeCanceled) : base(cache, key, canBeCanceled) @@ -187,8 +187,6 @@ private CacheItem SetResult(T value) public override void SetCanceled() => _result?.TrySetCanceled(SharedToken); - private Task? _sharedUnwrap; - internal ValueTask UnwrapAsync() { var task = Task; diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 3c9e4ec11506..5e2808eaf839 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -11,6 +11,7 @@ using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -25,7 +26,7 @@ internal sealed partial class DefaultHybridCache : HybridCache private readonly IServiceProvider _services; // we can't resolve per-type serializers until we see each T private readonly IHybridCacheSerializerFactory[] _serializerFactories; private readonly HybridCacheOptions _options; - private readonly ILogger? _logger; + private readonly ILogger _logger; private readonly CacheFeatures _features; // used to avoid constant type-testing private readonly HybridCacheEntryFlags _hardFlags; // *always* present (for example, because no L2) @@ -56,7 +57,7 @@ public DefaultHybridCache(IOptions options, IServiceProvider _services = services ?? throw new ArgumentNullException(nameof(services)); _localCache = services.GetRequiredService(); _options = options.Value; - _logger = services.GetService()?.CreateLogger(typeof(HybridCache)); // note optional + _logger = services.GetService()?.CreateLogger(typeof(HybridCache)) ?? NullLogger.Instance; _backendCache = services.GetService(); // note optional From 366bd820d52936f555b72150e9de9272335f4569 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 13:49:07 +0100 Subject: [PATCH 68/75] Update src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs Co-authored-by: Safia Abdalla --- .../Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 3daf4bc68694..93228d75b4ab 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -125,7 +125,7 @@ public Task> Task return _result is null ? Invalid() : _result.Task; static Task> Invalid() => System.Threading.Tasks.Task.FromException>(new InvalidOperationException("Task should not be accessed for non-shared instances")); - } + static Task> Invalid() => Task.FromException>(new InvalidOperationException("Task should not be accessed for non-shared instances")); } private void SetException(Exception ex) From 256581b5ef529ef3b88738cace2e5a642f4c7ffb Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 17:17:38 +0100 Subject: [PATCH 69/75] add proper buffer recycling of buffers inside cache items --- .../Hybrid/src/Internal/BufferChunk.cs | 77 +++++++++++++++ .../Internal/DefaultHybridCache.CacheItem.cs | 37 ++++++- .../src/Internal/DefaultHybridCache.Debug.cs | 42 ++++++++ .../DefaultHybridCache.ImmutableCacheItem.cs | 18 ++-- .../src/Internal/DefaultHybridCache.L2.cs | 42 +++++--- .../DefaultHybridCache.MutableCacheItem.cs | 97 ++++++++++++++++--- .../DefaultHybridCache.StampedeState.cs | 6 +- .../DefaultHybridCache.StampedeStateT.cs | 56 ++++++++--- .../Hybrid/src/Internal/DefaultHybridCache.cs | 5 +- src/Caching/Hybrid/src/Internal/readme.md | 6 +- src/Caching/Hybrid/test/BufferReleaseTests.cs | 70 +++++++++++++ 11 files changed, 395 insertions(+), 61 deletions(-) create mode 100644 src/Caching/Hybrid/src/Internal/BufferChunk.cs create mode 100644 src/Caching/Hybrid/src/Internal/DefaultHybridCache.Debug.cs create mode 100644 src/Caching/Hybrid/test/BufferReleaseTests.cs diff --git a/src/Caching/Hybrid/src/Internal/BufferChunk.cs b/src/Caching/Hybrid/src/Internal/BufferChunk.cs new file mode 100644 index 000000000000..c783810fcf30 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/BufferChunk.cs @@ -0,0 +1,77 @@ +// 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.Buffers; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +// used to convey buffer status; like ArraySegment, but Offset is always +// zero, and we use the MSB of the length to track whether or not to recycle this value +internal readonly struct BufferChunk +{ + private const int MSB = (1 << 31); + + private readonly int _lengthAndPoolFlag; + public byte[]? Array { get; } // null for default + + public int Length => _lengthAndPoolFlag & ~MSB; + + public bool ReturnToPool => (_lengthAndPoolFlag & MSB) != 0; + + public byte[] ToArray() + { + var length = Length; + if (length == 0) + { + return []; + } + + var copy = new byte[length]; + Buffer.BlockCopy(Array!, 0, copy, 0, length); + return copy; + } + + public BufferChunk(byte[] array) + { + Debug.Assert(array is not null, "expected valid array input"); + Array = array; + _lengthAndPoolFlag = array.Length; + // assume not pooled, if exact-sized + Debug.Assert(!ReturnToPool, "do not return right-sized arrays"); + Debug.Assert(Length == array.Length, "array length not respected"); + } + + public BufferChunk(byte[] array, int length, bool returnToPool) + { + Debug.Assert(array is not null, "expected valid array input"); + Debug.Assert(length >= 0, "expected valid length"); + Array = array; + _lengthAndPoolFlag = length | (returnToPool ? MSB : 0); + Debug.Assert(ReturnToPool == returnToPool, "return-to-pool not respected"); + Debug.Assert(Length == length, "length not respected"); + } + + internal void RecycleIfAppropriate() + { + if (ReturnToPool) + { + ArrayPool.Shared.Return(Array!); + } + Unsafe.AsRef(in this) = default; // anti foot-shotgun double-return guard; not 100%, but worth doing + Debug.Assert(Array is null && !ReturnToPool, "expected clean slate after recycle"); + } + + internal ReadOnlySequence AsSequence() => Length == 0 ? default : new ReadOnlySequence(Array!, 0, Length); + + internal BufferChunk DoNotReturnToPool() + { + var copy = this; + Unsafe.AsRef(in copy._lengthAndPoolFlag) &= ~MSB; + Debug.Assert(copy.Length == Length, "same length expected"); + Debug.Assert(!copy.ReturnToPool, "do not return to pool"); + return copy; + } +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs index 1632e7091891..fd221779a46a 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.CacheItem.cs @@ -1,16 +1,45 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -using System.Diagnostics.CodeAnalysis; +using System; +using Microsoft.Extensions.Caching.Memory; namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - internal abstract class CacheItem + internal abstract class CacheItem { - public abstract T GetValue(); + internal static readonly PostEvictionDelegate OnEviction = (key, value, reason, state) => + { + if (value is CacheItem item) + { + // in reality we only set this up for mutable cache items, as a mechanism + // to recycle the buffers + item.Release(); + } + }; - public abstract bool TryGetBytes(out int length, [NotNullWhen(true)] out byte[]? data); + public virtual void Release() { } // for recycling purposes + + public virtual bool NeedsEvictionCallback => false; // do we need to call Release when evicted? + + public abstract bool TryReserveBuffer(out BufferChunk buffer); + } + + internal abstract class CacheItem : CacheItem + { + public abstract bool TryGetValue(out T value); + + public T GetValue() + { + if (!TryGetValue(out var value)) + { + Throw(); + } + return value; + + static void Throw() => throw new ObjectDisposedException("The cache item has been recycled before the value was obtained"); + } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Debug.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Debug.cs new file mode 100644 index 000000000000..888b4fc0cb41 --- /dev/null +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.Debug.cs @@ -0,0 +1,42 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Threading; + +namespace Microsoft.Extensions.Caching.Hybrid.Internal; + +partial class DefaultHybridCache +{ + internal bool DebugTryGetCacheItem(string key, [NotNullWhen(true)] out CacheItem? value) + { + if (_localCache.TryGetValue(key, out var untyped) && untyped is CacheItem typed) + { + value = typed; + return true; + } + value = null; + return false; + } + +#if DEBUG // enable ref-counted buffers + + private int _outstandingBufferCount; + + internal int DebugGetOutstandingBuffers(bool flush = false) + => flush ? Interlocked.Exchange(ref _outstandingBufferCount, 0) : Volatile.Read(ref _outstandingBufferCount); + + [Conditional("DEBUG")] + internal void DebugDecrementOutstandingBuffers() + { + Interlocked.Decrement(ref _outstandingBufferCount); + } + + [Conditional("DEBUG")] + internal void DebugIncrementOutstandingBuffers() + { + Interlocked.Increment(ref _outstandingBufferCount); + } +#endif +} diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs index 7ab5e2bdc235..223309dfd0e4 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.ImmutableCacheItem.cs @@ -7,20 +7,26 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - private sealed class ImmutableCacheItem(T value) : CacheItem // used to hold types that do not require defensive copies + private sealed class ImmutableCacheItem : CacheItem // used to hold types that do not require defensive copies { + private readonly T _value; + public ImmutableCacheItem(T value) => _value = value; + private static ImmutableCacheItem? SharedDefault; // this is only used when the underlying store is disabled; we don't need 100% singleton; "good enough is" public static ImmutableCacheItem Default => SharedDefault ??= new(default!); - public override T GetValue() => value; + public override bool TryGetValue(out T value) + { + value = _value; + return true; // always available + } - public override bool TryGetBytes(out int length, [NotNullWhen(true)] out byte[]? data) + public override bool TryReserveBuffer(out BufferChunk buffer) { - length = 0; - data = null; - return false; + buffer = default; + return false; // we don't have one to reserve! } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs index 9d7c434314fd..e0fcd6b1f30b 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.L2.cs @@ -14,7 +14,7 @@ namespace Microsoft.Extensions.Caching.Hybrid.Internal; partial class DefaultHybridCache { - internal ValueTask> GetFromL2Async(string key, CancellationToken token) + internal ValueTask GetFromL2Async(string key, CancellationToken token) { switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { @@ -37,31 +37,31 @@ internal ValueTask> GetFromL2Async(string key, CancellationTo { return new(AwaitedBuffers(pendingBuffers, writer)); } - ArraySegment result = pendingBuffers.GetAwaiter().GetResult() - ? new(writer.DetachCommitted(out var length), 0, length) + BufferChunk result = pendingBuffers.GetAwaiter().GetResult() + ? new(writer.DetachCommitted(out var length), length, returnToPool: true) : default; writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened return new(result); } return default; - static async Task> AwaitedLegacy(Task pending, DefaultHybridCache @this) + static async Task AwaitedLegacy(Task pending, DefaultHybridCache @this) { var bytes = await pending.ConfigureAwait(false); return @this.GetValidPayloadSegment(bytes); } - static async Task> AwaitedBuffers(ValueTask pending, RecyclableArrayBufferWriter writer) + static async Task AwaitedBuffers(ValueTask pending, RecyclableArrayBufferWriter writer) { - ArraySegment result = await pending.ConfigureAwait(false) - ? new(writer.DetachCommitted(out var length), 0, length) + BufferChunk result = await pending.ConfigureAwait(false) + ? new(writer.DetachCommitted(out var length), length, returnToPool: true) : default; writer.Dispose(); // it is not accidental that this isn't "using"; avoid recycling if not 100% sure what happened return result; } } - private ArraySegment GetValidPayloadSegment(byte[]? payload) + private BufferChunk GetValidPayloadSegment(byte[]? payload) { if (payload is not null) { @@ -81,21 +81,22 @@ private void ThrowPayloadLengthExceeded(int size) // splitting the exception bit throw new InvalidOperationException($"Maximum cache length ({MaximumPayloadBytes} bytes) exceeded"); } - internal ValueTask SetL2Async(string key, byte[] value, int length, HybridCacheEntryOptions? options, CancellationToken token) + internal ValueTask SetL2Async(string key, in BufferChunk buffer, HybridCacheEntryOptions? options, CancellationToken token) { - Debug.Assert(value.Length >= length); + Debug.Assert(buffer.Array is not null); switch (GetFeatures(CacheFeatures.BackendCache | CacheFeatures.BackendBuffers)) { case CacheFeatures.BackendCache: // legacy byte[]-based - if (value.Length > length) + var arr = buffer.Array; + if (arr.Length != buffer.Length) { - Array.Resize(ref value, length); + // we'll need a right-sized snapshot + arr = buffer.ToArray(); } - Debug.Assert(value.Length == length); - return new(_backendCache!.SetAsync(key, value, GetOptions(options), token)); + return new(_backendCache!.SetAsync(key, arr, GetOptions(options), token)); case CacheFeatures.BackendCache | CacheFeatures.BackendBuffers: // ReadOnlySequence-based var cache = Unsafe.As(_backendCache!); // type-checked already - return cache.SetAsync(key, new(value, 0, length), GetOptions(options), token); + return cache.SetAsync(key, buffer.AsSequence(), GetOptions(options), token); } return default; } @@ -111,5 +112,14 @@ private DistributedCacheEntryOptions GetOptions(HybridCacheEntryOptions? options } internal void SetL1(string key, CacheItem value, HybridCacheEntryOptions? options) - => _localCache.Set(key, value, options?.LocalCacheExpiration ?? _defaultLocalCacheExpiration); + { + // based on CacheExtensions.Set, but with post-eviction recycling + using ICacheEntry cacheEntry = _localCache.CreateEntry(key); + cacheEntry.AbsoluteExpirationRelativeToNow = options?.LocalCacheExpiration ?? _defaultLocalCacheExpiration; + cacheEntry.Value = value; + if (value.NeedsEvictionCallback) + { + cacheEntry.RegisterPostEvictionCallback(CacheItem.OnEviction); + } + } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs index 92643c3a4b8f..3a28cd8a5b82 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.MutableCacheItem.cs @@ -1,7 +1,9 @@ // 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; +using System.Diagnostics; +using System.Threading; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -10,14 +12,25 @@ partial class DefaultHybridCache private sealed class MutableCacheItem : CacheItem // used to hold types that require defensive copies { private readonly IHybridCacheSerializer _serializer; - private readonly byte[] _bytes; - private readonly int _length; + private readonly BufferChunk _buffer; + private int _refCount = 1; // buffer released when this becomes zero +#if DEBUG + private DefaultHybridCache? _cache; // for buffer-tracking - only enabled in DEBUG + internal void DebugTrackBuffer(DefaultHybridCache cache) + { + _cache = cache; + if (_buffer.ReturnToPool) + { + _cache.DebugIncrementOutstandingBuffers(); + } + } +#endif - public MutableCacheItem(byte[] bytes, int length, IHybridCacheSerializer serializer) + public MutableCacheItem(ref BufferChunk buffer, IHybridCacheSerializer serializer) { _serializer = serializer; - _bytes = bytes; - _length = length; + _buffer = buffer; + buffer = default; // we're taking over the lifetime; the caller no longer has it! } public MutableCacheItem(T value, IHybridCacheSerializer serializer, int maxLength) @@ -25,17 +38,75 @@ public MutableCacheItem(T value, IHybridCacheSerializer serializer, int maxLe _serializer = serializer; var writer = RecyclableArrayBufferWriter.Create(maxLength); serializer.Serialize(value, writer); - _bytes = writer.DetachCommitted(out _length); - writer.Dispose(); // only recycle on success + + _buffer = new(writer.DetachCommitted(out int length), length, returnToPool: true); + writer.Dispose(); // no buffers left (we just detached them), but just in case of other logic + } + + public override bool NeedsEvictionCallback => true; + + public override void Release() + { + var newCount = Interlocked.Decrement(ref _refCount); + if (newCount == 0) + { +#if DEBUG // avoid even the property touch if not in debug + if (_buffer.ReturnToPool) + { + _cache?.DebugDecrementOutstandingBuffers(); + } +#endif + _buffer.RecycleIfAppropriate(); + } } - public override T GetValue() => _serializer.Deserialize(new ReadOnlySequence(_bytes, 0, _length)); + public bool TryReserve() + { + int oldValue = Volatile.Read(ref _refCount); + do + { + if (oldValue is 0 or -1) + { + return false; // already burned, or about to roll around back to zero + } + + var updated = Interlocked.CompareExchange(ref _refCount, oldValue + 1, oldValue); + if (updated == oldValue) + { + return true; // we exchanged + } + oldValue = updated; // we failed, but we have an updated state + } while (true); + } + + public override bool TryGetValue(out T value) + { + if (!TryReserve()) // only if we haven't already burned + { + value = default!; + return false; + } + + try + { + value = _serializer.Deserialize(_buffer.AsSequence()); + return true; + } + finally + { + Release(); + } + } - public override bool TryGetBytes(out int length, out byte[] data) + public override bool TryReserveBuffer(out BufferChunk buffer) { - length = _length; - data = _bytes; - return true; + if (TryReserve()) // only if we haven't already burned + { + buffer = _buffer.DoNotReturnToPool(); // not up to them! + return true; + } + buffer = default; + return false; } } } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs index 02c54e7146a0..f052e1d2d0c4 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeState.cs @@ -88,12 +88,12 @@ public bool TryAddCaller() // essentially just interlocked-increment, but with a int oldValue = Volatile.Read(ref _activeCallers); do { - if (oldValue == 0) + if (oldValue is 0 or -1) { - return false; // already burned + return false; // already burned or about to roll around back to zero } - var updated = Interlocked.CompareExchange(ref _activeCallers, checked(oldValue + 1), oldValue); + var updated = Interlocked.CompareExchange(ref _activeCallers, oldValue + 1, oldValue); if (updated == oldValue) { return true; // we exchanged diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs index 3daf4bc68694..c5f48075f511 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeStateT.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Caching.Memory; namespace Microsoft.Extensions.Caching.Hybrid.Internal; @@ -72,7 +73,7 @@ private async Task BackgroundFetchAsync() if (result.Array is not null) { - SetResult(result); + SetResultAndRecycleIfAppropriate(ref result); return; } } @@ -88,18 +89,19 @@ private async Task BackgroundFetchAsync() // write to L2 if appropriate if ((Key.Flags & HybridCacheEntryFlags.DisableDistributedCacheWrite) == 0) { - if (cacheItem.TryGetBytes(out int length, out var bytes)) + if (cacheItem.TryReserveBuffer(out var buffer)) { // mutable; we've already serialized it for the shared cache item - await Cache.SetL2Async(Key.Key, bytes, length, _options, SharedToken).ConfigureAwait(false); + await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false); + cacheItem.Release(); // because we reserved } - else + else if (cacheItem.TryGetValue(out var value)) { // immutable: we'll need to do the serialize ourselves var writer = RecyclableArrayBufferWriter.Create(MaximumPayloadBytes); // note this lifetime spans the SetL2Async - Cache.GetSerializer().Serialize(cacheItem.GetValue(), writer); // note GetValue() is fixed value here - bytes = writer.GetBuffer(out length); - await Cache.SetL2Async(Key.Key, bytes, length, _options, SharedToken).ConfigureAwait(false); + Cache.GetSerializer().Serialize(value, writer); + buffer = new(writer.GetBuffer(out var length), length, returnToPool: false); // writer still owns the buffer + await Cache.SetL2Async(Key.Key, in buffer, _options, SharedToken).ConfigureAwait(false); writer.Dispose(); // recycle on success } } @@ -161,15 +163,28 @@ private void SetDefaultResult() } } - private void SetResult(ArraySegment value) + private void SetResultAndRecycleIfAppropriate(ref BufferChunk value) { // set a result from L2 cache - Debug.Assert(value.Array is not null && value.Offset == 0); + Debug.Assert(value.Array is not null, "expected buffer"); var serializer = Cache.GetSerializer(); - CacheItem cacheItem = ImmutableTypeCache.IsImmutable - ? new ImmutableCacheItem(serializer.Deserialize(new(value.Array!, value.Offset, value.Count))) // deserialize - : new MutableCacheItem(value.Array!, value.Count, Cache.GetSerializer()); // store the same bytes + CacheItem cacheItem; + if (ImmutableTypeCache.IsImmutable) + { + // deserialize; and store object; buffer can be recycled now + cacheItem = new ImmutableCacheItem(serializer.Deserialize(new(value.Array!, 0, value.Length))); + value.RecycleIfAppropriate(); + } + else + { + // use the buffer directly as the backing in the cache-item; do *not* recycle now + var tmp = new MutableCacheItem(ref value, serializer); +#if DEBUG + tmp.DebugTrackBuffer(Cache); +#endif + cacheItem = tmp; + } SetResult(cacheItem); } @@ -177,10 +192,19 @@ private void SetResult(ArraySegment value) private CacheItem SetResult(T value) { // set a result from a value we calculated directly - CacheItem cacheItem = ImmutableTypeCache.IsImmutable - ? new ImmutableCacheItem(value) // no serialize needed - : new MutableCacheItem(value, Cache.GetSerializer(), MaximumPayloadBytes); // serialization happens here - + CacheItem cacheItem; + if (ImmutableTypeCache.IsImmutable) + { + cacheItem = new ImmutableCacheItem(value); // no serialize needed + } + else + { + var tmp = new MutableCacheItem(value, Cache.GetSerializer(), MaximumPayloadBytes); // serialization happens here +#if DEBUG + tmp.DebugTrackBuffer(Cache); +#endif + cacheItem = tmp; + } SetResult(cacheItem); return cacheItem; } diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs index 5e2808eaf839..fd3515ba4ce9 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.cs @@ -117,10 +117,11 @@ public override ValueTask GetOrCreateAsync(string key, TState stat } var flags = GetEffectiveFlags(options); - if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && _localCache.TryGetValue(key, out var untyped) && untyped is CacheItem typed) + if ((flags & HybridCacheEntryFlags.DisableLocalCacheRead) == 0 && _localCache.TryGetValue(key, out var untyped) + && untyped is CacheItem typed && typed.TryGetValue(out var value)) { // short-circuit - return new(typed.GetValue()); + return new(value); } if (GetOrCreateStampede(key, flags, out var stampede, canBeCanceled)) diff --git a/src/Caching/Hybrid/src/Internal/readme.md b/src/Caching/Hybrid/src/Internal/readme.md index 2339d3631253..8d6a7d878481 100644 --- a/src/Caching/Hybrid/src/Internal/readme.md +++ b/src/Caching/Hybrid/src/Internal/readme.md @@ -20,4 +20,8 @@ is still active); this covers all L2 access and serialization operations, releas shared callers for the same operation. Note that L2 storage can occur *after* callers have been released. - +To ensure correct buffer recycling, when dealing with cache entries that need defensive copies +we use more ref-counting while reading the buffer, combined with an eviction callback which +decrements that counter. This means that we recycle things when evicted, without impacting +in-progress deserialize operations. To simplify tracking, `BufferChunk` acts like a `byte[]`+`int` +(we don't need non-zero offset), but also tracking "should this be returned to the pool?". diff --git a/src/Caching/Hybrid/test/BufferReleaseTests.cs b/src/Caching/Hybrid/test/BufferReleaseTests.cs new file mode 100644 index 000000000000..a8ea8b76baaa --- /dev/null +++ b/src/Caching/Hybrid/test/BufferReleaseTests.cs @@ -0,0 +1,70 @@ +// 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; +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 +{ + static IDisposable GetDefaultCache(out DefaultHybridCache cache) + { + var services = new ServiceCollection(); + services.AddHybridCache(); + var provider = services.BuildServiceProvider(); + cache = Assert.IsType(provider.GetRequiredService()); + return provider; + } + + [Fact] + public async Task BufferGetsReleased() + { + using var provider = GetDefaultCache(out var cache); +#if DEBUG + cache.DebugGetOutstandingBuffers(flush: true); +#endif + + var key = Me(); +#if DEBUG + Assert.Equal(0, cache.DebugGetOutstandingBuffers()); +#endif + var first = await cache.GetOrCreateAsync(key, _ => GetAsync()); + Assert.NotNull(first); +#if DEBUG + Assert.Equal(1, cache.DebugGetOutstandingBuffers()); +#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.TryReserveBuffer(out _)); + cacheItem.Release(); // for the above reserve + + var second = await cache.GetOrCreateAsync(key, _ => GetAsync(), new HybridCacheEntryOptions { Flags = HybridCacheEntryFlags.DisableUnderlyingData }); + Assert.NotNull(second); + Assert.NotSame(first, second); + + await cache.RemoveKeyAsync(key); + var third = await cache.GetOrCreateAsync(key, _ => GetAsync(), new HybridCacheEntryOptions { Flags = HybridCacheEntryFlags.DisableUnderlyingData }); + Assert.Null(third); + + await Task.Delay(500); // give it a moment +#if DEBUG + Assert.Equal(0, cache.DebugGetOutstandingBuffers()); +#endif + // assert that we can *no longer* reserve this buffer, because we've already recycled it + Assert.False(cacheItem.TryReserveBuffer(out _)); + + 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; +} From 3de225e4c79c4cb17c8bc17db4fa64b6afcb11a4 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Thu, 18 Apr 2024 17:28:56 +0100 Subject: [PATCH 70/75] prefer HashCode when possible --- .../Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs | 4 ++++ .../Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs index 3d02810806e9..bf5001360eb3 100644 --- a/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs +++ b/src/Caching/Hybrid/src/Internal/DefaultHybridCache.StampedeKey.cs @@ -17,7 +17,11 @@ public StampedeKey(string key, HybridCacheEntryFlags flags) { _key = key; _flags = flags; +#if NETCOREAPP2_1_OR_GREATER || NETSTANDARD2_1_OR_GREATER + _hashCode = HashCode.Combine(key, flags); +#else _hashCode = key.GetHashCode() ^ (int)flags; +#endif } public string Key => _key; diff --git a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj index d30f322d8bd6..041d425c1d69 100644 --- a/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj +++ b/src/Caching/Hybrid/src/Microsoft.Extensions.Caching.Hybrid.csproj @@ -4,7 +4,7 @@ Multi-level caching implementation building on and extending IDistributedCache