Skip to content

Conversation

mohammed-saalim
Copy link
Contributor

@mohammed-saalim mohammed-saalim commented Aug 19, 2025

Summary

Implements a HybridCache-based caching resilience strategy for Polly v8 in a new Polly.Caching package. This follows the requested direction to use HybridCache (async-first) instead of IMemoryCache.

Scope

  • Abstraction: HybridCache-only (no IMemoryCache in this PR).
  • TFM: net9.0 only.
  • Package/deps: Microsoft.Extensions.Caching.Hybrid 9.3.0 via VersionOverride; central package versions remain unchanged.

Implementation

  • New package: src/Polly.Caching
    • Polly.Caching.HybridCacheStrategyOptions<TResult> (minimal options: HybridCache instance, key generator; TTL kept for future use but not applied yet to keep scope small).
    • Polly.Caching.HybridCacheResilienceStrategy<TResult>.
    • Polly.HybridCacheResiliencePipelineBuilderExtensions with:
      • AddHybridCache(this ResiliencePipelineBuilder, HybridCacheStrategyOptions)
      • AddHybridCache<TResult>(this ResiliencePipelineBuilder<TResult>, HybridCacheStrategyOptions<TResult>)
  • Public API analyzers enabled; PublicAPI.Unshipped.txt updated. EnablePackageValidation=false with a TODO to enable after the first NuGet release baseline.

Tests

  • New tests under test/Polly.Caching.Tests:
    • Miss → cache → hit
    • Empty-key bypass
  • All tests and build green locally.

Out-of-scope / Follow-ups

  • Applying TTL/sliding expiration with HybridCache options (kept minimal here; happy to add if preferred in this PR).
  • IDistributedCache scenarios can be achieved by configuring HybridCache appropriately. Additional adapters can be explored in a follow-up if needed.

Checklist

  • Builds successfully
  • Tests added and pass locally
  • Central package versions unchanged
  • Public API updated; analyzers enabled

@mohammed-saalim
Copy link
Contributor Author

@dotnet-policy-service agree

@mohammed-saalim
Copy link
Contributor Author

@martincostello All feedback addressed; please re-review

Copy link
Member

@martincostello martincostello left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some initial comments.

This is also missing changes to add the project to the solution.

Mutation tests and documentation would need adding later if this PR progresses to the point of being a candidate to be merged, but those can wait until the API/design is determined.

@martincostello martincostello marked this pull request as draft August 21, 2025 16:05
@martincostello
Copy link
Member

I've moved this back to draft until the API shape is agreed.

@mohammed-saalim
Copy link
Contributor Author

Thanks for the review. Given the open API direction (IMemoryCache vs IDistributedCache/HybridCache), I’ll park this PR as draft for now and move on to other issues. If a concrete direction is decided, I’m happy to pick it back up.

@martincostello
Copy link
Member

I'm saying we should pursue HybridCache as it gives us more flexibility than IMemoryCache.

@mohammed-saalim
Copy link
Contributor Author

Thanks for clarifying the direction toward HybridCache. Before I implement, could you confirm the scope so I don’t go off-track?
Abstraction: implement HybridCache-only (drop IMemoryCache from this PR). OK?
TFM/deps: target net8.0 (or net9.0 if Microsoft.Extensions.Caching.Hybrid requires it)? I’ll reference Microsoft.Extensions.Caching.Hybrid.
Package/shape: add to Polly.Caching with AddHybridCache(...) extensions. Options = HybridCache instance (required), TTL (absolute/sliding), key generator (defaults to ResilienceContext.OperationKey). Anything else you want surfaced?
Scope: keep this PR minimal (miss→cache→hit, empty-key bypass, exception/rejection path, XML docs), and do IDistributedCache in a separate follow-up. Or do you want both in one PR? @martincostello

@martincostello
Copy link
Member

Abstraction: implement HybridCache-only (drop IMemoryCache from this PR)

Yep.

TFM/deps: target net8.0 (or net9.0 if Microsoft.Extensions.Caching.Hybrid requires it)?

Let's start with just net9.0, as otherwise we might get into issues with the net8.0 TFM requiring 9.0.x dependencies. Use the 9.3.0 version as that's the lowest stable version of the package.

Package/shape: add to Polly.Caching

Yep. Start with the minimum amount of options to get things working, and we can add things if needed.

do IDistributedCache in a separate follow-up

IDistributedCache should be usable by just configuring HybridCache to only use that?

