Skip to content

Conversation

kjac
Copy link
Contributor

@kjac kjac commented Oct 15, 2025

Prerequisites

  • I have added steps to test this contribution in the description below

Description

When editing documents and media, the isolated caches are explicitly updated by the repository cache policy (here) - but only by ID. Any document and media entities cached by key are flushed by the ContentCacheRefresher and MediaCacheRefresher (here and here, respectively).

Unfortunately, the by-key flushing comes too late for any notification handlers tied to the "Saved" notifications (ContentSavedNotification and MediaSavedNotification). If one attempts to fetch the saved document or media by key from the content or media services in these notification handlers, the returned entity will consistently be the one previously loaded into the cache - that is, the previous version.

Note: This is seemingly not an issue for members, even though they share the same repository base.

To fix this, I have replicated the cache flushing performed by the content and media cache refreshers in the document and media repositories.

A temporary fix

This is at best a patch to work around the underlying problem, which is likely linked to the use of sub-repositories in the document and media repositories (here and here, respectively).

We need to plan for a less fragile solution in the future, but this will suffice for the time being.

Note: The member repository does not have a sub-repository, which is probably why we don't see the issue for members.

Testing this PR

Include the notification handlers below, and test this PR by changing the names of documents and media.

Without this PR, the log output from the handlers will look like this (sampled from documents, but media looks the same):

## Name values for notification type: ContentSavingNotification
- From notification entity: [new name]
- From ContentService (by ID): [old name]
- From ContentService (by key): [old name]
## Name values for notification type: ContentSavedNotification
- From notification entity: [new name]
- From ContentService (by ID): [new name]
- From ContentService (by key): [old name]

With this PR, the output looks like this:

## Name values for notification type: ContentSavingNotification
- From notification entity: [new name]
- From ContentService (by ID): [old name]
- From ContentService (by key): [old name]
## Name values for notification type: ContentSavedNotification
- From notification entity: [new name]
- From ContentService (by ID): [new name]
- From ContentService (by key): [new name]

The subtle difference is the very last line of the logging output; the "Saved" notification outputs the previous name when this PR is not applied.

I have also included a notification handler to validate that members are not affected by this issue.

Content notification handler

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;

namespace Umbraco.Cms.Web.UI.Custom;

public class TestContentSavedNotificationHandler :
    INotificationHandler<ContentSavingNotification>,
    INotificationHandler<ContentSavedNotification>
{
    private readonly IContentService _contentService;
    private readonly ILogger<TestContentSavedNotificationHandler> _logger;

    public TestContentSavedNotificationHandler(
        IContentService contentService,
        ILogger<TestContentSavedNotificationHandler> logger)
    {
        _contentService = contentService;
        _logger = logger;
    }

    public void Handle(ContentSavingNotification notification)
        => LogTitleValue<ContentSavingNotification>(notification.SavedEntities);

    public void Handle(ContentSavedNotification notification)
        => LogTitleValue<ContentSavedNotification>(notification.SavedEntities);

    private void LogTitleValue<T>(IEnumerable<IContent> entities)
    {
        _logger.LogInformation("## Name values for notification type: {notificationType}", typeof(T).Name);
        IContent? content = entities.FirstOrDefault();
        if (content is null)
        {
            _logger.LogInformation("- No content found in notification, aborting.");
            return;
        }

        _logger.LogInformation("- From notification entity: {name}", content.Name);

        content = _contentService.GetById(content.Id);
        if (content is null)
        {
            _logger.LogInformation("- No content fetched from ContentService (by ID), aborting.");
            return;
        }

        _logger.LogInformation("- From ContentService (by ID): {name}", content.Name);

        content = _contentService.GetById(content.Key);
        if (content is null)
        {
            _logger.LogInformation("- No content fetched from ContentService (by key), aborting.");
            return;
        }

        _logger.LogInformation("- From ContentService (by key): {name}", content.Name);
    }
}

public class TestContentSavedNotificationHandlerComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
        => builder
            .AddNotificationHandler<ContentSavingNotification, TestContentSavedNotificationHandler>()
            .AddNotificationHandler<ContentSavedNotification, TestContentSavedNotificationHandler>();
}

Media notification handler

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;

namespace Umbraco.Cms.Web.UI.Custom;

