diff --git a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs index c24aa44dc960..e6e4118095a7 100644 --- a/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs +++ b/src/Umbraco.Core/Cache/DistributedCacheExtensions.cs @@ -467,6 +467,20 @@ public static void RefreshElementCache(this DistributedCache dc, IEnumerable + /// Invalidates the id/key map for the specified deleted element containers (folders). + /// + /// The distributed cache. + /// The element containers that were deleted. + public static void RemoveElementContainerCache(this DistributedCache dc, IEnumerable deletedContainers) + => dc.RefreshByPayload( + ElementContainerCacheRefresher.UniqueId, + deletedContainers.Select(container => new ElementContainerCacheRefresher.JsonPayload(container.Id, container.Key))); + + #endregion + #region Published Snapshot /// diff --git a/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementContainerDeletedDistributedCacheNotificationHandler.cs b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementContainerDeletedDistributedCacheNotificationHandler.cs new file mode 100644 index 000000000000..a7e961ce5a1e --- /dev/null +++ b/src/Umbraco.Core/Cache/NotificationHandlers/Implement/ElementContainerDeletedDistributedCacheNotificationHandler.cs @@ -0,0 +1,43 @@ +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Extensions; + +namespace Umbraco.Cms.Core.Cache; + +/// +/// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping +/// is evicted from on every server. +/// +/// +/// Element container deletions only publish and an +/// for the contained elements - never for the container node +/// itself, so without this handler the container's stale id/key mapping survives until the next app +/// restart (see #23072). +/// +public sealed class ElementContainerDeletedDistributedCacheNotificationHandler + : DeletedDistributedCacheNotificationHandlerBase +{ + private readonly DistributedCache _distributedCache; + + /// + /// Initializes a new instance of the class. + /// + /// The distributed cache. + public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache) + => _distributedCache = distributedCache; + + /// + protected override void Handle(IEnumerable entities, IDictionary state) + { + EntityContainer[] elementContainers = entities + .Where(container => container.ContainerObjectType == Constants.ObjectTypes.ElementContainer) + .ToArray(); + + if (elementContainers.Length == 0) + { + return; + } + + _distributedCache.RemoveElementContainerCache(elementContainers); + } +} diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ElementContainerCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementContainerCacheRefresher.cs new file mode 100644 index 000000000000..bb769f3458b3 --- /dev/null +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ElementContainerCacheRefresher.cs @@ -0,0 +1,109 @@ +using Umbraco.Cms.Core.Events; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; + +namespace Umbraco.Cms.Core.Cache; + +/// +/// Provides cache refresh functionality for element containers (folders). +/// +/// +/// A deleted container's node id is never reused, so its key→id mapping in must be +/// evicted on every server. Otherwise a container recreated under the same key resolves to the stale id and +/// the element tree's children query returns nothing until the next app restart. This refresher only evicts +/// the id/key map - element data is unaffected by container changes, so it deliberately avoids the broader +/// invalidation performed by . +/// +public sealed class ElementContainerCacheRefresher : PayloadCacheRefresherBase +{ + private readonly IIdKeyMap _idKeyMap; + + /// + /// Initializes a new instance of the class. + /// + public ElementContainerCacheRefresher( + AppCaches appCaches, + IJsonSerializer serializer, + IIdKeyMap idKeyMap, + IEventAggregator eventAggregator, + ICacheRefresherNotificationFactory factory) + : base(appCaches, serializer, eventAggregator, factory) + => _idKeyMap = idKeyMap; + + #region Json + + /// + /// Represents a JSON-serializable payload identifying an element container that changed. + /// + public class JsonPayload + { + /// + /// Initializes a new instance of the class. + /// + /// The unique integer identifier for the container. + /// The unique GUID key associated with the container. + public JsonPayload(int id, Guid key) + { + Id = id; + Key = key; + } + + /// + /// Gets the unique integer identifier for the container. + /// + public int Id { get; } + + /// + /// Gets the unique GUID key associated with the container. + /// + public Guid Key { get; } + } + + #endregion + + #region Define + + /// + /// Represents a unique identifier for the cache refresher. + /// + public static readonly Guid UniqueId = Guid.Parse("9C9D8B0E-2F1A-4D63-9C2E-7E6B5A4F3C21"); + + /// + public override Guid RefresherUniqueId => UniqueId; + + /// + public override string Name => "Element Container Cache Refresher"; + + #endregion + + #region Refresher + + /// + public override void Refresh(JsonPayload[] payloads) + { + foreach (JsonPayload payload in payloads) + { + // Clearing by id also evicts the key→id direction, as the id/key map keeps both in sync. + _idKeyMap.ClearCache(payload.Id); + } + + base.Refresh(payloads); + } + + // 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/src/Umbraco.Core/Notifications/ElementContainerCacheRefresherNotification.cs b/src/Umbraco.Core/Notifications/ElementContainerCacheRefresherNotification.cs new file mode 100644 index 000000000000..d470db30b1a4 --- /dev/null +++ b/src/Umbraco.Core/Notifications/ElementContainerCacheRefresherNotification.cs @@ -0,0 +1,19 @@ +using Umbraco.Cms.Core.Sync; + +namespace Umbraco.Cms.Core.Notifications; + +/// +/// A notification that is used to trigger the Element Container Cache Refresher. +/// +public class ElementContainerCacheRefresherNotification : CacheRefresherNotification +{ + /// + /// Initializes a new instance of the class. + /// + /// The refresher payload. + /// Type of the cache refresher message, . + public ElementContainerCacheRefresherNotification(object messageObject, MessageType messageType) + : base(messageObject, messageType) + { + } +} diff --git a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs index 114b570a285f..13a30f23025d 100644 --- a/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs +++ b/src/Umbraco.Infrastructure/DependencyInjection/UmbracoBuilder.CoreServices.cs @@ -466,6 +466,7 @@ public static IUmbracoBuilder AddCoreNotifications(this IUmbracoBuilder builder) .AddNotificationHandler() .AddNotificationHandler() .AddNotificationHandler() + .AddNotificationHandler() ; // add notification handlers for auditing diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/ElementContainerDeletedDistributedCacheNotificationHandlerTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/ElementContainerDeletedDistributedCacheNotificationHandlerTests.cs new file mode 100644 index 000000000000..f3b62c85ca58 --- /dev/null +++ b/tests/Umbraco.Tests.Integration/Umbraco.Core/Cache/ElementContainerDeletedDistributedCacheNotificationHandlerTests.cs @@ -0,0 +1,114 @@ +using NUnit.Framework; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.Cache; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.Models.Entities; +using Umbraco.Cms.Core.Notifications; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Services.OperationStatus; +using Umbraco.Cms.Core.Sync; +using Umbraco.Cms.Tests.Common.Builders; +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.Core.Cache; + +/// +/// Tests for . +/// +[TestFixture] +[UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, WithApplication = true)] +internal sealed class ElementContainerDeletedDistributedCacheNotificationHandlerTests : UmbracoIntegrationTest +{ + private IElementContainerService ElementContainerService => GetRequiredService(); + + private IContentTypeService ContentTypeService => GetRequiredService(); + + private IElementService ElementService => GetRequiredService(); + + private IEntityService EntityService => GetRequiredService(); + + private static readonly UmbracoObjectTypes[] _treeObjectTypes = + [UmbracoObjectTypes.ElementContainer, UmbracoObjectTypes.Element]; + + protected override void CustomTestSetup(IUmbracoBuilder builder) + { + // Integration tests use a no-op server messenger and do not register the distributed cache + // notification handlers by default, so opt in to the element handlers under test and a messenger + // that delivers cache refreshes locally. + builder.AddNotificationHandler(); + builder.AddNotificationHandler(); + builder.Services.AddUnique(); + } + + /// + /// Regression test for https://github.com/umbraco/Umbraco-CMS/issues/23072: the element tree's children + /// query resolves the container key to an id via . When a container is deleted the + /// handler must evict its mapping, otherwise a container recreated under the same key resolves to the old + /// (now non-existent) id and nested elements stay invisible in the tree until the application is restarted. + /// + [Test] + public async Task Can_Resolve_Children_After_Container_Recreated_Under_Same_Key() + { + IContentType elementType = await CreateElementTypeAsync(); + var containerKey = Guid.NewGuid(); + + // Create the container and resolve its children once, so its key->id mapping is cached in IdKeyMap. + EntityContainer firstContainer = await CreateContainerAsync(containerKey, "Container v1"); + Attempt warmResolve = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer); + Assert.IsTrue(warmResolve.Success, "Expected IdKeyMap to resolve the newly created container key."); + Assert.AreEqual(firstContainer.Id, warmResolve.Result); + + // Delete and recreate under the same key - the recreated container gets a new id. + Attempt deleteResult = + await ElementContainerService.DeleteAsync(containerKey, Constants.Security.SuperUserKey); + Assert.IsTrue(deleteResult.Success, $"Failed to delete container: {deleteResult.Status}"); + + EntityContainer secondContainer = await CreateContainerAsync(containerKey, "Container v2"); + Assert.AreNotEqual(firstContainer.Id, secondContainer.Id, "Recreated container should have a new id."); + + IElement element = CreateElementUnder(secondContainer.Id, elementType); + + // Without the fix, the stale containerKey->firstContainer.Id mapping survives and the children query + // resolves to the old (now non-existent) parent id, returning nothing. + Attempt resolvedAfter = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer); + Assert.IsTrue(resolvedAfter.Success, "Expected IdKeyMap to resolve the recreated container key."); + Assert.AreEqual(secondContainer.Id, resolvedAfter.Result, "Container key should resolve to the recreated container id."); + + AssertChildrenContains(containerKey, element.Key); + } + + private void AssertChildrenContains(Guid containerKey, Guid expectedElementKey) + { + IEntitySlim[] children = EntityService + .GetPagedChildren(containerKey, _treeObjectTypes, _treeObjectTypes, 0, 100, false, out var total) + .ToArray(); + + Assert.AreEqual(1, total, "Expected the element tree children query to return the nested element."); + Assert.IsTrue(children.Any(child => child.Key == expectedElementKey), "Nested element was not returned by the children query."); + } + + private async Task CreateElementTypeAsync() + { + IContentType elementType = ContentTypeBuilder.CreateSimpleElementType(); + await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); + return elementType; + } + + private async Task CreateContainerAsync(Guid key, string name) + { + Attempt result = + await ElementContainerService.CreateAsync(key, name, null, Constants.Security.SuperUserKey); + Assert.IsTrue(result.Success, $"Failed to create container: {result.Status}"); + return result.Result!; + } + + private IElement CreateElementUnder(int parentId, IContentType elementType) + { + var element = new Element($"Element {Guid.NewGuid():N}", parentId, elementType); + OperationResult saveResult = ElementService.Save(element); + Assert.IsTrue(saveResult.Success, "Failed to save element."); + return element; + } +}