Skip to content

Commit 8aa9dc8

Browse files
committed
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.
1 parent 5488c77 commit 8aa9dc8

File tree

7 files changed

+141
-58
lines changed

7 files changed

+141
-58
lines changed

src/Umbraco.PublishedCache.HybridCache/DependencyInjection/UmbracoBuilderExtensions.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-

21
using Microsoft.Extensions.DependencyInjection;
32
using Microsoft.Extensions.Options;
43
using Umbraco.Cms.Core.Configuration.Models;
@@ -74,7 +73,7 @@ public static IUmbracoBuilder AddUmbracoHybridCache(this IUmbracoBuilder builder
7473
builder.AddNotificationAsyncHandler<ContentTypeDeletedNotification, CacheRefreshingNotificationHandler>();
7574
builder.AddNotificationAsyncHandler<MediaTypeRefreshedNotification, CacheRefreshingNotificationHandler>();
7675
builder.AddNotificationAsyncHandler<MediaTypeDeletedNotification, CacheRefreshingNotificationHandler>();
77-
builder.AddNotificationAsyncHandler<UmbracoApplicationStartedNotification, SeedingNotificationHandler>();
76+
builder.AddNotificationAsyncHandler<UmbracoApplicationStartingNotification, SeedingNotificationHandler>();
7877
builder.AddCacheSeeding();
7978
return builder;
8079
}
Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using System.Collections.Concurrent;
12
using Microsoft.Extensions.Caching.Hybrid;
23

34
namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
@@ -7,19 +8,24 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions;
78
/// </summary>
89
internal static class HybridCacheExtensions
910
{
11+
// Per-key semaphores to ensure the GetOrCreateAsync + RemoveAsync sequence
12+
// executes atomically for a given cache key.
13+
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _keyLocks = new();
14+
1015
/// <summary>
1116
/// Returns true if the cache contains an item with a matching key.
1217
/// </summary>
1318
/// <param name="cache">An instance of <see cref="Microsoft.Extensions.Caching.Hybrid.HybridCache"/></param>
1419
/// <param name="key">The name (key) of the item to search for in the cache.</param>
20+
/// <param name="token">The cancellation token.</param>
1521
/// <returns>True if the item exists already. False if it doesn't.</returns>
1622
/// <remarks>
1723
/// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191
1824
/// Will never add or alter the state of any items in the cache.
1925
/// </remarks>
20-
public static async Task<bool> ExistsAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key)
26+
public static async Task<bool> ExistsAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token)
2127
{
22-
(bool exists, _) = await TryGetValueAsync<T>(cache, key);
28+
(bool exists, _) = await TryGetValueAsync<T>(cache, key, token).ConfigureAwait(false);
2329
return exists;
2430
}
2531

