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.19%. Comparing base (8ba1e3b) to head (9540ed9).
⚠️ Report is 27 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #2709      +/-   ##
==========================================
+ Coverage   96.12%   96.19%   +0.06%     
==========================================
  Files         309      313       +4     
  Lines        7118     7196      +78     
  Branches     1008     1018      +10     
==========================================
+ Hits         6842     6922      +80     
+ Misses        222      221       -1     
+ Partials       54       53       -1     
Flag Coverage Δ
linux 96.19% <100.00%> (+0.06%) ⬆️
macos 96.19% <100.00%> (+0.06%) ⬆️
windows 96.18% <100.00%> (+0.07%) ⬆️

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 and others added 10 commits September 3, 2025 10:05
Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
…xtensionsTests.cs

Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
Co-authored-by: Martin Costello <martin@martincostello.com>
@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 ---

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 you’d like full type preservation for complex objects in non-generic pipelines, I’ll follow up with an opt-in option on HybridCacheStrategyOptions (default false) so we don’t change existing behavior by default.
Current state: null/empty keys bypass caching, untyped wrapper + primitive JsonElement normalization, typed unchanged; tests updated; 100% coverage.

…efaults

- Add PreserveComplexUntypedValues option (default false)
- Untyped path: if enabled, restore complex types via recorded type name; otherwise keep existing normalization
- Added tests for custom object + opt-in paths
- All tests passing; 100% coverage
@mohammed-saalim
Copy link
Contributor Author

@martincostello Implemented an opt-in option to preserve complex types in non-generic pipelines without changing defaults:

  • Added HybridCacheStrategyOptions.PreserveComplexUntypedValues (default false).
  • When enabled, values cached via untyped pipelines store a CacheObject wrapper with the recorded type name (AssemblyQualifiedName). On retrieval, if the serializer returns JsonElement, we deserialize back to the original runtime type using the recorded type.
  • When disabled (default), existing semantics remain: primitive JsonElement values normalize to native .NET types (int/long/double/bool/string/null), and object/array JsonElement values convert to their string representation.
  • Typed pipelines are unchanged and fully type-preserving.
  • Added tests including your custom object scenario (NonGeneric_AddHybridCache_For_Custom_Object_WithOptIn_Preserves_Type); all 48 tests pass with 100% coverage.

This keeps backward compatibility while enabling the desired behavior for users who need it. If you prefer a different default or approach, happy to adjust.

@martincostello
Copy link
Member

I'm still not convinced by the latest changes. Untyped pipelines seem to basically be a pit-of-failure that require a workaround internally that I don't think we should really need to be dealing with.

The new setting feels weird to me. Given the caching strategy would be new, having to turn a setting on by default to get it to work would look weird to users, but having a "make it work" option itself is weirder.

I think either it needs to be refactored in a way that it Just Works™️, or all the support for untyped pipelines should just be removed.

@mohammed-saalim
Copy link
Contributor Author

@martincostello I understand your concern about the complexity. You're right that the opt-in setting adds friction.

I see three paths forward:

1. Make it "Just Work"™️ by default

  • Remove the PreserveComplexUntypedValues flag entirely and always preserve types for untyped pipelines.
  • The CacheObject wrapper + type deserialization would be the default behavior (no opt-in needed).

2. Remove untyped pipeline support

  • Throw a descriptive exception when typeof(TResult) == typeof(object), guiding users to use typed pipelines instead.
  • This would be the cleanest solution if untyped pipelines aren't a common use case.

3. Keep primitives working, block complex types

  • Allow untyped pipelines for primitive types (int/bool/string/etc.) since they work naturally with JsonElement normalization.
  • Block or warn when complex types are detected.

Which direction do you prefer? I'm happy to implement any of these approaches.

@martincostello
Copy link
Member

I'm not sure 1 is possible using the second step proposed for it to work.

I'm not sure we need to throw an exception for untyped pipelines, as we could just make it unusable by not adding any factory/build methods that allow you to create one for an untyped pipeline?

Option 3 just sounds like a pit of failure - you try to use it, then find it doesn't actually work.


1 is preferred if you can find a way to make it work somehow, otherwise we can take a look at 2, but if the implementation is only partially working and incomplete, then I'm not sure it's something we should build into Polly.Core.

For option 2 that makes me gravitate more towards this being an add-on the community can provide with another package, rather than it being something we support and include in-box here.

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