Skip to content

Commit 4f1604f

Browse files
V13/bugfix/partial cache (#19314)
* Make sure that each optional section of the cachekey always starts and ends with a - * Move secondary logic of clearing the membercaches into its own replacable class * Regsiter the new implementation * Add a mock to the integration tests as appCaches are disabled * Added header comments to components. * Refactored cache key into a method and exposed for testing. Added unit tests to verify behaviour. * Verified also that regex matches only the supplied member and asserted on the key itself. --------- Co-authored-by: Andy Butland <[email protected]>
1 parent 0597662 commit 4f1604f

File tree

7 files changed

+217
-22
lines changed

7 files changed

+217
-22
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
namespace Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
2+
3+
/// <summary>
4+
/// Defines behaviours for clearing of cached partials views that are configured to be cached individually by member.
5+
/// </summary>
6+
public interface IMemberPartialViewCacheInvalidator
7+
{
8+
/// <summary>
9+
/// Clears the partial view cache items for the specified member ids.
10+
/// </summary>
11+
/// <param name="memberIds">The member Ids to clear the cache for.</param>
12+
/// <remarks>
13+
/// Called from the <see cref="MemberCacheRefresher"/> when a member is saved or deleted.
14+
/// </remarks>
15+
void ClearPartialViewCacheItems(IEnumerable<int> memberIds);
16+
}

src/Umbraco.Core/Cache/Refreshers/Implement/MemberCacheRefresher.cs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
// using Newtonsoft.Json;
22

3+
using Microsoft.Extensions.DependencyInjection;
4+
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
5+
using Umbraco.Cms.Core.DependencyInjection;
36
using Umbraco.Cms.Core.Events;
47
using Umbraco.Cms.Core.Models;
58
using Umbraco.Cms.Core.Notifications;
@@ -15,10 +18,37 @@ public sealed class MemberCacheRefresher : PayloadCacheRefresherBase<MemberCache
1518
public static readonly Guid UniqueId = Guid.Parse("E285DF34-ACDC-4226-AE32-C0CB5CF388DA");
1619

1720
private readonly IIdKeyMap _idKeyMap;
21+
private readonly IMemberPartialViewCacheInvalidator _memberPartialViewCacheInvalidator;
22+
23+
[Obsolete("Use the non obsoleted constructor instead. Scheduled for removal in v17")]
24+
public MemberCacheRefresher(
25+
AppCaches appCaches,
26+
IJsonSerializer serializer,
27+
IIdKeyMap idKeyMap,
28+
IEventAggregator eventAggregator,
29+
ICacheRefresherNotificationFactory factory)
30+
: this(
31+
appCaches,
32+
serializer,
33+
idKeyMap,
34+
eventAggregator,
35+
factory,
36+
StaticServiceProvider.Instance.GetRequiredService<IMemberPartialViewCacheInvalidator>())
37+
{
38+
}
1839

19-
public MemberCacheRefresher(AppCaches appCaches, IJsonSerializer serializer, IIdKeyMap idKeyMap, IEventAggregator eventAggregator, ICacheRefresherNotificationFactory factory)
20-
: base(appCaches, serializer, eventAggregator, factory) =>
40+
public MemberCacheRefresher(
41+
AppCaches appCaches,
42+
IJsonSerializer serializer,
43+
IIdKeyMap idKeyMap,
44+
IEventAggregator eventAggregator,
45+
ICacheRefresherNotificationFactory factory,
46+
IMemberPartialViewCacheInvalidator memberPartialViewCacheInvalidator)
47+
: base(appCaches, serializer, eventAggregator, factory)
48+
{
2149
_idKeyMap = idKeyMap;
50+
_memberPartialViewCacheInvalidator = memberPartialViewCacheInvalidator;
51+
}
2252

2353
#region Indirect
2454

@@ -67,7 +97,9 @@ public override void Remove(int id)
6797

6898
private void ClearCache(params JsonPayload[] payloads)
6999
{
70-
AppCaches.ClearPartialViewCache();
100+
// Clear the partial views cache for all partials that are cached by member, for the updates members.
101+
_memberPartialViewCacheInvalidator.ClearPartialViewCacheItems(payloads.Select(p => p.Id));
102+
71103
Attempt<IAppPolicyCache?> memberCache = AppCaches.IsolatedCaches.Get<IMember>();
72104

73105
foreach (JsonPayload p in payloads)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Umbraco.Cms.Core.Cache;
2+
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
3+
using Umbraco.Extensions;
4+
5+
namespace Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators;
6+
7+
/// <summary>
8+
/// Implementation of <see cref="IMemberPartialViewCacheInvalidator"/> that only remove cached partial views
9+
/// that were cached for the specified member(s).
10+
/// </summary>
11+
public class MemberPartialViewCacheInvalidator : IMemberPartialViewCacheInvalidator
12+
{
13+
private readonly AppCaches _appCaches;
14+
15+
/// <summary>
16+
/// Initializes a new instance of the <see cref="MemberPartialViewCacheInvalidator"/> class.
17+
/// </summary>
18+
public MemberPartialViewCacheInvalidator(AppCaches appCaches) => _appCaches = appCaches;
19+
20+
/// <inheritdoc/>
21+
/// <remarks>
22+
/// Partial view cache keys follow the following format:
23+
/// [] is optional or only added if the information is available
24+
/// {} is a parameter
25+
/// "Umbraco.Web.PartialViewCacheKey{partialViewName}-[{currentThreadCultureName}-][m{memberId}-][c{contextualKey}-]"
26+
/// See <see cref="HtmlHelperRenderExtensions.CachedPartialAsync"/> for more information.
27+
/// </remarks>
28+
public void ClearPartialViewCacheItems(IEnumerable<int> memberIds)
29+
{
30+
foreach (var memberId in memberIds)
31+
{
32+
_appCaches.RuntimeCache.ClearByRegex($"{CoreCacheHelperExtensions.PartialViewCacheKey}.*-m{memberId}-*");
33+
}
34+
}
35+
}

src/Umbraco.Web.Website/DependencyInjection/UmbracoBuilderExtensions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.Extensions.DependencyInjection.Extensions;
66
using Microsoft.Extensions.Logging;
77
using Microsoft.Extensions.Options;
8+
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
89
using Umbraco.Cms.Core.Configuration.Models;
910
using Umbraco.Cms.Core.DependencyInjection;
1011
using Umbraco.Cms.Core.Routing;
@@ -13,6 +14,7 @@
1314
using Umbraco.Cms.Infrastructure.DependencyInjection;
1415
using Umbraco.Cms.Web.Common.Middleware;
1516
using Umbraco.Cms.Web.Common.Routing;
17+
using Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators;
1618
using Umbraco.Cms.Web.Website.Collections;
1719
using Umbraco.Cms.Web.Website.Models;
1820
using Umbraco.Cms.Web.Website.Routing;
@@ -74,6 +76,9 @@ public static IUmbracoBuilder AddWebsite(this IUmbracoBuilder builder)
7476
builder.Services.AddSingleton<IPublicAccessRequestHandler, PublicAccessRequestHandler>();
7577
builder.Services.AddSingleton<BasicAuthenticationMiddleware>();
7678

79+
// Partial view cache invalidators
80+
builder.Services.AddUnique<IMemberPartialViewCacheInvalidator, MemberPartialViewCacheInvalidator>();
81+
7782
builder
7883
.AddDistributedCache()
7984
.AddModelsBuilder();

src/Umbraco.Web.Website/Extensions/HtmlHelperRenderExtensions.cs

Lines changed: 47 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,45 @@ public static IHtmlContent PreviewBadge(
104104
ViewDataDictionary? viewData = null,
105105
Func<object, ViewDataDictionary?, string>? contextualKeyBuilder = null)
106106
{
107-
var cacheKey = new StringBuilder(partialViewName);
107+
IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService<IUmbracoContextAccessor>(htmlHelper);
108+
umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext);
109+
110+
string cacheKey = await GenerateCacheKeyForCachedPartialViewAsync(
111+
partialViewName,
112+
cacheByPage,
113+
umbracoContext,
114+
cacheByMember,
115+
cacheByMember ? GetRequiredService<IMemberManager>(htmlHelper) : null,
116+
model,
117+
viewData,
118+
contextualKeyBuilder);
119+
120+
AppCaches appCaches = GetRequiredService<AppCaches>(htmlHelper);
121+
IHostingEnvironment hostingEnvironment = GetRequiredService<IHostingEnvironment>(htmlHelper);
122+
123+
return appCaches.CachedPartialView(
124+
hostingEnvironment,
125+
umbracoContext!,
126+
htmlHelper,
127+
partialViewName,
128+
model,
129+
cacheTimeout,
130+
cacheKey.ToString(),
131+
viewData);
132+
}
133+
134+
// Internal for tests.
135+
internal static async Task<string> GenerateCacheKeyForCachedPartialViewAsync(
136+
string partialViewName,
137+
bool cacheByPage,
138+
IUmbracoContext? umbracoContext,
139+
bool cacheByMember,
140+
IMemberManager? memberManager,
141+
object model,
142+
ViewDataDictionary? viewData,
143+
Func<object, ViewDataDictionary?, string>? contextualKeyBuilder)
144+
{
145+
var cacheKey = new StringBuilder(partialViewName + "-");
108146

109147
// let's always cache by the current culture to allow variants to have different cache results
110148
var cultureName = Thread.CurrentThread.CurrentUICulture.Name;
@@ -113,24 +151,25 @@ public static IHtmlContent PreviewBadge(
113151
cacheKey.AppendFormat("{0}-", cultureName);
114152
}
115153

116-
IUmbracoContextAccessor umbracoContextAccessor = GetRequiredService<IUmbracoContextAccessor>(htmlHelper);
117-
umbracoContextAccessor.TryGetUmbracoContext(out IUmbracoContext? umbracoContext);
118-
119154
if (cacheByPage)
120155
{
121156
if (umbracoContext == null)
122157
{
123158
throw new InvalidOperationException(
124-
"Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request");
159+
"Cannot cache by page if the UmbracoContext has not been initialized, this parameter can only be used in the context of an Umbraco request.");
125160
}
126161

127162
cacheKey.AppendFormat("{0}-", umbracoContext.PublishedRequest?.PublishedContent?.Id ?? 0);
128163
}
129164

130165
if (cacheByMember)
131166
{
132-
IMemberManager memberManager =
133-
htmlHelper.ViewContext.HttpContext.RequestServices.GetRequiredService<IMemberManager>();
167+
if (memberManager == null)
168+
{
169+
throw new InvalidOperationException(
170+
"Cannot cache by member if the MemberManager is not available.");
171+
}
172+
134173
MemberIdentityUser? currentMember = await memberManager.GetCurrentMemberAsync();
135174
cacheKey.AppendFormat("m{0}-", currentMember?.Id ?? "0");
136175
}
@@ -141,18 +180,7 @@ public static IHtmlContent PreviewBadge(
141180
cacheKey.AppendFormat("c{0}-", contextualKey);
142181
}
143182

144-
AppCaches appCaches = GetRequiredService<AppCaches>(htmlHelper);
145-
IHostingEnvironment hostingEnvironment = GetRequiredService<IHostingEnvironment>(htmlHelper);
146-
147-
return appCaches.CachedPartialView(
148-
hostingEnvironment,
149-
umbracoContext!,
150-
htmlHelper,
151-
partialViewName,
152-
model,
153-
cacheTimeout,
154-
cacheKey.ToString(),
155-
viewData);
183+
return cacheKey.ToString();
156184
}
157185

158186
// public static IHtmlContent EditorFor<T>(this IHtmlHelper htmlHelper, string templateName = "", string htmlFieldName = "", object additionalViewData = null)

tests/Umbraco.Tests.Integration/DependencyInjection/UmbracoBuilderExtensions.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Moq;
1212
using NUnit.Framework;
1313
using Umbraco.Cms.Core.Cache;
14+
using Umbraco.Cms.Core.Cache.PartialViewCacheInvalidators;
1415
using Umbraco.Cms.Core.Composing;
1516
using Umbraco.Cms.Core.Configuration.Models;
1617
using Umbraco.Cms.Core.DistributedLocking;
@@ -43,6 +44,8 @@ public static class UmbracoBuilderExtensions
4344
public static IUmbracoBuilder AddTestServices(this IUmbracoBuilder builder, TestHelper testHelper)
4445
{
4546
builder.Services.AddUnique(AppCaches.NoCache);
47+
builder.Services.AddUnique(Mock.Of<IMemberPartialViewCacheInvalidator>());
48+
4649
builder.Services.AddUnique(Mock.Of<IUmbracoBootPermissionChecker>());
4750
builder.Services.AddUnique(testHelper.MainDom);
4851

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
using System.Text.RegularExpressions;
2+
using Microsoft.AspNetCore.Mvc.ModelBinding;
3+
using Microsoft.AspNetCore.Mvc.ViewFeatures;
4+
using Moq;
5+
using NUnit.Framework;
6+
using Umbraco.Cms.Core.Cache;
7+
using Umbraco.Cms.Core.Security;
8+
using Umbraco.Cms.Core.Web;
9+
using Umbraco.Cms.Web.Website.Cache.PartialViewCacheInvalidators;
10+
using Umbraco.Extensions;
11+
12+
namespace Umbraco.Cms.Tests.UnitTests.Umbraco.Web.Website.Routing;
13+
14+
[TestFixture]
15+
public class MemberPartialViewCacheInvalidatorTests
16+
{
17+
[Test]
18+
public void ClearPartialViewCacheItems_Clears_ExpectedItems()
19+
{
20+
var runTimeCacheMock = new Mock<IAppPolicyCache>();
21+
runTimeCacheMock
22+
.Setup(x => x.ClearByRegex(It.IsAny<string>()))
23+
.Verifiable();
24+
var appCaches = new AppCaches(
25+
runTimeCacheMock.Object,
26+
NoAppCache.Instance,
27+
new IsolatedCaches(type => new ObjectCacheAppCache()));
28+
var memberPartialViewCacheInvalidator = new MemberPartialViewCacheInvalidator(appCaches);
29+
30+
var memberIds = new[] { 1, 2, 3 };
31+
32+
memberPartialViewCacheInvalidator.ClearPartialViewCacheItems(memberIds);
33+
34+
foreach (var memberId in memberIds)
35+
{
36+
var regex = $"Umbraco.Web.PartialViewCacheKey.*-m{memberId}-*";
37+
runTimeCacheMock
38+
.Verify(x => x.ClearByRegex(It.Is<string>(x => x == regex)), Times.Once);
39+
}
40+
}
41+
42+
[Test]
43+
public async Task ClearPartialViewCacheItems_Regex_Matches_CachedKeys()
44+
{
45+
const int MemberId = 1234;
46+
47+
var memberManagerMock = new Mock<IMemberManager>();
48+
memberManagerMock
49+
.Setup(x => x.GetCurrentMemberAsync())
50+
.ReturnsAsync(new MemberIdentityUser { Id = MemberId.ToString() });
51+
52+
var cacheKey = await HtmlHelperRenderExtensions.GenerateCacheKeyForCachedPartialViewAsync(
53+
"TestPartial.cshtml",
54+
true,
55+
Mock.Of<IUmbracoContext>(),
56+
true,
57+
memberManagerMock.Object,
58+
new TestViewModel(),
59+
new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()),
60+
null);
61+
cacheKey = CoreCacheHelperExtensions.PartialViewCacheKey + cacheKey;
62+
Assert.AreEqual("Umbraco.Web.PartialViewCacheKeyTestPartial.cshtml-en-US-0-m1234-", cacheKey);
63+
64+
var regexForMember = $"Umbraco.Web.PartialViewCacheKey.*-m{MemberId}-*";
65+
var regexMatch = Regex.IsMatch(cacheKey, regexForMember);
66+
Assert.IsTrue(regexMatch);
67+
68+
var regexForAnotherMember = $"Umbraco.Web.PartialViewCacheKey.*-m{4321}-*";
69+
regexMatch = Regex.IsMatch(cacheKey, regexForAnotherMember);
70+
Assert.IsFalse(regexMatch);
71+
}
72+
73+
private class TestViewModel
74+
{
75+
}
76+
}

0 commit comments

Comments
 (0)