From 7f5c6953cfed389749ac5f0353be3c615dfdd245 Mon Sep 17 00:00:00 2001 From: Laura Neto <12862535+lauraneto@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:35:26 +0200 Subject: [PATCH 1/2] Add PublishedCultures and UnpublishedCultures to ElementCacheRefresher.JsonPayload Adds culture-specific publishing details to the element cache refresher payload, matching the existing ContentCacheRefresher.JsonPayload structure. Also replicates the performance optimization from #21415 by only clearing partial view cache when there are actual publish/unpublish culture changes, and fixes the Remove change type check to use HasType instead of equality (flags enum). --- .../Cache/DistributedCacheExtensions.cs | 16 +++- .../Implement/ElementCacheRefresher.cs | 78 +++++++++++++++++-- .../Umbraco.Core/Cache/RefresherTests.cs | 34 ++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs index 8d2b5046a8c5..ec277ddd1fa3 100644 --- a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs +++ b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs @@ -374,13 +374,25 @@ public static void RefreshMediaCache(this DistributedCache dc, IEnumerable + /// Refreshes all elements in the distributed cache. + /// + /// The distributed cache. public static void RefreshAllElementCache(this DistributedCache dc) // note: refresh all element cache does refresh content types too => dc.RefreshByPayload(ElementCacheRefresher.UniqueId, new ElementCacheRefresher.JsonPayload(0, Guid.Empty, TreeChangeTypes.RefreshAll).Yield()); - + /// + /// Refreshes the element cache for the specified element changes. + /// + /// The distributed cache. + /// The element changes to refresh. public static void RefreshElementCache(this DistributedCache dc, IEnumerable> changes) - => dc.RefreshByPayload(ElementCacheRefresher.UniqueId, changes.DistinctBy(x => (x.Item.Id, x.Item.Key, x.ChangeTypes)).Select(x => new ElementCacheRefresher.JsonPayload(x.Item.Id, x.Item.Key, x.ChangeTypes))); + => dc.RefreshByPayload(ElementCacheRefresher.UniqueId, changes.DistinctBy(x => (x.Item.Id, x.Item.Key, x.ChangeTypes)).Select(x => new ElementCacheRefresher.JsonPayload(x.Item.Id, x.Item.Key, x.ChangeTypes) + { + PublishedCultures = x.PublishedCultures?.ToArray(), + UnpublishedCultures = x.UnpublishedCultures?.ToArray(), + })); #endregion diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs index 169073f650e0..f389428dbf55 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs @@ -10,12 +10,25 @@ namespace Umbraco.Cms.Core.Cache; +/// +/// Provides cache refresh functionality for element items, ensuring that element-related caches are updated or +/// invalidated in response to element changes. +/// +/// +/// The ElementCacheRefresher coordinates cache invalidation for elements, including memory caches and +/// isolated caches. It responds to element change notifications and ensures that all relevant caches reflect +/// the current state of published and unpublished elements. This refresher is used internally to maintain +/// cache consistency after element operations such as publish, unpublish, or delete. +/// public sealed class ElementCacheRefresher : PayloadCacheRefresherBase { private readonly IIdKeyMap _idKeyMap; private readonly IElementCacheService _elementCacheService; private readonly ICacheManager _cacheManager; + /// + /// Initializes a new instance of the class. + /// public ElementCacheRefresher( AppCaches appCaches, IJsonSerializer serializer, @@ -35,6 +48,10 @@ public ElementCacheRefresher( #region Json + /// + /// Represents a JSON-serializable payload containing information about an element change event, including + /// identifiers, change types, and culture-specific publishing details. + /// public class JsonPayload { public JsonPayload(int id, Guid key, TreeChangeTypes changeTypes) @@ -44,23 +61,45 @@ public JsonPayload(int id, Guid key, TreeChangeTypes changeTypes) ChangeTypes = changeTypes; } + /// + /// Gets the unique integer identifier for the entity. + /// public int Id { get; } + /// + /// Gets the unique GUID key associated with the entity. + /// public Guid Key { get; } + /// + /// Gets the types of changes that have occurred in the tree. + /// public TreeChangeTypes ChangeTypes { get; } - // TODO ELEMENTS: should we support (un)published cultures in this payload? see ContentCacheRefresher.JsonPayload + /// + /// Gets the collection of culture codes in which the element is published. + /// + public string[]? PublishedCultures { get; init; } + + /// + /// Gets the collection of culture codes for which the element has been unpublished. + /// + public string[]? UnpublishedCultures { get; init; } } #endregion #region Define + /// + /// Represents a unique identifier for the cache refresher. + /// public static readonly Guid UniqueId = Guid.Parse("EE5BB23A-A656-4F7E-A234-16F21AAABFD1"); + /// public override Guid RefresherUniqueId => UniqueId; + /// public override string Name => "Element Cache Refresher"; #endregion @@ -93,17 +132,42 @@ public override void Refresh(JsonPayload[] payloads) // TODO ELEMENTS: if we need published status caching for elements (e.g. for seeding purposes), make sure // it is kept in sync here (see ContentCacheRefresher) - if (payload.ChangeTypes == TreeChangeTypes.Remove) + if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) { _idKeyMap.ClearCache(payload.Id); } } - AppCaches.ClearPartialViewCache(); + if (ShouldClearPartialViewCache(payloads)) + { + AppCaches.ClearPartialViewCache(); + } base.Refresh(payloads); } + private static bool ShouldClearPartialViewCache(JsonPayload[] payloads) + { + return payloads.Any(x => + { + // Check for relevant change type + var isRelevantChangeType = x.ChangeTypes.HasType(TreeChangeTypes.RefreshAll) || + x.ChangeTypes.HasType(TreeChangeTypes.Remove) || + x.ChangeTypes.HasType(TreeChangeTypes.RefreshNode) || + x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch); + + // Check for published/unpublished changes + var hasChanges = x.PublishedCultures?.Length > 0 || + x.UnpublishedCultures?.Length > 0; + + // There's no other way to detect trashed elements as the change type is only Remove when deleted permanently + var isTrashed = x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch) && x.PublishedCultures is null && x.UnpublishedCultures is null; + + // Only clear the partial cache for removals or refreshes with changes + return isTrashed || (isRelevantChangeType && hasChanges); + }); + } + private void HandleMemoryCache(JsonPayload payload) { if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) @@ -122,14 +186,18 @@ private void HandleMemoryCache(JsonPayload payload) } } - // these events should never trigger - // everything should be JSON + // These events should never trigger. Everything should be PAYLOAD/JSON. + + /// public override void RefreshAll() => throw new NotSupportedException(); + /// public override void Refresh(int id) => throw new NotSupportedException(); + /// public override void Refresh(Guid id) => throw new NotSupportedException(); + /// public override void Remove(int id) => throw new NotSupportedException(); #endregion diff --git a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs index 27ecf666563a..a02f3bf63fdc 100644 --- a/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs +++ b/tests/Umbraco.Tests.UnitTests/Umbraco.Core/Cache/RefresherTests.cs @@ -110,6 +110,40 @@ public void ElementCacheRefresherCanDeserializeJsonPayload(TreeChangeTypes chang Assert.AreEqual(1234, payload[0].Id); Assert.AreEqual(key, payload[0].Key); Assert.AreEqual(changeTypes, payload[0].ChangeTypes); + Assert.IsNull(payload[0].PublishedCultures); + Assert.IsNull(payload[0].UnpublishedCultures); + } + + [Test] + public void ElementCacheRefresherCanDeserializeJsonPayloadWithCultures() + { + var key = Guid.NewGuid(); + ElementCacheRefresher.JsonPayload[] source = + { + new(1234, key, TreeChangeTypes.RefreshNode) + { + PublishedCultures = ["en-US", "da-DK"], + UnpublishedCultures = ["de-DE"] + } + }; + + var json = JsonSerializer.Serialize(source); + var payload = JsonSerializer.Deserialize(json); + + Assert.IsNotNull(payload[0].PublishedCultures); + Assert.Multiple(() => + { + Assert.AreEqual(2, payload[0].PublishedCultures.Length); + Assert.AreEqual("en-US", payload[0].PublishedCultures.First()); + Assert.AreEqual("da-DK", payload[0].PublishedCultures.Last()); + }); + + Assert.IsNotNull(payload[0].UnpublishedCultures); + Assert.Multiple(() => + { + Assert.AreEqual(1, payload[0].UnpublishedCultures.Length); + Assert.AreEqual("de-DE", payload[0].UnpublishedCultures.First()); + }); } [Test] From 18cc60614cd8ff8f5bbf199f1457356208f6dfd4 Mon Sep 17 00:00:00 2001 From: kjac Date: Tue, 31 Mar 2026 17:13:23 +0200 Subject: [PATCH 2/2] Reuse content cache logic for partial view cache clearing --- .../Implement/ContentCacheRefresher.cs | 33 ++++++++++--------- .../Implement/ElementCacheRefresher.cs | 23 ++----------- 2 files changed, 21 insertions(+), 35 deletions(-) diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs index 72a23ff13393..514f851c8174 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs @@ -223,27 +223,30 @@ public override void Refresh(JsonPayload[] payloads) base.Refresh(payloads); } - private static bool ShouldClearPartialViewCache(JsonPayload[] payloads) - { - return payloads.Any(x => + internal static bool ShouldClearPartialViewCache(IEnumerable<(TreeChangeTypes ChangeTypes, string[]? PublishedCultures, string[]? UnpublishedCultures)> changes) + => changes.Any(change => { - // Check for relelvant change type - var isRelevantChangeType = x.ChangeTypes.HasType(TreeChangeTypes.RefreshAll) || - x.ChangeTypes.HasType(TreeChangeTypes.Remove) || - x.ChangeTypes.HasType(TreeChangeTypes.RefreshNode) || - x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch); + // Check for relevant change type + var isRelevantChangeType = change.ChangeTypes.HasType(TreeChangeTypes.RefreshAll) || + change.ChangeTypes.HasType(TreeChangeTypes.Remove) || + change.ChangeTypes.HasType(TreeChangeTypes.RefreshNode) || + change.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch); // Check for published/unpublished changes - var hasChanges = x.PublishedCultures?.Length > 0 || - x.UnpublishedCultures?.Length > 0; + var hasChanges = change.PublishedCultures?.Length > 0 || + change.UnpublishedCultures?.Length > 0; - // There's no other way to detect trashed content as the change type is only Remove when deleted permanently - var isTrashed = x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch) && x.PublishedCultures is null && x.UnpublishedCultures is null; + // There's no other way to detect trashed state as the change type is only Remove when deleted permanently + var isTrashed = change.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch) && change.PublishedCultures is null && change.UnpublishedCultures is null; - // Skip blueprints and only clear the partial cache for removals or refreshes with changes - return x.Blueprint == false && (isTrashed || (isRelevantChangeType && hasChanges)); + // Only clear the partial cache for removals or refreshes with changes + return isTrashed || (isRelevantChangeType && hasChanges); }); - } + + private static bool ShouldClearPartialViewCache(JsonPayload[] payloads) + => ShouldClearPartialViewCache(payloads + .Where(payload => payload.Blueprint is false) + .Select(payload => (payload.ChangeTypes, payload.PublishedCultures, payload.UnpublishedCultures))); private void HandleMemoryCache(JsonPayload payload) { diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs index f389428dbf55..c4a06ece4f12 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementCacheRefresher.cs @@ -147,26 +147,9 @@ public override void Refresh(JsonPayload[] payloads) } private static bool ShouldClearPartialViewCache(JsonPayload[] payloads) - { - return payloads.Any(x => - { - // Check for relevant change type - var isRelevantChangeType = x.ChangeTypes.HasType(TreeChangeTypes.RefreshAll) || - x.ChangeTypes.HasType(TreeChangeTypes.Remove) || - x.ChangeTypes.HasType(TreeChangeTypes.RefreshNode) || - x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch); - - // Check for published/unpublished changes - var hasChanges = x.PublishedCultures?.Length > 0 || - x.UnpublishedCultures?.Length > 0; - - // There's no other way to detect trashed elements as the change type is only Remove when deleted permanently - var isTrashed = x.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch) && x.PublishedCultures is null && x.UnpublishedCultures is null; - - // Only clear the partial cache for removals or refreshes with changes - return isTrashed || (isRelevantChangeType && hasChanges); - }); - } + // Reuse the "should clear partial view cache" logic from the content cache refresher. + => ContentCacheRefresher.ShouldClearPartialViewCache(payloads + .Select(payload => (payload.ChangeTypes, payload.PublishedCultures, payload.UnpublishedCultures))); private void HandleMemoryCache(JsonPayload payload) {