-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Polly.Caching: HybridCache-based caching strategy (net9.0) #2709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
694e9fb
5aa00b3
03e7fae
07fe487
7a16bc7
cba7705
e5bd049
48fd1f5
3b60d25
eaabc48
1b1b4bd
293275b
99ac6f3
c71ae5a
72e8994
586159b
a1256f6
7751a66
50f2508
63d459f
a2eaa7e
b536f8a
c390ea0
b5af9f8
e474c5e
7264b62
db46553
abbb6e2
2b5863f
3ac4a2d
45f40a3
0d6bff6
2f8bbd2
cb4d6de
4a4cc66
189fa13
9540ed9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,25 @@ | ||
namespace Polly.Caching; | ||
|
||
/// <summary> | ||
/// Internal wrapper used to avoid JsonElement serialization issues when caching | ||
/// values via untyped (object) resilience pipelines. By wrapping the value into | ||
/// a reference type with a stable shape, the caching layer does not attempt to | ||
/// serialize primitive types into JsonElement, preserving original types for callers. | ||
/// </summary> | ||
internal sealed class CacheObject | ||
{ | ||
public CacheObject() | ||
{ | ||
} | ||
|
||
public CacheObject(object value) | ||
{ | ||
Value = value; | ||
TypeName = value?.GetType().AssemblyQualifiedName; | ||
} | ||
|
||
public string? TypeName { get; init; } | ||
|
||
public object? Value { get; init; } | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,54 @@ | ||
using System.Diagnostics.CodeAnalysis; | ||
using Polly.Caching; | ||
|
||
namespace Polly; | ||
|
||
/// <summary> | ||
/// Extensions for integrating HybridCache with <see cref="ResiliencePipelineBuilder"/>. | ||
/// </summary> | ||
public static class HybridCacheResiliencePipelineBuilderExtensions | ||
{ | ||
/// <summary> | ||
/// Adds a HybridCache-based caching strategy to an untyped resilience pipeline. | ||
/// </summary> | ||
/// <param name="builder">The pipeline builder.</param> | ||
/// <param name="options">The HybridCache strategy options.</param> | ||
/// <returns>The same builder instance.</returns> | ||
[UnconditionalSuppressMessage( | ||
"Trimming", | ||
"IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", | ||
Justification = "Options are validated and all members preserved.")] | ||
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(HybridCacheStrategyOptions))] | ||
public static ResiliencePipelineBuilder AddHybridCache(this ResiliencePipelineBuilder builder, HybridCacheStrategyOptions options) | ||
{ | ||
Guard.NotNull(builder); | ||
Guard.NotNull(options); | ||
|
||
return builder.AddStrategy( | ||
_ => new HybridCacheResilienceStrategy<object>(options), | ||
options); | ||
} | ||
|
||
/// <summary> | ||
/// Adds a HybridCache-based caching strategy to a typed resilience pipeline producing TResult. | ||
/// </summary> | ||
/// <typeparam name="TResult">The result type of the pipeline.</typeparam> | ||
/// <param name="builder">The typed pipeline builder.</param> | ||
/// <param name="options">The HybridCache strategy options.</param> | ||
/// <returns>The same typed builder instance.</returns> | ||
[UnconditionalSuppressMessage( | ||
"Trimming", | ||
"IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise break functionality when trimming application code", | ||
Justification = "Options are validated and all members preserved.")] | ||
public static ResiliencePipelineBuilder<TResult> AddHybridCache<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] TResult>( | ||
this ResiliencePipelineBuilder<TResult> builder, | ||
HybridCacheStrategyOptions<TResult> options) | ||
{ | ||
Guard.NotNull(builder); | ||
Guard.NotNull(options); | ||
|
||
return builder.AddStrategy( | ||
_ => new HybridCacheResilienceStrategy<TResult>(options), | ||
options); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
using System; | ||
using System.Text.Json; | ||
using System.Threading.Tasks; | ||
using Microsoft.Extensions.Caching.Hybrid; | ||
|
||
namespace Polly.Caching; | ||
|
||
internal sealed class HybridCacheResilienceStrategy<TResult> : ResilienceStrategy<TResult> | ||
{ | ||
private readonly HybridCache _cache; | ||
private readonly Func<ResilienceContext, string?> _keyGenerator; | ||
|
||
public HybridCacheResilienceStrategy(HybridCacheStrategyOptions<TResult> options) | ||
{ | ||
Guard.NotNull(options); | ||
_cache = options.Cache!; | ||
_keyGenerator = options.CacheKeyGenerator ?? (static ctx => ctx.OperationKey); | ||
_preserveComplexUntypedValues = options.PreserveComplexUntypedValues; | ||
} | ||
|
||
protected override async ValueTask<Outcome<TResult>> ExecuteCore<TState>( | ||
Func<ResilienceContext, TState, ValueTask<Outcome<TResult>>> callback, | ||
ResilienceContext context, | ||
TState state) | ||
{ | ||
var key = _keyGenerator(context) ?? string.Empty; | ||
|
||
// For non-generic (object) pipelines, use a wrapper to avoid JsonElement serialization issues | ||
if (typeof(TResult) == typeof(object)) | ||
{ | ||
var result = await _cache.GetOrCreateAsync<CacheObject>( | ||
key, | ||
async (_) => | ||
{ | ||
var outcome = await callback(context, state).ConfigureAwait(context.ContinueOnCapturedContext); | ||
outcome.ThrowIfException(); | ||
return new CacheObject(outcome.Result!); | ||
}, | ||
cancellationToken: context.CancellationToken).ConfigureAwait(context.ContinueOnCapturedContext); | ||
|
||
var normalized = _preserveComplexUntypedValues ? NormalizeComplex(result) : NormalizeValue(result.Value); | ||
return Outcome.FromResult((TResult)normalized!); | ||
} | ||
|
||
// For typed pipelines, use direct caching (no wrapper needed) | ||
var typedResult = await _cache.GetOrCreateAsync( | ||
key, | ||
async (_) => | ||
{ | ||
var outcome = await callback(context, state).ConfigureAwait(context.ContinueOnCapturedContext); | ||
outcome.ThrowIfException(); | ||
return outcome.Result!; | ||
}, | ||
cancellationToken: context.CancellationToken).ConfigureAwait(context.ContinueOnCapturedContext); | ||
|
||
return Outcome.FromResult(typedResult); | ||
} | ||
|
||
// wrapper moved to top-level public type | ||
private readonly bool _preserveComplexUntypedValues; | ||
|
||
private static object? NormalizeValue(object? value) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately despite the wrapping type, While this might work for primitive values, it doesn't work when complex types are used: [Fact]
public async Task NonGeneric_AddHybridCache_For_Custom_Object()
{
var services = new ServiceCollection().AddHybridCache();
using var provider = services.Services.BuildServiceProvider();
var cache = provider.GetRequiredService<HybridCache>();
var options = new HybridCacheStrategyOptions
{
Cache = cache,
CacheKeyGenerator = _ => "json-key"
};
var bandit = new Dog { Name = "Bandit" };
var chilli = new Dog { Name = "Chilli" };
var bluey = new Dog { Name = "Bluey", Father = bandit, Mother = chilli };
var bingo = new Dog { Name = "Bingo", Father = bandit, Mother = chilli };
var family = new List<Dog>
{
bandit,
bingo,
bluey,
chilli,
};
var pipeline = new ResiliencePipelineBuilder()
.AddHybridCache(options)
.Build();
var r1 = await pipeline.ExecuteAsync(_ => ValueTask.FromResult(family));
var r2 = await pipeline.ExecuteAsync(_ => ValueTask.FromResult(family));
r1.ShouldBe(family);
r2.ShouldBe(family);
}
private sealed class Dog
{
public required string Name { get; init; }
public Dog? Father { get; set; }
public Dog? Mother { get; set; }
}
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @martincostello We’ve kept existing non-generic semantics for complex values (object/array → string) to avoid breaking established tests and behaviors. Typed pipelines are fully type-preserving and unaffected. |
||
{ | ||
if (value is JsonElement json) | ||
{ | ||
switch (json.ValueKind) | ||
{ | ||
case JsonValueKind.True: | ||
case JsonValueKind.False: | ||
return json.GetBoolean(); | ||
case JsonValueKind.String: | ||
return json.GetString(); | ||
case JsonValueKind.Number: | ||
{ | ||
if (json.TryGetInt32(out var i)) | ||
{ | ||
return i; | ||
} | ||
|
||
if (json.TryGetInt64(out var l)) | ||
{ | ||
return l; | ||
} | ||
|
||
// Fallback: represent as double | ||
return json.GetDouble(); | ||
} | ||
|
||
default: | ||
{ | ||
return json.ToString(); | ||
} | ||
} | ||
} | ||
|
||
return value; | ||
} | ||
|
||
private static object? NormalizeComplex(CacheObject wrapper) | ||
{ | ||
if (wrapper.Value is null) | ||
{ | ||
return null; | ||
} | ||
|
||
if (wrapper.Value is JsonElement json && !string.IsNullOrEmpty(wrapper.TypeName)) | ||
{ | ||
var type = Type.GetType(wrapper.TypeName!, throwOnError: false); | ||
if (type != null && type != typeof(JsonElement)) | ||
{ | ||
var restored = json.Deserialize(type); | ||
if (restored != null) | ||
{ | ||
return restored; | ||
} | ||
} | ||
} | ||
|
||
return NormalizeValue(wrapper.Value); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,45 @@ | ||
using System.ComponentModel.DataAnnotations; | ||
using System.Diagnostics.CodeAnalysis; | ||
using Microsoft.Extensions.Caching.Hybrid; | ||
|
||
namespace Polly.Caching; | ||
|
||
/// <summary> | ||
/// Options for the HybridCache-based caching strategy. | ||
/// </summary> | ||
/// <typeparam name="TResult">The result type.</typeparam> | ||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Members preserved via builder validation.")] | ||
public class HybridCacheStrategyOptions<TResult> : ResilienceStrategyOptions | ||
{ | ||
/// <summary> | ||
/// Gets or sets the <see cref="HybridCache"/> instance to use. | ||
/// </summary> | ||
[Required] | ||
public HybridCache? Cache { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets the time-to-live for cached entries. | ||
/// The default is 5 minutes. | ||
/// </summary> | ||
[Range(typeof(TimeSpan), "00:00:00", "365.00:00:00")] | ||
public TimeSpan Ttl { get; set; } = TimeSpan.FromMinutes(5); | ||
|
||
/// <summary> | ||
/// Gets or sets a value indicating whether sliding expiration should be used. | ||
/// The default is <see langword="false"/>. | ||
/// </summary> | ||
public bool UseSlidingExpiration { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets a delegate that generates the cache key from the resilience context. | ||
/// If <see langword="null"/>, <see cref="ResilienceContext.OperationKey"/> is used. | ||
/// </summary> | ||
public Func<ResilienceContext, string?>? CacheKeyGenerator { get; set; } | ||
|
||
/// <summary> | ||
/// Gets or sets a value indicating whether complex objects should be preserved for untyped (object) pipelines. | ||
/// When <see langword="true"/>, complex values serialized as <see cref="System.Text.Json.JsonElement"/> are | ||
/// deserialized back to their original runtime type. Defaults to <see langword="false"/> to preserve existing semantics. | ||
/// </summary> | ||
public bool PreserveComplexUntypedValues { get; set; } | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
namespace Polly.Caching; | ||
|
||
/// <inheritdoc/> | ||
public class HybridCacheStrategyOptions : HybridCacheStrategyOptions<object>; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
<Project Sdk="Microsoft.NET.Sdk"> | ||
<PropertyGroup> | ||
<TargetFramework>net9.0</TargetFramework> | ||
<AssemblyTitle>Polly.Caching</AssemblyTitle> | ||
<RootNamespace>Polly.Caching</RootNamespace> | ||
<Nullable>enable</Nullable> | ||
<GenerateDocumentationFile>true</GenerateDocumentationFile> | ||
<ProjectType>Library</ProjectType> | ||
<UsePublicApiAnalyzers>true</UsePublicApiAnalyzers> | ||
<!-- TODO: Enable after first NuGet release with a published baseline --> | ||
<EnablePackageValidation>false</EnablePackageValidation> | ||
<LegacySupport>true</LegacySupport> | ||
<MutationScore>100</MutationScore> | ||
mohammed-saalim marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</PropertyGroup> | ||
<PropertyGroup> | ||
<Description>Polly.Caching provides caching strategies for Polly.Core.</Description> | ||
<PackageTags>Polly Caching HybridCache Resilience Policy</PackageTags> | ||
</PropertyGroup> | ||
<ItemGroup> | ||
<Using Include="Polly.Utils" /> | ||
<Compile Include="..\Polly.Core\Utils\ExceptionUtilities.cs" Link="utils\ExceptionUtilities.cs" /> | ||
<Compile Include="..\Polly.Core\Utils\Guard.cs" Link="Utils\Guard.cs" /> | ||
<InternalsVisibleToProject Include="Polly.Caching.Tests" /> | ||
martincostello marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</ItemGroup> | ||
<ItemGroup> | ||
<PackageReference Include="Microsoft.Extensions.Caching.Hybrid" VersionOverride="9.3.0" /> | ||
mohammed-saalim marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</ItemGroup> | ||
<ItemGroup> | ||
<ProjectReference Include="..\Polly.Core\Polly.Core.csproj" /> | ||
</ItemGroup> | ||
</Project> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
#nullable enable |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
#nullable enable | ||
Polly.HybridCacheResiliencePipelineBuilderExtensions | ||
Polly.Caching.HybridCacheStrategyOptions | ||
Polly.Caching.HybridCacheStrategyOptions<TResult> | ||
Polly.Caching.HybridCacheStrategyOptions.HybridCacheStrategyOptions() -> void | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.HybridCacheStrategyOptions() -> void | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.Cache.get -> Microsoft.Extensions.Caching.Hybrid.HybridCache? | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.Cache.set -> void | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.Ttl.get -> System.TimeSpan | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.Ttl.set -> void | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.UseSlidingExpiration.get -> bool | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.UseSlidingExpiration.set -> void | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.CacheKeyGenerator.get -> System.Func<Polly.ResilienceContext!, string?>? | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.CacheKeyGenerator.set -> void | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.PreserveComplexUntypedValues.get -> bool | ||
Polly.Caching.HybridCacheStrategyOptions<TResult>.PreserveComplexUntypedValues.set -> void | ||
static Polly.HybridCacheResiliencePipelineBuilderExtensions.AddHybridCache(this Polly.ResiliencePipelineBuilder! builder, Polly.Caching.HybridCacheStrategyOptions! options) -> Polly.ResiliencePipelineBuilder! | ||
static Polly.HybridCacheResiliencePipelineBuilderExtensions.AddHybridCache<TResult>(this Polly.ResiliencePipelineBuilder<TResult>! builder, Polly.Caching.HybridCacheStrategyOptions<TResult>! options) -> Polly.ResiliencePipelineBuilder<TResult>! |
Uh oh!
There was an error while loading. Please reload this page.