Hybrid Cache: Resolve start-up errors with mis-matched types (#20554)

* Be consistent in use of GetOrCreateAsync overload in exists and retrieval.
Ensure nullability of ContentCacheNode is consistent in exists and retrieval.

* Applied suggestion from code review.

* Move seeding to Umbraco application starting rather than started, ensuring an initial request is served.

* Tighten up hybrid cache exists check with locking around check and remove, and use of cancellation token.

(cherry picked from commit 81a8a0c191)
This commit is contained in:
Andy Butland
2025-10-21 09:57:29 +02:00
committed by mole
parent e6f48799a1
commit 68d1b9481a
7 changed files with 141 additions and 58 deletions

View File

@@ -1,4 +1,3 @@

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core.Configuration.Models;
@@ -74,7 +73,7 @@ public static class UmbracoBuilderExtensions
builder.AddNotificationAsyncHandler<ContentTypeDeletedNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<MediaTypeRefreshedNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<MediaTypeDeletedNotification, CacheRefreshingNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, SeedingNotificationHandler>();
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
builder.AddCacheSeeding();
return builder;
}

View File

@@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using Microsoft.Extensions.Caching.Hybrid;
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
@@ -7,19 +8,24 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
/// </summary>
internal static class HybridCacheExtensions
{
// Per-key semaphores to ensure the GetOrCreateAsync + RemoveAsync sequence
// executes atomically for a given cache key.
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _keyLocks = new();
/// <summary>
/// Returns true if the cache contains an item with a matching key.
/// </summary>
/// <param name="cache">An instance of <see cref="Microsoft.Extensions.Caching.Hybrid.HybridCache"/></param>
/// <param name="key">The name (key) of the item to search for in the cache.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>True if the item exists already. False if it doesn't.</returns>
/// <remarks>
/// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191
/// Will never add or alter the state of any items in the cache.
/// </remarks>
public static async Task<bool> ExistsAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key)
public static async Task<bool> ExistsAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token)
{
(bool exists, _) = await TryGetValueAsync<T>(cache, key);
(bool exists, _) = await TryGetValueAsync<T>(cache, key, token).ConfigureAwait(false);
return exists;
}
@@ -29,34 +35,55 @@ internal static class HybridCacheExtensions
/// <typeparam name="T">The type of the value of the item in the cache.</typeparam>
/// <param name="cache">An instance of <see cref="Microsoft.Extensions.Caching.Hybrid.HybridCache"/></param>
/// <param name="key">The name (key) of the item to search for in the cache.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>A tuple of <see cref="bool"/> and the object (if found) retrieved from the cache.</returns>
/// <remarks>
/// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191
/// Will never add or alter the state of any items in the cache.
/// </remarks>
public static async Task<(bool Exists, T? Value)> TryGetValueAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key)
public static async Task<(bool Exists, T? Value)> TryGetValueAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token)
{
var exists = true;
T? result = await cache.GetOrCreateAsync<object, T>(
key,
null!,
(_, _) =>
{
exists = false;
return new ValueTask<T>(default(T)!);
},
new HybridCacheEntryOptions(),
null,
CancellationToken.None);
// Acquire a per-key semaphore so that GetOrCreateAsync and the possible RemoveAsync
// complete without another thread retrieving/creating the same key in-between.
SemaphoreSlim sem = _keyLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
// In checking for the existence of the item, if not found, we will have created a cache entry with a null value.
// So remove it again.
if (exists is false)
await sem.WaitAsync().ConfigureAwait(false);
try
{
await cache.RemoveAsync(key);
}
T? result = await cache.GetOrCreateAsync<T?>(
key,
cancellationToken =>
{
exists = false;
return default;
},
new HybridCacheEntryOptions(),
null,
token).ConfigureAwait(false);
return (exists, result);
// In checking for the existence of the item, if not found, we will have created a cache entry with a null value.
// So remove it again. Because we're holding the per-key lock there is no chance another thread
// will observe the temporary entry between GetOrCreateAsync and RemoveAsync.
if (exists is false)
{
await cache.RemoveAsync(key).ConfigureAwait(false);
}
return (exists, result);
}
finally
{
sem.Release();
// Only remove the semaphore mapping if it still points to the same instance we used.
// This avoids removing another thread's semaphore or corrupting the map.
if (_keyLocks.TryGetValue(key, out SemaphoreSlim? current) && ReferenceEquals(current, sem))
{
_keyLocks.TryRemove(key, out _);
}
}
}
}

View File

@@ -1,4 +1,4 @@
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Options;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Configuration.Models;
using Umbraco.Cms.Core.Events;
@@ -9,7 +9,7 @@ using Umbraco.Cms.Infrastructure.HybridCache.Services;
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<UmbracoApplicationStartingNotification>
{
private readonly IDocumentCacheService _documentCacheService;
private readonly IMediaCacheService _mediaCacheService;
@@ -24,7 +24,7 @@ internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<Umb
_globalSettings = globalSettings.Value;
}
public async Task HandleAsync(UmbracoApplicationStartedNotification notification,
public async Task HandleAsync(UmbracoApplicationStartingNotification notification,
CancellationToken cancellationToken)
{

View File

@@ -205,7 +205,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
var cacheKey = GetCacheKey(key, false);
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode>(cacheKey);
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode?>(cacheKey, cancellationToken).ConfigureAwait(false);
if (existsInCache is false)
{
uncachedKeys.Add(key);
@@ -278,7 +278,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService
return false;
}
return await _hybridCache.ExistsAsync<ContentCacheNode>(GetCacheKey(keyAttempt.Result, preview));
return await _hybridCache.ExistsAsync<ContentCacheNode?>(GetCacheKey(keyAttempt.Result, preview), CancellationToken.None);
}
public async Task RefreshContentAsync(IContent content)

View File

@@ -133,7 +133,7 @@ internal sealed class MediaCacheService : IMediaCacheService
return false;
}
return await _hybridCache.ExistsAsync<ContentCacheNode>($"{keyAttempt.Result}");
return await _hybridCache.ExistsAsync<ContentCacheNode?>($"{keyAttempt.Result}", CancellationToken.None);
}
public async Task RefreshMediaAsync(IMedia media)
@@ -170,7 +170,7 @@ internal sealed class MediaCacheService : IMediaCacheService
var cacheKey = GetCacheKey(key, false);
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode>(cacheKey);
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
if (existsInCache is false)
{
uncachedKeys.Add(key);