Performance: Cache published content instances at cache service level (#20681)

Cache published content instances at cache service level
This commit is contained in:
Kenn Jacobsen
2025-11-03 10:55:23 +01:00
committed by GitHub
parent 95cc6cc67b
commit 4ee1d7b13e
9 changed files with 508 additions and 257 deletions

View File

@@ -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]

View File

@@ -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<IDocumentCacheService>();
private IContentEditingService ContentEditingService => GetRequiredService<IContentEditingService>();
private IContentPublishingService ContentPublishingService => GetRequiredService<IContentPublishingService>();
private IContentTypeEditingService ContentTypeEditingService => GetRequiredService<IContentTypeEditingService>();
[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<string>("title");
Assert.IsNotNull(titleValue1);
var titleValue2 = publishedContent2.Value<string>("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<string>("title");
titleValue1 = publishedContent1.Value<string>("title");
titleValue1 = publishedContent1.Value<string>("title");
titleValue1 = publishedContent1.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("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<string>("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<string>("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);
}
}

View File

@@ -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<IPublishedContentFactory>();
private IPublishedValueFallback PublishedValueFallback => GetRequiredService<IPublishedValueFallback>();
private IMediaService MediaService => GetRequiredService<IMediaService>();
private IMediaTypeService MediaTypeService => GetRequiredService<IMediaTypeService>();
private IMemberService MemberService => GetRequiredService<IMemberService>();
private IMemberTypeService MemberTypeService => GetRequiredService<IMemberTypeService>();
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<string, PropertyData[]>
{
{
"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<string>(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<string, PropertyData[]>(),
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);
}
}

View File

@@ -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<IMediaCacheService>();
private IMediaEditingService MediaEditingService => GetRequiredService<IMediaEditingService>();
private IMediaTypeEditingService MediaTypeEditingService => GetRequiredService<IMediaTypeEditingService>();
[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<string>("title");
Assert.IsNotNull(titleValue1);
var titleValue2 = publishedContent2.Value<string>("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<string>("title");
titleValue1 = publishedContent1.Value<string>("title");
titleValue1 = publishedContent1.Value<string>("title");
titleValue1 = publishedContent1.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("title");
titleValue2 = publishedContent2.Value<string>("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<string>("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<string>("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);
}
}

View File

@@ -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<IMemberCacheService>();
private IMemberEditingService MemberEditingService => GetRequiredService<IMemberEditingService>();
private IMemberTypeService MemberTypeService => GetRequiredService<IMemberTypeService>();
[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<string>("title");
Assert.AreEqual("The title", titleValue1);
var titleValue2 = publishedMember2.Value<string>("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<string>("title");
titleValue1 = publishedMember1.Value<string>("title");
titleValue1 = publishedMember1.Value<string>("title");
titleValue1 = publishedMember1.Value<string>("title");
titleValue2 = publishedMember2.Value<string>("title");
titleValue2 = publishedMember2.Value<string>("title");
titleValue2 = publishedMember2.Value<string>("title");
titleValue2 = publishedMember2.Value<string>("title");
Assert.AreEqual(expectedSourceConverts, PropertyValueLevelDetectionTestsConverter.SourceConverts);
Assert.AreEqual(expectedInterConverts, PropertyValueLevelDetectionTestsConverter.InterConverts);
}
private IUser SuperUser() => GetRequiredService<IUserService>().GetAsync(Constants.Security.SuperUserKey).GetAwaiter().GetResult();
private async Task<IMember> 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;
}
}

View File

@@ -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<ContentTreeChangeNotification, ContentTreeChangeDistributedCacheNotificationHandler>();
builder.AddNotificationHandler<MediaTreeChangeNotification, MediaTreeChangeDistributedCacheNotificationHandler>();
builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>();
builder.PropertyValueConverters().Append<PropertyValueLevelDetectionTestsConverter>();
}
[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);
}
}
}