Skip to content

Fix HybridCache wildcard invalidation timestamp under frozen TimeProvider (#7849) - #7908

Open
Manish-1kumar wants to merge 2 commits into
dotnet:mainfrom
Manish-1kumar:fix/hybridcache-offline-l2-faketime
Open

Manish-1kumar wants to merge 2 commits into
dotnet:mainfrom
Manish-1kumar:fix/hybridcache-offline-l2-faketime

Conversation

@Manish-1kumar

@Manish-1kumar Manish-1kumar commented Nov 2, 2026 •

Copy link
Copy Markdown

Description

When DefaultHybridCache is initialized with an IDistributedCache backend, it asynchronously prefetches the global wildcard invalidation timestamp (SafeReadTagInvalidationAsync(TagSet.WildcardTag)). If the backend distributed cache is offline or throws on startup, SafeReadTagInvalidationAsync catches the failure and falls back to CurrentTimestamp().

In unit and integration tests using a frozen FakeTimeProvider, _globalInvalidateTimestamp is initialized to the current time tick ($T_0$). When an application subsequently invokes SetAsync, the item is stored with CreationTimestamp = T_0. When the item is subsequently looked up via GetOrCreateAsync, IsWildcardExpired(timestamp) compares timestamp <= _globalInvalidateTimestamp.Result ($T_0 \le T_0$), which evaluates to true, immediately expiring and discarding the newly written entry and triggering a cache miss.

This fix adjusts SafeReadTagInvalidationAsync on the error and unexpected length fallback paths to return Math.Max(0L, CurrentTimestamp() - 1). Any cache entries created strictly prior to the failed read are still invalidated as intended, but entries written at or after that tick remain valid, decoupling behavior from clock resolution.

Fixes #7849

Customer Impact

Consumers running integration tests with FakeTimeProvider and disconnected or unavailable distributed cache backends (or fail-fast Redis configurations) will not have newly written L1 entries immediately discarded by wildcard expiration.

Regression?

No.

Risk

Extremely low. Minimal two-line adjustment to fallback timestamp calculation on the failure paths of SafeReadTagInvalidationAsync.

Testing

  • Added regression tests OfflineL2_WithFrozenClock_RetainsSetEntries and OfflineL2_WithFrozenClock_RetainsTaggedSetEntries to UnreliableL2Tests.cs (demonstrating failure before fix, passing after fix).
  • dotnet build src/Libraries/Microsoft.Extensions.Caching.Hybrid/Microsoft.Extensions.Caching.Hybrid.csproj -c Release (0 Warnings, 0 Errors across all 6 TFMs).
  • dotnet test test/Libraries/Microsoft.Extensions.Caching.Hybrid.Tests/Microsoft.Extensions.Caching.Hybrid.Tests.csproj -c Release (284/284 passed on net10.0, 284/284 on net9.0, 279/279 on net8.0).
  • git diff --check passed with 0 errors.
Microsoft Reviewers: Open in CodeFlow

@dotnet-comment-bot

Copy link
Copy Markdown
Collaborator

‼️ Found issues ‼️

Project Coverage Type Expected Actual
Microsoft.Extensions.AI Line 89 87.21 🔻
Microsoft.Extensions.AI Branch 89 79.21 🔻
Microsoft.Extensions.AI.OpenAI Line 75 68.82 🔻
Microsoft.Extensions.AI.OpenAI Branch 75 60.26 🔻
Microsoft.Extensions.DataIngestion.MarkItDown Line 75 4.46 🔻
Microsoft.Extensions.DataIngestion.MarkItDown Branch 75 0.75 🔻
Microsoft.Extensions.Diagnostics.ResourceMonitoring Line 99 96.06 🔻
Microsoft.Extensions.Diagnostics.ResourceMonitoring Branch 99 92.28 🔻
Microsoft.Extensions.Diagnostics.ResourceMonitoring.Kubernetes Line 99 97.73 🔻
Microsoft.Extensions.Diagnostics.ResourceMonitoring.Kubernetes Branch 99 92.86 🔻
Microsoft.Extensions.Diagnostics.Testing Line 99 98.22 🔻
Microsoft.Extensions.Diagnostics.Testing Branch 99 93.71 🔻
Microsoft.Extensions.ServiceDiscovery Line 75 61.99 🔻
Microsoft.Extensions.ServiceDiscovery Branch 75 64.06 🔻
Microsoft.Extensions.ServiceDiscovery.Abstractions Line 75 41.14 🔻
Microsoft.Extensions.ServiceDiscovery.Abstractions Branch 75 45.1 🔻
Microsoft.Extensions.ServiceDiscovery.Dns Line 75 67.15 🔻
Microsoft.Extensions.ServiceDiscovery.Dns Branch 75 73.96 🔻
Microsoft.Extensions.ServiceDiscovery.Yarp Line 75 73.85 🔻
Microsoft.Extensions.ServiceDiscovery.Yarp Branch 75 60 🔻
Microsoft.Extensions.Telemetry Line 93 90.46 🔻
Microsoft.Extensions.Telemetry Branch 93 86.78 🔻
Microsoft.Extensions.VectorData.Abstractions Line 75 32.15 🔻
Microsoft.Extensions.VectorData.Abstractions Branch 75 26.31 🔻

🎉 Good job! The coverage increased 🎉
Update MinCodeCoverage in the project files.

Project Expected Actual
Microsoft.Extensions.AI.Evaluation.NLP 0 74
Microsoft.Extensions.DataIngestion 75 77
Microsoft.Extensions.DataIngestion.Markdig 75 88
Microsoft.Extensions.Http.Resilience 97 99
Microsoft.Gen.MetadataExtractor 57 72
Microsoft.Gen.MetricsReports 67 69

Full code coverage report: https://dev.azure.com/dnceng-public/public/_build/results?buildId=1660958&view=codecoverage-tab

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The regression tests cover only throwing backend reads, leaving the malformed nonempty invalidation-payload fallback unverified; add a frozen-clock test confirming that a newly set entry remains in L1.

1 open issue
What changed in this PR

This PR fixes HybridCache invalidation fallback timestamps so newly written L1 entries remain valid when the clock is frozen and the distributed cache is unavailable, addressing #7849.

Changes:

  • Adjusts failed and malformed invalidation-read fallbacks to one tick before the current time, clamped to zero.
  • Adds frozen-clock regression tests for retaining tagged and untagged entries with an offline distributed cache.
File Description
test/​Libraries/​Microsoft.Extensions.Caching.Hybrid.Tests/​UnreliableL2Tests.cs Adds frozen-clock retention tests and an offline cache stub.
src/​Libraries/​Microsoft.Extensions.Caching.Hybrid/​Internal/​DefaultHybridCache.L2.cs Adjusts both invalidation-read fallback timestamps.

Review effort: Balanced

{
// not what we expected! assume invalid
timestamp = CurrentTimestamp();
timestamp = Math.Max(0L, CurrentTimestamp() - 1);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I agree with Copilot, can you, please, add test for the unexpected buffer size case?

{
// not what we expected! assume invalid
timestamp = CurrentTimestamp();
timestamp = Math.Max(0L, CurrentTimestamp() - 1);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think it would be a bit better if both changes were just CurrentTimestamp() - 1. That would mean that even when time is 0, we don't incorrectly invalidate current entries.

(We don't have to worry about overflow, since CurrentTimestamp() is always >= 0.)

Comment on lines +125 to +126
// Wait briefly for the background wildcard invalidation read task to complete
await Task.Delay(100);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Fixed delays (here and in the other test) should generally be avoided, they lead to flaky tests.

In this case, I think they can be removed, since the cache used is actually synchronous.

@svick

svick commented Nov 10, 2026

Copy link
Copy Markdown
Member

@Manish-1kumar Thank you for the PR. I have a couple of suggestions.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hybrid Cache with Offline Distributed Redis Cache loses entries when using FakeTimeProvider

4 participants