@mohammed-saalim mohammed-saalim force-pushed the feature/caching-strategy-v8 branch from 76d01e4 to b7c110f Compare August 28, 2025 01:07
@mohammed-saalim
Copy link
Contributor Author

I’ve pivoted this PR to HybridCache-only per feedback:

  • New package: Polly.Caching (net9.0)
  • Uses Microsoft.Extensions.Caching.Hybrid 9.3.0 (central versions unchanged; VersionOverride only)
  • Minimal options + AddHybridCache(...) builder extensions
  • Tests: miss→hit and empty-key bypass
  • IMemoryCache code removed from this PR
  • Public API analyzers enabled; package validation left disabled with a TODO

Build and tests are green locally. Ready for review @martincostello

@mohammed-saalim mohammed-saalim changed the title Add caching strategy for Polly.Core v8 using IMemoryCache Polly.Caching: HybridCache-based caching strategy (net9.0) Aug 28, 2025
@mohammed-saalim mohammed-saalim force-pushed the feature/caching-strategy-v8 branch from cb41f65 to 7a16bc7 Compare August 29, 2025 17:48
Copy link

codecov bot commented Aug 29, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.18%. Comparing base (8ba1e3b) to head (4a4cc66).
⚠️ Report is 10 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2709      +/-   ##
==========================================
+ Coverage   96.12%   96.18%   +0.05%     
==========================================
  Files         309      313       +4     
  Lines        7118     7174      +56     
  Branches     1008     1015       +7     
==========================================
+ Hits         6842     6900      +58     
+ Misses        222      221       -1     
+ Partials       54       53       -1     
Flag Coverage Δ
linux 96.18% <100.00%> (+0.05%) ⬆️
macos 96.18% <100.00%> (+0.05%) ⬆️
windows 96.17% <100.00%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@mohammed-saalim
Copy link
Contributor Author

Rebased and addressed all feedback:

  • AOT test ref to Polly.Caching.
  • Central PackageVersion for Microsoft.Extensions.Caching.Hybrid (9.3.0); csprojs de-versioned.
  • Added Polly.Caching to build.cake packages and MutationTestsCaching; included in aggregate.
  • Switched TargetFrameworks → TargetFramework (net9.0).
  • Options docs improved (HybridCache cref, TTL/sliding defaults, “delegate” wording).
  • Strategy: keyGenerator rename, static default generator, static value factory with state, non-empty key enforced, removed null-forgiving.
  • Tests updated accordingly.

Build and tests are green. Ready for review @martincostello

@mohammed-saalim mohammed-saalim marked this pull request as ready for review August 29, 2025 22:00
Copy link
Member

@martincostello martincostello left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid further force pushes - it makes reviewing the deltas more difficult, and I would squash merge anyway.

@mohammed-saalim
Copy link
Contributor Author

@martincostello All feedback addressed!

Key changes:

  1. Updated to Microsoft.Extensions.Caching.Hybrid 9.8.0
  2. Fixed project structure (TargetFramework, ItemGroup)
  3. Enhanced null/empty key support with EmptyKeyPlaceholder
  4. Replaced FactoryState with tuple approach
  5. Updated test syntax to use ValueTask.FromResult()

@mohammed-saalim
Copy link
Contributor Author

@martincostello The build is failing due to code coverage in Polly.Core.Tests being 99.83% instead of 100% on .NET 9.0 (but passes on .NET 8.0). This appears to be a framework-specific issue unrelated to the caching changes. Could you re-run the CI or advise on how to proceed? All other feedbacks are addressed

@mohammed-saalim
Copy link
Contributor Author

@martincostello All CI checks are now passing!

The previous coverage failure appears to have been a transient CI issue - the fresh run shows all tests passing with full coverage maintained.

Summary of changes addressed from review:

  • Moved to separate Polly.Caching package structure
  • Added package description and tags
  • Fixed multiline object initialization style throughout tests
  • Simplified key handling logic
  • Added comprehensive JsonElement conversion tests with detailed explanation
  • Addressed ExceptionUtilities exclusion question

