From d2a0cba933e01fd03121847bacb765d9da793550 Mon Sep 17 00:00:00 2001 From: Sven Geusens Date: Mon, 2 Jun 2025 14:52:38 +0200 Subject: [PATCH] V16 cherry pick of member partial cache invalidator see #19314 (#19459) * v16 cherry pick of member partial cache invalidator see #19314 # Resolved merge conflic in src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs * Take nullmember cacheitems into account --- .../IMemberPartialViewCacheInvalidator.cs | 16 ++++ .../Implement/MemberCacheRefresher.cs | 31 +++++- .../MemberPartialViewCacheInvalidator.cs | 38 ++++++++ .../UmbracoBuilderExtensions.cs | 5 + .../Extensions/HtmlHelperRenderExtensions.cs | 94 ++++++++++++------- .../UmbracoBuilderExtensions.cs | 3 + .../MemberPartialViewCacheInvalidatorTests.cs | 76 +++++++++++++++ 7 files changed, 227 insertions(+), 36 deletions(-) create mode 100644 src/Umbraco.Core/Cache/PartialViewCacheInvalidators/IMemberPartialViewCacheInvalidator.cs create mode 100644 src/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidator.cs create mode 100644 tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidatorTests.cs diff --git a/src/Umbraco.Core/Cache/PartialViewCacheInvalidators/IMemberPartialViewCacheInvalidator.cs b/src/Umbraco.Core/Cache/PartialViewCacheInvalidators/IMemberPartialViewCacheInvalidator.cs new file mode 100644 index 0000000000..289dad5288 --- /dev/null +++ b/src/Umbraco.Core/Cache/PartialViewCacheInvalidators/IMemberPartialViewCacheInvalidator.cs @@ -0,0 +1,16 @@ +namespace Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; + +/// +/// Defines behaviours for clearing of cached partials views that are configured to be cached individually by member. +/// +public interface IMemberPartialViewCacheInvalidator +{ + /// + /// Clears the partial view cache items for the specified member ids. + /// + /// The member Ids to clear the cache for. + /// + /// Called from the when a member is saved or deleted. + /// + void ClearPartialViewCacheItems(IEnumerable memberIds); +} diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs index 1c19f62576..3e4181feac 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs @@ -1,10 +1,12 @@ +using Microsoft.Extensions.DependencyInjection; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; +using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Events; using Umbraco.Cms.Core.Models; using Umbraco.Cms.Core.Notifications; using Umbraco.Cms.Core.Persistence.Repositories; using Umbraco.Cms.Core.Serialization; using Umbraco.Cms.Core.Services; -using Umbraco.Extensions; namespace Umbraco.Cms.Core.Cache; @@ -13,10 +15,32 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase + : this( + appCaches, + serializer, + idKeyMap, + eventAggregator, + factory, + StaticServiceProvider.Instance.GetRequiredService()) + { + } + + public MemberCacheRefresher( + AppCaches appCaches, + IJsonSerializer serializer, + IIdKeyMap idKeyMap, + IEventAggregator eventAggregator, + ICacheRefresherNotificationFactory factory, + IMemberPartialViewCacheInvalidator memberPartialViewCacheInvalidator) + : base(appCaches, serializer, eventAggregator, factory) + { _idKeyMap = idKeyMap; + _memberPartialViewCacheInvalidator = memberPartialViewCacheInvalidator; + } #region Indirect @@ -65,7 +89,8 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase p.Id)); + Attempt memberCache = AppCaches.IsolatedCaches.Get(); foreach (JsonPayload p in payloads) diff --git a/src/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidator.cs b/src/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidator.cs new file mode 100644 index 0000000000..8ee4be4c00 --- /dev/null +++ b/src/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidator.cs @@ -0,0 +1,38 @@ +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators; + +/// +/// Implementation of that only remove cached partial views +/// that were cached for the specified member(s). +/// +public class MemberPartialViewCacheInvalidator : IMemberPartialViewCacheInvalidator +{ + private readonly AppCaches _appCaches; + + /// + /// Initializes a new instance of the class. + /// + public MemberPartialViewCacheInvalidator(AppCaches appCaches) => _appCaches = appCaches; + + /// + /// + /// Partial view cache keys follow the following format: + /// [] is optional or only added if the information is available + /// {} is a parameter + /// "Umbraco.Web.PartialViewCacheKey{partialViewName}-[{currentThreadCultureName}-][m{memberId}-][c{contextualKey}-]" + /// See for more information. + /// + public void ClearPartialViewCacheItems(IEnumerable memberIds) + { + foreach (var memberId in memberIds) + { + _appCaches.RuntimeCache.ClearByRegex($"{CoreCacheHelperExtensions.PartialViewCacheKey}.*-m{memberId}-*"); + } + + // since it is possible to add a cache item linked to members without a member logged in, we should always clear these items. + _appCaches.RuntimeCache.ClearByRegex($"{CoreCacheHelperExtensions.PartialViewCacheKey}.*-m-*"); + } +} diff --git a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs index 400b288786..2fd979d8ca 100644 --- a/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DependencyInjection; using Umbraco.Cms.Core.Routing; @@ -13,6 +14,7 @@ using Umbraco.Cms.Core.Web; using Umbraco.Cms.Infrastructure.DependencyInjection; using Umbraco.Cms.Web.Common.Middleware; using Umbraco.Cms.Web.Common.Routing; +using Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators; using Umbraco.Cms.Web.Website.Collections; using Umbraco.Cms.Web.Website.Models; using Umbraco.Cms.Web.Website.Routing; @@ -73,6 +75,9 @@ public static partial class UmbracoBuilderExtensions builder.Services.AddSingleton(); builder.Services.AddSingleton(); + // Partial view cache invalidators + builder.Services.AddUnique(); + builder .AddDistributedCache() .AddModelsBuilder(); diff --git a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs index 6892ffd145..efb48d59da 100644 --- a/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs +++ b/src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs @@ -105,42 +105,18 @@ public static class HtmlHelperRenderExtensions ViewDataDictionary? viewData = null, Func? contextualKeyBuilder = null) { - var cacheKey = new StringBuilder(partialViewName); - - // let's always cache by the current culture to allow variants to have different cache results - var cultureName = Thread.CurrentThread.CurrentUICulture.Name; - if (!string.IsNullOrEmpty(cultureName)) - { - cacheKey.AppendFormat("{0}-", cultureName); - } - IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService(htmlHelper); umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext); - if (cacheByPage) - { - if (umbracoContext == null) - { - throw new InvalidOperationException( - "Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request"); - } - - cacheKey.AppendFormat("{0}-", umbracoContext.PublishedRequest?.PublishedContent?.Id ?? 0); - } - - if (cacheByMember) - { - IMemberManager memberManager = - htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService(); - MemberIdentityUser? currentMember = await memberManager.GetCurrentMemberAsync(); - cacheKey.AppendFormat("m{0}-", currentMember?.Id ?? "0"); - } - - if (contextualKeyBuilder != null) - { - var contextualKey = contextualKeyBuilder(model, viewData); - cacheKey.AppendFormat("c{0}-", contextualKey); - } + string cacheKey = await GenerateCacheKeyForCachedPartialViewAsync( + partialViewName, + cacheByPage, + umbracoContext, + cacheByMember, + cacheByMember ? GetRequiredService(htmlHelper) : null, + model, + viewData, + contextualKeyBuilder); AppCaches appCaches = GetRequiredService(htmlHelper); IHostingEnvironment hostingEnvironment = GetRequiredService(htmlHelper); @@ -156,6 +132,58 @@ public static class HtmlHelperRenderExtensions viewData); } + // Internal for tests. + internal static async Task GenerateCacheKeyForCachedPartialViewAsync( + string partialViewName, + bool cacheByPage, + IUmbracoContext? umbracoContext, + bool cacheByMember, + IMemberManager? memberManager, + object model, + ViewDataDictionary? viewData, + Func? contextualKeyBuilder) + { + var cacheKey = new StringBuilder(partialViewName + "-"); + + // let's always cache by the current culture to allow variants to have different cache results + var cultureName = Thread.CurrentThread.CurrentUICulture.Name; + if (!string.IsNullOrEmpty(cultureName)) + { + cacheKey.AppendFormat("{0}-", cultureName); + } + + if (cacheByPage) + { + if (umbracoContext == null) + { + throw new InvalidOperationException( + "Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request."); + } + + cacheKey.AppendFormat("{0}-", umbracoContext.PublishedRequest?.PublishedContent?.Id ?? 0); + } + + if (cacheByMember) + { + if (memberManager == null) + { + throw new InvalidOperationException( + "Cannot cache by member if the MemberManager is not available."); + } + + MemberIdentityUser? currentMember = await memberManager.GetCurrentMemberAsync(); + cacheKey.AppendFormat("m{0}-", currentMember?.Id ?? "0"); + } + + if (contextualKeyBuilder != null) + { + var contextualKey = contextualKeyBuilder(model, viewData); + cacheKey.AppendFormat("c{0}-", contextualKey); + } + + return cacheKey.ToString(); + } + // public static IHtmlContent EditorFor(this IHtmlHelper htmlHelper, string templateName = "", string htmlFieldName = "", object additionalViewData = null) // where T : new() // { diff --git a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs index c03512b483..f2fdfe2128 100644 --- a/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs +++ b/tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs @@ -12,6 +12,7 @@ using Moq; using NUnit.Framework; using Umbraco.Cms.Core; using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators; using Umbraco.Cms.Core.Composing; using Umbraco.Cms.Core.Configuration.Models; using Umbraco.Cms.Core.DistributedLocking; @@ -43,6 +44,8 @@ public static class UmbracoBuilderExtensions public static IUmbracoBuilder AddTestServices(this IUmbracoBuilder builder, TestHelper testHelper) { builder.Services.AddUnique(AppCaches.NoCache); + builder.Services.AddUnique(Mock.Of()); + builder.Services.AddUnique(Mock.Of()); builder.Services.AddUnique(testHelper.MainDom); diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidatorTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidatorTests.cs new file mode 100644 index 0000000000..3e67e27d4f --- /dev/null +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Web.Website/Cache/PartialViewCacheInvalidators/MemberPartialViewCacheInvalidatorTests.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Moq; +using NUnit.Framework; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Web; +using Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Routing; + +[TestFixture] +public class MemberPartialViewCacheInvalidatorTests +{ + [Test] + public void ClearPartialViewCacheItems_Clears_ExpectedItems() + { + var runTimeCacheMock = new Mock(); + runTimeCacheMock + .Setup(x => x.ClearByRegex(It.IsAny())) + .Verifiable(); + var appCaches = new AppCaches( + runTimeCacheMock.Object, + NoAppCache.Instance, + new IsolatedCaches(type => new ObjectCacheAppCache())); + var memberPartialViewCacheInvalidator = new MemberPartialViewCacheInvalidator(appCaches); + + var memberIds = new[] { 1, 2, 3 }; + + memberPartialViewCacheInvalidator.ClearPartialViewCacheItems(memberIds); + + foreach (var memberId in memberIds) + { + var regex = $"Umbraco.Web.PartialViewCacheKey.*-m{memberId}-*"; + runTimeCacheMock + .Verify(x => x.ClearByRegex(It.Is(x => x == regex)), Times.Once); + } + } + + [Test] + public async Task ClearPartialViewCacheItems_Regex_Matches_CachedKeys() + { + const int MemberId = 1234; + + var memberManagerMock = new Mock(); + memberManagerMock + .Setup(x => x.GetCurrentMemberAsync()) + .ReturnsAsync(new MemberIdentityUser { Id = MemberId.ToString() }); + + var cacheKey = await HtmlHelperRenderExtensions.GenerateCacheKeyForCachedPartialViewAsync( + "TestPartial.cshtml", + true, + Mock.Of(), + true, + memberManagerMock.Object, + new TestViewModel(), + new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()), + null); + cacheKey = CoreCacheHelperExtensions.PartialViewCacheKey + cacheKey; + Assert.AreEqual("Umbraco.Web.PartialViewCacheKeyTestPartial.cshtml-en-US-0-m1234-", cacheKey); + + var regexForMember = $"Umbraco.Web.PartialViewCacheKey.*-m{MemberId}-*"; + var regexMatch = Regex.IsMatch(cacheKey, regexForMember); + Assert.IsTrue(regexMatch); + + var regexForAnotherMember = $"Umbraco.Web.PartialViewCacheKey.*-m{4321}-*"; + regexMatch = Regex.IsMatch(cacheKey, regexForAnotherMember); + Assert.IsFalse(regexMatch); + } + + private class TestViewModel + { + } +}