@@ -29,34 +35,55 @@ public static async Task<bool> ExistsAsync<T>(this Microsoft.Extensions.Caching.
2935
/// <typeparam name="T">The type of the value of the item in the cache.</typeparam>
3036
/// <param name="cache">An instance of <see cref="Microsoft.Extensions.Caching.Hybrid.HybridCache"/></param>
3137
/// <param name="key">The name (key) of the item to search for in the cache.</param>
38+
/// <param name="token">The cancellation token.</param>
3239
/// <returns>A tuple of <see cref="bool"/> and the object (if found) retrieved from the cache.</returns>
3340
/// <remarks>
3441
/// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191
3542
/// Will never add or alter the state of any items in the cache.
3643
/// </remarks>
37-
public static async Task<(bool Exists, T? Value)> TryGetValueAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key)
44+
public static async Task<(bool Exists, T? Value)> TryGetValueAsync<T>(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key, CancellationToken token)
3845
{
3946
var exists = true;
4047

41-
T? result = await cache.GetOrCreateAsync<object, T>(
42-
key,
43-
null!,
44-
(_, _) =>
45-
{
46-
exists = false;
47-
return new ValueTask<T>(default(T)!);
48-
},
49-
new HybridCacheEntryOptions(),
50-
null,
51-
CancellationToken.None);
52-
53-
// In checking for the existence of the item, if not found, we will have created a cache entry with a null value.
54-
// So remove it again.
55-
if (exists is false)
48+
// Acquire a per-key semaphore so that GetOrCreateAsync and the possible RemoveAsync
49+
// complete without another thread retrieving/creating the same key in-between.
50+
SemaphoreSlim sem = _keyLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
51+
52+
await sem.WaitAsync().ConfigureAwait(false);
53+
54+
try
5655
{
57-
await cache.RemoveAsync(key);
56+
T? result = await cache.GetOrCreateAsync<T?>(
57+
key,
58+
cancellationToken =>
59+
{
60+
exists = false;
61+
return default;
62+
},
63+
new HybridCacheEntryOptions(),
64+
null,
65+
token).ConfigureAwait(false);
66+
67+
// In checking for the existence of the item, if not found, we will have created a cache entry with a null value.
68+
// So remove it again. Because we're holding the per-key lock there is no chance another thread
69+
// will observe the temporary entry between GetOrCreateAsync and RemoveAsync.
70+
if (exists is false)
71+
{
72+
await cache.RemoveAsync(key).ConfigureAwait(false);
73+
}
74+
75+
return (exists, result);
5876
}
77+
finally
78+
{
79+
sem.Release();
5980

60-
return (exists, result);
81+
// Only remove the semaphore mapping if it still points to the same instance we used.
82+
// This avoids removing another thread's semaphore or corrupting the map.
83+
if (_keyLocks.TryGetValue(key, out SemaphoreSlim? current) && ReferenceEquals(current, sem))
84+
{
85+
_keyLocks.TryRemove(key, out _);
86+
}
87+
}
6188
}
6289
}

src/Umbraco.PublishedCache.HybridCache/NotificationHandlers/SeedingNotificationHandler.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using Microsoft.Extensions.Options;
1+
using Microsoft.Extensions.Options;
22
using Umbraco.Cms.Core;
33
using Umbraco.Cms.Core.Configuration.Models;
44
using Umbraco.Cms.Core.Events;
@@ -9,7 +9,7 @@
99

1010
namespace Umbraco.Cms.Infrastructure.HybridCache.NotificationHandlers;
1111

12-
internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<UmbracoApplicationStartedNotification>
12+
internal sealed class SeedingNotificationHandler : INotificationAsyncHandler<UmbracoApplicationStartingNotification>
1313
{
1414
private readonly IDocumentCacheService _documentCacheService;
1515
private readonly IMediaCacheService _mediaCacheService;
@@ -24,7 +24,7 @@ public SeedingNotificationHandler(IDocumentCacheService documentCacheService, IM
2424
_globalSettings = globalSettings.Value;
2525
}
2626

27-
public async Task HandleAsync(UmbracoApplicationStartedNotification notification,
27+
public async Task HandleAsync(UmbracoApplicationStartingNotification notification,
2828
CancellationToken cancellationToken)
2929
{
3030

src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ public async Task SeedAsync(CancellationToken cancellationToken)
205205

206206
var cacheKey = GetCacheKey(key, false);
207207

208-
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode>(cacheKey);
208+
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode?>(cacheKey, cancellationToken).ConfigureAwait(false);
209209
if (existsInCache is false)
210210
{
211211
uncachedKeys.Add(key);
@@ -278,7 +278,7 @@ public async Task<bool> HasContentByIdAsync(int id, bool preview = false)
278278
return false;
279279
}
280280

281-
return await _hybridCache.ExistsAsync<ContentCacheNode>(GetCacheKey(keyAttempt.Result, preview));
281+
return await _hybridCache.ExistsAsync<ContentCacheNode?>(GetCacheKey(keyAttempt.Result, preview), CancellationToken.None);
282282
}
283283

284284
public async Task RefreshContentAsync(IContent content)

src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ public async Task<bool> HasContentByIdAsync(int id)
133133
return false;
134134
}
135135

136-
return await _hybridCache.ExistsAsync<ContentCacheNode>($"{keyAttempt.Result}");
136+
return await _hybridCache.ExistsAsync<ContentCacheNode?>($"{keyAttempt.Result}", CancellationToken.None);
137137
}
138138