Comment on lines 27 to 28
var key = _keyGenerator(context) ?? string.Empty;
if (key.Length == 0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hasn't directly answered my question. I can see that if the key is null or empty a specific key is used instead, but why?

What is the behaviour of HybridCache here? Why are null and "" equivalent?

Comment on lines 34 to 62
var result = await _cache.GetOrCreateAsync(
key,
(callback, context, state),
static async (s, _) =>
{
var outcome = await s.callback(s.context, s.state).ConfigureAwait(s.context.ContinueOnCapturedContext);
outcome.ThrowIfException();
return outcome.Result!;
},
cancellationToken: context.CancellationToken).ConfigureAwait(context.ContinueOnCapturedContext);

// Handle non-generic (object) pipelines where serializer may return JsonElement.
return Outcome.FromResult(ConvertUntypedIfJsonElement(result));
}

private static TResult ConvertUntypedIfJsonElement(TResult value)
{
if (typeof(TResult) == typeof(object) && value is System.Text.Json.JsonElement json)
{
if (json.ValueKind == System.Text.Json.JsonValueKind.Null)
{
return (TResult)(object?)null!;
}

return (TResult)(object?)json.ToString()!;
}

return value;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to mull this over further and play with the code locally when I get the time - this still feels a little odd to me, so I'd like to avoid this special-casing code if possible.

return (TResult)(object?)null!;
}

return (TResult)(object?)json.ToString()!;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doing some investigation, this behaviour is just wrong. While I'm not sure what we should do instead, but the following test illustrates that this is broken:

[Fact]
public async Task NonGeneric_AddHybridCache_BuildsAndCaches_ValueType()
{
    var services = new ServiceCollection().AddHybridCache();
    using var provider = services.Services.BuildServiceProvider();
    var cache = provider.GetRequiredService<HybridCache>();

    var options = new HybridCacheStrategyOptions
    {
        Cache = cache,
        CacheKeyGenerator = _ => "builder-key",
    };

    var pipeline = new ResiliencePipelineBuilder()
        .AddHybridCache(options)
        .Build();

    var r1 = await pipeline.ExecuteAsync(static _ => ValueTask.FromResult(1));
    var r2 = await pipeline.ExecuteAsync(static _ => ValueTask.FromResult(2));

    r1.ShouldBe(1);
    r2.ShouldBe(1);
}

This fails with the following exception:

System.InvalidCastException : Unable to cast object of type 'System.String' to type 'System.Int32'.

We can't just keep special-casing types to coerce the type to make the object behaviour magically "just work" for the user.

Maybe the fix is something like swapping out object for some internal surrogate type before doing the cache operation to bypass the JsonElement behaviour, but there's definitely something off with the non-generic/object behaviour here.

Another option might just be to prevent the non-generic pipeline being used with the cache, but that would be a last resort.

@mohammed-saalim
Copy link
Contributor Author

Updated per feedback:
Empty/null keys now bypass caching using var key = keyGenerator(context) ?? string.Empty; so Polly doesn’t cache values HybridCache wouldn’t.
For untyped pipelines, replaced string coercion with a robust approach:
Store a small CacheObject wrapper so HybridCache operates on a concrete type.
On retrieval, normalize JsonElement to native .NET types (bool/int/long/double/string/null). Typed pipelines remain unchanged.
The previously failing value-type test now passes.
Tests updated to assert native types; added focused cases for int/long/double/bool/null/object; coverage is 100% for the new module. @martincostello


// wrapper moved to top-level public type

private static object? NormalizeValue(object? value)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately despite the wrapping type, object is still used, so this ultimately falls down for the same reason as before as it tries to manually convert the JsonDocument back to a specific 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; }
}
  Message: 
System.InvalidCastException : Unable to cast object of type 'System.String' to type 'System.Collections.Generic.List`1[Polly.Caching.Tests.HybridCacheResiliencePipelineBuilderExtensionsTests+Dog]'.

  Stack Trace: 
BridgeComponentBase.ConvertOutcome[TFrom,TTo](Outcome`1 outcome) line 30
BridgeComponent`1.ExecuteCore[TResult,TState](Func`3 callback, ResilienceContext context, TState state) line 40
CompositeComponent.ExecuteCoreWithoutTelemetry[TResult,TState](Func`3 callback, ResilienceContext context, TState state) line 95
CompositeComponent.ExecuteCore[TResult,TState](Func`3 callback, ResilienceContext context, TState state) line 78
ResiliencePipeline.ExecuteAsync[TResult](Func`2 callback, CancellationToken cancellationToken)
HybridCacheResiliencePipelineBuilderExtensionsTests.NonGeneric_AddHybridCache_For_Custom_Object() line 114
--- End of stack trace from previous location ---

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants