From 4efe8f59b84018c5223a72086a1524052b5b419d Mon Sep 17 00:00:00 2001 From: Andy Butland Date: Tue, 12 Aug 2025 11:58:41 +0200 Subject: [PATCH] Optimize document and media seeding by looking up from database in batches (#19890) * Optimize document and media seeding by looking up from database in batches. * Ensure null values aren't stored in the cache when checking existance. * Fixed failing integration tests. * Resolved issue with not writing to the L1 cache on an L2 hit. * Tidied up and populated XML header comments. * Address issue raised in code review. --- .../Extensions/HybridCacheExtensions.cs | 62 ++++++ .../Persistence/DatabaseCacheRepository.cs | 39 ++++ .../Persistence/IDatabaseCacheRepository.cs | 66 ++++++- .../Services/DocumentCacheService.cs | 102 ++++++---- .../Services/MediaCacheService.cs | 91 ++++++--- .../DocumentHybridCacheMockTests.cs | 78 ++++---- .../DocumentBreadthFirstKeyProviderTests.cs | 3 +- .../Extensions/HybridCacheExtensionsTests.cs | 186 ++++++++++++++++++ 8 files changed, 508 insertions(+), 119 deletions(-) create mode 100644 src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs diff --git a/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs new file mode 100644 index 0000000000..427bc67d3f --- /dev/null +++ b/src/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensions.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Caching.Hybrid; + +namespace Umbraco.Cms.Infrastructure.HybridCache.Extensions; + +/// +/// Provides extension methods on . +/// +internal static class HybridCacheExtensions +{ + /// + /// Returns true if the cache contains an item with a matching key. + /// + /// An instance of + /// The name (key) of the item to search for in the cache. + /// True if the item exists already. False if it doesn't. + /// + /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 + /// Will never add or alter the state of any items in the cache. + /// + public static async Task ExistsAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + { + (bool exists, _) = await TryGetValueAsync(cache, key); + return exists; + } + + /// + /// Returns true if the cache contains an item with a matching key, along with the value of the matching cache entry. + /// + /// The type of the value of the item in the cache. + /// An instance of + /// The name (key) of the item to search for in the cache. + /// A tuple of and the object (if found) retrieved from the cache. + /// + /// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 + /// Will never add or alter the state of any items in the cache. + /// + public static async Task<(bool Exists, T? Value)> TryGetValueAsync(this Microsoft.Extensions.Caching.Hybrid.HybridCache cache, string key) + { + var exists = true; + + T? result = await cache.GetOrCreateAsync( + key, + null!, + (_, _) => + { + exists = false; + return new ValueTask(default(T)!); + }, + new HybridCacheEntryOptions(), + null, + CancellationToken.None); + + // 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 cache.RemoveAsync(key); + } + + return (exists, result); + } +} diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs index 91f8ad8e92..b72af50a59 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/DatabaseCacheRepository.cs @@ -146,6 +146,26 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe return CreateContentNodeKit(dto, serializer, preview); } + public async Task> GetContentSourcesAsync(IEnumerable keys, bool preview = false) + { + Sql? sql = SqlContentSourcesSelect() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Document)) + .WhereIn(x => x.UniqueId, keys) + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + + List dtos = await Database.FetchAsync(sql); + + dtos = dtos + .Where(x => x is not null) + .Where(x => preview || x.PubDataRaw is not null || x.PubData is not null) + .ToList(); + + IContentCacheDataSerializer serializer = + _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Document); + return dtos + .Select(x => CreateContentNodeKit(x, serializer, preview)); + } + private IEnumerable GetContentSourceByDocumentTypeKey(IEnumerable documentTypeKeys, Guid objectType) { Guid[] keys = documentTypeKeys.ToArray(); @@ -220,6 +240,25 @@ internal sealed class DatabaseCacheRepository : RepositoryBase, IDatabaseCacheRe return CreateMediaNodeKit(dto, serializer); } + public async Task> GetMediaSourcesAsync(IEnumerable keys) + { + Sql? sql = SqlMediaSourcesSelect() + .Append(SqlObjectTypeNotTrashed(SqlContext, Constants.ObjectTypes.Media)) + .WhereIn(x => x.UniqueId, keys) + .Append(SqlOrderByLevelIdSortOrder(SqlContext)); + + List dtos = await Database.FetchAsync(sql); + + dtos = dtos + .Where(x => x is not null) + .ToList(); + + IContentCacheDataSerializer serializer = + _contentCacheDataSerializerFactory.Create(ContentCacheDataSerializerEntityType.Media); + return dtos + .Select(x => CreateMediaNodeKit(x, serializer)); + } + private async Task OnRepositoryRefreshed(IContentCacheDataSerializer serializer, ContentCacheNode content, bool preview) { // use a custom SQL to update row version on each update diff --git a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs index 21a9e8cfbc..a10a616fdc 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Persistence/IDatabaseCacheRepository.cs @@ -5,48 +5,96 @@ namespace Umbraco.Cms.Infrastructure.HybridCache.Persistence; internal interface IDatabaseCacheRepository { + /// + /// Deletes the specified content item from the cache database. + /// Task DeleteContentItemAsync(int id); + /// + /// Gets a single cache node for a document key. + /// Task GetContentSourceAsync(Guid key, bool preview = false); + /// + /// Gets a collection of cache nodes for a collection of document keys. + /// + // TODO (V18): Remove the default implementation on this method. + async Task> GetContentSourcesAsync(IEnumerable keys, bool preview = false) + { + var contentCacheNodes = new List(); + foreach (Guid key in keys) + { + ContentCacheNode? contentSource = await GetContentSourceAsync(key, preview); + if (contentSource is not null) + { + contentCacheNodes.Add(contentSource); + } + } + + return contentCacheNodes; + } + + /// + /// Gets a single cache node for a media key. + /// Task GetMediaSourceAsync(Guid key); + /// + /// Gets a collection of cache nodes for a collection of media keys. + /// + // TODO (V18): Remove the default implementation on this method. + async Task> GetMediaSourcesAsync(IEnumerable keys) + { + var contentCacheNodes = new List(); + foreach (Guid key in keys) + { + ContentCacheNode? contentSource = await GetMediaSourceAsync(key); + if (contentSource is not null) + { + contentCacheNodes.Add(contentSource); + } + } + return contentCacheNodes; + } + + /// + /// Gets a collection of cache nodes for a collection of content type keys and entity type. + /// IEnumerable GetContentByContentTypeKey(IEnumerable keys, ContentCacheDataSerializerEntityType entityType); /// - /// Gets all content keys of specific document types + /// Gets all content keys of specific document types. /// /// The document types to find content using. + /// A flag indicating whether to restrict to just published content. /// The keys of all content use specific document types. IEnumerable GetDocumentKeysByContentTypeKeys(IEnumerable keys, bool published = false); /// - /// Refreshes the nucache database row for the given cache node /> + /// Refreshes the cache for the given document cache node. /// - /// A representing the asynchronous operation. Task RefreshContentAsync(ContentCacheNode contentCacheNode, PublishedState publishedState); /// - /// Refreshes the nucache database row for the given cache node /> + /// Refreshes the cache row for the given media cache node. /// - /// A representing the asynchronous operation. Task RefreshMediaAsync(ContentCacheNode contentCacheNode); /// - /// Rebuilds the caches for content, media and/or members based on the content type ids specified + /// Rebuilds the caches for content, media and/or members based on the content type ids specified. /// /// /// If not null will process content for the matching content types, if empty will process all - /// content + /// content. /// /// /// If not null will process content for the matching media types, if empty will process all - /// media + /// media. /// /// /// If not null will process content for the matching members types, if empty will process all - /// members + /// members. /// void Rebuild( IReadOnlyCollection? contentTypeIds = null, diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs index d083739b45..1e68acad7f 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -1,4 +1,8 @@ +#if DEBUG + using System.Diagnostics; +#endif using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; @@ -7,6 +11,7 @@ using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; using Umbraco.Cms.Core.Services.Navigation; +using Umbraco.Cms.Infrastructure.HybridCache.Extensions; using Umbraco.Cms.Infrastructure.HybridCache.Factories; using Umbraco.Cms.Infrastructure.HybridCache.Persistence; using Umbraco.Cms.Infrastructure.HybridCache.Serialization; @@ -26,8 +31,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService private readonly IPublishedModelFactory _publishedModelFactory; private readonly IPreviewService _previewService; private readonly IPublishStatusQueryService _publishStatusQueryService; - private readonly IDocumentNavigationQueryService _documentNavigationQueryService; private readonly CacheSettings _cacheSettings; + private readonly ILogger _logger; private HashSet? _seedKeys; private HashSet SeedKeys @@ -62,7 +67,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService IPublishedModelFactory publishedModelFactory, IPreviewService previewService, IPublishStatusQueryService publishStatusQueryService, - IDocumentNavigationQueryService documentNavigationQueryService) + ILogger logger) { _databaseCacheRepository = databaseCacheRepository; _idKeyMap = idKeyMap; @@ -74,8 +79,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService _publishedModelFactory = publishedModelFactory; _previewService = previewService; _publishStatusQueryService = publishStatusQueryService; - _documentNavigationQueryService = documentNavigationQueryService; _cacheSettings = cacheSettings.Value; + _logger = logger; } public async Task GetByKeyAsync(Guid key, bool? preview = null) @@ -185,44 +190,64 @@ internal sealed class DocumentCacheService : IDocumentCacheService public async Task SeedAsync(CancellationToken cancellationToken) { - foreach (Guid key in SeedKeys) +#if DEBUG + var sw = new Stopwatch(); + sw.Start(); +#endif + + const int GroupSize = 100; + foreach (IEnumerable group in SeedKeys.InGroupsOf(GroupSize)) { - if (cancellationToken.IsCancellationRequested) + var uncachedKeys = new HashSet(); + foreach (Guid key in group) { - break; + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var cacheKey = GetCacheKey(key, false); + + var existsInCache = await _hybridCache.ExistsAsync(cacheKey); + if (existsInCache is false) + { + uncachedKeys.Add(key); + } } - var cacheKey = GetCacheKey(key, false); + _logger.LogDebug("Uncached key count {KeyCount}", uncachedKeys.Count); - // We'll use GetOrCreateAsync because it may be in the second level cache, in which case we don't have to re-seed. - ContentCacheNode? cachedValue = await _hybridCache.GetOrCreateAsync( - cacheKey, - async cancel => - { - using ICoreScope scope = _scopeProvider.CreateCoreScope(); - - ContentCacheNode? cacheNode = await _databaseCacheRepository.GetContentSourceAsync(key); - - scope.Complete(); - - // We don't want to seed drafts - if (cacheNode is null || cacheNode.IsDraft) - { - return null; - } - - return cacheNode; - }, - GetSeedEntryOptions(), - GenerateTags(key), - cancellationToken: cancellationToken); - - // If the value is null, it's likely because - if (cachedValue is null) + if (uncachedKeys.Count == 0) { - await _hybridCache.RemoveAsync(cacheKey, cancellationToken); + continue; + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + IEnumerable cacheNodes = await _databaseCacheRepository.GetContentSourcesAsync(uncachedKeys); + + scope.Complete(); + + _logger.LogDebug("Document nodes to cache {NodeCount}", cacheNodes.Count()); + + foreach (ContentCacheNode cacheNode in cacheNodes) + { + var cacheKey = GetCacheKey(cacheNode.Key, false); + await _hybridCache.SetAsync( + cacheKey, + cacheNode, + GetSeedEntryOptions(), + GenerateTags(cacheNode.Key), + cancellationToken: cancellationToken); } } + +#if DEBUG + sw.Stop(); + _logger.LogInformation("Document cache seeding completed in {ElapsedMilliseconds} ms with {SeedCount} seed keys.", sw.ElapsedMilliseconds, SeedKeys.Count); +#else + _logger.LogInformation("Document cache seeding completed with {SeedCount} seed keys.", SeedKeys.Count); +#endif } // Internal for test purposes. @@ -256,16 +281,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService return false; } - ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( - GetCacheKey(keyAttempt.Result, preview), // Unique key to the cache entry - cancel => ValueTask.FromResult(null)); - - if (contentCacheNode is null) - { - await _hybridCache.RemoveAsync(GetCacheKey(keyAttempt.Result, preview)); - } - - return contentCacheNode is not null; + return await _hybridCache.ExistsAsync(GetCacheKey(keyAttempt.Result, preview)); } public async Task RefreshContentAsync(IContent content) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs index 4359f824c8..ca9691a1c4 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -1,4 +1,8 @@ +#if DEBUG + using System.Diagnostics; +#endif using Microsoft.Extensions.Caching.Hybrid; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Models; @@ -6,6 +10,7 @@ using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; using Umbraco.Cms.Core.Scoping; using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Infrastructure.HybridCache.Extensions; using Umbraco.Cms.Infrastructure.HybridCache.Factories; using Umbraco.Cms.Infrastructure.HybridCache.Persistence; using Umbraco.Cms.Infrastructure.HybridCache.Serialization; @@ -23,6 +28,7 @@ internal sealed class MediaCacheService : IMediaCacheService private readonly ICacheNodeFactory _cacheNodeFactory; private readonly IEnumerable _seedKeyProviders; private readonly IPublishedModelFactory _publishedModelFactory; + private readonly ILogger _logger; private readonly CacheSettings _cacheSettings; private HashSet? _seedKeys; @@ -55,7 +61,8 @@ internal sealed class MediaCacheService : IMediaCacheService ICacheNodeFactory cacheNodeFactory, IEnumerable seedKeyProviders, IPublishedModelFactory publishedModelFactory, - IOptions cacheSettings) + IOptions cacheSettings, + ILogger logger) { _databaseCacheRepository = databaseCacheRepository; _idKeyMap = idKeyMap; @@ -66,6 +73,7 @@ internal sealed class MediaCacheService : IMediaCacheService _seedKeyProviders = seedKeyProviders; _publishedModelFactory = publishedModelFactory; _cacheSettings = cacheSettings.Value; + _logger = logger; } public async Task GetByKeyAsync(Guid key) @@ -125,19 +133,9 @@ internal sealed class MediaCacheService : IMediaCacheService return false; } - ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( - $"{keyAttempt.Result}", // Unique key to the cache entry - cancel => ValueTask.FromResult(null)); - - if (contentCacheNode is null) - { - await _hybridCache.RemoveAsync($"{keyAttempt.Result}"); - } - - return contentCacheNode is not null; + return await _hybridCache.ExistsAsync($"{keyAttempt.Result}"); } - public async Task RefreshMediaAsync(IMedia media) { using ICoreScope scope = _scopeProvider.CreateCoreScope(); @@ -155,33 +153,64 @@ internal sealed class MediaCacheService : IMediaCacheService public async Task SeedAsync(CancellationToken cancellationToken) { - foreach (Guid key in SeedKeys) +#if DEBUG + var sw = new Stopwatch(); + sw.Start(); +#endif + + const int GroupSize = 100; + foreach (IEnumerable group in SeedKeys.InGroupsOf(GroupSize)) { - if (cancellationToken.IsCancellationRequested) + var uncachedKeys = new HashSet(); + foreach (Guid key in group) { - break; + if (cancellationToken.IsCancellationRequested) + { + break; + } + + var cacheKey = GetCacheKey(key, false); + + var existsInCache = await _hybridCache.ExistsAsync(cacheKey); + if (existsInCache is false) + { + uncachedKeys.Add(key); + } } - var cacheKey = GetCacheKey(key, false); + _logger.LogDebug("Uncached key count {KeyCount}", uncachedKeys.Count); - ContentCacheNode? cachedValue = await _hybridCache.GetOrCreateAsync( - cacheKey, - async cancel => - { - using ICoreScope scope = _scopeProvider.CreateCoreScope(); - ContentCacheNode? mediaCacheNode = await _databaseCacheRepository.GetMediaSourceAsync(key); - scope.Complete(); - return mediaCacheNode; - }, - GetSeedEntryOptions(), - GenerateTags(key), - cancellationToken: cancellationToken); - - if (cachedValue is null) + if (uncachedKeys.Count == 0) { - await _hybridCache.RemoveAsync(cacheKey); + continue; + } + + using ICoreScope scope = _scopeProvider.CreateCoreScope(); + + IEnumerable cacheNodes = await _databaseCacheRepository.GetMediaSourcesAsync(uncachedKeys); + + scope.Complete(); + + _logger.LogDebug("Media nodes to cache {NodeCount}", cacheNodes.Count()); + + foreach (ContentCacheNode cacheNode in cacheNodes) + { + var cacheKey = GetCacheKey(cacheNode.Key, false); + await _hybridCache.SetAsync( + cacheKey, + cacheNode, + GetSeedEntryOptions(), + GenerateTags(cacheNode.Key), + cancellationToken: cancellationToken); } } + +#if DEBUG + sw.Stop(); + _logger.LogInformation("Media cache seeding completed in {ElapsedMilliseconds} ms with {SeedCount} seed keys.", sw.ElapsedMilliseconds, SeedKeys.Count); +#else + _logger.LogInformation("Media cache seeding completed with {SeedCount} seed keys.", SeedKeys.Count); +#endif } public async Task RefreshMemoryCacheAsync(Guid key) diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs index e6ba41cefc..1b68301380 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheMockTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; @@ -26,8 +27,8 @@ namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithContent { private IPublishedContentCache _mockedCache; - private Mock _mockedNucacheRepository; - private IDocumentCacheService _mockDocumentCacheService; + private Mock _mockDatabaseCacheRepository; + private IDocumentCacheService _documentCacheService; protected override void CustomTestSetup(IUmbracoBuilder builder) => builder.AddUmbracoHybridCache(); @@ -38,7 +39,7 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC [SetUp] public void SetUp() { - _mockedNucacheRepository = new Mock(); + _mockDatabaseCacheRepository = new Mock(); var contentData = new ContentData( Textpage.Name, @@ -76,25 +77,29 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC IsDraft = false, }; - _mockedNucacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), true)) + _mockDatabaseCacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), true)) .ReturnsAsync(draftTestCacheNode); + _mockDatabaseCacheRepository.Setup(r => r.GetContentSourcesAsync(It.IsAny>(), true)) + .ReturnsAsync([draftTestCacheNode]); - _mockedNucacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), false)) + _mockDatabaseCacheRepository.Setup(r => r.GetContentSourceAsync(It.IsAny(), false)) .ReturnsAsync(publishedTestCacheNode); + _mockDatabaseCacheRepository.Setup(r => r.GetContentSourcesAsync(It.IsAny>(), false)) + .ReturnsAsync([publishedTestCacheNode]); - _mockedNucacheRepository.Setup(r => r.GetContentByContentTypeKey(It.IsAny>(), ContentCacheDataSerializerEntityType.Document)).Returns( + _mockDatabaseCacheRepository.Setup(r => r.GetContentByContentTypeKey(It.IsAny>(), ContentCacheDataSerializerEntityType.Document)).Returns( new List() { draftTestCacheNode, }); - _mockedNucacheRepository.Setup(r => r.DeleteContentItemAsync(It.IsAny())); + _mockDatabaseCacheRepository.Setup(r => r.DeleteContentItemAsync(It.IsAny())); var mockedPublishedStatusService = new Mock(); mockedPublishedStatusService.Setup(x => x.IsDocumentPublishedInAnyCulture(It.IsAny())).Returns(true); - _mockDocumentCacheService = new DocumentCacheService( - _mockedNucacheRepository.Object, + _documentCacheService = new DocumentCacheService( + _mockDatabaseCacheRepository.Object, GetRequiredService(), GetRequiredService(), GetRequiredService(), @@ -105,9 +110,10 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC GetRequiredService(), GetRequiredService(), mockedPublishedStatusService.Object, - GetRequiredService()); + new NullLogger()); - _mockedCache = new DocumentCache(_mockDocumentCacheService, + _mockedCache = new DocumentCache( + _documentCacheService, GetRequiredService(), GetRequiredService(), GetRequiredService(), @@ -118,8 +124,10 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC // So we'll manually create them with a magic options mock. private IEnumerable GetSeedProviders(IPublishStatusQueryService publishStatusQueryService) { - _cacheSettings = new CacheSettings(); - _cacheSettings.DocumentBreadthFirstSeedCount = 0; + _cacheSettings = new CacheSettings + { + DocumentBreadthFirstSeedCount = 0 + }; var mock = new Mock>(); mock.Setup(m => m.Value).Returns(() => _cacheSettings); @@ -140,7 +148,7 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC var textPage2 = await _mockedCache.GetByIdAsync(Textpage.Key, true); AssertTextPage(textPage); AssertTextPage(textPage2); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); } [Test] @@ -152,79 +160,79 @@ internal sealed class DocumentHybridCacheMockTests : UmbracoIntegrationTestWithC var textPage2 = await _mockedCache.GetByIdAsync(Textpage.Id, true); AssertTextPage(textPage); AssertTextPage(textPage2); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); } [Test] public async Task Content_Is_Seeded_By_Id() { - var schedule = new CultureAndScheduleModel + var schedule = new CulturePublishScheduleModel { - CulturesToPublishImmediately = new HashSet { "*" }, Schedules = new ContentScheduleCollection(), + Culture = Constants.System.InvariantCulture, }; - var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, schedule, Constants.Security.SuperUserKey); + var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, [schedule], Constants.Security.SuperUserKey); Assert.IsTrue(publishResult.Success); Textpage.Published = true; - await _mockDocumentCacheService.DeleteItemAsync(Textpage); + await _documentCacheService.DeleteItemAsync(Textpage); _cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ]; - await _mockDocumentCacheService.SeedAsync(CancellationToken.None); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + await _documentCacheService.SeedAsync(CancellationToken.None); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1)); var textPage = await _mockedCache.GetByIdAsync(Textpage.Id); AssertTextPage(textPage); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1)); } [Test] public async Task Content_Is_Seeded_By_Key() { - var schedule = new CultureAndScheduleModel + var schedule = new CulturePublishScheduleModel { - CulturesToPublishImmediately = new HashSet { "*" }, Schedules = new ContentScheduleCollection(), + Culture = Constants.System.InvariantCulture, }; - var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, schedule, Constants.Security.SuperUserKey); + var publishResult = await ContentPublishingService.PublishAsync(Textpage.Key, [schedule], Constants.Security.SuperUserKey); Assert.IsTrue(publishResult.Success); Textpage.Published = true; - await _mockDocumentCacheService.DeleteItemAsync(Textpage); + await _documentCacheService.DeleteItemAsync(Textpage); _cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ]; - await _mockDocumentCacheService.SeedAsync(CancellationToken.None); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + await _documentCacheService.SeedAsync(CancellationToken.None); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1)); var textPage = await _mockedCache.GetByIdAsync(Textpage.Key); AssertTextPage(textPage); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourcesAsync(It.IsAny>(), It.IsAny()), Times.Exactly(1)); } [Test] public async Task Content_Is_Not_Seeded_If_Unpblished_By_Id() { - await _mockDocumentCacheService.DeleteItemAsync(Textpage); + await _documentCacheService.DeleteItemAsync(Textpage); _cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ]; - await _mockDocumentCacheService.SeedAsync(CancellationToken.None); + await _documentCacheService.SeedAsync(CancellationToken.None); var textPage = await _mockedCache.GetByIdAsync(Textpage.Id, true); AssertTextPage(textPage); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); } [Test] public async Task Content_Is_Not_Seeded_If_Unpublished_By_Key() { _cacheSettings.ContentTypeKeys = [ Textpage.ContentType.Key ]; - await _mockDocumentCacheService.DeleteItemAsync(Textpage); + await _documentCacheService.DeleteItemAsync(Textpage); - await _mockDocumentCacheService.SeedAsync(CancellationToken.None); + await _documentCacheService.SeedAsync(CancellationToken.None); var textPage = await _mockedCache.GetByIdAsync(Textpage.Key, true); AssertTextPage(textPage); - _mockedNucacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); + _mockDatabaseCacheRepository.Verify(x => x.GetContentSourceAsync(It.IsAny(), It.IsAny()), Times.Exactly(1)); } private void AssertTextPage(IPublishedContent textPage) diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs index beab657044..649a161f13 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/DocumentBreadthFirstKeyProviderTests.cs @@ -1,4 +1,4 @@ -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Options; using Moq; using NUnit.Framework; using Umbraco.Cms.Core.Models; @@ -6,6 +6,7 @@ using Umbraco.Cms.Core.Services.Navigation; using Umbraco.Cms.Infrastructure.HybridCache.SeedKeyProviders.Document; namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache; + [TestFixture] public class DocumentBreadthFirstKeyProviderTests { diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs new file mode 100644 index 0000000000..27e0cb0d0a --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.PublishedCache.HybridCache/Extensions/HybridCacheExtensionsTests.cs @@ -0,0 +1,186 @@ +using Microsoft.Extensions.Caching.Hybrid; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Infrastructure.HybridCache.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.PublishedCache.HybridCache.Extensions; + +/// +/// Provides tests to cover the class. +/// +/// +/// Hat-tip: https://github.com/dotnet/aspnetcore/discussions/57191 +/// +[TestFixture] +public class HybridCacheExtensionsTests +{ + private Mock _cacheMock; + + [SetUp] + public void TestInitialize() + { + _cacheMock = new Mock(); + } + + [Test] + public async Task ExistsAsync_WhenKeyExists_ShouldReturnTrue() + { + // Arrange + string key = "test-key"; + var expectedValue = "test-value"; + + _cacheMock + .Setup(cache => cache.GetOrCreateAsync( + key, + null!, + It.IsAny>>(), + It.IsAny(), + null, + CancellationToken.None)) + .ReturnsAsync(expectedValue); + + // Act + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + + // Assert + Assert.IsTrue(exists); + } + + [Test] + public async Task ExistsAsync_WhenKeyDoesNotExist_ShouldReturnFalse() + { + // Arrange + string key = "test-key"; + + _cacheMock + .Setup(cache => cache.GetOrCreateAsync( + key, + null!, + It.IsAny>>(), + It.IsAny(), + null, + CancellationToken.None)) + .Returns(( + string key, + object? state, + Func> factory, + HybridCacheEntryOptions? options, + IEnumerable? tags, + CancellationToken token) => + { + return factory(state!, token); + }); + + // Act + var exists = await HybridCacheExtensions.ExistsAsync(_cacheMock.Object, key); + + // Assert + Assert.IsFalse(exists); + } + + [Test] + public async Task TryGetValueAsync_WhenKeyExists_ShouldReturnTrueAndValueAsString() + { + // Arrange + string key = "test-key"; + var expectedValue = "test-value"; + + _cacheMock + .Setup(cache => cache.GetOrCreateAsync( + key, + null!, + It.IsAny>>(), + It.IsAny(), + null, + CancellationToken.None)) + .ReturnsAsync(expectedValue); + + // Act + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + + // Assert + Assert.IsTrue(exists); + Assert.AreEqual(expectedValue, value); + } + + [Test] + public async Task TryGetValueAsync_WhenKeyExists_ShouldReturnTrueAndValueAsInteger() + { + // Arrange + string key = "test-key"; + var expectedValue = 5; + + _cacheMock + .Setup(cache => cache.GetOrCreateAsync( + key, + null!, + It.IsAny>>(), + It.IsAny(), + null, + CancellationToken.None)) + .ReturnsAsync(expectedValue); + + // Act + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + + // Assert + Assert.IsTrue(exists); + Assert.AreEqual(expectedValue, value); + } + + [Test] + public async Task TryGetValueAsync_WhenKeyExistsButValueIsNull_ShouldReturnTrueAndNullValue() + { + // Arrange + string key = "test-key"; + + _cacheMock + .Setup(cache => cache.GetOrCreateAsync( + key, + null!, + It.IsAny>>(), + It.IsAny(), + null, + CancellationToken.None)) + .ReturnsAsync(null!); + + // Act + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + + // Assert + Assert.IsTrue(exists); + Assert.IsNull(value); + } + + [Test] + public async Task TryGetValueAsync_WhenKeyDoesNotExist_ShouldReturnFalseAndNull() + { + // Arrange + string key = "test-key"; + + _cacheMock.Setup(cache => cache.GetOrCreateAsync( + key, + null, + It.IsAny>>(), + It.IsAny(), + null, + CancellationToken.None)) + .Returns(( + string key, + object? state, + Func> factory, + HybridCacheEntryOptions? options, + IEnumerable? tags, + CancellationToken token) => + { + return factory(state, token); + }); + + // Act + var (exists, value) = await HybridCacheExtensions.TryGetValueAsync(_cacheMock.Object, key); + + // Assert + Assert.IsFalse(exists); + Assert.IsNull(value); + } +}