139139
public async Task RefreshMediaAsync(IMedia media)
@@ -170,7 +170,7 @@ public async Task SeedAsync(CancellationToken cancellationToken)
170170

171171
var cacheKey = GetCacheKey(key, false);
172172

173-
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode>(cacheKey);
173+
var existsInCache = await _hybridCache.ExistsAsync<ContentCacheNode?>(cacheKey, CancellationToken.None);
174174
if (existsInCache is false)
175175
{
176176
uncachedKeys.Add(key);

tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Umbraco.Cms.Core.PublishedCache;
88
using Umbraco.Cms.Core.Services;
99
using Umbraco.Cms.Core.Sync;
10+
using Umbraco.Cms.Tests.Common.Builders;
1011
using Umbraco.Cms.Tests.Common.Testing;
1112
using Umbraco.Cms.Tests.Integration.Testing;
1213
using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
@@ -25,10 +26,10 @@ protected override void CustomTestSetup(IUmbracoBuilder builder)
2526

2627
private IPublishedContentCache PublishedContentHybridCache => GetRequiredService<IPublishedContentCache>();
2728

28-
private IContentEditingService ContentEditingService => GetRequiredService<IContentEditingService>();
29-
3029
private IContentPublishingService ContentPublishingService => GetRequiredService<IContentPublishingService>();
3130

31+
private IDocumentCacheService DocumentCacheService => GetRequiredService<IDocumentCacheService>();
32+
3233
private const string NewName = "New Name";
3334
private const string NewTitle = "New Title";
3435

@@ -460,6 +461,61 @@ public async Task Can_Not_Get_Deleted_Published_Content_By_Key(bool preview)
460461
Assert.IsNull(textPage);
461462
}
462463

464+
[Test]
465+
public async Task Can_Get_Published_Content_By_Id_After_Previous_Check_Where_Not_Found()
466+
{
467+
// Arrange
468+
var testPageKey = Guid.NewGuid();
469+
470+
// Act & Assert
471+
// - assert we cannot get the content that doesn't yet exist from the cache
472+
var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey);
473+
Assert.IsNull(testPage);
474+
475+
testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey);
476+
Assert.IsNull(testPage);
477+
478+
// - create and publish the content
479+
var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey);
480+
var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey);
481+
Assert.IsTrue(createResult.Success);
482+
var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey);
483+
Assert.IsTrue(publishResult.Success);
484+
485+
// - assert we can now get the content from the cache
486+
testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey);
487+
Assert.IsNotNull(testPage);
488+
}
489+
490+
[Test]
491+
public async Task Can_Get_Published_Content_By_Id_After_Previous_Exists_Check()
492+
{
493+
// Act
494+
var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(PublishedTextPageId);
495+
Assert.IsTrue(hasContentForTextPageCached);
496+
var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId);
497+
498+
// Assert
499+
AssertPublishedTextPage(textPage);
500+
}
501+
502+
[Test]
503+
public async Task Can_Do_Exists_Check_On_Created_Published_Content()
504+
{
505+
var testPageKey = Guid.NewGuid();
506+
var testPageContent = ContentEditingBuilder.CreateBasicContent(ContentType.Key, testPageKey);
507+
var createResult = await ContentEditingService.CreateAsync(testPageContent, Constants.Security.SuperUserKey);
508+
Assert.IsTrue(createResult.Success);
509+
var publishResult = await ContentPublishingService.PublishAsync(testPageKey, CultureAndSchedule, Constants.Security.SuperUserKey);
510+
Assert.IsTrue(publishResult.Success);
511+
512+
var testPage = await PublishedContentHybridCache.GetByIdAsync(testPageKey);
513+
Assert.IsNotNull(testPage);
514+
515+
var hasContentForTextPageCached = await DocumentCacheService.HasContentByIdAsync(testPage.Id);
516+
Assert.IsTrue(hasContentForTextPageCached);
517+
}
518+
463519
private void AssertTextPage(IPublishedContent textPage)
464520
{
465521
Assert.Multiple(() =>

0 commit comments

Comments
 (0)