From 4ee1d7b13ede5512872146e6a3ff786d074b8bc7 Mon Sep 17 00:00:00 2001 From: Kenn Jacobsen Date: Mon, 3 Nov 2025 10:55:23 +0100 Subject: [PATCH] Performance: Cache published content instances at cache service level (#20681) Cache published content instances at cache service level --- .../Factories/PublishedContentFactory.cs | 97 +----------- .../Services/DocumentCacheService.cs | 35 ++++- .../Services/MediaCacheService.cs | 37 ++++- .../DocumentHybridCacheTests.cs | 43 ++++- .../DocumentPropertyCacheLevelTests.cs | 130 ++++++++++++++++ .../Factories/PublishedContentFactoryTests.cs | 147 ------------------ .../MediaPropertyCacheLevelTests.cs | 111 +++++++++++++ .../MemberPropertyCacheLevelTests.cs | 96 ++++++++++++ .../PropertyCacheLevelTestsBase.cs | 69 ++++++++ 9 files changed, 508 insertions(+), 257 deletions(-) create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentPropertyCacheLevelTests.cs delete mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaPropertyCacheLevelTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberPropertyCacheLevelTests.cs create mode 100644 tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyCacheLevelTestsBase.cs diff --git a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs index f6ff872ad1..a6c6273645 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactory.cs @@ -1,10 +1,7 @@ -using Microsoft.Extensions.Logging; -using Umbraco.Cms.Core.Cache; using Umbraco.Cms.Core.Extensions; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Models.PublishedContent; using Umbraco.Cms.Core.PublishedCache; -using Umbraco.Extensions; namespace Umbraco.Cms.Infrastructure.HybridCache.Factories; @@ -16,8 +13,6 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory private readonly IElementsCache _elementsCache; private readonly IVariationContextAccessor _variationContextAccessor; private readonly IPublishedContentTypeCache _publishedContentTypeCache; - private readonly ILogger _logger; - private readonly AppCaches _appCaches; /// /// Initializes a new instance of the class. @@ -25,40 +20,16 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory public PublishedContentFactory( IElementsCache elementsCache, IVariationContextAccessor variationContextAccessor, - IPublishedContentTypeCache publishedContentTypeCache, - ILogger logger, - AppCaches appCaches) + IPublishedContentTypeCache publishedContentTypeCache) { _elementsCache = elementsCache; _variationContextAccessor = variationContextAccessor; _publishedContentTypeCache = publishedContentTypeCache; - _logger = logger; - _appCaches = appCaches; } /// public IPublishedContent? ToIPublishedContent(ContentCacheNode contentCacheNode, bool preview) { - var cacheKey = $"{nameof(PublishedContentFactory)}DocumentCache_{contentCacheNode.Id}_{preview}_{contentCacheNode.Data?.VersionDate.Ticks ?? 0}"; - IPublishedContent? publishedContent = null; - if (_appCaches.RequestCache.IsAvailable) - { - publishedContent = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (publishedContent is not null) - { - _logger.LogDebug( - "Using cached IPublishedContent for document {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - return publishedContent; - } - } - - _logger.LogDebug( - "Creating IPublishedContent for document {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Content, contentCacheNode.ContentTypeId); var contentNode = new ContentNode( @@ -71,44 +42,19 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory preview ? contentCacheNode.Data : null, preview ? null : contentCacheNode.Data); - publishedContent = GetModel(contentNode, preview); + IPublishedContent? publishedContent = GetModel(contentNode, preview); if (preview) { publishedContent ??= GetPublishedContentAsDraft(publishedContent); } - if (_appCaches.RequestCache.IsAvailable && publishedContent is not null) - { - _appCaches.RequestCache.Set(cacheKey, publishedContent); - } - return publishedContent; } /// public IPublishedContent? ToIPublishedMedia(ContentCacheNode contentCacheNode) { - var cacheKey = $"{nameof(PublishedContentFactory)}MediaCache_{contentCacheNode.Id}"; - IPublishedContent? publishedContent = null; - if (_appCaches.RequestCache.IsAvailable) - { - publishedContent = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (publishedContent is not null) - { - _logger.LogDebug( - "Using cached IPublishedContent for media {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - return publishedContent; - } - } - - _logger.LogDebug( - "Creating IPublishedContent for media {ContentCacheNodeName} ({ContentCacheNodeId}).", - contentCacheNode.Data?.Name ?? "No Name", - contentCacheNode.Id); - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Media, contentCacheNode.ContentTypeId); var contentNode = new ContentNode( @@ -121,40 +67,12 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory null, contentCacheNode.Data); - publishedContent = GetModel(contentNode, false); - - if (_appCaches.RequestCache.IsAvailable && publishedContent is not null) - { - _appCaches.RequestCache.Set(cacheKey, publishedContent); - } - - return publishedContent; + return GetModel(contentNode, false); } /// public IPublishedMember ToPublishedMember(IMember member) { - string cacheKey = $"{nameof(PublishedContentFactory)}MemberCache_{member.Id}"; - IPublishedMember? publishedMember = null; - if (_appCaches.RequestCache.IsAvailable) - { - publishedMember = _appCaches.RequestCache.GetCacheItem(cacheKey); - if (publishedMember is not null) - { - _logger.LogDebug( - "Using cached IPublishedMember for member {MemberName} ({MemberId}).", - member.Username, - member.Id); - - return publishedMember; - } - } - - _logger.LogDebug( - "Creating IPublishedMember for member {MemberName} ({MemberId}).", - member.Username, - member.Id); - IPublishedContentType contentType = _publishedContentTypeCache.Get(PublishedItemType.Member, member.ContentTypeId); @@ -179,14 +97,7 @@ internal sealed class PublishedContentFactory : IPublishedContentFactory contentType, null, contentData); - publishedMember = new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); - - if (_appCaches.RequestCache.IsAvailable) - { - _appCaches.RequestCache.Set(cacheKey, publishedMember); - } - - return publishedMember; + return new PublishedMember(member, contentNode, _elementsCache, _variationContextAccessor); } private static Dictionary GetPropertyValues(IPublishedContentType contentType, IMember member) diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs index 2879be5c8d..79d9235e28 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/DocumentCacheService.cs @@ -1,6 +1,7 @@ #if DEBUG using System.Diagnostics; #endif +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -35,6 +36,8 @@ internal sealed class DocumentCacheService : IDocumentCacheService private readonly ILogger _logger; private HashSet? _seedKeys; + private readonly ConcurrentDictionary _publishedContentCache = []; + private HashSet SeedKeys { get @@ -108,6 +111,11 @@ internal sealed class DocumentCacheService : IDocumentCacheService { var cacheKey = GetCacheKey(key, preview); + if (preview is false && _publishedContentCache.TryGetValue(cacheKey, out IPublishedContent? cached)) + { + return cached; + } + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( cacheKey, async cancel => @@ -137,7 +145,13 @@ internal sealed class DocumentCacheService : IDocumentCacheService return null; } - return _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory); + IPublishedContent? result = _publishedContentFactory.ToIPublishedContent(contentCacheNode, preview).CreateModel(_publishedModelFactory); + if (result is not null) + { + _publishedContentCache[cacheKey] = result; + } + + return result; } private bool GetPreview() => _previewService.IsInPreview(); @@ -174,7 +188,9 @@ internal sealed class DocumentCacheService : IDocumentCacheService ContentCacheNode? publishedNode = await _databaseCacheRepository.GetContentSourceAsync(key, false); if (publishedNode is not null && _publishStatusQueryService.HasPublishedAncestorPath(publishedNode.Key)) { - await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key, false), publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(key)); + var cacheKey = GetCacheKey(publishedNode.Key, false); + await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key, false), GenerateTags(key)); + _publishedContentCache.Remove(cacheKey, out _); } scope.Complete(); @@ -183,7 +199,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService public async Task RemoveFromMemoryCacheAsync(Guid key) { await _hybridCache.RemoveAsync(GetCacheKey(key, true)); - await _hybridCache.RemoveAsync(GetCacheKey(key, false)); + await ClearPublishedCacheAsync(key); } public async Task SeedAsync(CancellationToken cancellationToken) @@ -300,7 +316,7 @@ internal sealed class DocumentCacheService : IDocumentCacheService if (content.PublishedState == PublishedState.Unpublishing) { - await _hybridCache.RemoveAsync(GetCacheKey(publishedCacheNode.Key, false)); + await ClearPublishedCacheAsync(publishedCacheNode.Key); } } @@ -338,12 +354,19 @@ internal sealed class DocumentCacheService : IDocumentCacheService foreach (ContentCacheNode content in contentByContentTypeKey) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)).GetAwaiter().GetResult(); + await _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)); if (content.IsDraft is false) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, false)).GetAwaiter().GetResult(); + await ClearPublishedCacheAsync(content.Key); } } } + + private async Task ClearPublishedCacheAsync(Guid key) + { + var cacheKey = GetCacheKey(key, false); + await _hybridCache.RemoveAsync(cacheKey); + _publishedContentCache.Remove(cacheKey, out _); + } } diff --git a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs index 46d782bdbe..9fe3dc5990 100644 --- a/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs +++ b/src/Umbraco.PublishedCache.HybridCache/Services/MediaCacheService.cs @@ -1,6 +1,7 @@ #if DEBUG using System.Diagnostics; #endif +using System.Collections.Concurrent; using Microsoft.Extensions.Caching.Hybrid; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -31,6 +32,8 @@ internal sealed class MediaCacheService : IMediaCacheService private readonly ILogger _logger; private readonly CacheSettings _cacheSettings; + private readonly ConcurrentDictionary _publishedContentCache = []; + private HashSet? _seedKeys; private HashSet SeedKeys { @@ -103,6 +106,12 @@ internal sealed class MediaCacheService : IMediaCacheService private async Task GetNodeAsync(Guid key) { var cacheKey = $"{key}"; + + if (_publishedContentCache.TryGetValue(cacheKey, out IPublishedContent? cached)) + { + return cached; + } + ContentCacheNode? contentCacheNode = await _hybridCache.GetOrCreateAsync( cacheKey, // Unique key to the cache entry async cancel => @@ -122,7 +131,13 @@ internal sealed class MediaCacheService : IMediaCacheService return null; } - return _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory); + IPublishedContent? result = _publishedContentFactory.ToIPublishedMedia(contentCacheNode).CreateModel(_publishedModelFactory); + if (result is not null) + { + _publishedContentCache[cacheKey] = result; + } + + return result; } public async Task HasContentByIdAsync(int id) @@ -141,6 +156,7 @@ internal sealed class MediaCacheService : IMediaCacheService using ICoreScope scope = _scopeProvider.CreateCoreScope(); var cacheNode = _cacheNodeFactory.ToContentCacheNode(media); await _databaseCacheRepository.RefreshMediaAsync(cacheNode); + _publishedContentCache.Remove(GetCacheKey(media.Key, false), out _); scope.Complete(); } @@ -219,7 +235,9 @@ internal sealed class MediaCacheService : IMediaCacheService ContentCacheNode? publishedNode = await _databaseCacheRepository.GetMediaSourceAsync(key); if (publishedNode is not null) { - await _hybridCache.SetAsync(GetCacheKey(publishedNode.Key, false), publishedNode, GetEntryOptions(publishedNode.Key)); + var cacheKey = GetCacheKey(publishedNode.Key, false); + await _hybridCache.SetAsync(cacheKey, publishedNode, GetEntryOptions(publishedNode.Key)); + _publishedContentCache.Remove(cacheKey, out _); } scope.Complete(); @@ -234,7 +252,7 @@ internal sealed class MediaCacheService : IMediaCacheService } public async Task RemoveFromMemoryCacheAsync(Guid key) - => await _hybridCache.RemoveAsync(GetCacheKey(key, false)); + => await ClearPublishedCacheAsync(key); public async Task RebuildMemoryCacheByContentTypeAsync(IEnumerable mediaTypeIds) { @@ -244,11 +262,11 @@ internal sealed class MediaCacheService : IMediaCacheService foreach (ContentCacheNode content in contentByContentTypeKey) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)).GetAwaiter().GetResult(); + await _hybridCache.RemoveAsync(GetCacheKey(content.Key, true)); if (content.IsDraft is false) { - _hybridCache.RemoveAsync(GetCacheKey(content.Key, false)).GetAwaiter().GetResult(); + await ClearPublishedCacheAsync(content.Key); } } @@ -269,7 +287,7 @@ internal sealed class MediaCacheService : IMediaCacheService foreach (ContentCacheNode media in mediaCacheNodesByContentTypeKey) { - _hybridCache.RemoveAsync(GetCacheKey(media.Key, false)); + ClearPublishedCacheAsync(media.Key).GetAwaiter().GetResult(); } scope.Complete(); @@ -313,4 +331,11 @@ internal sealed class MediaCacheService : IMediaCacheService // We use the tags to be able to clear all cache entries that are related to a given content item. // Tags for now are only content/media, but can be expanded with draft/published later. private static HashSet GenerateTags(Guid? key) => key is null ? [] : [Constants.Cache.Tags.Media]; + + private async Task ClearPublishedCacheAsync(Guid key) + { + var cacheKey = GetCacheKey(key, false); + await _hybridCache.RemoveAsync(cacheKey); + _publishedContentCache.Remove(cacheKey, out _); + } } diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs index 45d192587f..4cfd8b5a8f 100644 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentHybridCacheTests.cs @@ -85,17 +85,50 @@ internal sealed class DocumentHybridCacheTests : UmbracoIntegrationTestWithConte Assert.IsFalse(textPage.IsPublished()); } - [Test] - public async Task Cannot_get_unpublished_content() + [TestCase(true)] + [TestCase(false)] + public async Task Can_Get_Unpublished_Content_By_Key(bool preview) { // Arrange var unpublishAttempt = await ContentPublishingService.UnpublishAsync(PublishedTextPage.Key.Value, null, Constants.Security.SuperUserKey); + Assert.IsTrue(unpublishAttempt.Success); - //Act - var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId, false); + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPage.Key.Value, preview); // Assert - Assert.IsNull(textPage); + if (preview) + { + Assert.IsNotNull(textPage); + Assert.IsFalse(textPage.IsPublished()); + } + else + { + Assert.IsNull(textPage); + } + } + + [TestCase(true)] + [TestCase(false)] + public async Task Can_Get_Unpublished_Content_By_Id(bool preview) + { + // Arrange + var unpublishAttempt = await ContentPublishingService.UnpublishAsync(PublishedTextPage.Key.Value, null, Constants.Security.SuperUserKey); + Assert.IsTrue(unpublishAttempt.Success); + + // Act + var textPage = await PublishedContentHybridCache.GetByIdAsync(PublishedTextPageId, preview); + + // Assert + if (preview) + { + Assert.IsNotNull(textPage); + Assert.IsFalse(textPage.IsPublished()); + } + else + { + Assert.IsNull(textPage); + } } [Test] diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentPropertyCacheLevelTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentPropertyCacheLevelTests.cs new file mode 100644 index 0000000000..a621290519 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/DocumentPropertyCacheLevelTests.cs @@ -0,0 +1,130 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +public class DocumentPropertyCacheLevelTests : PropertyCacheLevelTestsBase +{ + private static readonly Guid _documentKey = new("9A526E75-DE41-4A81-8883-3E63F11A388D"); + + private IDocumentCacheService DocumentCacheService => GetRequiredService(); + + private IContentEditingService ContentEditingService => GetRequiredService(); + + private IContentPublishingService ContentPublishingService => GetRequiredService(); + + private IContentTypeEditingService ContentTypeEditingService => GetRequiredService(); + + [SetUp] + public async Task SetUpTest() + { + PropertyValueLevelDetectionTestsConverter.Reset(); + + var contentTypeCreateModel = ContentTypeEditingBuilder.CreateSimpleContentType(); + var contentTypeAttempt = await ContentTypeEditingService.CreateAsync(contentTypeCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(contentTypeAttempt.Success); + + var contentCreateModel = ContentEditingBuilder.CreateSimpleContent(contentTypeAttempt.Result.Key); + contentCreateModel.Key = _documentKey; + var contentAttempt = await ContentEditingService.CreateAsync(contentCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(contentAttempt.Success); + + await PublishPage(); + } + + [TestCase(PropertyCacheLevel.None, false, 1, 10)] + [TestCase(PropertyCacheLevel.None, true, 2, 10)] + [TestCase(PropertyCacheLevel.Element, false, 1, 1)] + [TestCase(PropertyCacheLevel.Element, true, 2, 2)] + [TestCase(PropertyCacheLevel.Elements, false, 1, 1)] + [TestCase(PropertyCacheLevel.Elements, true, 1, 1)] + public async Task Property_Value_Conversion_Respects_Property_Cache_Level(PropertyCacheLevel cacheLevel, bool preview, int expectedSourceConverts, int expectedInterConverts) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent1); + + var publishedContent2 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent2); + + if (preview) + { + Assert.AreNotSame(publishedContent1, publishedContent2); + } + else + { + Assert.AreSame(publishedContent1, publishedContent2); + } + + var titleValue1 = publishedContent1.Value("title"); + Assert.IsNotNull(titleValue1); + + var titleValue2 = publishedContent2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual(titleValue1, titleValue2); + + // fetch title values 10 times in total, 5 times from each published content instance + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + + Assert.AreEqual(expectedSourceConverts, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(expectedInterConverts, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + [TestCase(PropertyCacheLevel.None, false)] + [TestCase(PropertyCacheLevel.None, true)] + [TestCase(PropertyCacheLevel.Element, false)] + [TestCase(PropertyCacheLevel.Element, true)] + [TestCase(PropertyCacheLevel.Elements, false)] + [TestCase(PropertyCacheLevel.Elements, true)] + public async Task Property_Value_Conversion_Is_Triggered_After_Cache_Refresh(PropertyCacheLevel cacheLevel, bool preview) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent1); + + var titleValue1 = publishedContent1.Value("title"); + Assert.IsNotNull(titleValue1); + + // re-publish the page to trigger a cache refresh for the page + await PublishPage(); + + var publishedContent2 = await DocumentCacheService.GetByKeyAsync(_documentKey, preview); + Assert.IsNotNull(publishedContent2); + + Assert.AreNotSame(publishedContent1, publishedContent2); + + var titleValue2 = publishedContent2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual(titleValue1, titleValue2); + + // expect conversions for each published content instance, due to the cache refresh + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + private async Task PublishPage() + { + var publishAttempt = await ContentPublishingService.PublishAsync( + _documentKey, + [new() { Culture = "*", }], + Constants.Security.SuperUserKey); + Assert.IsTrue(publishAttempt.Success); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs deleted file mode 100644 index f9f604658c..0000000000 --- a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/Factories/PublishedContentFactoryTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using NUnit.Framework; -using Umbraco.Cms.Core; -using Umbraco.Cms.Core.Cache; -using Umbraco.Cms.Core.Models.PublishedContent; -using Umbraco.Cms.Core.Services; -using Umbraco.Cms.Infrastructure.HybridCache; -using Umbraco.Cms.Infrastructure.HybridCache.Factories; -using Umbraco.Cms.Tests.Common.Builders; -using Umbraco.Cms.Tests.Common.Builders.Extensions; -using Umbraco.Cms.Tests.Common.Testing; -using Umbraco.Cms.Tests.Integration.Testing; - -namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; - -[TestFixture] -[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] -internal sealed class PublishedContentFactoryTests : UmbracoIntegrationTestWithContent -{ - private IPublishedContentFactory PublishedContentFactory => GetRequiredService(); - - private IPublishedValueFallback PublishedValueFallback => GetRequiredService(); - - private IMediaService MediaService => GetRequiredService(); - - private IMediaTypeService MediaTypeService => GetRequiredService(); - - private IMemberService MemberService => GetRequiredService(); - - private IMemberTypeService MemberTypeService => GetRequiredService(); - - protected override void CustomTestSetup(IUmbracoBuilder builder) - { - var requestCache = new DictionaryAppCache(); - var appCaches = new AppCaches( - NoAppCache.Instance, - requestCache, - new IsolatedCaches(type => NoAppCache.Instance)); - builder.Services.AddUnique(appCaches); - } - - [Test] - public void Can_Create_Published_Content_For_Document() - { - var contentCacheNode = new ContentCacheNode - { - Id = Textpage.Id, - Key = Textpage.Key, - ContentTypeId = Textpage.ContentType.Id, - CreateDate = Textpage.CreateDate, - CreatorId = Textpage.CreatorId, - SortOrder = Textpage.SortOrder, - Data = new ContentData( - Textpage.Name, - "text-page", - Textpage.VersionId, - Textpage.UpdateDate, - Textpage.WriterId, - Textpage.TemplateId, - true, - new Dictionary - { - { - "title", new[] - { - new PropertyData - { - Value = "Test title", - Culture = string.Empty, - Segment = string.Empty, - }, - } - }, - }, - null), - }; - var result = PublishedContentFactory.ToIPublishedContent(contentCacheNode, false); - Assert.IsNotNull(result); - Assert.AreEqual(Textpage.Id, result.Id); - Assert.AreEqual(Textpage.Name, result.Name); - Assert.AreEqual("Test title", result.Properties.Single(x => x.Alias == "title").Value(PublishedValueFallback)); - - // Verify that requesting the same content again returns the same instance (from request cache). - var result2 = PublishedContentFactory.ToIPublishedContent(contentCacheNode, false); - Assert.AreSame(result, result2); - } - - [Test] - public async Task Can_Create_Published_Content_For_Media() - { - var mediaType = new MediaTypeBuilder().Build(); - mediaType.AllowedAsRoot = true; - await MediaTypeService.CreateAsync(mediaType, Constants.Security.SuperUserKey); - - var media = new MediaBuilder() - .WithMediaType(mediaType) - .WithName("Media 1") - .Build(); - MediaService.Save(media); - - var contentCacheNode = new ContentCacheNode - { - Id = media.Id, - Key = media.Key, - ContentTypeId = media.ContentType.Id, - Data = new ContentData( - media.Name, - null, - 0, - media.UpdateDate, - media.WriterId, - null, - false, - new Dictionary(), - null), - }; - var result = PublishedContentFactory.ToIPublishedMedia(contentCacheNode); - Assert.IsNotNull(result); - Assert.AreEqual(media.Id, result.Id); - Assert.AreEqual(media.Name, result.Name); - - // Verify that requesting the same content again returns the same instance (from request cache). - var result2 = PublishedContentFactory.ToIPublishedMedia(contentCacheNode); - Assert.AreSame(result, result2); - } - - [Test] - public async Task Can_Create_Published_Member_For_Member() - { - var memberType = new MemberTypeBuilder().Build(); - await MemberTypeService.CreateAsync(memberType, Constants.Security.SuperUserKey); - - var member = new MemberBuilder() - .WithMemberType(memberType) - .WithName("Member 1") - .Build(); - MemberService.Save(member); - - var result = PublishedContentFactory.ToPublishedMember(member); - Assert.IsNotNull(result); - Assert.AreEqual(member.Id, result.Id); - Assert.AreEqual(member.Name, result.Name); - - // Verify that requesting the same content again returns the same instance (from request cache). - var result2 = PublishedContentFactory.ToPublishedMember(member); - Assert.AreSame(result, result2); - } -} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaPropertyCacheLevelTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaPropertyCacheLevelTests.cs new file mode 100644 index 0000000000..dea2f00881 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MediaPropertyCacheLevelTests.cs @@ -0,0 +1,111 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +public class MediaPropertyCacheLevelTests : PropertyCacheLevelTestsBase +{ + private static readonly Guid _mediaKey = new("B4507763-591F-4E32-AD14-7EA67C6AE0D3"); + + private IMediaCacheService MediaCacheService => GetRequiredService(); + + private IMediaEditingService MediaEditingService => GetRequiredService(); + + private IMediaTypeEditingService MediaTypeEditingService => GetRequiredService(); + + [SetUp] + public async Task SetUpTest() + { + PropertyValueLevelDetectionTestsConverter.Reset(); + + var mediaTypeCreateModel = MediaTypeEditingBuilder.CreateMediaTypeWithOneProperty(propertyAlias: "title"); + var mediaTypeAttempt = await MediaTypeEditingService.CreateAsync(mediaTypeCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(mediaTypeAttempt.Success); + + var mediaCreateModel = MediaEditingBuilder.CreateMediaWithAProperty(mediaTypeAttempt.Result.Key, "My Media", null, propertyAlias: "title", propertyValue: "The title"); + mediaCreateModel.Key = _mediaKey; + var mediaAttempt = await MediaEditingService.CreateAsync(mediaCreateModel, Constants.Security.SuperUserKey); + Assert.IsTrue(mediaAttempt.Success); + } + + [TestCase(PropertyCacheLevel.None, 1, 10)] + [TestCase(PropertyCacheLevel.Element, 1, 1)] + [TestCase(PropertyCacheLevel.Elements, 1, 1)] + public async Task Property_Value_Conversion_Respects_Property_Cache_Level(PropertyCacheLevel cacheLevel, int expectedSourceConverts, int expectedInterConverts) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent1); + + var publishedContent2 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent2); + + Assert.AreSame(publishedContent1, publishedContent2); + + var titleValue1 = publishedContent1.Value("title"); + Assert.IsNotNull(titleValue1); + + var titleValue2 = publishedContent2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual(titleValue1, titleValue2); + + // fetch title values 10 times in total, 5 times from each published content instance + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + titleValue1 = publishedContent1.Value("title"); + + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + titleValue2 = publishedContent2.Value("title"); + + Assert.AreEqual(expectedSourceConverts, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(expectedInterConverts, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + [TestCase(PropertyCacheLevel.None)] + [TestCase(PropertyCacheLevel.Element)] + [TestCase(PropertyCacheLevel.Elements)] + public async Task Property_Value_Conversion_Is_Triggered_After_Cache_Refresh(PropertyCacheLevel cacheLevel) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var publishedContent1 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent1); + + var titleValue1 = publishedContent1.Value("title"); + Assert.AreEqual("The title", titleValue1); + + // save the media to trigger a cache refresh for the media + var mediaAttempt = await MediaEditingService.UpdateAsync( + _mediaKey, + new () + { + Properties = [new () { Alias = "title", Value = "New title" }], + Variants = [new() { Name = publishedContent1.Name }], + }, + Constants.Security.SuperUserKey); + Assert.IsTrue(mediaAttempt.Success); + + var publishedContent2 = await MediaCacheService.GetByKeyAsync(_mediaKey); + Assert.IsNotNull(publishedContent2); + + Assert.AreNotSame(publishedContent1, publishedContent2); + + var titleValue2 = publishedContent2.Value("title"); + Assert.AreEqual("New title", titleValue2); + + // expect conversions for each published content instance, due to the cache refresh + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(2, PropertyValueLevelDetectionTestsConverter.InterConverts); + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberPropertyCacheLevelTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberPropertyCacheLevelTests.cs new file mode 100644 index 0000000000..20ea900414 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/MemberPropertyCacheLevelTests.cs @@ -0,0 +1,96 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.ContentEditing; +using Umbraco.Cms.Core.Models.Membership; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.PublishedCache; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.ContentTypeEditing; +using Umbraco.Cms.Infrastructure.HybridCache.Services; +using Umbraco.Cms.Tests.Common.Builders; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +public class MemberPropertyCacheLevelTests : PropertyCacheLevelTestsBase +{ + private static readonly Guid _memberKey = new("1ADC9048-E437-460B-95DC-3B8E19239CBD"); + + private IMemberCacheService MemberCacheService => GetRequiredService(); + + private IMemberEditingService MemberEditingService => GetRequiredService(); + + private IMemberTypeService MemberTypeService => GetRequiredService(); + + [SetUp] + public void SetUpTest() + => PropertyValueLevelDetectionTestsConverter.Reset(); + + [TestCase(PropertyCacheLevel.None, 2, 10)] + [TestCase(PropertyCacheLevel.Element, 2, 2)] + [TestCase(PropertyCacheLevel.Elements, 2, 10)] + public async Task Property_Value_Conversion_Respects_Property_Cache_Level(PropertyCacheLevel cacheLevel, int expectedSourceConverts, int expectedInterConverts) + { + PropertyValueLevelDetectionTestsConverter.SetCacheLevel(cacheLevel); + + var member = await CreateMember(); + + var publishedMember1 = await MemberCacheService.Get(member); + Assert.IsNotNull(publishedMember1); + + var publishedMember2 = await MemberCacheService.Get(member); + Assert.IsNotNull(publishedMember2); + + Assert.AreNotSame(publishedMember1, publishedMember2); + + var titleValue1 = publishedMember1.Value("title"); + Assert.AreEqual("The title", titleValue1); + + var titleValue2 = publishedMember2.Value("title"); + Assert.IsNotNull(titleValue2); + + Assert.AreEqual("The title", titleValue2); + + // fetch title values 10 times in total, 5 times from each published member instance + titleValue1 = publishedMember1.Value("title"); + titleValue1 = publishedMember1.Value("title"); + titleValue1 = publishedMember1.Value("title"); + titleValue1 = publishedMember1.Value("title"); + + titleValue2 = publishedMember2.Value("title"); + titleValue2 = publishedMember2.Value("title"); + titleValue2 = publishedMember2.Value("title"); + titleValue2 = publishedMember2.Value("title"); + + Assert.AreEqual(expectedSourceConverts, PropertyValueLevelDetectionTestsConverter.SourceConverts); + Assert.AreEqual(expectedInterConverts, PropertyValueLevelDetectionTestsConverter.InterConverts); + } + + private IUser SuperUser() => GetRequiredService().GetAsync(Constants.Security.SuperUserKey).GetAwaiter().GetResult(); + + private async Task CreateMember() + { + IMemberType memberType = MemberTypeBuilder.CreateSimpleMemberType(); + var memberTypeCreateResult = await MemberTypeService.UpdateAsync(memberType, Constants.Security.SuperUserKey); + Assert.IsTrue(memberTypeCreateResult.Success); + + var createModel = new MemberCreateModel + { + Key = _memberKey, + Email = "test@test.com", + Username = "test", + Password = "SuperSecret123", + IsApproved = true, + ContentTypeKey = memberType.Key, + Roles = [], + Variants = [new() { Name = "T. Est" }], + Properties = [new() { Alias = "title", Value = "The title" }], + }; + + var memberCreateResult = await MemberEditingService.CreateAsync(createModel, SuperUser()); + Assert.IsTrue(memberCreateResult.Success); + Assert.IsNotNull(memberCreateResult.Result.Content); + + return memberCreateResult.Result.Content; + } +} diff --git a/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyCacheLevelTestsBase.cs b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyCacheLevelTestsBase.cs new file mode 100644 index 0000000000..92836f2aaf --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.PublishedCache.HybridCache/PropertyCacheLevelTestsBase.cs @@ -0,0 +1,69 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Composing; +using Umbraco.Cms.Core.Models.PublishedContent; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Testing; +using Umbraco.Cms.Tests.Integration.Testing; +using Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services; + +namespace Umbraco.Cms.Tests.Integration.Umbraco.PublishedCache.HybridCache; + +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest)] +public abstract class PropertyCacheLevelTestsBase : UmbracoIntegrationTest +{ + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + + builder.PropertyValueConverters().Append(); + } + + [HideFromTypeFinder] + public class PropertyValueLevelDetectionTestsConverter : PropertyValueConverterBase + { + private static PropertyCacheLevel _cacheLevel; + + public static void Reset() + => SourceConverts = InterConverts = 0; + + public static void SetCacheLevel(PropertyCacheLevel cacheLevel) + => _cacheLevel = cacheLevel; + + public static int SourceConverts { get; private set; } + + public static int InterConverts { get; private set; } + + public override bool IsConverter(IPublishedPropertyType propertyType) + => propertyType.EditorAlias is Constants.PropertyEditors.Aliases.TextBox or Constants.PropertyEditors.Aliases.TextArea; + + public override Type GetPropertyValueType(IPublishedPropertyType propertyType) + => typeof(string); + + public override PropertyCacheLevel GetPropertyCacheLevel(IPublishedPropertyType propertyType) + => _cacheLevel; + + public override object? ConvertSourceToIntermediate(IPublishedElement owner, IPublishedPropertyType propertyType, object source, bool preview) + { + SourceConverts++; + return base.ConvertSourceToIntermediate(owner, propertyType, source, preview); + } + + public override object? ConvertIntermediateToObject( + IPublishedElement owner, + IPublishedPropertyType propertyType, + PropertyCacheLevel referenceCacheLevel, + object inter, + bool preview) + { + InterConverts++; + return base.ConvertIntermediateToObject(owner, propertyType, referenceCacheLevel, inter, preview); + } + } +}