public class TestMediaSavedNotificationHandler :
    INotificationHandler<MediaSavingNotification>,
    INotificationHandler<MediaSavedNotification>
{
    private readonly IMediaService _mediaService;
    private readonly ILogger<TestMediaSavedNotificationHandler> _logger;

    public TestMediaSavedNotificationHandler(
        IMediaService mediaService,
        ILogger<TestMediaSavedNotificationHandler> logger)
    {
        _mediaService = mediaService;
        _logger = logger;
    }

    public void Handle(MediaSavingNotification notification)
        => LogTitleValue<MediaSavingNotification>(notification.SavedEntities);

    public void Handle(MediaSavedNotification notification)
        => LogTitleValue<MediaSavedNotification>(notification.SavedEntities);

    private void LogTitleValue<T>(IEnumerable<IMedia> entities)
    {
        _logger.LogInformation("## Name values for notification type: {notificationType}", typeof(T).Name);
        IMedia? media = entities.FirstOrDefault();
        if (media is null)
        {
            _logger.LogInformation("- No media found in notification, aborting.");
            return;
        }

        _logger.LogInformation("- From notification entity: {name}", media.Name);

        media = _mediaService.GetById(media.Id);
        if (media is null)
        {
            _logger.LogInformation("- No media fetched from MediaService (by ID), aborting.");
            return;
        }

        _logger.LogInformation("- From MediaService (by ID): {name}", media.Name);

        media = _mediaService.GetById(media.Key);
        if (media is null)
        {
            _logger.LogInformation("- No media fetched from MediaService (by key), aborting.");
            return;
        }

        _logger.LogInformation("- From MediaService (by key): {name}", media.Name);
    }
}

public class TestMediaSavedNotificationHandlerComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
        => builder
            .AddNotificationHandler<MediaSavingNotification, TestMediaSavedNotificationHandler>()
            .AddNotificationHandler<MediaSavedNotification, TestMediaSavedNotificationHandler>();
}

Member notification handler

using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.Events;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.Notifications;
using Umbraco.Cms.Core.Services;

namespace Umbraco.Cms.Web.UI.Custom;

public class TestMemberSavedNotificationHandler :
    INotificationHandler<MemberSavingNotification>,
    INotificationHandler<MemberSavedNotification>
{
    private readonly IMemberService _memberService;
    private readonly ILogger<TestMemberSavedNotificationHandler> _logger;

    public TestMemberSavedNotificationHandler(
        IMemberService memberService,
        ILogger<TestMemberSavedNotificationHandler> logger)
    {
        _memberService = memberService;
        _logger = logger;
    }

    public void Handle(MemberSavingNotification notification)
        => LogTitleValue<MemberSavingNotification>(notification.SavedEntities);

    public void Handle(MemberSavedNotification notification)
        => LogTitleValue<MemberSavedNotification>(notification.SavedEntities);

    private void LogTitleValue<T>(IEnumerable<IMember> entities)
    {
        _logger.LogInformation("## Name values for notification type: {notificationType}", typeof(T).Name);
        IMember? member = entities.FirstOrDefault();
        if (member is null)
        {
            _logger.LogInformation("- No member found in notification, aborting.");
            return;
        }

        _logger.LogInformation("- From notification entity: {name}", member.Name);

        member = _memberService.GetById(member.Id);
        if (member is null)
        {
            _logger.LogInformation("- No member fetched from MemberService (by ID), aborting.");
            return;
        }

        _logger.LogInformation("- From MemberService (by ID): {name}", member.Name);

        member = _memberService.GetById(member.Key);
        if (member is null)
        {
            _logger.LogInformation("- No member fetched from MemberService (by key), aborting.");
            return;
        }

        _logger.LogInformation("- From MemberService (by key): {name}", member.Name);
    }
}

public class TestMemberSavedNotificationHandlerComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
        => builder
            .AddNotificationHandler<MemberSavingNotification, TestMemberSavedNotificationHandler>()
            .AddNotificationHandler<MemberSavedNotification, TestMemberSavedNotificationHandler>();
}

@kjac kjac changed the base branch from v17/dev to release/17.0 October 16, 2025 07:03
@kjac kjac changed the base branch from release/17.0 to v17/dev October 16, 2025 07:03
@kjac
Copy link
Contributor Author

kjac commented Oct 16, 2025

Closing this one because it targets the wrong branch, and can't be retargeted 😆

@kjac kjac closed this Oct 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant