-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Elements: Invalidate the id/key map when an element container is deleted (closes #23072) #23074
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
AndyButland
merged 4 commits into
release/18.0
from
v18/bugfix/23072-refresh-element-cache-on-container-delete
Jun 15, 2026
Merged
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
a4a53d8
Refresh the element container cache on delete to ensure the id/key ma…
AndyButland 339741e
Rename and additional asserts in test.
AndyButland 46336ef
Use a dedicated refresher for element container id/key map eviction
AndyButland a99023e
Addressed code review feedback.
AndyButland File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
44 changes: 44 additions & 0 deletions
44
...ificationHandlers/Implement/ElementContainerDeletedDistributedCacheNotificationHandler.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| using Umbraco.Cms.Core.Models; | ||
| using Umbraco.Cms.Core.Notifications; | ||
| using Umbraco.Extensions; | ||
|
|
||
| namespace Umbraco.Cms.Core.Cache; | ||
|
|
||
| /// <summary> | ||
| /// Invalidates element caches when an element container (folder) is deleted, so that its key→id mapping | ||
| /// is evicted from <see cref="Services.IIdKeyMap"/> on every server. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Element container deletions only publish <see cref="EntityContainerDeletedNotification"/> and an | ||
| /// <see cref="ElementTreeChangeNotification"/> for the contained elements - never for the container node | ||
| /// itself. Without this handler the container's stale mapping survives until the next app restart, and a | ||
| /// container recreated under the same key resolves to the old id, so the element tree's children query | ||
| /// returns nothing (see #23072). | ||
| /// </remarks> | ||
| public sealed class ElementContainerDeletedDistributedCacheNotificationHandler | ||
| : DeletedDistributedCacheNotificationHandlerBase<EntityContainer, EntityContainerDeletedNotification> | ||
| { | ||
| private readonly DistributedCache _distributedCache; | ||
|
|
||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/> class. | ||
| /// </summary> | ||
| /// <param name="distributedCache">The distributed cache.</param> | ||
| public ElementContainerDeletedDistributedCacheNotificationHandler(DistributedCache distributedCache) | ||
| => _distributedCache = distributedCache; | ||
|
|
||
| /// <inheritdoc /> | ||
| protected override void Handle(IEnumerable<EntityContainer> entities, IDictionary<string, object?> state) | ||
| { | ||
| EntityContainer[] elementContainers = entities | ||
| .Where(container => container.ContainedObjectType == Constants.ObjectTypes.Element) | ||
|
AndyButland marked this conversation as resolved.
Outdated
|
||
| .ToArray(); | ||
|
|
||
| if (elementContainers.Length == 0) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| _distributedCache.RefreshElementCache(elementContainers); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
112 changes: 112 additions & 0 deletions
112
...ion/Umbraco.Core/Cache/ElementContainerDeletedDistributedCacheNotificationHandlerTests.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| 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; | ||
|
|
||
| /// <summary> | ||
| /// Tests for <see cref="ElementContainerDeletedDistributedCacheNotificationHandler"/>. | ||
| /// </summary> | ||
| [TestFixture] | ||
| [UmbracoTest(Database = UmbracoTestOptions.Database.NewSchemaPerTest, WithApplication = true)] | ||
| internal sealed class ElementContainerDeletedDistributedCacheNotificationHandlerTests : UmbracoIntegrationTest | ||
| { | ||
| private IElementContainerService ElementContainerService => GetRequiredService<IElementContainerService>(); | ||
|
|
||
| private IContentTypeService ContentTypeService => GetRequiredService<IContentTypeService>(); | ||
|
|
||
| private IElementService ElementService => GetRequiredService<IElementService>(); | ||
|
|
||
| private IEntityService EntityService => GetRequiredService<IEntityService>(); | ||
|
|
||
| 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<ElementTreeChangeNotification, ElementTreeChangeDistributedCacheNotificationHandler>(); | ||
| builder.AddNotificationHandler<EntityContainerDeletedNotification, ElementContainerDeletedDistributedCacheNotificationHandler>(); | ||
| builder.Services.AddUnique<IServerMessenger, ContentEventsTests.LocalServerMessenger>(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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 <see cref="IIdKeyMap"/>. 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. | ||
| /// </summary> | ||
| [Test] | ||
| public async Task Child_Element_Is_Returned_After_Container_Recreated_Under_Same_Key() | ||
|
AndyButland marked this conversation as resolved.
Outdated
|
||
| { | ||
| 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<int> warmResolve = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer); | ||
| Assert.AreEqual(firstContainer.Id, warmResolve.Result); | ||
|
AndyButland marked this conversation as resolved.
|
||
|
|
||
| // Delete and recreate under the same key - the recreated container gets a new id. | ||
| Attempt<EntityContainer?, EntityContainerOperationStatus> 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<int> resolvedAfter = IdKeyMap.GetIdForKey(containerKey, UmbracoObjectTypes.ElementContainer); | ||
| Assert.AreEqual(secondContainer.Id, resolvedAfter.Result, "Container key should resolve to the recreated container id."); | ||
|
AndyButland marked this conversation as resolved.
|
||
|
|
||
| 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<IContentType> CreateElementTypeAsync() | ||
| { | ||
| IContentType elementType = ContentTypeBuilder.CreateSimpleElementType(); | ||
| await ContentTypeService.CreateAsync(elementType, Constants.Security.SuperUserKey); | ||
| return elementType; | ||
| } | ||
|
|
||
| private async Task<EntityContainer> CreateContainerAsync(Guid key, string name) | ||
| { | ||
| Attempt<EntityContainer?, EntityContainerOperationStatus> 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; | ||
| } | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.