diff --git a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs index 6c626efb2c5e..0cb3c52ae287 100644 --- a/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs +++ b/src/Umbraco.Core/Cache/Refreshers/Implement/ContentCacheRefresher.cs @@ -11,6 +11,17 @@ namespace Umbraco.Cms.Core.Cache; +/// +/// Provides cache refresh functionality for content items, ensuring that content-related caches are updated or +/// invalidated in response to content changes. +/// +/// +/// The ContentCacheRefresher coordinates cache invalidation for content, including memory caches, URL +/// caches, navigation structures, and domain assignments. It responds to content change notifications and ensures that +/// all relevant caches reflect the current state of published and unpublished content. This refresher is used +/// internally to maintain cache consistency after content operations such as publish, unpublish, move, +/// or delete. +/// public sealed class ContentCacheRefresher : PayloadCacheRefresherBase { @@ -25,6 +36,9 @@ public sealed class ContentCacheRefresher : PayloadCacheRefresherBase + /// Initializes a new instance of the class. + /// public ContentCacheRefresher( AppCaches appCaches, IJsonSerializer serializer, @@ -60,11 +74,15 @@ public ContentCacheRefresher( #region Indirect + /// + /// Clears cached content and public access data from the provided application caches. + /// + /// The application caches instance from which to clear content and public access entries. public static void RefreshContentTypes(AppCaches appCaches) { - // we could try to have a mechanism to notify the PublishedCachesService + // We could try to have a mechanism to notify the PublishedCachesService // and figure out whether published items were modified or not... keep it - // simple for now, just clear the whole thing + // simple for now, just clear the whole thing. appCaches.ClearPartialViewCache(); appCaches.IsolatedCaches.ClearCache(); @@ -75,16 +93,22 @@ public static void RefreshContentTypes(AppCaches appCaches) #region Define + /// + /// Represents a unique identifier for the cache refresher. + /// public static readonly Guid UniqueId = Guid.Parse("900A4FBE-DF3C-41E6-BB77-BE896CD158EA"); + /// public override Guid RefresherUniqueId => UniqueId; + /// public override string Name => "ContentCacheRefresher"; #endregion #region Refresher + /// public override void RefreshInternal(JsonPayload[] payloads) { AppCaches.RuntimeCache.ClearOfType(); @@ -121,13 +145,15 @@ public override void RefreshInternal(JsonPayload[] payloads) base.RefreshInternal(payloads); } + /// public override void Refresh(JsonPayload[] payloads) { var idsRemoved = new HashSet(); foreach (JsonPayload payload in payloads) { - // if the item is not a blueprint and is being completely removed, we need to refresh the domains cache if any domain was assigned to the content + // If the item is not a blueprint and is being completely removed, we need to refresh the domains cache if any domain was assigned to the content. + // So track the IDs that have been removed. if (payload.Blueprint is false && payload.ChangeTypes.HasTypesAny(TreeChangeTypes.Remove)) { idsRemoved.Add(payload.Id); @@ -138,43 +164,18 @@ public override void Refresh(JsonPayload[] payloads) HandleNavigation(payload); HandlePublishedAsync(payload, CancellationToken.None).GetAwaiter().GetResult(); - if (payload.Id != default) - { - _idKeyMap.ClearCache(payload.Id); - } - if (payload.Key.HasValue) - { - _idKeyMap.ClearCache(payload.Key.Value); - } + HandleIdKeyMap(payload); } - // Clear partial view cache when published content changes + // Clear partial view cache when published content changes. if (ShouldClearPartialViewCache(payloads)) { AppCaches.ClearPartialViewCache(); } - if (idsRemoved.Count > 0) - { - var assignedDomains = _domainService.GetAll(true) - ?.Where(x => x.RootContentId.HasValue && idsRemoved.Contains(x.RootContentId.Value)).ToList(); - - if (assignedDomains?.Count > 0) - { - // TODO: this is duplicating the logic in DomainCacheRefresher BUT we cannot inject that into this because it it not registered explicitly in the container, - // and we cannot inject the CacheRefresherCollection since that would be a circular reference, so what is the best way to call directly in to the - // DomainCacheRefresher? - ClearAllIsolatedCacheByEntityType(); - - // note: must do what's above FIRST else the repositories still have the old cached - // content and when the PublishedCachesService is notified of changes it does not see - // the new content... - // notify - _domainCacheService.Refresh(assignedDomains - .Select(x => new DomainCacheRefresher.JsonPayload(x.Id, DomainChangeTypes.Remove)).ToArray()); - } - } + // Clear the domain cache if any domain is assigned to removed content. + HandleDomainCache(idsRemoved); base.Refresh(payloads); } @@ -251,18 +252,51 @@ private void HandleMemoryCache(JsonPayload payload) } } - private bool IsBranchUnpublished(JsonPayload payload) - { + private static bool IsBranchUnpublished(JsonPayload payload) => + // If unpublished cultures has one or more values, but published cultures does not, this means that the branch is unpublished entirely // And therefore should no longer be resolve-able from the cache, so we need to remove it instead. // Otherwise, some culture is still published, so it should be resolve-able from cache, and published cultures should instead be used. - return payload.UnpublishedCultures is not null && payload.UnpublishedCultures.Length != 0 && + payload.UnpublishedCultures is not null && payload.UnpublishedCultures.Length != 0 && (payload.PublishedCultures is null || payload.PublishedCultures.Length == 0); + + private void HandleRouting(JsonPayload payload) + { + if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) + { + Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; + + // Note that we need to clear the navigation service as the last thing. + if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeys(key, out IEnumerable? descendantsOrSelfKeys)) + { + _documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeys).GetAwaiter().GetResult(); + } + else if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeysInBin(key, out IEnumerable? descendantsOrSelfKeysInBin)) + { + _documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeysInBin).GetAwaiter().GetResult(); + } + } + + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) + { + _documentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); // TODO: make async + } + + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode)) + { + Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; + _documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult(); + } + + if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch)) + { + Guid key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; + _documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult(); + } } private void HandleNavigation(JsonPayload payload) { - if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) { _documentNavigationManagementService.RebuildAsync().GetAwaiter().GetResult(); @@ -389,50 +423,68 @@ private async Task HandlePublishedAsync(JsonPayload payload, CancellationToken c await _publishStatusManagementService.AddOrUpdateStatusWithDescendantsAsync(payload.Key.Value, cancellationToken); } } - private void HandleRouting(JsonPayload payload) + + private void HandleIdKeyMap(JsonPayload payload) { - if (payload.ChangeTypes.HasType(TreeChangeTypes.Remove)) + // We only need to flush the ID/Key map when content is deleted. + if (payload.ChangeTypes.HasTypesAny(TreeChangeTypes.Remove) is false) { - var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; - - //Note the we need to clear the navigation service as the last thing - if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeys(key, out var descendantsOrSelfKeys)) - { - _documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeys).GetAwaiter().GetResult(); - } - else if (_documentNavigationQueryService.TryGetDescendantsKeysOrSelfKeysInBin(key, out var descendantsOrSelfKeysInBin)) - { - _documentUrlService.DeleteUrlsFromCacheAsync(descendantsOrSelfKeysInBin).GetAwaiter().GetResult(); - } + return; + } + if (payload.Id != default) + { + _idKeyMap.ClearCache(payload.Id); } - if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshAll)) + + if (payload.Key.HasValue) { - _documentUrlService.InitAsync(false, CancellationToken.None).GetAwaiter().GetResult(); //TODO make async + _idKeyMap.ClearCache(payload.Key.Value); } + } - if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshNode)) + private void HandleDomainCache(HashSet idsRemoved) + { + if (idsRemoved.Count == 0) { - var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; - _documentUrlService.CreateOrUpdateUrlSegmentsAsync(key).GetAwaiter().GetResult(); + return; } - if (payload.ChangeTypes.HasType(TreeChangeTypes.RefreshBranch)) +#pragma warning disable CS0618 // Type or member is obsolete + var assignedDomains = _domainService.GetAll(true) + .Where(x => x.RootContentId.HasValue && idsRemoved.Contains(x.RootContentId.Value)) + .ToList(); +#pragma warning restore CS0618 // Type or member is obsolete + if (assignedDomains.Count <= 0) { - var key = payload.Key ?? _idKeyMap.GetKeyForId(payload.Id, UmbracoObjectTypes.Document).Result; - _documentUrlService.CreateOrUpdateUrlSegmentsWithDescendantsAsync(key).GetAwaiter().GetResult(); + return; } + // TODO: this is duplicating the logic in DomainCacheRefresher BUT we cannot inject that into this because it it not registered explicitly in the container, + // and we cannot inject the CacheRefresherCollection since that would be a circular reference, so what is the best way to call directly in to the + // DomainCacheRefresher? + ClearAllIsolatedCacheByEntityType(); + + // note: must do what's above FIRST else the repositories still have the old cached + // content and when the PublishedCachesService is notified of changes it does not see + // the new content... + // notify + _domainCacheService.Refresh(assignedDomains + .Select(x => new DomainCacheRefresher.JsonPayload(x.Id, DomainChangeTypes.Remove)).ToArray()); } - // these events should never trigger - // everything should be PAYLOAD/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 @@ -440,19 +492,40 @@ private void HandleRouting(JsonPayload payload) #region Json // TODO (V14): Change into a record + /// + /// Represents a JSON-serializable payload containing information about a content or tree change event, including + /// identifiers, change types, and culture-specific publishing details. + /// public class JsonPayload { - + /// + /// Gets the unique integer identifier for the entity. + /// public int Id { get; init; } + /// + /// Gets the unique GUID key associated with the entity, or null if no key is assigned. + /// public Guid? Key { get; init; } + /// + /// Gets the types of changes that have occurred in the tree. + /// public TreeChangeTypes ChangeTypes { get; init; } + /// + /// Gets a value indicating whether the content represents a document blueprint. + /// public bool Blueprint { get; init; } + /// + /// Gets the collection of culture codes in which the content is published. + /// public string[]? PublishedCultures { get; init; } + /// + /// Gets the collection of culture codes for which the content has been unpublished. + /// public string[]? UnpublishedCultures { get; init; } }